@ephemushroom/opencode-claude-mem 0.5.0 → 0.6.0

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.
package/README.md CHANGED
@@ -6,6 +6,9 @@ Persistent memory for [OpenCode](https://opencode.ai), powered by
6
6
  **Supports OpenCode 1 and OpenCode 2 with the same package name from v0.5.0.**
7
7
  No `/v2` or `/cli` suffix is required. Existing version-specific entries remain compatible.
8
8
 
9
+ **v0.6.0** adds per-turn memory tracking, `mem-save`, file history and opt-in semantic
10
+ injection, and fixes the OpenCode 2 sidebar's `No renderer found` crash.
11
+
9
12
  Share the same Claude-Mem worker, database, and memory across your coding
10
13
  agents: memories written by Claude Code are visible to OpenCode, and vice
11
14
  versa. Previous observations and summaries are injected into new OpenCode
@@ -74,11 +77,12 @@ contract. No `/v2` or `/cli` suffix is needed:
74
77
  Both adapters talk to the same Claude-Mem worker, so memory written from V1,
75
78
  V2, and Claude Code is shared. The V2 entrypoint covers the same behavior —
76
79
  context injection, tool observation capture, the `mem-search`/`mem-timeline`/
77
- `mem-get-observations` tools, session init/summarize/cleanup via the event
80
+ `mem-get-observations`/`mem-save` tools, per-user-message registration and session summarize/cleanup via the event
78
81
  stream.
79
82
 
80
83
  The bare package is tested with `opencode 1.18.29` and
81
- `opencode2 0.0.0-beta-19151`; the V2 SDK is pinned because preview APIs can
84
+ `opencode2 0.0.0-beta-19151`; v0.6.0 also passes server and real
85
+ sidebar interaction checks on `0.0.0-beta-19192`. The V2 SDK is pinned because preview APIs can
82
86
  change. Unified routing requires **v0.5.0 or later**; the older `0.4.4` package
83
87
  does not provide it. Upgrade the package in both server and sidebar configurations.
84
88
  Existing `/server`, `/tui`, `/v2`, and `/cli` exports remain supported; replace
@@ -106,7 +110,9 @@ from the server config alone. To register manually, use:
106
110
  | Surface | What it looks like |
107
111
  |---|---|
108
112
  | **System prompt** | `<claude-mem-context>` block with recent observations + session summaries for the current project |
109
- | **Tools** | `mem-search` → `mem-timeline` → `mem-get-observations` (full search workflow, no MCP server needed) |
113
+ | **Tools** | `mem-search` → `mem-timeline` → `mem-get-observations`, plus explicit `mem-save` (no MCP server needed) |
114
+ | **Read history** | Bounded supplementary file history after eligible file reads; original file output is preserved |
115
+ | **Optional semantic context** | Relevant project memories for the current real user prompt; **off by default** |
110
116
  | **Sidebar** | `▶ Memory (online, 11.7k obs)` — click to expand recent sessions and latest observations |
111
117
  | **Background** | Every tool call, assistant message, and file edit captured as observations; sessions summarized on idle |
112
118
 
@@ -153,7 +159,7 @@ manually in that case.
153
159
 
154
160
  ### Memory Tools
155
161
 
156
- Three native OpenCode tools cover the same 3-step workflow as the upstream
162
+ Three native OpenCode tools cover the same 3-step search workflow as the upstream
157
163
  Claude-Mem MCP server — no MCP server or stdio subprocess required:
158
164
 
159
165
  ```mermaid
@@ -167,6 +173,118 @@ flowchart LR
167
173
  | `mem-search` | `GET /api/search` | Formatted index with query, project, source, type, date, pagination, and ordering filters |
168
174
  | `mem-timeline` | `GET /api/timeline` | Chronological records around an `anchor` ID (or auto-located via `query`) |
169
175
  | `mem-get-observations` | `POST /api/observations/batch` | Full details for IDs — e.g. the IDs shown in the injected context |
176
+ | `mem-save` | `POST /api/memory/save` | Explicitly save required `text`, optional `title` and `project` (defaults to current project) |
177
+
178
+ `mem-save` strips `<private>` and `<claude-mem-context>` material from text/title,
179
+ rejects empty/fully stripped content and values above 24 KiB, and reports success
180
+ only for a valid Worker acknowledgement. It never retries automatically: after
181
+ a timeout, check with search before retrying because the write may have succeeded.
182
+ Manual saves do not require an observation-model inference pass, but the Worker
183
+ still performs asynchronous vector indexing; this is **not** a promise of zero
184
+ embedding cost. Native and namespaced `mem-save` calls are excluded from automatic
185
+ observation capture.
186
+
187
+ ### Memory configuration (both runtimes)
188
+
189
+ The per-turn registration, `mem-save`, file history, and semantic options described
190
+ here require **v0.6.0 or later**. Upgrade both server and sidebar package entries;
191
+ `0.5.0` does not include these features or the renderer fix.
192
+
193
+ Configuration is read once when the server plugin is set up. Quit and restart
194
+ OpenCode after changing options, environment, or settings; on V2 also restart the
195
+ background service. No semantic feature is enabled globally by this plugin.
196
+
197
+ **OpenCode 1** uses a tuple in `opencode.json`:
198
+
199
+ ```json
200
+ {
201
+ "plugin": [["@ephemushroom/opencode-claude-mem", {
202
+ "semanticInjection": { "enabled": true, "limit": 5, "maxChars": 6000, "timeoutMs": 2000 },
203
+ "fileContext": { "enabled": true, "limit": 15, "maxChars": 6000, "timeoutMs": 1500 }
204
+ }]]
205
+ }
206
+ ```
207
+
208
+ **OpenCode 2** uses a package/options object (not a V1 tuple):
209
+
210
+ ```json
211
+ {
212
+ "plugins": [{
213
+ "package": "@ephemushroom/opencode-claude-mem",
214
+ "options": {
215
+ "semanticInjection": { "enabled": true, "limit": 5, "maxChars": 6000, "timeoutMs": 2000 },
216
+ "fileContext": { "enabled": true, "limit": 15, "maxChars": 6000, "timeoutMs": 1500 }
217
+ }
218
+ }]
219
+ }
220
+ ```
221
+
222
+ | Option | Semantic default | File-history default | Accepted values |
223
+ |---|---|---|---|
224
+ | `enabled` | `false` | `true` | Boolean |
225
+ | `limit` | `5` | `15` | Integer 1–50 |
226
+ | `maxChars` | `6000` | `6000` | Integer 256–24000; supplementary payload only |
227
+ | `timeoutMs` | `2000` | `1500` | Integer 100–10000 |
228
+
229
+ Per-field precedence for semantic `enabled` and `limit` is **explicit plugin
230
+ options > environment > `~/.claude-mem/settings.json` > defaults**. The environment
231
+ and settings keys are `CLAUDE_MEM_SEMANTIC_INJECT` (`true`/`false` or `1`/`0`) and
232
+ `CLAUDE_MEM_SEMANTIC_INJECT_LIMIT` (integer 1–50). Settings may use booleans/numbers
233
+ or their string equivalents. Explicit `false` overrides environment `true`.
234
+ Invalid values fall through to the next valid source; other controls use explicit
235
+ options then defaults. Settings are read-only and never logged.
236
+
237
+ Semantic retrieval uses `POST /api/context/semantic` with `{q, project, limit}`
238
+ and reads JSON `{context, count}`. `q` is the sanitized real current prompt,
239
+ at least 20 characters; media-only, fully private, and identifiable internal
240
+ messages are excluded. No `platformSource` filter is sent, so Claude Code and
241
+ OpenCode memories remain shared. Each real prompt identity gets at most one
242
+ retrieval attempt, including failure/timeout, reused across tool-loop dispatches.
243
+ The system block is replaced rather than accumulated. Old Workers, malformed
244
+ responses and offline retrieval fail open.
245
+
246
+ File history uses `GET /api/observations/by-file` with repeated `path` parameters
247
+ (absolute and project-relative, normalized to forward slashes), `projects` and `limit`. Supported read arguments
248
+ include V1 `filePath`, V2 `path`, and `file_path`/`filePaths`/`file_paths` aliases.
249
+ Only existing regular files of at least 1500 bytes qualify, at most 10 paths per
250
+ read. Lookups run concurrently within one configured per-read deadline (including
251
+ file metadata lookup), not ten serial timeout periods. Rows are deduplicated by
252
+ memory session and the displayed text is bounded; a file whose mtime is at least
253
+ the newest observation timestamp gets no history. Delivered history is deduplicated per
254
+ session/file revision (mtime and size); failures and timeouts may retry on a later read.
255
+ Editing the file or starting a new session also permits another lookup. Supplementary history is explicitly
256
+ labeled and wrapped in memory tags, appended without replacing/truncating the
257
+ original read result, and never included in the automatic tool observation.
258
+ Known child sessions (`parentID` when supplied by the host) skip file-history
259
+ enrichment; V1 resumed sessions without that metadata cannot reliably be identified
260
+ as subagents and are not guessed from the agent's name.
261
+
262
+ ### Prompt lifecycle and Worker limitations
263
+
264
+ V1 registers stable `chat.message` IDs; V2 records admission IDs without writing,
265
+ then registers only IDs present in delivered `session.context` records at model
266
+ dispatch. Thus queued V2 prompts do not advance the Worker's current prompt.
267
+ Resumed sessions without local admission history use the latest delivered real
268
+ user turn, not a synthetic `SESSION_START`, and tool callbacks do not reread the
269
+ whole history. Registrations are serialized per session and successful IDs are
270
+ deduplicated locally; a failed registration retains its real prompt for retry.
271
+ Media-only turns use `[media prompt]`. Fully private or identifiable synthetic
272
+ turns suppress automatic observations, summaries and semantic retrieval locally,
273
+ including when the Worker reports a skipped/private registration.
274
+
275
+ **Upstream v13.24.1 still deduplicates identical prompt text within its own time
276
+ window and has no consumed external prompt-ID contract.** This plugin submits
277
+ distinct IDs even if their text is identical, without prefixing/changing the text,
278
+ but cannot guarantee distinct database prompt rows. A `duplicate` acknowledgement
279
+ is accepted; other skipped registrations do not authorize automatic capture.
280
+ V1 exposes submission callbacks rather than V2's delivered/inbox distinction;
281
+ it cannot provide the same queued-consumption guarantee. A restart cannot restore
282
+ the plugin's in-memory ID dedupe ledger, so upstream dedupe remains relevant.
283
+
284
+ Base context is session-isolated, limited to 24000 characters, with a two-second
285
+ request timeout. It is refreshed at V1 compaction start/end and V2 compaction
286
+ requests/completed events or changed compaction records. Failed reads are retryable;
287
+ an invalidated or deleted session cannot be repopulated by an older in-flight read.
170
288
 
171
289
  ## How It Works
172
290
 
@@ -179,7 +297,7 @@ sequenceDiagram
179
297
  OC->>P: plugin loads
180
298
  P->>P: self-heal tui.json / cli.json
181
299
  P->>W: health check (auto-start via bunx if down)
182
- OC->>P: first user prompt (chat.message / context hook)
300
+ OC->>P: each real user prompt (chat.message / delivered context)
183
301
  P->>W: POST /api/sessions/init
184
302
  OC->>P: system prompt transform / context hook
185
303
  W-->>P: GET /api/context/inject (cached per session)
@@ -206,8 +324,8 @@ APIs:
206
324
  | Behavior | OpenCode 1 | OpenCode 2 |
207
325
  |---|---|---|
208
326
  | Inject memory context | `experimental.chat.system.transform` | `ctx.session.hook('context')` |
209
- | Preserve memory on compaction | `experimental.session.compacting` | _(context hook re-runs per dispatch)_ |
210
- | Session init with user prompt | `chat.message` | `ctx.session.hook('context')` (from `event.messages`) |
327
+ | Refresh memory on compaction | `experimental.session.compacting` + `session.compacted` | compaction model requests/events + delivered compaction records |
328
+ | Register each real user prompt | `chat.message` (message ID) | `ctx.session.hook('context')` (`ctx.session.context` delivered IDs; not inbox admission) |
211
329
  | Capture tool observations | `tool.execute.after` | `ctx.tool.hook('execute.after')` |
212
330
  | Custom memory tools | `tool` (`mem-search`, …) | `ctx.tool.transform` (`tools.add`) |
213
331
  | Assistant text capture | `event` (`message.updated`, debounced 250ms) | `session.text.ended` event (complete text) |
@@ -355,6 +473,9 @@ for the per-runtime API):
355
473
  | `GET` | `/api/search?query=...&project=...&dateStart=...&dateEnd=...` | `mem-search`; also supports `limit`, `platformSource`, `type`, `obs_type`, `offset`, and `orderBy` |
356
474
  | `GET` | `/api/timeline?project={name}&anchor={id}` | `mem-timeline` |
357
475
  | `POST` | `/api/observations/batch` | `mem-get-observations` |
476
+ | `POST` | `/api/memory/save` | Explicit `mem-save`, nested `metadata.platformSource` attribution |
477
+ | `POST` | `/api/context/semantic` | Opt-in semantic context; JSON `{context,count}` response |
478
+ | `GET` | `/api/observations/by-file?path=...&path=...&projects=...&limit=...` | Supplementary file history |
358
479
  | `GET` | `/api/stats` + `/api/processing-status` | Sidebar status |
359
480
  | `GET` | `/api/summaries` + `/api/observations` | Sidebar recent items (expanded only) |
360
481
 
@@ -376,9 +497,13 @@ The worker endpoint is resolved in this order: `CLAUDE_MEM_WORKER_HOST` /
376
497
  invokes both adapters. Both installed server loaders prefer `./server`, so
377
498
  routing only the root export to V2 is insufficient. Never combine `server`
378
499
  and `tui` in one object: V1 rejects that shape.
500
+ - **Host renderer ownership** — the built TUI bridge uses explicit `.js`
501
+ relative imports so the host discovers both adapter modules and routes their
502
+ OpenTUI/Solid imports to its own runtime. Extensionless bridge imports can bypass
503
+ that prescan and crash the sidebar with `No renderer found`.
379
504
  - **Zero runtime dependencies for server plugins** — the OpenCode plugin SDK
380
- is bundled into `dist/` (V1 `index.js` ~480 KB, V2 `v2.js` ~30 KB via a deep
381
- `@opencode-ai/plugin` import that skips effect). The TUI entrypoints keep
505
+ is bundled into `dist/` (V1 `index.js` ~490 KB, V2 `v2.js` ~160 KB including
506
+ bounded-response validation, using a deep SDK import that skips effect). The TUI entrypoints keep
382
507
  `@opentui/solid` external (provided by the host TUI).
383
508
  - **Reactive sidebar** — collapse state and view data are solid-js signals
384
509
  (shared with OpenCode's own solid instance via `--external solid-js`), so
@@ -392,8 +517,8 @@ The worker endpoint is resolved in this order: `CLAUDE_MEM_WORKER_HOST` /
392
517
  `bunx claude-mem start` once per OpenCode process (skipped if `bun` is not
393
518
  on `PATH`). Health is rechecked on subsequent hooks, so a recovered Worker
394
519
  does not require restarting OpenCode.
395
- - **Context caching** — memory context is fetched once per session and reused
396
- across prompt injection and compaction. Failed fetches are not cached.
520
+ - **Context caching** — base memory context is reused per session until a
521
+ compaction boundary invalidates it. Failed fetches are not cached.
397
522
  - **Circular memory protection** — injected context is wrapped in
398
523
  `<claude-mem-context>` tags, Claude-Mem search tools are skipped from
399
524
  observation capture, and memory tags are stripped before storage.
@@ -472,7 +597,8 @@ bun run lint # oxlint
472
597
  bun run fmt:check # oxfmt
473
598
  bun src/test-fixtures/v2-runtime.ts # installed V2: packed bare package, local model + Worker
474
599
  bun src/test-fixtures/v2-runtime.ts v1 # installed V1: same package and behavior assertions
475
- bun src/test-fixtures/tui-runtime.ts # installed V2 TUI: bare CLI entry loads the sidebar
600
+ bun src/test-fixtures/tui-runtime.ts # installed V2: real PTY session, render and mouse toggle
601
+ bun src/test-fixtures/tui-runtime.ts offline # offline sidebar still renders and toggles
476
602
  bun --conditions=browser src/test-fixtures/cli-driver.ts render # real rendering + mouse click
477
603
  ```
478
604
 
@@ -483,11 +609,16 @@ The runtime QA drivers never write to your real config or memory database and
483
609
  never use paid models. They pack the current build, serve it through a temporary
484
610
  local registry, and ask the installed host to resolve the bare npm name in an
485
611
  isolated home/cache. Registry access is needed for package dependencies. Server
486
- QA drives injection, exactly-once tool capture, all three memory tools, and
487
- summarization. The V2 TUI probe proves sidebar setup through Worker polling and
488
- unchanged bare CLI config, not whole-window visual layout (its terminal is piped).
489
- The separate OpenTUI driver exercises actual bridge rendering and clicks. V1's
490
- interactive host is not verified through pipes; its server runtime is verified.
612
+ QA drives base/opt-in semantic injection, exactly-once tool capture, all four memory tools,
613
+ private-save rejection, file-history enrichment without capture pollution, and
614
+ summarization. HTTP regression fixtures also cover repeated/distinct IDs, queued
615
+ V2 admission versus consumption, private turns, retries and compaction invalidation.
616
+ The V2 TUI probe uses ConPTY and xterm terminal cells to open a real session and
617
+ capture collapsed, expanded, and re-collapsed states with actual mouse events.
618
+ It checks online/offline operation, unchanged CLI config, and zero model requests.
619
+ Set `MEM_TUI_EVIDENCE_DIR` to retain text and truecolor ANSI captures. The separate
620
+ OpenTUI driver covers component behavior. V1's interactive host remains outside
621
+ this check; its server runtime is verified. Browser PNG fidelity is not claimed.
491
622
 
492
623
  ## License
493
624
 
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js CHANGED
@@ -4,6 +4,46 @@ import { existsSync, readFileSync } from "fs";
4
4
  import { homedir } from "os";
5
5
  import { join } from "path";
6
6
  import { spawn } from "child_process";
7
+
8
+ // src/shared.ts
9
+ var MAX_OBSERVATION_BYTES = 24 * 1024;
10
+ var META_TOOLS = new Set([
11
+ "askuserquestion",
12
+ "getmcpresource",
13
+ "listmcpresourcestool",
14
+ "listmcptools",
15
+ "mem-get-observations",
16
+ "mem-search",
17
+ "mem-timeline",
18
+ "skill",
19
+ "slashcommand",
20
+ "todowrite"
21
+ ]);
22
+ function stripTaggedContent(text) {
23
+ let result = "";
24
+ let offset = 0;
25
+ const stack = [];
26
+ for (const match of text.matchAll(/<(\/?)(private|claude-mem-context)\b[^>]*>/gi)) {
27
+ if (stack.length === 0) {
28
+ result += text.slice(offset, match.index);
29
+ }
30
+ const name = match[2].toLowerCase();
31
+ if (match[1] === "/") {
32
+ if (stack.at(-1) === name) {
33
+ stack.pop();
34
+ }
35
+ } else {
36
+ stack.push(name);
37
+ }
38
+ offset = match.index + match[0].length;
39
+ }
40
+ if (stack.length === 0) {
41
+ result += text.slice(offset);
42
+ }
43
+ return result.trim();
44
+ }
45
+
46
+ // src/worker-client.ts
7
47
  var HEALTH_TIMEOUT_MS = 1000;
8
48
  var WRITE_TIMEOUT_MS = 1e4;
9
49
  var AUTOSTART_POLL_INTERVAL_MS = 500;
@@ -199,7 +239,16 @@ class WorkerClient {
199
239
  if (!response.ok) {
200
240
  return null;
201
241
  }
202
- return await response.json();
242
+ const value = await response.json();
243
+ if (!isRecord(value) || typeof value.sessionDbId !== "number" || typeof value.promptNumber !== "number" || !Number.isFinite(value.sessionDbId) || !Number.isFinite(value.promptNumber)) {
244
+ return null;
245
+ }
246
+ return {
247
+ sessionDbId: value.sessionDbId,
248
+ promptNumber: value.promptNumber,
249
+ skipped: value.skipped === true,
250
+ reason: typeof value.reason === "string" ? value.reason : undefined
251
+ };
203
252
  } catch {
204
253
  return null;
205
254
  }
@@ -233,8 +282,8 @@ class WorkerClient {
233
282
  headers: { "Content-Type": "application/json" },
234
283
  body: JSON.stringify({
235
284
  contentSessionId,
236
- last_user_message: lastUserMessage,
237
- last_assistant_message: lastAssistantMessage,
285
+ last_user_message: stripTaggedContent(lastUserMessage),
286
+ last_assistant_message: stripTaggedContent(lastAssistantMessage),
238
287
  platformSource: PLATFORM_SOURCE,
239
288
  observedModel: metadata.observedModel,
240
289
  observedBilling: metadata.observedBilling
@@ -247,7 +296,7 @@ class WorkerClient {
247
296
  }
248
297
  static async getContext(project) {
249
298
  try {
250
- const response = await fetch(`${this.BASE_URL}/api/context/inject?project=${encodeURIComponent(project)}`);
299
+ const response = await fetch(`${this.BASE_URL}/api/context/inject?project=${encodeURIComponent(project)}`, { signal: AbortSignal.timeout(2000) });
251
300
  if (!response.ok) {
252
301
  return null;
253
302
  }
@@ -0,0 +1,17 @@
1
+ import type { InjectionOptions } from './memory-options';
2
+ import { MemoryWorker } from './memory-worker';
3
+ export declare class FileContext {
4
+ private readonly sessions;
5
+ private readonly options;
6
+ private readonly worker;
7
+ constructor(options: InjectionOptions, worker?: typeof MemoryWorker);
8
+ read(session: string, input: {
9
+ readonly tool: string;
10
+ readonly args: unknown;
11
+ }, location: {
12
+ readonly directory: string;
13
+ readonly project: string;
14
+ }): Promise<string>;
15
+ delete(session: string): void;
16
+ clear(): void;
17
+ }