@yeaft/webchat-agent 1.0.335 → 1.0.337

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.
@@ -24,6 +24,12 @@ import { writeAtomic } from '../storage/atomic.js';
24
24
  import { pairSanitize } from '../pair-sanitize.js';
25
25
  import { sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
26
26
  import { isHiddenConversationRow, isVisibleConversationRow } from './internal-control.js';
27
+ import {
28
+ findLiteralSearch,
29
+ iterateCanonicalVisibleEntriesNewestFirst,
30
+ normalizeLiteralSearch,
31
+ } from './visible-entry.js';
32
+ import { markConversationDirty } from './history-index-state.js';
27
33
 
28
34
  /**
29
35
  * Default cold-start "recent window" size, expressed in TURNS (not raw
@@ -134,6 +140,11 @@ function applyFoldedMessageTombstones(rows, additionalIds = []) {
134
140
  return rows.filter(row => !foldedIds.has(row?.id));
135
141
  }
136
142
 
143
+ function addScanMetric(stats, key, value = 1) {
144
+ if (!stats || typeof stats !== 'object' || !Number.isFinite(value) || value <= 0) return;
145
+ stats[key] = (Number(stats[key]) || 0) + value;
146
+ }
147
+
137
148
  function segmentNameForNumber(n) {
138
149
  return `${String(n).padStart(6, '0')}.jsonl`;
139
150
  }
@@ -238,6 +249,7 @@ function maybeWarnHistoryTruncated(sessionId, storeDir, recentTurnsLimit, hasCom
238
249
  * Used to avoid spamming the console with repeated warnings.
239
250
  */
240
251
  let _permissionWarned = false;
252
+ let _historyIndexMutationWarned = false;
241
253
 
242
254
  /** Rough token estimation: ~4 chars per token. */
243
255
  export function estimateTokens(text) {
@@ -775,7 +787,7 @@ class SegmentStore {
775
787
  : out.sort(compareMessagesBySeq);
776
788
  }
777
789
 
778
- *scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
790
+ *scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, scanStats = null } = {}) {
779
791
  if (!this.hasData()) return;
780
792
  const idx = this.loadIndex();
781
793
  const segments = (idx.segments || [])
@@ -784,6 +796,9 @@ class SegmentStore {
784
796
  .sort((a, b) => desc ? (b.lastSeq || 0) - (a.lastSeq || 0) : (a.firstSeq || 0) - (b.firstSeq || 0));
785
797
  for (const seg of segments) {
786
798
  const rows = this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold });
799
+ addScanMetric(scanStats, 'segments');
800
+ addScanMetric(scanStats, 'bytes', Number(seg.bytes) || 0);
801
+ addScanMetric(scanStats, 'rows', rows.length);
787
802
  yield* applyFoldedMessageTombstones(rows, idx.foldedMessageIds);
788
803
  }
789
804
  }
@@ -836,7 +851,7 @@ class SegmentStore {
836
851
  }
837
852
 
838
853
  markCold(id) {
839
- return this.updateById(id, msg => ({ ...msg, cold: true })) ? 1 : 0;
854
+ return this.updateById(id, msg => ({ ...msg, cold: true }));
840
855
  }
841
856
 
