claude-mem-lite 3.92.0 → 3.93.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -0
- package/hash-utils.mjs +12 -0
- package/hook-llm.mjs +40 -9
- package/hook-optimize.mjs +4 -1
- package/hook-shared.mjs +6 -1
- package/hook-update.mjs +4 -4
- package/hook.mjs +45 -31
- package/install.mjs +24 -16
- package/lib/export-columns.mjs +45 -0
- package/lib/get-core.mjs +17 -0
- package/lib/hook-stdin.mjs +140 -0
- package/lib/plugin-key.mjs +44 -0
- package/lib/registry-core.mjs +48 -0
- package/lib/resolve-data-dir.mjs +68 -0
- package/mem-cli.mjs +22 -38
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/registry-recommend.mjs +2 -2
- package/schema.mjs +18 -1
- package/scripts/binding-probe-cli.mjs +1 -1
- package/scripts/hook-launcher.mjs +32 -12
- package/scripts/launch.mjs +1 -1
- package/scripts/post-tool-recall.js +7 -4
- package/scripts/pre-agent-inject.js +12 -4
- package/scripts/pre-skill-bridge.js +9 -5
- package/scripts/pre-tool-recall.js +35 -7
- package/scripts/user-prompt-search.js +24 -24
- package/server.mjs +28 -46
- package/source-files.mjs +2 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// lib/hook-stdin.mjs — one bounded stdin reader for every hook entry point.
|
|
2
|
+
//
|
|
3
|
+
// Audit 2026-09-02 P1-9. Six hook processes read the host's JSON payload off stdin and they
|
|
4
|
+
// did it six ways, in two groups:
|
|
5
|
+
//
|
|
6
|
+
// BOUNDED, three different calibers: hook.mjs (3 s / 256 KB / `{text, truncated}`),
|
|
7
|
+
// scripts/user-prompt-search.js (2 s / 64 KB / bare string), scripts/pre-agent-inject.js
|
|
8
|
+
// (1.5 s / 262144 / never rejects).
|
|
9
|
+
//
|
|
10
|
+
// UNBOUNDED, three copies of `for await (const chunk of process.stdin) input += chunk`:
|
|
11
|
+
// pre-tool-recall.js, pre-skill-bridge.js, post-tool-recall.js. No cap and no timeout.
|
|
12
|
+
// That matters most on `PreToolUse:Write`, whose `tool_input.content` is the ENTIRE file
|
|
13
|
+
// being written: writing a multi-megabyte file made pre-tool-recall buffer all of it and
|
|
14
|
+
// `JSON.parse` all of it, to read `file_path`. The only bound was the host's own 3 s
|
|
15
|
+
// fail-open — i.e. the hook silently did nothing, which is indistinguishable from the
|
|
16
|
+
// hook having nothing to say.
|
|
17
|
+
//
|
|
18
|
+
// ZERO DEPENDENCIES, deliberately: the importers are latency-sensitive hook processes whose
|
|
19
|
+
// whole cost is module loading, and one of them (`pre-agent-inject.js`) exists to be the
|
|
20
|
+
// cheap default-off path. Nothing here imports from the repo.
|
|
21
|
+
//
|
|
22
|
+
// The calibers are NOT unified. Each caller passes its own, because those numbers are
|
|
23
|
+
// decisions about different payloads — a user prompt is not a tool response — and quietly
|
|
24
|
+
// giving one caller another's limits is a behaviour change wearing a refactor's clothes.
|
|
25
|
+
// What is shared is the mechanism: cap, timer, teardown, and the guarantee that the promise
|
|
26
|
+
// settles exactly once.
|
|
27
|
+
|
|
28
|
+
/** Default cap: matches the host's own full-payload tier. */
|
|
29
|
+
export const DEFAULT_STDIN_MAX_BYTES = 256 * 1024;
|
|
30
|
+
/**
|
|
31
|
+
* Cap for a payload that carries a WHOLE FILE: `PreToolUse`/`PostToolUse` on `Write` puts the
|
|
32
|
+
* entire file being written in `tool_input.content`. Exported and named rather than typed at
|
|
33
|
+
* each call site because two entry points see that same payload class, and two hand-written
|
|
34
|
+
* numbers for one class is the drift shape this module exists to remove — this is NOT the
|
|
35
|
+
* calibers being unified, which the note above rules out: a caller with a different payload
|
|
36
|
+
* class still passes its own.
|
|
37
|
+
*
|
|
38
|
+
* Sized as a MEMORY backstop, not a functional gate. The v3.93.0 pre-tag review measured the
|
|
39
|
+
* 256 KB default silently dropping `pretool` recall for any Write over that size, on the
|
|
40
|
+
* reasoning that a truncated payload matched what the host's 3 s fail-open already did —
|
|
41
|
+
* `JSON.parse` is 2.99 ms at 5 MB and 10.8 ms at 10 MB, so it did not.
|
|
42
|
+
*/
|
|
43
|
+
export const TOOL_INPUT_FILE_MAX_BYTES = 8 * 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Recover the scalar fields a hook needs from a TRUNCATED JSON payload prefix.
|
|
47
|
+
*
|
|
48
|
+
* Mirrors `hook.mjs handlePostToolUse`'s salvage of `tool_name`. Returns null when the
|
|
49
|
+
* prefix does not carry a `file_path` — the caller then behaves exactly as it did before
|
|
50
|
+
* salvage existed, so this can only add recalls, never remove one.
|
|
51
|
+
*
|
|
52
|
+
* Lives here rather than in the one script that calls it because it is about a payload this
|
|
53
|
+
* module bounded: the cap and the recovery from the cap are one decision, and a helper that
|
|
54
|
+
* only the truncating module's caller can reach is a helper nothing can unit-test.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately regex over the prefix rather than a streaming parser: the fields are flat
|
|
57
|
+
* string scalars, and a partial-JSON parser is a dependency and a failure mode for a path
|
|
58
|
+
* whose entire budget is the host's fail-open window.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} prefix Truncated payload text.
|
|
61
|
+
* @returns {{filePath: string, sessionId: string|null, toolName: string|null} | null}
|
|
62
|
+
*/
|
|
63
|
+
export function salvageTruncatedHookEvent(prefix) {
|
|
64
|
+
// Bounded capture. Unbounded, an 8 MB prefix whose `file_path` string is never closed
|
|
65
|
+
// makes V8 exceed its regexp backtrack limit and THROW RangeError — measured at the cap by
|
|
66
|
+
// the v3.93.0 post-release review. The throw escapes the caller's catch into its top-level
|
|
67
|
+
// one, which still exits 0 but writes a `pre-recall:top` telemetry row: the same
|
|
68
|
+
// hook-error noise the caller split `pre-recall:json` away from. 4096 is far above any
|
|
69
|
+
// real path (PATH_MAX is 4096 on Linux, 1024 on macOS), so no reachable payload is lost.
|
|
70
|
+
const fp = prefix.match(/"file_path"\s*:\s*"((?:[^"\\]|\\.){0,4096})"/);
|
|
71
|
+
if (!fp) return null;
|
|
72
|
+
let filePath;
|
|
73
|
+
// The captured group is still JSON-escaped (Windows paths arrive as `C:\\x`).
|
|
74
|
+
try { filePath = JSON.parse(`"${fp[1]}"`); } catch { return null; }
|
|
75
|
+
if (!filePath) return null;
|
|
76
|
+
const sid = prefix.match(/"session_id"\s*:\s*"([^"\\]*)"/);
|
|
77
|
+
const tn = prefix.match(/"tool_name"\s*:\s*"([^"\\]*)"/);
|
|
78
|
+
return { filePath, sessionId: sid ? sid[1] : null, toolName: tn ? tn[1] : null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
/** Default timeout: under the host's ~3 s fail-open, so we decide rather than get killed. */
|
|
83
|
+
export const DEFAULT_STDIN_TIMEOUT_MS = 3000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read the hook payload from a stream, bounded in both bytes and time.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} [opts]
|
|
89
|
+
* @param {number} [opts.timeoutMs] Give up after this long.
|
|
90
|
+
* @param {number} [opts.maxBytes] Stop reading past this many characters.
|
|
91
|
+
* @param {boolean} [opts.rejectOnTimeout=false] `true` reproduces hook.mjs's contract, where
|
|
92
|
+
* a timeout drops the event rather than acting on a partial payload. `false` resolves with
|
|
93
|
+
* whatever arrived and `timedOut: true` — the right call for a path that is advisory and
|
|
94
|
+
* must never throw into a host hook.
|
|
95
|
+
* @param {NodeJS.ReadableStream} [opts.stream=process.stdin] Injectable for tests; nothing
|
|
96
|
+
* else about this module is observable without it.
|
|
97
|
+
* @returns {Promise<{text: string, truncated: boolean, timedOut: boolean}>}
|
|
98
|
+
*/
|
|
99
|
+
export function readHookStdin({
|
|
100
|
+
timeoutMs = DEFAULT_STDIN_TIMEOUT_MS,
|
|
101
|
+
maxBytes = DEFAULT_STDIN_MAX_BYTES,
|
|
102
|
+
rejectOnTimeout = false,
|
|
103
|
+
stream = process.stdin,
|
|
104
|
+
} = {}) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
let data = '';
|
|
107
|
+
// Every path below routes through settle(), and settle() is a no-op after the first
|
|
108
|
+
// call. The hand-written copies each had to get this right on four separate paths
|
|
109
|
+
// (cap hit, end, error, timer) and the `.destroy()` in the cap branch can itself emit
|
|
110
|
+
// 'error' — so "resolve twice" and "resolve then reject" were both reachable shapes.
|
|
111
|
+
let done = false;
|
|
112
|
+
const settle = (fn, arg) => {
|
|
113
|
+
if (done) return;
|
|
114
|
+
done = true;
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
try { stream.destroy(); } catch { /* already gone */ }
|
|
117
|
+
fn(arg);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
if (rejectOnTimeout) settle(reject, new Error('timeout'));
|
|
122
|
+
else settle(resolve, { text: data, truncated: false, timedOut: true });
|
|
123
|
+
}, timeoutMs);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
stream.setEncoding('utf8');
|
|
127
|
+
stream.on('data', (chunk) => {
|
|
128
|
+
data += chunk;
|
|
129
|
+
if (data.length > maxBytes) {
|
|
130
|
+
settle(resolve, { text: data.slice(0, maxBytes), truncated: true, timedOut: false });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
stream.on('end', () => settle(resolve, { text: data, truncated: false, timedOut: false }));
|
|
134
|
+
stream.on('error', (err) => settle(reject, err));
|
|
135
|
+
stream.resume();
|
|
136
|
+
} catch (err) {
|
|
137
|
+
settle(reject, err);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// lib/plugin-key.mjs — the plugin's identity in Claude Code's settings, and the one
|
|
2
|
+
// predicate that reads it.
|
|
3
|
+
//
|
|
4
|
+
// Audit 2026-09-02 P2-7. `PLUGIN_KEY` and `isPluginExplicitlyDisabled` shipped twice in JS —
|
|
5
|
+
// `hook.mjs` (which exits 0 on every event when the plugin is off) and `install.mjs` (which
|
|
6
|
+
// reports it in `doctor`/`status` and branches on it during cleanup) — plus a third,
|
|
7
|
+
// deliberate copy in `scripts/post-tool-use.sh`.
|
|
8
|
+
//
|
|
9
|
+
// What makes the duplication load-bearing rather than untidy: `install.mjs` writes hooks
|
|
10
|
+
// DIRECTLY into `~/.claude/settings.json`, so turning the plugin off in the Claude UI does
|
|
11
|
+
// not remove them. They keep firing, and this predicate is the only thing that makes
|
|
12
|
+
// "disabled" mean disabled. A key that drifts on one side leaves the user with a plugin they
|
|
13
|
+
// switched off and a hook set that never noticed.
|
|
14
|
+
//
|
|
15
|
+
// ZERO DEPENDENCIES: `hook.mjs` imports this at module scope on the hottest path in the
|
|
16
|
+
// system, and the check runs before anything else can.
|
|
17
|
+
//
|
|
18
|
+
// The shell copy in `scripts/post-tool-use.sh` STAYS a copy and is not a defect. That path
|
|
19
|
+
// is the ~5 ms bash pre-filter whose entire purpose is never reaching `node`, so it cannot
|
|
20
|
+
// import anything; its own header documents the parity requirement and
|
|
21
|
+
// `tests/post-tool-use-disabled.test.mjs` pins it. Two homes with a tested bridge is a
|
|
22
|
+
// different thing from two homes and a comment.
|
|
23
|
+
|
|
24
|
+
/** Marketplace this plugin is published under. */
|
|
25
|
+
export const MARKETPLACE_KEY = 'sdsrss';
|
|
26
|
+
|
|
27
|
+
/** The key Claude Code writes under `enabledPlugins` in `~/.claude/settings.json`. */
|
|
28
|
+
export const PLUGIN_KEY = `claude-mem-lite@${MARKETPLACE_KEY}`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether the user has EXPLICITLY switched the plugin off.
|
|
32
|
+
*
|
|
33
|
+
* Strict `=== false`, not falsiness: an absent key means "never installed via the
|
|
34
|
+
* marketplace" (an npm-only install, which must keep working) and `true` means enabled.
|
|
35
|
+
* Only an explicit `false` is a decision to honour — treating absent as disabled would
|
|
36
|
+
* silently kill every direct-install user.
|
|
37
|
+
*
|
|
38
|
+
* @param {object|null|undefined} settings Parsed ~/.claude/settings.json, or nullish when
|
|
39
|
+
* it is missing or unreadable — which reads as NOT disabled, so an unparseable settings
|
|
40
|
+
* file fails open rather than disabling the plugin the user is trying to use.
|
|
41
|
+
*/
|
|
42
|
+
export function isPluginExplicitlyDisabled(settings) {
|
|
43
|
+
return settings?.enabledPlugins?.[PLUGIN_KEY] === false;
|
|
44
|
+
}
|
package/lib/registry-core.mjs
CHANGED
|
@@ -208,3 +208,51 @@ export async function enrichNamedResource(db, name, { confineTo, enrichResource,
|
|
|
208
208
|
const { status, error } = await enrichResourceRow(db, row, { confineTo, enrichResource, env });
|
|
209
209
|
return { status, name: row.name, error };
|
|
210
210
|
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* How a registry search hit should be invoked, and the path worth showing for it.
|
|
214
|
+
*
|
|
215
|
+
* Audit 2026-09-02 P2-6: this ran to ~15 lines in `mem-cli.mjs cmdRegistry` and again in
|
|
216
|
+
* `server.mjs mem_registry`, and the copies had already diverged on `portablePath`:
|
|
217
|
+
*
|
|
218
|
+
* MCP `isManaged ? toPortable(local_path) : ''`
|
|
219
|
+
* CLI `isManaged && local_path.startsWith(home) ? '~'+… : (local_path || '')`
|
|
220
|
+
*
|
|
221
|
+
* For a NON-managed resource the CLI therefore printed `Path: /home/<user>/…` — an absolute
|
|
222
|
+
* path, un-tilde'd, for a row whose `Use:` line is `Skill("x")` or `mem_use(name=…)` and
|
|
223
|
+
* never mentions the path at all. MCP printed nothing. The MCP behaviour is the one kept:
|
|
224
|
+
* the line carried no action for the reader and spelled out a home directory to do it.
|
|
225
|
+
*
|
|
226
|
+
* Returns DATA, not a rendered line. The two faces genuinely differ in dialect — the CLI
|
|
227
|
+
* indents four spaces and prints through `out()`, MCP indents two and emits Markdown bold —
|
|
228
|
+
* and collapsing that would replace a real duplicate with a formatting flag.
|
|
229
|
+
*
|
|
230
|
+
* @param {{name:string,type:string,local_path?:string,invocation_name?:string}} r
|
|
231
|
+
* @param {{home:string, managedPrefix:string}} ctx `managedPrefix` is `<dataDir>/managed/`,
|
|
232
|
+
* already including the trailing separator, so the caller owns CLAUDE_MEM_DIR resolution.
|
|
233
|
+
* @returns {{isManaged:boolean, portablePath:string, howToUse:string}}
|
|
234
|
+
*/
|
|
235
|
+
export function resourceUseHint(r, { home, managedPrefix }) {
|
|
236
|
+
const isManaged = Boolean(r.local_path && r.local_path.includes(managedPrefix));
|
|
237
|
+
const portablePath = isManaged
|
|
238
|
+
? (r.local_path.startsWith(home) ? '~' + r.local_path.slice(home.length) : r.local_path)
|
|
239
|
+
: '';
|
|
240
|
+
const agentArg = r.type === 'agent' ? ', type="agent"' : '';
|
|
241
|
+
|
|
242
|
+
let howToUse;
|
|
243
|
+
if (isManaged) {
|
|
244
|
+
// Managed: Read(path) or mem_use — Skill() does not resolve managed resources.
|
|
245
|
+
// Agents always carry a complete .md path; only skills can be a directory, which
|
|
246
|
+
// resolves to its SKILL.md.
|
|
247
|
+
const resolvedPath = portablePath.endsWith('.md') ? portablePath : `${portablePath}/SKILL.md`;
|
|
248
|
+
howToUse = `Read("${resolvedPath}") or mem_use(name="${r.name}"${agentArg})`;
|
|
249
|
+
} else if (r.invocation_name) {
|
|
250
|
+
// Native plugin/user resource: invoke by its full invocation name.
|
|
251
|
+
howToUse = r.type === 'skill'
|
|
252
|
+
? `Skill("${r.invocation_name}")`
|
|
253
|
+
: `Agent(subagent_type="${r.invocation_name}")`;
|
|
254
|
+
} else {
|
|
255
|
+
howToUse = `mem_use(name="${r.name}"${agentArg})`;
|
|
256
|
+
}
|
|
257
|
+
return { isManaged, portablePath, howToUse };
|
|
258
|
+
}
|
package/lib/resolve-data-dir.mjs
CHANGED
|
@@ -82,3 +82,71 @@ export function resolveDataDir(raw) {
|
|
|
82
82
|
}
|
|
83
83
|
return containInTests(raw);
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The runtime directory: episode buffers, per-session markers, cooldowns, hook telemetry.
|
|
88
|
+
*
|
|
89
|
+
* Audit 2026-09-02 P1-14. `CLAUDE_MEM_RUNTIME_DIR` was honoured by exactly the five
|
|
90
|
+
* standalone hook scripts and `scripts/hook-launcher.mjs`, and IGNORED by `hook-shared.mjs`
|
|
91
|
+
* (which `hook.mjs`, `server.mjs`, `hook-context.mjs` and `hook-episode.mjs` all take it
|
|
92
|
+
* from) and by `hook-optimize.mjs`. So setting it did not relocate the runtime dir — it
|
|
93
|
+
* SPLIT it: the `fyi` and `pretool` faces wrote markers to the override while `ups` and
|
|
94
|
+
* `keyctx` read them from the real one. A harness pointing the system at an isolated
|
|
95
|
+
* runtime got a half-isolated one, silently, with no error and no empty directory to
|
|
96
|
+
* notice.
|
|
97
|
+
*
|
|
98
|
+
* The audit's own suggestion was to delete the variable from the five scripts. Measured
|
|
99
|
+
* before copying it: it is load-bearing in ten test files, in `hook-launcher.mjs`, and in
|
|
100
|
+
* `experiment/lib/arms.mjs`, which uses it to keep an experiment arm off the real state.
|
|
101
|
+
* Removing it would have cost all of that to fix a split that one shared resolver fixes.
|
|
102
|
+
* The defect was never that the knob exists — it is that the rule for reading it had eight
|
|
103
|
+
* hand-written homes and two modules that had never heard of it.
|
|
104
|
+
*
|
|
105
|
+
* WHAT BELONGS BEHIND THIS RESOLVER, AND WHAT DELIBERATELY DOES NOT. The v3.93.0 pre-tag
|
|
106
|
+
* review found the first cut of P1-14 half-applied — `scripts/user-prompt-search.js`
|
|
107
|
+
* resolved its RUNTIME_DIR here and then built its cross-hook marker from
|
|
108
|
+
* `join(DB_DIR, 'runtime')` anyway, so under the override the `fyi` face wrote the SHARED
|
|
109
|
+
* marker to one directory while `pretool` wrote it to another. Four subsystems were split
|
|
110
|
+
* that way (marker + skill cooldown, hook-error telemetry, the metrics sink and its GC, and
|
|
111
|
+
* the native-binding breakage marker, which is the self-heal trigger). The rule that
|
|
112
|
+
* resolved them, stated so the next sweep does not overshoot:
|
|
113
|
+
*
|
|
114
|
+
* MOVES with the override — state a HOOK writes INTO THE RUNTIME DIR and another
|
|
115
|
+
* component reads back: cross-hook injected-ids markers, skill/pre-recall cooldowns,
|
|
116
|
+
* hook-error telemetry, the launcher's breakage/heal markers, the native-binding
|
|
117
|
+
* breakage marker, `ep-flush-*` / `pending-*` buffers, the shadow-recommendation log.
|
|
118
|
+
*
|
|
119
|
+
* STAYS under the data dir, for two DIFFERENT reasons — do not collapse them:
|
|
120
|
+
* (a) state about the ONE REAL INSTALLATION — `install.lock`, `update-state.json`,
|
|
121
|
+
* `swap-in-progress`, the `.update-staging-*` / `.update-backup-*` residue scan.
|
|
122
|
+
* These must not follow a per-harness override: the lock exists to serialise
|
|
123
|
+
* concurrent installers, and two installers pointed at different override
|
|
124
|
+
* directories would each take their own lock and both proceed.
|
|
125
|
+
* (b) state that was never under `runtime/` at all — `metrics/` is a SIBLING of it
|
|
126
|
+
* (`lib/metrics.mjs` is `join(dbDir, 'metrics')`) and every caller passes the data
|
|
127
|
+
* dir. A v3.93.0 draft of this list put it in the MOVES column, which is the one
|
|
128
|
+
* error a reader following this docblock would act on: a sweep obeying it would
|
|
129
|
+
* convert `recordMetric(DB_DIR, …)` to a runtime-relative path and re-open exactly
|
|
130
|
+
* what that release closed. What was fixed there was `join(RUNTIME_DIR, '..')` —
|
|
131
|
+
* an inverse derivation of the data dir that stopped equalling `DB_DIR` the moment
|
|
132
|
+
* the override was honoured. Deriving one dir from the other is the defect; the
|
|
133
|
+
* sink itself never moved.
|
|
134
|
+
*
|
|
135
|
+
* `tests/runtime-dir-single-home.test.mjs` sweeps for the defect FORM (a shipped module
|
|
136
|
+
* building `join(…, 'runtime')` itself) with the stays-put sites as a named allowlist, so
|
|
137
|
+
* moving one out of that list is a deliberate edit rather than an omission.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} dataDir An already-resolved data dir (from resolveDataDir).
|
|
140
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
141
|
+
* @returns {string} An absolute runtime directory.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveRuntimeDir(dataDir, env = process.env) {
|
|
144
|
+
const raw = env.CLAUDE_MEM_RUNTIME_DIR;
|
|
145
|
+
// Same "falsy means default" rule as above. A relative override is NOT rejected the way
|
|
146
|
+
// CLAUDE_MEM_DIR is: this variable is set by test harnesses that predate that check, and
|
|
147
|
+
// turning a previously-working relative path into a throw would break isolation setups
|
|
148
|
+
// in order to enforce tidiness. It is resolved against cwd instead, so the value is at
|
|
149
|
+
// least absolute by the time anything writes to it.
|
|
150
|
+
if (raw === undefined || raw === null || raw === '') return join(dataDir, 'runtime'); // runtime-dir:stays-put — this IS the default branch of the rule
|
|
151
|
+
return isAbsolute(raw) ? raw : resolve(raw);
|
|
152
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { homedir } from 'os';
|
|
6
6
|
import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
7
|
+
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
7
8
|
import { truncate, typeIcon, inferProject, scrubSecrets, COMPRESSED_PENDING_PURGE } from './utils.mjs';
|
|
8
9
|
import { resolveProject } from './project-utils.mjs';
|
|
9
10
|
// READ commands resolve the project DB-aware: a subdirectory whose own name holds no rows
|
|
@@ -17,11 +18,11 @@ import { resolveCliProject as cliProject } from './lib/cli-project.mjs';
|
|
|
17
18
|
import { _resetVocabCache, vecTextForRow, vectorsEnabled } from './tfidf.mjs';
|
|
18
19
|
import { reRankWithContext } from './search-scoring.mjs';
|
|
19
20
|
import { searchObservationsHybrid } from './search-engine.mjs';
|
|
20
|
-
import { fetchObsDetail, fetchPromptDetail, fetchEventDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, PROMPT_DETAIL_FIELDS, EVENT_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
|
|
21
|
+
import { fetchObsDetail, fetchPromptDetail, fetchEventDetail, fetchSessionDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, PROMPT_DETAIL_FIELDS, EVENT_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
|
|
21
22
|
import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
|
|
22
23
|
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
|
|
23
24
|
import { ensureRegistryDb, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
|
|
24
|
-
import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources, enrichResourceRow, enrichImportedResources, enrichNamedResource, REGISTRY_CONFINE_ENV } from './lib/registry-core.mjs';
|
|
25
|
+
import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources, enrichResourceRow, enrichImportedResources, enrichNamedResource, REGISTRY_CONFINE_ENV, resourceUseHint } from './lib/registry-core.mjs';
|
|
25
26
|
import { searchResources } from './registry-retriever.mjs';
|
|
26
27
|
import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
|
|
27
28
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
@@ -56,7 +57,7 @@ import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
|
|
|
56
57
|
import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
|
|
57
58
|
import { saveObservation, saveWithClosures, formatSupersedeSkipped, formatSupersededNote } from './lib/save-observation.mjs';
|
|
58
59
|
import { normalizeScope, insertObservationVector, applyObsUpdate } from './lib/observation-write.mjs';
|
|
59
|
-
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
60
|
+
import { EXPORT_COLUMNS_SQL, buildExportWhere } from './lib/export-columns.mjs';
|
|
60
61
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
61
62
|
import { fetchRecent, RECENT_MAX } from './lib/recent-core.mjs';
|
|
62
63
|
import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
|
|
@@ -561,8 +562,7 @@ function renderObsRows(db, ids, requestedFields) {
|
|
|
561
562
|
}
|
|
562
563
|
|
|
563
564
|
function renderSessionRows(db, ids) {
|
|
564
|
-
const
|
|
565
|
-
const rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
|
|
565
|
+
const rows = fetchSessionDetail(db, ids);
|
|
566
566
|
if (rows.length === 0) return null;
|
|
567
567
|
const parts = [];
|
|
568
568
|
for (const r of rows) {
|
|
@@ -1248,7 +1248,7 @@ async function cmdStats(db, args) {
|
|
|
1248
1248
|
// recorded in the last 24h. Surfaces silent breakage (DB corruption,
|
|
1249
1249
|
// CC upstream field rename) that would otherwise stay invisible — the
|
|
1250
1250
|
// failure mode that left code-graph's matcher bug undetected for 10 sessions.
|
|
1251
|
-
const hookErrors24h = countRecentHookErrors(
|
|
1251
|
+
const hookErrors24h = countRecentHookErrors(resolveRuntimeDir(DB_DIR), now - DAY_MS);
|
|
1252
1252
|
|
|
1253
1253
|
// M-9 (audit 2026-08-14): disk footprint — a "lite" store had accumulated 360MB of
|
|
1254
1254
|
// pre-maintain snapshots against a 59MB DB with nothing reporting it. Cheap probes
|
|
@@ -1326,7 +1326,7 @@ async function cmdStats(db, args) {
|
|
|
1326
1326
|
out(` Low-value (imp≤1, never used, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`);
|
|
1327
1327
|
out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
|
|
1328
1328
|
out(` Compressed: ${compressedCount.c}`);
|
|
1329
|
-
out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, '
|
|
1329
|
+
out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(resolveRuntimeDir(DB_DIR), 'hook-errors')}` : ''}`);
|
|
1330
1330
|
// Hint threshold = the REAL eviction budget (pre-release review 2026-08-16: a
|
|
1331
1331
|
// hardcoded 3×-DB heuristic promised an eviction that fires only past the budget).
|
|
1332
1332
|
out(` Disk: DB ${mb(dbBytes)}MB | ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB${backupBytes > backupBudgetBytes() ? ` ← over the ${mb(backupBudgetBytes())}MB backup budget; next maintain/save snapshot evicts oldest (>7d old)` : ''}`);
|
|
@@ -1654,42 +1654,37 @@ function cmdExport(db, args) {
|
|
|
1654
1654
|
// script (`export --to "$END" > backup.json`) with an unset `$END` would silently
|
|
1655
1655
|
// write an empty backup and report success. Reject like cmdSearch does.
|
|
1656
1656
|
if (rejectBareStringFlags(flags, ['project', 'type', 'from', 'to'])) return;
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
//
|
|
1660
|
-
//
|
|
1661
|
-
// of the live-row pair (backup/export of tombstones is opt-in, retractions are not).
|
|
1662
|
-
if (flags['include-compressed'] === true || flags['include-compressed'] === 'true') {
|
|
1663
|
-
wheres.push('superseded_at IS NULL');
|
|
1664
|
-
} else {
|
|
1665
|
-
wheres.push(liveObsFilterSql(''));
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1657
|
+
// PARSE + VALIDATE here; the SQL predicate itself comes from buildExportWhere (P2-5),
|
|
1658
|
+
// shared with server.mjs runExport. The split is deliberate: the CLI `fail()`s with a
|
|
1659
|
+
// usage message where the MCP tool throws, and only this face has a stderr channel for
|
|
1660
|
+
// the inverted-range note below.
|
|
1668
1661
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
1669
|
-
if (project) { wheres.push('project = ?'); params.push(project); }
|
|
1670
1662
|
if (flags.type) {
|
|
1671
1663
|
// Reject unknown types — silently returning [] for `--type bogus` looked like a
|
|
1672
1664
|
// legitimate empty filter result, hiding the typo. Mirrors cmdSearch / cmdSave / cmdUpdate.
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
fail(`[mem] Invalid --type "${flags.type}". Valid: ${[...validObsTypes].join(', ')}`);
|
|
1665
|
+
if (!OBS_TYPE_SET.has(flags.type)) {
|
|
1666
|
+
fail(`[mem] Invalid --type "${flags.type}". Valid: ${[...OBS_TYPE_SET].join(', ')}`);
|
|
1676
1667
|
return;
|
|
1677
1668
|
}
|
|
1678
|
-
wheres.push('type = ?'); params.push(flags.type);
|
|
1679
1669
|
}
|
|
1680
1670
|
let exportFromEpoch = null;
|
|
1681
1671
|
let exportToEpoch = null;
|
|
1682
1672
|
if (flags.from) {
|
|
1683
1673
|
exportFromEpoch = new Date(flags.from).getTime();
|
|
1684
1674
|
if (isNaN(exportFromEpoch)) { fail(`[mem] Invalid --from date: "${flags.from}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
1685
|
-
wheres.push('created_at_epoch >= ?'); params.push(exportFromEpoch);
|
|
1686
1675
|
}
|
|
1687
1676
|
if (flags.to) {
|
|
1688
1677
|
exportToEpoch = new Date(flags.to).getTime();
|
|
1689
1678
|
if (isNaN(exportToEpoch)) { fail(`[mem] Invalid --to date: "${flags.to}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
1690
1679
|
if (/^\d{4}-\d{2}-\d{2}$/.test(flags.to)) exportToEpoch += DAY_MS - 1;
|
|
1691
|
-
wheres.push('created_at_epoch <= ?'); params.push(exportToEpoch);
|
|
1692
1680
|
}
|
|
1681
|
+
// --include-compressed: include compressed observations (aligned with MCP mem_export).
|
|
1682
|
+
// Superseded rows are excluded either way; the flag only toggles the compressed half
|
|
1683
|
+
// of the live-row pair (backup/export of tombstones is opt-in, retractions are not).
|
|
1684
|
+
const { wheres, params } = buildExportWhere({
|
|
1685
|
+
includeCompressed: flags['include-compressed'] === true || flags['include-compressed'] === 'true',
|
|
1686
|
+
project, type: flags.type || null, fromEpoch: exportFromEpoch, toEpoch: exportToEpoch,
|
|
1687
|
+
});
|
|
1693
1688
|
if (exportFromEpoch !== null && exportToEpoch !== null && exportFromEpoch > exportToEpoch) {
|
|
1694
1689
|
process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
|
|
1695
1690
|
}
|
|
@@ -2211,19 +2206,8 @@ function cmdRegistry(_memDb, args) {
|
|
|
2211
2206
|
for (const r of results) {
|
|
2212
2207
|
const badge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
|
|
2213
2208
|
const categoryLabel = r.category ? ` [${r.category}]` : '';
|
|
2214
|
-
|
|
2215
|
-
const portablePath
|
|
2216
|
-
let howToUse;
|
|
2217
|
-
if (isManaged) {
|
|
2218
|
-
const resolvedPath = portablePath.endsWith('.md') ? portablePath : `${portablePath}/SKILL.md`;
|
|
2219
|
-
howToUse = `Read("${resolvedPath}") or mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
|
|
2220
|
-
} else if (r.invocation_name) {
|
|
2221
|
-
howToUse = r.type === 'skill'
|
|
2222
|
-
? `Skill("${r.invocation_name}")`
|
|
2223
|
-
: `Agent(subagent_type="${r.invocation_name}")`;
|
|
2224
|
-
} else {
|
|
2225
|
-
howToUse = `mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
|
|
2226
|
-
}
|
|
2209
|
+
// P2-6: the invocation rule is shared; only the four-space indent is this face's.
|
|
2210
|
+
const { portablePath, howToUse } = resourceUseHint(r, { home, managedPrefix: join(DB_DIR, 'managed') + sep });
|
|
2227
2211
|
const pathLine = portablePath ? `\n Path: ${portablePath}` : '';
|
|
2228
2212
|
out(` ${badge} ${r.type === 'skill' ? 'S' : 'A'} ${r.name}${categoryLabel} — ${truncate(r.capability_summary || '', 80)}${pathLine}\n Use: ${howToUse}`);
|
|
2229
2213
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.93.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.93.1",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.93.1",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -87,6 +87,8 @@
|
|
|
87
87
|
"lib/lesson-bridge.mjs",
|
|
88
88
|
"lib/binding-probe.mjs",
|
|
89
89
|
"lib/install-shape.mjs",
|
|
90
|
+
"lib/hook-stdin.mjs",
|
|
91
|
+
"lib/plugin-key.mjs",
|
|
90
92
|
"lib/hook-stdout.mjs",
|
|
91
93
|
"lib/proc-lock.mjs",
|
|
92
94
|
"lib/atomic-write.mjs",
|
package/registry-recommend.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// `off` skips all work.
|
|
8
8
|
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, appendFileSync, readdirSync, unlinkSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
|
-
import { resolveDataDir } from './lib/resolve-data-dir.mjs';
|
|
10
|
+
import { resolveDataDir, resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
11
11
|
import { searchResources, cjkIntentTokens } from './registry-retriever.mjs';
|
|
12
12
|
|
|
13
13
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
@@ -57,7 +57,7 @@ const RECO_COOLDOWN_MS = 300_000; // 5 min, mirrors T4 SKILL_COOLDOWN_MS
|
|
|
57
57
|
// DB_DIR formula) so tests sandbox via env without ESM-cache gymnastics, and prod reads
|
|
58
58
|
// the same dir as the rest of the app.
|
|
59
59
|
function recoRuntimeDir() {
|
|
60
|
-
return
|
|
60
|
+
return resolveRuntimeDir(resolveDataDir(process.env.CLAUDE_MEM_DIR));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const TOKEN_SPLIT = /[^a-z0-9一-鿿]+/;
|
package/schema.mjs
CHANGED
|
@@ -152,7 +152,12 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
|
|
|
152
152
|
// (citation_surface_log.surface) in LATEST_MIGRATION_COLUMNS: a table that only
|
|
153
153
|
// the forced pass can create is unreachable forever once the version row says
|
|
154
154
|
// "done", which is not a hypothetical — see the note there.
|
|
155
|
-
|
|
155
|
+
// v47: two additive indexes (P2-11 + ALGO-7). The bump is LOAD-BEARING, not bookkeeping:
|
|
156
|
+
// `initSchema`'s fast path returns before the `CREATE INDEX IF NOT EXISTS` block, so on
|
|
157
|
+
// every existing install at v46 a new index there would simply never be created. Same trap
|
|
158
|
+
// the FTS5 migration hit — a DDL change that is not reachable from the version the DB
|
|
159
|
+
// already reports is a no-op with a convincing diff.
|
|
160
|
+
export const CURRENT_SCHEMA_VERSION = 47;
|
|
156
161
|
|
|
157
162
|
// Sentinel columns for the LATEST migration set(s). The fast-path uses these
|
|
158
163
|
// to self-heal half-migrated DBs — schema_version bumped but column ALTERs
|
|
@@ -551,6 +556,18 @@ export function initSchema(db) {
|
|
|
551
556
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sdk_sessions(project)`);
|
|
552
557
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_not_compressed ON observations(created_at_epoch DESC) WHERE COALESCE(compressed_into, 0) = 0`);
|
|
553
558
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_handoffs_project_time ON session_handoffs(project, type, created_at_epoch DESC)`);
|
|
559
|
+
// v47 (audit 2026-09-02 P2-11 + the previous round's ALGO-7), one additive migration for
|
|
560
|
+
// both. Additive only: new indexes on existing columns, no table rewrite, no data move.
|
|
561
|
+
//
|
|
562
|
+
// The first was the ONLY genuine full table scan in the 30 statements the audit ran
|
|
563
|
+
// through EXPLAIN QUERY PLAN. Stop and SessionStart both probe "does a summary exist for
|
|
564
|
+
// this memory session?", `session_summaries` is 10,160 rows here, and the cost grows
|
|
565
|
+
// linearly with the table for a question asked on every hook event.
|
|
566
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sess_sum_memory_session ON session_summaries(memory_session_id)`);
|
|
567
|
+
// The second narrows the live-row scan the injection faces run per project. Partial on the
|
|
568
|
+
// same predicate `liveObsFilterSql` uses, so the index covers exactly the rows those
|
|
569
|
+
// queries can return.
|
|
570
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_project_live ON observations(project, created_at_epoch DESC) WHERE superseded_at IS NULL AND COALESCE(compressed_into, 0) = 0`);
|
|
554
571
|
|
|
555
572
|
// FTS5 migration: recreate observations_fts when columns are missing (one-time)
|
|
556
573
|
// Detect old FTS5 table missing lesson_learned or search_aliases and recreate with full column set
|
|
@@ -102,7 +102,7 @@ if (first.ok) process.exit(0);
|
|
|
102
102
|
// .node mid-compile.
|
|
103
103
|
let lockPath;
|
|
104
104
|
try {
|
|
105
|
-
lockPath = join(helpers.resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock');
|
|
105
|
+
lockPath = join(helpers.resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
|
|
106
106
|
} catch (e) {
|
|
107
107
|
// resolveDataDir THROWS on a non-absolute CLAUDE_MEM_DIR. Unhandled, that
|
|
108
108
|
// prints an 8-line rejection stack onto SessionStart stderr; one line is enough.
|
|
@@ -39,13 +39,36 @@ const INSTALL_DIR = join(__dirname, '..');
|
|
|
39
39
|
// non-absolute → default. Data-writing paths import that module and throw instead.
|
|
40
40
|
const MEM_DIR = process.env.CLAUDE_MEM_DIR;
|
|
41
41
|
const DATA_DIR = MEM_DIR && isAbsolute(MEM_DIR) ? MEM_DIR : join(homedir(), '.claude-mem-lite');
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// TWO runtime dirs, because this file writes BOTH classes of state and v3.93.0 shipped a
|
|
43
|
+
// split by moving only doctor's READ side. `RUNTIME_DIR` is data-dir-relative and serves
|
|
44
|
+
// `swap-in-progress`, which is installation identity and must NOT follow a per-harness
|
|
45
|
+
// override (two installers pointed at different override dirs would each see no swap in
|
|
46
|
+
// progress). `HOOK_RUNTIME_DIR` is override-aware and serves every marker another component
|
|
47
|
+
// reads back — the launcher's own breakage/heal markers, which `install.mjs doctor` reads,
|
|
48
|
+
// and the native-binding pair below. That second constant already existed here as
|
|
49
|
+
// `NB_RUNTIME_DIR` and was applied to only half this file's markers.
|
|
50
|
+
//
|
|
51
|
+
// Inline `||`, not `lib/resolve-data-dir.mjs::resolveRuntimeDir`, and this is the single
|
|
52
|
+
// declared exception in the tree: the launcher runs BEFORE the native binding is known to
|
|
53
|
+
// work, so it imports only `node:` builtins on purpose — even a leaf lib module is a
|
|
54
|
+
// resolution it declines to make on the path whose job is surviving a broken install. The
|
|
55
|
+
// standalone hook scripts it mirrors (pre-tool-recall / pre-skill-bridge) honour the same
|
|
56
|
+
// variable and write 78 of every 79 of these markers, so reading a different dir would mean
|
|
57
|
+
// never healing. `resolveRuntimeDir` is the canonical rule (audit 2026-09-02 P1-14) and this
|
|
58
|
+
// is NOT identical to it: the resolver additionally makes a RELATIVE override absolute
|
|
59
|
+
// (`isAbsolute(raw) ? raw : resolve(raw)`), while this hands the relative value straight to
|
|
60
|
+
// `fs`, which resolves it against cwd at call time rather than at module load. They agree on
|
|
61
|
+
// unset, empty and absolute — the three cases that reach a real install. Keep the DEFAULTING
|
|
62
|
+
// behaviour in step; do not read "identical" into the difference.
|
|
63
|
+
// `tests/runtime-dir-single-home.test.mjs` asserts this file still carries the rule.
|
|
64
|
+
const RUNTIME_DIR = join(DATA_DIR, 'runtime'); // runtime-dir:stays-put — serves swap-in-progress only; HOOK_RUNTIME_DIR carries the hook markers
|
|
65
|
+
const HOOK_RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || RUNTIME_DIR;
|
|
66
|
+
const HEAL_MARKER = join(HOOK_RUNTIME_DIR, 'hook-launcher-lastheal');
|
|
44
67
|
const HEAL_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
|
45
68
|
// Observable breakage state: written when the launcher degrades a broken install
|
|
46
69
|
// to exit 0, cleared once the install is confirmed healthy. `doctor` reads it so
|
|
47
70
|
// the intentional silence (no stack trace per fire) stays detectable. (#4/#8)
|
|
48
|
-
const BROKEN_MARKER = join(
|
|
71
|
+
const BROKEN_MARKER = join(HOOK_RUNTIME_DIR, 'hook-launcher-broken');
|
|
49
72
|
|
|
50
73
|
// ── Native-binding (ABI) self-heal ──────────────────────────────────────────
|
|
51
74
|
// A stale better_sqlite3.node after a Node upgrade does NOT throw at import time
|
|
@@ -63,12 +86,9 @@ const BROKEN_MARKER = join(RUNTIME_DIR, 'hook-launcher-broken');
|
|
|
63
86
|
// lib/hook-telemetry.mjs and lib/native-binding-hint.mjs); this heals from it at
|
|
64
87
|
// SESSION-START only — never on the per-tool hot path, where an npm run would
|
|
65
88
|
// stall the user's edit.
|
|
66
|
-
// Marker dir
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const NB_RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || RUNTIME_DIR;
|
|
70
|
-
const NB_BROKEN_MARKER = join(NB_RUNTIME_DIR, 'native-binding-broken');
|
|
71
|
-
const NB_HEAL_MARKER = join(NB_RUNTIME_DIR, 'native-binding-lastheal');
|
|
89
|
+
// Marker dir: HOOK_RUNTIME_DIR (see its definition above for why it is override-aware).
|
|
90
|
+
const NB_BROKEN_MARKER = join(HOOK_RUNTIME_DIR, 'native-binding-broken');
|
|
91
|
+
const NB_HEAL_MARKER = join(HOOK_RUNTIME_DIR, 'native-binding-lastheal');
|
|
72
92
|
// Literal, not imported: the pure-`node:` charter above forbids importing lib/
|
|
73
93
|
// here (this file must survive a broken install). Kept in sync with
|
|
74
94
|
// lib/binding-probe.mjs::NATIVE_BINDING_REBUILD_CMD, which is the single home
|
|
@@ -215,7 +235,7 @@ function recentHealAttempt() {
|
|
|
215
235
|
|
|
216
236
|
function recordHealAttempt() {
|
|
217
237
|
try {
|
|
218
|
-
mkdirSync(
|
|
238
|
+
mkdirSync(HOOK_RUNTIME_DIR, { recursive: true });
|
|
219
239
|
writeFileSync(HEAL_MARKER, String(Date.now()));
|
|
220
240
|
} catch { /* best-effort */ }
|
|
221
241
|
}
|
|
@@ -229,7 +249,7 @@ function clearHealMarker() {
|
|
|
229
249
|
|
|
230
250
|
function recordBreakage(reason) {
|
|
231
251
|
try {
|
|
232
|
-
mkdirSync(
|
|
252
|
+
mkdirSync(HOOK_RUNTIME_DIR, { recursive: true });
|
|
233
253
|
writeFileSync(BROKEN_MARKER, JSON.stringify({ reason, ts: Date.now() }));
|
|
234
254
|
} catch { /* best-effort */ }
|
|
235
255
|
}
|
|
@@ -306,7 +326,7 @@ function healNativeBindingIfBroken() {
|
|
|
306
326
|
if (Date.now() - statSync(NB_HEAL_MARKER).mtimeMs < HEAL_COOLDOWN_MS) return;
|
|
307
327
|
} catch { /* no marker → not on cooldown */ }
|
|
308
328
|
try {
|
|
309
|
-
mkdirSync(
|
|
329
|
+
mkdirSync(HOOK_RUNTIME_DIR, { recursive: true });
|
|
310
330
|
writeFileSync(NB_HEAL_MARKER, String(Date.now()));
|
|
311
331
|
} catch { /* best-effort */ }
|
|
312
332
|
|
package/scripts/launch.mjs
CHANGED
|
@@ -76,7 +76,7 @@ try {
|
|
|
76
76
|
// proceeds; a broken one defers to the peer instead of racing it).
|
|
77
77
|
const { acquireLock } = await import('../lib/proc-lock.mjs');
|
|
78
78
|
const { resolveDataDir } = await import('../lib/resolve-data-dir.mjs');
|
|
79
|
-
const lockPath = join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock');
|
|
79
|
+
const lockPath = join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
|
|
80
80
|
let release = null;
|
|
81
81
|
for (let i = 0; i < 20 && !(release = acquireLock(lockPath)); i++) {
|
|
82
82
|
await new Promise((r) => setTimeout(r, 500));
|