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.
@@ -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
+ }
@@ -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
+ }
@@ -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 placeholders = ids.map(() => '?').join(',');
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(join(DB_DIR, 'runtime'), now - DAY_MS);
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, 'runtime/hook-errors')}` : ''}`);
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
- const wheres = [];
1658
- const params = [];
1659
- // --include-compressed: include compressed observations (aligned with MCP mem_export).
1660
- // Superseded rows are excluded either way; the flag only toggles the compressed half
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
- const validObsTypes = OBS_TYPE_SET;
1674
- if (!validObsTypes.has(flags.type)) {
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
- const isManaged = r.local_path && r.local_path.includes(join(DB_DIR, 'managed') + sep);
2215
- const portablePath = isManaged && r.local_path.startsWith(home) ? '~' + r.local_path.slice(home.length) : (r.local_path || '');
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
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.92.0",
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.92.0",
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.92.0",
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",
@@ -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 join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime');
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
- export const CURRENT_SCHEMA_VERSION = 46;
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
- const RUNTIME_DIR = join(DATA_DIR, 'runtime');
43
- const HEAL_MARKER = join(RUNTIME_DIR, 'hook-launcher-lastheal');
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(RUNTIME_DIR, 'hook-launcher-broken');
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 mirrors the standalone hook scripts (pre-tool-recall /
67
- // pre-skill-bridge), which honor CLAUDE_MEM_RUNTIME_DIR — they write 78 of every
68
- // 79 of these markers, so reading a different dir would mean never healing.
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(RUNTIME_DIR, { recursive: true });
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(RUNTIME_DIR, { recursive: true });
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(NB_RUNTIME_DIR, { recursive: true });
329
+ mkdirSync(HOOK_RUNTIME_DIR, { recursive: true });
310
330
  writeFileSync(NB_HEAL_MARKER, String(Date.now()));
311
331
  } catch { /* best-effort */ }
312
332
 
@@ -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));