@ephemushroom/opencode-claude-mem 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ into new OpenCode sessions automatically.
9
9
 
10
10
  > **Note:** This plugin is a thin OpenCode adapter for an existing Claude-Mem
11
11
  > installation. It does **not** install Claude-Mem, manage slash commands, or
12
- > start the worker for you.
12
+ > register Claude Code MCP servers for you.
13
13
 
14
14
  ## Quick Start
15
15
 
@@ -30,17 +30,23 @@ 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
36
39
  compaction so long conversations keep their historical context.
37
40
  - **Observation Capture** — Sends tool observations to Claude-Mem for future
38
41
  retrieval and summarization.
42
+ - **Native Memory Search** — Exposes a `mem-search` OpenCode tool backed by the
43
+ Claude-Mem worker search API, so search remains available without the Claude
44
+ Code compatibility bridge.
39
45
  - **Observation Hardening** — Skips low-value meta tools, strips
40
46
  `<claude-mem-context>` and `<private>` tags before storage, and truncates
41
47
  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.
48
+ - **Graceful Degradation** — If the worker is offline and cannot be started,
49
+ the plugin fails open and OpenCode continues to work normally.
44
50
 
45
51
  ## How It Works
46
52
 
@@ -48,14 +54,19 @@ into new OpenCode sessions automatically.
48
54
  OpenCode session
49
55
  |
50
56
  |-- plugin loads
51
- | '-- connect to Claude-Mem worker on port 37777
57
+ | '-- connect to Claude-Mem worker from env/settings/default endpoint
52
58
  |
59
+ |-- tool(mem-search) ............... search memory via worker API
53
60
  |-- session.created ................ track session + reset cache
54
61
  |-- chat.message ................... init session with real user prompt
55
62
  |-- tool.execute.after ............. send tool observation
56
63
  |-- system.transform ............... inject memory into system prompt
57
64
  |-- session.compacting ............. preserve memory during compaction
58
- '-- session.idle ................... summarize + complete session
65
+ |-- message.updated ................ capture assistant text (debounced)
66
+ |-- file.edited .................... record file edit observation
67
+ |-- session.compacted .............. summarize after compaction
68
+ |-- session.idle ................... flush + summarize
69
+ '-- session.deleted ................ flush + complete (no zombie sessions)
59
70
  ```
60
71
 
61
72
  The plugin is intentionally small. It only adapts OpenCode hook events to the
@@ -65,9 +76,13 @@ storage stay in upstream Claude-Mem.
65
76
  ## Architecture
66
77
 
67
78
  ```
