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.
@@ -590,7 +590,7 @@ var MIGRATIONS = [
590
590
  -- Sync outbox (offline-first queue)
591
591
  CREATE TABLE sync_outbox (
592
592
  id INTEGER PRIMARY KEY AUTOINCREMENT,
593
- record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
593
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
594
594
  record_id INTEGER NOT NULL,
595
595
  status TEXT DEFAULT 'pending' CHECK (status IN (
596
596
  'pending', 'syncing', 'synced', 'failed'
@@ -883,6 +883,18 @@ var MIGRATIONS = [
883
883
  ON tool_events(created_at_epoch DESC, id DESC);
884
884
  `
885
885
  },
886
+ {
887
+ version: 11,
888
+ description: "Add observation provenance from tool and prompt chronology",
889
+ sql: `
890
+ ALTER TABLE observations ADD COLUMN source_tool TEXT;
891
+ ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
892
+ CREATE INDEX IF NOT EXISTS idx_observations_source_tool
893
+ ON observations(source_tool, created_at_epoch DESC);
894
+ CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
895
+ ON observations(session_id, source_prompt_number DESC);
896
+ `
897
+ },
886
898
  {
887
899
  version: 12,
888
900
  description: "Add synced handoff metadata to session summaries",
@@ -894,15 +906,92 @@ var MIGRATIONS = [
894
906
  `
895
907
  },
896
908
  {
897
- version: 11,
898
- description: "Add observation provenance from tool and prompt chronology",
909
+ version: 13,
910
+ description: "Add current_thread to session summaries",
899
911
  sql: `
900
- ALTER TABLE observations ADD COLUMN source_tool TEXT;
901
- ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
902
- CREATE INDEX IF NOT EXISTS idx_observations_source_tool
903
- ON observations(source_tool, created_at_epoch DESC);
904
- CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
905
- ON observations(session_id, source_prompt_number DESC);
912
+ ALTER TABLE session_summaries ADD COLUMN current_thread TEXT;
913
+ `
914
+ },
915
+ {
916
+ version: 14,
917
+ description: "Add chat_messages lane for raw conversation recall",
918
+ sql: `
919
+ CREATE TABLE IF NOT EXISTS chat_messages (
920
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
921
+ session_id TEXT NOT NULL,
922
+ project_id INTEGER REFERENCES projects(id),
923
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
924
+ content TEXT NOT NULL,
925
+ user_id TEXT NOT NULL,
926
+ device_id TEXT NOT NULL,
927
+ agent TEXT DEFAULT 'claude-code',
928
+ created_at_epoch INTEGER NOT NULL
929
+ );
930
+
931
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_session
932
+ ON chat_messages(session_id, created_at_epoch DESC, id DESC);
933
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_project
934
+ ON chat_messages(project_id, created_at_epoch DESC, id DESC);
935
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_created
936
+ ON chat_messages(created_at_epoch DESC, id DESC);
937
+ `
938
+ },
939
+ {
940
+ version: 15,
941
+ description: "Add remote_source_id for chat message sync deduplication",
942
+ sql: `
943
+ ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT;
944
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source
945
+ ON chat_messages(remote_source_id)
946
+ WHERE remote_source_id IS NOT NULL;
947
+ `
948
+ },
949
+ {
950
+ version: 16,
951
+ description: "Allow chat_message records in sync_outbox",
952
+ sql: `
953
+ CREATE TABLE sync_outbox_new (
954
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
955
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
956
+ record_id INTEGER NOT NULL,
957
+ status TEXT DEFAULT 'pending' CHECK (status IN (
958
+ 'pending', 'syncing', 'synced', 'failed'
959
+ )),
960
+ retry_count INTEGER DEFAULT 0,
961
+ max_retries INTEGER DEFAULT 10,
962
+ last_error TEXT,
963
+ created_at_epoch INTEGER NOT NULL,
964
+ synced_at_epoch INTEGER,
965
+ next_retry_epoch INTEGER
966
+ );
967
+
968
+ INSERT INTO sync_outbox_new (
969
+ id, record_type, record_id, status, retry_count, max_retries,
970
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
971
+ )
972
+ SELECT
973
+ id, record_type, record_id, status, retry_count, max_retries,
974
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
975
+ FROM sync_outbox;
976
+
977
+ DROP TABLE sync_outbox;
978
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
979
+
980
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
981
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
982
+ `
983
+ },
984
+ {
985
+ version: 17,
986
+ description: "Track transcript-backed chat messages separately from hook chat",
987
+ sql: `
988
+ ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook';
989
+ ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER;
990
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind
991
+ ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC);
992
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript
993
+ ON chat_messages(session_id, transcript_index)
994
+ WHERE transcript_index IS NOT NULL;
906
995
  `
907
996
  }
908
997
  ];
@@ -962,6 +1051,21 @@ function inferLegacySchemaVersion(db) {
962
1051
  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")) {
963
1052
  version = Math.max(version, 12);
964
1053
  }
1054
+ if (columnExists(db, "session_summaries", "current_thread")) {
1055
+ version = Math.max(version, 13);
1056
+ }
1057
+ if (tableExists(db, "chat_messages")) {
1058
+ version = Math.max(version, 14);
1059
+ }
1060
+ if (columnExists(db, "chat_messages", "remote_source_id")) {
1061
+ version = Math.max(version, 15);
1062
+ }
1063
+ if (syncOutboxSupportsChatMessages(db)) {
1064
+ version = Math.max(version, 16);
1065
+ }
1066
+ if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
1067
+ version = Math.max(version, 17);
1068
+ }
965
1069
  return version;
966
1070
  }
967
1071
  function runMigrations(db) {
@@ -1045,7 +1149,8 @@ function ensureSessionSummaryColumns(db) {
1045
1149
  "capture_state",
1046
1150
  "recent_tool_names",
1047
1151
  "hot_files",
1048
- "recent_outcomes"
1152
+ "recent_outcomes",
1153
+ "current_thread"
1049
1154
  ];
1050
1155
  for (const column of required) {
1051
1156
  if (columnExists(db, "session_summaries", column))
@@ -1053,10 +1158,83 @@ function ensureSessionSummaryColumns(db) {
1053
1158
  db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
1054
1159
  }
1055
1160
  const current = getSchemaVersion(db);
1056
- if (current < 12) {
1057
- db.exec("PRAGMA user_version = 12");
1161
+ if (current < 13) {
1162
+ db.exec("PRAGMA user_version = 13");
1058
1163
  }
1059
1164
  }
1165
+ function ensureChatMessageColumns(db) {
1166
+ if (!tableExists(db, "chat_messages"))
1167
+ return;
1168
+ if (!columnExists(db, "chat_messages", "remote_source_id")) {
1169
+ db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
1170
+ }
1171
+ 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");
1172
+ if (!columnExists(db, "chat_messages", "source_kind")) {
1173
+ db.exec("ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook'");
1174
+ }
1175
+ if (!columnExists(db, "chat_messages", "transcript_index")) {
1176
+ db.exec("ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER");
1177
+ }
1178
+ 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)");
1179
+ 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");
1180
+ const current = getSchemaVersion(db);
1181
+ if (current < 17) {
1182
+ db.exec("PRAGMA user_version = 17");
1183
+ }
1184
+ }
1185
+ function ensureSyncOutboxSupportsChatMessages(db) {
1186
+ if (syncOutboxSupportsChatMessages(db)) {
1187
+ const current = getSchemaVersion(db);
1188
+ if (current < 16) {
1189
+ db.exec("PRAGMA user_version = 16");
1190
+ }
1191
+ return;
1192
+ }
1193
+ db.exec("BEGIN TRANSACTION");
1194
+ try {
1195
+ db.exec(`
1196
+ CREATE TABLE sync_outbox_new (
1197
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1198
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
1199
+ record_id INTEGER NOT NULL,
1200
+ status TEXT DEFAULT 'pending' CHECK (status IN (
1201
+ 'pending', 'syncing', 'synced', 'failed'
1202
+ )),
1203
+ retry_count INTEGER DEFAULT 0,
1204
+ max_retries INTEGER DEFAULT 10,
1205
+ last_error TEXT,
1206
+ created_at_epoch INTEGER NOT NULL,
1207
+ synced_at_epoch INTEGER,
1208
+ next_retry_epoch INTEGER
1209
+ );
1210
+
1211
+ INSERT INTO sync_outbox_new (
1212
+ id, record_type, record_id, status, retry_count, max_retries,
1213
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
1214
+ )
1215
+ SELECT
1216
+ id, record_type, record_id, status, retry_count, max_retries,
1217
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
1218
+ FROM sync_outbox;
1219
+
1220
+ DROP TABLE sync_outbox;
1221
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
1222
+
1223
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
1224
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
1225
+ `);
1226
+ db.exec("PRAGMA user_version = 16");
1227
+ db.exec("COMMIT");
1228
+ } catch (error) {
1229
+ db.exec("ROLLBACK");
1230
+ throw new Error(`sync_outbox repair failed: ${error instanceof Error ? error.message : String(error)}`);
1231
+ }
1232
+ }
1233
+ function syncOutboxSupportsChatMessages(db) {
1234
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("sync_outbox");
1235
+ const sql = row?.sql ?? "";
1236
+ return sql.includes("'chat_message'");
1237
+ }
1060
1238
  function getSchemaVersion(db) {
1061
1239
  const result = db.query("PRAGMA user_version").get();
1062
1240
  return result.user_version;
@@ -1136,6 +1314,8 @@ class MemDatabase {
1136
1314
  runMigrations(this.db);
1137
1315
  ensureObservationTypes(this.db);
1138
1316
  ensureSessionSummaryColumns(this.db);
1317
+ ensureChatMessageColumns(this.db);
1318
+ ensureSyncOutboxSupportsChatMessages(this.db);
1139
1319
  }
1140
1320
  loadVecExtension() {
1141
1321
  try {
@@ -1204,6 +1384,22 @@ class MemDatabase {
1204
1384
  getObservationById(id) {
1205
1385
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1206
1386
  }
1387
+ updateObservationContent(id, update) {
1388
+ const existing = this.getObservationById(id);
1389
+ if (!existing)
1390
+ return null;
1391
+ const createdAtEpoch = update.created_at_epoch ?? existing.created_at_epoch;
1392
+ const createdAt = new Date(createdAtEpoch * 1000).toISOString();
1393
+ this.db.query(`UPDATE observations
1394
+ SET title = ?, narrative = ?, facts = ?, concepts = ?, created_at = ?, created_at_epoch = ?
1395
+ WHERE id = ?`).run(update.title, update.narrative ?? null, update.facts ?? null, update.concepts ?? null, createdAt, createdAtEpoch, id);
1396
+ this.ftsDelete(existing);
1397
+ const refreshed = this.getObservationById(id);
1398
+ if (!refreshed)
1399
+ return null;
1400
+ this.ftsInsert(refreshed);
1401
+ return refreshed;
1402
+ }
1207
1403
  getObservationsByIds(ids, userId) {
1208
1404
  if (ids.length === 0)
1209
1405
  return [];
@@ -1361,6 +1557,7 @@ class MemDatabase {
1361
1557
  p.name AS project_name,
1362
1558
  ss.request AS request,
1363
1559
  ss.completed AS completed,
1560
+ ss.current_thread AS current_thread,
1364
1561
  ss.capture_state AS capture_state,
1365
1562
  ss.recent_tool_names AS recent_tool_names,
1366
1563
  ss.hot_files AS hot_files,
@@ -1379,6 +1576,7 @@ class MemDatabase {
1379
1576
  p.name AS project_name,
1380
1577
  ss.request AS request,
1381
1578
  ss.completed AS completed,
1579
+ ss.current_thread AS current_thread,
1382
1580
  ss.capture_state AS capture_state,
1383
1581
  ss.recent_tool_names AS recent_tool_names,
1384
1582
  ss.hot_files AS hot_files,
@@ -1469,6 +1667,99 @@ class MemDatabase {
1469
1667
  ORDER BY created_at_epoch DESC, id DESC
1470
1668
  LIMIT ?`).all(...userId ? [userId] : [], limit);
1471
1669
  }
1670
+ insertChatMessage(input) {
1671
+ const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1672
+ const content = input.content.trim();
1673
+ const result = this.db.query(`INSERT INTO chat_messages (
1674
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id, source_kind, transcript_index
1675
+ ) 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);
1676
+ return this.getChatMessageById(Number(result.lastInsertRowid));
1677
+ }
1678
+ getChatMessageById(id) {
1679
+ return this.db.query("SELECT * FROM chat_messages WHERE id = ?").get(id) ?? null;
1680
+ }
1681
+ getChatMessageByRemoteSourceId(remoteSourceId) {
1682
+ return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
1683
+ }
1684
+ getSessionChatMessages(sessionId, limit = 50) {
1685
+ return this.db.query(`SELECT * FROM chat_messages
1686
+ WHERE session_id = ?
1687
+ AND (
1688
+ source_kind = 'transcript'
1689
+ OR NOT EXISTS (
1690
+ SELECT 1 FROM chat_messages t2
1691
+ WHERE t2.session_id = chat_messages.session_id
1692
+ AND t2.source_kind = 'transcript'
1693
+ )
1694
+ )
1695
+ ORDER BY
1696
+ CASE WHEN transcript_index IS NULL THEN created_at_epoch ELSE transcript_index END ASC,
1697
+ id ASC
1698
+ LIMIT ?`).all(sessionId, limit);
1699
+ }
1700
+ getRecentChatMessages(projectId, limit = 20, userId) {
1701
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1702
+ if (projectId !== null) {
1703
+ return this.db.query(`SELECT * FROM chat_messages
1704
+ WHERE project_id = ?${visibilityClause}
1705
+ AND (
1706
+ source_kind = 'transcript'
1707
+ OR NOT EXISTS (
1708
+ SELECT 1 FROM chat_messages t2
1709
+ WHERE t2.session_id = chat_messages.session_id
1710
+ AND t2.source_kind = 'transcript'
1711
+ )
1712
+ )
1713
+ ORDER BY created_at_epoch DESC, id DESC
1714
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1715
+ }
1716
+ return this.db.query(`SELECT * FROM chat_messages
1717
+ WHERE 1 = 1${visibilityClause}
1718
+ AND (
1719
+ source_kind = 'transcript'
1720
+ OR NOT EXISTS (
1721
+ SELECT 1 FROM chat_messages t2
1722
+ WHERE t2.session_id = chat_messages.session_id
1723
+ AND t2.source_kind = 'transcript'
1724
+ )
1725
+ )
1726
+ ORDER BY created_at_epoch DESC, id DESC
1727
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
1728
+ }
1729
+ searchChatMessages(query, projectId, limit = 20, userId) {
1730
+ const needle = `%${query.toLowerCase()}%`;
1731
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1732
+ if (projectId !== null) {
1733
+ return this.db.query(`SELECT * FROM chat_messages
1734
+ WHERE project_id = ?
1735
+ AND lower(content) LIKE ?${visibilityClause}
1736
+ AND (
1737
+ source_kind = 'transcript'
1738
+ OR NOT EXISTS (
1739
+ SELECT 1 FROM chat_messages t2
1740
+ WHERE t2.session_id = chat_messages.session_id
1741
+ AND t2.source_kind = 'transcript'
1742
+ )
1743
+ )
1744
+ ORDER BY created_at_epoch DESC, id DESC
1745
+ LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
1746
+ }
1747
+ return this.db.query(`SELECT * FROM chat_messages
1748
+ WHERE lower(content) LIKE ?${visibilityClause}
1749
+ AND (
1750
+ source_kind = 'transcript'
1751
+ OR NOT EXISTS (
1752
+ SELECT 1 FROM chat_messages t2
1753
+ WHERE t2.session_id = chat_messages.session_id
1754
+ AND t2.source_kind = 'transcript'
1755
+ )
1756
+ )
1757
+ ORDER BY created_at_epoch DESC, id DESC
1758
+ LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1759
+ }
1760
+ getTranscriptChatMessage(sessionId, transcriptIndex) {
1761
+ return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
1762
+ }
1472
1763
  addToOutbox(recordType, recordId) {
1473
1764
  const now = Math.floor(Date.now() / 1000);
1474
1765
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -1557,9 +1848,9 @@ class MemDatabase {
1557
1848
  };
1558
1849
  const result = this.db.query(`INSERT INTO session_summaries (
1559
1850
  session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
1560
- capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1851
+ current_thread, capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1561
1852
  )
1562
- 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);
1853
+ 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);
1563
1854
  const id = Number(result.lastInsertRowid);
1564
1855
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
1565
1856
  }
@@ -1575,6 +1866,7 @@ class MemDatabase {
1575
1866
  learned: normalizeSummarySection(summary.learned ?? existing.learned),
1576
1867
  completed: normalizeSummarySection(summary.completed ?? existing.completed),
1577
1868
  next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
1869
+ current_thread: summary.current_thread ?? existing.current_thread,
1578
1870
  capture_state: summary.capture_state ?? existing.capture_state,
1579
1871
  recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
1580
1872
  hot_files: summary.hot_files ?? existing.hot_files,
@@ -1588,12 +1880,13 @@ class MemDatabase {
1588
1880
  learned = ?,
1589
1881
  completed = ?,
1590
1882
  next_steps = ?,
1883
+ current_thread = ?,
1591
1884
  capture_state = ?,
1592
1885
  recent_tool_names = ?,
1593
1886
  hot_files = ?,
1594
1887
  recent_outcomes = ?,
1595
1888
  created_at_epoch = ?
1596
- 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);
1889
+ 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);
1597
1890
  return this.getSessionSummary(summary.session_id);
1598
1891
  }
1599
1892
  getSessionSummary(sessionId) {
@@ -1854,22 +2147,27 @@ function buildSourceId(config, localId, type = "obs") {
1854
2147
  return `${config.user_id}-${config.device_id}-${type}-${localId}`;
1855
2148
  }
1856
2149
  function parseSourceId(sourceId) {
1857
- const obsIndex = sourceId.lastIndexOf("-obs-");
1858
- if (obsIndex === -1)
1859
- return null;
1860
- const prefix = sourceId.slice(0, obsIndex);
1861
- const localIdStr = sourceId.slice(obsIndex + 5);
1862
- const localId = parseInt(localIdStr, 10);
1863
- if (isNaN(localId))
1864
- return null;
1865
- const firstDash = prefix.indexOf("-");
1866
- if (firstDash === -1)
1867
- return null;
1868
- return {
1869
- userId: prefix.slice(0, firstDash),
1870
- deviceId: prefix.slice(firstDash + 1),
1871
- localId
1872
- };
2150
+ for (const type of ["obs", "summary", "chat"]) {
2151
+ const marker = `-${type}-`;
2152
+ const idx = sourceId.lastIndexOf(marker);
2153
+ if (idx === -1)
2154
+ continue;
2155
+ const prefix = sourceId.slice(0, idx);
2156
+ const localIdStr = sourceId.slice(idx + marker.length);
2157
+ const localId = parseInt(localIdStr, 10);
2158
+ if (isNaN(localId))
2159
+ return null;
2160
+ const firstDash = prefix.indexOf("-");
2161
+ if (firstDash === -1)
2162
+ return null;
2163
+ return {
2164
+ userId: prefix.slice(0, firstDash),
2165
+ deviceId: prefix.slice(firstDash + 1),
2166
+ localId,
2167
+ type
2168
+ };
2169
+ }
2170
+ return null;
1873
2171
  }
1874
2172
 
1875
2173
  // src/sync/client.ts
@@ -2045,11 +2343,13 @@ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
2045
2343
  return acc;
2046
2344
  }, new Map).entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
2047
2345
  const latestObservationPromptNumber = observations.map((obs) => obs.source_prompt_number).filter((value) => typeof value === "number").sort((a, b) => b - a)[0] ?? null;
2346
+ const currentThread = buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolNames);
2048
2347
  return {
2049
2348
  prompt_count: prompts.length,
2050
2349
  tool_event_count: toolEvents.length,
2051
2350
  recent_request_prompts: recentRequestPrompts,
2052
2351
  latest_request: latestRequest,
2352
+ current_thread: currentThread,
2053
2353
  recent_tool_names: recentToolNames,
2054
2354
  recent_tool_commands: recentToolCommands,
2055
2355
  capture_state: captureState,
@@ -2059,6 +2359,37 @@ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
2059
2359
  latest_observation_prompt_number: latestObservationPromptNumber
2060
2360
  };
2061
2361
  }
2362
+ function buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolNames) {
2363
+ const request = compactLine(latestRequest);
2364
+ const outcome = recentOutcomes.map((item) => compactLine(item)).find(Boolean);
2365
+ const file = hotFiles[0] ? compactFileHint(hotFiles[0]) : null;
2366
+ const tools = recentToolNames.slice(0, 2).join("/");
2367
+ if (outcome && file) {
2368
+ return `${outcome} · ${file}${tools ? ` · ${tools}` : ""}`;
2369
+ }
2370
+ if (request && file) {
2371
+ return `${request} · ${file}${tools ? ` · ${tools}` : ""}`;
2372
+ }
2373
+ if (outcome) {
2374
+ return `${outcome}${tools ? ` · ${tools}` : ""}`;
2375
+ }
2376
+ if (request) {
2377
+ return `${request}${tools ? ` · ${tools}` : ""}`;
2378
+ }
2379
+ return null;
2380
+ }
2381
+ function compactLine(value) {
2382
+ const trimmed = value?.replace(/\s+/g, " ").trim();
2383
+ if (!trimmed)
2384
+ return null;
2385
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
2386
+ }
2387
+ function compactFileHint(value) {
2388
+ const parts = value.split("/");
2389
+ if (parts.length <= 2)
2390
+ return value;
2391
+ return parts.slice(-2).join("/");
2392
+ }
2062
2393
  function parseJsonArray2(value) {
2063
2394
  if (!value)
2064
2395
  return [];
@@ -2071,6 +2402,30 @@ function parseJsonArray2(value) {
2071
2402
  }
2072
2403
 
2073
2404
  // src/sync/push.ts
2405
+ function buildChatVectorDocument(chat, config, project) {
2406
+ return {
2407
+ site_id: config.site_id,
2408
+ namespace: config.namespace,
2409
+ source_type: "chat",
2410
+ source_id: buildSourceId(config, chat.id, "chat"),
2411
+ content: chat.content,
2412
+ metadata: {
2413
+ project_canonical: project.canonical_id,
2414
+ project_name: project.name,
2415
+ user_id: chat.user_id,
2416
+ device_id: chat.device_id,
2417
+ device_name: __require("node:os").hostname(),
2418
+ agent: chat.agent,
2419
+ type: "chat",
2420
+ role: chat.role,
2421
+ session_id: chat.session_id,
2422
+ created_at_epoch: chat.created_at_epoch,
2423
+ local_id: chat.id,
2424
+ source_kind: chat.source_kind,
2425
+ transcript_index: chat.transcript_index
2426
+ }
2427
+ };
2428
+ }
2074
2429
  function buildVectorDocument(obs, config, project) {
2075
2430
  const parts = [obs.title];
2076
2431
  if (obs.narrative)
@@ -2151,6 +2506,7 @@ function buildSummaryVectorDocument(summary, config, project, observations = [],
2151
2506
  learned: summary.learned,
2152
2507
  completed: summary.completed,
2153
2508
  next_steps: summary.next_steps,
2509
+ current_thread: summary.current_thread,
2154
2510
  summary_sections_present: countPresentSections(summary),
2155
2511
  investigated_items: extractSectionItems(summary.investigated),
2156
2512
  learned_items: extractSectionItems(summary.learned),
@@ -2161,6 +2517,7 @@ function buildSummaryVectorDocument(summary, config, project, observations = [],
2161
2517
  capture_state: captureContext?.capture_state ?? "summary-only",
2162
2518
  recent_request_prompts: captureContext?.recent_request_prompts ?? [],
2163
2519
  latest_request: captureContext?.latest_request ?? null,
2520
+ current_thread: captureContext?.current_thread ?? null,
2164
2521
  recent_tool_names: captureContext?.recent_tool_names ?? [],
2165
2522
  recent_tool_commands: captureContext?.recent_tool_commands ?? [],
2166
2523
  hot_files: captureContext?.hot_files ?? [],
@@ -2214,6 +2571,32 @@ async function pushOutbox(db, client, config, batchSize = 50) {
2214
2571
  batch.push({ entryId: entry.id, doc: doc2 });
2215
2572
  continue;
2216
2573
  }
2574
+ if (entry.record_type === "chat_message") {
2575
+ const chat = db.getChatMessageById(entry.record_id);
2576
+ if (!chat || chat.remote_source_id) {
2577
+ markSynced(db, entry.id);
2578
+ skipped++;
2579
+ continue;
2580
+ }
2581
+ if (!chat.project_id) {
2582
+ markSynced(db, entry.id);
2583
+ skipped++;
2584
+ continue;
2585
+ }
2586
+ const project2 = db.getProjectById(chat.project_id);
2587
+ if (!project2) {
2588
+ markSynced(db, entry.id);
2589
+ skipped++;
2590
+ continue;
2591
+ }
2592
+ markSyncing(db, entry.id);
2593
+ const doc2 = buildChatVectorDocument(chat, config, {
2594
+ canonical_id: project2.canonical_id,
2595
+ name: project2.name
2596
+ });
2597
+ batch.push({ entryId: entry.id, doc: doc2 });
2598
+ continue;
2599
+ }
2217
2600
  if (entry.record_type !== "observation") {
2218
2601
  skipped++;
2219
2602
  continue;
@@ -2251,7 +2634,13 @@ async function pushOutbox(db, client, config, batchSize = 50) {
2251
2634
  return { pushed, failed, skipped };
2252
2635
  try {
2253
2636
  await client.batchIngest(batch.map((b) => b.doc));
2254
- for (const { entryId } of batch) {
2637
+ for (const { entryId, doc } of batch) {
2638
+ if (doc.source_type === "chat") {
2639
+ const localId = typeof doc.metadata?.local_id === "number" ? doc.metadata.local_id : null;
2640
+ if (localId !== null) {
2641
+ db.db.query("UPDATE chat_messages SET remote_source_id = ? WHERE id = ?").run(doc.source_id, localId);
2642
+ }
2643
+ }
2255
2644
  markSynced(db, entryId);
2256
2645
  pushed++;
2257
2646
  }
@@ -2259,6 +2648,12 @@ async function pushOutbox(db, client, config, batchSize = 50) {
2259
2648
  for (const { entryId, doc } of batch) {
2260
2649
  try {
2261
2650
  await client.ingest(doc);
2651
+ if (doc.source_type === "chat") {
2652
+ const localId = typeof doc.metadata?.local_id === "number" ? doc.metadata.local_id : null;
2653
+ if (localId !== null) {
2654
+ db.db.query("UPDATE chat_messages SET remote_source_id = ? WHERE id = ?").run(doc.source_id, localId);
2655
+ }
2656
+ }
2262
2657
  markSynced(db, entryId);
2263
2658
  pushed++;
2264
2659
  } catch (err) {
@@ -2614,7 +3009,7 @@ function buildBeacon(db, config, sessionId, metrics) {
2614
3009
  sentinel_used: valueSignals.security_findings_count > 0,
2615
3010
  risk_score: riskScore,
2616
3011
  stacks_detected: stacks,
2617
- client_version: "0.4.22",
3012
+ client_version: "0.4.25",
2618
3013
  context_observations_injected: metrics?.contextObsInjected ?? 0,
2619
3014
  context_total_available: metrics?.contextTotalAvailable ?? 0,
2620
3015
  recall_attempts: metrics?.recallAttempts ?? 0,
@@ -3515,6 +3910,40 @@ function readTranscript(sessionId, cwd, transcriptPath) {
3515
3910
  }
3516
3911
  return messages;
3517
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
+ }
3518
3947
  function truncateTranscript(messages, maxBytes = 50000) {
3519
3948
  const lines = [];
3520
3949
  for (const msg of messages) {
@@ -3590,6 +4019,417 @@ async function saveTranscriptResults(db, config, results, sessionId, cwd) {
3590
4019
  return saved;
3591
4020
  }
3592
4021
 
4022
+ // src/tools/session-story.ts
4023
+ function getSessionStory(db, input) {
4024
+ const session = db.getSessionById(input.session_id);
4025
+ const summary = db.getSessionSummary(input.session_id);
4026
+ const prompts = db.getSessionUserPrompts(input.session_id, 50);
4027
+ const chatMessages = db.getSessionChatMessages(input.session_id, 50);
4028
+ const toolEvents = db.getSessionToolEvents(input.session_id, 100);
4029
+ const allObservations = db.getObservationsBySession(input.session_id);
4030
+ const handoffs = allObservations.filter((obs) => looksLikeHandoff(obs));
4031
+ const rollingHandoffDrafts = handoffs.filter((obs) => isDraftHandoff(obs));
4032
+ const savedHandoffs = handoffs.filter((obs) => !isDraftHandoff(obs));
4033
+ const observations = allObservations.filter((obs) => !looksLikeHandoff(obs));
4034
+ const metrics = db.getSessionMetrics(input.session_id);
4035
+ const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
4036
+ const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
4037
+ return {
4038
+ session,
4039
+ project_name: projectName,
4040
+ summary,
4041
+ prompts,
4042
+ chat_messages: chatMessages,
4043
+ tool_events: toolEvents,
4044
+ observations,
4045
+ handoffs,
4046
+ saved_handoffs: savedHandoffs,
4047
+ rolling_handoff_drafts: rollingHandoffDrafts,
4048
+ metrics,
4049
+ capture_state: classifyCaptureState({
4050
+ hasSummary: Boolean(summary?.request || summary?.completed),
4051
+ promptCount: prompts.length,
4052
+ toolEventCount: toolEvents.length
4053
+ }),
4054
+ capture_gaps: buildCaptureGaps({
4055
+ promptCount: prompts.length,
4056
+ toolEventCount: toolEvents.length,
4057
+ toolCallsCount: metrics?.tool_calls_count ?? 0,
4058
+ observationCount: observations.length,
4059
+ hasSummary: Boolean(summary?.request || summary?.completed)
4060
+ }),
4061
+ latest_request: latestRequest,
4062
+ recent_outcomes: collectRecentOutcomes(observations),
4063
+ hot_files: collectHotFiles(observations),
4064
+ provenance_summary: collectProvenanceSummary(observations)
4065
+ };
4066
+ }
4067
+ function classifyCaptureState(input) {
4068
+ if (input.promptCount > 0 && input.toolEventCount > 0)
4069
+ return "rich";
4070
+ if (input.promptCount > 0 || input.toolEventCount > 0)
4071
+ return "partial";
4072
+ if (input.hasSummary)
4073
+ return "summary-only";
4074
+ return "legacy";
4075
+ }
4076
+ function buildCaptureGaps(input) {
4077
+ const gaps = [];
4078
+ if (input.promptCount === 0)
4079
+ gaps.push("missing prompts");
4080
+ if (input.toolCallsCount > 0 && input.toolEventCount === 0) {
4081
+ gaps.push("missing raw tool chronology");
4082
+ } else if (input.toolEventCount === 0) {
4083
+ gaps.push("no tool events");
4084
+ }
4085
+ if (input.observationCount === 0 && input.hasSummary) {
4086
+ gaps.push("summary without reusable observations");
4087
+ }
4088
+ return gaps;
4089
+ }
4090
+ function collectRecentOutcomes(observations) {
4091
+ const seen = new Set;
4092
+ const outcomes = [];
4093
+ for (const obs of observations) {
4094
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
4095
+ continue;
4096
+ const title = obs.title.trim();
4097
+ if (!title || looksLikeFileOperationTitle(title))
4098
+ continue;
4099
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
4100
+ if (seen.has(normalized))
4101
+ continue;
4102
+ seen.add(normalized);
4103
+ outcomes.push(title);
4104
+ if (outcomes.length >= 6)
4105
+ break;
4106
+ }
4107
+ return outcomes;
4108
+ }
4109
+ function collectHotFiles(observations) {
4110
+ const counts = new Map;
4111
+ for (const obs of observations) {
4112
+ for (const path of [...parseJsonArray3(obs.files_modified), ...parseJsonArray3(obs.files_read)]) {
4113
+ counts.set(path, (counts.get(path) ?? 0) + 1);
4114
+ }
4115
+ }
4116
+ 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);
4117
+ }
4118
+ function parseJsonArray3(value) {
4119
+ if (!value)
4120
+ return [];
4121
+ try {
4122
+ const parsed = JSON.parse(value);
4123
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
4124
+ } catch {
4125
+ return [];
4126
+ }
4127
+ }
4128
+ function looksLikeFileOperationTitle(value) {
4129
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
4130
+ }
4131
+ function collectProvenanceSummary(observations) {
4132
+ const counts = new Map;
4133
+ for (const obs of observations) {
4134
+ if (!obs.source_tool)
4135
+ continue;
4136
+ counts.set(obs.source_tool, (counts.get(obs.source_tool) ?? 0) + 1);
4137
+ }
4138
+ 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);
4139
+ }
4140
+
4141
+ // src/tools/handoffs.ts
4142
+ async function upsertRollingHandoff(db, config, input) {
4143
+ const resolved = resolveTargetSession(db, input.cwd, config.user_id, input.session_id);
4144
+ if (!resolved.session) {
4145
+ return {
4146
+ success: false,
4147
+ reason: "No recent session found to draft a handoff yet"
4148
+ };
4149
+ }
4150
+ const story = getSessionStory(db, { session_id: resolved.session.session_id });
4151
+ if (!story.session) {
4152
+ return {
4153
+ success: false,
4154
+ reason: `Session ${resolved.session.session_id} not found`
4155
+ };
4156
+ }
4157
+ const includeChat = input.include_chat === true || input.include_chat !== false && shouldAutoIncludeChat(story);
4158
+ const chatLimit = Math.max(1, Math.min(input.chat_limit ?? 3, 6));
4159
+ const title = `Handoff Draft: ${buildHandoffTitle(story.summary, story.latest_request)}`;
4160
+ const narrative = buildHandoffNarrative(story.summary, story, {
4161
+ includeChat,
4162
+ chatLimit
4163
+ });
4164
+ const facts = buildHandoffFacts(story.summary, story);
4165
+ const concepts = buildDraftHandoffConcepts(story.project_name, story.capture_state);
4166
+ const existing = getSessionRollingHandoff(db, story.session.session_id);
4167
+ const now = Math.floor(Date.now() / 1000);
4168
+ if (existing) {
4169
+ const nextFacts = JSON.stringify(facts);
4170
+ const nextConcepts = JSON.stringify(concepts);
4171
+ const shouldRefresh = existing.title !== title || (existing.narrative ?? null) !== narrative || (existing.facts ?? null) !== nextFacts || (existing.concepts ?? null) !== nextConcepts || now - existing.created_at_epoch >= 120;
4172
+ if (!shouldRefresh) {
4173
+ return {
4174
+ success: true,
4175
+ observation_id: existing.id,
4176
+ session_id: story.session.session_id,
4177
+ title: existing.title
4178
+ };
4179
+ }
4180
+ const updated = db.updateObservationContent(existing.id, {
4181
+ title,
4182
+ narrative,
4183
+ facts: nextFacts,
4184
+ concepts: nextConcepts,
4185
+ created_at_epoch: now
4186
+ });
4187
+ if (!updated) {
4188
+ return {
4189
+ success: false,
4190
+ reason: "Failed to update rolling handoff draft"
4191
+ };
4192
+ }
4193
+ db.addToOutbox("observation", updated.id);
4194
+ return {
4195
+ success: true,
4196
+ observation_id: updated.id,
4197
+ session_id: story.session.session_id,
4198
+ title: updated.title
4199
+ };
4200
+ }
4201
+ const result = await saveObservation(db, config, {
4202
+ type: "message",
4203
+ title,
4204
+ narrative,
4205
+ facts,
4206
+ concepts,
4207
+ session_id: story.session.session_id,
4208
+ cwd: input.cwd,
4209
+ agent: "engrm-handoff",
4210
+ source_tool: "rolling_handoff"
4211
+ });
4212
+ return {
4213
+ success: result.success,
4214
+ observation_id: result.observation_id,
4215
+ session_id: story.session.session_id,
4216
+ title,
4217
+ reason: result.reason
4218
+ };
4219
+ }
4220
+ function getRecentHandoffs(db, input) {
4221
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
4222
+ const queryLimit = input.current_device_id ? Math.max(limit, Math.min(limit * 5, 50)) : limit;
4223
+ const projectScoped = input.project_scoped !== false;
4224
+ let projectId = null;
4225
+ let projectName;
4226
+ if (projectScoped) {
4227
+ const cwd = input.cwd ?? process.cwd();
4228
+ const detected = detectProject(cwd);
4229
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
4230
+ if (project) {
4231
+ projectId = project.id;
4232
+ projectName = project.name;
4233
+ }
4234
+ }
4235
+ const conditions = [
4236
+ "o.type = 'message'",
4237
+ "o.lifecycle IN ('active', 'aging', 'pinned')",
4238
+ "o.superseded_by IS NULL",
4239
+ `(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
4240
+ ];
4241
+ const params = [];
4242
+ if (input.user_id) {
4243
+ conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
4244
+ params.push(input.user_id);
4245
+ }
4246
+ if (projectId !== null) {
4247
+ conditions.push("o.project_id = ?");
4248
+ params.push(projectId);
4249
+ }
4250
+ params.push(queryLimit);
4251
+ const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
4252
+ FROM observations o
4253
+ LEFT JOIN projects p ON p.id = o.project_id
4254
+ WHERE ${conditions.join(" AND ")}
4255
+ ORDER BY o.created_at_epoch DESC, o.id DESC
4256
+ LIMIT ?`).all(...params);
4257
+ handoffs.sort((a, b) => compareHandoffs(a, b, input.current_device_id));
4258
+ return {
4259
+ handoffs: handoffs.slice(0, limit),
4260
+ project: projectName
4261
+ };
4262
+ }
4263
+ function formatHandoffSource(handoff) {
4264
+ const ageSeconds = Math.max(0, Math.floor(Date.now() / 1000) - handoff.created_at_epoch);
4265
+ 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`;
4266
+ return `from ${handoff.device_id} · ${ageLabel}`;
4267
+ }
4268
+ function isDraftHandoff(obs) {
4269
+ if (obs.title.startsWith("Handoff Draft:"))
4270
+ return true;
4271
+ const concepts = parseJsonArray4(obs.concepts);
4272
+ return concepts.includes("draft-handoff") || concepts.includes("auto-handoff");
4273
+ }
4274
+ function getSessionRollingHandoff(db, sessionId) {
4275
+ return db.db.query(`SELECT o.*, p.name AS project_name
4276
+ FROM observations o
4277
+ LEFT JOIN projects p ON p.id = o.project_id
4278
+ WHERE o.session_id = ?
4279
+ AND o.type = 'message'
4280
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
4281
+ AND o.superseded_by IS NULL
4282
+ AND (o.title LIKE 'Handoff Draft:%' OR o.concepts LIKE '%"draft-handoff"%')
4283
+ ORDER BY o.created_at_epoch DESC, o.id DESC
4284
+ LIMIT 1`).get(sessionId) ?? null;
4285
+ }
4286
+ function compareHandoffs(a, b, currentDeviceId) {
4287
+ const aDraft = isDraftHandoff(a) ? 1 : 0;
4288
+ const bDraft = isDraftHandoff(b) ? 1 : 0;
4289
+ if (aDraft !== bDraft)
4290
+ return aDraft - bDraft;
4291
+ if (currentDeviceId) {
4292
+ const aOther = a.device_id !== currentDeviceId ? 1 : 0;
4293
+ const bOther = b.device_id !== currentDeviceId ? 1 : 0;
4294
+ if (aOther !== bOther)
4295
+ return bOther - aOther;
4296
+ }
4297
+ if (b.created_at_epoch !== a.created_at_epoch) {
4298
+ return b.created_at_epoch - a.created_at_epoch;
4299
+ }
4300
+ return b.id - a.id;
4301
+ }
4302
+ function resolveTargetSession(db, cwd, userId, sessionId) {
4303
+ if (sessionId) {
4304
+ const session = db.getSessionById(sessionId);
4305
+ if (!session)
4306
+ return { session: null };
4307
+ const projectName = session.project_id ? db.getProjectById(session.project_id)?.name : undefined;
4308
+ return {
4309
+ session: {
4310
+ ...session,
4311
+ project_name: projectName ?? null,
4312
+ request: db.getSessionSummary(sessionId)?.request ?? null,
4313
+ completed: db.getSessionSummary(sessionId)?.completed ?? null,
4314
+ current_thread: db.getSessionSummary(sessionId)?.current_thread ?? null,
4315
+ capture_state: db.getSessionSummary(sessionId)?.capture_state ?? null,
4316
+ recent_tool_names: db.getSessionSummary(sessionId)?.recent_tool_names ?? null,
4317
+ hot_files: db.getSessionSummary(sessionId)?.hot_files ?? null,
4318
+ recent_outcomes: db.getSessionSummary(sessionId)?.recent_outcomes ?? null,
4319
+ prompt_count: db.getSessionUserPrompts(sessionId, 200).length,
4320
+ tool_event_count: db.getSessionToolEvents(sessionId, 200).length
4321
+ },
4322
+ projectName: projectName ?? undefined
4323
+ };
4324
+ }
4325
+ const detected = detectProject(cwd ?? process.cwd());
4326
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
4327
+ const sessions = db.getRecentSessions(project?.id ?? null, 10, userId);
4328
+ return {
4329
+ session: sessions[0] ?? null,
4330
+ projectName: project?.name
4331
+ };
4332
+ }
4333
+ function buildHandoffTitle(summary, latestRequest, explicit) {
4334
+ const chosen = explicit?.trim() || summary?.current_thread?.trim() || summary?.completed?.trim() || latestRequest?.trim() || "Current work";
4335
+ return compactLine2(chosen) ?? "Current work";
4336
+ }
4337
+ function buildHandoffNarrative(summary, story, options) {
4338
+ const sections = [];
4339
+ if (summary?.request || story.latest_request) {
4340
+ sections.push(`Request: ${summary?.request ?? story.latest_request}`);
4341
+ }
4342
+ if (summary?.current_thread) {
4343
+ sections.push(`Current thread: ${summary.current_thread}`);
4344
+ }
4345
+ if (summary?.investigated) {
4346
+ sections.push(`Investigated: ${summary.investigated}`);
4347
+ }
4348
+ if (summary?.learned) {
4349
+ sections.push(`Learned: ${summary.learned}`);
4350
+ }
4351
+ if (summary?.completed) {
4352
+ sections.push(`Completed: ${summary.completed}`);
4353
+ }
4354
+ if (summary?.next_steps) {
4355
+ sections.push(`Next Steps: ${summary.next_steps}`);
4356
+ }
4357
+ if (story.recent_outcomes.length > 0) {
4358
+ sections.push(`Recent outcomes:
4359
+ ${story.recent_outcomes.slice(0, 5).map((item) => `- ${item}`).join(`
4360
+ `)}`);
4361
+ }
4362
+ if (story.hot_files.length > 0) {
4363
+ sections.push(`Hot files:
4364
+ ${story.hot_files.slice(0, 5).map((file) => `- ${file.path}`).join(`
4365
+ `)}`);
4366
+ }
4367
+ if (story.provenance_summary.length > 0) {
4368
+ sections.push(`Tool trail:
4369
+ ${story.provenance_summary.slice(0, 5).map((item) => `- ${item.tool}: ${item.count}`).join(`
4370
+ `)}`);
4371
+ }
4372
+ if (options.includeChat && story.chat_messages.length > 0) {
4373
+ const chatLines = story.chat_messages.slice(-options.chatLimit).map((msg) => `- [${msg.role}] ${compactLine2(msg.content) ?? msg.content.slice(0, 120)}`);
4374
+ sections.push(`Chat snippets:
4375
+ ${chatLines.join(`
4376
+ `)}`);
4377
+ }
4378
+ return sections.filter(Boolean).join(`
4379
+
4380
+ `);
4381
+ }
4382
+ function shouldAutoIncludeChat(story) {
4383
+ if (story.chat_messages.length === 0)
4384
+ return false;
4385
+ const summary = story.summary;
4386
+ const thinSummary = !summary?.completed && !summary?.current_thread && story.recent_outcomes.length < 2;
4387
+ const thinChronology = story.capture_state !== "rich" || story.tool_events.length === 0;
4388
+ return thinSummary || thinChronology;
4389
+ }
4390
+ function buildHandoffFacts(summary, story) {
4391
+ const facts = [
4392
+ `session_id=${story.session?.session_id ?? "unknown"}`,
4393
+ `capture_state=${story.capture_state}`,
4394
+ story.project_name ? `project=${story.project_name}` : null,
4395
+ summary?.current_thread ? `current_thread=${summary.current_thread}` : null,
4396
+ story.hot_files[0] ? `hot_file=${story.hot_files[0].path}` : null,
4397
+ story.provenance_summary[0] ? `primary_tool=${story.provenance_summary[0].tool}` : null
4398
+ ];
4399
+ return facts.filter((item) => Boolean(item));
4400
+ }
4401
+ function buildDraftHandoffConcepts(projectName, captureState) {
4402
+ return [
4403
+ "handoff",
4404
+ "draft-handoff",
4405
+ "auto-handoff",
4406
+ `capture:${captureState}`,
4407
+ ...projectName ? [projectName] : []
4408
+ ];
4409
+ }
4410
+ function looksLikeHandoff(obs) {
4411
+ if (obs.title.startsWith("Handoff:") || obs.title.startsWith("Handoff Draft:"))
4412
+ return true;
4413
+ const concepts = parseJsonArray4(obs.concepts);
4414
+ return concepts.includes("handoff") || concepts.includes("session-handoff") || concepts.includes("draft-handoff");
4415
+ }
4416
+ function parseJsonArray4(value) {
4417
+ if (!value)
4418
+ return [];
4419
+ try {
4420
+ const parsed = JSON.parse(value);
4421
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
4422
+ } catch {
4423
+ return [];
4424
+ }
4425
+ }
4426
+ function compactLine2(value) {
4427
+ const trimmed = value?.replace(/\s+/g, " ").trim();
4428
+ if (!trimmed)
4429
+ return null;
4430
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
4431
+ }
4432
+
3593
4433
  // hooks/stop.ts
3594
4434
  function printRetrospective(summary) {
3595
4435
  const lines = [];
@@ -3637,8 +4477,30 @@ async function main() {
3637
4477
  try {
3638
4478
  if (event.session_id) {
3639
4479
  db.completeSession(event.session_id);
4480
+ syncTranscriptChat(db, config, event.session_id, event.cwd, event.transcript_path);
3640
4481
  if (event.last_assistant_message) {
3641
4482
  try {
4483
+ const detected = detectProject(event.cwd);
4484
+ const project = db.getProjectByCanonicalId(detected.canonical_id) ?? db.upsertProject({
4485
+ canonical_id: detected.canonical_id,
4486
+ name: detected.name,
4487
+ local_path: event.cwd,
4488
+ remote_url: detected.remote_url ?? null
4489
+ });
4490
+ const compactAssistant = event.last_assistant_message.replace(/\s+/g, " ").trim();
4491
+ if (compactAssistant.length >= 24) {
4492
+ const chatMessage = db.insertChatMessage({
4493
+ session_id: event.session_id,
4494
+ project_id: project.id,
4495
+ role: "assistant",
4496
+ content: event.last_assistant_message,
4497
+ user_id: config.user_id,
4498
+ device_id: config.device_id,
4499
+ agent: "claude-code",
4500
+ source_kind: "hook"
4501
+ });
4502
+ db.addToOutbox("chat_message", chatMessage.id);
4503
+ }
3642
4504
  createAssistantCheckpoint(db, event.session_id, event.cwd, event.last_assistant_message);
3643
4505
  } catch {}
3644
4506
  }
@@ -3652,6 +4514,10 @@ async function main() {
3652
4514
  if (summary) {
3653
4515
  const row = db.upsertSessionSummary(summary);
3654
4516
  db.addToOutbox("summary", row.id);
4517
+ await upsertRollingHandoff(db, config, {
4518
+ session_id: event.session_id,
4519
+ cwd: event.cwd
4520
+ });
3655
4521
  let securityFindings = [];
3656
4522
  try {
3657
4523
  if (session?.project_id) {