@yeaft/webchat-agent 1.0.365 → 1.0.366

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.
Binary file
@@ -16,6 +16,6 @@
16
16
  </head>
17
17
  <body>
18
18
  <div id="app"></div>
19
- <script type="module" src="app.bundle.js?v=5304df26"></script>
19
+ <script type="module" src="app.bundle.js?v=86aebbdf"></script>
20
20
  </body>
21
21
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.365",
3
+ "version": "1.0.366",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync } from 'fs';
21
+ import { createHash, randomUUID } from 'node:crypto';
21
22
  import { join, basename } from 'path';
22
23
  import { isPermissionError } from '../init.js';
23
24
  import { writeAtomic } from '../storage/atomic.js';
@@ -71,6 +72,7 @@ const DELTA_TOOL_PAIR_EXTENSION_CAP = 500;
71
72
 
72
73
 
73
74
  const SEGMENT_INDEX_FILE = 'index.json';
75
+ const SEGMENT_LINEAGE_FILE = 'lineage.json';
74
76
  const SEGMENT_DIR = 'segments';
75
77
  const SEGMENT_TARGET_BYTES = 1024 * 1024;
76
78
  const SEGMENT_FIRST_NAME = '000001.jsonl';
@@ -98,7 +100,9 @@ function projectAssistantToolsForVisibleHistory(message) {
98
100
 
99
101
  function emptySegmentIndex() {
100
102
  return {
101
- version: 1,
103
+ version: 2,
104
+ streamId: null,
105
+ revision: 0,
102
106
  nextSeq: 1,
103
107
  totalMessages: 0,
104
108
  lastMessageId: null,
@@ -700,7 +704,9 @@ class SegmentStore {
700
704
  this.rootDir = rootDir;
701
705
  this.segmentDir = join(rootDir, SEGMENT_DIR);
702
706
  this.indexPath = join(rootDir, SEGMENT_INDEX_FILE);
707
+ this.lineagePath = join(rootDir, SEGMENT_LINEAGE_FILE);
703
708
  this.index = null;
709
+ this.lineage = null;
704
710
  }
705
711
 
706
712
  ensure() {
@@ -727,8 +733,18 @@ class SegmentStore {
727
733
  }
728
734
  }
729
735
  const indexWasStale = idx && !this.#indexMatchesDisk(idx);
730
- this.index = this.#normalizeIndex(!idx || indexWasStale ? this.#rebuildIndex() : idx);
731
- if ((!existsSync(this.indexPath) || indexWasStale) && this.hasData()) this.saveIndex();
736
+ const normalizedSource = !idx || indexWasStale ? this.#rebuildIndex() : idx;
737
+ this.index = this.#normalizeIndex(normalizedSource);
738
+ this.lineage = this.#resolveLineage(this.index, {
739
+ verifyAnchor: !idx || !normalizedSource?.streamId,
740
+ });
741
+ this.index.streamId = this.lineage.streamId;
742
+ this.index.revision = this.lineage.revision;
743
+ const needsMetadataUpgrade = !normalizedSource?.streamId
744
+ || normalizedSource.streamId !== this.lineage.streamId
745
+ || !Number.isFinite(Number(normalizedSource?.revision))
746
+ || Number(normalizedSource.revision) !== this.lineage.revision;
747
+ if ((!existsSync(this.indexPath) || indexWasStale || needsMetadataUpgrade) && this.hasData()) this.saveIndex();
732
748
  return this.index;
733
749
  }
734
750
 
@@ -737,9 +753,20 @@ class SegmentStore {
737
753
  writeFileSync(this.indexPath, `${JSON.stringify(this.index || emptySegmentIndex(), null, 2)}\n`, { encoding: 'utf8', mode: 0o644 });
738
754
  }
739
755
 
