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/.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/haiku-client.mjs +7 -58
- package/hook-context.mjs +4 -7
- package/hook-episode.mjs +44 -20
- package/hook-llm.mjs +7 -2
- package/hook-memory.mjs +25 -10
- package/hook.mjs +132 -178
- package/hooks/hooks.json +1 -1
- package/install.mjs +35 -2
- package/lib/citation-tracker.mjs +67 -51
- 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/persist-reminder.mjs +8 -8
- package/lib/registry-core.mjs +94 -0
- package/lib/resolve-data-dir.mjs +53 -4
- package/lib/summary-extractor.mjs +2 -8
- package/lib/task-imperative.mjs +8 -1
- package/lib/transcript-scan.mjs +66 -0
- package/lib/ups-query.mjs +26 -0
- package/mem-cli.mjs +53 -44
- package/memdir.mjs +5 -1
- package/nlp.mjs +20 -2
- package/npm-shrinkwrap.json +3 -2
- package/package.json +7 -1
- package/registry-recommend.mjs +30 -4
- package/schema.mjs +3 -1
- package/scoring-sql.mjs +21 -7
- package/scripts/pre-agent-inject.sh +43 -0
- package/scripts/user-prompt-search.js +8 -3
- package/server.mjs +30 -38
- package/source-files.mjs +15 -0
- package/utils.mjs +13 -5
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
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
@@ -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
|
|
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
|
-
|
|
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'); }
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
request: fastRequestRaw,
|
|
699
|
-
|
|
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.
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
|
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
|
-
|
|
1260
|
-
|
|
1261
|
-
completed: fastCompletedRaw,
|
|
1262
|
-
|
|
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
|
|
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
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
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
|
|