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. 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/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/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/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/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/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/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/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/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/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..92af929 --- /dev/null +++ b/test/readOnly.test.ts @@ -0,0 +1,195 @@ +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" +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, waitFor } 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 +} + +// 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) + 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("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()) + 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("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-") + 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([]) + }) +}) 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"]) + }) +})