@ephemushroom/opencode-claude-mem 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -6,6 +6,10 @@ 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.1** updates the OpenCode 2 TUI adapter for the `@opencode/plugin` 2.0.16
10
+ plugin contract. **v0.6.0** adds per-turn memory tracking, `mem-save`, file history and opt-in semantic
11
+ injection, and fixes the OpenCode 2 sidebar's `No renderer found` crash.
12
+
9
13
  Share the same Claude-Mem worker, database, and memory across your coding
10
14
  agents: memories written by Claude Code are visible to OpenCode, and vice
11
15
  versa. Previous observations and summaries are injected into new OpenCode
@@ -74,11 +78,12 @@ contract. No `/v2` or `/cli` suffix is needed:
74
78
  Both adapters talk to the same Claude-Mem worker, so memory written from V1,
75
79
  V2, and Claude Code is shared. The V2 entrypoint covers the same behavior —
76
80
  context injection, tool observation capture, the `mem-search`/`mem-timeline`/
77
- `mem-get-observations` tools, session init/summarize/cleanup via the event
81
+ `mem-get-observations`/`mem-save` tools, per-user-message registration and session summarize/cleanup via the event
78
82
  stream.
79
83
 
80
84
  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
85
+ `opencode2 0.0.0-beta-19151`; v0.6.0 also passes server and real
86
+ sidebar interaction checks on `0.0.0-beta-19192`. The V2 SDK is pinned because preview APIs can
82
87
  change. Unified routing requires **v0.5.0 or later**; the older `0.4.4` package
83
88
  does not provide it. Upgrade the package in both server and sidebar configurations.
84
89
  Existing `/server`, `/tui`, `/v2`, and `/cli` exports remain supported; replace
@@ -106,7 +111,9 @@ from the server config alone. To register manually, use:
106
111
  | Surface | What it looks like |
107
112
  |---|---|
108
113
  | **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) |
114
+ | **Tools** | `mem-search` → `mem-timeline` → `mem-get-observations`, plus explicit `mem-save` (no MCP server needed) |
115
+ | **Read history** | Bounded supplementary file history after eligible file reads; original file output is preserved |
116
+ | **Optional semantic context** | Relevant project memories for the current real user prompt; **off by default** |
110
117
  | **Sidebar** | `▶ Memory (online, 11.7k obs)` — click to expand recent sessions and latest observations |
111
118
  | **Background** | Every tool call, assistant message, and file edit captured as observations; sessions summarized on idle |
112
119
 
@@ -153,7 +160,7 @@ manually in that case.
153
160
 
154
161
  ### Memory Tools
155
162
 
156
- Three native OpenCode tools cover the same 3-step workflow as the upstream
163
+ Three native OpenCode tools cover the same 3-step search workflow as the upstream
157
164
  Claude-Mem MCP server — no MCP server or stdio subprocess required:
158
165
 
