diff --git a/packages/opencode/src/memory/admission.ts b/packages/opencode/src/memory/admission.ts index 1dabe4ea8d..2672ebff1a 100644 --- a/packages/opencode/src/memory/admission.ts +++ b/packages/opencode/src/memory/admission.ts @@ -11,6 +11,7 @@ import { MemoryConfig } from "./config" import { MemoryHome } from "./home" import { MemoryIdentityFence } from "./identity-fence" import { MemoryPaths } from "./paths" +import { MemorySchema } from "./schema" import { MemoryStore } from "./store" const Code = Schema.Literals([ @@ -73,7 +74,7 @@ type TopicCandidate = { type ConfigCandidate = { readonly file: string - readonly config: ReturnType | undefined + readonly config: MemorySchema.Config | undefined } export const layer = Layer.effect( @@ -228,7 +229,7 @@ export const layer = Layer.effect( const decoded = MemoryConfig.decodeConfig(text) return { file, - config: Option.isSome(decoded) ? MemoryConfig.normalizeConfig(decoded.value) : undefined, + config: Option.isSome(decoded) ? decoded.value : undefined, } satisfies ConfigCandidate }), { concurrency: 4 }, @@ -248,7 +249,7 @@ export const layer = Layer.effect( if (text === undefined) return true const decoded = MemoryConfig.decodeConfig(text) if (Option.isNone(decoded)) return false - return same(MemoryConfig.normalizeConfig(decoded.value), candidate.config) + return same(decoded.value, candidate.config) }) const removeValidated = Effect.fnUntraced(function* (candidates: ReadonlyArray) { diff --git a/packages/opencode/src/memory/config.ts b/packages/opencode/src/memory/config.ts index db8162af5e..f2a7315212 100644 --- a/packages/opencode/src/memory/config.ts +++ b/packages/opencode/src/memory/config.ts @@ -71,10 +71,7 @@ export const layer = Layer.effect( yield* Effect.logWarning("memory config is invalid — ignoring", { path: found.path }) return undefined } - if (decoded.value.topic_limit === decoded.value.topic_limit_floor) return decoded.value - const config = normalizeConfig(decoded.value) - yield* flock.withLock(MemoryFile.atomicWrite(fs, found.path, serialize(config)), writeLockKey(found.path)) - return config + return decoded.value }) const load = Effect.fn("MemoryConfig.load")(function* (projectDir: string) { @@ -186,13 +183,8 @@ export function decodeConfig(text: string) { const errors: ParseError[] = [] const value = parse(text, errors, { allowTrailingComma: true }) if (errors.length > 0) return Option.none() - const decoded = Schema.decodeUnknownOption(MemorySchema.Config)(value ?? {}) - if (Option.isNone(decoded) || decoded.value.topic_limit < decoded.value.topic_limit_floor) - return Option.none() - return decoded -} - -export function normalizeConfig(config: MemorySchema.Config) { - if (config.topic_limit === config.topic_limit_floor) return config - return MemorySchema.updateConfig(config, { topic_limit_floor: config.topic_limit }) + // Legacy files may still carry the removed topic_limit_floor knob — Schema + // Struct decoding tolerates the extra property, so such files stay valid + // and the dead value is simply dropped on the next write. + return Schema.decodeUnknownOption(MemorySchema.Config)(value ?? {}) } diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 6aa9e9b12e..6e081178ee 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -130,7 +130,6 @@ export const layer: Layer.Layer< enabled: true, model: selected, topic_limit: 10, - topic_limit_floor: 10, turn_interval: 5, injection: { max_topics: MemorySchema.MAX_INJECTION_TOPICS, diff --git a/packages/opencode/src/memory/schema.ts b/packages/opencode/src/memory/schema.ts index cab3802971..1dfc95d6b4 100644 --- a/packages/opencode/src/memory/schema.ts +++ b/packages/opencode/src/memory/schema.ts @@ -37,10 +37,6 @@ export class Config extends Schema.Class("MemoryConfig")({ Schema.isInt(), Schema.isBetween({ minimum: MIN_TOPIC_LIMIT, maximum: MAX_TOPIC_LIMIT }), ), - topic_limit_floor: Schema.Number.check( - Schema.isInt(), - Schema.isBetween({ minimum: MIN_TOPIC_LIMIT, maximum: MAX_TOPIC_LIMIT }), - ), turn_interval: Schema.Number.check( Schema.isInt(), Schema.isBetween({ minimum: MIN_TURN_INTERVAL, maximum: MAX_TURN_INTERVAL }), @@ -50,14 +46,13 @@ export class Config extends Schema.Class("MemoryConfig")({ export function updateConfig( config: Config, - updates: { enabled?: boolean; model?: string; topic_limit_floor?: number }, + updates: { enabled?: boolean; model?: string }, ) { return new Config({ schema_version: config.schema_version, enabled: updates.enabled ?? config.enabled, model: updates.model ?? config.model, topic_limit: config.topic_limit, - topic_limit_floor: updates.topic_limit_floor ?? config.topic_limit_floor, turn_interval: config.turn_interval, injection: config.injection, }) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index c295d761f5..f5c68749ea 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -213,7 +213,6 @@ export function withCliFixture( enabled: true, model: testModelID, topic_limit: 10, - topic_limit_floor: 10, turn_interval: 5, injection: { max_topics: 3, max_tokens: 1_200 }, }), diff --git a/packages/opencode/test/memory/config.test.ts b/packages/opencode/test/memory/config.test.ts new file mode 100644 index 0000000000..68a6468fa6 --- /dev/null +++ b/packages/opencode/test/memory/config.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { decodeConfig } from "@/memory/config" +import { MemorySchema, updateConfig } from "@/memory/schema" + +// memory-config-fidelity: topic_limit_floor was a dead knob — no runtime +// consumer read it, and readConfig silently rewrote any floor ≠ limit file to +// floor := limit on every load, overwriting explicit user values. The field +// must be gone: legacy files carrying it still decode (extra property is +// tolerated), and decoded/updated configs never contain it. + +const valid = { + schema_version: 1, + enabled: true, + model: "p/m", + topic_limit: 10, + turn_interval: 5, + injection: { max_topics: 3, max_tokens: 1200 }, +} + +describe("MemoryConfig decode/update — topic_limit_floor removed", () => { + test("legacy file with a divergent floor still decodes (field ignored)", () => { + const decoded = decodeConfig(JSON.stringify({ ...valid, topic_limit_floor: 30 })) + expect(decoded._tag).toBe("Some") + if (decoded._tag === "Some") { + expect(Object.keys(decoded.value)).not.toContain("topic_limit_floor") + } + }) + + test("decoded config never carries the field", () => { + const decoded = decodeConfig(JSON.stringify(valid)) + expect(decoded._tag).toBe("Some") + if (decoded._tag === "Some") { + expect(Object.keys(decoded.value)).not.toContain("topic_limit_floor") + } + }) + + test("updateConfig output never carries the field", () => { + const decoded = decodeConfig(JSON.stringify(valid)) + expect(decoded._tag).toBe("Some") + if (decoded._tag === "Some") { + const next = updateConfig(decoded.value, { enabled: false }) + expect(Object.keys(next)).not.toContain("topic_limit_floor") + } + }) + + test("schema bounds for the live knobs are preserved", () => { + expect(decodeConfig(JSON.stringify({ ...valid, topic_limit: 9 }))._tag).toBe("None") + expect(decodeConfig(JSON.stringify({ ...valid, turn_interval: 0 }))._tag).toBe("None") + expect(decodeConfig(JSON.stringify({ ...valid, injection: { max_topics: 4, max_tokens: 1200 } }))._tag).toBe( + "None", + ) + expect(MemorySchema.MIN_TOPIC_LIMIT).toBe(10) + }) +}) diff --git a/packages/opencode/test/memory/memory-admission.test.ts b/packages/opencode/test/memory/memory-admission.test.ts index a514313c59..ab6a1f1ad3 100644 --- a/packages/opencode/test/memory/memory-admission.test.ts +++ b/packages/opencode/test/memory/memory-admission.test.ts @@ -23,7 +23,6 @@ const config = { enabled: true, model: "test/memory-small", topic_limit: 10, - topic_limit_floor: 10, turn_interval: 5, injection: { max_topics: 3, max_tokens: 1_200 }, } as const diff --git a/packages/opencode/test/memory/memory-global-identity.test.ts b/packages/opencode/test/memory/memory-global-identity.test.ts index 687b7a416f..9d38c5672e 100644 --- a/packages/opencode/test/memory/memory-global-identity.test.ts +++ b/packages/opencode/test/memory/memory-global-identity.test.ts @@ -59,7 +59,6 @@ const baseConfig = { enabled: true, model: "test/memory-on", topic_limit: 10, - topic_limit_floor: 10, turn_interval: 5, injection: { max_topics: 3, max_tokens: 1_200 }, } satisfies MemorySchema.Config diff --git a/packages/opencode/test/memory/memory-persistence.test.ts b/packages/opencode/test/memory/memory-persistence.test.ts index 33f1dc95e0..a1d5421e0f 100644 --- a/packages/opencode/test/memory/memory-persistence.test.ts +++ b/packages/opencode/test/memory/memory-persistence.test.ts @@ -29,7 +29,6 @@ const config = { enabled: true, model: "test/memory-small", topic_limit: 10, - topic_limit_floor: 10, turn_interval: 5, injection: { max_topics: 3, max_tokens: 1_200 }, } satisfies MemorySchema.Config diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 730889688b..40c0f0105b 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -37,7 +37,6 @@ const config = { enabled: true, model: "test/memory-small", topic_limit: 10, - topic_limit_floor: 10, turn_interval: 5, injection: { max_topics: 3, max_tokens: 1_200 }, } satisfies MemorySchema.Config @@ -454,10 +453,13 @@ describe("memory config and YAML store", () => { ) expect(yield* memoryConfig.load(tmp)).toBeUndefined() + // topic_limit_floor was removed — legacy files still carrying it decode + // normally (extra property tolerated); the decoded config never has it, + // and load never rewrites the file. yield* Effect.promise(() => fs.writeFile(path.join(directory, "memory.jsonc"), JSON.stringify({ ...config, topic_limit_floor: 50 })), ) - expect(yield* memoryConfig.load(tmp)).toBeUndefined() + expect((yield* memoryConfig.load(tmp))?.config.topic_limit).toBe(10) yield* Effect.promise(() => fs.writeFile( @@ -465,7 +467,7 @@ describe("memory config and YAML store", () => { JSON.stringify({ ...config, topic_limit: 50, topic_limit_floor: 10 }), ), ) - expect((yield* memoryConfig.load(tmp))?.config).toMatchObject({ topic_limit: 50, topic_limit_floor: 50 }) + expect((yield* memoryConfig.load(tmp))?.config).toMatchObject({ topic_limit: 50 }) yield* Effect.promise(() => fs.writeFile( @@ -473,7 +475,7 @@ describe("memory config and YAML store", () => { JSON.stringify({ ...config, topic_limit: 20, topic_limit_floor: 50 }), ), ) - expect(yield* memoryConfig.load(tmp)).toBeUndefined() + expect((yield* memoryConfig.load(tmp))?.config.topic_limit).toBe(20) }), ) @@ -1392,7 +1394,6 @@ describe("memory bootstrap", () => { enabled: true, model: "test/small", topic_limit: 10, - topic_limit_floor: 10, turn_interval: 5, injection: { max_topics: 3, max_tokens: 1_200 }, }) @@ -1446,7 +1447,6 @@ describe("memory bootstrap", () => { ...config, model: "test/conversation", topic_limit: 42, - topic_limit_floor: 42, turn_interval: 9, } const memory = yield* Memory.Service @@ -1457,7 +1457,6 @@ describe("memory bootstrap", () => { expect(bootstrap.state.global).toMatchObject({ model: "test/conversation", topic_limit: 42, - topic_limit_floor: 42, turn_interval: 9, }) expect(bootstrap.state.modelCalls).toBe(0) @@ -1475,7 +1474,6 @@ describe("memory bootstrap", () => { ...config, model: "removed/model", topic_limit: 37, - topic_limit_floor: 37, turn_interval: 7, } const memory = yield* Memory.Service @@ -1485,7 +1483,6 @@ describe("memory bootstrap", () => { expect(bootstrap.state.written).toMatchObject({ model: "test/compaction", topic_limit: 37, - topic_limit_floor: 37, turn_interval: 7, }) expect(bootstrap.state.modelCalls).toBe(0) @@ -1523,7 +1520,6 @@ describe("memory bootstrap", () => { expect(bootstrap.state.written).toMatchObject({ model: "test/conversation", topic_limit: 10, - topic_limit_floor: 10, turn_interval: 5, }) expect(bootstrap.state.modelCalls).toBe(0) @@ -1544,7 +1540,6 @@ describe("memory bootstrap", () => { ...config, model: "removed/model", topic_limit: 37, - topic_limit_floor: 37, turn_interval: 7, } const memory = yield* Memory.Service @@ -1561,7 +1556,6 @@ describe("memory bootstrap", () => { expect(bootstrap.state.written).toMatchObject({ model: "test/conversation", topic_limit: 37, - topic_limit_floor: 37, turn_interval: 7, }) expect(bootstrap.state.modelCalls).toBe(0) @@ -1601,7 +1595,6 @@ describe("memory bootstrap", () => { expect(bootstrap.state.written).toMatchObject({ model: "test/conversation", topic_limit: 10, - topic_limit_floor: 10, turn_interval: 5, }) expect(bootstrap.state.modelCalls).toBe(0) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index 503e6860aa..44a0bf7470 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -159,7 +159,6 @@ describe("Worktree.remove", () => { enabled, model, topic_limit: 10, - topic_limit_floor: 10, turn_interval: 5, injection: { max_topics: 3, max_tokens: 1_200 }, })