diff --git a/src/config.ts b/src/config.ts index 09bf229..2898552 100644 --- a/src/config.ts +++ b/src/config.ts @@ -30,11 +30,13 @@ function loadEnvFile(): Record { if (eqIdx === -1) continue; const key = trimmed.slice(0, eqIdx).trim(); let val = trimmed.slice(eqIdx + 1).trim(); - if ( - (val.startsWith('"') && val.endsWith('"')) || - (val.startsWith("'") && val.endsWith("'")) - ) { - val = val.slice(1, -1); + const quoteChar = val[0] === '"' || val[0] === "'" ? val[0] : ""; + if (quoteChar) { + const closeIdx = val.indexOf(quoteChar, 1); + if (closeIdx !== -1) val = val.slice(1, closeIdx); + } else { + const hashIdx = val.indexOf(" #"); + if (hashIdx !== -1) val = val.slice(0, hashIdx).trim(); } vars[key] = val; } @@ -147,6 +149,20 @@ export function getEnvVar(key: string): string | undefined { return getMergedEnv()[key]; } +export function detectLlmProviderKind(): "llm" | "noop" { + const env = getMergedEnv(); + if ( + hasRealValue(env["ANTHROPIC_API_KEY"]) || + hasRealValue(env["GEMINI_API_KEY"]) || + hasRealValue(env["GOOGLE_API_KEY"]) || + hasRealValue(env["OPENROUTER_API_KEY"]) || + hasRealValue(env["MINIMAX_API_KEY"]) + ) { + return "llm"; + } + return "noop"; +} + export function loadEmbeddingConfig(): EmbeddingConfig { const env = getMergedEnv(); let bm25Weight = parseFloat(env["BM25_WEIGHT"] || "0.4"); diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 3c993f5..884c284 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -14,6 +14,7 @@ import { isAutoCompressEnabled, isContextInjectionEnabled, detectEmbeddingProvider, + detectLlmProviderKind, } from "../config.js"; type Response = { @@ -153,8 +154,7 @@ export function registerApiTriggers( async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; - const env = process.env; - const providerKind = env["ANTHROPIC_API_KEY"] || env["GEMINI_API_KEY"] || env["OPENROUTER_API_KEY"] || env["MINIMAX_API_KEY"] ? "llm" : "noop"; + const providerKind = detectLlmProviderKind(); const embeddingProvider = detectEmbeddingProvider() ? "embeddings" : "none"; const flags = [ { diff --git a/test/env-loader.test.ts b/test/env-loader.test.ts new file mode 100644 index 0000000..9c6f295 --- /dev/null +++ b/test/env-loader.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ORIGINAL_HOME = process.env["HOME"]; +const ORIGINAL_USERPROFILE = process.env["USERPROFILE"]; + +let sandboxHome: string; + +async function freshConfig() { + vi.resetModules(); + return await import("../src/config.js"); +} + +function writeEnv(contents: string) { + const dir = join(sandboxHome, ".agentmemory"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".env"), contents); +} + +describe("loadEnvFile", () => { + beforeEach(() => { + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-env-")); + process.env["HOME"] = sandboxHome; + process.env["USERPROFILE"] = sandboxHome; + delete process.env["AGENTMEMORY_AUTO_COMPRESS"]; + delete process.env["CONSOLIDATION_ENABLED"]; + delete process.env["GRAPH_EXTRACTION_ENABLED"]; + delete process.env["TOKEN"]; + delete process.env["HASHVAL"]; + }); + + afterEach(() => { + if (ORIGINAL_HOME === undefined) delete process.env["HOME"]; + else process.env["HOME"] = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE; + rmSync(sandboxHome, { recursive: true, force: true }); + }); + + it("strips trailing inline # comments on unquoted values", async () => { + writeEnv( + [ + "AGENTMEMORY_AUTO_COMPRESS=true # opt in to LLM compression", + "CONSOLIDATION_ENABLED=true # daily summarization", + "GRAPH_EXTRACTION_ENABLED=true # entity graph", + ].join("\n"), + ); + const cfg = await freshConfig(); + expect(cfg.isAutoCompressEnabled()).toBe(true); + expect(cfg.isConsolidationEnabled()).toBe(true); + expect(cfg.isGraphExtractionEnabled()).toBe(true); + }); + + it("preserves # inside double-quoted values", async () => { + writeEnv('TOKEN="abc#def"'); + const cfg = await freshConfig(); + expect(cfg.getEnvVar("TOKEN")).toBe("abc#def"); + }); + + it("preserves # inside single-quoted values", async () => { + writeEnv("TOKEN='abc#def'"); + const cfg = await freshConfig(); + expect(cfg.getEnvVar("TOKEN")).toBe("abc#def"); + }); + + it("treats hash without leading space as part of value", async () => { + writeEnv("HASHVAL=abc#def"); + const cfg = await freshConfig(); + expect(cfg.getEnvVar("HASHVAL")).toBe("abc#def"); + }); + + it("strips inline comment after a quoted value and unwraps quotes", async () => { + writeEnv('TOKEN="abc" # trailing comment'); + const cfg = await freshConfig(); + expect(cfg.getEnvVar("TOKEN")).toBe("abc"); + }); + + it("strips inline comment after a single-quoted value and unwraps quotes", async () => { + writeEnv("TOKEN='abc' # trailing comment"); + const cfg = await freshConfig(); + expect(cfg.getEnvVar("TOKEN")).toBe("abc"); + }); +});