claude-mem-lite 3.91.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.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +14 -0
  4. package/README.zh-CN.md +1 -0
  5. package/claudemd.mjs +9 -6
  6. package/hash-utils.mjs +12 -0
  7. package/hook-context.mjs +9 -8
  8. package/hook-llm.mjs +96 -37
  9. package/hook-optimize.mjs +51 -29
  10. package/hook-shared.mjs +62 -12
  11. package/hook.mjs +139 -56
  12. package/install.mjs +24 -13
  13. package/lib/atomic-write.mjs +14 -3
  14. package/lib/cite-back-hint.mjs +3 -3
  15. package/lib/cite-recall-path.mjs +52 -0
  16. package/lib/compress-core.mjs +31 -15
  17. package/lib/export-columns.mjs +45 -0
  18. package/lib/frontmatter.mjs +67 -0
  19. package/lib/get-core.mjs +17 -0
  20. package/lib/hook-stdin.mjs +134 -0
  21. package/lib/injected-ids.mjs +93 -4
  22. package/lib/maintain-core.mjs +191 -5
  23. package/lib/observation-write.mjs +71 -13
  24. package/lib/plugin-key.mjs +44 -0
  25. package/lib/registry-core.mjs +165 -1
  26. package/lib/resolve-data-dir.mjs +58 -0
  27. package/lib/save-enrich.mjs +10 -6
  28. package/lib/save-observation.mjs +44 -0
  29. package/lib/transcript-scan.mjs +59 -8
  30. package/mem-cli.mjs +95 -229
  31. package/memdir.mjs +6 -6
  32. package/npm-shrinkwrap.json +20 -2
  33. package/package.json +11 -1
  34. package/registry-importer.mjs +4 -34
  35. package/registry-recommend.mjs +2 -2
  36. package/registry.mjs +2 -1
  37. package/schema.mjs +18 -1
  38. package/scripts/hook-launcher.mjs +10 -0
  39. package/scripts/post-tool-recall.js +19 -8
  40. package/scripts/pre-agent-inject.js +12 -4
  41. package/scripts/pre-skill-bridge.js +9 -5
  42. package/scripts/pre-tool-recall.js +44 -40
  43. package/scripts/user-prompt-search.js +37 -60
  44. package/search-scoring.mjs +10 -0
  45. package/server.mjs +95 -215
  46. package/source-files.mjs +4 -0
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,11 +44,13 @@ 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 {
48
51
  RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
49
52
  SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS, AUTO_MAINTAIN_LOCK,
53
+ STALE_EPISODE_BUFFER_AGE_MS,
50
54
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
51
55
  sessionFile, getSessionId, createSessionId, openDb,
52
56
  spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
@@ -80,20 +84,15 @@ import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from
80
84
  import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
81
85
  import { upsFtsQuery } from './lib/ups-query.mjs';
82
86
  import { formatTaskImperative } from './lib/task-imperative.mjs';
83
- import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
84
87
  import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
85
88
  import { detectMemOverride } from './lib/mem-override.mjs';
86
- import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
87
- import { pathAMeterEnabled, coerceMarkerIds, recordPathAExclude } from './lib/patha-exclude-meter.mjs';
89
+ import { injectedIdsFileName, keyContextIdsFileName, readInjectedMarker } from './lib/injected-ids.mjs';
88
90
  import { recordKeyContextInjection, touchKeyContextMarker } from './lib/keyctx-marker.mjs';
89
91
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
90
92
  import { selectErrorRecall } from './lib/error-recall-core.mjs';
91
93
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
92
- import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
93
- import { handleLLMOptimize } from './hook-optimize.mjs';
94
- import { silentAutoAdopt } from './adopt-cli.mjs';
95
- import { emitV270UpgradeBanner, hasPreV270Data } from './lib/upgrade-banner.mjs';
96
94
  import { loadCiteBackForEpisode, extractCiteBackSignals, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
95
+ import { citeRecallPathFor } from './lib/cite-recall-path.mjs';
97
96
  import { detectUnpersistedDecision } from './lib/persist-reminder.mjs';
98
97
  // plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
99
98
  // from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
@@ -105,6 +104,31 @@ async function loadCacheGuard() {
105
104
  catch { _cacheGuardCache = {}; }
106
105
  return _cacheGuardCache;
107
106
  }
107
+ // Audit 2026-09-02 P1-8. `hook.mjs` is ONE entry point for seven events, so every static
108
+ // import is paid by every event — PostToolUse, the highest-frequency one, was loading 85
109
+ // modules (1.37 MB) to use a handful. The six modules below are loaded inside the handler
110
+ // that needs them instead: registry-recommend, patha-exclude-meter, hook-update,
111
+ // hook-optimize, adopt-cli, upgrade-banner. Measured marginal cost of the four largest,
112
+ // same process: hook-update 9.4 ms, registry-recommend 5.1, hook-optimize 5.9, the rest
113
+ // 0.5-2.4 each.
114
+ //
115
+ // A failing dynamic import lands in the dispatcher's tail catch -> recordHookError, the
116
+ // same place a failing static import would have landed the whole process; each site keeps
117
+ // whatever local try/catch it already had, so a missing module degrades exactly one
118
+ // feature rather than the event.
119
+ //
120
+ // Three named by the audit are deliberately NOT converted, because the reason they are
121
+ // loaded is not that nobody looked:
122
+ // * hook-llm.mjs (+16.3 ms, the single largest) — `flushEpisode` is SYNCHRONOUS and on
123
+ // the PostToolUse path, and it calls `saveEpisodeImmediate` from that module. Lazy
124
+ // loading needs either an async rewrite of the flush path or extracting the function,
125
+ // and extraction does not pay: `saveEpisodeImmediate` reaches `saveObservation`, which
126
+ // pulls tfidf / observation-write / activity / maintain-core anyway — only
127
+ // haiku-client would actually stop loading.
128
+ // * lib/db-backup.mjs and lib/compress-core.mjs (<=2.4 ms each) — their call sites sit
129
+ // in `runSessionStartAutoMaintain` and `handleAutoCompress`, both synchronous. Turning
130
+ // two functions and their callers async across a background-worker path costs more
131
+ // risk than the milliseconds are worth.
108
132
  import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
109
133
  import { getVocabulary } from './tfidf.mjs';
110
134
 
@@ -128,18 +152,22 @@ const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-o
128
152
  // Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
129
153
  // install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
130
154
  // in Claude UI does not automatically remove them. Exit early to make disable actually work.
131
- const PLUGIN_KEY = 'claude-mem-lite@sdsrss';
132
- 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() {
133
160
  try {
134
161
  const settingsPath = join(homedir(), '.claude', 'settings.json');
135
- const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
136
- return settings.enabledPlugins?.[PLUGIN_KEY] === false;
162
+ return isPluginExplicitlyDisabled(JSON.parse(readFileSync(settingsPath, 'utf8')));
137
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.
138
166
  return false;
139
167
  }
140
168
  }
141
169
 
142
- if (event && isPluginExplicitlyDisabled()) process.exit(0);
170
+ if (event && pluginDisabledHere()) process.exit(0);
143
171
  if (process.env.CLAUDE_MEM_HOOK_RUNNING && !BG_EVENTS.has(event)) process.exit(0);
144
172
 
145
173
  // Crash-safe: flush episode buffer on unexpected termination to prevent data loss
@@ -374,7 +402,7 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
374
402
  // and with the flag on that case is strictly better than before, because the reads file
375
403
  // was never touched and the retry still finds it.
376
404
  // Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
377
- recordMetric(join(RUNTIME_DIR, '..'), {
405
+ recordMetric(DB_DIR, {
378
406
  event: 'episode_reads',
379
407
  readsConsumed: (episode.filesRead || []).length,
380
408
  readsHeld,
@@ -438,7 +466,7 @@ function flushEpisodeGroup(ep, db) {
438
466
  // episodes are kept ONLY because of their Greps — and demoting what the product
439
467
  // remembers on a deduction is how work disappears silently. This is that counter.
440
468
  // Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
441
- recordMetric(join(RUNTIME_DIR, '..'), {
469
+ recordMetric(DB_DIR, {
442
470
  event: 'episode_significance',
443
471
  rule: verdict.rule,
444
472
  significant: isSignificant,
@@ -523,7 +551,12 @@ async function handlePostToolUse() {
523
551
  const ti = typeof tool_input === 'string' ? tryParseJson(tool_input) : (tool_input || {});
524
552
  // hookData.session_id (CC UUID) pairs this adoption to the would-be reco from the
525
553
  // UserPromptSubmit hook earlier in the same session (matched precision, B1).
526
- try { recordSkillAdoption('Skill', ti, inferProject(), hookData.session_id); } catch {}
554
+ // Lazy: only a `Skill` tool call needs this module, which is a small fraction of
555
+ // PostToolUse fires.
556
+ try {
557
+ const { recordSkillAdoption } = await import('./registry-recommend.mjs');
558
+ recordSkillAdoption('Skill', ti, inferProject(), hookData.session_id);
559
+ } catch { /* telemetry only — never blocks capture */ }
527
560
  }
528
561
 
529
562
  const resp = normalizeToolResponse(tool_response);
@@ -656,7 +689,7 @@ function triggerErrorRecall(db, toolInput, response, opts = {}) {
656
689
  // G13: this surface feeds the citation denominator but had zero metering —
657
690
  // the G8 gate change (isError→isHardError) could not be volume-verified
658
691
  // from metrics. Counter only; no latency (query is bundled in the hook).
659
- recordMetric(join(RUNTIME_DIR, '..'), { event: metricEvent, returned: rows.length });
692
+ recordMetric(DB_DIR, { event: metricEvent, returned: rows.length });
660
693
  // MED-3 (full audit 2026-07-16): go through the envelope, NOT raw stdout —
661
694
  // a raw multi-line write corrupts a co-emitted episode-flush receipt.
662
695
  // The follow-up correction (2026-08-17): "two separate JSON lines each parse
@@ -1160,7 +1193,7 @@ async function handleStop() {
1160
1193
  // alongside cite-recall. Same scan target (transcript already in OS
1161
1194
  // cache); same persistence file; one extra line in buildCiteRecallNudge.
1162
1195
  const bugfixStats = countUnsavedBugfixShape(transcriptPath);
1163
- const dest = join(RUNTIME_DIR, `cite-recall-${project.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64)}.json`);
1196
+ const dest = citeRecallPathFor(RUNTIME_DIR, project);
1164
1197
  // Carry the consecutive-low-cite streak forward so the SessionStart
1165
1198
  // nag can self-silence after the project has ignored it N times.
1166
1199
  let priorStreak = 0;
@@ -1459,7 +1492,13 @@ function runSessionStartAutoMaintain(db, project) {
1459
1492
  // Auto-dedup (fuzzy): catches near-identical titles that exact-match
1460
1493
  // misses across larger time windows — e.g. episode-batch titles like
1461
1494
  // "Modified A.mjs, B.mjs" vs "Modified B.mjs, A.mjs" written days apart.
1462
- // 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
1463
1502
  // well clear of legit "two updates same area" pairs (those typically
1464
1503
  // score 0.7–0.85, surfaced via `maintain scan` for manual review).
1465
1504
  // Bounded by ${SCAN_LIMIT} recent rows × ${FUZZY_MAX_MERGES}-merge cap.
@@ -1526,8 +1565,18 @@ function runSessionStartAutoMaintain(db, project) {
1526
1565
  // the file behind, and the doctor "Stale temp files" warning then
1527
1566
  // accumulates indefinitely. fs-only; runs inside the 24h gate so it
1528
1567
  // shares cadence with the rest of auto-maintain.
1568
+ //
1569
+ // Since audit P1-12 this also reclaims abandoned per-project episode buffers at 7d.
1570
+ // That one is named individually in the log: every other family it sweeps is residue
1571
+ // or a re-derivable tracker, while `ep-<project>.json` holds unflushed observations —
1572
+ // and the alternative to deleting it is worse (SessionStart flushes whatever it finds
1573
+ // with no staleness gate, so a revisit stamps months-old activity with today's date).
1529
1574
  try {
1530
- const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR);
1575
+ const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR, {
1576
+ onSweep: (name, kind) => {
1577
+ if (kind === 'buffer') debugLog('DEBUG', 'auto-maintain', `discarding abandoned episode buffer ${name} (>7d; would otherwise flush mis-dated on revisit)`);
1578
+ },
1579
+ });
1531
1580
  if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
1532
1581
  } catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
1533
1582
 
@@ -1798,10 +1847,10 @@ async function handleSessionStart() {
1798
1847
  // SessionStart cadence, 30d gate, named family list (hook-shared.mjs).
1799
1848
  try { sweepStaleProjectMarkers(RUNTIME_DIR); } catch { /* best-effort */ }
1800
1849
  // Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
1801
- try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
1802
- // Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
1850
+ try { const { gcOldShadowShards } = await import('./registry-recommend.mjs'); gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
1851
+ // Same for the opt-in metrics sink, which lives under DB_DIR. Runs even when
1803
1852
  // metrics are disabled, so shards left by a since-toggled-off run still get pruned.
1804
- try { gcOldMetricShards(join(RUNTIME_DIR, '..')); } catch { /* best-effort */ }
1853
+ try { gcOldMetricShards(DB_DIR); } catch { /* best-effort */ }
1805
1854
 
1806
1855
  // Plugin cache self-heal: Claude Code auto-updates the marketplace plugin can
1807
1856
  // re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
@@ -1842,6 +1891,7 @@ async function handleSessionStart() {
1842
1891
  if (process.env.MEM_NO_AUTO_ADOPT !== '1') {
1843
1892
  const project = inferProject();
1844
1893
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
1894
+ const { silentAutoAdopt } = await import('./adopt-cli.mjs');
1845
1895
  const r = silentAutoAdopt({ cwd, markerDir: RUNTIME_DIR, markerKey: project });
1846
1896
  if (r.ok) {
1847
1897
  debugLog('DEBUG', 'session-start-auto-adopt', `action=${r.action} project=${project}`);
@@ -1865,12 +1915,30 @@ async function handleSessionStart() {
1865
1915
  // Snapshot episode BEFORE flush for handoff extraction
1866
1916
  const episodeSnapshot = readEpisodeRaw();
1867
1917
 
1868
- // Flush any leftover episode buffer from previous session (e.g. after /clear)
1918
+ // Flush any leftover episode buffer from previous session (e.g. after /clear).
1919
+ //
1920
+ // A buffer older than STALE_EPISODE_BUFFER_AGE_MS is DISCARDED, not flushed. This is the
1921
+ // half of P1-12 the orphan sweep cannot reach: the sweep runs inside the detached
1922
+ // auto-maintain worker scheduled further down this same function, so on the revisit that
1923
+ // matters the flush below has already happened and the sweeper finds nothing. Without this
1924
+ // gate, returning to a project abandoned months ago injects its months-old tool activity
1925
+ // stamped with today's date — the exact harm hook-shared.mjs's threshold docblock names.
1926
+ // Same constant, because it is the same question ("did this buffer outlive its session by
1927
+ // an order of magnitude?"), asked at the other end.
1869
1928
  if (acquireLock()) {
1870
1929
  try {
1871
- const prevEpisode = readEpisode();
1872
- if (prevEpisode && prevEpisode.entries && prevEpisode.entries.length > 0) {
1873
- flushEpisode(prevEpisode, 'SessionStart');
1930
+ let stale = false;
1931
+ try {
1932
+ stale = Date.now() - statSync(episodeFile()).mtimeMs > STALE_EPISODE_BUFFER_AGE_MS;
1933
+ } catch { /* no buffer file — readEpisode() returns null below */ }
1934
+ if (stale) {
1935
+ debugLog('INFO', 'session-start', `discarding stale episode buffer (>${STALE_EPISODE_BUFFER_AGE_MS}ms): ${episodeFile()}`);
1936
+ try { unlinkSync(episodeFile()); } catch { /* best-effort */ }
1937
+ } else {
1938
+ const prevEpisode = readEpisode();
1939
+ if (prevEpisode && prevEpisode.entries && prevEpisode.entries.length > 0) {
1940
+ flushEpisode(prevEpisode, 'SessionStart');
1941
+ }
1874
1942
  }
1875
1943
  } finally {
1876
1944
  releaseLock();
@@ -1949,6 +2017,7 @@ async function handleSessionStart() {
1949
2017
  // the single envelope; the spawn stays a side effect and is fired below.
1950
2018
  let updateCheckDue = false;
1951
2019
  try {
2020
+ const { getCachedUpdateBanner, isUpdateCheckDue } = await import('./hook-update.mjs');
1952
2021
  const banner = getCachedUpdateBanner();
1953
2022
  // The human channel, not additionalContext: "vX available" is a notice for the
1954
2023
  // USER. Folding it into additionalContext under suppressOutput:true kept its
@@ -1998,6 +2067,7 @@ async function handleSessionStart() {
1998
2067
  // "Any observations at all" still misfired for someone who installed today
1999
2068
  // and saved a few memories before their first SessionStart — age is what
2000
2069
  // actually identifies an upgrader (see lib/upgrade-banner.mjs).
2070
+ const { emitV270UpgradeBanner, hasPreV270Data } = await import('./lib/upgrade-banner.mjs');
2001
2071
  emitV270UpgradeBanner({
2002
2072
  project,
2003
2073
  runtimeDir: RUNTIME_DIR,
@@ -2156,8 +2226,11 @@ async function handleUserPrompt() {
2156
2226
  // (inject, maybe duplicate) rather than fail closed (suppress).
2157
2227
  const keyContextIds = [];
2158
2228
  try {
2159
- const raw = readFileSync(join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)), 'utf8');
2160
- 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);
2161
2234
  if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
2162
2235
  keyContextIds.push(...ids);
2163
2236
  }
@@ -2175,13 +2248,16 @@ async function handleUserPrompt() {
2175
2248
  // project-keyed name), so a concurrent session's write can no longer
2176
2249
  // replace this session's payload between the UPS write and this read.
2177
2250
  const injectedFile = join(RUNTIME_DIR, injectedIdsFileName(project, ccSessionId));
2178
- const raw = readFileSync(injectedFile, 'utf8');
2179
- const { ids, ts, session } = JSON.parse(raw);
2180
- // Only use if written within last 10 seconds (same prompt cycle) AND by this
2181
- // CC session (M-6 payload gate, still load-bearing for legacy files).
2182
- // Legacy payloads without `session` keep the old time-window-only behavior.
2183
- if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
2184
- && !(session && ccSessionId && session !== ccSessionId)) {
2251
+ // The freshness + same-session gate is lib/injected-ids.mjs's (audit 2026-09-02
2252
+ // P1-2); this was the third hand-typed copy of it. THE 10 s WINDOW STAYS HERE and
2253
+ // is passed in: the two writers gate on DEDUP_STALE_MS (5 min) and this reader on
2254
+ // 10 s ("same prompt cycle"), and that disagreement is a real open question
2255
+ // (P1-2's second half — the 10 s window still accepts the PREVIOUS prompt's
2256
+ // marker), not a copy-paste slip to be normalised away by the consolidation.
2257
+ // Legacy payloads without `session` keep the old time-window-only behaviour.
2258
+ const { ids, fresh } = readInjectedMarker(injectedFile,
2259
+ { sessionId: ccSessionId, maxAgeMs: 10000 });
2260
+ if (fresh) {
2185
2261
  // D#193, DELIBERATELY NOT NUMERICALISED — read this before "fixing" it.
2186
2262
  //
2187
2263
  // Ids arrive here as written. `user-prompt-search.js` writes plain numbers, but
@@ -2258,7 +2334,16 @@ async function handleUserPrompt() {
2258
2334
  // Arm B also carries its OWN imperative pick. Reusing arm A's put a pick the
2259
2335
  // repaired system would not have made into arm B's exclude, so on any prompt where
2260
2336
  // the pick changed, the delta described a system that does not exist.
2261
- const meterCoerced = (pathAMeterEnabled() && pathAInjectedIds.length > 0)
2337
+ // Lazy on "the marker carried ids", NOT on the metrics env. Gating the import on
2338
+ // `CLAUDE_MEM_METRICS === '1'` would read cheaper still, and would put a second copy
2339
+ // of `pathAMeterEnabled`'s own predicate here — the twin shape this meter's tests
2340
+ // exist to pin. `pathAMeterEnabled()` stays the only place that predicate lives; the
2341
+ // module still stops loading on every OTHER event, which is what P1-8 is about.
2342
+ let pathAMeterEnabled, coerceMarkerIds, recordPathAExclude;
2343
+ if (pathAInjectedIds.length > 0) {
2344
+ ({ pathAMeterEnabled, coerceMarkerIds, recordPathAExclude } = await import('./lib/patha-exclude-meter.mjs'));
2345
+ }
2346
+ const meterCoerced = (pathAMeterEnabled && pathAMeterEnabled())
2262
2347
  ? [...coerceMarkerIds(pathAInjectedIds)]
2263
2348
  : null;
2264
2349
  let meterArmB = null;
@@ -2333,7 +2418,7 @@ async function handleUserPrompt() {
2333
2418
  // counterfactual search and the second lesson selection off a stock install.
2334
2419
  try {
2335
2420
  if (meterCoerced) {
2336
- recordPathAExclude(join(RUNTIME_DIR, '..'), {
2421
+ recordPathAExclude(DB_DIR, {
2337
2422
  markerIds: pathAInjectedIds,
2338
2423
  emitted: memories,
2339
2424
  after: meterArmB,
@@ -2368,14 +2453,14 @@ async function handleEnrichSave(rawId) {
2368
2453
  // work" was invisible (32% alias coverage with 3 indistinguishable failure
2369
2454
  // causes). reason 'filled-concurrently' = txn ran but a concurrent optimize/
2370
2455
  // update had already filled every empty field.
2371
- recordMetric(join(RUNTIME_DIR, '..'), {
2456
+ recordMetric(DB_DIR, {
2372
2457
  event: 'enrich_save',
2373
2458
  id,
2374
2459
  enriched: result.enriched,
2375
2460
  reason: result.reason ?? (result.enriched ? 'enriched' : 'filled-concurrently'),
2376
2461
  });
2377
2462
  } catch (e) {
2378
- 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' });
2379
2464
  debugCatch(e, 'enrich-save');
2380
2465
  } finally {
2381
2466
  try { db.close(); } catch {}
@@ -2418,22 +2503,20 @@ function handleAutoCompress() {
2418
2503
 
2419
2504
  // ─── Utilities ──────────────────────────────────────────────────────────────
2420
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.
2421
2512
  function readStdin() {
2422
- const MAX_STDIN = MAX_HOOK_STDIN_BYTES; // large tool responses are truncated (shared tier, utils.mjs)
2423
- return new Promise((resolve, reject) => {
2424
- let data = '';
2425
- const timeout = setTimeout(() => { debugLog('WARN', 'readStdin', 'stdin timeout after 3s — event dropped'); process.stdin.destroy(); reject(new Error('timeout')); }, 3000);
2426
- process.stdin.setEncoding('utf8');
2427
- process.stdin.on('data', chunk => {
2428
- data += chunk;
2429
- if (data.length > MAX_STDIN) {
2430
- process.stdin.destroy(); clearTimeout(timeout);
2431
- resolve({ text: data.slice(0, MAX_STDIN), truncated: true });
2432
- }
2433
- });
2434
- process.stdin.on('end', () => { clearTimeout(timeout); resolve({ text: data, truncated: false }); });
2435
- process.stdin.on('error', err => { clearTimeout(timeout); reject(err); });
2436
- 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;
2437
2520
  });
2438
2521
  }
2439
2522
 
@@ -2493,7 +2576,7 @@ try {
2493
2576
  case 'auto-compress': handleAutoCompress(); break;
2494
2577
  case 'enrich-save': await handleEnrichSave(process.argv[3]); break;
2495
2578
  case 'auto-maintain': handleAutoMaintain(process.argv[3]); break;
2496
- case 'llm-optimize': await handleLLMOptimize(); break;
2579
+ case 'llm-optimize': { const { handleLLMOptimize } = await import('./hook-optimize.mjs'); await handleLLMOptimize(); break; }
2497
2580
  // Detached update refresh spawned by handleSessionStart (audit P3d) — does the
2498
2581
  // GitHub fetch off the SessionStart critical path, writing update-state.json so
2499
2582
  // the NEXT session's cached banner is fresh.
@@ -2507,7 +2590,7 @@ try {
2507
2590
  // self-installer back on in the same release that resurrects the worker. The
2508
2591
  // module default and the installer's own guards are unchanged — install.mjs
2509
2592
  // still passes allowInstall:true for the explicit, user-invoked update.
2510
- case 'update-check': await checkForUpdate({ allowInstall: false }); break;
2593
+ case 'update-check': { const { checkForUpdate } = await import('./hook-update.mjs'); await checkForUpdate({ allowInstall: false }); break; }
2511
2594
  }
2512
2595
  } catch (err) {
2513
2596
  // Log fatal errors (ungated) with structured format. ERR_DLOPEN_FAILED (an
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';
@@ -614,7 +620,11 @@ if (existsSync(pluginDir)) {
614
620
  if (existsSync(pluginHooksPath)) {
615
621
  const pluginHooks = JSON.parse(readFileSync(pluginHooksPath, 'utf8'));
616
622
  if (pluginHooks.hooks && Object.keys(pluginHooks.hooks).length > 0) {
617
- writeFileSync(pluginHooksPath, JSON.stringify({
623
+ // Atomic (audit 2026-09-02 P1-10): a torn hooks.json is not a fail-open marker —
624
+ // Claude Code parses it at plugin load, so half a file disables the plugin's hooks
625
+ // for that install until the next successful write. Same writer settings.json
626
+ // already uses 1600 lines down.
627
+ atomicWriteFileSync(pluginHooksPath, JSON.stringify({
618
628
  description: pluginHooks.description || 'claude-mem-lite hooks',
619
629
  _note: 'Hooks managed by install.mjs in settings.json — this file cleared to prevent duplicates',
620
630
  hooks: {}
@@ -653,7 +663,10 @@ if (existsSync(pluginDir)) {
653
663
  try {
654
664
  const h = JSON.parse(readFileSync(cachedHooksPath, 'utf8'));
655
665
  if (h.hooks && Object.keys(h.hooks).length > 0) {
656
- writeFileSync(cachedHooksPath, JSON.stringify({
666
+ // Atomic, same reason as the marketplace-source copy above (P1-10). This one
667
+ // is the higher-cost of the two: it runs once PER CACHED VERSION, so a tear
668
+ // here disables hooks for whichever version Claude Code happens to load.
669
+ atomicWriteFileSync(cachedHooksPath, JSON.stringify({
657
670
  description: h.description || 'claude-mem-lite hooks',
658
671
  _note: `Hooks managed by install.mjs in settings.json — cache hooks.json cleared to prevent duplicate registration (cache ver: ${ver})`,
659
672
  hooks: {}
@@ -1609,7 +1622,7 @@ async function doctor() {
1609
1622
  // broken install to exit 0 so it never spams a Node stack trace on every hook
1610
1623
  // fire. That silence is intentional but hides failure — it drops a breakage
1611
1624
  // marker so this check can surface the otherwise-invisible degraded state.
1612
- const brokenMarker = join(MEM_DATA_DIR, 'runtime', 'hook-launcher-broken');
1625
+ const brokenMarker = join(MEM_RUNTIME_DIR, 'hook-launcher-broken');
1613
1626
  if (existsSync(brokenMarker)) {
1614
1627
  let detail = '';
1615
1628
  try {
@@ -1627,7 +1640,7 @@ async function doctor() {
1627
1640
  // is 6h-rate-limited stderr nobody reads), the live probe says "is it broken
1628
1641
  // right now". A Node upgrade breaks every DB-touching path at once, so this is
1629
1642
  // the single highest-value line in doctor when it fires.
1630
- const breakage = readNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
1643
+ const breakage = readNativeBindingBreakage(MEM_RUNTIME_DIR);
1631
1644
  // Reuses the per-root probes above — same trees, same question, and doctor
1632
1645
  // should not pay for another round of child spawns to ask it twice.
1633
1646
  if (brokenRoots.length > 0) {
@@ -2203,9 +2216,7 @@ function cleanupMemHooksFromSettings(settings) {
2203
2216
  return removed;
2204
2217
  }
2205
2218
 
2206
- function isPluginExplicitlyDisabled(settings) {
2207
- return settings?.enabledPlugins?.[PLUGIN_KEY] === false;
2208
- }
2219
+
2209
2220
 
2210
2221
  function getInstalledPluginEntries(installed) {
2211
2222
  if (installed?.plugins && typeof installed.plugins === 'object') return installed.plugins;
@@ -2266,8 +2277,8 @@ function cleanup() {
2266
2277
  }
2267
2278
  }
2268
2279
 
2269
- // Clean pending-* / ep-flush-* in runtime/ (under the env-aware data dir)
2270
- 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;
2271
2282
  if (existsSync(runtimeDir)) {
2272
2283
  for (const f of readdirSync(runtimeDir)) {
2273
2284
  if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
@@ -2585,7 +2596,7 @@ async function rebuildBinding() {
2585
2596
  process.exitCode = 1;
2586
2597
  } else {
2587
2598
  // Every tree is loadable → drop the marker so session-start stops retrying.
2588
- clearNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
2599
+ clearNativeBindingBreakage(MEM_RUNTIME_DIR);
2589
2600
  }
2590
2601
  } finally {
2591
2602
  release();
@@ -8,7 +8,7 @@
8
8
  // This writes to a pid-unique temp then renames (atomic on POSIX), and can drop
9
9
  // a one-time ".bak" so a logic bug in the caller's merge is recoverable.
10
10
 
11
- import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync, lstatSync, realpathSync } from 'node:fs';
11
+ import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync, lstatSync, realpathSync, unlinkSync } from 'node:fs';
12
12
  import { dirname } from 'node:path';
13
13
 
14
14
  /**
@@ -43,7 +43,18 @@ export function atomicWriteFileSync(filePath, data, { backup = false } = {}) {
43
43
  // pid-unique temp: a fixed ".tmp" name lets two concurrent installs clobber
44
44
  // each other's temp mid-write. Same-dir-as-target temp keeps the rename atomic (no
45
45
  // cross-device move) even when the target lives in a dotfiles repo on another mount.
46
+ // The temp is removed on ANY failure. Without this a failed rename (target replaced by a
47
+ // directory, permissions, a symlink whose resolution path fails) leaves
48
+ // `<name>.tmp-<pid>` behind — and several call sites write into the USER's project root
49
+ // (CLAUDE.md), where the residue shows up in their `git status` and nothing sweeps it:
50
+ // the orphan sweep only walks RUNTIME_DIR. Cleaning here covers every caller rather than
51
+ // asking each one to remember, which is the mistake the private twins made.
46
52
  const tmp = `${target}.tmp-${process.pid}`;
47
- writeFileSync(tmp, data);
48
- renameSync(tmp, target);
53
+ try {
54
+ writeFileSync(tmp, data);
55
+ renameSync(tmp, target);
56
+ } catch (e) {
57
+ try { unlinkSync(tmp); } catch { /* nothing written, or already gone */ }
58
+ throw e;
59
+ }
49
60
  }
@@ -11,7 +11,7 @@
11
11
  // Cooldown schema (post-v2.81): { "<path>": { ts: <number>, lessonIds: [#NN, ...] } }
12
12
  // Legacy schema (pre-v2.81): { "<path>": <number> } — tolerated, never emits.
13
13
 
14
- import { basename, join } from 'path';
14
+ import { basename } from 'path';
15
15
  import { readFileSync } from 'fs';
16
16
  import { readTranscriptEntries } from './transcript-scan.mjs';
17
17
  import { EDIT_TOOLS } from '../utils.mjs';
@@ -22,6 +22,7 @@ import { EDIT_TOOLS } from '../utils.mjs';
22
22
  // in the ordinary case: it is whatever the repository being worked on happens to contain.
23
23
  import { neutralizeContextDelimiters } from '../format-utils.mjs';
24
24
  import { cooldownPathFor as sharedCooldownPathFor } from './cooldown-path.mjs';
25
+ import { citeRecallPathFor } from './cite-recall-path.mjs';
25
26
  // One caliber for `#NN`. citation-tracker.mjs does NOT import this module, so the edge
26
27
  // is acyclic.
27
28
  import { citationIdRe } from './citation-tracker.mjs';
@@ -230,8 +231,7 @@ export function nextCiteLowStreak(priorStreak, stats, env = process.env) {
230
231
  export function buildCiteRecallNudge(project, runtimeDir, env = process.env) {
231
232
  if (env.CLAUDE_MEM_NO_CITE_NUDGE === '1') return '';
232
233
  try {
233
- const safe = project.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
234
- const path = join(runtimeDir, `cite-recall-${safe}.json`);
234
+ const path = citeRecallPathFor(runtimeDir, project);
235
235
  const raw = readFileSync(path, 'utf8');
236
236
  const data = JSON.parse(raw);
237
237
  const silenceAfter = env.CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER !== undefined
@@ -0,0 +1,52 @@
1
+ // lib/cite-recall-path.mjs — the ONE definition of the cite-recall snapshot file's path.
2
+ //
3
+ // Same shape, same failure mode and same fix as lib/cooldown-path.mjs (audit 2026-08-29
4
+ // ARCH-2). `handleStop` writes `runtime/cite-recall-<project>.json` and
5
+ // `buildCiteRecallNudge` reads it back, and each derived the name itself:
6
+ //
7
+ // hook.mjs:1163 join(RUNTIME_DIR, `cite-recall-${project.replace(…).slice(0,64)}.json`)
8
+ // lib/cite-back-hint.mjs const safe = project.replace(…).slice(0,64); join(runtimeDir, `cite-recall-${safe}.json`)
9
+ //
10
+ // A writer and a reader disagreeing about a filename does not throw — the reader opens a
11
+ // file nobody wrote, `readFileSync` misses, the catch swallows it, and the SessionStart
12
+ // nudge is silently gone. That is the whole reason this class keeps getting collapsed:
13
+ // nothing in the system reports it, so the only defence is that the rule has one home.
14
+ // This is the third instance found (cooldown, injected-ids, cite-recall); the sanitize
15
+ // literal is deliberately NOT shared across the three, because they key on different
16
+ // things (session id vs project) and a single "sanitize" helper would invite exactly the
17
+ // cross-family coupling that makes one of them impossible to change later.
18
+ //
19
+ // `hook-shared.mjs`'s marker-GC list is the third consumer: it matches this family by
20
+ // prefix, so the prefix is exported rather than re-typed there.
21
+
22
+ import { join } from 'path';
23
+
24
+ /** Filename prefix. Exported so the marker GC can match the family without re-deriving it. */
25
+ export const CITE_RECALL_FILE_PREFIX = 'cite-recall-';
26
+
27
+ /**
28
+ * Project name → filesystem-safe key. The 64-char cap and the character class are part of
29
+ * the contract, not defensive hygiene: writer and reader must derive the same name from the
30
+ * same project, so a change here is a change to both at once.
31
+ *
32
+ * Note the cap differs from `project-utils.mjs:sanitizeProject` (100) — these are different
33
+ * keys for different files, and silently unifying them would rename every existing snapshot
34
+ * on upgrade, i.e. drop one nudge per project. Kept separate on purpose.
35
+ *
36
+ * @param {unknown} project
37
+ * @returns {string}
38
+ */
39
+ export function citeRecallProjectKey(project) {
40
+ return String(project).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
41
+ }
42
+
43
+ /**
44
+ * Absolute path to a project's cite-recall snapshot.
45
+ *
46
+ * @param {string} runtimeDir Absolute RUNTIME_DIR.
47
+ * @param {unknown} project Project name as `inferProject` returns it.
48
+ * @returns {string}
49
+ */
50
+ export function citeRecallPathFor(runtimeDir, project) {
51
+ return join(runtimeDir, `${CITE_RECALL_FILE_PREFIX}${citeRecallProjectKey(project)}.json`);
52
+ }