claude-mem-lite 3.74.1 → 3.75.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
@@ -23,10 +23,10 @@ import { join } from 'path';
23
23
  import { readFileSync, writeFileSync, unlinkSync, readdirSync, renameSync, statSync, existsSync } from 'fs';
24
24
  import { homedir } from 'os';
25
25
  import {
26
- truncate, inferProject, detectBashSignificance,
26
+ inferProject, detectBashSignificance,
27
27
  planErrorRecall, extractFilePaths, isRelatedToEpisode,
28
28
  makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
29
- COMPRESSED_AUTO, OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
29
+ OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
30
30
  MAX_HOOK_STDIN_BYTES,
31
31
  } from './utils.mjs';
32
32
  import {
@@ -44,13 +44,13 @@ import {
44
44
  sessionFile, getSessionId, createSessionId, openDb,
45
45
  spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
46
46
  } from './hook-shared.mjs';
47
- import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation, saveEpisodeImmediate } from './hook-llm.mjs';
48
- import { scrubRecord } from './lib/scrub-record.mjs';
47
+ import { handleLLMEpisode, handleLLMSummary, saveEpisodeImmediate } from './hook-llm.mjs';
48
+ import { readFastSummarySource, insertFastSummary, FAST_SUMMARY_LIMITS } from './lib/fast-summary.mjs';
49
49
  import { formatHookError } from './lib/native-binding-hint.mjs';
50
50
  import { recordHookError } from './lib/hook-telemetry.mjs';
51
51
  import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
