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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.92.0",
13
+ "version": "3.93.1",
14
14
  "source": "./",
15
15
  "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)."
16
16
  }
@@ -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
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -895,6 +895,20 @@ Set by the tool or by the test harness. Setting these by hand is not supported:
895
895
  `CLAUDE_MEM_DB_PATH`, `CLAUDE_MEM_RUNTIME_DIR`, `MEM_DISABLE_SPAWN_LOG`.
896
896
  `CLAUDE_PLUGIN_ROOT` is set by Claude Code itself.
897
897
 
898
+ The last two are worth one more sentence each, because they are the ones a harness reaches
899
+ for. `CLAUDE_MEM_RUNTIME_DIR` relocates the runtime directory for hook-written state — markers,
900
+ cooldowns, hook-error telemetry, the native-binding breakage marker, episode buffers.
901
+ (`metrics/` is NOT in that set: it is a sibling of `runtime/` under the data dir and moves
902
+ with `CLAUDE_MEM_DIR` only.) Before v3.93.0 it was honoured by some readers and ignored by others, so setting it
903
+ split the runtime rather than moving it. Installation-identity state (`install.lock`,
904
+ `update-state.json`, update residue) deliberately stays under `CLAUDE_MEM_DIR`: two
905
+ installers pointed at different override directories would otherwise each take their own
906
+ lock and both proceed. **`CLAUDE_MEM_DB_PATH` still has the split shape**, and more narrowly
907
+ than it looks: exactly ONE component reads it — `scripts/pre-tool-recall.js` — so setting it
908
+ aims that single hook at one database and leaves the other four hook faces, the CLI and the
909
+ MCP server on the default. Use `CLAUDE_MEM_DIR` — the only override
910
+ every component respects, including the bash pre-filter — to isolate state.
911
+
898
912
  Three more are set by `vitest.config.mjs` / `tests/global-setup.mjs` and exist only to
