opencode-claude-memory 2.0.2 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +67 -10
  2. package/dist/agents.d.ts +8 -10
  3. package/dist/agents.js +4 -43
  4. package/dist/extraction/ExtractionCoordinator.d.ts +10 -12
  5. package/dist/extraction/ExtractionCoordinator.js +66 -50
  6. package/dist/extraction/autodream.d.ts +2 -5
  7. package/dist/extraction/autodream.js +3 -7
  8. package/dist/host/types.d.ts +52 -0
  9. package/dist/host/types.js +6 -0
  10. package/dist/host/v1/agents.d.ts +11 -0
  11. package/dist/host/v1/agents.js +48 -0
  12. package/dist/host/v1/coordinators.d.ts +12 -0
  13. package/dist/host/v1/coordinators.js +43 -0
  14. package/dist/{extraction/forkSession.d.ts → host/v1/fork.d.ts} +3 -2
  15. package/dist/{extraction/forkSession.js → host/v1/fork.js} +2 -2
  16. package/dist/host/v1/host.d.ts +9 -0
  17. package/dist/host/v1/host.js +79 -0
  18. package/dist/{hooks → host/v1}/ignore.d.ts +2 -3
  19. package/dist/host/v1/ignore.js +29 -0
  20. package/dist/host/v1/log.d.ts +4 -0
  21. package/dist/host/v1/log.js +20 -0
  22. package/dist/{hooks → host/v1}/messages.d.ts +3 -2
  23. package/dist/{hooks → host/v1}/messages.js +3 -17
  24. package/dist/host/v1/plugin.d.ts +2 -0
  25. package/dist/host/v1/plugin.js +58 -0
  26. package/dist/host/v1/selector.d.ts +18 -0
  27. package/dist/host/v1/selector.js +12 -0
  28. package/dist/host/v1/tools.d.ts +5 -0
  29. package/dist/host/v1/tools.js +17 -0
  30. package/dist/host/v2/agents.d.ts +15 -0
  31. package/dist/host/v2/agents.js +83 -0
  32. package/dist/host/v2/events.d.ts +40 -0
  33. package/dist/host/v2/events.js +137 -0
  34. package/dist/host/v2/fork.d.ts +76 -0
  35. package/dist/host/v2/fork.js +136 -0
  36. package/dist/host/v2/host.d.ts +35 -0
  37. package/dist/host/v2/host.js +86 -0
  38. package/dist/host/v2/log.d.ts +4 -0
  39. package/dist/host/v2/log.js +40 -0
  40. package/dist/host/v2/plugin.d.ts +26 -0
  41. package/dist/host/v2/plugin.js +149 -0
  42. package/dist/host/v2/transcript.d.ts +3 -0
  43. package/dist/host/v2/transcript.js +50 -0
  44. package/dist/index.d.ts +7 -2
  45. package/dist/index.js +5 -55
  46. package/dist/recall/RecallCoordinator.d.ts +15 -10
  47. package/dist/recall/RecallCoordinator.js +26 -35
  48. package/dist/recall/selector.d.ts +14 -19
  49. package/dist/recall/selector.js +37 -47
  50. package/dist/recall/turn.d.ts +4 -0
  51. package/dist/recall/turn.js +49 -0
  52. package/dist/tools.d.ts +16 -3
  53. package/dist/tools.js +59 -41
  54. package/dist/util/log.d.ts +0 -2
  55. package/dist/util/log.js +2 -18
  56. package/package.json +16 -4
  57. package/dist/hooks/ignore.js +0 -53
  58. /package/dist/{sdk.d.ts → host/v1/sdk.d.ts} +0 -0
  59. /package/dist/{sdk.js → host/v1/sdk.js} +0 -0
