claude-mem-lite 3.92.0 → 3.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.92.0",
13
+ "version": "3.93.0",
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.0",
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,19 @@ 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, `metrics/`, episode
901
+ buffers. Before v3.93.0 it was honoured by some readers and ignored by others, so setting it
902
+ split the runtime rather than moving it. Installation-identity state (`install.lock`,
903
+ `update-state.json`, update residue) deliberately stays under `CLAUDE_MEM_DIR`: two
904
+ installers pointed at different override directories would otherwise each take their own
905
+ lock and both proceed. **`CLAUDE_MEM_DB_PATH` still has the split shape**, and more narrowly
906
+ than it looks: exactly ONE component reads it — `scripts/pre-tool-recall.js` — so setting it
907
+ aims that single hook at one database and leaves the other four hook faces, the CLI and the
908
+ MCP server on the default. Use `CLAUDE_MEM_DIR` — the only override
909
+ every component respects, including the bash pre-filter — to isolate state.
910
+
898
911
  Three more are set by `vitest.config.mjs` / `tests/global-setup.mjs` and exist only to
899
912
  keep a test run off the live database: `CLAUDE_MEM_TEST_GUARD` (`1` arms the guard, `off`
900
913
  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.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) {
@@ -2210,9 +2216,7 @@ function cleanupMemHooksFromSettings(settings) {
2210
2216
  return removed;
2211
2217
  }
2212
2218
 
2213
- function isPluginExplicitlyDisabled(settings) {
2214
- return settings?.enabledPlugins?.[PLUGIN_KEY] === false;
2215
- }
2219
+
2216
2220
 
2217
2221
  function getInstalledPluginEntries(installed) {
2218
2222
  if (installed?.plugins && typeof installed.plugins === 'object') return installed.plugins;
@@ -2273,8 +2277,8 @@ function cleanup() {
2273
2277
  }
2274
2278
  }
2275
2279
 
2276
- // Clean pending-* / ep-flush-* in runtime/ (under the env-aware data dir)
2277
- const runtimeDir = join(MEM_DATA_DIR, 'runtime');
2280
+ // Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override)
2281
+ const runtimeDir = MEM_RUNTIME_DIR;
2278
2282
  if (existsSync(runtimeDir)) {
2279
2283
  for (const f of readdirSync(runtimeDir)) {
2280
2284
  if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
@@ -2592,7 +2596,7 @@ async function rebuildBinding() {
2592
2596
  process.exitCode = 1;
2593
2597
  } else {
2594
2598
  // Every tree is loadable → drop the marker so session-start stops retrying.
2595
- clearNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
2599
+ clearNativeBindingBreakage(MEM_RUNTIME_DIR);
2596
2600
  }
2597
2601
  } finally {
2598
2602
  release();
@@ -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
  *
@@ -0,0 +1,134 @@
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
+ const fp = prefix.match(/"file_path"\s*:\s*"((?:[^"\\]|\\.)*)"/);
65
+ if (!fp) return null;
66
+ let filePath;
67
+ // The captured group is still JSON-escaped (Windows paths arrive as `C:\\x`).
68
+ try { filePath = JSON.parse(`"${fp[1]}"`); } catch { return null; }
69
+ if (!filePath) return null;
70
+ const sid = prefix.match(/"session_id"\s*:\s*"([^"\\]*)"/);
71
+ const tn = prefix.match(/"tool_name"\s*:\s*"([^"\\]*)"/);
72
+ return { filePath, sessionId: sid ? sid[1] : null, toolName: tn ? tn[1] : null };
73
+ }
74
+
75
+
76
+ /** Default timeout: under the host's ~3 s fail-open, so we decide rather than get killed. */
77
+ export const DEFAULT_STDIN_TIMEOUT_MS = 3000;
78
+
79
+ /**
80
+ * Read the hook payload from a stream, bounded in both bytes and time.
81
+ *
82
+ * @param {object} [opts]
83
+ * @param {number} [opts.timeoutMs] Give up after this long.
84
+ * @param {number} [opts.maxBytes] Stop reading past this many characters.
85
+ * @param {boolean} [opts.rejectOnTimeout=false] `true` reproduces hook.mjs's contract, where
86
+ * a timeout drops the event rather than acting on a partial payload. `false` resolves with
87
+ * whatever arrived and `timedOut: true` — the right call for a path that is advisory and
88
+ * must never throw into a host hook.
89
+ * @param {NodeJS.ReadableStream} [opts.stream=process.stdin] Injectable for tests; nothing
90
+ * else about this module is observable without it.
91
+ * @returns {Promise<{text: string, truncated: boolean, timedOut: boolean}>}
92
+ */
93
+ export function readHookStdin({
94
+ timeoutMs = DEFAULT_STDIN_TIMEOUT_MS,
95
+ maxBytes = DEFAULT_STDIN_MAX_BYTES,
96
+ rejectOnTimeout = false,
97
+ stream = process.stdin,
98
+ } = {}) {
99
+ return new Promise((resolve, reject) => {
100
+ let data = '';
101
+ // Every path below routes through settle(), and settle() is a no-op after the first
102
+ // call. The hand-written copies each had to get this right on four separate paths
103
+ // (cap hit, end, error, timer) and the `.destroy()` in the cap branch can itself emit
104
+ // 'error' — so "resolve twice" and "resolve then reject" were both reachable shapes.
105
+ let done = false;
106
+ const settle = (fn, arg) => {
107
+ if (done) return;
108
+ done = true;
109
+ clearTimeout(timer);
110
+ try { stream.destroy(); } catch { /* already gone */ }
111
+ fn(arg);
112
+ };
113
+
114
+ const timer = setTimeout(() => {
115
+ if (rejectOnTimeout) settle(reject, new Error('timeout'));
116
+ else settle(resolve, { text: data, truncated: false, timedOut: true });
117
+ }, timeoutMs);
118
+
119
+ try {
120
+ stream.setEncoding('utf8');
121
+ stream.on('data', (chunk) => {
122
+ data += chunk;
123
+ if (data.length > maxBytes) {
124
+ settle(resolve, { text: data.slice(0, maxBytes), truncated: true, timedOut: false });
125
+ }
126
+ });
127
+ stream.on('end', () => settle(resolve, { text: data, truncated: false, timedOut: false }));
128
+ stream.on('error', (err) => settle(reject, err));
129
+ stream.resume();
130
+ } catch (err) {
131
+ settle(reject, err);
132
+ }
133
+ });
134
+ }