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.
@@ -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'
@@ -885,6 +885,18 @@ var MIGRATIONS = [
885
885
  },
886
886
  {
887
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
+ },
898
+ {
899
+ version: 12,
888
900
  description: "Add synced handoff metadata to session summaries",
889
901
  sql: `
890
902
  ALTER TABLE session_summaries ADD COLUMN capture_state TEXT;
@@ -894,15 +906,79 @@ 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);
906
982
  `
907
983
  }
908
984
  ];
@@ -959,6 +1035,21 @@ function inferLegacySchemaVersion(db) {
959
1035
  version = Math.max(version, 10);
960
1036
  if (columnExists(db, "observations", "source_tool"))
961
1037
  version = Math.max(version, 11);
1038
+ 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")) {
1039
+ version = Math.max(version, 12);
1040
+ }
1041
+ if (columnExists(db, "session_summaries", "current_thread")) {
1042
+ version = Math.max(version, 13);
1043
+ }
1044
+ if (tableExists(db, "chat_messages")) {
1045
+ version = Math.max(version, 14);
1046
+ }
1047
+ if (columnExists(db, "chat_messages", "remote_source_id")) {
1048
+ version = Math.max(version, 15);
1049
+ }
1050
+ if (syncOutboxSupportsChatMessages(db)) {
1051
+ version = Math.max(version, 16);
1052
+ }
962
1053
  return version;
963
1054
  }
964
1055
  function runMigrations(db) {
@@ -1037,6 +1128,93 @@ function ensureObservationTypes(db) {
1037
1128
  }
1038
1129
  }
1039
1130
  }
1131
+ function ensureSessionSummaryColumns(db) {
1132
+ const required = [
1133
+ "capture_state",
1134
+ "recent_tool_names",
1135
+ "hot_files",
1136
+ "recent_outcomes",
1137
+ "current_thread"
1138
+ ];
1139
+ for (const column of required) {
1140
+ if (columnExists(db, "session_summaries", column))
1141
+ continue;
1142
+ db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
1143
+ }
1144
+ const current = getSchemaVersion(db);
1145
+ if (current < 13) {
1146
+ db.exec("PRAGMA user_version = 13");
1147
+ }
1148
+ }
1149
+ function ensureChatMessageColumns(db) {
1150
+ if (!tableExists(db, "chat_messages"))
1151
+ return;
1152
+ if (!columnExists(db, "chat_messages", "remote_source_id")) {
1153
+ db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
1154
+ }
1155
+ 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");
1156
+ const current = getSchemaVersion(db);
1157
+ if (current < 15) {
1158
+ db.exec("PRAGMA user_version = 15");
1159
+ }
1160
+ }
1161
+ function ensureSyncOutboxSupportsChatMessages(db) {
1162
+ if (syncOutboxSupportsChatMessages(db)) {
1163
+ const current = getSchemaVersion(db);
1164
+ if (current < 16) {
1165
+ db.exec("PRAGMA user_version = 16");
1166
+ }
1167
+ return;
1168
+ }
1169
+ db.exec("BEGIN TRANSACTION");
1170
+ try {
1171
+ db.exec(`
1172
+ CREATE TABLE sync_outbox_new (
1173
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1174
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
1175
+ record_id INTEGER NOT NULL,
1176
+ status TEXT DEFAULT 'pending' CHECK (status IN (
1177
+ 'pending', 'syncing', 'synced', 'failed'
1178
+ )),
1179
+ retry_count INTEGER DEFAULT 0,
1180
+ max_retries INTEGER DEFAULT 10,
1181
+ last_error TEXT,
1182
+ created_at_epoch INTEGER NOT NULL,
1183
+ synced_at_epoch INTEGER,
1184
+ next_retry_epoch INTEGER
1185
+ );
1186
+
1187
+ INSERT INTO sync_outbox_new (
1188
+ id, record_type, record_id, status, retry_count, max_retries,
1189
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
1190
+ )
1191
+ SELECT
1192
+ id, record_type, record_id, status, retry_count, max_retries,
1193
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
1194
+ FROM sync_outbox;
1195
+
1196
+ DROP TABLE sync_outbox;
1197
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
1198
+
1199
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
1200
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
1201
+ `);
1202
+ db.exec("PRAGMA user_version = 16");
1203
+ db.exec("COMMIT");
1204
+ } catch (error) {
1205
+ db.exec("ROLLBACK");
1206
+ throw new Error(`sync_outbox repair failed: ${error instanceof Error ? error.message : String(error)}`);
1207
+ }
1208
+ }
1209
+ function syncOutboxSupportsChatMessages(db) {
1210
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("sync_outbox");
1211
+ const sql = row?.sql ?? "";
1212
+ return sql.includes("'chat_message'");
1213
+ }
1214
+ function getSchemaVersion(db) {
1215
+ const result = db.query("PRAGMA user_version").get();
1216
+ return result.user_version;
1217
+ }
1040
1218
  var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
