@yeaft/webchat-agent 1.0.75 → 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 +1 -1
- package/yeaft/conversation/persist.js +186 -69
- package/yeaft/web-bridge.js +0 -2
package/package.json
CHANGED
|
@@ -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 {
|
|
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}
|
|
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,
|
|
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 ${
|
|
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:
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
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
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
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
|
|
1136
|
-
|
|
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
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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),
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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,
|