engrm 0.4.21 → 0.4.23

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.
@@ -473,6 +473,729 @@ function normalizeItem(value) {
473
473
  return value.toLowerCase().replace(/\*+/g, "").replace(/\s+/g, " ").trim();
474
474
  }
475
475
 
476
+ // src/tools/save.ts
477
+ import { relative, isAbsolute } from "node:path";
478
+
479
+ // src/capture/scrubber.ts
480
+ var DEFAULT_PATTERNS = [
481
+ {
482
+ source: "sk-[a-zA-Z0-9]{20,}",
483
+ flags: "g",
484
+ replacement: "[REDACTED_API_KEY]",
485
+ description: "OpenAI API keys",
486
+ category: "api_key",
487
+ severity: "critical"
488
+ },
489
+ {
490
+ source: "Bearer [a-zA-Z0-9\\-._~+/]+=*",
491
+ flags: "g",
492
+ replacement: "[REDACTED_BEARER]",
493
+ description: "Bearer auth tokens",
494
+ category: "token",
495
+ severity: "medium"
496
+ },
497
+ {
498
+ source: "password[=:]\\s*\\S+",
499
+ flags: "gi",
500
+ replacement: "password=[REDACTED]",
501
+ description: "Passwords in config",
502
+ category: "password",
503
+ severity: "high"
504
+ },
505
+ {
506
+ source: "postgresql://[^\\s]+",
507
+ flags: "g",
508
+ replacement: "[REDACTED_DB_URL]",
509
+ description: "PostgreSQL connection strings",
510
+ category: "db_url",
511
+ severity: "high"
512
+ },
513
+ {
514
+ source: "mongodb://[^\\s]+",
515
+ flags: "g",
516
+ replacement: "[REDACTED_DB_URL]",
517
+ description: "MongoDB connection strings",
518
+ category: "db_url",
519
+ severity: "high"
520
+ },
521
+ {
522
+ source: "mysql://[^\\s]+",
523
+ flags: "g",
524
+ replacement: "[REDACTED_DB_URL]",
525
+ description: "MySQL connection strings",
526
+ category: "db_url",
527
+ severity: "high"
528
+ },
529
+ {
530
+ source: "AKIA[A-Z0-9]{16}",
531
+ flags: "g",
532
+ replacement: "[REDACTED_AWS_KEY]",
533
+ description: "AWS access keys",
534
+ category: "api_key",
535
+ severity: "critical"
536
+ },
537
+ {
538
+ source: "ghp_[a-zA-Z0-9]{36}",
539
+ flags: "g",
540
+ replacement: "[REDACTED_GH_TOKEN]",
541
+ description: "GitHub personal access tokens",
542
+ category: "token",
543
+ severity: "high"
544
+ },
545
+ {
546
+ source: "gho_[a-zA-Z0-9]{36}",
547
+ flags: "g",
548
+ replacement: "[REDACTED_GH_TOKEN]",
549
+ description: "GitHub OAuth tokens",
550
+ category: "token",
551
+ severity: "high"
552
+ },
553
+ {
554
+ source: "github_pat_[a-zA-Z0-9_]{22,}",
555
+ flags: "g",
556
+ replacement: "[REDACTED_GH_TOKEN]",
557
+ description: "GitHub fine-grained PATs",
558
+ category: "token",
559
+ severity: "high"
560
+ },
561
+ {
562
+ source: "cvk_[a-f0-9]{64}",
563
+ flags: "g",
564
+ replacement: "[REDACTED_CANDENGO_KEY]",
565
+ description: "Candengo API keys",
566
+ category: "api_key",
567
+ severity: "critical"
568
+ },
569
+ {
570
+ source: "xox[bpras]-[a-zA-Z0-9\\-]+",
571
+ flags: "g",
572
+ replacement: "[REDACTED_SLACK_TOKEN]",
573
+ description: "Slack tokens",
574
+ category: "token",
575
+ severity: "high"
576
+ }
577
+ ];
578
+ function compileCustomPatterns(patterns) {
579
+ const compiled = [];
580
+ for (const pattern of patterns) {
581
+ try {
582
+ new RegExp(pattern);
583
+ compiled.push({
584
+ source: pattern,
585
+ flags: "g",
586
+ replacement: "[REDACTED_CUSTOM]",
587
+ description: `Custom pattern: ${pattern}`,
588
+ category: "custom",
589
+ severity: "medium"
590
+ });
591
+ } catch {}
592
+ }
593
+ return compiled;
594
+ }
595
+ function scrubSecrets(text, customPatterns = []) {
596
+ let result = text;
597
+ const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
598
+ for (const pattern of allPatterns) {
599
+ result = result.replace(new RegExp(pattern.source, pattern.flags), pattern.replacement);
600
+ }
601
+ return result;
602
+ }
603
+ function containsSecrets(text, customPatterns = []) {
604
+ const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
605
+ for (const pattern of allPatterns) {
606
+ if (new RegExp(pattern.source, pattern.flags).test(text))
607
+ return true;
608
+ }
609
+ return false;
610
+ }
611
+
612
+ // src/capture/quality.ts
613
+ var QUALITY_THRESHOLD = 0.1;
614
+ function scoreQuality(input) {
615
+ let score = 0;
616
+ switch (input.type) {
617
+ case "bugfix":
618
+ score += 0.3;
619
+ break;
620
+ case "decision":
621
+ score += 0.3;
622
+ break;
623
+ case "discovery":
624
+ score += 0.2;
625
+ break;
626
+ case "pattern":
627
+ score += 0.2;
628
+ break;
629
+ case "feature":
630
+ score += 0.15;
631
+ break;
632
+ case "refactor":
633
+ score += 0.15;
634
+ break;
635
+ case "change":
636
+ score += 0.05;
637
+ break;
638
+ case "digest":
639
+ score += 0.3;
640
+ break;
641
+ case "standard":
642
+ score += 0.25;
643
+ break;
644
+ case "message":
645
+ score += 0.1;
646
+ break;
647
+ }
648
+ if (input.narrative && input.narrative.length > 50) {
649
+ score += 0.15;
650
+ }
651
+ if (input.facts) {
652
+ try {
653
+ const factsArray = JSON.parse(input.facts);
654
+ if (factsArray.length >= 2)
655
+ score += 0.15;
656
+ else if (factsArray.length === 1)
657
+ score += 0.05;
658
+ } catch {
659
+ if (input.facts.length > 20)
660
+ score += 0.05;
661
+ }
662
+ }
663
+ if (input.concepts) {
664
+ try {
665
+ const conceptsArray = JSON.parse(input.concepts);
666
+ if (conceptsArray.length >= 1)
667
+ score += 0.1;
668
+ } catch {
669
+ if (input.concepts.length > 10)
670
+ score += 0.05;
671
+ }
672
+ }
673
+ const modifiedCount = input.filesModified?.length ?? 0;
674
+ if (modifiedCount >= 3)
675
+ score += 0.2;
676
+ else if (modifiedCount >= 1)
677
+ score += 0.1;
678
+ if (input.isDuplicate) {
679
+ score -= 0.3;
680
+ }
681
+ return Math.max(0, Math.min(1, score));
682
+ }
683
+ function meetsQualityThreshold(input) {
684
+ return scoreQuality(input) >= QUALITY_THRESHOLD;
685
+ }
686
+
687
+ // src/capture/facts.ts
688
+ var FACT_ELIGIBLE_TYPES = new Set([
689
+ "bugfix",
690
+ "decision",
691
+ "discovery",
692
+ "pattern",
693
+ "feature",
694
+ "refactor",
695
+ "change"
696
+ ]);
697
+ function buildStructuredFacts(input) {
698
+ const seedFacts = dedupeFacts(input.facts ?? []);
699
+ if (!FACT_ELIGIBLE_TYPES.has(input.type)) {
700
+ return seedFacts;
701
+ }
702
+ const derived = [...seedFacts];
703
+ if (seedFacts.length === 0 && looksMeaningful(input.title)) {
704
+ derived.push(input.title.trim());
705
+ }
706
+ for (const sentence of extractNarrativeFacts(input.narrative)) {
707
+ derived.push(sentence);
708
+ }
709
+ const fileFact = buildFilesFact(input.filesModified);
710
+ if (fileFact) {
711
+ derived.push(fileFact);
712
+ }
713
+ return dedupeFacts(derived).slice(0, 4);
714
+ }
715
+ function extractNarrativeFacts(narrative) {
716
+ if (!narrative)
717
+ return [];
718
+ const cleaned = narrative.replace(/\s+/g, " ").trim();
719
+ if (cleaned.length < 24)
720
+ return [];
721
+ const parts = cleaned.split(/(?<=[.!?;])\s+/).map((part) => part.trim().replace(/[.!?;]+$/, "")).filter(Boolean).filter(looksMeaningful);
722
+ return parts.slice(0, 2);
723
+ }
724
+ function buildFilesFact(filesModified) {
725
+ if (!filesModified || filesModified.length === 0)
726
+ return null;
727
+ const cleaned = filesModified.map((file) => file.trim()).filter(Boolean).slice(0, 3);
728
+ if (cleaned.length === 0)
729
+ return null;
730
+ if (cleaned.length === 1) {
731
+ return `Touched ${cleaned[0]}`;
732
+ }
733
+ return `Touched ${cleaned.join(", ")}`;
734
+ }
735
+ function dedupeFacts(facts) {
736
+ const seen = new Set;
737
+ const result = [];
738
+ for (const fact of facts) {
739
+ const cleaned = fact.trim().replace(/\s+/g, " ");
740
+ if (!looksMeaningful(cleaned))
741
+ continue;
742
+ const key = cleaned.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
743
+ if (!key || seen.has(key))
744
+ continue;
745
+ seen.add(key);
746
+ result.push(cleaned);
747
+ }
748
+ return result;
749
+ }
750
+ function looksMeaningful(value) {
751
+ const cleaned = value.trim();
752
+ if (cleaned.length < 12)
753
+ return false;
754
+ if (/^[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+$/.test(cleaned))
755
+ return false;
756
+ if (/^(updated|modified|edited|changed|touched)\s+[A-Za-z0-9_.\-\/]+$/i.test(cleaned))
757
+ return false;
758
+ return true;
759
+ }
760
+
761
+ // src/embeddings/embedder.ts
762
+ var _available = null;
763
+ var _pipeline = null;
764
+ var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
765
+ async function embedText(text) {
766
+ const pipe = await getPipeline();
767
+ if (!pipe)
768
+ return null;
769
+ try {
770
+ const output = await pipe(text, { pooling: "mean", normalize: true });
771
+ return new Float32Array(output.data);
772
+ } catch {
773
+ return null;
774
+ }
775
+ }
776
+ function composeEmbeddingText(obs) {
777
+ const parts = [obs.title];
778
+ if (obs.narrative)
779
+ parts.push(obs.narrative);
780
+ if (obs.facts) {
781
+ try {
782
+ const facts = JSON.parse(obs.facts);
783
+ if (Array.isArray(facts) && facts.length > 0) {
784
+ parts.push(facts.map((f) => `- ${f}`).join(`
785
+ `));
786
+ }
787
+ } catch {
788
+ parts.push(obs.facts);
789
+ }
790
+ }
791
+ if (obs.concepts) {
792
+ try {
793
+ const concepts = JSON.parse(obs.concepts);
794
+ if (Array.isArray(concepts) && concepts.length > 0) {
795
+ parts.push(concepts.join(", "));
796
+ }
797
+ } catch {}
798
+ }
799
+ return parts.join(`
800
+
801
+ `);
802
+ }
803
+ async function getPipeline() {
804
+ if (_pipeline)
805
+ return _pipeline;
806
+ if (_available === false)
807
+ return null;
808
+ try {
809
+ const { pipeline } = await import("@xenova/transformers");
810
+ _pipeline = await pipeline("feature-extraction", MODEL_NAME);
811
+ _available = true;
812
+ return _pipeline;
813
+ } catch (err) {
814
+ _available = false;
815
+ console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
816
+ return null;
817
+ }
818
+ }
819
+
820
+ // src/capture/recurrence.ts
821
+ var DISTANCE_THRESHOLD = 0.15;
822
+ async function detectRecurrence(db, config, observation) {
823
+ if (observation.type !== "bugfix") {
824
+ return { patternCreated: false };
825
+ }
826
+ if (!db.vecAvailable) {
827
+ return { patternCreated: false };
828
+ }
829
+ const text = composeEmbeddingText(observation);
830
+ const embedding = await embedText(text);
831
+ if (!embedding) {
832
+ return { patternCreated: false };
833
+ }
834
+ const vecResults = db.searchVec(embedding, null, ["active", "aging", "pinned"], 10);
835
+ for (const match of vecResults) {
836
+ if (match.observation_id === observation.id)
837
+ continue;
838
+ if (match.distance > DISTANCE_THRESHOLD)
839
+ continue;
840
+ const matched = db.getObservationById(match.observation_id);
841
+ if (!matched)
842
+ continue;
843
+ if (matched.type !== "bugfix")
844
+ continue;
845
+ if (matched.session_id === observation.session_id)
846
+ continue;
847
+ if (await patternAlreadyExists(db, observation, matched))
848
+ continue;
849
+ let matchedProjectName;
850
+ if (matched.project_id !== observation.project_id) {
851
+ const proj = db.getProjectById(matched.project_id);
852
+ if (proj)
853
+ matchedProjectName = proj.name;
854
+ }
855
+ const similarity = 1 - match.distance;
856
+ const result = await saveObservation(db, config, {
857
+ type: "pattern",
858
+ title: `Recurring bugfix: ${observation.title}`,
859
+ narrative: `This bug pattern has appeared in multiple sessions. Original: "${matched.title}" (session ${matched.session_id?.slice(0, 8) ?? "unknown"}). Latest: "${observation.title}". Similarity: ${(similarity * 100).toFixed(0)}%. Consider addressing the root cause.`,
860
+ facts: [
861
+ `First seen: ${matched.created_at.split("T")[0]}`,
862
+ `Recurred: ${observation.created_at.split("T")[0]}`,
863
+ `Similarity: ${(similarity * 100).toFixed(0)}%`
864
+ ],
865
+ concepts: mergeConceptsFromBoth(observation, matched),
866
+ cwd: process.cwd(),
867
+ session_id: observation.session_id ?? undefined
868
+ });
869
+ if (result.success && result.observation_id) {
870
+ return {
871
+ patternCreated: true,
872
+ patternId: result.observation_id,
873
+ matchedObservationId: matched.id,
874
+ matchedProjectName,
875
+ matchedTitle: matched.title,
876
+ similarity
877
+ };
878
+ }
879
+ }
880
+ return { patternCreated: false };
881
+ }
882
+ async function patternAlreadyExists(db, obs1, obs2) {
883
+ const recentPatterns = db.db.query(`SELECT * FROM observations
884
+ WHERE type = 'pattern' AND lifecycle IN ('active', 'aging', 'pinned')
885
+ AND title LIKE ?
886
+ ORDER BY created_at_epoch DESC LIMIT 5`).all(`%${obs1.title.slice(0, 30)}%`);
887
+ for (const p of recentPatterns) {
888
+ if (p.narrative?.includes(obs2.title.slice(0, 30)))
889
+ return true;
890
+ }
891
+ return false;
892
+ }
893
+ function mergeConceptsFromBoth(obs1, obs2) {
894
+ const concepts = new Set;
895
+ for (const obs of [obs1, obs2]) {
896
+ if (obs.concepts) {
897
+ try {
898
+ const parsed = JSON.parse(obs.concepts);
899
+ if (Array.isArray(parsed)) {
900
+ for (const c of parsed) {
901
+ if (typeof c === "string")
902
+ concepts.add(c);
903
+ }
904
+ }
905
+ } catch {}
906
+ }
907
+ }
908
+ return [...concepts];
909
+ }
910
+
911
+ // src/capture/conflict.ts
912
+ var SIMILARITY_THRESHOLD = 0.25;
913
+ async function detectDecisionConflict(db, observation) {
914
+ if (observation.type !== "decision") {
915
+ return { hasConflict: false };
916
+ }
917
+ if (!observation.narrative || observation.narrative.trim().length < 20) {
918
+ return { hasConflict: false };
919
+ }
920
+ if (db.vecAvailable) {
921
+ return detectViaVec(db, observation);
922
+ }
923
+ return detectViaFts(db, observation);
924
+ }
925
+ async function detectViaVec(db, observation) {
926
+ const text = composeEmbeddingText(observation);
927
+ const embedding = await embedText(text);
928
+ if (!embedding)
929
+ return { hasConflict: false };
930
+ const results = db.searchVec(embedding, observation.project_id, ["active", "aging", "pinned"], 10);
931
+ for (const match of results) {
932
+ if (match.observation_id === observation.id)
933
+ continue;
934
+ if (match.distance > SIMILARITY_THRESHOLD)
935
+ continue;
936
+ const existing = db.getObservationById(match.observation_id);
937
+ if (!existing)
938
+ continue;
939
+ if (existing.type !== "decision")
940
+ continue;
941
+ if (!existing.narrative)
942
+ continue;
943
+ const conflict = narrativesConflict(observation.narrative, existing.narrative);
944
+ if (conflict) {
945
+ return {
946
+ hasConflict: true,
947
+ conflictingId: existing.id,
948
+ conflictingTitle: existing.title,
949
+ reason: conflict
950
+ };
951
+ }
952
+ }
953
+ return { hasConflict: false };
954
+ }
955
+ async function detectViaFts(db, observation) {
956
+ const keywords = observation.title.split(/\s+/).filter((w) => w.length > 3).slice(0, 5).join(" ");
957
+ if (!keywords)
958
+ return { hasConflict: false };
959
+ const ftsResults = db.searchFts(keywords, observation.project_id, ["active", "aging", "pinned"], 10);
960
+ for (const match of ftsResults) {
961
+ if (match.id === observation.id)
962
+ continue;
963
+ const existing = db.getObservationById(match.id);
964
+ if (!existing)
965
+ continue;
966
+ if (existing.type !== "decision")
967
+ continue;
968
+ if (!existing.narrative)
969
+ continue;
970
+ const conflict = narrativesConflict(observation.narrative, existing.narrative);
971
+ if (conflict) {
972
+ return {
973
+ hasConflict: true,
974
+ conflictingId: existing.id,
975
+ conflictingTitle: existing.title,
976
+ reason: conflict
977
+ };
978
+ }
979
+ }
980
+ return { hasConflict: false };
981
+ }
982
+ function narrativesConflict(narrative1, narrative2) {
983
+ const n1 = narrative1.toLowerCase();
984
+ const n2 = narrative2.toLowerCase();
985
+ const opposingPairs = [
986
+ [["should use", "decided to use", "chose", "prefer", "went with"], ["should not", "decided against", "avoid", "rejected", "don't use"]],
987
+ [["enable", "turn on", "activate", "add"], ["disable", "turn off", "deactivate", "remove"]],
988
+ [["increase", "more", "higher", "scale up"], ["decrease", "less", "lower", "scale down"]],
989
+ [["keep", "maintain", "preserve"], ["replace", "migrate", "switch from", "deprecate"]]
990
+ ];
991
+ for (const [positive, negative] of opposingPairs) {
992
+ const n1HasPositive = positive.some((w) => n1.includes(w));
993
+ const n1HasNegative = negative.some((w) => n1.includes(w));
994
+ const n2HasPositive = positive.some((w) => n2.includes(w));
995
+ const n2HasNegative = negative.some((w) => n2.includes(w));
996
+ if (n1HasPositive && n2HasNegative || n1HasNegative && n2HasPositive) {
997
+ return "Narratives suggest opposing conclusions on a similar topic";
998
+ }
999
+ }
1000
+ return null;
1001
+ }
1002
+
1003
+ // src/tools/save.ts
1004
+ var VALID_TYPES = [
1005
+ "bugfix",
1006
+ "discovery",
1007
+ "decision",
1008
+ "pattern",
1009
+ "change",
1010
+ "feature",
1011
+ "refactor",
1012
+ "digest",
1013
+ "standard",
1014
+ "message"
1015
+ ];
1016
+ async function saveObservation(db, config, input) {
1017
+ if (!VALID_TYPES.includes(input.type)) {
1018
+ return {
1019
+ success: false,
1020
+ reason: `Invalid type '${input.type}'. Must be one of: ${VALID_TYPES.join(", ")}`
1021
+ };
1022
+ }
1023
+ if (!input.title || input.title.trim().length === 0) {
1024
+ return { success: false, reason: "Title is required" };
1025
+ }
1026
+ const cwd = input.cwd ?? process.cwd();
1027
+ const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
1028
+ const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
1029
+ const project = db.upsertProject({
1030
+ canonical_id: detected.canonical_id,
1031
+ name: detected.name,
1032
+ local_path: detected.local_path,
1033
+ remote_url: detected.remote_url
1034
+ });
1035
+ const customPatterns = config.scrubbing.enabled ? config.scrubbing.custom_patterns : [];
1036
+ const title = config.scrubbing.enabled ? scrubSecrets(input.title, customPatterns) : input.title;
1037
+ const narrative = input.narrative ? config.scrubbing.enabled ? scrubSecrets(input.narrative, customPatterns) : input.narrative : null;
1038
+ const conceptsJson = input.concepts ? JSON.stringify(input.concepts) : null;
1039
+ const filesRead = input.files_read ? input.files_read.map((f) => toRelativePath(f, cwd)) : null;
1040
+ const filesModified = input.files_modified ? input.files_modified.map((f) => toRelativePath(f, cwd)) : null;
1041
+ const structuredFacts = buildStructuredFacts({
1042
+ type: input.type,
1043
+ title: input.title,
1044
+ narrative: input.narrative,
1045
+ facts: input.facts,
1046
+ filesModified
1047
+ });
1048
+ const factsJson = structuredFacts.length > 0 ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(structuredFacts), customPatterns) : JSON.stringify(structuredFacts) : null;
1049
+ const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
1050
+ const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
1051
+ let sensitivity = input.sensitivity ?? config.scrubbing.default_sensitivity;
1052
+ if (config.scrubbing.enabled && containsSecrets([input.title, input.narrative, JSON.stringify(input.facts)].filter(Boolean).join(" "), customPatterns)) {
1053
+ if (sensitivity === "shared") {
1054
+ sensitivity = "personal";
1055
+ }
1056
+ }
1057
+ const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
1058
+ const recentObs = db.getRecentObservations(project.id, oneDayAgo);
1059
+ const candidates = recentObs.map((o) => ({
1060
+ id: o.id,
1061
+ title: o.title
1062
+ }));
1063
+ const duplicate = findDuplicate(title, candidates);
1064
+ const qualityInput = {
1065
+ type: input.type,
1066
+ title,
1067
+ narrative,
1068
+ facts: factsJson,
1069
+ concepts: conceptsJson,
1070
+ filesRead,
1071
+ filesModified,
1072
+ isDuplicate: duplicate !== null
1073
+ };
1074
+ const qualityScore = scoreQuality(qualityInput);
1075
+ if (!meetsQualityThreshold(qualityInput)) {
1076
+ return {
1077
+ success: false,
1078
+ quality_score: qualityScore,
1079
+ reason: `Quality score ${qualityScore.toFixed(2)} below threshold`
1080
+ };
1081
+ }
1082
+ if (duplicate) {
1083
+ return {
1084
+ success: true,
1085
+ merged_into: duplicate.id,
1086
+ quality_score: qualityScore,
1087
+ reason: `Merged into existing observation #${duplicate.id}`
1088
+ };
1089
+ }
1090
+ const sourcePromptNumber = input.source_prompt_number ?? (input.session_id ? db.getLatestSessionPromptNumber(input.session_id) : null);
1091
+ const obs = db.insertObservation({
1092
+ session_id: input.session_id ?? null,
1093
+ project_id: project.id,
1094
+ type: input.type,
1095
+ title,
1096
+ narrative,
1097
+ facts: factsJson,
1098
+ concepts: conceptsJson,
1099
+ files_read: filesReadJson,
1100
+ files_modified: filesModifiedJson,
1101
+ quality: qualityScore,
1102
+ lifecycle: "active",
1103
+ sensitivity,
1104
+ user_id: config.user_id,
1105
+ device_id: config.device_id,
1106
+ agent: input.agent ?? "claude-code",
1107
+ source_tool: input.source_tool ?? null,
1108
+ source_prompt_number: sourcePromptNumber
1109
+ });
1110
+ db.addToOutbox("observation", obs.id);
1111
+ if (db.vecAvailable) {
1112
+ try {
1113
+ const text = composeEmbeddingText(obs);
1114
+ const embedding = await embedText(text);
1115
+ if (embedding) {
1116
+ db.vecInsert(obs.id, embedding);
1117
+ }
1118
+ } catch {}
1119
+ }
1120
+ let recallHint;
1121
+ if (input.type === "bugfix") {
1122
+ try {
1123
+ const recurrence = await detectRecurrence(db, config, obs);
1124
+ if (recurrence.patternCreated && recurrence.matchedTitle) {
1125
+ const projectLabel = recurrence.matchedProjectName ? ` in ${recurrence.matchedProjectName}` : "";
1126
+ recallHint = `You solved a similar issue${projectLabel}: "${recurrence.matchedTitle}"`;
1127
+ }
1128
+ } catch {}
1129
+ }
1130
+ let conflictWarning;
1131
+ if (input.type === "decision") {
1132
+ try {
1133
+ const conflict = await detectDecisionConflict(db, obs);
1134
+ if (conflict.hasConflict && conflict.conflictingTitle) {
1135
+ conflictWarning = `Potential conflict with existing decision: "${conflict.conflictingTitle}" — ${conflict.reason}`;
1136
+ }
1137
+ } catch {}
1138
+ }
1139
+ return {
1140
+ success: true,
1141
+ observation_id: obs.id,
1142
+ quality_score: qualityScore,
1143
+ recall_hint: recallHint,
1144
+ conflict_warning: conflictWarning
1145
+ };
1146
+ }
1147
+ function toRelativePath(filePath, projectRoot) {
1148
+ if (!isAbsolute(filePath))
1149
+ return filePath;
1150
+ const rel = relative(projectRoot, filePath);
1151
+ if (rel.startsWith(".."))
1152
+ return filePath;
1153
+ return rel;
1154
+ }
1155
+
1156
+ // src/tools/handoffs.ts
1157
+ function getRecentHandoffs(db, input) {
1158
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
1159
+ const projectScoped = input.project_scoped !== false;
1160
+ let projectId = null;
1161
+ let projectName;
1162
+ if (projectScoped) {
1163
+ const cwd = input.cwd ?? process.cwd();
1164
+ const detected = detectProject(cwd);
1165
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
1166
+ if (project) {
1167
+ projectId = project.id;
1168
+ projectName = project.name;
1169
+ }
1170
+ }
1171
+ const conditions = [
1172
+ "o.type = 'message'",
1173
+ "o.lifecycle IN ('active', 'aging', 'pinned')",
1174
+ "o.superseded_by IS NULL",
1175
+ `(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
1176
+ ];
1177
+ const params = [];
1178
+ if (input.user_id) {
1179
+ conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
1180
+ params.push(input.user_id);
1181
+ }
1182
+ if (projectId !== null) {
1183
+ conditions.push("o.project_id = ?");
1184
+ params.push(projectId);
1185
+ }
1186
+ params.push(limit);
1187
+ const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
1188
+ FROM observations o
1189
+ LEFT JOIN projects p ON p.id = o.project_id
1190
+ WHERE ${conditions.join(" AND ")}
1191
+ ORDER BY o.created_at_epoch DESC, o.id DESC
1192
+ LIMIT ?`).all(...params);
1193
+ return {
1194
+ handoffs,
1195
+ project: projectName
1196
+ };
1197
+ }
1198
+
476
1199
  // src/context/inject.ts