68
- OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker (port 37777) <-> SQLite + ChromaDB
79
+ OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker <-> SQLite + ChromaDB
69
80
  ```
70
81
 
82
+ The worker endpoint is resolved in this order: `CLAUDE_MEM_WORKER_HOST` /
83
+ `CLAUDE_MEM_WORKER_PORT` environment variables, then
84
+ `~/.claude-mem/settings.json`, then `127.0.0.1:37777`.
85
+
71
86
  ### Hook Mapping
72
87
 
73
88
  | Claude Code | OpenCode plugin | Purpose |
@@ -76,8 +91,12 @@ OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker (port 37777) <-> SQLite +
76
91
  | `SessionStart` | `experimental.session.compacting` | Preserve memory during compaction |
77
92
  | `UserPromptSubmit` | `chat.message` | Initialize session with real user prompt |
78
93
  | `PostToolUse` | `tool.execute.after` | Capture tool observations |
79
- | `Stop` | `event` (`session.idle`) | Summarize the session |
80
- | `SessionEnd` | `event` (`session.idle`) | Mark session complete |
94
+ | Claude-Mem MCP | `tool` (`mem-search`) | Search memory from OpenCode |
95
+ | _(streaming)_ | `event` (`message.updated`) | Capture assistant text (debounced) |
96
+ | _(streaming)_ | `event` (`file.edited`) | Record file edit observations |
97
+ | _(compaction)_ | `event` (`session.compacted`) | Summarize after OpenCode compacts |
98
+ | `Stop` | `event` (`session.idle`) | Flush + summarize |
99
+ | `SessionEnd` | `event` (`session.deleted`) | Flush + complete (no zombie active rows) |
81
100
 
82
101
  ### Worker API Endpoints Used
83
102
 
@@ -89,6 +108,11 @@ OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker (port 37777) <-> SQLite +
89
108
  | `POST` | `/api/sessions/observations` | Store tool observation |
90
109
  | `POST` | `/api/sessions/summarize` | Trigger summarization |
91
110
  | `POST` | `/api/sessions/complete` | Complete session |
111
+ | `GET` | `/api/search?query={query}&project={name}` | Search memory |
112
+
113
+ Session and observation writes include `platformSource: "opencode"` so
114
+ Claude-Mem can attribute new OpenCode memories separately from Claude Code,
115
+ while context injection and `mem-search` still search the shared project memory.
92
116
 
93
117
  ## Installation
94
118
 
@@ -97,7 +121,8 @@ OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker (port 37777) <-> SQLite +
97
121
  - [Claude Code](https://claude.com/claude-code) with
98
122
  [Claude-Mem](https://github.com/thedotmack/claude-mem) installed
99
123
  - [OpenCode](https://opencode.ai) with plugin support
100
- - A running Claude-Mem worker on port `37777` (default)
124
+ - A running Claude-Mem worker (default `127.0.0.1:37777`, or your
125
+ `CLAUDE_MEM_WORKER_HOST` / `CLAUDE_MEM_WORKER_PORT` settings)
101
126
 
102
127
  ### Step 1: Install Claude-Mem
103
128
 
@@ -141,22 +166,38 @@ Once installed, the plugin works automatically:
141
166
  OpenCode’s compaction path so memory survives conversation compression.
142
167
  - **Tool observation capture** — Tool executions are stored as observations,
143
168
  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.
169
+ - **Memory search** — The `mem-search` tool queries the worker's `/api/search`
170
+ endpoint for the current project.
171
+ - **Assistant message capture** — Assistant text from `message.updated` is
172
+ buffered with 250ms debounce per session and sent as a single
173
+ `assistant_message` observation per turn (avoids streaming-chunk floods).
174
+ - **File edit capture** — `file.edited` events are forwarded as `file_edit`
175
+ observations (path only; OpenCode does not include diff in the event).
176
+ - **Session summarization** — On `session.compacted` and `session.idle`, the
177
+ plugin flushes any pending assistant buffer, fetches the latest user and
178
+ assistant messages, and asks Claude-Mem to summarize them.
179
+ - **Session lifecycle hygiene** — On `session.deleted` the plugin tells the
180
+ worker to `completeSession`, preventing zombie `'active'` rows from
181
+ accumulating stale `pending_messages` (the "queueDepth never decreases"
182
+ failure mode).
146
183
 
147
184
  ## Memory Search
148
185
 
149
- Memory search is provided by Claude-Mem’s MCP server, not by this plugin.
186
+ This plugin provides a native OpenCode tool named `mem-search`. It calls the
187
+ Claude-Mem worker's `/api/search` endpoint for the current project and returns
188
+ the same formatted result text the worker exposes over HTTP.
150
189
 
151
- If your OpenCode environment already has Claude-Mem MCP tools configured, the
152
- assistant can query project memory directly with Claude-Mem’s search workflow:
190
+ Claude Code's claude-mem plugin also registers an MCP server through its own
191
+ `.mcp.json`. OpenCode does not automatically load Claude Code plugin MCP
192
+ servers. If you previously saw Claude-Mem MCP tools in OpenCode, they were most
193
+ likely supplied by a Claude Code compatibility bridge such as oh-my-openagent.
194
+ Disabling that bridge for `claude-mem@thedotmack` removes those bridged MCP
195
+ tools; `mem-search` is the native replacement inside this plugin.
153
196
 
154
- 1. `search(query="...")`
155
- 2. `timeline(anchor=ID)`
156
- 3. `get_observations(ids=[...])`
157
-
158
- This plugin focuses on sending memory data to the worker and injecting returned
159
- context back into OpenCode sessions.
197
+ The richer Claude-Mem MCP workflow (`search`, `timeline`, `get_observations`) can
198
+ still be configured separately in OpenCode if you want it. It is not required for
199
+ automatic context injection, observation capture, summarization, or the built-in
200
+ `mem-search` tool.
160
201
 
161
202
  ## Key Implementation Details
162
203
 
@@ -168,6 +209,11 @@ context back into OpenCode sessions.
168
209
  avoiding startup crashes caused by early TUI access.
169
210
  - **Real prompt initialization** — `chat.message` sends the actual user prompt
170
211
  to Claude-Mem instead of a synthetic placeholder.
212
+ - **OpenCode attribution** — Session, observation, summarize, and complete
213
+ payloads include `platformSource: "opencode"`.
214
+ - **Worker endpoint resolution** — The plugin reads `CLAUDE_MEM_WORKER_HOST` /
215
+ `CLAUDE_MEM_WORKER_PORT`, then `~/.claude-mem/settings.json`, then defaults to
216
+ `127.0.0.1:37777`.
171
217
  - **Real summarize payloads** — On `session.idle`, the plugin reads the latest
172
218
  session messages and sends the last user and assistant messages for summary.
173
219
  - **Context caching** — Memory context is fetched once per session and reused
@@ -179,6 +225,10 @@ context back into OpenCode sessions.
179
225
  reduce token waste and avoid oversize worker payloads.
180
226
  - **Field name correctness** — Worker payloads use `contentSessionId`, not
181
227
  `claudeSessionId`.
228
+ - **PreToolUse file context** — Claude Code's native `PreToolUse Read`
229
+ `file-context` hook is not mirrored because the current OpenCode plugin
230
+ `tool.execute.before` API only lets plugins alter tool args; it has no channel
231
+ to inject extra LLM context before the tool call.
182
232
 
183
233
  ## Differences from `bloodf/opencode-mem`
184
234
 
@@ -207,7 +257,15 @@ curl -s http://127.0.0.1:37777/api/health
207
257
 
208
258
  ### OpenCode shows `Worker offline`
209
259
 
210
- - Restart Claude Code to bring Claude-Mem back up.
260
+ The plugin will try to launch the worker via `bunx claude-mem start` once on
261
+ plugin load. If that toast still appears:
262
+
263
+ - Confirm `bun` is on your `PATH` — `bun --version` should print a version.
264
+ - Confirm `claude-mem` is installed for `bunx`/`npx` — run
265
+ `bunx claude-mem --version` once to populate the cache.
266
+ - On Windows after a forced kill, port `37777` may stay in `TIME_WAIT` for
267
+ 30-120 seconds; wait it out or restart Claude Code.
268
+ - Restart Claude Code to bring Claude-Mem back up via its own supervisor.
211
269
  - Verify the worker port is still `37777`.
212
270
 
213
271
  ### OpenCode crashes on startup
@@ -225,9 +283,10 @@ curl -s http://127.0.0.1:37777/api/health
225
283
 
226
284
  ### Memory search is unavailable
227
285
 
228
- - This plugin does not configure MCP tools for you.
229
- - Configure Claude-Mem’s MCP server separately in your OpenCode environment if
230
- you want in-editor memory search.
286
+ - Use the built-in `mem-search` tool exposed by this plugin.
287
+ - If you specifically need Claude-Mem's MCP `timeline` or `get_observations`
288
+ tools, configure the Claude-Mem MCP server separately in OpenCode or enable a
289
+ compatible bridge for MCP only.
231
290
 
232
291
  ## Development
233
292
 
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,97 @@
1
1
  // @bun
2
+ // src/index.ts
3
+ import { tool } from "@opencode-ai/plugin";
4
+
2
5
  // src/worker-client.ts
6
+ import { existsSync, readFileSync } from "fs";
7
+ import { homedir } from "os";
8
+ import { join } from "path";
9
+ import { spawn } from "child_process";
10
+ var HEALTH_TIMEOUT_MS = 1000;
11
+ var AUTOSTART_POLL_INTERVAL_MS = 500;
12
+ var AUTOSTART_POLL_MAX_ATTEMPTS = 16;
13
+ var DEFAULT_WORKER_HOST = "127.0.0.1";
14
+ var DEFAULT_WORKER_PORT = 37777;
15
+ var PLATFORM_SOURCE = "opencode";
16
+ var SETTINGS_FILE = join(homedir(), ".claude-mem", "settings.json");
17
+ function isRecord(value) {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+ function readString(value) {
21
+ if (typeof value !== "string") {
22
+ return null;
23
+ }
24
+ const trimmed = value.trim();
25
+ return trimmed || null;
26
+ }
27
+ function readPort(value) {
28
+ const text = readString(value);
29
+ if (!text || !/^\d+$/.test(text)) {
30
+ return null;
31
+ }
32
+ const port = Number(text);
33
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
34
+ }
35
+ function parseSettings(rawSettings) {
36
+ if (!rawSettings) {
37
+ return {};
38
+ }
39
+ try {
40
+ const parsed = JSON.parse(rawSettings);
41
+ return isRecord(parsed) ? parsed : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+ function parseWorkerEndpoint(env, rawSettings) {
47
+ const settings = parseSettings(rawSettings);
48
+ const host = readString(env["CLAUDE_MEM_WORKER_HOST"]) ?? readString(settings["CLAUDE_MEM_WORKER_HOST"]) ?? DEFAULT_WORKER_HOST;
49
+ const port = readPort(env["CLAUDE_MEM_WORKER_PORT"]) ?? readPort(settings["CLAUDE_MEM_WORKER_PORT"]) ?? DEFAULT_WORKER_PORT;
50
+ return { host, port };
51
+ }
52
+ function readSettingsRaw() {
53
+ try {
54
+ return readFileSync(SETTINGS_FILE, "utf8");
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+ function getWorkerBaseUrl() {
60
+ const endpoint = parseWorkerEndpoint(process.env, readSettingsRaw());
61
+ return `http://${endpoint.host}:${endpoint.port}`;
62
+ }
63
+ function extractTextContent(value) {
64
+ if (typeof value === "string") {
65
+ return value.trim() || null;
66
+ }
67
+ if (!isRecord(value)) {
68
+ return null;
69
+ }
70
+ const { content } = value;
71
+ if (typeof content === "string") {
72
+ return content.trim() || null;
73
+ }
74
+ if (Array.isArray(content)) {
75
+ const text2 = content.map((part) => {
76
+ if (!isRecord(part) || part["type"] !== "text" || typeof part["text"] !== "string") {
77
+ return "";
78
+ }
79
+ return part["text"];
80
+ }).filter(Boolean).join(`
81
+ `).trim();
82
+ return text2 || null;
83
+ }
84
+ const text = JSON.stringify(value, null, 2);
85
+ return text === "{}" || text === "null" ? null : text;
86
+ }
87
+
3
88
  class WorkerClient {
4
- static PORT = 37777;
5
- static BASE_URL = `http://127.0.0.1:${WorkerClient.PORT}`;
89
+ static BASE_URL = getWorkerBaseUrl();
90
+ static autoStartPromise = null;
6
91
  static async isHealthy() {
7
92
  try {
8
93
  const controller = new AbortController;
9
- const timeoutId = setTimeout(() => controller.abort(), 1000);
94
+ const timeoutId = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
10
95
  const response = await fetch(`${this.BASE_URL}/api/health`, {
11
96
  signal: controller.signal
12
97
  });
@@ -16,15 +101,83 @@ class WorkerClient {
16
101
  return false;
17
102
  }
18
103
  }
19
- static async ensureRunning(_projectRoot) {
20
- return await this.isHealthy();
104
+ static async ensureRunning() {
105
+ const result = await this.tryAutoStart();
106
+ return result === "already-running" || result === "started";
107
+ }
108
+ static tryAutoStart() {
109
+ if (!this.autoStartPromise) {
110
+ this.autoStartPromise = this.runAutoStart().catch(() => "spawn-failed");
111
+ }
112
+ return this.autoStartPromise;
113
+ }
114
+ static async runAutoStart() {
115
+ if (await this.isHealthy()) {
116
+ return "already-running";
117
+ }
118
+ const bunPath = this.findBun();
119
+ if (!bunPath) {
120
+ return "no-bun";
121
+ }
122
+ try {
123
+ const child = spawn(bunPath, ["x", "claude-mem", "start"], {
124
+ detached: true,
125
+ stdio: "ignore",
126
+ windowsHide: true,
127
+ env: process.env
128
+ });
129
+ child.on("error", () => {});
130
+ child.unref();
131
+ } catch {
132
+ return "spawn-failed";
133
+ }
134
+ for (let i = 0;i < AUTOSTART_POLL_MAX_ATTEMPTS; i++) {
135
+ await new Promise((resolve) => setTimeout(resolve, AUTOSTART_POLL_INTERVAL_MS));
136
+ if (await this.isHealthy()) {
137
+ return "started";
138
+ }
139
+ }
140
+ return "timeout";
141
+ }
142
+ static findBun() {
143
+ const bunGlobal = globalThis.Bun;
144
+ if (bunGlobal && typeof bunGlobal.which === "function") {
145
+ try {
146
+ const found = bunGlobal.which("bun");
147
+ if (typeof found === "string" && found) {
148
+ return found;
149
+ }
150
+ } catch {}
151
+ }
152
+ const path = process.env.PATH || "";
153
+ const sep = process.platform === "win32" ? ";" : ":";
154
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
155
+ const slash = process.platform === "win32" ? "\\" : "/";
156
+ const dirs = path.split(sep).filter(Boolean);
157
+ for (const dir of dirs) {
158
+ const trailing = dir.endsWith("/") || dir.endsWith("\\") ? "" : slash;
159
+ for (const ext of exts) {
160
+ const candidate = `${dir}${trailing}bun${ext}`;
161
+ try {
162
+ if (existsSync(candidate)) {
163
+ return candidate;
164
+ }
165
+ } catch {}
166
+ }
167
+ }
168
+ return null;
21
169
  }
22
170
  static async sessionInit(contentSessionId, project, prompt) {
23
171
  try {
24
172
  const response = await fetch(`${this.BASE_URL}/api/sessions/init`, {
25
173
  method: "POST",
26
174
  headers: { "Content-Type": "application/json" },
27
- body: JSON.stringify({ contentSessionId, project, prompt })
175
+ body: JSON.stringify({
176
+ contentSessionId,
177
+ project,
178
+ prompt,
179
+ platformSource: PLATFORM_SOURCE
180
+ })
28
181
  });
29
182
  if (!response.ok) {
30
183
  return null;
@@ -44,7 +197,8 @@ class WorkerClient {
44
197
  tool_name: toolName,
45
198
  tool_input: toolInput,
46
199
  tool_response: toolResponse,
47
- cwd
200
+ cwd,
201
+ platformSource: PLATFORM_SOURCE
48
202
  })
49
203
  });
50
204
  } catch {}
@@ -57,7 +211,8 @@ class WorkerClient {
57
211
  body: JSON.stringify({
58
212
  contentSessionId,
59
213
  last_user_message: lastUserMessage,
60
- last_assistant_message: lastAssistantMessage
214
+ last_assistant_message: lastAssistantMessage,
215
+ platformSource: PLATFORM_SOURCE
61
216
  })
62
217
  });
63
218
  } catch {}