159
166
  ```mermaid
@@ -167,6 +174,118 @@ flowchart LR
167
174
  | `mem-search` | `GET /api/search` | Formatted index with query, project, source, type, date, pagination, and ordering filters |
168
175
  | `mem-timeline` | `GET /api/timeline` | Chronological records around an `anchor` ID (or auto-located via `query`) |
169
176
  | `mem-get-observations` | `POST /api/observations/batch` | Full details for IDs — e.g. the IDs shown in the injected context |
177
+ | `mem-save` | `POST /api/memory/save` | Explicitly save required `text`, optional `title` and `project` (defaults to current project) |
178
+
179
+ `mem-save` strips `<private>` and `<claude-mem-context>` material from text/title,
180
+ rejects empty/fully stripped content and values above 24 KiB, and reports success
181
+ only for a valid Worker acknowledgement. It never retries automatically: after
182
+ a timeout, check with search before retrying because the write may have succeeded.
183
+ Manual saves do not require an observation-model inference pass, but the Worker
184
+ still performs asynchronous vector indexing; this is **not** a promise of zero
185
+ embedding cost. Native and namespaced `mem-save` calls are excluded from automatic
186
+ observation capture.
187
+
188
+ ### Memory configuration (both runtimes)
189
+
190
+ The per-turn registration, `mem-save`, file history, and semantic options described
191
+ here require **v0.6.0 or later**. Upgrade both server and sidebar package entries;
192
+ `0.5.0` does not include these features or the renderer fix.
193
+
194
+ Configuration is read once when the server plugin is set up. Quit and restart
195
+ OpenCode after changing options, environment, or settings; on V2 also restart the
196
+ background service. No semantic feature is enabled globally by this plugin.
197
+
198
+ **OpenCode 1** uses a tuple in `opencode.json`:
199
+
200
+ ```json
201
+ {
202
+ "plugin": [["@ephemushroom/opencode-claude-mem", {
203
+ "semanticInjection": { "enabled": true, "limit": 5, "maxChars": 6000, "timeoutMs": 2000 },
204
+ "fileContext": { "enabled": true, "limit": 15, "maxChars": 6000, "timeoutMs": 1500 }
205
+ }]]
206
+ }
207
+ ```
208
+
209
+ **OpenCode 2** uses a package/options object (not a V1 tuple):
210
+
211
+ ```json
212
+ {
213
+ "plugins": [{
214
+ "package": "@ephemushroom/opencode-claude-mem",
215
+ "options": {
216
+ "semanticInjection": { "enabled": true, "limit": 5, "maxChars": 6000, "timeoutMs": 2000 },
217
+ "fileContext": { "enabled": true, "limit": 15, "maxChars": 6000, "timeoutMs": 1500 }
218
+ }
219
+ }]
220
+ }
221
+ ```
222
+
223
+ | Option | Semantic default | File-history default | Accepted values |
224
+ |---|---|---|---|
225
+ | `enabled` | `false` | `true` | Boolean |
226
+ | `limit` | `5` | `15` | Integer 1–50 |
227
+ | `maxChars` | `6000` | `6000` | Integer 256–24000; supplementary payload only |
228
+ | `timeoutMs` | `2000` | `1500` | Integer 100–10000 |
229
+
230
+ Per-field precedence for semantic `enabled` and `limit` is **explicit plugin
231
+ options > environment > `~/.claude-mem/settings.json` > defaults**. The environment
232
+ and settings keys are `CLAUDE_MEM_SEMANTIC_INJECT` (`true`/`false` or `1`/`0`) and
233
+ `CLAUDE_MEM_SEMANTIC_INJECT_LIMIT` (integer 1–50). Settings may use booleans/numbers
234
+ or their string equivalents. Explicit `false` overrides environment `true`.
235
+ Invalid values fall through to the next valid source; other controls use explicit
236
+ options then defaults. Settings are read-only and never logged.
237
+
238
+ Semantic retrieval uses `POST /api/context/semantic` with `{q, project, limit}`
239
+ and reads JSON `{context, count}`. `q` is the sanitized real current prompt,
240
+ at least 20 characters; media-only, fully private, and identifiable internal
241
+ messages are excluded. No `platformSource` filter is sent, so Claude Code and
242
+ OpenCode memories remain shared. Each real prompt identity gets at most one
243
+ retrieval attempt, including failure/timeout, reused across tool-loop dispatches.
244
+ The system block is replaced rather than accumulated. Old Workers, malformed
245
+ responses and offline retrieval fail open.
246
+
247
+ File history uses `GET /api/observations/by-file` with repeated `path` parameters
248
+ (absolute and project-relative, normalized to forward slashes), `projects` and `limit`. Supported read arguments
249
+ include V1 `filePath`, V2 `path`, and `file_path`/`filePaths`/`file_paths` aliases.
250
+ Only existing regular files of at least 1500 bytes qualify, at most 10 paths per
251
+ read. Lookups run concurrently within one configured per-read deadline (including
252
+ file metadata lookup), not ten serial timeout periods. Rows are deduplicated by
253
+ memory session and the displayed text is bounded; a file whose mtime is at least
254
+ the newest observation timestamp gets no history. Delivered history is deduplicated per
255
+ session/file revision (mtime and size); failures and timeouts may retry on a later read.
256
+ Editing the file or starting a new session also permits another lookup. Supplementary history is explicitly
257
+ labeled and wrapped in memory tags, appended without replacing/truncating the
258
+ original read result, and never included in the automatic tool observation.
259
+ Known child sessions (`parentID` when supplied by the host) skip file-history
260
+ enrichment; V1 resumed sessions without that metadata cannot reliably be identified
261
+ as subagents and are not guessed from the agent's name.
262
+
263
+ ### Prompt lifecycle and Worker limitations
264
+
265
+ V1 registers stable `chat.message` IDs; V2 records admission IDs without writing,
266
+ then registers only IDs present in delivered `session.context` records at model
267
+ dispatch. Thus queued V2 prompts do not advance the Worker's current prompt.
268
+ Resumed sessions without local admission history use the latest delivered real
269
+ user turn, not a synthetic `SESSION_START`, and tool callbacks do not reread the
270
+ whole history. Registrations are serialized per session and successful IDs are
271
+ deduplicated locally; a failed registration retains its real prompt for retry.
272
+ Media-only turns use `[media prompt]`. Fully private or identifiable synthetic
273
+ turns suppress automatic observations, summaries and semantic retrieval locally,
274
+ including when the Worker reports a skipped/private registration.
275
+
276
+ **Upstream v13.24.1 still deduplicates identical prompt text within its own time
277
+ window and has no consumed external prompt-ID contract.** This plugin submits
278
+ distinct IDs even if their text is identical, without prefixing/changing the text,
279
+ but cannot guarantee distinct database prompt rows. A `duplicate` acknowledgement
280
+ is accepted; other skipped registrations do not authorize automatic capture.
281
+ V1 exposes submission callbacks rather than V2's delivered/inbox distinction;
282
+ it cannot provide the same queued-consumption guarantee. A restart cannot restore
283
+ the plugin's in-memory ID dedupe ledger, so upstream dedupe remains relevant.
284
+
285
+ Base context is session-isolated, limited to 24000 characters, with a two-second
286
+ request timeout. It is refreshed at V1 compaction start/end and V2 compaction
287
+ requests/completed events or changed compaction records. Failed reads are retryable;
288
+ an invalidated or deleted session cannot be repopulated by an older in-flight read.
170
289
 
171
290
  ## How It Works
172
291
 
@@ -179,7 +298,7 @@ sequenceDiagram
179
298
  OC->>P: plugin loads
180
299
  P->>P: self-heal tui.json / cli.json
181
300
  P->>W: health check (auto-start via bunx if down)
182
- OC->>P: first user prompt (chat.message / context hook)
301
+ OC->>P: each real user prompt (chat.message / delivered context)
183
302
  P->>W: POST /api/sessions/init
184
303
  OC->>P: system prompt transform / context hook
185
304
  W-->>P: GET /api/context/inject (cached per session)
@@ -206,8 +325,8 @@ APIs:
206
325
  | Behavior | OpenCode 1 | OpenCode 2 |
207
326
  |---|---|---|
208
327
  | 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`) |