842
857
  clear() {
@@ -1025,6 +1040,40 @@ export class ConversationStore {
1025
1040
 
1026
1041
  // ─── Write API ──────────────────────────────────────────
1027
1042
 
1043
+ #markDirty(message, reason, sourceIds = null) {
1044
+ const sessionId = message?.sessionId || null;
1045
+ try {
1046
+ markConversationDirty({
1047
+ ownerRoot: this.#dir,
1048
+ scopeKind: sessionId ? 'session' : 'chat',
1049
+ scopeId: sessionId || message?.chatId || '*',
1050
+ reason,
1051
+ sourceIds,
1052
+ });
1053
+ } catch (error) {
1054
+ if (!_historyIndexMutationWarned) {
1055
+ console.warn(`[Yeaft] Cannot mark conversation index dirty: ${error?.message || error}`);
1056
+ _historyIndexMutationWarned = true;
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ #markAllDirty(reason) {
1062
+ try {
1063
+ markConversationDirty({
1064
+ ownerRoot: this.#dir,
1065
+ scopeKind: 'session',
1066
+ scopeId: '*',
1067
+ reason,
1068
+ });
1069
+ } catch (error) {
1070
+ if (!_historyIndexMutationWarned) {
1071
+ console.warn(`[Yeaft] Cannot mark conversation index dirty: ${error?.message || error}`);
1072
+ _historyIndexMutationWarned = true;
1073
+ }
1074
+ }
1075
+ }
1076
+
1028
1077
  /**
1029
1078
  * Append a single message to the conversation.
1030
1079
  *
@@ -1055,6 +1104,15 @@ export class ConversationStore {
1055
1104
  }
1056
1105
 
1057
1106
  this.#nextSeq = seq + 1;
1107
+ if (fullMsg._reflection || (
1108
+ (fullMsg.role === 'user' || fullMsg.role === 'assistant')
1109
+ && isVisibleConversationRow(fullMsg)
1110
+ )) {
1111
+ this.#markDirty(fullMsg, fullMsg._reflection ? 'fold' : 'append', [
1112
+ fullMsg.id,
1113
+ ...(Array.isArray(fullMsg.foldedMessageIds) ? fullMsg.foldedMessageIds : []),
1114
+ ]);
1115
+ }
1058
1116
 
1059
1117
  return fullMsg;
1060
1118
  }
@@ -1082,7 +1140,20 @@ export class ConversationStore {
1082
1140
  if (!message?.id || !patch || typeof patch !== 'object') return null;
1083
1141
  try {
1084
1142
  const store = this.#segmentStoreFor(message, { create: false });
1085
- return store.updateById(message.id, current => ({ ...current, ...patch }));
1143
+ let before = null;
1144
+ const updated = store.updateById(message.id, current => {
1145
+ before = { ...current };
1146
+ return { ...current, ...patch };
1147
+ });
1148
+ const affectsCanonicalProjection = row => !!row && (
1149
+ row._reflection
1150
+ || ((row.role === 'user' || row.role === 'assistant')
1151
+ && isVisibleConversationRow(row))
1152
+ );
1153
+ if (updated && (affectsCanonicalProjection(before) || affectsCanonicalProjection(updated))) {
1154
+ this.#markDirty(updated, 'update', [message.id]);
1155
+ }
1156
+ return updated;
1086
1157
  } catch (err) {
1087
1158
  if (isPermissionError(err)) {
1088
1159
  if (!_permissionWarned) {
@@ -1122,7 +1193,7 @@ export class ConversationStore {
1122
1193
  moveToCold(id) {
1123
1194
  for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
1124
1195
  const moved = this.#segmentStoreForConversationDir(dir).markCold(id);
1125
- if (moved > 0) return;
1196
+ if (moved) return;
1126
1197
  }
1127
1198
  for (const [hotDir, coldDir] of this.#hotColdDirPairs({ includeLegacy: false })) {
1128
1199
  const src = join(hotDir, `${id}.md`);
@@ -1130,6 +1201,7 @@ export class ConversationStore {
1130
1201
  if (!existsSync(src)) continue;
1131
1202
  try {
1132
1203
  renameSync(src, dst);
1204
+
1133
1205
  } catch (err) {
1134
1206
  if (isPermissionError(err)) {
1135
1207
  if (!_permissionWarned) {
@@ -1378,6 +1450,7 @@ export class ConversationStore {
1378
1450
  }
1379
1451
  this.#nextSeq = 1;
1380
1452
  this.updateIndex({ totalMessages: 0, lastMessageId: null });
1453
+ this.#markAllDirty('clear');
1381
1454
  }
1382
1455
 
1383
1456
 
@@ -1618,24 +1691,37 @@ export class ConversationStore {
1618
1691
  * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
1619
1692
  * @returns {{ messages: object[], oldestSeq: number|null, hasMore: boolean }}
1620
1693
  */
1621
- loadVisibleBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
1694
+ loadVisibleBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS, opts = {}) {
1622
1695
  if (!sessionId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
1623
1696
 
1624
1697
  const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
1698
+ const stopAtSeq = Number.isFinite(opts.stopAtSeq) ? Math.max(1, opts.stopAtSeq) : null;
1625
1699
  const page = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
1626
1700
  beforeSeq: cutoff,
1701
+ afterSeq: stopAtSeq === null ? -Infinity : stopAtSeq - 1,
1627
1702
  roles: null,
1628
1703
  stripAssistantToolCalls: false,
1629
1704
  visibleOnly: true,
1630
1705
  });
1631
1706
  const messages = projectVisibleSessionMessages(page.messages);
1632
- if (messages.length === 0) return { messages: [], oldestSeq: null, hasMore: page.truncated };
1707
+ if (messages.length === 0) {
1708
+ const nextBeforeSeq = Number.isFinite(page.nextBeforeSeq) ? page.nextBeforeSeq : null;
1709
+ return {
1710
+ messages: [],
1711
+ oldestSeq: null,
1712
+ nextBeforeSeq,
1713
+ hasMore: page.truncated && (stopAtSeq === null || nextBeforeSeq > stopAtSeq),
1714
+ };
1715
+ }
1633
1716
 
1634
1717
  const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
1635
1718
  return {
1636
1719
  messages,
1637
1720
  oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
1638
- hasMore: page.truncated,
1721
+ nextBeforeSeq: Number.isFinite(page.nextBeforeSeq)
1722
+ ? Math.min(page.nextBeforeSeq, oldestSeq)
1723
+ : (Number.isFinite(oldestSeq) ? oldestSeq : null),
1724
+ hasMore: page.truncated && (stopAtSeq === null || oldestSeq > stopAtSeq),
1639
1725
  };
1640
1726
  }
1641
1727
 
@@ -1751,6 +1837,23 @@ export class ConversationStore {
1751
1837
  return Number.isFinite(seq) ? seq : null;
1752
1838
  }
1753
1839
 
1840
+ /**
1841
+ * Return every canonical visible entry newest-first. This is the single read
1842
+ * model used by JSONL fallback search and the rebuildable SQLite worker.
1843
+ *
1844
+ * @param {string} sessionId
1845
+ * @param {{ scanStats?: object }} [opts]
1846
+ * @returns {object[]}
1847
+ */
1848
+ *iterateCanonicalVisibleEntriesBySession(sessionId, opts = {}) {
1849
+ if (!sessionId) return;
1850
+ yield* this.#iterateVisibleResponseEntries(sessionId, { scanStats: opts.scanStats });
1851
+ }
1852
+
1853
+ loadCanonicalVisibleEntriesBySession(sessionId, opts = {}) {
1854
+ return Array.from(this.iterateCanonicalVisibleEntriesBySession(sessionId, opts));
1855
+ }
1856
+
1754
1857
  /**
1755
1858
  * Search user-visible messages inside one Session. The scan is newest-first
1756
1859
  * and stops as soon as one page plus a `hasMore` sentinel is found, so a
@@ -1758,11 +1861,11 @@ export class ConversationStore {
1758
1861
  *
1759
1862
  * @param {string} sessionId
1760
1863
  * @param {string} query
1761
- * @param {{ limit?: number, beforeSeq?: number|null, senderKey?: string }} [opts]
1864
+ * @param {{ limit?: number, beforeSeq?: number|null, senderKey?: string, scanStats?: object }} [opts]
1762
1865
  * @returns {{ results: object[], hasMore: boolean, nextBeforeSeq: number|null }}
1763
1866
  */
1764
1867
  searchVisibleBySession(sessionId, query, opts = {}) {
1765
- const needle = typeof query === 'string' ? query.trim().toLocaleLowerCase() : '';
1868
+ const needle = normalizeLiteralSearch(typeof query === 'string' ? query.trim() : '');
1766
1869
  const senderKey = typeof opts.senderKey === 'string' ? opts.senderKey : '';
1767
1870
  if (!sessionId || (needle.length < 2 && !senderKey)) return { results: [], hasMore: false, nextBeforeSeq: null };
1768
1871
 
@@ -1771,7 +1874,7 @@ export class ConversationStore {
1771
1874
  const results = [];
1772
1875
  let hasMore = false;
1773
1876
 
1774
- for (const entry of this.#iterateVisibleResponseEntries(sessionId, { beforeSeq })) {
1877
+ for (const entry of this.#iterateVisibleResponseEntries(sessionId, { beforeSeq, scanStats: opts.scanStats })) {
1775
1878
  const senderMatches = senderKey === 'user'
1776
1879
  ? entry.role === 'user'
1777
1880
  : (senderKey.startsWith('vp:')
@@ -1779,7 +1882,7 @@ export class ConversationStore {
1779
1882
  : true);
1780
1883
  if (!senderMatches) continue;
1781
1884
  const text = entry.textParts.join(' ');
1782
- const matchIndex = needle ? text.toLocaleLowerCase().indexOf(needle) : 0;
1885
+ const matchIndex = needle ? findLiteralSearch(text, needle) : 0;
1783
1886
  if (matchIndex < 0) continue;
1784
1887
  if (results.length >= limit) {
1785
1888
  hasMore = true;
@@ -1807,7 +1910,7 @@ export class ConversationStore {
1807
1910
  * bodies never leave the Agent through this API.
1808
1911
  *
1809
1912
  * @param {string} sessionId
1810
- * @param {{ limit?: number, beforeSeq?: number|null, includeTotal?: boolean }} [opts]
1913
+ * @param {{ limit?: number, beforeSeq?: number|null, includeTotal?: boolean, scanStats?: object }} [opts]
1811
1914
  * @returns {{ results: object[], hasMore: boolean, nextBeforeSeq: number|null, totalCount: number|null }}
1812
1915
  */
1813
1916
  loadVisibleOutlineBySession(sessionId, opts = {}) {
@@ -1818,7 +1921,7 @@ export class ConversationStore {
1818
1921
  const newestFirst = [];
1819
1922
  let hasMore = false;
1820
1923
 
1821
- for (const entry of this.#iterateVisibleResponseEntries(sessionId, { beforeSeq })) {
1924
+ for (const entry of this.#iterateVisibleResponseEntries(sessionId, { beforeSeq, scanStats: opts.scanStats })) {
1822
1925
  if (newestFirst.length >= limit) {
1823
1926
  hasMore = true;
1824
1927
  break;
@@ -1831,9 +1934,9 @@ export class ConversationStore {
1831
1934
  }
1832
1935
 
1833
1936
  let totalCount = null;
1834
- if (opts.includeTotal !== false) {
1937
+ if (opts.includeTotal === true) {
1835
1938
  totalCount = 0;
1836
- for (const _entry of this.#iterateVisibleResponseEntries(sessionId)) totalCount += 1;
1939
+ for (const _entry of this.#iterateVisibleResponseEntries(sessionId, { scanStats: opts.scanStats })) totalCount += 1;
1837
1940
  }
1838
1941
 
1839
1942
  const oldestEntry = newestFirst[newestFirst.length - 1] || null;
@@ -1853,7 +1956,9 @@ export class ConversationStore {
1853
1956
  *
1854
1957
  * @param {string} sessionId
1855
1958
  * @param {number} anchorSeq
1856
- * @param {{ beforeTurns?: number, afterTurns?: number }} [opts]
1959
+ * @param {{ beforeTurns?: number, afterTurns?: number, entryStartSeq?: number,
1960
+ * entryEndSeq?: number, sourceMessageIds?: string[], maxRows?: number,
1961
+ * maxBytes?: number }} [opts]
1857
1962
  * @returns {{ messages: object[], oldestSeq: number|null, hasMoreBefore: boolean }}
1858
1963
  */
1859
1964
  loadVisibleWindowBySession(sessionId, anchorSeq, opts = {}) {
@@ -1884,13 +1989,47 @@ export class ConversationStore {
1884
1989
  messages.push(message);
1885
1990
  }
1886
1991
 
1992
+ const anchorIds = new Set(Array.isArray(opts.sourceMessageIds) ? opts.sourceMessageIds : []);
1993
+ const entryStartSeq = Number.isFinite(opts.entryStartSeq) ? opts.entryStartSeq : anchorSeq;
1994
+ const entryEndSeq = Number.isFinite(opts.entryEndSeq) ? opts.entryEndSeq : anchorSeq;
1995
+ for (const message of this.#iterateSessionRows(sessionId, {
1996
+ afterSeq: entryStartSeq - 1,
1997
+ beforeSeq: entryEndSeq + 1,
1998
+ desc: false,
1999
+ })) {
2000
+ if (!message?.id || seen.has(message.id)) continue;
2001
+ if (anchorIds.size > 0 && !anchorIds.has(message.id)) continue;
2002
+ seen.add(message.id);
2003
+ messages.push(message);
2004
+ }
2005
+
1887
2006
  messages.sort(compareMessagesBySeq);
1888
- const visibleMessages = projectVisibleSessionMessages(messages);
1889
- const oldestSeq = visibleMessages.length > 0 ? parseSeqFromId(visibleMessages[0].id) : null;
2007
+ const projected = projectVisibleSessionMessages(messages);
2008
+ const maxRows = Math.min(500, Math.max(10, Number.isFinite(opts.maxRows) ? Math.floor(opts.maxRows) : 200));
2009
+ const maxBytes = Math.min(2 * 1024 * 1024, Math.max(32 * 1024, Number.isFinite(opts.maxBytes) ? Math.floor(opts.maxBytes) : 512 * 1024));
2010
+ const projectedById = new Map(projected.map(message => [message?.id, message]));
2011
+ const anchorRows = Array.from(anchorIds, id => projectedById.get(id)).filter(Boolean);
2012
+ const selected = anchorRows.slice();
2013
+ const selectedIds = new Set(selected.map(message => message.id));
2014
+ let selectedBytes = selected.reduce((sum, message) => sum + Buffer.byteLength(JSON.stringify(message)), 0);
2015
+ const candidates = projected
2016
+ .filter(message => !selectedIds.has(message?.id))
2017
+ .sort((a, b) => Math.abs(parseSeqFromId(a.id) - anchorSeq) - Math.abs(parseSeqFromId(b.id) - anchorSeq));
2018
+ for (const message of candidates) {
2019
+ if (selected.length >= maxRows) break;
2020
+ const bytes = Buffer.byteLength(JSON.stringify(message));
2021
+ if (selected.length > 0 && selectedBytes + bytes > maxBytes) continue;
2022
+ selected.push(message);
2023
+ selectedBytes += bytes;
2024
+ }
2025
+ selected.sort(compareMessagesBySeq);
2026
+ const oldestSeq = selected.length > 0 ? parseSeqFromId(selected[0].id) : null;
1890
2027
  return {
1891
- messages: visibleMessages,
2028
+ messages: selected,
1892
2029
  oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
1893
- hasMoreBefore: beforeRaw.truncated,
2030
+ hasMoreBefore: beforeRaw.truncated || selected.length < projected.length,
2031
+ rowCount: selected.length,
2032
+ byteCount: selectedBytes,
1894
2033
  };
1895
2034
  }
1896
2035
 
@@ -2008,6 +2147,7 @@ export class ConversationStore {
2008
2147
  }
2009
2148
  }
2010
2149
  this.#nextSeq = null;
2150
+ this.#markDirty({ sessionId }, 'delete-session');
2011
2151
  return removed;
2012
2152
  }
2013
2153
 
@@ -2092,7 +2232,10 @@ export class ConversationStore {
2092
2232
  }
2093
2233
  }
2094
2234
  }
2095
- if (removed > 0) this.#nextSeq = null;
2235
+ if (removed > 0) {
2236
+ this.#nextSeq = null;
2237
+ this.#markAllDirty('compact-orphans');
2238
+ }
2096
2239
  return { scanned, removed, orphans, skipped: false };
2097
2240
  }
2098
2241
 
@@ -2168,6 +2311,7 @@ export class ConversationStore {
2168
2311
  }
2169
2312
  }
2170
2313
  }
2314
+ if (rewritten > 0) this.#markAllDirty('reassign-thread');
2171
2315
  return rewritten;
2172
2316
  }
2173
2317
 
@@ -2266,6 +2410,7 @@ export class ConversationStore {
2266
2410
  throw err;
2267
2411
  }
2268
2412
  }
2413
+ if (copied > 0) this.#markAllDirty('copy-thread');
2269
2414
  return copied;
2270
2415
  }
2271
2416
 
@@ -2523,6 +2668,7 @@ export class ConversationStore {
2523
2668
 
2524
2669
  #loadRecentSessionWindow(sessionId, turnsLimit, {
2525
2670
  beforeSeq = Infinity,
2671
+ afterSeq = -Infinity,
2526
2672
  roles = null,
2527
2673
  stripAssistantToolCalls = false,
2528
2674
  includeReflections = false,
@@ -2535,6 +2681,8 @@ export class ConversationStore {
2535
2681
  let boundaryCanonical = null;
2536
2682
  let truncated = false;
2537
2683
  let parsed = 0;
2684
+ let oldestScannedSeq = null;
2685
+ let scanCapped = false;
2538
2686
  const scanCap = recentSessionScanCap(turnsLimit);
2539
2687
 
2540
2688
  const project = (m) => {
@@ -2562,14 +2710,17 @@ export class ConversationStore {
2562
2710
  // and stop after the requested turn window is complete. Hidden/internal and
2563
2711
  // non-turn rows are not allowed to force an unbounded scan; a hard parse cap
2564
2712
  // conservatively marks the page truncated.
2565
- for (const m of this.#iterateSessionRows(sessionId, { beforeSeq, desc: true })) {
2713
+ for (const m of this.#iterateSessionRows(sessionId, { beforeSeq, afterSeq, desc: true })) {
2566
2714
  if (parsed >= scanCap) {
2567
2715
  truncated = true;
2716
+ scanCapped = true;
2568
2717
  break;
2569
2718
  }
2570
2719
  parsed += 1;
2571
2720
 
2572
2721
  if (!m || m.sessionId !== sessionId) continue;
2722
+ const scannedSeq = parseSeqFromId(m.id);
2723
+ if (Number.isFinite(scannedSeq)) oldestScannedSeq = scannedSeq;
2573
2724
 
2574
2725
  const boundaryComplete = turnsFromEnd >= turnsLimit;
2575
2726
  const hidden = isHiddenConversationRow(m)
@@ -2618,93 +2769,40 @@ export class ConversationStore {
2618
2769
  return {
2619
2770
  messages: turnsFromEnd > 0 ? sliceLastNTurns(kept, turnsLimit) : kept,
2620
2771
  truncated,
2772
+ nextBeforeSeq: scanCapped
2773
+ && pendingBoundaryRows.length === 0
2774
+ && Number.isFinite(oldestScannedSeq)
2775
+ ? oldestScannedSeq
2776
+ : null,
2621
2777
  };
2622
2778
  }
2623
2779
 
2624
2780
  *#iterateVisibleResponseEntries(sessionId, opts = {}) {
2625
2781
  const beforeSeq = Number.isFinite(opts.beforeSeq) ? opts.beforeSeq : Infinity;
2626
- const seen = new Set();
2627
- let current = null;
2628
-
2629
- const visibleRow = (message) => {
2630
- if (!message || message.sessionId !== sessionId || !isVisibleConversationRow(message)) return null;
2631
- if (message.role !== 'user' && message.role !== 'assistant') return null;
2632
- if (!message.id || seen.has(message.id)) return null;
2633
- seen.add(message.id);
2634
- const seq = parseSeqFromId(message.id);
2635
- if (!Number.isFinite(seq)) return null;
2636
- const text = this.#visibleSearchText(message.content);
2637
- // Session logs have used three sender shapes over time. Resolve them at
2638
- // the read boundary so existing data works without a disk migration.
2639
- const speakerVpId = message.speakerVpId || message.meta?.senderVpId
2640
- || (message.role === 'assistant' && message.from && message.from !== 'user'
2641
- ? message.from : null);
2642
- return {
2643
- message,
2644
- seq,
2645
- text,
2646
- speakerVpId,
2647
- groupKey: message.role === 'assistant'
2648
- ? `assistant:${message.turnId || message.id}:${speakerVpId || ''}`
2649
- : `user:${message.id}`,
2650
- };
2651
- };
2652
- const startEntry = (row) => ({
2653
- groupKey: row.groupKey,
2654
- role: row.message.role,
2655
- turnId: row.message.turnId || row.message.threadId || row.message.id,
2656
- speakerVpId: row.speakerVpId,
2657
- oldestSeq: row.seq,
2658
- anchor: row,
2659
- anchorHasText: !!row.text,
2660
- textParts: row.text ? [row.text] : [],
2782
+ const rows = this.#iterateSessionRows(sessionId, {
2783
+ beforeSeq,
2784
+ desc: true,
2785
+ scanStats: opts.scanStats,
2661
2786
  });
2662
- const mergeRow = (entry, row) => {
2663
- entry.oldestSeq = Math.min(entry.oldestSeq, row.seq);
2664
- if (row.text) entry.textParts.unshift(row.text);
2665
- if (!entry.anchorHasText && row.text) {
2666
- entry.anchor = row;
2667
- entry.anchorHasText = true;
2668
- }
2669
- };
2670
-
2671
- for (const message of this.#iterateSessionRows(sessionId, { beforeSeq, desc: true })) {
2672
- const row = visibleRow(message);
2673
- if (!row) continue;
2674
- if (current && current.groupKey === row.groupKey) {
2675
- mergeRow(current, row);
2676
- continue;
2677
- }
2678
- if (current) yield current;
2679
- current = startEntry(row);
2680
- }
2681
- if (current) yield current;
2787
+ yield* iterateCanonicalVisibleEntriesNewestFirst(rows, sessionId);
2682
2788
  }
2683
2789
 
2684
2790
  #projectVisibleResponseEntry(entry) {
2685
2791
  return {
2686
- messageId: entry.anchor.message.id,
2687
- ...(entry.anchor.message.clientMessageId ? { clientMessageId: entry.anchor.message.clientMessageId } : {}),
2792
+ entryId: entry.entryId,
2793
+ messageId: entry.anchorMessageId,
2794
+ ...(entry.clientMessageId ? { clientMessageId: entry.clientMessageId } : {}),
2688
2795
  turnId: entry.turnId,
2689
- seq: entry.anchor.seq,
2796
+ seq: entry.anchorSeq,
2797
+ entryStartSeq: entry.entryStartSeq,
2690
2798
  role: entry.role,
2691
2799
  speakerVpId: entry.speakerVpId,
2692
- timestamp: entry.anchor.message.ts || entry.anchor.message.time || null,
2693
- _beforeSeq: entry.oldestSeq,
2800
+ sourceMessageIds: entry.sourceMessageIds,
2801
+ timestamp: entry.timestamp,
2802
+ _beforeSeq: entry.entryStartSeq,
2694
2803
  };
2695
2804
  }
2696
2805
 
2697
- #visibleSearchText(content) {
2698
- if (typeof content === 'string') return content.replace(/\s+/g, ' ').trim();
2699
- if (!Array.isArray(content)) return '';
2700
- return content
2701
- .filter(part => part && typeof part === 'object' && part.type === 'text')
2702
- .map(part => typeof part.text === 'string' ? part.text : '')
2703
- .join(' ')
2704
- .replace(/\s+/g, ' ')
2705
- .trim();
2706
- }
2707
-
2708
2806
  #searchSnippet(text, matchIndex, needleLength) {
2709
2807
  const radius = 90;
2710
2808
  const start = Math.max(0, matchIndex - radius);
@@ -2728,7 +2826,12 @@ export class ConversationStore {
2728
2826
  for (const entry of this.#sessionFileEntries('all', sessionId, opts)) {
2729
2827
  try {
2730
2828
  const msg = this.readMessageFile(entry.path);
2731
- if (msg) yield msg;
2829
+ addScanMetric(opts.scanStats, 'legacyFiles');
2830
+ try { addScanMetric(opts.scanStats, 'bytes', statSync(entry.path).size); } catch {}
2831
+ if (msg) {
2832
+ addScanMetric(opts.scanStats, 'rows');
2833
+ yield msg;
2834
+ }
2732
2835
  } catch (err) {
2733
2836
  if (isPermissionError(err)) continue;
2734
2837
  throw err;
@@ -0,0 +1,143 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isVisibleConversationRow } from './internal-control.js';
3
+
4
+ export const VISIBLE_ENTRY_SCHEMA_VERSION = 1;
5
+
6
+ function messageSeq(message) {
7
+ if (Number.isFinite(message?.seq)) return Number(message.seq);
8
+ const match = typeof message?.id === 'string' ? message.id.match(/^m(\d+)$/) : null;
9
+ return match ? Number.parseInt(match[1], 10) : null;
10
+ }
11
+
12
+ function visibleText(content) {
13
+ if (typeof content === 'string') return content.replace(/\s+/g, ' ').trim();
14
+ if (!Array.isArray(content)) return '';
15
+ return content
16
+ .filter(part => part && typeof part === 'object' && part.type === 'text')
17
+ .map(part => typeof part.text === 'string' ? part.text : '')
18
+ .join(' ')
19
+ .replace(/\s+/g, ' ')
20
+ .trim();
21
+ }
22
+
23
+ function speakerVpId(message) {
24
+ return message?.speakerVpId || message?.meta?.senderVpId
25
+ || (message?.role === 'assistant' && message?.from && message.from !== 'user'
26
+ ? message.from : null);
27
+ }
28
+
29
+ function entryIdentity(message, speaker) {
30
+ if (message.role === 'user') return `user:${message.id}`;
31
+ const turnId = typeof message.turnId === 'string' && message.turnId ? message.turnId : null;
32
+ return turnId
33
+ ? `assistant:turn:${turnId}:speaker:${speaker || ''}`
34
+ : `assistant:message:${message.id}`;
35
+ }
36
+
37
+ function entryId(sessionId, identity) {
38
+ const digest = createHash('sha256')
39
+ .update(`${VISIBLE_ENTRY_SCHEMA_VERSION}\0${sessionId}\0${identity}`, 'utf8')
40
+ .digest('base64url')
41
+ .slice(0, 22);
42
+ return `entry_${digest}`;
43
+ }
44
+
45
+ function visibleRow(message, sessionId, seenMessageIds) {
46
+ if (!message || message.sessionId !== sessionId || !isVisibleConversationRow(message)) return null;
47
+ if (message.role !== 'user' && message.role !== 'assistant') return null;
48
+ if (!message.id || seenMessageIds.has(message.id)) return null;
49
+ const seq = messageSeq(message);
50
+ if (!Number.isFinite(seq)) return null;
51
+ seenMessageIds.add(message.id);
52
+ const speaker = speakerVpId(message);
53
+ return {
54
+ message,
55
+ seq,
56
+ text: visibleText(message.content),
57
+ speakerVpId: speaker,
58
+ identity: entryIdentity(message, speaker),
59
+ };
60
+ }
61
+
62
+ function projectEntry(sessionId, identity, rows) {
63
+ const ordered = rows.slice().sort((a, b) => a.seq - b.seq);
64
+ const latest = ordered[ordered.length - 1];
65
+ const latestText = ordered.slice().reverse().find(row => row.text) || latest;
66
+ const first = ordered[0];
67
+ const message = latestText.message;
68
+ return {
69
+ entryId: entryId(sessionId, identity),
70
+ role: first.message.role,
71
+ turnId: first.message.turnId || first.message.threadId || first.message.id,
72
+ speakerVpId: first.speakerVpId,
73
+ entryStartSeq: first.seq,
74
+ entryEndSeq: latest.seq,
75
+ anchorMessageId: message.id,
76
+ anchorSeq: latestText.seq,
77
+ sourceMessageIds: ordered.map(row => row.message.id),
78
+ textParts: ordered.filter(row => row.text).map(row => row.text),
79
+ timestamp: message.ts || message.time || null,
80
+ ...(message.clientMessageId ? { clientMessageId: message.clientMessageId } : {}),
81
+ };
82
+ }
83
+
84
+ function flushAssistantEntries(sessionId, entries, boundaryMessageId = null) {
85
+ const projected = Array.from(entries.entries(), ([identity, rows]) => {
86
+ const oldest = rows.reduce((candidate, row) => (
87
+ !candidate || row.seq < candidate.seq ? row : candidate
88
+ ), null);
89
+ const boundaryIdentity = boundaryMessageId || `start:${oldest?.message?.id || 'unknown'}`;
90
+ return projectEntry(sessionId, `${identity}:after:${boundaryIdentity}`, rows);
91
+ });
92
+ projected.sort((a, b) => b.entryEndSeq - a.entryEndSeq || b.entryStartSeq - a.entryStartSeq);
93
+ entries.clear();
94
+ return projected;
95
+ }
96
+
97
+ /**
98
+ * Project visible persisted rows into canonical user/assistant entries.
99
+ *
100
+ * Input must be newest-first. A visible user row closes the assistant response
101
+ * bucket above it. That boundary lets interleaved VP rows (A-B-A) coalesce by
102
+ * explicit turnId + speaker without scanning the whole transcript. Legacy
103
+ * assistant rows without a turnId remain separate because guessing from
104
+ * adjacency would make their identity change after prepend or compaction.
105
+ */
106
+ export function* iterateCanonicalVisibleEntriesNewestFirst(messages, sessionId) {
107
+ const seenMessageIds = new Set();
108
+ const assistantEntries = new Map();
109
+
110
+ for (const message of messages) {
111
+ const row = visibleRow(message, sessionId, seenMessageIds);
112
+ if (!row) continue;
113
+ if (row.message.role === 'user') {
114
+ yield* flushAssistantEntries(sessionId, assistantEntries, row.message.id);
115
+ yield projectEntry(sessionId, row.identity, [row]);
116
+ continue;
117
+ }
118
+ const rows = assistantEntries.get(row.identity) || [];
119
+ rows.push(row);
120
+ assistantEntries.set(row.identity, rows);
121
+ }
122
+
123
+ yield* flushAssistantEntries(sessionId, assistantEntries);
124
+ }
125
+
126
+ export function normalizeLiteralSearch(value) {
127
+ return typeof value === 'string' ? value.toLocaleLowerCase() : '';
128
+ }
129
+
130
+ export function findLiteralSearch(text, query) {
131
+ const needle = normalizeLiteralSearch(query);
132
+ if (!needle) return 0;
133
+ return normalizeLiteralSearch(text).indexOf(needle);
134
+ }
135
+
136
+ export const __visibleEntryForTest = {
137
+ entryIdentity,
138
+ entryId,
139
+ messageSeq,
140
+ projectEntry,
141
+ speakerVpId,
142
+ visibleText,
143
+ };