engrm 0.4.22 → 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.
- package/README.md +18 -0
- package/dist/cli.js +308 -24
- package/dist/hooks/elicitation-result.js +223 -15
- package/dist/hooks/post-tool-use.js +257 -15
- package/dist/hooks/pre-compact.js +962 -17
- package/dist/hooks/sentinel.js +223 -15
- package/dist/hooks/session-start.js +1066 -92
- package/dist/hooks/stop.js +361 -33
- package/dist/hooks/user-prompt-submit.js +267 -15
- package/dist/server.js +863 -49
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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'
|
|
@@ -2286,6 +3004,18 @@ var MIGRATIONS = [
|
|
|
2286
3004
|
ON tool_events(created_at_epoch DESC, id DESC);
|
|
2287
3005
|
`
|
|
2288
3006
|
},
|
|
3007
|
+
{
|
|
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
|
+
},
|
|
2289
3019
|
{
|
|
2290
3020
|
version: 12,
|
|
2291
3021
|
description: "Add synced handoff metadata to session summaries",
|
|
@@ -2297,15 +3027,79 @@ var MIGRATIONS = [
|
|
|
2297
3027
|
`
|
|
2298
3028
|
},
|
|
2299
3029
|
{
|
|
2300
|
-
version:
|
|
2301
|
-
description: "Add
|
|
3030
|
+
version: 13,
|
|
3031
|
+
description: "Add current_thread to session summaries",
|
|
2302
3032
|
sql: `
|
|
2303
|
-
ALTER TABLE
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
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
|
];
|
|
@@ -2365,6 +3159,18 @@ function inferLegacySchemaVersion(db) {
|
|
|
2365
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")) {
|
|
2366
3160
|
version = Math.max(version, 12);
|
|
2367
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
|
+
}
|
|
2368
3174
|
return version;
|
|
2369
3175
|
}
|
|
2370
3176
|
function runMigrations(db) {
|
|
@@ -2448,7 +3254,8 @@ function ensureSessionSummaryColumns(db) {
|
|
|
2448
3254
|
"capture_state",
|
|
2449
3255
|
"recent_tool_names",
|
|
2450
3256
|
"hot_files",
|
|
2451
|
-
"recent_outcomes"
|
|
3257
|
+
"recent_outcomes",
|
|
3258
|
+
"current_thread"
|
|
2452
3259
|
];
|
|
2453
3260
|
for (const column of required) {
|
|
2454
3261
|
if (columnExists(db, "session_summaries", column))
|
|
@@ -2456,10 +3263,75 @@ function ensureSessionSummaryColumns(db) {
|
|
|
2456
3263
|
db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
|
|
2457
3264
|
}
|
|
2458
3265
|
const current = getSchemaVersion(db);
|
|
2459
|
-
if (current <
|
|
2460
|
-
db.exec("PRAGMA user_version =
|
|
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");
|
|
2461
3280
|
}
|
|
2462
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
|
+
}
|
|
2463
3335
|
function getSchemaVersion(db) {
|
|
2464
3336
|
const result = db.query("PRAGMA user_version").get();
|
|
2465
3337
|
return result.user_version;
|
|
@@ -2539,6 +3411,8 @@ class MemDatabase {
|
|
|
2539
3411
|
runMigrations(this.db);
|
|
2540
3412
|
ensureObservationTypes(this.db);
|
|
2541
3413
|
ensureSessionSummaryColumns(this.db);
|
|
3414
|
+
ensureChatMessageColumns(this.db);
|
|
3415
|
+
ensureSyncOutboxSupportsChatMessages(this.db);
|
|
2542
3416
|
}
|
|
2543
3417
|
loadVecExtension() {
|
|
2544
3418
|
try {
|
|
@@ -2764,6 +3638,7 @@ class MemDatabase {
|
|
|
2764
3638
|
p.name AS project_name,
|
|
2765
3639
|
ss.request AS request,
|
|
2766
3640
|
ss.completed AS completed,
|
|
3641
|
+
ss.current_thread AS current_thread,
|
|
2767
3642
|
ss.capture_state AS capture_state,
|
|
2768
3643
|
ss.recent_tool_names AS recent_tool_names,
|
|
2769
3644
|
ss.hot_files AS hot_files,
|
|
@@ -2782,6 +3657,7 @@ class MemDatabase {
|
|
|
2782
3657
|
p.name AS project_name,
|
|
2783
3658
|
ss.request AS request,
|
|
2784
3659
|
ss.completed AS completed,
|
|
3660
|
+
ss.current_thread AS current_thread,
|
|
2785
3661
|
ss.capture_state AS capture_state,
|
|
2786
3662
|
ss.recent_tool_names AS recent_tool_names,
|
|
2787
3663
|
ss.hot_files AS hot_files,
|
|
@@ -2872,6 +3748,54 @@ class MemDatabase {
|
|
|
2872
3748
|
ORDER BY created_at_epoch DESC, id DESC
|
|
2873
3749
|
LIMIT ?`).all(...userId ? [userId] : [], limit);
|
|
2874
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
|
+
}
|
|
2875
3799
|
addToOutbox(recordType, recordId) {
|
|
2876
3800
|
const now = Math.floor(Date.now() / 1000);
|
|
2877
3801
|
this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
|
|
@@ -2960,9 +3884,9 @@ class MemDatabase {
|
|
|
2960
3884
|
};
|
|
2961
3885
|
const result = this.db.query(`INSERT INTO session_summaries (
|
|
2962
3886
|
session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
|
|
2963
|
-
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
|
|
2964
3888
|
)
|
|
2965
|
-
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);
|
|
2966
3890
|
const id = Number(result.lastInsertRowid);
|
|
2967
3891
|
return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
|
|
2968
3892
|
}
|
|
@@ -2978,6 +3902,7 @@ class MemDatabase {
|
|
|
2978
3902
|
learned: normalizeSummarySection(summary.learned ?? existing.learned),
|
|
2979
3903
|
completed: normalizeSummarySection(summary.completed ?? existing.completed),
|
|
2980
3904
|
next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
|
|
3905
|
+
current_thread: summary.current_thread ?? existing.current_thread,
|
|
2981
3906
|
capture_state: summary.capture_state ?? existing.capture_state,
|
|
2982
3907
|
recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
|
|
2983
3908
|
hot_files: summary.hot_files ?? existing.hot_files,
|
|
@@ -2991,12 +3916,13 @@ class MemDatabase {
|
|
|
2991
3916
|
learned = ?,
|
|
2992
3917
|
completed = ?,
|
|
2993
3918
|
next_steps = ?,
|
|
3919
|
+
current_thread = ?,
|
|
2994
3920
|
capture_state = ?,
|
|
2995
3921
|
recent_tool_names = ?,
|
|
2996
3922
|
hot_files = ?,
|
|
2997
3923
|
recent_outcomes = ?,
|
|
2998
3924
|
created_at_epoch = ?
|
|
2999
|
-
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);
|
|
3000
3926
|
return this.getSessionSummary(summary.session_id);
|
|
3001
3927
|
}
|
|
3002
3928
|
getSessionSummary(sessionId) {
|
|
@@ -3332,8 +4258,17 @@ function formatVisibleStartupBrief(context) {
|
|
|
3332
4258
|
const toolFallbacks = buildToolFallbacks(context);
|
|
3333
4259
|
const sessionFallbacks = sessionFallbacksFromContext(context);
|
|
3334
4260
|
const recentOutcomeLines = buildRecentOutcomeLines(context, latest);
|
|
4261
|
+
const currentThread = buildCurrentThreadLine(context, latest);
|
|
3335
4262
|
const projectSignals = buildProjectSignalLine(context);
|
|
3336
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
|
+
}
|
|
3337
4272
|
if (promptLines.length > 0) {
|
|
3338
4273
|
lines.push(`${c2.cyan}Asked recently:${c2.reset}`);
|
|
3339
4274
|
for (const item of promptLines) {
|
|
@@ -3381,6 +4316,11 @@ function formatVisibleStartupBrief(context) {
|
|
|
3381
4316
|
}
|
|
3382
4317
|
}
|
|
3383
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
|
+
}
|
|
3384
4324
|
if (latest && currentRequest && !hasRequestSection(lines) && !duplicatesPromptLine(currentRequest, latestPromptLine)) {
|
|
3385
4325
|
lines.push(`${c2.cyan}What you're on:${c2.reset}`);
|
|
3386
4326
|
lines.push(` - ${truncateInline(currentRequest, 160)}`);
|
|
@@ -3425,6 +4365,20 @@ function formatVisibleStartupBrief(context) {
|
|
|
3425
4365
|
}
|
|
3426
4366
|
return lines.slice(0, 14);
|
|
3427
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
|
+
}
|
|
3428
4382
|
function formatContextEconomics(data) {
|
|
3429
4383
|
const totalMemories = Math.max(0, data.loaded + data.available);
|
|
3430
4384
|
const parts = [];
|
|
@@ -3468,6 +4422,7 @@ function formatInspectHints(context, visibleObservationIds = []) {
|
|
|
3468
4422
|
if ((context.recentSessions?.length ?? 0) > 0) {
|
|
3469
4423
|
hints.push("recent_sessions");
|
|
3470
4424
|
hints.push("session_story");
|
|
4425
|
+
hints.push("create_handoff");
|
|
3471
4426
|
}
|
|
3472
4427
|
if ((context.recentPrompts?.length ?? 0) > 0 || (context.recentToolEvents?.length ?? 0) > 0) {
|
|
3473
4428
|
hints.push("activity_feed");
|
|
@@ -3475,6 +4430,9 @@ function formatInspectHints(context, visibleObservationIds = []) {
|
|
|
3475
4430
|
if (context.observations.length > 0) {
|
|
3476
4431
|
hints.push("memory_console");
|
|
3477
4432
|
}
|
|
4433
|
+
if ((context.recentSessions?.length ?? 0) > 0) {
|
|
4434
|
+
hints.push("recent_handoffs");
|
|
4435
|
+
}
|
|
3478
4436
|
const unique = Array.from(new Set(hints)).slice(0, 4);
|
|
3479
4437
|
if (unique.length === 0)
|
|
3480
4438
|
return [];
|
|
@@ -3607,6 +4565,22 @@ function buildRecentOutcomeLines(context, summary) {
|
|
|
3607
4565
|
}
|
|
3608
4566
|
return picked;
|
|
3609
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
|
+
}
|
|
3610
4584
|
function chooseMeaningfulSessionSummary(request, completed) {
|
|
3611
4585
|
if (request && !looksLikeFileOperationTitle2(request))
|
|
3612
4586
|
return request;
|