@yeaft/webchat-agent 1.0.73 → 1.0.76

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.73",
3
+ "version": "1.0.76",
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",
@@ -27,7 +27,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rename
27
27
  import { join, basename } from 'path';
28
28
  import { isPermissionError } from '../init.js';
29
29
  import { pairSanitize } from '../pair-sanitize.js';
30
- import { countTurns, indexOfNthTurnFromEnd, sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
30
+ import { sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
31
31
  import { isHiddenConversationRow } from './internal-control.js';
32
32
 
33
33
  /**
@@ -57,6 +57,13 @@ import { isHiddenConversationRow } from './internal-control.js';
57
57
  */
58
58
  let DEFAULT_RECENT_TURNS = 20;
59
59
 
60
+ // Circuit breaker for newest-to-oldest session scans. This bounds event-loop
61
+ // starvation when the newest transcript tail is dense with hidden/internal or
62
+ // otherwise non-turn rows and no user boundary is found quickly.
63
+ const RECENT_SESSION_SCAN_BASE_CAP = 64;
64
+ const RECENT_SESSION_SCAN_PER_TURN_CAP = 4;
65
+ const RECENT_SESSION_SCAN_MAX_CAP = 256;
66
+
60
67
  /** Read the current default cold-start replay window (turn count). */
61
68
  export function getDefaultRecentTurnsLimit() {
62
69
  return DEFAULT_RECENT_TURNS;
@@ -114,20 +121,18 @@ export function __resetTruncationWarned() {
114
121
  *
115
122
  * @param {string} sessionId
116
123
  * @param {string} storeDir
117
- * @param {number} totalTurns turns available on disk
118
- * @param {number} returnedTurns — turns the load returned
124
+ * @param {number} recentTurnsLimit configured recent-turn window
119
125
  * @param {boolean} hasCompactSummary
120
126
  */
121
- function maybeWarnHistoryTruncated(sessionId, storeDir, totalTurns, returnedTurns, hasCompactSummary) {
127
+ function maybeWarnHistoryTruncated(sessionId, storeDir, recentTurnsLimit, hasCompactSummary) {
122
128
  if (!sessionId || !storeDir) return;
123
- if (returnedTurns >= totalTurns) return;
124
129
  if (hasCompactSummary) return;
125
130
  const key = `${storeDir}::${sessionId}`;
126
131
  if (_truncationWarned.has(key)) return;
127
132
  _truncationWarned.add(key);
128
133
  // eslint-disable-next-line no-console
129
134
  console.warn(
130
- `[Yeaft] history for session ${sessionId} truncated to ${returnedTurns} of ${totalTurns} turns (recentTurnsLimit=${DEFAULT_RECENT_TURNS}); ` +
135
+ `[Yeaft] history for session ${sessionId} truncated to ${recentTurnsLimit} recent turns (recentTurnsLimit=${DEFAULT_RECENT_TURNS}); ` +
131
136
  `no compact summary exists, so older context is dropped. ` +
132
137
  `Raise yeaft.recentTurnsLimit in ~/.yeaft/config.json if this is a problem.`
133
138
  );
@@ -179,6 +184,11 @@ function canonicalUserTurnContent(content) {
179
184
  return text ? stripVpMentionPrefix(text) : null;
180
185
  }
181
186
 
187
+ function recentSessionScanCap(turnsLimit) {
188
+ const turns = Number.isFinite(turnsLimit) && turnsLimit > 0 ? Math.ceil(turnsLimit) : DEFAULT_RECENT_TURNS;
189
+ return Math.min(RECENT_SESSION_SCAN_MAX_CAP, RECENT_SESSION_SCAN_BASE_CAP + turns * RECENT_SESSION_SCAN_PER_TURN_CAP);
190
+ }
191
+
182
192
  // ─── Frontmatter helpers ─────────────────────────────────────
183
193
 
184
194
  /**
@@ -889,13 +899,10 @@ export class ConversationStore {
889
899
  * boundary cuts are pair-safe by construction, but if a hand-edited
890
900
  * store somehow contains pre-existing orphans we drop them anyway.
891
901
  *
892
- * Implementation note: filters AFTER reading the most recent files
893
- * because the on-disk order is global by sequence id. We over-read by
894
- * loading every hot file and slicing the tail of the FILTERED set so
895
- * `turnsLimit` reflects "N most recent turns in this group", not "N
896
- * most recent messages on disk that happen to be in this group". For
897
- * typical inboxes (≤ a few thousand hot messages) this is cheap; if
898
- * it ever becomes a hot path we add a per-group on-disk index.
902
+ * Implementation note: this walks session files newest-to-oldest and stops
903
+ * once it has the requested turn window plus proof of an older turn. Do not
904
+ * replace this with `#loadSessionMessages()`; that full synchronous parse is
905
+ * exactly what can starve websocket heartbeat on large sessions.
899
906
  *
900
907
  * @param {string} sessionId — required; null/empty returns []
901
908
  * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
@@ -903,24 +910,22 @@ export class ConversationStore {
903
910
  */
904
911
  loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS) {
905
912
  if (!sessionId) return [];
906
- const all = this.#loadSessionMessages(sessionId);
907
- const filtered = all.filter(m => m && m.sessionId === sessionId && !isHiddenConversationRow(m));
908
- if (turnsLimit === Infinity || turnsLimit < 0) return pairSanitize(filtered);
909
- const sliced = sliceLastNTurns(filtered, turnsLimit);
910
- // Warn once per (sessionId, storeDir) when truncation drops turns
911
- // that no compact summary covers — the user is silently losing
912
- // older context otherwise. Cheap check: countTurns is O(N) over
913
- // already-loaded messages; we only run it when the slice actually
914
- // returned fewer rows than the full filtered set.
915
- if (sliced.length < filtered.length) {
916
- const totalTurns = countTurns(filtered);
917
- const returnedTurns = countTurns(sliced);
918
- if (returnedTurns < totalTurns) {
919
- const hasCompact = this.hasAnyCompactSummaryForSession(sessionId);
920
- maybeWarnHistoryTruncated(sessionId, this.#dir, totalTurns, returnedTurns, hasCompact);
921
- }
913
+ if (turnsLimit === Infinity || turnsLimit < 0) {
914
+ const all = this.#loadSessionMessages(sessionId);
915
+ const filtered = all.filter(m => m && m.sessionId === sessionId && !isHiddenConversationRow(m));
916
+ return pairSanitize(filtered);
922
917
  }
923
- return pairSanitize(sliced);
918
+ if (!(turnsLimit > 0)) return [];
919
+
920
+ const { messages, truncated } = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
921
+ roles: null,
922
+ stripAssistantToolCalls: false,
923
+ });
924
+ if (truncated) {
925
+ const hasCompact = this.hasAnyCompactSummaryForSession(sessionId);
926
+ maybeWarnHistoryTruncated(sessionId, this.#dir, turnsLimit, hasCompact);
927
+ }
928
+ return pairSanitize(messages);
924
929
  }
925
930
 
926
931
  /**
@@ -1073,40 +1078,18 @@ export class ConversationStore {
1073
1078
  if (!sessionId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
1074
1079
 
1075
1080
  const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
1076
- const all = this.#loadSessionMessages(sessionId);
1077
- const visible = all.filter(m => m && m.sessionId === sessionId
1078
- && !isHiddenConversationRow(m)
1079
- && (m.role === 'user' || m.role === 'assistant')
1080
- && parseSeqFromId(m.id) < cutoff);
1081
-
1082
- if (visible.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
1083
-
1084
- const sliced = sliceLastNTurns(visible, turnsLimit);
1085
-
1086
- // Turn-based hasMore: an EARLIER turn boundary was dropped.
1087
- const oldestSlicedSeq = sliced.length ? parseSeqFromId(sliced[0].id) : NaN;
1088
- const oldestVisibleSeq = parseSeqFromId(visible[0].id);
1089
- const hasMore = sliced.length > 0
1090
- && Number.isFinite(oldestSlicedSeq)
1091
- && Number.isFinite(oldestVisibleSeq)
1092
- && oldestSlicedSeq > oldestVisibleSeq;
1093
-
1094
- // Visible history is for UI replay, not LLM context. Strip tool-call
1095
- // metadata — don't run pairSanitize (it can incorrectly drop assistant
1096
- // messages that reference tool calls stripped from the UI).
1097
- const messages = sliced.map(m => {
1098
- if (m && m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
1099
- const { toolCalls, ...rest } = m;
1100
- return { ...rest, toolSummaryCount: toolCalls.length };
1101
- }
1102
- return m;
1081
+ const page = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
1082
+ beforeSeq: cutoff,
1083
+ roles: new Set(['user', 'assistant']),
1084
+ stripAssistantToolCalls: true,
1103
1085
  });
1104
- const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
1086
+ if (page.messages.length === 0) return { messages: [], oldestSeq: null, hasMore: page.truncated };
1105
1087
 
1088
+ const oldestSeq = page.messages.length ? parseSeqFromId(page.messages[0].id) : null;
1106
1089
  return {
1107
- messages,
1090
+ messages: page.messages,
1108
1091
  oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
1109
- hasMore,
1092
+ hasMore: page.truncated,
1110
1093
  };
1111
1094
  }
1112
1095
 
@@ -1132,16 +1115,23 @@ export class ConversationStore {
1132
1115
  const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? opts.limit : 500;
1133
1116
  const cutoff = Number.isFinite(afterSeq) && afterSeq >= 0 ? afterSeq : null;
1134
1117
  if (cutoff === null) return { messages: [], latestSeq: null };
1135
- const rawAfter = this.#loadSessionMessages(sessionId).filter((m) => {
1136
- if (!m || m.sessionId !== sessionId) return false;
1118
+ const after = [];
1119
+ let newestScannedSeq = cutoff;
1120
+ for (const entry of this.#sessionFileEntries('messages', sessionId, { afterSeq: cutoff, desc: false })) {
1121
+ let m;
1122
+ try {
1123
+ m = this.readMessageFile(entry.path);
1124
+ } catch (err) {
1125
+ if (isPermissionError(err)) continue;
1126
+ throw err;
1127
+ }
1128
+ if (!m || m.sessionId !== sessionId) continue;
1137
1129
  const seq = parseSeqFromId(m.id);
1138
- return Number.isFinite(seq) && seq > cutoff;
1139
- });
1140
- const newestScannedSeq = rawAfter.slice(0, limit).reduce((max, m) => {
1141
- const seq = parseSeqFromId(m?.id);
1142
- return Number.isFinite(seq) && seq > max ? seq : max;
1143
- }, cutoff);
1144
- const after = rawAfter.filter(m => !isHiddenConversationRow(m));
1130
+ if (Number.isFinite(seq) && seq > newestScannedSeq) newestScannedSeq = seq;
1131
+ if (isHiddenConversationRow(m)) continue;
1132
+ after.push(m);
1133
+ if (after.length >= limit) break;
1134
+ }
1145
1135
  const sliced = pairSanitize(after.slice(0, limit));
1146
1136
  const lastSeq = sliced.length ? parseSeqFromId(sliced[sliced.length - 1].id) : null;
1147
1137
  const latestSeq = Number.isFinite(lastSeq) ? Math.max(lastSeq, newestScannedSeq) : newestScannedSeq;
@@ -1730,6 +1720,133 @@ export class ConversationStore {
1730
1720
  return this.#loadSessionHotMessages(sessionId);
1731
1721
  }
1732
1722
 
1723
+ readMessageFile(path) {
1724
+ return parseMessage(readFileSync(path, 'utf8'));
1725
+ }
1726
+
1727
+ #loadRecentSessionWindow(sessionId, turnsLimit, { beforeSeq = Infinity, roles = null, stripAssistantToolCalls = false } = {}) {
1728
+ const kept = [];
1729
+ const pendingBoundaryRows = [];
1730
+ let turnsFromEnd = 0;
1731
+ let openCanonical = null;
1732
+ let boundaryCanonical = null;
1733
+ let truncated = false;
1734
+ let parsed = 0;
1735
+ const scanCap = recentSessionScanCap(turnsLimit);
1736
+
1737
+ const project = (m) => {
1738
+ if (roles && !roles.has(m.role)) return null;
1739
+ if (stripAssistantToolCalls && m.role === 'assistant') {
1740
+ const { toolCalls, ...rest } = m;
1741
+ if (!rest.content && !rest.attachments && (!Array.isArray(toolCalls) || toolCalls.length === 0)) return null;
1742
+ if (Array.isArray(toolCalls) && toolCalls.length > 0) return { ...rest, toolSummaryCount: toolCalls.length };
1743
+ return rest;
1744
+ }
1745
+ return m;
1746
+ };
1747
+ const keep = (m) => {
1748
+ const projected = project(m);
1749
+ if (projected) kept.push(projected);
1750
+ };
1751
+ const queueBoundaryRow = (m) => {
1752
+ const projected = project(m);
1753
+ if (projected) pendingBoundaryRows.push(projected);
1754
+ };
1755
+
1756
+ // Do not materialize the whole session transcript just to paint or hydrate
1757
+ // the recent context window. Large Yeaft sessions can have thousands of
1758
+ // markdown rows; parsing all of them is synchronous and can starve websocket
1759
+ // heartbeat long enough for the agent to look dead. Walk newest-to-oldest
1760
+ // and stop after the requested turn window is complete. Hidden/internal and
1761
+ // non-turn rows are not allowed to force an unbounded scan; a hard parse cap
1762
+ // conservatively marks the page truncated.
1763
+ for (const entry of this.#sessionFileEntries('messages', sessionId, { beforeSeq, desc: true })) {
1764
+ if (parsed >= scanCap) {
1765
+ truncated = true;
1766
+ break;
1767
+ }
1768
+ parsed += 1;
1769
+
1770
+ let m;
1771
+ try {
1772
+ m = this.readMessageFile(entry.path);
1773
+ } catch (err) {
1774
+ if (isPermissionError(err)) continue;
1775
+ throw err;
1776
+ }
1777
+ if (!m || m.sessionId !== sessionId) continue;
1778
+
1779
+ const boundaryComplete = turnsFromEnd >= turnsLimit;
1780
+ if (isHiddenConversationRow(m)) {
1781
+ if (boundaryComplete) {
1782
+ truncated = true;
1783
+ break;
1784
+ }
1785
+ continue;
1786
+ }
1787
+
1788
+ if (boundaryComplete) {
1789
+ if (m.role === 'user') {
1790
+ const canonical = canonicalUserTurnContent(m.content);
1791
+ if (canonical != null && canonical === boundaryCanonical) {
1792
+ kept.push(...pendingBoundaryRows.splice(0));
1793
+ keep(m);
1794
+ continue;
1795
+ }
1796
+ } else {
1797
+ queueBoundaryRow(m);
1798
+ continue;
1799
+ }
1800
+ truncated = true;
1801
+ break;
1802
+ }
1803
+
1804
+ if (m.role === 'user') {
1805
+ const canonical = canonicalUserTurnContent(m.content);
1806
+ if (canonical != null && canonical !== openCanonical) {
1807
+ turnsFromEnd += 1;
1808
+ openCanonical = canonical;
1809
+ if (turnsFromEnd === turnsLimit) boundaryCanonical = canonical;
1810
+ if (turnsFromEnd > turnsLimit) {
1811
+ truncated = true;
1812
+ break;
1813
+ }
1814
+ }
1815
+ }
1816
+
1817
+ keep(m);
1818
+ }
1819
+
1820
+ kept.reverse();
1821
+ return {
1822
+ messages: turnsFromEnd > 0 ? sliceLastNTurns(kept, turnsLimit) : kept,
1823
+ truncated,
1824
+ };
1825
+ }
1826
+
1827
+ #sessionFileEntries(kind, sessionId, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false } = {}) {
1828
+ const entries = [];
1829
+ for (const dir of this.#sessionMessageDirs(kind, sessionId)) {
1830
+ let files;
1831
+ try {
1832
+ files = readdirSync(dir).filter(f => f.endsWith('.md'));
1833
+ } catch (err) {
1834
+ if (isPermissionError(err)) continue;
1835
+ throw err;
1836
+ }
1837
+ for (const file of files) {
1838
+ const seqMatch = file.match(/^m(\d+)\.md$/);
1839
+ const seq = seqMatch ? parseInt(seqMatch[1], 10) : NaN;
1840
+ if (!Number.isFinite(seq)) continue;
1841
+ if (Number.isFinite(beforeSeq) && seq >= beforeSeq) continue;
1842
+ if (Number.isFinite(afterSeq) && seq <= afterSeq) continue;
1843
+ entries.push({ path: join(dir, file), seq });
1844
+ }
1845
+ }
1846
+ entries.sort((a, b) => desc ? b.seq - a.seq : a.seq - b.seq);
1847
+ return entries;
1848
+ }
1849
+
1733
1850
  #loadAllMessages() {
1734
1851
  return [
1735
1852
  ...this.#loadFromDir(this.#legacyColdDir, Infinity),
@@ -5626,8 +5626,6 @@ export async function handleYeaftLoadHistory(msg) {
5626
5626
  mode: 'recent',
5627
5627
  count: replayEntries.length,
5628
5628
  hasCompactSummary: hasCompactSummaryFlag,
5629
- totalHot: session.conversationStore.countHot(),
5630
- totalCold: session.conversationStore.countCold(),
5631
5629
  sessionId,
5632
5630
  hasMore,
5633
5631
  oldestSeq,