opencode-claude-mem-capture 1.0.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.
Files changed (4) hide show
  1. package/README.md +47 -0
  2. package/index.js +562 -0
  3. package/index.test.js +254 -0
  4. package/package.json +25 -0
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # OpenCode adapter
2
+
3
+ A capture-only OpenCode plugin. It talks directly to the claude-mem worker over HTTP (pure Node/Bun built-in `fetch`, zero dependencies) and never summarizes locally — the worker at `127.0.0.1:37701` does summarization, embeddings and vector search.
4
+
5
+ ## Files
6
+
7
+ | File | Purpose |
8
+ |---|---|
9
+ | `index.js` | The plugin (`export default { id, server }`) |
10
+ | `index.test.js` | Unit tests (run with `bun run index.test.js` or `node index.test.js`) |
11
+ | `package.json` | Plugin package metadata |
12
+
13
+ ## Captured events
14
+
15
+ - `chat.message` — user message -> session init; assistant reply -> observation
16
+ - `tool.execute.after` — tool calls (name + input/output) -> observation
17
+ - `session.idle` — trigger worker summarization (polled, with toast feedback)
18
+ - `session.deleted` — clear local session maps
19
+ - `tool.claude_mem_search` — recall tool, proxies `GET /api/search/observations`
20
+
21
+ POSTs use exponential backoff (up to 3 tries: 2s / 4s / 8s) so a transient worker hiccup or rate limit does not drop observations. Every payload is tagged `platformSource: "opencode"`.
22
+
23
+ ## Enable
24
+
25
+ Add this directory to the `"plugin"` array of `~/.config/opencode/opencode.json`:
26
+
27
+ ```json
28
+ {
29
+ "plugin": [
30
+ "/ABS/PATH/agent-memory-bridge/agents/opencode"
31
+ ]
32
+ }
33
+ ```
34
+
35
+ Restart opencode; on load it logs `[claude-mem] capture plugin loading`.
36
+
37
+ **Double-capture pitfall:** remove the official shim `./plugins/claude-mem.js` from the `plugin` array — keep exactly one memory plugin.
38
+
39
+ See [`../../docs/INSTALL.md`](../../docs/INSTALL.md) §3.1 for the copy-based alternative.
40
+
41
+ ## License
42
+
43
+ [MIT](../../LICENSE). This is an **independent implementation** written against
44
+ the public OpenCode plugin contract and the claude-mem worker HTTP protocol; it
45
+ contains no source copied from the official claude-mem OpenCode plugin. It only
46
+ interoperates, over local HTTP, with the separately installed Apache-2.0-licensed
47
+ claude-mem worker (which is not bundled here). See the root [NOTICE](../../NOTICE).
package/index.js ADDED
@@ -0,0 +1,562 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 yeah <camplus360@163.com>
3
+ //
4
+ // opencode-claude-mem: capture-only plugin for claude-mem.
5
+ // Independent implementation written against the public OpenCode plugin
6
+ // contract and the claude-mem worker HTTP protocol; no third-party source
7
+ // is copied into this file (see ../../NOTICE).
8
+ //
9
+ // Responsibility (capture and send only; no summarization):
10
+ // - tool.execute.after -> POST /api/sessions/observations (tool calls)
11
+ // - chat.message (assistant)-> POST /api/sessions/observations (assistant replies)
12
+ // - session.idle -> POST /api/sessions/summarize (worker summarizes)
13
+ // - session.deleted -> clear local session maps
14
+ // - tool.claude_mem_search -> GET /api/search/observations (recall)
15
+ //
16
+ // Summarization, embedding and vector search all happen in the claude-mem worker (127.0.0.1:37701).
17
+ // Zero dependencies; pure Node/Bun built-in fetch.
18
+
19
+ import { spawn } from "node:child_process";
20
+
21
+ const DEFAULT_HOST = "127.0.0.1";
22
+ const DEFAULT_PORT = 37701;
23
+ const MAX_RESPONSE_CHARS = 1000;
24
+ const MAX_SESSIONS_TRACKED = 1000;
25
+ const TOAST_DONE_MS = 4000;
26
+ const TOAST_ERROR_MS = 5000;
27
+ const POLL_INTERVAL_MS = 3000;
28
+ const POLL_TIMEOUT_MS = 120000;
29
+
30
+ function workerBase() {
31
+ const host = process.env.CLAUDE_MEM_WORKER_HOST || DEFAULT_HOST;
32
+ const port = Number(process.env.CLAUDE_MEM_WORKER_PORT || DEFAULT_PORT);
33
+ return `http://${host}:${port}`;
34
+ }
35
+
36
+ const JSON_HEADERS = { "Content-Type": "application/json" };
37
+
38
+ // Exponential-backoff retries: transient worker hiccups / OpenRouter rate limits can fail a POST;
39
+ // a one-shot request would lose observations. Up to 3 tries (2s / 4s / 8s).
40
+ const POST_MAX_RETRIES = 3;
41
+ const POST_BACKOFF_MS = [2000, 4000, 8000];
42
+
43
+ // ---- Transport: unified claude-mem-worker.py vs in-process fetch ------------
44
+ // CLAUDE_MEM_TRANSPORT=py (default): every call is proxied through the unified
45
+ // claude-mem-worker.py `api` passthrough (spawned), i.e. opencode -> .py -> worker.
46
+ // CLAUDE_MEM_TRANSPORT=http: force the original direct-fetch path.
47
+ // Either way, if the .py shim is missing / exits non-zero / cannot reach the
48
+ // worker, the call transparently falls back to direct fetch, so memory capture
49
+ // is never broken by the migration. Override the shim path with CLAUDE_MEM_WORKER_PY.
50
+ const TRANSPORT = (process.env.CLAUDE_MEM_TRANSPORT || "py").toLowerCase();
51
+ const WORKER_PY =
52
+ process.env.CLAUDE_MEM_WORKER_PY ||
53
+ (process.env.HOME ? `${process.env.HOME}/.local/share/claude-mem/claude-mem-worker.py` : "");
54
+ const PY_CALL_TIMEOUT_MS = 15000;
55
+
56
+ // Call the .py shim. Resolves { ok, text } on delivery, or null on any failure
57
+ // (missing interpreter/script, timeout, unreachable worker -> exit 4, etc.).
58
+ function callWorkerPy(method, path, body) {
59
+ return new Promise((resolve) => {
60
+ if (!WORKER_PY) return resolve(null);
61
+ let child;
62
+ try {
63
+ child = spawn("python3", [WORKER_PY, "api", method, path], {
64
+ stdio: ["pipe", "pipe", "ignore"],
65
+ });
66
+ } catch {
67
+ return resolve(null);
68
+ }
69
+ const chunks = [];
70
+ let settled = false;
71
+ const finish = (v) => {
72
+ if (!settled) {
73
+ settled = true;
74
+ resolve(v);
75
+ }
76
+ };
77
+ const timer = setTimeout(() => {
78
+ try {
79
+ child.kill();
80
+ } catch {}
81
+ finish(null);
82
+ }, PY_CALL_TIMEOUT_MS);
83
+ child.stdout.on("data", (d) => chunks.push(d));
84
+ child.on("error", () => {
85
+ clearTimeout(timer);
86
+ finish(null);
87
+ });
88
+ child.on("close", (code) => {
89
+ clearTimeout(timer);
90
+ finish(code === 0 ? { ok: true, text: Buffer.concat(chunks).toString("utf8") } : null);
91
+ });
92
+ try {
93
+ if (method === "POST" && body !== undefined) {
94
+ child.stdin.end(Buffer.from(JSON.stringify(body), "utf8"));
95
+ } else {
96
+ child.stdin.end();
97
+ }
98
+ } catch {
99
+ clearTimeout(timer);
100
+ finish(null);
101
+ }
102
+ });
103
+ }
104
+
105
+ async function workerPostHttp(path, payload, attempt = 0) {
106
+ try {
107
+ const res = await fetch(`${workerBase()}${path}`, {
108
+ method: "POST",
109
+ headers: JSON_HEADERS,
110
+ body: JSON.stringify(payload),
111
+ });
112
+ if (!res.ok) {
113
+ throw new Error(`worker returned ${res.status}`);
114
+ }
115
+ return true;
116
+ } catch (error) {
117
+ const msg = error instanceof Error ? error.message : String(error);
118
+ if (msg.includes("ECONNREFUSED")) {
119
+ // Worker not running: give up immediately, no retry (avoids spinning)
120
+ return false;
121
+ }
122
+ if (attempt < POST_MAX_RETRIES - 1) {
123
+ console.warn(
124
+ `[claude-mem] Worker POST ${path} failed (attempt ${attempt + 1}): ${msg} — retrying`
125
+ );
126
+ await new Promise((r) => setTimeout(r, POST_BACKOFF_MS[attempt] ?? 8000));
127
+ return workerPostHttp(path, payload, attempt + 1);
128
+ }
129
+ console.warn(`[claude-mem] Worker POST ${path} failed: ${msg}`);
130
+ return false;
131
+ }
132
+ }
133
+
134
+ async function workerGetHttp(path) {
135
+ try {
136
+ const res = await fetch(`${workerBase()}${path}`, { headers: JSON_HEADERS });
137
+ if (!res.ok) {
138
+ console.warn(`[claude-mem] Worker GET ${path} returned ${res.status}`);
139
+ return null;
140
+ }
141
+ return await res.text();
142
+ } catch (error) {
143
+ const msg = error instanceof Error ? error.message : String(error);
144
+ if (!msg.includes("ECONNREFUSED")) {
145
+ console.warn(`[claude-mem] Worker GET ${path} failed: ${msg}`);
146
+ }
147
+ return null;
148
+ }
149
+ }
150
+
151
+ async function workerPost(path, body, attempt = 0) {
152
+ // Always tag the source as opencode, otherwise the worker defaults it to claude and source stats are wrong.
153
+ const payload = body && typeof body === "object" && !body.platformSource
154
+ ? { ...body, platformSource: "opencode" }
155
+ : body;
156
+ if (TRANSPORT === "py") {
157
+ const r = await callWorkerPy("POST", path, payload);
158
+ if (r?.ok) return true;
159
+ // Shim failed/missing -> fall back to direct fetch for this call (and its retries).
160
+ return workerPostHttp(path, payload, 0);
161
+ }
162
+ return workerPostHttp(path, payload, attempt);
163
+ }
164
+
165
+ async function workerGet(path) {
166
+ if (TRANSPORT === "py") {
167
+ const r = await callWorkerPy("GET", path, undefined);
168
+ if (r) return r.text;
169
+ return workerGetHttp(path);
170
+ }
171
+ return workerGetHttp(path);
172
+ }
173
+
174
+ // opencode sessionID -> claude-mem contentSessionId (matches the official shim)
175
+ const sessionIdMap = new Map();
176
+ const initializedSessions = new Set();
177
+ // opencode sessionID -> latest assistant text, passed as last_assistant_message on summarize
178
+ const assistantLastMessage = new Map();
179
+ // opencode messageID -> role of that message ("user" / "assistant"), used to filter user text parts
180
+ const messageRoles = new Map();
181
+ // opencode sessionID -> Map<messageID, Map<partID, text>>: streamed-but-not-yet-stored assistant text
182
+ const assistantText = new Map();
183
+
184
+ // Record one assistant text part. The same part is pushed repeatedly (delta -> complete);
185
+ // keyed by partID we overwrite, keeping the final complete text.
186
+ function recordAssistantText(sessionId, messageId, partId, text) {
187
+ if (!sessionId || !messageId || !text) return;
188
+ let byMessage = assistantText.get(sessionId);
189
+ if (!byMessage) {
190
+ byMessage = new Map();
191
+ assistantText.set(sessionId, byMessage);
192
+ }
193
+ let byPart = byMessage.get(messageId);
194
+ if (!byPart) {
195
+ byPart = new Map();
196
+ byMessage.set(messageId, byPart);
197
+ }
198
+ byPart.set(partId ?? `part-${byPart.size}`, text);
199
+ }
200
+
201
+ // Take (and remove) the complete text of an assistant message; empty string if already taken.
202
+ function takeAssistantText(sessionId, messageId) {
203
+ const byMessage = assistantText.get(sessionId);
204
+ const byPart = byMessage?.get(messageId);
205
+ if (!byPart || byPart.size === 0) return "";
206
+ byMessage.delete(messageId);
207
+ return [...byPart.values()].join("\n").trim();
208
+ }
209
+
210
+ function contentSessionId(opencodeSessionId) {
211
+ let id = sessionIdMap.get(opencodeSessionId);
212
+ if (!id) {
213
+ if (sessionIdMap.size >= MAX_SESSIONS_TRACKED) {
214
+ const oldest = sessionIdMap.keys().next().value;
215
+ if (oldest !== undefined) {
216
+ sessionIdMap.delete(oldest);
217
+ initializedSessions.delete(oldest);
218
+ assistantLastMessage.delete(oldest);
219
+ assistantText.delete(oldest);
220
+ }
221
+ }
222
+ id = `opencode-${opencodeSessionId}-${Date.now()}`;
223
+ sessionIdMap.set(opencodeSessionId, id);
224
+ }
225
+ return id;
226
+ }
227
+
228
+ function ensureSession(opencodeSessionId, project) {
229
+ // Only resolve/generate the contentSessionId; never send an empty init. The real init (with the
230
+ // user prompt) fires from the user branch of chat.message, avoiding an empty "[media prompt]" fallback.
231
+ return contentSessionId(opencodeSessionId);
232
+ }
233
+
234
+ function truncate(text) {
235
+ return text.length > MAX_RESPONSE_CHARS
236
+ ? text.slice(0, MAX_RESPONSE_CHARS)
237
+ : text;
238
+ }
239
+
240
+ // opencode tui.showToast accepts { title, description, variant?, duration? }.
241
+ // Also tolerate the { title, message, ... } shape; silent when the TUI is unavailable.
242
+ async function toast(ctx, body) {
243
+ const payload = {
244
+ title: body.title ?? body.message ?? "claude-mem",
245
+ description: body.description ?? body.message ?? "",
246
+ variant: body.variant ?? "success",
247
+ duration: body.duration ?? TOAST_DONE_MS,
248
+ };
249
+ try {
250
+ await ctx?.client?.tui?.showToast?.(payload);
251
+ } catch {
252
+ // Silent when the TUI is unavailable (e.g. headless / test environments)
253
+ }
254
+ }
255
+
256
+ async function getStats() {
257
+ const text = await workerGet("/api/stats");
258
+ if (!text) return null;
259
+ try {
260
+ const data = JSON.parse(text);
261
+ return data?.database ?? null;
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ // After idle, poll stats until the worker writes the summary, then show a visible toast
268
+ const watchingSessions = new Set();
269
+ // Never trigger summarize twice for one session while a poll is in flight
270
+ const summarizeInflight = new Set();
271
+
272
+ function watchSummary(ctx, sessionId, baselineSummaries, baselineObservations) {
273
+ if (watchingSessions.has(sessionId)) return;
274
+ watchingSessions.add(sessionId);
275
+ const started = Date.now();
276
+ const finish = () => watchingSessions.delete(sessionId);
277
+ const tick = async () => {
278
+ const db = await getStats();
279
+ if (!db) {
280
+ if (Date.now() - started < POLL_TIMEOUT_MS) {
281
+ setTimeout(tick, POLL_INTERVAL_MS);
282
+ return;
283
+ }
284
+ await toast(ctx, {
285
+ title: "claude-mem not responding",
286
+ message: "Cannot reach the worker; the memory summary may not have completed (systemctl --user status claude-mem-worker)",
287
+ variant: "error",
288
+ duration: TOAST_ERROR_MS,
289
+ });
290
+ finish();
291
+ return;
292
+ }
293
+
294
+ const newSummaries = (db.summaries ?? 0) - baselineSummaries;
295
+ const newObservations = (db.observations ?? 0) - baselineObservations;
296
+
297
+ if (newSummaries > 0) {
298
+ await toast(ctx, {
299
+ title: "Memory saved (claude-mem)",
300
+ description: `This turn was summarized and stored (+${newSummaries} summary${newSummaries > 1 ? "ies" : ""}${newObservations > 0 ? ` / +${newObservations} observation(s)` : ""}, ${db.summaries} total)`,
301
+ variant: "success",
302
+ duration: TOAST_DONE_MS,
303
+ });
304
+ finish();
305
+ return;
306
+ }
307
+
308
+ if (Date.now() - started < POLL_TIMEOUT_MS) {
309
+ setTimeout(tick, POLL_INTERVAL_MS);
310
+ } else {
311
+ finish();
312
+ }
313
+ };
314
+ setTimeout(tick, POLL_INTERVAL_MS);
315
+ }
316
+
317
+ // Minimal zod-compatible shim: the opencode plugin SDK describes tool args with zod schemas.
318
+ // Implement only the interface string() needs to avoid a dependency.
319
+ function zodString() {
320
+ const schema = {
321
+ _def: { typeName: "ZodString" },
322
+ isOptional: false,
323
+ description: undefined,
324
+ describe(text) {
325
+ this.description = text;
326
+ return this;
327
+ },
328
+ optional() {
329
+ this.isOptional = true;
330
+ return this;
331
+ },
332
+ safeParse(value) {
333
+ if (typeof value === "string") return { success: true, data: value };
334
+ if (value === undefined && this.isOptional) return { success: true, data: undefined };
335
+ return { success: false, error: new Error("expected string") };
336
+ },
337
+ parse(value) {
338
+ const result = this.safeParse(value);
339
+ if (!result.success) throw result.error;
340
+ return result.data;
341
+ },
342
+ };
343
+ return schema;
344
+ }
345
+
346
+ function parseSearchResponse(raw, query) {
347
+ let data;
348
+ try {
349
+ data = JSON.parse(raw);
350
+ } catch (error) {
351
+ console.warn(
352
+ "[claude-mem] Failed to parse search results:",
353
+ error instanceof Error ? error.message : String(error)
354
+ );
355
+ return "Failed to parse search results.";
356
+ }
357
+ const content = data.content;
358
+ if (!Array.isArray(content) || content.length === 0) {
359
+ return `No results found for "${query}".`;
360
+ }
361
+ const text = content
362
+ .filter((part) => part.type === "text" && typeof part.text === "string")
363
+ .map((part) => part.text)
364
+ .join("\n")
365
+ .trim();
366
+ return text || `No results found for "${query}".`;
367
+ }
368
+
369
+ export const ClaudeMemCapturePlugin = async (ctx) => {
370
+ const project = ctx?.project?.name || "opencode";
371
+ const directory = ctx?.directory || process.cwd();
372
+
373
+ console.log(`[claude-mem] capture plugin loading (project: ${project}, worker: ${workerBase()})`);
374
+
375
+ // Store one assistant reply: POST a chat.message observation + update the last_assistant_message cache.
376
+ async function flushAssistantMessage(opencodeSessionId, messageId) {
377
+ const text = takeAssistantText(opencodeSessionId, messageId);
378
+ if (!text) return;
379
+ assistantLastMessage.set(opencodeSessionId, text);
380
+ await workerPost("/api/sessions/observations", {
381
+ contentSessionId: contentSessionId(opencodeSessionId),
382
+ tool_name: "chat.message",
383
+ tool_input: {},
384
+ tool_response: truncate(text),
385
+ cwd: directory,
386
+ });
387
+ }
388
+
389
+ // Fallback: flush all not-yet-stored assistant text for the session (in message order).
390
+ // Called before summarize to guarantee last_assistant_message is non-empty.
391
+ async function flushPendingAssistant(opencodeSessionId) {
392
+ const byMessage = assistantText.get(opencodeSessionId);
393
+ if (!byMessage || byMessage.size === 0) return;
394
+ for (const messageId of [...byMessage.keys()]) {
395
+ await flushAssistantMessage(opencodeSessionId, messageId);
396
+ }
397
+ }
398
+
399
+ return {
400
+ "tool.execute.after": async (input, output) => {
401
+ const session = ensureSession(input.sessionID, project);
402
+ const raw = output?.output;
403
+ // Tool results can be objects (e.g. {content:[...]}); stringify them instead of [object Object]
404
+ const respText =
405
+ typeof raw === "string" ? raw : JSON.stringify(raw ?? {});
406
+ await workerPost("/api/sessions/observations", {
407
+ contentSessionId: session,
408
+ tool_name: input.tool,
409
+ tool_input: input?.args || {},
410
+ tool_response: truncate(respText),
411
+ cwd: directory,
412
+ });
413
+ },
414
+
415
+ // opencode chat.message trigger: input = {sessionID, agent, model, messageID, variant},
416
+ // output = {message, parts}. sessionID is on input (first arg), not on output.message.
417
+ //
418
+ // Note (observed on opencode 1.18.x): chat.message fires only when a **user message** is enqueued;
419
+ // the binary has no assistant-side trigger, so output.message.role is always "user".
420
+ // Assistant replies are captured via experimental.text.complete + message.part.updated (below).
421
+ "chat.message": async (input, output) => {
422
+ const sessionId = input?.sessionID;
423
+ if (!sessionId) return;
424
+ const text = (output?.parts || [])
425
+ .filter((part) => part.type === "text" && typeof part.text === "string")
426
+ .map((part) => part.text)
427
+ .join("\n");
428
+ if (!text) return;
429
+ const role = output?.message?.role || input?.role;
430
+ if (role === "user") {
431
+ // User prompt: init WITH the prompt creates the session and writes user_prompts (same as pi);
432
+ // never use prompt:"" or it falls back to "[media prompt]".
433
+ const csid = contentSessionId(sessionId);
434
+ initializedSessions.add(sessionId);
435
+ await workerPost("/api/sessions/init", {
436
+ contentSessionId: csid,
437
+ project,
438
+ prompt: truncate(text),
439
+ });
440
+ return;
441
+ }
442
+ // If opencode ever adds assistant-side chat.message, route it through the same path to avoid duplicates.
443
+ messageRoles.set(output?.message?.id, "assistant");
444
+ recordAssistantText(sessionId, output?.message?.id, null, text);
445
+ },
446
+
447
+ // One assistant text part finished streaming: input = {sessionID, messageID, partID},
448
+ // output = {text}. This is the most reliable source of assistant text.
449
+ "experimental.text.complete": async (input, output) => {
450
+ const text = output?.text;
451
+ if (typeof text !== "string" || !text.trim()) return;
452
+ const messageId = input?.messageID;
453
+ if (messageId) messageRoles.set(messageId, "assistant");
454
+ recordAssistantText(input?.sessionID, messageId, input?.partID, text);
455
+ },
456
+
457
+ "experimental.session.compacting": async (input) => {
458
+ await flushPendingAssistant(input.sessionID);
459
+ const session = ensureSession(input.sessionID, project);
460
+ await workerPost("/api/sessions/summarize", {
461
+ contentSessionId: session,
462
+ last_assistant_message: assistantLastMessage.get(input.sessionID) ?? "",
463
+ });
464
+ },
465
+
466
+ event: async ({ event }) => {
467
+ const type = event?.type;
468
+ const props = event?.properties ?? {};
469
+ const part = props.part;
470
+ const info = props.info;
471
+ const sessionId =
472
+ props.sessionID ?? info?.sessionID ?? info?.id ?? part?.sessionID;
473
+ if (!sessionId) return;
474
+
475
+ switch (type) {
476
+ // Track each message role to exclude text parts belonging to user messages
477
+ case "message.updated": {
478
+ const role = info?.role;
479
+ if (info?.id && role) messageRoles.set(info.id, role);
480
+ // time.completed on an assistant message means the reply is fully done -> store it
481
+ if (role === "assistant" && info?.time?.completed) {
482
+ await flushAssistantMessage(sessionId, info.id);
483
+ }
484
+ break;
485
+ }
486
+ case "message.part.updated": {
487
+ if (part?.type !== "text" || typeof part.text !== "string") break;
488
+ // Take only when the part is complete (time.end present); part.text is then full, not a delta
489
+ if (!part.time?.end) break;
490
+ if (messageRoles.get(part.messageID) === "user") break;
491
+ recordAssistantText(sessionId, part.messageID, part.id, part.text);
492
+ break;
493
+ }
494
+ case "session.idle": {
495
+ // Dedupe: do not trigger summarize again for a session while a poll is in flight;
496
+ // avoids duplicate summaries / worker queue buildup from repeated idle events.
497
+ if (summarizeInflight.has(sessionId)) break;
498
+ summarizeInflight.add(sessionId);
499
+
500
+ // Flush pending assistant text first, then summarize;
501
+ // otherwise last_assistant_message is empty -> worker reports Missing last_assistant_message.
502
+ await flushPendingAssistant(sessionId);
503
+
504
+ const session = ensureSession(sessionId, project);
505
+ const before = await getStats();
506
+ const baselineSummaries = before?.summaries ?? 0;
507
+ const baselineObservations = before?.observations ?? 0;
508
+ const ok = await workerPost("/api/sessions/summarize", {
509
+ contentSessionId: session,
510
+ last_assistant_message: assistantLastMessage.get(sessionId) ?? "",
511
+ });
512
+ if (ok) {
513
+ watchSummary(ctx, sessionId, baselineSummaries, baselineObservations);
514
+ } else {
515
+ summarizeInflight.delete(sessionId);
516
+ await toast(ctx, {
517
+ title: "claude-mem worker not running",
518
+ description: "Memory capture skipped: systemctl --user start claude-mem-worker",
519
+ variant: "error",
520
+ duration: TOAST_ERROR_MS,
521
+ });
522
+ }
523
+ break;
524
+ }
525
+ case "session.deleted": {
526
+ sessionIdMap.delete(sessionId);
527
+ initializedSessions.delete(sessionId);
528
+ assistantLastMessage.delete(sessionId);
529
+ assistantText.delete(sessionId);
530
+ summarizeInflight.delete(sessionId);
531
+ break;
532
+ }
533
+ default:
534
+ break;
535
+ }
536
+ },
537
+
538
+ tool: {
539
+ claude_mem_search: {
540
+ description:
541
+ "Search claude-mem memory database for past observations, sessions, and context",
542
+ args: {
543
+ query: zodString().describe("Search query for memory observations"),
544
+ },
545
+ async execute(args) {
546
+ const query = String(args?.query || "");
547
+ if (!query) return "Please provide a search query.";
548
+ const raw = await workerGet(
549
+ `/api/search/observations?query=${encodeURIComponent(query)}&limit=10`
550
+ );
551
+ return raw
552
+ ? parseSearchResponse(raw, query)
553
+ : "claude-mem worker is not running. Start it with: systemctl --user start claude-mem-worker";
554
+ },
555
+ },
556
+ },
557
+ };
558
+ };
559
+
560
+ // Align with the official opencode-mem plugin (the PluginModule contract of @opencode-ai/plugin):
561
+ // default export { id, server }; the opencode loader takes the plugin factory from default.server.
562
+ export default { id: "opencode-claude-mem", server: ClaudeMemCapturePlugin };
package/index.test.js ADDED
@@ -0,0 +1,254 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 yeah <camplus360@163.com>
3
+
4
+ // Mocked tests for the opencode-claude-mem capture plugin.
5
+ // Run: bun test index.test.js (or: node index.test.js)
6
+ //
7
+ // These are transport-level unit tests: they assert the event -> endpoint
8
+ // mapping (init/observation/summarize), dedupe and retry against a fetch mock,
9
+ // so they pin the mockable in-process HTTP transport. The production default is
10
+ // the spawned claude-mem-worker.py shim (CLAUDE_MEM_TRANSPORT=py), which is
11
+ // covered by real E2E; force http here so every request goes through the mock.
12
+ // This MUST be set before the dynamic import() below (TRANSPORT is read once at
13
+ // module load).
14
+ process.env.CLAUDE_MEM_TRANSPORT = "http";
15
+
16
+ let served = []; // {method, path, body}
17
+ let toastCalls = [];
18
+
19
+ // Worker behaviour switches: 200 by default; inject failure counts as needed.
20
+ let failNext = 0; // next N non-ECONNREFUSED calls fail
21
+ let connRefused = false;
22
+
23
+ globalThis.fetch = async (url, opts) => {
24
+ const u = String(url);
25
+ let body = opts?.body ? JSON.parse(opts.body) : undefined;
26
+
27
+ if (connRefused) {
28
+ // Connection refused: record nothing (worker down; the plugin must give up).
29
+ throw new Error("fetch failed: ECONNREFUSED");
30
+ }
31
+ served.push({ method: opts?.method ?? "GET", path: u, body });
32
+ if (failNext > 0) {
33
+ failNext -= 1;
34
+ served.push({ failed: true });
35
+ return new Response("boom", { status: 500 });
36
+ }
37
+ // /api/stats returns counters used by watchSummary to detect completion.
38
+ if (u.endsWith("/api/stats")) {
39
+ const summaries = served.filter(
40
+ (s) => s.method === "POST" && s.path.endsWith("/api/sessions/summarize")
41
+ ).length;
42
+ const observations = served.filter(
43
+ (s) => s.method === "POST" && s.path.endsWith("/api/sessions/observations")
44
+ ).length;
45
+ return new Response(
46
+ JSON.stringify({ database: { summaries, observations, sessions: 1 } }),
47
+ { status: 200 }
48
+ );
49
+ }
50
+ return new Response("{}", { status: 200 });
51
+ };
52
+
53
+ const ctx = {
54
+ project: { name: "opencode" },
55
+ directory: "/home/yourname/test-project",
56
+ client: {
57
+ tui: {
58
+ async showToast(p) {
59
+ toastCalls.push(p);
60
+ },
61
+ },
62
+ },
63
+ };
64
+
65
+ const mod = await import("./index.js");
66
+ // PluginModule shape: export default { id, server }
67
+ const ClaudeMemCapturePlugin = mod.default.server;
68
+ const plugin = await ClaudeMemCapturePlugin(ctx);
69
+ // The plugin returns a flat object: top-level keys are the hooks
70
+ // ("chat.message" / "tool.execute.after" / "experimental.session.compacting"
71
+ // / "event" / "tool"); there is no nested hooks key.
72
+ const hooks = plugin;
73
+
74
+ function assert(cond, msg) {
75
+ if (!cond) throw new Error("ASSERT FAILED: " + msg);
76
+ }
77
+
78
+ const reset = () => {
79
+ served = [];
80
+ toastCalls = [];
81
+ failNext = 0;
82
+ connRefused = false;
83
+ };
84
+
85
+ const SID = "test-session-1";
86
+
87
+ // ---- 1. assistant text part -> observation (flushed on message.updated) ----
88
+ await (async () => {
89
+ reset();
90
+ // A streamed assistant text part completes...
91
+ await hooks["experimental.text.complete"](
92
+ { sessionID: SID, messageID: "m1", partID: "p1" },
93
+ { text: "We switched the memory backend to claude-mem; the worker listens on 37701." }
94
+ );
95
+ // ...and the assistant message is marked complete -> flush to observations.
96
+ await hooks["event"]({
97
+ event: {
98
+ type: "message.updated",
99
+ properties: {
100
+ sessionID: SID,
101
+ info: { id: "m1", role: "assistant", time: { completed: 1 } },
102
+ },
103
+ },
104
+ });
105
+ const obs = served.filter(
106
+ (s) => s.method === "POST" && s.path.endsWith("/api/sessions/observations")
107
+ );
108
+ assert(obs.length === 1, "assistant message should yield 1 observation, got " + obs.length);
109
+ assert(
110
+ obs[0].body && typeof obs[0].body.tool_response === "string",
111
+ "observation body.tool_response must be a string"
112
+ );
113
+ assert(
114
+ obs[0].body.tool_response.includes("37701"),
115
+ "observation should carry the original text, got: " + obs[0].body.tool_response
116
+ );
117
+ assert(
118
+ obs[0].body.contentSessionId && obs[0].body.contentSessionId.length > 0,
119
+ "contentSessionId must be a non-empty string"
120
+ );
121
+ console.log("PASS 1. assistant part -> observation");
122
+ })();
123
+
124
+ // ---- 2. user message -> init, never an observation ----
125
+ await (async () => {
126
+ reset();
127
+ await hooks["chat.message"](
128
+ { sessionID: SID },
129
+ {
130
+ message: { id: "u1", role: "user" },
131
+ parts: [{ type: "text", text: "please remember this for me" }],
132
+ }
133
+ );
134
+ const obs = served.filter((s) => s.path.endsWith("/api/sessions/observations"));
135
+ const inits = served.filter((s) => s.path.endsWith("/api/sessions/init"));
136
+ assert(obs.length === 0, "user message must not yield an observation, got " + obs.length);
137
+ assert(inits.length === 1, "user message should yield exactly 1 init, got " + inits.length);
138
+ assert(
139
+ inits[0].body.prompt && inits[0].body.prompt.includes("remember"),
140
+ "init should carry the user prompt"
141
+ );
142
+ console.log("PASS 2. user message -> init (no observation)");
143
+ })();
144
+
145
+ // ---- 3. tool.execute.after -> observation (tool name + result) ----
146
+ await (async () => {
147
+ reset();
148
+ const input = { sessionID: SID, tool: "write" };
149
+ const output = {
150
+ args: { file_path: "/tmp/x.txt" },
151
+ output: { content: [{ type: "text", text: "file written" }] },
152
+ };
153
+ await hooks["tool.execute.after"](input, output);
154
+ const obs = served.filter((s) => s.path.endsWith("/api/sessions/observations"));
155
+ assert(obs.length === 1, "tool result should yield 1 observation, got " + obs.length);
156
+ assert(
157
+ obs[0].body.tool_name === "write",
158
+ "tool observation should record tool name write, got: " + obs[0].body.tool_name
159
+ );
160
+ assert(
161
+ obs[0].body.tool_response.includes("file written"),
162
+ "tool observation should contain the result text, got: " + obs[0].body.tool_response
163
+ );
164
+ console.log("PASS 3. tool.execute.after -> observation");
165
+ })();
166
+
167
+ // ---- 4. session.idle -> summarize (dedupe: two idles fire once) ----
168
+ await (async () => {
169
+ reset();
170
+ const idleEvent = {
171
+ event: {
172
+ type: "session.idle",
173
+ properties: { sessionID: SID },
174
+ },
175
+ };
176
+ await hooks["event"](idleEvent);
177
+ await hooks["event"](idleEvent); // immediate repeat: the dedupe lock must block it
178
+ const sum = served.filter((s) => s.path.endsWith("/api/sessions/summarize"));
179
+ assert(sum.length === 1, "two consecutive idles should trigger 1 summarize, got " + sum.length);
180
+ console.log("PASS 4. session.idle dedupe");
181
+ })();
182
+
183
+ // ---- 5. claude_mem_search tool ----
184
+ await (async () => {
185
+ reset();
186
+ const res = await plugin.tool.claude_mem_search.execute({ query: "memory backend" });
187
+ assert(
188
+ served.some((s) => s.path.includes("/api/search/observations")),
189
+ "the search tool must call /api/search/observations"
190
+ );
191
+ assert(typeof res === "string" && res.length > 0, "search should return text");
192
+ console.log("PASS 5. claude_mem_search");
193
+ })();
194
+
195
+ // ---- 6. worker flakiness: exponential-backoff retries then success ----
196
+ await (async () => {
197
+ reset();
198
+ failNext = 2; // first two calls 500, third succeeds
199
+ await hooks["experimental.text.complete"](
200
+ { sessionID: SID, messageID: "m6", partID: "p6" },
201
+ { text: "retry test content" }
202
+ );
203
+ await hooks["event"]({
204
+ event: {
205
+ type: "message.updated",
206
+ properties: {
207
+ sessionID: SID,
208
+ info: { id: "m6", role: "assistant", time: { completed: 1 } },
209
+ },
210
+ },
211
+ });
212
+ const failedAttempts = served.filter((s) => s.failed).length;
213
+ const okObs = served.filter(
214
+ (s) => s.method === "POST" && s.path.endsWith("/api/sessions/observations")
215
+ ).length;
216
+ assert(failedAttempts === 2, "expected 2 failed attempts, got " + failedAttempts);
217
+ assert(okObs >= 1, "expected at least 1 successful observation after retry, got " + okObs);
218
+ console.log("PASS 6. backoff retry");
219
+ })();
220
+
221
+ // ---- 7. ECONNREFUSED: give up immediately, no retries, no writes ----
222
+ await (async () => {
223
+ reset();
224
+ connRefused = true;
225
+ await hooks["chat.message"](
226
+ { sessionID: SID },
227
+ {
228
+ message: { id: "u7", role: "user" },
229
+ parts: [{ type: "text", text: "x" }],
230
+ }
231
+ );
232
+ const writes = served.filter(
233
+ (s) =>
234
+ s.method === "POST" &&
235
+ (s.path.endsWith("/api/sessions/observations") ||
236
+ s.path.endsWith("/api/sessions/summarize") ||
237
+ s.path.endsWith("/api/sessions/init"))
238
+ );
239
+ assert(writes.length === 0, "ECONNREFUSED must not produce any write request (no retries)");
240
+ console.log("PASS 7. ECONNREFUSED give up");
241
+ })();
242
+
243
+ // ---- 8. compacting -> summarize ----
244
+ await (async () => {
245
+ reset();
246
+ await hooks["experimental.session.compacting"]({ sessionID: SID });
247
+ assert(
248
+ served.some((s) => s.path.endsWith("/api/sessions/summarize")),
249
+ "compacting should trigger summarize"
250
+ );
251
+ console.log("PASS 8. session.compacting -> summarize");
252
+ })();
253
+
254
+ console.log("\nALL TESTS PASSED");
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "opencode-claude-mem-capture",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode capture-only plugin: sends conversation observations to the local claude-mem worker; summarization/embedding/search stay in claude-mem.",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "claude-mem",
9
+ "memory",
10
+ "persistent-memory",
11
+ "cross-session"
12
+ ],
13
+ "author": "camplus <camplus360@163.com>",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/camplus360/agent-memory-bridge.git",
18
+ "directory": "agents/opencode"
19
+ },
20
+ "type": "module",
21
+ "main": "index.js",
22
+ "scripts": {
23
+ "test": "bun run index.test.js"
24
+ }
25
+ }