diff --git a/src/config.ts b/src/config.ts index 46b9278..343ece2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -621,6 +621,13 @@ export function normalizeAutoCaptureMaxContextBytes(value: number): number { return value; } +export function normalizeAutoCleanupRetentionDays(value: number): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Invalid autoCleanupRetentionDays config: ${value}`); + } + return value; +} + function buildConfig(fileConfig: OpenCodeMemConfig) { const memoryApiKey = resolveSecretValue(fileConfig.memoryApiKey); const embeddingDimensions = @@ -697,8 +704,9 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { : undefined, maxVectorsPerShard: fileConfig.maxVectorsPerShard ?? DEFAULTS.maxVectorsPerShard, autoCleanupEnabled: fileConfig.autoCleanupEnabled ?? DEFAULTS.autoCleanupEnabled, - autoCleanupRetentionDays: - fileConfig.autoCleanupRetentionDays ?? DEFAULTS.autoCleanupRetentionDays, + autoCleanupRetentionDays: normalizeAutoCleanupRetentionDays( + fileConfig.autoCleanupRetentionDays ?? DEFAULTS.autoCleanupRetentionDays + ), deduplicationEnabled: fileConfig.deduplicationEnabled ?? DEFAULTS.deduplicationEnabled, deduplicationSimilarityThreshold: fileConfig.deduplicationSimilarityThreshold ?? DEFAULTS.deduplicationSimilarityThreshold, @@ -845,7 +853,10 @@ export function initConfig(directory: string): void { const globalConfig = loadConfigFromPaths(CONFIG_FILES); const projectConfig = loadConfigFromPaths(projectPaths); assertProjectRemoteProviderConfigIsSafe(projectConfig); - const merged: OpenCodeMemConfig = { ...globalConfig, ...projectConfig }; + const projectOverrides = { ...projectConfig }; + delete projectOverrides.autoCleanupEnabled; + delete projectOverrides.autoCleanupRetentionDays; + const merged: OpenCodeMemConfig = { ...globalConfig, ...projectOverrides }; CONFIG = buildConfig(merged); } diff --git a/tests/config-resolution.test.ts b/tests/config-resolution.test.ts index 7e88e3c..fcdc771 100644 --- a/tests/config-resolution.test.ts +++ b/tests/config-resolution.test.ts @@ -47,6 +47,48 @@ describe("project-scoped config resolution", () => { expect(CONFIG.opencodeModel).toBe("project-model"); }); + it("keeps automatic cleanup policy under global configuration", () => { + existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); + readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { + const path = normalizePath(p); + if (path.includes(".opencode/opencode-mem")) { + return JSON.stringify({ + opencodeModel: "project-model", + autoCleanupEnabled: true, + autoCleanupRetentionDays: 0, + }) as any; + } + return JSON.stringify({ + opencodeModel: "global-model", + autoCleanupEnabled: false, + autoCleanupRetentionDays: 90, + }) as any; + }); + + initConfig("/my/project"); + + expect(CONFIG.opencodeModel).toBe("project-model"); + expect(CONFIG.autoCleanupEnabled).toBe(false); + expect(CONFIG.autoCleanupRetentionDays).toBe(90); + }); + + it("uses safe cleanup defaults when only project cleanup settings exist", () => { + existsSpy = spyOn(fs, "existsSync").mockImplementation((p) => + normalizePath(p).includes("/my/project/.opencode/opencode-mem") + ); + readSpy = spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ + autoCleanupEnabled: true, + autoCleanupRetentionDays: -1, + }) + ); + + initConfig("/my/project"); + + expect(CONFIG.autoCleanupEnabled).toBe(true); + expect(CONFIG.autoCleanupRetentionDays).toBe(30); + }); + it("rejects project embedding transport settings before they can inherit secrets", () => { const oldOpenAiKey = process.env.OPENAI_API_KEY; process.env.OPENAI_API_KEY = "ambient-secret"; diff --git a/tests/config.test.ts b/tests/config.test.ts index 3438710..f1304a2 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -15,6 +15,7 @@ const { hasAutoCaptureProviderConfig, isConfigured, isPlaceholderApiKey, + normalizeAutoCleanupRetentionDays, normalizeAutoCaptureMaxContextBytes, } = await import("../src/config.js"); @@ -99,6 +100,15 @@ describe("config", () => { expect(() => normalizeAutoCaptureMaxContextBytes(16 * 1024 * 1024 + 1)).toThrow(); }); + it("should reject unsafe automatic cleanup retention periods", () => { + expect(() => normalizeAutoCleanupRetentionDays(0)).toThrow(); + expect(() => normalizeAutoCleanupRetentionDays(-1)).toThrow(); + expect(() => normalizeAutoCleanupRetentionDays(1.5)).toThrow(); + expect(() => normalizeAutoCleanupRetentionDays(Number.POSITIVE_INFINITY)).toThrow(); + expect(normalizeAutoCleanupRetentionDays(1)).toBe(1); + expect(normalizeAutoCleanupRetentionDays(30)).toBe(30); + }); + it("should expose memory scope config", () => { const defaultScope = CONFIG.memory.defaultScope ?? "project"; expect(["project", "all-projects"]).toContain(defaultScope);