328
+ | Refresh memory on compaction | `experimental.session.compacting` + `session.compacted` | compaction model requests/events + delivered compaction records |
329
+ | Register each real user prompt | `chat.message` (message ID) | `ctx.session.hook('context')` (`ctx.session.context` delivered IDs; not inbox admission) |
211
330
  | Capture tool observations | `tool.execute.after` | `ctx.tool.hook('execute.after')` |
212
331
  | Custom memory tools | `tool` (`mem-search`, …) | `ctx.tool.transform` (`tools.add`) |
213
332
  | Assistant text capture | `event` (`message.updated`, debounced 250ms) | `session.text.ended` event (complete text) |
@@ -355,6 +474,9 @@ for the per-runtime API):
355
474
  | `GET` | `/api/search?query=...&project=...&dateStart=...&dateEnd=...` | `mem-search`; also supports `limit`, `platformSource`, `type`, `obs_type`, `offset`, and `orderBy` |
356
475
  | `GET` | `/api/timeline?project={name}&anchor={id}` | `mem-timeline` |
357
476
  | `POST` | `/api/observations/batch` | `mem-get-observations` |
477
+ | `POST` | `/api/memory/save` | Explicit `mem-save`, nested `metadata.platformSource` attribution |
478
+ | `POST` | `/api/context/semantic` | Opt-in semantic context; JSON `{context,count}` response |
479
+ | `GET` | `/api/observations/by-file?path=...&path=...&projects=...&limit=...` | Supplementary file history |
358
480
  | `GET` | `/api/stats` + `/api/processing-status` | Sidebar status |
359
481
  | `GET` | `/api/summaries` + `/api/observations` | Sidebar recent items (expanded only) |
360
482
 
@@ -376,9 +498,13 @@ The worker endpoint is resolved in this order: `CLAUDE_MEM_WORKER_HOST` /
376
498
  invokes both adapters. Both installed server loaders prefer `./server`, so
377
499
  routing only the root export to V2 is insufficient. Never combine `server`
378
500
  and `tui` in one object: V1 rejects that shape.
501
+ - **Host renderer ownership** — the built TUI bridge uses explicit `.js`
502
+ relative imports so the host discovers both adapter modules and routes their
503
+ OpenTUI/Solid imports to its own runtime. Extensionless bridge imports can bypass
504
+ that prescan and crash the sidebar with `No renderer found`.
379
505
  - **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
506
+ is bundled into `dist/` (V1 `index.js` ~490 KB, V2 `v2.js` ~160 KB including
507
+ bounded-response validation, using a deep SDK import that skips effect). The TUI entrypoints keep
382
508
  `@opentui/solid` external (provided by the host TUI).
383
509
  - **Reactive sidebar** — collapse state and view data are solid-js signals
