@ephemushroom/opencode-claude-mem 0.3.0 → 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
 
@@ -39,6 +39,9 @@ into new OpenCode sessions automatically.
39
39
  compaction so long conversations keep their historical context.
40
40
  - **Observation Capture** — Sends tool observations to Claude-Mem for future
41
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.
42
45
  - **Observation Hardening** — Skips low-value meta tools, strips
43
46
  `<claude-mem-context>` and `<private>` tags before storage, and truncates
44
47
  oversized observation payloads by UTF-8 byte size.
@@ -51,8 +54,9 @@ into new OpenCode sessions automatically.
51
54
  OpenCode session
52
55
  |
53
56
  |-- plugin loads
54
- | '-- connect to Claude-Mem worker on port 37777
57
+ | '-- connect to Claude-Mem worker from env/settings/default endpoint
55
58
  |
59
+ |-- tool(mem-search) ............... search memory via worker API
56
60
  |-- session.created ................ track session + reset cache
57
61
  |-- chat.message ................... init session with real user prompt
58
62
  |-- tool.execute.after ............. send tool observation
@@ -61,7 +65,7 @@ OpenCode session
61
65
  |-- message.updated ................ capture assistant text (debounced)
62
66
  |-- file.edited .................... record file edit observation
63
67
  |-- session.compacted .............. summarize after compaction
64
- |-- session.idle ................... flush + summarize + complete session
68
+ |-- session.idle ................... flush + summarize
65
69
  '-- session.deleted ................ flush + complete (no zombie sessions)
66
70
  ```
67
71
 
@@ -72,9 +76,13 @@ storage stay in upstream Claude-Mem.
72
76
  ## Architecture
73
77
 
74
78
  ```
75
- OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker (port 37777) <-> SQLite + ChromaDB
79
+ OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker <-> SQLite + ChromaDB
76
80
  ```
77
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
+
78
86
  ### Hook Mapping
79
87
 
80
88
  | Claude Code | OpenCode plugin | Purpose |
@@ -83,10 +91,11 @@ OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker (port 37777) <-> SQLite +
83
91
  | `SessionStart` | `experimental.session.compacting` | Preserve memory during compaction |
84
92
  | `UserPromptSubmit` | `chat.message` | Initialize session with real user prompt |
85
93
  | `PostToolUse` | `tool.execute.after` | Capture tool observations |
94
+ | Claude-Mem MCP | `tool` (`mem-search`) | Search memory from OpenCode |
86
95
  | _(streaming)_ | `event` (`message.updated`) | Capture assistant text (debounced) |
87
96
  | _(streaming)_ | `event` (`file.edited`) | Record file edit observations |
88
97
  | _(compaction)_ | `event` (`session.compacted`) | Summarize after OpenCode compacts |
89
- | `Stop` | `event` (`session.idle`) | Flush + summarize + complete |
98
+ | `Stop` | `event` (`session.idle`) | Flush + summarize |
90
99
  | `SessionEnd` | `event` (`session.deleted`) | Flush + complete (no zombie active rows) |
91
100
 
92
101
  ### Worker API Endpoints Used
@@ -99,6 +108,11 @@ OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker (port 37777) <-> SQLite +
99
108
  | `POST` | `/api/sessions/observations` | Store tool observation |
100
109
  | `POST` | `/api/sessions/summarize` | Trigger summarization |
101
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.
102
116
 
103
117
  ## Installation
104
118
 
@@ -107,7 +121,8 @@ OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker (port 37777) <-> SQLite +
107
121
  - [Claude Code](https://claude.com/claude-code) with
108
122
  [Claude-Mem](https://github.com/thedotmack/claude-mem) installed
109
123
  - [OpenCode](https://opencode.ai) with plugin support
110
- - 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)
111
126
 
112
127
  ### Step 1: Install Claude-Mem
113
128
 
@@ -151,6 +166,8 @@ Once installed, the plugin works automatically:
151
166
  OpenCode’s compaction path so memory survives conversation compression.
152
167
  - **Tool observation capture** — Tool executions are stored as observations,
153
168
  except for low-value/meta tools and Claude-Mem search tools.
169
+ - **Memory search** — The `mem-search` tool queries the worker's `/api/search`
170
+ endpoint for the current project.
154
171
  - **Assistant message capture** — Assistant text from `message.updated` is
155
172
  buffered with 250ms debounce per session and sent as a single
156
173
  `assistant_message` observation per turn (avoids streaming-chunk floods).
@@ -166,17 +183,21 @@ Once installed, the plugin works automatically:
166
183
 
167
184
  ## Memory Search
168
185
 
169
- Memory search is provided by Claude-Mem’s MCP server, not by this plugin.
170
-
171
- If your OpenCode environment already has Claude-Mem MCP tools configured, the
172
- assistant can query project memory directly with Claude-Mem’s search workflow:
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.
173
189
 
174
- 1. `search(query="...")`
175
- 2. `timeline(anchor=ID)`
176
- 3. `get_observations(ids=[...])`
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.
177
196
 
178
- This plugin focuses on sending memory data to the worker and injecting returned
179
- 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.
180
201
 
181
202
  ## Key Implementation Details
182
203
 
@@ -188,6 +209,11 @@ context back into OpenCode sessions.
188
209
  avoiding startup crashes caused by early TUI access.
189
210
  - **Real prompt initialization** — `chat.message` sends the actual user prompt
190
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`.
191
217
  - **Real summarize payloads** — On `session.idle`, the plugin reads the latest