@@ -67,7 +222,7 @@ class WorkerClient {
67
222
  await fetch(`${this.BASE_URL}/api/sessions/complete`, {
68
223
  method: "POST",
69
224
  headers: { "Content-Type": "application/json" },
70
- body: JSON.stringify({ contentSessionId })
225
+ body: JSON.stringify({ contentSessionId, platformSource: PLATFORM_SOURCE })
71
226
  });
72
227
  } catch {}
73
228
  }
@@ -79,15 +234,7 @@ class WorkerClient {
79
234
  }
80
235
  const contentType = response.headers.get("content-type") || "";
81
236
  if (contentType.includes("application/json")) {
82
- const data = await response.json();
83
- if (typeof data === "string") {
84
- return data;
85
- }
86
- if (data && typeof data.content === "string") {
87
- return data.content;
88
- }
89
- const text2 = JSON.stringify(data, null, 2);
90
- return text2 === "{}" || text2 === "null" ? null : text2;
237
+ return extractTextContent(await response.json());
91
238
  }
92
239
  const text = await response.text();
93
240
  return text.trim() || null;
@@ -95,16 +242,38 @@ class WorkerClient {
95
242
  return null;
96
243
  }
97
244
  }
245
+ static async search(query, project, limit) {
246
+ try {
247
+ const params = new URLSearchParams({ query, project });
248
+ if (limit !== undefined) {
249
+ params.set("limit", String(limit));
250
+ }
251
+ const response = await fetch(`${this.BASE_URL}/api/search?${params.toString()}`);
252
+ if (!response.ok) {
253
+ return "";
254
+ }
255
+ const contentType = response.headers.get("content-type") || "";
256
+ if (contentType.includes("application/json")) {
257
+ return extractTextContent(await response.json()) || "";
258
+ }
259
+ const text = await response.text();
260
+ return text.trim();
261
+ } catch {
262
+ return "";
263
+ }
264
+ }
98
265
  }
