claude-mem-lite 3.6.0 → 3.7.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 +21 -13
- package/README.zh-CN.md +1 -1
- package/deep-search.mjs +26 -4
- package/hook-update.mjs +17 -1
- package/hook.mjs +403 -373
- package/install.mjs +691 -639
- package/lib/atomic-write.mjs +38 -0
- package/lib/doctor-benchmark.mjs +4 -4
- package/lib/err-sampler.mjs +7 -3
- package/lib/lesson-idents.mjs +32 -0
- package/lib/proc-lock.mjs +112 -0
- package/lib/search-core.mjs +272 -16
- package/mem-cli.mjs +56 -175
- package/package.json +6 -2
- package/schema.mjs +119 -65
- package/scoring-sql.mjs +25 -0
- package/scripts/post-tool-recall.js +71 -0
- package/scripts/pre-tool-recall.js +27 -2
- package/search-engine.mjs +1 -1
- package/{server-internals.mjs → search-scoring.mjs} +6 -2
- package/server.mjs +85 -295
- package/source-files.mjs +11 -1
package/hook.mjs
CHANGED
|
@@ -647,6 +647,403 @@ function gcStalePreRecallCooldowns() {
|
|
|
647
647
|
} catch { /* silent — RUNTIME_DIR may not exist on first run */ }
|
|
648
648
|
}
|
|
649
649
|
|
|
650
|
+
// ─── SessionStart phase helpers ──────────────────────────────────────────────
|
|
651
|
+
// Extracted verbatim from handleSessionStart (audit P1-10) so the dispatcher
|
|
652
|
+
// reads as a sequence of named phases. Each is a self-contained side-effect unit
|
|
653
|
+
// (db / fs / stdout / background spawns) with a narrow input contract and no
|
|
654
|
+
// return-state coupling; behavior is byte-identical to the prior inline blocks.
|
|
655
|
+
|
|
656
|
+
function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now }) {
|
|
657
|
+
// ── DB mutations in a transaction (crash-safe consistency) ──
|
|
658
|
+
const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
|
|
659
|
+
const autoCompressAge = Date.now() - 30 * 86400000; // 30 days (accelerated from 90)
|
|
660
|
+
|
|
661
|
+
db.transaction(() => {
|
|
662
|
+
// Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
|
|
663
|
+
db.prepare(`
|
|
664
|
+
INSERT OR IGNORE INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
|
|
665
|
+
VALUES (?, ?, ?, ?, ?, 'active')
|
|
666
|
+
`).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
|
|
667
|
+
|
|
668
|
+
// Complete previous session if this is a mid-session restart (/clear, /compact, crash)
|
|
669
|
+
if (prevSessionId) {
|
|
670
|
+
db.prepare(`
|
|
671
|
+
UPDATE sdk_sessions SET status = 'completed', completed_at = ?, completed_at_epoch = ?
|
|
672
|
+
WHERE content_session_id = ? AND status = 'active'
|
|
673
|
+
`).run(now.toISOString(), now.getTime(), prevSessionId);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Stale session cleanup: mark 24h+ active sessions as abandoned
|
|
677
|
+
db.prepare(`
|
|
678
|
+
UPDATE sdk_sessions SET status = 'abandoned'
|
|
679
|
+
WHERE status = 'active' AND started_at_epoch < ?
|
|
680
|
+
`).run(staleSessionCutoff);
|
|
681
|
+
|
|
682
|
+
// Auto-compress: mark old low-importance observations as compressed (30+ days, importance=1)
|
|
683
|
+
// Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
|
|
684
|
+
// v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant
|
|
685
|
+
// via hook-memory injection, even if user never explicitly fetched). Same
|
|
686
|
+
// protection applied symmetrically in auto-maintain decay/mark-idle below.
|
|
687
|
+
const compressed = db.prepare(`
|
|
688
|
+
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
689
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
690
|
+
AND importance = 1
|
|
691
|
+
AND COALESCE(injection_count, 0) = 0
|
|
692
|
+
AND created_at_epoch < ?
|
|
693
|
+
AND project = ?
|
|
694
|
+
`).run(autoCompressAge, project);
|
|
695
|
+
if (compressed.changes > 0) {
|
|
696
|
+
debugLog('DEBUG', 'session-start', `auto-compressed ${compressed.changes} old observations`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// v2.47 P0-3: accelerated compress for LOW_SIGNAL + no-signal noise.
|
|
700
|
+
// 7-day window instead of 30. The write-side capNoiseImportance forces
|
|
701
|
+
// imp=1 on these already; this just shrinks the GC latency so the
|
|
702
|
+
// projected 32.5% corpus reduction materializes within a week on live
|
|
703
|
+
// DBs instead of bleeding into the 30-day tier.
|
|
704
|
+
const noiseCompressAge = Date.now() - 7 * 86400000;
|
|
705
|
+
const noiseCompressed = db.prepare(`
|
|
706
|
+
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
707
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
708
|
+
AND importance = 1
|
|
709
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
710
|
+
AND (facts IS NULL OR facts = '' OR facts = '[]')
|
|
711
|
+
AND (
|
|
712
|
+
title LIKE 'Modified %' OR title LIKE 'Worked on %'
|
|
713
|
+
OR title LIKE 'Reviewed %' OR title LIKE 'Error%'
|
|
714
|
+
)
|
|
715
|
+
AND created_at_epoch < ?
|
|
716
|
+
AND project = ?
|
|
717
|
+
`).run(noiseCompressAge, project);
|
|
718
|
+
if (noiseCompressed.changes > 0) {
|
|
719
|
+
debugLog('DEBUG', 'session-start', `auto-compressed ${noiseCompressed.changes} LOW_SIGNAL noise (7d window)`);
|
|
720
|
+
}
|
|
721
|
+
})();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function runSessionStartAutoMaintain(db) {
|
|
725
|
+
// Auto-maintain: cleanup + decay + boost + purge, gated to once per 24h
|
|
726
|
+
const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
|
|
727
|
+
let shouldMaintain = true;
|
|
728
|
+
try {
|
|
729
|
+
const last = JSON.parse(readFileSync(maintainFile, 'utf8'));
|
|
730
|
+
if (Date.now() - last.epoch < 24 * 3600000) shouldMaintain = false;
|
|
731
|
+
} catch {}
|
|
732
|
+
if (shouldMaintain) {
|
|
733
|
+
try {
|
|
734
|
+
const STALE_AGE = Date.now() - 30 * 86400000;
|
|
735
|
+
const OP_CAP = 500;
|
|
736
|
+
|
|
737
|
+
// Purge FIRST: delete pending-purge entries. Schema has no marked_at_epoch, so we
|
|
738
|
+
// anchor retention on created_at_epoch instead: 30d marking gate + 7d grace = 37d.
|
|
739
|
+
// Older cutoffs (e.g. 7d) were always redundant with the 30d marking filter and
|
|
740
|
+
// made purge effectively immediate on the next maintenance cycle — fix for T4-P1-A.
|
|
741
|
+
const purged = db.prepare(`
|
|
742
|
+
DELETE FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE}
|
|
743
|
+
AND created_at_epoch < ?
|
|
744
|
+
`).run(Date.now() - 37 * 86400000);
|
|
745
|
+
if (purged.changes > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged.changes} stale observations`);
|
|
746
|
+
|
|
747
|
+
// cleanup / decay+mark-idle / boost via maintain-core (shared with CLI + MCP).
|
|
748
|
+
// injection_count>0 protection lives in decayAndMarkIdle. Whole-DB, cap 500.
|
|
749
|
+
const mctx = { projectFilter: '', baseParams: [], staleAge: STALE_AGE, opCap: OP_CAP };
|
|
750
|
+
|
|
751
|
+
const cleaned = cleanupBroken(db, mctx);
|
|
752
|
+
if (cleaned > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned} broken observations`);
|
|
753
|
+
|
|
754
|
+
const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
|
|
755
|
+
if (decayed > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed} stale observations`);
|
|
756
|
+
if (idleMarked > 0) debugLog('DEBUG', 'auto-maintain', `marked ${idleMarked} idle as pending-purge`);
|
|
757
|
+
|
|
758
|
+
const boosted = boostAccessed(db, mctx);
|
|
759
|
+
if (boosted > 0) debugLog('DEBUG', 'auto-maintain', `boosted ${boosted} frequently-accessed observations`);
|
|
760
|
+
|
|
761
|
+
// Auto-dedup (exact): merge identical-title observations within 1h.
|
|
762
|
+
// Catches rapid duplicate writes (same hook firing twice, race conditions).
|
|
763
|
+
const dupPairs = db.prepare(`
|
|
764
|
+
SELECT a.id as keep_id, b.id as remove_id
|
|
765
|
+
FROM observations a
|
|
766
|
+
JOIN observations b ON a.title = b.title AND a.project = b.project
|
|
767
|
+
AND a.id < b.id
|
|
768
|
+
AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
|
|
769
|
+
AND COALESCE(a.compressed_into, 0) = 0
|
|
770
|
+
AND COALESCE(b.compressed_into, 0) = 0
|
|
771
|
+
LIMIT 20
|
|
772
|
+
`).all();
|
|
773
|
+
if (dupPairs.length > 0) {
|
|
774
|
+
const removeIds = dupPairs.map(p => p.remove_id);
|
|
775
|
+
const ph = removeIds.map(() => '?').join(',');
|
|
776
|
+
db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph})`).run(Date.now(), ...removeIds);
|
|
777
|
+
debugLog('DEBUG', 'auto-maintain', `auto-deduped ${dupPairs.length} near-identical observations`);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Auto-dedup (fuzzy): catches near-identical titles that exact-match
|
|
781
|
+
// misses across larger time windows — e.g. episode-batch titles like
|
|
782
|
+
// "Modified A.mjs, B.mjs" vs "Modified B.mjs, A.mjs" written days apart.
|
|
783
|
+
// MinHash pre-filter (≥0.7) cuts the O(N²) scan; Jaccard ≥0.95 stays
|
|
784
|
+
// well clear of legit "two updates same area" pairs (those typically
|
|
785
|
+
// score 0.7–0.85, surfaced via `maintain scan` for manual review).
|
|
786
|
+
// Bounded by ${SCAN_LIMIT} recent rows × ${FUZZY_MAX_MERGES}-merge cap.
|
|
787
|
+
if (!process.env.CLAUDE_MEM_SKIP_AUTO_DEDUP_FUZZY) {
|
|
788
|
+
const SCAN_LIMIT = 500;
|
|
789
|
+
const FUZZY_MAX_MERGES = 20;
|
|
790
|
+
const recent = db.prepare(`
|
|
791
|
+
SELECT id, title, importance, created_at_epoch
|
|
792
|
+
FROM observations
|
|
793
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
794
|
+
AND superseded_at IS NULL
|
|
795
|
+
AND created_at_epoch > ?
|
|
796
|
+
AND title IS NOT NULL AND title != ''
|
|
797
|
+
ORDER BY created_at_epoch DESC LIMIT ${SCAN_LIMIT}
|
|
798
|
+
`).all(STALE_AGE);
|
|
799
|
+
if (recent.length >= 2) {
|
|
800
|
+
const titles = recent.map(r => r.title.trim());
|
|
801
|
+
const minhashes = titles.map(t => t ? computeMinHash(t) : null);
|
|
802
|
+
const fuzzyRemoveIds = [];
|
|
803
|
+
const removed = new Set();
|
|
804
|
+
outer: for (let i = 0; i < recent.length; i++) {
|
|
805
|
+
if (!minhashes[i] || removed.has(recent[i].id)) continue;
|
|
806
|
+
for (let j = i + 1; j < recent.length; j++) {
|
|
807
|
+
if (!minhashes[j] || removed.has(recent[j].id)) continue;
|
|
808
|
+
if (estimateJaccardFromMinHash(minhashes[i], minhashes[j]) < MINHASH_PREFILTER) continue;
|
|
809
|
+
if (jaccardSimilarity(titles[i], titles[j]) < FUZZY_DEDUP_THRESHOLD) continue;
|
|
810
|
+
// Keep the higher-importance row; tiebreak by older (lower id wins access history)
|
|
811
|
+
const keep = (recent[i].importance ?? 1) >= (recent[j].importance ?? 1) ? recent[i] : recent[j];
|
|
812
|
+
const remove = keep === recent[i] ? recent[j] : recent[i];
|
|
813
|
+
fuzzyRemoveIds.push(remove.id);
|
|
814
|
+
removed.add(remove.id);
|
|
815
|
+
if (fuzzyRemoveIds.length >= FUZZY_MAX_MERGES) break outer;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (fuzzyRemoveIds.length > 0) {
|
|
819
|
+
const ph = fuzzyRemoveIds.map(() => '?').join(',');
|
|
820
|
+
db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup-fuzzy' WHERE id IN (${ph})`)
|
|
821
|
+
.run(Date.now(), ...fuzzyRemoveIds);
|
|
822
|
+
debugLog('DEBUG', 'auto-maintain', `fuzzy auto-deduped ${fuzzyRemoveIds.length} near-identical observations`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Orphan sweep: remove `ep-flush-*` / `pending-*` runtime files older
|
|
828
|
+
// than 1h. handleLLMEpisode normally unlinks its own tmpFile on every
|
|
829
|
+
// exit path, but a crashed worker (OOM, host reboot, kill -9) leaves
|
|
830
|
+
// the file behind, and the doctor "Stale temp files" warning then
|
|
831
|
+
// accumulates indefinitely. fs-only; runs inside the 24h gate so it
|
|
832
|
+
// shares cadence with the rest of auto-maintain.
|
|
833
|
+
try {
|
|
834
|
+
const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR);
|
|
835
|
+
if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
|
|
836
|
+
} catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
|
|
837
|
+
|
|
838
|
+
// Mark maintenance as done (24h gate) — even though compression runs in background
|
|
839
|
+
writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
|
|
840
|
+
// Weekly summary grouping runs in background to avoid blocking SessionStart
|
|
841
|
+
if (!process.env.CLAUDE_MEM_SKIP_COMPRESS) spawnBackground('auto-compress');
|
|
842
|
+
if (!process.env.CLAUDE_MEM_SKIP_OPTIMIZE) spawnBackground('llm-optimize');
|
|
843
|
+
} catch (e) { debugCatch(e, 'auto-maintain'); }
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, ccSessionId, episodeSnapshot, now }) {
|
|
848
|
+
// Shared clear handoff reference — queried once, used by fast summary + working state
|
|
849
|
+
let prevClearHandoff = null;
|
|
850
|
+
|
|
851
|
+
if (prevSessionId) {
|
|
852
|
+
// Save handoff for cross-session continuity (/clear or /compact).
|
|
853
|
+
// prevSessionId is the mem-internal id — use it to look up the finished session's
|
|
854
|
+
// user_prompts / observations. ccSessionId (same CC session across /clear) scopes
|
|
855
|
+
// the stored row so UserPromptSubmit can read its own handoff back.
|
|
856
|
+
// Legacy/test paths (no stdin) fall back to prevSessionId for both.
|
|
857
|
+
const handoffScopeId = ccSessionId || prevSessionId;
|
|
858
|
+
try { buildAndSaveHandoff(db, prevSessionId, prevProject || project, 'clear', episodeSnapshot, handoffScopeId); }
|
|
859
|
+
catch (e) { debugCatch(e, 'session-start-handoff'); }
|
|
860
|
+
|
|
861
|
+
// Read the just-saved handoff for downstream consumers (fast summary remaining, working state).
|
|
862
|
+
// Session-scoped read to avoid picking up a parallel session's clear handoff.
|
|
863
|
+
try {
|
|
864
|
+
prevClearHandoff = db.prepare(
|
|
865
|
+
'SELECT working_on, unfinished, key_files FROM session_handoffs WHERE project = ? AND type = ? AND session_id = ?'
|
|
866
|
+
).get(prevProject || project, 'clear', handoffScopeId);
|
|
867
|
+
} catch {}
|
|
868
|
+
|
|
869
|
+
// Generate session summary for previous session (background Haiku — richer version)
|
|
870
|
+
spawnBackground('llm-summary', prevSessionId, prevProject || project);
|
|
871
|
+
|
|
872
|
+
// Build fast synchronous summary for immediate context availability.
|
|
873
|
+
// Background llm-summary will produce a richer Haiku version later;
|
|
874
|
+
// context injection query (ORDER BY created_at_epoch DESC) auto-prefers latest.
|
|
875
|
+
try {
|
|
876
|
+
const firstPrompt = db.prepare(`
|
|
877
|
+
SELECT prompt_text FROM user_prompts
|
|
878
|
+
WHERE content_session_id = ?
|
|
879
|
+
ORDER BY prompt_number ASC LIMIT 1
|
|
880
|
+
`).get(prevSessionId);
|
|
881
|
+
|
|
882
|
+
const prevObs = db.prepare(`
|
|
883
|
+
SELECT title FROM observations
|
|
884
|
+
WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
|
|
885
|
+
ORDER BY created_at_epoch DESC LIMIT 5
|
|
886
|
+
`).all(prevSessionId);
|
|
887
|
+
|
|
888
|
+
// Raw values flow into scrubRecord; truncation deferred to .run() so
|
|
889
|
+
// secrets straddling the truncation boundary still match scrubSecrets
|
|
890
|
+
// regex length floors.
|
|
891
|
+
const fastRequestRaw = firstPrompt?.prompt_text || '';
|
|
892
|
+
const fastCompletedRaw = prevObs.map(o => o.title).filter(Boolean).join('; ');
|
|
893
|
+
|
|
894
|
+
// Infer remaining_items from handoff unfinished (already built above at line 476)
|
|
895
|
+
let fastRemainingRaw = '';
|
|
896
|
+
if (prevClearHandoff?.unfinished) {
|
|
897
|
+
fastRemainingRaw = extractUnfinishedSummary(prevClearHandoff.unfinished, 0);
|
|
898
|
+
}
|
|
899
|
+
// Fallback: episode errors
|
|
900
|
+
if (!fastRemainingRaw && episodeSnapshot?.entries) {
|
|
901
|
+
const errors = episodeSnapshot.entries.filter(e => e.isError).map(e => e.desc).filter(Boolean);
|
|
902
|
+
if (errors.length > 0) fastRemainingRaw = errors.join('; ');
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (fastRequestRaw || fastCompletedRaw) {
|
|
906
|
+
const safe = scrubRecord('session_summaries', {
|
|
907
|
+
request: fastRequestRaw,
|
|
908
|
+
completed: fastCompletedRaw,
|
|
909
|
+
remaining_items: fastRemainingRaw,
|
|
910
|
+
});
|
|
911
|
+
db.prepare(`
|
|
912
|
+
INSERT INTO session_summaries
|
|
913
|
+
(memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
|
|
914
|
+
VALUES (?, ?, ?, '', '', ?, '', ?, '[]', '[]', 'fast', ?, ?)
|
|
915
|
+
`).run(prevSessionId, prevProject || project, truncate(safe.request, 200), truncate(safe.completed, 300), truncate(safe.remaining_items, 200), now.toISOString(), now.getTime());
|
|
916
|
+
}
|
|
917
|
+
} catch (e) { debugCatch(e, 'session-start-fast-summary'); }
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function cleanStaleLockFiles() {
|
|
922
|
+
// Clean stale lock files in runtime dir
|
|
923
|
+
try {
|
|
924
|
+
for (const f of readdirSync(RUNTIME_DIR)) {
|
|
925
|
+
if (!f.endsWith('.lock')) continue;
|
|
926
|
+
const lp = join(RUNTIME_DIR, f);
|
|
927
|
+
try {
|
|
928
|
+
const raw = readFileSync(lp, 'utf8');
|
|
929
|
+
const info = JSON.parse(raw);
|
|
930
|
+
const age = Date.now() - (info.ts || 0);
|
|
931
|
+
let stale = age > STALE_LOCK_MS;
|
|
932
|
+
if (!stale && info.pid) {
|
|
933
|
+
try { process.kill(info.pid, 0); } catch (killErr) {
|
|
934
|
+
stale = killErr.code === 'ESRCH';
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (stale) unlinkSync(lp);
|
|
938
|
+
} catch {
|
|
939
|
+
try {
|
|
940
|
+
const st = statSync(lp);
|
|
941
|
+
if (Date.now() - st.mtimeMs > STALE_LOCK_MS) unlinkSync(lp);
|
|
942
|
+
} catch {}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
} catch {}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function buildFallbackFastSummary(db, { project, now, prevSessionId }) {
|
|
949
|
+
// Fallback fast summary: if a recently completed session has no summary yet
|
|
950
|
+
// (e.g. /exit → fast restart before Haiku finishes), build one synchronously.
|
|
951
|
+
// Skipped when prevSessionId is set (already handled above).
|
|
952
|
+
if (!prevSessionId) {
|
|
953
|
+
try {
|
|
954
|
+
const recentSession = db.prepare(`
|
|
955
|
+
SELECT content_session_id, project FROM sdk_sessions
|
|
956
|
+
WHERE project = ? AND status = 'completed' AND completed_at_epoch > ?
|
|
957
|
+
ORDER BY completed_at_epoch DESC LIMIT 1
|
|
958
|
+
`).get(project, Date.now() - 120000); // within last 2 minutes
|
|
959
|
+
|
|
960
|
+
if (recentSession) {
|
|
961
|
+
const hasSummary = db.prepare(`
|
|
962
|
+
SELECT 1 FROM session_summaries WHERE memory_session_id = ? LIMIT 1
|
|
963
|
+
`).get(recentSession.content_session_id);
|
|
964
|
+
|
|
965
|
+
if (!hasSummary) {
|
|
966
|
+
const fp = db.prepare(`
|
|
967
|
+
SELECT prompt_text FROM user_prompts
|
|
968
|
+
WHERE content_session_id = ? ORDER BY prompt_number ASC LIMIT 1
|
|
969
|
+
`).get(recentSession.content_session_id);
|
|
970
|
+
const po = db.prepare(`
|
|
971
|
+
SELECT title FROM observations
|
|
972
|
+
WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
|
|
973
|
+
ORDER BY created_at_epoch DESC LIMIT 5
|
|
974
|
+
`).all(recentSession.content_session_id);
|
|
975
|
+
|
|
976
|
+
// Raw values into scrubRecord; truncation at .run() preserves
|
|
977
|
+
// straddling-secret detection (per privacy review).
|
|
978
|
+
const frRaw = fp?.prompt_text || '';
|
|
979
|
+
const fcRaw = po.map(o => o.title).filter(Boolean).join('; ');
|
|
980
|
+
if (frRaw || fcRaw) {
|
|
981
|
+
const safe = scrubRecord('session_summaries', {
|
|
982
|
+
request: frRaw,
|
|
983
|
+
completed: fcRaw,
|
|
984
|
+
});
|
|
985
|
+
db.prepare(`
|
|
986
|
+
INSERT INTO session_summaries
|
|
987
|
+
(memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
|
|
988
|
+
VALUES (?, ?, ?, '', '', ?, '', '', '[]', '[]', 'fast', ?, ?)
|
|
989
|
+
`).run(recentSession.content_session_id, project, truncate(safe.request, 200), truncate(safe.completed, 300), now.toISOString(), now.getTime());
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
} catch (e) { debugCatch(e, 'session-start-exit-fast-summary'); }
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async function emitStartupDashboard(db, project) {
|
|
998
|
+
// T10c: Startup dashboard — aggregate git/tasks/plans/handoff/events into a
|
|
999
|
+
// structured JSON hookSpecificOutput block. Emitted BEFORE the plain-text
|
|
1000
|
+
// <claude-mem-context> so both surfaces coexist. Empty string → skip.
|
|
1001
|
+
try {
|
|
1002
|
+
const { buildDashboard } = await import('./lib/startup-dashboard.mjs');
|
|
1003
|
+
let dashboardText = buildDashboard({ db, project, projectPath: process.cwd() });
|
|
1004
|
+
const citeNudge = buildCiteRecallNudge(project);
|
|
1005
|
+
if (citeNudge) {
|
|
1006
|
+
dashboardText = dashboardText ? `${citeNudge}\n${dashboardText}` : citeNudge;
|
|
1007
|
+
}
|
|
1008
|
+
// v2.79: surface setup.sh dependency-install failure as a high-visibility
|
|
1009
|
+
// line at the very top of the dashboard. setup.sh writes runtime/.deps-broken
|
|
1010
|
+
// (JSON: ts/reason/root/repair) on failure and removes it on success — so
|
|
1011
|
+
// a stale flag self-heals on the next clean SessionStart. Without this
|
|
1012
|
+
// surface, hook degradation looks identical to "nothing happening" until
|
|
1013
|
+
// the user notices missing context days later.
|
|
1014
|
+
try {
|
|
1015
|
+
const depsFlag = join(RUNTIME_DIR, '.deps-broken');
|
|
1016
|
+
if (existsSync(depsFlag)) {
|
|
1017
|
+
let detail = 'unknown';
|
|
1018
|
+
let repair = '';
|
|
1019
|
+
try {
|
|
1020
|
+
const raw = readFileSync(depsFlag, 'utf8').trim();
|
|
1021
|
+
const parsed = JSON.parse(raw);
|
|
1022
|
+
detail = parsed.reason || detail;
|
|
1023
|
+
repair = parsed.repair || '';
|
|
1024
|
+
} catch { /* corrupt flag — surface the fact only */ }
|
|
1025
|
+
const nudgeLines = [
|
|
1026
|
+
'⚠️ [claude-mem-lite] Hook dependencies failed to install on the last SessionStart.',
|
|
1027
|
+
` Reason: ${detail}`,
|
|
1028
|
+
];
|
|
1029
|
+
if (repair) nudgeLines.push(` Repair: ${repair}`);
|
|
1030
|
+
nudgeLines.push(' Until fixed, PreToolUse / PostToolUse / memory injection are degraded.');
|
|
1031
|
+
const nudge = nudgeLines.join('\n');
|
|
1032
|
+
dashboardText = dashboardText ? `${nudge}\n${dashboardText}` : nudge;
|
|
1033
|
+
}
|
|
1034
|
+
} catch (e) { debugCatch(e, 'session-start-deps-flag'); }
|
|
1035
|
+
if (dashboardText) {
|
|
1036
|
+
process.stdout.write(JSON.stringify({
|
|
1037
|
+
suppressOutput: true,
|
|
1038
|
+
hookSpecificOutput: {
|
|
1039
|
+
hookEventName: 'SessionStart',
|
|
1040
|
+
additionalContext: dashboardText,
|
|
1041
|
+
},
|
|
1042
|
+
}) + '\n');
|
|
1043
|
+
}
|
|
1044
|
+
} catch (e) { debugCatch(e, 'session-start-dashboard'); }
|
|
1045
|
+
}
|
|
1046
|
+
|
|
650
1047
|
async function handleSessionStart() {
|
|
651
1048
|
// GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
|
|
652
1049
|
// moved here from pre-tool-recall.js's hot path.
|
|
@@ -754,386 +1151,19 @@ async function handleSessionStart() {
|
|
|
754
1151
|
try {
|
|
755
1152
|
const now = new Date();
|
|
756
1153
|
|
|
757
|
-
|
|
758
|
-
const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
|
|
759
|
-
const autoCompressAge = Date.now() - 30 * 86400000; // 30 days (accelerated from 90)
|
|
760
|
-
|
|
761
|
-
db.transaction(() => {
|
|
762
|
-
// Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
|
|
763
|
-
db.prepare(`
|
|
764
|
-
INSERT OR IGNORE INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
|
|
765
|
-
VALUES (?, ?, ?, ?, ?, 'active')
|
|
766
|
-
`).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
|
|
767
|
-
|
|
768
|
-
// Complete previous session if this is a mid-session restart (/clear, /compact, crash)
|
|
769
|
-
if (prevSessionId) {
|
|
770
|
-
db.prepare(`
|
|
771
|
-
UPDATE sdk_sessions SET status = 'completed', completed_at = ?, completed_at_epoch = ?
|
|
772
|
-
WHERE content_session_id = ? AND status = 'active'
|
|
773
|
-
`).run(now.toISOString(), now.getTime(), prevSessionId);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// Stale session cleanup: mark 24h+ active sessions as abandoned
|
|
777
|
-
db.prepare(`
|
|
778
|
-
UPDATE sdk_sessions SET status = 'abandoned'
|
|
779
|
-
WHERE status = 'active' AND started_at_epoch < ?
|
|
780
|
-
`).run(staleSessionCutoff);
|
|
781
|
-
|
|
782
|
-
// Auto-compress: mark old low-importance observations as compressed (30+ days, importance=1)
|
|
783
|
-
// Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
|
|
784
|
-
// v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant
|
|
785
|
-
// via hook-memory injection, even if user never explicitly fetched). Same
|
|
786
|
-
// protection applied symmetrically in auto-maintain decay/mark-idle below.
|
|
787
|
-
const compressed = db.prepare(`
|
|
788
|
-
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
789
|
-
WHERE COALESCE(compressed_into, 0) = 0
|
|
790
|
-
AND importance = 1
|
|
791
|
-
AND COALESCE(injection_count, 0) = 0
|
|
792
|
-
AND created_at_epoch < ?
|
|
793
|
-
AND project = ?
|
|
794
|
-
`).run(autoCompressAge, project);
|
|
795
|
-
if (compressed.changes > 0) {
|
|
796
|
-
debugLog('DEBUG', 'session-start', `auto-compressed ${compressed.changes} old observations`);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// v2.47 P0-3: accelerated compress for LOW_SIGNAL + no-signal noise.
|
|
800
|
-
// 7-day window instead of 30. The write-side capNoiseImportance forces
|
|
801
|
-
// imp=1 on these already; this just shrinks the GC latency so the
|
|
802
|
-
// projected 32.5% corpus reduction materializes within a week on live
|
|
803
|
-
// DBs instead of bleeding into the 30-day tier.
|
|
804
|
-
const noiseCompressAge = Date.now() - 7 * 86400000;
|
|
805
|
-
const noiseCompressed = db.prepare(`
|
|
806
|
-
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
807
|
-
WHERE COALESCE(compressed_into, 0) = 0
|
|
808
|
-
AND importance = 1
|
|
809
|
-
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
810
|
-
AND (facts IS NULL OR facts = '' OR facts = '[]')
|
|
811
|
-
AND (
|
|
812
|
-
title LIKE 'Modified %' OR title LIKE 'Worked on %'
|
|
813
|
-
OR title LIKE 'Reviewed %' OR title LIKE 'Error%'
|
|
814
|
-
)
|
|
815
|
-
AND created_at_epoch < ?
|
|
816
|
-
AND project = ?
|
|
817
|
-
`).run(noiseCompressAge, project);
|
|
818
|
-
if (noiseCompressed.changes > 0) {
|
|
819
|
-
debugLog('DEBUG', 'session-start', `auto-compressed ${noiseCompressed.changes} LOW_SIGNAL noise (7d window)`);
|
|
820
|
-
}
|
|
821
|
-
})();
|
|
822
|
-
|
|
823
|
-
// Auto-maintain: cleanup + decay + boost + purge, gated to once per 24h
|
|
824
|
-
const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
|
|
825
|
-
let shouldMaintain = true;
|
|
826
|
-
try {
|
|
827
|
-
const last = JSON.parse(readFileSync(maintainFile, 'utf8'));
|
|
828
|
-
if (Date.now() - last.epoch < 24 * 3600000) shouldMaintain = false;
|
|
829
|
-
} catch {}
|
|
830
|
-
if (shouldMaintain) {
|
|
831
|
-
try {
|
|
832
|
-
const STALE_AGE = Date.now() - 30 * 86400000;
|
|
833
|
-
const OP_CAP = 500;
|
|
834
|
-
|
|
835
|
-
// Purge FIRST: delete pending-purge entries. Schema has no marked_at_epoch, so we
|
|
836
|
-
// anchor retention on created_at_epoch instead: 30d marking gate + 7d grace = 37d.
|
|
837
|
-
// Older cutoffs (e.g. 7d) were always redundant with the 30d marking filter and
|
|
838
|
-
// made purge effectively immediate on the next maintenance cycle — fix for T4-P1-A.
|
|
839
|
-
const purged = db.prepare(`
|
|
840
|
-
DELETE FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE}
|
|
841
|
-
AND created_at_epoch < ?
|
|
842
|
-
`).run(Date.now() - 37 * 86400000);
|
|
843
|
-
if (purged.changes > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged.changes} stale observations`);
|
|
844
|
-
|
|
845
|
-
// cleanup / decay+mark-idle / boost via maintain-core (shared with CLI + MCP).
|
|
846
|
-
// injection_count>0 protection lives in decayAndMarkIdle. Whole-DB, cap 500.
|
|
847
|
-
const mctx = { projectFilter: '', baseParams: [], staleAge: STALE_AGE, opCap: OP_CAP };
|
|
848
|
-
|
|
849
|
-
const cleaned = cleanupBroken(db, mctx);
|
|
850
|
-
if (cleaned > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned} broken observations`);
|
|
851
|
-
|
|
852
|
-
const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
|
|
853
|
-
if (decayed > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed} stale observations`);
|
|
854
|
-
if (idleMarked > 0) debugLog('DEBUG', 'auto-maintain', `marked ${idleMarked} idle as pending-purge`);
|
|
855
|
-
|
|
856
|
-
const boosted = boostAccessed(db, mctx);
|
|
857
|
-
if (boosted > 0) debugLog('DEBUG', 'auto-maintain', `boosted ${boosted} frequently-accessed observations`);
|
|
858
|
-
|
|
859
|
-
// Auto-dedup (exact): merge identical-title observations within 1h.
|
|
860
|
-
// Catches rapid duplicate writes (same hook firing twice, race conditions).
|
|
861
|
-
const dupPairs = db.prepare(`
|
|
862
|
-
SELECT a.id as keep_id, b.id as remove_id
|
|
863
|
-
FROM observations a
|
|
864
|
-
JOIN observations b ON a.title = b.title AND a.project = b.project
|
|
865
|
-
AND a.id < b.id
|
|
866
|
-
AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
|
|
867
|
-
AND COALESCE(a.compressed_into, 0) = 0
|
|
868
|
-
AND COALESCE(b.compressed_into, 0) = 0
|
|
869
|
-
LIMIT 20
|
|
870
|
-
`).all();
|
|
871
|
-
if (dupPairs.length > 0) {
|
|
872
|
-
const removeIds = dupPairs.map(p => p.remove_id);
|
|
873
|
-
const ph = removeIds.map(() => '?').join(',');
|
|
874
|
-
db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph})`).run(Date.now(), ...removeIds);
|
|
875
|
-
debugLog('DEBUG', 'auto-maintain', `auto-deduped ${dupPairs.length} near-identical observations`);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// Auto-dedup (fuzzy): catches near-identical titles that exact-match
|
|
879
|
-
// misses across larger time windows — e.g. episode-batch titles like
|
|
880
|
-
// "Modified A.mjs, B.mjs" vs "Modified B.mjs, A.mjs" written days apart.
|
|
881
|
-
// MinHash pre-filter (≥0.7) cuts the O(N²) scan; Jaccard ≥0.95 stays
|
|
882
|
-
// well clear of legit "two updates same area" pairs (those typically
|
|
883
|
-
// score 0.7–0.85, surfaced via `maintain scan` for manual review).
|
|
884
|
-
// Bounded by ${SCAN_LIMIT} recent rows × ${FUZZY_MAX_MERGES}-merge cap.
|
|
885
|
-
if (!process.env.CLAUDE_MEM_SKIP_AUTO_DEDUP_FUZZY) {
|
|
886
|
-
const SCAN_LIMIT = 500;
|
|
887
|
-
const FUZZY_MAX_MERGES = 20;
|
|
888
|
-
const recent = db.prepare(`
|
|
889
|
-
SELECT id, title, importance, created_at_epoch
|
|
890
|
-
FROM observations
|
|
891
|
-
WHERE COALESCE(compressed_into, 0) = 0
|
|
892
|
-
AND superseded_at IS NULL
|
|
893
|
-
AND created_at_epoch > ?
|
|
894
|
-
AND title IS NOT NULL AND title != ''
|
|
895
|
-
ORDER BY created_at_epoch DESC LIMIT ${SCAN_LIMIT}
|
|
896
|
-
`).all(STALE_AGE);
|
|
897
|
-
if (recent.length >= 2) {
|
|
898
|
-
const titles = recent.map(r => r.title.trim());
|
|
899
|
-
const minhashes = titles.map(t => t ? computeMinHash(t) : null);
|
|
900
|
-
const fuzzyRemoveIds = [];
|
|
901
|
-
const removed = new Set();
|
|
902
|
-
outer: for (let i = 0; i < recent.length; i++) {
|
|
903
|
-
if (!minhashes[i] || removed.has(recent[i].id)) continue;
|
|
904
|
-
for (let j = i + 1; j < recent.length; j++) {
|
|
905
|
-
if (!minhashes[j] || removed.has(recent[j].id)) continue;
|
|
906
|
-
if (estimateJaccardFromMinHash(minhashes[i], minhashes[j]) < MINHASH_PREFILTER) continue;
|
|
907
|
-
if (jaccardSimilarity(titles[i], titles[j]) < FUZZY_DEDUP_THRESHOLD) continue;
|
|
908
|
-
// Keep the higher-importance row; tiebreak by older (lower id wins access history)
|
|
909
|
-
const keep = (recent[i].importance ?? 1) >= (recent[j].importance ?? 1) ? recent[i] : recent[j];
|
|
910
|
-
const remove = keep === recent[i] ? recent[j] : recent[i];
|
|
911
|
-
fuzzyRemoveIds.push(remove.id);
|
|
912
|
-
removed.add(remove.id);
|
|
913
|
-
if (fuzzyRemoveIds.length >= FUZZY_MAX_MERGES) break outer;
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
if (fuzzyRemoveIds.length > 0) {
|
|
917
|
-
const ph = fuzzyRemoveIds.map(() => '?').join(',');
|
|
918
|
-
db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup-fuzzy' WHERE id IN (${ph})`)
|
|
919
|
-
.run(Date.now(), ...fuzzyRemoveIds);
|
|
920
|
-
debugLog('DEBUG', 'auto-maintain', `fuzzy auto-deduped ${fuzzyRemoveIds.length} near-identical observations`);
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
}
|
|
1154
|
+
runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now });
|
|
924
1155
|
|
|
925
|
-
|
|
926
|
-
// than 1h. handleLLMEpisode normally unlinks its own tmpFile on every
|
|
927
|
-
// exit path, but a crashed worker (OOM, host reboot, kill -9) leaves
|
|
928
|
-
// the file behind, and the doctor "Stale temp files" warning then
|
|
929
|
-
// accumulates indefinitely. fs-only; runs inside the 24h gate so it
|
|
930
|
-
// shares cadence with the rest of auto-maintain.
|
|
931
|
-
try {
|
|
932
|
-
const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR);
|
|
933
|
-
if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
|
|
934
|
-
} catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
|
|
935
|
-
|
|
936
|
-
// Mark maintenance as done (24h gate) — even though compression runs in background
|
|
937
|
-
writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
|
|
938
|
-
// Weekly summary grouping runs in background to avoid blocking SessionStart
|
|
939
|
-
if (!process.env.CLAUDE_MEM_SKIP_COMPRESS) spawnBackground('auto-compress');
|
|
940
|
-
if (!process.env.CLAUDE_MEM_SKIP_OPTIMIZE) spawnBackground('llm-optimize');
|
|
941
|
-
} catch (e) { debugCatch(e, 'auto-maintain'); }
|
|
942
|
-
}
|
|
1156
|
+
runSessionStartAutoMaintain(db);
|
|
943
1157
|
|
|
944
1158
|
// ── Non-transactional operations (side effects, background work) ──
|
|
945
1159
|
|
|
946
|
-
|
|
947
|
-
let prevClearHandoff = null;
|
|
948
|
-
|
|
949
|
-
if (prevSessionId) {
|
|
950
|
-
// Save handoff for cross-session continuity (/clear or /compact).
|
|
951
|
-
// prevSessionId is the mem-internal id — use it to look up the finished session's
|
|
952
|
-
// user_prompts / observations. ccSessionId (same CC session across /clear) scopes
|
|
953
|
-
// the stored row so UserPromptSubmit can read its own handoff back.
|
|
954
|
-
// Legacy/test paths (no stdin) fall back to prevSessionId for both.
|
|
955
|
-
const handoffScopeId = ccSessionId || prevSessionId;
|
|
956
|
-
try { buildAndSaveHandoff(db, prevSessionId, prevProject || project, 'clear', episodeSnapshot, handoffScopeId); }
|
|
957
|
-
catch (e) { debugCatch(e, 'session-start-handoff'); }
|
|
958
|
-
|
|
959
|
-
// Read the just-saved handoff for downstream consumers (fast summary remaining, working state).
|
|
960
|
-
// Session-scoped read to avoid picking up a parallel session's clear handoff.
|
|
961
|
-
try {
|
|
962
|
-
prevClearHandoff = db.prepare(
|
|
963
|
-
'SELECT working_on, unfinished, key_files FROM session_handoffs WHERE project = ? AND type = ? AND session_id = ?'
|
|
964
|
-
).get(prevProject || project, 'clear', handoffScopeId);
|
|
965
|
-
} catch {}
|
|
966
|
-
|
|
967
|
-
// Generate session summary for previous session (background Haiku — richer version)
|
|
968
|
-
spawnBackground('llm-summary', prevSessionId, prevProject || project);
|
|
969
|
-
|
|
970
|
-
// Build fast synchronous summary for immediate context availability.
|
|
971
|
-
// Background llm-summary will produce a richer Haiku version later;
|
|
972
|
-
// context injection query (ORDER BY created_at_epoch DESC) auto-prefers latest.
|
|
973
|
-
try {
|
|
974
|
-
const firstPrompt = db.prepare(`
|
|
975
|
-
SELECT prompt_text FROM user_prompts
|
|
976
|
-
WHERE content_session_id = ?
|
|
977
|
-
ORDER BY prompt_number ASC LIMIT 1
|
|
978
|
-
`).get(prevSessionId);
|
|
979
|
-
|
|
980
|
-
const prevObs = db.prepare(`
|
|
981
|
-
SELECT title FROM observations
|
|
982
|
-
WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
|
|
983
|
-
ORDER BY created_at_epoch DESC LIMIT 5
|
|
984
|
-
`).all(prevSessionId);
|
|
985
|
-
|
|
986
|
-
// Raw values flow into scrubRecord; truncation deferred to .run() so
|
|
987
|
-
// secrets straddling the truncation boundary still match scrubSecrets
|
|
988
|
-
// regex length floors.
|
|
989
|
-
const fastRequestRaw = firstPrompt?.prompt_text || '';
|
|
990
|
-
const fastCompletedRaw = prevObs.map(o => o.title).filter(Boolean).join('; ');
|
|
991
|
-
|
|
992
|
-
// Infer remaining_items from handoff unfinished (already built above at line 476)
|
|
993
|
-
let fastRemainingRaw = '';
|
|
994
|
-
if (prevClearHandoff?.unfinished) {
|
|
995
|
-
fastRemainingRaw = extractUnfinishedSummary(prevClearHandoff.unfinished, 0);
|
|
996
|
-
}
|
|
997
|
-
// Fallback: episode errors
|
|
998
|
-
if (!fastRemainingRaw && episodeSnapshot?.entries) {
|
|
999
|
-
const errors = episodeSnapshot.entries.filter(e => e.isError).map(e => e.desc).filter(Boolean);
|
|
1000
|
-
if (errors.length > 0) fastRemainingRaw = errors.join('; ');
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
if (fastRequestRaw || fastCompletedRaw) {
|
|
1004
|
-
const safe = scrubRecord('session_summaries', {
|
|
1005
|
-
request: fastRequestRaw,
|
|
1006
|
-
completed: fastCompletedRaw,
|
|
1007
|
-
remaining_items: fastRemainingRaw,
|
|
1008
|
-
});
|
|
1009
|
-
db.prepare(`
|
|
1010
|
-
INSERT INTO session_summaries
|
|
1011
|
-
(memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
|
|
1012
|
-
VALUES (?, ?, ?, '', '', ?, '', ?, '[]', '[]', 'fast', ?, ?)
|
|
1013
|
-
`).run(prevSessionId, prevProject || project, truncate(safe.request, 200), truncate(safe.completed, 300), truncate(safe.remaining_items, 200), now.toISOString(), now.getTime());
|
|
1014
|
-
}
|
|
1015
|
-
} catch (e) { debugCatch(e, 'session-start-fast-summary'); }
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
// Clean stale lock files in runtime dir
|
|
1019
|
-
try {
|
|
1020
|
-
for (const f of readdirSync(RUNTIME_DIR)) {
|
|
1021
|
-
if (!f.endsWith('.lock')) continue;
|
|
1022
|
-
const lp = join(RUNTIME_DIR, f);
|
|
1023
|
-
try {
|
|
1024
|
-
const raw = readFileSync(lp, 'utf8');
|
|
1025
|
-
const info = JSON.parse(raw);
|
|
1026
|
-
const age = Date.now() - (info.ts || 0);
|
|
1027
|
-
let stale = age > STALE_LOCK_MS;
|
|
1028
|
-
if (!stale && info.pid) {
|
|
1029
|
-
try { process.kill(info.pid, 0); } catch (killErr) {
|
|
1030
|
-
stale = killErr.code === 'ESRCH';
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
if (stale) unlinkSync(lp);
|
|
1034
|
-
} catch {
|
|
1035
|
-
try {
|
|
1036
|
-
const st = statSync(lp);
|
|
1037
|
-
if (Date.now() - st.mtimeMs > STALE_LOCK_MS) unlinkSync(lp);
|
|
1038
|
-
} catch {}
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
} catch {}
|
|
1160
|
+
saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, ccSessionId, episodeSnapshot, now });
|
|
1042
1161
|
|
|
1043
|
-
|
|
1044
|
-
// (e.g. /exit → fast restart before Haiku finishes), build one synchronously.
|
|
1045
|
-
// Skipped when prevSessionId is set (already handled above).
|
|
1046
|
-
if (!prevSessionId) {
|
|
1047
|
-
try {
|
|
1048
|
-
const recentSession = db.prepare(`
|
|
1049
|
-
SELECT content_session_id, project FROM sdk_sessions
|
|
1050
|
-
WHERE project = ? AND status = 'completed' AND completed_at_epoch > ?
|
|
1051
|
-
ORDER BY completed_at_epoch DESC LIMIT 1
|
|
1052
|
-
`).get(project, Date.now() - 120000); // within last 2 minutes
|
|
1053
|
-
|
|
1054
|
-
if (recentSession) {
|
|
1055
|
-
const hasSummary = db.prepare(`
|
|
1056
|
-
SELECT 1 FROM session_summaries WHERE memory_session_id = ? LIMIT 1
|
|
1057
|
-
`).get(recentSession.content_session_id);
|
|
1162
|
+
cleanStaleLockFiles();
|
|
1058
1163
|
|
|
1059
|
-
|
|
1060
|
-
const fp = db.prepare(`
|
|
1061
|
-
SELECT prompt_text FROM user_prompts
|
|
1062
|
-
WHERE content_session_id = ? ORDER BY prompt_number ASC LIMIT 1
|
|
1063
|
-
`).get(recentSession.content_session_id);
|
|
1064
|
-
const po = db.prepare(`
|
|
1065
|
-
SELECT title FROM observations
|
|
1066
|
-
WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
|
|
1067
|
-
ORDER BY created_at_epoch DESC LIMIT 5
|
|
1068
|
-
`).all(recentSession.content_session_id);
|
|
1069
|
-
|
|
1070
|
-
// Raw values into scrubRecord; truncation at .run() preserves
|
|
1071
|
-
// straddling-secret detection (per privacy review).
|
|
1072
|
-
const frRaw = fp?.prompt_text || '';
|
|
1073
|
-
const fcRaw = po.map(o => o.title).filter(Boolean).join('; ');
|
|
1074
|
-
if (frRaw || fcRaw) {
|
|
1075
|
-
const safe = scrubRecord('session_summaries', {
|
|
1076
|
-
request: frRaw,
|
|
1077
|
-
completed: fcRaw,
|
|
1078
|
-
});
|
|
1079
|
-
db.prepare(`
|
|
1080
|
-
INSERT INTO session_summaries
|
|
1081
|
-
(memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
|
|
1082
|
-
VALUES (?, ?, ?, '', '', ?, '', '', '[]', '[]', 'fast', ?, ?)
|
|
1083
|
-
`).run(recentSession.content_session_id, project, truncate(safe.request, 200), truncate(safe.completed, 300), now.toISOString(), now.getTime());
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
} catch (e) { debugCatch(e, 'session-start-exit-fast-summary'); }
|
|
1088
|
-
}
|
|
1164
|
+
buildFallbackFastSummary(db, { project, now, prevSessionId });
|
|
1089
1165
|
|
|
1090
|
-
|
|
1091
|
-
// structured JSON hookSpecificOutput block. Emitted BEFORE the plain-text
|
|
1092
|
-
// <claude-mem-context> so both surfaces coexist. Empty string → skip.
|
|
1093
|
-
try {
|
|
1094
|
-
const { buildDashboard } = await import('./lib/startup-dashboard.mjs');
|
|
1095
|
-
let dashboardText = buildDashboard({ db, project, projectPath: process.cwd() });
|
|
1096
|
-
const citeNudge = buildCiteRecallNudge(project);
|
|
1097
|
-
if (citeNudge) {
|
|
1098
|
-
dashboardText = dashboardText ? `${citeNudge}\n${dashboardText}` : citeNudge;
|
|
1099
|
-
}
|
|
1100
|
-
// v2.79: surface setup.sh dependency-install failure as a high-visibility
|
|
1101
|
-
// line at the very top of the dashboard. setup.sh writes runtime/.deps-broken
|
|
1102
|
-
// (JSON: ts/reason/root/repair) on failure and removes it on success — so
|
|
1103
|
-
// a stale flag self-heals on the next clean SessionStart. Without this
|
|
1104
|
-
// surface, hook degradation looks identical to "nothing happening" until
|
|
1105
|
-
// the user notices missing context days later.
|
|
1106
|
-
try {
|
|
1107
|
-
const depsFlag = join(RUNTIME_DIR, '.deps-broken');
|
|
1108
|
-
if (existsSync(depsFlag)) {
|
|
1109
|
-
let detail = 'unknown';
|
|
1110
|
-
let repair = '';
|
|
1111
|
-
try {
|
|
1112
|
-
const raw = readFileSync(depsFlag, 'utf8').trim();
|
|
1113
|
-
const parsed = JSON.parse(raw);
|
|
1114
|
-
detail = parsed.reason || detail;
|
|
1115
|
-
repair = parsed.repair || '';
|
|
1116
|
-
} catch { /* corrupt flag — surface the fact only */ }
|
|
1117
|
-
const nudgeLines = [
|
|
1118
|
-
'⚠️ [claude-mem-lite] Hook dependencies failed to install on the last SessionStart.',
|
|
1119
|
-
` Reason: ${detail}`,
|
|
1120
|
-
];
|
|
1121
|
-
if (repair) nudgeLines.push(` Repair: ${repair}`);
|
|
1122
|
-
nudgeLines.push(' Until fixed, PreToolUse / PostToolUse / memory injection are degraded.');
|
|
1123
|
-
const nudge = nudgeLines.join('\n');
|
|
1124
|
-
dashboardText = dashboardText ? `${nudge}\n${dashboardText}` : nudge;
|
|
1125
|
-
}
|
|
1126
|
-
} catch (e) { debugCatch(e, 'session-start-deps-flag'); }
|
|
1127
|
-
if (dashboardText) {
|
|
1128
|
-
process.stdout.write(JSON.stringify({
|
|
1129
|
-
suppressOutput: true,
|
|
1130
|
-
hookSpecificOutput: {
|
|
1131
|
-
hookEventName: 'SessionStart',
|
|
1132
|
-
additionalContext: dashboardText,
|
|
1133
|
-
},
|
|
1134
|
-
}) + '\n');
|
|
1135
|
-
}
|
|
1136
|
-
} catch (e) { debugCatch(e, 'session-start-dashboard'); }
|
|
1166
|
+
await emitStartupDashboard(db, project);
|
|
1137
1167
|
|
|
1138
1168
|
// Build the full context body via shared helper (also used by `mem-cli context`).
|
|
1139
1169
|
// Queries session_summaries, key observations, clear handoff, and the
|