engrm 0.4.23 → 0.4.26

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,9 +211,9 @@ 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 |
215
- | `memory_console` | Show a high-signal local memory console for the current project |
216
- | `project_memory_index` | Show typed local memory by project, including hot files and recent sessions |
214
+ | `activity_feed` | Inspect one chronological local feed across prompts, tools, chat, handoffs, observations, and summaries |
215
+ | `memory_console` | Show a high-signal local memory console for the current project, including continuity state |
216
+ | `project_memory_index` | Show typed local memory by project, including hot files, recent sessions, and continuity state |
217
217
  | `workspace_memory_index` | Show cross-project local memory coverage across the whole workspace |
218
218
  | `tool_memory_index` | Show which source tools and plugins are creating durable memory |
219
219
  | `session_tool_memory` | Show which tools in one session produced reusable memory and which produced none |
@@ -222,8 +222,12 @@ The MCP server exposes tools that supported agents can call directly:
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
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 |
225
226
  | `recent_handoffs` | List recent saved handoffs for the current project or workspace |
226
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 |
227
231
  | `plugin_catalog` | Inspect Engrm plugin manifests for memory-aware integrations |
228
232
  | `save_plugin_memory` | Save reduced plugin output with stable Engrm provenance |
229
233
  | `capture_git_diff` | Reduce a git diff into a durable memory object and save it |
@@ -282,15 +286,55 @@ For long-running work across devices, Engrm now has an explicit handoff flow:
282
286
 
283
287
  - `create_handoff`
284
288
  - snapshot the active thread into a syncable handoff message
289
+ - `refresh_handoff`
290
+ - refresh the rolling live handoff draft for the current session
285
291
  - `recent_handoffs`
286
292
  - list the latest saved handoffs
287
293
  - `load_handoff`
288
294
  - reopen a saved handoff as a clear resume point in a new session
289
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
+
290
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.
291
320
 
292
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.
293
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
+
294
338
  ### Local Memory Inspection
295
339
 
296
340
  For local testing, Engrm now exposes a small inspection set that lets you see
@@ -313,14 +357,16 @@ Recommended flow:
313
357
  What each tool is good for:
314
358
 
315
359
  - `capture_status` tells you whether prompt/tool hooks are live on this machine
316
- - `memory_console` gives the quickest project snapshot
317
- - `activity_feed` shows the merged chronology across prompts, tools, observations, and summaries
360
+ - `capture_quality` shows whether chat recall is transcript-backed or still hook-only across the workspace
361
+ - `memory_console` gives the quickest project snapshot, including whether continuity is `fresh`, `thin`, or `cold`
362
+ - `activity_feed` shows the merged chronology across prompts, tools, chat, handoffs, observations, and summaries
318
363
  - `recent_sessions` helps you pick a session worth opening
319
- - `session_story` reconstructs one session in detail
364
+ - `session_story` reconstructs one session in detail, including handoffs and chat recall
320
365
  - `tool_memory_index` shows which tools and plugins are actually producing durable memory
321
366
  - `session_tool_memory` shows which tool calls in one session turned into reusable memory and which did not
322
- - `project_memory_index` shows typed memory by repo
367
+ - `project_memory_index` shows typed memory by repo, including continuity state and hot files
323
368
  - `workspace_memory_index` shows coverage across all repos on the machine
369
+ - `recent_chat` / `search_chat` now report transcript-vs-hook coverage too, so weak OpenClaw recall is easier to diagnose and refresh
324
370
 
325
371
  ### Thin Tool Workflow
326
372
 
package/dist/cli.js CHANGED
@@ -695,6 +695,19 @@ var MIGRATIONS = [
695
695
  CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
696
696
  CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
697
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;
710
+ `
698
711
  }
699
712
  ];
700
713
  function isVecExtensionLoaded(db) {
@@ -765,6 +778,9 @@ function inferLegacySchemaVersion(db) {
765
778
  if (syncOutboxSupportsChatMessages(db)) {
766
779
  version = Math.max(version, 16);
767
780
  }
781
+ if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
782
+ version = Math.max(version, 17);
783
+ }
768
784
  return version;
769
785
  }
770
786
  function runMigrations(db) {
@@ -868,9 +884,17 @@ function ensureChatMessageColumns(db) {
868
884
  db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
869
885
  }
870
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");
871
895
  const current = getSchemaVersion(db);
872
- if (current < 15) {
873
- db.exec("PRAGMA user_version = 15");
896
+ if (current < 17) {
897
+ db.exec("PRAGMA user_version = 17");
874
898
  }
875
899
  }
876
900
  function ensureSyncOutboxSupportsChatMessages(db) {
@@ -1155,6 +1179,22 @@ class MemDatabase {
1155
1179
  getObservationById(id) {
1156
1180
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1157
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
+ }
1158
1198
  getObservationsByIds(ids, userId) {
1159
1199
  if (ids.length === 0)
1160
1200
  return [];
@@ -1426,8 +1466,8 @@ class MemDatabase {
1426
1466
  const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1427
1467
  const content = input.content.trim();
1428
1468
  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);
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);
1431
1471
  return this.getChatMessageById(Number(result.lastInsertRowid));
1432
1472
  }
1433
1473
  getChatMessageById(id) {
@@ -1439,7 +1479,17 @@ class MemDatabase {
1439
1479
  getSessionChatMessages(sessionId, limit = 50) {
1440
1480
  return this.db.query(`SELECT * FROM chat_messages
1441
1481
  WHERE session_id = ?
1442
- ORDER BY created_at_epoch ASC, id ASC
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
1443
1493
  LIMIT ?`).all(sessionId, limit);
1444
1494
  }
1445
1495
  getRecentChatMessages(projectId, limit = 20, userId) {
@@ -1447,11 +1497,27 @@ class MemDatabase {
1447
1497
  if (projectId !== null) {
1448
1498
  return this.db.query(`SELECT * FROM chat_messages
1449
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
+ )
1450
1508
  ORDER BY created_at_epoch DESC, id DESC
