claude-mem-lite 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hook.mjs CHANGED
@@ -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
- // ── DB mutations in a transaction (crash-safe consistency) ──
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
- // Orphan sweep: remove `ep-flush-*` / `pending-*` runtime files older
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
- // Shared clear handoff reference queried once, used by fast summary + working state
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
- // Fallback fast summary: if a recently completed session has no summary yet
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
- if (!hasSummary) {
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
- // T10c: Startup dashboard — aggregate git/tasks/plans/handoff/events into a
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