engrm 0.4.27 → 0.4.29

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
@@ -227,7 +227,7 @@ The MCP server exposes tools that supported agents can call directly:
227
227
  | `load_handoff` | Open a saved handoff as a resume point for a new session |
228
228
  | `refresh_chat_recall` | Rehydrate the separate chat lane from a Claude transcript when a long session feels under-captured |
229
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 |
230
+ | `search_chat` | Search recent chat recall with hybrid lexical + semantic matching, separately from reusable memory observations |
231
231
  | `search_recall` | Search durable memory and chat recall together when you do not want to guess the right lane |
232
232
  | `plugin_catalog` | Inspect Engrm plugin manifests for memory-aware integrations |
233
233
  | `save_plugin_memory` | Save reduced plugin output with stable Engrm provenance |
@@ -362,6 +362,8 @@ What each tool is good for:
362
362
  - `memory_console` gives the quickest project snapshot, including whether continuity is `fresh`, `thin`, or `cold`
363
363
  - `memory_console`, `project_memory_index`, and `session_context` now also show whether project chat recall is transcript-backed or only hook-captured
364
364
  - when chat continuity is only hook-captured, the workbench and startup hints now prefer `refresh_chat_recall`
365
+ - the workbench and startup hints now also prefer `search_recall` as the first “what were we just talking about?” path when recent prompts/chat/observations exist
366
+ - `search_chat` now uses hybrid lexical + semantic ranking when sqlite-vec and local embeddings are available, so recent conversation recall is less dependent on exact wording
365
367
  - `activity_feed` shows the merged chronology across prompts, tools, chat, handoffs, observations, and summaries
366
368
  - `recent_sessions` helps you pick a session worth opening
367
369
  - `session_story` reconstructs one session in detail, including handoffs and chat recall
@@ -369,7 +371,7 @@ What each tool is good for:
369
371
  - `session_tool_memory` shows which tool calls in one session turned into reusable memory and which did not
370
372
  - `project_memory_index` shows typed memory by repo, including continuity state and hot files
371
373
  - `workspace_memory_index` shows coverage across all repos on the machine
372
- - `recent_chat` / `search_chat` now report transcript-vs-hook coverage too, so weak OpenClaw recall is easier to diagnose and refresh
374
+ - `recent_chat` / `search_chat` now report transcript-vs-hook coverage too, and `search_chat` will also mark when semantic ranking was available, so weak OpenClaw recall is easier to diagnose and refresh
373
375
 
374
376
  ### Thin Tool Workflow
375
377
 