756
+ metadata() {
757
+ const idx = this.loadIndex();
758
+ return {
759
+ streamId: idx.streamId,
760
+ revision: Number(idx.revision) || 0,
761
+ headSeq: Math.max(0, (Number(idx.nextSeq) || 1) - 1),
762
+ };
763
+ }
764
+
740
765
  append(msg) {
741
766
  this.ensure();
742
767
  const idx = this.loadIndex();
768
+ const establishesAnchor = (Number(idx.totalMessages) || 0) === 0
769
+ && (!Array.isArray(idx.segments) || idx.segments.length === 0);
743
770
  let segment = idx.segments[idx.segments.length - 1] || null;
744
771
  let active = segment?.file || idx.activeSegment || SEGMENT_FIRST_NAME;
745
772
  let activePath = join(this.segmentDir, active);
@@ -765,12 +792,24 @@ class SegmentStore {
765
792
  idx.totalMessages = (idx.totalMessages || 0) + 1;
766
793
  idx.lastMessageId = msg.id || null;
767
794
  idx.nextSeq = Math.max(Number(idx.nextSeq) || 1, seq + 1);
795
+ const invalidatesEarlierRows = msg._reflection
796
+ || (Array.isArray(msg.foldedMessageIds) && msg.foldedMessageIds.length > 0);
797
+ // Ordinary appends are recoverable through afterSeq. Only an append that
798
+ // invalidates older visible rows (fold reflection/tombstones) advances the
799
+ // mutation revision and forces the browser to rebuild its snapshot.
800
+ if (invalidatesEarlierRows) {
801
+ idx.revision = (Number(idx.revision) || 0) + 1;
802
+ }
768
803
  if (msg._reflection && Array.isArray(msg.foldedMessageIds)) {
769
804
  idx.foldedMessageIds = Array.from(new Set([
770
805
  ...(Array.isArray(idx.foldedMessageIds) ? idx.foldedMessageIds : []),
771
806
  ...msg.foldedMessageIds.filter(id => typeof id === 'string' && id),
772
807
  ]));
773
808
  }
809
+ this.#persistCurrentLineage({
810
+ revision: idx.revision,
811
+ anchor: establishesAnchor ? this.#lineageAnchorForLine(line) : undefined,
812
+ });
774
813
  this.saveIndex();
775
814
  }
776
815
 
@@ -846,10 +885,16 @@ class SegmentStore {
846
885
  if (!next || typeof next !== 'object') return null;
847
886
  const updated = { ...next, id: rows[rowIndex].id };
848
887
  rows[rowIndex] = updated;
888
+ const updatesAnchor = rowIndex === 0 && segment.file === idx.segments[0]?.file;
849
889
 
850
890
  const body = rows.map(row => JSON.stringify(row)).join('\n') + (rows.length > 0 ? '\n' : '');
851
891
  writeAtomic(path, body);
852
892
  segment.bytes = Buffer.byteLength(body);
893
+ idx.revision = (Number(idx.revision) || 0) + 1;
894
+ this.#persistCurrentLineage({
895
+ revision: idx.revision,
896
+ anchor: updatesAnchor ? this.#lineageAnchorForLine(JSON.stringify(updated)) : undefined,
897
+ });
853
898
  this.saveIndex();
854
899
  return updated;
855
900
  }
@@ -865,7 +910,10 @@ class SegmentStore {
865
910
  }
866
911
  }
867
912
  if (existsSync(this.indexPath)) unlinkSync(this.indexPath);
868
- this.index = emptySegmentIndex();
913
+ this.lineage = { streamId: randomUUID(), revision: 0, anchor: null };
914
+ this.#writeLineage(this.lineage);
915
+ this.index = { ...emptySegmentIndex(), streamId: this.lineage.streamId };
916
+ this.saveIndex();
869
917
  }
870
918
 
871
919
  #indexMatchesDisk(idx) {
@@ -881,8 +929,128 @@ class SegmentStore {
881
929
  });
882
930
  }
883
931
 
