@ephemushroom/opencode-claude-mem 0.2.5 → 0.3.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
@@ -30,6 +30,9 @@ into new OpenCode sessions automatically.
30
30
 
31
31
  - **Shared Memory** — Uses the same Claude-Mem worker and memory store as
32
32
  Claude Code.
33
+ - **Auto-Start** — When the plugin loads and the Claude-Mem worker is not
34
+ already running, it spawns `bunx claude-mem start` once per OpenCode process
35
+ (skipped silently if `bun` is not on `PATH` or the worker is already up).
33
36
  - **Automatic Context Injection** — Injects relevant project memory into the
34
37
  system prompt for new OpenCode turns.
35
38
  - **Compaction Support** — Re-injects memory context during OpenCode session
@@ -39,8 +42,8 @@ into new OpenCode sessions automatically.
39
42
  - **Observation Hardening** — Skips low-value meta tools, strips
40
43
  `<claude-mem-context>` and `<private>` tags before storage, and truncates
41
44
  oversized observation payloads by UTF-8 byte size.
42
- - **Graceful Degradation** — If the worker is offline, the plugin fails open
43
- and OpenCode continues to work normally.
45
+ - **Graceful Degradation** — If the worker is offline and cannot be started,
46
+ the plugin fails open and OpenCode continues to work normally.
44
47
 
45
48
  ## How It Works
46
49
 
@@ -55,7 +58,11 @@ OpenCode session
55
58
  |-- tool.execute.after ............. send tool observation
56
59
  |-- system.transform ............... inject memory into system prompt
57
60
  |-- session.compacting ............. preserve memory during compaction