package/dist/cli.js CHANGED
@@ -708,6 +708,17 @@ var MIGRATIONS = [
708
708
  ON chat_messages(session_id, transcript_index)
709
709
  WHERE transcript_index IS NOT NULL;
710
710
  `
711
+ },
712
+ {
713
+ version: 18,
714
+ description: "Add sqlite-vec semantic search for chat recall",
715
+ condition: (db) => isVecExtensionLoaded(db),
716
+ sql: `
717
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chat_messages USING vec0(
718
+ chat_message_id INTEGER PRIMARY KEY,
719
+ embedding FLOAT[384]
720
+ );
721
+ `
711
722
  }
712
723
  ];
713
724
  function isVecExtensionLoaded(db) {
@@ -781,6 +792,9 @@ function inferLegacySchemaVersion(db) {
781
792
  if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
782
793
  version = Math.max(version, 17);
783
794
  }
795
+ if (tableExists(db, "vec_chat_messages")) {
796
+ version = Math.max(version, 18);
797
+ }
784
798
  return version;
785
799
  }
786
800
  function runMigrations(db) {
@@ -897,6 +911,20 @@ function ensureChatMessageColumns(db) {
897
911
  db.exec("PRAGMA user_version = 17");
898
912
  }
899
913
  }
914
+ function ensureChatVectorTable(db) {
915
+ if (!isVecExtensionLoaded(db))
916
+ return;
917
+ db.exec(`
918
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chat_messages USING vec0(
919
+ chat_message_id INTEGER PRIMARY KEY,
920
+ embedding FLOAT[384]
921
+ );
922
+ `);
923
+ const current = getSchemaVersion(db);
924
+ if (current < 18) {
925
+ db.exec("PRAGMA user_version = 18");
926
+ }
927
+ }
900
928
  function ensureSyncOutboxSupportsChatMessages(db) {
901
929
  if (syncOutboxSupportsChatMessages(db)) {
902
930
  const current = getSchemaVersion(db);
@@ -1110,6 +1138,7 @@ class MemDatabase {
1110
1138
  ensureObservationTypes(this.db);
1111
1139
  ensureSessionSummaryColumns(this.db);
1112
1140
  ensureChatMessageColumns(this.db);
1141
+ ensureChatVectorTable(this.db);
1113
1142
  ensureSyncOutboxSupportsChatMessages(this.db);
1114
1143
  }
1115
1144
  loadVecExtension() {
@@ -1476,6 +1505,14 @@ class MemDatabase {
1476
1505
  getChatMessageByRemoteSourceId(remoteSourceId) {
1477
1506
  return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
1478
1507
  }
1508
+ getChatMessagesByIds(ids) {
1509
+ if (ids.length === 0)
1510
+ return [];
1511
+ const placeholders = ids.map(() => "?").join(",");
1512
+ const rows = this.db.query(`SELECT * FROM chat_messages WHERE id IN (${placeholders})`).all(...ids);
1513
+ const order = new Map(ids.map((id, index) => [id, index]));
1514
+ return rows.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
1515
+ }
1479
1516
  getSessionChatMessages(sessionId, limit = 50) {
1480
1517
  return this.db.query(`SELECT * FROM chat_messages
1481
1518
  WHERE session_id = ?
@@ -1552,6 +1589,39 @@ class MemDatabase {
1552
1589
  ORDER BY created_at_epoch DESC, id DESC
1553
1590
  LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1554
1591
  }
1592
+ vecChatInsert(chatMessageId, embedding) {
1593
+ if (!this.vecAvailable)
1594
+ return;
1595
+ this.db.query("INSERT OR REPLACE INTO vec_chat_messages (chat_message_id, embedding) VALUES (?, ?)").run(chatMessageId, new Uint8Array(embedding.buffer));
1596
+ }
1597
+ searchChatVec(queryEmbedding, projectId, limit = 20, userId) {
1598
+ if (!this.vecAvailable)
1599
+ return [];
1600
+ const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
1601
+ const visibilityClause = userId ? " AND c.user_id = ?" : "";
1602
+ const transcriptPreference = `
1603
+ AND (
1604
+ c.source_kind = 'transcript'
1605
+ OR NOT EXISTS (
1606
+ SELECT 1 FROM chat_messages t2
1607
+ WHERE t2.session_id = c.session_id
1608
+ AND t2.source_kind = 'transcript'
1609
+ )
1610
+ )`;
1611
+ if (projectId !== null) {
1612
+ return this.db.query(`SELECT v.chat_message_id, v.distance
1613
+ FROM vec_chat_messages v
1614
+ JOIN chat_messages c ON c.id = v.chat_message_id
1615
+ WHERE v.embedding MATCH ?
1616
+ AND k = ?
1617
+ AND c.project_id = ?` + visibilityClause + transcriptPreference).all(embeddingBlob, limit, projectId, ...userId ? [userId] : []);
1618
+ }
1619
+ return this.db.query(`SELECT v.chat_message_id, v.distance
1620
+ FROM vec_chat_messages v
1621
+ JOIN chat_messages c ON c.id = v.chat_message_id
1622
+ WHERE v.embedding MATCH ?
1623
+ AND k = ?` + visibilityClause + transcriptPreference).all(embeddingBlob, limit, ...userId ? [userId] : []);
1624
+ }
1555
1625
  getTranscriptChatMessage(sessionId, transcriptIndex) {
1556
1626
  return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
1557
1627
  }
@@ -2296,6 +2366,17 @@ var MIGRATIONS2 = [
2296
2366
  ON chat_messages(session_id, transcript_index)
2297
2367
  WHERE transcript_index IS NOT NULL;
2298
2368
  `
2369
+ },
2370
+ {
2371
+ version: 18,
2372
+ description: "Add sqlite-vec semantic search for chat recall",
2373
+ condition: (db) => isVecExtensionLoaded2(db),
2374
+ sql: `
2375
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chat_messages USING vec0(
2376
+ chat_message_id INTEGER PRIMARY KEY,
2377
+ embedding FLOAT[384]
2378
+ );
2379
+ `
2299
2380
  }
2300
2381
  ];
2301
2382
  function isVecExtensionLoaded2(db) {
@@ -3288,6 +3369,9 @@ function composeEmbeddingText(obs) {
3288
3369
 
3289
3370
  `);
3290
3371
  }