99
266
 
100
267
  // src/index.ts
101
268
  var MAX_OBSERVATION_BYTES = 24 * 1024;
102
269
  var MAX_TAG_REPLACEMENTS = 100;
270
+ var ASSISTANT_FLUSH_DEBOUNCE_MS = 250;
103
271
  var META_TOOLS = new Set([
104
272
  "askuserquestion",
105
273
  "getmcpresource",
106
274
  "listmcpresourcestool",
107
275
  "listmcptools",
276
+ "mem-search",
108
277
  "skill",
109
278
  "slashcommand",
110
279
  "todowrite"
@@ -170,7 +339,10 @@ function shouldSkipObservationTool(toolName) {
170
339
  return true;
171
340
  }
172
341
  const normalizedName = toolName.toLowerCase();
173
- return normalizedName.startsWith("claude-mem_mcp-search_") || META_TOOLS.has(normalizedName);
342
+ if (normalizedName.includes("mcp-search") || normalizedName.includes("mem-search")) {
343
+ return true;
344
+ }
345
+ return META_TOOLS.has(normalizedName);
174
346
  }
175
347
  function normalizeToolOutput(output) {
176
348
  if (typeof output === "string") {
@@ -185,6 +357,13 @@ function normalizeToolOutput(output) {
185
357
  return String(output);
186
358
  }
187
359
  }
360
+ function extractTextFromParts(parts) {
361
+ if (!parts || !Array.isArray(parts)) {
362
+ return "";
363
+ }
364
+ return parts.filter((p) => p && p.type === "text" && typeof p.text === "string" && !p.synthetic && !p.ignored).map((p) => p.text).join(`
365
+ `).trim();
366
+ }
188
367
  var ClaudeMemPlugin = async (ctx) => {
189
368
  const { project, directory, client } = ctx;
190
369
  const projectRoot = directory || process.cwd();
@@ -209,20 +388,60 @@ var ClaudeMemPlugin = async (ctx) => {
209
388
  let initToastShown = false;
210
389
  async function checkWorkerAndToast() {
211
390
  if (workerHealthy === null) {
212
- workerHealthy = await WorkerClient.isHealthy();
391
+ workerHealthy = await WorkerClient.ensureRunning();
213
392
  }
214
393
  if (!initToastShown) {
215
394
  initToastShown = true;
216
395
  if (workerHealthy) {
217
396
  await toast(`Memory active \xB7 ${projectName}`, "success");
218
397
  } else {
219
- await toast("Worker offline \u2014 start Claude Code first", "warning", 5000);
398
+ await toast("Worker offline \u2014 install Claude-Mem (bunx claude-mem start) or start Claude Code first", "warning", 5000);
220
399
  }
221
400
  }
222
401
  return workerHealthy;
223
402
  }
224
403
  let currentSessionId = null;
225
404
  const initializedSessions = new Set;
405
+ const assistantBuffers = new Map;
406
+ async function flushAssistantBuffer(sessionId) {
407
+ const buf = assistantBuffers.get(sessionId);
408
+ if (!buf || !buf.messageId) {
409
+ return;
410
+ }
411
+ if (buf.timer) {
412
+ clearTimeout(buf.timer);
413
+ buf.timer = null;
414
+ }
415
+ const { messageId } = buf;
416
+ buf.messageId = null;
417
+ try {
418
+ const text = await fetchAssistantMessageText(sessionId, messageId);
419
+ const sanitized = truncateUtf8Bytes(stripTaggedContent(text), MAX_OBSERVATION_BYTES);
420
+ if (sanitized) {
421
+ await WorkerClient.sendObservation(sessionId, "assistant_message", { messageId }, sanitized, projectRoot);
422
+ }
423
+ } catch {}
424
+ }
425
+ function scheduleAssistantFlush(sessionId) {
426
+ const buf = assistantBuffers.get(sessionId);
427
+ if (!buf) {
428
+ return;
429
+ }
430
+ if (buf.timer) {
431
+ clearTimeout(buf.timer);
432
+ }
433
+ buf.timer = setTimeout(() => {
434
+ buf.timer = null;
435
+ flushAssistantBuffer(sessionId);
436
+ }, ASSISTANT_FLUSH_DEBOUNCE_MS);
437
+ }
438
+ function discardAssistantBuffer(sessionId) {
439
+ const buf = assistantBuffers.get(sessionId);
440
+ if (buf?.timer) {
441
+ clearTimeout(buf.timer);
442
+ }
443
+ assistantBuffers.delete(sessionId);
444
+ }
226
445
  async function ensureSessionInit(sessionId, prompt) {
227
446
  if (initializedSessions.has(sessionId)) {
228
447
  return true;
@@ -240,56 +459,160 @@ var ClaudeMemPlugin = async (ctx) => {
240
459
  return false;
241
460
  }
242
461
  }
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();
462
+ async function fetchAssistantMessageText(sessionId, messageId) {
463
+ try {
464
+ const result = await client.session.messages({ path: { id: sessionId } });
465
+ if (result.data && Array.isArray(result.data)) {
466
+ const target = result.data.find((m) => m?.info?.id === messageId);
467
+ if (target) {
468
+ return extractTextFromParts(target.parts);
469
+ }
470
+ }
471
+ } catch {}
472
+ return "";
473
+ }
474
+ async function fetchLastMessages(sessionId) {
475
+ let user = "";
476
+ let assistant = "";
477
+ try {
478
+ const result = await client.session.messages({ path: { id: sessionId } });
479
+ if (result.data && Array.isArray(result.data)) {
480
+ const messages = result.data;
481
+ for (let i = messages.length - 1;i >= 0; i--) {
482
+ if (messages[i].info.role === "user") {
483
+ user = extractTextFromParts(messages[i].parts);
484
+ break;
485
+ }
486
+ }
487
+ for (let i = messages.length - 1;i >= 0; i--) {
488
+ if (messages[i].info.role === "assistant") {
489
+ assistant = extractTextFromParts(messages[i].parts);
490
+ break;
491
+ }
492
+ }
493
+ }
494
+ } catch {}
495
+ return { user, assistant };
249
496
  }
250
497
  return {
498
+ tool: {
499
+ "mem-search": tool({
500
+ description: "Search Claude-Mem persistent memory for this OpenCode project.",
501
+ args: {
502
+ query: tool.schema.string().min(1).describe("Search query for Claude-Mem memory"),
503
+ limit: tool.schema.number().int().positive().max(50).optional().describe("Maximum number of search results")
504
+ },
505
+ execute: async ({ query, limit }) => {
506
+ const isHealthy = await checkWorkerAndToast();
507
+ if (!isHealthy) {
508
+ return "Claude-Mem worker is offline. Start Claude-Mem and retry the search.";
509
+ }
510
+ const result = await WorkerClient.search(query, projectName, limit);
511
+ return result || `No Claude-Mem results found for "${query}".`;
512
+ }
513
+ })
514
+ },
251
515
  event: async ({ event }) => {
252
- if (event.type === "session.created") {
253
- const sessionId = event.properties?.info?.id;
254
- if (!sessionId) {
516
+ switch (event.type) {
517
+ case "session.created": {
518
+ const sessionId = event.properties?.info?.id;
519
+ if (!sessionId) {
520
+ return;
521
+ }
522
+ currentSessionId = sessionId;
523
+ contextCache = undefined;
524
+ await checkWorkerAndToast();
255
525
  return;
256
526
  }
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) {
527
+ case "message.updated": {
528
+ const info = event.properties?.info;
529
+ const sessionId = info?.sessionID;
530
+ const messageId = info?.id;
531
+ if (!sessionId || !messageId || info?.role !== "assistant") {
532
+ return;
533
+ }
534
+ const isHealthy = await checkWorkerAndToast();
535
+ if (!isHealthy) {
536
+ return;
537
+ }
538
+ if (!await ensureSessionInit(sessionId)) {
539
+ return;
540
+ }
541
+ let buf = assistantBuffers.get(sessionId);
542
+ if (!buf) {
543
+ buf = { messageId: null, timer: null };
544
+ assistantBuffers.set(sessionId, buf);
545
+ }
546
+ buf.messageId = messageId;
547
+ scheduleAssistantFlush(sessionId);
264
548
  return;
265
549
  }
266
- try {
267
- let lastUserMessage = "";
268
- let lastAssistantMessage = "";
550
+ case "file.edited": {
551
+ const filePath = event.properties?.file;
552
+ const sessionId = currentSessionId;
553
+ if (!sessionId || !filePath) {
554
+ return;
555
+ }
556
+ const isHealthy = await checkWorkerAndToast();
557
+ if (!isHealthy) {
558
+ return;
559
+ }
560
+ if (!await ensureSessionInit(sessionId)) {
561
+ return;
562
+ }
269
563
  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
- }
564
+ await WorkerClient.sendObservation(sessionId, "file_edit", { path: filePath }, `File edited: ${filePath}`, projectRoot);
288
565
  } catch {}
289
- await WorkerClient.summarize(sessionId, lastUserMessage, lastAssistantMessage);
290
- await WorkerClient.completeSession(sessionId);
291
- await toast("Session summarized", "success", 2000);
292
- } catch {}
566
+ return;
567
+ }
568
+ case "session.compacted": {
569
+ const sessionId = event.properties?.sessionID || currentSessionId;
570
+ if (!sessionId) {
571
+ return;
572
+ }
573
+ await flushAssistantBuffer(sessionId);
574
+ const isHealthy = await checkWorkerAndToast();
575
+ if (!isHealthy) {
576
+ return;
577
+ }
578
+ try {
579
+ const { user, assistant } = await fetchLastMessages(sessionId);
580
+ await WorkerClient.summarize(sessionId, user, assistant);
581
+ } catch {}
582
+ return;
583
+ }
584
+ case "session.idle": {
585
+ const sessionId = event.properties?.sessionID || currentSessionId;
586
+ if (!sessionId) {
587
+ return;
588
+ }
589
+ await flushAssistantBuffer(sessionId);
590
+ try {
591
+ const { user, assistant } = await fetchLastMessages(sessionId);
592
+ await WorkerClient.summarize(sessionId, user, assistant);
593
+ await toast("Session summarized", "success", 2000);
594
+ } catch {}
595
+ return;
596
+ }
597
+ case "session.deleted": {
598
+ const sessionId = event.properties?.info?.id || currentSessionId;
599
+ if (!sessionId) {
600
+ return;
601
+ }
602
+ await flushAssistantBuffer(sessionId);
603
+ discardAssistantBuffer(sessionId);
604
+ initializedSessions.delete(sessionId);
605
+ if (currentSessionId === sessionId) {
606
+ currentSessionId = null;
607
+ }
608
+ try {
609
+ await WorkerClient.completeSession(sessionId);
610
+ } catch {}
611
+ return;
612
+ }
613
+ default: {
614
+ return;
615
+ }
293
616
  }
294
617
  },
295
618
  "chat.message": async (input, output) => {
@@ -1,18 +1,45 @@
1
- /**
2
- * Worker Client for Claude-Mem
3
- * Handles communication with the local worker service running on port 37777.
4
- */
1
+ export interface WorkerEndpoint {
2
+ readonly host: string;
3
+ readonly port: number;
4
+ }
5
+ export type AutoStartResult = 'already-running' | 'started' | 'no-bun' | 'spawn-failed' | 'timeout' | 'skipped';
6
+ export declare function parseWorkerEndpoint(env: Record<string, string | undefined>, rawSettings: string | null): WorkerEndpoint;
5
7
  export declare class WorkerClient {
6
- private static readonly PORT;
7
8
  private static readonly BASE_URL;
9
+ /** Per-process guard so multiple OpenCode windows don't all spawn workers. */
10
+ private static autoStartPromise;
8
11
  /**
9
12
  * Check if the worker is healthy
10
13
  */
11
14
  static isHealthy(): Promise<boolean>;
12
15
  /**
13
- * Ensure the worker is running.
16
+ * Ensure the worker is running. If healthy, returns true. Otherwise tries
17
+ * to spawn it via `bunx claude-mem start` in the background and resolves
18
+ * once the worker reports healthy or the timeout is reached.
19
+ *
20
+ * Idempotent + serialised within the process via `autoStartPromise`.
21
+ */
22
+ static ensureRunning(): Promise<boolean>;
23
+ /**
24
+ * Try to auto-start the Claude-Mem worker via `bunx claude-mem start`.
25
+ *
26
+ * Behaviour:
27
+ * - If the worker is already healthy, returns 'already-running' immediately.
28
+ * - If `bun` is not on PATH, returns 'no-bun' without attempting to spawn.
29
+ * - Otherwise spawns a detached `bunx claude-mem start` process and polls
30
+ * /api/health for ~8 seconds. Returns 'started' on success, 'timeout' on
31
+ * failure to come up, 'spawn-failed' if the spawn itself errored.
32
+ *
33
+ * Concurrent calls share the same in-flight promise.
34
+ */
35
+ static tryAutoStart(): Promise<AutoStartResult>;
36
+ private static runAutoStart;
37
+ /**
38
+ * Locate the `bun` executable. Prefers Bun's own runtime detector when the
39
+ * plugin is loaded inside Bun (which OpenCode currently is), then falls back
40
+ * to PATH probing via `process.env.PATH`.
14
41
  */
15
- static ensureRunning(_projectRoot: string): Promise<boolean>;
42
+ private static findBun;
16
43
  /**
17
44
  * Initialize a session
18
45
  */
@@ -37,4 +64,5 @@ export declare class WorkerClient {
37
64
  * Uses /api/context/inject which returns rich markdown with observations + session summaries.
38
65
  */
39
66
  static getContext(project: string): Promise<string | null>;
67
+ static search(query: string, project: string, limit?: number): Promise<string>;
40
68
  }
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.1",
4
4
  "description": "OpenCode plugin for Claude-Mem persistent memory system",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "scripts": {
22
- "build": "bun build src/index.ts --outfile dist/index.js --target bun --format esm --external @opencode-ai/plugin && tsc --emitDeclarationOnly",
22
+ "build": "bun build src/index.ts --outfile dist/index.js --target bun --format esm --external @opencode-ai/plugin && tsc -p tsconfig.build.json --emitDeclarationOnly",
23
23
  "dev": "tsc --watch",
24
24
  "clean": "rm -rf dist",
25
25
  "lint": "oxlint",