engrm 0.4.21 → 0.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -221,6 +221,9 @@ The MCP server exposes tools that supported agents can call directly:
221
221
  | `recent_tools` | Inspect captured raw tool chronology |
222
222
  | `recent_sessions` | List recent local sessions to inspect further |
223
223
  | `session_story` | Show prompts, tools, observations, and summary for one session |
224
+ | `create_handoff` | Save an explicit syncable handoff so you can resume work on another device |
225
+ | `recent_handoffs` | List recent saved handoffs for the current project or workspace |
226
+ | `load_handoff` | Open a saved handoff as a resume point for a new session |
224
227
  | `plugin_catalog` | Inspect Engrm plugin manifests for memory-aware integrations |
225
228
  | `save_plugin_memory` | Save reduced plugin output with stable Engrm provenance |
226
229
  | `capture_git_diff` | Reduce a git diff into a durable memory object and save it |
@@ -273,6 +276,21 @@ These tools are intentionally small:
273
276
  - reduced durable memory output
274
277
  - visible in Engrm's local inspection tools so we can judge tool value honestly
275
278
 
279
+ ### Explicit Handoffs
280
+
281
+ For long-running work across devices, Engrm now has an explicit handoff flow:
282
+
283
+ - `create_handoff`
284
+ - snapshot the active thread into a syncable handoff message
285
+ - `recent_handoffs`
286
+ - list the latest saved handoffs
287
+ - `load_handoff`
288
+ - reopen a saved handoff as a clear resume point in a new session
289
+
290
+ This is the deliberate version of multi-device continuity: useful when you want to move from laptop to home machine without waiting for an end-of-session summary.
291
+
292
+ The separate chat lane is still kept distinct from durable observations, but it can now sync too, so recent user/assistant conversation is recoverable on another machine without polluting the main memory feed.
293
+
276
294
  ### Local Memory Inspection
277
295
 
278
296
  For local testing, Engrm now exposes a small inspection set that lets you see
