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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ bun run build # dist/ via tsconfig.build.json
- Plugin state: `<CLAUDE_CONFIG_DIR>/opencode-memory/<same key>/extraction-state.json` (+ `extraction-state.lock` around every update, + `maintenance.lock` shared by extraction forks and auto-dream across processes, + `opencode-memory.log` on V2). A v1 `<cksum>.consolidate-lock` is migrated on first catch-up; a v1 shell hook still present in an rc file is reported with a warn log.
- Agent names are fixed: `opencode-memory-recall`, `opencode-memory-extract`, `opencode-memory-dream`. V1's `config` hook merges defaults under whatever the user configured (`agent.<name>`); V2's `agent.transform` does the same for `agents.<name>`.
- OpenCode dedupes `plugin` entries by package name across global/project config, last one wins — plugin options are not merged across files.
- `readOnly` (config): nothing under `CLAUDE_CONFIG_DIR` is written — `parseConfig` forces `extract` / `autodream` off, `MemoryStore` skips `mkdir` and its `save` / `delete` throw, `buildMemoryToolSpecs` drops `memory_save` / `memory_delete`, only the recall agent is registered, V2 logs nowhere, and the prompt is the read-only variant (`WHEN_TO_ACCESS_READ_ONLY`).
- V1 recall falls back to text: a `StructuredOutputError` from the json_schema fork switches that host to `buildGeneratePrompt` (`src/host/generate.ts`, shared with V2) and `parseSelectedMemories`, which also takes a bare JSON array.
- `readOnly` (config): the plugin writes nothing under `CLAUDE_CONFIG_DIR` — `parseConfig` forces `extract` / `autodream` off, `MemoryStore` skips `mkdir` and its `save` / `delete` throw, `buildMemoryToolSpecs` drops `memory_save` / `memory_delete`, only the recall agent is registered, session deletion leaves persistent state unchanged, V2 logs nowhere, and the prompt is the read-only variant (`WHEN_TO_ACCESS_READ_ONLY`). Host shell/file tools and other plugins are outside this guarantee; enforced read-only access requires host permissions or filesystem access controls.
- V1 recall falls back to text per request: a `StructuredOutputError` from the json_schema fork retries that request once with `buildGeneratePrompt` (`src/host/generate.ts`, shared with V2) and `parseSelectedMemories`, which also takes a bare JSON array. Later requests still try structured output; there is no host-wide capability cache. Models that consistently fail structured output require two attempts per recall request.
- Design history for v2 lives in `docs/v2/`.

## V2 notes
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ All behaviour is configured through OpenCode's own configuration. There are no `
- When the same plugin is listed in both the global and the project `opencode.json`, OpenCode keeps the **last** declaration (project wins); options are not merged across files.
- The three agents are registered hidden with a memory-only tool sandbox. On 1.x, override any field (`model`, `steps`, `temperature`, …) under `agent.<name>`; the plugin fills in the rest.
- `CLAUDE_CONFIG_DIR` is honoured exactly like Claude Code does, and is the only environment variable the plugin reads.
- `readOnly: true` uses Claude Code's memory without ever writing to the Claude config directory: no `memory_save` / `memory_delete`, no extraction or auto-dream (whatever `extract` / `autodream` say), no plugin state or log file, and the memory folder is not created. The index, recall and `memory_list` / `memory_search` / `memory_read` keep working, and the prompt tells the model the memory is read-only.
- `readOnly: true` prevents this plugin from writing to the Claude config directory: no `memory_save` / `memory_delete`, no extraction or auto-dream (whatever `extract` / `autodream` say), no plugin state or log writes, and the memory folder is not created. The index, recall and `memory_list` / `memory_search` / `memory_read` keep working, and the prompt tells the model the memory is read-only.
- This option does not restrict the host's shell or file-editing tools, or other plugins. To enforce read-only access for the entire host, also configure host permissions or filesystem access controls; the memory prompt alone does not enforce it.