58
- '-- session.idle ................... summarize + complete session
61
+ |-- message.updated ................ capture assistant text (debounced)
62
+ |-- file.edited .................... record file edit observation
63
+ |-- session.compacted .............. summarize after compaction
64
+ |-- session.idle ................... flush + summarize + complete session
65
+ '-- session.deleted ................ flush + complete (no zombie sessions)
59
66
  ```
60
67
 
61
68
  The plugin is intentionally small. It only adapts OpenCode hook events to the
@@ -76,8 +83,11 @@ OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker (port 37777) <-> SQLite +
76
83
  | `SessionStart` | `experimental.session.compacting` | Preserve memory during compaction |
77
84
  | `UserPromptSubmit` | `chat.message` | Initialize session with real user prompt |
78
85
  | `PostToolUse` | `tool.execute.after` | Capture tool observations |
79
- | `Stop` | `event` (`session.idle`) | Summarize the session |
80
- | `SessionEnd` | `event` (`session.idle`) | Mark session complete |
86
+ | _(streaming)_ | `event` (`message.updated`) | Capture assistant text (debounced) |
87
+ | _(streaming)_ | `event` (`file.edited`) | Record file edit observations |
88
+ | _(compaction)_ | `event` (`session.compacted`) | Summarize after OpenCode compacts |
89
+ | `Stop` | `event` (`session.idle`) | Flush + summarize + complete |
90
+ | `SessionEnd` | `event` (`session.deleted`) | Flush + complete (no zombie active rows) |
81
91
 
82
92
  ### Worker API Endpoints Used
83
93
 
@@ -141,8 +151,18 @@ Once installed, the plugin works automatically:
141
151
  OpenCode’s compaction path so memory survives conversation compression.
142
152
  - **Tool observation capture** — Tool executions are stored as observations,
143
153
  except for low-value/meta tools and Claude-Mem search tools.
144
- - **Session summarization** — On `session.idle`, the plugin fetches the latest
145
- user and assistant messages and asks Claude-Mem to summarize them.
154
+ - **Assistant message capture** — Assistant text from `message.updated` is
155
+ buffered with 250ms debounce per session and sent as a single
156
+ `assistant_message` observation per turn (avoids streaming-chunk floods).
157
+ - **File edit capture** — `file.edited` events are forwarded as `file_edit`
158
+ observations (path only; OpenCode does not include diff in the event).
159
+ - **Session summarization** — On `session.compacted` and `session.idle`, the
160
+ plugin flushes any pending assistant buffer, fetches the latest user and
161
+ assistant messages, and asks Claude-Mem to summarize them.
162
+ - **Session lifecycle hygiene** — On `session.deleted` the plugin tells the
163
+ worker to `completeSession`, preventing zombie `'active'` rows from
164
+ accumulating stale `pending_messages` (the "queueDepth never decreases"
165
+ failure mode).
146
166
 
147
167
  ## Memory Search
148
168
 
@@ -207,7 +227,15 @@ curl -s http://127.0.0.1:37777/api/health
207
227
 
208
228
  ### OpenCode shows `Worker offline`
209
229
 
210
- - Restart Claude Code to bring Claude-Mem back up.
230
+ The plugin will try to launch the worker via `bunx claude-mem start` once on
231
+ plugin load. If that toast still appears:
232
+
233
+ - Confirm `bun` is on your `PATH` — `bun --version` should print a version.
234
+ - Confirm `claude-mem` is installed for `bunx`/`npx` — run
235
+ `bunx claude-mem --version` once to populate the cache.
236
+ - On Windows after a forced kill, port `37777` may stay in `TIME_WAIT` for
237
+ 30-120 seconds; wait it out or restart Claude Code.
238
+ - Restart Claude Code to bring Claude-Mem back up via its own supervisor.
211
239
  - Verify the worker port is still `37777`.
212
240
 
213
241
  ### OpenCode crashes on startup
package/dist/index.d.ts CHANGED
@@ -3,9 +3,11 @@ import { type Plugin } from '@opencode-ai/plugin';
3
3
  * OpenCode Plugin for Claude-Mem
4
4
  *
5
5
  * Hooks used:
6
- * - `event` — session lifecycle (session.created, session.idle)
6
+ * - `event` — session lifecycle (session.created, session.idle, session.compacted,
7
+ * session.deleted, message.updated, file.edited)
7
8
  * - `tool.execute.after` — capture tool observations
8
9
  * - `experimental.chat.system.transform` — inject memory context into system prompt
10
+ * - `experimental.session.compacting` — preserve memory context during compaction
9
11
  * - `chat.message` — session init with real user prompt
10
12
  *
11
13
  * Memory context is automatically injected into every conversation via system prompt.
package/dist/index.js CHANGED
@@ -1,12 +1,19 @@
1
1
  // @bun
2
2
  // src/worker-client.ts
3
+ import { existsSync } from "fs";
4
+ import { spawn } from "child_process";
5
+ var HEALTH_TIMEOUT_MS = 1000;
6
+ var AUTOSTART_POLL_INTERVAL_MS = 500;
7
+ var AUTOSTART_POLL_MAX_ATTEMPTS = 16;
8
+
3
9
  class WorkerClient {
4
10
  static PORT = 37777;
5
11
  static BASE_URL = `http://127.0.0.1:${WorkerClient.PORT}`;