52
52
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
53
- import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
53
+ import { cleanupBroken, decayAndMarkIdle, boostAccessed, markAutoCompressible, selectFuzzyDedupeIds, stampDedupSuperseded, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
54
54
  import { snapshotDb } from './lib/db-backup.mjs';
55
55
  import {
56
56
  extractCitationsFromTranscript,
@@ -297,15 +297,13 @@ function flushEpisodeGroup(ep, db) {
297
297
  const isSignificant = episodeHasSignificantContent(ep);
298
298
 
299
299
  // Immediate save: rule-based observation for instant visibility; the LLM
300
- // background worker upgrades title/narrative/importance later.
300
+ // background worker upgrades title/narrative/importance later. `db` is
301
+ // flushEpisode's handle — passed in so the caller owns open/close and the whole
302
+ // flush is gated on one availability check (B1). saveEpisodeImmediate re-checks
303
+ // significance itself, so the guard here is about the flush-file decision below.
301
304
  if (isSignificant) {
302
- try {
303
- const obs = buildImmediateObservation(ep);
304
- // `db` is flushEpisode's handle — passed in so the caller owns open/close and the
305
- // whole flush is gated on one availability check (B1).
306
- const id = saveObservation(obs, ep.project, ep.sessionId, db);
307
- if (id) ep.savedId = id;
308
- } catch (e) { debugCatch(e, 'flushEpisode-immediateSave'); }
305
+ const id = saveEpisodeImmediate(ep, db, 'flushEpisode-immediateSave');
306
+ if (id) ep.savedId = id;
309
307
  }
310
308
 
311
309
  const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
@@ -596,21 +594,18 @@ async function handleStop() {
596
594
  // one garbled observation. planEpisodeFlush returns [episode] by reference for the common
597
595
  // single-session case, so this is a no-op there. Immediate-save each group BEFORE its
598
596
  // flush-file write (same ordering as flushEpisodeGroup) so a worker crash can't lose it.
597
+ // One body for both paths (audit 2026-08-22 P2-9). This loop used to be a
598
+ // hand-copy of flushEpisodeGroup carrying three comments asserting parity with
599
+ // it, and it was not in parity: it ignored CLAUDE_MEM_SKIP_EPISODE_LLM, so a
600
+ // lock-contended Stop under test spawned a real background worker; and a failed
601
+ // flush-file write threw out of the whole loop into the outer catch, whose
602
+ // `finally` then deleted the claim file — abandoning the subs that had not been
603
+ // written yet. flushEpisodeGroup returns 'writefail' and the caller skips only
604
+ // that sub. Per-sub significance gating lives inside it too.
599
605
  for (const sub of planEpisodeFlush(episode)) {
600
606
  if (!sub.sessionId) sub.sessionId = sessionId;
601
607
  if (!sub.project) sub.project = project;
602
- // Per-sub significance gate — parity with flushEpisodeGroup. The whole-episode check
603
- // above can pass while an interleaved concurrent-session sub is pure noise (e.g. a lone
604
- // Read); without this the fallback persists that noise sub AND spawns an LLM worker for it.
605
- if (!episodeHasSignificantContent(sub)) continue;
606
- try {
607
- const obs = buildImmediateObservation(sub);
608
- const id = saveObservation(obs, sub.project, sub.sessionId, claimDb);
609
- if (id) sub.savedId = id;
610
- } catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
611
- const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
612
- writeFileSync(flushFile, JSON.stringify(sub), { mode: 0o600 }); // captured paths + scrubbed activity — owner-only (sec P3-2)
613
- spawnBackground('llm-episode', flushFile);
608
+ flushEpisodeGroup(sub, claimDb);
614
609
  }
615
610
  }
616
611
  } finally {
@@ -651,21 +646,7 @@ async function handleStop() {
651
646
  'SELECT 1 FROM session_summaries WHERE memory_session_id = ? LIMIT 1'
652
647
  ).get(sessionId);
653
648
  if (!existingSummary) {
654
- const firstPrompt = db.prepare(`
655
- SELECT prompt_text FROM user_prompts
656
- WHERE content_session_id = ?
657
- ORDER BY prompt_number ASC LIMIT 1
658
- `).get(sessionId);
659
- const recentObs = db.prepare(`
660
- SELECT title FROM observations
661
- WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
662
- ORDER BY created_at_epoch DESC LIMIT 5
663
- `).all(sessionId);
664
- // Raw values flow into scrubRecord below; truncation at .run() site
665
- // so secrets straddling the boundary still match scrubSecrets's
666
- // length floors.
667
- const fastRequestRaw = firstPrompt?.prompt_text || '';
668
- const obsCompleted = recentObs.map(o => o.title).filter(Boolean).join('; ');
649
+ const { request: fastRequestRaw, completed: obsCompleted } = readFastSummarySource(db, sessionId);
669
650
 
670
651
  // Structural extraction from the assistant's tail message.
671
652
  // CLAUDE.md §10 mandates Done/Not done/Failed/Uncertain markers, so the
@@ -693,24 +674,11 @@ async function handleStop() {
693
674
  const finalNotes = structuredNotes || 'fast';
694
675
 
695
676
  if (fastRequestRaw || finalCompleted || finalRemaining) {
696
- const now = new Date();
697
- const safe = scrubRecord('session_summaries', {
698
- request: fastRequestRaw,
699
- completed: finalCompleted,
700
- remaining_items: finalRemaining,
701
- notes: finalNotes,
677
+ insertFastSummary(db, {
678
+ sessionId, project, now: new Date(),
679
+ values: { request: fastRequestRaw, completed: finalCompleted, remaining: finalRemaining, notes: finalNotes },
680
+ limits: FAST_SUMMARY_LIMITS.stop,
702
681
  });
703
- db.prepare(`
704
- INSERT INTO session_summaries
705
- (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
706
- VALUES (?, ?, ?, '', '', ?, '', ?, '[]', '[]', ?, ?, ?)
707
- `).run(
708
- sessionId, project, truncate(safe.request, 200),
709
- truncate(safe.completed, 600),
710
- truncate(safe.remaining_items, 600),
711
- truncate(safe.notes, 400),
712
- now.toISOString(), now.getTime()
713
- );
714
682
  }
715
683
  }
716
684
  } catch (e) { debugCatch(e, 'handleStop-fast-summary'); }
@@ -942,7 +910,6 @@ function gcStalePreRecallCooldowns() {
942
910
  function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now }) {
943
911
  // ── DB mutations in a transaction (crash-safe consistency) ──
944
912
  const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
945
- const autoCompressAge = Date.now() - 30 * DAY_MS; // 30 days (accelerated from 90)
946
913
 
947
914
  db.transaction(() => {
948
915
  // Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
@@ -965,59 +932,14 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
965
932
  WHERE status = 'active' AND started_at_epoch < ?
966
933
  `).run(staleSessionCutoff);
967
934
 
968
- // Auto-compress: mark old low-importance observations as compressed (30+ days, importance<=1)
969
- // Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
970
- // v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant
971
- // via hook-memory injection, even if user never explicitly fetched). Same
972
- // protection applied symmetrically in auto-maintain decay/mark-idle below.
973
- // `<= 1` (was `= 1`): citation-decay floors importance at 0 (added v2.73.2, after this
974
- // predicate was written) and the LLM low-signal filter saves at imp=0 — those rows are
975
- // STRICTLY lower value than imp=1 yet escaped GC, accumulating to ~40% of a mature DB
976
- // (immortal: hidden from injection by the imp>=1 floor, but visible as explicit-search noise).
977
- const compressed = db.prepare(`
978
- UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
979
- WHERE COALESCE(compressed_into, 0) = 0
980
- AND COALESCE(importance, 1) <= 1
981
- AND COALESCE(injection_count, 0) = 0
982
- -- v3.23: never auto-hide a row that carries a real lesson. compression folds
983
- -- sources into a title-only summary (lesson lost), and COMPRESSED_AUTO hides the
984
- -- row from search entirely. A low-importance obs whose lesson_learned is the
985
- -- distilled value must stay findable (audit: 62 lessons buried this way). The 7d
986
- -- noise block below already excludes lessons; this 30d block had drifted.
987
- AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
988
- AND created_at_epoch < ?
989
- AND project = ?
990
- `).run(autoCompressAge, project);
991
- if (compressed.changes > 0) {
992
- debugLog('DEBUG', 'session-start', `auto-compressed ${compressed.changes} old observations`);
993
- }
994
-
995
- // v2.47 P0-3: accelerated compress for LOW_SIGNAL + no-signal noise.
996
- // 7-day window instead of 30. The write-side capNoiseImportance forces
997
- // imp=1 on these already; this just shrinks the GC latency so the
998
- // projected 32.5% corpus reduction materializes within a week on live
999
- // DBs instead of bleeding into the 30-day tier.
1000
- const noiseCompressAge = Date.now() - 7 * DAY_MS;
1001
- const noiseCompressed = db.prepare(`
1002
- UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
1003
- WHERE COALESCE(compressed_into, 0) = 0
1004
- AND COALESCE(importance, 1) <= 1
1005
- AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
1006
- AND (facts IS NULL OR facts = '' OR facts = '[]')
1007
- AND (
1008
- title LIKE 'Modified %' OR title LIKE 'Worked on %'
1009
- OR title LIKE 'Reviewed %' OR title LIKE 'Error%'
1010
- )
1011
- AND created_at_epoch < ?
1012
- AND project = ?
1013
- `).run(noiseCompressAge, project);
1014
- if (noiseCompressed.changes > 0) {
1015
- debugLog('DEBUG', 'session-start', `auto-compressed ${noiseCompressed.changes} LOW_SIGNAL noise (7d window)`);
1016
- }
935
+ // The two auto-compress marking passes that used to run here moved to the 24h
936
+ // auto-maintain worker (audit 2026-08-22, P2-11): they are maintenance, and every
937
+ // boot paid two full-table conditional UPDATEs for them. markAutoCompressible in
938
+ // lib/maintain-core.mjs holds the (unchanged) predicates.
1017
939
  })();
1018
940
  }
1019
941
 
1020
- function runSessionStartAutoMaintain(db) {
942
+ function runSessionStartAutoMaintain(db, project) {
1021
943
  // Auto-maintain: cleanup + decay + boost + purge, gated to once per 24h
1022
944
  const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
1023
945
  let shouldMaintain = true;
@@ -1047,6 +969,20 @@ function runSessionStartAutoMaintain(db) {
1047
969
  // children (compressed_into dangling at a deleted id). purgeStale recovers them
1048
970
  // first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
1049
971
  // created_at_epoch: 30d marking gate + 7d grace = 37d.
972
+ // Mark auto-compressible rows BEFORE the purge/decay ops below, preserving the
973
+ // order the SessionStart transaction had relative to them (marking ran first, at
974
+ // boot). Project-scoped exactly as before — P2-11 moved the cadence, not the scope.
975
+ if (project) {
976
+ const marked = markAutoCompressible(db, project);
977
+ if (marked.aged > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.aged} old observations`);
978
+ if (marked.noise > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.noise} LOW_SIGNAL noise (7d window)`);
979
+ } else {
980
+ // Every production spawn passes it (spawnBackground('auto-maintain', project)); a
981
+ // hand-run `hook.mjs auto-maintain` does not. Say so rather than skipping in
982
+ // silence — work vanishing without a word is this codebase's recurring shape.
983
+ debugLog('DEBUG', 'auto-maintain', 'no project argument — skipping auto-compress marking');
984
+ }
985
+
1050
986
  const purged = purgeStale(db, mctx, Date.now() - 37 * DAY_MS);
1051
987
  if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
1052
988
 
@@ -1099,8 +1035,7 @@ function runSessionStartAutoMaintain(db) {
1099
1035
  `).all();
1100
1036
  if (dupPairs.length > 0) {
1101
1037
  const removeIds = dupPairs.map(p => p.remove_id);
1102
- const ph = removeIds.map(() => '?').join(',');
1103
- db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph}) AND superseded_at IS NULL`).run(Date.now(), ...removeIds);
1038
+ stampDedupSuperseded(db, removeIds, 'auto-dedup');
1104
1039
  debugLog('DEBUG', 'auto-maintain', `auto-deduped ${dupPairs.length} near-identical observations`);
1105
1040
  }
1106
1041
 
@@ -1132,9 +1067,7 @@ function runSessionStartAutoMaintain(db) {
1132
1067
  }));
1133
1068
  const fuzzyRemoveIds = selectFuzzyDedupeIds(rows, { maxMerges: FUZZY_MAX_MERGES });
1134
1069
  if (fuzzyRemoveIds.length > 0) {
1135
- const ph = fuzzyRemoveIds.map(() => '?').join(',');
1136
- db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup-fuzzy' WHERE id IN (${ph})`)
1137
- .run(Date.now(), ...fuzzyRemoveIds);
1070
+ stampDedupSuperseded(db, fuzzyRemoveIds, 'auto-dedup-fuzzy');
1138
1071
  debugLog('DEBUG', 'auto-maintain', `fuzzy auto-deduped ${fuzzyRemoveIds.length} near-identical observations`);
1139
1072
  }
1140
1073
  }
@@ -1178,22 +1111,22 @@ function runSessionStartAutoMaintain(db) {
1178
1111
  // due, the heavy pass (VACUUM-INTO snapshot + purge/cleanup/decay/dedup) is handed to
1179
1112
  // a detached `auto-maintain` worker via spawnBackground so it never blocks interactive
1180
1113
  // session start. The worker re-checks the same gate (idempotent) before doing the work.
1181
- function scheduleSessionStartAutoMaintain() {
1114
+ function scheduleSessionStartAutoMaintain(project) {
1182
1115
  const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
1183
1116
  try {
1184
1117
  const last = JSON.parse(readFileSync(maintainFile, 'utf8'));
1185
1118
  if (Date.now() - last.epoch < 24 * 3600000) return; // not due — no spawn
1186
1119
  } catch { /* no gate file → due */ }
1187
- if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain');
1120
+ if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
1188
1121
  }
1189
1122
 
1190
1123
  // Detached `auto-maintain` worker entry: opens its own DB and runs the maintenance
1191
1124
  // pass off the interactive boot path. runSessionStartAutoMaintain still owns the 24h
1192
1125
  // gate + the compress/optimize spawns at its tail.
1193
- function handleAutoMaintain() {
1126
+ function handleAutoMaintain(project) {
1194
1127
  const db = openDb();
1195
1128
  if (!db) return;
1196
- try { runSessionStartAutoMaintain(db); }
1129
+ try { runSessionStartAutoMaintain(db, project); }
1197
1130
  finally { try { db.close(); } catch { /* ignore */ } }
1198
1131
  }
1199
1132
 
@@ -1226,23 +1159,7 @@ function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, cc
1226
1159
  // Background llm-summary will produce a richer Haiku version later;
1227
1160
  // context injection query (ORDER BY created_at_epoch DESC) auto-prefers latest.
1228
1161
  try {
1229
- const firstPrompt = db.prepare(`
1230
- SELECT prompt_text FROM user_prompts
1231
- WHERE content_session_id = ?
1232
- ORDER BY prompt_number ASC LIMIT 1
1233
- `).get(prevSessionId);
1234
-
1235
- const prevObs = db.prepare(`
1236
- SELECT title FROM observations
1237
- WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
1238
- ORDER BY created_at_epoch DESC LIMIT 5
1239
- `).all(prevSessionId);
1240
-
1241
- // Raw values flow into scrubRecord; truncation deferred to .run() so
1242
- // secrets straddling the truncation boundary still match scrubSecrets
1243
- // regex length floors.
1244
- const fastRequestRaw = firstPrompt?.prompt_text || '';
1245
- const fastCompletedRaw = prevObs.map(o => o.title).filter(Boolean).join('; ');
1162
+ const { request: fastRequestRaw, completed: fastCompletedRaw } = readFastSummarySource(db, prevSessionId);
1246
1163
 
1247
1164
  // Infer remaining_items from handoff unfinished (already built above at line 476)
1248
1165
  let fastRemainingRaw = '';
@@ -1256,16 +1173,11 @@ function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, cc
1256
1173
  }
1257
1174
 
1258
1175
  if (fastRequestRaw || fastCompletedRaw) {
1259
- const safe = scrubRecord('session_summaries', {
1260
- request: fastRequestRaw,
1261
- completed: fastCompletedRaw,
1262
- remaining_items: fastRemainingRaw,
1176
+ insertFastSummary(db, {
1177
+ sessionId: prevSessionId, project: prevProject || project, now,
1178
+ values: { request: fastRequestRaw, completed: fastCompletedRaw, remaining: fastRemainingRaw },
1179
+ limits: FAST_SUMMARY_LIMITS.sessionStart,
1263
1180
  });
1264
- db.prepare(`
1265
- INSERT INTO session_summaries
1266
- (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
1267
- VALUES (?, ?, ?, '', '', ?, '', ?, '[]', '[]', 'fast', ?, ?)
1268
- `).run(prevSessionId, prevProject || project, truncate(safe.request, 200), truncate(safe.completed, 300), truncate(safe.remaining_items, 200), now.toISOString(), now.getTime());
1269
1181
  }
1270
1182
  } catch (e) { debugCatch(e, 'session-start-fast-summary'); }
1271
1183
  }
@@ -1316,30 +1228,15 @@ function buildFallbackFastSummary(db, { project, now, prevSessionId }) {
1316
1228
  `).get(recentSession.content_session_id);
1317
1229
 
1318
1230
  if (!hasSummary) {
1319
- const fp = db.prepare(`
1320
- SELECT prompt_text FROM user_prompts
1321
- WHERE content_session_id = ? ORDER BY prompt_number ASC LIMIT 1
1322
- `).get(recentSession.content_session_id);
1323
- const po = db.prepare(`
1324
- SELECT title FROM observations
1325
- WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
1326
- ORDER BY created_at_epoch DESC LIMIT 5
1327
- `).all(recentSession.content_session_id);
1328
-
1329
- // Raw values into scrubRecord; truncation at .run() preserves
1330
- // straddling-secret detection (per privacy review).
1331
- const frRaw = fp?.prompt_text || '';
1332
- const fcRaw = po.map(o => o.title).filter(Boolean).join('; ');
1231
+ const { request: frRaw, completed: fcRaw } = readFastSummarySource(db, recentSession.content_session_id);
1333
1232
  if (frRaw || fcRaw) {
1334
- const safe = scrubRecord('session_summaries', {
1335
- request: frRaw,
1336
- completed: fcRaw,
1233
+ // No remaining_items on this path: an /exit restart has no handoff and no
1234
+ // episode snapshot to infer one from. It was a bare '' in the SQL before.
1235
+ insertFastSummary(db, {
1236
+ sessionId: recentSession.content_session_id, project, now,
1237
+ values: { request: frRaw, completed: fcRaw },
1238
+ limits: FAST_SUMMARY_LIMITS.exitRestart,
1337
1239
  });
1338
- db.prepare(`
1339
- INSERT INTO session_summaries
1340
- (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
1341
- VALUES (?, ?, ?, '', '', ?, '', '', '[]', '[]', 'fast', ?, ?)
1342
- `).run(recentSession.content_session_id, project, truncate(safe.request, 200), truncate(safe.completed, 300), now.toISOString(), now.getTime());
1343
1240
  }
1344
1241
  }
1345
1242
  }
@@ -1509,7 +1406,7 @@ async function handleSessionStart() {
1509
1406
 
1510
1407
  runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now });
1511
1408
 
1512
- scheduleSessionStartAutoMaintain();
1409
+ scheduleSessionStartAutoMaintain(project);
1513
1410
 
1514
1411
  // ── Non-transactional operations (side effects, background work) ──
1515
1412
 
@@ -1992,7 +1889,7 @@ try {
1992
1889
  case 'llm-summary': await handleLLMSummary(); break;
1993
1890
  case 'auto-compress': handleAutoCompress(); break;
1994
1891
  case 'enrich-save': await handleEnrichSave(process.argv[3]); break;
1995
- case 'auto-maintain': handleAutoMaintain(); break;
1892
+ case 'auto-maintain': handleAutoMaintain(process.argv[3]); break;
1996
1893
  case 'llm-optimize': await handleLLMOptimize(); break;
1997
1894
  // Detached update refresh spawned by handleSessionStart (audit P3d) — does the
1998
1895
  // GitHub fetch off the SessionStart critical path, writing update-state.json so
package/hooks/hooks.json CHANGED
@@ -56,7 +56,7 @@
56
56
  "hooks": [
57
57
  {
58
58
  "type": "command",
59
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-launcher.mjs\" scripts/pre-agent-inject.js",
59
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-agent-inject.sh\"",
60
60
  "timeout": 5
61
61
  }
62
62
  ]
package/install.mjs CHANGED
@@ -682,6 +682,9 @@ settings.hooks = settings.hooks || {};
682
682
 
683
683
  const SCRIPTS_PATH = join(INSTALL_DIR, 'scripts');
684
684
  const PREFILTER_PATH = join(SCRIPTS_PATH, 'post-tool-use.sh');
685
+ // Second bash prefilter, same idea one event over: skip the Node start for a
686
+ // default-off feature (audit 2026-08-22 P2-5, see the script's header).
687
+ const AGENT_PREFILTER_PATH = join(SCRIPTS_PATH, 'pre-agent-inject.sh');
685
688
  // v2.84: every Node hook invocation routes through hook-launcher.mjs so an
686
689
  // ERR_MODULE_NOT_FOUND from a partial-install drift auto-heals via
687
690
  // install.mjs repair instead of permanently bricking the hook chain.
@@ -792,12 +795,16 @@ const memPreSkillBridge = {
792
795
  // P0 subagent dispatch-time injection (default off — CLAUDE_MEM_SUBAGENT_INJECT).
793
796
  // Fires on the Agent/Task dispatch so a subagent (otherwise memory-blind — #8848)
794
797
  // can receive one relevant lesson via updatedInput. Parity with hooks/hooks.json.
798
+ // Behind the bash prefilter since 2026-08-22 (audit P2-5): the flag is off by
799
+ // default, and a disabled feature was starting a Node interpreter on every single
800
+ // Agent dispatch (22.6ms → 2.4ms; see scripts/pre-agent-inject.sh). The prefilter
801
+ // execs the same launcher when the flag is on.
795
802
  const memPreAgentInject = {
796
803
  matcher: 'Agent|Task',
797
804
  hooks: [
798
805
  {
799
806
  type: 'command',
800
- command: nodeHook('scripts/pre-agent-inject.js'),
807
+ command: `bash "${AGENT_PREFILTER_PATH}"`,
801
808
  timeout: 5
802
809
  }
803
810
  ]
@@ -1957,6 +1964,26 @@ async function doctor() {
1957
1964
  }
1958
1965
  }
1959
1966
 
1967
+ // Env flags that are ACCEPTED but do nothing. A flag a user set and believes is
1968
+ // in effect is a silent lie the rest of doctor cannot see: every other check here
1969
+ // asks whether the install is healthy, and this install is perfectly healthy while
1970
+ // behaving as though the flag were unset (audit 2026-08-22 P2-5).
1971
+ // Dynamically imported, and the mode table is read from the module that owns it:
1972
+ // a static import would drag the registry retriever's dependency chain into a tool
1973
+ // whose whole job is to run when the tree is broken, and a local copy of the list
1974
+ // is the drift shape this repo keeps paying for.
1975
+ try {
1976
+ const { getRequestedRecommendMode, RECOMMEND_MODE_UNIMPLEMENTED } = await import('./registry-recommend.mjs');
1977
+ const requested = getRequestedRecommendMode();
1978
+ if (RECOMMEND_MODE_UNIMPLEMENTED.has(requested)) {
1979
+ dwarn(`CLAUDE_MEM_RECOMMEND_MODE=${requested}: accepted but NOT implemented — `
1980
+ + 'live skill-recommendation injection is Phase 2 and does not exist. The engine '
1981
+ + 'is running in shadow (logs only, injects nothing). Set shadow or off.');
1982
+ }
1983
+ } catch (e) {
1984
+ dwarn('Env flags: check failed — ' + e.message);
1985
+ }
1986
+
1960
1987
  // Plugin cache versions
1961
1988
  const pluginCacheBase = join(homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_KEY, 'claude-mem-lite');
1962
1989
  if (existsSync(pluginCacheBase)) {
@@ -2005,7 +2032,13 @@ export function isMemHook(cfg) {
2005
2032
  const cmd = h.command || '';
2006
2033
  return cmd.includes('claude-mem-lite') ||
2007
2034
  cmd.includes('hook-launcher.mjs') ||
2008
- cmd.includes('scripts/post-tool-use.sh');
2035
+ cmd.includes('scripts/post-tool-use.sh') ||
2036
+ // Same reason post-tool-use.sh is named here: a bash prefilter routes through
2037
+ // NO launcher, and the product-name clause only fires when the install dir
2038
+ // happens to contain it — which CLAUDE_MEM_DIR can relocate. Without this line
2039
+ // an Agent|Task hook in a relocated install survives uninstall and duplicates
2040
+ // on reinstall (audit 2026-08-22 P2-5 added the second prefilter).
2041
+ cmd.includes('scripts/pre-agent-inject.sh');
2009
2042
  });
2010
2043
  }
2011
2044
 
@@ -10,10 +10,11 @@
10
10
  // column UPDATE including access_count. Per-row UPDATEs wrapped in try-catch
11
11
  // to prevent SQLITE_CORRUPT_VTAB cascades from stopping the whole scan.
12
12
 
13
- import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
13
+ import { readFileSync, readdirSync, statSync } from 'fs';
14
14
  import { join } from 'path';
15
15
  import { debugCatch } from '../utils.mjs';
16
16
  import { keyContextIdsFileName } from './injected-ids.mjs';
17
+ import { readTranscriptEntries } from './transcript-scan.mjs';
17
18
 
18
19
  import { DAY_MS } from './time-constants.mjs';
19
20
  // `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
@@ -32,13 +33,7 @@ const CITATION_RE = /#(\d{1,7})\b/g;
32
33
  export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
33
34
  const { mainOnly = false } = opts;
34
35
  const ids = new Set();
35
- if (!transcriptPath || !existsSync(transcriptPath)) return ids;
36
- let raw;
37
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
38
- for (const line of raw.split('\n')) {
39
- if (!line.trim()) continue;
40
- let entry;
41
- try { entry = JSON.parse(line); } catch { continue; }
36
+ for (const entry of readTranscriptEntries(transcriptPath)) {
42
37
  // Claude Code transcript: one JSON per line with type='assistant' | 'user' | ...
43
38
  if (entry.type !== 'assistant' || !entry.message) continue;
44
39
  // Citation-decay loop scopes citation signal to main-thread text only —
@@ -77,18 +72,10 @@ export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
77
72
  * Returns zeros if transcript is missing or empty.
78
73
  */
79
74
  export function computeCiteRecall(transcriptPath) {
80
- const empty = { injected: 0, cited: 0, recalled: 0, ratio: 0 };
81
- if (!transcriptPath || !existsSync(transcriptPath)) return empty;
82
- let raw;
83
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return empty; }
84
-
85
75
  const injected = new Set();
86
76
  const cited = new Set();
87
77
 
88
- for (const line of raw.split('\n')) {
89
- if (!line.trim()) continue;
90
- let entry;
91
- try { entry = JSON.parse(line); } catch { continue; }
78
+ for (const entry of readTranscriptEntries(transcriptPath)) {
92
79
  const target = entry.type === 'assistant' ? cited : injected;
93
80
  // Walk every text-bearing surface the transcript carries: top-level content,
94
81
  // nested message content (assistant/user blocks), and tool_result-style
@@ -199,13 +186,7 @@ function normalizeHookCommand(command) {
199
186
  */
200
187
  function eachHookAttachment(transcriptPath, fn, opts = {}) {
201
188
  const { mainOnly = false } = opts;
202
- if (!transcriptPath || !existsSync(transcriptPath)) return;
203
- let raw;
204
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return; }
205
- for (const line of raw.split('\n')) {
206
- if (!line.trim()) continue;
207
- let entry;
208
- try { entry = JSON.parse(line); } catch { continue; }
189
+ for (const entry of readTranscriptEntries(transcriptPath)) {
209
190
  if (entry.type !== 'attachment') continue;
210
191
  if (mainOnly && entry.isSidechain === true) continue;
211
192
  const att = entry.attachment;
@@ -523,12 +504,7 @@ const SUBAGENT_INJECT_ID_RE = /^\s{0,4}#(\d{1,7})\s+—/;
523
504
  */
524
505
  export function extractInjectedFromSubagentPrompt(transcriptPath) {
525
506
  const ids = new Set();
526
- let raw;
527
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
528
- for (const line of raw.split('\n')) {
529
- if (!line) continue;
530
- let entry;
531
- try { entry = JSON.parse(line); } catch { continue; }
507
+ for (const entry of readTranscriptEntries(transcriptPath)) {
532
508
  const c = entry.message?.content;
533
509
  const text = typeof c === 'string'
534
510
  ? c
@@ -612,22 +588,15 @@ export function aggregateProjectCiteRecall(txDir, { cutoff = 0 } = {}) {
612
588
  * @returns {boolean}
613
589
  */
614
590
  export function hasMainThreadAssistantText(transcriptPath) {
615
- if (!transcriptPath || !existsSync(transcriptPath)) return false;
616
- let raw;
617
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return false; }
618
- // Reverse-iterate so a turn that just produced text returns true on the
619
- // FIRST line examined instead of walking the entire transcript. Common case
620
- // (model wrote a paragraph → Stop fires) short-circuits in O(1) line parses;
621
- // pathological case (no text anywhere) still walks all entries but that's
622
- // the degenerate state we want false for anyway. v2.80 perf polish — the
623
- // pre-v2.80 forward scan held the full transcript in memory and walked
624
- // every line on the common case too.
625
- const lines = raw.split('\n');
626
- for (let i = lines.length - 1; i >= 0; i--) {
627
- const line = lines[i];
628
- if (!line || !line.trim()) continue;
629
- let entry;
630
- try { entry = JSON.parse(line); } catch { continue; }
591
+ // Reverse-iterate so a turn that just produced text returns true on the FIRST entry
592
+ // examined instead of walking the whole transcript; the pathological case (no text
593
+ // anywhere) still walks everything, which is the degenerate state we want false for.
594
+ // v2.80 polish. The short-circuit is now over an already-parsed array rather than over
595
+ // JSON.parse calls, so it saves iteration rather than parsing — the parse is shared
596
+ // with the other seven scanners of the same file (audit P2-8).
597
+ const entries = readTranscriptEntries(transcriptPath);
598
+ for (let i = entries.length - 1; i >= 0; i--) {
599
+ const entry = entries[i];
631
600
  if (entry.type !== 'assistant' || !entry.message) continue;
632
601
  if (entry.isSidechain === true) continue;
633
602
  const content = entry.message.content;
@@ -12,7 +12,8 @@
12
12
  // Legacy schema (pre-v2.81): { "<path>": <number> } — tolerated, never emits.
13
13
 
14
14
  import { basename, join } from 'path';
15
- import { readFileSync, existsSync } from 'fs';
15
+ import { readFileSync } from 'fs';
16
+ import { readTranscriptEntries } from './transcript-scan.mjs';
16
17
  import { EDIT_TOOLS } from '../utils.mjs';
17
18
 
18
19
  const MAX_FILES = 2;
@@ -140,19 +141,10 @@ const MEM_SAVE_TOOL_NAMES = new Set([
140
141
  ]);
141
142
 
142
143
  export function countUnsavedBugfixShape(transcriptPath) {
143
- const empty = { nudged: 0, saved: 0, unsaved: 0 };
144
- if (!transcriptPath || !existsSync(transcriptPath)) return empty;
145
- let raw;
146
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return empty; }
147
-
148
144
  let nudged = 0;
149
145
  let saved = 0;
150
146
 
151
- for (const line of raw.split('\n')) {
152
- if (!line.trim()) continue;
153
- let entry;
154
- try { entry = JSON.parse(line); } catch { continue; }
155
-
147
+ for (const entry of readTranscriptEntries(transcriptPath)) {
156
148
  // Bugfix-shape hint emissions live in PostToolUse attachment.stdout.
157
149
  if (entry.type === 'attachment') {
158
150
  const stdout = entry.attachment?.stdout || '';
@@ -281,13 +273,7 @@ const CITE_BACK_ID_RE = /#(\d{1,7})\b/g;
281
273
 
282
274
  export function extractCiteBackSignals(transcriptPath) {
283
275
  const ids = new Set();
284
- if (!transcriptPath || !existsSync(transcriptPath)) return ids;
285
- let raw;
286
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
287
- for (const line of raw.split('\n')) {
288
- if (!line.trim()) continue;
289
- let entry;
290
- try { entry = JSON.parse(line); } catch { continue; }
276
+ for (const entry of readTranscriptEntries(transcriptPath)) {
291
277
  if (entry.type !== 'attachment') continue;
292
278
  const stdout = entry.attachment?.stdout || '';
293
279
  if (!stdout.includes(CITE_BACK_HINT_LEADER)) continue;
@@ -82,9 +82,16 @@ export function checkDevDrift(installDir, sourceFiles) {
82
82
  // stale in silence: tests/doctor-hook-script-manifest.test.mjs re-derives it from the
83
83
  // `command` strings in hooks/hooks.json and asserts equality, so registering a new hook
84
84
  // script without classifying it here goes red.
85
+ //
86
+ // `pre-agent-inject.js` moved OUT of this set on 2026-08-22 (audit P2-5): no command line
87
+ // names it any more — `pre-agent-inject.sh` does, and execs the .js only when the feature
88
+ // is switched on. It is still shipped and still executed, so `checkHookScriptDrift` keeps
89
+ // grading it; it now reports as the module class, whose message ("ERR_MODULE_NOT_FOUND at
90
+ // hook time") is the truer description of what its absence does. Same severity either way,
91
+ // which is why this set is documented as driving the MESSAGE, not the grade.
85
92
  export const HOOK_SCRIPT_ENTRY_POINTS = new Set([
86
93
  'post-tool-use.sh', 'user-prompt-search.js', 'pre-tool-recall.js',
87
- 'post-tool-recall.js', 'pre-skill-bridge.js', 'pre-agent-inject.js',
94
+ 'post-tool-recall.js', 'pre-skill-bridge.js', 'pre-agent-inject.sh',
88
95
  'hook-launcher.mjs',
89
96
  ]);
90
97