3372
+ function composeChatEmbeddingText(text) {
3373
+ return text.replace(/\s+/g, " ").trim().slice(0, 2000);
3374
+ }
3291
3375
  async function getPipeline() {
3292
3376
  if (_pipeline)
3293
3377
  return _pipeline;
@@ -510,6 +510,9 @@ function composeEmbeddingText(obs) {
510
510
 
511
511
  `);
512
512
  }
513
+ function composeChatEmbeddingText(text) {
514
+ return text.replace(/\s+/g, " ").trim().slice(0, 2000);
515
+ }
513
516
  async function getPipeline() {
514
517
  if (_pipeline)
515
518
  return _pipeline;
@@ -1541,6 +1544,17 @@ var MIGRATIONS = [
1541
1544
  ON chat_messages(session_id, transcript_index)
1542
1545
  WHERE transcript_index IS NOT NULL;
1543
1546
  `
1547
+ },
1548
+ {
1549
+ version: 18,
1550
+ description: "Add sqlite-vec semantic search for chat recall",
1551
+ condition: (db) => isVecExtensionLoaded(db),
1552
+ sql: `
1553
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chat_messages USING vec0(
1554
+ chat_message_id INTEGER PRIMARY KEY,
1555
+ embedding FLOAT[384]
1556
+ );
1557
+ `
1544
1558
  }
1545
1559
  ];
1546
1560
  function isVecExtensionLoaded(db) {
@@ -1614,6 +1628,9 @@ function inferLegacySchemaVersion(db) {
1614
1628
  if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
1615
1629
  version = Math.max(version, 17);
1616
1630
  }
1631
+ if (tableExists(db, "vec_chat_messages")) {
1632
+ version = Math.max(version, 18);
1633
+ }
1617
1634
  return version;
1618
1635
  }
1619
1636
  function runMigrations(db) {
@@ -1730,6 +1747,20 @@ function ensureChatMessageColumns(db) {
1730
1747
  db.exec("PRAGMA user_version = 17");
1731
1748
  }
1732
1749
  }
