claude-mem-lite 3.74.1 → 3.75.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hook.mjs CHANGED
@@ -23,17 +23,17 @@ 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 {
33
33
  readEpisodeRaw, episodeFile,
34
34
  acquireLock, releaseLock, readEpisode, writeEpisode,
35
35
  createEpisode, addFileToEpisode, planEpisodeFlush,
36
- writePendingEntry, mergePendingEntries, episodeHasSignificantContent,
36
+ writePendingEntry, mergePendingEntries, episodeHasSignificantContent, explainSignificance,
37
37
  } from './hook-episode.mjs';
38
38
  import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
39
39
  import { entry as preCompactEntry } from './hook-precompact.mjs';
@@ -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,
@@ -294,18 +294,30 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
294
294
  // suppresses the detached enrichment spawn (test determinism; sibling of
295
295
  // CLAUDE_MEM_SKIP_COMPRESS / _OPTIMIZE) — the synchronous immediate obs still lands.
296
296
  function flushEpisodeGroup(ep, db) {
297
- const isSignificant = episodeHasSignificantContent(ep);
297
+ const verdict = explainSignificance(ep);
298
+ const isSignificant = verdict.significant;
299
+ // Audit P2-14 instrument: moving Grep into the bash prefilter would save 85ms per Grep
300
+ // (91.2ms handoff vs 6.1ms for the already-skipped Read), but nothing records how many
301
+ // episodes are kept ONLY because of their Greps — and demoting what the product
302
+ // remembers on a deduction is how work disappears silently. This is that counter.
303
+ // Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
304
+ recordMetric(join(RUNTIME_DIR, '..'), {
305
+ event: 'episode_significance',
306
+ rule: verdict.rule,
307
+ significant: isSignificant,
308
+ readCount: verdict.readCount,
309
+ grepCount: verdict.grepCount,
310
+ grepDecisive: verdict.grepDecisive,
311
+ });
298
312
 
299
313
  // Immediate save: rule-based observation for instant visibility; the LLM
300
- // background worker upgrades title/narrative/importance later.
314
+ // background worker upgrades title/narrative/importance later. `db` is
315
+ // flushEpisode's handle — passed in so the caller owns open/close and the whole
316
+ // flush is gated on one availability check (B1). saveEpisodeImmediate re-checks
317
+ // significance itself, so the guard here is about the flush-file decision below.
301
318
  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'); }
319
+ const id = saveEpisodeImmediate(ep, db, 'flushEpisode-immediateSave');
320
+ if (id) ep.savedId = id;
309
321
  }
310
322
 
311
323
  const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
@@ -596,21 +608,18 @@ async function handleStop() {
596
608
  // one garbled observation. planEpisodeFlush returns [episode] by reference for the common
597
609
  // single-session case, so this is a no-op there. Immediate-save each group BEFORE its
598
610
  // flush-file write (same ordering as flushEpisodeGroup) so a worker crash can't lose it.
611
+ // One body for both paths (audit 2026-08-22 P2-9). This loop used to be a
612
+ // hand-copy of flushEpisodeGroup carrying three comments asserting parity with
613
+ // it, and it was not in parity: it ignored CLAUDE_MEM_SKIP_EPISODE_LLM, so a
614
+ // lock-contended Stop under test spawned a real background worker; and a failed
615
+ // flush-file write threw out of the whole loop into the outer catch, whose
616
+ // `finally` then deleted the claim file — abandoning the subs that had not been
617
+ // written yet. flushEpisodeGroup returns 'writefail' and the caller skips only
618
+ // that sub. Per-sub significance gating lives inside it too.
599
619
  for (const sub of planEpisodeFlush(episode)) {
600
620
  if (!sub.sessionId) sub.sessionId = sessionId;
601
621
  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);
622
+ flushEpisodeGroup(sub, claimDb);
614
623
  }
615
624
  }
616
625
  } finally {
@@ -651,21 +660,7 @@ async function handleStop() {
651
660
  'SELECT 1 FROM session_summaries WHERE memory_session_id = ? LIMIT 1'
652
661
  ).get(sessionId);
653
662
  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('; ');
663
+ const { request: fastRequestRaw, completed: obsCompleted } = readFastSummarySource(db, sessionId);
669
664
 
670
665
  // Structural extraction from the assistant's tail message.
671
666
  // CLAUDE.md §10 mandates Done/Not done/Failed/Uncertain markers, so the
@@ -693,24 +688,11 @@ async function handleStop() {
693
688
  const finalNotes = structuredNotes || 'fast';
694
689
 
695
690
  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,
691
+ insertFastSummary(db, {
692
+ sessionId, project, now: new Date(),
693
+ values: { request: fastRequestRaw, completed: finalCompleted, remaining: finalRemaining, notes: finalNotes },
694
+ limits: FAST_SUMMARY_LIMITS.stop,
702
695
  });
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
696
  }
715
697
  }
