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.
package/README.md CHANGED
@@ -211,7 +211,7 @@ The MCP server exposes tools that supported agents can call directly:
211
211
  | `recent_activity` | Inspect what Engrm captured most recently |
212
212
  | `memory_stats` | View high-level capture and sync health |
213
213
  | `capture_status` | Check whether local hooks are registered and raw prompt/tool chronology is actually being captured |
214
- | `activity_feed` | Inspect one chronological local feed across prompts, tools, observations, and summaries |
214
+ | `activity_feed` | Inspect one chronological local feed across prompts, tools, chat, handoffs, observations, and summaries |
215
215
  | `memory_console` | Show a high-signal local memory console for the current project |
216
216
  | `project_memory_index` | Show typed local memory by project, including hot files and recent sessions |
217
217
  | `workspace_memory_index` | Show cross-project local memory coverage across the whole workspace |
@@ -221,6 +221,13 @@ 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
+ | `refresh_handoff` | Refresh the rolling live handoff draft for the current session without creating a new saved handoff |
226
+ | `recent_handoffs` | List recent saved handoffs for the current project or workspace |
227
+ | `load_handoff` | Open a saved handoff as a resume point for a new session |
228
+ | `refresh_chat_recall` | Rehydrate the separate chat lane from a Claude transcript when a long session feels under-captured |
229
+ | `recent_chat` | Inspect the separate synced chat lane without mixing it into durable memory |
230
+ | `search_chat` | Search recent chat recall separately from reusable memory observations |
224
231
  | `plugin_catalog` | Inspect Engrm plugin manifests for memory-aware integrations |
225
232
  | `save_plugin_memory` | Save reduced plugin output with stable Engrm provenance |
226
233
  | `capture_git_diff` | Reduce a git diff into a durable memory object and save it |
@@ -273,6 +280,61 @@ These tools are intentionally small:
273
280
  - reduced durable memory output
274
281
  - visible in Engrm's local inspection tools so we can judge tool value honestly
275
282
 
283
+ ### Explicit Handoffs
284
+
285
+ For long-running work across devices, Engrm now has an explicit handoff flow:
286
+
287
+ - `create_handoff`
288
+ - snapshot the active thread into a syncable handoff message
289
+ - `refresh_handoff`
290
+ - refresh the rolling live handoff draft for the current session
291
+ - `recent_handoffs`
292
+ - list the latest saved handoffs
293
+ - `load_handoff`
294
+ - reopen a saved handoff as a clear resume point in a new session
295
+
296
+ Recent handoffs now carry:
297
+
298
+ - source machine
299
+ - freshness
300
+ - current thread / recent outcomes
301
+ - optional chat snippets when the session is still thin
302
+
303
+ Rolling handoff drafts:
304
+
305
+ - are kept as one updatable syncable draft per session
306
+ - refresh during prompt-time and tool-time summary updates
307
+ - let another machine resume live work even before you save a deliberate handoff
308
+ - are refreshed again before Claude compacts, so the active thread survives context compression better
309
+
310
+ The local workbench now shows handoff split too:
311
+
312
+ - saved handoffs
313
+ - rolling drafts
314
+
315
+ `activity_feed` and `session_story` now keep that distinction visible too, so a live rolling draft does not masquerade as a deliberate saved handoff.
316
+
317
+ When Engrm knows your current device, handoff tools also prefer resume points from another machine before showing the newest local handoff again.
318
+
319
+ 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.
320
+
321
+ 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.
322
+
323
+ For long sessions, Engrm now also supports transcript-backed chat hydration:
324
+
325
+ - `refresh_chat_recall`
326
+ - reads the Claude transcript for the current session
327
+ - fills gaps in the separate chat lane with transcript-backed messages
328
+ - keeps those rows marked separately from hook-edge chat so recall can prefer the fuller thread
329
+
330
+ Before Claude compacts, Engrm now also:
331
+
332
+ - refreshes transcript-backed chat recall for the active session
333
+ - refreshes the rolling handoff draft
334
+ - then injects the preserved thread into the compacted context
335
+
336
+ So compaction should reduce prompt-window pressure without making the memory layer act like the conversation never happened.
337
+
276
338
  ### Local Memory Inspection
