Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
version: 1
delivery: issue347
delivery: issue350
context:
kind: branch
branch: feat/347-issue347
branch: feat/350-issue350
issues:
- 347
pr: 364
- 350
pr: 365
35 changes: 31 additions & 4 deletions packages/opencode/src/memory/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ export interface Interface {
query: string
}) => Effect.Effect<SearchResult>
readonly checkpoint: (input: { sessionID: SessionID; messages: SessionV1.WithParts[] }) => Effect.Effect<string[]>
readonly setEnabled: (enabled: boolean) => Effect.Effect<"Memory on" | "Memory off" | "Memory remains off">
readonly setEnabled: (enabled: boolean) => Effect.Effect<string>
/** #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<string | undefined>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Memory") {}
Expand Down Expand Up @@ -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* () {
Expand Down Expand Up @@ -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 })
}),
)

Expand Down
20 changes: 16 additions & 4 deletions packages/opencode/src/tool/memory-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,23 @@ export const MemorySearchTool = Tool.define<typeof Parameters, Metadata, never>(

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<typeof Parameters, Metadata>),
)
Expand Down Expand Up @@ -81,10 +93,10 @@ function response(result: Memory.SearchResult): Tool.ExecuteResult<Metadata> {
return unavailable()
}

function unavailable(): Tool.ExecuteResult<Metadata> {
function unavailable(reason?: string): Tool.ExecuteResult<Metadata> {
return {
title: "memory unavailable",
output: "Memory search is unavailable for this session",
output: reason ?? "Memory search is unavailable for this session",
metadata: { status: "unavailable" },
}
}
Expand Down
39 changes: 37 additions & 2 deletions packages/opencode/test/memory/memory-global-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}),
)
}),
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 },
)
})
Loading