716
698
  } catch (e) { debugCatch(e, 'handleStop-fast-summary'); }
@@ -841,10 +823,13 @@ async function handleStop() {
841
823
  } catch (e) { debugCatch(e, 'handleStop-citation-decay'); }
842
824
 
843
825
  // Persist cite-recall ratio for the next SessionStart to surface as
844
- // feedback. We deliberately scan the transcript a second time here
845
- // (cheap; the file is already in OS cache) rather than threading the
846
- // count through `extractCitationsFromTranscript` so the bump path stays
847
- // unchanged.
826
+ // feedback. This block re-scans the transcript rather than threading the
827
+ // count through `extractCitationsFromTranscript`, so the bump path stays
828
+ // unchanged — and since P2-8 every scanner in it shares ONE parse via
829
+ // lib/transcript-scan.mjs, so "scan again" costs an array iteration, not a
830
+ // re-read. (The old wording, "cheap; the file is already in OS cache", was
831
+ // arguing the pre-memo case: the OS cache saved the read, never the parse,
832
+ // which was ~all of the cost.)
848
833
  try {
849
834
  const stats = computeCiteRecall(transcriptPath);
850
835
  // B2 (v2.83.1): also persist the bugfix-shape nudge/save delta so
@@ -942,7 +927,6 @@ function gcStalePreRecallCooldowns() {
942
927
  function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now }) {
943
928
  // ── DB mutations in a transaction (crash-safe consistency) ──
944
929
  const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
945
- const autoCompressAge = Date.now() - 30 * DAY_MS; // 30 days (accelerated from 90)
946
930
 
947
931
  db.transaction(() => {
948
932
  // Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
@@ -965,59 +949,55 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
965
949
  WHERE status = 'active' AND started_at_epoch < ?
966
950
  `).run(staleSessionCutoff);
967
951
 
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
- }
952
+ // The two auto-compress marking passes that used to run here moved to the 24h
953
+ // auto-maintain worker (audit 2026-08-22, P2-11): they are maintenance, and every
954
+ // boot paid two full-table conditional UPDATEs for them. markAutoCompressible in
955
+ // lib/maintain-core.mjs holds the (unchanged) predicates.
1017
956
  })();
1018
957
  }
1019
958
 
1020
- function runSessionStartAutoMaintain(db) {
959
+ // Per-project 24h gate for the auto-compressible marking. Separate from the global
960
+ // maintain gate on purpose: the marking is project-scoped work that every project needs
961
+ // daily, while the pass it used to ride on (VACUUM snapshot + purge + decay + dedup) is
962
+ // whole-DB work that should happen once a day in total.
963
+ function markCompressibleGateFile(project) {
964
+ // Project names arrive already mangled by inferProject (`projects--mem`), but this is
965
+ // a filename, so do not trust that — one stray separator writes outside RUNTIME_DIR.
966
+ return join(RUNTIME_DIR, `last-mark-compressible-${String(project).replace(/[^A-Za-z0-9._-]/g, '-')}.json`);
967
+ }
968
+
969
+ function markAutoCompressibleIfDue(db, project) {
970
+ if (!project) {
971
+ // Every production spawn passes it (spawnBackground('auto-maintain', project)); a
972
+ // hand-run `hook.mjs auto-maintain` does not. Say so rather than skipping in
973
+ // silence — work vanishing without a word is this codebase's recurring shape.
974
+ debugLog('DEBUG', 'auto-maintain', 'no project argument — skipping auto-compress marking');
975
+ return;
976
+ }
977
+ const gate = markCompressibleGateFile(project);
978
+ try {
979
+ const last = JSON.parse(readFileSync(gate, 'utf8'));
980
+ if (Date.now() - last.epoch < 24 * 3600000) return;
981
+ } catch { /* no gate file → due */ }
982
+ try {
983
+ const marked = markAutoCompressible(db, project);
984
+ if (marked.aged > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.aged} old observations`);
985
+ if (marked.noise > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.noise} LOW_SIGNAL noise (7d window)`);
986
+ writeFileSync(gate, JSON.stringify({ epoch: Date.now() }));
987
+ } catch (e) { debugCatch(e, 'auto-maintain-mark-compressible'); }
988
+ }
989
+
990
+ function runSessionStartAutoMaintain(db, project) {
991
+ // The auto-compressible marking runs on its OWN per-project gate, before the global
992
+ // one below. v3.75.0 moved this work off SessionStart onto this worker (P2-11) and,
993
+ // by putting it inside the global `shouldMaintain` block, cut its coverage from
994
+ // "every project, every boot" to "one project per 24h" — RUNTIME_DIR is a single
995
+ // global directory, so whichever project wins the gate is the only one marked, and
996
+ // with N projects in rotation N-1 never get the 7-day noise pass. The move was right;
997
+ // the shared gate was not. Heavy work (VACUUM snapshot, purge, decay, dedup) stays on
998
+ // the global gate — that genuinely should run once a day, not once per project.
999
+ markAutoCompressibleIfDue(db, project);
1000
+
1021
1001
  // Auto-maintain: cleanup + decay + boost + purge, gated to once per 24h
1022
1002
  const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
1023
1003
  let shouldMaintain = true;
@@ -1047,6 +1027,11 @@ function runSessionStartAutoMaintain(db) {
1047
1027
  // children (compressed_into dangling at a deleted id). purgeStale recovers them
1048
1028
  // first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
1049
1029
  // created_at_epoch: 30d marking gate + 7d grace = 37d.
1030
+ // The marking itself already ran at the top of this function, on its own
1031
+ // per-project gate — it must not be conditioned on the global one (see
1032
+ // markAutoCompressibleIfDue). It still happens BEFORE the purge/decay ops below,
1033
+ // preserving the order the SessionStart transaction had relative to them.
1034
+
1050
1035
  const purged = purgeStale(db, mctx, Date.now() - 37 * DAY_MS);
1051
1036
  if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
1052
1037
 
@@ -1099,8 +1084,7 @@ function runSessionStartAutoMaintain(db) {
1099
1084
  `).all();
1100
1085
  if (dupPairs.length > 0) {
1101
1086
  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);
1087
+ stampDedupSuperseded(db, removeIds, 'auto-dedup');
1104
1088
  debugLog('DEBUG', 'auto-maintain', `auto-deduped ${dupPairs.length} near-identical observations`);
1105
1089
  }