1451
1509
  LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1452
1510
  }
1453
1511
  return this.db.query(`SELECT * FROM chat_messages
1454
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
+ )
1455
1521
  ORDER BY created_at_epoch DESC, id DESC
1456
1522
  LIMIT ?`).all(...userId ? [userId] : [], limit);
1457
1523
  }
@@ -1462,14 +1528,33 @@ class MemDatabase {
1462
1528
  return this.db.query(`SELECT * FROM chat_messages
1463
1529
  WHERE project_id = ?
1464
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
+ )
1465
1539
  ORDER BY created_at_epoch DESC, id DESC
1466
1540
  LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
1467
1541
  }
1468
1542
  return this.db.query(`SELECT * FROM chat_messages
1469
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
+ )
1470
1552
  ORDER BY created_at_epoch DESC, id DESC
1471
1553
  LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1472
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
+ }
1473
1558
  addToOutbox(recordType, recordId) {
1474
1559
  const now = Math.floor(Date.now() / 1000);
1475
1560
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -2198,6 +2283,19 @@ var MIGRATIONS2 = [
2198
2283
  CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
2199
2284
  CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
2200
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;
2298
+ `
2201
2299
  }
2202
2300
  ];
2203
2301
  function isVecExtensionLoaded2(db) {
@@ -1528,6 +1528,19 @@ var MIGRATIONS = [
1528
1528
  CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
1529
1529
  CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
1530
1530
  `
1531
+ },
1532
+ {
1533
+ version: 17,
1534
+ description: "Track transcript-backed chat messages separately from hook chat",
1535
+ sql: `
1536
+ ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook';
1537
+ ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER;
1538
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind
1539
+ ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC);
1540
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript
1541
+ ON chat_messages(session_id, transcript_index)
1542
+ WHERE transcript_index IS NOT NULL;
1543
+ `
1531
1544
  }
1532
1545
  ];
1533
1546
  function isVecExtensionLoaded(db) {
@@ -1598,6 +1611,9 @@ function inferLegacySchemaVersion(db) {
1598
1611
  if (syncOutboxSupportsChatMessages(db)) {
1599
1612
  version = Math.max(version, 16);
1600
1613
  }
1614
+ if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
1615
+ version = Math.max(version, 17);
1616
+ }
1601
1617
  return version;
1602
1618
  }
1603
1619
  function runMigrations(db) {
@@ -1701,9 +1717,17 @@ function ensureChatMessageColumns(db) {
1701
1717
  db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
1702
1718
  }
1703
1719
  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");
1720
+ if (!columnExists(db, "chat_messages", "source_kind")) {
1721
+ db.exec("ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook'");
1722
+ }
1723
+ if (!columnExists(db, "chat_messages", "transcript_index")) {
1724
+ db.exec("ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER");
1725
+ }
1726
+ 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)");
1727
+ 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");
1704
1728
  const current = getSchemaVersion(db);
1705
- if (current < 15) {
1706
- db.exec("PRAGMA user_version = 15");
1729
+ if (current < 17) {
1730
+ db.exec("PRAGMA user_version = 17");
1707
1731
  }
1708
1732
  }