package/dist/cli.js CHANGED
@@ -305,7 +305,7 @@ var MIGRATIONS = [
305
305
  -- Sync outbox (offline-first queue)
306
306
  CREATE TABLE sync_outbox (
307
307
  id INTEGER PRIMARY KEY AUTOINCREMENT,
308
- record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
308
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
309
309
  record_id INTEGER NOT NULL,
310
310
  status TEXT DEFAULT 'pending' CHECK (status IN (
311
311
  'pending', 'syncing', 'synced', 'failed'
@@ -600,6 +600,18 @@ var MIGRATIONS = [
600
600
  },
601
601
  {
602
602
  version: 11,
603
+ description: "Add observation provenance from tool and prompt chronology",
604
+ sql: `
605
+ ALTER TABLE observations ADD COLUMN source_tool TEXT;
606
+ ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
607
+ CREATE INDEX IF NOT EXISTS idx_observations_source_tool
608
+ ON observations(source_tool, created_at_epoch DESC);
609
+ CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
610
+ ON observations(session_id, source_prompt_number DESC);
611
+ `
612
+ },
613
+ {
614
+ version: 12,
603
615
  description: "Add synced handoff metadata to session summaries",
604
616
  sql: `
605
617
  ALTER TABLE session_summaries ADD COLUMN capture_state TEXT;
@@ -609,15 +621,79 @@ var MIGRATIONS = [
609
621
  `
610
622
  },
611
623
  {
612
- version: 11,
613
- description: "Add observation provenance from tool and prompt chronology",
624
+ version: 13,
625
+ description: "Add current_thread to session summaries",
614
626
  sql: `
615
- ALTER TABLE observations ADD COLUMN source_tool TEXT;
616
- ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
617
- CREATE INDEX IF NOT EXISTS idx_observations_source_tool
618
- ON observations(source_tool, created_at_epoch DESC);
619
- CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
620
- ON observations(session_id, source_prompt_number DESC);
627
+ ALTER TABLE session_summaries ADD COLUMN current_thread TEXT;
628
+ `
629
+ },
630
+ {
631
+ version: 14,
632
+ description: "Add chat_messages lane for raw conversation recall",
633
+ sql: `
634
+ CREATE TABLE IF NOT EXISTS chat_messages (
635
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
636
+ session_id TEXT NOT NULL,
637
+ project_id INTEGER REFERENCES projects(id),
638
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
639
+ content TEXT NOT NULL,
640
+ user_id TEXT NOT NULL,
641
+ device_id TEXT NOT NULL,
642
+ agent TEXT DEFAULT 'claude-code',
643
+ created_at_epoch INTEGER NOT NULL
644
+ );
645
+
646
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_session
647
+ ON chat_messages(session_id, created_at_epoch DESC, id DESC);
648
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_project
649
+ ON chat_messages(project_id, created_at_epoch DESC, id DESC);
650
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_created
651
+ ON chat_messages(created_at_epoch DESC, id DESC);
652
+ `
653
+ },
654
+ {
655
+ version: 15,
656
+ description: "Add remote_source_id for chat message sync deduplication",
657
+ sql: `
658
+ ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT;
659
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source
660
+ ON chat_messages(remote_source_id)
661
+ WHERE remote_source_id IS NOT NULL;
662
+ `
663
+ },
664
+ {
665
+ version: 16,
666
+ description: "Allow chat_message records in sync_outbox",
667
+ sql: `
668
+ CREATE TABLE sync_outbox_new (
669
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
670
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
671
+ record_id INTEGER NOT NULL,
672
+ status TEXT DEFAULT 'pending' CHECK (status IN (
673
+ 'pending', 'syncing', 'synced', 'failed'
674
+ )),
675
+ retry_count INTEGER DEFAULT 0,
676
+ max_retries INTEGER DEFAULT 10,
677
+ last_error TEXT,
678
+ created_at_epoch INTEGER NOT NULL,
679
+ synced_at_epoch INTEGER,
680
+ next_retry_epoch INTEGER
681
+ );
682
+
683
+ INSERT INTO sync_outbox_new (
684
+ id, record_type, record_id, status, retry_count, max_retries,
685
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
686
+ )
687
+ SELECT
688
+ id, record_type, record_id, status, retry_count, max_retries,
689
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
690
+ FROM sync_outbox;
691
+
692
+ DROP TABLE sync_outbox;
693
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
694
+
695
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
696
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
621
697
  `
622
698
  }
623
699
  ];
@@ -674,6 +750,21 @@ function inferLegacySchemaVersion(db) {
674
750
  version = Math.max(version, 10);
675
751
  if (columnExists(db, "observations", "source_tool"))
676
752
  version = Math.max(version, 11);
753
+ 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")) {
754
+ version = Math.max(version, 12);
755
+ }
756
+ if (columnExists(db, "session_summaries", "current_thread")) {
757
+ version = Math.max(version, 13);
758
+ }
759
+ if (tableExists(db, "chat_messages")) {
760
+ version = Math.max(version, 14);
761
+ }
762
+ if (columnExists(db, "chat_messages", "remote_source_id")) {
763
+ version = Math.max(version, 15);
764
+ }
765
+ if (syncOutboxSupportsChatMessages(db)) {
766
+ version = Math.max(version, 16);
767
+ }
677
768
  return version;
678
769
  }
679
770
  function runMigrations(db) {
@@ -752,6 +843,89 @@ function ensureObservationTypes(db) {
752
843
  }
753
844
  }
754
845
  }
846
+ function ensureSessionSummaryColumns(db) {
847
+ const required = [
848
+ "capture_state",
849
+ "recent_tool_names",
850
+ "hot_files",
851
+ "recent_outcomes",
852
+ "current_thread"
853
+ ];
854
+ for (const column of required) {
855
+ if (columnExists(db, "session_summaries", column))
856
+ continue;
857
+ db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
858
+ }
859
+ const current = getSchemaVersion(db);
860
+ if (current < 13) {
861
+ db.exec("PRAGMA user_version = 13");
862
+ }
863
+ }
864
+ function ensureChatMessageColumns(db) {
865
+ if (!tableExists(db, "chat_messages"))
866
+ return;
867
+ if (!columnExists(db, "chat_messages", "remote_source_id")) {
868
+ db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
869
+ }
870
+ 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");
871
+ const current = getSchemaVersion(db);
872
+ if (current < 15) {
873
+ db.exec("PRAGMA user_version = 15");
874
+ }
875
+ }
876
+ function ensureSyncOutboxSupportsChatMessages(db) {
877
+ if (syncOutboxSupportsChatMessages(db)) {
878
+ const current = getSchemaVersion(db);
879
+ if (current < 16) {
880
+ db.exec("PRAGMA user_version = 16");
881
+ }
882
+ return;
883
+ }
884
+ db.exec("BEGIN TRANSACTION");
885
+ try {
886
+ db.exec(`
887
+ CREATE TABLE sync_outbox_new (
888
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
889
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
890
+ record_id INTEGER NOT NULL,
891
+ status TEXT DEFAULT 'pending' CHECK (status IN (
892
+ 'pending', 'syncing', 'synced', 'failed'
893
+ )),
894
+ retry_count INTEGER DEFAULT 0,
895
+ max_retries INTEGER DEFAULT 10,
896
+ last_error TEXT,
897
+ created_at_epoch INTEGER NOT NULL,
898
+ synced_at_epoch INTEGER,
899
+ next_retry_epoch INTEGER
900
+ );
901
+
902
+ INSERT INTO sync_outbox_new (
903
+ id, record_type, record_id, status, retry_count, max_retries,
904
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
905
+ )
906
+ SELECT
907
+ id, record_type, record_id, status, retry_count, max_retries,
908
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
909
+ FROM sync_outbox;
910
+
911
+ DROP TABLE sync_outbox;
912
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
913
+
914
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
915
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
916
+ `);
917
+ db.exec("PRAGMA user_version = 16");
918
+ db.exec("COMMIT");
919
+ } catch (error) {
920
+ db.exec("ROLLBACK");
921
+ throw new Error(`sync_outbox repair failed: ${error instanceof Error ? error.message : String(error)}`);
922
+ }
923
+ }
924
+ function syncOutboxSupportsChatMessages(db) {
925
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("sync_outbox");
926
+ const sql = row?.sql ?? "";
927
+ return sql.includes("'chat_message'");
928
+ }
755
929
  function getSchemaVersion(db) {
756
930
  const result = db.query("PRAGMA user_version").get();
757
931
  return result.user_version;
@@ -910,6 +1084,9 @@ class MemDatabase {
910
1084
  this.vecAvailable = this.loadVecExtension();
911
1085
  runMigrations(this.db);
912
1086
  ensureObservationTypes(this.db);
1087
+ ensureSessionSummaryColumns(this.db);
1088
+ ensureChatMessageColumns(this.db);
1089
+ ensureSyncOutboxSupportsChatMessages(this.db);
913
1090
  }
914
1091
  loadVecExtension() {
915
1092
  try {
@@ -1135,6 +1312,7 @@ class MemDatabase {
1135
1312
  p.name AS project_name,
1136
1313
  ss.request AS request,
1137
1314
  ss.completed AS completed,
1315
+ ss.current_thread AS current_thread,
1138
1316
  ss.capture_state AS capture_state,
1139
1317
  ss.recent_tool_names AS recent_tool_names,
1140
1318
  ss.hot_files AS hot_files,
@@ -1153,6 +1331,7 @@ class MemDatabase {
1153
1331
  p.name AS project_name,
1154
1332
  ss.request AS request,
1155
1333
  ss.completed AS completed,
1334
+ ss.current_thread AS current_thread,
1156
1335
  ss.capture_state AS capture_state,
1157
1336
  ss.recent_tool_names AS recent_tool_names,
1158
1337
  ss.hot_files AS hot_files,
@@ -1243,6 +1422,54 @@ class MemDatabase {
1243
1422
  ORDER BY created_at_epoch DESC, id DESC
1244
1423
  LIMIT ?`).all(...userId ? [userId] : [], limit);
1245
1424
  }
1425
+ insertChatMessage(input) {
1426
+ const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1427
+ const content = input.content.trim();
1428
+ const result = this.db.query(`INSERT INTO chat_messages (
1429
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
1430
+ ) 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);
1431
+ return this.getChatMessageById(Number(result.lastInsertRowid));
1432
+ }
1433
+ getChatMessageById(id) {
1434
+ return this.db.query("SELECT * FROM chat_messages WHERE id = ?").get(id) ?? null;
1435
+ }
1436
+ getChatMessageByRemoteSourceId(remoteSourceId) {
1437
+ return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
1438
+ }
1439
+ getSessionChatMessages(sessionId, limit = 50) {
1440
+ return this.db.query(`SELECT * FROM chat_messages
1441
+ WHERE session_id = ?
1442
+ ORDER BY created_at_epoch ASC, id ASC
1443
+ LIMIT ?`).all(sessionId, limit);
1444
+ }
1445
+ getRecentChatMessages(projectId, limit = 20, userId) {
1446
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1447
+ if (projectId !== null) {
1448
+ return this.db.query(`SELECT * FROM chat_messages
1449
+ WHERE project_id = ?${visibilityClause}
1450
+ ORDER BY created_at_epoch DESC, id DESC
1451
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1452
+ }
1453
+ return this.db.query(`SELECT * FROM chat_messages
1454
+ WHERE 1 = 1${visibilityClause}
1455
+ ORDER BY created_at_epoch DESC, id DESC
1456
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
1457
+ }
1458
+ searchChatMessages(query, projectId, limit = 20, userId) {
1459
+ const needle = `%${query.toLowerCase()}%`;
1460
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1461
+ if (projectId !== null) {
1462
+ return this.db.query(`SELECT * FROM chat_messages
1463
+ WHERE project_id = ?
1464
+ AND lower(content) LIKE ?${visibilityClause}
1465
+ ORDER BY created_at_epoch DESC, id DESC
1466
+ LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
1467
+ }
1468
+ return this.db.query(`SELECT * FROM chat_messages
1469
+ WHERE lower(content) LIKE ?${visibilityClause}
1470
+ ORDER BY created_at_epoch DESC, id DESC
1471
+ LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1472
+ }
1246
1473
  addToOutbox(recordType, recordId) {
1247
1474
  const now = Math.floor(Date.now() / 1000);
1248
1475
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -1331,9 +1558,9 @@ class MemDatabase {
1331
1558
  };
1332
1559
  const result = this.db.query(`INSERT INTO session_summaries (
1333
1560
  session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
1334
- capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1561
+ current_thread, capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1335
1562
  )
1336
- 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);
1563
+ 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);
1337
1564
  const id = Number(result.lastInsertRowid);
1338
1565
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
1339
1566
  }
@@ -1349,6 +1576,7 @@ class MemDatabase {
1349
1576
  learned: normalizeSummarySection(summary.learned ?? existing.learned),
1350
1577
  completed: normalizeSummarySection(summary.completed ?? existing.completed),
1351
1578
  next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
1579
+ current_thread: summary.current_thread ?? existing.current_thread,
1352
1580
  capture_state: summary.capture_state ?? existing.capture_state,
1353
1581
  recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
1354
1582
  hot_files: summary.hot_files ?? existing.hot_files,
@@ -1362,12 +1590,13 @@ class MemDatabase {
1362
1590
  learned = ?,
1363
1591
  completed = ?,
1364
1592
  next_steps = ?,
1593
+ current_thread = ?,
1365
1594
  capture_state = ?,
1366
1595
  recent_tool_names = ?,
1367
1596
  hot_files = ?,
1368
1597
  recent_outcomes = ?,
1369
1598
  created_at_epoch = ?
1370
- 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);
1599
+ 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);
1371
1600
  return this.getSessionSummary(summary.session_id);
1372
1601
  }
1373
1602
  getSessionSummary(sessionId) {
@@ -1579,7 +1808,7 @@ var MIGRATIONS2 = [
1579
1808
  -- Sync outbox (offline-first queue)
1580
1809
  CREATE TABLE sync_outbox (
1581
1810
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1582
- record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
1811
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
1583
1812
  record_id INTEGER NOT NULL,
1584
1813
  status TEXT DEFAULT 'pending' CHECK (status IN (
1585
1814
  'pending', 'syncing', 'synced', 'failed'
@@ -1874,6 +2103,18 @@ var MIGRATIONS2 = [
1874
2103
  },
1875
2104
  {
1876
2105
  version: 11,
2106
+ description: "Add observation provenance from tool and prompt chronology",
2107
+ sql: `
2108
+ ALTER TABLE observations ADD COLUMN source_tool TEXT;
2109
+ ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
2110
+ CREATE INDEX IF NOT EXISTS idx_observations_source_tool
2111
+ ON observations(source_tool, created_at_epoch DESC);
2112
+ CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
2113
+ ON observations(session_id, source_prompt_number DESC);
2114
+ `
2115
+ },
2116
+ {
2117
+ version: 12,
1877
2118
  description: "Add synced handoff metadata to session summaries",
1878
2119
  sql: `
1879
2120
  ALTER TABLE session_summaries ADD COLUMN capture_state TEXT;
@@ -1883,15 +2124,79 @@ var MIGRATIONS2 = [
1883
2124
  `
1884
2125
  },
1885
2126
  {
1886
- version: 11,
1887
- description: "Add observation provenance from tool and prompt chronology",
2127
+ version: 13,
2128
+ description: "Add current_thread to session summaries",
1888
2129
  sql: `
1889
- ALTER TABLE observations ADD COLUMN source_tool TEXT;
1890
- ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
1891
- CREATE INDEX IF NOT EXISTS idx_observations_source_tool
1892
- ON observations(source_tool, created_at_epoch DESC);
1893
- CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
1894
- ON observations(session_id, source_prompt_number DESC);
2130
+ ALTER TABLE session_summaries ADD COLUMN current_thread TEXT;
2131
+ `
2132
+ },
2133
+ {
2134
+ version: 14,
2135
+ description: "Add chat_messages lane for raw conversation recall",
2136
+ sql: `
2137
+ CREATE TABLE IF NOT EXISTS chat_messages (
2138
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2139
+ session_id TEXT NOT NULL,
2140
+ project_id INTEGER REFERENCES projects(id),
2141
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
2142
+ content TEXT NOT NULL,
2143
+ user_id TEXT NOT NULL,
2144
+ device_id TEXT NOT NULL,
2145
+ agent TEXT DEFAULT 'claude-code',
2146
+ created_at_epoch INTEGER NOT NULL
2147
+ );
2148
+
2149
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_session
2150
+ ON chat_messages(session_id, created_at_epoch DESC, id DESC);
2151
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_project
2152
+ ON chat_messages(project_id, created_at_epoch DESC, id DESC);
2153
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_created
2154
+ ON chat_messages(created_at_epoch DESC, id DESC);
2155
+ `
2156
+ },
2157
+ {
2158
+ version: 15,
2159
+ description: "Add remote_source_id for chat message sync deduplication",
2160
+ sql: `
2161
+ ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT;
2162
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source
2163
+ ON chat_messages(remote_source_id)
2164
+ WHERE remote_source_id IS NOT NULL;
2165
+ `
2166
+ },
2167
+ {
2168
+ version: 16,
2169
+ description: "Allow chat_message records in sync_outbox",
2170
+ sql: `
2171
+ CREATE TABLE sync_outbox_new (
2172
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2173
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
2174
+ record_id INTEGER NOT NULL,
2175
+ status TEXT DEFAULT 'pending' CHECK (status IN (
2176
+ 'pending', 'syncing', 'synced', 'failed'
2177
+ )),
2178
+ retry_count INTEGER DEFAULT 0,
2179
+ max_retries INTEGER DEFAULT 10,
2180
+ last_error TEXT,
2181
+ created_at_epoch INTEGER NOT NULL,
2182
+ synced_at_epoch INTEGER,
2183
+ next_retry_epoch INTEGER
2184
+ );
2185
+
2186
+ INSERT INTO sync_outbox_new (
2187
+ id, record_type, record_id, status, retry_count, max_retries,
2188
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
2189
+ )
2190
+ SELECT
2191
+ id, record_type, record_id, status, retry_count, max_retries,
2192
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
2193
+ FROM sync_outbox;
2194
+
2195
+ DROP TABLE sync_outbox;
2196
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
2197
+
2198
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
2199
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
1895
2200
  `
1896
2201
  }
1897
2202
  ];