@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.
Files changed (100) hide show
  1. package/conversation.js +4 -2
  2. package/local-runtime/server/handlers/agent-conversation.js +11 -9
  3. package/local-runtime/server/handlers/client-conversation.js +28 -2
  4. package/local-runtime/version.json +1 -1
  5. package/local-runtime/web/app.bundle.js +99 -94
  6. package/local-runtime/web/app.bundle.js.gz +0 -0
  7. package/local-runtime/web/index.html +4 -3
  8. package/local-runtime/web/style.bundle.css +1 -1
  9. package/local-runtime/web/style.bundle.css.gz +0 -0
  10. package/local-runtime/web/vendor/katex/LICENSE +21 -0
  11. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
  12. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
  13. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  14. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  15. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  16. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  17. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  18. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  19. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  20. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  21. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  22. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  23. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  24. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  25. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  26. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
  27. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
  28. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  29. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  30. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  31. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  32. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
  33. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
  34. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  35. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
  36. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
  37. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  38. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  39. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  40. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  41. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
  42. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
  43. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  44. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  45. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  46. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  47. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  48. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  49. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  50. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  51. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  52. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  53. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
  54. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
  55. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
  57. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
  58. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  59. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
  60. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
  61. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  62. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
  63. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
  64. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  65. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
  66. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
  67. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  68. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  69. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  70. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  71. package/local-runtime/web/vendor/katex/katex.min.css +1 -0
  72. package/local-runtime/web/vendor.bundle.js +1 -0
  73. package/local-runtime/web/vendor.bundle.js.gz +0 -0
  74. package/package.json +1 -1
  75. package/yeaft/cli.js +3 -0
  76. package/yeaft/config-api.js +16 -5
  77. package/yeaft/config.js +11 -2
  78. package/yeaft/conversation/history-index-worker.js +151 -0
  79. package/yeaft/conversation/history-index.js +96 -1
  80. package/yeaft/conversation/persist.js +21 -10
  81. package/yeaft/conversation/recall-relevance.js +103 -0
  82. package/yeaft/conversation/search.js +2 -1
  83. package/yeaft/debug-trace.js +15 -4
  84. package/yeaft/effort.js +159 -6
  85. package/yeaft/engine.js +180 -41
  86. package/yeaft/history-window.js +430 -5
  87. package/yeaft/llm/adapter.js +3 -2
  88. package/yeaft/llm/anthropic.js +97 -25
  89. package/yeaft/llm/openai-responses.js +71 -7
  90. package/yeaft/llm/provider-state.js +242 -0
  91. package/yeaft/llm/router.js +14 -2
  92. package/yeaft/llm/usage-accounting.js +3 -0
  93. package/yeaft/models.js +4 -1
  94. package/yeaft/snapshot-filter.js +1 -0
  95. package/yeaft/sub-agent/output-log.js +4 -0
  96. package/yeaft/sub-agent/prompt-queue.js +3 -0
  97. package/yeaft/sub-agent/runner.js +13 -2
  98. package/yeaft/tools/agent.js +3 -0
  99. package/yeaft/tools/send-message.js +2 -0
  100. package/yeaft/web-bridge.js +24 -17
@@ -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
- total += estimateThinkingBlocksTokens(message.thinkingBlocks);
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 callId of completeToolCallIds(toolSource)) {
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
@@ -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: 'usage', inputTokens: number, outputTokens: number, cacheReadTokens?: number, cacheWriteTokens?: number, cacheTokensAreIncludedInInput?: boolean }} UsageEvent
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 ─────────────────────────────────────