package/README.md CHANGED
@@ -12,7 +12,7 @@ Claude Code writes memory → OpenCode reads it. OpenCode writes memory → Clau
12
12
  [![npm downloads](https://img.shields.io/npm/dm/opencode-claude-memory.svg?style=flat-square)](https://www.npmjs.com/package/opencode-claude-memory)
13
13
  [![License](https://img.shields.io/npm/l/opencode-claude-memory.svg?style=flat-square)](https://github.com/kuitos/opencode-claude-memory/blob/main/LICENSE)
14
14
 
15
- [Quick Start](#-quick-start) • [How it works](#-how-it-works) • [Configuration](#-configuration) • [Compatibility](#-compatibility-with-claude-code) • [Migrating from v1](#-migrating-from-v1) • [FAQ](#-faq)
15
+ [Quick Start](#-quick-start) • [How it works](#-how-it-works) • [Configuration](#-configuration) • [OpenCode 2.x](#-differences-on-opencode-2x) • [Compatibility](#-compatibility-with-claude-code) • [Migrating from v1](#-migrating-from-v1) • [FAQ](#-faq)
16
16
 
17
17
  </div>
18
18
 
@@ -26,18 +26,28 @@ Claude Code writes memory → OpenCode reads it. OpenCode writes memory → Clau
26
26
  - **Auto-dream** — periodic consolidation (merge / prune / rewrite) gated on time and session count, like Claude Code.
27
27
  - **Claude Code-compatible** — same directory, same file format, same taxonomy, same worktree handling. `MEMORY.md` is edited line by line so hand-organised indexes stay intact.
28
28
  - **Cross-platform, no shell hook** — everything runs inside the OpenCode process through the plugin SDK. No `python3`, no `jq`, no wrapper.
29
+ - **OpenCode 1.x and 2.x** — one package serves both plugin APIs (V1 `server`, V2 `setup`) with the same options.
29
30
 
30
31
  ## 🚀 Quick Start
31
32
 
32
- Requires OpenCode **≥ 1.18**.
33
+ Requires OpenCode **≥ 1.18.29** (V1 plugin API) or OpenCode **2.x** (`@opencode/cli`, tested on 2.0.22). The package exports one `{ id, server, setup }` object: 1.x loads `server`, 2.x loads `setup`; 1.x accepts that dual object only from 1.18.29 on.
33
34
 
34
35
  ```jsonc
35
- // opencode.json (project) or ~/.config/opencode/opencode.json (global)
36
+ // OpenCode 1.x — opencode.json (project) or ~/.config/opencode/opencode.json (global)
36
37
  {
37
38
  "plugin": ["opencode-claude-memory"]
38
39
  }
39
40
  ```
40
41
 
42
+ ```jsonc
43
+ // OpenCode 2.x — opencode.json
44
+ {
45
+ "plugins": [{ "package": "opencode-claude-memory" }]
46
+ }
47
+ ```
48
+
49
+ OpenCode 2.x also accepts (and normalises) the 1.x `plugin` key.
50
+
41
51
  That's it. Start `opencode` and use it as usual. Memories live in `~/.claude/projects/<project>/memory/` (or under `$CLAUDE_CONFIG_DIR`), exactly where Claude Code keeps them.
42
52
 
43
53
  ## ⚙️ How it works
@@ -55,20 +65,22 @@ graph LR
55
65
  C --> M
56
66
  ```
57
67
 
68
+ The hook names below are OpenCode 1.x's; on 2.x the same steps run on the V2 plugin API, with the differences listed in [Differences on OpenCode 2.x](#-differences-on-opencode-2x).
69
+
58
70
  1. **Recall** — `experimental.chat.messages.transform` starts a selector prefetch for each new user turn (a hidden child session running `opencode-memory-recall`). `experimental.chat.system.transform` waits for it up to `recall.waitMs` (default 1.5 s) and injects the selected memories. Memories already in the conversation are not re-injected; after compaction they can surface again.
59
71
  2. **Extraction** — every `session.idle` is debounced (`extract.debounceMs`). The plugin fetches the session's messages, slices them after the per-session watermark, and — only if there is a new user message — runs `opencode-memory-extract` in a child session restricted to `memory_save` / `memory_list` / `memory_read`. On success the watermark advances; if the main agent already saved memory in that stretch the fork is skipped.
60
72
  3. **Catch-up** — on start-up the plugin lists the project's sessions and extracts the ones updated after their watermark (at most `extract.catchUpLimit`). This covers "answer, then quit immediately".
61
73
  4. **Auto-dream** — after each extracted session the gate is evaluated (`autodream.minHours` since the last pass **and** `autodream.minSessions` extracted since). When it passes, `opencode-memory-dream` runs with all five memory tools. A lock file prevents two OpenCode processes from consolidating at once.
62
74
  5. **Ignore memory** — "ignore memory" in a user message switches memory off for the rest of the session (no index, no recall); "use memory again" switches it back on.
63
75
 
64
- State that is private to the plugin (watermarks, auto-dream gate, lock) lives in `<CLAUDE_CONFIG_DIR>/opencode-memory/<project>/`, never inside the Claude Code project directory.
76
+ State that is private to the plugin (watermarks, auto-dream gate, lock and, on OpenCode 2.x, the log file) lives in `<CLAUDE_CONFIG_DIR>/opencode-memory/<project>/`, never inside the Claude Code project directory.
65
77
 
66
78
  ## 🔧 Configuration
67
79
 
68
- All behaviour is configured through OpenCode's own configuration. There are no `OPENCODE_MEMORY_*` environment variables.
80
+ All behaviour is configured through OpenCode's own configuration. There are no `OPENCODE_MEMORY_*` environment variables. The plugin options are identical on OpenCode 1.x and 2.x; only the surrounding keys differ.
69
81
 
70
82
  ```jsonc
71
- // opencode.json
83
+ // opencode.json — OpenCode 1.x
72
84
  {
73
85
  "plugin": [
74
86
  ["opencode-claude-memory", {
@@ -87,10 +99,43 @@ All behaviour is configured through OpenCode's own configuration. There are no `
87
99
 
88
100
  - Every option above is optional; the values shown are the defaults. Unknown keys are rejected when the plugin loads.
89
101
  - 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.
90
- - The three agents are registered hidden with a memory-only tool sandbox. Override any field (`model`, `steps`, `temperature`, …) under `agent.<name>`; the plugin fills in the rest.
102
+ - 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.
91
103
  - `CLAUDE_CONFIG_DIR` is honoured exactly like Claude Code does, and is the only environment variable the plugin reads.
92
104
 
93
- Logs go to the OpenCode service log (`opencode` log directory, service `opencode-claude-memory`).
105
+ ```jsonc
106
+ // opencode.json — OpenCode 2.x
107
+ {
108
+ "plugins": [
109
+ { "package": "opencode-claude-memory", "options": { "extract": { "debounceMs": 10000 } } }
110
+ ],
111
+ "agents": {
112
+ "opencode-memory-extract": {
113
+ "model": "opencode/gpt-6-luna",
114
+ "steps": 20,
115
+ "permissions": [{ "action": "webfetch", "resource": "*", "effect": "allow" }]
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ - `options` takes the same keys as the 1.x example above.
122
+ - Agent overrides live under `agents.<name>` and use 2.x agent fields (`model`, `steps`, `permissions`, …). The plugin's sandbox rules (`*` deny, then allow for the agent's memory tools) come first and your own `permissions` after them; 2.x applies the last matching rule, so your rule wins.
123
+ - The main agent needs no extra permission on 2.x's default `build` agent, which allows everything. If you restrict permissions (for example `*` → `ask` or `deny`), add `{ "action": "memory_*", "resource": "*", "effect": "allow" }` to your `permissions` so the memory tools are neither blocked nor prompted for.
124
+
125
+ **Models for the memory agents.** OpenCode Zen's free models (e.g. `opencode/big-pickle`) reject every request whose tool list lacks OpenCode's `shell` tool (HTTP 403, *"OpenCode's free tier can only be used from within OpenCode"*). The sandboxed extraction and auto-dream forks are exactly such requests on both 1.x and 2.x, and so is recall on 2.x. On a free Zen model, extraction, auto-dream and (on 2.x) recall therefore fail. Configure a paid model for `opencode-memory-extract`, `opencode-memory-dream` and `opencode-memory-recall` (e.g. `opencode/gpt-6-luna`); the main agent can stay on a free model.
126
+
127
+ **Logs.** On 1.x logs go to the OpenCode service log (`opencode` log directory, service `opencode-claude-memory`). 2.x gives plugins no log API, so the plugin writes `<CLAUDE_CONFIG_DIR>/opencode-memory/<project>/opencode-memory.log` instead (rotated at about 1 MB).
128
+
129
+ ## 🆕 Differences on OpenCode 2.x
130
+
131
+ The V2 plugin API exposes less than V1, so a few behaviours differ:
132
+
133
+ - **Extraction only sees messages after the last compaction.** V2 plugins cannot read the full message history. If the extraction watermark was compacted away, extraction restarts from the start of the remaining context; anything before it that was not extracted yet is lost (a warning is logged).
134
+ - **Catch-up only covers known sessions.** V2 plugins cannot list sessions, so start-up catch-up only covers sessions already recorded in `extraction-state.json`. A session created and finished while the plugin was not running is not caught up.
135
+ - **One sandbox layer instead of two.** Forks are restricted by the agents' permission rules (also passed to the fork as session permissions); V2 has no per-prompt tool map.
136
+ - **Recall is a one-shot `generate.text` call** instead of a child session with structured output. The selector's answer is parsed as JSON text, and an answer that does not parse recalls nothing. The recall agent's `model` is used if set; temperature is not configurable.
137
+ - **Logs go to a file** in the plugin state directory, not to the OpenCode log (see [Logs](#-configuration)).
138
+ - **Free Zen models break recall too**, not only extraction and auto-dream (see [Models for the memory agents](#-configuration)).
94
139
 
95
140
  ## 🤝 Compatibility with Claude Code
96
141
 
@@ -102,7 +147,7 @@ Logs go to the OpenCode service log (`opencode` log directory, service `opencode
102
147
  | `MEMORY.md` | one-line pointers, hand-organisable | read with the same truncation rules; written with minimal line-level edits |
103
148
  | Sub-directories | `team/x.md` etc. | scanned, recalled and addressable from every tool |
104
149
  | System prompt | memory instructions + index + recalled memories | ported sections (`memoryTypes.ts`, `memdir.ts`) |
105
- | Recall | LLM side query | LLM side query in a hidden child session (`findRelevantMemories.ts` port) |
150
+ | Recall | LLM side query | LLM side query (`findRelevantMemories.ts` port): a hidden child session on 1.x, a `generate.text` call on 2.x |
106
151
  | Extraction / auto-dream | after session, gated | after `session.idle` + start-up catch-up, gated the same way |
107
152
 
108
153
  Memory files written by either tool need no conversion in either direction.
@@ -157,7 +202,7 @@ Everything the environment variables used to control now lives under `extract`,
157
202
 
158
203
  **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).
159
204
 
160
- **Does the extraction fork see my whole conversation?** Only the messages after the last extraction, capped at `extract.maxConversationChars` (newest first). The fork can only call memory tools.
205
+ **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.
161
206
 
162
207
  ## 🧪 Development
163
208
 
@@ -170,6 +215,18 @@ bun run typecheck
170
215
  bun run build # emits dist/
171
216
  ```
172
217
 
218
+ To try an unpublished build, run `bun run build` and point OpenCode at the checkout:
219
+
220
+ ```jsonc
221
+ // OpenCode 1.x — reads package.json "main" (dist/index.js)
222
+ { "plugin": [["file:///abs/path/to/opencode-claude-memory", { "extract": { "debounceMs": 10000 } }]] }
223
+ ```
224
+
225
+ ```jsonc
226
+ // OpenCode 2.x — resolves a directory as <dir>/server or <dir>/index, not via package.json
227
+ { "plugins": [{ "package": "file:///abs/path/to/opencode-claude-memory/dist" }] }
228
+ ```
229
+
173
230
  Releases are cut by semantic-release on push to `main`.
174
231
 
175
232
  ## 📄 License
package/dist/agents.d.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  import type { MemoryAgents } from "./config.js";
2
- import type { AgentConfig, PluginConfig } from "./sdk.js";
3
2
  export declare const MEMORY_TOOL_NAMES: readonly ["memory_save", "memory_delete", "memory_list", "memory_search", "memory_read"];
4
- export declare function buildAgentDefaults(agents: MemoryAgents): Record<string, AgentConfig>;
5
- export declare function mergeAgentConfig(defaults: AgentConfig, user: AgentConfig | undefined): AgentConfig;
6
- export declare class AgentRegistry {
7
- private readonly defaults;
8
- private readonly merged;
9
- constructor(agents: MemoryAgents);
10
- register(config: PluginConfig): void;
11
- toolsFor(name: string): Record<string, boolean> | undefined;
12
- }
3
+ export type MemoryToolName = (typeof MEMORY_TOOL_NAMES)[number];
4
+ export type MemoryAgentDefaults = {
5
+ prompt: string;
6
+ temperature?: number;
7
+ steps?: number;
8
+ allowedTools: readonly MemoryToolName[];
9
+ };
10
+ export declare function memoryAgentDefaults(agents: MemoryAgents): Record<string, MemoryAgentDefaults>;
package/dist/agents.js CHANGED
@@ -7,63 +7,24 @@ export const MEMORY_TOOL_NAMES = [
7
7
  "memory_search",
8
8
  "memory_read",
9
9
  ];
10
- const ALL_MEMORY_TOOLS = Object.fromEntries(MEMORY_TOOL_NAMES.map((name) => [name, true]));
11
- export function buildAgentDefaults(agents) {
10
+ export function memoryAgentDefaults(agents) {
12
11
  return {
13
12
  [agents.recall]: {
14
- mode: "all",
15
- hidden: true,
16
13
  temperature: 0,
17
14
  prompt: SELECT_MEMORIES_SYSTEM_PROMPT,
18
- tools: { "*": false },
15
+ allowedTools: [],
19
16
  },
20
17
  [agents.extract]: {
21
- mode: "all",
22
- hidden: true,
23
18
  prompt: EXTRACT_PROMPT,
24
19
  // A legitimate extraction is a handful of memory_save calls; the step cap terminates a model
25
20
  // that keeps re-saving the same files instead of letting it spin until the timeout (#35).
26
21
  steps: 30,
27
- tools: { "*": false, memory_save: true, memory_list: true, memory_read: true },
22
+ allowedTools: ["memory_save", "memory_list", "memory_read"],
28
23
  },
29
24
  [agents.dream]: {
30
- mode: "all",
31
- hidden: true,
32
25
  prompt: AUTODREAM_PROMPT,
33
26
  steps: 60,
34
- tools: { "*": false, ...ALL_MEMORY_TOOLS },
27
+ allowedTools: MEMORY_TOOL_NAMES,
35
28
  },
36
29
  };
37
30
  }
38
- export function mergeAgentConfig(defaults, user) {
39
- if (!user)
40
- return { ...defaults };
41
- const merged = { ...defaults, ...user };
42
- // `maxSteps` is the deprecated spelling of `steps`; a user who only set the old name must not be
43
- // overridden by the default `steps`.
44
- if (user.steps === undefined && user.maxSteps !== undefined)
45
- delete merged.steps;
46
- return merged;
47
- }
48
- export class AgentRegistry {
49
- defaults;
50
- merged;
51
- constructor(agents) {
52
- this.defaults = buildAgentDefaults(agents);
53
- this.merged = { ...this.defaults };
54
- }
55
- // `config` hook: merge defaults under the user's own entries and remember the outcome so forks can
56
- // pass the effective tool sandbox explicitly (defence in depth, see forkSession.ts).
57
- register(config) {
58
- config.agent ??= {};
59
- const agent = config.agent;
60
- for (const [name, defaults] of Object.entries(this.defaults)) {
61
- const merged = mergeAgentConfig(defaults, agent[name]);
62
- agent[name] = merged;
63
- this.merged[name] = merged;
64
- }
65
- }
66
- toolsFor(name) {
67
- return this.merged[name]?.tools;
68
- }
69
- }
@@ -1,35 +1,32 @@
1
- import type { AgentRegistry } from "../agents.js";
2
1
  import type { MemoryConfig } from "../config.js";
3
- import { type ChatMessage, type OpencodeClient, type PluginEvent } from "../sdk.js";
2
+ import { type MemoryHost, SDK_READ_TIMEOUT_MS, type TranscriptMessage } from "../host/types.js";
4
3
  import type { MemoryStore } from "../store/MemoryStore.js";
5
4
  import { type Logger } from "../util/log.js";
6
5
  import type { OwnedSessions } from "../util/ownedSessions.js";
7
6
  import { AutoDream } from "./autodream.js";
8
7
  import { MaintenanceLock } from "./lock.js";
9
8
  import { ExtractionStateStore, type SessionExtractionState } from "./state.js";
9
+ export { SDK_READ_TIMEOUT_MS };
10
10
  export declare const EXTRACTION_TITLE = "opencode-memory extraction";
11
11
  export declare const FORK_GRACE_MS = 60000;
12
12
  export declare const MAX_EXTRACTION_FAILURES = 3;
13
13
  export declare const MIN_CONVERSATION_CHARS = 20;
14
- export declare const SDK_READ_TIMEOUT_MS = 30000;
15
- export declare function sliceNewMessages(messages: readonly ChatMessage[], state: SessionExtractionState | undefined): ChatMessage[];
16
- export declare function trimIncompleteTail(messages: readonly ChatMessage[]): ChatMessage[];
17
- export declare function hasExtractableUserMessage(messages: readonly ChatMessage[]): boolean;
18
- export declare function buildConversationForExtraction(messages: readonly ChatMessage[], maxChars: number): string;
14
+ export declare function sliceNewMessages<M extends TranscriptMessage>(messages: readonly M[], state: SessionExtractionState | undefined): M[];
15
+ export declare function trimIncompleteTail<M extends TranscriptMessage>(messages: readonly M[]): M[];
16
+ export declare function hasExtractableUserMessage(messages: readonly TranscriptMessage[]): boolean;
17
+ export declare function buildConversationForExtraction(messages: readonly TranscriptMessage[], maxChars: number): string;
19
18
  export type ExtractionCoordinatorDeps = {
20
19
  store: MemoryStore;
21
20
  config: MemoryConfig;
22
- client: OpencodeClient | undefined;
23
- directory: string;
21
+ host: MemoryHost | undefined;
24
22
  owned: OwnedSessions;
25
- agents: AgentRegistry;
26
23
  log: Logger;
27
24
  now?: () => number;
28
25
  state?: ExtractionStateStore;
29
26
  lock?: MaintenanceLock;
30
27
  };
31
28
  export declare class ExtractionCoordinator {
32
- private readonly deps;
29
+ protected readonly deps: ExtractionCoordinatorDeps;
33
30
  readonly state: ExtractionStateStore;
34
31
  readonly autodream: AutoDream | undefined;
35
32
  private readonly lock;
@@ -44,7 +41,6 @@ export declare class ExtractionCoordinator {
44
41
  private disposed;
45
42
  constructor(deps: ExtractionCoordinatorDeps);
46
43
  get enabled(): boolean;
47
- onEvent(event: PluginEvent): void;
48
44
  onSessionIdle(sessionID: string): void;
49
45
  onSessionStatus(sessionID: string, status: string): void;
50
46
  onSessionDeleted(sessionID: string): void;
@@ -53,6 +49,8 @@ export declare class ExtractionCoordinator {
53
49
  idle(): Promise<void>;
54
50
  catchUp(): Promise<void>;
55
51
  dispose(): void;
52
+ private updatedSessions;
53
+ private knownSessions;
56
54
  private clearTimer;
57
55
  private warnLegacyShellHook;
58
56
  private enqueue;
@@ -1,21 +1,21 @@
1
- import { roleOf } from "../hooks/messages.js";
2
- import { unwrapData } from "../sdk.js";
1
+ import { SDK_READ_TIMEOUT_MS } from "../host/types.js";
3
2
  import { findLegacyShellHooks } from "../util/legacyShellHook.js";
4
3
  import { getErrorMessage } from "../util/log.js";
5
- import { TimeoutError, withDeadline } from "../util/timeout.js";
4
+ import { TimeoutError } from "../util/timeout.js";
6
5
  import { AutoDream } from "./autodream.js";
7
- import { runForkSession } from "./forkSession.js";
8
6
  import { MaintenanceLock } from "./lock.js";
9
7
  import { buildExtractionSystemPrompt } from "./prompts.js";
10
8
  import { ExtractionStateStore, migrateLegacyAutodreamState } from "./state.js";
9
+ export { SDK_READ_TIMEOUT_MS };
11
10
  export const EXTRACTION_TITLE = "opencode-memory extraction";
12
- // Keep the fork in the owned-session guard past delete: its `session.idle` can arrive after the
13
- // delete HTTP call resolved and would otherwise trigger an extraction of the fork itself.
11
+ // Keep the fork in the owned-session guard past delete: its idle event can arrive after the delete
12
+ // call resolved and would otherwise trigger an extraction of the fork itself.
14
13
  export const FORK_GRACE_MS = 60_000;
15
14
  export const MAX_EXTRACTION_FAILURES = 3;
16
15
  export const MIN_CONVERSATION_CHARS = 20;
17
- // Deadline for the plain SDK reads (`session.messages`, `session.list`); see util/timeout.ts.
18
- export const SDK_READ_TIMEOUT_MS = 30_000;
16
+ function roleOf(message) {
17
+ return String(message.info?.role ?? "");
18
+ }
19
19
  function messageTime(message) {
20
20
  const time = message.info?.time;
21
21
  return {
@@ -53,10 +53,7 @@ export function hasExtractableUserMessage(messages) {
53
53
  return messages.some((message) => {
54
54
  if (roleOf(message) !== "user" || !Array.isArray(message.parts))
55
55
  return false;
56
- return message.parts.some((part) => {
57
- const p = part;
58
- return p.type === "text" && typeof p.text === "string" && p.text.trim().length > 0 && !p.synthetic;
59
- });
56
+ return message.parts.some((p) => p.type === "text" && typeof p.text === "string" && p.text.trim().length > 0 && !p.synthetic);
60
57
  });
61
58
  }
62
59
  export function buildConversationForExtraction(messages, maxChars) {
@@ -65,8 +62,7 @@ export function buildConversationForExtraction(messages, maxChars) {
65
62
  const role = roleOf(message);
66
63
  if (!role || !Array.isArray(message.parts))
67
64
  continue;
68
- for (const part of message.parts) {
69
- const p = part;
65
+ for (const p of message.parts) {
70
66
  if (p.type === "text" && typeof p.text === "string" && !p.synthetic) {
71
67
  lines.push(`### ${role === "user" ? "User" : "Assistant"}\n${p.text}`);
72
68
  }
@@ -102,20 +98,12 @@ export class ExtractionCoordinator {
102
98
  this.now = deps.now ?? Date.now;
103
99
  this.state = deps.state ?? new ExtractionStateStore(deps.store.stateDir, this.now);
104
100
  this.lock = deps.lock ?? new MaintenanceLock(this.state.lockPath, this.now);
105
- this.autodream = deps.client
106
- ? new AutoDream({ ...deps, client: deps.client, state: this.state, now: this.now, lock: this.lock })
101
+ this.autodream = deps.host
102
+ ? new AutoDream({ ...deps, host: deps.host, state: this.state, now: this.now, lock: this.lock })
107
103
  : undefined;
108
104
  }
109
105
  get enabled() {
110
- return this.deps.config.extract.enabled && this.deps.client !== undefined;
111
- }
112
- onEvent(event) {
113
- if (event.type === "session.idle")
114
- this.onSessionIdle(event.properties.sessionID);
115
- else if (event.type === "session.deleted")
116
- this.onSessionDeleted(event.properties.info.id);
117
- else if (event.type === "session.status")
118
- this.onSessionStatus(event.properties.sessionID, event.properties.status.type);
106
+ return this.deps.config.extract.enabled && this.deps.host !== undefined;
119
107
  }
120
108
  onSessionIdle(sessionID) {
121
109
  if (!this.enabled || this.disposed || !sessionID)
@@ -147,6 +135,18 @@ export class ExtractionCoordinator {
147
135
  this.clearTimer(sessionID);
148
136
  this.busy.delete(sessionID);
149
137
  this.savedByMainAgent.delete(sessionID);
138
+ // A deleted session never needs catching up: hosts that cannot list sessions (V2) would otherwise
139
+ // keep spending catch-up slots and a failing transcript read on it until the 30-day prune.
140
+ if (!this.state.getSession(sessionID))
141
+ return;
142
+ try {
143
+ this.state.update((data) => {
144
+ delete data.sessions[sessionID];
145
+ });
146
+ }
147
+ catch {
148
+ // best-effort: the entry is pruned with the session TTL anyway
149
+ }
150
150
  }
151
151
  // memory_save reports every write. Inside an extraction fork the list of files saved so far is
152
152
  // returned so the tool result can carry the done-signal (#35); a save by the main agent marks the
@@ -177,8 +177,8 @@ export class ExtractionCoordinator {
177
177
  if (this.caughtUp || !this.enabled)
178
178
  return;
179
179
  this.caughtUp = true;
180
- const { client, config, directory, store, log } = this.deps;
181
- if (!client)
180
+ const { host, config, store, log } = this.deps;
181
+ if (!host)
182
182
  return;
183
183
  this.warnLegacyShellHook();
184
184
  migrateLegacyAutodreamState(this.state, `${store.claudeConfigDir}/opencode-memory`, [
@@ -190,33 +190,46 @@ export class ExtractionCoordinator {
190
190
  return;
191
191
  let sessions;
192
192
  try {
193
- sessions =
194
- unwrapData(await withDeadline("session.list", SDK_READ_TIMEOUT_MS, (signal) => client.session.list({ query: { directory }, signal }))) ?? [];
193
+ sessions = await host.listSessions();
195
194
  }
196
195
  catch (error) {
197
196
  log("warn", "Extraction catch-up could not list sessions", { error: getErrorMessage(error) });
198
197
  return;
199
198
  }
200
- const pending = sessions
199
+ const pending = sessions === undefined ? this.knownSessions() : this.updatedSessions(sessions);
200
+ for (const sessionID of pending.slice(0, config.extract.catchUpLimit))
201
+ await this.enqueue(sessionID);
202
+ }
203
+ dispose() {
204
+ this.disposed = true;
205
+ for (const timer of this.timers.values())
206
+ clearTimeout(timer);
207
+ this.timers.clear();
208
+ this.busy.clear();
209
+ }
210
+ // Listed sessions whose last update is newer than their watermark, newest first.
211
+ updatedSessions(sessions) {
212
+ return sessions
201
213
  .filter((session) => !session.parentID && !this.deps.owned.has(session.id))
202
214
  .filter((session) => {
203
215
  const known = this.state.getSession(session.id);
204
216
  // Compare against the watermark *message* time, not the fork's finish time: a turn that
205
217
  // completed while the previous fork was running must still be caught up.
206
218
  const boundary = known?.lastMessageAt ?? known?.updatedAt ?? 0;
207
- return (session.time?.updated ?? 0) > boundary;
219
+ return (session.updatedAt ?? 0) > boundary;
208
220
  })
209
- .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
210
- .slice(0, config.extract.catchUpLimit);
211
- for (const session of pending)
212
- await this.enqueue(session.id);
221
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
222
+ .map((session) => session.id);
213
223
  }
214
- dispose() {
215
- this.disposed = true;
216
- for (const timer of this.timers.values())
217
- clearTimeout(timer);
218
- this.timers.clear();
219
- this.busy.clear();
224
+ // Hosts that cannot list sessions: re-check the sessions extraction-state.json knows about, most
225
+ // recently extracted first. A session without new messages costs one transcript read.
226
+ knownSessions() {
227
+ const known = this.state.read().sessions;
228
+ return Object.entries(known)
229
+ .filter(([id]) => !this.deps.owned.has(id))
230
+ .sort(([, a], [, b]) => Math.max(b.lastMessageAt ?? 0, b.attemptedAt ?? 0, b.updatedAt) -
231
+ Math.max(a.lastMessageAt ?? 0, a.attemptedAt ?? 0, a.updatedAt))
232
+ .map(([id]) => id);
220
233
  }
221
234
  clearTimer(sessionID) {
222
235
  const existing = this.timers.get(sessionID);
@@ -274,8 +287,8 @@ export class ExtractionCoordinator {
274
287
  return advanced;
275
288
  }
276
289
  async runIncremental(sessionID) {
277
- const { client, config, store, directory, owned, agents, log } = this.deps;
278
- if (!client || this.disposed || this.inFlight.has(sessionID))
290
+ const { host, config, store, owned, log } = this.deps;
291
+ if (!host || this.disposed || this.inFlight.has(sessionID))
279
292
  return;
280
293
  // Re-check at dequeue time: the debounce may have fired just before the session went busy, or
281
294
  // the session may have started a new turn while this job waited in the queue.
@@ -283,10 +296,16 @@ export class ExtractionCoordinator {
283
296
  return;
284
297
  this.inFlight.add(sessionID);
285
298
  try {
286
- const response = await withDeadline("session.messages", SDK_READ_TIMEOUT_MS, (signal) => client.session.messages({ path: { id: sessionID }, query: { directory }, signal }));
287
- const messages = unwrapData(response) ?? [];
299
+ const { messages, truncated } = await host.readTranscript(sessionID, this.state.getSession(sessionID)?.lastExtractedMessageID);
288
300
  if (this.busy.has(sessionID))
289
301
  return;
302
+ if (truncated) {
303
+ // The host only returns the messages after the last compaction and the watermark message is
304
+ // gone: extraction continues from the start of what is left, earlier turns are lost.
305
+ log("warn", "Extraction watermark is no longer in the session context; extracting from its start", {
306
+ sessionID,
307
+ });
308
+ }
290
309
  let snapshot = this.snapshot(messages, this.state.getSession(sessionID));
291
310
  if (!snapshot)
292
311
  return;
@@ -320,15 +339,12 @@ export class ExtractionCoordinator {
320
339
  return;
321
340
  }
322
341
  try {
323
- await runForkSession({
324
- client,
325
- directory,
342
+ await host.runFork({
326
343
  parentSessionID: sessionID,
327
344
  title: EXTRACTION_TITLE,
328
345
  agent: config.agents.extract,
329
346
  system: buildExtractionSystemPrompt(store.manifest()),
330
- tools: agents.toolsFor(config.agents.extract),
331
- parts: [{ type: "text", text: conversation }],
347
+ text: conversation,
332
348
  timeoutMs: config.extract.timeoutMs,
333
349
  onCreated: (forkID) => {
334
350
  owned.add(forkID);
@@ -1,6 +1,5 @@
1
- import type { AgentRegistry } from "../agents.js";
2
1
  import type { MemoryConfig } from "../config.js";
3
- import type { OpencodeClient } from "../sdk.js";
2
+ import type { MemoryHost } from "../host/types.js";
4
3
  import type { MemoryStore } from "../store/MemoryStore.js";
5
4
  import { type Logger } from "../util/log.js";
6
5
  import type { OwnedSessions } from "../util/ownedSessions.js";
@@ -13,11 +12,9 @@ export declare function shouldRunAutodream(state: AutodreamState, gate: Autodrea
13
12
  export type AutoDreamDeps = {
14
13
  store: MemoryStore;
15
14
  config: MemoryConfig;
16
- client: OpencodeClient;
17
- directory: string;
15
+ host: MemoryHost;
18
16
  state: ExtractionStateStore;
19
17
  owned: OwnedSessions;
20
- agents: AgentRegistry;
21
18
  log: Logger;
22
19
  now?: () => number;
23
20
  lock?: MaintenanceLock;
@@ -1,5 +1,4 @@
1
1
  import { getErrorMessage } from "../util/log.js";
2
- import { runForkSession } from "./forkSession.js";
3
2
  import { MaintenanceLock } from "./lock.js";
4
3
  import { AUTODREAM_PROMPT, AUTODREAM_USER_MESSAGE } from "./prompts.js";
5
4
  export const AUTODREAM_TITLE = "opencode-memory auto-dream";
@@ -39,7 +38,7 @@ export class AutoDream {
39
38
  this.deps.log("info", "Auto-dream skipped: another process holds the maintenance lock");
40
39
  return false;
41
40
  }
42
- const { client, config, directory, owned, agents, state, log } = this.deps;
41
+ const { host, config, owned, state, log } = this.deps;
43
42
  try {
44
43
  // Re-check under the lock: another process may have consolidated while we waited to acquire.
45
44
  if (!this.shouldRun())
@@ -49,15 +48,12 @@ export class AutoDream {
49
48
  sessionsSince: autodream.sessionsSince.length,
50
49
  lastConsolidatedAt: autodream.lastConsolidatedAt,
51
50
  });
52
- await runForkSession({
53
- client,
54
- directory,
51
+ await host.runFork({
55
52
  parentSessionID,
56
53
  title: AUTODREAM_TITLE,
57
54
  agent: config.agents.dream,
58
55
  system: AUTODREAM_PROMPT,
59
- tools: agents.toolsFor(config.agents.dream),
60
- parts: [{ type: "text", text: AUTODREAM_USER_MESSAGE }],
56
+ text: AUTODREAM_USER_MESSAGE,
61
57
  timeoutMs: config.autodream.timeoutMs,
62
58
  onCreated: (id) => owned.add(id),
63
59
  onFinished: (id) => owned.release(id, AUTODREAM_FORK_GRACE_MS),
@@ -0,0 +1,52 @@
1
+ export declare const SDK_READ_TIMEOUT_MS = 30000;
2
+ export type TranscriptPart = {
3
+ type?: string;
4
+ text?: string;
5
+ synthetic?: boolean;
6
+ tool?: string;
7
+ state?: {
8
+ status?: string;
9
+ output?: string;
10
+ };
11
+ };
12
+ export type TranscriptMessage = {
13
+ info: {
14
+ id: string;
15
+ role?: string;
16
+ time?: {
17
+ created?: number;
18
+ completed?: number;
19
+ };
20
+ };
21
+ parts?: readonly TranscriptPart[];
22
+ };
23
+ export type Transcript = {
24
+ messages: TranscriptMessage[];
25
+ truncated: boolean;
26
+ };
27
+ export type SessionSummary = {
28
+ id: string;
29
+ parentID?: string;
30
+ updatedAt?: number;
31
+ };
32
+ export type ForkCleanupStage = "abort" | "delete";
33
+ export type ForkInput = {
34
+ agent: string;
35
+ title: string;
36
+ system?: string;
37
+ text: string;
38
+ parentSessionID?: string;
39
+ timeoutMs: number;
40
+ onCreated?: (forkID: string) => void;
41
+ onFinished?: (forkID: string) => void;
42
+ onCleanupFailed?: (forkID: string, stage: ForkCleanupStage, error: unknown) => void;
43
+ };
44
+ export type GenerateInput = ForkInput & {
45
+ schema?: Record<string, unknown>;
46
+ };
47
+ export interface MemoryHost {
48
+ readTranscript(sessionID: string, afterMessageID?: string): Promise<Transcript>;
49
+ listSessions(): Promise<SessionSummary[] | undefined>;
50
+ runFork(input: ForkInput): Promise<void>;
51
+ generate(input: GenerateInput): Promise<string>;
52
+ }
@@ -0,0 +1,6 @@
1
+ // The capabilities the coordinators need from the OpenCode host. Each host generation (V1 `server`,
2
+ // V2 `setup`) implements it in its own adapter under src/host/<version>/; nothing outside src/host
3
+ // knows which SDK it runs on. Differences that cannot be papered over are part of the return values
4
+ // (`truncated`, `listSessions() === undefined`) so the coordinators know which guarantees they have.
5
+ // Deadline for plain SDK reads (transcript, session list); see util/timeout.ts.
6
+ export const SDK_READ_TIMEOUT_MS = 30_000;
@@ -0,0 +1,11 @@
1
+ import type { MemoryAgents } from "../../config.js";
2
+ import type { AgentConfig, PluginConfig } from "./sdk.js";
3
+ export declare function buildAgentDefaults(agents: MemoryAgents): Record<string, AgentConfig>;
4
+ export declare function mergeAgentConfig(defaults: AgentConfig, user: AgentConfig | undefined): AgentConfig;
5
+ export declare class AgentRegistry {
6
+ private readonly defaults;
7
+ private readonly merged;
8
+ constructor(agents: MemoryAgents);
9
+ register(config: PluginConfig): void;
10
+ toolsFor(name: string): Record<string, boolean> | undefined;
11
+ }