1106
1090
 
@@ -1132,9 +1116,7 @@ function runSessionStartAutoMaintain(db) {
1132
1116
  }));
1133
1117
  const fuzzyRemoveIds = selectFuzzyDedupeIds(rows, { maxMerges: FUZZY_MAX_MERGES });
1134
1118
  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);
1119
+ stampDedupSuperseded(db, fuzzyRemoveIds, 'auto-dedup-fuzzy');
1138
1120
  debugLog('DEBUG', 'auto-maintain', `fuzzy auto-deduped ${fuzzyRemoveIds.length} near-identical observations`);
1139
1121
  }
1140
1122
  }
@@ -1178,22 +1160,30 @@ function runSessionStartAutoMaintain(db) {
1178
1160
  // due, the heavy pass (VACUUM-INTO snapshot + purge/cleanup/decay/dedup) is handed to
1179
1161
  // a detached `auto-maintain` worker via spawnBackground so it never blocks interactive
1180
1162
  // session start. The worker re-checks the same gate (idempotent) before doing the work.
1181
- function scheduleSessionStartAutoMaintain() {
1182
- const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
1183
- try {
1184
- const last = JSON.parse(readFileSync(maintainFile, 'utf8'));
1185
- if (Date.now() - last.epoch < 24 * 3600000) return; // not due — no spawn
1186
- } catch { /* no gate file → due */ }
1187
- if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain');
1163
+ function scheduleSessionStartAutoMaintain(project) {
1164
+ // TWO gates, either of which is enough to spawn. Checking only the global one is what
1165
+ // made the marking single-project in v3.75.0: in a multi-project rotation the global
1166
+ // stamp is already fresh by the time the second project boots, so its worker never
1167
+ // ran and its rows were never marked.
1168
+ const due = (file) => {
1169
+ try {
1170
+ const last = JSON.parse(readFileSync(file, 'utf8'));
1171
+ return Date.now() - last.epoch >= 24 * 3600000;
1172
+ } catch { return true; } // no gate file → due
1173
+ };
1174
+ const maintainDue = due(join(RUNTIME_DIR, 'last-auto-maintain.json'));
1175
+ const markingDue = Boolean(project) && due(markCompressibleGateFile(project));
1176
+ if (!maintainDue && !markingDue) return;
1177
+ if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
1188
1178
  }
1189
1179
 
1190
1180
  // Detached `auto-maintain` worker entry: opens its own DB and runs the maintenance
1191
1181
  // pass off the interactive boot path. runSessionStartAutoMaintain still owns the 24h
1192
1182
  // gate + the compress/optimize spawns at its tail.
1193
- function handleAutoMaintain() {
1183
+ function handleAutoMaintain(project) {
1194
1184
  const db = openDb();
1195
1185
  if (!db) return;
1196
- try { runSessionStartAutoMaintain(db); }
1186
+ try { runSessionStartAutoMaintain(db, project); }
1197
1187
  finally { try { db.close(); } catch { /* ignore */ } }
1198
1188
  }
1199
1189
 
@@ -1226,23 +1216,7 @@ function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, cc
1226
1216
  // Background llm-summary will produce a richer Haiku version later;
1227
1217
  // context injection query (ORDER BY created_at_epoch DESC) auto-prefers latest.
1228
1218
  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('; ');
1219
+ const { request: fastRequestRaw, completed: fastCompletedRaw } = readFastSummarySource(db, prevSessionId);
1246
1220
 
1247
1221
  // Infer remaining_items from handoff unfinished (already built above at line 476)
1248
1222
  let fastRemainingRaw = '';
@@ -1256,16 +1230,11 @@ function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, cc
1256
1230
  }
1257
1231
 
1258
1232
  if (fastRequestRaw || fastCompletedRaw) {
1259
- const safe = scrubRecord('session_summaries', {
1260
- request: fastRequestRaw,
1261
- completed: fastCompletedRaw,
1262
- remaining_items: fastRemainingRaw,
1233
+ insertFastSummary(db, {
1234
+ sessionId: prevSessionId, project: prevProject || project, now,
1235
+ values: { request: fastRequestRaw, completed: fastCompletedRaw, remaining: fastRemainingRaw },
1236
+ limits: FAST_SUMMARY_LIMITS.sessionStart,
1263
1237
  });
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
1238
  }
1270
1239
  } catch (e) { debugCatch(e, 'session-start-fast-summary'); }
1271
1240
  }
@@ -1316,30 +1285,15 @@ function buildFallbackFastSummary(db, { project, now, prevSessionId }) {
1316
1285
  `).get(recentSession.content_session_id);
1317
1286
 
1318
1287
  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('; ');
1288
+ const { request: frRaw, completed: fcRaw } = readFastSummarySource(db, recentSession.content_session_id);
1333
1289
  if (frRaw || fcRaw) {
1334
- const safe = scrubRecord('session_summaries', {
1335
- request: frRaw,
1336
- completed: fcRaw,
1290
+ // No remaining_items on this path: an /exit restart has no handoff and no
1291
+ // episode snapshot to infer one from. It was a bare '' in the SQL before.
1292
+ insertFastSummary(db, {
1293
+ sessionId: recentSession.content_session_id, project, now,
1294
+ values: { request: frRaw, completed: fcRaw },
1295
+ limits: FAST_SUMMARY_LIMITS.exitRestart,
1337
1296
  });
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
1297
  }
1344
1298
  }
1345
1299
  }
@@ -1509,7 +1463,7 @@ async function handleSessionStart() {
1509
1463
 
1510
1464
  runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now });
1511
1465
 
1512
- scheduleSessionStartAutoMaintain();
1466
+ scheduleSessionStartAutoMaintain(project);
1513
1467
 
1514
1468
  // ── Non-transactional operations (side effects, background work) ──
1515
1469
 
@@ -1992,7 +1946,7 @@ try {
1992
1946
  case 'llm-summary': await handleLLMSummary(); break;
1993
1947
  case 'auto-compress': handleAutoCompress(); break;
1994
1948
  case 'enrich-save': await handleEnrichSave(process.argv[3]); break;
1995
- case 'auto-maintain': handleAutoMaintain(); break;
1949
+ case 'auto-maintain': handleAutoMaintain(process.argv[3]); break;
1996
1950
  case 'llm-optimize': await handleLLMOptimize(); break;
1997
1951
  // Detached update refresh spawned by handleSessionStart (audit P3d) — does the
1998
1952
  // 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