1750
+ function ensureChatVectorTable(db) {
1751
+ if (!isVecExtensionLoaded(db))
1752
+ return;
1753
+ db.exec(`
1754
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chat_messages USING vec0(
1755
+ chat_message_id INTEGER PRIMARY KEY,
1756
+ embedding FLOAT[384]
1757
+ );
1758
+ `);
1759
+ const current = getSchemaVersion(db);
1760
+ if (current < 18) {
1761
+ db.exec("PRAGMA user_version = 18");
1762
+ }
1763
+ }
1733
1764
  function ensureSyncOutboxSupportsChatMessages(db) {
1734
1765
  if (syncOutboxSupportsChatMessages(db)) {
1735
1766
  const current = getSchemaVersion(db);
@@ -1943,6 +1974,7 @@ class MemDatabase {
1943
1974
  ensureObservationTypes(this.db);
1944
1975
  ensureSessionSummaryColumns(this.db);
1945
1976
  ensureChatMessageColumns(this.db);
1977
+ ensureChatVectorTable(this.db);
1946
1978
  ensureSyncOutboxSupportsChatMessages(this.db);
1947
1979
  }
1948
1980
  loadVecExtension() {
@@ -2309,6 +2341,14 @@ class MemDatabase {
2309
2341
  getChatMessageByRemoteSourceId(remoteSourceId) {
2310
2342
  return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
2311
2343
  }
2344
+ getChatMessagesByIds(ids) {
2345
+ if (ids.length === 0)
2346
+ return [];
2347
+ const placeholders = ids.map(() => "?").join(",");
2348
+ const rows = this.db.query(`SELECT * FROM chat_messages WHERE id IN (${placeholders})`).all(...ids);
2349
+ const order = new Map(ids.map((id, index) => [id, index]));
2350
+ return rows.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
2351
+ }
2312
2352
  getSessionChatMessages(sessionId, limit = 50) {
2313
2353
  return this.db.query(`SELECT * FROM chat_messages
2314
2354
  WHERE session_id = ?
@@ -2385,6 +2425,39 @@ class MemDatabase {
2385
2425
  ORDER BY created_at_epoch DESC, id DESC
2386
2426
  LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
2387
2427
  }
2428
+ vecChatInsert(chatMessageId, embedding) {
2429
+ if (!this.vecAvailable)
2430
+ return;
2431
+ this.db.query("INSERT OR REPLACE INTO vec_chat_messages (chat_message_id, embedding) VALUES (?, ?)").run(chatMessageId, new Uint8Array(embedding.buffer));
2432
+ }
2433
+ searchChatVec(queryEmbedding, projectId, limit = 20, userId) {
2434
+ if (!this.vecAvailable)
2435
+ return [];
2436
+ const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
2437
+ const visibilityClause = userId ? " AND c.user_id = ?" : "";
2438
+ const transcriptPreference = `
2439
+ AND (
2440
+ c.source_kind = 'transcript'
2441
+ OR NOT EXISTS (
2442
+ SELECT 1 FROM chat_messages t2
2443
+ WHERE t2.session_id = c.session_id
2444
+ AND t2.source_kind = 'transcript'
2445
+ )
2446
+ )`;
2447
+ if (projectId !== null) {
2448
+ return this.db.query(`SELECT v.chat_message_id, v.distance
2449
+ FROM vec_chat_messages v
2450
+ JOIN chat_messages c ON c.id = v.chat_message_id
2451
+ WHERE v.embedding MATCH ?
2452
+ AND k = ?
2453
+ AND c.project_id = ?` + visibilityClause + transcriptPreference).all(embeddingBlob, limit, projectId, ...userId ? [userId] : []);
2454
+ }
2455
+ return this.db.query(`SELECT v.chat_message_id, v.distance
2456
+ FROM vec_chat_messages v
2457
+ JOIN chat_messages c ON c.id = v.chat_message_id
2458
+ WHERE v.embedding MATCH ?
2459
+ AND k = ?` + visibilityClause + transcriptPreference).all(embeddingBlob, limit, ...userId ? [userId] : []);
2460
+ }
2388
2461
  getTranscriptChatMessage(sessionId, transcriptIndex) {
2389
2462
  return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
2390
2463
  }
@@ -886,6 +886,17 @@ var MIGRATIONS = [
886
886
  ON chat_messages(session_id, transcript_index)
887
887
  WHERE transcript_index IS NOT NULL;
888
888
  `
889
+ },
890
+ {
891
+ version: 18,
892
+ description: "Add sqlite-vec semantic search for chat recall",
893
+ condition: (db) => isVecExtensionLoaded(db),
894
+ sql: `
895
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chat_messages USING vec0(
896
+ chat_message_id INTEGER PRIMARY KEY,
897
+ embedding FLOAT[384]
898
+ );
899
+ `
889
900
  }
890
901
  ];
891
902
  function isVecExtensionLoaded(db) {
@@ -959,6 +970,9 @@ function inferLegacySchemaVersion(db) {
959
970
  if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
960
971
  version = Math.max(version, 17);
961
972
  }
973
+ if (tableExists(db, "vec_chat_messages")) {
974
+ version = Math.max(version, 18);
975
+ }
962
976
  return version;
963
977
  }
964
978
  function runMigrations(db) {
@@ -1075,6 +1089,20 @@ function ensureChatMessageColumns(db) {
1075
1089
  db.exec("PRAGMA user_version = 17");
1076
1090
  }
1077
1091
  }
1092
+ function ensureChatVectorTable(db) {
1093
+ if (!isVecExtensionLoaded(db))
1094
+ return;
1095
+ db.exec(`
1096
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chat_messages USING vec0(
1097
+ chat_message_id INTEGER PRIMARY KEY,
1098
+ embedding FLOAT[384]
1099
+ );
1100
+ `);
1101
+ const current = getSchemaVersion(db);
1102
+ if (current < 18) {
1103
+ db.exec("PRAGMA user_version = 18");
1104
+ }
1105
+ }
1078
1106
  function ensureSyncOutboxSupportsChatMessages(db) {
1079
1107
  if (syncOutboxSupportsChatMessages(db)) {
1080
1108
  const current = getSchemaVersion(db);
@@ -1288,6 +1316,7 @@ class MemDatabase {
1288
1316
  ensureObservationTypes(this.db);
1289
1317
  ensureSessionSummaryColumns(this.db);
1290
1318
  ensureChatMessageColumns(this.db);
1319
+ ensureChatVectorTable(this.db);
1291
1320
  ensureSyncOutboxSupportsChatMessages(this.db);
1292
1321
  }
1293
1322
  loadVecExtension() {
@@ -1654,6 +1683,14 @@ class MemDatabase {
1654
1683
  getChatMessageByRemoteSourceId(remoteSourceId) {
1655
1684
  return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
1656
1685
  }
1686
+ getChatMessagesByIds(ids) {
1687
+ if (ids.length === 0)
1688
+ return [];
1689
+ const placeholders = ids.map(() => "?").join(",");
1690
+ const rows = this.db.query(`SELECT * FROM chat_messages WHERE id IN (${placeholders})`).all(...ids);
1691
+ const order = new Map(ids.map((id, index) => [id, index]));
1692
+ return rows.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
1693
+ }
1657
1694
  getSessionChatMessages(sessionId, limit = 50) {
1658
1695
  return this.db.query(`SELECT * FROM chat_messages
1659
1696
  WHERE session_id = ?
@@ -1730,6 +1767,39 @@ class MemDatabase {
1730
1767
  ORDER BY created_at_epoch DESC, id DESC
1731
1768
  LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1732
1769
  }
1770
+ vecChatInsert(chatMessageId, embedding) {
1771
+ if (!this.vecAvailable)
1772
+ return;
1773
+ this.db.query("INSERT OR REPLACE INTO vec_chat_messages (chat_message_id, embedding) VALUES (?, ?)").run(chatMessageId, new Uint8Array(embedding.buffer));
1774
+ }
1775
+ searchChatVec(queryEmbedding, projectId, limit = 20, userId) {
1776
+ if (!this.vecAvailable)
1777
+ return [];
1778
+ const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
1779
+ const visibilityClause = userId ? " AND c.user_id = ?" : "";
1780
+ const transcriptPreference = `
1781
+ AND (
1782
+ c.source_kind = 'transcript'
1783
+ OR NOT EXISTS (
1784
+ SELECT 1 FROM chat_messages t2
1785
+ WHERE t2.session_id = c.session_id
1786
+ AND t2.source_kind = 'transcript'
1787
+ )
1788
+ )`;
1789
+ if (projectId !== null) {
1790
+ return this.db.query(`SELECT v.chat_message_id, v.distance
1791
+ FROM vec_chat_messages v
1792
+ JOIN chat_messages c ON c.id = v.chat_message_id
1793
+ WHERE v.embedding MATCH ?
1794
+ AND k = ?
1795
+ AND c.project_id = ?` + visibilityClause + transcriptPreference).all(embeddingBlob, limit, projectId, ...userId ? [userId] : []);
1796
+ }
1797
+ return this.db.query(`SELECT v.chat_message_id, v.distance
1798
+ FROM vec_chat_messages v
1799
+ JOIN chat_messages c ON c.id = v.chat_message_id
1800
+ WHERE v.embedding MATCH ?
1801
+ AND k = ?` + visibilityClause + transcriptPreference).all(embeddingBlob, limit, ...userId ? [userId] : []);
1802
+ }
1733
1803
  getTranscriptChatMessage(sessionId, transcriptIndex) {
1734
1804
  return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
1735
1805
  }
@@ -2514,6 +2584,9 @@ function composeEmbeddingText(obs) {
2514
2584
 
2515
2585
  `);
2516
2586
  }
2587
+ function composeChatEmbeddingText(text) {
2588
+ return text.replace(/\s+/g, " ").trim().slice(0, 2000);
2589
+ }
2517
2590
  async function getPipeline() {
2518
2591
  if (_pipeline)
2519
2592
  return _pipeline;
@@ -3648,6 +3721,7 @@ function parseJsonArray(value) {
3648
3721
  }
3649
3722
 
3650
3723
  // src/capture/transcript.ts
3724
+ import { createHash as createHash3 } from "node:crypto";
3651
3725
  import { readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
3652
3726
  import { join as join4 } from "node:path";
3653
3727
  import { homedir as homedir3 } from "node:os";
@@ -3701,23 +3775,109 @@ function readTranscript(sessionId, cwd, transcriptPath) {
3701
3775
  }
3702
3776
  return messages;
3703
3777
  }
3704
- function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
3705
- const messages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
3778
+ function resolveHistoryPath(historyPath) {
3779
+ if (historyPath)
3780
+ return historyPath;
3781
+ const override = process.env["ENGRM_CLAUDE_HISTORY_PATH"];
3782
+ if (override)
3783
+ return override;
3784
+ return join4(homedir3(), ".claude", "history.jsonl");
3785
+ }
3786
+ function readHistoryFallback(sessionId, cwd, opts) {
3787
+ const path = resolveHistoryPath(opts?.historyPath);
3788
+ if (!existsSync4(path))
3789
+ return [];
3790
+ let raw;
3791
+ try {
3792
+ raw = readFileSync4(path, "utf-8");
3793
+ } catch {
3794
+ return [];
3795
+ }
3796
+ const targetCanonical = detectProject(cwd).canonical_id;
3797
+ const windowStart = Math.max(0, (opts?.startedAtEpoch ?? Math.floor(Date.now() / 1000) - 6 * 3600) - 600);
3798
+ const windowEnd = (opts?.completedAtEpoch ?? Math.floor(Date.now() / 1000)) + 600;
3799
+ const entries = [];
3800
+ for (const line of raw.split(`
3801
+ `)) {
3802
+ if (!line.trim())
3803
+ continue;
3804
+ let entry;
3805
+ try {
3806
+ entry = JSON.parse(line);
3807
+ } catch {
3808
+ continue;
3809
+ }
3810
+ if (typeof entry?.display !== "string" || typeof entry?.timestamp !== "number")
3811
+ continue;
3812
+ const createdAtEpoch = Math.floor(entry.timestamp / 1000);
3813
+ entries.push({
3814
+ display: entry.display.trim(),
3815
+ project: typeof entry.project === "string" ? entry.project : "",
3816
+ sessionId: typeof entry.sessionId === "string" ? entry.sessionId : "",
3817
+ timestamp: createdAtEpoch
3818
+ });
3819
+ }
3820
+ const bySession = entries.filter((entry) => entry.display.length > 0 && entry.sessionId === sessionId).sort((a, b) => a.timestamp - b.timestamp);
3821
+ if (bySession.length > 0) {
3822
+ return dedupeHistoryMessages(bySession.map((entry) => ({
3823
+ role: "user",
3824
+ text: entry.display,
3825
+ createdAtEpoch: entry.timestamp
3826
+ })));
3827
+ }
3828
+ const byProjectAndWindow = entries.filter((entry) => {
3829
+ if (entry.display.length === 0)
3830
+ return false;
3831
+ if (entry.timestamp < windowStart || entry.timestamp > windowEnd)
3832
+ return false;
3833
+ if (!entry.project)
3834
+ return false;
3835
+ return detectProject(entry.project).canonical_id === targetCanonical;
3836
+ }).sort((a, b) => a.timestamp - b.timestamp);
3837
+ return dedupeHistoryMessages(byProjectAndWindow.map((entry) => ({
3838
+ role: "user",
3839
+ text: entry.display,
3840
+ createdAtEpoch: entry.timestamp
3841
+ })));
3842
+ }
3843
+ async function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
3844
+ const session = db.getSessionById(sessionId);
3845
+ const transcriptMessages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
3706
3846
  ...message,
3707
3847
  text: message.text.trim()
3708
3848
  })).filter((message) => message.text.length > 0);
3849
+ const messages = transcriptMessages.length > 0 ? transcriptMessages.map((message, index) => ({
3850
+ ...message,
3851
+ sourceKind: "transcript",
3852
+ transcriptIndex: index + 1,
3853
+ createdAtEpoch: null,
3854
+ remoteSourceId: null
3855
+ })) : readHistoryFallback(sessionId, cwd, {
3856
+ startedAtEpoch: session?.started_at_epoch ?? null,
3857
+ completedAtEpoch: session?.completed_at_epoch ?? null
3858
+ }).map((message) => ({
3859
+ role: message.role,
3860
+ text: message.text,
3861
+ sourceKind: "hook",
3862
+ transcriptIndex: null,
3863
+ createdAtEpoch: message.createdAtEpoch,
3864
+ remoteSourceId: buildHistorySourceId(sessionId, message.createdAtEpoch, message.text)
3865
+ }));
3709
3866
  if (messages.length === 0)
3710
3867
  return { imported: 0, total: 0 };
3711
- const session = db.getSessionById(sessionId);
3712
3868
  const projectId = session?.project_id ?? null;
3713
3869
  const now = Math.floor(Date.now() / 1000);
3714
3870
  let imported = 0;
3715
3871
  for (let index = 0;index < messages.length; index++) {
3716
- const transcriptIndex = index + 1;
3717
- if (db.getTranscriptChatMessage(sessionId, transcriptIndex))
3718
- continue;
3719
3872
  const message = messages[index];
3720
- const createdAtEpoch = Math.max(0, now - (messages.length - transcriptIndex));
3873
+ const transcriptIndex = message.transcriptIndex ?? index + 1;
3874
+ if (message.sourceKind === "transcript" && db.getTranscriptChatMessage(sessionId, transcriptIndex)) {
3875
+ continue;
3876
+ }
3877
+ if (message.remoteSourceId && db.getChatMessageByRemoteSourceId(message.remoteSourceId)) {
3878
+ continue;
3879
+ }
3880
+ const createdAtEpoch = message.createdAtEpoch ?? Math.max(0, now - (messages.length - transcriptIndex));
3721
3881
  const row = db.insertChatMessage({
3722
3882
  session_id: sessionId,
3723
3883
  project_id: projectId,
@@ -3727,14 +3887,50 @@ function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
3727
3887
  device_id: config.device_id,
3728
3888
  agent: "claude-code",
3729
3889
  created_at_epoch: createdAtEpoch,
3730
- source_kind: "transcript",
3731
- transcript_index: transcriptIndex
3890
+ remote_source_id: message.remoteSourceId,
3891
+ source_kind: message.sourceKind,
3892
+ transcript_index: message.transcriptIndex
3732
3893
  });
3733
3894
  db.addToOutbox("chat_message", row.id);
3895
+ if (message.role === "user") {
3896
+ db.insertUserPrompt({
3897
+ session_id: sessionId,
3898
+ project_id: projectId,
3899
+ prompt: message.text,
3900
+ cwd,
3901
+ user_id: config.user_id,
3902
+ device_id: config.device_id,
3903
+ agent: "claude-code",
3904
+ created_at_epoch: createdAtEpoch
3905
+ });
3906
+ }
3907
+ if (db.vecAvailable) {
3908
+ const embedding = await embedText(composeChatEmbeddingText(message.text));
3909
+ if (embedding) {
3910
+ db.vecChatInsert(row.id, embedding);
3911
+ }
3912
+ }
3734
3913
  imported++;
3735
3914
  }
3736
3915
  return { imported, total: messages.length };
3737
3916
  }
3917
+ function dedupeHistoryMessages(messages) {
3918
+ const deduped = [];
3919
+ for (const message of messages) {
3920
+ const compact = message.text.replace(/\s+/g, " ").trim();
3921
+ if (!compact)
3922
+ continue;
3923
+ const previous = deduped[deduped.length - 1];
3924
+ if (previous && previous.text.replace(/\s+/g, " ").trim() === compact)
3925
+ continue;
3926
+ deduped.push({ ...message, text: compact });
3927
+ }
3928
+ return deduped;
3929
+ }
3930
+ function buildHistorySourceId(sessionId, createdAtEpoch, text) {
3931
+ const digest = createHash3("sha1").update(text).digest("hex").slice(0, 12);
3932
+ return `history:${sessionId}:${createdAtEpoch}:${digest}`;
3933
+ }
3738
3934
  function truncateTranscript(messages, maxBytes = 50000) {
3739
3935
  const lines = [];
3740
3936
  for (const msg of messages) {
@@ -3810,6 +4006,16 @@ async function saveTranscriptResults(db, config, results, sessionId, cwd) {
3810
4006
  return saved;
3811
4007
  }
3812
4008
 
4009
+ // src/tools/recent-chat.ts
4010
+ function getChatCaptureOrigin(message) {
4011
+ if (message.source_kind === "transcript")
4012
+ return "transcript";
4013
+ if (typeof message.remote_source_id === "string" && message.remote_source_id.startsWith("history:")) {
4014
+ return "history";
4015
+ }
4016
+ return "hook";
4017
+ }
4018
+
3813
4019
  // src/tools/session-story.ts
3814
4020
  function getSessionStory(db, input) {
3815
4021
  const session = db.getSessionById(input.session_id);
@@ -3932,9 +4138,9 @@ function collectProvenanceSummary(observations) {
3932
4138
  }
3933
4139
  function summarizeChatSources(messages) {
3934
4140
  return messages.reduce((summary, message) => {
3935
- summary[message.source_kind] += 1;
4141
+ summary[getChatCaptureOrigin(message)] += 1;
3936
4142
  return summary;
3937
- }, { transcript: 0, hook: 0 });
4143
+ }, { transcript: 0, history: 0, hook: 0 });
3938
4144
  }
3939
4145
 
3940
4146
  // src/tools/handoffs.ts
@@ -4256,7 +4462,7 @@ async function main() {
4256
4462
  try {
4257
4463
  if (event.session_id) {
4258
4464
  persistRawToolChronology(event, config.user_id, config.device_id);
4259
- syncTranscriptChat(db, config, event.session_id, event.cwd);
4465
+ await syncTranscriptChat(db, config, event.session_id, event.cwd);
4260
4466
  }
4261
4467
  const textToScan = extractScanText(event);
4262
4468
  if (textToScan) {