932
+ #readLineage() {
933
+ if (!existsSync(this.lineagePath)) return null;
934
+ try {
935
+ const parsed = JSON.parse(readFileSync(this.lineagePath, 'utf8') || '{}');
936
+ if (!parsed || typeof parsed !== 'object'
937
+ || typeof parsed.streamId !== 'string' || !parsed.streamId
938
+ || !Number.isFinite(Number(parsed.revision))
939
+ || (parsed.anchor !== null && typeof parsed.anchor !== 'string')) return null;
940
+ return {
941
+ streamId: parsed.streamId,
942
+ revision: Math.max(0, Number(parsed.revision)),
943
+ anchor: parsed.anchor,
944
+ };
945
+ } catch {
946
+ return null;
947
+ }
948
+ }
949
+
950
+ #writeLineage(lineage) {
951
+ this.ensure();
952
+ const normalized = {
953
+ version: 1,
954
+ streamId: lineage.streamId,
955
+ revision: Math.max(0, Number(lineage.revision) || 0),
956
+ anchor: typeof lineage.anchor === 'string' ? lineage.anchor : null,
957
+ };
958
+ writeAtomic(this.lineagePath, `${JSON.stringify(normalized, null, 2)}\n`);
959
+ this.lineage = normalized;
960
+ return normalized;
961
+ }
962
+
963
+ #currentLineageAnchor() {
964
+ if (!existsSync(this.segmentDir)) return null;
965
+ const files = readdirSync(this.segmentDir).filter(file => file.endsWith('.jsonl')).sort();
966
+ for (const file of files) {
967
+ const path = join(this.segmentDir, file);
968
+ if (!existsSync(path)) continue;
969
+ let raw;
970
+ try {
971
+ raw = readFileSync(path, 'utf8');
972
+ } catch (error) {
973
+ if (isPermissionError(error)) return null;
974
+ throw error;
975
+ }
976
+ for (const line of raw.split('\n')) {
977
+ if (!parseJsonLine(line)) continue;
978
+ return this.#lineageAnchorForLine(line);
979
+ }
980
+ }
981
+ return null;
982
+ }
983
+
984
+ #lineageAnchorForLine(line) {
985
+ return createHash('sha256').update(String(line || '').trim()).digest('hex');
986
+ }
987
+
988
+ #legacyLineageId(anchor) {
989
+ return `legacy-${createHash('sha256')
990
+ .update(this.rootDir)
991
+ .update('\0')
992
+ .update(anchor || '<empty>')
993
+ .digest('hex')}`;
994
+ }
995
+
996
+ #resolveLineage(idx, { verifyAnchor = false } = {}) {
997
+ const persisted = this.#readLineage();
998
+ const anchor = !persisted || verifyAnchor ? this.#currentLineageAnchor() : persisted.anchor;
999
+ if (!persisted) {
1000
+ const initialStreamId = anchor ? this.#legacyLineageId(anchor) : randomUUID();
1001
+ const indexStreamId = typeof idx?.streamId === 'string' && idx.streamId ? idx.streamId : null;
1002
+ // Missing sidecar is a rolling-upgrade boundary. Recompute from the
1003
+ // immutable first row for existing data. A truly empty store starts a
1004
+ // new random lifetime so delete + same-id recreation cannot collide.
1005
+ const streamId = verifyAnchor ? initialStreamId : (indexStreamId || initialStreamId);
1006
+ return this.#writeLineage({
1007
+ streamId,
1008
+ revision: Number.isFinite(Number(idx?.revision)) ? Number(idx.revision) : 0,
1009
+ anchor,
1010
+ });
1011
+ }
1012
+ if (persisted.anchor !== anchor) {
1013
+ // A reader/writer that predates lineage.json can still clear, recreate,
1014
+ // or rewrite the first durable row. The anchor detects that mutation
1015
+ // without relying on metadata fields the old index writer discards.
1016
+ return this.#writeLineage({ streamId: randomUUID(), revision: 0, anchor });
1017
+ }
1018
+ const indexRevision = (!idx?.streamId || idx.streamId === persisted.streamId)
1019
+ && Number.isFinite(Number(idx?.revision))
1020
+ ? Math.max(0, Number(idx.revision))
1021
+ : 0;
1022
+ const revision = Math.max(persisted.revision, indexRevision);
1023
+ if (revision !== persisted.revision) return this.#writeLineage({ ...persisted, revision });
1024
+ return persisted;
1025
+ }
1026
+
1027
+ #persistCurrentLineage({ revision = null, anchor = undefined } = {}) {
1028
+ const current = this.lineage || this.#readLineage() || {
1029
+ streamId: this.index?.streamId || this.#legacyLineageId(this.#currentLineageAnchor()),
1030
+ revision: Number(this.index?.revision) || 0,
1031
+ anchor: this.#currentLineageAnchor(),
1032
+ };
1033
+ const nextRevision = Number.isFinite(Number(revision)) ? Number(revision) : current.revision;
1034
+ const nextAnchor = typeof anchor === 'string' && anchor ? anchor : current.anchor;
1035
+ const needsWrite = !this.lineage
1036
+ || current.revision !== nextRevision
1037
+ || current.anchor !== nextAnchor;
1038
+ const next = needsWrite
1039
+ ? this.#writeLineage({ ...current, revision: nextRevision, anchor: nextAnchor })
1040
+ : current;
1041
+ this.lineage = next;
1042
+ if (this.index) {
1043
+ this.index.streamId = next.streamId;
1044
+ this.index.revision = next.revision;
1045
+ }
1046
+ return next;
1047
+ }
1048
+
884
1049
  #normalizeIndex(idx) {
