engrm 0.4.22 → 0.4.25
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 +65 -3
- package/dist/cli.js +406 -24
- package/dist/hooks/elicitation-result.js +308 -15
- package/dist/hooks/post-tool-use.js +932 -26
- package/dist/hooks/pre-compact.js +1660 -59
- package/dist/hooks/sentinel.js +308 -15
- package/dist/hooks/session-start.js +1594 -110
- package/dist/hooks/stop.js +899 -33
- package/dist/hooks/user-prompt-submit.js +1651 -15
- package/dist/server.js +1379 -63
- package/package.json +1 -1
|
@@ -277,7 +277,7 @@ var MIGRATIONS = [
|
|
|
277
277
|
-- Sync outbox (offline-first queue)
|
|
278
278
|
CREATE TABLE sync_outbox (
|
|
279
279
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
280
|
-
record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
|
|
280
|
+
record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
|
|
281
281
|
record_id INTEGER NOT NULL,
|
|
282
282
|
status TEXT DEFAULT 'pending' CHECK (status IN (
|
|
283
283
|
'pending', 'syncing', 'synced', 'failed'
|
|
@@ -570,6 +570,18 @@ var MIGRATIONS = [
|
|
|
570
570
|
ON tool_events(created_at_epoch DESC, id DESC);
|
|
571
571
|
`
|
|
572
572
|
},
|
|
573
|
+
{
|
|
574
|
+
version: 11,
|
|
575
|
+
description: "Add observation provenance from tool and prompt chronology",
|
|
576
|
+
sql: `
|
|
577
|
+
ALTER TABLE observations ADD COLUMN source_tool TEXT;
|
|
578
|
+
ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
|
|
579
|
+
CREATE INDEX IF NOT EXISTS idx_observations_source_tool
|
|
580
|
+
ON observations(source_tool, created_at_epoch DESC);
|
|
581
|
+
CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
|
|
582
|
+
ON observations(session_id, source_prompt_number DESC);
|
|
583
|
+
`
|
|
584
|
+
},
|
|
573
585
|
{
|
|
574
586
|
version: 12,
|
|
575
587
|
description: "Add synced handoff metadata to session summaries",
|
|
@@ -581,15 +593,92 @@ var MIGRATIONS = [
|
|
|
581
593
|
`
|
|
582
594
|
},
|
|
583
595
|
{
|
|
584
|
-
version:
|
|
585
|
-
description: "Add
|
|
596
|
+
version: 13,
|
|
597
|
+
description: "Add current_thread to session summaries",
|
|
586
598
|
sql: `
|
|
587
|
-
ALTER TABLE
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
599
|
+
ALTER TABLE session_summaries ADD COLUMN current_thread TEXT;
|
|
600
|
+
`
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
version: 14,
|
|
604
|
+
description: "Add chat_messages lane for raw conversation recall",
|
|
605
|
+
sql: `
|
|
606
|
+
CREATE TABLE IF NOT EXISTS chat_messages (
|
|
607
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
608
|
+
session_id TEXT NOT NULL,
|
|
609
|
+
project_id INTEGER REFERENCES projects(id),
|
|
610
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
|
611
|
+
content TEXT NOT NULL,
|
|
612
|
+
user_id TEXT NOT NULL,
|
|
613
|
+
device_id TEXT NOT NULL,
|
|
614
|
+
agent TEXT DEFAULT 'claude-code',
|
|
615
|
+
created_at_epoch INTEGER NOT NULL
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
CREATE INDEX IF NOT EXISTS idx_chat_messages_session
|
|
619
|
+
ON chat_messages(session_id, created_at_epoch DESC, id DESC);
|
|
620
|
+
CREATE INDEX IF NOT EXISTS idx_chat_messages_project
|
|
621
|
+
ON chat_messages(project_id, created_at_epoch DESC, id DESC);
|
|
622
|
+
CREATE INDEX IF NOT EXISTS idx_chat_messages_created
|
|
623
|
+
ON chat_messages(created_at_epoch DESC, id DESC);
|
|
624
|
+
`
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
version: 15,
|
|
628
|
+
description: "Add remote_source_id for chat message sync deduplication",
|
|
629
|
+
sql: `
|
|
630
|
+
ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT;
|
|
631
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source
|
|
632
|
+
ON chat_messages(remote_source_id)
|
|
633
|
+
WHERE remote_source_id IS NOT NULL;
|
|
634
|
+
`
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
version: 16,
|
|
638
|
+
description: "Allow chat_message records in sync_outbox",
|
|
639
|
+
sql: `
|
|
640
|
+
CREATE TABLE sync_outbox_new (
|
|
641
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
642
|
+
record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
|
|
643
|
+
record_id INTEGER NOT NULL,
|
|
644
|
+
status TEXT DEFAULT 'pending' CHECK (status IN (
|
|
645
|
+
'pending', 'syncing', 'synced', 'failed'
|
|
646
|
+
)),
|
|
647
|
+
retry_count INTEGER DEFAULT 0,
|
|
648
|
+
max_retries INTEGER DEFAULT 10,
|
|
649
|
+
last_error TEXT,
|
|
650
|
+
created_at_epoch INTEGER NOT NULL,
|
|
651
|
+
synced_at_epoch INTEGER,
|
|
652
|
+
next_retry_epoch INTEGER
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
INSERT INTO sync_outbox_new (
|
|
656
|
+
id, record_type, record_id, status, retry_count, max_retries,
|
|
657
|
+
last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
|
|
658
|
+
)
|
|
659
|
+
SELECT
|
|
660
|
+
id, record_type, record_id, status, retry_count, max_retries,
|
|
661
|
+
last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
|
|
662
|
+
FROM sync_outbox;
|
|
663
|
+
|
|
664
|
+
DROP TABLE sync_outbox;
|
|
665
|
+
ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
|
|
666
|
+
|
|
667
|
+
CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
|
|
668
|
+
CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
|
|
669
|
+
`
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
version: 17,
|
|
673
|
+
description: "Track transcript-backed chat messages separately from hook chat",
|
|
674
|
+
sql: `
|
|
675
|
+
ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook';
|
|
676
|
+
ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER;
|
|
677
|
+
CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind
|
|
678
|
+
ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC);
|
|
679
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript
|
|
680
|
+
ON chat_messages(session_id, transcript_index)
|
|
681
|
+
WHERE transcript_index IS NOT NULL;
|
|
593
682
|
`
|
|
594
683
|
}
|
|
595
684
|
];
|
|
@@ -649,6 +738,21 @@ function inferLegacySchemaVersion(db) {
|
|
|
649
738
|
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")) {
|
|
650
739
|
version = Math.max(version, 12);
|
|
651
740
|
}
|
|
741
|
+
if (columnExists(db, "session_summaries", "current_thread")) {
|
|
742
|
+
version = Math.max(version, 13);
|
|
743
|
+
}
|
|
744
|
+
if (tableExists(db, "chat_messages")) {
|
|
745
|
+
version = Math.max(version, 14);
|
|
746
|
+
}
|
|
747
|
+
if (columnExists(db, "chat_messages", "remote_source_id")) {
|
|
748
|
+
version = Math.max(version, 15);
|
|
749
|
+
}
|
|
750
|
+
if (syncOutboxSupportsChatMessages(db)) {
|
|
751
|
+
version = Math.max(version, 16);
|
|
752
|
+
}
|
|
753
|
+
if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
|
|
754
|
+
version = Math.max(version, 17);
|
|
755
|
+
}
|
|
652
756
|
return version;
|
|
653
757
|
}
|
|
654
758
|
function runMigrations(db) {
|
|
@@ -732,7 +836,8 @@ function ensureSessionSummaryColumns(db) {
|
|
|
732
836
|
"capture_state",
|
|
733
837
|
"recent_tool_names",
|
|
734
838
|
"hot_files",
|
|
735
|
-
"recent_outcomes"
|
|
839
|
+
"recent_outcomes",
|
|
840
|
+
"current_thread"
|
|
736
841
|
];
|
|
737
842
|
for (const column of required) {
|
|
738
843
|
if (columnExists(db, "session_summaries", column))
|
|
@@ -740,10 +845,83 @@ function ensureSessionSummaryColumns(db) {
|
|
|
740
845
|
db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
|
|
741
846
|
}
|
|
742
847
|
const current = getSchemaVersion(db);
|
|
743
|
-
if (current <
|
|
744
|
-
db.exec("PRAGMA user_version =
|
|
848
|
+
if (current < 13) {
|
|
849
|
+
db.exec("PRAGMA user_version = 13");
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
function ensureChatMessageColumns(db) {
|
|
853
|
+
if (!tableExists(db, "chat_messages"))
|
|
854
|
+
return;
|
|
855
|
+
if (!columnExists(db, "chat_messages", "remote_source_id")) {
|
|
856
|
+
db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
|
|
857
|
+
}
|
|
858
|
+
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");
|
|
859
|
+
if (!columnExists(db, "chat_messages", "source_kind")) {
|
|
860
|
+
db.exec("ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook'");
|
|
861
|
+
}
|
|
862
|
+
if (!columnExists(db, "chat_messages", "transcript_index")) {
|
|
863
|
+
db.exec("ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER");
|
|
864
|
+
}
|
|
865
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC)");
|
|
866
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript ON chat_messages(session_id, transcript_index) WHERE transcript_index IS NOT NULL");
|
|
867
|
+
const current = getSchemaVersion(db);
|
|
868
|
+
if (current < 17) {
|
|
869
|
+
db.exec("PRAGMA user_version = 17");
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
function ensureSyncOutboxSupportsChatMessages(db) {
|
|
873
|
+
if (syncOutboxSupportsChatMessages(db)) {
|
|
874
|
+
const current = getSchemaVersion(db);
|
|
875
|
+
if (current < 16) {
|
|
876
|
+
db.exec("PRAGMA user_version = 16");
|
|
877
|
+
}
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
db.exec("BEGIN TRANSACTION");
|
|
881
|
+
try {
|
|
882
|
+
db.exec(`
|
|
883
|
+
CREATE TABLE sync_outbox_new (
|
|
884
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
885
|
+
record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
|
|
886
|
+
record_id INTEGER NOT NULL,
|
|
887
|
+
status TEXT DEFAULT 'pending' CHECK (status IN (
|
|
888
|
+
'pending', 'syncing', 'synced', 'failed'
|
|
889
|
+
)),
|
|
890
|
+
retry_count INTEGER DEFAULT 0,
|
|
891
|
+
max_retries INTEGER DEFAULT 10,
|
|
892
|
+
last_error TEXT,
|
|
893
|
+
created_at_epoch INTEGER NOT NULL,
|
|
894
|
+
synced_at_epoch INTEGER,
|
|
895
|
+
next_retry_epoch INTEGER
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
INSERT INTO sync_outbox_new (
|
|
899
|
+
id, record_type, record_id, status, retry_count, max_retries,
|
|
900
|
+
last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
|
|
901
|
+
)
|
|
902
|
+
SELECT
|
|
903
|
+
id, record_type, record_id, status, retry_count, max_retries,
|
|
904
|
+
last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
|
|
905
|
+
FROM sync_outbox;
|
|
906
|
+
|
|
907
|
+
DROP TABLE sync_outbox;
|
|
908
|
+
ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
|
|
909
|
+
|
|
910
|
+
CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
|
|
911
|
+
CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
|
|
912
|
+
`);
|
|
913
|
+
db.exec("PRAGMA user_version = 16");
|
|
914
|
+
db.exec("COMMIT");
|
|
915
|
+
} catch (error) {
|
|
916
|
+
db.exec("ROLLBACK");
|
|
917
|
+
throw new Error(`sync_outbox repair failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
745
918
|
}
|
|
746
919
|
}
|
|
920
|
+
function syncOutboxSupportsChatMessages(db) {
|
|
921
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("sync_outbox");
|
|
922
|
+
const sql = row?.sql ?? "";
|
|
923
|
+
return sql.includes("'chat_message'");
|
|
924
|
+
}
|
|
747
925
|
function getSchemaVersion(db) {
|
|
748
926
|
const result = db.query("PRAGMA user_version").get();
|
|
749
927
|
return result.user_version;
|
|
@@ -903,6 +1081,8 @@ class MemDatabase {
|
|
|
903
1081
|
runMigrations(this.db);
|
|
904
1082
|
ensureObservationTypes(this.db);
|
|
905
1083
|
ensureSessionSummaryColumns(this.db);
|
|
1084
|
+
ensureChatMessageColumns(this.db);
|
|
1085
|
+
ensureSyncOutboxSupportsChatMessages(this.db);
|
|
906
1086
|
}
|
|
907
1087
|
loadVecExtension() {
|
|
908
1088
|
try {
|
|
@@ -971,6 +1151,22 @@ class MemDatabase {
|
|
|
971
1151
|
getObservationById(id) {
|
|
972
1152
|
return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
|
|
973
1153
|
}
|
|
1154
|
+
updateObservationContent(id, update) {
|
|
1155
|
+
const existing = this.getObservationById(id);
|
|
1156
|
+
if (!existing)
|
|
1157
|
+
return null;
|
|
1158
|
+
const createdAtEpoch = update.created_at_epoch ?? existing.created_at_epoch;
|
|
1159
|
+
const createdAt = new Date(createdAtEpoch * 1000).toISOString();
|
|
1160
|
+
this.db.query(`UPDATE observations
|
|
1161
|
+
SET title = ?, narrative = ?, facts = ?, concepts = ?, created_at = ?, created_at_epoch = ?
|
|
1162
|
+
WHERE id = ?`).run(update.title, update.narrative ?? null, update.facts ?? null, update.concepts ?? null, createdAt, createdAtEpoch, id);
|
|
1163
|
+
this.ftsDelete(existing);
|
|
1164
|
+
const refreshed = this.getObservationById(id);
|
|
1165
|
+
if (!refreshed)
|
|
1166
|
+
return null;
|
|
1167
|
+
this.ftsInsert(refreshed);
|
|
1168
|
+
return refreshed;
|
|
1169
|
+
}
|
|
974
1170
|
getObservationsByIds(ids, userId) {
|
|
975
1171
|
if (ids.length === 0)
|
|
976
1172
|
return [];
|
|
@@ -1128,6 +1324,7 @@ class MemDatabase {
|
|
|
1128
1324
|
p.name AS project_name,
|
|
1129
1325
|
ss.request AS request,
|
|
1130
1326
|
ss.completed AS completed,
|
|
1327
|
+
ss.current_thread AS current_thread,
|
|
1131
1328
|
ss.capture_state AS capture_state,
|
|
1132
1329
|
ss.recent_tool_names AS recent_tool_names,
|
|
1133
1330
|
ss.hot_files AS hot_files,
|
|
@@ -1146,6 +1343,7 @@ class MemDatabase {
|
|
|
1146
1343
|
p.name AS project_name,
|
|
1147
1344
|
ss.request AS request,
|
|
1148
1345
|
ss.completed AS completed,
|
|
1346
|
+
ss.current_thread AS current_thread,
|
|
1149
1347
|
ss.capture_state AS capture_state,
|
|
1150
1348
|
ss.recent_tool_names AS recent_tool_names,
|
|
1151
1349
|
ss.hot_files AS hot_files,
|
|
@@ -1236,6 +1434,99 @@ class MemDatabase {
|
|
|
1236
1434
|
ORDER BY created_at_epoch DESC, id DESC
|
|
1237
1435
|
LIMIT ?`).all(...userId ? [userId] : [], limit);
|
|
1238
1436
|
}
|
|
1437
|
+
insertChatMessage(input) {
|
|
1438
|
+
const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
|
|
1439
|
+
const content = input.content.trim();
|
|
1440
|
+
const result = this.db.query(`INSERT INTO chat_messages (
|
|
1441
|
+
session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id, source_kind, transcript_index
|
|
1442
|
+
) 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, input.source_kind ?? "hook", input.transcript_index ?? null);
|
|
1443
|
+
return this.getChatMessageById(Number(result.lastInsertRowid));
|
|
1444
|
+
}
|
|
1445
|
+
getChatMessageById(id) {
|
|
1446
|
+
return this.db.query("SELECT * FROM chat_messages WHERE id = ?").get(id) ?? null;
|
|
1447
|
+
}
|
|
1448
|
+
getChatMessageByRemoteSourceId(remoteSourceId) {
|
|
1449
|
+
return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
|
|
1450
|
+
}
|
|
1451
|
+
getSessionChatMessages(sessionId, limit = 50) {
|
|
1452
|
+
return this.db.query(`SELECT * FROM chat_messages
|
|
1453
|
+
WHERE session_id = ?
|
|
1454
|
+
AND (
|
|
1455
|
+
source_kind = 'transcript'
|
|
1456
|
+
OR NOT EXISTS (
|
|
1457
|
+
SELECT 1 FROM chat_messages t2
|
|
1458
|
+
WHERE t2.session_id = chat_messages.session_id
|
|
1459
|
+
AND t2.source_kind = 'transcript'
|
|
1460
|
+
)
|
|
1461
|
+
)
|
|
1462
|
+
ORDER BY
|
|
1463
|
+
CASE WHEN transcript_index IS NULL THEN created_at_epoch ELSE transcript_index END ASC,
|
|
1464
|
+
id ASC
|
|
1465
|
+
LIMIT ?`).all(sessionId, limit);
|
|
1466
|
+
}
|
|
1467
|
+
getRecentChatMessages(projectId, limit = 20, userId) {
|
|
1468
|
+
const visibilityClause = userId ? " AND user_id = ?" : "";
|
|
1469
|
+
if (projectId !== null) {
|
|
1470
|
+
return this.db.query(`SELECT * FROM chat_messages
|
|
1471
|
+
WHERE project_id = ?${visibilityClause}
|
|
1472
|
+
AND (
|
|
1473
|
+
source_kind = 'transcript'
|
|
1474
|
+
OR NOT EXISTS (
|
|
1475
|
+
SELECT 1 FROM chat_messages t2
|
|
1476
|
+
WHERE t2.session_id = chat_messages.session_id
|
|
1477
|
+
AND t2.source_kind = 'transcript'
|
|
1478
|
+
)
|
|
1479
|
+
)
|
|
1480
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1481
|
+
LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
|
|
1482
|
+
}
|
|
1483
|
+
return this.db.query(`SELECT * FROM chat_messages
|
|
1484
|
+
WHERE 1 = 1${visibilityClause}
|
|
1485
|
+
AND (
|
|
1486
|
+
source_kind = 'transcript'
|
|
1487
|
+
OR NOT EXISTS (
|
|
1488
|
+
SELECT 1 FROM chat_messages t2
|
|
1489
|
+
WHERE t2.session_id = chat_messages.session_id
|
|
1490
|
+
AND t2.source_kind = 'transcript'
|
|
1491
|
+
)
|
|
1492
|
+
)
|
|
1493
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1494
|
+
LIMIT ?`).all(...userId ? [userId] : [], limit);
|
|
1495
|
+
}
|
|
1496
|
+
searchChatMessages(query, projectId, limit = 20, userId) {
|
|
1497
|
+
const needle = `%${query.toLowerCase()}%`;
|
|
1498
|
+
const visibilityClause = userId ? " AND user_id = ?" : "";
|
|
1499
|
+
if (projectId !== null) {
|
|
1500
|
+
return this.db.query(`SELECT * FROM chat_messages
|
|
1501
|
+
WHERE project_id = ?
|
|
1502
|
+
AND lower(content) LIKE ?${visibilityClause}
|
|
1503
|
+
AND (
|
|
1504
|
+
source_kind = 'transcript'
|
|
1505
|
+
OR NOT EXISTS (
|
|
1506
|
+
SELECT 1 FROM chat_messages t2
|
|
1507
|
+
WHERE t2.session_id = chat_messages.session_id
|
|
1508
|
+
AND t2.source_kind = 'transcript'
|
|
1509
|
+
)
|
|
1510
|
+
)
|
|
1511
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1512
|
+
LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
|
|
1513
|
+
}
|
|
1514
|
+
return this.db.query(`SELECT * FROM chat_messages
|
|
1515
|
+
WHERE lower(content) LIKE ?${visibilityClause}
|
|
1516
|
+
AND (
|
|
1517
|
+
source_kind = 'transcript'
|
|
1518
|
+
OR NOT EXISTS (
|
|
1519
|
+
SELECT 1 FROM chat_messages t2
|
|
1520
|
+
WHERE t2.session_id = chat_messages.session_id
|
|
1521
|
+
AND t2.source_kind = 'transcript'
|
|
1522
|
+
)
|
|
1523
|
+
)
|
|
1524
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1525
|
+
LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
|
|
1526
|
+
}
|
|
1527
|
+
getTranscriptChatMessage(sessionId, transcriptIndex) {
|
|
1528
|
+
return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
|
|
1529
|
+
}
|
|
1239
1530
|
addToOutbox(recordType, recordId) {
|
|
1240
1531
|
const now = Math.floor(Date.now() / 1000);
|
|
1241
1532
|
this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
|
|
@@ -1324,9 +1615,9 @@ class MemDatabase {
|
|
|
1324
1615
|
};
|
|
1325
1616
|
const result = this.db.query(`INSERT INTO session_summaries (
|
|
1326
1617
|
session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
|
|
1327
|
-
capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
|
|
1618
|
+
current_thread, capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
|
|
1328
1619
|
)
|
|
1329
|
-
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);
|
|
1620
|
+
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);
|
|
1330
1621
|
const id = Number(result.lastInsertRowid);
|
|
1331
1622
|
return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
|
|
1332
1623
|
}
|
|
@@ -1342,6 +1633,7 @@ class MemDatabase {
|
|
|
1342
1633
|
learned: normalizeSummarySection(summary.learned ?? existing.learned),
|
|
1343
1634
|
completed: normalizeSummarySection(summary.completed ?? existing.completed),
|
|
1344
1635
|
next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
|
|
1636
|
+
current_thread: summary.current_thread ?? existing.current_thread,
|
|
1345
1637
|
capture_state: summary.capture_state ?? existing.capture_state,
|
|
1346
1638
|
recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
|
|
1347
1639
|
hot_files: summary.hot_files ?? existing.hot_files,
|
|
@@ -1355,12 +1647,13 @@ class MemDatabase {
|
|
|
1355
1647
|
learned = ?,
|
|
1356
1648
|
completed = ?,
|
|
1357
1649
|
next_steps = ?,
|
|
1650
|
+
current_thread = ?,
|
|
1358
1651
|
capture_state = ?,
|
|
1359
1652
|
recent_tool_names = ?,
|
|
1360
1653
|
hot_files = ?,
|
|
1361
1654
|
recent_outcomes = ?,
|
|
1362
1655
|
created_at_epoch = ?
|
|
1363
|
-
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);
|
|
1656
|
+
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);
|
|
1364
1657
|
return this.getSessionSummary(summary.session_id);
|
|
1365
1658
|
}
|
|
1366
1659
|
getSessionSummary(sessionId) {
|
|
@@ -1840,11 +2133,103 @@ function computeObservationPriority(obs, nowEpoch) {
|
|
|
1840
2133
|
return computeBlendedScore(obs.quality, obs.created_at_epoch, nowEpoch) + observationTypeBoost(obs.type);
|
|
1841
2134
|
}
|
|
1842
2135
|
|
|
1843
|
-
// src/
|
|
1844
|
-
function
|
|
1845
|
-
|
|
2136
|
+
// src/tools/session-story.ts
|
|
2137
|
+
function getSessionStory(db, input) {
|
|
2138
|
+
const session = db.getSessionById(input.session_id);
|
|
2139
|
+
const summary = db.getSessionSummary(input.session_id);
|
|
2140
|
+
const prompts = db.getSessionUserPrompts(input.session_id, 50);
|
|
2141
|
+
const chatMessages = db.getSessionChatMessages(input.session_id, 50);
|
|
2142
|
+
const toolEvents = db.getSessionToolEvents(input.session_id, 100);
|
|
2143
|
+
const allObservations = db.getObservationsBySession(input.session_id);
|
|
2144
|
+
const handoffs = allObservations.filter((obs) => looksLikeHandoff(obs));
|
|
2145
|
+
const rollingHandoffDrafts = handoffs.filter((obs) => isDraftHandoff(obs));
|
|
2146
|
+
const savedHandoffs = handoffs.filter((obs) => !isDraftHandoff(obs));
|
|
2147
|
+
const observations = allObservations.filter((obs) => !looksLikeHandoff(obs));
|
|
2148
|
+
const metrics = db.getSessionMetrics(input.session_id);
|
|
2149
|
+
const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
|
|
2150
|
+
const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
|
|
2151
|
+
return {
|
|
2152
|
+
session,
|
|
2153
|
+
project_name: projectName,
|
|
2154
|
+
summary,
|
|
2155
|
+
prompts,
|
|
2156
|
+
chat_messages: chatMessages,
|
|
2157
|
+
tool_events: toolEvents,
|
|
2158
|
+
observations,
|
|
2159
|
+
handoffs,
|
|
2160
|
+
saved_handoffs: savedHandoffs,
|
|
2161
|
+
rolling_handoff_drafts: rollingHandoffDrafts,
|
|
2162
|
+
metrics,
|
|
2163
|
+
capture_state: classifyCaptureState({
|
|
2164
|
+
hasSummary: Boolean(summary?.request || summary?.completed),
|
|
2165
|
+
promptCount: prompts.length,
|
|
2166
|
+
toolEventCount: toolEvents.length
|
|
2167
|
+
}),
|
|
2168
|
+
capture_gaps: buildCaptureGaps({
|
|
2169
|
+
promptCount: prompts.length,
|
|
2170
|
+
toolEventCount: toolEvents.length,
|
|
2171
|
+
toolCallsCount: metrics?.tool_calls_count ?? 0,
|
|
2172
|
+
observationCount: observations.length,
|
|
2173
|
+
hasSummary: Boolean(summary?.request || summary?.completed)
|
|
2174
|
+
}),
|
|
2175
|
+
latest_request: latestRequest,
|
|
2176
|
+
recent_outcomes: collectRecentOutcomes(observations),
|
|
2177
|
+
hot_files: collectHotFiles(observations),
|
|
2178
|
+
provenance_summary: collectProvenanceSummary(observations)
|
|
2179
|
+
};
|
|
1846
2180
|
}
|
|
1847
|
-
function
|
|
2181
|
+
function classifyCaptureState(input) {
|
|
2182
|
+
if (input.promptCount > 0 && input.toolEventCount > 0)
|
|
2183
|
+
return "rich";
|
|
2184
|
+
if (input.promptCount > 0 || input.toolEventCount > 0)
|
|
2185
|
+
return "partial";
|
|
2186
|
+
if (input.hasSummary)
|
|
2187
|
+
return "summary-only";
|
|
2188
|
+
return "legacy";
|
|
2189
|
+
}
|
|
2190
|
+
function buildCaptureGaps(input) {
|
|
2191
|
+
const gaps = [];
|
|
2192
|
+
if (input.promptCount === 0)
|
|
2193
|
+
gaps.push("missing prompts");
|
|
2194
|
+
if (input.toolCallsCount > 0 && input.toolEventCount === 0) {
|
|
2195
|
+
gaps.push("missing raw tool chronology");
|
|
2196
|
+
} else if (input.toolEventCount === 0) {
|
|
2197
|
+
gaps.push("no tool events");
|
|
2198
|
+
}
|
|
2199
|
+
if (input.observationCount === 0 && input.hasSummary) {
|
|
2200
|
+
gaps.push("summary without reusable observations");
|
|
2201
|
+
}
|
|
2202
|
+
return gaps;
|
|
2203
|
+
}
|
|
2204
|
+
function collectRecentOutcomes(observations) {
|
|
2205
|
+
const seen = new Set;
|
|
2206
|
+
const outcomes = [];
|
|
2207
|
+
for (const obs of observations) {
|
|
2208
|
+
if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
|
|
2209
|
+
continue;
|
|
2210
|
+
const title = obs.title.trim();
|
|
2211
|
+
if (!title || looksLikeFileOperationTitle(title))
|
|
2212
|
+
continue;
|
|
2213
|
+
const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2214
|
+
if (seen.has(normalized))
|
|
2215
|
+
continue;
|
|
2216
|
+
seen.add(normalized);
|
|
2217
|
+
outcomes.push(title);
|
|
2218
|
+
if (outcomes.length >= 6)
|
|
2219
|
+
break;
|
|
2220
|
+
}
|
|
2221
|
+
return outcomes;
|
|
2222
|
+
}
|
|
2223
|
+
function collectHotFiles(observations) {
|
|
2224
|
+
const counts = new Map;
|
|
2225
|
+
for (const obs of observations) {
|
|
2226
|
+
for (const path of [...parseJsonArray(obs.files_modified), ...parseJsonArray(obs.files_read)]) {
|
|
2227
|
+
counts.set(path, (counts.get(path) ?? 0) + 1);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
return Array.from(counts.entries()).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)).slice(0, 8);
|
|
2231
|
+
}
|
|
2232
|
+
function parseJsonArray(value) {
|
|
1848
2233
|
if (!value)
|
|
1849
2234
|
return [];
|
|
1850
2235
|
try {
|
|
@@ -1854,44 +2239,1043 @@ function parseSummaryJsonList(value) {
|
|
|
1854
2239
|
return [];
|
|
1855
2240
|
}
|
|
1856
2241
|
}
|
|
1857
|
-
function
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
obs.
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
2242
|
+
function looksLikeFileOperationTitle(value) {
|
|
2243
|
+
return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
|
|
2244
|
+
}
|
|
2245
|
+
function collectProvenanceSummary(observations) {
|
|
2246
|
+
const counts = new Map;
|
|
2247
|
+
for (const obs of observations) {
|
|
2248
|
+
if (!obs.source_tool)
|
|
2249
|
+
continue;
|
|
2250
|
+
counts.set(obs.source_tool, (counts.get(obs.source_tool) ?? 0) + 1);
|
|
2251
|
+
}
|
|
2252
|
+
return Array.from(counts.entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// src/tools/save.ts
|
|
2256
|
+
import { relative, isAbsolute } from "node:path";
|
|
2257
|
+
|
|
2258
|
+
// src/capture/scrubber.ts
|
|
2259
|
+
var DEFAULT_PATTERNS = [
|
|
2260
|
+
{
|
|
2261
|
+
source: "sk-[a-zA-Z0-9]{20,}",
|
|
2262
|
+
flags: "g",
|
|
2263
|
+
replacement: "[REDACTED_API_KEY]",
|
|
2264
|
+
description: "OpenAI API keys",
|
|
2265
|
+
category: "api_key",
|
|
2266
|
+
severity: "critical"
|
|
2267
|
+
},
|
|
2268
|
+
{
|
|
2269
|
+
source: "Bearer [a-zA-Z0-9\\-._~+/]+=*",
|
|
2270
|
+
flags: "g",
|
|
2271
|
+
replacement: "[REDACTED_BEARER]",
|
|
2272
|
+
description: "Bearer auth tokens",
|
|
2273
|
+
category: "token",
|
|
2274
|
+
severity: "medium"
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
source: "password[=:]\\s*\\S+",
|
|
2278
|
+
flags: "gi",
|
|
2279
|
+
replacement: "password=[REDACTED]",
|
|
2280
|
+
description: "Passwords in config",
|
|
2281
|
+
category: "password",
|
|
2282
|
+
severity: "high"
|
|
2283
|
+
},
|
|
2284
|
+
{
|
|
2285
|
+
source: "postgresql://[^\\s]+",
|
|
2286
|
+
flags: "g",
|
|
2287
|
+
replacement: "[REDACTED_DB_URL]",
|
|
2288
|
+
description: "PostgreSQL connection strings",
|
|
2289
|
+
category: "db_url",
|
|
2290
|
+
severity: "high"
|
|
2291
|
+
},
|
|
2292
|
+
{
|
|
2293
|
+
source: "mongodb://[^\\s]+",
|
|
2294
|
+
flags: "g",
|
|
2295
|
+
replacement: "[REDACTED_DB_URL]",
|
|
2296
|
+
description: "MongoDB connection strings",
|
|
2297
|
+
category: "db_url",
|
|
2298
|
+
severity: "high"
|
|
2299
|
+
},
|
|
2300
|
+
{
|
|
2301
|
+
source: "mysql://[^\\s]+",
|
|
2302
|
+
flags: "g",
|
|
2303
|
+
replacement: "[REDACTED_DB_URL]",
|
|
2304
|
+
description: "MySQL connection strings",
|
|
2305
|
+
category: "db_url",
|
|
2306
|
+
severity: "high"
|
|
2307
|
+
},
|
|
2308
|
+
{
|
|
2309
|
+
source: "AKIA[A-Z0-9]{16}",
|
|
2310
|
+
flags: "g",
|
|
2311
|
+
replacement: "[REDACTED_AWS_KEY]",
|
|
2312
|
+
description: "AWS access keys",
|
|
2313
|
+
category: "api_key",
|
|
2314
|
+
severity: "critical"
|
|
2315
|
+
},
|
|
2316
|
+
{
|
|
2317
|
+
source: "ghp_[a-zA-Z0-9]{36}",
|
|
2318
|
+
flags: "g",
|
|
2319
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
2320
|
+
description: "GitHub personal access tokens",
|
|
2321
|
+
category: "token",
|
|
2322
|
+
severity: "high"
|
|
2323
|
+
},
|
|
2324
|
+
{
|
|
2325
|
+
source: "gho_[a-zA-Z0-9]{36}",
|
|
2326
|
+
flags: "g",
|
|
2327
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
2328
|
+
description: "GitHub OAuth tokens",
|
|
2329
|
+
category: "token",
|
|
2330
|
+
severity: "high"
|
|
2331
|
+
},
|
|
2332
|
+
{
|
|
2333
|
+
source: "github_pat_[a-zA-Z0-9_]{22,}",
|
|
2334
|
+
flags: "g",
|
|
2335
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
2336
|
+
description: "GitHub fine-grained PATs",
|
|
2337
|
+
category: "token",
|
|
2338
|
+
severity: "high"
|
|
2339
|
+
},
|
|
2340
|
+
{
|
|
2341
|
+
source: "cvk_[a-f0-9]{64}",
|
|
2342
|
+
flags: "g",
|
|
2343
|
+
replacement: "[REDACTED_CANDENGO_KEY]",
|
|
2344
|
+
description: "Candengo API keys",
|
|
2345
|
+
category: "api_key",
|
|
2346
|
+
severity: "critical"
|
|
2347
|
+
},
|
|
2348
|
+
{
|
|
2349
|
+
source: "xox[bpras]-[a-zA-Z0-9\\-]+",
|
|
2350
|
+
flags: "g",
|
|
2351
|
+
replacement: "[REDACTED_SLACK_TOKEN]",
|
|
2352
|
+
description: "Slack tokens",
|
|
2353
|
+
category: "token",
|
|
2354
|
+
severity: "high"
|
|
2355
|
+
}
|
|
2356
|
+
];
|
|
2357
|
+
function compileCustomPatterns(patterns) {
|
|
2358
|
+
const compiled = [];
|
|
2359
|
+
for (const pattern of patterns) {
|
|
2360
|
+
try {
|
|
2361
|
+
new RegExp(pattern);
|
|
2362
|
+
compiled.push({
|
|
2363
|
+
source: pattern,
|
|
2364
|
+
flags: "g",
|
|
2365
|
+
replacement: "[REDACTED_CUSTOM]",
|
|
2366
|
+
description: `Custom pattern: ${pattern}`,
|
|
2367
|
+
category: "custom",
|
|
2368
|
+
severity: "medium"
|
|
2369
|
+
});
|
|
2370
|
+
} catch {}
|
|
2371
|
+
}
|
|
2372
|
+
return compiled;
|
|
2373
|
+
}
|
|
2374
|
+
function scrubSecrets(text, customPatterns = []) {
|
|
2375
|
+
let result = text;
|
|
2376
|
+
const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
|
|
2377
|
+
for (const pattern of allPatterns) {
|
|
2378
|
+
result = result.replace(new RegExp(pattern.source, pattern.flags), pattern.replacement);
|
|
2379
|
+
}
|
|
2380
|
+
return result;
|
|
2381
|
+
}
|
|
2382
|
+
function containsSecrets(text, customPatterns = []) {
|
|
2383
|
+
const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
|
|
2384
|
+
for (const pattern of allPatterns) {
|
|
2385
|
+
if (new RegExp(pattern.source, pattern.flags).test(text))
|
|
1876
2386
|
return true;
|
|
1877
2387
|
}
|
|
1878
2388
|
return false;
|
|
1879
2389
|
}
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
2390
|
+
|
|
2391
|
+
// src/capture/quality.ts
|
|
2392
|
+
var QUALITY_THRESHOLD = 0.1;
|
|
2393
|
+
function scoreQuality(input) {
|
|
2394
|
+
let score = 0;
|
|
2395
|
+
switch (input.type) {
|
|
2396
|
+
case "bugfix":
|
|
2397
|
+
score += 0.3;
|
|
2398
|
+
break;
|
|
2399
|
+
case "decision":
|
|
2400
|
+
score += 0.3;
|
|
2401
|
+
break;
|
|
2402
|
+
case "discovery":
|
|
2403
|
+
score += 0.2;
|
|
2404
|
+
break;
|
|
2405
|
+
case "pattern":
|
|
2406
|
+
score += 0.2;
|
|
2407
|
+
break;
|
|
2408
|
+
case "feature":
|
|
2409
|
+
score += 0.15;
|
|
2410
|
+
break;
|
|
2411
|
+
case "refactor":
|
|
2412
|
+
score += 0.15;
|
|
2413
|
+
break;
|
|
2414
|
+
case "change":
|
|
2415
|
+
score += 0.05;
|
|
2416
|
+
break;
|
|
2417
|
+
case "digest":
|
|
2418
|
+
score += 0.3;
|
|
2419
|
+
break;
|
|
2420
|
+
case "standard":
|
|
2421
|
+
score += 0.25;
|
|
2422
|
+
break;
|
|
2423
|
+
case "message":
|
|
2424
|
+
score += 0.1;
|
|
2425
|
+
break;
|
|
2426
|
+
}
|
|
2427
|
+
if (input.narrative && input.narrative.length > 50) {
|
|
2428
|
+
score += 0.15;
|
|
2429
|
+
}
|
|
2430
|
+
if (input.facts) {
|
|
2431
|
+
try {
|
|
2432
|
+
const factsArray = JSON.parse(input.facts);
|
|
2433
|
+
if (factsArray.length >= 2)
|
|
2434
|
+
score += 0.15;
|
|
2435
|
+
else if (factsArray.length === 1)
|
|
2436
|
+
score += 0.05;
|
|
2437
|
+
} catch {
|
|
2438
|
+
if (input.facts.length > 20)
|
|
2439
|
+
score += 0.05;
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
if (input.concepts) {
|
|
2443
|
+
try {
|
|
2444
|
+
const conceptsArray = JSON.parse(input.concepts);
|
|
2445
|
+
if (conceptsArray.length >= 1)
|
|
2446
|
+
score += 0.1;
|
|
2447
|
+
} catch {
|
|
2448
|
+
if (input.concepts.length > 10)
|
|
2449
|
+
score += 0.05;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
const modifiedCount = input.filesModified?.length ?? 0;
|
|
2453
|
+
if (modifiedCount >= 3)
|
|
2454
|
+
score += 0.2;
|
|
2455
|
+
else if (modifiedCount >= 1)
|
|
2456
|
+
score += 0.1;
|
|
2457
|
+
if (input.isDuplicate) {
|
|
2458
|
+
score -= 0.3;
|
|
2459
|
+
}
|
|
2460
|
+
return Math.max(0, Math.min(1, score));
|
|
1884
2461
|
}
|
|
1885
|
-
function
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2462
|
+
function meetsQualityThreshold(input) {
|
|
2463
|
+
return scoreQuality(input) >= QUALITY_THRESHOLD;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// src/capture/facts.ts
|
|
2467
|
+
var FACT_ELIGIBLE_TYPES = new Set([
|
|
2468
|
+
"bugfix",
|
|
2469
|
+
"decision",
|
|
2470
|
+
"discovery",
|
|
2471
|
+
"pattern",
|
|
2472
|
+
"feature",
|
|
2473
|
+
"refactor",
|
|
2474
|
+
"change"
|
|
2475
|
+
]);
|
|
2476
|
+
function buildStructuredFacts(input) {
|
|
2477
|
+
const seedFacts = dedupeFacts(input.facts ?? []);
|
|
2478
|
+
if (!FACT_ELIGIBLE_TYPES.has(input.type)) {
|
|
2479
|
+
return seedFacts;
|
|
2480
|
+
}
|
|
2481
|
+
const derived = [...seedFacts];
|
|
2482
|
+
if (seedFacts.length === 0 && looksMeaningful(input.title)) {
|
|
2483
|
+
derived.push(input.title.trim());
|
|
2484
|
+
}
|
|
2485
|
+
for (const sentence of extractNarrativeFacts(input.narrative)) {
|
|
2486
|
+
derived.push(sentence);
|
|
2487
|
+
}
|
|
2488
|
+
const fileFact = buildFilesFact(input.filesModified);
|
|
2489
|
+
if (fileFact) {
|
|
2490
|
+
derived.push(fileFact);
|
|
2491
|
+
}
|
|
2492
|
+
return dedupeFacts(derived).slice(0, 4);
|
|
2493
|
+
}
|
|
2494
|
+
function extractNarrativeFacts(narrative) {
|
|
2495
|
+
if (!narrative)
|
|
2496
|
+
return [];
|
|
2497
|
+
const cleaned = narrative.replace(/\s+/g, " ").trim();
|
|
2498
|
+
if (cleaned.length < 24)
|
|
2499
|
+
return [];
|
|
2500
|
+
const parts = cleaned.split(/(?<=[.!?;])\s+/).map((part) => part.trim().replace(/[.!?;]+$/, "")).filter(Boolean).filter(looksMeaningful);
|
|
2501
|
+
return parts.slice(0, 2);
|
|
2502
|
+
}
|
|
2503
|
+
function buildFilesFact(filesModified) {
|
|
2504
|
+
if (!filesModified || filesModified.length === 0)
|
|
2505
|
+
return null;
|
|
2506
|
+
const cleaned = filesModified.map((file) => file.trim()).filter(Boolean).slice(0, 3);
|
|
2507
|
+
if (cleaned.length === 0)
|
|
2508
|
+
return null;
|
|
2509
|
+
if (cleaned.length === 1) {
|
|
2510
|
+
return `Touched ${cleaned[0]}`;
|
|
2511
|
+
}
|
|
2512
|
+
return `Touched ${cleaned.join(", ")}`;
|
|
2513
|
+
}
|
|
2514
|
+
function dedupeFacts(facts) {
|
|
2515
|
+
const seen = new Set;
|
|
2516
|
+
const result = [];
|
|
2517
|
+
for (const fact of facts) {
|
|
2518
|
+
const cleaned = fact.trim().replace(/\s+/g, " ");
|
|
2519
|
+
if (!looksMeaningful(cleaned))
|
|
2520
|
+
continue;
|
|
2521
|
+
const key = cleaned.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
|
|
2522
|
+
if (!key || seen.has(key))
|
|
2523
|
+
continue;
|
|
2524
|
+
seen.add(key);
|
|
2525
|
+
result.push(cleaned);
|
|
2526
|
+
}
|
|
2527
|
+
return result;
|
|
2528
|
+
}
|
|
2529
|
+
function looksMeaningful(value) {
|
|
2530
|
+
const cleaned = value.trim();
|
|
2531
|
+
if (cleaned.length < 12)
|
|
2532
|
+
return false;
|
|
2533
|
+
if (/^[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+$/.test(cleaned))
|
|
2534
|
+
return false;
|
|
2535
|
+
if (/^(updated|modified|edited|changed|touched)\s+[A-Za-z0-9_.\-\/]+$/i.test(cleaned))
|
|
2536
|
+
return false;
|
|
2537
|
+
return true;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// src/embeddings/embedder.ts
|
|
2541
|
+
var _available = null;
|
|
2542
|
+
var _pipeline = null;
|
|
2543
|
+
var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
|
|
2544
|
+
async function embedText(text) {
|
|
2545
|
+
const pipe = await getPipeline();
|
|
2546
|
+
if (!pipe)
|
|
2547
|
+
return null;
|
|
2548
|
+
try {
|
|
2549
|
+
const output = await pipe(text, { pooling: "mean", normalize: true });
|
|
2550
|
+
return new Float32Array(output.data);
|
|
2551
|
+
} catch {
|
|
2552
|
+
return null;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
function composeEmbeddingText(obs) {
|
|
2556
|
+
const parts = [obs.title];
|
|
2557
|
+
if (obs.narrative)
|
|
2558
|
+
parts.push(obs.narrative);
|
|
2559
|
+
if (obs.facts) {
|
|
2560
|
+
try {
|
|
2561
|
+
const facts = JSON.parse(obs.facts);
|
|
2562
|
+
if (Array.isArray(facts) && facts.length > 0) {
|
|
2563
|
+
parts.push(facts.map((f) => `- ${f}`).join(`
|
|
2564
|
+
`));
|
|
2565
|
+
}
|
|
2566
|
+
} catch {
|
|
2567
|
+
parts.push(obs.facts);
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
if (obs.concepts) {
|
|
2571
|
+
try {
|
|
2572
|
+
const concepts = JSON.parse(obs.concepts);
|
|
2573
|
+
if (Array.isArray(concepts) && concepts.length > 0) {
|
|
2574
|
+
parts.push(concepts.join(", "));
|
|
2575
|
+
}
|
|
2576
|
+
} catch {}
|
|
2577
|
+
}
|
|
2578
|
+
return parts.join(`
|
|
2579
|
+
|
|
2580
|
+
`);
|
|
2581
|
+
}
|
|
2582
|
+
async function getPipeline() {
|
|
2583
|
+
if (_pipeline)
|
|
2584
|
+
return _pipeline;
|
|
2585
|
+
if (_available === false)
|
|
2586
|
+
return null;
|
|
2587
|
+
try {
|
|
2588
|
+
const { pipeline } = await import("@xenova/transformers");
|
|
2589
|
+
_pipeline = await pipeline("feature-extraction", MODEL_NAME);
|
|
2590
|
+
_available = true;
|
|
2591
|
+
return _pipeline;
|
|
2592
|
+
} catch (err) {
|
|
2593
|
+
_available = false;
|
|
2594
|
+
console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
2595
|
+
return null;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
// src/capture/recurrence.ts
|
|
2600
|
+
var DISTANCE_THRESHOLD = 0.15;
|
|
2601
|
+
async function detectRecurrence(db, config, observation) {
|
|
2602
|
+
if (observation.type !== "bugfix") {
|
|
2603
|
+
return { patternCreated: false };
|
|
2604
|
+
}
|
|
2605
|
+
if (!db.vecAvailable) {
|
|
2606
|
+
return { patternCreated: false };
|
|
2607
|
+
}
|
|
2608
|
+
const text = composeEmbeddingText(observation);
|
|
2609
|
+
const embedding = await embedText(text);
|
|
2610
|
+
if (!embedding) {
|
|
2611
|
+
return { patternCreated: false };
|
|
2612
|
+
}
|
|
2613
|
+
const vecResults = db.searchVec(embedding, null, ["active", "aging", "pinned"], 10);
|
|
2614
|
+
for (const match of vecResults) {
|
|
2615
|
+
if (match.observation_id === observation.id)
|
|
2616
|
+
continue;
|
|
2617
|
+
if (match.distance > DISTANCE_THRESHOLD)
|
|
2618
|
+
continue;
|
|
2619
|
+
const matched = db.getObservationById(match.observation_id);
|
|
2620
|
+
if (!matched)
|
|
2621
|
+
continue;
|
|
2622
|
+
if (matched.type !== "bugfix")
|
|
2623
|
+
continue;
|
|
2624
|
+
if (matched.session_id === observation.session_id)
|
|
2625
|
+
continue;
|
|
2626
|
+
if (await patternAlreadyExists(db, observation, matched))
|
|
2627
|
+
continue;
|
|
2628
|
+
let matchedProjectName;
|
|
2629
|
+
if (matched.project_id !== observation.project_id) {
|
|
2630
|
+
const proj = db.getProjectById(matched.project_id);
|
|
2631
|
+
if (proj)
|
|
2632
|
+
matchedProjectName = proj.name;
|
|
2633
|
+
}
|
|
2634
|
+
const similarity = 1 - match.distance;
|
|
2635
|
+
const result = await saveObservation(db, config, {
|
|
2636
|
+
type: "pattern",
|
|
2637
|
+
title: `Recurring bugfix: ${observation.title}`,
|
|
2638
|
+
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.`,
|
|
2639
|
+
facts: [
|
|
2640
|
+
`First seen: ${matched.created_at.split("T")[0]}`,
|
|
2641
|
+
`Recurred: ${observation.created_at.split("T")[0]}`,
|
|
2642
|
+
`Similarity: ${(similarity * 100).toFixed(0)}%`
|
|
2643
|
+
],
|
|
2644
|
+
concepts: mergeConceptsFromBoth(observation, matched),
|
|
2645
|
+
cwd: process.cwd(),
|
|
2646
|
+
session_id: observation.session_id ?? undefined
|
|
2647
|
+
});
|
|
2648
|
+
if (result.success && result.observation_id) {
|
|
2649
|
+
return {
|
|
2650
|
+
patternCreated: true,
|
|
2651
|
+
patternId: result.observation_id,
|
|
2652
|
+
matchedObservationId: matched.id,
|
|
2653
|
+
matchedProjectName,
|
|
2654
|
+
matchedTitle: matched.title,
|
|
2655
|
+
similarity
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
return { patternCreated: false };
|
|
2660
|
+
}
|
|
2661
|
+
async function patternAlreadyExists(db, obs1, obs2) {
|
|
2662
|
+
const recentPatterns = db.db.query(`SELECT * FROM observations
|
|
2663
|
+
WHERE type = 'pattern' AND lifecycle IN ('active', 'aging', 'pinned')
|
|
2664
|
+
AND title LIKE ?
|
|
2665
|
+
ORDER BY created_at_epoch DESC LIMIT 5`).all(`%${obs1.title.slice(0, 30)}%`);
|
|
2666
|
+
for (const p of recentPatterns) {
|
|
2667
|
+
if (p.narrative?.includes(obs2.title.slice(0, 30)))
|
|
2668
|
+
return true;
|
|
2669
|
+
}
|
|
2670
|
+
return false;
|
|
2671
|
+
}
|
|
2672
|
+
function mergeConceptsFromBoth(obs1, obs2) {
|
|
2673
|
+
const concepts = new Set;
|
|
2674
|
+
for (const obs of [obs1, obs2]) {
|
|
2675
|
+
if (obs.concepts) {
|
|
2676
|
+
try {
|
|
2677
|
+
const parsed = JSON.parse(obs.concepts);
|
|
2678
|
+
if (Array.isArray(parsed)) {
|
|
2679
|
+
for (const c of parsed) {
|
|
2680
|
+
if (typeof c === "string")
|
|
2681
|
+
concepts.add(c);
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
} catch {}
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
return [...concepts];
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
// src/capture/conflict.ts
|
|
2691
|
+
var SIMILARITY_THRESHOLD = 0.25;
|
|
2692
|
+
async function detectDecisionConflict(db, observation) {
|
|
2693
|
+
if (observation.type !== "decision") {
|
|
2694
|
+
return { hasConflict: false };
|
|
2695
|
+
}
|
|
2696
|
+
if (!observation.narrative || observation.narrative.trim().length < 20) {
|
|
2697
|
+
return { hasConflict: false };
|
|
2698
|
+
}
|
|
2699
|
+
if (db.vecAvailable) {
|
|
2700
|
+
return detectViaVec(db, observation);
|
|
2701
|
+
}
|
|
2702
|
+
return detectViaFts(db, observation);
|
|
2703
|
+
}
|
|
2704
|
+
async function detectViaVec(db, observation) {
|
|
2705
|
+
const text = composeEmbeddingText(observation);
|
|
2706
|
+
const embedding = await embedText(text);
|
|
2707
|
+
if (!embedding)
|
|
2708
|
+
return { hasConflict: false };
|
|
2709
|
+
const results = db.searchVec(embedding, observation.project_id, ["active", "aging", "pinned"], 10);
|
|
2710
|
+
for (const match of results) {
|
|
2711
|
+
if (match.observation_id === observation.id)
|
|
2712
|
+
continue;
|
|
2713
|
+
if (match.distance > SIMILARITY_THRESHOLD)
|
|
2714
|
+
continue;
|
|
2715
|
+
const existing = db.getObservationById(match.observation_id);
|
|
2716
|
+
if (!existing)
|
|
2717
|
+
continue;
|
|
2718
|
+
if (existing.type !== "decision")
|
|
2719
|
+
continue;
|
|
2720
|
+
if (!existing.narrative)
|
|
2721
|
+
continue;
|
|
2722
|
+
const conflict = narrativesConflict(observation.narrative, existing.narrative);
|
|
2723
|
+
if (conflict) {
|
|
2724
|
+
return {
|
|
2725
|
+
hasConflict: true,
|
|
2726
|
+
conflictingId: existing.id,
|
|
2727
|
+
conflictingTitle: existing.title,
|
|
2728
|
+
reason: conflict
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
return { hasConflict: false };
|
|
2733
|
+
}
|
|
2734
|
+
async function detectViaFts(db, observation) {
|
|
2735
|
+
const keywords = observation.title.split(/\s+/).filter((w) => w.length > 3).slice(0, 5).join(" ");
|
|
2736
|
+
if (!keywords)
|
|
2737
|
+
return { hasConflict: false };
|
|
2738
|
+
const ftsResults = db.searchFts(keywords, observation.project_id, ["active", "aging", "pinned"], 10);
|
|
2739
|
+
for (const match of ftsResults) {
|
|
2740
|
+
if (match.id === observation.id)
|
|
2741
|
+
continue;
|
|
2742
|
+
const existing = db.getObservationById(match.id);
|
|
2743
|
+
if (!existing)
|
|
2744
|
+
continue;
|
|
2745
|
+
if (existing.type !== "decision")
|
|
2746
|
+
continue;
|
|
2747
|
+
if (!existing.narrative)
|
|
2748
|
+
continue;
|
|
2749
|
+
const conflict = narrativesConflict(observation.narrative, existing.narrative);
|
|
2750
|
+
if (conflict) {
|
|
2751
|
+
return {
|
|
2752
|
+
hasConflict: true,
|
|
2753
|
+
conflictingId: existing.id,
|
|
2754
|
+
conflictingTitle: existing.title,
|
|
2755
|
+
reason: conflict
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
return { hasConflict: false };
|
|
2760
|
+
}
|
|
2761
|
+
function narrativesConflict(narrative1, narrative2) {
|
|
2762
|
+
const n1 = narrative1.toLowerCase();
|
|
2763
|
+
const n2 = narrative2.toLowerCase();
|
|
2764
|
+
const opposingPairs = [
|
|
2765
|
+
[["should use", "decided to use", "chose", "prefer", "went with"], ["should not", "decided against", "avoid", "rejected", "don't use"]],
|
|
2766
|
+
[["enable", "turn on", "activate", "add"], ["disable", "turn off", "deactivate", "remove"]],
|
|
2767
|
+
[["increase", "more", "higher", "scale up"], ["decrease", "less", "lower", "scale down"]],
|
|
2768
|
+
[["keep", "maintain", "preserve"], ["replace", "migrate", "switch from", "deprecate"]]
|
|
2769
|
+
];
|
|
2770
|
+
for (const [positive, negative] of opposingPairs) {
|
|
2771
|
+
const n1HasPositive = positive.some((w) => n1.includes(w));
|
|
2772
|
+
const n1HasNegative = negative.some((w) => n1.includes(w));
|
|
2773
|
+
const n2HasPositive = positive.some((w) => n2.includes(w));
|
|
2774
|
+
const n2HasNegative = negative.some((w) => n2.includes(w));
|
|
2775
|
+
if (n1HasPositive && n2HasNegative || n1HasNegative && n2HasPositive) {
|
|
2776
|
+
return "Narratives suggest opposing conclusions on a similar topic";
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
return null;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
// src/tools/save.ts
|
|
2783
|
+
var VALID_TYPES = [
|
|
2784
|
+
"bugfix",
|
|
2785
|
+
"discovery",
|
|
2786
|
+
"decision",
|
|
2787
|
+
"pattern",
|
|
2788
|
+
"change",
|
|
2789
|
+
"feature",
|
|
2790
|
+
"refactor",
|
|
2791
|
+
"digest",
|
|
2792
|
+
"standard",
|
|
2793
|
+
"message"
|
|
2794
|
+
];
|
|
2795
|
+
async function saveObservation(db, config, input) {
|
|
2796
|
+
if (!VALID_TYPES.includes(input.type)) {
|
|
2797
|
+
return {
|
|
2798
|
+
success: false,
|
|
2799
|
+
reason: `Invalid type '${input.type}'. Must be one of: ${VALID_TYPES.join(", ")}`
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
if (!input.title || input.title.trim().length === 0) {
|
|
2803
|
+
return { success: false, reason: "Title is required" };
|
|
2804
|
+
}
|
|
2805
|
+
const cwd = input.cwd ?? process.cwd();
|
|
2806
|
+
const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
|
|
2807
|
+
const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
|
|
2808
|
+
const project = db.upsertProject({
|
|
2809
|
+
canonical_id: detected.canonical_id,
|
|
2810
|
+
name: detected.name,
|
|
2811
|
+
local_path: detected.local_path,
|
|
2812
|
+
remote_url: detected.remote_url
|
|
2813
|
+
});
|
|
2814
|
+
const customPatterns = config.scrubbing.enabled ? config.scrubbing.custom_patterns : [];
|
|
2815
|
+
const title = config.scrubbing.enabled ? scrubSecrets(input.title, customPatterns) : input.title;
|
|
2816
|
+
const narrative = input.narrative ? config.scrubbing.enabled ? scrubSecrets(input.narrative, customPatterns) : input.narrative : null;
|
|
2817
|
+
const conceptsJson = input.concepts ? JSON.stringify(input.concepts) : null;
|
|
2818
|
+
const filesRead = input.files_read ? input.files_read.map((f) => toRelativePath(f, cwd)) : null;
|
|
2819
|
+
const filesModified = input.files_modified ? input.files_modified.map((f) => toRelativePath(f, cwd)) : null;
|
|
2820
|
+
const structuredFacts = buildStructuredFacts({
|
|
2821
|
+
type: input.type,
|
|
2822
|
+
title: input.title,
|
|
2823
|
+
narrative: input.narrative,
|
|
2824
|
+
facts: input.facts,
|
|
2825
|
+
filesModified
|
|
2826
|
+
});
|
|
2827
|
+
const factsJson = structuredFacts.length > 0 ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(structuredFacts), customPatterns) : JSON.stringify(structuredFacts) : null;
|
|
2828
|
+
const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
|
|
2829
|
+
const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
|
|
2830
|
+
let sensitivity = input.sensitivity ?? config.scrubbing.default_sensitivity;
|
|
2831
|
+
if (config.scrubbing.enabled && containsSecrets([input.title, input.narrative, JSON.stringify(input.facts)].filter(Boolean).join(" "), customPatterns)) {
|
|
2832
|
+
if (sensitivity === "shared") {
|
|
2833
|
+
sensitivity = "personal";
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
|
|
2837
|
+
const recentObs = db.getRecentObservations(project.id, oneDayAgo);
|
|
2838
|
+
const candidates = recentObs.map((o) => ({
|
|
2839
|
+
id: o.id,
|
|
2840
|
+
title: o.title
|
|
2841
|
+
}));
|
|
2842
|
+
const duplicate = findDuplicate(title, candidates);
|
|
2843
|
+
const qualityInput = {
|
|
2844
|
+
type: input.type,
|
|
2845
|
+
title,
|
|
2846
|
+
narrative,
|
|
2847
|
+
facts: factsJson,
|
|
2848
|
+
concepts: conceptsJson,
|
|
2849
|
+
filesRead,
|
|
2850
|
+
filesModified,
|
|
2851
|
+
isDuplicate: duplicate !== null
|
|
2852
|
+
};
|
|
2853
|
+
const qualityScore = scoreQuality(qualityInput);
|
|
2854
|
+
if (!meetsQualityThreshold(qualityInput)) {
|
|
2855
|
+
return {
|
|
2856
|
+
success: false,
|
|
2857
|
+
quality_score: qualityScore,
|
|
2858
|
+
reason: `Quality score ${qualityScore.toFixed(2)} below threshold`
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
if (duplicate) {
|
|
2862
|
+
return {
|
|
2863
|
+
success: true,
|
|
2864
|
+
merged_into: duplicate.id,
|
|
2865
|
+
quality_score: qualityScore,
|
|
2866
|
+
reason: `Merged into existing observation #${duplicate.id}`
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
const sourcePromptNumber = input.source_prompt_number ?? (input.session_id ? db.getLatestSessionPromptNumber(input.session_id) : null);
|
|
2870
|
+
const obs = db.insertObservation({
|
|
2871
|
+
session_id: input.session_id ?? null,
|
|
2872
|
+
project_id: project.id,
|
|
2873
|
+
type: input.type,
|
|
2874
|
+
title,
|
|
2875
|
+
narrative,
|
|
2876
|
+
facts: factsJson,
|
|
2877
|
+
concepts: conceptsJson,
|
|
2878
|
+
files_read: filesReadJson,
|
|
2879
|
+
files_modified: filesModifiedJson,
|
|
2880
|
+
quality: qualityScore,
|
|
2881
|
+
lifecycle: "active",
|
|
2882
|
+
sensitivity,
|
|
2883
|
+
user_id: config.user_id,
|
|
2884
|
+
device_id: config.device_id,
|
|
2885
|
+
agent: input.agent ?? "claude-code",
|
|
2886
|
+
source_tool: input.source_tool ?? null,
|
|
2887
|
+
source_prompt_number: sourcePromptNumber
|
|
2888
|
+
});
|
|
2889
|
+
db.addToOutbox("observation", obs.id);
|
|
2890
|
+
if (db.vecAvailable) {
|
|
2891
|
+
try {
|
|
2892
|
+
const text = composeEmbeddingText(obs);
|
|
2893
|
+
const embedding = await embedText(text);
|
|
2894
|
+
if (embedding) {
|
|
2895
|
+
db.vecInsert(obs.id, embedding);
|
|
2896
|
+
}
|
|
2897
|
+
} catch {}
|
|
2898
|
+
}
|
|
2899
|
+
let recallHint;
|
|
2900
|
+
if (input.type === "bugfix") {
|
|
2901
|
+
try {
|
|
2902
|
+
const recurrence = await detectRecurrence(db, config, obs);
|
|
2903
|
+
if (recurrence.patternCreated && recurrence.matchedTitle) {
|
|
2904
|
+
const projectLabel = recurrence.matchedProjectName ? ` in ${recurrence.matchedProjectName}` : "";
|
|
2905
|
+
recallHint = `You solved a similar issue${projectLabel}: "${recurrence.matchedTitle}"`;
|
|
2906
|
+
}
|
|
2907
|
+
} catch {}
|
|
2908
|
+
}
|
|
2909
|
+
let conflictWarning;
|
|
2910
|
+
if (input.type === "decision") {
|
|
2911
|
+
try {
|
|
2912
|
+
const conflict = await detectDecisionConflict(db, obs);
|
|
2913
|
+
if (conflict.hasConflict && conflict.conflictingTitle) {
|
|
2914
|
+
conflictWarning = `Potential conflict with existing decision: "${conflict.conflictingTitle}" — ${conflict.reason}`;
|
|
2915
|
+
}
|
|
2916
|
+
} catch {}
|
|
2917
|
+
}
|
|
2918
|
+
return {
|
|
2919
|
+
success: true,
|
|
2920
|
+
observation_id: obs.id,
|
|
2921
|
+
quality_score: qualityScore,
|
|
2922
|
+
recall_hint: recallHint,
|
|
2923
|
+
conflict_warning: conflictWarning
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
function toRelativePath(filePath, projectRoot) {
|
|
2927
|
+
if (!isAbsolute(filePath))
|
|
2928
|
+
return filePath;
|
|
2929
|
+
const rel = relative(projectRoot, filePath);
|
|
2930
|
+
if (rel.startsWith(".."))
|
|
2931
|
+
return filePath;
|
|
2932
|
+
return rel;
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
// src/tools/handoffs.ts
|
|
2936
|
+
async function upsertRollingHandoff(db, config, input) {
|
|
2937
|
+
const resolved = resolveTargetSession(db, input.cwd, config.user_id, input.session_id);
|
|
2938
|
+
if (!resolved.session) {
|
|
2939
|
+
return {
|
|
2940
|
+
success: false,
|
|
2941
|
+
reason: "No recent session found to draft a handoff yet"
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
const story = getSessionStory(db, { session_id: resolved.session.session_id });
|
|
2945
|
+
if (!story.session) {
|
|
2946
|
+
return {
|
|
2947
|
+
success: false,
|
|
2948
|
+
reason: `Session ${resolved.session.session_id} not found`
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
const includeChat = input.include_chat === true || input.include_chat !== false && shouldAutoIncludeChat(story);
|
|
2952
|
+
const chatLimit = Math.max(1, Math.min(input.chat_limit ?? 3, 6));
|
|
2953
|
+
const title = `Handoff Draft: ${buildHandoffTitle(story.summary, story.latest_request)}`;
|
|
2954
|
+
const narrative = buildHandoffNarrative(story.summary, story, {
|
|
2955
|
+
includeChat,
|
|
2956
|
+
chatLimit
|
|
2957
|
+
});
|
|
2958
|
+
const facts = buildHandoffFacts(story.summary, story);
|
|
2959
|
+
const concepts = buildDraftHandoffConcepts(story.project_name, story.capture_state);
|
|
2960
|
+
const existing = getSessionRollingHandoff(db, story.session.session_id);
|
|
2961
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2962
|
+
if (existing) {
|
|
2963
|
+
const nextFacts = JSON.stringify(facts);
|
|
2964
|
+
const nextConcepts = JSON.stringify(concepts);
|
|
2965
|
+
const shouldRefresh = existing.title !== title || (existing.narrative ?? null) !== narrative || (existing.facts ?? null) !== nextFacts || (existing.concepts ?? null) !== nextConcepts || now - existing.created_at_epoch >= 120;
|
|
2966
|
+
if (!shouldRefresh) {
|
|
2967
|
+
return {
|
|
2968
|
+
success: true,
|
|
2969
|
+
observation_id: existing.id,
|
|
2970
|
+
session_id: story.session.session_id,
|
|
2971
|
+
title: existing.title
|
|
2972
|
+
};
|
|
2973
|
+
}
|
|
2974
|
+
const updated = db.updateObservationContent(existing.id, {
|
|
2975
|
+
title,
|
|
2976
|
+
narrative,
|
|
2977
|
+
facts: nextFacts,
|
|
2978
|
+
concepts: nextConcepts,
|
|
2979
|
+
created_at_epoch: now
|
|
2980
|
+
});
|
|
2981
|
+
if (!updated) {
|
|
2982
|
+
return {
|
|
2983
|
+
success: false,
|
|
2984
|
+
reason: "Failed to update rolling handoff draft"
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
db.addToOutbox("observation", updated.id);
|
|
2988
|
+
return {
|
|
2989
|
+
success: true,
|
|
2990
|
+
observation_id: updated.id,
|
|
2991
|
+
session_id: story.session.session_id,
|
|
2992
|
+
title: updated.title
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
const result = await saveObservation(db, config, {
|
|
2996
|
+
type: "message",
|
|
2997
|
+
title,
|
|
2998
|
+
narrative,
|
|
2999
|
+
facts,
|
|
3000
|
+
concepts,
|
|
3001
|
+
session_id: story.session.session_id,
|
|
3002
|
+
cwd: input.cwd,
|
|
3003
|
+
agent: "engrm-handoff",
|
|
3004
|
+
source_tool: "rolling_handoff"
|
|
3005
|
+
});
|
|
3006
|
+
return {
|
|
3007
|
+
success: result.success,
|
|
3008
|
+
observation_id: result.observation_id,
|
|
3009
|
+
session_id: story.session.session_id,
|
|
3010
|
+
title,
|
|
3011
|
+
reason: result.reason
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
function getRecentHandoffs(db, input) {
|
|
3015
|
+
const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
|
|
3016
|
+
const queryLimit = input.current_device_id ? Math.max(limit, Math.min(limit * 5, 50)) : limit;
|
|
3017
|
+
const projectScoped = input.project_scoped !== false;
|
|
3018
|
+
let projectId = null;
|
|
3019
|
+
let projectName;
|
|
3020
|
+
if (projectScoped) {
|
|
3021
|
+
const cwd = input.cwd ?? process.cwd();
|
|
3022
|
+
const detected = detectProject(cwd);
|
|
3023
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
3024
|
+
if (project) {
|
|
3025
|
+
projectId = project.id;
|
|
3026
|
+
projectName = project.name;
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
const conditions = [
|
|
3030
|
+
"o.type = 'message'",
|
|
3031
|
+
"o.lifecycle IN ('active', 'aging', 'pinned')",
|
|
3032
|
+
"o.superseded_by IS NULL",
|
|
3033
|
+
`(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
|
|
3034
|
+
];
|
|
3035
|
+
const params = [];
|
|
3036
|
+
if (input.user_id) {
|
|
3037
|
+
conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
|
|
3038
|
+
params.push(input.user_id);
|
|
3039
|
+
}
|
|
3040
|
+
if (projectId !== null) {
|
|
3041
|
+
conditions.push("o.project_id = ?");
|
|
3042
|
+
params.push(projectId);
|
|
3043
|
+
}
|
|
3044
|
+
params.push(queryLimit);
|
|
3045
|
+
const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
|
|
3046
|
+
FROM observations o
|
|
3047
|
+
LEFT JOIN projects p ON p.id = o.project_id
|
|
3048
|
+
WHERE ${conditions.join(" AND ")}
|
|
3049
|
+
ORDER BY o.created_at_epoch DESC, o.id DESC
|
|
3050
|
+
LIMIT ?`).all(...params);
|
|
3051
|
+
handoffs.sort((a, b) => compareHandoffs(a, b, input.current_device_id));
|
|
3052
|
+
return {
|
|
3053
|
+
handoffs: handoffs.slice(0, limit),
|
|
3054
|
+
project: projectName
|
|
3055
|
+
};
|
|
3056
|
+
}
|
|
3057
|
+
function formatHandoffSource(handoff) {
|
|
3058
|
+
const ageSeconds = Math.max(0, Math.floor(Date.now() / 1000) - handoff.created_at_epoch);
|
|
3059
|
+
const ageLabel = ageSeconds < 3600 ? `${Math.max(1, Math.floor(ageSeconds / 60) || 1)}m ago` : ageSeconds < 86400 ? `${Math.floor(ageSeconds / 3600)}h ago` : `${Math.floor(ageSeconds / 86400)}d ago`;
|
|
3060
|
+
return `from ${handoff.device_id} · ${ageLabel}`;
|
|
3061
|
+
}
|
|
3062
|
+
function isDraftHandoff(obs) {
|
|
3063
|
+
if (obs.title.startsWith("Handoff Draft:"))
|
|
3064
|
+
return true;
|
|
3065
|
+
const concepts = parseJsonArray2(obs.concepts);
|
|
3066
|
+
return concepts.includes("draft-handoff") || concepts.includes("auto-handoff");
|
|
3067
|
+
}
|
|
3068
|
+
function getSessionRollingHandoff(db, sessionId) {
|
|
3069
|
+
return db.db.query(`SELECT o.*, p.name AS project_name
|
|
3070
|
+
FROM observations o
|
|
3071
|
+
LEFT JOIN projects p ON p.id = o.project_id
|
|
3072
|
+
WHERE o.session_id = ?
|
|
3073
|
+
AND o.type = 'message'
|
|
3074
|
+
AND o.lifecycle IN ('active', 'aging', 'pinned')
|
|
3075
|
+
AND o.superseded_by IS NULL
|
|
3076
|
+
AND (o.title LIKE 'Handoff Draft:%' OR o.concepts LIKE '%"draft-handoff"%')
|
|
3077
|
+
ORDER BY o.created_at_epoch DESC, o.id DESC
|
|
3078
|
+
LIMIT 1`).get(sessionId) ?? null;
|
|
3079
|
+
}
|
|
3080
|
+
function compareHandoffs(a, b, currentDeviceId) {
|
|
3081
|
+
const aDraft = isDraftHandoff(a) ? 1 : 0;
|
|
3082
|
+
const bDraft = isDraftHandoff(b) ? 1 : 0;
|
|
3083
|
+
if (aDraft !== bDraft)
|
|
3084
|
+
return aDraft - bDraft;
|
|
3085
|
+
if (currentDeviceId) {
|
|
3086
|
+
const aOther = a.device_id !== currentDeviceId ? 1 : 0;
|
|
3087
|
+
const bOther = b.device_id !== currentDeviceId ? 1 : 0;
|
|
3088
|
+
if (aOther !== bOther)
|
|
3089
|
+
return bOther - aOther;
|
|
3090
|
+
}
|
|
3091
|
+
if (b.created_at_epoch !== a.created_at_epoch) {
|
|
3092
|
+
return b.created_at_epoch - a.created_at_epoch;
|
|
3093
|
+
}
|
|
3094
|
+
return b.id - a.id;
|
|
3095
|
+
}
|
|
3096
|
+
function resolveTargetSession(db, cwd, userId, sessionId) {
|
|
3097
|
+
if (sessionId) {
|
|
3098
|
+
const session = db.getSessionById(sessionId);
|
|
3099
|
+
if (!session)
|
|
3100
|
+
return { session: null };
|
|
3101
|
+
const projectName = session.project_id ? db.getProjectById(session.project_id)?.name : undefined;
|
|
3102
|
+
return {
|
|
3103
|
+
session: {
|
|
3104
|
+
...session,
|
|
3105
|
+
project_name: projectName ?? null,
|
|
3106
|
+
request: db.getSessionSummary(sessionId)?.request ?? null,
|
|
3107
|
+
completed: db.getSessionSummary(sessionId)?.completed ?? null,
|
|
3108
|
+
current_thread: db.getSessionSummary(sessionId)?.current_thread ?? null,
|
|
3109
|
+
capture_state: db.getSessionSummary(sessionId)?.capture_state ?? null,
|
|
3110
|
+
recent_tool_names: db.getSessionSummary(sessionId)?.recent_tool_names ?? null,
|
|
3111
|
+
hot_files: db.getSessionSummary(sessionId)?.hot_files ?? null,
|
|
3112
|
+
recent_outcomes: db.getSessionSummary(sessionId)?.recent_outcomes ?? null,
|
|
3113
|
+
prompt_count: db.getSessionUserPrompts(sessionId, 200).length,
|
|
3114
|
+
tool_event_count: db.getSessionToolEvents(sessionId, 200).length
|
|
3115
|
+
},
|
|
3116
|
+
projectName: projectName ?? undefined
|
|
3117
|
+
};
|
|
3118
|
+
}
|
|
3119
|
+
const detected = detectProject(cwd ?? process.cwd());
|
|
3120
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
3121
|
+
const sessions = db.getRecentSessions(project?.id ?? null, 10, userId);
|
|
3122
|
+
return {
|
|
3123
|
+
session: sessions[0] ?? null,
|
|
3124
|
+
projectName: project?.name
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
function buildHandoffTitle(summary, latestRequest, explicit) {
|
|
3128
|
+
const chosen = explicit?.trim() || summary?.current_thread?.trim() || summary?.completed?.trim() || latestRequest?.trim() || "Current work";
|
|
3129
|
+
return compactLine(chosen) ?? "Current work";
|
|
3130
|
+
}
|
|
3131
|
+
function buildHandoffNarrative(summary, story, options) {
|
|
3132
|
+
const sections = [];
|
|
3133
|
+
if (summary?.request || story.latest_request) {
|
|
3134
|
+
sections.push(`Request: ${summary?.request ?? story.latest_request}`);
|
|
3135
|
+
}
|
|
3136
|
+
if (summary?.current_thread) {
|
|
3137
|
+
sections.push(`Current thread: ${summary.current_thread}`);
|
|
3138
|
+
}
|
|
3139
|
+
if (summary?.investigated) {
|
|
3140
|
+
sections.push(`Investigated: ${summary.investigated}`);
|
|
3141
|
+
}
|
|
3142
|
+
if (summary?.learned) {
|
|
3143
|
+
sections.push(`Learned: ${summary.learned}`);
|
|
3144
|
+
}
|
|
3145
|
+
if (summary?.completed) {
|
|
3146
|
+
sections.push(`Completed: ${summary.completed}`);
|
|
3147
|
+
}
|
|
3148
|
+
if (summary?.next_steps) {
|
|
3149
|
+
sections.push(`Next Steps: ${summary.next_steps}`);
|
|
3150
|
+
}
|
|
3151
|
+
if (story.recent_outcomes.length > 0) {
|
|
3152
|
+
sections.push(`Recent outcomes:
|
|
3153
|
+
${story.recent_outcomes.slice(0, 5).map((item) => `- ${item}`).join(`
|
|
3154
|
+
`)}`);
|
|
3155
|
+
}
|
|
3156
|
+
if (story.hot_files.length > 0) {
|
|
3157
|
+
sections.push(`Hot files:
|
|
3158
|
+
${story.hot_files.slice(0, 5).map((file) => `- ${file.path}`).join(`
|
|
3159
|
+
`)}`);
|
|
3160
|
+
}
|
|
3161
|
+
if (story.provenance_summary.length > 0) {
|
|
3162
|
+
sections.push(`Tool trail:
|
|
3163
|
+
${story.provenance_summary.slice(0, 5).map((item) => `- ${item.tool}: ${item.count}`).join(`
|
|
3164
|
+
`)}`);
|
|
3165
|
+
}
|
|
3166
|
+
if (options.includeChat && story.chat_messages.length > 0) {
|
|
3167
|
+
const chatLines = story.chat_messages.slice(-options.chatLimit).map((msg) => `- [${msg.role}] ${compactLine(msg.content) ?? msg.content.slice(0, 120)}`);
|
|
3168
|
+
sections.push(`Chat snippets:
|
|
3169
|
+
${chatLines.join(`
|
|
3170
|
+
`)}`);
|
|
3171
|
+
}
|
|
3172
|
+
return sections.filter(Boolean).join(`
|
|
3173
|
+
|
|
3174
|
+
`);
|
|
3175
|
+
}
|
|
3176
|
+
function shouldAutoIncludeChat(story) {
|
|
3177
|
+
if (story.chat_messages.length === 0)
|
|
3178
|
+
return false;
|
|
3179
|
+
const summary = story.summary;
|
|
3180
|
+
const thinSummary = !summary?.completed && !summary?.current_thread && story.recent_outcomes.length < 2;
|
|
3181
|
+
const thinChronology = story.capture_state !== "rich" || story.tool_events.length === 0;
|
|
3182
|
+
return thinSummary || thinChronology;
|
|
3183
|
+
}
|
|
3184
|
+
function buildHandoffFacts(summary, story) {
|
|
3185
|
+
const facts = [
|
|
3186
|
+
`session_id=${story.session?.session_id ?? "unknown"}`,
|
|
3187
|
+
`capture_state=${story.capture_state}`,
|
|
3188
|
+
story.project_name ? `project=${story.project_name}` : null,
|
|
3189
|
+
summary?.current_thread ? `current_thread=${summary.current_thread}` : null,
|
|
3190
|
+
story.hot_files[0] ? `hot_file=${story.hot_files[0].path}` : null,
|
|
3191
|
+
story.provenance_summary[0] ? `primary_tool=${story.provenance_summary[0].tool}` : null
|
|
3192
|
+
];
|
|
3193
|
+
return facts.filter((item) => Boolean(item));
|
|
3194
|
+
}
|
|
3195
|
+
function buildDraftHandoffConcepts(projectName, captureState) {
|
|
3196
|
+
return [
|
|
3197
|
+
"handoff",
|
|
3198
|
+
"draft-handoff",
|
|
3199
|
+
"auto-handoff",
|
|
3200
|
+
`capture:${captureState}`,
|
|
3201
|
+
...projectName ? [projectName] : []
|
|
3202
|
+
];
|
|
3203
|
+
}
|
|
3204
|
+
function looksLikeHandoff(obs) {
|
|
3205
|
+
if (obs.title.startsWith("Handoff:") || obs.title.startsWith("Handoff Draft:"))
|
|
3206
|
+
return true;
|
|
3207
|
+
const concepts = parseJsonArray2(obs.concepts);
|
|
3208
|
+
return concepts.includes("handoff") || concepts.includes("session-handoff") || concepts.includes("draft-handoff");
|
|
3209
|
+
}
|
|
3210
|
+
function parseJsonArray2(value) {
|
|
3211
|
+
if (!value)
|
|
3212
|
+
return [];
|
|
3213
|
+
try {
|
|
3214
|
+
const parsed = JSON.parse(value);
|
|
3215
|
+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
|
|
3216
|
+
} catch {
|
|
3217
|
+
return [];
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
function compactLine(value) {
|
|
3221
|
+
const trimmed = value?.replace(/\s+/g, " ").trim();
|
|
3222
|
+
if (!trimmed)
|
|
3223
|
+
return null;
|
|
3224
|
+
return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
// src/context/inject.ts
|
|
3228
|
+
function tokenizeProjectHint(text) {
|
|
3229
|
+
return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
|
|
3230
|
+
}
|
|
3231
|
+
function parseSummaryJsonList(value) {
|
|
3232
|
+
if (!value)
|
|
3233
|
+
return [];
|
|
3234
|
+
try {
|
|
3235
|
+
const parsed = JSON.parse(value);
|
|
3236
|
+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
|
|
3237
|
+
} catch {
|
|
3238
|
+
return [];
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
function isObservationRelatedToProject(obs, detected) {
|
|
3242
|
+
const hints = new Set([
|
|
3243
|
+
...tokenizeProjectHint(detected.name),
|
|
3244
|
+
...tokenizeProjectHint(detected.canonical_id)
|
|
3245
|
+
]);
|
|
3246
|
+
if (hints.size === 0)
|
|
3247
|
+
return false;
|
|
3248
|
+
const haystack = [
|
|
3249
|
+
obs.title,
|
|
3250
|
+
obs.narrative ?? "",
|
|
3251
|
+
obs.facts ?? "",
|
|
3252
|
+
obs.concepts ?? "",
|
|
3253
|
+
obs.files_read ?? "",
|
|
3254
|
+
obs.files_modified ?? "",
|
|
3255
|
+
obs._source_project ?? ""
|
|
3256
|
+
].join(`
|
|
3257
|
+
`).toLowerCase();
|
|
3258
|
+
for (const hint of hints) {
|
|
3259
|
+
if (haystack.includes(hint))
|
|
3260
|
+
return true;
|
|
3261
|
+
}
|
|
3262
|
+
return false;
|
|
3263
|
+
}
|
|
3264
|
+
function estimateTokens(text) {
|
|
3265
|
+
if (!text)
|
|
3266
|
+
return 0;
|
|
3267
|
+
return Math.ceil(text.length / 4);
|
|
3268
|
+
}
|
|
3269
|
+
function buildSessionContext(db, cwd, options = {}) {
|
|
3270
|
+
const opts = typeof options === "number" ? { maxCount: options } : options;
|
|
3271
|
+
const tokenBudget = opts.tokenBudget ?? 3000;
|
|
3272
|
+
const maxCount = opts.maxCount;
|
|
3273
|
+
const visibilityClause = opts.userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
3274
|
+
const visibilityParams = opts.userId ? [opts.userId] : [];
|
|
3275
|
+
const detected = detectProject(cwd);
|
|
3276
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
3277
|
+
const projectId = project?.id ?? -1;
|
|
3278
|
+
const isNewProject = !project;
|
|
1895
3279
|
const totalActive = isNewProject ? (db.db.query(`SELECT COUNT(*) as c FROM observations
|
|
1896
3280
|
WHERE lifecycle IN ('active', 'aging', 'pinned')
|
|
1897
3281
|
${visibilityClause}
|
|
@@ -1986,6 +3370,14 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1986
3370
|
const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
|
|
1987
3371
|
const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
|
|
1988
3372
|
const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions2);
|
|
3373
|
+
const recentHandoffs2 = getRecentHandoffs(db, {
|
|
3374
|
+
cwd,
|
|
3375
|
+
project_scoped: !isNewProject,
|
|
3376
|
+
user_id: opts.userId,
|
|
3377
|
+
current_device_id: opts.currentDeviceId,
|
|
3378
|
+
limit: 3
|
|
3379
|
+
}).handoffs;
|
|
3380
|
+
const recentChatMessages2 = !isNewProject && project ? db.getRecentChatMessages(project.id, 4, opts.userId) : [];
|
|
1989
3381
|
return {
|
|
1990
3382
|
project_name: projectName,
|
|
1991
3383
|
canonical_id: canonicalId,
|
|
@@ -1996,7 +3388,9 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1996
3388
|
recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
|
|
1997
3389
|
recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
|
|
1998
3390
|
projectTypeCounts: projectTypeCounts2,
|
|
1999
|
-
recentOutcomes: recentOutcomes2
|
|
3391
|
+
recentOutcomes: recentOutcomes2,
|
|
3392
|
+
recentHandoffs: recentHandoffs2.length > 0 ? recentHandoffs2 : undefined,
|
|
3393
|
+
recentChatMessages: recentChatMessages2.length > 0 ? recentChatMessages2 : undefined
|
|
2000
3394
|
};
|
|
2001
3395
|
}
|
|
2002
3396
|
let remainingBudget = tokenBudget - 30;
|
|
@@ -2024,6 +3418,14 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
2024
3418
|
const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
|
|
2025
3419
|
const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
|
|
2026
3420
|
const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions);
|
|
3421
|
+
const recentHandoffs = getRecentHandoffs(db, {
|
|
3422
|
+
cwd,
|
|
3423
|
+
project_scoped: !isNewProject,
|
|
3424
|
+
user_id: opts.userId,
|
|
3425
|
+
current_device_id: opts.currentDeviceId,
|
|
3426
|
+
limit: 3
|
|
3427
|
+
}).handoffs;
|
|
3428
|
+
const recentChatMessages = !isNewProject ? db.getRecentChatMessages(projectId, 4, opts.userId) : [];
|
|
2027
3429
|
let securityFindings = [];
|
|
2028
3430
|
if (!isNewProject) {
|
|
2029
3431
|
try {
|
|
@@ -2082,7 +3484,9 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
2082
3484
|
recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
|
|
2083
3485
|
recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
|
|
2084
3486
|
projectTypeCounts,
|
|
2085
|
-
recentOutcomes
|
|
3487
|
+
recentOutcomes,
|
|
3488
|
+
recentHandoffs: recentHandoffs.length > 0 ? recentHandoffs : undefined,
|
|
3489
|
+
recentChatMessages: recentChatMessages.length > 0 ? recentChatMessages : undefined
|
|
2086
3490
|
};
|
|
2087
3491
|
}
|
|
2088
3492
|
function estimateObservationTokens(obs, index) {
|
|
@@ -2118,6 +3522,27 @@ function formatContextForInjection(context) {
|
|
|
2118
3522
|
lines.push(`${context.session_count} relevant observation(s) from prior sessions:`);
|
|
2119
3523
|
lines.push("");
|
|
2120
3524
|
}
|
|
3525
|
+
if (context.recentHandoffs && context.recentHandoffs.length > 0) {
|
|
3526
|
+
lines.push("## Recent Handoffs");
|
|
3527
|
+
for (const handoff of context.recentHandoffs.slice(0, 3)) {
|
|
3528
|
+
const title = handoff.title.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+·\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
|
|
3529
|
+
if (title) {
|
|
3530
|
+
lines.push(`- ${truncateText(`${title} (${formatHandoffSource(handoff)})`, 160)}`);
|
|
3531
|
+
}
|
|
3532
|
+
const narrative = handoff.narrative?.split(/\n{2,}/).map((part) => part.replace(/\s+/g, " ").trim()).find((part) => /^(Current thread:|Completed:|Next Steps:)/i.test(part));
|
|
3533
|
+
if (narrative) {
|
|
3534
|
+
lines.push(` ${truncateText(narrative, 180)}`);
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
lines.push("");
|
|
3538
|
+
}
|
|
3539
|
+
if (context.recentChatMessages && context.recentChatMessages.length > 0) {
|
|
3540
|
+
lines.push("## Recent Chat");
|
|
3541
|
+
for (const message of context.recentChatMessages.slice(0, 4).reverse()) {
|
|
3542
|
+
lines.push(`- [${message.role}] ${truncateText(message.content.replace(/\s+/g, " ").trim(), 160)}`);
|
|
3543
|
+
}
|
|
3544
|
+
lines.push("");
|
|
3545
|
+
}
|
|
2121
3546
|
if (context.recentPrompts && context.recentPrompts.length > 0) {
|
|
2122
3547
|
const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
|
|
2123
3548
|
if (promptLines.length > 0) {
|
|
@@ -2233,7 +3658,7 @@ function formatSessionBrief(summary) {
|
|
|
2233
3658
|
return lines;
|
|
2234
3659
|
}
|
|
2235
3660
|
function chooseMeaningfulSessionHeadline(request, completed) {
|
|
2236
|
-
if (request && !
|
|
3661
|
+
if (request && !looksLikeFileOperationTitle2(request))
|
|
2237
3662
|
return request;
|
|
2238
3663
|
const completedItems = extractMeaningfulLines(completed, 1);
|
|
2239
3664
|
if (completedItems.length > 0)
|
|
@@ -2256,7 +3681,7 @@ function isMeaningfulPrompt(value) {
|
|
|
2256
3681
|
return false;
|
|
2257
3682
|
return /[a-z]{3,}/i.test(compact);
|
|
2258
3683
|
}
|
|
2259
|
-
function
|
|
3684
|
+
function looksLikeFileOperationTitle2(value) {
|
|
2260
3685
|
return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
|
|
2261
3686
|
}
|
|
2262
3687
|
function stripInlineSectionLabel(value) {
|
|
@@ -2265,7 +3690,7 @@ function stripInlineSectionLabel(value) {
|
|
|
2265
3690
|
function extractMeaningfulLines(value, limit) {
|
|
2266
3691
|
if (!value)
|
|
2267
3692
|
return [];
|
|
2268
|
-
return extractSummaryItems(value).map((line) => stripInlineSectionLabel(line)).filter((line) => line.length > 0 && !
|
|
3693
|
+
return extractSummaryItems(value).map((line) => stripInlineSectionLabel(line)).filter((line) => line.length > 0 && !looksLikeFileOperationTitle2(line)).slice(0, limit);
|
|
2269
3694
|
}
|
|
2270
3695
|
function formatObservationDetailFromContext(obs) {
|
|
2271
3696
|
if (obs.facts) {
|
|
@@ -2421,7 +3846,7 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
|
|
|
2421
3846
|
continue;
|
|
2422
3847
|
const title = stripInlineSectionLabel(obs.title);
|
|
2423
3848
|
const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2424
|
-
if (!normalized || seen.has(normalized) ||
|
|
3849
|
+
if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle2(title))
|
|
2425
3850
|
continue;
|
|
2426
3851
|
seen.add(normalized);
|
|
2427
3852
|
picked.push(title);
|
|
@@ -2431,6 +3856,169 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
|
|
|
2431
3856
|
return picked;
|
|
2432
3857
|
}
|
|
2433
3858
|
|
|
3859
|
+
// src/capture/transcript.ts
|
|
3860
|
+
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
|
|
3861
|
+
import { join as join3 } from "node:path";
|
|
3862
|
+
import { homedir as homedir2 } from "node:os";
|
|
3863
|
+
function resolveTranscriptPath(sessionId, cwd, transcriptPath) {
|
|
3864
|
+
if (transcriptPath)
|
|
3865
|
+
return transcriptPath;
|
|
3866
|
+
const encodedCwd = cwd.replace(/\//g, "-");
|
|
3867
|
+
return join3(homedir2(), ".claude", "projects", encodedCwd, `${sessionId}.jsonl`);
|
|
3868
|
+
}
|
|
3869
|
+
function readTranscript(sessionId, cwd, transcriptPath) {
|
|
3870
|
+
const path = resolveTranscriptPath(sessionId, cwd, transcriptPath);
|
|
3871
|
+
if (!existsSync3(path))
|
|
3872
|
+
return [];
|
|
3873
|
+
let raw;
|
|
3874
|
+
try {
|
|
3875
|
+
raw = readFileSync3(path, "utf-8");
|
|
3876
|
+
} catch {
|
|
3877
|
+
return [];
|
|
3878
|
+
}
|
|
3879
|
+
const messages = [];
|
|
3880
|
+
for (const line of raw.split(`
|
|
3881
|
+
`)) {
|
|
3882
|
+
if (!line.trim())
|
|
3883
|
+
continue;
|
|
3884
|
+
let entry;
|
|
3885
|
+
try {
|
|
3886
|
+
entry = JSON.parse(line);
|
|
3887
|
+
} catch {
|
|
3888
|
+
continue;
|
|
3889
|
+
}
|
|
3890
|
+
const role = entry.role;
|
|
3891
|
+
if (role !== "user" && role !== "assistant")
|
|
3892
|
+
continue;
|
|
3893
|
+
const content = entry.content;
|
|
3894
|
+
if (typeof content === "string") {
|
|
3895
|
+
messages.push({ role, text: content });
|
|
3896
|
+
continue;
|
|
3897
|
+
}
|
|
3898
|
+
if (Array.isArray(content)) {
|
|
3899
|
+
const textParts = [];
|
|
3900
|
+
for (const block of content) {
|
|
3901
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
3902
|
+
textParts.push(block.text);
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
if (textParts.length > 0) {
|
|
3906
|
+
messages.push({ role, text: textParts.join(`
|
|
3907
|
+
`) });
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
return messages;
|
|
3912
|
+
}
|
|
3913
|
+
function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
|
|
3914
|
+
const messages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
|
|
3915
|
+
...message,
|
|
3916
|
+
text: message.text.trim()
|
|
3917
|
+
})).filter((message) => message.text.length > 0);
|
|
3918
|
+
if (messages.length === 0)
|
|
3919
|
+
return { imported: 0, total: 0 };
|
|
3920
|
+
const session = db.getSessionById(sessionId);
|
|
3921
|
+
const projectId = session?.project_id ?? null;
|
|
3922
|
+
const now = Math.floor(Date.now() / 1000);
|
|
3923
|
+
let imported = 0;
|
|
3924
|
+
for (let index = 0;index < messages.length; index++) {
|
|
3925
|
+
const transcriptIndex = index + 1;
|
|
3926
|
+
if (db.getTranscriptChatMessage(sessionId, transcriptIndex))
|
|
3927
|
+
continue;
|
|
3928
|
+
const message = messages[index];
|
|
3929
|
+
const createdAtEpoch = Math.max(0, now - (messages.length - transcriptIndex));
|
|
3930
|
+
const row = db.insertChatMessage({
|
|
3931
|
+
session_id: sessionId,
|
|
3932
|
+
project_id: projectId,
|
|
3933
|
+
role: message.role,
|
|
3934
|
+
content: message.text,
|
|
3935
|
+
user_id: config.user_id,
|
|
3936
|
+
device_id: config.device_id,
|
|
3937
|
+
agent: "claude-code",
|
|
3938
|
+
created_at_epoch: createdAtEpoch,
|
|
3939
|
+
source_kind: "transcript",
|
|
3940
|
+
transcript_index: transcriptIndex
|
|
3941
|
+
});
|
|
3942
|
+
db.addToOutbox("chat_message", row.id);
|
|
3943
|
+
imported++;
|
|
3944
|
+
}
|
|
3945
|
+
return { imported, total: messages.length };
|
|
3946
|
+
}
|
|
3947
|
+
function truncateTranscript(messages, maxBytes = 50000) {
|
|
3948
|
+
const lines = [];
|
|
3949
|
+
for (const msg of messages) {
|
|
3950
|
+
lines.push(`[${msg.role}]: ${msg.text}`);
|
|
3951
|
+
}
|
|
3952
|
+
const full = lines.join(`
|
|
3953
|
+
`);
|
|
3954
|
+
if (Buffer.byteLength(full, "utf-8") <= maxBytes)
|
|
3955
|
+
return full;
|
|
3956
|
+
let result = "";
|
|
3957
|
+
for (let i = lines.length - 1;i >= 0; i--) {
|
|
3958
|
+
const candidate = lines[i] + `
|
|
3959
|
+
` + result;
|
|
3960
|
+
if (Buffer.byteLength(candidate, "utf-8") > maxBytes)
|
|
3961
|
+
break;
|
|
3962
|
+
result = candidate;
|
|
3963
|
+
}
|
|
3964
|
+
return result.trim();
|
|
3965
|
+
}
|
|
3966
|
+
async function analyzeTranscript(config, transcript, sessionId) {
|
|
3967
|
+
if (!config.candengo_url || !config.candengo_api_key)
|
|
3968
|
+
return null;
|
|
3969
|
+
const url = `${config.candengo_url}/v1/mem/transcript-analysis`;
|
|
3970
|
+
const controller = new AbortController;
|
|
3971
|
+
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
3972
|
+
try {
|
|
3973
|
+
const response = await fetch(url, {
|
|
3974
|
+
method: "POST",
|
|
3975
|
+
headers: {
|
|
3976
|
+
"Content-Type": "application/json",
|
|
3977
|
+
Authorization: `Bearer ${config.candengo_api_key}`
|
|
3978
|
+
},
|
|
3979
|
+
body: JSON.stringify({
|
|
3980
|
+
transcript,
|
|
3981
|
+
session_id: sessionId
|
|
3982
|
+
}),
|
|
3983
|
+
signal: controller.signal
|
|
3984
|
+
});
|
|
3985
|
+
if (!response.ok)
|
|
3986
|
+
return null;
|
|
3987
|
+
const data = await response.json();
|
|
3988
|
+
if (!Array.isArray(data.plans) || !Array.isArray(data.decisions) || !Array.isArray(data.insights)) {
|
|
3989
|
+
return null;
|
|
3990
|
+
}
|
|
3991
|
+
return data;
|
|
3992
|
+
} catch {
|
|
3993
|
+
return null;
|
|
3994
|
+
} finally {
|
|
3995
|
+
clearTimeout(timeout);
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
async function saveTranscriptResults(db, config, results, sessionId, cwd) {
|
|
3999
|
+
let saved = 0;
|
|
4000
|
+
const items = [
|
|
4001
|
+
...results.plans.map((item) => ({ item, type: "decision" })),
|
|
4002
|
+
...results.decisions.map((item) => ({ item, type: "decision" })),
|
|
4003
|
+
...results.insights.map((item) => ({ item, type: "discovery" }))
|
|
4004
|
+
];
|
|
4005
|
+
for (const { item, type } of items) {
|
|
4006
|
+
if (!item.title || item.title.trim().length === 0)
|
|
4007
|
+
continue;
|
|
4008
|
+
const result = await saveObservation(db, config, {
|
|
4009
|
+
type,
|
|
4010
|
+
title: item.title.slice(0, 80),
|
|
4011
|
+
narrative: item.narrative,
|
|
4012
|
+
concepts: item.concepts,
|
|
4013
|
+
session_id: sessionId,
|
|
4014
|
+
cwd
|
|
4015
|
+
});
|
|
4016
|
+
if (result.success)
|
|
4017
|
+
saved++;
|
|
4018
|
+
}
|
|
4019
|
+
return saved;
|
|
4020
|
+
}
|
|
4021
|
+
|
|
2434
4022
|
// hooks/pre-compact.ts
|
|
2435
4023
|
function formatCurrentSessionContext(observations) {
|
|
2436
4024
|
if (observations.length === 0)
|
|
@@ -2483,9 +4071,19 @@ async function main() {
|
|
|
2483
4071
|
process.exit(0);
|
|
2484
4072
|
}
|
|
2485
4073
|
try {
|
|
4074
|
+
let importedChat = 0;
|
|
4075
|
+
if (event.session_id) {
|
|
4076
|
+
const chatSync = syncTranscriptChat(db, config, event.session_id, event.cwd);
|
|
4077
|
+
importedChat = chatSync.imported;
|
|
4078
|
+
await upsertRollingHandoff(db, config, {
|
|
4079
|
+
session_id: event.session_id,
|
|
4080
|
+
cwd: event.cwd
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
2486
4083
|
const context = buildSessionContext(db, event.cwd, {
|
|
2487
4084
|
tokenBudget: 800,
|
|
2488
|
-
scope: config.search.scope
|
|
4085
|
+
scope: config.search.scope,
|
|
4086
|
+
currentDeviceId: config.device_id
|
|
2489
4087
|
});
|
|
2490
4088
|
if (context && context.observations.length > 0) {
|
|
2491
4089
|
console.log(formatContextForInjection(context));
|
|
@@ -2511,6 +4109,9 @@ async function main() {
|
|
|
2511
4109
|
if (sessionCount > 0) {
|
|
2512
4110
|
console.error(`Engrm: ${sessionCount} session observation(s) carried forward`);
|
|
2513
4111
|
}
|
|
4112
|
+
if (importedChat > 0) {
|
|
4113
|
+
console.error(`Engrm: ${importedChat} transcript chat message(s) preserved before compaction`);
|
|
4114
|
+
}
|
|
2514
4115
|
} finally {
|
|
2515
4116
|
db.close();
|
|
2516
4117
|
}
|