claude-mem-lite 3.91.0 → 3.92.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.
package/hook.mjs CHANGED
@@ -47,6 +47,7 @@ import { entry as preCompactEntry } from './hook-precompact.mjs';
47
47
  import {
48
48
  RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
49
49
  SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS, AUTO_MAINTAIN_LOCK,
50
+ STALE_EPISODE_BUFFER_AGE_MS,
50
51
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
51
52
  sessionFile, getSessionId, createSessionId, openDb,
52
53
  spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
@@ -80,20 +81,15 @@ import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from
80
81
  import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
81
82
  import { upsFtsQuery } from './lib/ups-query.mjs';
82
83
  import { formatTaskImperative } from './lib/task-imperative.mjs';
83
- import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
84
84
  import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
85
85
  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';
86
+ import { injectedIdsFileName, keyContextIdsFileName, readInjectedMarker } from './lib/injected-ids.mjs';
88
87
  import { recordKeyContextInjection, touchKeyContextMarker } from './lib/keyctx-marker.mjs';
89
88
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
90
89
  import { selectErrorRecall } from './lib/error-recall-core.mjs';
91
90
  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
91
  import { loadCiteBackForEpisode, extractCiteBackSignals, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
92
+ import { citeRecallPathFor } from './lib/cite-recall-path.mjs';
97
93
  import { detectUnpersistedDecision } from './lib/persist-reminder.mjs';
98
94
  // plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
99
95
  // from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
@@ -105,6 +101,31 @@ async function loadCacheGuard() {
105
101
  catch { _cacheGuardCache = {}; }
106
102
  return _cacheGuardCache;
107
103
  }
104
+ // Audit 2026-09-02 P1-8. `hook.mjs` is ONE entry point for seven events, so every static
105
+ // import is paid by every event — PostToolUse, the highest-frequency one, was loading 85
106
+ // modules (1.37 MB) to use a handful. The six modules below are loaded inside the handler
107
+ // that needs them instead: registry-recommend, patha-exclude-meter, hook-update,
108
+ // hook-optimize, adopt-cli, upgrade-banner. Measured marginal cost of the four largest,
109
+ // same process: hook-update 9.4 ms, registry-recommend 5.1, hook-optimize 5.9, the rest
110
+ // 0.5-2.4 each.
111
+ //
112
+ // A failing dynamic import lands in the dispatcher's tail catch -> recordHookError, the
113
+ // same place a failing static import would have landed the whole process; each site keeps
114
+ // whatever local try/catch it already had, so a missing module degrades exactly one
115
+ // feature rather than the event.
116
+ //
117
+ // Three named by the audit are deliberately NOT converted, because the reason they are
118
+ // loaded is not that nobody looked:
119
+ // * hook-llm.mjs (+16.3 ms, the single largest) — `flushEpisode` is SYNCHRONOUS and on
120
+ // the PostToolUse path, and it calls `saveEpisodeImmediate` from that module. Lazy
121
+ // loading needs either an async rewrite of the flush path or extracting the function,
122
+ // and extraction does not pay: `saveEpisodeImmediate` reaches `saveObservation`, which
123
+ // pulls tfidf / observation-write / activity / maintain-core anyway — only
124
+ // haiku-client would actually stop loading.
125
+ // * lib/db-backup.mjs and lib/compress-core.mjs (<=2.4 ms each) — their call sites sit
126
+ // in `runSessionStartAutoMaintain` and `handleAutoCompress`, both synchronous. Turning
127
+ // two functions and their callers async across a background-worker path costs more
128
+ // risk than the milliseconds are worth.
108
129
  import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
109
130
  import { getVocabulary } from './tfidf.mjs';
110
131
 
@@ -523,7 +544,12 @@ async function handlePostToolUse() {
523
544
  const ti = typeof tool_input === 'string' ? tryParseJson(tool_input) : (tool_input || {});
524
545
  // hookData.session_id (CC UUID) pairs this adoption to the would-be reco from the
525
546
  // UserPromptSubmit hook earlier in the same session (matched precision, B1).
526
- try { recordSkillAdoption('Skill', ti, inferProject(), hookData.session_id); } catch {}
547
+ // Lazy: only a `Skill` tool call needs this module, which is a small fraction of
548
+ // PostToolUse fires.
549
+ try {
550
+ const { recordSkillAdoption } = await import('./registry-recommend.mjs');
551
+ recordSkillAdoption('Skill', ti, inferProject(), hookData.session_id);
552
+ } catch { /* telemetry only — never blocks capture */ }
527
553
  }
528
554
 
529
555
  const resp = normalizeToolResponse(tool_response);
@@ -1160,7 +1186,7 @@ async function handleStop() {
1160
1186
  // alongside cite-recall. Same scan target (transcript already in OS
1161
1187
  // cache); same persistence file; one extra line in buildCiteRecallNudge.
1162
1188
  const bugfixStats = countUnsavedBugfixShape(transcriptPath);
1163
- const dest = join(RUNTIME_DIR, `cite-recall-${project.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64)}.json`);
1189
+ const dest = citeRecallPathFor(RUNTIME_DIR, project);
1164
1190
  // Carry the consecutive-low-cite streak forward so the SessionStart
1165
1191
  // nag can self-silence after the project has ignored it N times.
1166
1192
  let priorStreak = 0;
@@ -1526,8 +1552,18 @@ function runSessionStartAutoMaintain(db, project) {
1526
1552
  // the file behind, and the doctor "Stale temp files" warning then
1527
1553
  // accumulates indefinitely. fs-only; runs inside the 24h gate so it
1528
1554
  // shares cadence with the rest of auto-maintain.
1555
+ //
1556
+ // Since audit P1-12 this also reclaims abandoned per-project episode buffers at 7d.
1557
+ // That one is named individually in the log: every other family it sweeps is residue
1558
+ // or a re-derivable tracker, while `ep-<project>.json` holds unflushed observations —
1559
+ // and the alternative to deleting it is worse (SessionStart flushes whatever it finds
1560
+ // with no staleness gate, so a revisit stamps months-old activity with today's date).
1529
1561
  try {
1530
- const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR);
1562
+ const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR, {
1563
+ onSweep: (name, kind) => {
1564
+ if (kind === 'buffer') debugLog('DEBUG', 'auto-maintain', `discarding abandoned episode buffer ${name} (>7d; would otherwise flush mis-dated on revisit)`);
1565
+ },
1566
+ });
1531
1567
  if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
1532
1568
  } catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
1533
1569
 
@@ -1798,7 +1834,7 @@ async function handleSessionStart() {
1798
1834
  // SessionStart cadence, 30d gate, named family list (hook-shared.mjs).
1799
1835
  try { sweepStaleProjectMarkers(RUNTIME_DIR); } catch { /* best-effort */ }
1800
1836
  // Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
1801
- try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
1837
+ try { const { gcOldShadowShards } = await import('./registry-recommend.mjs'); gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
1802
1838
  // Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
1803
1839
  // metrics are disabled, so shards left by a since-toggled-off run still get pruned.
1804
1840
  try { gcOldMetricShards(join(RUNTIME_DIR, '..')); } catch { /* best-effort */ }
@@ -1842,6 +1878,7 @@ async function handleSessionStart() {
1842
1878
  if (process.env.MEM_NO_AUTO_ADOPT !== '1') {
1843
1879
  const project = inferProject();
1844
1880
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
1881
+ const { silentAutoAdopt } = await import('./adopt-cli.mjs');
1845
1882
  const r = silentAutoAdopt({ cwd, markerDir: RUNTIME_DIR, markerKey: project });
1846
1883
  if (r.ok) {
1847
1884
  debugLog('DEBUG', 'session-start-auto-adopt', `action=${r.action} project=${project}`);
@@ -1865,12 +1902,30 @@ async function handleSessionStart() {
1865
1902
  // Snapshot episode BEFORE flush for handoff extraction
1866
1903
  const episodeSnapshot = readEpisodeRaw();
1867
1904
 
1868
- // Flush any leftover episode buffer from previous session (e.g. after /clear)
1905
+ // Flush any leftover episode buffer from previous session (e.g. after /clear).
1906
+ //
1907
+ // A buffer older than STALE_EPISODE_BUFFER_AGE_MS is DISCARDED, not flushed. This is the
1908
+ // half of P1-12 the orphan sweep cannot reach: the sweep runs inside the detached
1909
+ // auto-maintain worker scheduled further down this same function, so on the revisit that
1910
+ // matters the flush below has already happened and the sweeper finds nothing. Without this
1911
+ // gate, returning to a project abandoned months ago injects its months-old tool activity
1912
+ // stamped with today's date — the exact harm hook-shared.mjs's threshold docblock names.
1913
+ // Same constant, because it is the same question ("did this buffer outlive its session by
1914
+ // an order of magnitude?"), asked at the other end.
1869
1915
  if (acquireLock()) {
1870
1916
  try {
1871
- const prevEpisode = readEpisode();
1872
- if (prevEpisode && prevEpisode.entries && prevEpisode.entries.length > 0) {
1873
- flushEpisode(prevEpisode, 'SessionStart');
1917
+ let stale = false;
1918
+ try {
1919
+ stale = Date.now() - statSync(episodeFile()).mtimeMs > STALE_EPISODE_BUFFER_AGE_MS;
1920
+ } catch { /* no buffer file — readEpisode() returns null below */ }
1921
+ if (stale) {
1922
+ debugLog('INFO', 'session-start', `discarding stale episode buffer (>${STALE_EPISODE_BUFFER_AGE_MS}ms): ${episodeFile()}`);
1923
+ try { unlinkSync(episodeFile()); } catch { /* best-effort */ }
1924
+ } else {
1925
+ const prevEpisode = readEpisode();
1926
+ if (prevEpisode && prevEpisode.entries && prevEpisode.entries.length > 0) {
1927
+ flushEpisode(prevEpisode, 'SessionStart');
1928
+ }
1874
1929
  }
1875
1930
  } finally {
1876
1931
  releaseLock();
@@ -1949,6 +2004,7 @@ async function handleSessionStart() {
1949
2004
  // the single envelope; the spawn stays a side effect and is fired below.
1950
2005
  let updateCheckDue = false;
1951
2006
  try {
2007
+ const { getCachedUpdateBanner, isUpdateCheckDue } = await import('./hook-update.mjs');
1952
2008
  const banner = getCachedUpdateBanner();
1953
2009
  // The human channel, not additionalContext: "vX available" is a notice for the
1954
2010
  // USER. Folding it into additionalContext under suppressOutput:true kept its
@@ -1998,6 +2054,7 @@ async function handleSessionStart() {
1998
2054
  // "Any observations at all" still misfired for someone who installed today
1999
2055
  // and saved a few memories before their first SessionStart — age is what
2000
2056
  // actually identifies an upgrader (see lib/upgrade-banner.mjs).
2057
+ const { emitV270UpgradeBanner, hasPreV270Data } = await import('./lib/upgrade-banner.mjs');
2001
2058
  emitV270UpgradeBanner({
2002
2059
  project,
2003
2060
  runtimeDir: RUNTIME_DIR,
@@ -2175,13 +2232,16 @@ async function handleUserPrompt() {
2175
2232
  // project-keyed name), so a concurrent session's write can no longer
2176
2233
  // replace this session's payload between the UPS write and this read.
2177
2234
  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)) {
2235
+ // The freshness + same-session gate is lib/injected-ids.mjs's (audit 2026-09-02
2236
+ // P1-2); this was the third hand-typed copy of it. THE 10 s WINDOW STAYS HERE and
2237
+ // is passed in: the two writers gate on DEDUP_STALE_MS (5 min) and this reader on
2238
+ // 10 s ("same prompt cycle"), and that disagreement is a real open question
2239
+ // (P1-2's second half — the 10 s window still accepts the PREVIOUS prompt's
2240
+ // marker), not a copy-paste slip to be normalised away by the consolidation.
2241
+ // Legacy payloads without `session` keep the old time-window-only behaviour.
2242
+ const { ids, fresh } = readInjectedMarker(injectedFile,
2243
+ { sessionId: ccSessionId, maxAgeMs: 10000 });
2244
+ if (fresh) {
2185
2245
  // D#193, DELIBERATELY NOT NUMERICALISED — read this before "fixing" it.
2186
2246
  //
2187
2247
  // Ids arrive here as written. `user-prompt-search.js` writes plain numbers, but
@@ -2258,7 +2318,16 @@ async function handleUserPrompt() {
2258
2318
  // Arm B also carries its OWN imperative pick. Reusing arm A's put a pick the
2259
2319
  // repaired system would not have made into arm B's exclude, so on any prompt where
2260
2320
  // the pick changed, the delta described a system that does not exist.
2261
- const meterCoerced = (pathAMeterEnabled() && pathAInjectedIds.length > 0)
2321
+ // Lazy on "the marker carried ids", NOT on the metrics env. Gating the import on
2322
+ // `CLAUDE_MEM_METRICS === '1'` would read cheaper still, and would put a second copy
2323
+ // of `pathAMeterEnabled`'s own predicate here — the twin shape this meter's tests
2324
+ // exist to pin. `pathAMeterEnabled()` stays the only place that predicate lives; the
2325
+ // module still stops loading on every OTHER event, which is what P1-8 is about.
2326
+ let pathAMeterEnabled, coerceMarkerIds, recordPathAExclude;
2327
+ if (pathAInjectedIds.length > 0) {
2328
+ ({ pathAMeterEnabled, coerceMarkerIds, recordPathAExclude } = await import('./lib/patha-exclude-meter.mjs'));
2329
+ }
2330
+ const meterCoerced = (pathAMeterEnabled && pathAMeterEnabled())
2262
2331
  ? [...coerceMarkerIds(pathAInjectedIds)]
2263
2332
  : null;
2264
2333
  let meterArmB = null;
@@ -2493,7 +2562,7 @@ try {
2493
2562
  case 'auto-compress': handleAutoCompress(); break;
2494
2563
  case 'enrich-save': await handleEnrichSave(process.argv[3]); break;
2495
2564
  case 'auto-maintain': handleAutoMaintain(process.argv[3]); break;
2496
- case 'llm-optimize': await handleLLMOptimize(); break;
2565
+ case 'llm-optimize': { const { handleLLMOptimize } = await import('./hook-optimize.mjs'); await handleLLMOptimize(); break; }
2497
2566
  // Detached update refresh spawned by handleSessionStart (audit P3d) — does the
2498
2567
  // GitHub fetch off the SessionStart critical path, writing update-state.json so
2499
2568
  // the NEXT session's cached banner is fresh.
@@ -2507,7 +2576,7 @@ try {
2507
2576
  // self-installer back on in the same release that resurrects the worker. The
2508
2577
  // module default and the installer's own guards are unchanged — install.mjs
2509
2578
  // still passes allowInstall:true for the explicit, user-invoked update.
2510
- case 'update-check': await checkForUpdate({ allowInstall: false }); break;
2579
+ case 'update-check': { const { checkForUpdate } = await import('./hook-update.mjs'); await checkForUpdate({ allowInstall: false }); break; }
2511
2580
  }
2512
2581
  } catch (err) {
2513
2582
  // Log fatal errors (ungated) with structured format. ERR_DLOPEN_FAILED (an
package/install.mjs CHANGED
@@ -614,7 +614,11 @@ if (existsSync(pluginDir)) {
614
614
  if (existsSync(pluginHooksPath)) {
615
615
  const pluginHooks = JSON.parse(readFileSync(pluginHooksPath, 'utf8'));
616
616
  if (pluginHooks.hooks && Object.keys(pluginHooks.hooks).length > 0) {
617
- writeFileSync(pluginHooksPath, JSON.stringify({
617
+ // Atomic (audit 2026-09-02 P1-10): a torn hooks.json is not a fail-open marker —
618
+ // Claude Code parses it at plugin load, so half a file disables the plugin's hooks
619
+ // for that install until the next successful write. Same writer settings.json
620
+ // already uses 1600 lines down.
621
+ atomicWriteFileSync(pluginHooksPath, JSON.stringify({
618
622
  description: pluginHooks.description || 'claude-mem-lite hooks',
619
623
  _note: 'Hooks managed by install.mjs in settings.json — this file cleared to prevent duplicates',
620
624
  hooks: {}
@@ -653,7 +657,10 @@ if (existsSync(pluginDir)) {
653
657
  try {
654
658
  const h = JSON.parse(readFileSync(cachedHooksPath, 'utf8'));
655
659
  if (h.hooks && Object.keys(h.hooks).length > 0) {
656
- writeFileSync(cachedHooksPath, JSON.stringify({
660
+ // Atomic, same reason as the marketplace-source copy above (P1-10). This one
661
+ // is the higher-cost of the two: it runs once PER CACHED VERSION, so a tear
662
+ // here disables hooks for whichever version Claude Code happens to load.
663
+ atomicWriteFileSync(cachedHooksPath, JSON.stringify({
657
664
  description: h.description || 'claude-mem-lite hooks',
658
665
  _note: `Hooks managed by install.mjs in settings.json — cache hooks.json cleared to prevent duplicate registration (cache ver: ${ver})`,
659
666
  hooks: {}
@@ -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
+ }
@@ -17,9 +17,9 @@
17
17
  // smart-compress path already wrote vectors, so the deterministic path was the
18
18
  // sole gap (audit P6).
19
19
 
20
- import { isoWeekKey, COMPRESSED_AUTO, debugCatch } from '../utils.mjs';
21
- import { getVocabulary, computeVector } from '../tfidf.mjs';
20
+ import { isoWeekKey, COMPRESSED_AUTO } from '../utils.mjs';
22
21
  import { scrubRecord } from './scrub-record.mjs';
22
+ import { upsertObservationVector } from './observation-write.mjs';
23
23
 
24
24
  /**
25
25
  * Low-value compression candidates: importance<=1, never accessed, older than
@@ -44,6 +44,13 @@ export function selectCompressionCandidates(db, { cutoff, project = null, includ
44
44
  -- weekly summary discards the lesson (the distilled value of a lessons store).
45
45
  -- Mirrors the hook auto-compress lesson guard so neither path buries a lesson.
46
46
  AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
47
+ -- Tombstones stay out (audit 2026-09-02 P0-3). A superseded row keeps
48
+ -- compressed_into NULL, so compressedFilter alone admitted retracted content into a
49
+ -- weekly summary — and the summary is a LIVE importance-2 row, so the retraction is
50
+ -- undone by the compression. Spelled out rather than reusing liveObsFilterSql because
51
+ -- the compressed_into half is deliberately looser here (includeAutoMarked folds in
52
+ -- COMPRESSED_AUTO rows for re-summarization).
53
+ AND superseded_at IS NULL
47
54
  AND created_at_epoch < ?
48
55
  ${compressedFilter}
49
56
  ${projectFilter}
@@ -105,21 +112,30 @@ export function compressGroup(db, proj, obs) {
105
112
  // with save-observation.mjs and the LLM smart-compress path). Best-effort:
106
113
  // vocab may be uninitialized on a fresh DB — a failure here must not abort the
107
114
  // compression the caller is transacting.
108
- try {
109
- const vocab = getVocabulary(db);
110
- if (vocab) {
111
- const vec = computeVector(`${safe.title} ${safe.narrative}`, vocab);
112
- if (vec) {
113
- db.prepare(
114
- 'INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)'
115
- ).run(summaryId, Buffer.from(vec.buffer), vocab.version, medianEpoch);
116
- }
117
- }
118
- } catch (e) { debugCatch(e, 'compress-vector'); }
115
+ // SQL + text derivation are lib/observation-write.mjs's (audit 2026-09-02 P1-4).
116
+ // `at: medianEpoch` is this site's one real difference: the summary is stamped with the
117
+ // median date of the rows it compresses, not with now. `gate: false` preserves the prior
118
+ // behaviour, which never consulted vectorsEnabled().
119
+ upsertObservationVector(db, summaryId, `${safe.title} ${safe.narrative}`, {
120
+ gate: false, at: medianEpoch, scope: 'compress-vector',
121
+ });
119
122
 
120
123
  const obsIds = obs.map((o) => o.id);
121
124
  const obsPh = obsIds.map(() => '?').join(',');
122
- db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${obsPh})`).run(summaryId, ...obsIds);
125
+ // Re-assert the candidate predicate at write time (audit 2026-09-02 P0-3): the hook path
126
+ // transacts per group, so a row can be superseded or absorbed by another summary between
127
+ // selection and here, and re-pointing it would drop it out of that summary's child set.
128
+ // Deliberately NOT liveObsFilterSql: COMPRESSED_AUTO rows are legitimate inputs
129
+ // (includeAutoMarked), so only the superseded half plus "no positive keeper yet" applies.
130
+ const compressed = db.prepare(`
131
+ UPDATE observations SET compressed_into = ?
132
+ WHERE id IN (${obsPh})
133
+ AND superseded_at IS NULL
134
+ AND (compressed_into IS NULL OR compressed_into = ${COMPRESSED_AUTO})
135
+ `).run(summaryId, ...obsIds).changes;
123
136
 
124
- return { summaryId, compressed: obs.length };
137
+ // `changes`, not obs.length: with the guard above those differ exactly when a row lost
138
+ // eligibility between selection and write, and reporting the intended count would make
139
+ // the guard invisible in every caller's totals.
140
+ return { summaryId, compressed };
125
141
  }
@@ -0,0 +1,67 @@
1
+ // lib/frontmatter.mjs — the ONE YAML-frontmatter parser for skill/agent markdown.
2
+ //
3
+ // Audit 2026-09-02 P1-16. There were three, and they were not all the same:
4
+ //
5
+ // registry-importer.mjs the shipped one, full
6
+ // scripts/index-managed.mjs byte-identical to it apart from the `export` keyword —
7
+ // 30 lines, the largest duplicate block in the tree
8
+ // scripts/convert-commands.mjs a SIMPLIFIED cut with no `|` / `>` block support and no
9
+ // JSON-array handling, i.e. already diverged
10
+ //
11
+ // The divergence is the part that matters: `description:` in a Claude Code SKILL.md is
12
+ // routinely a `|` block, and the simplified parser returned the literal `|` for it. Two
13
+ // scripts reading the same files with different parsers produce different registry rows
14
+ // depending on which one last ran.
15
+ //
16
+ // Zero dependencies on purpose — it is imported by a shipped module (registry-importer)
17
+ // and by two dev scripts, so it must not drag anything into either.
18
+
19
+ /**
20
+ * Split `---`-delimited YAML frontmatter from a markdown body.
21
+ *
22
+ * Not a YAML parser, and deliberately so: it handles the subset Claude Code skill and
23
+ * agent files actually use — scalars, quoted scalars, JSON-ish arrays, and `|` / `>`
24
+ * blocks — and leaves anything else as the raw string rather than guessing.
25
+ *
26
+ * The `description` special case is load-bearing rather than defensive: a description
27
+ * written as a plain scalar is very often CONTINUED on the following indented lines
28
+ * without a block marker, and dropping the continuation silently truncates the one field
29
+ * the recommendation gate reads.
30
+ *
31
+ * @param {string} content full file text
32
+ * @returns {{frontmatter: Record<string, unknown>, body: string}}
33
+ */
34
+ export function parseFrontmatter(content) {
35
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
36
+ if (!match) return { frontmatter: {}, body: content };
37
+
38
+ const raw = match[1];
39
+ const body = content.slice(match[0].length).trim();
40
+ const fm = {};
41
+ let currentKey = null, currentValue = '', inMultiline = false;
42
+
43
+ for (const line of raw.split('\n')) {
44
+ if (inMultiline && (line.startsWith(' ') || line.startsWith('\t') || line.trim() === '')) {
45
+ currentValue += ' ' + line.trim();
46
+ continue;
47
+ }
48
+ if (inMultiline && currentKey) { fm[currentKey] = currentValue.trim(); inMultiline = false; }
49
+
50
+ const kv = line.match(/^(\w[\w-]*)\s*:\s*(.*)/);
51
+ if (kv) {
52
+ currentKey = kv[1];
53
+ let val = kv[2].trim();
54
+ if (val === '|' || val === '>') { inMultiline = true; currentValue = ''; continue; }
55
+ if (val.startsWith('[') && val.endsWith(']')) {
56
+ try { fm[currentKey] = JSON.parse(val); } catch { fm[currentKey] = val; }
57
+ continue;
58
+ }
59
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))
60
+ val = val.slice(1, -1);
61
+ if (currentKey === 'description' && val) { inMultiline = true; currentValue = val; continue; }
62
+ fm[currentKey] = val;
63
+ }
64
+ }
65
+ if (inMultiline && currentKey) fm[currentKey] = currentValue.trim();
66
+ return { frontmatter: fm, body };
67
+ }
@@ -1,7 +1,11 @@
1
- // lib/injected-ids.mjs — file-name derivation for the cross-hook injected-ids
2
- // dedup marker. Single source of truth for user-prompt-search.js (writer),
3
- // pre-tool-recall.js (read/merge), and hook.mjs (path-A reader): all three must
4
- // derive the same name or cross-hook dedup silently goes blind.
1
+ // lib/injected-ids.mjs — the cross-hook injected-ids dedup marker: file name, freshness +
2
+ // same-session gate, and payload shape. Single source of truth for user-prompt-search.js
3
+ // (writer), pre-tool-recall.js (read/merge), and hook.mjs (path-A reader): all three must
4
+ // derive the same name AND agree on when a payload counts, or cross-hook dedup silently
5
+ // goes blind — it does not error, it reads a marker nobody wrote.
6
+ //
7
+ // Scope widened 2026-09-03 (audit P1-2): this module used to own only the FILE NAME while
8
+ // the gate and the write were hand-typed in five places across three files.
5
9
  //
6
10
  // D#120: M-6 session-keyed the marker's PAYLOAD but kept ONE file per project,
7
11
  // so two concurrent CC windows full-replaced each other's marker — no dedup
@@ -14,6 +18,9 @@
14
18
  // colliding with the scripts/ directory rename in installExtractedRelease —
15
19
  // same constraint as lib/mem-override.mjs.
16
20
 
21
+ import { readFileSync } from 'node:fs';
22
+ import { atomicWriteFileSync } from './atomic-write.mjs';
23
+
17
24
  /**
18
25
  * Runtime-dir FILE NAME for the injected-ids marker (no directory component).
19
26
  * No sessionId → legacy project-keyed name (env-less harnesses, old callers).
@@ -28,6 +35,88 @@ export function injectedIdsFileName(project, sessionId) {
28
35
  return `${base}-${safe}`;
29
36
  }
30
37
 
38
+ /**
39
+ * Read a marker file, applying the freshness + same-session gate.
40
+ *
41
+ * That gate had THREE byte-identical hand-typed copies (audit 2026-09-02 P1-2):
42
+ * `hook.mjs handleUserPrompt`, `readCrossHookInjected` and `mergeCrossHookInjected` in
43
+ * scripts/pre-tool-recall.js, and both legs of scripts/user-prompt-search.js — five sites
44
+ * spelling out the same `ts && Date.now() - ts < W && !(session && mine && session !== mine)`.
45
+ * The lib had collapsed only the FILE NAME. Every new writer added a sixth copy, and a
46
+ * writer/reader disagreement here does not error: it reads a marker nobody wrote.
47
+ *
48
+ * THE WINDOW STAYS A PARAMETER, because the callers genuinely disagree and that
49
+ * disagreement is a live open question, not an accident to be tidied away: the two writers
50
+ * use DEDUP_STALE_MS (5 min) and the hook.mjs reader uses 10 s. Hard-coding either one here
51
+ * would silently decide it. Legacy payloads with no `session` keep the old
52
+ * window-only behaviour, as before.
53
+ *
54
+ * IDS COME BACK EXACTLY AS WRITTEN — no Number(), no String(). The marker holds a mix of
55
+ * raw numbers and strings and the consumers test `Set.has()` against numbers from SQLite,
56
+ * so coercing here would turn an inert exclude live. That is D#213/D#216's decision to
57
+ * make, on its ruler (lib/patha-exclude-meter.mjs), not a drive-by of this consolidation.
58
+ *
59
+ * @param {string} file Absolute path to the marker.
60
+ * @param {object} opts
61
+ * @param {string} [opts.sessionId] CC session id; a payload from another session is rejected.
62
+ * @param {number} opts.maxAgeMs Freshness window.
63
+ * @returns {{ids: Array<number|string>, count: number, fresh: boolean}} `fresh:false` ⇒ ids [] and count 0.
64
+ *
65
+ * ONE deliberate non-identity in the consolidation, stated rather than glossed: the copies
66
+ * disagreed at the boundary by 1 ms. pre-tool-recall used `age > W` (stale only when
67
+ * strictly greater), hook.mjs used `age < W` (fresh only when strictly less), so a payload
68
+ * exactly W old was fresh to one and stale to the other. This adopts the former for all
69
+ * callers. Nothing selects on a 1 ms boundary, but "identical behaviour" would have been
70
+ * false and the next person to diff the two would have had to rediscover why.
71
+ */
72
+ export function readInjectedMarker(file, { sessionId, maxAgeMs } = {}) {
73
+ const empty = { ids: [], count: 0, fresh: false };
74
+ try {
75
+ const { ids, ts, count, session } = JSON.parse(readFileSync(file, 'utf8'));
76
+ if (session && sessionId && session !== sessionId) return empty;
77
+ if (!ts || Date.now() - ts > maxAgeMs) return empty;
78
+ if (!Array.isArray(ids)) return empty;
79
+ return { ids, count: count || 0, fresh: true };
80
+ } catch { return empty; }
81
+ }
82
+
83
+ /**
84
+ * Write `newIds` into a marker, unioning with a fresh same-session payload or replacing it.
85
+ *
86
+ * `mode` is not a convenience — the two modes have different id-typing behaviour and BOTH
87
+ * are load-bearing:
88
+ * - `union` stringifies the whole result. Both union callers already did
89
+ * (`prev.ids.map(String)` plus new ids that are `D<id>` strings anyway), so
90
+ * this is their behaviour, not a new normalisation.
91
+ * - `replace` writes `newIds` verbatim. The UPS main leg passes `candidateIds`, a MIX of
92
+ * raw observation numbers and `P<id>` strings, and writing those unchanged is
93
+ * exactly the state D#213 measures. Stringifying here would change it.
94
+ * Keeping both under one function is the point: the next writer picks a mode instead of
95
+ * copying a fifth predicate and inventing a fifth typing rule.
96
+ *
97
+ * The write is atomic for the reason M-6 recorded: a plain write torn by a concurrent hook
98
+ * left the shared marker as invalid JSON, silently disabling cross-hook dedup for the window.
99
+ *
100
+ * @param {string} file
101
+ * @param {Array<number|string>} newIds
102
+ * @param {object} opts
103
+ * @param {string} [opts.sessionId]
104
+ * @param {number} opts.maxAgeMs
105
+ * @param {'union'|'replace'} opts.mode
106
+ */
107
+ export function mergeInjectedMarker(file, newIds, { sessionId, maxAgeMs, mode } = {}) {
108
+ const prev = readInjectedMarker(file, { sessionId, maxAgeMs });
109
+ const ids = mode === 'union'
110
+ ? [...new Set([...prev.ids.map(String), ...newIds.map(String)])]
111
+ : newIds;
112
+ atomicWriteFileSync(file, JSON.stringify({
113
+ ids,
114
+ ts: Date.now(),
115
+ count: prev.count + 1,
116
+ ...(sessionId ? { session: sessionId } : {}),
117
+ }));
118
+ }
119
+
31
120
  /**
32
121
  * Namespace prefix for an id written into the shared injected-ids marker.
33
122
  *