1709
1733
  function ensureSyncOutboxSupportsChatMessages(db) {
@@ -1988,6 +2012,22 @@ class MemDatabase {
1988
2012
  getObservationById(id) {
1989
2013
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1990
2014
  }
2015
+ updateObservationContent(id, update) {
2016
+ const existing = this.getObservationById(id);
2017
+ if (!existing)
2018
+ return null;
2019
+ const createdAtEpoch = update.created_at_epoch ?? existing.created_at_epoch;
2020
+ const createdAt = new Date(createdAtEpoch * 1000).toISOString();
2021
+ this.db.query(`UPDATE observations
2022
+ SET title = ?, narrative = ?, facts = ?, concepts = ?, created_at = ?, created_at_epoch = ?
2023
+ WHERE id = ?`).run(update.title, update.narrative ?? null, update.facts ?? null, update.concepts ?? null, createdAt, createdAtEpoch, id);
2024
+ this.ftsDelete(existing);
2025
+ const refreshed = this.getObservationById(id);
2026
+ if (!refreshed)
2027
+ return null;
2028
+ this.ftsInsert(refreshed);
2029
+ return refreshed;
2030
+ }
1991
2031
  getObservationsByIds(ids, userId) {
1992
2032
  if (ids.length === 0)
1993
2033
  return [];
@@ -2259,8 +2299,8 @@ class MemDatabase {
2259
2299
  const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
2260
2300
  const content = input.content.trim();
2261
2301
  const result = this.db.query(`INSERT INTO chat_messages (
2262
- session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
2263
- ) 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);
2302
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id, source_kind, transcript_index
2303
+ ) 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);
2264
2304
  return this.getChatMessageById(Number(result.lastInsertRowid));
2265
2305
  }
2266
2306
  getChatMessageById(id) {
@@ -2272,7 +2312,17 @@ class MemDatabase {
2272
2312
  getSessionChatMessages(sessionId, limit = 50) {
2273
2313
  return this.db.query(`SELECT * FROM chat_messages
2274
2314
  WHERE session_id = ?
2275
- ORDER BY created_at_epoch ASC, id ASC
2315
+ AND (
2316
+ source_kind = 'transcript'
2317
+ OR NOT EXISTS (
2318
+ SELECT 1 FROM chat_messages t2
2319
+ WHERE t2.session_id = chat_messages.session_id
2320
+ AND t2.source_kind = 'transcript'
2321
+ )
2322
+ )
2323
+ ORDER BY
2324
+ CASE WHEN transcript_index IS NULL THEN created_at_epoch ELSE transcript_index END ASC,
2325
+ id ASC
2276
2326
  LIMIT ?`).all(sessionId, limit);
2277
2327
  }
2278
2328
  getRecentChatMessages(projectId, limit = 20, userId) {
@@ -2280,11 +2330,27 @@ class MemDatabase {
2280
2330
  if (projectId !== null) {
2281
2331
  return this.db.query(`SELECT * FROM chat_messages
2282
2332
  WHERE project_id = ?${visibilityClause}
2333
+ AND (
2334
+ source_kind = 'transcript'
2335
+ OR NOT EXISTS (
2336
+ SELECT 1 FROM chat_messages t2
2337
+ WHERE t2.session_id = chat_messages.session_id
2338
+ AND t2.source_kind = 'transcript'
2339
+ )
2340
+ )
2283
2341
  ORDER BY created_at_epoch DESC, id DESC
2284
2342
  LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
2285
2343
  }
2286
2344
  return this.db.query(`SELECT * FROM chat_messages
2287
2345
  WHERE 1 = 1${visibilityClause}
2346
+ AND (
2347
+ source_kind = 'transcript'
2348
+ OR NOT EXISTS (
2349
+ SELECT 1 FROM chat_messages t2
2350
+ WHERE t2.session_id = chat_messages.session_id
2351
+ AND t2.source_kind = 'transcript'
2352
+ )
2353
+ )
2288
2354
  ORDER BY created_at_epoch DESC, id DESC
2289
2355
  LIMIT ?`).all(...userId ? [userId] : [], limit);
2290
2356
  }
@@ -2295,14 +2361,33 @@ class MemDatabase {
2295
2361
  return this.db.query(`SELECT * FROM chat_messages
2296
2362
  WHERE project_id = ?
2297
2363
  AND lower(content) LIKE ?${visibilityClause}
2364
+ AND (
2365
+ source_kind = 'transcript'
2366
+ OR NOT EXISTS (
2367
+ SELECT 1 FROM chat_messages t2
2368
+ WHERE t2.session_id = chat_messages.session_id
2369
+ AND t2.source_kind = 'transcript'
2370
+ )
2371
+ )
2298
2372
  ORDER BY created_at_epoch DESC, id DESC
2299
2373
  LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
2300
2374
  }
2301
2375
  return this.db.query(`SELECT * FROM chat_messages
2302
2376
  WHERE lower(content) LIKE ?${visibilityClause}
2377
+ AND (
2378
+ source_kind = 'transcript'
2379
+ OR NOT EXISTS (
2380
+ SELECT 1 FROM chat_messages t2
2381
+ WHERE t2.session_id = chat_messages.session_id
2382
+ AND t2.source_kind = 'transcript'
2383
+ )
2384
+ )
2303
2385
  ORDER BY created_at_epoch DESC, id DESC
2304
2386
  LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
2305
2387
  }
2388
+ getTranscriptChatMessage(sessionId, transcriptIndex) {
2389
+ return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
2390
+ }
2306
2391
  addToOutbox(recordType, recordId) {
2307
2392
  const now = Math.floor(Date.now() / 1000);
2308
2393
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)