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.
@@ -421,7 +421,7 @@ var MIGRATIONS = [
421
421
  -- Sync outbox (offline-first queue)
422
422
  CREATE TABLE sync_outbox (
423
423
  id INTEGER PRIMARY KEY AUTOINCREMENT,
424
- record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
424
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
425
425
  record_id INTEGER NOT NULL,
426
426
  status TEXT DEFAULT 'pending' CHECK (status IN (
427
427
  'pending', 'syncing', 'synced', 'failed'
@@ -714,6 +714,18 @@ var MIGRATIONS = [
714
714
  ON tool_events(created_at_epoch DESC, id DESC);
715
715
  `
716
716
  },
717
+ {
718
+ version: 11,
719
+ description: "Add observation provenance from tool and prompt chronology",
720
+ sql: `
721
+ ALTER TABLE observations ADD COLUMN source_tool TEXT;
722
+ ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
723
+ CREATE INDEX IF NOT EXISTS idx_observations_source_tool
724
+ ON observations(source_tool, created_at_epoch DESC);
725
+ CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
726
+ ON observations(session_id, source_prompt_number DESC);
727
+ `
728
+ },
717
729
  {
718
730
  version: 12,
719
731
  description: "Add synced handoff metadata to session summaries",
@@ -725,15 +737,79 @@ var MIGRATIONS = [
725
737
  `
726
738
  },
727
739
  {
728
- version: 11,
729
- description: "Add observation provenance from tool and prompt chronology",
740
+ version: 13,
741
+ description: "Add current_thread to session summaries",
730
742
  sql: `
731
- ALTER TABLE observations ADD COLUMN source_tool TEXT;
732
- ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
733
- CREATE INDEX IF NOT EXISTS idx_observations_source_tool
734
- ON observations(source_tool, created_at_epoch DESC);
735
- CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
736
- ON observations(session_id, source_prompt_number DESC);
743
+ ALTER TABLE session_summaries ADD COLUMN current_thread TEXT;
744
+ `
745
+ },
746
+ {
747
+ version: 14,
748
+ description: "Add chat_messages lane for raw conversation recall",
749
+ sql: `
750
+ CREATE TABLE IF NOT EXISTS chat_messages (
751
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
752
+ session_id TEXT NOT NULL,
753
+ project_id INTEGER REFERENCES projects(id),
754
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
755
+ content TEXT NOT NULL,
756
+ user_id TEXT NOT NULL,
757
+ device_id TEXT NOT NULL,
758
+ agent TEXT DEFAULT 'claude-code',
759
+ created_at_epoch INTEGER NOT NULL
760
+ );
761
+
762
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_session
763
+ ON chat_messages(session_id, created_at_epoch DESC, id DESC);
764
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_project
765
+ ON chat_messages(project_id, created_at_epoch DESC, id DESC);
766
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_created
767
+ ON chat_messages(created_at_epoch DESC, id DESC);
768
+ `
769
+ },
770
+ {
771
+ version: 15,
772
+ description: "Add remote_source_id for chat message sync deduplication",
773
+ sql: `
774
+ ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT;
775
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source
776
+ ON chat_messages(remote_source_id)
777
+ WHERE remote_source_id IS NOT NULL;
778
+ `
779
+ },
780
+ {
781
+ version: 16,
782
+ description: "Allow chat_message records in sync_outbox",
783
+ sql: `
784
+ CREATE TABLE sync_outbox_new (
785
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
786
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
787
+ record_id INTEGER NOT NULL,
788
+ status TEXT DEFAULT 'pending' CHECK (status IN (
789
+ 'pending', 'syncing', 'synced', 'failed'
790
+ )),
791
+ retry_count INTEGER DEFAULT 0,
792
+ max_retries INTEGER DEFAULT 10,
793
+ last_error TEXT,
794
+ created_at_epoch INTEGER NOT NULL,
795
+ synced_at_epoch INTEGER,
796
+ next_retry_epoch INTEGER
797
+ );
798
+
799
+ INSERT INTO sync_outbox_new (
800
+ id, record_type, record_id, status, retry_count, max_retries,
801
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
802
+ )
803
+ SELECT
804
+ id, record_type, record_id, status, retry_count, max_retries,
805
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
806
+ FROM sync_outbox;
807
+
808
+ DROP TABLE sync_outbox;
809
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
810
+
811
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
812
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
737
813
  `
738
814
  }
739
815
  ];
@@ -793,6 +869,18 @@ function inferLegacySchemaVersion(db) {
793
869
  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")) {
794
870
  version = Math.max(version, 12);
795
871
  }
872
+ if (columnExists(db, "session_summaries", "current_thread")) {
873
+ version = Math.max(version, 13);
874
+ }
875
+ if (tableExists(db, "chat_messages")) {
876
+ version = Math.max(version, 14);
877
+ }
878
+ if (columnExists(db, "chat_messages", "remote_source_id")) {
879
+ version = Math.max(version, 15);
880
+ }
881
+ if (syncOutboxSupportsChatMessages(db)) {
882
+ version = Math.max(version, 16);
883
+ }
796
884
  return version;
797
885
  }
798
886
  function runMigrations(db) {
@@ -876,7 +964,8 @@ function ensureSessionSummaryColumns(db) {
876
964
  "capture_state",
877
965
  "recent_tool_names",
878
966
  "hot_files",
879
- "recent_outcomes"
967
+ "recent_outcomes",
968
+ "current_thread"
880
969
  ];
881
970
  for (const column of required) {
882
971
  if (columnExists(db, "session_summaries", column))
@@ -884,10 +973,75 @@ function ensureSessionSummaryColumns(db) {
884
973
  db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
885
974
  }
886
975
  const current = getSchemaVersion(db);
887
- if (current < 12) {
888
- db.exec("PRAGMA user_version = 12");
976
+ if (current < 13) {
977
+ db.exec("PRAGMA user_version = 13");
978
+ }
979
+ }
980
+ function ensureChatMessageColumns(db) {
981
+ if (!tableExists(db, "chat_messages"))
982
+ return;
983
+ if (!columnExists(db, "chat_messages", "remote_source_id")) {
984
+ db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
985
+ }
986
+ 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");
987
+ const current = getSchemaVersion(db);
988
+ if (current < 15) {
989
+ db.exec("PRAGMA user_version = 15");
990
+ }
991
+ }
992
+ function ensureSyncOutboxSupportsChatMessages(db) {
993
+ if (syncOutboxSupportsChatMessages(db)) {
994
+ const current = getSchemaVersion(db);
995
+ if (current < 16) {
996
+ db.exec("PRAGMA user_version = 16");
997
+ }
998
+ return;
999
+ }
1000
+ db.exec("BEGIN TRANSACTION");
1001
+ try {
1002
+ db.exec(`
1003
+ CREATE TABLE sync_outbox_new (
1004
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1005
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
1006
+ record_id INTEGER NOT NULL,
1007
+ status TEXT DEFAULT 'pending' CHECK (status IN (
1008
+ 'pending', 'syncing', 'synced', 'failed'
1009
+ )),
1010
+ retry_count INTEGER DEFAULT 0,
1011
+ max_retries INTEGER DEFAULT 10,
1012
+ last_error TEXT,
1013
+ created_at_epoch INTEGER NOT NULL,
1014
+ synced_at_epoch INTEGER,
1015
+ next_retry_epoch INTEGER
1016
+ );
1017
+
1018
+ INSERT INTO sync_outbox_new (
1019
+ id, record_type, record_id, status, retry_count, max_retries,
1020
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
1021
+ )
1022
+ SELECT
1023
+ id, record_type, record_id, status, retry_count, max_retries,
1024
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
1025
+ FROM sync_outbox;
1026
+
1027
+ DROP TABLE sync_outbox;
1028
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
1029
+
1030
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
1031
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
1032
+ `);
1033
+ db.exec("PRAGMA user_version = 16");
1034
+ db.exec("COMMIT");
1035
+ } catch (error) {
1036
+ db.exec("ROLLBACK");
1037
+ throw new Error(`sync_outbox repair failed: ${error instanceof Error ? error.message : String(error)}`);
889
1038
  }
890
1039
  }
1040
+ function syncOutboxSupportsChatMessages(db) {
1041
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("sync_outbox");
1042
+ const sql = row?.sql ?? "";
1043
+ return sql.includes("'chat_message'");
1044
+ }
891
1045
  function getSchemaVersion(db) {
892
1046
  const result = db.query("PRAGMA user_version").get();
893
1047
  return result.user_version;
@@ -1047,6 +1201,8 @@ class MemDatabase {
1047
1201
  runMigrations(this.db);
1048
1202
  ensureObservationTypes(this.db);
1049
1203
  ensureSessionSummaryColumns(this.db);
1204
+ ensureChatMessageColumns(this.db);
1205
+ ensureSyncOutboxSupportsChatMessages(this.db);
1050
1206
  }
1051
1207
  loadVecExtension() {
1052
1208
  try {
@@ -1272,6 +1428,7 @@ class MemDatabase {
1272
1428
  p.name AS project_name,
1273
1429
  ss.request AS request,
1274
1430
  ss.completed AS completed,
1431
+ ss.current_thread AS current_thread,
1275
1432
  ss.capture_state AS capture_state,
1276
1433
  ss.recent_tool_names AS recent_tool_names,
1277
1434
  ss.hot_files AS hot_files,
@@ -1290,6 +1447,7 @@ class MemDatabase {
1290
1447
  p.name AS project_name,
1291
1448
  ss.request AS request,
1292
1449
  ss.completed AS completed,
1450
+ ss.current_thread AS current_thread,
1293
1451
  ss.capture_state AS capture_state,
1294
1452
  ss.recent_tool_names AS recent_tool_names,
1295
1453
  ss.hot_files AS hot_files,
@@ -1380,6 +1538,54 @@ class MemDatabase {
1380
1538
  ORDER BY created_at_epoch DESC, id DESC
1381
1539
  LIMIT ?`).all(...userId ? [userId] : [], limit);
1382
1540
  }
1541
+ insertChatMessage(input) {
1542
+ const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1543
+ const content = input.content.trim();
1544
+ const result = this.db.query(`INSERT INTO chat_messages (
1545
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
1546
+ ) 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);
1547
+ return this.getChatMessageById(Number(result.lastInsertRowid));
1548
+ }
1549
+ getChatMessageById(id) {
1550
+ return this.db.query("SELECT * FROM chat_messages WHERE id = ?").get(id) ?? null;
1551
+ }
1552
+ getChatMessageByRemoteSourceId(remoteSourceId) {
1553
+ return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
1554
+ }
1555
+ getSessionChatMessages(sessionId, limit = 50) {
1556
+ return this.db.query(`SELECT * FROM chat_messages
1557
+ WHERE session_id = ?
1558
+ ORDER BY created_at_epoch ASC, id ASC
1559
+ LIMIT ?`).all(sessionId, limit);
1560
+ }
1561
+ getRecentChatMessages(projectId, limit = 20, userId) {
1562
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1563
+ if (projectId !== null) {
1564
+ return this.db.query(`SELECT * FROM chat_messages
1565
+ WHERE project_id = ?${visibilityClause}
1566
+ ORDER BY created_at_epoch DESC, id DESC
1567
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1568
+ }
1569
+ return this.db.query(`SELECT * FROM chat_messages
1570
+ WHERE 1 = 1${visibilityClause}
1571
+ ORDER BY created_at_epoch DESC, id DESC
1572
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
1573
+ }
1574
+ searchChatMessages(query, projectId, limit = 20, userId) {
1575
+ const needle = `%${query.toLowerCase()}%`;
1576
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1577
+ if (projectId !== null) {
1578
+ return this.db.query(`SELECT * FROM chat_messages
1579
+ WHERE project_id = ?
1580
+ AND lower(content) LIKE ?${visibilityClause}
1581
+ ORDER BY created_at_epoch DESC, id DESC
1582
+ LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
1583
+ }
1584
+ return this.db.query(`SELECT * FROM chat_messages
1585
+ WHERE lower(content) LIKE ?${visibilityClause}
1586
+ ORDER BY created_at_epoch DESC, id DESC
1587
+ LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1588
+ }
1383
1589
  addToOutbox(recordType, recordId) {
1384
1590
  const now = Math.floor(Date.now() / 1000);
1385
1591
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -1468,9 +1674,9 @@ class MemDatabase {
1468
1674
  };
1469
1675
  const result = this.db.query(`INSERT INTO session_summaries (
1470
1676
  session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
1471
- capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1677
+ current_thread, capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1472
1678
  )
1473
- 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);
1679
+ 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);
1474
1680
  const id = Number(result.lastInsertRowid);
1475
1681
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
1476
1682
  }
@@ -1486,6 +1692,7 @@ class MemDatabase {
1486
1692
  learned: normalizeSummarySection(summary.learned ?? existing.learned),
1487
1693
  completed: normalizeSummarySection(summary.completed ?? existing.completed),
1488
1694
  next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
1695
+ current_thread: summary.current_thread ?? existing.current_thread,
1489
1696
  capture_state: summary.capture_state ?? existing.capture_state,
1490
1697
  recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
1491
1698
  hot_files: summary.hot_files ?? existing.hot_files,
@@ -1499,12 +1706,13 @@ class MemDatabase {
1499
1706
  learned = ?,
1500
1707
  completed = ?,
1501
1708
  next_steps = ?,
1709
+ current_thread = ?,
1502
1710
  capture_state = ?,
1503
1711
  recent_tool_names = ?,
1504
1712
  hot_files = ?,
1505
1713
  recent_outcomes = ?,
1506
1714
  created_at_epoch = ?
1507
- 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);
1715
+ 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);
1508
1716
  return this.getSessionSummary(summary.session_id);
1509
1717
  }
1510
1718
  getSessionSummary(sessionId) {
@@ -1670,11 +1878,13 @@ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
1670
1878
  return acc;
1671
1879
  }, new Map).entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
1672
1880
  const latestObservationPromptNumber = observations.map((obs) => obs.source_prompt_number).filter((value) => typeof value === "number").sort((a, b) => b - a)[0] ?? null;
1881
+ const currentThread = buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolNames);
1673
1882
  return {
1674
1883
  prompt_count: prompts.length,
1675
1884
  tool_event_count: toolEvents.length,
1676
1885
  recent_request_prompts: recentRequestPrompts,
1677
1886
  latest_request: latestRequest,
1887
+ current_thread: currentThread,
1678
1888
  recent_tool_names: recentToolNames,
1679
1889
  recent_tool_commands: recentToolCommands,
1680
1890
  capture_state: captureState,
@@ -1684,6 +1894,37 @@ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
1684
1894
  latest_observation_prompt_number: latestObservationPromptNumber
1685
1895
  };
1686
1896
  }
1897
+ function buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolNames) {
1898
+ const request = compactLine(latestRequest);
1899
+ const outcome = recentOutcomes.map((item) => compactLine(item)).find(Boolean);
1900
+ const file = hotFiles[0] ? compactFileHint(hotFiles[0]) : null;
1901
+ const tools = recentToolNames.slice(0, 2).join("/");
1902
+ if (outcome && file) {
1903
+ return `${outcome} · ${file}${tools ? ` · ${tools}` : ""}`;
1904
+ }
1905
+ if (request && file) {
1906
+ return `${request} · ${file}${tools ? ` · ${tools}` : ""}`;
1907
+ }
1908
+ if (outcome) {
1909
+ return `${outcome}${tools ? ` · ${tools}` : ""}`;
1910
+ }
1911
+ if (request) {
1912
+ return `${request}${tools ? ` · ${tools}` : ""}`;
1913
+ }
1914
+ return null;
1915
+ }
1916
+ function compactLine(value) {
1917
+ const trimmed = value?.replace(/\s+/g, " ").trim();
1918
+ if (!trimmed)
1919
+ return null;
1920
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
1921
+ }
1922
+ function compactFileHint(value) {
1923
+ const parts = value.split("/");
1924
+ if (parts.length <= 2)
1925
+ return value;
1926
+ return parts.slice(-2).join("/");
1927
+ }
1687
1928
  function parseJsonArray(value) {
1688
1929
  if (!value)
1689
1930
  return [];
@@ -1722,6 +1963,16 @@ async function main() {
1722
1963
  device_id: config.device_id,
1723
1964
  agent: "claude-code"
1724
1965
  });
1966
+ const chatMessage = db.insertChatMessage({
1967
+ session_id: event.session_id,
1968
+ project_id: project.id,
1969
+ role: "user",
1970
+ content: event.prompt,
1971
+ user_id: config.user_id,
1972
+ device_id: config.device_id,
1973
+ agent: "claude-code"
1974
+ });
1975
+ db.addToOutbox("chat_message", chatMessage.id);
1725
1976
  const compactPrompt = event.prompt.replace(/\s+/g, " ").trim();
1726
1977
  if (compactPrompt.length >= 8) {
1727
1978
  const sessionPrompts = db.getSessionUserPrompts(event.session_id, 20);
@@ -1737,6 +1988,7 @@ async function main() {
1737
1988
  learned: null,
1738
1989
  completed: null,
1739
1990
  next_steps: null,
1991
+ current_thread: handoff.current_thread,
1740
1992
  capture_state: handoff.capture_state,
1741
1993
  recent_tool_names: JSON.stringify(handoff.recent_tool_names),
1742
1994
  hot_files: JSON.stringify(handoff.hot_files),