@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 +83 -24
- package/dist/index.d.ts +3 -1
- package/dist/index.js +385 -62
- package/dist/worker-client.d.ts +35 -7
- package/package.json +2 -2
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
|
-
>
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
|
80
|
-
|
|
|
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
|
|
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
|
-
- **
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
229
|
-
-
|
|
230
|
-
|
|
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
|
|
5
|
-
static
|
|
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(),
|
|
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(
|
|
20
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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) => {
|
package/dist/worker-client.d.ts
CHANGED
|
@@ -1,18 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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.
|
|
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",
|