899
913
  keep a test run off the live database: `CLAUDE_MEM_TEST_GUARD` (`1` arms the guard, `off`
900
914
  opts a test out), `CLAUDE_MEM_TEST_REALDIR` (the live data dir, captured before the suite
package/hash-utils.mjs CHANGED
@@ -68,6 +68,18 @@ export function estimateJaccardFromMinHash(sig1, sig2) {
68
68
  if (sig1.length !== sig2.length) return 0;
69
69
  const numHashes = sig1.length / 8;
70
70
  if (numHashes === 0) return 0;
71
+ // NEGATIVE RESULT, kept so nobody re-proposes it (audit 2026-09-02 P2-13 suggested an
72
+ // "allocation-free comparison"). `slice()` does allocate two 8-char strings per band, and
73
+ // the caller is a full nested pair loop — ~125k pairs at the 500-row scan bound. A
74
+ // charCodeAt inner loop was written and measured against this form over all 124,750 pairs
75
+ // of a 500-title fixture: identical results (0 mismatches, so the rewrite was correct) and
76
+ // NO time difference — 0.77× / 1.08× / 1.03× across three passes, i.e. the first pass was
77
+ // slower and the rest were noise. V8 handles short slices well enough that the byte loop
78
+ // buys nothing, and the whole pass is 0.6 ms.
79
+ //
80
+ // So the slice form stays: it is the more readable of two equally fast implementations,
81
+ // and shipping the other would be churn with a performance claim behind it that the
82
+ // measurement does not support.
71
83
  let matches = 0;
72
84
  for (let i = 0; i < numHashes; i++) {
73
85
  const offset = i * 8;
package/hook-llm.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // claude-mem-lite: Background LLM workers for episode extraction and session summaries
2
2
  // Extracted from hook.mjs for testability and reduced complexity
3
3
 
4
- import { basename } from 'path';
5
- import { existsSync, readFileSync, unlinkSync, readdirSync } from 'fs';
4
+ import { basename, join } from 'path';
5
+ import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from 'fs';
6
6
  import {
7
7
  jaccardSimilarity, truncate, clampImportance, computeRuleImportance,
8
8
  inferProject, parseJsonFromLLM, scrubSecrets,
@@ -16,7 +16,7 @@ import { vecTextForRow } from './tfidf.mjs';
16
16
  import { insertObservationRow, insertObservationFiles, insertObservationVector, upsertObservationVector, normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
17
17
  import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
18
18
  import {
19
- RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
19
+ RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS, ORPHAN_EPISODE_AGE_MS,
20
20
  sessionFile, getSessionId, openDb, callLLM, sleep,
21
21
  } from './hook-shared.mjs';
22
22
  import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
@@ -1147,13 +1147,44 @@ ${actionList}`;
1147
1147
  export async function handleLLMSummary() {
1148
1148
  const parsed = parseInt(process.env.CLAUDE_MEM_FLUSH_TIMEOUT, 10);
1149
1149
  const flushTimeout = Number.isNaN(parsed) ? 15 : parsed;
1150
- for (let i = 0; i < flushTimeout; i++) {
1151
- try {
1152
- const files = readdirSync(RUNTIME_DIR).filter(f => f.startsWith('ep-flush-'));
1153
- if (files.length === 0) break;
1154
- } catch { break; }
1155
- debugLog('DEBUG', 'llm-summary', `waiting for flush files (${i + 1}/15)`);
1150
+
1151
+ // Wait for a DEFINED SET of flush files, not for "the directory is empty" (audit
1152
+ // 2026-09-02 P1-7). RUNTIME_DIR is shared by every project on the machine, and the old
1153
+ // predicate was `readdirSync(RUNTIME_DIR).some(f => f.startsWith('ep-flush-'))` — so:
1154
+ //
1155
+ // • ONE crashed llm-episode worker leaves a file nothing will ever delete, and from
1156
+ // then on EVERY project's summary burns the full 15 s on every Stop until the next
1157
+ // maintain run sweeps it. Orphan cleanup lives behind a 24 h gate, so "until then"
1158
+ // is up to a day.
1159
+ // • A flush spawned by an unrelated project WHILE this summary is waiting extends the
1160
+ // wait, for work this summary will never read.
1161
+ //
1162
+ // The set is snapshotted at entry and filtered to files young enough to belong to a live
1163
+ // worker. A file that appears after this point is somebody else's; a file older than
1164
+ // ORPHAN_EPISODE_AGE_MS is nobody's. Both were previously indistinguishable from work in
1165
+ // progress.
1166
+ //
1167
+ // Not narrowed to this project: the flush filename is `ep-flush-<ts>-<rand>.json` and
1168
+ // carries no project. Widening it is a marker-format change with in-flight files during
1169
+ // an upgrade, and the two filters above already remove the unbounded cases — what is left
1170
+ // is a bounded overlap with genuinely concurrent work.
1171
+ let pending;
1172
+ try {
1173
+ const cutoff = Date.now() - ORPHAN_EPISODE_AGE_MS;
1174
+ pending = readdirSync(RUNTIME_DIR)
1175
+ .filter((f) => f.startsWith('ep-flush-'))
1176
+ .filter((f) => {
1177
+ try { return statSync(join(RUNTIME_DIR, f)).mtimeMs >= cutoff; } catch { return false; }
1178
+ });
1179
+ } catch { pending = []; }
1180
+
1181
+ for (let i = 0; i < flushTimeout && pending.length > 0; i++) {
1156
1182
  await sleep(1000);
1183
+ pending = pending.filter((f) => existsSync(join(RUNTIME_DIR, f)));
1184
+ debugLog('DEBUG', 'llm-summary', `waiting for ${pending.length} flush file(s) (${i + 1}/${flushTimeout})`);
1185
+ }
1186
+ if (pending.length > 0) {
1187
+ debugLog('DEBUG', 'llm-summary', `gave up waiting on ${pending.length} flush file(s) after ${flushTimeout}s: ${pending.join(', ')}`);
1157
1188
  }
1158
1189
 
1159
1190
  const db = openDb();
package/hook-optimize.mjs CHANGED
@@ -22,9 +22,12 @@ import { DB_DIR } from './schema.mjs';
22
22
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
23
23
  import { normalizeScope, SCOPE_PROMPT_LEGEND, upsertObservationVector } from './lib/observation-write.mjs';
24
24
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
25
+ import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
25
26
 
26
27
  import { DAY_MS } from './lib/time-constants.mjs';
27
- const RUNTIME_DIR = join(DB_DIR, 'runtime');
28
+ // P1-14: same resolver as hook-shared.mjs — this was the second module that had never
29
+ // heard of CLAUDE_MEM_RUNTIME_DIR, and a third hand-written copy of the join().
30
+ const RUNTIME_DIR = resolveRuntimeDir(DB_DIR);
28
31
 
29
32
  // ─── Budget ─────────────────────────────────────────────────────────────────
30
33
 
package/hook-shared.mjs CHANGED
@@ -8,6 +8,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdir
8
8
  import { inferProject, debugCatch } from './utils.mjs';
9
9
  import { CITE_RECALL_FILE_PREFIX } from './lib/cite-recall-path.mjs';
10
10
  import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
11
+ import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
11
12
  // Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
12
13
  // neither imports this file) — no cycle.
13
14
  import { recordHookError } from './lib/hook-telemetry.mjs';
@@ -22,7 +23,11 @@ import { PLUGIN_SLUG as _PLUGIN_SLUG } from './adopt-content.mjs';
22
23
  import { DAY_MS } from './lib/time-constants.mjs';
23
24
  // ─── Constants ────────────────────────────────────────────────────────────────
24
25
 
25
- export const RUNTIME_DIR = join(DB_DIR, 'runtime');
26
+ // P1-14: one resolver, so this module honours CLAUDE_MEM_RUNTIME_DIR like the five
27
+ // standalone hook scripts already did. It did not, and hook.mjs / server.mjs /
28
+ // hook-context.mjs / hook-episode.mjs all take RUNTIME_DIR from here — so the override
29
+ // split the runtime dir in half instead of relocating it.
30
+ export const RUNTIME_DIR = resolveRuntimeDir(DB_DIR);
26
31
  export const SCRIPT_PATH = process.argv[1];
27
32
 
28
33
  // Timing constants
package/hook-update.mjs CHANGED
@@ -33,7 +33,7 @@ const INSTALL_DIR = CODE_DIR; // ~/.claude-mem-lite/ (code)
33
33
  // DB_DIR), matching hook-shared RUNTIME_DIR and install.mjs doctor's read path.
34
34
  // Equal to INSTALL_DIR unless CLAUDE_MEM_DIR relocates the data dir.
35
35
  const STATE_DIR = DB_DIR;
36
- const STATE_FILE = join(STATE_DIR, 'runtime', 'update-state.json');
36
+ const STATE_FILE = join(STATE_DIR, 'runtime', 'update-state.json'); // runtime-dir:stays-put — installation identity
37
37
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
38
38
  const FETCH_TIMEOUT_MS = 3000; // 3s network timeout
39
39
  // When rate-limited we got NO release data, so re-check sooner than the normal 24h
@@ -641,7 +641,7 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
641
641
  // hooks are best-effort, so losing one fire beats importing a mixed module graph.
642
642
  // Carries pid + ts because the launcher must never be muted permanently by an
643
643
  // updater that was killed mid-swap (it applies the same staleness bound).
644
- const SWAP_MARKER = join(STATE_DIR, 'runtime', 'swap-in-progress');
644
+ const SWAP_MARKER = join(STATE_DIR, 'runtime', 'swap-in-progress'); // runtime-dir:stays-put — installation identity
645
645
  // Intent journal, written INSIDE the backup dir before each rename. On a hard kill
646
646
  // the backup dir survives (every normal exit deletes it) and this file says exactly
647
647
  // which paths were in flight, so the next entry can finish the rollback at the right
@@ -781,7 +781,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
781
781
  // holding the lock means an install is already in flight — skip rather than
782
782
  // race. Shared path with install.mjs so direct install + repair + auto-update
783
783
  // are mutually exclusive.
784
- const release = acquireLock(join(STATE_DIR, 'runtime', 'install.lock'));
784
+ const release = acquireLock(join(STATE_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
785
785
  if (!release) {
786
786
  debugLog('DEBUG', 'hook-update', 'installExtractedRelease: another install/update is in progress — skipping');
787
787
  return false;
@@ -1115,7 +1115,7 @@ function readState() {
1115
1115
 
1116
1116
  function saveState(state) {
1117
1117
  try {
1118
- const dir = join(STATE_DIR, 'runtime');
1118
+ const dir = join(STATE_DIR, 'runtime'); // runtime-dir:stays-put — mkdir for update-state.json above
1119
1119
  mkdirSync(dir, { recursive: true });
1120
1120
  const tmpFile = STATE_FILE + `.tmp-${process.pid}`;
1121
1121
  writeFileSync(tmpFile, JSON.stringify(state, null, 2));
package/hook.mjs CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  // backward-compat surface that knip already lists as unused; new shared symbols go to
34
34
  // their canonical module.
35
35
  import { inferProjectDir } from './project-utils.mjs';
36
+ import { isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
37
+ import { readHookStdin } from './lib/hook-stdin.mjs';
36
38
  // Aliased: `acquireLock` from hook-episode.mjs below is the episode buffer's own
37
39
  // (argument-less) lock — a different mutex with a different staleness policy.
38
40
  import { acquireLock as acquireProcLock } from './lib/proc-lock.mjs';
@@ -42,6 +44,7 @@ import {
42
44
  createEpisode, addFileToEpisode, planEpisodeFlush,
43
45
  writePendingEntry, mergePendingEntries, episodeHasSignificantContent, explainSignificance,
44
46
  } from './hook-episode.mjs';
47
+ import { DB_DIR } from './schema.mjs';
45
48
  import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
46
49
  import { entry as preCompactEntry } from './hook-precompact.mjs';
47
50
  import {
@@ -149,18 +152,22 @@ const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-o
149
152
  // Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
150
153
  // install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
151
154
  // in Claude UI does not automatically remove them. Exit early to make disable actually work.
152
- const PLUGIN_KEY = 'claude-mem-lite@sdsrss';
153
- function isPluginExplicitlyDisabled() {
155
+ // The KEY and the predicate live in lib/plugin-key.mjs (P2-7) — install.mjs branches on the
156
+ // same decision, and a key that drifts on one side leaves the user with a plugin they
157
+ // switched off and a settings.json hook set that never noticed. Reading the file stays here:
158
+ // install.mjs already holds a parsed settings object when it asks, this process does not.
159
+ function pluginDisabledHere() {
154
160
  try {
155
161
  const settingsPath = join(homedir(), '.claude', 'settings.json');
156
- const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
157
- return settings.enabledPlugins?.[PLUGIN_KEY] === false;
162
+ return isPluginExplicitlyDisabled(JSON.parse(readFileSync(settingsPath, 'utf8')));
158
163
  } catch {
164
+ // Missing or unparseable settings.json → not disabled. Fail OPEN: a corrupt file must
165
+ // not switch the plugin off for a user who never asked for that.
159
166
  return false;
160
167
  }
161
168
  }
162
169
 
163
- if (event && isPluginExplicitlyDisabled()) process.exit(0);
170
+ if (event && pluginDisabledHere()) process.exit(0);
164
171
  if (process.env.CLAUDE_MEM_HOOK_RUNNING && !BG_EVENTS.has(event)) process.exit(0);
165
172
 
166
173
  // Crash-safe: flush episode buffer on unexpected termination to prevent data loss
@@ -395,7 +402,7 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
395
402
  // and with the flag on that case is strictly better than before, because the reads file
396
403
  // was never touched and the retry still finds it.
397
404
  // Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
398
- recordMetric(join(RUNTIME_DIR, '..'), {
405
+ recordMetric(DB_DIR, {
399
406
  event: 'episode_reads',
400
407
  readsConsumed: (episode.filesRead || []).length,
401
408
  readsHeld,
@@ -459,7 +466,7 @@ function flushEpisodeGroup(ep, db) {
459
466
  // episodes are kept ONLY because of their Greps — and demoting what the product
460
467
  // remembers on a deduction is how work disappears silently. This is that counter.
461
468
  // Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
462
- recordMetric(join(RUNTIME_DIR, '..'), {
469
+ recordMetric(DB_DIR, {
463
470
  event: 'episode_significance',
464
471
  rule: verdict.rule,
465
472
  significant: isSignificant,
@@ -682,7 +689,7 @@ function triggerErrorRecall(db, toolInput, response, opts = {}) {
682
689
  // G13: this surface feeds the citation denominator but had zero metering —
683
690
  // the G8 gate change (isError→isHardError) could not be volume-verified
684
691
  // from metrics. Counter only; no latency (query is bundled in the hook).
685
- recordMetric(join(RUNTIME_DIR, '..'), { event: metricEvent, returned: rows.length });
692
+ recordMetric(DB_DIR, { event: metricEvent, returned: rows.length });
686
693
  // MED-3 (full audit 2026-07-16): go through the envelope, NOT raw stdout —
687
694
  // a raw multi-line write corrupts a co-emitted episode-flush receipt.
688
695
  // The follow-up correction (2026-08-17): "two separate JSON lines each parse
@@ -1485,7 +1492,13 @@ function runSessionStartAutoMaintain(db, project) {
1485
1492
  // Auto-dedup (fuzzy): catches near-identical titles that exact-match
1486
1493
  // misses across larger time windows — e.g. episode-batch titles like
1487
1494
  // "Modified A.mjs, B.mjs" vs "Modified B.mjs, A.mjs" written days apart.
1488
- // MinHash pre-filter (≥0.7) cuts the O(N²) scan; Jaccard ≥0.95 stays
1495
+ // MinHash pre-filter (≥0.7) makes each PAIR cheap; it does not reduce the number of
1496
+ // pairs. Said precisely because the comment here used to read "cuts the O(N²) scan",
1497
+ // which is false and reads as an algorithmic bound (audit 2026-09-02 P2-13): the
1498
+ // estimate is evaluated INSIDE the inner loop of a full nested scan in
1499
+ // `lib/maintain-core.mjs`, so every pair is still visited — what it skips is the
1500
+ // expensive exact Jaccard behind it. Measured, the whole pair pass is ~0.6 ms at
1501
+ // n=500. Jaccard ≥0.95 stays
1489
1502
  // well clear of legit "two updates same area" pairs (those typically
1490
1503
  // score 0.7–0.85, surfaced via `maintain scan` for manual review).
1491
1504
  // Bounded by ${SCAN_LIMIT} recent rows × ${FUZZY_MAX_MERGES}-merge cap.
@@ -1835,9 +1848,9 @@ async function handleSessionStart() {
1835
1848
  try { sweepStaleProjectMarkers(RUNTIME_DIR); } catch { /* best-effort */ }
1836
1849
  // Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
1837
1850
  try { const { gcOldShadowShards } = await import('./registry-recommend.mjs'); gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
1838
- // Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
1851
+ // Same for the opt-in metrics sink, which lives under DB_DIR. Runs even when
1839
1852
  // metrics are disabled, so shards left by a since-toggled-off run still get pruned.
1840
- try { gcOldMetricShards(join(RUNTIME_DIR, '..')); } catch { /* best-effort */ }
1853
+ try { gcOldMetricShards(DB_DIR); } catch { /* best-effort */ }
1841
1854
 
1842
1855
  // Plugin cache self-heal: Claude Code auto-updates the marketplace plugin can
1843
1856
  // re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
@@ -2213,8 +2226,11 @@ async function handleUserPrompt() {
2213
2226
  // (inject, maybe duplicate) rather than fail closed (suppress).
2214
2227
  const keyContextIds = [];
2215
2228
  try {
2216
- const raw = readFileSync(join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)), 'utf8');
2217
- const { ids, session } = JSON.parse(raw);
2229
+ // `keyCtxRaw`, not `raw`: this function already binds `raw` to the stdin payload
2230
+ // ~120 lines up, and a second `raw` holding a marker FILE's contents reads as that
2231
+ // one (audit 2026-09-02 P2-17 — the one hit in the tree worth a rename).
2232
+ const keyCtxRaw = readFileSync(join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)), 'utf8');
2233
+ const { ids, session } = JSON.parse(keyCtxRaw);
2218
2234
  if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
2219
2235
  keyContextIds.push(...ids);
2220
2236
  }
@@ -2402,7 +2418,7 @@ async function handleUserPrompt() {
2402
2418
  // counterfactual search and the second lesson selection off a stock install.
2403
2419
  try {
2404
2420
  if (meterCoerced) {
2405
- recordPathAExclude(join(RUNTIME_DIR, '..'), {
2421
+ recordPathAExclude(DB_DIR, {
2406
2422
  markerIds: pathAInjectedIds,
2407
2423
  emitted: memories,
2408
2424
  after: meterArmB,
@@ -2437,14 +2453,14 @@ async function handleEnrichSave(rawId) {
2437
2453
  // work" was invisible (32% alias coverage with 3 indistinguishable failure
2438
2454
  // causes). reason 'filled-concurrently' = txn ran but a concurrent optimize/
2439
2455
  // update had already filled every empty field.
2440
- recordMetric(join(RUNTIME_DIR, '..'), {
2456
+ recordMetric(DB_DIR, {
2441
2457
  event: 'enrich_save',
2442
2458
  id,
2443
2459
  enriched: result.enriched,
2444
2460
  reason: result.reason ?? (result.enriched ? 'enriched' : 'filled-concurrently'),
2445
2461
  });
2446
2462
  } catch (e) {
2447
- recordMetric(join(RUNTIME_DIR, '..'), { event: 'enrich_save', id, enriched: false, reason: 'worker-error' });
2463
+ recordMetric(DB_DIR, { event: 'enrich_save', id, enriched: false, reason: 'worker-error' });
2448
2464
  debugCatch(e, 'enrich-save');
2449
2465
  } finally {
2450
2466
  try { db.close(); } catch {}
@@ -2487,22 +2503,20 @@ function handleAutoCompress() {
2487
2503
 
2488
2504
  // ─── Utilities ──────────────────────────────────────────────────────────────
2489
2505
 
2506
+ // P1-9: the mechanism is shared (lib/hook-stdin.mjs); the CALIBER stays this entry point's
2507
+ // own. 256 KB because a tool response is the largest payload the host sends here, and
2508
+ // `rejectOnTimeout` because this reader's callers treat a timeout as "drop the event" — the
2509
+ // alternative, acting on a partial payload, means writing a truncated tool response into
2510
+ // memory as if it were the whole thing. The other four hook processes are advisory and
2511
+ // resolve instead; those are different decisions about different payloads, not drift.
2490
2512
  function readStdin() {
2491
- const MAX_STDIN = MAX_HOOK_STDIN_BYTES; // large tool responses are truncated (shared tier, utils.mjs)
2492
- return new Promise((resolve, reject) => {
2493
- let data = '';
2494
- const timeout = setTimeout(() => { debugLog('WARN', 'readStdin', 'stdin timeout after 3s — event dropped'); process.stdin.destroy(); reject(new Error('timeout')); }, 3000);
2495
- process.stdin.setEncoding('utf8');
2496
- process.stdin.on('data', chunk => {
2497
- data += chunk;
2498
- if (data.length > MAX_STDIN) {
2499
- process.stdin.destroy(); clearTimeout(timeout);
2500
- resolve({ text: data.slice(0, MAX_STDIN), truncated: true });
2501
- }
2502
- });
2503
- process.stdin.on('end', () => { clearTimeout(timeout); resolve({ text: data, truncated: false }); });
2504
- process.stdin.on('error', err => { clearTimeout(timeout); reject(err); });
2505
- process.stdin.resume();
2513
+ return readHookStdin({
2514
+ timeoutMs: 3000,
2515
+ maxBytes: MAX_HOOK_STDIN_BYTES, // shared tier, utils.mjs
2516
+ rejectOnTimeout: true,
2517
+ }).catch((err) => {
2518
+ if (err?.message === 'timeout') debugLog('WARN', 'readStdin', 'stdin timeout after 3s — event dropped');
2519
+ throw err;
2506
2520
  });
2507
2521
  }
2508
2522
 
package/install.mjs CHANGED
@@ -7,7 +7,7 @@ import { join, resolve, dirname, isAbsolute, basename } from 'path';
7
7
  import { homedir, tmpdir } from 'os';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
9
9
  import { createRequire } from 'node:module';
10
- import { resolveDataDir } from './lib/resolve-data-dir.mjs';
10
+ import { resolveDataDir, resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
11
11
 
12
12
  const PROJECT_DIR = resolve(import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)));
13
13
  const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
@@ -22,6 +22,11 @@ const DATA_DIR = join(homedir(), '.claude-mem-lite');
22
22
  // the relocated dir → preinstalled skills silently vanished, doctor read the wrong
23
23
  // DB). Equals DATA_DIR when CLAUDE_MEM_DIR is unset (the common case).
24
24
  const MEM_DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
25
+ // Hook-WRITTEN runtime state (breakage markers, ep-flush/pending buffers) lives here.
26
+ // Installation-identity state — install.lock, update-state.json, update residue — stays
27
+ // under MEM_DATA_DIR on purpose: those are about the one real installation, and moving
28
+ // them with a per-harness override would let two concurrent installs take separate locks.
29
+ const MEM_RUNTIME_DIR = resolveRuntimeDir(MEM_DATA_DIR);
25
30
  const DB_PATH = join(MEM_DATA_DIR, 'claude-mem-lite.db');
26
31
  const OLD_DATA_DIR = join(homedir(), '.claude-mem');
27
32
 
@@ -33,8 +38,9 @@ const IS_NPX = process.env.npm_command === 'exec' ||
33
38
  const INSTALL_DIR = DATA_DIR;
34
39
  const SERVER_PATH = join(INSTALL_DIR, 'server.mjs');
35
40
  const HOOK_PATH = join(INSTALL_DIR, 'hook.mjs');
36
- const MARKETPLACE_KEY = 'sdsrss';
37
- const PLUGIN_KEY = `claude-mem-lite@${MARKETPLACE_KEY}`;
41
+ // P2-7: both constants and the predicate come from lib/plugin-key.mjs, which hook.mjs also
42
+ // imports this pair used to be typed out in each.
43
+ import { MARKETPLACE_KEY, PLUGIN_KEY, isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
38
44
  const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
39
45
 
40
46
  import { RESOURCE_METADATA } from './install-metadata.mjs';
@@ -1616,7 +1622,7 @@ async function doctor() {
1616
1622
  // broken install to exit 0 so it never spams a Node stack trace on every hook
1617
1623
  // fire. That silence is intentional but hides failure — it drops a breakage
1618
1624
  // marker so this check can surface the otherwise-invisible degraded state.
1619
- const brokenMarker = join(MEM_DATA_DIR, 'runtime', 'hook-launcher-broken');
1625
+ const brokenMarker = join(MEM_RUNTIME_DIR, 'hook-launcher-broken');
1620
1626
  if (existsSync(brokenMarker)) {
1621
1627
  let detail = '';
1622
1628
  try {
@@ -1634,7 +1640,7 @@ async function doctor() {
1634
1640
  // is 6h-rate-limited stderr nobody reads), the live probe says "is it broken
1635
1641
  // right now". A Node upgrade breaks every DB-touching path at once, so this is
1636
1642
  // the single highest-value line in doctor when it fires.
1637
- const breakage = readNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
1643
+ const breakage = readNativeBindingBreakage(MEM_RUNTIME_DIR);
1638
1644
  // Reuses the per-root probes above — same trees, same question, and doctor
1639
1645
  // should not pay for another round of child spawns to ask it twice.
1640
1646
  if (brokenRoots.length > 0) {
@@ -1785,7 +1791,7 @@ async function doctor() {
1785
1791
 
1786
1792
  // Update state
1787
1793
  try {
1788
- const stateFile = join(MEM_DATA_DIR, 'runtime', 'update-state.json');
1794
+ const stateFile = join(MEM_DATA_DIR, 'runtime', 'update-state.json'); // runtime-dir:stays-put — installation identity
1789
1795
  if (existsSync(stateFile)) {
1790
1796
  const state = JSON.parse(readFileSync(stateFile, 'utf8'));
1791
1797
  const parts = [];
@@ -1945,8 +1951,12 @@ async function doctor() {
1945
1951
  try {
1946
1952
  // hook-update + the episode workers write runtime/ + staging under DB_DIR
1947
1953
  // (= MEM_DATA_DIR, env-aware), NOT the homedir code dir — scan there so doctor
1948
- // sees the real residue under relocation.
1949
- const runtimeDir = join(MEM_DATA_DIR, 'runtime');
1954
+ // sees the real residue under relocation. MEM_RUNTIME_DIR rather than
1955
+ // join(MEM_DATA_DIR,'runtime'): `pending-*` / `ep-flush-*` are written through
1956
+ // hook-shared.mjs's override-aware RUNTIME_DIR, and `cleanup()` below deletes them from
1957
+ // MEM_RUNTIME_DIR — v3.93.0 moved the deleter and left this scanner behind, so under the
1958
+ // override doctor reported "none" while the cleanup it recommends removed files.
1959
+ const runtimeDir = MEM_RUNTIME_DIR;
1950
1960
  let staleCount = 0;
1951
1961
  const stalePatterns = ['.update-staging-', '.update-backup-'];
1952
1962
  if (existsSync(MEM_DATA_DIR)) {
@@ -2210,9 +2220,7 @@ function cleanupMemHooksFromSettings(settings) {
2210
2220
  return removed;
2211
2221
  }
2212
2222
 
2213
- function isPluginExplicitlyDisabled(settings) {
2214
- return settings?.enabledPlugins?.[PLUGIN_KEY] === false;
2215
- }
2223
+
2216
2224
 
2217
2225
  function getInstalledPluginEntries(installed) {
2218
2226
  if (installed?.plugins && typeof installed.plugins === 'object') return installed.plugins;
@@ -2273,8 +2281,8 @@ function cleanup() {
2273
2281
  }
2274
2282
  }
2275
2283
 
2276
- // Clean pending-* / ep-flush-* in runtime/ (under the env-aware data dir)
2277
- const runtimeDir = join(MEM_DATA_DIR, 'runtime');
2284
+ // Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override)
2285
+ const runtimeDir = MEM_RUNTIME_DIR;
2278
2286
  if (existsSync(runtimeDir)) {
2279
2287
  for (const f of readdirSync(runtimeDir)) {
2280
2288
  if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
@@ -2556,7 +2564,7 @@ function bindingHostDir() {
2556
2564
  // two concurrent rebuilds can clobber the .node mid-compile. A live peer → report
2557
2565
  // and exit 0 (it is doing this very work), never race it.
2558
2566
  async function rebuildBinding() {
2559
- const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock'));
2567
+ const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
2560
2568
  if (!release) {
2561
2569
  // NOT exit 0: skipping is not healing. Callers key their state on the exit
2562
2570
  // code — a false success would let the launcher drop its cooldown and the
@@ -2592,7 +2600,7 @@ async function rebuildBinding() {
2592
2600
  process.exitCode = 1;
2593
2601
  } else {
2594
2602
  // Every tree is loadable → drop the marker so session-start stops retrying.
2595
- clearNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
2603
+ clearNativeBindingBreakage(MEM_RUNTIME_DIR);
2596
2604
  }
2597
2605
  } finally {
2598
2606
  release();
@@ -2605,7 +2613,7 @@ async function rebuildBinding() {
2605
2613
  // install/self-heal) holds it → skip rather than race into a torn install. Lock
2606
2614
  // path is shared with hook-update.installExtractedRelease (both env-aware).
2607
2615
  async function runLockedInstall() {
2608
- const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock'));
2616
+ const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
2609
2617
  if (!release) {
2610
2618
  console.log('[install] Another install/repair is in progress — skipping to avoid a torn write.');
2611
2619
  return;
@@ -24,6 +24,8 @@
24
24
  // LIVE row and turned up in search results next to the weekly-summary keeper that
25
25
  // already absorbed it. Restore does not remap it (the keeper's id is meaningless in
26
26
  // the target store); it REJECTS marked rows instead — see cmdRestore.
27
+ import { liveObsFilterSql } from './inject-search-core.mjs';
28
+
27
29
  export const EXPORT_COLUMNS = [
28
30
  'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
29
31
  'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
@@ -34,3 +36,46 @@ export const EXPORT_COLUMNS = [
34
36
 
35
37
  // The SELECT column fragment (comma-joined) — drop into `SELECT ${EXPORT_COLUMNS_SQL} FROM …`.
36
38
  export const EXPORT_COLUMNS_SQL = EXPORT_COLUMNS.join(', ');
39
+
40
+ /**
41
+ * Assemble the `WHERE` predicate both export faces share.
42
+ *
43
+ * Audit 2026-09-02 P2-5. The COLUMN set above has been shared since v3.42 (HIGH-2, when the
44
+ * MCP handler was found carrying a narrower 16-column SELECT and silently dropping
45
+ * text/aliases/citation-signals from the advertised backup→restore flow). The PREDICATE was
46
+ * still typed out twice — `mem-cli.mjs cmdExport` and `server.mjs runExport` — which is the
47
+ * half-collapsed shape that lets a `WHERE` drift while the columns stay in step, on the one
48
+ * command whose output people restore from.
49
+ *
50
+ * Only the SQL is shared. Everything the two faces genuinely disagree about stays with them,
51
+ * because each disagreement is a decision rather than an accident:
52
+ * - PARSING and validation: the CLI `fail()`s with a usage message on a bad date and
53
+ * rejects an unknown `--type`; the MCP tool throws, and its type arrives through a
54
+ * schema enum. Callers pass epochs, already parsed.
55
+ * - The LIMIT: the CLI defaults to the complete matching set (it is the documented backup
56
+ * half of backup/restore); MCP defaults to 200 and probes limit+1, because an MCP result
57
+ * is model context and a bare exploratory call must not dump a store into a transcript.
58
+ * - The inverted-range note, which only the CLI has a stderr channel for.
59
+ *
60
+ * @param {object} o
61
+ * @param {boolean} [o.includeCompressed] Include compressed rows. Superseded rows are
62
+ * excluded EITHER WAY — exporting tombstones is opt-in, exporting retractions never is.
63
+ * @param {string|null} [o.project] Already resolved to a canonical project name.
64
+ * @param {string|null} [o.type] Already validated by the caller.
65
+ * @param {number|null} [o.fromEpoch] Inclusive lower bound, ms.
66
+ * @param {number|null} [o.toEpoch] Inclusive upper bound, ms (callers push it to
67
+ * end-of-day themselves when the user gave a bare date).
68
+ * @returns {{wheres: string[], params: Array<string|number>, where: string}}
69
+ * `wheres`/`params` for a caller that composes its own clause; `where` is the ready
70
+ * `WHERE …` string (never empty — the live-row predicate is always present).
71
+ */
72
+ export function buildExportWhere({ includeCompressed = false, project = null, type = null, fromEpoch = null, toEpoch = null } = {}) {
73
+ const wheres = [];
74
+ const params = [];
75
+ wheres.push(includeCompressed ? 'superseded_at IS NULL' : liveObsFilterSql(''));
76
+ if (project) { wheres.push('project = ?'); params.push(project); }
77
+ if (type) { wheres.push('type = ?'); params.push(type); }
78
+ if (fromEpoch !== null && fromEpoch !== undefined) { wheres.push('created_at_epoch >= ?'); params.push(fromEpoch); }
79
+ if (toEpoch !== null && toEpoch !== undefined) { wheres.push('created_at_epoch <= ?'); params.push(toEpoch); }
80
+ return { wheres, params, where: 'WHERE ' + wheres.join(' AND ') };
81
+ }
package/lib/get-core.mjs CHANGED
@@ -34,6 +34,23 @@ export function fetchPromptDetail(db, ids) {
34
34
  return db.prepare(`SELECT * FROM user_prompts WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...ids);
35
35
  }
36
36
 
37
+ /**
38
+ * Fetch session-summary detail rows, oldest-first.
39
+ *
40
+ * Audit 2026-09-02 P2-4: the session leg was the one detail source with no shared fetch.
41
+ * Its FIELD SET already came from `SESSION_DETAIL_FIELDS` here, so the twin was down to the
42
+ * query itself — typed out in `mem-cli.mjs renderSessionRows` and in `server.mjs mem_get`.
43
+ * A shared field list over two hand-copied SELECTs is the half-collapsed shape that lets a
44
+ * `WHERE` clause drift while the columns stay in step.
45
+ *
46
+ * No access bump, matching `fetchPromptDetail`: session summaries are not ranked, so
47
+ * reading one is not a usage signal.
48
+ */
49
+ export function fetchSessionDetail(db, ids) {
50
+ const ph = ids.map(() => '?').join(',');
51
+ return db.prepare(`SELECT * FROM session_summaries WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...ids);
52
+ }
53
+
37
54
  /**
38
55
  * Fetch event detail rows, oldest-first.
39
56
  *