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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +122 -4
- package/README.zh-CN.md +18 -10
- package/hook-context.mjs +4 -7
- package/hook-llm.mjs +7 -2
- package/hook-memory.mjs +4 -6
- package/hook.mjs +64 -167
- package/hooks/hooks.json +1 -1
- package/install.mjs +35 -2
- package/lib/citation-tracker.mjs +15 -46
- package/lib/cite-back-hint.mjs +4 -18
- package/lib/doctor-drift.mjs +8 -1
- package/lib/fast-summary.mjs +87 -0
- package/lib/get-core.mjs +35 -0
- package/lib/maintain-core.mjs +95 -1
- package/lib/registry-core.mjs +94 -0
- package/lib/resolve-data-dir.mjs +53 -4
- package/lib/summary-extractor.mjs +2 -8
- package/lib/transcript-scan.mjs +66 -0
- package/mem-cli.mjs +43 -39
- package/nlp.mjs +20 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/registry-recommend.mjs +30 -4
- package/scoring-sql.mjs +21 -7
- package/scripts/pre-agent-inject.sh +43 -0
- package/scripts/user-prompt-search.js +17 -3
- package/server.mjs +30 -38
- package/source-files.mjs +12 -0
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
|
-
|
|
26
|
+
inferProject, detectBashSignificance,
|
|
27
27
|
planErrorRecall, extractFilePaths, isRelatedToEpisode,
|
|
28
28
|
makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
|
|
29
|
-
|
|
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,
|
|
48
|
-
import {
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
request: fastRequestRaw,
|
|
699
|
-
|
|
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
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1260
|
-
|
|
1261
|
-
completed: fastCompletedRaw,
|
|
1262
|
-
|
|
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
|
|
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
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
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:
|
|
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
|
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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;
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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;
|
package/lib/doctor-drift.mjs
CHANGED
|
@@ -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.
|
|
94
|
+
'post-tool-recall.js', 'pre-skill-bridge.js', 'pre-agent-inject.sh',
|
|
88
95
|
'hook-launcher.mjs',
|
|
89
96
|
]);
|
|
90
97
|
|