277
339
 
278
340
  For local testing, Engrm now exposes a small inspection set that lets you see
@@ -296,9 +358,9 @@ What each tool is good for:
296
358
 
297
359
  - `capture_status` tells you whether prompt/tool hooks are live on this machine
298
360
  - `memory_console` gives the quickest project snapshot
299
- - `activity_feed` shows the merged chronology across prompts, tools, observations, and summaries
361
+ - `activity_feed` shows the merged chronology across prompts, tools, chat, handoffs, observations, and summaries
300
362
  - `recent_sessions` helps you pick a session worth opening
301
- - `session_story` reconstructs one session in detail
363
+ - `session_story` reconstructs one session in detail, including handoffs and chat recall
302
364
  - `tool_memory_index` shows which tools and plugins are actually producing durable memory
303
365
  - `session_tool_memory` shows which tool calls in one session turned into reusable memory and which did not
304
366
  - `project_memory_index` shows typed memory by repo
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'
@@ -598,6 +598,18 @@ var MIGRATIONS = [
598
598
  ON tool_events(created_at_epoch DESC, id DESC);
599
599
  `
600
600
  },
601
+ {
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
+ },
601
613
  {
602
614
  version: 12,
603
615
  description: "Add synced handoff metadata to session summaries",
@@ -609,15 +621,92 @@ 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);
697
+ `
698
+ },
699
+ {
700
+ version: 17,
701
+ description: "Track transcript-backed chat messages separately from hook chat",
702
+ sql: `
703
+ ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook';
704
+ ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER;
705
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind
706
+ ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC);
707
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript
708
+ ON chat_messages(session_id, transcript_index)
709
+ WHERE transcript_index IS NOT NULL;
621
710
  `
622
711
  }
623
712
  ];
@@ -677,6 +766,21 @@ function inferLegacySchemaVersion(db) {
677
766
  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")) {
678
767
  version = Math.max(version, 12);
679
768
  }
769
+ if (columnExists(db, "session_summaries", "current_thread")) {
770
+ version = Math.max(version, 13);
771
+ }
772
+ if (tableExists(db, "chat_messages")) {
773
+ version = Math.max(version, 14);
774
+ }
775
+ if (columnExists(db, "chat_messages", "remote_source_id")) {
776
+ version = Math.max(version, 15);
777
+ }
778
+ if (syncOutboxSupportsChatMessages(db)) {
779
+ version = Math.max(version, 16);
780
+ }
781
+ if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
782
+ version = Math.max(version, 17);
783
+ }
680
784
  return version;
681
785
  }
