engrm 0.4.21 → 0.4.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/cli.js +326 -21
- package/dist/hooks/elicitation-result.js +245 -12
- package/dist/hooks/post-tool-use.js +333 -14
- package/dist/hooks/pre-compact.js +984 -14
- package/dist/hooks/sentinel.js +245 -12
- package/dist/hooks/session-start.js +1097 -90
- package/dist/hooks/stop.js +430 -75
- package/dist/hooks/user-prompt-submit.js +342 -13
- package/dist/server.js +923 -86
- 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'
|
|
@@ -572,6 +572,18 @@ var MIGRATIONS = [
|
|
|
572
572
|
},
|
|
573
573
|
{
|
|
574
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
|
+
},
|
|
585
|
+
{
|
|
586
|
+
version: 12,
|
|
575
587
|
description: "Add synced handoff metadata to session summaries",
|
|
576
588
|
sql: `
|
|
577
589
|
ALTER TABLE session_summaries ADD COLUMN capture_state TEXT;
|
|
@@ -581,15 +593,79 @@ 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);
|
|
593
669
|
`
|
|
594
670
|
}
|
|
595
671
|
];
|
|
@@ -646,6 +722,21 @@ function inferLegacySchemaVersion(db) {
|
|
|
646
722
|
version = Math.max(version, 10);
|
|
647
723
|
if (columnExists(db, "observations", "source_tool"))
|
|
648
724
|
version = Math.max(version, 11);
|
|
725
|
+
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")) {
|
|
726
|
+
version = Math.max(version, 12);
|
|
727
|
+
}
|
|
728
|
+
if (columnExists(db, "session_summaries", "current_thread")) {
|
|
729
|
+
version = Math.max(version, 13);
|
|
730
|
+
}
|
|
731
|
+
if (tableExists(db, "chat_messages")) {
|
|
732
|
+
version = Math.max(version, 14);
|
|
733
|
+
}
|
|
734
|
+
if (columnExists(db, "chat_messages", "remote_source_id")) {
|
|
735
|
+
version = Math.max(version, 15);
|
|
736
|
+
}
|
|
737
|
+
if (syncOutboxSupportsChatMessages(db)) {
|
|
738
|
+
version = Math.max(version, 16);
|
|
739
|
+
}
|
|
649
740
|
return version;
|
|
650
741
|
}
|
|
651
742
|
function runMigrations(db) {
|
|
@@ -724,6 +815,93 @@ function ensureObservationTypes(db) {
|
|
|
724
815
|
}
|
|
725
816
|
}
|
|
726
817
|
}
|
|
818
|
+
function ensureSessionSummaryColumns(db) {
|
|
819
|
+
const required = [
|
|
820
|
+
"capture_state",
|
|
821
|
+
"recent_tool_names",
|
|
822
|
+
"hot_files",
|
|
823
|
+
"recent_outcomes",
|
|
824
|
+
"current_thread"
|
|
825
|
+
];
|
|
826
|
+
for (const column of required) {
|
|
827
|
+
if (columnExists(db, "session_summaries", column))
|
|
828
|
+
continue;
|
|
829
|
+
db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
|
|
830
|
+
}
|
|
831
|
+
const current = getSchemaVersion(db);
|
|
832
|
+
if (current < 13) {
|
|
833
|
+
db.exec("PRAGMA user_version = 13");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
function ensureChatMessageColumns(db) {
|
|
837
|
+
if (!tableExists(db, "chat_messages"))
|
|
838
|
+
return;
|
|
839
|
+
if (!columnExists(db, "chat_messages", "remote_source_id")) {
|
|
840
|
+
db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
|
|
841
|
+
}
|
|
842
|
+
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");
|
|
843
|
+
const current = getSchemaVersion(db);
|
|
844
|
+
if (current < 15) {
|
|
845
|
+
db.exec("PRAGMA user_version = 15");
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
function ensureSyncOutboxSupportsChatMessages(db) {
|
|
849
|
+
if (syncOutboxSupportsChatMessages(db)) {
|
|
850
|
+
const current = getSchemaVersion(db);
|
|
851
|
+
if (current < 16) {
|
|
852
|
+
db.exec("PRAGMA user_version = 16");
|
|
853
|
+
}
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
db.exec("BEGIN TRANSACTION");
|
|
857
|
+
try {
|
|
858
|
+
db.exec(`
|
|
859
|
+
CREATE TABLE sync_outbox_new (
|
|
860
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
861
|
+
record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
|
|
862
|
+
record_id INTEGER NOT NULL,
|
|
863
|
+
status TEXT DEFAULT 'pending' CHECK (status IN (
|
|
864
|
+
'pending', 'syncing', 'synced', 'failed'
|
|
865
|
+
)),
|
|
866
|
+
retry_count INTEGER DEFAULT 0,
|
|
867
|
+
max_retries INTEGER DEFAULT 10,
|
|
868
|
+
last_error TEXT,
|
|
869
|
+
created_at_epoch INTEGER NOT NULL,
|
|
870
|
+
synced_at_epoch INTEGER,
|
|
871
|
+
next_retry_epoch INTEGER
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
INSERT INTO sync_outbox_new (
|
|
875
|
+
id, record_type, record_id, status, retry_count, max_retries,
|
|
876
|
+
last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
|
|
877
|
+
)
|
|
878
|
+
SELECT
|
|
879
|
+
id, record_type, record_id, status, retry_count, max_retries,
|
|
880
|
+
last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
|
|
881
|
+
FROM sync_outbox;
|
|
882
|
+
|
|
883
|
+
DROP TABLE sync_outbox;
|
|
884
|
+
ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
|
|
885
|
+
|
|
886
|
+
CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
|
|
887
|
+
CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
|
|
888
|
+
`);
|
|
889
|
+
db.exec("PRAGMA user_version = 16");
|
|
890
|
+
db.exec("COMMIT");
|
|
891
|
+
} catch (error) {
|
|
892
|
+
db.exec("ROLLBACK");
|
|
893
|
+
throw new Error(`sync_outbox repair failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
function syncOutboxSupportsChatMessages(db) {
|
|
897
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("sync_outbox");
|
|
898
|
+
const sql = row?.sql ?? "";
|
|
899
|
+
return sql.includes("'chat_message'");
|
|
900
|
+
}
|
|
901
|
+
function getSchemaVersion(db) {
|
|
902
|
+
const result = db.query("PRAGMA user_version").get();
|
|
903
|
+
return result.user_version;
|
|
904
|
+
}
|
|
727
905
|
var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
|
|
728
906
|
|
|
729
907
|
// src/storage/sqlite.ts
|
|
@@ -878,6 +1056,9 @@ class MemDatabase {
|
|
|
878
1056
|
this.vecAvailable = this.loadVecExtension();
|
|
879
1057
|
runMigrations(this.db);
|
|
880
1058
|
ensureObservationTypes(this.db);
|
|
1059
|
+
ensureSessionSummaryColumns(this.db);
|
|
1060
|
+
ensureChatMessageColumns(this.db);
|
|
1061
|
+
ensureSyncOutboxSupportsChatMessages(this.db);
|
|
881
1062
|
}
|
|
882
1063
|
loadVecExtension() {
|
|
883
1064
|
try {
|
|
@@ -1103,6 +1284,7 @@ class MemDatabase {
|
|
|
1103
1284
|
p.name AS project_name,
|
|
1104
1285
|
ss.request AS request,
|
|
1105
1286
|
ss.completed AS completed,
|
|
1287
|
+
ss.current_thread AS current_thread,
|
|
1106
1288
|
ss.capture_state AS capture_state,
|
|
1107
1289
|
ss.recent_tool_names AS recent_tool_names,
|
|
1108
1290
|
ss.hot_files AS hot_files,
|
|
@@ -1121,6 +1303,7 @@ class MemDatabase {
|
|
|
1121
1303
|
p.name AS project_name,
|
|
1122
1304
|
ss.request AS request,
|
|
1123
1305
|
ss.completed AS completed,
|
|
1306
|
+
ss.current_thread AS current_thread,
|
|
1124
1307
|
ss.capture_state AS capture_state,
|
|
1125
1308
|
ss.recent_tool_names AS recent_tool_names,
|
|
1126
1309
|
ss.hot_files AS hot_files,
|
|
@@ -1211,6 +1394,54 @@ class MemDatabase {
|
|
|
1211
1394
|
ORDER BY created_at_epoch DESC, id DESC
|
|
1212
1395
|
LIMIT ?`).all(...userId ? [userId] : [], limit);
|
|
1213
1396
|
}
|
|
1397
|
+
insertChatMessage(input) {
|
|
1398
|
+
const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
|
|
1399
|
+
const content = input.content.trim();
|
|
1400
|
+
const result = this.db.query(`INSERT INTO chat_messages (
|
|
1401
|
+
session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
|
|
1402
|
+
) 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);
|
|
1403
|
+
return this.getChatMessageById(Number(result.lastInsertRowid));
|
|
1404
|
+
}
|
|
1405
|
+
getChatMessageById(id) {
|
|
1406
|
+
return this.db.query("SELECT * FROM chat_messages WHERE id = ?").get(id) ?? null;
|
|
1407
|
+
}
|
|
1408
|
+
getChatMessageByRemoteSourceId(remoteSourceId) {
|
|
1409
|
+
return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
|
|
1410
|
+
}
|
|
1411
|
+
getSessionChatMessages(sessionId, limit = 50) {
|
|
1412
|
+
return this.db.query(`SELECT * FROM chat_messages
|
|
1413
|
+
WHERE session_id = ?
|
|
1414
|
+
ORDER BY created_at_epoch ASC, id ASC
|
|
1415
|
+
LIMIT ?`).all(sessionId, limit);
|
|
1416
|
+
}
|
|
1417
|
+
getRecentChatMessages(projectId, limit = 20, userId) {
|
|
1418
|
+
const visibilityClause = userId ? " AND user_id = ?" : "";
|
|
1419
|
+
if (projectId !== null) {
|
|
1420
|
+
return this.db.query(`SELECT * FROM chat_messages
|
|
1421
|
+
WHERE project_id = ?${visibilityClause}
|
|
1422
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1423
|
+
LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
|
|
1424
|
+
}
|
|
1425
|
+
return this.db.query(`SELECT * FROM chat_messages
|
|
1426
|
+
WHERE 1 = 1${visibilityClause}
|
|
1427
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1428
|
+
LIMIT ?`).all(...userId ? [userId] : [], limit);
|
|
1429
|
+
}
|
|
1430
|
+
searchChatMessages(query, projectId, limit = 20, userId) {
|
|
1431
|
+
const needle = `%${query.toLowerCase()}%`;
|
|
1432
|
+
const visibilityClause = userId ? " AND user_id = ?" : "";
|
|
1433
|
+
if (projectId !== null) {
|
|
1434
|
+
return this.db.query(`SELECT * FROM chat_messages
|
|
1435
|
+
WHERE project_id = ?
|
|
1436
|
+
AND lower(content) LIKE ?${visibilityClause}
|
|
1437
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1438
|
+
LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
|
|
1439
|
+
}
|
|
1440
|
+
return this.db.query(`SELECT * FROM chat_messages
|
|
1441
|
+
WHERE lower(content) LIKE ?${visibilityClause}
|
|
1442
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1443
|
+
LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
|
|
1444
|
+
}
|
|
1214
1445
|
addToOutbox(recordType, recordId) {
|
|
1215
1446
|
const now = Math.floor(Date.now() / 1000);
|
|
1216
1447
|
this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
|
|
@@ -1299,9 +1530,9 @@ class MemDatabase {
|
|
|
1299
1530
|
};
|
|
1300
1531
|
const result = this.db.query(`INSERT INTO session_summaries (
|
|
1301
1532
|
session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
|
|
1302
|
-
capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
|
|
1533
|
+
current_thread, capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
|
|
1303
1534
|
)
|
|
1304
|
-
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);
|
|
1535
|
+
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);
|
|
1305
1536
|
const id = Number(result.lastInsertRowid);
|
|
1306
1537
|
return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
|
|
1307
1538
|
}
|
|
@@ -1317,6 +1548,7 @@ class MemDatabase {
|
|
|
1317
1548
|
learned: normalizeSummarySection(summary.learned ?? existing.learned),
|
|
1318
1549
|
completed: normalizeSummarySection(summary.completed ?? existing.completed),
|
|
1319
1550
|
next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
|
|
1551
|
+
current_thread: summary.current_thread ?? existing.current_thread,
|
|
1320
1552
|
capture_state: summary.capture_state ?? existing.capture_state,
|
|
1321
1553
|
recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
|
|
1322
1554
|
hot_files: summary.hot_files ?? existing.hot_files,
|
|
@@ -1330,12 +1562,13 @@ class MemDatabase {
|
|
|
1330
1562
|
learned = ?,
|
|
1331
1563
|
completed = ?,
|
|
1332
1564
|
next_steps = ?,
|
|
1565
|
+
current_thread = ?,
|
|
1333
1566
|
capture_state = ?,
|
|
1334
1567
|
recent_tool_names = ?,
|
|
1335
1568
|
hot_files = ?,
|
|
1336
1569
|
recent_outcomes = ?,
|
|
1337
1570
|
created_at_epoch = ?
|
|
1338
|
-
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);
|
|
1571
|
+
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);
|
|
1339
1572
|
return this.getSessionSummary(summary.session_id);
|
|
1340
1573
|
}
|
|
1341
1574
|
getSessionSummary(sessionId) {
|
|
@@ -1815,6 +2048,729 @@ function computeObservationPriority(obs, nowEpoch) {
|
|
|
1815
2048
|
return computeBlendedScore(obs.quality, obs.created_at_epoch, nowEpoch) + observationTypeBoost(obs.type);
|
|
1816
2049
|
}
|
|
1817
2050
|
|
|
2051
|
+
// src/tools/save.ts
|
|
2052
|
+
import { relative, isAbsolute } from "node:path";
|
|
2053
|
+
|
|
2054
|
+
// src/capture/scrubber.ts
|
|
2055
|
+
var DEFAULT_PATTERNS = [
|
|
2056
|
+
{
|
|
2057
|
+
source: "sk-[a-zA-Z0-9]{20,}",
|
|
2058
|
+
flags: "g",
|
|
2059
|
+
replacement: "[REDACTED_API_KEY]",
|
|
2060
|
+
description: "OpenAI API keys",
|
|
2061
|
+
category: "api_key",
|
|
2062
|
+
severity: "critical"
|
|
2063
|
+
},
|
|
2064
|
+
{
|
|
2065
|
+
source: "Bearer [a-zA-Z0-9\\-._~+/]+=*",
|
|
2066
|
+
flags: "g",
|
|
2067
|
+
replacement: "[REDACTED_BEARER]",
|
|
2068
|
+
description: "Bearer auth tokens",
|
|
2069
|
+
category: "token",
|
|
2070
|
+
severity: "medium"
|
|
2071
|
+
},
|
|
2072
|
+
{
|
|
2073
|
+
source: "password[=:]\\s*\\S+",
|
|
2074
|
+
flags: "gi",
|
|
2075
|
+
replacement: "password=[REDACTED]",
|
|
2076
|
+
description: "Passwords in config",
|
|
2077
|
+
category: "password",
|
|
2078
|
+
severity: "high"
|
|
2079
|
+
},
|
|
2080
|
+
{
|
|
2081
|
+
source: "postgresql://[^\\s]+",
|
|
2082
|
+
flags: "g",
|
|
2083
|
+
replacement: "[REDACTED_DB_URL]",
|
|
2084
|
+
description: "PostgreSQL connection strings",
|
|
2085
|
+
category: "db_url",
|
|
2086
|
+
severity: "high"
|
|
2087
|
+
},
|
|
2088
|
+
{
|
|
2089
|
+
source: "mongodb://[^\\s]+",
|
|
2090
|
+
flags: "g",
|
|
2091
|
+
replacement: "[REDACTED_DB_URL]",
|
|
2092
|
+
description: "MongoDB connection strings",
|
|
2093
|
+
category: "db_url",
|
|
2094
|
+
severity: "high"
|
|
2095
|
+
},
|
|
2096
|
+
{
|
|
2097
|
+
source: "mysql://[^\\s]+",
|
|
2098
|
+
flags: "g",
|
|
2099
|
+
replacement: "[REDACTED_DB_URL]",
|
|
2100
|
+
description: "MySQL connection strings",
|
|
2101
|
+
category: "db_url",
|
|
2102
|
+
severity: "high"
|
|
2103
|
+
},
|
|
2104
|
+
{
|
|
2105
|
+
source: "AKIA[A-Z0-9]{16}",
|
|
2106
|
+
flags: "g",
|
|
2107
|
+
replacement: "[REDACTED_AWS_KEY]",
|
|
2108
|
+
description: "AWS access keys",
|
|
2109
|
+
category: "api_key",
|
|
2110
|
+
severity: "critical"
|
|
2111
|
+
},
|
|
2112
|
+
{
|
|
2113
|
+
source: "ghp_[a-zA-Z0-9]{36}",
|
|
2114
|
+
flags: "g",
|
|
2115
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
2116
|
+
description: "GitHub personal access tokens",
|
|
2117
|
+
category: "token",
|
|
2118
|
+
severity: "high"
|
|
2119
|
+
},
|
|
2120
|
+
{
|
|
2121
|
+
source: "gho_[a-zA-Z0-9]{36}",
|
|
2122
|
+
flags: "g",
|
|
2123
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
2124
|
+
description: "GitHub OAuth tokens",
|
|
2125
|
+
category: "token",
|
|
2126
|
+
severity: "high"
|
|
2127
|
+
},
|
|
2128
|
+
{
|
|
2129
|
+
source: "github_pat_[a-zA-Z0-9_]{22,}",
|
|
2130
|
+
flags: "g",
|
|
2131
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
2132
|
+
description: "GitHub fine-grained PATs",
|
|
2133
|
+
category: "token",
|
|
2134
|
+
severity: "high"
|
|
2135
|
+
},
|
|
2136
|
+
{
|
|
2137
|
+
source: "cvk_[a-f0-9]{64}",
|
|
2138
|
+
flags: "g",
|
|
2139
|
+
replacement: "[REDACTED_CANDENGO_KEY]",
|
|
2140
|
+
description: "Candengo API keys",
|
|
2141
|
+
category: "api_key",
|
|
2142
|
+
severity: "critical"
|
|
2143
|
+
},
|
|
2144
|
+
{
|
|
2145
|
+
source: "xox[bpras]-[a-zA-Z0-9\\-]+",
|
|
2146
|
+
flags: "g",
|
|
2147
|
+
replacement: "[REDACTED_SLACK_TOKEN]",
|
|
2148
|
+
description: "Slack tokens",
|
|
2149
|
+
category: "token",
|
|
2150
|
+
severity: "high"
|
|
2151
|
+
}
|
|
2152
|
+
];
|
|
2153
|
+
function compileCustomPatterns(patterns) {
|
|
2154
|
+
const compiled = [];
|
|
2155
|
+
for (const pattern of patterns) {
|
|
2156
|
+
try {
|
|
2157
|
+
new RegExp(pattern);
|
|
2158
|
+
compiled.push({
|
|
2159
|
+
source: pattern,
|
|
2160
|
+
flags: "g",
|
|
2161
|
+
replacement: "[REDACTED_CUSTOM]",
|
|
2162
|
+
description: `Custom pattern: ${pattern}`,
|
|
2163
|
+
category: "custom",
|
|
2164
|
+
severity: "medium"
|
|
2165
|
+
});
|
|
2166
|
+
} catch {}
|
|
2167
|
+
}
|
|
2168
|
+
return compiled;
|
|
2169
|
+
}
|
|
2170
|
+
function scrubSecrets(text, customPatterns = []) {
|
|
2171
|
+
let result = text;
|
|
2172
|
+
const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
|
|
2173
|
+
for (const pattern of allPatterns) {
|
|
2174
|
+
result = result.replace(new RegExp(pattern.source, pattern.flags), pattern.replacement);
|
|
2175
|
+
}
|
|
2176
|
+
return result;
|
|
2177
|
+
}
|
|
2178
|
+
function containsSecrets(text, customPatterns = []) {
|
|
2179
|
+
const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
|
|
2180
|
+
for (const pattern of allPatterns) {
|
|
2181
|
+
if (new RegExp(pattern.source, pattern.flags).test(text))
|
|
2182
|
+
return true;
|
|
2183
|
+
}
|
|
2184
|
+
return false;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
// src/capture/quality.ts
|
|
2188
|
+
var QUALITY_THRESHOLD = 0.1;
|
|
2189
|
+
function scoreQuality(input) {
|
|
2190
|
+
let score = 0;
|
|
2191
|
+
switch (input.type) {
|
|
2192
|
+
case "bugfix":
|
|
2193
|
+
score += 0.3;
|
|
2194
|
+
break;
|
|
2195
|
+
case "decision":
|
|
2196
|
+
score += 0.3;
|
|
2197
|
+
break;
|
|
2198
|
+
case "discovery":
|
|
2199
|
+
score += 0.2;
|
|
2200
|
+
break;
|
|
2201
|
+
case "pattern":
|
|
2202
|
+
score += 0.2;
|
|
2203
|
+
break;
|
|
2204
|
+
case "feature":
|
|
2205
|
+
score += 0.15;
|
|
2206
|
+
break;
|
|
2207
|
+
case "refactor":
|
|
2208
|
+
score += 0.15;
|
|
2209
|
+
break;
|
|
2210
|
+
case "change":
|
|
2211
|
+
score += 0.05;
|
|
2212
|
+
break;
|
|
2213
|
+
case "digest":
|
|
2214
|
+
score += 0.3;
|
|
2215
|
+
break;
|
|
2216
|
+
case "standard":
|
|
2217
|
+
score += 0.25;
|
|
2218
|
+
break;
|
|
2219
|
+
case "message":
|
|
2220
|
+
score += 0.1;
|
|
2221
|
+
break;
|
|
2222
|
+
}
|
|
2223
|
+
if (input.narrative && input.narrative.length > 50) {
|
|
2224
|
+
score += 0.15;
|
|
2225
|
+
}
|
|
2226
|
+
if (input.facts) {
|
|
2227
|
+
try {
|
|
2228
|
+
const factsArray = JSON.parse(input.facts);
|
|
2229
|
+
if (factsArray.length >= 2)
|
|
2230
|
+
score += 0.15;
|
|
2231
|
+
else if (factsArray.length === 1)
|
|
2232
|
+
score += 0.05;
|
|
2233
|
+
} catch {
|
|
2234
|
+
if (input.facts.length > 20)
|
|
2235
|
+
score += 0.05;
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
if (input.concepts) {
|
|
2239
|
+
try {
|
|
2240
|
+
const conceptsArray = JSON.parse(input.concepts);
|
|
2241
|
+
if (conceptsArray.length >= 1)
|
|
2242
|
+
score += 0.1;
|
|
2243
|
+
} catch {
|
|
2244
|
+
if (input.concepts.length > 10)
|
|
2245
|
+
score += 0.05;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
const modifiedCount = input.filesModified?.length ?? 0;
|
|
2249
|
+
if (modifiedCount >= 3)
|
|
2250
|
+
score += 0.2;
|
|
2251
|
+
else if (modifiedCount >= 1)
|
|
2252
|
+
score += 0.1;
|
|
2253
|
+
if (input.isDuplicate) {
|
|
2254
|
+
score -= 0.3;
|
|
2255
|
+
}
|
|
2256
|
+
return Math.max(0, Math.min(1, score));
|
|
2257
|
+
}
|
|
2258
|
+
function meetsQualityThreshold(input) {
|
|
2259
|
+
return scoreQuality(input) >= QUALITY_THRESHOLD;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// src/capture/facts.ts
|
|
2263
|
+
var FACT_ELIGIBLE_TYPES = new Set([
|
|
2264
|
+
"bugfix",
|
|
2265
|
+
"decision",
|
|
2266
|
+
"discovery",
|
|
2267
|
+
"pattern",
|
|
2268
|
+
"feature",
|
|
2269
|
+
"refactor",
|
|
2270
|
+
"change"
|
|
2271
|
+
]);
|
|
2272
|
+
function buildStructuredFacts(input) {
|
|
2273
|
+
const seedFacts = dedupeFacts(input.facts ?? []);
|
|
2274
|
+
if (!FACT_ELIGIBLE_TYPES.has(input.type)) {
|
|
2275
|
+
return seedFacts;
|
|
2276
|
+
}
|
|
2277
|
+
const derived = [...seedFacts];
|
|
2278
|
+
if (seedFacts.length === 0 && looksMeaningful(input.title)) {
|
|
2279
|
+
derived.push(input.title.trim());
|
|
2280
|
+
}
|
|
2281
|
+
for (const sentence of extractNarrativeFacts(input.narrative)) {
|
|
2282
|
+
derived.push(sentence);
|
|
2283
|
+
}
|
|
2284
|
+
const fileFact = buildFilesFact(input.filesModified);
|
|
2285
|
+
if (fileFact) {
|
|
2286
|
+
derived.push(fileFact);
|
|
2287
|
+
}
|
|
2288
|
+
return dedupeFacts(derived).slice(0, 4);
|
|
2289
|
+
}
|
|
2290
|
+
function extractNarrativeFacts(narrative) {
|
|
2291
|
+
if (!narrative)
|
|
2292
|
+
return [];
|
|
2293
|
+
const cleaned = narrative.replace(/\s+/g, " ").trim();
|
|
2294
|
+
if (cleaned.length < 24)
|
|
2295
|
+
return [];
|
|
2296
|
+
const parts = cleaned.split(/(?<=[.!?;])\s+/).map((part) => part.trim().replace(/[.!?;]+$/, "")).filter(Boolean).filter(looksMeaningful);
|
|
2297
|
+
return parts.slice(0, 2);
|
|
2298
|
+
}
|
|
2299
|
+
function buildFilesFact(filesModified) {
|
|
2300
|
+
if (!filesModified || filesModified.length === 0)
|
|
2301
|
+
return null;
|
|
2302
|
+
const cleaned = filesModified.map((file) => file.trim()).filter(Boolean).slice(0, 3);
|
|
2303
|
+
if (cleaned.length === 0)
|
|
2304
|
+
return null;
|
|
2305
|
+
if (cleaned.length === 1) {
|
|
2306
|
+
return `Touched ${cleaned[0]}`;
|
|
2307
|
+
}
|
|
2308
|
+
return `Touched ${cleaned.join(", ")}`;
|
|
2309
|
+
}
|
|
2310
|
+
function dedupeFacts(facts) {
|
|
2311
|
+
const seen = new Set;
|
|
2312
|
+
const result = [];
|
|
2313
|
+
for (const fact of facts) {
|
|
2314
|
+
const cleaned = fact.trim().replace(/\s+/g, " ");
|
|
2315
|
+
if (!looksMeaningful(cleaned))
|
|
2316
|
+
continue;
|
|
2317
|
+
const key = cleaned.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
|
|
2318
|
+
if (!key || seen.has(key))
|
|
2319
|
+
continue;
|
|
2320
|
+
seen.add(key);
|
|
2321
|
+
result.push(cleaned);
|
|
2322
|
+
}
|
|
2323
|
+
return result;
|
|
2324
|
+
}
|
|
2325
|
+
function looksMeaningful(value) {
|
|
2326
|
+
const cleaned = value.trim();
|
|
2327
|
+
if (cleaned.length < 12)
|
|
2328
|
+
return false;
|
|
2329
|
+
if (/^[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+$/.test(cleaned))
|
|
2330
|
+
return false;
|
|
2331
|
+
if (/^(updated|modified|edited|changed|touched)\s+[A-Za-z0-9_.\-\/]+$/i.test(cleaned))
|
|
2332
|
+
return false;
|
|
2333
|
+
return true;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// src/embeddings/embedder.ts
|
|
2337
|
+
var _available = null;
|
|
2338
|
+
var _pipeline = null;
|
|
2339
|
+
var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
|
|
2340
|
+
async function embedText(text) {
|
|
2341
|
+
const pipe = await getPipeline();
|
|
2342
|
+
if (!pipe)
|
|
2343
|
+
return null;
|
|
2344
|
+
try {
|
|
2345
|
+
const output = await pipe(text, { pooling: "mean", normalize: true });
|
|
2346
|
+
return new Float32Array(output.data);
|
|
2347
|
+
} catch {
|
|
2348
|
+
return null;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
function composeEmbeddingText(obs) {
|
|
2352
|
+
const parts = [obs.title];
|
|
2353
|
+
if (obs.narrative)
|
|
2354
|
+
parts.push(obs.narrative);
|
|
2355
|
+
if (obs.facts) {
|
|
2356
|
+
try {
|
|
2357
|
+
const facts = JSON.parse(obs.facts);
|
|
2358
|
+
if (Array.isArray(facts) && facts.length > 0) {
|
|
2359
|
+
parts.push(facts.map((f) => `- ${f}`).join(`
|
|
2360
|
+
`));
|
|
2361
|
+
}
|
|
2362
|
+
} catch {
|
|
2363
|
+
parts.push(obs.facts);
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
if (obs.concepts) {
|
|
2367
|
+
try {
|
|
2368
|
+
const concepts = JSON.parse(obs.concepts);
|
|
2369
|
+
if (Array.isArray(concepts) && concepts.length > 0) {
|
|
2370
|
+
parts.push(concepts.join(", "));
|
|
2371
|
+
}
|
|
2372
|
+
} catch {}
|
|
2373
|
+
}
|
|
2374
|
+
return parts.join(`
|
|
2375
|
+
|
|
2376
|
+
`);
|
|
2377
|
+
}
|
|
2378
|
+
async function getPipeline() {
|
|
2379
|
+
if (_pipeline)
|
|
2380
|
+
return _pipeline;
|
|
2381
|
+
if (_available === false)
|
|
2382
|
+
return null;
|
|
2383
|
+
try {
|
|
2384
|
+
const { pipeline } = await import("@xenova/transformers");
|
|
2385
|
+
_pipeline = await pipeline("feature-extraction", MODEL_NAME);
|
|
2386
|
+
_available = true;
|
|
2387
|
+
return _pipeline;
|
|
2388
|
+
} catch (err) {
|
|
2389
|
+
_available = false;
|
|
2390
|
+
console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
2391
|
+
return null;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// src/capture/recurrence.ts
|
|
2396
|
+
var DISTANCE_THRESHOLD = 0.15;
|
|
2397
|
+
async function detectRecurrence(db, config, observation) {
|
|
2398
|
+
if (observation.type !== "bugfix") {
|
|
2399
|
+
return { patternCreated: false };
|
|
2400
|
+
}
|
|
2401
|
+
if (!db.vecAvailable) {
|
|
2402
|
+
return { patternCreated: false };
|
|
2403
|
+
}
|
|
2404
|
+
const text = composeEmbeddingText(observation);
|
|
2405
|
+
const embedding = await embedText(text);
|
|
2406
|
+
if (!embedding) {
|
|
2407
|
+
return { patternCreated: false };
|
|
2408
|
+
}
|
|
2409
|
+
const vecResults = db.searchVec(embedding, null, ["active", "aging", "pinned"], 10);
|
|
2410
|
+
for (const match of vecResults) {
|
|
2411
|
+
if (match.observation_id === observation.id)
|
|
2412
|
+
continue;
|
|
2413
|
+
if (match.distance > DISTANCE_THRESHOLD)
|
|
2414
|
+
continue;
|
|
2415
|
+
const matched = db.getObservationById(match.observation_id);
|
|
2416
|
+
if (!matched)
|
|
2417
|
+
continue;
|
|
2418
|
+
if (matched.type !== "bugfix")
|
|
2419
|
+
continue;
|
|
2420
|
+
if (matched.session_id === observation.session_id)
|
|
2421
|
+
continue;
|
|
2422
|
+
if (await patternAlreadyExists(db, observation, matched))
|
|
2423
|
+
continue;
|
|
2424
|
+
let matchedProjectName;
|
|
2425
|
+
if (matched.project_id !== observation.project_id) {
|
|
2426
|
+
const proj = db.getProjectById(matched.project_id);
|
|
2427
|
+
if (proj)
|
|
2428
|
+
matchedProjectName = proj.name;
|
|
2429
|
+
}
|
|
2430
|
+
const similarity = 1 - match.distance;
|
|
2431
|
+
const result = await saveObservation(db, config, {
|
|
2432
|
+
type: "pattern",
|
|
2433
|
+
title: `Recurring bugfix: ${observation.title}`,
|
|
2434
|
+
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.`,
|
|
2435
|
+
facts: [
|
|
2436
|
+
`First seen: ${matched.created_at.split("T")[0]}`,
|
|
2437
|
+
`Recurred: ${observation.created_at.split("T")[0]}`,
|
|
2438
|
+
`Similarity: ${(similarity * 100).toFixed(0)}%`
|
|
2439
|
+
],
|
|
2440
|
+
concepts: mergeConceptsFromBoth(observation, matched),
|
|
2441
|
+
cwd: process.cwd(),
|
|
2442
|
+
session_id: observation.session_id ?? undefined
|
|
2443
|
+
});
|
|
2444
|
+
if (result.success && result.observation_id) {
|
|
2445
|
+
return {
|
|
2446
|
+
patternCreated: true,
|
|
2447
|
+
patternId: result.observation_id,
|
|
2448
|
+
matchedObservationId: matched.id,
|
|
2449
|
+
matchedProjectName,
|
|
2450
|
+
matchedTitle: matched.title,
|
|
2451
|
+
similarity
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
return { patternCreated: false };
|
|
2456
|
+
}
|
|
2457
|
+
async function patternAlreadyExists(db, obs1, obs2) {
|
|
2458
|
+
const recentPatterns = db.db.query(`SELECT * FROM observations
|
|
2459
|
+
WHERE type = 'pattern' AND lifecycle IN ('active', 'aging', 'pinned')
|
|
2460
|
+
AND title LIKE ?
|
|
2461
|
+
ORDER BY created_at_epoch DESC LIMIT 5`).all(`%${obs1.title.slice(0, 30)}%`);
|
|
2462
|
+
for (const p of recentPatterns) {
|
|
2463
|
+
if (p.narrative?.includes(obs2.title.slice(0, 30)))
|
|
2464
|
+
return true;
|
|
2465
|
+
}
|
|
2466
|
+
return false;
|
|
2467
|
+
}
|
|
2468
|
+
function mergeConceptsFromBoth(obs1, obs2) {
|
|
2469
|
+
const concepts = new Set;
|
|
2470
|
+
for (const obs of [obs1, obs2]) {
|
|
2471
|
+
if (obs.concepts) {
|
|
2472
|
+
try {
|
|
2473
|
+
const parsed = JSON.parse(obs.concepts);
|
|
2474
|
+
if (Array.isArray(parsed)) {
|
|
2475
|
+
for (const c of parsed) {
|
|
2476
|
+
if (typeof c === "string")
|
|
2477
|
+
concepts.add(c);
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
} catch {}
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
return [...concepts];
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// src/capture/conflict.ts
|
|
2487
|
+
var SIMILARITY_THRESHOLD = 0.25;
|
|
2488
|
+
async function detectDecisionConflict(db, observation) {
|
|
2489
|
+
if (observation.type !== "decision") {
|
|
2490
|
+
return { hasConflict: false };
|
|
2491
|
+
}
|
|
2492
|
+
if (!observation.narrative || observation.narrative.trim().length < 20) {
|
|
2493
|
+
return { hasConflict: false };
|
|
2494
|
+
}
|
|
2495
|
+
if (db.vecAvailable) {
|
|
2496
|
+
return detectViaVec(db, observation);
|
|
2497
|
+
}
|
|
2498
|
+
return detectViaFts(db, observation);
|
|
2499
|
+
}
|
|
2500
|
+
async function detectViaVec(db, observation) {
|
|
2501
|
+
const text = composeEmbeddingText(observation);
|
|
2502
|
+
const embedding = await embedText(text);
|
|
2503
|
+
if (!embedding)
|
|
2504
|
+
return { hasConflict: false };
|
|
2505
|
+
const results = db.searchVec(embedding, observation.project_id, ["active", "aging", "pinned"], 10);
|
|
2506
|
+
for (const match of results) {
|
|
2507
|
+
if (match.observation_id === observation.id)
|
|
2508
|
+
continue;
|
|
2509
|
+
if (match.distance > SIMILARITY_THRESHOLD)
|
|
2510
|
+
continue;
|
|
2511
|
+
const existing = db.getObservationById(match.observation_id);
|
|
2512
|
+
if (!existing)
|
|
2513
|
+
continue;
|
|
2514
|
+
if (existing.type !== "decision")
|
|
2515
|
+
continue;
|
|
2516
|
+
if (!existing.narrative)
|
|
2517
|
+
continue;
|
|
2518
|
+
const conflict = narrativesConflict(observation.narrative, existing.narrative);
|
|
2519
|
+
if (conflict) {
|
|
2520
|
+
return {
|
|
2521
|
+
hasConflict: true,
|
|
2522
|
+
conflictingId: existing.id,
|
|
2523
|
+
conflictingTitle: existing.title,
|
|
2524
|
+
reason: conflict
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
return { hasConflict: false };
|
|
2529
|
+
}
|
|
2530
|
+
async function detectViaFts(db, observation) {
|
|
2531
|
+
const keywords = observation.title.split(/\s+/).filter((w) => w.length > 3).slice(0, 5).join(" ");
|
|
2532
|
+
if (!keywords)
|
|
2533
|
+
return { hasConflict: false };
|
|
2534
|
+
const ftsResults = db.searchFts(keywords, observation.project_id, ["active", "aging", "pinned"], 10);
|
|
2535
|
+
for (const match of ftsResults) {
|
|
2536
|
+
if (match.id === observation.id)
|
|
2537
|
+
continue;
|
|
2538
|
+
const existing = db.getObservationById(match.id);
|
|
2539
|
+
if (!existing)
|
|
2540
|
+
continue;
|
|
2541
|
+
if (existing.type !== "decision")
|
|
2542
|
+
continue;
|
|
2543
|
+
if (!existing.narrative)
|
|
2544
|
+
continue;
|
|
2545
|
+
const conflict = narrativesConflict(observation.narrative, existing.narrative);
|
|
2546
|
+
if (conflict) {
|
|
2547
|
+
return {
|
|
2548
|
+
hasConflict: true,
|
|
2549
|
+
conflictingId: existing.id,
|
|
2550
|
+
conflictingTitle: existing.title,
|
|
2551
|
+
reason: conflict
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
return { hasConflict: false };
|
|
2556
|
+
}
|
|
2557
|
+
function narrativesConflict(narrative1, narrative2) {
|
|
2558
|
+
const n1 = narrative1.toLowerCase();
|
|
2559
|
+
const n2 = narrative2.toLowerCase();
|
|
2560
|
+
const opposingPairs = [
|
|
2561
|
+
[["should use", "decided to use", "chose", "prefer", "went with"], ["should not", "decided against", "avoid", "rejected", "don't use"]],
|
|
2562
|
+
[["enable", "turn on", "activate", "add"], ["disable", "turn off", "deactivate", "remove"]],
|
|
2563
|
+
[["increase", "more", "higher", "scale up"], ["decrease", "less", "lower", "scale down"]],
|
|
2564
|
+
[["keep", "maintain", "preserve"], ["replace", "migrate", "switch from", "deprecate"]]
|
|
2565
|
+
];
|
|
2566
|
+
for (const [positive, negative] of opposingPairs) {
|
|
2567
|
+
const n1HasPositive = positive.some((w) => n1.includes(w));
|
|
2568
|
+
const n1HasNegative = negative.some((w) => n1.includes(w));
|
|
2569
|
+
const n2HasPositive = positive.some((w) => n2.includes(w));
|
|
2570
|
+
const n2HasNegative = negative.some((w) => n2.includes(w));
|
|
2571
|
+
if (n1HasPositive && n2HasNegative || n1HasNegative && n2HasPositive) {
|
|
2572
|
+
return "Narratives suggest opposing conclusions on a similar topic";
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
return null;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// src/tools/save.ts
|
|
2579
|
+
var VALID_TYPES = [
|
|
2580
|
+
"bugfix",
|
|
2581
|
+
"discovery",
|
|
2582
|
+
"decision",
|
|
2583
|
+
"pattern",
|
|
2584
|
+
"change",
|
|
2585
|
+
"feature",
|
|
2586
|
+
"refactor",
|
|
2587
|
+
"digest",
|
|
2588
|
+
"standard",
|
|
2589
|
+
"message"
|
|
2590
|
+
];
|
|
2591
|
+
async function saveObservation(db, config, input) {
|
|
2592
|
+
if (!VALID_TYPES.includes(input.type)) {
|
|
2593
|
+
return {
|
|
2594
|
+
success: false,
|
|
2595
|
+
reason: `Invalid type '${input.type}'. Must be one of: ${VALID_TYPES.join(", ")}`
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
if (!input.title || input.title.trim().length === 0) {
|
|
2599
|
+
return { success: false, reason: "Title is required" };
|
|
2600
|
+
}
|
|
2601
|
+
const cwd = input.cwd ?? process.cwd();
|
|
2602
|
+
const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
|
|
2603
|
+
const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
|
|
2604
|
+
const project = db.upsertProject({
|
|
2605
|
+
canonical_id: detected.canonical_id,
|
|
2606
|
+
name: detected.name,
|
|
2607
|
+
local_path: detected.local_path,
|
|
2608
|
+
remote_url: detected.remote_url
|
|
2609
|
+
});
|
|
2610
|
+
const customPatterns = config.scrubbing.enabled ? config.scrubbing.custom_patterns : [];
|
|
2611
|
+
const title = config.scrubbing.enabled ? scrubSecrets(input.title, customPatterns) : input.title;
|
|
2612
|
+
const narrative = input.narrative ? config.scrubbing.enabled ? scrubSecrets(input.narrative, customPatterns) : input.narrative : null;
|
|
2613
|
+
const conceptsJson = input.concepts ? JSON.stringify(input.concepts) : null;
|
|
2614
|
+
const filesRead = input.files_read ? input.files_read.map((f) => toRelativePath(f, cwd)) : null;
|
|
2615
|
+
const filesModified = input.files_modified ? input.files_modified.map((f) => toRelativePath(f, cwd)) : null;
|
|
2616
|
+
const structuredFacts = buildStructuredFacts({
|
|
2617
|
+
type: input.type,
|
|
2618
|
+
title: input.title,
|
|
2619
|
+
narrative: input.narrative,
|
|
2620
|
+
facts: input.facts,
|
|
2621
|
+
filesModified
|
|
2622
|
+
});
|
|
2623
|
+
const factsJson = structuredFacts.length > 0 ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(structuredFacts), customPatterns) : JSON.stringify(structuredFacts) : null;
|
|
2624
|
+
const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
|
|
2625
|
+
const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
|
|
2626
|
+
let sensitivity = input.sensitivity ?? config.scrubbing.default_sensitivity;
|
|
2627
|
+
if (config.scrubbing.enabled && containsSecrets([input.title, input.narrative, JSON.stringify(input.facts)].filter(Boolean).join(" "), customPatterns)) {
|
|
2628
|
+
if (sensitivity === "shared") {
|
|
2629
|
+
sensitivity = "personal";
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
|
|
2633
|
+
const recentObs = db.getRecentObservations(project.id, oneDayAgo);
|
|
2634
|
+
const candidates = recentObs.map((o) => ({
|
|
2635
|
+
id: o.id,
|
|
2636
|
+
title: o.title
|
|
2637
|
+
}));
|
|
2638
|
+
const duplicate = findDuplicate(title, candidates);
|
|
2639
|
+
const qualityInput = {
|
|
2640
|
+
type: input.type,
|
|
2641
|
+
title,
|
|
2642
|
+
narrative,
|
|
2643
|
+
facts: factsJson,
|
|
2644
|
+
concepts: conceptsJson,
|
|
2645
|
+
filesRead,
|
|
2646
|
+
filesModified,
|
|
2647
|
+
isDuplicate: duplicate !== null
|
|
2648
|
+
};
|
|
2649
|
+
const qualityScore = scoreQuality(qualityInput);
|
|
2650
|
+
if (!meetsQualityThreshold(qualityInput)) {
|
|
2651
|
+
return {
|
|
2652
|
+
success: false,
|
|
2653
|
+
quality_score: qualityScore,
|
|
2654
|
+
reason: `Quality score ${qualityScore.toFixed(2)} below threshold`
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
if (duplicate) {
|
|
2658
|
+
return {
|
|
2659
|
+
success: true,
|
|
2660
|
+
merged_into: duplicate.id,
|
|
2661
|
+
quality_score: qualityScore,
|
|
2662
|
+
reason: `Merged into existing observation #${duplicate.id}`
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
const sourcePromptNumber = input.source_prompt_number ?? (input.session_id ? db.getLatestSessionPromptNumber(input.session_id) : null);
|
|
2666
|
+
const obs = db.insertObservation({
|
|
2667
|
+
session_id: input.session_id ?? null,
|
|
2668
|
+
project_id: project.id,
|
|
2669
|
+
type: input.type,
|
|
2670
|
+
title,
|
|
2671
|
+
narrative,
|
|
2672
|
+
facts: factsJson,
|
|
2673
|
+
concepts: conceptsJson,
|
|
2674
|
+
files_read: filesReadJson,
|
|
2675
|
+
files_modified: filesModifiedJson,
|
|
2676
|
+
quality: qualityScore,
|
|
2677
|
+
lifecycle: "active",
|
|
2678
|
+
sensitivity,
|
|
2679
|
+
user_id: config.user_id,
|
|
2680
|
+
device_id: config.device_id,
|
|
2681
|
+
agent: input.agent ?? "claude-code",
|
|
2682
|
+
source_tool: input.source_tool ?? null,
|
|
2683
|
+
source_prompt_number: sourcePromptNumber
|
|
2684
|
+
});
|
|
2685
|
+
db.addToOutbox("observation", obs.id);
|
|
2686
|
+
if (db.vecAvailable) {
|
|
2687
|
+
try {
|
|
2688
|
+
const text = composeEmbeddingText(obs);
|
|
2689
|
+
const embedding = await embedText(text);
|
|
2690
|
+
if (embedding) {
|
|
2691
|
+
db.vecInsert(obs.id, embedding);
|
|
2692
|
+
}
|
|
2693
|
+
} catch {}
|
|
2694
|
+
}
|
|
2695
|
+
let recallHint;
|
|
2696
|
+
if (input.type === "bugfix") {
|
|
2697
|
+
try {
|
|
2698
|
+
const recurrence = await detectRecurrence(db, config, obs);
|
|
2699
|
+
if (recurrence.patternCreated && recurrence.matchedTitle) {
|
|
2700
|
+
const projectLabel = recurrence.matchedProjectName ? ` in ${recurrence.matchedProjectName}` : "";
|
|
2701
|
+
recallHint = `You solved a similar issue${projectLabel}: "${recurrence.matchedTitle}"`;
|
|
2702
|
+
}
|
|
2703
|
+
} catch {}
|
|
2704
|
+
}
|
|
2705
|
+
let conflictWarning;
|
|
2706
|
+
if (input.type === "decision") {
|
|
2707
|
+
try {
|
|
2708
|
+
const conflict = await detectDecisionConflict(db, obs);
|
|
2709
|
+
if (conflict.hasConflict && conflict.conflictingTitle) {
|
|
2710
|
+
conflictWarning = `Potential conflict with existing decision: "${conflict.conflictingTitle}" — ${conflict.reason}`;
|
|
2711
|
+
}
|
|
2712
|
+
} catch {}
|
|
2713
|
+
}
|
|
2714
|
+
return {
|
|
2715
|
+
success: true,
|
|
2716
|
+
observation_id: obs.id,
|
|
2717
|
+
quality_score: qualityScore,
|
|
2718
|
+
recall_hint: recallHint,
|
|
2719
|
+
conflict_warning: conflictWarning
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
function toRelativePath(filePath, projectRoot) {
|
|
2723
|
+
if (!isAbsolute(filePath))
|
|
2724
|
+
return filePath;
|
|
2725
|
+
const rel = relative(projectRoot, filePath);
|
|
2726
|
+
if (rel.startsWith(".."))
|
|
2727
|
+
return filePath;
|
|
2728
|
+
return rel;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// src/tools/handoffs.ts
|
|
2732
|
+
function getRecentHandoffs(db, input) {
|
|
2733
|
+
const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
|
|
2734
|
+
const projectScoped = input.project_scoped !== false;
|
|
2735
|
+
let projectId = null;
|
|
2736
|
+
let projectName;
|
|
2737
|
+
if (projectScoped) {
|
|
2738
|
+
const cwd = input.cwd ?? process.cwd();
|
|
2739
|
+
const detected = detectProject(cwd);
|
|
2740
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
2741
|
+
if (project) {
|
|
2742
|
+
projectId = project.id;
|
|
2743
|
+
projectName = project.name;
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
const conditions = [
|
|
2747
|
+
"o.type = 'message'",
|
|
2748
|
+
"o.lifecycle IN ('active', 'aging', 'pinned')",
|
|
2749
|
+
"o.superseded_by IS NULL",
|
|
2750
|
+
`(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
|
|
2751
|
+
];
|
|
2752
|
+
const params = [];
|
|
2753
|
+
if (input.user_id) {
|
|
2754
|
+
conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
|
|
2755
|
+
params.push(input.user_id);
|
|
2756
|
+
}
|
|
2757
|
+
if (projectId !== null) {
|
|
2758
|
+
conditions.push("o.project_id = ?");
|
|
2759
|
+
params.push(projectId);
|
|
2760
|
+
}
|
|
2761
|
+
params.push(limit);
|
|
2762
|
+
const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
|
|
2763
|
+
FROM observations o
|
|
2764
|
+
LEFT JOIN projects p ON p.id = o.project_id
|
|
2765
|
+
WHERE ${conditions.join(" AND ")}
|
|
2766
|
+
ORDER BY o.created_at_epoch DESC, o.id DESC
|
|
2767
|
+
LIMIT ?`).all(...params);
|
|
2768
|
+
return {
|
|
2769
|
+
handoffs,
|
|
2770
|
+
project: projectName
|
|
2771
|
+
};
|
|
2772
|
+
}
|
|
2773
|
+
|
|
1818
2774
|
// src/context/inject.ts
|
|
1819
2775
|
function tokenizeProjectHint(text) {
|
|
1820
2776
|
return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
|
|
@@ -1961,6 +2917,12 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1961
2917
|
const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
|
|
1962
2918
|
const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
|
|
1963
2919
|
const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions2);
|
|
2920
|
+
const recentHandoffs2 = getRecentHandoffs(db, {
|
|
2921
|
+
cwd,
|
|
2922
|
+
project_scoped: !isNewProject,
|
|
2923
|
+
user_id: opts.userId,
|
|
2924
|
+
limit: 3
|
|
2925
|
+
}).handoffs;
|
|
1964
2926
|
return {
|
|
1965
2927
|
project_name: projectName,
|
|
1966
2928
|
canonical_id: canonicalId,
|
|
@@ -1971,7 +2933,8 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1971
2933
|
recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
|
|
1972
2934
|
recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
|
|
1973
2935
|
projectTypeCounts: projectTypeCounts2,
|
|
1974
|
-
recentOutcomes: recentOutcomes2
|
|
2936
|
+
recentOutcomes: recentOutcomes2,
|
|
2937
|
+
recentHandoffs: recentHandoffs2.length > 0 ? recentHandoffs2 : undefined
|
|
1975
2938
|
};
|
|
1976
2939
|
}
|
|
1977
2940
|
let remainingBudget = tokenBudget - 30;
|
|
@@ -1999,6 +2962,12 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1999
2962
|
const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
|
|
2000
2963
|
const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
|
|
2001
2964
|
const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions);
|
|
2965
|
+
const recentHandoffs = getRecentHandoffs(db, {
|
|
2966
|
+
cwd,
|
|
2967
|
+
project_scoped: !isNewProject,
|
|
2968
|
+
user_id: opts.userId,
|
|
2969
|
+
limit: 3
|
|
2970
|
+
}).handoffs;
|
|
2002
2971
|
let securityFindings = [];
|
|
2003
2972
|
if (!isNewProject) {
|
|
2004
2973
|
try {
|
|
@@ -2057,7 +3026,8 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
2057
3026
|
recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
|
|
2058
3027
|
recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
|
|
2059
3028
|
projectTypeCounts,
|
|
2060
|
-
recentOutcomes
|
|
3029
|
+
recentOutcomes,
|
|
3030
|
+
recentHandoffs: recentHandoffs.length > 0 ? recentHandoffs : undefined
|
|
2061
3031
|
};
|
|
2062
3032
|
}
|
|
2063
3033
|
function estimateObservationTokens(obs, index) {
|