From d0d4547456f87f366c2eafdd5b6b3b232c360503 Mon Sep 17 00:00:00 2001 From: Meryl Hu Date: Wed, 7 Oct 2026 09:15:38 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(config):=20readOnly=20=E2=80=94=20use?= =?UTF-8?q?=20Claude=20Code's=20memory=20without=20writing=20to=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For hosts that may read what Claude Code remembers but must not add to it. With `readOnly: true` nothing under CLAUDE_CONFIG_DIR is written: - parseConfig forces extract / autodream off - MemoryStore skips mkdir; save / delete throw - memory_save / memory_delete are not offered (list / search / read stay) - only the recall agent is registered (V1 and V2) - V2 logs nowhere (its log file lives under the config directory) - the prompt is a read-only variant: no "write to it directly", no how-to-save, no update/delete bullets Co-Authored-By: Claude Opus 5.5 --- src/agents.ts | 15 ++-- src/config.ts | 12 ++- src/host/v1/agents.ts | 8 +- src/host/v1/plugin.ts | 2 +- src/host/v2/agents.ts | 4 +- src/host/v2/plugin.ts | 11 ++- src/prompt/sections.ts | 9 +++ src/prompt/systemPrompt.ts | 51 +++++++++---- src/store/MemoryStore.ts | 14 +++- src/tools.ts | 6 +- test/readOnly.test.ts | 145 +++++++++++++++++++++++++++++++++++++ 11 files changed, 241 insertions(+), 36 deletions(-) create mode 100644 test/readOnly.test.ts diff --git a/src/agents.ts b/src/agents.ts index 0be82ee..5273e33 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -24,13 +24,16 @@ export type MemoryAgentDefaults = { allowedTools: readonly MemoryToolName[] } -export function memoryAgentDefaults(agents: MemoryAgents): Record { +export function memoryAgentDefaults(agents: MemoryAgents, readOnly = false): Record { + const recall: MemoryAgentDefaults = { + temperature: 0, + prompt: SELECT_MEMORIES_SYSTEM_PROMPT, + allowedTools: [], + } + // Read-only runs no extraction or auto-dream, so their agents (which may write) are not registered. + if (readOnly) return { [agents.recall]: recall } return { - [agents.recall]: { - temperature: 0, - prompt: SELECT_MEMORIES_SYSTEM_PROMPT, - allowedTools: [], - }, + [agents.recall]: recall, [agents.extract]: { prompt: EXTRACT_PROMPT, // A legitimate extraction is a handful of memory_save calls; the step cap terminates a model diff --git a/src/config.ts b/src/config.ts index 391d9f9..61317f8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,11 @@ export const MemoryOptionsSchema = z }) .strict() .default({}), + // Use Claude Code's memory without ever writing to the Claude config directory: no memory_save / + // memory_delete tools, no extraction or auto-dream, no plugin state or log files, and the memory + // folder is not created. Recall and memory_list / memory_search / memory_read keep working. For + // hosts that may read what Claude Code remembers but must not add to it. + readOnly: z.boolean().default(false), }) .strict() @@ -94,8 +99,13 @@ export function parseConfig( env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir(), ): MemoryConfig { + const parsed = parseMemoryOptions(options) return { - ...parseMemoryOptions(options), + ...parsed, + // Read-only switches off everything that writes, whatever the per-feature options say. + ...(parsed.readOnly + ? { extract: { ...parsed.extract, enabled: false }, autodream: { ...parsed.autodream, enabled: false } } + : {}), claudeConfigDir: resolveClaudeConfigDir(env), homeDir, agents: MEMORY_AGENTS, diff --git a/src/host/v1/agents.ts b/src/host/v1/agents.ts index 9127b46..c6d49ac 100644 --- a/src/host/v1/agents.ts +++ b/src/host/v1/agents.ts @@ -4,9 +4,9 @@ import { memoryAgentDefaults } from "../../agents.js" import type { MemoryAgents } from "../../config.js" import type { AgentConfig, PluginConfig } from "./sdk.js" -export function buildAgentDefaults(agents: MemoryAgents): Record { +export function buildAgentDefaults(agents: MemoryAgents, readOnly = false): Record { return Object.fromEntries( - Object.entries(memoryAgentDefaults(agents)).map(([name, defaults]) => { + Object.entries(memoryAgentDefaults(agents, readOnly)).map(([name, defaults]) => { const config: AgentConfig = { mode: "all", hidden: true, @@ -33,8 +33,8 @@ export class AgentRegistry { private readonly defaults: Record private readonly merged: Record - constructor(agents: MemoryAgents) { - this.defaults = buildAgentDefaults(agents) + constructor(agents: MemoryAgents, readOnly = false) { + this.defaults = buildAgentDefaults(agents, readOnly) this.merged = { ...this.defaults } } diff --git a/src/host/v1/plugin.ts b/src/host/v1/plugin.ts index 240f437..7f9cd38 100644 --- a/src/host/v1/plugin.ts +++ b/src/host/v1/plugin.ts @@ -23,7 +23,7 @@ export const createMemoryPlugin = const store = new MemoryStore(resolveMemoryRoot(worktree, dir), config) const log = createLogger(client, dir) const owned = new OwnedSessions() - const agents = new AgentRegistry(config.agents) + const agents = new AgentRegistry(config.agents, config.readOnly) const host = client ? createV1Host({ client, directory: dir, toolsFor: (name) => agents.toolsFor(name) }) : undefined diff --git a/src/host/v2/agents.ts b/src/host/v2/agents.ts index ec2a4d9..5778e7c 100644 --- a/src/host/v2/agents.ts +++ b/src/host/v2/agents.ts @@ -52,7 +52,7 @@ export function userRules(rules: readonly PermissionRule[], baseline: readonly P return rules.filter((rule) => !base.has(ruleKey(rule))).map((rule) => ({ ...rule })) } -export function applyMemoryAgents(editor: AgentEditor, agents: MemoryAgents): void { +export function applyMemoryAgents(editor: AgentEditor, agents: MemoryAgents, readOnly = false): void { editor.update(BASELINE_PROBE_AGENT, () => {}) const baseline = (editor.get(BASELINE_PROBE_AGENT)?.permissions ?? []).map((rule) => ({ ...rule })) editor.remove(BASELINE_PROBE_AGENT) @@ -64,7 +64,7 @@ export function applyMemoryAgents(editor: AgentEditor, agents: MemoryAgents): vo agent.permissions = PROBE_RULES.map((rule) => ({ ...rule })) }) - for (const [name, defaults] of Object.entries(memoryAgentDefaults(agents))) { + for (const [name, defaults] of Object.entries(memoryAgentDefaults(agents, readOnly))) { applyAgent(editor, name, defaults, baseline) } } diff --git a/src/host/v2/plugin.ts b/src/host/v2/plugin.ts index e0aa4a6..183c964 100644 --- a/src/host/v2/plugin.ts +++ b/src/host/v2/plugin.ts @@ -13,7 +13,7 @@ import { RecallCoordinator } from "../../recall/RecallCoordinator.js" import { MemoryStore } from "../../store/MemoryStore.js" import { resolveMemoryRoot } from "../../store/paths.js" import { buildMemoryToolSpecs, type MemoryToolSpec } from "../../tools.js" -import { getErrorMessage } from "../../util/log.js" +import { getErrorMessage, type Logger } from "../../util/log.js" import { OwnedSessions } from "../../util/ownedSessions.js" import { withDeadline } from "../../util/timeout.js" import { applyMemoryAgents, type PermissionRule, sandboxRules } from "./agents.js" @@ -58,9 +58,10 @@ export const createV2Setup = const directory = ctx.location.directory const worktree = ctx.location.project?.directory ?? directory const store = new MemoryStore(resolveMemoryRoot(worktree, directory), config) - const log = createFileLogger(store.stateDir) + // The log file lives under the Claude config directory, which read-only mode never writes. + const log: Logger = config.readOnly ? () => {} : createFileLogger(store.stateDir) const owned = new OwnedSessions() - const defaults = memoryAgentDefaults(config.agents) + const defaults = memoryAgentDefaults(config.agents, config.readOnly) const host = new V2Host({ session: ctx.session as unknown as V2SessionApi, generateText: (input) => ctx.generate.text(input), @@ -92,7 +93,9 @@ export const createV2Setup = } const registerHooks = async (): Promise => { - await register("agent.transform", () => ctx.agent.transform((editor) => applyMemoryAgents(editor, config.agents))) + await register("agent.transform", () => + ctx.agent.transform((editor) => applyMemoryAgents(editor, config.agents, config.readOnly)), + ) await register("tool.transform", () => ctx.tool.transform((editor) => { diff --git a/src/prompt/sections.ts b/src/prompt/sections.ts index 9119e29..805f351 100644 --- a/src/prompt/sections.ts +++ b/src/prompt/sections.ts @@ -107,6 +107,15 @@ export const WHEN_TO_ACCESS = [ '- Do not delete a memory because it looks stale or because what it names cannot be found: it may live on another branch, in another checkout or outside the repo, and the note is still history. Update the memory with what you observed instead (e.g. "`scripts/deploy.sh` not found as of 2026-03-05"), so the next session does not repeat the check. Delete a memory only when the user asks you to forget it, or when a corrected or merged memory replaces it.', ].join("\n") +// WHEN_TO_ACCESS for `readOnly`: the same reading rules, without the bullets that edit or delete. +export const WHEN_TO_ACCESS_READ_ONLY = [ + "## When to access memories", + "- When memories seem relevant, or the user references prior-conversation work.", + "- You MUST access memory when the user explicitly asks you to check or recall something.", + "- If the user says to *ignore* or *not use* memory: proceed as if MEMORY.md were empty. Do not apply remembered facts, cite, compare against, or mention memory content.", + "- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now and answer from that, not from the memory.", +].join("\n") + // Port of Claude Code's TRUSTING_RECALL_SECTION from memoryTypes.ts export const TRUSTING_RECALL = [ "## Before recommending from memory", diff --git a/src/prompt/systemPrompt.ts b/src/prompt/systemPrompt.ts index 163efae..8f69e9d 100644 --- a/src/prompt/systemPrompt.ts +++ b/src/prompt/systemPrompt.ts @@ -9,6 +9,7 @@ import { TYPES_SECTION, WHAT_NOT_TO_SAVE, WHEN_TO_ACCESS, + WHEN_TO_ACCESS_READ_ONLY, } from "./sections.js" // First line of every system prompt segment this plugin injects. The messages transform uses it to @@ -22,7 +23,7 @@ export type BuildMemorySystemPromptOptions = { } export function buildMemorySystemPrompt( - store: Pick, + store: Pick & { readOnly?: boolean }, recalledMemoriesSection?: string, options: BuildMemorySystemPromptOptions = {}, ): string { @@ -48,30 +49,46 @@ export function buildMemorySystemPrompt( "- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.", ].join("\n") - const lines: string[] = [ + // Read-only (config `readOnly`): how to read the memory, nothing about writing it. + const readOnlyLines = [ AUTO_MEMORY_MARKER, "# Auto Memory", "", - `You have a persistent, file-based memory system at \`${store.memoryDir}\`. This directory already exists — write to it directly (do not run mkdir or check for its existence).`, + `You have a persistent, file-based memory system at \`${store.memoryDir}\`, shared with Claude Code. In this session it is read-only: never create, edit or delete anything in that directory, even when the user asks you to remember or forget something — tell them memory is read-only here instead.`, "", - "You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.", - "", - "If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.", - "", - TYPES_SECTION, - WHAT_NOT_TO_SAVE, - "", - howToSave, - "", - WHEN_TO_ACCESS, + WHEN_TO_ACCESS_READ_ONLY, "", TRUSTING_RECALL, "", - PERSISTENCE_SECTION, - "", ...buildSearchingPastContextSection(store.memoryDir, store.projectDir), ] + const lines: string[] = store.readOnly + ? readOnlyLines + : [ + AUTO_MEMORY_MARKER, + "# Auto Memory", + "", + `You have a persistent, file-based memory system at \`${store.memoryDir}\`. This directory already exists — write to it directly (do not run mkdir or check for its existence).`, + "", + "You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.", + "", + "If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.", + "", + TYPES_SECTION, + WHAT_NOT_TO_SAVE, + "", + howToSave, + "", + WHEN_TO_ACCESS, + "", + TRUSTING_RECALL, + "", + PERSISTENCE_SECTION, + "", + ...buildSearchingPastContextSection(store.memoryDir, store.projectDir), + ] + if (includeIndex) { const indexContent = store.readIndex() if (indexContent.trim()) { @@ -81,7 +98,9 @@ export function buildMemorySystemPrompt( lines.push( `## ${ENTRYPOINT_NAME}`, "", - `Your ${ENTRYPOINT_NAME} is currently empty. When you save new memories, they will appear here.`, + store.readOnly + ? `${ENTRYPOINT_NAME} is currently empty.` + : `Your ${ENTRYPOINT_NAME} is currently empty. When you save new memories, they will appear here.`, ) } } diff --git a/src/store/MemoryStore.ts b/src/store/MemoryStore.ts index 5efabcd..dfab2a0 100644 --- a/src/store/MemoryStore.ts +++ b/src/store/MemoryStore.ts @@ -68,6 +68,8 @@ export type ListOptions = { export type MemoryStoreOptions = { claudeConfigDir: string + // Never write: the memory folder is not created and save / delete throw (see config `readOnly`). + readOnly?: boolean // Clock for the `modified` stamp; injectable for tests. now?: () => Date } @@ -85,6 +87,7 @@ export class MemoryStore { // Plugin-private state (extraction watermarks, auto-dream gate) lives next to, not inside, the // Claude Code project directory so Claude Code never sees it. readonly stateDir: string + readonly readOnly: boolean private readonly now: () => Date constructor(memoryRoot: string, options: MemoryStoreOptions) { @@ -92,13 +95,14 @@ export class MemoryStore { this.gitRoot = findGitRoot(memoryRoot) this.canonicalRoot = findCanonicalGitRoot(memoryRoot) ?? memoryRoot this.claudeConfigDir = options.claudeConfigDir + this.readOnly = options.readOnly ?? false this.now = options.now ?? (() => new Date()) const projectKey = sanitizePath(this.canonicalRoot) this.projectDir = join(this.claudeConfigDir, "projects", projectKey) this.memoryDir = join(this.projectDir, "memory") this.entrypoint = join(this.memoryDir, ENTRYPOINT_NAME) this.stateDir = join(this.claudeConfigDir, "opencode-memory", projectKey) - mkdirSync(this.memoryDir, { recursive: true }) + if (!this.readOnly) mkdirSync(this.memoryDir, { recursive: true }) } scan(): MemoryHeader[] { @@ -127,6 +131,7 @@ export class MemoryStore { } save(input: SaveMemoryInput): SaveMemoryResult { + this.assertWritable() const { relativePath, filePath } = resolveMemoryFilePath(this.memoryDir, input.fileName) if (typeof input.name !== "string" || !input.name.trim()) { throw new Error("Memory name is required") @@ -175,6 +180,7 @@ export class MemoryStore { // directory, as dsh-unified-memory does, so an auto-dream prune can never silently destroy // another tool's work. delete(fileName: string): DeleteMemoryResult { + this.assertWritable() const { relativePath, filePath } = resolveMemoryFilePath(this.memoryDir, fileName) const existing = readTextFile(filePath) if (existing === null) return { deleted: false } @@ -212,6 +218,12 @@ export class MemoryStore { private writeIndex(content: string): void { writeFileAtomicSync(this.entrypoint, content) } + + // Defence in depth: in read-only mode nothing registers a writing tool or runs a writing fork, so + // reaching this is a bug, and it must fail rather than touch Claude Code's files. + private assertWritable(): void { + if (this.readOnly) throw new Error("Memory is read-only (readOnly: true)") + } } // A memory's name and description as the scanner (and `read()`) reports them: missing fields diff --git a/src/tools.ts b/src/tools.ts index 4d222ab..4f26017 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -74,11 +74,14 @@ function str(args: Record, key: string): string { return typeof value === "string" ? value : "" } +// The tools that change memory files; a read-only store (config `readOnly`) does not offer them. +const WRITING_TOOLS: readonly string[] = ["memory_save", "memory_delete"] + export function buildMemoryToolSpecs( store: MemoryStore, extraction: Pick, ): MemoryToolSpec[] { - return [ + const specs: MemoryToolSpec[] = [ { name: "memory_save", description: @@ -214,4 +217,5 @@ export function buildMemoryToolSpecs( }, }, ] + return store.readOnly ? specs.filter((spec) => !WRITING_TOOLS.includes(spec.name)) : specs } diff --git a/test/readOnly.test.ts b/test/readOnly.test.ts new file mode 100644 index 0000000..3f287c0 --- /dev/null +++ b/test/readOnly.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" +import { join } from "node:path" +import { MEMORY_AGENTS, parseConfig } from "../src/config.js" +import { PERMISSIONS_PROBE_AGENT } from "../src/host/v2/agents.js" +import { buildMemorySystemPrompt } from "../src/prompt/systemPrompt.js" +import { MemoryStore } from "../src/store/MemoryStore.js" +import { resolveMemoryRoot } from "../src/store/paths.js" +import { + cleanupTempDirs, + makePlugin, + makeSelectorClient, + makeStore, + methods, + seedMemory, + tempDir, + tempGitRepo, + userMessage, +} from "./helpers/index.js" +import { makeV2Context, runHook, setupV2 } from "./helpers/v2ctx.js" + +afterEach(cleanupTempDirs) + +const READ_ONLY = { readOnly: true } +const READ_TOOLS = ["memory_list", "memory_search", "memory_read"] + +// Every path under the Claude config directory with its contents ("" for a directory). +function snapshot(dir: string): Array<[string, string]> { + return (readdirSync(dir, { recursive: true }) as string[]) + .sort() + .map((file) => [file, statSync(join(dir, file)).isFile() ? readFileSync(join(dir, file), "utf-8") : ""]) +} + +// A Claude config directory that already holds one project memory, as Claude Code leaves it. +function seededClaudeDir(worktree: string): string { + const claudeConfigDir = tempDir("ocm-claude-") + const store = new MemoryStore(resolveMemoryRoot(worktree, worktree), { claudeConfigDir }) + seedMemory(store, { + fileName: "deploy", + name: "deploy", + description: "where staging runs", + type: "project", + content: "Staging Postgres is the `pg` host.", + }) + return claudeConfigDir +} + +describe("readOnly: config and store", () => { + test("defaults to false, and switches extraction and auto-dream off whatever they say", () => { + expect(parseConfig({}).readOnly).toBe(false) + const config = parseConfig({ readOnly: true, extract: { enabled: true }, autodream: { enabled: true } }) + expect(config.readOnly).toBe(true) + expect(config.extract.enabled).toBe(false) + expect(config.autodream.enabled).toBe(false) + }) + + test("a read-only store creates no folder, reads what is there, and refuses to save or delete", () => { + const claudeConfigDir = tempDir("ocm-claude-") + const empty = new MemoryStore(tempGitRepo(), { claudeConfigDir, readOnly: true }) + expect(existsSync(empty.memoryDir)).toBe(false) + expect(empty.scan()).toEqual([]) + expect(empty.readIndex()).toBe("") + + const writable = makeStore(tempGitRepo(), claudeConfigDir) + seedMemory(writable, { + fileName: "role", + name: "role", + description: "the user's role", + type: "user", + content: "SRE", + }) + const store = new MemoryStore(writable.memoryRoot, { claudeConfigDir, readOnly: true }) + expect(store.read("role")?.body).toContain("SRE") + expect(() => store.save({ fileName: "x", name: "x", description: "x", type: "user", content: "x" })).toThrow( + "read-only", + ) + expect(() => store.delete("role")).toThrow("read-only") + expect(store.read("role")?.body).toContain("SRE") + }) + + test("the prompt says memory is read-only and drops every instruction to write it", () => { + const store = makeStore() + const prompt = buildMemorySystemPrompt(Object.assign(store, { readOnly: true })) + expect(prompt).toContain("read-only") + for (const write of ["write to it directly", "## How to save memories", "save it immediately", "Update the memory"]) + expect(prompt).not.toContain(write) + expect(prompt).toContain("MEMORY.md is currently empty.") + }) +}) + +describe("readOnly: V1 plugin", () => { + test("offers only the reading tools and registers only the recall agent", async () => { + const hooks = await makePlugin({ options: READ_ONLY, client: makeSelectorClient().client }) + expect(Object.keys(hooks.tool ?? {}).sort()).toEqual([...READ_TOOLS].sort()) + const cfg: { agent?: Record } = {} + await hooks.config?.(cfg as never) + expect(Object.keys(cfg.agent ?? {})).toEqual([MEMORY_AGENTS.recall]) + }) + + test("a whole turn reads Claude Code's memory and writes nothing under the config directory", async () => { + const worktree = tempGitRepo() + const claudeConfigDir = seededClaudeDir(worktree) + const before = snapshot(claudeConfigDir) + const selector = makeSelectorClient([["deploy.md"]]) + const hooks = await makePlugin({ worktree, claudeConfigDir, options: READ_ONLY, client: selector.client }) + + await hooks.config?.({} as never) + await hooks["experimental.chat.messages.transform"]?.( + {} as never, + { messages: [userMessage("where does staging run?", "ses_main")] } as never, + ) + const output = { system: [] as string[] } + await hooks["experimental.chat.system.transform"]?.({ sessionID: "ses_main" } as never, output as never) + expect(output.system[0]).toContain("Staging Postgres is the `pg` host.") + expect(output.system[0]).not.toContain("write to it directly") + + for (const type of ["session.status", "session.idle"]) + await hooks.event?.({ + event: { type, properties: { sessionID: "ses_main", status: { type: "idle" } } }, + } as never) + await hooks.dispose?.() + expect(snapshot(claudeConfigDir)).toEqual(before) + // Extraction lists sessions the moment it starts; read-only it never does. + expect(methods(selector.calls)).not.toContain("list") + }) + + test("a project Claude Code never saw gets no memory folder", async () => { + const hooks = await makePlugin({ options: READ_ONLY }) + expect(readdirSync(hooks.claudeConfigDir)).toEqual([]) + }) +}) + +describe("readOnly: V2 setup", () => { + test("registers only the reading tools and the recall agent, and writes no log file", async () => { + const mock = makeV2Context({ options: READ_ONLY }) + const claudeConfigDir = tempDir("ocm-v2-claude-") + const { cleanup } = await setupV2(mock, claudeConfigDir) + expect(mock.tools.map((tool) => tool.name)).toEqual(READ_TOOLS) + expect([...mock.agents.keys()].sort()).toEqual([MEMORY_AGENTS.recall, PERMISSIONS_PROBE_AGENT].sort()) + + await runHook(mock, "context", { sessionID: "ses_main", system: [] }) + await cleanup() + expect(snapshot(claudeConfigDir)).toEqual([]) + }) +}) From 8b685b3630d1f099c99dbee21db8f5144f29b6e6 Mon Sep 17 00:00:00 2001 From: Meryl Hu Date: Wed, 7 Oct 2026 09:15:38 -0700 Subject: [PATCH 2/4] fix(recall): fall back to text when the model has no structured output On 1.x the selector fork asks for json_schema output. A model or provider without structured output gets a StructuredOutputError from the server even though its text answer is right, so recall silently selected nothing. The first StructuredOutputError switches that host to the text path V2 already uses (buildGeneratePrompt, now shared in src/host/generate.ts) and parseSelectedMemories, which now also accepts a bare JSON array. Other model errors still fail the call. Co-Authored-By: Claude Opus 5.5 --- src/host/generate.ts | 13 ++++++ src/host/v1/host.ts | 58 +++++++++++++++++------- src/host/v2/host.ts | 11 +---- src/recall/selector.ts | 2 + test/recall/structuredFallback.test.ts | 62 ++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 27 deletions(-) create mode 100644 src/host/generate.ts create mode 100644 test/recall/structuredFallback.test.ts diff --git a/src/host/generate.ts b/src/host/generate.ts new file mode 100644 index 0000000..d491385 --- /dev/null +++ b/src/host/generate.ts @@ -0,0 +1,13 @@ +// A `generate` request as plain text, for a host or model without structured output: the schema is +// described in the prompt and the caller parses the answer (recall/selector.ts). +import type { GenerateInput } from "./types.js" + +export function buildGeneratePrompt(input: Pick): string { + const sections = [input.system?.trim(), input.text] + if (input.schema) { + sections.push( + `Respond with only a JSON object matching this JSON schema, without code fences or commentary:\n${JSON.stringify(input.schema)}`, + ) + } + return sections.filter((section): section is string => Boolean(section)).join("\n\n") +} diff --git a/src/host/v1/host.ts b/src/host/v1/host.ts index 8b87600..41ea097 100644 --- a/src/host/v1/host.ts +++ b/src/host/v1/host.ts @@ -1,8 +1,9 @@ // MemoryHost over the V1 SDK client (`server` plugin entry). Every call keeps the exact SDK traffic // the coordinators used to make directly: deadlines, `directory` query and the tool sandbox. import { withDeadline } from "../../util/timeout.js" +import { buildGeneratePrompt } from "../generate.js" import { type MemoryHost, SDK_READ_TIMEOUT_MS, type SessionSummary, type TranscriptMessage } from "../types.js" -import { runForkSession } from "./fork.js" +import { ForkSessionError, runForkSession } from "./fork.js" import { type ChatMessage, type OpencodeClient, type SessionInfo, unwrapData } from "./sdk.js" export type V1HostOptions = { @@ -31,7 +32,16 @@ export function forkAnswerText(response: unknown): string { .join("\n") } +// A model or provider without structured output fails a json_schema request (the server reports a +// StructuredOutputError) even though its text answer is fine, so recall silently selected nothing. +function isStructuredOutputError(error: unknown): boolean { + return error instanceof ForkSessionError && error.message.includes("StructuredOutputError") +} + 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) => @@ -76,22 +86,36 @@ export function createV1Host({ client, directory, toolsFor }: V1HostOptions): Me }, async generate(input) { - const response = await runForkSession({ - client, - directory, - parentSessionID: input.parentSessionID, - title: input.title, - agent: input.agent, - system: input.system, - tools: toolsFor?.(input.agent) ?? { "*": false }, - ...(input.schema ? { format: { type: "json_schema", schema: input.schema } } : {}), - parts: [{ type: "text", text: input.text }], - timeoutMs: input.timeoutMs, - onCreated: input.onCreated, - onFinished: input.onFinished, - onCleanupFailed: input.onCleanupFailed, - }) - return forkAnswerText(response) + const run = (structured: boolean) => + runForkSession({ + client, + directory, + parentSessionID: input.parentSessionID, + title: input.title, + agent: input.agent, + system: input.system, + tools: toolsFor?.(input.agent) ?? { "*": false }, + ...(structured && input.schema ? { format: { type: "json_schema", schema: input.schema } } : {}), + parts: [ + { + type: "text", + text: structured ? input.text : buildGeneratePrompt({ text: input.text, schema: input.schema }), + }, + ], + timeoutMs: input.timeoutMs, + onCreated: input.onCreated, + onFinished: input.onFinished, + onCleanupFailed: input.onCleanupFailed, + }) + if (structuredOutput && input.schema) { + try { + return forkAnswerText(await run(true)) + } catch (error) { + if (!isStructuredOutputError(error)) throw error + structuredOutput = false + } + } + return forkAnswerText(await run(false)) }, } } diff --git a/src/host/v2/host.ts b/src/host/v2/host.ts index 52bf520..f5c92a3 100644 --- a/src/host/v2/host.ts +++ b/src/host/v2/host.ts @@ -9,6 +9,7 @@ // builds the request with `tools: []`), so the recall agent's ruleset never comes into play. import type { Logger } from "../../util/log.js" import { withDeadline } from "../../util/timeout.js" +import { buildGeneratePrompt } from "../generate.js" import { type GenerateInput, type MemoryHost, SDK_READ_TIMEOUT_MS } from "../types.js" import { forkSessionRules, PERMISSIONS_PROBE_AGENT } from "./agents.js" import { type ModelRef, type PermissionRule, runV2Fork, type V2Message, type V2SessionApi } from "./fork.js" @@ -31,16 +32,6 @@ function bounded(what: string, timeoutMs: number, call: () => Promise): Pr return withDeadline(what, timeoutMs, () => call()) } -export function buildGeneratePrompt(input: Pick): string { - const sections = [input.system?.trim(), input.text] - if (input.schema) { - sections.push( - `Respond with only a JSON object matching this JSON schema, without code fences or commentary:\n${JSON.stringify(input.schema)}`, - ) - } - return sections.filter((section): section is string => Boolean(section)).join("\n\n") -} - export class V2Host implements MemoryHost { // Per-fork system prompt, injected by the context hook (V2 prompts carry no system field). private readonly forkSystems = new Map() diff --git a/src/recall/selector.ts b/src/recall/selector.ts index 296233a..9e144a1 100644 --- a/src/recall/selector.ts +++ b/src/recall/selector.ts @@ -37,6 +37,8 @@ export type SelectRelevantMemoriesInput = { } function selectedFrom(value: unknown): string[] | undefined { + // A text answer often drops the wrapper object and gives the list alone. + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string") if (!value || typeof value !== "object") return undefined const selected = (value as { selected_memories?: unknown }).selected_memories if (!Array.isArray(selected)) return undefined diff --git a/test/recall/structuredFallback.test.ts b/test/recall/structuredFallback.test.ts new file mode 100644 index 0000000..7a5ed59 --- /dev/null +++ b/test/recall/structuredFallback.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { MEMORY_AGENTS } from "../../src/config.js" +import { createV1Host } from "../../src/host/v1/host.js" +import type { OpencodeClient } from "../../src/host/v1/sdk.js" +import { parseSelectedMemories } from "../../src/recall/selector.js" +import { cleanupTempDirs, makeSelectorClient, tempGitRepo } from "../helpers/index.js" + +afterEach(cleanupTempDirs) + +describe("recall without structured output", () => { + // A model that cannot do structured output: the server fails the json_schema request with a + // StructuredOutputError although the text answer is fine. + function textOnlyClient(answer: string) { + const selector = makeSelectorClient() + const prompts: Array> = [] + selector.raw.session.prompt = async (options?: unknown) => { + const body = (options as { body: Record }).body + prompts.push(body) + if (body.format !== undefined) + return { + data: { + info: { + error: { name: "StructuredOutputError", data: { message: "Model did not produce structured output" } }, + }, + parts: [{ type: "text", text: answer }], + }, + } + return { data: { info: {}, parts: [{ type: "text", text: answer }] } } + } + return { client: selector.client as OpencodeClient, prompts } + } + + const input = { + agent: MEMORY_AGENTS.recall, + title: "selector", + text: "Query: where does staging run?", + schema: { type: "object", properties: { selected_memories: { type: "array" } } }, + timeoutMs: 5_000, + } + + test("retries as text once, then stays in text mode for that host", 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]) + 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("any other model error still fails the call", async () => { + const selector = makeSelectorClient() + selector.raw.session.prompt = async () => ({ data: { info: { error: { name: "APIError" } }, parts: [] } }) + const host = createV1Host({ client: selector.client, directory: tempGitRepo() }) + await expect(host.generate(input)).rejects.toThrow("APIError") + }) + + test("a bare JSON array is read as the selection", () => { + expect(parseSelectedMemories('["a.md", "b.md"]')).toEqual(["a.md", "b.md"]) + }) +}) From 1437ec1c0f3777be0edbf3feaf4bd5093b91839e Mon Sep 17 00:00:00 2001 From: Meryl Hu Date: Wed, 7 Oct 2026 09:15:38 -0700 Subject: [PATCH 3/4] docs: readOnly and the recall text fallback Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +++ README.md | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index cd9cf21..58925da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ src/ ├── tools.ts # MemoryToolSpec[] for memory_save / delete / list / search / read (zod/v4 args), wrapped per host; results carry their own titles ├── host/ │ ├── types.ts # MemoryHost interface (readTranscript / listSessions / runFork / generate), TranscriptMessage, SDK_READ_TIMEOUT_MS +│ ├── generate.ts # buildGeneratePrompt: a generate request as text, schema in the prompt (V2 always, V1 after a StructuredOutputError) │ ├── v1/ # OpenCode 1.18.29+ (`server` entry, @opencode-ai/plugin) │ │ ├── plugin.ts # createMemoryPlugin: parseConfig → MemoryStore → V1 host → coordinators → Hooks │ │ ├── sdk.ts # Type aliases derived from @opencode-ai/plugin (client, events, messages) + unwrapData @@ -157,6 +158,8 @@ bun run build # dist/ via tsconfig.build.json - Plugin state: `/opencode-memory//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 `.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.`); V2's `agent.transform` does the same for `agents.`. - 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. - Design history for v2 lives in `docs/v2/`. ## V2 notes diff --git a/README.md b/README.md index 9da1f90..46d43fd 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,8 @@ All behaviour is configured through OpenCode's own configuration. There are no ` ["opencode-claude-memory", { "extract": { "enabled": true, "timeoutMs": 120000, "debounceMs": 10000, "maxConversationChars": 60000, "catchUpLimit": 5 }, "autodream": { "enabled": true, "minHours": 24, "minSessions": 5, "timeoutMs": 300000 }, - "recall": { "enabled": true, "waitMs": 1500, "timeoutMs": 30000, "maxMemories": 5 } + "recall": { "enabled": true, "waitMs": 1500, "timeoutMs": 30000, "maxMemories": 5 }, + "readOnly": false }] ], "agent": { @@ -101,6 +102,7 @@ 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.`; 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. ```jsonc // opencode.json — OpenCode 2.x @@ -205,6 +207,10 @@ 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). + +**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. + **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). **Does the extraction fork see my whole conversation?** Only the messages after the last extraction, capped at `extract.maxConversationChars` (newest first); on OpenCode 2.x only those after the last compaction. The fork can only call memory tools. From 5d274641e0cc53ff87a30d5eb1f9d842ff51b199 Mon Sep 17 00:00:00 2001 From: kuitos Date: Fri, 9 Oct 2026 22:26:11 +0800 Subject: [PATCH 4/4] fix(extraction): preserve existing state in read-only mode --- src/extraction/ExtractionCoordinator.ts | 2 + test/readOnly.test.ts | 52 ++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/extraction/ExtractionCoordinator.ts b/src/extraction/ExtractionCoordinator.ts index 1874ea2..a19f8b6 100644 --- a/src/extraction/ExtractionCoordinator.ts +++ b/src/extraction/ExtractionCoordinator.ts @@ -167,6 +167,8 @@ export class ExtractionCoordinator { this.clearTimer(sessionID) this.busy.delete(sessionID) this.savedByMainAgent.delete(sessionID) + // Existing state may come from an earlier writable run; read-only mode must leave it untouched. + if (this.deps.config.readOnly) return // A deleted session never needs catching up: hosts that cannot list sessions (V2) would otherwise // keep spending catch-up slots and a failing transcript read on it until the 30-day prune. if (!this.state.getSession(sessionID)) return diff --git a/test/readOnly.test.ts b/test/readOnly.test.ts index 3f287c0..92af929 100644 --- a/test/readOnly.test.ts +++ b/test/readOnly.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" import { join } from "node:path" import { MEMORY_AGENTS, parseConfig } from "../src/config.js" +import { ExtractionStateStore } from "../src/extraction/state.js" import { PERMISSIONS_PROBE_AGENT } from "../src/host/v2/agents.js" import { buildMemorySystemPrompt } from "../src/prompt/systemPrompt.js" import { MemoryStore } from "../src/store/MemoryStore.js" @@ -17,7 +18,7 @@ import { tempGitRepo, userMessage, } from "./helpers/index.js" -import { makeV2Context, runHook, setupV2 } from "./helpers/v2ctx.js" +import { makeV2Context, runHook, setupV2, waitFor } from "./helpers/v2ctx.js" afterEach(cleanupTempDirs) @@ -45,6 +46,15 @@ function seededClaudeDir(worktree: string): string { return claudeConfigDir } +// A session extracted before the host switched to read-only mode. +function seedExtractionState(worktree: string, claudeConfigDir: string): void { + const store = new MemoryStore(resolveMemoryRoot(worktree, worktree), { claudeConfigDir, readOnly: true }) + const state = new ExtractionStateStore(store.stateDir) + state.update((data) => { + data.sessions.ses_existing = { lastExtractedMessageID: "msg_1", updatedAt: Date.now(), failures: 0 } + }) +} + describe("readOnly: config and store", () => { test("defaults to false, and switches extraction and auto-dream off whatever they say", () => { expect(parseConfig({}).readOnly).toBe(false) @@ -89,6 +99,22 @@ describe("readOnly: config and store", () => { }) describe("readOnly: V1 plugin", () => { + test("deleting a previously extracted session leaves the config directory unchanged", async () => { + const worktree = tempGitRepo() + const claudeConfigDir = seededClaudeDir(worktree) + seedExtractionState(worktree, claudeConfigDir) + const before = snapshot(claudeConfigDir) + const hooks = await makePlugin({ worktree, claudeConfigDir, options: READ_ONLY }) + try { + await hooks.event?.({ + event: { type: "session.deleted", properties: { info: { id: "ses_existing" } } }, + } as never) + expect(snapshot(claudeConfigDir)).toEqual(before) + } finally { + await hooks.dispose?.() + } + }) + test("offers only the reading tools and registers only the recall agent", async () => { const hooks = await makePlugin({ options: READ_ONLY, client: makeSelectorClient().client }) expect(Object.keys(hooks.tool ?? {}).sort()).toEqual([...READ_TOOLS].sort()) @@ -131,6 +157,30 @@ describe("readOnly: V1 plugin", () => { }) describe("readOnly: V2 setup", () => { + test("deleting a previously extracted session leaves the config directory unchanged", async () => { + const mock = makeV2Context({ options: READ_ONLY }) + const claudeConfigDir = seededClaudeDir(mock.directory) + seedExtractionState(mock.directory, claudeConfigDir) + const before = snapshot(claudeConfigDir) + const subscribe = mock.ctx.event.subscribe.bind(mock.ctx.event) + let processed = false + mock.ctx.event.subscribe = async function* (options) { + for await (const event of subscribe(options)) { + yield event + // The consumer handles the event before requesting the next one. + processed = true + } + } + const { cleanup } = await setupV2(mock, claudeConfigDir) + try { + mock.emit({ type: "session.deleted", data: { sessionID: "ses_existing" } }) + await waitFor(() => processed) + expect(snapshot(claudeConfigDir)).toEqual(before) + } finally { + await cleanup() + } + }) + test("registers only the reading tools and the recall agent, and writes no log file", async () => { const mock = makeV2Context({ options: READ_ONLY }) const claudeConfigDir = tempDir("ocm-v2-claude-")