192
218
  session messages and sends the last user and assistant messages for summary.
193
219
  - **Context caching** — Memory context is fetched once per session and reused
@@ -199,6 +225,10 @@ context back into OpenCode sessions.
199
225
  reduce token waste and avoid oversize worker payloads.
200
226
  - **Field name correctness** — Worker payloads use `contentSessionId`, not
201
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.
202
232
 
203
233
  ## Differences from `bloodf/opencode-mem`
204
234
 
@@ -253,9 +283,10 @@ plugin load. If that toast still appears:
253
283
 
254
284
  ### Memory search is unavailable
255
285
 
256
- - This plugin does not configure MCP tools for you.
257
- - Configure Claude-Mem’s MCP server separately in your OpenCode environment if
258
- 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.
259
290
 
260
291
  ## Development
261
292
 
package/dist/index.js CHANGED
@@ -1,14 +1,92 @@
1
1
  // @bun
2
+ // src/index.ts
3
+ import { tool } from "@opencode-ai/plugin";
4
+
2
5
  // src/worker-client.ts
3
- import { existsSync } from "fs";
6
+ import { existsSync, readFileSync } from "fs";
7
+ import { homedir } from "os";
8
+ import { join } from "path";
4
9
  import { spawn } from "child_process";
5
10
  var HEALTH_TIMEOUT_MS = 1000;
6
11
  var AUTOSTART_POLL_INTERVAL_MS = 500;
7
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
+ }
8
87
 
9
88
  class WorkerClient {
10
- static PORT = 37777;
11
- static BASE_URL = `http://127.0.0.1:${WorkerClient.PORT}`;
89
+ static BASE_URL = getWorkerBaseUrl();
12
90
  static autoStartPromise = null;
13
91
  static async isHealthy() {
14
92
  try {
@@ -94,7 +172,12 @@ class WorkerClient {
94
172
  const response = await fetch(`${this.BASE_URL}/api/sessions/init`, {
95
173
  method: "POST",
96
174
  headers: { "Content-Type": "application/json" },
97
- body: JSON.stringify({ contentSessionId, project, prompt })
175
+ body: JSON.stringify({
176
+ contentSessionId,
177
+ project,
178
+ prompt,
179
+ platformSource: PLATFORM_SOURCE
180
+ })
98
181
  });
99
182
  if (!response.ok) {
100
183
  return null;
@@ -114,7 +197,8 @@ class WorkerClient {
114
197
  tool_name: toolName,
115
198
  tool_input: toolInput,
116
199
  tool_response: toolResponse,
117
- cwd
200
+ cwd,
201
+ platformSource: PLATFORM_SOURCE
118
202
  })
119
203
  });
120
204
  } catch {}
@@ -127,7 +211,8 @@ class WorkerClient {
127
211
  body: JSON.stringify({
128
212
  contentSessionId,
129
213
  last_user_message: lastUserMessage,
130
- last_assistant_message: lastAssistantMessage
214
+ last_assistant_message: lastAssistantMessage,
215
+ platformSource: PLATFORM_SOURCE
131
216
  })
132
217
  });
133
218
  } catch {}
@@ -137,7 +222,7 @@ class WorkerClient {
137
222
  await fetch(`${this.BASE_URL}/api/sessions/complete`, {
138
223
  method: "POST",
139
224
  headers: { "Content-Type": "application/json" },
140
- body: JSON.stringify({ contentSessionId })
225
+ body: JSON.stringify({ contentSessionId, platformSource: PLATFORM_SOURCE })
141
226
  });
142
227
  } catch {}
143
228
  }
@@ -149,15 +234,7 @@ class WorkerClient {
149
234
  }
150
235
  const contentType = response.headers.get("content-type") || "";
