@ephemushroom/opencode-claude-mem 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,57 +1,20 @@
1
1
  # opencode-claude-mem
2
2
 
3
- OpenCode plugin for [Claude-Mem](https://github.com/thedotmack/claude-mem) persistent memory system.
3
+ Persistent memory for [OpenCode](https://opencode.ai), powered by
4
+ [Claude-Mem](https://github.com/thedotmack/claude-mem).
4
5
 
5
- Enables OpenCode to share the same memory database as Claude Code — observations, summaries, and context injection all work across both editors.
6
+ Share the same Claude-Mem worker, database, and search tools between Claude Code
7
+ and OpenCode. Once connected, previous observations and summaries are injected
8
+ into new OpenCode sessions automatically.
6
9
 
7
- ## How It Works
8
-
9
- This plugin communicates with the Claude-Mem worker service (HTTP API on port 37777) to:
10
-
11
- - **Inject memory context** — previous session context is automatically injected into the system prompt on every LLM call
12
- - **Capture tool observations** — every tool execution is recorded as a structured observation
13
- - **Summarize sessions** — when a session goes idle, the last user/assistant messages are sent for summarization
14
- - **Search memory** — memory search is available via claude-mem's MCP server
15
-
16
- ## Differences from Claude Code Version
17
-
18
- The Claude Code version of claude-mem uses shell-based hooks where `SessionStart` stdout is automatically injected into the system prompt — one-time, at session start.
19
-
20
- This OpenCode version works differently:
21
-
22
- | | Claude Code | OpenCode (this plugin) |
23
- |---|---|---|
24
- | **Architecture** | Shell commands (`node script.js`) | JavaScript plugin API |
25
- | **Context injection** | `SessionStart` hook stdout → system prompt (once) | `experimental.chat.system.transform` → system prompt (every LLM call, session-level cache) |
26
- | **Context freshness** | Snapshot at session start | Cached once per session (same as Claude Code) |
27
- | **Session init** | `UserPromptSubmit` hook | `chat.message` hook with real user prompt |
28
- | **Observations** | `PostToolUse` hook | `tool.execute.after` hook (with circular memory protection) |
29
- | **Summarization** | `Stop` hook | `session.idle` event |
30
- | **Memory search** | MCP server | MCP server (same — no custom tool needed) |
31
-
32
- Both versions behave the same way: context is fetched once per session and cached. The OpenCode plugin uses `experimental.chat.system.transform` which fires on every LLM call, but the context is session-level cached so the worker is only called once.
33
-
34
- ## Prerequisites
35
-
36
- - [Claude Code](https://claude.com/claude-code) with [claude-mem plugin](https://github.com/thedotmack/claude-mem) installed and running
37
- - [OpenCode](https://opencode.ai) with plugin support
38
-
39
- ## Installation
40
-
41
- ### Step 1: Install Claude-Mem (if not already)
10
+ > **Note:** This plugin is a thin OpenCode adapter for an existing Claude-Mem
11
+ > installation. It does **not** install Claude-Mem, manage slash commands, or
12
+ > start the worker for you.
42
13
 
43
- In Claude Code terminal:
14
+ ## Quick Start
44
15
 
45
- ```
46
- /plugin marketplace add thedotmack/claude-mem
47
- /plugin install claude-mem
48
- ```
49
-
50
- Restart Claude Code. The worker service will start automatically on port 37777.
51
-
52
- ### Step 2: Add the Plugin
53
-
54
- Add to your `opencode.json` (project or global `~/.config/opencode/opencode.json`):
16
+ 1. Install and configure Claude-Mem in Claude Code.
17
+ 2. Add this plugin to your `opencode.json`:
55
18
 
56
19
  ```json
57
20
  {
@@ -59,241 +22,252 @@ Add to your `opencode.json` (project or global `~/.config/opencode/opencode.json
59
22
  }
60
23
  ```
61
24
 
62
- Restart OpenCode. The plugin will be installed automatically.
25
+ 3. Restart OpenCode.
26
+ 4. Start a session — memory context will be injected automatically when the
27
+ Claude-Mem worker is available.
28
+
29
+ ## Key Features
30
+
31
+ - **Shared Memory** — Uses the same Claude-Mem worker and memory store as
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).
36
+ - **Automatic Context Injection** — Injects relevant project memory into the
37
+ system prompt for new OpenCode turns.
38
+ - **Compaction Support** — Re-injects memory context during OpenCode session
39
+ compaction so long conversations keep their historical context.
40
+ - **Observation Capture** — Sends tool observations to Claude-Mem for future
41
+ retrieval and summarization.
42
+ - **Observation Hardening** — Skips low-value meta tools, strips
43
+ `<claude-mem-context>` and `<private>` tags before storage, and truncates
44
+ oversized observation payloads by UTF-8 byte size.
45
+ - **Graceful Degradation** — If the worker is offline and cannot be started,
46
+ the plugin fails open and OpenCode continues to work normally.
63
47
 
64
- ### Step 3: Verify
65
-
66
- ```bash
67
- # Check worker is running
68
- curl -s http://127.0.0.1:37777/api/health
48
+ ## How It Works
69
49
 
70
- # Restart OpenCode — you should see toast: "Memory active · {project}"
71
50
  ```
72
-
73
- ## Usage
74
-
75
- ### Automatic (no action needed)
76
-
77
- Once installed, the plugin works automatically:
78
-
79
- - **System prompt injection** — memory context is automatically injected into every LLM call via `experimental.chat.system.transform`. The LLM sees your project's observation history, past decisions, and session summaries. Context is cached once per session to avoid redundant worker calls.
80
- - **Tool observation capture** — every tool execution (file reads, edits, searches, etc.) is recorded as an observation in the memory database. Claude-mem's own MCP tools are automatically filtered to prevent circular memory.
81
- - **Session summarization** — when a session goes idle, the last user/assistant exchange is summarized and stored.
82
- - **Toast notification** — on session start, a "Memory active · {project}" toast confirms the plugin is connected to the worker.
83
-
84
- **Note**: Memory context is injected transparently into every conversation. You don't need to run any commands — the plugin works automatically in the background.
85
-
86
- ### Memory Search
87
-
88
- Memory search is provided by claude-mem's MCP server. If you have the claude-mem MCP configured in OpenCode, the LLM can search project history directly via MCP tools — no plugin-level tool needed.
89
-
90
- ### Alternative: Manual Installation (from source)
91
-
92
- If you prefer to install from source instead of npm:
93
-
94
- ```bash
95
- git clone https://github.com/Ephemushroom/opencode-claude-mem.git
96
- cd opencode-claude-mem
97
- bun install
98
- bun run build
51
+ OpenCode session
52
+ |
53
+ |-- plugin loads
54
+ | '-- connect to Claude-Mem worker on port 37777
55
+ |
56
+ |-- session.created ................ track session + reset cache
57
+ |-- chat.message ................... init session with real user prompt
58
+ |-- tool.execute.after ............. send tool observation
59
+ |-- system.transform ............... inject memory into system prompt
60
+ |-- session.compacting ............. preserve memory during compaction
61
+ |-- message.updated ................ capture assistant text (debounced)
62
+ |-- file.edited .................... record file edit observation
63
+ |-- session.compacted .............. summarize after compaction
64
+ |-- session.idle ................... flush + summarize + complete session
65
+ '-- session.deleted ................ flush + complete (no zombie sessions)
99
66
  ```
100
67
 
101
- Then symlink into OpenCode:
68
+ The plugin is intentionally small. It only adapts OpenCode hook events to the
69
+ Claude-Mem worker HTTP API. All indexing, summarization, memory search, and
70
+ storage stay in upstream Claude-Mem.
102
71
 
103
- **macOS / Linux:**
72
+ ## Architecture
104
73
 
105
- ```bash
106
- mkdir -p ~/.config/opencode/plugin
107
- ln -sf "$(pwd)/dist/index.js" ~/.config/opencode/plugin/claude-mem.js
108
74
  ```
109
-
110
- **Windows (requires elevated terminal):**
111
-
112
- ```powershell
113
- # Option A: If you have gsudo / sudo installed
114
- sudo powershell -Command "New-Item -ItemType SymbolicLink -Path '$env:USERPROFILE\.config\opencode\plugin\claude-mem.js' -Target 'D:\path\to\opencode-claude-mem\dist\index.js'"
115
-
116
- # Option B: Run PowerShell as Administrator
117
- New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.config\opencode\plugin\claude-mem.js" -Target "D:\path\to\opencode-claude-mem\dist\index.js"
75
+ OpenCode <-> Plugin (this repo) <-> Claude-Mem Worker (port 37777) <-> SQLite + ChromaDB
118
76
  ```
119
77
 
120
- ---
78
+ ### Hook Mapping
121
79
 
122
- ## Windows-Specific Setup
123
-
124
- ### Python Version for ChromaDB
80
+ | Claude Code | OpenCode plugin | Purpose |
81
+ |---|---|---|
82
+ | `SessionStart` | `experimental.chat.system.transform` | Inject memory context |
83
+ | `SessionStart` | `experimental.session.compacting` | Preserve memory during compaction |
84
+ | `UserPromptSubmit` | `chat.message` | Initialize session with real user prompt |
85
+ | `PostToolUse` | `tool.execute.after` | Capture tool observations |
86
+ | _(streaming)_ | `event` (`message.updated`) | Capture assistant text (debounced) |
87
+ | _(streaming)_ | `event` (`file.edited`) | Record file edit observations |
88
+ | _(compaction)_ | `event` (`session.compacted`) | Summarize after OpenCode compacts |
89
+ | `Stop` | `event` (`session.idle`) | Flush + summarize + complete |
90
+ | `SessionEnd` | `event` (`session.deleted`) | Flush + complete (no zombie active rows) |
125
91
 
126
- Claude-Mem uses ChromaDB for vector semantic search. ChromaDB's `chroma-mcp` tool requires **Python ≤ 3.13** (Pydantic V1 is incompatible with Python 3.14+).
92
+ ### Worker API Endpoints Used
127
93
 
128
- If `uv` defaults to Python 3.14, you'll see:
94
+ | Method | Endpoint | Purpose |
95
+ |---|---|---|
96
+ | `GET` | `/api/health` | Health check |
97
+ | `GET` | `/api/context/inject?project={name}` | Fetch formatted memory context |
98
+ | `POST` | `/api/sessions/init` | Initialize session |
99
+ | `POST` | `/api/sessions/observations` | Store tool observation |
100
+ | `POST` | `/api/sessions/summarize` | Trigger summarization |
101
+ | `POST` | `/api/sessions/complete` | Complete session |
129
102
 
130
- ```
131
- Chroma connection failed: Chroma server not reachable.
132
- ```
103
+ ## Installation
133
104
 
134
- **Fix — set global Python version:**
105
+ ### Prerequisites
135
106
 
136
- ```bash
137
- uv python pin --global 3.13
138
- ```
107
+ - [Claude Code](https://claude.com/claude-code) with
108
+ [Claude-Mem](https://github.com/thedotmack/claude-mem) installed
109
+ - [OpenCode](https://opencode.ai) with plugin support
110
+ - A running Claude-Mem worker on port `37777` (default)
139
111
 
140
- This creates a global `.python-version` file in `%APPDATA%\uv\` so all `uvx` calls default to 3.13.
112
+ ### Step 1: Install Claude-Mem
141
113
 
142
- ### ChromaDB on Windows x64
114
+ In Claude Code:
143
115
 
144
- The npm `chromadb` package only supports Windows ARM64, not x64. The worker will fail to start Chroma natively.
116
+ ```text
117
+ /plugin marketplace add thedotmack/claude-mem
118
+ /plugin install claude-mem
119
+ ```
145
120
 
146
- **Fix — use Python ChromaDB instead:**
121
+ Restart Claude Code so the worker can start and initialize its data directory.
147
122
 
148
- ```bash
149
- # Install Python ChromaDB CLI
150
- uv tool install chromadb --python 3.13
151
- ```
123
+ ### Step 2: Add the OpenCode Plugin
152
124
 
153
- Update `~/.claude-mem/settings.json`:
125
+ Add this plugin to your project or global `opencode.json`:
154
126
 
155
127
  ```json
156
128
  {
157
- "CLAUDE_MEM_CHROMA_MODE": "remote"
129
+ "plugin": ["@ephemushroom/opencode-claude-mem"]
158
130
  }
159
131
  ```
160
132
 
161
- Start Chroma server manually:
162
-
163
- ```bash
164
- chroma run --path %USERPROFILE%\.claude-mem\vector-db --host 127.0.0.1 --port 8000
165
- ```
133
+ Then restart OpenCode.
166
134
 
167
- > **Note**: Chroma server must be running before Claude Code starts. Consider creating a startup script or scheduled task.
168
-
169
- ### ChromaDB Embedding Model Crash (Bun Runtime)
170
-
171
- After connecting to Chroma, the worker may crash with:
135
+ ### Step 3: Verify
172
136
 
173
- ```
174
- Collection setup failed: undefined is not an object
175
- (evaluating 'e.cacheDir = ...')
137
+ ```bash
138
+ curl -s http://127.0.0.1:37777/api/health
176
139
  ```
177
140
 
178
- This happens because `@huggingface/transformers` returns `undefined` when imported through Bun's CJS shim.
141
+ If the worker is healthy, OpenCode should show a toast like
142
+ `Memory active · <project>` when a session starts.
179
143
 
180
- **Fix — patch `worker-service.cjs`:**
144
+ ## Usage
181
145
 
182
- Locate the bundled worker file:
146
+ Once installed, the plugin works automatically:
183
147
 
184
- ```
185
- ~/.claude/plugins/cache/thedotmack/claude-mem/<version>/scripts/worker-service.cjs
186
- ```
148
+ - **Context injection** — Memory is injected into the system prompt on each LLM
149
+ call, with session-level caching to avoid repeated worker requests.
150
+ - **Compaction preservation** — The same cached memory context is pushed into
151
+ OpenCode’s compaction path so memory survives conversation compression.
152
+ - **Tool observation capture** — Tool executions are stored as observations,
153
+ except for low-value/meta tools and Claude-Mem search tools.
154
+ - **Assistant message capture** — Assistant text from `message.updated` is
155
+ buffered with 250ms debounce per session and sent as a single
156
+ `assistant_message` observation per turn (avoids streaming-chunk floods).
157
+ - **File edit capture** — `file.edited` events are forwarded as `file_edit`
158
+ observations (path only; OpenCode does not include diff in the event).
159
+ - **Session summarization** — On `session.compacted` and `session.idle`, the
160
+ plugin flushes any pending assistant buffer, fetches the latest user and
161
+ assistant messages, and asks Claude-Mem to summarize them.
162
+ - **Session lifecycle hygiene** — On `session.deleted` the plugin tells the
163
+ worker to `completeSession`, preventing zombie `'active'` rows from
164
+ accumulating stale `pending_messages` (the "queueDepth never decreases"
165
+ failure mode).
166
+
167
+ ## Memory Search
168
+
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:
173
+
174
+ 1. `search(query="...")`
175
+ 2. `timeline(anchor=ID)`
176
+ 3. `get_observations(ids=[...])`
177
+
178
+ This plugin focuses on sending memory data to the worker and injecting returned
179
+ context back into OpenCode sessions.
187
180
 
188
- Find this pattern (minified):
181
+ ## Key Implementation Details
189
182
 
190
- ```javascript
191
- let{env:e}=await Promise.resolve().then(()=>(F9(),N9));
192
- ```
183
+ - **Thin client architecture** — `src/index.ts` handles OpenCode hooks and
184
+ session state; `src/worker-client.ts` is a static HTTP client.
185
+ - **No console logging** — The plugin never writes to `console.*` because that
186
+ can corrupt the OpenCode TUI.
187
+ - **Deferred toast** — Health toasts only happen after hook execution begins,
188
+ avoiding startup crashes caused by early TUI access.
189
+ - **Real prompt initialization** — `chat.message` sends the actual user prompt
190
+ to Claude-Mem instead of a synthetic placeholder.
191
+ - **Real summarize payloads** — On `session.idle`, the plugin reads the latest
192
+ session messages and sends the last user and assistant messages for summary.
193
+ - **Context caching** — Memory context is fetched once per session and reused
194
+ across prompt injection and compaction.
195
+ - **Circular memory protection** — Injected context is wrapped in
196
+ `<claude-mem-context>` tags, Claude-Mem MCP search tools are skipped, and
197
+ memory-related tags are stripped before storing observations.
198
+ - **UTF-8 truncation** — Large observation outputs are capped by byte size to
199
+ reduce token waste and avoid oversize worker payloads.
200
+ - **Field name correctness** — Worker payloads use `contentSessionId`, not
201
+ `claudeSessionId`.
202
+
203
+ ## Differences from `bloodf/opencode-mem`
204
+
205
+ This project intentionally stays smaller in scope.
206
+
207
+ - **This plugin does**: bridge OpenCode hooks to an already-installed
208
+ Claude-Mem worker.
209
+ - **This plugin does not**: auto-install Claude-Mem, auto-edit OpenCode config,
210
+ auto-copy skills, auto-register slash commands, or auto-start the worker.
211
+
212
+ That keeps the runtime behavior predictable and leaves worker ownership with the
213
+ upstream Claude-Mem installation.
193
214
 
194
- Replace with:
215
+ ## Troubleshooting
195
216
 
196
- ```javascript
197
- let{env:e}=await import("@huggingface/transformers");
198
- ```
217
+ ### No memory appears in OpenCode
199
218
 
200
- Then install the dependency:
219
+ - Confirm the worker is running:
201
220
 
202
221
  ```bash
203
- cd ~/.claude/plugins/cache/thedotmack/claude-mem/<version>
204
- bun install
222
+ curl -s http://127.0.0.1:37777/api/health
205
223
  ```
206
224
 
207
- > ⚠️ **This patch is lost on plugin updates.** You'll need to re-apply after each claude-mem version upgrade.
225
+ - Make sure Claude-Mem has already been installed and used from Claude Code.
226
+ - Start a fresh OpenCode session after the worker is healthy.
208
227
 
209
- ### Verifying ChromaDB Works
228
+ ### OpenCode shows `Worker offline`
210
229
 
211
- ```bash
212
- # Check Chroma server
213
- curl -s http://127.0.0.1:8000/api/v1/heartbeat
230
+ The plugin will try to launch the worker via `bunx claude-mem start` once on
231
+ plugin load. If that toast still appears:
214
232
 
215
- # Check worker health
216
- curl -s http://127.0.0.1:37777/api/health
233
+ - Confirm `bun` is on your `PATH` — `bun --version` should print a version.
234
+ - Confirm `claude-mem` is installed for `bunx`/`npx` — run
235
+ `bunx claude-mem --version` once to populate the cache.
236
+ - On Windows after a forced kill, port `37777` may stay in `TIME_WAIT` for
237
+ 30-120 seconds; wait it out or restart Claude Code.
238
+ - Restart Claude Code to bring Claude-Mem back up via its own supervisor.
239
+ - Verify the worker port is still `37777`.
217
240
 
218
- # Check for sync errors in worker logs
219
- # Open http://localhost:37777 in browser for the web viewer
220
- ```
241
+ ### OpenCode crashes on startup
221
242
 
222
- When working correctly, worker logs should show:
243
+ - Update to a version with deferred TUI access.
244
+ - Ensure there are no `console.log`, `console.warn`, or `console.error` calls in
245
+ local plugin modifications.
223
246
 
224
- ```
225
- CHROMA_SYNC connected (remote mode, 127.0.0.1:8000)
226
- ```
247
+ ### Observations are missing or incomplete
227
248
 
228
- > **ChromaDB is optional.** Without it, claude-mem falls back to SQLite FTS5 full-text search. You lose semantic/vector search but keyword search still works.
249
+ - Ensure the worker API payload uses `contentSessionId`.
250
+ - Be aware that low-value meta tools and Claude-Mem search tools are skipped by
251
+ design.
252
+ - Very large tool outputs are truncated before storage.
229
253
 
230
- ---
254
+ ### Memory search is unavailable
231
255
 
232
- ## Development
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.
233
259
 
234
- After editing source:
260
+ ## Development
235
261
 
236
262
  ```bash
263
+ bun install
237
264
  bun run build
238
- # Restart OpenCode to pick up changes
265
+ bun run lint
266
+ bun run fmt:check
239
267
  ```
240
268
 
241
- If using symlink install, changes are picked up after rebuild. If using npm install, bump version and republish.
242
-
243
- ## Architecture
244
-
245
- ```
246
- OpenCode ←→ Plugin (this repo) ←→ Claude-Mem Worker (port 37777) ←→ SQLite + ChromaDB
247
- ```
248
-
249
- The plugin is a thin HTTP client. All heavy lifting (LLM processing, storage, search) happens in the worker.
250
-
251
- ### Hook Mapping (Claude Code → OpenCode)
252
-
253
- | Claude Code Hook | OpenCode Hook | Purpose |
254
- |-----------------|---------------|---------|
255
- | `SessionStart` → context | `experimental.chat.system.transform` | Inject memory into system prompt (every LLM call, session-level cache) |
256
- | `UserPromptSubmit` → session-init | `chat.message` | Init session with real user prompt |
257
- | `PostToolUse` → observation | `tool.execute.after` | Capture tool executions |
258
- | `Stop` → summarize | `event` (session.idle) | Summarize with real message content |
259
- | `Stop` → session-complete | `event` (session.idle) | Mark session complete |
260
- | *(no equivalent)* | `config` + `command.execute.before` | `/memory` command for user-visible context |
261
-
262
- ### Worker API Endpoints Used
263
-
264
- | Method | Endpoint | Purpose |
265
- |--------|----------|---------|
266
- | `GET` | `/api/health` | Health check |
267
- | `GET` | `/api/context/inject?project={name}` | Get formatted context for system prompt |
268
- | `POST` | `/api/sessions/init` | Initialize session |
269
- | `POST` | `/api/sessions/observations` | Send tool observation |
270
- | `POST` | `/api/sessions/summarize` | Trigger summarization |
271
- | `POST` | `/api/sessions/complete` | Complete session |
272
-
273
- ## Key Implementation Details
274
-
275
- - **Field name**: All worker API calls use `contentSessionId` (not `claudeSessionId`) — using the wrong field name causes silent failures
276
- - **No console output**: All `console.log/warn/error` removed to avoid corrupting OpenCode TUI
277
- - **Deferred toast**: `client.tui.showToast()` is deferred to first hook invocation (TUI not ready during plugin init, calling it crashes OpenCode)
278
- - **Real prompt**: `chat.message` hook extracts actual user input instead of hardcoded "SESSION_START"
279
- - **Real summarize**: `session.idle` fetches last user/assistant messages via `client.session.messages()` for meaningful summaries
280
- - **Context caching**: `getCachedContext()` caches context once per session (invalidated on `session.created`) to avoid redundant worker calls
281
- - **Content-type handling**: Worker returns `text/plain` markdown — `getContext()` checks content-type and uses `response.text()` instead of `response.json()`
282
- - **Context tag wrapping**: Injected context is wrapped in `<claude-mem-context>` tags so the worker's tag-stripping logic can remove it from observations, preventing circular memory
283
- - **Circular memory protection**: `tool.execute.after` filters out claude-mem's own MCP tools (`claude-mem_mcp-search_*`) to prevent search results from being re-recorded as observations
284
- - **`/memory` command**: Registered via `config` hook, handled by `command.execute.before` — displays context as an `ignored: true` message (visible to user, not sent to LLM)
285
-
286
- ## Troubleshooting
287
-
288
- | Symptom | Cause | Fix |
289
- |---------|-------|-----|
290
- | No toast on startup | Worker not running | Start Claude Code first, or check `curl http://127.0.0.1:37777/api/health` |
291
- | "Worker offline" toast | Worker crashed or not started | Restart Claude Code |
292
- | OpenCode crashes on startup | Plugin calling TUI too early | Update to latest version (deferred toast fix) |
293
- | TUI display corrupted | console.log in plugin code | Ensure no console output in source |
294
- | "SESSION_START" in prompts | Old version bug | Update — `session.created` no longer calls sessionInit |
295
- | Chroma sync failed | Python version or Chroma not running | See [Windows-Specific Setup](#windows-specific-setup) |
296
- | Observations not saved | Wrong field name | Ensure `contentSessionId` (not `claudeSessionId`) |
269
+ If you edit source code locally, rebuild and restart OpenCode to pick up the new
270
+ plugin bundle.
297
271
 
298
272
  ## License
299
273
 
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,19 @@
1
1
  // @bun
2
2
  // src/worker-client.ts
3
+ import { existsSync } from "fs";
4
+ import { spawn } from "child_process";
5
+ var HEALTH_TIMEOUT_MS = 1000;
6
+ var AUTOSTART_POLL_INTERVAL_MS = 500;
7
+ var AUTOSTART_POLL_MAX_ATTEMPTS = 16;
8
+
3
9
  class WorkerClient {
4
10
  static PORT = 37777;
5
11
  static BASE_URL = `http://127.0.0.1:${WorkerClient.PORT}`;
12
+ static autoStartPromise = null;
6
13
  static async isHealthy() {
7
14
  try {
8
15
  const controller = new AbortController;
9
- const timeoutId = setTimeout(() => controller.abort(), 1000);
16
+ const timeoutId = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
10
17
  const response = await fetch(`${this.BASE_URL}/api/health`, {
11
18
  signal: controller.signal
12
19
  });
@@ -16,8 +23,71 @@ class WorkerClient {
16
23
  return false;
17
24
  }
18
25
  }
19
- static async ensureRunning(_projectRoot) {
20
- return await this.isHealthy();
26
+ static async ensureRunning() {
27
+ const result = await this.tryAutoStart();
28
+ return result === "already-running" || result === "started";
29
+ }
30
+ static tryAutoStart() {
31
+ if (!this.autoStartPromise) {
32
+ this.autoStartPromise = this.runAutoStart().catch(() => "spawn-failed");
33
+ }
34
+ return this.autoStartPromise;
35
+ }
36
+ static async runAutoStart() {
37
+ if (await this.isHealthy()) {
38
+ return "already-running";
39
+ }
40
+ const bunPath = this.findBun();
41
+ if (!bunPath) {
42
+ return "no-bun";
43
+ }
44
+ try {
45
+ const child = spawn(bunPath, ["x", "claude-mem", "start"], {
46
+ detached: true,
47
+ stdio: "ignore",
48
+ windowsHide: true,
49
+ env: process.env
50
+ });
51
+ child.on("error", () => {});
52
+ child.unref();
53
+ } catch {
54
+ return "spawn-failed";
55
+ }
56
+ for (let i = 0;i < AUTOSTART_POLL_MAX_ATTEMPTS; i++) {
57
+ await new Promise((resolve) => setTimeout(resolve, AUTOSTART_POLL_INTERVAL_MS));
58
+ if (await this.isHealthy()) {
59
+ return "started";
60
+ }
61
+ }
62
+ return "timeout";
63
+ }
64
+ static findBun() {
65
+ const bunGlobal = globalThis.Bun;
66
+ if (bunGlobal && typeof bunGlobal.which === "function") {
67
+ try {
68
+ const found = bunGlobal.which("bun");
69
+ if (typeof found === "string" && found) {
70
+ return found;
71
+ }
72
+ } catch {}
73
+ }
74
+ const path = process.env.PATH || "";
75
+ const sep = process.platform === "win32" ? ";" : ":";
76
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
77
+ const slash = process.platform === "win32" ? "\\" : "/";
78
+ const dirs = path.split(sep).filter(Boolean);
79
+ for (const dir of dirs) {
80
+ const trailing = dir.endsWith("/") || dir.endsWith("\\") ? "" : slash;
81
+ for (const ext of exts) {
82
+ const candidate = `${dir}${trailing}bun${ext}`;
83
+ try {
84
+ if (existsSync(candidate)) {
85
+ return candidate;
86
+ }
87
+ } catch {}
88
+ }
89
+ }
90
+ return null;
21
91
  }
22
92
  static async sessionInit(contentSessionId, project, prompt) {
23
93
  try {
@@ -98,6 +168,101 @@ class WorkerClient {
98
168
  }
99
169
 
100
170
  // src/index.ts
171
+ var MAX_OBSERVATION_BYTES = 24 * 1024;
172
+ var MAX_TAG_REPLACEMENTS = 100;
173
+ var ASSISTANT_FLUSH_DEBOUNCE_MS = 250;
174
+ var META_TOOLS = new Set([
175
+ "askuserquestion",
176
+ "getmcpresource",
177
+ "listmcpresourcestool",
178
+ "listmcptools",
179
+ "skill",
180
+ "slashcommand",
181
+ "todowrite"
182
+ ]);
183
+ var PRIVATE_TAG_REGEX = /<private>[\s\S]*?<\/private>/g;
184
+ var CONTEXT_TAG_REGEX = /<claude-mem-context>[\s\S]*?<\/claude-mem-context>/g;
185
+ function stripTaggedContent(text) {
186
+ if (!text) {
187
+ return text;
188
+ }
189
+ let result = text;
190
+ let replacements = 0;
191
+ while (replacements < MAX_TAG_REPLACEMENTS && PRIVATE_TAG_REGEX.test(result)) {
192
+ PRIVATE_TAG_REGEX.lastIndex = 0;
193
+ result = result.replace(PRIVATE_TAG_REGEX, "");
194
+ replacements++;
195
+ }
196
+ PRIVATE_TAG_REGEX.lastIndex = 0;
197
+ while (replacements < MAX_TAG_REPLACEMENTS && CONTEXT_TAG_REGEX.test(result)) {
198
+ CONTEXT_TAG_REGEX.lastIndex = 0;
199
+ result = result.replace(CONTEXT_TAG_REGEX, "");
200
+ replacements++;
201
+ }
202
+ CONTEXT_TAG_REGEX.lastIndex = 0;
203
+ return result.trim();
204
+ }
205
+ function sanitizeObservationValue(value) {
206
+ if (typeof value === "string") {
207
+ return stripTaggedContent(value);
208
+ }
209
+ if (Array.isArray(value)) {
210
+ return value.map((item) => sanitizeObservationValue(item));
211
+ }
212
+ if (value && typeof value === "object") {
213
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeObservationValue(item)]));
214
+ }
215
+ return value;
216
+ }
217
+ function truncateUtf8Bytes(text, maxBytes) {
218
+ const encoder = new TextEncoder;
219
+ if (encoder.encode(text).length <= maxBytes) {
220
+ return text;
221
+ }
222
+ const suffix = `
223
+ [truncated]`;
224
+ const suffixBytes = encoder.encode(suffix).length;
225
+ const budget = Math.max(maxBytes - suffixBytes, 0);
226
+ let low = 0;
227
+ let high = text.length;
228
+ while (low < high) {
229
+ const mid = Math.ceil((low + high) / 2);
230
+ const candidate = text.slice(0, mid);
231
+ if (encoder.encode(candidate).length <= budget) {
232
+ low = mid;
233
+ } else {
234
+ high = mid - 1;
235
+ }
236
+ }
237
+ return `${text.slice(0, low)}${suffix}`;
238
+ }
239
+ function shouldSkipObservationTool(toolName) {
240
+ if (!toolName) {
241
+ return true;
242
+ }
243
+ const normalizedName = toolName.toLowerCase();
244
+ return normalizedName.startsWith("claude-mem_mcp-search_") || META_TOOLS.has(normalizedName);
245
+ }
246
+ function normalizeToolOutput(output) {
247
+ if (typeof output === "string") {
248
+ return output;
249
+ }
250
+ if (output === undefined || output === null) {
251
+ return "";
252
+ }
253
+ try {
254
+ return JSON.stringify(output);
255
+ } catch {
256
+ return String(output);
257
+ }
258
+ }
259
+ function extractTextFromParts(parts) {
260
+ if (!parts || !Array.isArray(parts)) {
261
+ return "";
262
+ }
263
+ return parts.filter((p) => p && p.type === "text" && typeof p.text === "string" && !p.synthetic && !p.ignored).map((p) => p.text).join(`
264
+ `).trim();
265
+ }
101
266
  var ClaudeMemPlugin = async (ctx) => {
102
267
  const { project, directory, client } = ctx;
103
268
  const projectRoot = directory || process.cwd();
@@ -122,20 +287,59 @@ var ClaudeMemPlugin = async (ctx) => {
122
287
  let initToastShown = false;
123
288
  async function checkWorkerAndToast() {
124
289
  if (workerHealthy === null) {
125
- workerHealthy = await WorkerClient.isHealthy();
290
+ workerHealthy = await WorkerClient.ensureRunning();
126
291
  }
127
292
  if (!initToastShown) {
128
293
  initToastShown = true;
129
294
  if (workerHealthy) {
130
295
  await toast(`Memory active \xB7 ${projectName}`, "success");
131
296
  } else {
132
- await toast("Worker offline \u2014 start Claude Code first", "warning", 5000);
297
+ await toast("Worker offline \u2014 install Claude-Mem (bunx claude-mem start) or start Claude Code first", "warning", 5000);
133
298
  }
134
299
  }
135
300
  return workerHealthy;
136
301
  }
137
302
  let currentSessionId = null;
138
303
  const initializedSessions = new Set;
304
+ const assistantBuffers = new Map;
305
+ async function flushAssistantBuffer(sessionId) {
306
+ const buf = assistantBuffers.get(sessionId);
307
+ if (!buf || !buf.text) {
308
+ return;
309
+ }
310
+ if (buf.timer) {
311
+ clearTimeout(buf.timer);
312
+ buf.timer = null;
313
+ }
314
+ const { text } = buf;
315
+ buf.text = "";
316
+ try {
317
+ const sanitized = truncateUtf8Bytes(stripTaggedContent(text), MAX_OBSERVATION_BYTES);
318
+ if (sanitized) {
319
+ await WorkerClient.sendObservation(sessionId, "assistant_message", buf.messageId ? { messageId: buf.messageId } : {}, sanitized, projectRoot);
320
+ }
321
+ } catch {}
322
+ }
323
+ function scheduleAssistantFlush(sessionId) {
324
+ const buf = assistantBuffers.get(sessionId);
325
+ if (!buf) {
326
+ return;
327
+ }
328
+ if (buf.timer) {
329
+ clearTimeout(buf.timer);
330
+ }
331
+ buf.timer = setTimeout(() => {
332
+ buf.timer = null;
333
+ flushAssistantBuffer(sessionId);
334
+ }, ASSISTANT_FLUSH_DEBOUNCE_MS);
335
+ }
336
+ function discardAssistantBuffer(sessionId) {
337
+ const buf = assistantBuffers.get(sessionId);
338
+ if (buf?.timer) {
339
+ clearTimeout(buf.timer);
340
+ }
341
+ assistantBuffers.delete(sessionId);
342
+ }
139
343
  async function ensureSessionInit(sessionId, prompt) {
140
344
  if (initializedSessions.has(sessionId)) {
141
345
  return true;
@@ -153,56 +357,149 @@ var ClaudeMemPlugin = async (ctx) => {
153
357
  return false;
154
358
  }
155
359
  }
156
- function extractTextFromParts(parts) {
157
- if (!parts || !Array.isArray(parts)) {
158
- return "";
159
- }
160
- return parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join(`
161
- `).trim();
360
+ async function fetchAssistantMessageText(sessionId, messageId) {
361
+ try {
362
+ const result = await client.session.messages({ path: { id: sessionId } });
363
+ if (result.data && Array.isArray(result.data)) {
364
+ const target = result.data.find((m) => m?.info?.id === messageId);
365
+ if (target) {
366
+ return extractTextFromParts(target.parts);
367
+ }
368
+ }
369
+ } catch {}
370
+ return "";
371
+ }
372
+ async function fetchLastMessages(sessionId) {
373
+ let user = "";
374
+ let assistant = "";
375
+ try {
376
+ const result = await client.session.messages({ path: { id: sessionId } });
377
+ if (result.data && Array.isArray(result.data)) {
378
+ const messages = result.data;
379
+ for (let i = messages.length - 1;i >= 0; i--) {
380
+ if (messages[i].info.role === "user") {
381
+ user = extractTextFromParts(messages[i].parts);
382
+ break;
383
+ }
384
+ }
385
+ for (let i = messages.length - 1;i >= 0; i--) {
386
+ if (messages[i].info.role === "assistant") {
387
+ assistant = extractTextFromParts(messages[i].parts);
388
+ break;
389
+ }
390
+ }
391
+ }
392
+ } catch {}
393
+ return { user, assistant };
162
394
  }
163
395
  return {
164
396
  event: async ({ event }) => {
165
- if (event.type === "session.created") {
166
- const sessionId = event.properties?.info?.id;
167
- if (!sessionId) {
397
+ switch (event.type) {
398
+ case "session.created": {
399
+ const sessionId = event.properties?.info?.id;
400
+ if (!sessionId) {
401
+ return;
402
+ }
403
+ currentSessionId = sessionId;
404
+ contextCache = undefined;
405
+ await checkWorkerAndToast();
168
406
  return;
169
407
  }
170
- currentSessionId = sessionId;
171
- contextCache = undefined;
172
- await checkWorkerAndToast();
173
- }
174
- if (event.type === "session.idle") {
175
- const sessionId = event.properties?.sessionID || currentSessionId;
176
- if (!sessionId) {
408
+ case "message.updated": {
409
+ const info = event.properties?.info;
410
+ const sessionId = info?.sessionID;
411
+ const messageId = info?.id;
412
+ if (!sessionId || !messageId || info?.role !== "assistant") {
413
+ return;
414
+ }
415
+ const isHealthy = await checkWorkerAndToast();
416
+ if (!isHealthy) {
417
+ return;
418
+ }
419
+ if (!await ensureSessionInit(sessionId)) {
420
+ return;
421
+ }
422
+ const text = await fetchAssistantMessageText(sessionId, messageId);
423
+ if (!text) {
424
+ return;
425
+ }
426
+ let buf = assistantBuffers.get(sessionId);
427
+ if (!buf) {
428
+ buf = { text: "", messageId: null, timer: null };
429
+ assistantBuffers.set(sessionId, buf);
430
+ }
431
+ buf.text = text;
432
+ buf.messageId = messageId;
433
+ scheduleAssistantFlush(sessionId);
177
434
  return;
178
435
  }
179
- try {
180
- let lastUserMessage = "";
181
- let lastAssistantMessage = "";
436
+ case "file.edited": {
437
+ const filePath = event.properties?.file;
438
+ const sessionId = currentSessionId;
439
+ if (!sessionId || !filePath) {
440
+ return;
441
+ }
442
+ const isHealthy = await checkWorkerAndToast();
443
+ if (!isHealthy) {
444
+ return;
445
+ }
446
+ if (!await ensureSessionInit(sessionId)) {
447
+ return;
448
+ }
182
449
  try {
183
- const result = await client.session.messages({
184
- path: { id: sessionId }
185
- });
186
- if (result.data && Array.isArray(result.data)) {
187
- const messages = result.data;
188
- for (let i = messages.length - 1;i >= 0; i--) {
189
- if (messages[i].info.role === "user") {
190
- lastUserMessage = extractTextFromParts(messages[i].parts);
191
- break;
192
- }
193
- }
194
- for (let i = messages.length - 1;i >= 0; i--) {
195
- if (messages[i].info.role === "assistant") {
196
- lastAssistantMessage = extractTextFromParts(messages[i].parts);
197
- break;
198
- }
199
- }
200
- }
450
+ await WorkerClient.sendObservation(sessionId, "file_edit", { path: filePath }, `File edited: ${filePath}`, projectRoot);
201
451
  } catch {}
202
- await WorkerClient.summarize(sessionId, lastUserMessage, lastAssistantMessage);
203
- await WorkerClient.completeSession(sessionId);
204
- await toast("Session summarized", "success", 2000);
205
- } catch {}
452
+ return;
453
+ }
454
+ case "session.compacted": {
455
+ const sessionId = event.properties?.sessionID || currentSessionId;
456
+ if (!sessionId) {
457
+ return;
458
+ }
459
+ await flushAssistantBuffer(sessionId);
460
+ const isHealthy = await checkWorkerAndToast();
461
+ if (!isHealthy) {
462
+ return;
463
+ }
464
+ try {
465
+ const { user, assistant } = await fetchLastMessages(sessionId);
466
+ await WorkerClient.summarize(sessionId, user, assistant);
467
+ } catch {}
468
+ return;
469
+ }
470
+ case "session.idle": {
471
+ const sessionId = event.properties?.sessionID || currentSessionId;
472
+ if (!sessionId) {
473
+ return;
474
+ }
475
+ await flushAssistantBuffer(sessionId);
476
+ try {
477
+ const { user, assistant } = await fetchLastMessages(sessionId);
478
+ await WorkerClient.summarize(sessionId, user, assistant);
479
+ await WorkerClient.completeSession(sessionId);
480
+ await toast("Session summarized", "success", 2000);
481
+ } catch {}
482
+ return;
483
+ }
484
+ case "session.deleted": {
485
+ const sessionId = event.properties?.info?.id || currentSessionId;
486
+ if (!sessionId) {
487
+ return;
488
+ }
489
+ await flushAssistantBuffer(sessionId);
490
+ discardAssistantBuffer(sessionId);
491
+ initializedSessions.delete(sessionId);
492
+ if (currentSessionId === sessionId) {
493
+ currentSessionId = null;
494
+ }
495
+ try {
496
+ await WorkerClient.completeSession(sessionId);
497
+ } catch {}
498
+ return;
499
+ }
500
+ default: {
501
+ return;
502
+ }
206
503
  }
207
504
  },
208
505
  "chat.message": async (input, output) => {
@@ -227,6 +524,25 @@ var ClaudeMemPlugin = async (ctx) => {
227
524
  output.system.push(`<claude-mem-context>
228
525
  [Claude-Mem] Memory Active. Previous Context:
229
526
  ${context}
527
+ </claude-mem-context>`);
528
+ }
529
+ } catch {}
530
+ },
531
+ "experimental.session.compacting": async (input, output) => {
532
+ const sessionId = input.sessionID;
533
+ if (sessionId) {
534
+ await ensureSessionInit(sessionId);
535
+ }
536
+ const isHealthy = await checkWorkerAndToast();
537
+ if (!isHealthy) {
538
+ return;
539
+ }
540
+ try {
541
+ const context = await getCachedContext();
542
+ if (context) {
543
+ output.context.push(`<claude-mem-context>
544
+ [Claude-Mem] Memory Active. Previous Context:
545
+ ${context}
230
546
  </claude-mem-context>`);
231
547
  }
232
548
  } catch {}
@@ -236,12 +552,14 @@ ${context}
236
552
  if (!sessionId) {
237
553
  return;
238
554
  }
239
- if (input.tool.startsWith("claude-mem_mcp-search_")) {
555
+ if (shouldSkipObservationTool(input.tool)) {
240
556
  return;
241
557
  }
242
558
  await ensureSessionInit(sessionId);
243
559
  try {
244
- await WorkerClient.sendObservation(sessionId, input.tool, input.args || {}, output.output, projectRoot);
560
+ const sanitizedToolInput = sanitizeObservationValue(input.args || {});
561
+ const sanitizedToolOutput = truncateUtf8Bytes(stripTaggedContent(normalizeToolOutput(output.output)), MAX_OBSERVATION_BYTES);
562
+ await WorkerClient.sendObservation(sessionId, input.tool, sanitizedToolInput, sanitizedToolOutput, projectRoot);
245
563
  } catch {}
246
564
  }
247
565
  };
@@ -1,18 +1,41 @@
1
- /**
2
- * Worker Client for Claude-Mem
3
- * Handles communication with the local worker service running on port 37777.
4
- */
1
+ export type AutoStartResult = 'already-running' | 'started' | 'no-bun' | 'spawn-failed' | 'timeout' | 'skipped';
5
2
  export declare class WorkerClient {
6
3
  private static readonly PORT;
7
4
  private static readonly BASE_URL;
5
+ /** Per-process guard so multiple OpenCode windows don't all spawn workers. */
6
+ private static autoStartPromise;
8
7
  /**
9
8
  * Check if the worker is healthy
10
9
  */
11
10
  static isHealthy(): Promise<boolean>;
12
11
  /**
13
- * Ensure the worker is running.
12
+ * Ensure the worker is running. If healthy, returns true. Otherwise tries
13
+ * to spawn it via `bunx claude-mem start` in the background and resolves
14
+ * once the worker reports healthy or the timeout is reached.
15
+ *
16
+ * Idempotent + serialised within the process via `autoStartPromise`.
14
17
  */
15
- static ensureRunning(_projectRoot: string): Promise<boolean>;
18
+ static ensureRunning(): Promise<boolean>;
19
+ /**
20
+ * Try to auto-start the Claude-Mem worker via `bunx claude-mem start`.
21
+ *
22
+ * Behaviour:
23
+ * - If the worker is already healthy, returns 'already-running' immediately.
24
+ * - If `bun` is not on PATH, returns 'no-bun' without attempting to spawn.
25
+ * - Otherwise spawns a detached `bunx claude-mem start` process and polls
26
+ * /api/health for ~8 seconds. Returns 'started' on success, 'timeout' on
27
+ * failure to come up, 'spawn-failed' if the spawn itself errored.
28
+ *
29
+ * Concurrent calls share the same in-flight promise.
30
+ */
31
+ static tryAutoStart(): Promise<AutoStartResult>;
32
+ private static runAutoStart;
33
+ /**
34
+ * Locate the `bun` executable. Prefers Bun's own runtime detector when the
35
+ * plugin is loaded inside Bun (which OpenCode currently is), then falls back
36
+ * to PATH probing via `process.env.PATH`.
37
+ */
38
+ private static findBun;
16
39
  /**
17
40
  * Initialize a session
18
41
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ephemushroom/opencode-claude-mem",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode plugin for Claude-Mem persistent memory system",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",