1041
1219
 
1042
1220
  // src/storage/sqlite.ts
@@ -1111,6 +1289,9 @@ class MemDatabase {
1111
1289
  this.vecAvailable = this.loadVecExtension();
1112
1290
  runMigrations(this.db);
1113
1291
  ensureObservationTypes(this.db);
1292
+ ensureSessionSummaryColumns(this.db);
1293
+ ensureChatMessageColumns(this.db);
1294
+ ensureSyncOutboxSupportsChatMessages(this.db);
1114
1295
  }
1115
1296
  loadVecExtension() {
1116
1297
  try {
@@ -1336,6 +1517,7 @@ class MemDatabase {
1336
1517
  p.name AS project_name,
1337
1518
  ss.request AS request,
1338
1519
  ss.completed AS completed,
1520
+ ss.current_thread AS current_thread,
1339
1521
  ss.capture_state AS capture_state,
1340
1522
  ss.recent_tool_names AS recent_tool_names,
1341
1523
  ss.hot_files AS hot_files,
@@ -1354,6 +1536,7 @@ class MemDatabase {
1354
1536
  p.name AS project_name,
1355
1537
  ss.request AS request,
1356
1538
  ss.completed AS completed,
1539
+ ss.current_thread AS current_thread,
1357
1540
  ss.capture_state AS capture_state,
1358
1541
  ss.recent_tool_names AS recent_tool_names,
1359
1542
  ss.hot_files AS hot_files,
@@ -1444,6 +1627,54 @@ class MemDatabase {
1444
1627
  ORDER BY created_at_epoch DESC, id DESC
1445
1628
  LIMIT ?`).all(...userId ? [userId] : [], limit);
1446
1629
  }
1630
+ insertChatMessage(input) {
1631
+ const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1632
+ const content = input.content.trim();
1633
+ const result = this.db.query(`INSERT INTO chat_messages (
1634
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
1635
+ ) 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);
1636
+ return this.getChatMessageById(Number(result.lastInsertRowid));
1637
+ }
1638
+ getChatMessageById(id) {
1639
+ return this.db.query("SELECT * FROM chat_messages WHERE id = ?").get(id) ?? null;
1640
+ }
1641
+ getChatMessageByRemoteSourceId(remoteSourceId) {
1642
+ return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
1643
+ }
1644
+ getSessionChatMessages(sessionId, limit = 50) {
1645
+ return this.db.query(`SELECT * FROM chat_messages
1646
+ WHERE session_id = ?
1647
+ ORDER BY created_at_epoch ASC, id ASC
1648
+ LIMIT ?`).all(sessionId, limit);
1649
+ }
1650
+ getRecentChatMessages(projectId, limit = 20, userId) {
1651
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1652
+ if (projectId !== null) {
1653
+ return this.db.query(`SELECT * FROM chat_messages
1654
+ WHERE project_id = ?${visibilityClause}
1655
+ ORDER BY created_at_epoch DESC, id DESC
1656
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1657
+ }
1658
+ return this.db.query(`SELECT * FROM chat_messages
1659
+ WHERE 1 = 1${visibilityClause}
1660
+ ORDER BY created_at_epoch DESC, id DESC
1661
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
1662
+ }
1663
+ searchChatMessages(query, projectId, limit = 20, userId) {
1664
+ const needle = `%${query.toLowerCase()}%`;
1665
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1666
+ if (projectId !== null) {
1667
+ return this.db.query(`SELECT * FROM chat_messages
1668
+ WHERE project_id = ?
1669
+ AND lower(content) LIKE ?${visibilityClause}
1670
+ ORDER BY created_at_epoch DESC, id DESC
1671
+ LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
1672
+ }
1673
+ return this.db.query(`SELECT * FROM chat_messages
1674
+ WHERE lower(content) LIKE ?${visibilityClause}
1675
+ ORDER BY created_at_epoch DESC, id DESC
1676
+ LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1677
+ }
1447
1678
  addToOutbox(recordType, recordId) {
1448
1679
  const now = Math.floor(Date.now() / 1000);
1449
1680
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -1532,9 +1763,9 @@ class MemDatabase {
1532
1763
  };
1533
1764
  const result = this.db.query(`INSERT INTO session_summaries (
1534
1765
  session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
1535
- capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1766
+ current_thread, capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1536
1767
  )
1537
- 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);
1768
+ 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);
1538
1769
  const id = Number(result.lastInsertRowid);
1539
1770
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
1540
1771
  }
@@ -1550,6 +1781,7 @@ class MemDatabase {
1550
1781
  learned: normalizeSummarySection(summary.learned ?? existing.learned),
1551
1782
  completed: normalizeSummarySection(summary.completed ?? existing.completed),
1552
1783
  next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
1784
+ current_thread: summary.current_thread ?? existing.current_thread,
1553
1785
  capture_state: summary.capture_state ?? existing.capture_state,
1554
1786
  recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
1555
1787
  hot_files: summary.hot_files ?? existing.hot_files,
@@ -1563,12 +1795,13 @@ class MemDatabase {
1563
1795
  learned = ?,
1564
1796
  completed = ?,
1565
1797
  next_steps = ?,
1798
+ current_thread = ?,
1566
1799
  capture_state = ?,
1567
1800
  recent_tool_names = ?,
1568
1801
  hot_files = ?,
1569
1802
  recent_outcomes = ?,
1570
1803
  created_at_epoch = ?
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.capture_state, normalized.recent_tool_names, normalized.hot_files, normalized.recent_outcomes, now, summary.session_id);
1804
+ 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);
1572
1805
  return this.getSessionSummary(summary.session_id);
1573
1806
  }
1574
1807
  getSessionSummary(sessionId) {
@@ -1829,22 +2062,27 @@ function buildSourceId(config, localId, type = "obs") {
1829
2062
  return `${config.user_id}-${config.device_id}-${type}-${localId}`;
1830
2063
  }
1831
2064
  function parseSourceId(sourceId) {
1832
- const obsIndex = sourceId.lastIndexOf("-obs-");
1833
- if (obsIndex === -1)
1834
- return null;
1835
- const prefix = sourceId.slice(0, obsIndex);
1836
- const localIdStr = sourceId.slice(obsIndex + 5);
1837
- const localId = parseInt(localIdStr, 10);
1838
- if (isNaN(localId))
1839
- return null;
1840
- const firstDash = prefix.indexOf("-");
1841
- if (firstDash === -1)
1842
- return null;
1843
- return {
1844
- userId: prefix.slice(0, firstDash),
1845
- deviceId: prefix.slice(firstDash + 1),
1846
- localId
1847
- };
2065
+ for (const type of ["obs", "summary", "chat"]) {
2066
+ const marker = `-${type}-`;
2067
+ const idx = sourceId.lastIndexOf(marker);
2068
+ if (idx === -1)
2069
+ continue;
2070
+ const prefix = sourceId.slice(0, idx);
2071
+ const localIdStr = sourceId.slice(idx + marker.length);
2072
+ const localId = parseInt(localIdStr, 10);
2073
+ if (isNaN(localId))
2074
+ return null;
2075
+ const firstDash = prefix.indexOf("-");
2076
+ if (firstDash === -1)
2077
+ return null;
2078
+ return {
2079
+ userId: prefix.slice(0, firstDash),
2080
+ deviceId: prefix.slice(firstDash + 1),
2081
+ localId,
2082
+ type
2083
+ };
2084
+ }
2085
+ return null;
1848
2086
  }
1849
2087
 
1850
2088
  // src/sync/client.ts
@@ -2001,7 +2239,106 @@ function computeSessionValueSignals(observations, securityFindings = []) {
2001
2239
  };
2002
2240
  }
2003
2241
 
2242
+ // src/capture/session-handoff.ts
2243
+ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
2244
+ const latestRequest = prompts.length > 0 ? prompts[prompts.length - 1]?.prompt ?? null : null;
2245
+ const recentRequestPrompts = prompts.slice(-3).map((prompt) => prompt.prompt.trim()).filter(Boolean);
2246
+ const recentToolNames = [...new Set(toolEvents.slice(-8).map((tool) => tool.tool_name).filter(Boolean))];
2247
+ const recentToolCommands = [...new Set(toolEvents.slice(-5).map((tool) => (tool.command ?? tool.file_path ?? "").trim()).filter(Boolean))];
2248
+ const hotFiles = [...new Set(observations.flatMap((obs) => [
2249
+ ...parseJsonArray2(obs.files_modified),
2250
+ ...parseJsonArray2(obs.files_read)
2251
+ ]).filter(Boolean))].slice(0, 6);
2252
+ const recentOutcomes = observations.filter((obs) => ["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type)).map((obs) => obs.title.trim()).filter((title) => title.length > 0).slice(0, 6);
2253
+ const captureState = prompts.length > 0 && toolEvents.length > 0 ? "rich" : prompts.length > 0 || toolEvents.length > 0 ? "partial" : "summary-only";
2254
+ const observationSourceTools = Array.from(observations.reduce((acc, obs) => {
2255
+ if (!obs.source_tool)
2256
+ return acc;
2257
+ acc.set(obs.source_tool, (acc.get(obs.source_tool) ?? 0) + 1);
2258
+ return acc;
2259
+ }, new Map).entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
2260
+ const latestObservationPromptNumber = observations.map((obs) => obs.source_prompt_number).filter((value) => typeof value === "number").sort((a, b) => b - a)[0] ?? null;
2261
+ const currentThread = buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolNames);
2262
+ return {
2263
+ prompt_count: prompts.length,
2264
+ tool_event_count: toolEvents.length,
2265
+ recent_request_prompts: recentRequestPrompts,
2266
+ latest_request: latestRequest,
2267
+ current_thread: currentThread,
2268
+ recent_tool_names: recentToolNames,
2269
+ recent_tool_commands: recentToolCommands,
2270
+ capture_state: captureState,
2271
+ hot_files: hotFiles,
2272
+ recent_outcomes: recentOutcomes,
2273
+ observation_source_tools: observationSourceTools,
2274
+ latest_observation_prompt_number: latestObservationPromptNumber
2275
+ };
2276
+ }
2277
+ function buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolNames) {
2278
+ const request = compactLine(latestRequest);
2279
+ const outcome = recentOutcomes.map((item) => compactLine(item)).find(Boolean);
2280
+ const file = hotFiles[0] ? compactFileHint(hotFiles[0]) : null;
2281
+ const tools = recentToolNames.slice(0, 2).join("/");
2282
+ if (outcome && file) {
2283
+ return `${outcome} · ${file}${tools ? ` · ${tools}` : ""}`;
2284
+ }
2285
+ if (request && file) {
2286
+ return `${request} · ${file}${tools ? ` · ${tools}` : ""}`;
2287
+ }
2288
+ if (outcome) {
2289
+ return `${outcome}${tools ? ` · ${tools}` : ""}`;
2290
+ }
2291
+ if (request) {
2292
+ return `${request}${tools ? ` · ${tools}` : ""}`;
2293
+ }
2294
+ return null;
2295
+ }
2296
+ function compactLine(value) {
2297
+ const trimmed = value?.replace(/\s+/g, " ").trim();
2298
+ if (!trimmed)
2299
+ return null;
2300
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
2301
+ }
2302
+ function compactFileHint(value) {
2303
+ const parts = value.split("/");
2304
+ if (parts.length <= 2)
2305
+ return value;
2306
+ return parts.slice(-2).join("/");
2307
+ }
2308
+ function parseJsonArray2(value) {
2309
+ if (!value)
2310
+ return [];
2311
+ try {
2312
+ const parsed = JSON.parse(value);
2313
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
2314
+ } catch {
2315
+ return [];
2316
+ }
2317
+ }
2318
+
2004
2319
  // src/sync/push.ts
2320
+ function buildChatVectorDocument(chat, config, project) {
2321
+ return {
2322
+ site_id: config.site_id,
2323
+ namespace: config.namespace,
2324
+ source_type: "chat",
2325
+ source_id: buildSourceId(config, chat.id, "chat"),
2326
+ content: chat.content,
2327
+ metadata: {
2328
+ project_canonical: project.canonical_id,
2329
+ project_name: project.name,
2330
+ user_id: chat.user_id,
2331
+ device_id: chat.device_id,
2332
+ device_name: __require("node:os").hostname(),
2333
+ agent: chat.agent,
2334
+ type: "chat",
2335
+ role: chat.role,
2336
+ session_id: chat.session_id,
2337
+ created_at_epoch: chat.created_at_epoch,
2338
+ local_id: chat.id
2339
+ }
2340
+ };
2341
+ }
2005
2342
  function buildVectorDocument(obs, config, project) {
2006
2343
  const parts = [obs.title];
2007
2344
  if (obs.narrative)
@@ -2082,6 +2419,7 @@ function buildSummaryVectorDocument(summary, config, project, observations = [],
2082
2419
  learned: summary.learned,
2083
2420
  completed: summary.completed,
2084
2421
  next_steps: summary.next_steps,
2422
+ current_thread: summary.current_thread,
2085
2423
  summary_sections_present: countPresentSections(summary),
2086
2424
  investigated_items: extractSectionItems(summary.investigated),
2087
2425
  learned_items: extractSectionItems(summary.learned),
@@ -2092,6 +2430,7 @@ function buildSummaryVectorDocument(summary, config, project, observations = [],
2092
2430
  capture_state: captureContext?.capture_state ?? "summary-only",
2093
2431
  recent_request_prompts: captureContext?.recent_request_prompts ?? [],
2094
2432
  latest_request: captureContext?.latest_request ?? null,
2433
+ current_thread: captureContext?.current_thread ?? null,
2095
2434
  recent_tool_names: captureContext?.recent_tool_names ?? [],
2096
2435
  recent_tool_commands: captureContext?.recent_tool_commands ?? [],
2097
2436
  hot_files: captureContext?.hot_files ?? [],
@@ -2141,7 +2480,33 @@ async function pushOutbox(db, client, config, batchSize = 50) {
2141
2480
  const doc2 = buildSummaryVectorDocument(summary, config, {
2142
2481
  canonical_id: project2.canonical_id,
2143
2482
  name: project2.name
2144
- }, summaryObservations, buildSummaryCaptureContext(sessionPrompts, sessionToolEvents, summaryObservations));
2483
+ }, summaryObservations, buildSessionHandoffMetadata(sessionPrompts, sessionToolEvents, summaryObservations));
2484
+ batch.push({ entryId: entry.id, doc: doc2 });
2485
+ continue;
2486
+ }
2487
+ if (entry.record_type === "chat_message") {
2488
+ const chat = db.getChatMessageById(entry.record_id);
2489
+ if (!chat || chat.remote_source_id) {
2490
+ markSynced(db, entry.id);
2491
+ skipped++;
2492
+ continue;
2493
+ }
2494
+ if (!chat.project_id) {
2495
+ markSynced(db, entry.id);
2496
+ skipped++;
2497
+ continue;
2498
+ }
2499
+ const project2 = db.getProjectById(chat.project_id);
2500
+ if (!project2) {
2501
+ markSynced(db, entry.id);
2502
+ skipped++;
2503
+ continue;
2504
+ }
2505
+ markSyncing(db, entry.id);
2506
+ const doc2 = buildChatVectorDocument(chat, config, {
2507
+ canonical_id: project2.canonical_id,
2508
+ name: project2.name
2509
+ });
2145
2510
  batch.push({ entryId: entry.id, doc: doc2 });
2146
2511
  continue;
2147
2512
  }
@@ -2182,7 +2547,13 @@ async function pushOutbox(db, client, config, batchSize = 50) {
2182
2547
  return { pushed, failed, skipped };
2183
2548
  try {
2184
2549
  await client.batchIngest(batch.map((b) => b.doc));
2185
- for (const { entryId } of batch) {
2550
+ for (const { entryId, doc } of batch) {
2551
+ if (doc.source_type === "chat") {
2552
+ const localId = typeof doc.metadata?.local_id === "number" ? doc.metadata.local_id : null;
2553
+ if (localId !== null) {
2554
+ db.db.query("UPDATE chat_messages SET remote_source_id = ? WHERE id = ?").run(doc.source_id, localId);
2555
+ }
2556
+ }
2186
2557
  markSynced(db, entryId);
2187
2558
  pushed++;
2188
2559
  }
@@ -2190,6 +2561,12 @@ async function pushOutbox(db, client, config, batchSize = 50) {
2190
2561
  for (const { entryId, doc } of batch) {
2191
2562
  try {
2192
2563
  await client.ingest(doc);
2564
+ if (doc.source_type === "chat") {
2565
+ const localId = typeof doc.metadata?.local_id === "number" ? doc.metadata.local_id : null;
2566
+ if (localId !== null) {
2567
+ db.db.query("UPDATE chat_messages SET remote_source_id = ? WHERE id = ?").run(doc.source_id, localId);
2568
+ }
2569
+ }
2193
2570
  markSynced(db, entryId);
2194
2571
  pushed++;
2195
2572
  } catch (err) {
@@ -2212,48 +2589,6 @@ function countPresentSections(summary) {
2212
2589
  function extractSectionItems(section) {
2213
2590
  return extractSummaryItems(section, 4);
2214
2591
  }
2215
- function buildSummaryCaptureContext(prompts, toolEvents, observations) {
2216
- const latestRequest = prompts.length > 0 ? prompts[prompts.length - 1]?.prompt ?? null : null;
2217
- const recentRequestPrompts = prompts.slice(-3).map((prompt) => prompt.prompt.trim()).filter(Boolean);
2218
- const recentToolNames = [...new Set(toolEvents.slice(-8).map((tool) => tool.tool_name).filter(Boolean))];
2219
- const recentToolCommands = [...new Set(toolEvents.slice(-5).map((tool) => (tool.command ?? tool.file_path ?? "").trim()).filter(Boolean))];
2220
- const hotFiles = [...new Set(observations.flatMap((obs) => [
2221
- ...parseJsonArray2(obs.files_modified),
2222
- ...parseJsonArray2(obs.files_read)
2223
- ]).filter(Boolean))].slice(0, 6);
2224
- const recentOutcomes = observations.filter((obs) => ["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type)).map((obs) => obs.title.trim()).filter((title) => title.length > 0).slice(0, 6);
2225
- const captureState = prompts.length > 0 && toolEvents.length > 0 ? "rich" : prompts.length > 0 || toolEvents.length > 0 ? "partial" : "summary-only";
2226
- const observationSourceTools = Array.from(observations.reduce((acc, obs) => {
2227
- if (!obs.source_tool)
2228
- return acc;
2229
- acc.set(obs.source_tool, (acc.get(obs.source_tool) ?? 0) + 1);
2230
- return acc;
2231
- }, new Map).entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
2232
- const latestObservationPromptNumber = observations.map((obs) => obs.source_prompt_number).filter((value) => typeof value === "number").sort((a, b) => b - a)[0] ?? null;
2233
- return {
2234
- prompt_count: prompts.length,
2235
- tool_event_count: toolEvents.length,
2236
- recent_request_prompts: recentRequestPrompts,
2237
- latest_request: latestRequest,
2238
- recent_tool_names: recentToolNames,
2239
- recent_tool_commands: recentToolCommands,
2240
- capture_state: captureState,
2241
- hot_files: hotFiles,
2242
- recent_outcomes: recentOutcomes,
2243
- observation_source_tools: observationSourceTools,
2244
- latest_observation_prompt_number: latestObservationPromptNumber
2245
- };
2246
- }
2247
- function parseJsonArray2(value) {
2248
- if (!value)
2249
- return [];
2250
- try {
2251
- const parsed = JSON.parse(value);
2252
- return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
2253
- } catch {
2254
- return [];
2255
- }
2256
- }
2257
2592
 
2258
2593
  // src/embeddings/embedder.ts
2259
2594
  var _available = null;
@@ -2587,7 +2922,7 @@ function buildBeacon(db, config, sessionId, metrics) {
2587
2922
  sentinel_used: valueSignals.security_findings_count > 0,
2588
2923
  risk_score: riskScore,
2589
2924
  stacks_detected: stacks,
2590
- client_version: "0.4.21",
2925
+ client_version: "0.4.23",
2591
2926
  context_observations_injected: metrics?.contextObsInjected ?? 0,
2592
2927
  context_total_available: metrics?.contextTotalAvailable ?? 0,
2593
2928
  recall_attempts: metrics?.recallAttempts ?? 0,
@@ -3612,6 +3947,26 @@ async function main() {
3612
3947
  db.completeSession(event.session_id);
3613
3948
  if (event.last_assistant_message) {
3614
3949
  try {
3950
+ const detected = detectProject(event.cwd);
3951
+ const project = db.getProjectByCanonicalId(detected.canonical_id) ?? db.upsertProject({
3952
+ canonical_id: detected.canonical_id,
3953
+ name: detected.name,
3954
+ local_path: event.cwd,
3955
+ remote_url: detected.remote_url ?? null
3956
+ });
3957
+ const compactAssistant = event.last_assistant_message.replace(/\s+/g, " ").trim();
3958
+ if (compactAssistant.length >= 24) {
3959
+ const chatMessage = db.insertChatMessage({
3960
+ session_id: event.session_id,
3961
+ project_id: project.id,
3962
+ role: "assistant",
3963
+ content: event.last_assistant_message,
3964
+ user_id: config.user_id,
3965
+ device_id: config.device_id,
3966
+ agent: "claude-code"
3967
+ });
3968
+ db.addToOutbox("chat_message", chatMessage.id);
3969
+ }
3615
3970
  createAssistantCheckpoint(db, event.session_id, event.cwd, event.last_assistant_message);
3616
3971
  } catch {}
3617
3972
  }
@@ -3623,7 +3978,7 @@ async function main() {
3623
3978
  const assistantSections = extractAssistantSummarySections(event.last_assistant_message);
3624
3979
  const summary = mergeSessionSummary(retrospective, assistantSections, event.session_id, session?.project_id ?? null, config.user_id) ?? mergeSessionSummary(buildFallbackSessionSummary(db, event.session_id, session?.project_id ?? null, config.user_id, event.last_assistant_message), assistantSections, event.session_id, session?.project_id ?? null, config.user_id) ?? buildFallbackSessionSummary(db, event.session_id, session?.project_id ?? null, config.user_id, event.last_assistant_message);
3625
3980
  if (summary) {
3626
- const row = db.insertSessionSummary(summary);
3981
+ const row = db.upsertSessionSummary(summary);
3627
3982
  db.addToOutbox("summary", row.id);
3628
3983
  let securityFindings = [];
3629
3984
  try {
@@ -3969,7 +4324,7 @@ function pickAssistantCheckpointTitle(substantiveLines, bulletLines) {
3969
4324
  }
3970
4325
  function isGenericCheckpointLine(value) {
3971
4326
  const normalized = value.toLowerCase().replace(/\s+/g, " ").trim();
3972
- return normalized === "here's where things stand:" || normalized === "here's where things stand" || normalized === "where things stand:" || normalized === "where things stand" || normalized === "current status:" || normalized === "current status" || normalized === "status update:" || normalized === "status update";
4327
+ return normalized === "here's where things stand:" || normalized === "here's where things stand" || normalized === "where things stand:" || normalized === "where things stand" || normalized === "current status:" || normalized === "current status" || normalized === "status update:" || normalized === "status update" || normalized.startsWith("all clean. here's a summary of what was fixed") || normalized.startsWith("all clean, here's a summary of what was fixed") || normalized.startsWith("now i have enough to give a clear, accurate assessment") || normalized.startsWith("here's the real picture") || normalized === "tl;dr:" || normalized.startsWith("tl;dr:");
3973
4328
  }
3974
4329
  function detectUnsavedPlans(message) {
3975
4330
  const hints = [];