@ephemushroom/opencode-claude-mem 0.7.0 → 0.7.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 +112 -62
- package/dist/capture-status.d.ts +26 -0
- package/dist/cli.js +171 -28
- package/dist/official-sync.d.ts +29 -0
- package/dist/server.d.ts +70 -0
- package/dist/server.js +1299 -0
- package/dist/sidebar-model.d.ts +4 -1
- package/dist/tui.js +171 -29
- package/dist/worker-client.d.ts +28 -1
- package/dist/worker-start.d.ts +46 -0
- package/dist/worker-tools.d.ts +33 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -18,10 +18,17 @@ The **Memory sidebar** for [OpenCode](https://opencode.ai), backed by
|
|
|
18
18
|
Click an item for details ← opens the full summary or observation
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
One package brings claude-mem to OpenCode 2, installed and updated like any other plugin:
|
|
22
|
+
|
|
23
|
+
- **Capture and context injection** run the **official claude-mem OpenCode plugin**. This
|
|
24
|
+
package downloads it at the running Worker's version (from the `claude-mem` npm release,
|
|
25
|
+
checked against the registry's sha512) and loads it, so capture always matches the
|
|
26
|
+
Worker's API and no file has to be copied by hand.
|
|
27
|
+
- **Memory tools** beyond the official plugin's search: `save_memory`, `work_state_write`,
|
|
28
|
+
`work_state_read`, `dumb_search`, `get_tool_uses` and the six corpus tools, served straight
|
|
29
|
+
from the Worker's HTTP API. No MCP server is needed.
|
|
30
|
+
- **The Worker is started** when nothing answers on its port.
|
|
31
|
+
- **The Memory sidebar**, below.
|
|
25
32
|
|
|
26
33
|
- Counts are for the current project (the directory name), matching the Worker's own
|
|
27
34
|
project filter: case-insensitive, merged projects included. They are read from the Worker's
|
|
@@ -38,61 +45,81 @@ removed so the two can never run side by side.
|
|
|
38
45
|
- Recent rows are fetched only while expanded; collapsed, it polls stats every 5s.
|
|
39
46
|
- Read-only and fail-open: an offline Worker shows the offline state, never blocks the TUI.
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
Verified on macOS with OpenCode **2.3.0** (built against `@opencode/plugin` 2.2.7,
|
|
49
|
+
`@opentui/solid` 0.8.1). The Windows paths follow claude-mem's own launcher location but
|
|
50
|
+
have not been run on Windows yet.
|
|
43
51
|
|
|
44
52
|
## Setup (OpenCode 2)
|
|
45
53
|
|
|
46
|
-
1. **Install
|
|
54
|
+
1. **Install claude-mem** once per machine. This installs the Worker and its launcher:
|
|
47
55
|
|
|
48
56
|
```bash
|
|
49
|
-
|
|
57
|
+
npx claude-mem install
|
|
50
58
|
```
|
|
51
59
|
|
|
52
|
-
2. **
|
|
53
|
-
`~/.config/opencode/
|
|
60
|
+
2. **Add the plugin.** This installs the package and lists it in
|
|
61
|
+
`~/.config/opencode/opencode.json`:
|
|
54
62
|
|
|
55
63
|
```bash
|
|
56
|
-
|
|
57
|
-
cd "$(mktemp -d)" && npm pack claude-mem@latest --silent \
|
|
58
|
-
&& tar -xzf claude-mem-*.tgz package/dist/opencode-plugin/index.js \
|
|
59
|
-
&& cp package/dist/opencode-plugin/index.js ~/.config/opencode/plugins/claude-mem.js
|
|
64
|
+
opencode plugin add @ephemushroom/opencode-claude-mem
|
|
60
65
|
```
|
|
61
66
|
|
|
62
|
-
|
|
63
|
-
Worker upgrade.
|
|
64
|
-
|
|
65
|
-
3. **Register the MCP server** (search, timeline, observations, `save_memory`,
|
|
66
|
-
`work_state_*`) in `~/.config/opencode/opencode.json`, under V2's `mcp.servers`:
|
|
67
|
+
3. **Add the sidebar** to `~/.config/opencode/cli.json`:
|
|
67
68
|
|
|
68
69
|
```json
|
|
69
|
-
{
|
|
70
|
-
"mcp": {
|
|
71
|
-
"servers": {
|
|
72
|
-
"claude-mem": {
|
|
73
|
-
"type": "local",
|
|
74
|
-
"command": [
|
|
75
|
-
"/opt/homebrew/bin/node",
|
|
76
|
-
"/Users/<you>/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs"
|
|
77
|
-
]
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
70
|
+
{ "plugins": ["@ephemushroom/opencode-claude-mem"] }
|
|
82
71
|
```
|
|
83
72
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
which writes the V1 `mcp` shape.
|
|
73
|
+
Restart OpenCode. The first start downloads the official files (about 10 MB) in the
|
|
74
|
+
background; the memory tools work at once, and capture begins when the download finishes.
|
|
87
75
|
|
|
88
|
-
|
|
76
|
+
### Updating
|
|
89
77
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
78
|
+
```bash
|
|
79
|
+
opencode plugin update # this package: server part and sidebar
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
OpenCode does not update plugins by itself. The official claude-mem files need no command:
|
|
83
|
+
after a Worker upgrade, the next OpenCode start fetches the matching version.
|
|
84
|
+
|
|
85
|
+
### Options
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"plugins": [{ "package": "@ephemushroom/opencode-claude-mem", "options": { "mcp": true } }]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- `mcp: true` registers claude-mem's own MCP server (every tool it has, including the
|
|
94
|
+
`smart_*` code-outline tools) in a separate process, instead of this package's tools.
|
|
95
|
+
A `claude-mem` entry you wrote under `mcp.servers` is left as it is.
|
|
96
|
+
|
|
97
|
+
claude-mem's settings apply as usual (its `settings.json`, in `~/.claude-mem` or
|
|
98
|
+
`CLAUDE_MEM_DATA_DIR`, or the environment):
|
|
93
99
|
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
- `CLAUDE_MEM_WORKER_HOST` / `CLAUDE_MEM_WORKER_PORT` select the Worker. Without a port,
|
|
101
|
+
claude-mem's own default applies (37700 plus the last two digits of the user id).
|
|
102
|
+
- `CLAUDE_MEM_LOCAL_API_TOKEN` is sent to a Worker on this machine that requires it. This
|
|
103
|
+
package's own requests refuse redirects; the official plugin's capture requests are
|
|
104
|
+
claude-mem's code and follow them (as of 15.18.3).
|
|
105
|
+
- `CLAUDE_MEM_WORKER_AUTOSTART=false` keeps this package from starting the Worker.
|
|
106
|
+
- `CLAUDE_MEM_WORK_STATE_ENABLED=false` removes the two work-state tools.
|
|
107
|
+
- `CLAUDE_MEM_WORKER_URL` is not supported: the official OpenCode plugin addresses the
|
|
108
|
+
Worker by host and port only, so the sidebar reports it and nothing is captured.
|
|
109
|
+
|
|
110
|
+
### Tools
|
|
111
|
+
|
|
112
|
+
OpenCode lists them as the `claude_mem` namespace (`tools.claude_mem.save_memory(...)` in
|
|
113
|
+
Code Mode, `claude_mem_save_memory` as a direct tool call). The official plugin adds
|
|
114
|
+
`claude_mem_search`, `claude_mem_timeline` and `claude_mem_get_observations`.
|
|
115
|
+
|
|
116
|
+
| Tool | Purpose |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `save_memory` | Save a durable note |
|
|
119
|
+
| `work_state_write` / `work_state_read` | Per-project to-do lists and working state, shown at session start |
|
|
120
|
+
| `dumb_search` | One-call lookup by title overlap |
|
|
121
|
+
| `get_tool_uses` | Raw tool input/output for selected ids |
|
|
122
|
+
| `build_corpus`, `list_corpora`, `prime_corpus`, `query_corpus`, `rebuild_corpus`, `reprime_corpus` | Knowledge corpora |
|
|
96
123
|
|
|
97
124
|
### Share memory across Claude Code and OpenCode
|
|
98
125
|
|
|
@@ -106,52 +133,75 @@ add this to `~/.claude-mem/settings.json` (it also applies to Claude Code's sess
|
|
|
106
133
|
### Verify
|
|
107
134
|
|
|
108
135
|
```bash
|
|
109
|
-
opencode plugin list # claude-mem
|
|
110
|
-
opencode mcp list # ✓ claude-mem connected
|
|
136
|
+
opencode plugin list # opencode-claude-mem 0.7.1 @ephemushroom/opencode-claude-mem
|
|
111
137
|
```
|
|
112
138
|
|
|
113
139
|
Open a session: the sidebar shows `▶ Memory (online, …)`, and new observations carry
|
|
114
140
|
canonical tool names (`Read`, `Edit`, `Bash`) with `files_modified` filled in.
|
|
115
141
|
|
|
116
|
-
|
|
142
|
+
### Where files go
|
|
117
143
|
|
|
118
|
-
|
|
119
|
-
`
|
|
144
|
+
`~/.cache/opencode-claude-mem/` (or `$XDG_CACHE_HOME/opencode-claude-mem/`): the official
|
|
145
|
+
files under `official/<version>/` (about 1.5 MB per claude-mem version; earlier versions are
|
|
146
|
+
kept, because another OpenCode process may still run from them) and `status/`, which carries
|
|
147
|
+
the server part's state to the sidebar. Deleting the directory while OpenCode is closed is
|
|
148
|
+
safe.
|
|
120
149
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
150
|
+
## Migrating
|
|
151
|
+
|
|
152
|
+
**From v0.7.0's manual setup.** Remove what the manual steps installed, then follow Setup:
|
|
153
|
+
|
|
154
|
+
- delete `~/.config/opencode/plugins/claude-mem.js`. While that file exists it keeps
|
|
155
|
+
capturing, this package does not load a second copy, and that file is not kept up to date;
|
|
156
|
+
- delete the `claude-mem` entry under `mcp.servers` in `opencode.json`, unless you want
|
|
157
|
+
the MCP server; in that case use `options.mcp` instead.
|
|
158
|
+
|
|
159
|
+
**From v0.6.x.** Its own capture adapter is gone; capture is the official plugin's.
|
|
160
|
+
`mem-search` / `mem-timeline` / `mem-get-observations` are now `claude_mem_search` /
|
|
161
|
+
`claude_mem_timeline` / `claude_mem_get_observations`, and `mem-save` is `save_memory`.
|
|
162
|
+
Read-file history and opt-in semantic injection are not available.
|
|
127
163
|
|
|
128
|
-
|
|
164
|
+
**OpenCode 1.** The server part needs OpenCode 2. On OpenCode 1 use
|
|
165
|
+
`npx claude-mem install --ide opencode`; the sidebar still loads from `tui.json`.
|
|
129
166
|
|
|
130
167
|
## Troubleshooting
|
|
131
168
|
|
|
132
|
-
- **`(offline)`**: the Worker is not running
|
|
169
|
+
- **`(offline)`**: the Worker is not running and could not be started. Run
|
|
170
|
+
`npx claude-mem start`; if that command is missing, `npx claude-mem install`.
|
|
171
|
+
- **`(capture off)`**: nothing records this OpenCode's sessions. Expand the sidebar: the
|
|
172
|
+
first row says why (claude-mem not installed, the Worker did not start, the download
|
|
173
|
+
failed, or a `plugins/claude-mem.js` file that OpenCode has not loaded) and what to do.
|
|
174
|
+
The package looks again every minute.
|
|
133
175
|
- **Sidebar missing**: check `~/.config/opencode/cli.json` lists the package; the sidebar
|
|
134
176
|
needs OpenCode's `@opentui/solid` runtime and is skipped silently without it.
|
|
135
|
-
- **Duplicate observations**: two capture integrations are active. Remove
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
177
|
+
- **Duplicate observations**: two capture integrations are active. Remove
|
|
178
|
+
`~/.config/opencode/plugins/claude-mem.js` or this package, and disable any Claude Code
|
|
179
|
+
compatibility bridge for claude-mem. In Code Mode one call is recorded as `execute` plus
|
|
180
|
+
the tool it ran; that is one call, not a duplicate.
|
|
181
|
+
- **Behind a registry mirror**: the download uses `CLAUDE_MEM_NPM_REGISTRY`, then
|
|
182
|
+
`npm_config_registry`, then `https://registry.npmjs.org`. The mirror must be HTTPS (or on
|
|
183
|
+
this machine): it names both the tarball and its checksum, so it is trusted like the
|
|
184
|
+
registry itself.
|
|
139
185
|
|
|
140
186
|
## Development
|
|
141
187
|
|
|
142
188
|
```bash
|
|
143
189
|
bun install
|
|
144
|
-
bun run build # sidebar adapters + TUI bridge + declarations
|
|
190
|
+
bun run build # server part + sidebar adapters + TUI bridge + declarations
|
|
145
191
|
bun test # build first: export tests import dist artifacts
|
|
146
192
|
bun run typecheck && bun run lint && bun run fmt:check
|
|
193
|
+
bun src/test-fixtures/server-runtime.ts # installed opencode: download, capture, tools
|
|
194
|
+
bun src/test-fixtures/server-runtime.ts mcp # options.mcp registers the MCP server
|
|
195
|
+
bun src/test-fixtures/server-runtime.ts start # no Worker: the launcher is run (macOS/Linux)
|
|
196
|
+
bun src/test-fixtures/server-runtime.ts standalone # plugins/claude-mem.js present: one capture
|
|
147
197
|
bun src/test-fixtures/tui-runtime.ts # installed opencode: real PTY, render + mouse toggle
|
|
148
198
|
bun src/test-fixtures/tui-runtime.ts offline # offline state renders and toggles
|
|
149
199
|
bun --conditions=browser src/test-fixtures/cli-driver.ts render # OpenTUI component driver
|
|
150
200
|
```
|
|
151
201
|
|
|
152
|
-
The
|
|
153
|
-
the installed `opencode` in an isolated home with a fake Worker.
|
|
154
|
-
or memory database
|
|
202
|
+
The runtime checks pack the current build, serve it from a temporary local registry, and run
|
|
203
|
+
the installed `opencode` in an isolated home with a fake Worker and model. They never touch
|
|
204
|
+
your config or memory database. The server check downloads the real `claude-mem` release. Set `MEM_TUI_EVIDENCE_DIR` to keep the captured
|
|
155
205
|
collapsed, expanded, detail-dialog and re-collapsed frames.
|
|
156
206
|
|
|
157
207
|
## License
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface CaptureStatus {
|
|
2
|
+
/** `problem`: nothing is capturing this checkout's sessions. */
|
|
3
|
+
readonly state: 'ok' | 'problem';
|
|
4
|
+
/** Empty when there is nothing to tell the user. */
|
|
5
|
+
readonly message: string;
|
|
6
|
+
}
|
|
7
|
+
/** Holds the official claude-mem files this package downloads, and the status files. */
|
|
8
|
+
export declare function cacheDirectory(env?: Record<string, string | undefined>, home?: string): string;
|
|
9
|
+
/** Names the status files of one checkout, however its path was spelled. */
|
|
10
|
+
export declare function statusKey(directory: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* The newest status a running server part wrote for this checkout, preferring one from
|
|
13
|
+
* this process. Null when none is running: a status left by a package that was since
|
|
14
|
+
* removed from `opencode.json`, or by a process that exited, must not stay on screen.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readCaptureStatus(directory: string, dir?: string): CaptureStatus | null;
|
|
17
|
+
export interface CaptureStatusWriter {
|
|
18
|
+
readonly write: (status: CaptureStatus) => void;
|
|
19
|
+
/** Called when the server part is unloaded for the checkout while the host keeps running. */
|
|
20
|
+
readonly clear: () => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The status file of one `setup` call. Each has its own, so two servers on one checkout,
|
|
24
|
+
* or a reload that sets up before the old instance is disposed, never remove each other's.
|
|
25
|
+
*/
|
|
26
|
+
export declare function captureStatusWriter(directory: string, dir?: string): CaptureStatusWriter;
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,82 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
// src/
|
|
3
|
-
import {
|
|
2
|
+
// src/capture-status.ts
|
|
3
|
+
import { createHash, randomUUID } from "crypto";
|
|
4
|
+
import { join, resolve } from "path";
|
|
5
|
+
import {
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
statSync,
|
|
13
|
+
writeFileSync
|
|
14
|
+
} from "fs";
|
|
4
15
|
import { homedir } from "os";
|
|
5
|
-
|
|
16
|
+
function cacheDirectory(env = process.env, home = homedir()) {
|
|
17
|
+
return join(env["XDG_CACHE_HOME"]?.trim() || join(home, ".cache"), "opencode-claude-mem");
|
|
18
|
+
}
|
|
19
|
+
function statusKey(directory) {
|
|
20
|
+
let resolved = resolve(directory);
|
|
21
|
+
try {
|
|
22
|
+
resolved = realpathSync(directory);
|
|
23
|
+
} catch {}
|
|
24
|
+
return createHash("sha256").update(resolved).digest("hex").slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
function isAlive(pid) {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return error.code === "EPERM";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function parseStatus(file) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
37
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const { state, message, pid } = parsed;
|
|
41
|
+
return (state === "ok" || state === "problem") && typeof message === "string" && typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? { state, message, pid } : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function readCaptureStatus(directory, dir = cacheDirectory()) {
|
|
47
|
+
const folder = join(dir, "status");
|
|
48
|
+
const prefix = `${statusKey(directory)}.`;
|
|
49
|
+
let names = [];
|
|
50
|
+
try {
|
|
51
|
+
names = readdirSync(folder).filter((name) => name.startsWith(prefix) && name.endsWith(".json"));
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
let best = null;
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
const file = join(folder, name);
|
|
58
|
+
const parsed = parseStatus(file);
|
|
59
|
+
if (!parsed || !isAlive(parsed.pid)) {
|
|
60
|
+
rmSync(file, { force: true });
|
|
61
|
+
} else {
|
|
62
|
+
const own = parsed.pid === process.pid;
|
|
63
|
+
const at = statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0;
|
|
64
|
+
if (!best || own && !best.own || own === best.own && at > best.at) {
|
|
65
|
+
best = { status: { state: parsed.state, message: parsed.message }, own, at };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return best?.status ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/worker-client.ts
|
|
73
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
74
|
+
import { join as join2, resolve as resolve2, win32 } from "path";
|
|
75
|
+
import { createHash as createHash2 } from "crypto";
|
|
76
|
+
import { homedir as homedir2 } from "os";
|
|
6
77
|
var DEFAULT_WORKER_HOST = "127.0.0.1";
|
|
7
|
-
var
|
|
8
|
-
var
|
|
9
|
-
var LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
78
|
+
var WORKER_PORT_BASE = 37700;
|
|
79
|
+
var TOKEN_HEADER = "x-claude-mem-token";
|
|
10
80
|
var PROJECT_COUNTS_SQL = `SELECT
|
|
11
81
|
(SELECT COUNT(*) FROM observations
|
|
12
82
|
WHERE project = ?1 COLLATE NOCASE OR merged_into_project = ?1 COLLATE NOCASE) AS observations,
|
|
@@ -36,25 +106,62 @@ function parseSettings(rawSettings) {
|
|
|
36
106
|
return {};
|
|
37
107
|
}
|
|
38
108
|
try {
|
|
39
|
-
const parsed = JSON.parse(rawSettings);
|
|
40
|
-
|
|
109
|
+
const parsed = JSON.parse(rawSettings.replace(/^\uFEFF/, ""));
|
|
110
|
+
if (!isRecord(parsed)) {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
const nested = parsed["env"];
|
|
114
|
+
const source = isRecord(nested) && Object.keys(nested).some((key) => key.startsWith("CLAUDE_MEM_")) ? nested : parsed;
|
|
115
|
+
return Object.fromEntries(Object.entries(source).map(([key, value]) => [
|
|
116
|
+
key,
|
|
117
|
+
typeof value === "number" || typeof value === "boolean" ? String(value) : value
|
|
118
|
+
]));
|
|
41
119
|
} catch {
|
|
42
120
|
return {};
|
|
43
121
|
}
|
|
44
122
|
}
|
|
45
|
-
function
|
|
123
|
+
function dataDirectory(env, home) {
|
|
124
|
+
const dir = readString(env["CLAUDE_MEM_DATA_DIR"]) ?? join2(home, ".claude-mem");
|
|
125
|
+
return dir.replace(/^~(?=$|[\\/])/, home);
|
|
126
|
+
}
|
|
127
|
+
function defaultWorkerPort(dataDir, platform = process.platform, uid = typeof process.getuid === "function" ? process.getuid() : null) {
|
|
128
|
+
if (uid !== null) {
|
|
129
|
+
return WORKER_PORT_BASE + (uid % 100 + 100) % 100;
|
|
130
|
+
}
|
|
131
|
+
const normalized = platform === "win32" ? win32.resolve(dataDir).toLowerCase() : resolve2(dataDir);
|
|
132
|
+
const digest = createHash2("sha256").update(normalized, "utf8").digest();
|
|
133
|
+
return WORKER_PORT_BASE + digest.readUInt32BE(0) % 100;
|
|
134
|
+
}
|
|
135
|
+
function parseWorkerEndpoint(env, rawSettings, fallbackPort = defaultWorkerPort(dataDirectory(env, homedir2()))) {
|
|
46
136
|
const settings = parseSettings(rawSettings);
|
|
47
|
-
const
|
|
48
|
-
const
|
|
137
|
+
const configured = readString(env["CLAUDE_MEM_WORKER_HOST"]) ?? readString(settings["CLAUDE_MEM_WORKER_HOST"]) ?? DEFAULT_WORKER_HOST;
|
|
138
|
+
const host = configured === "localhost" ? DEFAULT_WORKER_HOST : configured;
|
|
139
|
+
const port = readPort(env["CLAUDE_MEM_WORKER_PORT"]) ?? readPort(settings["CLAUDE_MEM_WORKER_PORT"]) ?? fallbackPort;
|
|
49
140
|
return { host, port };
|
|
50
141
|
}
|
|
142
|
+
function isLoopbackHost(host) {
|
|
143
|
+
const name = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
144
|
+
return name === "localhost" || name.endsWith(".localhost") || name === "::1" || /^127(?:\.\d{1,3}){1,3}$/.test(name);
|
|
145
|
+
}
|
|
146
|
+
function workerUrl(endpoint) {
|
|
147
|
+
const host = endpoint.host.includes(":") && !endpoint.host.startsWith("[") ? `[${endpoint.host}]` : endpoint.host;
|
|
148
|
+
return `http://${host}:${endpoint.port}`;
|
|
149
|
+
}
|
|
150
|
+
function tokenHeaders(baseUrl, rawSettings) {
|
|
151
|
+
const token = readString(parseSettings(rawSettings)["CLAUDE_MEM_LOCAL_API_TOKEN"]);
|
|
152
|
+
try {
|
|
153
|
+
return token && isLoopbackHost(new URL(baseUrl).hostname) ? { [TOKEN_HEADER]: token } : {};
|
|
154
|
+
} catch {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
51
158
|
function resolveDatabasePath(env, rawSettings, home) {
|
|
52
|
-
if (!
|
|
159
|
+
if (!isLoopbackHost(parseWorkerEndpoint(env, rawSettings).host)) {
|
|
53
160
|
return null;
|
|
54
161
|
}
|
|
55
162
|
const settings = parseSettings(rawSettings);
|
|
56
|
-
const dir = readString(env["CLAUDE_MEM_DATA_DIR"]) ?? readString(settings["CLAUDE_MEM_DATA_DIR"]) ??
|
|
57
|
-
return
|
|
163
|
+
const dir = readString(env["CLAUDE_MEM_DATA_DIR"]) ?? readString(settings["CLAUDE_MEM_DATA_DIR"]) ?? join2(home, ".claude-mem");
|
|
164
|
+
return join2(dir.replace(/^~(?=$|[\\/])/, home), "claude-mem.db");
|
|
58
165
|
}
|
|
59
166
|
function readCount(value) {
|
|
60
167
|
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
@@ -88,27 +195,45 @@ function readEpoch(item) {
|
|
|
88
195
|
}
|
|
89
196
|
function readSettingsRaw() {
|
|
90
197
|
try {
|
|
91
|
-
return
|
|
198
|
+
return readFileSync2(join2(dataDirectory(process.env, homedir2()), "settings.json"), "utf8");
|
|
92
199
|
} catch {
|
|
93
200
|
return null;
|
|
94
201
|
}
|
|
95
202
|
}
|
|
203
|
+
function readWorkerSetting(key) {
|
|
204
|
+
return readString(process.env[key]) ?? readString(parseSettings(readSettingsRaw())[key]);
|
|
205
|
+
}
|
|
206
|
+
function getWorkerEndpoint() {
|
|
207
|
+
return parseWorkerEndpoint(process.env, readSettingsRaw());
|
|
208
|
+
}
|
|
96
209
|
function getWorkerBaseUrl() {
|
|
97
|
-
|
|
98
|
-
|
|
210
|
+
return workerUrl(getWorkerEndpoint());
|
|
211
|
+
}
|
|
212
|
+
function workerHeaders(baseUrl = getWorkerBaseUrl()) {
|
|
213
|
+
return tokenHeaders(baseUrl, readSettingsRaw());
|
|
99
214
|
}
|
|
100
215
|
|
|
101
216
|
class WorkerClient {
|
|
102
217
|
static BASE_URL = getWorkerBaseUrl();
|
|
103
|
-
static
|
|
218
|
+
static SUPPORTED = !readWorkerSetting("CLAUDE_MEM_WORKER_URL");
|
|
219
|
+
static DATABASE_PATH = resolveDatabasePath(process.env, readSettingsRaw(), homedir2());
|
|
220
|
+
static get(path) {
|
|
221
|
+
if (!this.SUPPORTED) {
|
|
222
|
+
return Promise.reject(new Error("CLAUDE_MEM_WORKER_URL is not supported"));
|
|
223
|
+
}
|
|
224
|
+
return fetch(`${this.BASE_URL}${path}`, {
|
|
225
|
+
headers: workerHeaders(this.BASE_URL),
|
|
226
|
+
redirect: "error"
|
|
227
|
+
});
|
|
228
|
+
}
|
|
104
229
|
static getProjectCounts(project) {
|
|
105
|
-
return this.DATABASE_PATH ? readProjectCounts(this.DATABASE_PATH, project) : Promise.resolve(null);
|
|
230
|
+
return this.DATABASE_PATH && this.SUPPORTED ? readProjectCounts(this.DATABASE_PATH, project) : Promise.resolve(null);
|
|
106
231
|
}
|
|
107
232
|
static async getStats() {
|
|
108
233
|
try {
|
|
109
234
|
const [statsRes, processingRes] = await Promise.all([
|
|
110
|
-
|
|
111
|
-
|
|
235
|
+
this.get(`/api/stats`),
|
|
236
|
+
this.get(`/api/processing-status`)
|
|
112
237
|
]);
|
|
113
238
|
if (!statsRes.ok) {
|
|
114
239
|
return null;
|
|
@@ -123,7 +248,7 @@ class WorkerClient {
|
|
|
123
248
|
static async getRecentSummaries(project, limit) {
|
|
124
249
|
try {
|
|
125
250
|
const params = new URLSearchParams({ project, limit: String(limit) });
|
|
126
|
-
const response = await
|
|
251
|
+
const response = await this.get(`/api/summaries?${params.toString()}`);
|
|
127
252
|
if (!response.ok) {
|
|
128
253
|
return [];
|
|
129
254
|
}
|
|
@@ -145,7 +270,7 @@ class WorkerClient {
|
|
|
145
270
|
static async getRecentObservations(project, limit) {
|
|
146
271
|
try {
|
|
147
272
|
const params = new URLSearchParams({ project, limit: String(limit) });
|
|
148
|
-
const response = await
|
|
273
|
+
const response = await this.get(`/api/observations?${params.toString()}`);
|
|
149
274
|
if (!response.ok) {
|
|
150
275
|
return [];
|
|
151
276
|
}
|
|
@@ -168,7 +293,7 @@ class WorkerClient {
|
|
|
168
293
|
static async getDetail(kind, id) {
|
|
169
294
|
try {
|
|
170
295
|
const path = kind === "observation" ? "observation" : "session";
|
|
171
|
-
const response = await
|
|
296
|
+
const response = await this.get(`/api/${path}/${id}`);
|
|
172
297
|
if (!response.ok) {
|
|
173
298
|
return null;
|
|
174
299
|
}
|
|
@@ -252,6 +377,9 @@ function summaryLine(view) {
|
|
|
252
377
|
if (!view.healthy) {
|
|
253
378
|
return { label: "(offline)", tone: "err" };
|
|
254
379
|
}
|
|
380
|
+
if (view.capture?.state === "problem") {
|
|
381
|
+
return { label: "(capture off)", tone: "err" };
|
|
382
|
+
}
|
|
255
383
|
if (view.isProcessing || (view.queueDepth ?? 0) > 0) {
|
|
256
384
|
return { label: `(queue ${view.queueDepth ?? 0})`, tone: "warn" };
|
|
257
385
|
}
|
|
@@ -279,11 +407,21 @@ function recentRow(icon, iconColor, title, age, theme, onSelect) {
|
|
|
279
407
|
]);
|
|
280
408
|
}
|
|
281
409
|
function expandedRows(view, theme, onSelect) {
|
|
410
|
+
const notice = view.capture?.message ? [
|
|
411
|
+
box({ flexDirection: "row", gap: 1 }, [
|
|
412
|
+
text({ fg: theme.warning, flexShrink: 0 }, "!"),
|
|
413
|
+
text({
|
|
414
|
+
fg: view.capture.state === "problem" ? theme.error : theme.warning,
|
|
415
|
+
wrapMode: "word"
|
|
416
|
+
}, view.capture.message)
|
|
417
|
+
])
|
|
418
|
+
] : [];
|
|
282
419
|
if (!view.healthy) {
|
|
283
|
-
return [bulletRow("\u2022", theme.error, "worker offline", theme)];
|
|
420
|
+
return [bulletRow("\u2022", theme.error, "worker offline", theme), ...notice];
|
|
284
421
|
}
|
|
285
422
|
const counts = `obs ${formatCount(view.observations)} \xB7 sum ${formatCount(view.summaries)} \xB7 ses ${formatCount(view.sessions)}`;
|
|
286
423
|
const rows = [
|
|
424
|
+
...notice,
|
|
287
425
|
bulletRow("\u2022", theme.success, view.scope === "all" ? `all \xB7 ${counts}` : counts, theme)
|
|
288
426
|
];
|
|
289
427
|
if (view.scope === "project") {
|
|
@@ -330,14 +468,16 @@ function viewKey(view) {
|
|
|
330
468
|
function readCount2(value) {
|
|
331
469
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
332
470
|
}
|
|
333
|
-
async function readMemView(project, collapsed) {
|
|
471
|
+
async function readMemView(project, collapsed, directory = "") {
|
|
334
472
|
const [stats, counts] = await Promise.all([
|
|
335
473
|
WorkerClient.getStats(),
|
|
336
474
|
WorkerClient.getProjectCounts(project)
|
|
337
475
|
]);
|
|
476
|
+
const capture = directory ? readCaptureStatus(directory) : null;
|
|
338
477
|
if (!stats) {
|
|
339
478
|
return {
|
|
340
479
|
healthy: false,
|
|
480
|
+
capture,
|
|
341
481
|
project,
|
|
342
482
|
scope: "all",
|
|
343
483
|
totalObservations: null,
|
|
@@ -358,6 +498,7 @@ async function readMemView(project, collapsed) {
|
|
|
358
498
|
const totalObservations = readCount2(stats.database?.observations);
|
|
359
499
|
return {
|
|
360
500
|
healthy: true,
|
|
501
|
+
capture,
|
|
361
502
|
project,
|
|
362
503
|
scope: counts ? "project" : "all",
|
|
363
504
|
totalObservations,
|
|
@@ -446,6 +587,7 @@ var REFRESH_EVENTS = ["session.created", "session.execution.succeeded"];
|
|
|
446
587
|
function emptyView(project) {
|
|
447
588
|
return {
|
|
448
589
|
healthy: false,
|
|
590
|
+
capture: null,
|
|
449
591
|
project,
|
|
450
592
|
scope: "all",
|
|
451
593
|
totalObservations: null,
|
|
@@ -466,7 +608,8 @@ var cli_default = define({
|
|
|
466
608
|
if (!solid) {
|
|
467
609
|
return;
|
|
468
610
|
}
|
|
469
|
-
const
|
|
611
|
+
const directory = String(ctx.location?.directory ?? process.cwd());
|
|
612
|
+
const project = directory.split(/[\\/]/).filter(Boolean).findLast(Boolean) ?? "unknown-project";
|
|
470
613
|
const [state, setState] = ctx.storage.memory("claude-mem.sidebar", {
|
|
471
614
|
initial: { collapsed: true, view: emptyView(project) }
|
|
472
615
|
});
|
|
@@ -487,7 +630,7 @@ var cli_default = define({
|
|
|
487
630
|
do {
|
|
488
631
|
refreshPending = false;
|
|
489
632
|
const { collapsed } = state;
|
|
490
|
-
const next = await readMemView(project, collapsed);
|
|
633
|
+
const next = await readMemView(project, collapsed, directory);
|
|
491
634
|
if (disposed) {
|
|
492
635
|
return;
|
|
493
636
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface OfficialFiles {
|
|
2
|
+
readonly version: string;
|
|
3
|
+
/** The official OpenCode plugin: default export `{ id, server, setup }`. */
|
|
4
|
+
readonly plugin: string;
|
|
5
|
+
readonly mcpServer: string;
|
|
6
|
+
}
|
|
7
|
+
export type SyncResult = {
|
|
8
|
+
readonly ok: true;
|
|
9
|
+
readonly files: OfficialFiles;
|
|
10
|
+
} | {
|
|
11
|
+
readonly ok: false;
|
|
12
|
+
readonly reason: string;
|
|
13
|
+
};
|
|
14
|
+
export interface SyncOptions {
|
|
15
|
+
/** Directory holding one sub-directory per version. */
|
|
16
|
+
readonly root?: string;
|
|
17
|
+
readonly registry?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function officialRoot(): string;
|
|
20
|
+
/** `CLAUDE_MEM_NPM_REGISTRY`, then npm's own registry setting, then the public registry. */
|
|
21
|
+
export declare function npmRegistry(env?: Record<string, string | undefined>): string;
|
|
22
|
+
/**
|
|
23
|
+
* Reads the named regular files out of a gzipped tar. npm tarballs are ustar; a path longer
|
|
24
|
+
* than the name field uses the prefix field, and anything else (pax records, directories)
|
|
25
|
+
* is skipped by its size. No `tar` binary is needed, which Windows may not have on PATH.
|
|
26
|
+
*/
|
|
27
|
+
export declare function extractTarFiles(archive: Uint8Array, members: readonly string[]): Map<string, Uint8Array>;
|
|
28
|
+
/** The official files for `version`, downloading them once when they are not on disk. */
|
|
29
|
+
export declare function syncOfficial(version: string, options?: SyncOptions): Promise<SyncResult>;
|