384
510
  (shared with OpenCode's own solid instance via `--external solid-js`), so
@@ -392,8 +518,8 @@ The worker endpoint is resolved in this order: `CLAUDE_MEM_WORKER_HOST` /
392
518
  `bunx claude-mem start` once per OpenCode process (skipped if `bun` is not
393
519
  on `PATH`). Health is rechecked on subsequent hooks, so a recovered Worker
394
520
  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.
521
+ - **Context caching** — base memory context is reused per session until a
522
+ compaction boundary invalidates it. Failed fetches are not cached.
397
523
  - **Circular memory protection** — injected context is wrapped in
398
524
  `<claude-mem-context>` tags, Claude-Mem search tools are skipped from
399
525
  observation capture, and memory tags are stripped before storage.
@@ -472,7 +598,8 @@ bun run lint # oxlint
472
598
  bun run fmt:check # oxfmt
473
599
  bun src/test-fixtures/v2-runtime.ts # installed V2: packed bare package, local model + Worker
474
600
  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
601
+ bun src/test-fixtures/tui-runtime.ts # installed V2: real PTY session, render and mouse toggle
602
+ bun src/test-fixtures/tui-runtime.ts offline # offline sidebar still renders and toggles
476
603
  bun --conditions=browser src/test-fixtures/cli-driver.ts render # real rendering + mouse click
477
604
  ```
478
605
 
@@ -483,11 +610,16 @@ The runtime QA drivers never write to your real config or memory database and
483
610
  never use paid models. They pack the current build, serve it through a temporary
484
611
  local registry, and ask the installed host to resolve the bare npm name in an
485
612
  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.
613
+ QA drives base/opt-in semantic injection, exactly-once tool capture, all four memory tools,
614
+ private-save rejection, file-history enrichment without capture pollution, and
615
+ summarization. HTTP regression fixtures also cover repeated/distinct IDs, queued
616
+ V2 admission versus consumption, private turns, retries and compaction invalidation.
617
+ The V2 TUI probe uses ConPTY and xterm terminal cells to open a real session and
618
+ capture collapsed, expanded, and re-collapsed states with actual mouse events.
619
+ It checks online/offline operation, unchanged CLI config, and zero model requests.
620
+ Set `MEM_TUI_EVIDENCE_DIR` to retain text and truecolor ANSI captures. The separate
621
+ OpenTUI driver covers component behavior. V1's interactive host remains outside
622
+ this check; its server runtime is verified. Browser PNG fidelity is not claimed.
491
623
 
492
624
  ## License
493
625
 
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Plugin } from '@opencode/plugin/tui';
1
2
  /**
2
3
  * OpenCode V2 TUI plugin for Claude-Mem (the Memory sidebar).
3
4
  *
@@ -13,5 +14,5 @@
13
14
  * V1 sidebar, but registered through a `sidebar.content` slot claim with
14
15
  * reactive state from `ctx.storage.memory` (survives hot reloads).
15
16
  */
16
- declare const _default: import("@opencode-ai/plugin-v2/tui/plugin").Definition;
17
+ declare const _default: Plugin.Definition;
17
18
  export default _default;
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
  }
@@ -537,11 +586,13 @@ async function readMemView(project, collapsed) {
537
586
  };
538
587
  }
539
588
 
540
- // node_modules/@opencode-ai/plugin-v2/dist/tui/plugin.js
589
+ // node_modules/@opencode/plugin/dist/tui/plugin.js
541
590
  function define(plugin) {
542
591
  return plugin;
543
592
  }
544
-
593
+ // node_modules/@opencode/plugin/dist/tui/solid.js
594
+ import { createComponent, createContext, useContext } from "solid-js";
595
+ var PluginContext = createContext();
545
596
  // src/cli.ts
546
597
  var REFRESH_EVENTS = ["session.created", "session.execution.succeeded"];
547
598
  function emptyView(project) {
@@ -661,13 +712,13 @@ var cli_default = define({
661
712
  function themeFromResolved(resolved) {
662
713
  const { text } = resolved;
663
714
  return {
664
- text: text.default,
665
- textMuted: text.subdued,
666
- info: text.action.primary.default,
667
- success: text.feedback.success.default,
668
- warning: text.feedback.warning.default,
669
- error: text.feedback.error.default,
670
- borderSubtle: resolved.border.default
715
+ text: text.base,
716
+ textMuted: text.muted,
717
+ info: text.action.primary.base,
718
+ success: text.feedback.success.base,
719
+ warning: text.feedback.warning.base,
720
+ error: text.feedback.error.base,
721
+ borderSubtle: resolved.border.base
671
722
  };
672
723
  }
673
724
  function materializeNode(node, solid) {
@@ -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
+ }