```jsonc
// opencode.json — OpenCode 2.x
Expand Down Expand Up @@ -207,9 +208,9 @@ Everything the environment variables used to control now lives under `extract`,

**Can I disable extraction, auto-dream or recall?** Yes — `extract.enabled`, `autodream.enabled`, `recall.enabled` in the plugin options.

**Can OpenCode read Claude Code's memory without adding to it?** Yes — `readOnly: true`. Nothing is written under `CLAUDE_CONFIG_DIR`; see [Configuration](#-configuration).
**Can I make this plugin read Claude Code's memory without changing it?** Yes — `readOnly: true`. The plugin does not write under `CLAUDE_CONFIG_DIR`. Enforcing read-only access for the entire host also requires host permissions or filesystem access controls; see [Configuration](#-configuration).

**My recall model has no structured output.** On 1.x the selector asks for structured output first; when the server answers with a `StructuredOutputError` the plugin retries once with the schema described in the prompt and parses the text, and keeps doing that for the rest of the session.
**My recall model has no structured output.** On 1.x each selector request asks for structured output first. If the server answers with a `StructuredOutputError`, the plugin retries that request once with the schema described in the prompt and parses the text. Later requests still try structured output: a single failure does not establish that the model lacks support. A model that consistently fails structured output therefore needs two attempts per recall request. Other errors are not retried.

**Why did my first answer take a moment longer?** The system prompt waits up to `recall.waitMs` for the selector. Set it to `0` to never wait (recalled memories then appear from the second LLM call of a turn onwards).

Expand Down
8 changes: 3 additions & 5 deletions src/host/v1/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,6 @@ function isStructuredOutputError(error: unknown): boolean {
}

export function createV1Host({ client, directory, toolsFor }: V1HostOptions): MemoryHost {
// Cleared by the first StructuredOutputError: from then on this host describes the schema in the
// prompt and the caller parses the text, as the V2 host always does.
let structuredOutput = true
return {
async readTranscript(sessionID) {
const response = await withDeadline("session.messages", SDK_READ_TIMEOUT_MS, (signal) =>
Expand Down Expand Up @@ -107,12 +104,13 @@ export function createV1Host({ client, directory, toolsFor }: V1HostOptions): Me
onFinished: input.onFinished,
onCleanupFailed: input.onCleanupFailed,
})
if (structuredOutput && input.schema) {
if (input.schema) {
try {
return forkAnswerText(await run(true))
} catch (error) {
if (!isStructuredOutputError(error)) throw error
structuredOutput = false
// One failed answer does not establish a model capability. Retry only this request as
// text, leaving later requests (in this session or another) free to use structured output.
}
}
return forkAnswerText(await run(false))
Expand Down
39 changes: 37 additions & 2 deletions test/recall/structuredFallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,52 @@ describe("recall without structured output", () => {
timeoutMs: 5_000,
}

test("retries as text once, then stays in text mode for that host", async () => {
test("retries as text once per request without disabling structured output for later requests", async () => {
const { client, prompts } = textOnlyClient('["deploy.md"]')
const host = createV1Host({ client, directory: tempGitRepo() })
expect(await host.generate(input)).toBe('["deploy.md"]')
expect(await host.generate(input)).toBe('["deploy.md"]')
expect(prompts.map((body) => body.format !== undefined)).toEqual([true, false, false])
expect(prompts.map((body) => body.format !== undefined)).toEqual([true, false, true, false])
const parts = (prompts[1]?.parts ?? []) as Array<{ text: string }>
const text = parts[0]?.text
expect(text).toContain("Respond with only a JSON object matching this JSON schema")
})

test.each(["ses_first", "ses_other"])(
"a transient structured-output failure does not downgrade the next request from %s",
async (parentSessionID) => {
const selector = makeSelectorClient()
const formats: boolean[] = []
selector.raw.session.prompt = async (options?: unknown) => {
const body = (options as { body: Record<string, unknown> }).body
formats.push(body.format !== undefined)
if (formats.length === 1) return { data: { info: { error: { name: "StructuredOutputError" } }, parts: [] } }
if (body.format !== undefined)
return { data: { info: { structured: { selected_memories: ["recovered.md"] } }, parts: [] } }
return { data: { info: {}, parts: [{ type: "text", text: '["fallback.md"]' }] } }
}
const host = createV1Host({ client: selector.client, directory: tempGitRepo() })
expect(parseSelectedMemories(await host.generate({ ...input, parentSessionID: "ses_first" }))).toEqual([
"fallback.md",
])
expect(parseSelectedMemories(await host.generate({ ...input, parentSessionID }))).toEqual(["recovered.md"])
expect(formats).toEqual([true, false, true])
},
)

test("a failed text retry rejects without another retry", async () => {
const selector = makeSelectorClient()
const formats: boolean[] = []
selector.raw.session.prompt = async (options?: unknown) => {
const body = (options as { body: Record<string, unknown> }).body
formats.push(body.format !== undefined)
return { data: { info: { error: { name: "StructuredOutputError" } }, parts: [] } }
}
const host = createV1Host({ client: selector.client, directory: tempGitRepo() })
await expect(host.generate(input)).rejects.toThrow("StructuredOutputError")
expect(formats).toEqual([true, false])
})

test("any other model error still fails the call", async () => {
const selector = makeSelectorClient()
selector.raw.session.prompt = async () => ({ data: { info: { error: { name: "APIError" } }, parts: [] } })
Expand Down
Loading