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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -157,6 +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.
- Design history for v2 lives in `docs/v2/`.

## V2 notes
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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.<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.

```jsonc
// opencode.json — OpenCode 2.x
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 9 additions & 6 deletions src/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ export type MemoryAgentDefaults = {
allowedTools: readonly MemoryToolName[]
}

export function memoryAgentDefaults(agents: MemoryAgents): Record<string, MemoryAgentDefaults> {
export function memoryAgentDefaults(agents: MemoryAgents, readOnly = false): Record<string, MemoryAgentDefaults> {
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
Expand Down
12 changes: 11 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/extraction/ExtractionCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/host/generate.ts
Original file line number Diff line number Diff line change
@@ -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<GenerateInput, "system" | "text" | "schema">): 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")
}
8 changes: 4 additions & 4 deletions src/host/v1/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AgentConfig> {
export function buildAgentDefaults(agents: MemoryAgents, readOnly = false): Record<string, AgentConfig> {
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,
Expand All @@ -33,8 +33,8 @@ export class AgentRegistry {
private readonly defaults: Record<string, AgentConfig>
private readonly merged: Record<string, AgentConfig>

constructor(agents: MemoryAgents) {
this.defaults = buildAgentDefaults(agents)
constructor(agents: MemoryAgents, readOnly = false) {
this.defaults = buildAgentDefaults(agents, readOnly)
this.merged = { ...this.defaults }
}

Expand Down
58 changes: 41 additions & 17 deletions src/host/v1/host.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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))
},
}
}
2 changes: 1 addition & 1 deletion src/host/v1/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/host/v2/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
Expand Down
11 changes: 1 addition & 10 deletions src/host/v2/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -31,16 +32,6 @@ function bounded<T>(what: string, timeoutMs: number, call: () => Promise<T>): Pr
return withDeadline(what, timeoutMs, () => call())
}

export function buildGeneratePrompt(input: Pick<GenerateInput, "system" | "text" | "schema">): 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<string, string>()
Expand Down
11 changes: 7 additions & 4 deletions src/host/v2/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -92,7 +93,9 @@ export const createV2Setup =
}

const registerHooks = async (): Promise<void> => {
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) => {
Expand Down
9 changes: 9 additions & 0 deletions src/prompt/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading