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.
- package/README.md +47 -0
- package/index.js +562 -0
- package/index.test.js +254 -0
- 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
|
+
}
|