151
236
  if (contentType.includes("application/json")) {
152
- const data = await response.json();
153
- if (typeof data === "string") {
154
- return data;
155
- }
156
- if (data && typeof data.content === "string") {
157
- return data.content;
158
- }
159
- const text2 = JSON.stringify(data, null, 2);
160
- return text2 === "{}" || text2 === "null" ? null : text2;
237
+ return extractTextContent(await response.json());
161
238
  }
162
239
  const text = await response.text();
163
240
  return text.trim() || null;
@@ -165,6 +242,26 @@ class WorkerClient {
165
242
  return null;
166
243
  }
167
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
+ }
168
265
  }
169
266
 
170
267
  // src/index.ts
@@ -176,6 +273,7 @@ var META_TOOLS = new Set([
176
273
  "getmcpresource",
177
274
  "listmcpresourcestool",
178
275
  "listmcptools",
276
+ "mem-search",
179
277
  "skill",
180
278
  "slashcommand",
181
279
  "todowrite"
@@ -241,7 +339,10 @@ function shouldSkipObservationTool(toolName) {
241
339
  return true;
242
340
  }
243
341
  const normalizedName = toolName.toLowerCase();
244
- 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);
245
346
  }
246
347
  function normalizeToolOutput(output) {
247
348
  if (typeof output === "string") {
@@ -304,19 +405,20 @@ var ClaudeMemPlugin = async (ctx) => {
304
405
  const assistantBuffers = new Map;
305
406
  async function flushAssistantBuffer(sessionId) {
306
407
  const buf = assistantBuffers.get(sessionId);
307
- if (!buf || !buf.text) {
408
+ if (!buf || !buf.messageId) {
308
409
  return;
309
410
  }
310
411
  if (buf.timer) {
311
412
  clearTimeout(buf.timer);
312
413
  buf.timer = null;
313
414
  }
314
- const { text } = buf;
315
- buf.text = "";
415
+ const { messageId } = buf;
416
+ buf.messageId = null;
316
417
  try {
418
+ const text = await fetchAssistantMessageText(sessionId, messageId);
317
419
  const sanitized = truncateUtf8Bytes(stripTaggedContent(text), MAX_OBSERVATION_BYTES);
318
420
  if (sanitized) {
319
- await WorkerClient.sendObservation(sessionId, "assistant_message", buf.messageId ? { messageId: buf.messageId } : {}, sanitized, projectRoot);
421
+ await WorkerClient.sendObservation(sessionId, "assistant_message", { messageId }, sanitized, projectRoot);
320
422
  }
321
423
  } catch {}
322
424
  }
@@ -393,6 +495,23 @@ var ClaudeMemPlugin = async (ctx) => {
393
495
  return { user, assistant };
394
496
  }
395
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
+ },
396
515
  event: async ({ event }) => {
397
516
  switch (event.type) {
398
517
  case "session.created": {
@@ -419,16 +538,11 @@ var ClaudeMemPlugin = async (ctx) => {
419
538
  if (!await ensureSessionInit(sessionId)) {
420
539
  return;
421
540
  }
422
- const text = await fetchAssistantMessageText(sessionId, messageId);
423
- if (!text) {
424
- return;
425
- }
426
541
  let buf = assistantBuffers.get(sessionId);
427
542
  if (!buf) {
428
- buf = { text: "", messageId: null, timer: null };
543
+ buf = { messageId: null, timer: null };
429
544
  assistantBuffers.set(sessionId, buf);
430
545
  }
431
- buf.text = text;
432
546
  buf.messageId = messageId;
433
547
  scheduleAssistantFlush(sessionId);
434
548
  return;
@@ -476,7 +590,6 @@ var ClaudeMemPlugin = async (ctx) => {
476
590
  try {
477
591
  const { user, assistant } = await fetchLastMessages(sessionId);
478
592
  await WorkerClient.summarize(sessionId, user, assistant);
479
- await WorkerClient.completeSession(sessionId);
480
593
  await toast("Session summarized", "success", 2000);
481
594
  } catch {}
482
595
  return;
@@ -1,6 +1,10 @@
1
+ export interface WorkerEndpoint {
2
+ readonly host: string;
3
+ readonly port: number;
4
+ }
1
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;
2
7
  export declare class WorkerClient {
3
- private static readonly PORT;
4
8
  private static readonly BASE_URL;
5
9
  /** Per-process guard so multiple OpenCode windows don't all spawn workers. */
6
10
  private static autoStartPromise;
@@ -60,4 +64,5 @@ export declare class WorkerClient {
60
64
  * Uses /api/context/inject which returns rich markdown with observations + session summaries.
61
65
  */
62
66
  static getContext(project: string): Promise<string | null>;
67
+ static search(query: string, project: string, limit?: number): Promise<string>;
63
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ephemushroom/opencode-claude-mem",
3
- "version": "0.3.0",
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",