From 85fb68e51f29573f58db08cef95d921d886c1ea5 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 13:38:27 +0800 Subject: [PATCH 1/2] fix(memory): /memory on and memory_search say why Memory is inert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every fail-closed activation gate returned the same bare 'Memory remains off'/'unavailable' — a project with a real git identity but no /init stamp was indistinguishable from a disabled Memory (real case: /memory on silently staying off). New Memory.statusReason() names the gate a user can act on (missing /init stamp, commit-less global identity, non-git repo; retirement and admission repair stay log-only operator concerns); setEnabled(true) returns it, and memory_search attaches it to the unavailable answer. Closes #350 --- .specgit.yaml | 7 ++-- packages/opencode/src/memory/memory.ts | 35 +++++++++++++++-- packages/opencode/src/tool/memory-search.ts | 20 ++++++++-- .../memory/memory-global-identity.test.ts | 39 ++++++++++++++++++- 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index fd649a098f..cdb38c8a20 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue347 +delivery: issue350 context: kind: branch - branch: feat/347-issue347 + branch: feat/350-issue350 issues: - - 347 -pr: 364 + - 350 diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 89b2f99da2..2329833914 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -55,7 +55,11 @@ export interface Interface { query: string }) => Effect.Effect readonly checkpoint: (input: { sessionID: SessionID; messages: SessionV1.WithParts[] }) => Effect.Effect - readonly setEnabled: (enabled: boolean) => Effect.Effect<"Memory on" | "Memory off" | "Memory remains off"> + readonly setEnabled: (enabled: boolean) => Effect.Effect + /** #350: why Memory is inert for the current project — undefined when the + * project passes every activation gate. Surface this wherever a silent + * "remains off" would leave the user guessing (e.g. /memory on). */ + readonly statusReason: () => Effect.Effect } export class Service extends Context.Service()("@opencode/Memory") {} @@ -767,9 +771,32 @@ export const layer: Layer.Layer< ), ) + // #350: the why-is-Memory-inert companion of configuration()'s fail-closed + // gates. Mirrors their order; only the gates a user can act on produce a + // reason (identity retirement and admission repair stay log-only — they + // are operator concerns, not /memory on guidance). + const statusReason = Effect.fn("Memory.statusReason")(function* () { + const ctx = yield* InstanceState.context + const current = yield* project.get(ctx.project.id) + if (!current) return "Memory is unavailable for this project: its identity is retired or unregistered." + if (current.id === ProjectV2.ID.global) + return "Memory is unavailable until this repository has a real identity: commit once or add a remote, then run /init." + if (current.vcs !== "git") return "Memory requires a git repository." + if (!current.time.initialized) + return "Memory is unavailable until the project is initialized — run /init first, then /memory on." + return undefined + }) + const setEnabledUnsafe = Effect.fn("Memory.setEnabledUnsafe")(function* (enabled: boolean) { const initial = yield* configuration() - if (!initial) return "Memory remains off" as const + if (!initial) { + if (!enabled) return "Memory remains off" + // #350: a /memory on that cannot activate must say WHY — the bare + // "remains off" sent users to guess (real case: an initialized git + // project whose /init stamp was missing looked identical to a + // disabled Memory). + return (yield* statusReason()) ?? "Memory remains off" + } const value = initial.loaded ? initial : yield* Effect.gen(function* () { @@ -800,13 +827,13 @@ export const layer: Layer.Layer< Effect.catchCause((cause) => Effect.gen(function* () { yield* Effect.logWarning("MEMORY command failed", { cause }) - return "Memory remains off" as const + return "Memory remains off" }), ), ), ) - return Service.of({ init, prepare, context, search, checkpoint, setEnabled }) + return Service.of({ init, prepare, context, search, checkpoint, setEnabled, statusReason }) }), ) diff --git a/packages/opencode/src/tool/memory-search.ts b/packages/opencode/src/tool/memory-search.ts index 0487719279..c2af0e33ff 100644 --- a/packages/opencode/src/tool/memory-search.ts +++ b/packages/opencode/src/tool/memory-search.ts @@ -33,11 +33,23 @@ export const MemorySearchTool = Tool.define( const memory = Option.getOrUndefined(yield* Effect.serviceOption(Memory.Service)) const sessions = Option.getOrUndefined(yield* Effect.serviceOption(Session.Service)) - if (!memory || !sessions) return unavailable() + // #350: when the service exists but Memory is inert, say why instead + // of a bare "unavailable" — the reason tells the user what to do + // (e.g. run /init, then /memory on). + if (!memory) return unavailable() + if (!sessions) return unavailable() const current = yield* sessions.get(ctx.sessionID).pipe(Effect.option) if (Option.isNone(current) || current.value.parentID) return unavailable() - return response(yield* memory.search({ sessionID: ctx.sessionID, messages: ctx.messages, query })) + const result = yield* memory.search({ sessionID: ctx.sessionID, messages: ctx.messages, query }) + // #350: an inert Memory answers "unavailable" with no field to carry + // why — surface the actionable reason (init stamp, git identity) + // instead of leaving the caller to guess. + if (result.status === "unavailable") { + const reason = yield* memory.statusReason() + if (reason) return unavailable(reason) + } + return response(result) }), } satisfies Tool.DefWithoutID), ) @@ -81,10 +93,10 @@ function response(result: Memory.SearchResult): Tool.ExecuteResult { return unavailable() } -function unavailable(): Tool.ExecuteResult { +function unavailable(reason?: string): Tool.ExecuteResult { return { title: "memory unavailable", - output: "Memory search is unavailable for this session", + output: reason ?? "Memory search is unavailable for this session", metadata: { status: "unavailable" }, } } diff --git a/packages/opencode/test/memory/memory-global-identity.test.ts b/packages/opencode/test/memory/memory-global-identity.test.ts index 9d38c5672e..108272dbc1 100644 --- a/packages/opencode/test/memory/memory-global-identity.test.ts +++ b/packages/opencode/test/memory/memory-global-identity.test.ts @@ -204,7 +204,10 @@ describe("MEM-PR01-R1-03: memory is inert once the identity row is retired", () Effect.gen(function* () { const retired = yield* memory.search({ sessionID, messages: [userMessage(sessionID)], query: "任意查询" }) expect(retired.status).toBe("unavailable") - expect(yield* memory.setEnabled(true)).toBe("Memory remains off") + // #350: a /memory on that cannot activate says WHY instead of + // the bare "remains off" (retired identity / global identity + // are actionable reasons). + expect(yield* memory.setEnabled(true)).toContain("unavailable") }), ) }), @@ -369,7 +372,9 @@ describe("MEM-PR01-00: memory is inert under the shared global identity", () => yield* project.setInitialized(info.id) yield* configStore.writeGlobal(baseConfig) - expect(yield* memory.setEnabled(true)).toBe("Memory remains off") + // #350: says WHY (commit-less repo under the shared global + // identity) instead of the bare "remains off". + expect(yield* memory.setEnabled(true)).toContain("unavailable") expect(fs.existsSync(path.join(dir, ".opencode", "memory.jsonc"))).toBe(false) }), ).pipe(Effect.provide(testInstanceStoreLayer)) @@ -408,4 +413,34 @@ describe("MEM-PR01-00: memory is inert under the shared global identity", () => }), { timeout: 30_000 }, ) + + // #350: the inert gates must be self-explanatory — /memory on and + // memory_search surface the actionable reason instead of a bare "off". + it.live( + "statusReason names the missing /init stamp, and /memory on carries it", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* provideInstance(dir)( + Effect.gen(function* () { + const project = yield* Project.Service + const memory = yield* Memory.Service + + const { project: info } = yield* project.fromDirectory(dir) + expect(info.id).not.toBe(ProjectV2.ID.global) + // NOT setInitialized: a real git identity without the /init stamp + // is the exact shape that used to fail silently. + expect(yield* memory.statusReason()).toContain("/init") + + const turningOn = yield* memory.setEnabled(true) + expect(turningOn).toContain("/init") + expect(turningOn).not.toBe("Memory remains off") + + yield* project.setInitialized(info.id) + expect(yield* memory.statusReason()).toBeUndefined() + }), + ).pipe(Effect.provide(testInstanceStoreLayer)) + }), + { timeout: 30_000 }, + ) }) From de6621351d7f4b5d2162996c677c689f0b365ef0 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 13:39:05 +0800 Subject: [PATCH 2/2] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index cdb38c8a20..04bc1f688a 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/350-issue350 issues: - 350 +pr: 365