12
+ static autoStartPromise = null;
6
13
  static async isHealthy() {
7
14
  try {
8
15
  const controller = new AbortController;
9
- const timeoutId = setTimeout(() => controller.abort(), 1000);
16
+ const timeoutId = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
10
17
  const response = await fetch(`${this.BASE_URL}/api/health`, {
11
18
  signal: controller.signal
12
19
  });
@@ -16,8 +23,71 @@ class WorkerClient {
16
23
  return false;
17
24
  }
18
25
  }
19
- static async ensureRunning(_projectRoot) {
20
- return await this.isHealthy();
26
+ static async ensureRunning() {
27
+ const result = await this.tryAutoStart();
28
+ return result === "already-running" || result === "started";
29
+ }
30
+ static tryAutoStart() {
31
+ if (!this.autoStartPromise) {
32
+ this.autoStartPromise = this.runAutoStart().catch(() => "spawn-failed");
33
+ }
34
+ return this.autoStartPromise;
35
+ }
36
+ static async runAutoStart() {
37
+ if (await this.isHealthy()) {
38
+ return "already-running";
39
+ }
40
+ const bunPath = this.findBun();
41
+ if (!bunPath) {
42
+ return "no-bun";
43
+ }
44
+ try {
45
+ const child = spawn(bunPath, ["x", "claude-mem", "start"], {
46
+ detached: true,
47
+ stdio: "ignore",
48
+ windowsHide: true,
49
+ env: process.env
50
+ });
51
+ child.on("error", () => {});
52
+ child.unref();
53
+ } catch {
54
+ return "spawn-failed";
55
+ }
56
+ for (let i = 0;i < AUTOSTART_POLL_MAX_ATTEMPTS; i++) {
57
+ await new Promise((resolve) => setTimeout(resolve, AUTOSTART_POLL_INTERVAL_MS));
58
+ if (await this.isHealthy()) {
59
+ return "started";
60
+ }
61
+ }
62
+ return "timeout";
63
+ }
64
+ static findBun() {
65
+ const bunGlobal = globalThis.Bun;
66
+ if (bunGlobal && typeof bunGlobal.which === "function") {
67
+ try {
68
+ const found = bunGlobal.which("bun");
69
+ if (typeof found === "string" && found) {
70
+ return found;
71
+ }
72
+ } catch {}
73
+ }
74
+ const path = process.env.PATH || "";
75
+ const sep = process.platform === "win32" ? ";" : ":";
76
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
77
+ const slash = process.platform === "win32" ? "\\" : "/";
78
+ const dirs = path.split(sep).filter(Boolean);
79
+ for (const dir of dirs) {
80
+ const trailing = dir.endsWith("/") || dir.endsWith("\\") ? "" : slash;
81
+ for (const ext of exts) {
82
+ const candidate = `${dir}${trailing}bun${ext}`;
83
+ try {
84
+ if (existsSync(candidate)) {
85
+ return candidate;
86
+ }
87
+ } catch {}
88
+ }
89
+ }
90
+ return null;
21
91
  }
22
92
  static async sessionInit(contentSessionId, project, prompt) {
23
93
  try {
@@ -100,6 +170,7 @@ class WorkerClient {
100
170
  // src/index.ts
101
171
  var MAX_OBSERVATION_BYTES = 24 * 1024;
102
172
  var MAX_TAG_REPLACEMENTS = 100;
173
+ var ASSISTANT_FLUSH_DEBOUNCE_MS = 250;
103
174
  var META_TOOLS = new Set([
104
175
  "askuserquestion",
105
176
  "getmcpresource",
@@ -185,6 +256,13 @@ function normalizeToolOutput(output) {
185
256
  return String(output);
186
257
  }
187
258
  }
259
+ function extractTextFromParts(parts) {
260
+ if (!parts || !Array.isArray(parts)) {
261
+ return "";
262
+ }
263
+ return parts.filter((p) => p && p.type === "text" && typeof p.text === "string" && !p.synthetic && !p.ignored).map((p) => p.text).join(`
264
+ `).trim();
265
+ }
188
266
  var ClaudeMemPlugin = async (ctx) => {
189
267
  const { project, directory, client } = ctx;
190
268
  const projectRoot = directory || process.cwd();
@@ -209,20 +287,59 @@ var ClaudeMemPlugin = async (ctx) => {
209
287
  let initToastShown = false;
210
288
  async function checkWorkerAndToast() {
211
289
  if (workerHealthy === null) {
212
- workerHealthy = await WorkerClient.isHealthy();
290
+ workerHealthy = await WorkerClient.ensureRunning();
213
291
  }
214
292
  if (!initToastShown) {
215
293
  initToastShown = true;
216
294
  if (workerHealthy) {
217
295
  await toast(`Memory active \xB7 ${projectName}`, "success");
218
296
  } else {
219
- await toast("Worker offline \u2014 start Claude Code first", "warning", 5000);
297
+ await toast("Worker offline \u2014 install Claude-Mem (bunx claude-mem start) or start Claude Code first", "warning", 5000);
220
298
  }
221
299
  }
222
300
  return workerHealthy;
223
301
  }
224
302
  let currentSessionId = null;
225
303
  const initializedSessions = new Set;
304
+ const assistantBuffers = new Map;
305
+ async function flushAssistantBuffer(sessionId) {
306
+ const buf = assistantBuffers.get(sessionId);
307
+ if (!buf || !buf.text) {
308
+ return;
309
+ }
310
+ if (buf.timer) {
311
+ clearTimeout(buf.timer);
312
+ buf.timer = null;
313
+ }
314
+ const { text } = buf;
315
+ buf.text = "";
316
+ try {
317
+ const sanitized = truncateUtf8Bytes(stripTaggedContent(text), MAX_OBSERVATION_BYTES);
318
+ if (sanitized) {
319
+ await WorkerClient.sendObservation(sessionId, "assistant_message", buf.messageId ? { messageId: buf.messageId } : {}, sanitized, projectRoot);
320
+ }
321
+ } catch {}
322
+ }
323
+ function scheduleAssistantFlush(sessionId) {
324
+ const buf = assistantBuffers.get(sessionId);
325
+ if (!buf) {
326
+ return;
327
+ }
328
+ if (buf.timer) {
329
+ clearTimeout(buf.timer);
330
+ }
331
+ buf.timer = setTimeout(() => {
332
+ buf.timer = null;
333
+ flushAssistantBuffer(sessionId);
334
+ }, ASSISTANT_FLUSH_DEBOUNCE_MS);
335
+ }
336
+ function discardAssistantBuffer(sessionId) {
337
+ const buf = assistantBuffers.get(sessionId);
338
+ if (buf?.timer) {
339
+ clearTimeout(buf.timer);
340
+ }
341
+ assistantBuffers.delete(sessionId);
342
+ }
226
343
  async function ensureSessionInit(sessionId, prompt) {
227
344
  if (initializedSessions.has(sessionId)) {
228
345
  return true;
@@ -240,56 +357,149 @@ var ClaudeMemPlugin = async (ctx) => {
240
357
  return false;
241
358
  }
242
359
  }
243
- function extractTextFromParts(parts) {
244
- if (!parts || !Array.isArray(parts)) {
245
- return "";
246
- }
247
- return parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join(`
248
- `).trim();
360
+ async function fetchAssistantMessageText(sessionId, messageId) {
361
+ try {
362
+ const result = await client.session.messages({ path: { id: sessionId } });
363
+ if (result.data && Array.isArray(result.data)) {
364
+ const target = result.data.find((m) => m?.info?.id === messageId);
365
+ if (target) {
366
+ return extractTextFromParts(target.parts);
367
+ }
368
+ }
369
+ } catch {}
370
+ return "";
371
+ }
372
+ async function fetchLastMessages(sessionId) {
373
+ let user = "";
374
+ let assistant = "";
375
+ try {
376
+ const result = await client.session.messages({ path: { id: sessionId } });
377
+ if (result.data && Array.isArray(result.data)) {
378
+ const messages = result.data;
379
+ for (let i = messages.length - 1;i >= 0; i--) {
380
+ if (messages[i].info.role === "user") {
381
+ user = extractTextFromParts(messages[i].parts);
382
+ break;
383
+ }
384
+ }
385
+ for (let i = messages.length - 1;i >= 0; i--) {
386
+ if (messages[i].info.role === "assistant") {
387
+ assistant = extractTextFromParts(messages[i].parts);
388
+ break;
389
+ }
390
+ }
391
+ }
392
+ } catch {}
393
+ return { user, assistant };
249
394
  }
250
395
  return {
251
396
  event: async ({ event }) => {
252
- if (event.type === "session.created") {
253
- const sessionId = event.properties?.info?.id;
254
- if (!sessionId) {
397
+ switch (event.type) {
398
+ case "session.created": {
399
+ const sessionId = event.properties?.info?.id;
400
+ if (!sessionId) {
401
+ return;
402
+ }
403
+ currentSessionId = sessionId;
404
+ contextCache = undefined;
405
+ await checkWorkerAndToast();
255
406
  return;
256
407
  }
257
- currentSessionId = sessionId;
258
- contextCache = undefined;
259
- await checkWorkerAndToast();
260
- }
261
- if (event.type === "session.idle") {
262
- const sessionId = event.properties?.sessionID || currentSessionId;
263
- if (!sessionId) {
408
+ case "message.updated": {
409
+ const info = event.properties?.info;
410
+ const sessionId = info?.sessionID;
411
+ const messageId = info?.id;
412
+ if (!sessionId || !messageId || info?.role !== "assistant") {
413
+ return;
414
+ }
415
+ const isHealthy = await checkWorkerAndToast();
416
+ if (!isHealthy) {
417
+ return;
418
+ }
419
+ if (!await ensureSessionInit(sessionId)) {
420
+ return;
421
+ }
422
+ const text = await fetchAssistantMessageText(sessionId, messageId);
423
+ if (!text) {
424
+ return;
425
+ }
426
+ let buf = assistantBuffers.get(sessionId);
427
+ if (!buf) {
428
+ buf = { text: "", messageId: null, timer: null };
429
+ assistantBuffers.set(sessionId, buf);
430
+ }
431
+ buf.text = text;
432
+ buf.messageId = messageId;
433
+ scheduleAssistantFlush(sessionId);
264
434
  return;
265
435
  }
266
- try {
267
- let lastUserMessage = "";
268
- let lastAssistantMessage = "";
436
+ case "file.edited": {
437
+ const filePath = event.properties?.file;
438
+ const sessionId = currentSessionId;
439
+ if (!sessionId || !filePath) {
440
+ return;
441
+ }
442
+ const isHealthy = await checkWorkerAndToast();
443
+ if (!isHealthy) {
444
+ return;
445
+ }
446
+ if (!await ensureSessionInit(sessionId)) {
447
+ return;
448
+ }
269
449
  try {
270
- const result = await client.session.messages({
271
- path: { id: sessionId }
272
- });
273
- if (result.data && Array.isArray(result.data)) {
274
- const messages = result.data;
275
- for (let i = messages.length - 1;i >= 0; i--) {
276
- if (messages[i].info.role === "user") {
277
- lastUserMessage = extractTextFromParts(messages[i].parts);
278
- break;
279
- }
280
- }
281
- for (let i = messages.length - 1;i >= 0; i--) {
282
- if (messages[i].info.role === "assistant") {
283
- lastAssistantMessage = extractTextFromParts(messages[i].parts);
284
- break;
285
- }
286
- }
287
- }
450
+ await WorkerClient.sendObservation(sessionId, "file_edit", { path: filePath }, `File edited: ${filePath}`, projectRoot);
288
451
  } catch {}
289
- await WorkerClient.summarize(sessionId, lastUserMessage, lastAssistantMessage);
290
- await WorkerClient.completeSession(sessionId);
291
- await toast("Session summarized", "success", 2000);
292
- } catch {}
452
+ return;
453
+ }
454
+ case "session.compacted": {
455
+ const sessionId = event.properties?.sessionID || currentSessionId;
456
+ if (!sessionId) {
457
+ return;
458
+ }
459
+ await flushAssistantBuffer(sessionId);
460
+ const isHealthy = await checkWorkerAndToast();
461
+ if (!isHealthy) {
462
+ return;
463
+ }
464
+ try {
465
+ const { user, assistant } = await fetchLastMessages(sessionId);
466
+ await WorkerClient.summarize(sessionId, user, assistant);
467
+ } catch {}
468
+ return;
469
+ }
470
+ case "session.idle": {
471
+ const sessionId = event.properties?.sessionID || currentSessionId;
472
+ if (!sessionId) {
473
+ return;
474
+ }
475
+ await flushAssistantBuffer(sessionId);
476
+ try {
477
+ const { user, assistant } = await fetchLastMessages(sessionId);
478
+ await WorkerClient.summarize(sessionId, user, assistant);
479
+ await WorkerClient.completeSession(sessionId);
480
+ await toast("Session summarized", "success", 2000);
481
+ } catch {}
482
+ return;
483
+ }
484
+ case "session.deleted": {
485
+ const sessionId = event.properties?.info?.id || currentSessionId;
486
+ if (!sessionId) {
487
+ return;
488
+ }
489
+ await flushAssistantBuffer(sessionId);
490
+ discardAssistantBuffer(sessionId);
491
+ initializedSessions.delete(sessionId);
492
+ if (currentSessionId === sessionId) {
493
+ currentSessionId = null;
494
+ }
495
+ try {
496
+ await WorkerClient.completeSession(sessionId);
497
+ } catch {}
498
+ return;
499
+ }
500
+ default: {
501
+ return;
502
+ }
293
503
  }
294
504
  },
295
505
  "chat.message": async (input, output) => {
@@ -1,18 +1,41 @@
1
- /**
2
- * Worker Client for Claude-Mem
3
- * Handles communication with the local worker service running on port 37777.
4
- */
1
+ export type AutoStartResult = 'already-running' | 'started' | 'no-bun' | 'spawn-failed' | 'timeout' | 'skipped';
5
2
  export declare class WorkerClient {
6
3
  private static readonly PORT;
7
4
  private static readonly BASE_URL;
5
+ /** Per-process guard so multiple OpenCode windows don't all spawn workers. */
6
+ private static autoStartPromise;
8
7
  /**
9
8
  * Check if the worker is healthy
10
9
  */
11
10
  static isHealthy(): Promise<boolean>;
12
11
  /**
13
- * Ensure the worker is running.
12
+ * Ensure the worker is running. If healthy, returns true. Otherwise tries
13
+ * to spawn it via `bunx claude-mem start` in the background and resolves
14
+ * once the worker reports healthy or the timeout is reached.
15
+ *
16
+ * Idempotent + serialised within the process via `autoStartPromise`.
14
17
  */
15
- static ensureRunning(_projectRoot: string): Promise<boolean>;
18
+ static ensureRunning(): Promise<boolean>;
19
+ /**
20
+ * Try to auto-start the Claude-Mem worker via `bunx claude-mem start`.
21
+ *
22
+ * Behaviour:
23
+ * - If the worker is already healthy, returns 'already-running' immediately.
24
+ * - If `bun` is not on PATH, returns 'no-bun' without attempting to spawn.
25
+ * - Otherwise spawns a detached `bunx claude-mem start` process and polls
26
+ * /api/health for ~8 seconds. Returns 'started' on success, 'timeout' on
27
+ * failure to come up, 'spawn-failed' if the spawn itself errored.
28
+ *
29
+ * Concurrent calls share the same in-flight promise.
30
+ */
31
+ static tryAutoStart(): Promise<AutoStartResult>;
32
+ private static runAutoStart;
33
+ /**
34
+ * Locate the `bun` executable. Prefers Bun's own runtime detector when the
35
+ * plugin is loaded inside Bun (which OpenCode currently is), then falls back
36
+ * to PATH probing via `process.env.PATH`.
37
+ */
38
+ private static findBun;
16
39
  /**
17
40
  * Initialize a session
18
41
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ephemushroom/opencode-claude-mem",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode plugin for Claude-Mem persistent memory system",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",