@yeaft/webchat-agent 1.0.504 → 1.0.506
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/conversation.js +4 -2
- package/local-runtime/server/handlers/agent-conversation.js +11 -9
- package/local-runtime/server/handlers/client-conversation.js +28 -2
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +99 -94
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +4 -3
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/local-runtime/web/vendor/katex/LICENSE +21 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/katex.min.css +1 -0
- package/local-runtime/web/vendor.bundle.js +1 -0
- package/local-runtime/web/vendor.bundle.js.gz +0 -0
- package/package.json +1 -1
- package/yeaft/cli.js +3 -0
- package/yeaft/config-api.js +16 -5
- package/yeaft/config.js +11 -2
- package/yeaft/conversation/history-index-worker.js +151 -0
- package/yeaft/conversation/history-index.js +96 -1
- package/yeaft/conversation/persist.js +21 -10
- package/yeaft/conversation/recall-relevance.js +103 -0
- package/yeaft/conversation/search.js +2 -1
- package/yeaft/debug-trace.js +15 -4
- package/yeaft/effort.js +159 -6
- package/yeaft/engine.js +180 -41
- package/yeaft/history-window.js +430 -5
- package/yeaft/llm/adapter.js +3 -2
- package/yeaft/llm/anthropic.js +97 -25
- package/yeaft/llm/openai-responses.js +71 -7
- package/yeaft/llm/provider-state.js +242 -0
- package/yeaft/llm/router.js +14 -2
- package/yeaft/llm/usage-accounting.js +3 -0
- package/yeaft/models.js +4 -1
- package/yeaft/snapshot-filter.js +1 -0
- package/yeaft/sub-agent/output-log.js +4 -0
- package/yeaft/sub-agent/prompt-queue.js +3 -0
- package/yeaft/sub-agent/runner.js +13 -2
- package/yeaft/tools/agent.js +3 -0
- package/yeaft/tools/send-message.js +2 -0
- package/yeaft/web-bridge.js +24 -17
package/yeaft/history-window.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { providerProjectionDigest, providerStateBytes, MAX_PROVIDER_STATE_BYTES, ProviderStateError } from './llm/provider-state.js';
|
|
1
2
|
/**
|
|
2
3
|
* history-window.js — deterministic history shaping for provider requests.
|
|
3
4
|
*
|
|
@@ -9,6 +10,8 @@
|
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { estimateTokens } from './conversation/persist.js';
|
|
13
|
+
import { isVisibleConversationRow } from './conversation/internal-control.js';
|
|
14
|
+
import { scoreRecallTurn } from './conversation/recall-relevance.js';
|
|
12
15
|
import { pairSanitize } from './pair-sanitize.js';
|
|
13
16
|
import { truncateToolResultIfNeeded } from './tools/registry.js';
|
|
14
17
|
import { countTurns, indexOfNthTurnFromEnd, sliceLastNTurns } from './turn-utils.js';
|
|
@@ -155,7 +158,19 @@ export function estimateContentTokens(content) {
|
|
|
155
158
|
export function estimateMessageTokens(message) {
|
|
156
159
|
if (!message || typeof message !== 'object') return 0;
|
|
157
160
|
let total = 2 + estimateContentTokens(message.content);
|
|
158
|
-
|
|
161
|
+
// Responses ciphertext has a byte budget, not a text-token cost. Anthropic
|
|
162
|
+
// thinking is plaintext and must be charged, without recounting the native
|
|
163
|
+
// text/tool items already represented by content/toolCalls below.
|
|
164
|
+
if (message.providerState && providerStateBytes(message.providerState) > MAX_PROVIDER_STATE_BYTES) {
|
|
165
|
+
throw new ProviderStateError('state exceeds byte budget');
|
|
166
|
+
}
|
|
167
|
+
if (message.providerState?.protocol === 'anthropic') {
|
|
168
|
+
const thinking = (message.providerState.items || [])
|
|
169
|
+
.filter(item => ['thinking', 'redacted_thinking'].includes(item?.type));
|
|
170
|
+
if (thinking.length > 0) total += estimateContentTokens(thinking);
|
|
171
|
+
} else if (!message.providerState) {
|
|
172
|
+
total += estimateThinkingBlocksTokens(message.thinkingBlocks);
|
|
173
|
+
}
|
|
159
174
|
if (Array.isArray(message.toolCalls)) {
|
|
160
175
|
for (const toolCall of message.toolCalls) {
|
|
161
176
|
total += 4;
|
|
@@ -240,6 +255,7 @@ export function stripToolNoiseFromOlderTurns(messages, options = {}) {
|
|
|
240
255
|
const next = { ...message };
|
|
241
256
|
if (Array.isArray(next.toolCalls)) delete next.toolCalls;
|
|
242
257
|
if (Array.isArray(next.thinkingBlocks)) delete next.thinkingBlocks;
|
|
258
|
+
delete next.providerState;
|
|
243
259
|
if (Array.isArray(next.content)) next.content = stripToolContentParts(next.content);
|
|
244
260
|
if (next.role === 'assistant' && !hasContentAfterToolStrip(next.content)) continue;
|
|
245
261
|
if (next.role === 'user' && Array.isArray(next.content) && next.content.length === 0) continue;
|
|
@@ -255,6 +271,7 @@ function stripAllToolNoise(messages) {
|
|
|
255
271
|
const next = { ...message };
|
|
256
272
|
if (Array.isArray(next.toolCalls)) delete next.toolCalls;
|
|
257
273
|
if (Array.isArray(next.thinkingBlocks)) delete next.thinkingBlocks;
|
|
274
|
+
delete next.providerState;
|
|
258
275
|
if (Array.isArray(next.content)) next.content = stripToolContentParts(next.content);
|
|
259
276
|
if (next.role === 'assistant' && !hasContentAfterToolStrip(next.content)) continue;
|
|
260
277
|
if (next.role === 'user' && Array.isArray(next.content) && next.content.length === 0) continue;
|
|
@@ -372,6 +389,7 @@ function dropEmptyAssistantRows(messages) {
|
|
|
372
389
|
if (!message || message.role !== 'assistant') return true;
|
|
373
390
|
return hasProviderContent(message.content)
|
|
374
391
|
|| (Array.isArray(message.toolCalls) && message.toolCalls.length > 0)
|
|
392
|
+
|| Boolean(message.providerState)
|
|
375
393
|
|| (Array.isArray(message.thinkingBlocks) && message.thinkingBlocks.length > 0);
|
|
376
394
|
});
|
|
377
395
|
}
|
|
@@ -419,6 +437,13 @@ function shrinkMessageToBudget(message, tokenBudget) {
|
|
|
419
437
|
if (estimateMessageTokens(next) > tokenBudget && Array.isArray(next.toolCalls)) {
|
|
420
438
|
delete next.toolCalls;
|
|
421
439
|
}
|
|
440
|
+
if (next.providerState && providerProjectionDigest(next) !== next.providerState.projectionDigest) {
|
|
441
|
+
// Modified assistant projections must never resurrect removed text/calls.
|
|
442
|
+
const signed = next.providerState.protocol === 'anthropic'
|
|
443
|
+
&& next.providerState.items?.some(item => ['thinking', 'redacted_thinking'].includes(item?.type));
|
|
444
|
+
delete next.providerState;
|
|
445
|
+
if (signed) delete next.toolCalls;
|
|
446
|
+
}
|
|
422
447
|
return next;
|
|
423
448
|
}
|
|
424
449
|
|
|
@@ -606,6 +631,16 @@ function enrichTextBaselineWithTools(textBaseline, toolSource, selectedCallIds)
|
|
|
606
631
|
const selectedParts = source.content.filter(part => selectedIds.has(toolContentPartCallId(part)));
|
|
607
632
|
owner.content = [...baselineContent, ...selectedParts];
|
|
608
633
|
}
|
|
634
|
+
if (source.providerState) {
|
|
635
|
+
if (providerProjectionDigest(owner) === source.providerState.projectionDigest) {
|
|
636
|
+
owner.providerState = source.providerState;
|
|
637
|
+
} else {
|
|
638
|
+
delete owner.providerState;
|
|
639
|
+
// A changed text baseline or call subset cannot replay this signed
|
|
640
|
+
// turn. Leave its text-only baseline and let pairing drop the results.
|
|
641
|
+
if (hasSignedProviderThinking(source)) continue;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
609
644
|
messagesBySourceIndex.set(sourceIndex, owner);
|
|
610
645
|
}
|
|
611
646
|
|
|
@@ -625,19 +660,41 @@ function preservesTextBaseline(textBaseline, enriched) {
|
|
|
625
660
|
});
|
|
626
661
|
}
|
|
627
662
|
|
|
663
|
+
function hasSignedProviderThinking(message) {
|
|
664
|
+
return message.providerState?.protocol === 'anthropic'
|
|
665
|
+
&& message.providerState.items?.some(item => ['thinking', 'redacted_thinking'].includes(item?.type));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function completeToolCallGroups(messages) {
|
|
669
|
+
const completeIds = new Set(completeToolCallIds(messages));
|
|
670
|
+
const groups = [];
|
|
671
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
672
|
+
const message = messages[index];
|
|
673
|
+
if (message?.role !== 'assistant' || !Array.isArray(message.toolCalls)) continue;
|
|
674
|
+
const ids = message.toolCalls.map(call => call?.id);
|
|
675
|
+
if (hasSignedProviderThinking(message)) {
|
|
676
|
+
// A signed assistant projection binds every call, including their order.
|
|
677
|
+
// A missing result makes the whole group ineligible, not a smaller turn.
|
|
678
|
+
if (ids.length > 0 && ids.every(id => completeIds.has(id))) groups.push(ids);
|
|
679
|
+
} else {
|
|
680
|
+
groups.push(...ids.reverse().filter(id => completeIds.has(id)).map(id => [id]));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return groups;
|
|
684
|
+
}
|
|
685
|
+
|
|
628
686
|
function addOptionalRecentToolPairs(textBaseline, toolSource, options) {
|
|
629
687
|
const selectedCallIds = new Set();
|
|
630
688
|
let out = pairSanitize(textBaseline);
|
|
631
|
-
for (const
|
|
632
|
-
const trialIds = new Set(selectedCallIds);
|
|
633
|
-
trialIds.add(callId);
|
|
689
|
+
for (const callIds of completeToolCallGroups(toolSource)) {
|
|
690
|
+
const trialIds = new Set([...selectedCallIds, ...callIds]);
|
|
634
691
|
const trial = enrichTextBaselineWithTools(textBaseline, toolSource, trialIds);
|
|
635
692
|
if (trial.length > options.maxMessageCount) continue;
|
|
636
693
|
const fitted = fitMessagesToBudget(trial, options.messageTokenBudget);
|
|
637
694
|
const fittedCallIds = new Set(completeToolCallIds(fitted));
|
|
638
695
|
if (![...trialIds].every(id => fittedCallIds.has(id))) continue;
|
|
639
696
|
if (!preservesTextBaseline(textBaseline, fitted)) continue;
|
|
640
|
-
selectedCallIds.add(callId);
|
|
697
|
+
for (const callId of callIds) selectedCallIds.add(callId);
|
|
641
698
|
out = fitted;
|
|
642
699
|
}
|
|
643
700
|
return out;
|
|
@@ -693,6 +750,374 @@ export function trimSnapshotForBudget(snapshot, options = {}) {
|
|
|
693
750
|
return withoutHistorySourceIndexes(enriched);
|
|
694
751
|
}
|
|
695
752
|
|
|
753
|
+
// Unlike the legacy runtime-cache slicer, provider buckets never infer user
|
|
754
|
+
// identity from text. A repeated question is still a new human turn.
|
|
755
|
+
function bucketSourceIds(message) {
|
|
756
|
+
return [...new Set([
|
|
757
|
+
...(Array.isArray(message?.sourceMessageIds) ? message.sourceMessageIds : []),
|
|
758
|
+
...(Array.isArray(message?.entry?.sourceMessageIds) ? message.entry.sourceMessageIds : []),
|
|
759
|
+
message?._persistedMessageId, message?.id, message?.messageId,
|
|
760
|
+
message?.anchorMessageId,
|
|
761
|
+
].filter(id => typeof id === 'string' && id))];
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function bucketUserKeys(message) {
|
|
765
|
+
const keys = bucketSourceIds(message).map(id => `message:${id}`);
|
|
766
|
+
if (message?.clientMessageId) keys.push(`client:${message.clientMessageId}`);
|
|
767
|
+
if (message?.entryId) keys.push(`entry:${message.entryId}`);
|
|
768
|
+
return keys;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function bucketUserBoundary(message) {
|
|
772
|
+
if (message?.role !== 'user' || !isVisibleConversationRow(message)) return false;
|
|
773
|
+
// Anthropic's tool-result carrier is not a human turn boundary.
|
|
774
|
+
return !Array.isArray(message.content) || message.content.some(part => (
|
|
775
|
+
typeof part === 'string' || !['tool_result', 'function_call_output'].includes(part?.type)
|
|
776
|
+
));
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function bucketSequence(message) {
|
|
780
|
+
for (const value of [message?.userSeq, message?.entryStartSeq, message?.seq]) {
|
|
781
|
+
if (Number.isFinite(value)) return value;
|
|
782
|
+
}
|
|
783
|
+
for (const id of bucketSourceIds(message)) {
|
|
784
|
+
const match = /^m(\d+)$/.exec(id);
|
|
785
|
+
if (match) return Number(match[1]);
|
|
786
|
+
}
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function bucketTextMessages(messages) {
|
|
791
|
+
return stripAllToolNoise(messages.filter(message => (
|
|
792
|
+
isVisibleConversationRow(message)
|
|
793
|
+
&& (message?.role === 'user' || message?.role === 'assistant')
|
|
794
|
+
)));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function bucketTurn(messages, index, supplied = {}) {
|
|
798
|
+
const user = messages.find(bucketUserBoundary);
|
|
799
|
+
const sourceIds = [...new Set(messages.flatMap(bucketSourceIds))];
|
|
800
|
+
const keys = [...new Set(messages.filter(bucketUserBoundary).flatMap(bucketUserKeys))];
|
|
801
|
+
if (supplied.id) keys.push(`turn:${supplied.id}`);
|
|
802
|
+
if (keys.length === 0 && index != null) keys.push(`snapshot:${index}`);
|
|
803
|
+
const text = bucketTextMessages(messages);
|
|
804
|
+
return {
|
|
805
|
+
...supplied,
|
|
806
|
+
id: supplied.id || keys[0] || `snapshot:${index}`,
|
|
807
|
+
keys,
|
|
808
|
+
sourceIds,
|
|
809
|
+
userSeq: Number.isFinite(supplied.userSeq) ? supplied.userSeq : bucketSequence(user),
|
|
810
|
+
index,
|
|
811
|
+
messages,
|
|
812
|
+
text,
|
|
813
|
+
tokens: estimateMessagesTokens(text),
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function splitBucketTurns(snapshot) {
|
|
818
|
+
const turns = [];
|
|
819
|
+
let messages = [];
|
|
820
|
+
let keys = new Set();
|
|
821
|
+
let startIndex = 0;
|
|
822
|
+
for (let index = 0; index < snapshot.length; index += 1) {
|
|
823
|
+
const message = snapshot[index];
|
|
824
|
+
if (bucketUserBoundary(message)) {
|
|
825
|
+
const nextKeys = bucketUserKeys(message);
|
|
826
|
+
const sameTurn = nextKeys.some(key => keys.has(key));
|
|
827
|
+
if (messages.length && !sameTurn) {
|
|
828
|
+
turns.push(bucketTurn(messages, startIndex));
|
|
829
|
+
messages = [];
|
|
830
|
+
keys = new Set();
|
|
831
|
+
}
|
|
832
|
+
if (!messages.length) startIndex = index;
|
|
833
|
+
nextKeys.forEach(key => keys.add(key));
|
|
834
|
+
}
|
|
835
|
+
// A leading assistant fragment cannot become a complete historical turn.
|
|
836
|
+
if (messages.length || bucketUserBoundary(message)) messages.push(message);
|
|
837
|
+
}
|
|
838
|
+
if (messages.length) turns.push(bucketTurn(messages, startIndex));
|
|
839
|
+
return turns;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function bucketOverlap(left, right) {
|
|
843
|
+
const ids = new Set(left.sourceIds);
|
|
844
|
+
const keys = new Set(left.keys);
|
|
845
|
+
return right.sourceIds.some(id => ids.has(id)) || right.keys.some(key => keys.has(key));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function bucketCap(value, fallback, maximum = Infinity) {
|
|
849
|
+
return Number.isFinite(value) && value >= 0
|
|
850
|
+
? Math.min(maximum, Math.floor(value)) : fallback;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function scoreBucketTurn(turn, options, stats = {}) {
|
|
854
|
+
// Relevance belongs to conversation/recall-relevance, not the budget layer.
|
|
855
|
+
// Indexed candidates already carry scores; raw/evicted turns use injection.
|
|
856
|
+
const result = typeof options.scoreTurn === 'function'
|
|
857
|
+
? options.scoreTurn({
|
|
858
|
+
id: turn.id, userSeq: turn.userSeq, messages: turn.text,
|
|
859
|
+
sourceMessageIds: turn.sourceIds,
|
|
860
|
+
}, options.prompt || '')
|
|
861
|
+
: Number.isFinite(turn.score)
|
|
862
|
+
? { score: turn.score, matchedTerms: turn.matchedTerms }
|
|
863
|
+
: scoreRecallTurn(options.prompt || '', turn.text.map(message => (
|
|
864
|
+
typeof message.content === 'string' ? message.content
|
|
865
|
+
: Array.isArray(message.content) ? message.content.map(part => (
|
|
866
|
+
typeof part === 'string' ? part : part?.text || ''
|
|
867
|
+
)).join('\n') : ''
|
|
868
|
+
)).join('\n'), stats);
|
|
869
|
+
const score = typeof result === 'number' ? result : result?.score;
|
|
870
|
+
return {
|
|
871
|
+
...turn,
|
|
872
|
+
score: Number.isFinite(score) && score > 0 && result?.eligible !== false ? score : 0,
|
|
873
|
+
matchedTerms: Array.isArray(result?.matchedTerms) ? result.matchedTerms : [],
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function bucketBefore(candidate, boundary) {
|
|
878
|
+
if (!boundary) return true;
|
|
879
|
+
if (candidate.index != null && boundary.index != null) return candidate.index < boundary.index;
|
|
880
|
+
return Number.isFinite(candidate.userSeq) && Number.isFinite(boundary.userSeq)
|
|
881
|
+
&& candidate.userSeq < boundary.userSeq;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function describeBucket(turns, messages = turns.flatMap(turn => turn.text)) {
|
|
885
|
+
return {
|
|
886
|
+
turnCount: turns.length,
|
|
887
|
+
turnIds: turns.map(turn => turn.id),
|
|
888
|
+
sourceMessageIds: [...new Set(turns.flatMap(turn => turn.sourceIds))],
|
|
889
|
+
tokenCount: estimateMessagesTokens(messages),
|
|
890
|
+
messageCount: messages.length,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Recompute provider history from untrimmed candidates; never mutate/cache the
|
|
896
|
+
* result in the transcript. Past human turns are atomic text units, including
|
|
897
|
+
* every VP's assistant text. Tools are optional enrichment, newest first.
|
|
898
|
+
*
|
|
899
|
+
* The active turn is outside both buckets and consumes the global budget first.
|
|
900
|
+
* Its opening user row is protected; an oversized active turn alone may be
|
|
901
|
+
* fitted using the legacy protocol-safe transform. Past turns are never fitted.
|
|
902
|
+
* External recall must have comparable userSeq/source identities to establish
|
|
903
|
+
* that it predates recent/current history; unknown chronology fails closed.
|
|
904
|
+
*
|
|
905
|
+
* @param {Array<object>} snapshot Untrimmed history plus the active execution.
|
|
906
|
+
* @param {{ prompt?: string, relatedTurns?: Array<object>, recentTurnCap?: number,
|
|
907
|
+
* relatedTurnCap?: number, messageTokenBudget?: number, maxMessageCount?: number,
|
|
908
|
+
* language?: string, currentTurnStartIndex?: number,
|
|
909
|
+
* scoreTurn?: (turn: object, prompt: string) => (number|object) }} [options]
|
|
910
|
+
* @returns {{messages: Array<object>, meta: object}}
|
|
911
|
+
*/
|
|
912
|
+
export function buildHistoryBuckets(snapshot, options = {}) {
|
|
913
|
+
const source = Array.isArray(snapshot) ? snapshot : [];
|
|
914
|
+
const tokenBudget = bucketCap(options.messageTokenBudget, DEFAULT_MESSAGE_TOKEN_BUDGET);
|
|
915
|
+
const messageCap = bucketCap(options.maxMessageCount, DEFAULT_RUNTIME_CACHE_MESSAGE_CAP);
|
|
916
|
+
const recentCap = bucketCap(options.recentTurnCap, 20);
|
|
917
|
+
const relatedCap = bucketCap(options.relatedTurnCap, 8, 10);
|
|
918
|
+
const allTurns = splitBucketTurns(source);
|
|
919
|
+
const currentStart = Number.isInteger(options.currentTurnStartIndex)
|
|
920
|
+
? Math.max(0, Math.min(source.length, options.currentTurnStartIndex))
|
|
921
|
+
: (allTurns.at(-1)?.index ?? source.length);
|
|
922
|
+
const currentSource = source.slice(currentStart);
|
|
923
|
+
const currentIdentity = bucketTurn(currentSource, currentStart);
|
|
924
|
+
let current = [];
|
|
925
|
+
if (currentSource.length && tokenBudget >= 2 && messageCap > 0) {
|
|
926
|
+
// Reserve the opening prompt before fitting later active execution units.
|
|
927
|
+
const first = shrinkMessageToBudget(stripAllToolNoise([currentSource[0]])[0], tokenBudget);
|
|
928
|
+
if (first && estimateMessageTokens(first) <= tokenBudget) current.push(first);
|
|
929
|
+
const remainingTokens = tokenBudget - estimateMessagesTokens(current);
|
|
930
|
+
const remainingRows = messageCap - current.length;
|
|
931
|
+
if (remainingTokens >= 2 && remainingRows > 0) {
|
|
932
|
+
// Active execution is not visible historical text: internal completion
|
|
933
|
+
// notices must reach the next provider call. Fit newest protocol units
|
|
934
|
+
// directly, without legacy human-turn filtering or text projection.
|
|
935
|
+
const units = providerUnits(pairSanitize(truncateToolResultsForModel(
|
|
936
|
+
currentSource.slice(1), { language: options.language },
|
|
937
|
+
)));
|
|
938
|
+
const fitted = [];
|
|
939
|
+
let tokens = remainingTokens;
|
|
940
|
+
let rows = remainingRows;
|
|
941
|
+
for (let index = units.length - 1; index >= 0; index -= 1) {
|
|
942
|
+
const unit = fitProviderUnit(units[index], tokens);
|
|
943
|
+
const cost = estimateMessagesTokens(unit);
|
|
944
|
+
if (unit.length > rows || cost > tokens) continue;
|
|
945
|
+
fitted.unshift(unit);
|
|
946
|
+
tokens -= cost;
|
|
947
|
+
rows -= unit.length;
|
|
948
|
+
}
|
|
949
|
+
current.push(...fitted.flat());
|
|
950
|
+
}
|
|
951
|
+
// The legacy fitter assumes a normal positive budget; at tiny allowances
|
|
952
|
+
// even an empty row's framing can exceed it. Remove complete tail units.
|
|
953
|
+
while (estimateMessagesTokens(current) > tokenBudget || current.length > messageCap) {
|
|
954
|
+
current = pairSanitize(current.slice(0, -1));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const availableTokens = Math.max(0, tokenBudget - estimateMessagesTokens(current));
|
|
958
|
+
const availableRows = Math.max(0, messageCap - current.length);
|
|
959
|
+
let duplicateCount = 0;
|
|
960
|
+
const past = [];
|
|
961
|
+
for (const turn of splitBucketTurns(source.slice(0, currentStart))) {
|
|
962
|
+
if (bucketOverlap(turn, currentIdentity)) {
|
|
963
|
+
duplicateCount += 1;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const previous = past.findIndex(other => turn.keys.some(key => other.keys.includes(key)));
|
|
967
|
+
if (previous >= 0) {
|
|
968
|
+
// Fan-out may return to the same human question after another question.
|
|
969
|
+
// Keep the first user boundary's position, with all complementary replies
|
|
970
|
+
// in their source order. Only repeated stable message identities disappear.
|
|
971
|
+
const original = past[previous];
|
|
972
|
+
const seen = new Set();
|
|
973
|
+
const messages = [...original.messages, ...turn.messages].filter(message => {
|
|
974
|
+
const ids = bucketSourceIds(message);
|
|
975
|
+
const repeated = ids.length > 0 && ids.every(id => seen.has(id));
|
|
976
|
+
ids.forEach(id => seen.add(id));
|
|
977
|
+
return !repeated;
|
|
978
|
+
});
|
|
979
|
+
past[previous] = bucketTurn(messages, original.index);
|
|
980
|
+
duplicateCount += 1;
|
|
981
|
+
} else past.push(turn);
|
|
982
|
+
}
|
|
983
|
+
let candidates = past.map(turn => scoreBucketTurn(turn, options));
|
|
984
|
+
if (typeof options.scoreTurn !== 'function') {
|
|
985
|
+
// Evicted in-memory turns pass the same distinctiveness gate as indexed
|
|
986
|
+
// turns. Otherwise ubiquitous topic words bypass the index rejection.
|
|
987
|
+
const termDocumentFrequency = Object.create(null);
|
|
988
|
+
for (const turn of candidates) {
|
|
989
|
+
for (const term of turn.matchedTerms) {
|
|
990
|
+
const key = term.toLocaleLowerCase();
|
|
991
|
+
termDocumentFrequency[key] = (termDocumentFrequency[key] || 0) + 1;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
const stats = { sampleSize: past.length, termDocumentFrequency };
|
|
995
|
+
candidates = past.map(turn => scoreBucketTurn(turn, options, stats));
|
|
996
|
+
}
|
|
997
|
+
for (const entry of Array.isArray(options.relatedTurns) ? options.relatedTurns : []) {
|
|
998
|
+
if (!Array.isArray(entry?.messages) || !entry.messages.some(bucketUserBoundary)) continue;
|
|
999
|
+
const turn = bucketTurn(entry.messages, null, entry);
|
|
1000
|
+
if (bucketOverlap(turn, currentIdentity)) { duplicateCount += 1; continue; }
|
|
1001
|
+
const duplicate = candidates.find(other => bucketOverlap(turn, other));
|
|
1002
|
+
if (duplicate) {
|
|
1003
|
+
duplicateCount += 1;
|
|
1004
|
+
// Preserve the complete untrimmed snapshot turn, but retain the index's
|
|
1005
|
+
// qualified score when no raw-turn scorer is installed.
|
|
1006
|
+
if (typeof options.scoreTurn !== 'function' && entry.score > (duplicate.score || 0)) {
|
|
1007
|
+
duplicate.score = entry.score;
|
|
1008
|
+
duplicate.matchedTerms = entry.matchedTerms || [];
|
|
1009
|
+
}
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
candidates.push(scoreBucketTurn(turn, options));
|
|
1013
|
+
}
|
|
1014
|
+
const eligible = candidates.filter(turn => turn.score > 0 && turn.text.length
|
|
1015
|
+
&& (currentSource.length === 0 || bucketBefore(turn, currentIdentity)
|
|
1016
|
+
// A runtime prompt can lack its persisted sequence. An older persisted
|
|
1017
|
+
// past-turn boundary is still a safe fence; never guess from text/time.
|
|
1018
|
+
|| (turn.index == null && currentIdentity.userSeq == null && past.length > 0
|
|
1019
|
+
&& bucketBefore(turn, past.at(-1)))));
|
|
1020
|
+
// A candidate inside the initial recent cap can become related after budget
|
|
1021
|
+
// eviction. Reserve before picking the final suffix so it can re-enter.
|
|
1022
|
+
const reservable = relatedCap > 0 ? eligible.filter(turn => turn.tokens <= availableTokens
|
|
1023
|
+
&& turn.text.length <= availableRows
|
|
1024
|
+
&& (recentCap === 0 || !past.length || bucketBefore(turn, past.at(-1)))) : [];
|
|
1025
|
+
const reserve = reservable.length ? Math.floor(availableTokens * 0.25) : 0;
|
|
1026
|
+
const reservedRows = reservable.length
|
|
1027
|
+
? Math.max(Math.floor(availableRows * 0.25), Math.min(...reservable.map(turn => turn.text.length))) : 0;
|
|
1028
|
+
function selectRecent(limit, rowLimit = availableRows) {
|
|
1029
|
+
const selected = [];
|
|
1030
|
+
let tokens = 0;
|
|
1031
|
+
let rows = 0;
|
|
1032
|
+
for (let index = past.length - 1; index >= 0 && selected.length < recentCap; index -= 1) {
|
|
1033
|
+
const turn = past[index];
|
|
1034
|
+
if (tokens + turn.tokens > limit || rows + turn.text.length > rowLimit) break;
|
|
1035
|
+
selected.unshift(turn);
|
|
1036
|
+
tokens += turn.tokens;
|
|
1037
|
+
rows += turn.text.length;
|
|
1038
|
+
}
|
|
1039
|
+
return selected;
|
|
1040
|
+
}
|
|
1041
|
+
let recent = selectRecent(availableTokens - reserve, availableRows - reservedRows);
|
|
1042
|
+
let remainingTokens = availableTokens - recent.reduce((total, turn) => total + turn.tokens, 0);
|
|
1043
|
+
let remainingRows = availableRows - recent.reduce((total, turn) => total + turn.text.length, 0);
|
|
1044
|
+
const related = [];
|
|
1045
|
+
const ranked = eligible.slice().sort((a, b) => b.score - a.score
|
|
1046
|
+
|| (a.userSeq ?? a.index ?? 0) - (b.userSeq ?? b.index ?? 0));
|
|
1047
|
+
for (const turn of ranked) {
|
|
1048
|
+
if (related.length >= relatedCap) break;
|
|
1049
|
+
if (!bucketBefore(turn, recent[0]) || recent.some(other => bucketOverlap(turn, other))
|
|
1050
|
+
|| related.some(other => bucketOverlap(turn, other)
|
|
1051
|
+
// Mixed-source turns require a proven order in either direction.
|
|
1052
|
+
// Unknown sequence is not zero; skip rather than invent chronology.
|
|
1053
|
+
|| (!bucketBefore(turn, other) && !bucketBefore(other, turn)))) continue;
|
|
1054
|
+
if (turn.tokens > remainingTokens || turn.text.length > remainingRows) continue;
|
|
1055
|
+
related.push(turn);
|
|
1056
|
+
remainingTokens -= turn.tokens;
|
|
1057
|
+
remainingRows -= turn.text.length;
|
|
1058
|
+
}
|
|
1059
|
+
if (!related.length) recent = selectRecent(availableTokens);
|
|
1060
|
+
else {
|
|
1061
|
+
// Pay the actual related cost, not the provisional reserve. Expand the
|
|
1062
|
+
// recent suffix only while every related turn remains strictly older.
|
|
1063
|
+
const expanded = selectRecent(
|
|
1064
|
+
availableTokens - related.reduce((sum, turn) => sum + turn.tokens, 0),
|
|
1065
|
+
availableRows - related.reduce((sum, turn) => sum + turn.text.length, 0),
|
|
1066
|
+
);
|
|
1067
|
+
while (expanded.length > recent.length && related.some(turn => (
|
|
1068
|
+
!bucketBefore(turn, expanded[0]) || bucketOverlap(turn, expanded[0])
|
|
1069
|
+
))) expanded.shift();
|
|
1070
|
+
if (expanded.length > recent.length) recent = expanded;
|
|
1071
|
+
}
|
|
1072
|
+
related.sort((a, b) => bucketBefore(a, b) ? -1 : bucketBefore(b, a) ? 1 : 0);
|
|
1073
|
+
|
|
1074
|
+
const relatedMessages = related.flatMap(turn => turn.text);
|
|
1075
|
+
const recentText = withHistorySourceIndexes(recent.flatMap(turn => turn.messages)
|
|
1076
|
+
.filter(isVisibleConversationRow));
|
|
1077
|
+
const recentBaseline = bucketTextMessages(recentText);
|
|
1078
|
+
// Enrich only after both complete-text buckets and the active turn are paid.
|
|
1079
|
+
const recentMessages = withoutHistorySourceIndexes(addOptionalRecentToolPairs(
|
|
1080
|
+
recentBaseline,
|
|
1081
|
+
truncateToolResultsForModel(recentText, { language: options.language }),
|
|
1082
|
+
{
|
|
1083
|
+
messageTokenBudget: availableTokens - estimateMessagesTokens(relatedMessages),
|
|
1084
|
+
maxMessageCount: availableRows - relatedMessages.length,
|
|
1085
|
+
},
|
|
1086
|
+
));
|
|
1087
|
+
const messages = [...relatedMessages.map(message => ({ ...message })), ...recentMessages, ...current];
|
|
1088
|
+
const retained = [...related, ...recent];
|
|
1089
|
+
const droppedTurns = past.filter(turn => !retained.some(other => bucketOverlap(turn, other)
|
|
1090
|
+
|| turn === other));
|
|
1091
|
+
return {
|
|
1092
|
+
messages,
|
|
1093
|
+
meta: {
|
|
1094
|
+
recent: describeBucket(recent, recentMessages),
|
|
1095
|
+
related: { ...describeBucket(related), scores: related.map(turn => ({
|
|
1096
|
+
id: turn.id, score: turn.score, matchedTerms: turn.matchedTerms,
|
|
1097
|
+
})) },
|
|
1098
|
+
current: {
|
|
1099
|
+
...describeBucket(currentSource.length ? [currentIdentity] : [], current),
|
|
1100
|
+
startIndex: currentStart,
|
|
1101
|
+
originalTokenCount: estimateMessagesTokens(currentSource),
|
|
1102
|
+
},
|
|
1103
|
+
budget: {
|
|
1104
|
+
messageTokenBudget: tokenBudget, maxMessageCount: messageCap,
|
|
1105
|
+
recentTurnCap: recentCap, relatedTurnCap: relatedCap,
|
|
1106
|
+
relatedReservedTokens: reserve, availableHistoryTokens: availableTokens,
|
|
1107
|
+
usedTokens: estimateMessagesTokens(messages), usedMessages: messages.length,
|
|
1108
|
+
},
|
|
1109
|
+
dropped: {
|
|
1110
|
+
pastTurnCount: droppedTurns.length,
|
|
1111
|
+
sourceMessageIds: [...new Set(droppedTurns.flatMap(turn => turn.sourceIds))],
|
|
1112
|
+
duplicateTurnCount: duplicateCount,
|
|
1113
|
+
oversizedTurnCount: candidates.filter(turn => turn.tokens > availableTokens
|
|
1114
|
+
|| turn.text.length > availableRows).length,
|
|
1115
|
+
unselectedRelatedTurnCount: eligible.length - related.length,
|
|
1116
|
+
},
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
696
1121
|
/**
|
|
697
1122
|
* Bound the Session-level runtime history cache. This is deliberately stricter
|
|
698
1123
|
* than the provider configuration: the cache is only a disposable source
|
package/yeaft/llm/adapter.js
CHANGED
|
@@ -44,11 +44,12 @@ import { utf8PrefixWithinBytes } from '../utf8.js';
|
|
|
44
44
|
* @typedef {{ type: 'thinking_delta', text: string }} ThinkingDeltaEvent
|
|
45
45
|
* @typedef {{ type: 'thinking_block_end', thinking: string, signature: string }} ThinkingBlockEndEvent
|
|
46
46
|
* @typedef {{ type: 'tool_call', id: string, name: string, input: object }} ToolCallEvent
|
|
47
|
-
* @typedef {{ type: '
|
|
47
|
+
* @typedef {{ type: 'provider_state', providerState: object, providerStateBytes: number }} ProviderStateEvent Internal only; never forward to UI/search.
|
|
48
|
+
* @typedef {{ type: 'usage', inputTokens: number, outputTokens: number, reasoningTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, cacheTokensAreIncludedInInput?: boolean }} UsageEvent
|
|
48
49
|
* @typedef {{ type: 'stop', stopReason: 'end_turn' | 'tool_use' | 'max_tokens' }} StopEvent
|
|
49
50
|
* @typedef {{ type: 'error', error: Error, retryable: boolean }} ErrorEvent
|
|
50
51
|
*
|
|
51
|
-
* @typedef {TextDeltaEvent | ThinkingDeltaEvent | ThinkingBlockEndEvent | ToolCallEvent | UsageEvent | StopEvent | ErrorEvent} StreamEvent
|
|
52
|
+
* @typedef {TextDeltaEvent | ThinkingDeltaEvent | ThinkingBlockEndEvent | ProviderStateEvent | ToolCallEvent | UsageEvent | StopEvent | ErrorEvent} StreamEvent
|
|
52
53
|
*/
|
|
53
54
|
|
|
54
55
|
// ─── Unified Message Types ─────────────────────────────────────
|