885
1050
  const out = { ...emptySegmentIndex(), ...(idx || {}) };
1051
+ out.version = 2;
1052
+ out.streamId = typeof out.streamId === 'string' && out.streamId ? out.streamId : null;
1053
+ out.revision = Number.isFinite(Number(out.revision)) ? Math.max(0, Number(out.revision)) : 0;
886
1054
  out.segments = Array.isArray(out.segments) ? out.segments.filter(s => s && s.file) : [];
887
1055
  out.segments.sort((a, b) => (Number(a.firstSeq) || 0) - (Number(b.firstSeq) || 0));
888
1056
  const maxSeq = out.segments.reduce((max, seg) => Math.max(max, Number(seg.lastSeq) || 0), 0);
@@ -1460,6 +1628,18 @@ export class ConversationStore {
1460
1628
 
1461
1629
  // ─── Read API ───────────────────────────────────────────
1462
1630
 
1631
+ /**
1632
+ * Return the durable identity of one Session transcript. `streamId`
1633
+ * changes on clear/recreate; `revision` changes on append/update/fold.
1634
+ * Browser caches use both values to detect non-append mutations before
1635
+ * trusting an `afterSeq` delta cursor.
1636
+ */
1637
+ getSessionHistoryMetadata(sessionId) {
1638
+ if (!sessionId) return null;
1639
+ const store = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sessionId));
1640
+ return store.metadata();
1641
+ }
1642
+
1463
1643
  /**
1464
1644
  * Load recent hot messages, sliced to the last `turnsLimit` TURNS and
1465
1645
  * sorted chronologically.
@@ -1740,17 +1920,21 @@ export class ConversationStore {
1740
1920
  * without downgrading the client to a cursor-less loaded state. Hidden rows
1741
1921
  * advance the cursor only at pair-safe boundaries; a cursor must never cross
1742
1922
  * an assistant tool call before all of that call's result rows are included.
1923
+ * Both row and byte budgets cut only at those safe boundaries. `hasMoreAfter`
1924
+ * tells the Web client to keep draining long offline gaps without waiting for
1925
+ * another reconnect or Session activation.
1743
1926
  *
1744
1927
  * @param {string} sessionId
1745
1928
  * @param {number|null} afterSeq — exclusive lower bound
1746
- * @param {{ limit?: number }} [opts]
1747
- * @returns {{ messages: object[], latestSeq: number|null }}
1929
+ * @param {{ limit?: number, maxBytes?: number }} [opts]
1930
+ * @returns {{ messages: object[], latestSeq: number|null, hasMoreAfter: boolean }}
1748
1931
  */