477
1200
  function tokenizeProjectHint(text) {
478
1201
  return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
@@ -619,6 +1342,12 @@ function buildSessionContext(db, cwd, options = {}) {
619
1342
  const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
620
1343
  const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
621
1344
  const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions2);
1345
+ const recentHandoffs2 = getRecentHandoffs(db, {
1346
+ cwd,
1347
+ project_scoped: !isNewProject,
1348
+ user_id: opts.userId,
1349
+ limit: 3
1350
+ }).handoffs;
622
1351
  return {
623
1352
  project_name: projectName,
624
1353
  canonical_id: canonicalId,
@@ -629,7 +1358,8 @@ function buildSessionContext(db, cwd, options = {}) {
629
1358
  recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
630
1359
  recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
631
1360
  projectTypeCounts: projectTypeCounts2,
632
- recentOutcomes: recentOutcomes2
1361
+ recentOutcomes: recentOutcomes2,
1362
+ recentHandoffs: recentHandoffs2.length > 0 ? recentHandoffs2 : undefined
633
1363
  };
634
1364
  }
635
1365
  let remainingBudget = tokenBudget - 30;
@@ -657,6 +1387,12 @@ function buildSessionContext(db, cwd, options = {}) {
657
1387
  const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
658
1388
  const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
659
1389
  const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions);
1390
+ const recentHandoffs = getRecentHandoffs(db, {
1391
+ cwd,
1392
+ project_scoped: !isNewProject,
1393
+ user_id: opts.userId,
1394
+ limit: 3
1395
+ }).handoffs;
660
1396
  let securityFindings = [];
661
1397
  if (!isNewProject) {
662
1398
  try {
@@ -715,7 +1451,8 @@ function buildSessionContext(db, cwd, options = {}) {
715
1451
  recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
716
1452
  recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
717
1453
  projectTypeCounts,
718
- recentOutcomes
1454
+ recentOutcomes,
1455
+ recentHandoffs: recentHandoffs.length > 0 ? recentHandoffs : undefined
719
1456
  };
720
1457
  }
721
1458
  function estimateObservationTokens(obs, index) {
@@ -1184,7 +1921,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync
1184
1921
  import { join as join3 } from "node:path";
1185
1922
  import { homedir } from "node:os";
1186
1923
  var STATE_PATH = join3(homedir(), ".engrm", "config-fingerprint.json");
1187
- var CLIENT_VERSION = "0.4.21";
1924
+ var CLIENT_VERSION = "0.4.23";
1188
1925
  function hashFile(filePath) {
1189
1926
  try {
1190
1927
  if (!existsSync3(filePath))
@@ -1545,81 +2282,27 @@ function buildSourceId(config, localId, type = "obs") {
1545
2282
  return `${config.user_id}-${config.device_id}-${type}-${localId}`;
1546
2283
  }
1547
2284
  function parseSourceId(sourceId) {
1548
- const obsIndex = sourceId.lastIndexOf("-obs-");
1549
- if (obsIndex === -1)
1550
- return null;
1551
- const prefix = sourceId.slice(0, obsIndex);
1552
- const localIdStr = sourceId.slice(obsIndex + 5);
1553
- const localId = parseInt(localIdStr, 10);
1554
- if (isNaN(localId))
1555
- return null;
1556
- const firstDash = prefix.indexOf("-");
1557
- if (firstDash === -1)
1558
- return null;
1559
- return {
1560
- userId: prefix.slice(0, firstDash),
1561
- deviceId: prefix.slice(firstDash + 1),
1562
- localId
1563
- };
1564
- }
1565
-
1566
- // src/embeddings/embedder.ts
1567
- var _available = null;
1568
- var _pipeline = null;
1569
- var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
1570
- async function embedText(text) {
1571
- const pipe = await getPipeline();
1572
- if (!pipe)
1573
- return null;
1574
- try {
1575
- const output = await pipe(text, { pooling: "mean", normalize: true });
1576
- return new Float32Array(output.data);
1577
- } catch {
1578
- return null;
1579
- }
1580
- }
1581
- function composeEmbeddingText(obs) {
1582
- const parts = [obs.title];
1583
- if (obs.narrative)
1584
- parts.push(obs.narrative);
1585
- if (obs.facts) {
1586
- try {
1587
- const facts = JSON.parse(obs.facts);
1588
- if (Array.isArray(facts) && facts.length > 0) {
1589
- parts.push(facts.map((f) => `- ${f}`).join(`
1590
- `));
1591
- }
1592
- } catch {
1593
- parts.push(obs.facts);
1594
- }
1595
- }
1596
- if (obs.concepts) {
1597
- try {
1598
- const concepts = JSON.parse(obs.concepts);
1599
- if (Array.isArray(concepts) && concepts.length > 0) {
1600
- parts.push(concepts.join(", "));
1601
- }
1602
- } catch {}
1603
- }
1604
- return parts.join(`
1605
-
1606
- `);
1607
- }
1608
- async function getPipeline() {
1609
- if (_pipeline)
1610
- return _pipeline;
1611
- if (_available === false)
1612
- return null;
1613
- try {
1614
- const { pipeline } = await import("@xenova/transformers");
1615
- _pipeline = await pipeline("feature-extraction", MODEL_NAME);
1616
- _available = true;
1617
- return _pipeline;
1618
- } catch (err) {
1619
- _available = false;
1620
- console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
1621
- return null;
2285
+ for (const type of ["obs", "summary", "chat"]) {
2286
+ const marker = `-${type}-`;
2287
+ const idx = sourceId.lastIndexOf(marker);
2288
+ if (idx === -1)
2289
+ continue;
2290
+ const prefix = sourceId.slice(0, idx);
2291
+ const localIdStr = sourceId.slice(idx + marker.length);
2292
+ const localId = parseInt(localIdStr, 10);
2293
+ if (isNaN(localId))
2294
+ return null;
2295
+ const firstDash = prefix.indexOf("-");
2296
+ if (firstDash === -1)
2297
+ return null;
2298
+ return {
2299
+ userId: prefix.slice(0, firstDash),
2300
+ deviceId: prefix.slice(firstDash + 1),
2301
+ localId,
2302
+ type
2303
+ };
1622
2304
  }
2305
+ return null;
1623
2306
  }
1624
2307
 
1625
2308
  // src/sync/pull.ts
@@ -1651,6 +2334,7 @@ function mergeChanges(db, config, changes) {
1651
2334
  for (const change of changes) {
1652
2335
  const parsed = parseSourceId(change.source_id);
1653
2336
  const remoteSummary = isRemoteSummary(change);
2337
+ const remoteChat = isRemoteChat(change);
1654
2338
  if (parsed && parsed.deviceId === config.device_id) {
1655
2339
  skipped++;
1656
2340
  continue;
@@ -1673,6 +2357,15 @@ function mergeChanges(db, config, changes) {
1673
2357
  merged++;
1674
2358
  }
1675
2359
  }
2360
+ if (remoteChat) {
2361
+ const mergedChat = mergeRemoteChat(db, config, change, project.id);
2362
+ if (mergedChat) {
2363
+ merged++;
2364
+ } else {
2365
+ skipped++;
2366
+ }
2367
+ continue;
2368
+ }
1676
2369
  const existing = db.db.query("SELECT id FROM observations WHERE remote_source_id = ?").get(change.source_id);
1677
2370
  if (existing) {
1678
2371
  if (!remoteSummary)
@@ -1714,6 +2407,10 @@ function isRemoteSummary(change) {
1714
2407
  const rawType = typeof change.metadata?.type === "string" ? change.metadata.type.toLowerCase() : "";
1715
2408
  return rawType === "summary" || change.source_id.includes("-summary-");
1716
2409
  }
2410
+ function isRemoteChat(change) {
2411
+ const rawType = typeof change.metadata?.type === "string" ? change.metadata.type.toLowerCase() : "";
2412
+ return rawType === "chat" || change.source_id.includes("-chat-");
2413
+ }
1717
2414
  function mergeRemoteSummary(db, config, change, projectId) {
1718
2415
  const sessionId = typeof change.metadata?.session_id === "string" ? change.metadata.session_id : null;
1719
2416
  if (!sessionId)
@@ -1727,6 +2424,7 @@ function mergeRemoteSummary(db, config, change, projectId) {
1727
2424
  learned: typeof change.metadata?.learned === "string" ? change.metadata.learned : null,
1728
2425
  completed: typeof change.metadata?.completed === "string" ? change.metadata.completed : null,
1729
2426
  next_steps: typeof change.metadata?.next_steps === "string" ? change.metadata.next_steps : null,
2427
+ current_thread: typeof change.metadata?.current_thread === "string" ? change.metadata.current_thread : null,
1730
2428
  capture_state: typeof change.metadata?.capture_state === "string" ? change.metadata.capture_state : null,
1731
2429
  recent_tool_names: encodeStringArray(change.metadata?.recent_tool_names),
1732
2430
  hot_files: encodeStringArray(change.metadata?.hot_files),
@@ -1734,6 +2432,26 @@ function mergeRemoteSummary(db, config, change, projectId) {
1734
2432
  });
1735
2433
  return Boolean(summary);
1736
2434
  }
2435
+ function mergeRemoteChat(db, config, change, projectId) {
2436
+ if (db.getChatMessageByRemoteSourceId(change.source_id))
2437
+ return false;
2438
+ const sessionId = typeof change.metadata?.session_id === "string" ? change.metadata.session_id : null;
2439
+ const role = change.metadata?.role === "assistant" ? "assistant" : "user";
2440
+ if (!sessionId || typeof change.content !== "string" || !change.content.trim())
2441
+ return false;
2442
+ db.insertChatMessage({
2443
+ session_id: sessionId,
2444
+ project_id: projectId,
2445
+ role,
2446
+ content: change.content,
2447
+ user_id: (typeof change.metadata?.user_id === "string" ? change.metadata.user_id : null) ?? config.user_id,
2448
+ device_id: (typeof change.metadata?.device_id === "string" ? change.metadata.device_id : null) ?? "remote",
2449
+ agent: typeof change.metadata?.agent === "string" ? change.metadata.agent : "unknown",
2450
+ created_at_epoch: typeof change.metadata?.created_at_epoch === "number" ? change.metadata.created_at_epoch : undefined,
2451
+ remote_source_id: change.source_id
2452
+ });
2453
+ return true;
2454
+ }
1737
2455
  function encodeStringArray(value) {
1738
2456
  if (!Array.isArray(value))
1739
2457
  return null;
@@ -1993,7 +2711,7 @@ var MIGRATIONS = [
1993
2711
  -- Sync outbox (offline-first queue)
1994
2712
  CREATE TABLE sync_outbox (
1995
2713
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1996
- record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
2714
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
1997
2715
  record_id INTEGER NOT NULL,
1998
2716
  status TEXT DEFAULT 'pending' CHECK (status IN (
1999
2717
  'pending', 'syncing', 'synced', 'failed'
@@ -2288,6 +3006,18 @@ var MIGRATIONS = [
2288
3006
  },
2289
3007
  {
2290
3008
  version: 11,
3009
+ description: "Add observation provenance from tool and prompt chronology",
3010
+ sql: `
3011
+ ALTER TABLE observations ADD COLUMN source_tool TEXT;
3012
+ ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
3013
+ CREATE INDEX IF NOT EXISTS idx_observations_source_tool
3014
+ ON observations(source_tool, created_at_epoch DESC);
3015
+ CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
3016
+ ON observations(session_id, source_prompt_number DESC);
3017
+ `
3018
+ },
3019
+ {
3020
+ version: 12,
2291
3021
  description: "Add synced handoff metadata to session summaries",
2292
3022
  sql: `
2293
3023
  ALTER TABLE session_summaries ADD COLUMN capture_state TEXT;
@@ -2297,15 +3027,79 @@ var MIGRATIONS = [
2297
3027
  `
2298
3028
  },
2299
3029
  {
2300
- version: 11,
2301
- description: "Add observation provenance from tool and prompt chronology",
3030
+ version: 13,
3031
+ description: "Add current_thread to session summaries",
2302
3032
  sql: `
2303
- ALTER TABLE observations ADD COLUMN source_tool TEXT;
2304
- ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
2305
- CREATE INDEX IF NOT EXISTS idx_observations_source_tool
2306
- ON observations(source_tool, created_at_epoch DESC);
2307
- CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
2308
- ON observations(session_id, source_prompt_number DESC);
3033
+ ALTER TABLE session_summaries ADD COLUMN current_thread TEXT;
3034
+ `
3035
+ },
3036
+ {
3037
+ version: 14,
3038
+ description: "Add chat_messages lane for raw conversation recall",
3039
+ sql: `
3040
+ CREATE TABLE IF NOT EXISTS chat_messages (
3041
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3042
+ session_id TEXT NOT NULL,
3043
+ project_id INTEGER REFERENCES projects(id),
3044
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
3045
+ content TEXT NOT NULL,
3046
+ user_id TEXT NOT NULL,
3047
+ device_id TEXT NOT NULL,
3048
+ agent TEXT DEFAULT 'claude-code',
3049
+ created_at_epoch INTEGER NOT NULL
3050
+ );
3051
+
3052
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_session
3053
+ ON chat_messages(session_id, created_at_epoch DESC, id DESC);
3054
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_project
3055
+ ON chat_messages(project_id, created_at_epoch DESC, id DESC);
3056
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_created
3057
+ ON chat_messages(created_at_epoch DESC, id DESC);
3058
+ `
3059
+ },
3060
+ {
3061
+ version: 15,
3062
+ description: "Add remote_source_id for chat message sync deduplication",
3063
+ sql: `
3064
+ ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT;
3065
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source
3066
+ ON chat_messages(remote_source_id)
3067
+ WHERE remote_source_id IS NOT NULL;
3068
+ `
3069
+ },
3070
+ {
3071
+ version: 16,
3072
+ description: "Allow chat_message records in sync_outbox",
3073
+ sql: `
3074
+ CREATE TABLE sync_outbox_new (
3075
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3076
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
3077
+ record_id INTEGER NOT NULL,
3078
+ status TEXT DEFAULT 'pending' CHECK (status IN (
3079
+ 'pending', 'syncing', 'synced', 'failed'
3080
+ )),
3081
+ retry_count INTEGER DEFAULT 0,
3082
+ max_retries INTEGER DEFAULT 10,
3083
+ last_error TEXT,
3084
+ created_at_epoch INTEGER NOT NULL,
3085
+ synced_at_epoch INTEGER,
3086
+ next_retry_epoch INTEGER
3087
+ );
3088
+
3089
+ INSERT INTO sync_outbox_new (
3090
+ id, record_type, record_id, status, retry_count, max_retries,
3091
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
3092
+ )
3093
+ SELECT
3094
+ id, record_type, record_id, status, retry_count, max_retries,
3095
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
3096
+ FROM sync_outbox;
3097
+
3098
+ DROP TABLE sync_outbox;
3099
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
3100
+
3101
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
3102
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
2309
3103
  `
2310
3104
  }
2311
3105
  ];
@@ -2362,6 +3156,21 @@ function inferLegacySchemaVersion(db) {
2362
3156
  version = Math.max(version, 10);
2363
3157
  if (columnExists(db, "observations", "source_tool"))
2364
3158
  version = Math.max(version, 11);
3159
+ if (columnExists(db, "session_summaries", "capture_state") && columnExists(db, "session_summaries", "recent_tool_names") && columnExists(db, "session_summaries", "hot_files") && columnExists(db, "session_summaries", "recent_outcomes")) {
3160
+ version = Math.max(version, 12);
3161
+ }
3162
+ if (columnExists(db, "session_summaries", "current_thread")) {
3163
+ version = Math.max(version, 13);
3164
+ }
3165
+ if (tableExists(db, "chat_messages")) {
3166
+ version = Math.max(version, 14);
3167
+ }
3168
+ if (columnExists(db, "chat_messages", "remote_source_id")) {
3169
+ version = Math.max(version, 15);
3170
+ }
3171
+ if (syncOutboxSupportsChatMessages(db)) {
3172
+ version = Math.max(version, 16);
3173
+ }
2365
3174
  return version;
2366
3175
  }
2367
3176
  function runMigrations(db) {
@@ -2440,6 +3249,93 @@ function ensureObservationTypes(db) {
2440
3249
  }
2441
3250
  }
2442
3251
  }
3252
+ function ensureSessionSummaryColumns(db) {
3253
+ const required = [
3254
+ "capture_state",
3255
+ "recent_tool_names",
3256
+ "hot_files",
3257
+ "recent_outcomes",
3258
+ "current_thread"
3259
+ ];
3260
+ for (const column of required) {
3261
+ if (columnExists(db, "session_summaries", column))
3262
+ continue;
3263
+ db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
3264
+ }
3265
+ const current = getSchemaVersion(db);
3266
+ if (current < 13) {
3267
+ db.exec("PRAGMA user_version = 13");
3268
+ }
3269
+ }
3270
+ function ensureChatMessageColumns(db) {
3271
+ if (!tableExists(db, "chat_messages"))
3272
+ return;
3273
+ if (!columnExists(db, "chat_messages", "remote_source_id")) {
3274
+ db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
3275
+ }
3276
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source ON chat_messages(remote_source_id) WHERE remote_source_id IS NOT NULL");
3277
+ const current = getSchemaVersion(db);
3278
+ if (current < 15) {
3279
+ db.exec("PRAGMA user_version = 15");
3280
+ }
3281
+ }
3282
+ function ensureSyncOutboxSupportsChatMessages(db) {
3283
+ if (syncOutboxSupportsChatMessages(db)) {
3284
+ const current = getSchemaVersion(db);
3285
+ if (current < 16) {
3286
+ db.exec("PRAGMA user_version = 16");
3287
+ }
3288
+ return;
3289
+ }
3290
+ db.exec("BEGIN TRANSACTION");
3291
+ try {
3292
+ db.exec(`
3293
+ CREATE TABLE sync_outbox_new (
3294
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3295
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
3296
+ record_id INTEGER NOT NULL,
3297
+ status TEXT DEFAULT 'pending' CHECK (status IN (
3298
+ 'pending', 'syncing', 'synced', 'failed'
3299
+ )),
3300
+ retry_count INTEGER DEFAULT 0,
3301
+ max_retries INTEGER DEFAULT 10,
3302
+ last_error TEXT,
3303
+ created_at_epoch INTEGER NOT NULL,
3304
+ synced_at_epoch INTEGER,
3305
+ next_retry_epoch INTEGER
3306
+ );
3307
+
3308
+ INSERT INTO sync_outbox_new (
3309
+ id, record_type, record_id, status, retry_count, max_retries,
3310
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
3311
+ )
3312
+ SELECT
3313
+ id, record_type, record_id, status, retry_count, max_retries,
3314
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
3315
+ FROM sync_outbox;
3316
+
3317
+ DROP TABLE sync_outbox;
3318
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
3319
+
3320
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
3321
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
3322
+ `);
3323
+ db.exec("PRAGMA user_version = 16");
3324
+ db.exec("COMMIT");
3325
+ } catch (error) {
3326
+ db.exec("ROLLBACK");
3327
+ throw new Error(`sync_outbox repair failed: ${error instanceof Error ? error.message : String(error)}`);
3328
+ }
3329
+ }
3330
+ function syncOutboxSupportsChatMessages(db) {
3331
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("sync_outbox");
3332
+ const sql = row?.sql ?? "";
3333
+ return sql.includes("'chat_message'");
3334
+ }
3335
+ function getSchemaVersion(db) {
3336
+ const result = db.query("PRAGMA user_version").get();
3337
+ return result.user_version;
3338
+ }
2443
3339
  var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
2444
3340
 
2445
3341
  // src/storage/sqlite.ts
@@ -2514,6 +3410,9 @@ class MemDatabase {
2514
3410
  this.vecAvailable = this.loadVecExtension();
2515
3411
  runMigrations(this.db);
2516
3412
  ensureObservationTypes(this.db);
3413
+ ensureSessionSummaryColumns(this.db);
3414
+ ensureChatMessageColumns(this.db);
3415
+ ensureSyncOutboxSupportsChatMessages(this.db);
2517
3416
  }
2518
3417
  loadVecExtension() {
2519
3418
  try {
@@ -2739,6 +3638,7 @@ class MemDatabase {
2739
3638
  p.name AS project_name,
2740
3639
  ss.request AS request,
2741
3640
  ss.completed AS completed,
3641
+ ss.current_thread AS current_thread,
2742
3642
  ss.capture_state AS capture_state,
2743
3643
  ss.recent_tool_names AS recent_tool_names,
2744
3644
  ss.hot_files AS hot_files,
@@ -2757,6 +3657,7 @@ class MemDatabase {
2757
3657
  p.name AS project_name,
2758
3658
  ss.request AS request,
2759
3659
  ss.completed AS completed,
3660
+ ss.current_thread AS current_thread,
2760
3661
  ss.capture_state AS capture_state,
2761
3662
  ss.recent_tool_names AS recent_tool_names,
2762
3663
  ss.hot_files AS hot_files,
@@ -2847,6 +3748,54 @@ class MemDatabase {
2847
3748
  ORDER BY created_at_epoch DESC, id DESC
2848
3749
  LIMIT ?`).all(...userId ? [userId] : [], limit);
2849
3750
  }
3751
+ insertChatMessage(input) {
3752
+ const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
3753
+ const content = input.content.trim();
3754
+ const result = this.db.query(`INSERT INTO chat_messages (
3755
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
3756
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.session_id, input.project_id, input.role, content, input.user_id, input.device_id, input.agent ?? "claude-code", createdAt, input.remote_source_id ?? null);
3757
+ return this.getChatMessageById(Number(result.lastInsertRowid));
3758
+ }
3759
+ getChatMessageById(id) {
3760
+ return this.db.query("SELECT * FROM chat_messages WHERE id = ?").get(id) ?? null;
3761
+ }
3762
+ getChatMessageByRemoteSourceId(remoteSourceId) {
3763
+ return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
3764
+ }
3765
+ getSessionChatMessages(sessionId, limit = 50) {
3766
+ return this.db.query(`SELECT * FROM chat_messages
3767
+ WHERE session_id = ?
3768
+ ORDER BY created_at_epoch ASC, id ASC
3769
+ LIMIT ?`).all(sessionId, limit);
3770
+ }
3771
+ getRecentChatMessages(projectId, limit = 20, userId) {
3772
+ const visibilityClause = userId ? " AND user_id = ?" : "";
3773
+ if (projectId !== null) {
3774
+ return this.db.query(`SELECT * FROM chat_messages
3775
+ WHERE project_id = ?${visibilityClause}
3776
+ ORDER BY created_at_epoch DESC, id DESC
3777
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
3778
+ }
3779
+ return this.db.query(`SELECT * FROM chat_messages
3780
+ WHERE 1 = 1${visibilityClause}
3781
+ ORDER BY created_at_epoch DESC, id DESC
3782
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
3783
+ }
3784
+ searchChatMessages(query, projectId, limit = 20, userId) {
3785
+ const needle = `%${query.toLowerCase()}%`;
3786
+ const visibilityClause = userId ? " AND user_id = ?" : "";
3787
+ if (projectId !== null) {
3788
+ return this.db.query(`SELECT * FROM chat_messages
3789
+ WHERE project_id = ?
3790
+ AND lower(content) LIKE ?${visibilityClause}
3791
+ ORDER BY created_at_epoch DESC, id DESC
3792
+ LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
3793
+ }
3794
+ return this.db.query(`SELECT * FROM chat_messages
3795
+ WHERE lower(content) LIKE ?${visibilityClause}
3796
+ ORDER BY created_at_epoch DESC, id DESC
3797
+ LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
3798
+ }
2850
3799
  addToOutbox(recordType, recordId) {
2851
3800
  const now = Math.floor(Date.now() / 1000);
2852
3801
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -2935,9 +3884,9 @@ class MemDatabase {
2935
3884
  };
2936
3885
  const result = this.db.query(`INSERT INTO session_summaries (
2937
3886
  session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
2938
- capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
3887
+ current_thread, capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
2939
3888
  )
2940
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, normalized.request, normalized.investigated, normalized.learned, normalized.completed, normalized.next_steps, summary.capture_state ?? null, summary.recent_tool_names ?? null, summary.hot_files ?? null, summary.recent_outcomes ?? null, now);
3889
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, normalized.request, normalized.investigated, normalized.learned, normalized.completed, normalized.next_steps, summary.current_thread ?? null, summary.capture_state ?? null, summary.recent_tool_names ?? null, summary.hot_files ?? null, summary.recent_outcomes ?? null, now);
2941
3890
  const id = Number(result.lastInsertRowid);
2942
3891
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
2943
3892
  }
@@ -2953,6 +3902,7 @@ class MemDatabase {
2953
3902
  learned: normalizeSummarySection(summary.learned ?? existing.learned),
2954
3903
  completed: normalizeSummarySection(summary.completed ?? existing.completed),
2955
3904
  next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
3905
+ current_thread: summary.current_thread ?? existing.current_thread,
2956
3906
  capture_state: summary.capture_state ?? existing.capture_state,
2957
3907
  recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
2958
3908
  hot_files: summary.hot_files ?? existing.hot_files,
@@ -2966,12 +3916,13 @@ class MemDatabase {
2966
3916
  learned = ?,
2967
3917
  completed = ?,
2968
3918
  next_steps = ?,
3919
+ current_thread = ?,
2969
3920
  capture_state = ?,
2970
3921
  recent_tool_names = ?,
2971
3922
  hot_files = ?,
2972
3923
  recent_outcomes = ?,
2973
3924
  created_at_epoch = ?
2974
- WHERE session_id = ?`).run(summary.project_id ?? existing.project_id, summary.user_id, normalized.request, normalized.investigated, normalized.learned, normalized.completed, normalized.next_steps, normalized.capture_state, normalized.recent_tool_names, normalized.hot_files, normalized.recent_outcomes, now, summary.session_id);
3925
+ WHERE session_id = ?`).run(summary.project_id ?? existing.project_id, summary.user_id, normalized.request, normalized.investigated, normalized.learned, normalized.completed, normalized.next_steps, normalized.current_thread, normalized.capture_state, normalized.recent_tool_names, normalized.hot_files, normalized.recent_outcomes, now, summary.session_id);
2975
3926
  return this.getSessionSummary(summary.session_id);
2976
3927
  }
2977
3928
  getSessionSummary(sessionId) {
@@ -3307,8 +4258,17 @@ function formatVisibleStartupBrief(context) {
3307
4258
  const toolFallbacks = buildToolFallbacks(context);
3308
4259
  const sessionFallbacks = sessionFallbacksFromContext(context);
3309
4260
  const recentOutcomeLines = buildRecentOutcomeLines(context, latest);
4261
+ const currentThread = buildCurrentThreadLine(context, latest);
3310
4262
  const projectSignals = buildProjectSignalLine(context);
3311
4263
  const shownItems = new Set;
4264
+ const latestHandoffLines = buildLatestHandoffLines(context);
4265
+ if (latestHandoffLines.length > 0) {
4266
+ lines.push(`${c2.cyan}Latest handoff:${c2.reset}`);
4267
+ for (const item of latestHandoffLines) {
4268
+ lines.push(` - ${truncateInline(item, 160)}`);
4269
+ rememberShownItem(shownItems, item);
4270
+ }
4271
+ }
3312
4272
  if (promptLines.length > 0) {
3313
4273
  lines.push(`${c2.cyan}Asked recently:${c2.reset}`);
3314
4274
  for (const item of promptLines) {
@@ -3356,6 +4316,11 @@ function formatVisibleStartupBrief(context) {
3356
4316
  }
3357
4317
  }
3358
4318
  }
4319
+ if (currentThread && !shownItems.has(normalizeStartupItem(currentThread))) {
4320
+ lines.push(`${c2.cyan}Current thread:${c2.reset}`);
4321
+ lines.push(` - ${truncateInline(currentThread, 160)}`);
4322
+ rememberShownItem(shownItems, currentThread);
4323
+ }
3359
4324
  if (latest && currentRequest && !hasRequestSection(lines) && !duplicatesPromptLine(currentRequest, latestPromptLine)) {
3360
4325
  lines.push(`${c2.cyan}What you're on:${c2.reset}`);
3361
4326
  lines.push(` - ${truncateInline(currentRequest, 160)}`);
@@ -3400,6 +4365,20 @@ function formatVisibleStartupBrief(context) {
3400
4365
  }
3401
4366
  return lines.slice(0, 14);
3402
4367
  }
4368
+ function buildLatestHandoffLines(context) {
4369
+ const latest = context.recentHandoffs?.[0];
4370
+ if (!latest)
4371
+ return [];
4372
+ const lines = [];
4373
+ const title = latest.title.replace(/^Handoff:\s*/i, "").replace(/\s+\u00B7\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
4374
+ if (title)
4375
+ lines.push(title);
4376
+ const narrative = latest.narrative?.split(/\n{2,}/).map((part) => part.replace(/\s+/g, " ").trim()).find((part) => /^(Current thread:|Completed:|Next Steps:)/i.test(part));
4377
+ if (narrative) {
4378
+ lines.push(narrative.replace(/^(Current thread:|Completed:|Next Steps:)\s*/i, ""));
4379
+ }
4380
+ return Array.from(new Set(lines.filter(Boolean))).slice(0, 2);
4381
+ }
3403
4382
  function formatContextEconomics(data) {
3404
4383
  const totalMemories = Math.max(0, data.loaded + data.available);
3405
4384
  const parts = [];
@@ -3443,6 +4422,7 @@ function formatInspectHints(context, visibleObservationIds = []) {
3443
4422
  if ((context.recentSessions?.length ?? 0) > 0) {
3444
4423
  hints.push("recent_sessions");
3445
4424
  hints.push("session_story");
4425
+ hints.push("create_handoff");
3446
4426
  }
3447
4427
  if ((context.recentPrompts?.length ?? 0) > 0 || (context.recentToolEvents?.length ?? 0) > 0) {
3448
4428
  hints.push("activity_feed");
@@ -3450,6 +4430,9 @@ function formatInspectHints(context, visibleObservationIds = []) {
3450
4430
  if (context.observations.length > 0) {
3451
4431
  hints.push("memory_console");
3452
4432
  }
4433
+ if ((context.recentSessions?.length ?? 0) > 0) {
4434
+ hints.push("recent_handoffs");
4435
+ }
3453
4436
  const unique = Array.from(new Set(hints)).slice(0, 4);
3454
4437
  if (unique.length === 0)
3455
4438
  return [];
@@ -3582,6 +4565,22 @@ function buildRecentOutcomeLines(context, summary) {
3582
4565
  }
3583
4566
  return picked;
3584
4567
  }
4568
+ function buildCurrentThreadLine(context, summary) {
4569
+ const explicit = summary?.current_thread ?? null;
4570
+ if (explicit && !looksLikeFileOperationTitle2(explicit))
4571
+ return explicit;
4572
+ for (const session of context.recentSessions ?? []) {
4573
+ if (session.current_thread && !looksLikeFileOperationTitle2(session.current_thread)) {
4574
+ return session.current_thread;
4575
+ }
4576
+ }
4577
+ const request = buildPromptFallback(context);
4578
+ const outcome = buildRecentOutcomeLines(context, summary)[0] ?? null;
4579
+ const tool = buildToolFallbacks(context)[0] ?? null;
4580
+ if (outcome && tool)
4581
+ return `${outcome} \xB7 ${tool}`;
4582
+ return outcome ?? request ?? null;
4583
+ }
3585
4584
  function chooseMeaningfulSessionSummary(request, completed) {
3586
4585
  if (request && !looksLikeFileOperationTitle2(request))
3587
4586
  return request;
@@ -3833,7 +4832,15 @@ function isWeakCompletedSection(value) {
3833
4832
  return weakCount === items.length;
3834
4833
  }
3835
4834
  function looksLikeFileOperationTitle2(value) {
3836
- return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
4835
+ const trimmed = value.trim();
4836
+ if (/^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(trimmed)) {
4837
+ return true;
4838
+ }
4839
+ return looksLikeGenericSummaryWrapper(trimmed);
4840
+ }
4841
+ function looksLikeGenericSummaryWrapper(value) {
4842
+ const normalized = value.toLowerCase().replace(/\s+/g, " ").trim();
4843
+ return normalized.startsWith("all clean. here's a summary of what was fixed") || normalized.startsWith("all clean, here's a summary of what was fixed") || normalized.startsWith("now i have enough to give a clear, accurate assessment") || normalized.startsWith("here's the real picture") || normalized === "event log \u2192 existing events feed" || normalized.startsWith("event log -> existing events feed") || normalized.startsWith("tl;dr:");
3837
4844
  }
3838
4845
  function scoreSplashLine(value) {
3839
4846
  let score = 0;