engrm 0.4.22 → 0.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -483,7 +483,7 @@ var MIGRATIONS = [
483
483
  -- Sync outbox (offline-first queue)
484
484
  CREATE TABLE sync_outbox (
485
485
  id INTEGER PRIMARY KEY AUTOINCREMENT,
486
- record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
486
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
487
487
  record_id INTEGER NOT NULL,
488
488
  status TEXT DEFAULT 'pending' CHECK (status IN (
489
489
  'pending', 'syncing', 'synced', 'failed'
@@ -776,6 +776,18 @@ var MIGRATIONS = [
776
776
  ON tool_events(created_at_epoch DESC, id DESC);
777
777
  `
778
778
  },
779
+ {
780
+ version: 11,
781
+ description: "Add observation provenance from tool and prompt chronology",
782
+ sql: `
783
+ ALTER TABLE observations ADD COLUMN source_tool TEXT;
784
+ ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
785
+ CREATE INDEX IF NOT EXISTS idx_observations_source_tool
786
+ ON observations(source_tool, created_at_epoch DESC);
787
+ CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
788
+ ON observations(session_id, source_prompt_number DESC);
789
+ `
790
+ },
779
791
  {
780
792
  version: 12,
781
793
  description: "Add synced handoff metadata to session summaries",
@@ -787,15 +799,79 @@ var MIGRATIONS = [
787
799
  `
788
800
  },
789
801
  {
790
- version: 11,
791
- description: "Add observation provenance from tool and prompt chronology",
802
+ version: 13,
803
+ description: "Add current_thread to session summaries",
792
804
  sql: `
793
- ALTER TABLE observations ADD COLUMN source_tool TEXT;
794
- ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
795
- CREATE INDEX IF NOT EXISTS idx_observations_source_tool
796
- ON observations(source_tool, created_at_epoch DESC);
797
- CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
798
- ON observations(session_id, source_prompt_number DESC);
805
+ ALTER TABLE session_summaries ADD COLUMN current_thread TEXT;
806
+ `
807
+ },
808
+ {
809
+ version: 14,
810
+ description: "Add chat_messages lane for raw conversation recall",
811
+ sql: `
812
+ CREATE TABLE IF NOT EXISTS chat_messages (
813
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
814
+ session_id TEXT NOT NULL,
815
+ project_id INTEGER REFERENCES projects(id),
816
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
817
+ content TEXT NOT NULL,
818
+ user_id TEXT NOT NULL,
819
+ device_id TEXT NOT NULL,
820
+ agent TEXT DEFAULT 'claude-code',
821
+ created_at_epoch INTEGER NOT NULL
822
+ );
823
+
824
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_session
825
+ ON chat_messages(session_id, created_at_epoch DESC, id DESC);
826
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_project
827
+ ON chat_messages(project_id, created_at_epoch DESC, id DESC);
828
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_created
829
+ ON chat_messages(created_at_epoch DESC, id DESC);
830
+ `
831
+ },
832
+ {
833
+ version: 15,
834
+ description: "Add remote_source_id for chat message sync deduplication",
835
+ sql: `
836
+ ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT;
837
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source
838
+ ON chat_messages(remote_source_id)
839
+ WHERE remote_source_id IS NOT NULL;
840
+ `
841
+ },
842
+ {
843
+ version: 16,
844
+ description: "Allow chat_message records in sync_outbox",
845
+ sql: `
846
+ CREATE TABLE sync_outbox_new (
847
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
848
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
849
+ record_id INTEGER NOT NULL,
850
+ status TEXT DEFAULT 'pending' CHECK (status IN (
851
+ 'pending', 'syncing', 'synced', 'failed'
852
+ )),
853
+ retry_count INTEGER DEFAULT 0,
854
+ max_retries INTEGER DEFAULT 10,
855
+ last_error TEXT,
856
+ created_at_epoch INTEGER NOT NULL,
857
+ synced_at_epoch INTEGER,
858
+ next_retry_epoch INTEGER
859
+ );
860
+
861
+ INSERT INTO sync_outbox_new (
862
+ id, record_type, record_id, status, retry_count, max_retries,
863
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
864
+ )
865
+ SELECT
866
+ id, record_type, record_id, status, retry_count, max_retries,
867
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
868
+ FROM sync_outbox;
869
+
870
+ DROP TABLE sync_outbox;
871
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
872
+
873
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
874
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
799
875
  `
800
876
  }
801
877
  ];
@@ -855,6 +931,18 @@ function inferLegacySchemaVersion(db) {
855
931
  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")) {
856
932
  version = Math.max(version, 12);
857
933
  }
934
+ if (columnExists(db, "session_summaries", "current_thread")) {
935
+ version = Math.max(version, 13);
936
+ }
937
+ if (tableExists(db, "chat_messages")) {
938
+ version = Math.max(version, 14);
939
+ }
940
+ if (columnExists(db, "chat_messages", "remote_source_id")) {
941
+ version = Math.max(version, 15);
942
+ }
943
+ if (syncOutboxSupportsChatMessages(db)) {
944
+ version = Math.max(version, 16);
945
+ }
858
946
  return version;
859
947
  }
860
948
  function runMigrations(db) {
@@ -938,7 +1026,8 @@ function ensureSessionSummaryColumns(db) {
938
1026
  "capture_state",
939
1027
  "recent_tool_names",
940
1028
  "hot_files",
941
- "recent_outcomes"
1029
+ "recent_outcomes",
1030
+ "current_thread"
942
1031
  ];
943
1032
  for (const column of required) {
944
1033
  if (columnExists(db, "session_summaries", column))
@@ -946,10 +1035,75 @@ function ensureSessionSummaryColumns(db) {
946
1035
  db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
947
1036
  }
948
1037
  const current = getSchemaVersion(db);
949
- if (current < 12) {
950
- db.exec("PRAGMA user_version = 12");
1038
+ if (current < 13) {
1039
+ db.exec("PRAGMA user_version = 13");
951
1040
  }
952
1041
  }
1042
+ function ensureChatMessageColumns(db) {
1043
+ if (!tableExists(db, "chat_messages"))
1044
+ return;
1045
+ if (!columnExists(db, "chat_messages", "remote_source_id")) {
1046
+ db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
1047
+ }
1048
+ 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");
1049
+ const current = getSchemaVersion(db);
1050
+ if (current < 15) {
1051
+ db.exec("PRAGMA user_version = 15");
1052
+ }
1053
+ }
1054
+ function ensureSyncOutboxSupportsChatMessages(db) {
1055
+ if (syncOutboxSupportsChatMessages(db)) {
1056
+ const current = getSchemaVersion(db);
1057
+ if (current < 16) {
1058
+ db.exec("PRAGMA user_version = 16");
1059
+ }
1060
+ return;
1061
+ }
1062
+ db.exec("BEGIN TRANSACTION");
1063
+ try {
1064
+ db.exec(`
1065
+ CREATE TABLE sync_outbox_new (
1066
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1067
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
1068
+ record_id INTEGER NOT NULL,
1069
+ status TEXT DEFAULT 'pending' CHECK (status IN (
1070
+ 'pending', 'syncing', 'synced', 'failed'
1071
+ )),
1072
+ retry_count INTEGER DEFAULT 0,
1073
+ max_retries INTEGER DEFAULT 10,
1074
+ last_error TEXT,
1075
+ created_at_epoch INTEGER NOT NULL,
1076
+ synced_at_epoch INTEGER,
1077
+ next_retry_epoch INTEGER
1078
+ );
1079
+
1080
+ INSERT INTO sync_outbox_new (
1081
+ id, record_type, record_id, status, retry_count, max_retries,
1082
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
1083
+ )
1084
+ SELECT
1085
+ id, record_type, record_id, status, retry_count, max_retries,
1086
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
1087
+ FROM sync_outbox;
1088
+
1089
+ DROP TABLE sync_outbox;
1090
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
1091
+
1092
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
1093
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
1094
+ `);
1095
+ db.exec("PRAGMA user_version = 16");
1096
+ db.exec("COMMIT");
1097
+ } catch (error) {
1098
+ db.exec("ROLLBACK");
1099
+ throw new Error(`sync_outbox repair failed: ${error instanceof Error ? error.message : String(error)}`);
1100
+ }
1101
+ }
1102
+ function syncOutboxSupportsChatMessages(db) {
1103
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("sync_outbox");
1104
+ const sql = row?.sql ?? "";
1105
+ return sql.includes("'chat_message'");
1106
+ }
953
1107
  function getSchemaVersion(db) {
954
1108
  const result = db.query("PRAGMA user_version").get();
955
1109
  return result.user_version;
@@ -1109,6 +1263,8 @@ class MemDatabase {
1109
1263
  runMigrations(this.db);
1110
1264
  ensureObservationTypes(this.db);
1111
1265
  ensureSessionSummaryColumns(this.db);
1266
+ ensureChatMessageColumns(this.db);
1267
+ ensureSyncOutboxSupportsChatMessages(this.db);
1112
1268
  }
1113
1269
  loadVecExtension() {
1114
1270
  try {
@@ -1334,6 +1490,7 @@ class MemDatabase {
1334
1490
  p.name AS project_name,
1335
1491
  ss.request AS request,
1336
1492
  ss.completed AS completed,
1493
+ ss.current_thread AS current_thread,
1337
1494
  ss.capture_state AS capture_state,
1338
1495
  ss.recent_tool_names AS recent_tool_names,
1339
1496
  ss.hot_files AS hot_files,
@@ -1352,6 +1509,7 @@ class MemDatabase {
1352
1509
  p.name AS project_name,
1353
1510
  ss.request AS request,
1354
1511
  ss.completed AS completed,
1512
+ ss.current_thread AS current_thread,
1355
1513
  ss.capture_state AS capture_state,
1356
1514
  ss.recent_tool_names AS recent_tool_names,
1357
1515
  ss.hot_files AS hot_files,
@@ -1442,6 +1600,54 @@ class MemDatabase {
1442
1600
  ORDER BY created_at_epoch DESC, id DESC
1443
1601
  LIMIT ?`).all(...userId ? [userId] : [], limit);
1444
1602
  }
1603
+ insertChatMessage(input) {
1604
+ const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1605
+ const content = input.content.trim();
1606
+ const result = this.db.query(`INSERT INTO chat_messages (
1607
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
1608
+ ) 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);
1609
+ return this.getChatMessageById(Number(result.lastInsertRowid));
1610
+ }
1611
+ getChatMessageById(id) {
1612
+ return this.db.query("SELECT * FROM chat_messages WHERE id = ?").get(id) ?? null;
1613
+ }
1614
+ getChatMessageByRemoteSourceId(remoteSourceId) {
1615
+ return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
1616
+ }
1617
+ getSessionChatMessages(sessionId, limit = 50) {
1618
+ return this.db.query(`SELECT * FROM chat_messages
1619
+ WHERE session_id = ?
1620
+ ORDER BY created_at_epoch ASC, id ASC
1621
+ LIMIT ?`).all(sessionId, limit);
1622
+ }
1623
+ getRecentChatMessages(projectId, limit = 20, userId) {
1624
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1625
+ if (projectId !== null) {
1626
+ return this.db.query(`SELECT * FROM chat_messages
1627
+ WHERE project_id = ?${visibilityClause}
1628
+ ORDER BY created_at_epoch DESC, id DESC
1629
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1630
+ }
1631
+ return this.db.query(`SELECT * FROM chat_messages
1632
+ WHERE 1 = 1${visibilityClause}
1633
+ ORDER BY created_at_epoch DESC, id DESC
1634
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
1635
+ }
1636
+ searchChatMessages(query, projectId, limit = 20, userId) {
1637
+ const needle = `%${query.toLowerCase()}%`;
1638
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1639
+ if (projectId !== null) {
1640
+ return this.db.query(`SELECT * FROM chat_messages
1641
+ WHERE project_id = ?
1642
+ AND lower(content) LIKE ?${visibilityClause}
1643
+ ORDER BY created_at_epoch DESC, id DESC
1644
+ LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
1645
+ }
1646
+ return this.db.query(`SELECT * FROM chat_messages
1647
+ WHERE lower(content) LIKE ?${visibilityClause}
1648
+ ORDER BY created_at_epoch DESC, id DESC
1649
+ LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1650
+ }
1445
1651
  addToOutbox(recordType, recordId) {
1446
1652
  const now = Math.floor(Date.now() / 1000);
1447
1653
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -1530,9 +1736,9 @@ class MemDatabase {
1530
1736
  };
1531
1737
  const result = this.db.query(`INSERT INTO session_summaries (
1532
1738
  session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
1533
- capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1739
+ current_thread, capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1534
1740
  )
1535
- 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);
1741
+ 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);
1536
1742
  const id = Number(result.lastInsertRowid);
1537
1743
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
1538
1744
  }
@@ -1548,6 +1754,7 @@ class MemDatabase {
1548
1754
  learned: normalizeSummarySection(summary.learned ?? existing.learned),
1549
1755
  completed: normalizeSummarySection(summary.completed ?? existing.completed),
1550
1756
  next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
1757
+ current_thread: summary.current_thread ?? existing.current_thread,
1551
1758
  capture_state: summary.capture_state ?? existing.capture_state,
1552
1759
  recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
1553
1760
  hot_files: summary.hot_files ?? existing.hot_files,
@@ -1561,12 +1768,13 @@ class MemDatabase {
1561
1768
  learned = ?,
1562
1769
  completed = ?,
1563
1770
  next_steps = ?,
1771
+ current_thread = ?,
1564
1772
  capture_state = ?,
1565
1773
  recent_tool_names = ?,
1566
1774
  hot_files = ?,
1567
1775
  recent_outcomes = ?,
1568
1776
  created_at_epoch = ?
1569
- 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);
1777
+ 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);
1570
1778
  return this.getSessionSummary(summary.session_id);
1571
1779
  }
1572
1780
  getSessionSummary(sessionId) {
@@ -3296,11 +3504,13 @@ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
3296
3504
  return acc;
3297
3505
  }, new Map).entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
3298
3506
  const latestObservationPromptNumber = observations.map((obs) => obs.source_prompt_number).filter((value) => typeof value === "number").sort((a, b) => b - a)[0] ?? null;
3507
+ const currentThread = buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolNames);
3299
3508
  return {
3300
3509
  prompt_count: prompts.length,
3301
3510
  tool_event_count: toolEvents.length,
3302
3511
  recent_request_prompts: recentRequestPrompts,
3303
3512
  latest_request: latestRequest,
3513
+ current_thread: currentThread,
3304
3514
  recent_tool_names: recentToolNames,
3305
3515
  recent_tool_commands: recentToolCommands,
3306
3516
  capture_state: captureState,
@@ -3310,6 +3520,37 @@ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
3310
3520
  latest_observation_prompt_number: latestObservationPromptNumber
3311
3521
  };
3312
3522
  }
3523
+ function buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolNames) {
3524
+ const request = compactLine(latestRequest);
3525
+ const outcome = recentOutcomes.map((item) => compactLine(item)).find(Boolean);
3526
+ const file = hotFiles[0] ? compactFileHint(hotFiles[0]) : null;
3527
+ const tools = recentToolNames.slice(0, 2).join("/");
3528
+ if (outcome && file) {
3529
+ return `${outcome} · ${file}${tools ? ` · ${tools}` : ""}`;
3530
+ }
3531
+ if (request && file) {
3532
+ return `${request} · ${file}${tools ? ` · ${tools}` : ""}`;
3533
+ }
3534
+ if (outcome) {
3535
+ return `${outcome}${tools ? ` · ${tools}` : ""}`;
3536
+ }
3537
+ if (request) {
3538
+ return `${request}${tools ? ` · ${tools}` : ""}`;
3539
+ }
3540
+ return null;
3541
+ }
3542
+ function compactLine(value) {
3543
+ const trimmed = value?.replace(/\s+/g, " ").trim();
3544
+ if (!trimmed)
3545
+ return null;
3546
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
3547
+ }
3548
+ function compactFileHint(value) {
3549
+ const parts = value.split("/");
3550
+ if (parts.length <= 2)
3551
+ return value;
3552
+ return parts.slice(-2).join("/");
3553
+ }
3313
3554
  function parseJsonArray(value) {
3314
3555
  if (!value)
3315
3556
  return [];
@@ -3534,6 +3775,7 @@ function updateRollingSummaryFromObservation(db, observationId, event, userId) {
3534
3775
  learned: merged.learned,
3535
3776
  completed: merged.completed,
3536
3777
  next_steps: existing?.next_steps ?? null,
3778
+ current_thread: handoff.current_thread,
3537
3779
  capture_state: handoff.capture_state,
3538
3780
  recent_tool_names: JSON.stringify(handoff.recent_tool_names),
3539
3781
  hot_files: JSON.stringify(handoff.hot_files),