1749
1932
  loadAfterSeqByGroup(sessionId, afterSeq, opts = {}) {
1750
- if (!sessionId) return { messages: [], latestSeq: null };
1933
+ if (!sessionId) return { messages: [], latestSeq: null, hasMoreAfter: false };
1751
1934
  const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? opts.limit : 500;
1935
+ const maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes > 0 ? opts.maxBytes : Infinity;
1752
1936
  const cutoff = Number.isFinite(afterSeq) && afterSeq >= 0 ? afterSeq : null;
1753
- if (cutoff === null) return { messages: [], latestSeq: null };
1937
+ if (cutoff === null) return { messages: [], latestSeq: null, hasMoreAfter: false };
1754
1938
  const after = [];
1755
1939
  const pendingToolResultIds = new Set();
1756
1940
  const completedBeforeCursor = new Set();
@@ -1788,8 +1972,14 @@ export class ConversationStore {
1788
1972
  ? Math.max(0, earliestBoundarySeq - 1)
1789
1973
  : cutoff;
1790
1974
  let visibleRows = after.length;
1975
+ let visibleBytes = after.reduce((sum, message) => sum + Buffer.byteLength(JSON.stringify(message)), 0);
1976
+ let stoppedAtBudget = false;
1791
1977
  let extensionRows = 0;
1792
- for (const m of this.#iterateSessionRows(sessionId, { afterSeq: cutoff, desc: false })) {
1978
+ const deltaRows = this.#iterateSessionRows(sessionId, { afterSeq: cutoff, desc: false });
1979
+ while (true) {
1980
+ const step = deltaRows.next();
1981
+ if (step.done) break;
1982
+ const m = step.value;
1793
1983
  if (!m || m.sessionId !== sessionId) continue;
1794
1984
  const seq = parseSeqFromId(m.id);
1795
1985
  const hidden = !isVisibleConversationRow(m);
@@ -1799,6 +1989,7 @@ export class ConversationStore {
1799
1989
  // messages between an assistant call and that call's result.
1800
1990
  after.push(m);
1801
1991
  visibleRows += 1;
1992
+ visibleBytes += Buffer.byteLength(JSON.stringify(m));
1802
1993
  if (m.role === 'assistant' && Array.isArray(m.toolCalls)) {
1803
1994
  for (const toolCall of m.toolCalls) {
1804
1995
  if (typeof toolCall?.id === 'string' && toolCall.id) pendingToolResultIds.add(toolCall.id);
@@ -1807,11 +1998,19 @@ export class ConversationStore {
1807
1998
  pendingToolResultIds.delete(m.toolCallId);
1808
1999
  }
1809
2000
  }
1810
- if (pendingToolResultIds.size === 0 && Number.isFinite(seq)) safeCursorSeq = seq;
1811
- if (visibleRows < limit) continue;
1812
- if (pendingToolResultIds.size === 0) break;
2001
+ if (pendingToolResultIds.size === 0 && Number.isFinite(seq)) {
2002
+ safeCursorSeq = seq;
2003
+ if (visibleRows >= limit || visibleBytes >= maxBytes) {
2004
+ stoppedAtBudget = true;
2005
+ break;
2006
+ }
2007
+ }
2008
+ if (visibleRows < limit && visibleBytes < maxBytes) continue;
1813
2009
  extensionRows += 1;
1814
- if (extensionRows >= DELTA_TOOL_PAIR_EXTENSION_CAP) break;
2010
+ if (extensionRows >= DELTA_TOOL_PAIR_EXTENSION_CAP) {
2011
+ stoppedAtBudget = true;
2012
+ break;
2013
+ }
1815
2014
  }
1816
2015
  // If the extension cap stopped inside a malformed arc, return only the
1817
2016
  // prefix covered by the safe cursor. Returning later rows with an earlier
@@ -1823,10 +2022,20 @@ export class ConversationStore {
1823
2022
  return Number.isFinite(seq) && seq <= safeCursorSeq;
1824
2023
  });
1825
2024
  const sliced = pairSanitize(pairSafeRows);
2025
+ let hasMoreAfter = false;
2026
+ if (stoppedAtBudget) {
2027
+ hasMoreAfter = !deltaRows.next().done;
2028
+ } else if (typeof deltaRows.return === 'function') {
2029
+ deltaRows.return();
2030
+ }
1826
2031
  // Never advance past a row the sanitizer had to drop. A malformed or
1827
2032
  // over-cap tool arc must be retried from its assistant call rather than
1828
2033
  // turning the following result into a permanent orphan on the next page.
1829
- return { messages: projectVisibleSessionMessages(sliced), latestSeq: safeCursorSeq };
2034
+ return {
2035
+ messages: projectVisibleSessionMessages(sliced),
2036
+ latestSeq: safeCursorSeq,
2037
+ hasMoreAfter,
2038
+ };
1830
2039
  }
1831
2040
 
1832
2041
  /**
@@ -1421,7 +1421,7 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1421
1421
  }));
1422
1422
  }
1423
1423
 
1424
- function emitHistoryChunk({ sessionId, messages, mode = 'older', oldestSeq = null, nextBeforeSeq = null, hasMore = false, latestSeq = null, afterSeq = null, turns = null, pageKind = null, gapStopAtSeq = null, cacheEpoch = null, requestId = null, requestClientId = null, perfTraceId = null }) {
1424
+ function emitHistoryChunk({ sessionId, messages, mode = 'older', oldestSeq = null, nextBeforeSeq = null, hasMore = false, latestSeq = null, afterSeq = null, hasMoreAfter = false, streamId = null, revision = null, turns = null, pageKind = null, gapStopAtSeq = null, cacheEpoch = null, requestId = null, requestClientId = null, perfTraceId = null }) {
1425
1425
  const projectedMessages = projectVisibleHistoryChunkMessages(messages);
1426
1426
  // Empty deltas still carry the authoritative safe cursor and clear the
1427
1427
  // browser's syncingAfterSeq fence. Dropping this envelope leaves Session
@@ -1440,6 +1440,9 @@ function emitHistoryChunk({ sessionId, messages, mode = 'older', oldestSeq = nul
1440
1440
  hasMore: !!hasMore,
1441
1441
  latestSeq,
1442
1442
  afterSeq,
1443
+ hasMoreAfter: !!hasMoreAfter,
1444
+ streamId,
1445
+ revision,
1443
1446
  turns,
1444
1447
  ...(pageKind ? { pageKind } : {}),
1445
1448
  ...(Number.isFinite(gapStopAtSeq) ? { gapStopAtSeq } : {}),
@@ -1499,6 +1502,9 @@ function emitLegacyHistoryOutputFrames(replayEntries) {
1499
1502
  }
1500
1503
 
1501
1504
  function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, mode = 'recent', requestId = null, requestClientId = null, perfTraceId = null }) {
1505
+ const historyMetadata = sessionId && typeof store.getSessionHistoryMetadata === 'function'
1506
+ ? store.getSessionHistoryMetadata(sessionId)
1507
+ : null;
1502
1508
  const visiblePage = sessionId
1503
1509
  ? loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq)
1504
1510
  : { messages: limit > 0 ? (store.loadRecent?.(limit) || []) : [], oldestSeq: null, hasMore: false };
@@ -1520,6 +1526,8 @@ function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, m
1520
1526
  nextBeforeSeq: visiblePage.nextBeforeSeq,
1521
1527
  hasMore: visiblePage.hasMore,
1522
1528
  latestSeq: Number.isFinite(latestSeq) ? latestSeq : null,
1529
+ streamId: historyMetadata?.streamId || null,
1530
+ revision: historyMetadata?.revision ?? null,
1523
1531
  turns: limit,
1524
1532
  requestId,
1525
1533
  requestClientId,
@@ -6762,7 +6770,20 @@ export async function handleYeaftLoadHistory(msg) {
6762
6770
  // and wants only the messages that arrived after that cursor. Returns
6763
6771
  // mode:'delta' so the frontend can append+dedupe instead of replacing
6764
6772
  // the pane.
6765
- const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
6773
+ const deltaLimit = Number.isFinite(msg?.maxRows)
6774
+ ? Math.min(500, Math.max(1, Math.floor(msg.maxRows)))
6775
+ : 100;
6776
+ const deltaMaxBytes = Number.isFinite(msg?.maxBytes)
6777
+ ? Math.min(2 * 1024 * 1024, Math.max(32 * 1024, Math.floor(msg.maxBytes)))
6778
+ : 512 * 1024;
6779
+ const historyMetadata = typeof session.conversationStore.getSessionHistoryMetadata === 'function'
6780
+ ? session.conversationStore.getSessionHistoryMetadata(sessionId)
6781
+ : null;
6782
+ const cacheIdentityMatches = !msg?.streamId || (
6783
+ msg.streamId === historyMetadata?.streamId
6784
+ && Number(msg.revision) === Number(historyMetadata?.revision)
6785
+ );
6786
+ const afterSeqRaw = cacheIdentityMatches && msg && Number.isFinite(msg.afterSeq) ? msg.afterSeq : null;
6766
6787
  const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
6767
6788
  let afterSeq = afterSeqRaw;
6768
6789
  if (afterSeq === null && afterMessageId && typeof session.conversationStore.getMessageSeqById === 'function') {
@@ -6770,7 +6791,10 @@ export async function handleYeaftLoadHistory(msg) {
6770
6791
  }
6771
6792
  if (sessionId && afterSeq !== null && typeof session.conversationStore.loadAfterSeqByGroup === 'function') {
6772
6793
  const loadStart = perfNowMs();
6773
- const delta = session.conversationStore.loadAfterSeqByGroup(sessionId, afterSeq);
6794
+ const delta = session.conversationStore.loadAfterSeqByGroup(sessionId, afterSeq, {
6795
+ limit: deltaLimit,
6796
+ maxBytes: deltaMaxBytes,
6797
+ });
6774
6798
  traceDuration('history.store_load_delta', loadStart, { detail: { count: delta.messages?.length || 0, afterSeq } });
6775
6799
  const emitStart = perfNowMs();
6776
6800
  const projectedMessages = emitHistoryChunk({
@@ -6779,6 +6803,9 @@ export async function handleYeaftLoadHistory(msg) {
6779
6803
  mode: 'delta',
6780
6804
  latestSeq: delta.latestSeq,
6781
6805
  afterSeq,
6806
+ hasMoreAfter: delta.hasMoreAfter,
6807
+ streamId: historyMetadata?.streamId || null,
6808
+ revision: historyMetadata?.revision ?? null,
6782
6809
  requestId,
6783
6810
  requestClientId,
6784
6811
  perfTraceId,
@@ -6792,6 +6819,9 @@ export async function handleYeaftLoadHistory(msg) {
6792
6819
  requestId,
6793
6820
  latestSeq: delta.latestSeq,
6794
6821
  afterSeq,
6822
+ hasMoreAfter: !!delta.hasMoreAfter,
6823
+ streamId: historyMetadata?.streamId || null,
6824
+ revision: historyMetadata?.revision ?? null,
6795
6825
  }, { sessionId, requestId, requestClientId, perfTraceId });
6796
6826
  return;
6797
6827
  }
@@ -6832,6 +6862,8 @@ export async function handleYeaftLoadHistory(msg) {
6832
6862
  nextBeforeSeq: visiblePage.nextBeforeSeq,
6833
6863
  hasMore: visiblePage.hasMore,
6834
6864
  latestSeq,
6865
+ streamId: historyMetadata?.streamId || null,
6866
+ revision: historyMetadata?.revision ?? null,
6835
6867
  turns: limit,
6836
6868
  requestId,
6837
6869
  requestClientId,
@@ -6874,12 +6906,19 @@ export async function handleYeaftLoadHistory(msg) {
6874
6906
  oldestSeq,
6875
6907
  nextBeforeSeq: visiblePage.nextBeforeSeq,
6876
6908
  latestSeq,
6909
+ streamId: historyMetadata?.streamId || null,
6910
+ revision: historyMetadata?.revision ?? null,
6877
6911
  }, { sessionId, requestId, requestClientId, perfTraceId });
6878
6912
  };
6879
6913
 
6880
6914
  if (!session) {
6881
6915
  const yeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
6882
- const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
6916
+ const deltaLimit = Number.isFinite(msg?.maxRows)
6917
+ ? Math.min(500, Math.max(1, Math.floor(msg.maxRows)))
6918
+ : 100;
6919
+ const deltaMaxBytes = Number.isFinite(msg?.maxBytes)
6920
+ ? Math.min(2 * 1024 * 1024, Math.max(32 * 1024, Math.floor(msg.maxBytes)))
6921
+ : 512 * 1024;
6883
6922
  const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
6884
6923
  const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
6885
6924
  ensureYeaftConversationId();
@@ -6902,6 +6941,14 @@ export async function handleYeaftLoadHistory(msg) {
6902
6941
  // window immediately, then finish loadSession below for actual turns.
6903
6942
  const coldStoreStart = perfNowMs();
6904
6943
  const coldStore = new ConversationStore(historyYeaftDir);
6944
+ const historyMetadata = sessionId && typeof coldStore.getSessionHistoryMetadata === 'function'
6945
+ ? coldStore.getSessionHistoryMetadata(sessionId)
6946
+ : null;
6947
+ const cacheIdentityMatches = !msg?.streamId || (
6948
+ msg.streamId === historyMetadata?.streamId
6949
+ && Number(msg.revision) === Number(historyMetadata?.revision)
6950
+ );
6951
+ const afterSeqRaw = cacheIdentityMatches && msg && Number.isFinite(msg.afterSeq) ? msg.afterSeq : null;
6905
6952
  traceDuration('history.cold_store_open', coldStoreStart);
6906
6953
  if (sessionId && (afterSeqRaw !== null || afterMessageId)) {
6907
6954
  let afterSeq = afterSeqRaw;
@@ -6910,8 +6957,11 @@ export async function handleYeaftLoadHistory(msg) {
6910
6957
  }
6911
6958
  const loadStart = perfNowMs();
6912
6959
  const delta = afterSeq !== null && typeof coldStore.loadAfterSeqByGroup === 'function'
6913
- ? coldStore.loadAfterSeqByGroup(sessionId, afterSeq)
6914
- : { messages: [], latestSeq: null };
6960
+ ? coldStore.loadAfterSeqByGroup(sessionId, afterSeq, {
6961
+ limit: deltaLimit,
6962
+ maxBytes: deltaMaxBytes,
6963
+ })
6964
+ : { messages: [], latestSeq: null, hasMoreAfter: false };
6915
6965
  traceDuration('history.store_load_delta', loadStart, { detail: { count: delta.messages?.length || 0, afterSeq, cold: true } });
6916
6966
  const emitStart = perfNowMs();
6917
6967
  const projectedMessages = emitHistoryChunk({
@@ -6920,12 +6970,26 @@ export async function handleYeaftLoadHistory(msg) {
6920
6970
  mode: 'delta',
6921
6971
  latestSeq: delta.latestSeq,
6922
6972
  afterSeq,
6973
+ hasMoreAfter: delta.hasMoreAfter,
6974
+ streamId: historyMetadata?.streamId || null,
6975
+ revision: historyMetadata?.revision ?? null,
6923
6976
  requestId,
6924
6977
  requestClientId,
6925
6978
  perfTraceId,
6926
6979
  });
6927
6980
  traceDuration('history.emit_chunk', emitStart, { detail: { mode: 'delta', count: projectedMessages.length, cold: true } });
6928
- sendSessionEvent({ type: 'history_loaded', mode: 'delta', count: projectedMessages.length, sessionId, requestId, latestSeq: delta.latestSeq, afterSeq }, { sessionId, requestId, requestClientId, perfTraceId });
6981
+ sendSessionEvent({
6982
+ type: 'history_loaded',
6983
+ mode: 'delta',
6984
+ count: projectedMessages.length,
6985
+ sessionId,
6986
+ requestId,
6987
+ latestSeq: delta.latestSeq,
6988
+ afterSeq,
6989
+ hasMoreAfter: !!delta.hasMoreAfter,
6990
+ streamId: historyMetadata?.streamId || null,
6991
+ revision: historyMetadata?.revision ?? null,
6992
+ }, { sessionId, requestId, requestClientId, perfTraceId });
6929
6993
  } else if (!metadataOnly) {
6930
6994
  const replayStart = perfNowMs();
6931
6995
  emitVisibleHistoryReplay({ store: coldStore, sessionId, limit, mode: 'recent', requestId, requestClientId, perfTraceId });