682
786
  function runMigrations(db) {
@@ -760,7 +864,8 @@ function ensureSessionSummaryColumns(db) {
760
864
  "capture_state",
761
865
  "recent_tool_names",
762
866
  "hot_files",
763
- "recent_outcomes"
867
+ "recent_outcomes",
868
+ "current_thread"
764
869
  ];
765
870
  for (const column of required) {
766
871
  if (columnExists(db, "session_summaries", column))
@@ -768,10 +873,83 @@ function ensureSessionSummaryColumns(db) {
768
873
  db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
769
874
  }
770
875
  const current = getSchemaVersion(db);
771
- if (current < 12) {
772
- db.exec("PRAGMA user_version = 12");
876
+ if (current < 13) {
877
+ db.exec("PRAGMA user_version = 13");
878
+ }
879
+ }
880
+ function ensureChatMessageColumns(db) {
881
+ if (!tableExists(db, "chat_messages"))
882
+ return;
883
+ if (!columnExists(db, "chat_messages", "remote_source_id")) {
884
+ db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
885
+ }
886
+ 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");
887
+ if (!columnExists(db, "chat_messages", "source_kind")) {
888
+ db.exec("ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook'");
889
+ }
890
+ if (!columnExists(db, "chat_messages", "transcript_index")) {
891
+ db.exec("ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER");
892
+ }
893
+ 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)");
894
+ 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");
895
+ const current = getSchemaVersion(db);
896
+ if (current < 17) {
897
+ db.exec("PRAGMA user_version = 17");
773
898
  }
774
899
  }
900
+ function ensureSyncOutboxSupportsChatMessages(db) {
901
+ if (syncOutboxSupportsChatMessages(db)) {
902
+ const current = getSchemaVersion(db);
903
+ if (current < 16) {
904
+ db.exec("PRAGMA user_version = 16");
905
+ }
906
+ return;
907
+ }
908
+ db.exec("BEGIN TRANSACTION");
909
+ try {
910
+ db.exec(`
911
+ CREATE TABLE sync_outbox_new (
912
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
913
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
914
+ record_id INTEGER NOT NULL,
915
+ status TEXT DEFAULT 'pending' CHECK (status IN (
916
+ 'pending', 'syncing', 'synced', 'failed'
917
+ )),
918
+ retry_count INTEGER DEFAULT 0,
919
+ max_retries INTEGER DEFAULT 10,
920
+ last_error TEXT,
921
+ created_at_epoch INTEGER NOT NULL,
922
+ synced_at_epoch INTEGER,
923
+ next_retry_epoch INTEGER
924
+ );
925
+
926
+ INSERT INTO sync_outbox_new (
927
+ id, record_type, record_id, status, retry_count, max_retries,
928
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
929
+ )
930
+ SELECT
931
+ id, record_type, record_id, status, retry_count, max_retries,
932
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
933
+ FROM sync_outbox;
934
+
935
+ DROP TABLE sync_outbox;
936
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
937
+
938
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
939
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
940
+ `);
941
+ db.exec("PRAGMA user_version = 16");
942
+ db.exec("COMMIT");
943
+ } catch (error) {
944
+ db.exec("ROLLBACK");
945
+ throw new Error(`sync_outbox repair failed: ${error instanceof Error ? error.message : String(error)}`);
946
+ }
947
+ }
948
+ function syncOutboxSupportsChatMessages(db) {
949
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("sync_outbox");
950
+ const sql = row?.sql ?? "";
951
+ return sql.includes("'chat_message'");
952
+ }
775
953
  function getSchemaVersion(db) {
776
954
  const result = db.query("PRAGMA user_version").get();
777
955
  return result.user_version;
@@ -931,6 +1109,8 @@ class MemDatabase {
931
1109
  runMigrations(this.db);
932
1110
  ensureObservationTypes(this.db);
933
1111
  ensureSessionSummaryColumns(this.db);
1112
+ ensureChatMessageColumns(this.db);
1113
+ ensureSyncOutboxSupportsChatMessages(this.db);
934
1114
  }
935
1115
  loadVecExtension() {
936
1116
  try {
@@ -999,6 +1179,22 @@ class MemDatabase {
999
1179
  getObservationById(id) {
1000
1180
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1001
1181
  }
1182
+ updateObservationContent(id, update) {
1183
+ const existing = this.getObservationById(id);
1184
+ if (!existing)
1185
+ return null;
1186
+ const createdAtEpoch = update.created_at_epoch ?? existing.created_at_epoch;
1187
+ const createdAt = new Date(createdAtEpoch * 1000).toISOString();
1188
+ this.db.query(`UPDATE observations
1189
+ SET title = ?, narrative = ?, facts = ?, concepts = ?, created_at = ?, created_at_epoch = ?
1190
+ WHERE id = ?`).run(update.title, update.narrative ?? null, update.facts ?? null, update.concepts ?? null, createdAt, createdAtEpoch, id);
1191
+ this.ftsDelete(existing);
1192
+ const refreshed = this.getObservationById(id);
1193
+ if (!refreshed)
1194
+ return null;
1195
+ this.ftsInsert(refreshed);
1196
+ return refreshed;
1197
+ }
1002
1198
  getObservationsByIds(ids, userId) {
1003
1199
  if (ids.length === 0)
1004
1200
  return [];
@@ -1156,6 +1352,7 @@ class MemDatabase {
1156
1352
  p.name AS project_name,
1157
1353
  ss.request AS request,
1158
1354
  ss.completed AS completed,
1355
+ ss.current_thread AS current_thread,
1159
1356
  ss.capture_state AS capture_state,
1160
1357
  ss.recent_tool_names AS recent_tool_names,
1161
1358
  ss.hot_files AS hot_files,
@@ -1174,6 +1371,7 @@ class MemDatabase {
1174
1371
  p.name AS project_name,
1175
1372
  ss.request AS request,
1176
1373
  ss.completed AS completed,
1374
+ ss.current_thread AS current_thread,
1177
1375
  ss.capture_state AS capture_state,
1178
1376
  ss.recent_tool_names AS recent_tool_names,
1179
1377
  ss.hot_files AS hot_files,
@@ -1264,6 +1462,99 @@ class MemDatabase {
1264
1462
  ORDER BY created_at_epoch DESC, id DESC
1265
1463
  LIMIT ?`).all(...userId ? [userId] : [], limit);
1266
1464
  }
1465
+ insertChatMessage(input) {
1466
+ const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1467
+ const content = input.content.trim();
1468
+ const result = this.db.query(`INSERT INTO chat_messages (
1469
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id, source_kind, transcript_index
1470
+ ) 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);
1471
+ return this.getChatMessageById(Number(result.lastInsertRowid));
1472
+ }
1473
+ getChatMessageById(id) {
1474
+ return this.db.query("SELECT * FROM chat_messages WHERE id = ?").get(id) ?? null;
1475
+ }
1476
+ getChatMessageByRemoteSourceId(remoteSourceId) {
1477
+ return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
1478
+ }
1479
+ getSessionChatMessages(sessionId, limit = 50) {
1480
+ return this.db.query(`SELECT * FROM chat_messages
1481
+ WHERE session_id = ?
1482
+ AND (
1483
+ source_kind = 'transcript'
1484
+ OR NOT EXISTS (
1485
+ SELECT 1 FROM chat_messages t2
1486
+ WHERE t2.session_id = chat_messages.session_id
1487
+ AND t2.source_kind = 'transcript'
1488
+ )
1489
+ )
1490
+ ORDER BY
1491
+ CASE WHEN transcript_index IS NULL THEN created_at_epoch ELSE transcript_index END ASC,
1492
+ id ASC
1493
+ LIMIT ?`).all(sessionId, limit);
1494
+ }
1495
+ getRecentChatMessages(projectId, limit = 20, userId) {
1496
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1497
+ if (projectId !== null) {
1498
+ return this.db.query(`SELECT * FROM chat_messages
1499
+ WHERE project_id = ?${visibilityClause}
1500
+ AND (
1501
+ source_kind = 'transcript'
1502
+ OR NOT EXISTS (
1503
+ SELECT 1 FROM chat_messages t2
1504
+ WHERE t2.session_id = chat_messages.session_id
1505
+ AND t2.source_kind = 'transcript'
1506
+ )
1507
+ )
1508
+ ORDER BY created_at_epoch DESC, id DESC
1509
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1510
+ }
1511
+ return this.db.query(`SELECT * FROM chat_messages
1512
+ WHERE 1 = 1${visibilityClause}
1513
+ AND (
1514
+ source_kind = 'transcript'
1515
+ OR NOT EXISTS (
1516
+ SELECT 1 FROM chat_messages t2
1517
+ WHERE t2.session_id = chat_messages.session_id
1518
+ AND t2.source_kind = 'transcript'
1519
+ )
1520
+ )
1521
+ ORDER BY created_at_epoch DESC, id DESC
1522
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
1523
+ }
1524
+ searchChatMessages(query, projectId, limit = 20, userId) {
1525
+ const needle = `%${query.toLowerCase()}%`;
1526
+ const visibilityClause = userId ? " AND user_id = ?" : "";
1527
+ if (projectId !== null) {
1528
+ return this.db.query(`SELECT * FROM chat_messages
1529
+ WHERE project_id = ?
1530
+ AND lower(content) LIKE ?${visibilityClause}
1531
+ AND (
1532
+ source_kind = 'transcript'
1533
+ OR NOT EXISTS (
1534
+ SELECT 1 FROM chat_messages t2
1535
+ WHERE t2.session_id = chat_messages.session_id
1536
+ AND t2.source_kind = 'transcript'
1537
+ )
1538
+ )
1539
+ ORDER BY created_at_epoch DESC, id DESC
1540
+ LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
1541
+ }
1542
+ return this.db.query(`SELECT * FROM chat_messages
1543
+ WHERE lower(content) LIKE ?${visibilityClause}
1544
+ AND (
1545
+ source_kind = 'transcript'
1546
+ OR NOT EXISTS (
1547
+ SELECT 1 FROM chat_messages t2
1548
+ WHERE t2.session_id = chat_messages.session_id
1549
+ AND t2.source_kind = 'transcript'
1550
+ )
1551
+ )
1552
+ ORDER BY created_at_epoch DESC, id DESC
1553
+ LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1554
+ }
1555
+ getTranscriptChatMessage(sessionId, transcriptIndex) {
1556
+ return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
1557
+ }
1267
1558
  addToOutbox(recordType, recordId) {
1268
1559
  const now = Math.floor(Date.now() / 1000);
1269
1560
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -1352,9 +1643,9 @@ class MemDatabase {
1352
1643
  };
1353
1644
  const result = this.db.query(`INSERT INTO session_summaries (
1354
1645
  session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
1355
- capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1646
+ current_thread, capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
1356
1647
  )
1357
- 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);
1648
+ 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);
1358
1649
  const id = Number(result.lastInsertRowid);
1359
1650
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
1360
1651
  }
@@ -1370,6 +1661,7 @@ class MemDatabase {
1370
1661
  learned: normalizeSummarySection(summary.learned ?? existing.learned),
1371
1662
  completed: normalizeSummarySection(summary.completed ?? existing.completed),
1372
1663
  next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
1664
+ current_thread: summary.current_thread ?? existing.current_thread,
1373
1665
  capture_state: summary.capture_state ?? existing.capture_state,
1374
1666
  recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
1375
1667
  hot_files: summary.hot_files ?? existing.hot_files,
@@ -1383,12 +1675,13 @@ class MemDatabase {
1383
1675
  learned = ?,
1384
1676
  completed = ?,
1385
1677
  next_steps = ?,
1678
+ current_thread = ?,
1386
1679
  capture_state = ?,
1387
1680
  recent_tool_names = ?,
1388
1681
  hot_files = ?,
1389
1682
  recent_outcomes = ?,
1390
1683
  created_at_epoch = ?
1391
- 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);
1684
+ 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);
1392
1685
  return this.getSessionSummary(summary.session_id);
1393
1686
  }
1394
1687
  getSessionSummary(sessionId) {
@@ -1600,7 +1893,7 @@ var MIGRATIONS2 = [
1600
1893
  -- Sync outbox (offline-first queue)
1601
1894
  CREATE TABLE sync_outbox (
1602
1895
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1603
- record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
1896
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
1604
1897
  record_id INTEGER NOT NULL,
1605
1898
  status TEXT DEFAULT 'pending' CHECK (status IN (
1606
1899
  'pending', 'syncing', 'synced', 'failed'
@@ -1893,6 +2186,18 @@ var MIGRATIONS2 = [
1893
2186
  ON tool_events(created_at_epoch DESC, id DESC);
1894
2187
  `
1895
2188
  },
2189
+ {
2190
+ version: 11,
2191
+ description: "Add observation provenance from tool and prompt chronology",
2192
+ sql: `
2193
+ ALTER TABLE observations ADD COLUMN source_tool TEXT;
2194
+ ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
2195
+ CREATE INDEX IF NOT EXISTS idx_observations_source_tool
2196
+ ON observations(source_tool, created_at_epoch DESC);
2197
+ CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
2198
+ ON observations(session_id, source_prompt_number DESC);
2199
+ `
2200
+ },
1896
2201
  {
1897
2202
  version: 12,
1898
2203
  description: "Add synced handoff metadata to session summaries",
@@ -1904,15 +2209,92 @@ var MIGRATIONS2 = [
1904
2209
  `
1905
2210
  },
1906
2211
  {
1907
- version: 11,
1908
- description: "Add observation provenance from tool and prompt chronology",
2212
+ version: 13,
2213
+ description: "Add current_thread to session summaries",
1909
2214
  sql: `
1910
- ALTER TABLE observations ADD COLUMN source_tool TEXT;
1911
- ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
1912
- CREATE INDEX IF NOT EXISTS idx_observations_source_tool
1913
- ON observations(source_tool, created_at_epoch DESC);
1914
- CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
1915
- ON observations(session_id, source_prompt_number DESC);
2215
+ ALTER TABLE session_summaries ADD COLUMN current_thread TEXT;
2216
+ `
2217
+ },
2218
+ {
2219
+ version: 14,
2220
+ description: "Add chat_messages lane for raw conversation recall",
2221
+ sql: `
2222
+ CREATE TABLE IF NOT EXISTS chat_messages (
2223
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2224
+ session_id TEXT NOT NULL,
2225
+ project_id INTEGER REFERENCES projects(id),
2226
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
2227
+ content TEXT NOT NULL,
2228
+ user_id TEXT NOT NULL,
2229
+ device_id TEXT NOT NULL,
2230
+ agent TEXT DEFAULT 'claude-code',
2231
+ created_at_epoch INTEGER NOT NULL
2232
+ );
2233
+
2234
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_session
2235
+ ON chat_messages(session_id, created_at_epoch DESC, id DESC);
2236
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_project
2237
+ ON chat_messages(project_id, created_at_epoch DESC, id DESC);
2238
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_created
2239
+ ON chat_messages(created_at_epoch DESC, id DESC);
2240
+ `
2241
+ },
2242
+ {
2243
+ version: 15,
2244
+ description: "Add remote_source_id for chat message sync deduplication",
2245
+ sql: `
2246
+ ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT;
2247
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source
2248
+ ON chat_messages(remote_source_id)
2249
+ WHERE remote_source_id IS NOT NULL;
2250
+ `
2251
+ },
2252
+ {
2253
+ version: 16,
2254
+ description: "Allow chat_message records in sync_outbox",
2255
+ sql: `
2256
+ CREATE TABLE sync_outbox_new (
2257
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2258
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
2259
+ record_id INTEGER NOT NULL,
2260
+ status TEXT DEFAULT 'pending' CHECK (status IN (
2261
+ 'pending', 'syncing', 'synced', 'failed'
2262
+ )),
2263
+ retry_count INTEGER DEFAULT 0,
2264
+ max_retries INTEGER DEFAULT 10,
2265
+ last_error TEXT,
2266
+ created_at_epoch INTEGER NOT NULL,
2267
+ synced_at_epoch INTEGER,
2268
+ next_retry_epoch INTEGER
2269
+ );
2270
+
2271
+ INSERT INTO sync_outbox_new (
2272
+ id, record_type, record_id, status, retry_count, max_retries,
2273
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
2274
+ )
2275
+ SELECT
2276
+ id, record_type, record_id, status, retry_count, max_retries,
2277
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
2278
+ FROM sync_outbox;
2279
+
2280
+ DROP TABLE sync_outbox;
2281
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
2282
+
2283
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
2284
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
2285
+ `
2286
+ },
2287
+ {
2288
+ version: 17,
2289
+ description: "Track transcript-backed chat messages separately from hook chat",
2290
+ sql: `
2291
+ ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook';
2292
+ ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER;
2293
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind
2294
+ ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC);
2295
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript
2296
+ ON chat_messages(session_id, transcript_index)
2297
+ WHERE transcript_index IS NOT NULL;
1916
2298
  `
1917
2299
  }
1918
2300
  ];