@aibranch/llm-meta-widget 0.12.0 → 0.12.2

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/README.md CHANGED
@@ -61,7 +61,7 @@ Option 3 if it does not.
61
61
 
62
62
  ```ruby
63
63
  # Gemfile
64
- gem "llm_meta_widget", "~> 0.12.0"
64
+ gem "llm_meta_widget", "~> 0.12.2"
65
65
  ```
66
66
 
67
67
  ```bash
@@ -178,6 +178,8 @@ async function runChatLoop(opts) {
178
178
  // Called when a tool result is dropped to make room, so the panel can say
179
179
  // so rather than the context quietly thinning.
180
180
  onContextEvicted,
181
+ // Called when one oversized result is shortened, so the panel can say so.
182
+ onContextTruncated,
181
183
  // "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
182
184
  // Ollama, with no server component of ours in between. The loop is the
183
185
  // same either way — only the call is swapped.
@@ -211,6 +213,7 @@ async function runChatLoop(opts) {
211
213
  const mergedLocalTools = Object.values(inlineByName);
212
214
  const rounds = [];
213
215
  const evicted = [];
216
+ const truncatedResults = [];
214
217
  const toolsTokens = estimateToolsTokens([].concat(
215
218
  singleOpts.localTools || [],
216
219
  remoteTools || [],
@@ -246,6 +249,15 @@ async function runChatLoop(opts) {
246
249
  for (let round = 0; round < maxRounds; round++) {
247
250
  if (signal?.aborted) throw new DOMException("aborted", "AbortError");
248
251
  onRoundStart?.(round);
252
+ const capped = truncateOversizedToolResults({
253
+ messages,
254
+ window: contextWindow,
255
+ truncated: truncatedResults
256
+ });
257
+ if (capped.messages !== messages) {
258
+ messages = capped.messages;
259
+ onContextTruncated?.(truncatedResults.slice());
260
+ }
249
261
  const room = evictOldToolResults({
250
262
  messages,
251
263
  window: contextWindow,
@@ -255,7 +267,7 @@ async function runChatLoop(opts) {
255
267
  });
256
268
  if (room.messages !== messages) {
257
269
  messages = room.messages;
258
- onContextEvicted?.(evicted.slice());
270
+ onContextEvicted?.({ dropped: evicted.slice(), needed: room.needed, window: room.window });
259
271
  }
260
272
  const call = provider === "ollama" ? ollamaChatCall : singleLlmCall;
261
273
  const turnResult = await call({
@@ -589,17 +601,29 @@ function parseSseFrame(raw) {
589
601
  var STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives";
590
602
  var PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json";
591
603
  var RESOURCE_SHARE_OF_WINDOW = 0.0625;
604
+ var RESOURCE_BYTES_PER_TOKEN = 4;
592
605
  function resourceBudgetBytes(window2, { floor = 8e3, cap = 64e3 } = {}) {
593
606
  if (!window2 || !Number.isFinite(window2)) return floor;
594
- const share = Math.round(window2 * RESOURCE_SHARE_OF_WINDOW * CHARS_PER_TOKEN);
607
+ const share = Math.round(window2 * RESOURCE_SHARE_OF_WINDOW * RESOURCE_BYTES_PER_TOKEN);
595
608
  return Math.min(cap, Math.max(floor, share));
596
609
  }
597
- var CHARS_PER_TOKEN = 4;
610
+ var PROSE_CHARS_PER_TOKEN = 3.5;
611
+ var DENSE_CHARS_PER_TOKEN = 1.6;
612
+ var CJK_CHARS_PER_TOKEN = 1;
598
613
  var MESSAGE_OVERHEAD_TOKENS = 4;
614
+ var CJK_PATTERN = /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g;
615
+ var PROSE_PATTERN = /[A-Za-z \n\t]/g;
616
+ function estimateTextTokens(text) {
617
+ const str = typeof text === "string" ? text : String(text ?? "");
618
+ const cjk = (str.match(CJK_PATTERN) || []).length;
619
+ const prose = (str.match(PROSE_PATTERN) || []).length;
620
+ const dense = str.length - cjk - prose;
621
+ return Math.ceil(cjk / CJK_CHARS_PER_TOKEN + prose / PROSE_CHARS_PER_TOKEN + dense / DENSE_CHARS_PER_TOKEN);
622
+ }
599
623
  function estimateTokens(messages) {
600
624
  return (messages || []).reduce((sum, m) => {
601
625
  const content = typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? "");
602
- return sum + Math.ceil(content.length / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD_TOKENS;
626
+ return sum + estimateTextTokens(content) + MESSAGE_OVERHEAD_TOKENS;
603
627
  }, 0);
604
628
  }
605
629
  function estimateToolsTokens(tools) {
@@ -608,12 +632,36 @@ function estimateToolsTokens(tools) {
608
632
  description: t?.description,
609
633
  schema: t?.input_schema || t?.inputSchema
610
634
  })).join("");
611
- return Math.ceil(text.length / CHARS_PER_TOKEN);
635
+ return estimateTextTokens(text);
636
+ }
637
+ var MAX_TOOL_RESULT_SHARE = 0.4;
638
+ function truncateOversizedToolResults({
639
+ messages,
640
+ window: window2,
641
+ share = MAX_TOOL_RESULT_SHARE,
642
+ truncated = []
643
+ } = {}) {
644
+ if (!window2 || !Number.isFinite(window2)) return { messages, truncated };
645
+ const cap = Math.floor(window2 * share);
646
+ let out = null;
647
+ (messages || []).forEach((m, i) => {
648
+ if (m?.role !== "tool" || typeof m.content !== "string") return;
649
+ const tokens = estimateTextTokens(m.content);
650
+ if (tokens <= cap) return;
651
+ const keep = Math.max(1, Math.floor(m.content.length * cap / tokens));
652
+ out = out || messages.slice();
653
+ truncated.push({ name: m.name, fromTokens: tokens, toTokens: cap });
654
+ out[i] = Object.assign({}, m, {
655
+ content: m.content.slice(0, keep) + "\n\n[truncated: " + keep + " of " + m.content.length + " characters shown. One tool result may use at most " + Math.round(share * 100) + "% of the context window. Call the tool again with a narrower query to see more.]"
656
+ });
657
+ });
658
+ return { messages: out || messages, truncated };
612
659
  }
613
660
  function evictOldToolResults({ messages, window: window2, reserve = 0, dropped = [] }) {
614
661
  if (!window2 || !Number.isFinite(window2)) return { messages, dropped };
615
662
  const budget = window2 - reserve;
616
- if (estimateTokens(messages) <= budget) return { messages, dropped };
663
+ const needed = estimateTokens(messages) + reserve;
664
+ if (estimateTokens(messages) <= budget) return { messages, dropped, needed, window: window2 };
617
665
  const out = messages.slice();
618
666
  for (let i = 0; i < out.length; i++) {
619
667
  if (estimateTokens(out) <= budget) break;
@@ -625,7 +673,7 @@ function evictOldToolResults({ messages, window: window2, reserve = 0, dropped =
625
673
  content: "[dropped to make room in the context window. Call the tool again if you still need it, with a narrower query.]"
626
674
  });
627
675
  }
628
- return { messages: out, dropped };
676
+ return { messages: out, dropped, needed, window: window2 };
629
677
  }
630
678
  function modelWindowFrom(payload, modelId) {
631
679
  for (const family of payload && payload.llms || []) {
@@ -3940,19 +3988,15 @@ function boot(cfg) {
3940
3988
  var toggleBtn = document.getElementById("llm-meta-widget-toggle");
3941
3989
  var historyEl = root.querySelector(".lmw-messages");
3942
3990
  var SCROLL_PIN_SLACK = 40;
3943
- var followingOutput = true;
3991
+ function thousands(n) {
3992
+ return (Math.round(n / 1e3) * 1e3).toLocaleString("en-US");
3993
+ }
3944
3994
  function atBottom() {
3945
3995
  return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
3946
3996
  }
3947
3997
  function pinToBottom() {
3948
3998
  historyEl.scrollTop = historyEl.scrollHeight;
3949
3999
  }
3950
- function pinIfFollowing() {
3951
- if (followingOutput) pinToBottom();
3952
- }
3953
- historyEl.addEventListener("scroll", function() {
3954
- followingOutput = atBottom();
3955
- });
3956
4000
  var formEl = root.querySelector(".lmw-form");
3957
4001
  var inputEl = root.querySelector(".lmw-input");
3958
4002
  var clearBtn = root.querySelector(".lmw-clear");
@@ -4505,6 +4549,7 @@ function boot(cfg) {
4505
4549
  }
4506
4550
  var pendingTurnLabel = null;
4507
4551
  function appendTurn(role, text, turnLabel) {
4552
+ var following = atBottom();
4508
4553
  var welcome = historyEl.querySelector(".lmw-welcome");
4509
4554
  if (welcome) welcome.remove();
4510
4555
  var div = document.createElement("div");
@@ -4531,7 +4576,7 @@ function boot(cfg) {
4531
4576
  div.appendChild(document.createTextNode(" "));
4532
4577
  div.appendChild(body);
4533
4578
  historyEl.appendChild(div);
4534
- pinIfFollowing();
4579
+ if (role === "user" || following) pinToBottom();
4535
4580
  body.roleLabel = label;
4536
4581
  return body;
4537
4582
  }
@@ -4581,6 +4626,7 @@ function boot(cfg) {
4581
4626
  return liveChips;
4582
4627
  }
4583
4628
  function announceToolCall(toolCall) {
4629
+ var following = atBottom();
4584
4630
  var row = chipRow();
4585
4631
  if (!row) return;
4586
4632
  var chip = document.createElement("span");
@@ -4589,7 +4635,7 @@ function boot(cfg) {
4589
4635
  chip.dataset.tool = toolCall.name;
4590
4636
  row.appendChild(chip);
4591
4637
  pendingChips.push(chip);
4592
- pinIfFollowing();
4638
+ if (following) pinToBottom();
4593
4639
  }
4594
4640
  function resolveToolCall(outcome) {
4595
4641
  var idx = pendingChips.findIndex(function(c) {
@@ -4605,6 +4651,7 @@ function boot(cfg) {
4605
4651
  var currentThinkingBody = null;
4606
4652
  var activeAssistantBody = null;
4607
4653
  function ensureThinkingBlock() {
4654
+ var following = atBottom();
4608
4655
  if (currentThinkingBody) return currentThinkingBody;
4609
4656
  var details = document.createElement("details");
4610
4657
  details.className = "message-thinking";
@@ -4629,20 +4676,33 @@ function boot(cfg) {
4629
4676
  } else {
4630
4677
  historyEl.appendChild(details);
4631
4678
  }
4632
- pinIfFollowing();
4679
+ if (following) pinToBottom();
4633
4680
  currentThinkingBlock = details;
4634
4681
  currentThinkingBody = body;
4635
4682
  return body;
4636
4683
  }
4637
4684
  function collapseThinkingBlock() {
4638
- if (currentThinkingBlock) {
4639
- currentThinkingBlock.classList.remove("thinking-active");
4640
- currentThinkingBlock.open = false;
4641
- var summary = currentThinkingBlock.querySelector("summary");
4642
- if (summary) summary.textContent = "\u{1F914} thinking (finished)";
4643
- }
4685
+ var block2 = currentThinkingBlock;
4644
4686
  currentThinkingBlock = null;
4645
4687
  currentThinkingBody = null;
4688
+ if (!block2) return;
4689
+ block2.classList.remove("thinking-active");
4690
+ var summary = block2.querySelector("summary");
4691
+ if (summary) summary.textContent = "\u{1F914} thinking (finished)";
4692
+ var body = block2.querySelector(".message-thinking-content");
4693
+ if (body) {
4694
+ body.style.overflow = "visible";
4695
+ body.style.maxHeight = "none";
4696
+ }
4697
+ if (typeof requestAnimationFrame !== "function") {
4698
+ block2.open = false;
4699
+ return;
4700
+ }
4701
+ requestAnimationFrame(function() {
4702
+ requestAnimationFrame(function() {
4703
+ block2.open = false;
4704
+ });
4705
+ });
4646
4706
  }
4647
4707
  function stateReaders() {
4648
4708
  var declared = window[STATE_GLOBAL] || {};
@@ -4746,7 +4806,6 @@ function boot(cfg) {
4746
4806
  var userText = inputEl.value.trim();
4747
4807
  if (!userText) return;
4748
4808
  inputEl.value = "";
4749
- followingOutput = true;
4750
4809
  appendTurn("user", userText, pendingTurnLabel);
4751
4810
  pendingTurnLabel = null;
4752
4811
  if (currentAbort) {
@@ -4786,10 +4845,18 @@ function boot(cfg) {
4786
4845
  contextWindow: modelWindow,
4787
4846
  provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
4788
4847
  signal: currentAbort.signal,
4789
- onContextEvicted: function(dropped) {
4790
- var last = dropped[dropped.length - 1];
4791
- var name = last && last.name ? last.name : "tool";
4792
- appendTurn("system", "Dropped an earlier " + name + " result to make room in the model's context. Ask again if you need it.");
4848
+ onContextTruncated: function(list2) {
4849
+ var cut = list2[list2.length - 1] || {};
4850
+ appendTurn("system", "Shortened an oversized " + (cut.name || "tool") + " result to fit the context: " + thousands(cut.fromTokens || 0) + " tokens, where one result may use at most " + thousands(cut.toTokens || 0) + ". Ask again with a narrower question for the complete result.");
4851
+ },
4852
+ onContextEvicted: function(info) {
4853
+ var dropped = info.dropped || [];
4854
+ var names = dropped.map(function(d) {
4855
+ return d.name || "a tool";
4856
+ });
4857
+ var what = names.length === 1 ? "the earliest tool result (" + names[0] + ")" : "the " + names.length + " earliest tool results (" + names.join(", ") + ")";
4858
+ var sizes = info.needed && info.window ? " About " + thousands(info.needed) + " tokens were needed and " + thousands(info.window) + " fit in " + MODEL + "'s context." : "";
4859
+ appendTurn("system", "Dropped " + what + " to make room." + sizes + " Ask again, or narrow the question, for an answer based on complete tool results.");
4793
4860
  },
4794
4861
  onToolCall: function(toolCall) {
4795
4862
  announceToolCall(toolCall);
@@ -4809,19 +4876,21 @@ function boot(cfg) {
4809
4876
  collapseThinkingBlock();
4810
4877
  },
4811
4878
  onThinkingDelta: function(delta) {
4879
+ var following = atBottom();
4812
4880
  var body = ensureThinkingBlock();
4813
4881
  body.appendChild(document.createTextNode(delta));
4814
4882
  body.scrollTop = body.scrollHeight;
4815
- pinIfFollowing();
4883
+ if (following) pinToBottom();
4816
4884
  },
4817
4885
  onTextDelta: function(delta) {
4886
+ var following = atBottom();
4818
4887
  markDone(assistantBody.roleLabel);
4819
4888
  collapseThinkingBlock();
4820
4889
  assistantMarkdown += delta;
4821
4890
  renderMarkdownInto(assistantBody, assistantMarkdown);
4822
4891
  liveTurn.content = assistantMarkdown;
4823
4892
  persistSoon();
4824
- pinIfFollowing();
4893
+ if (following) pinToBottom();
4825
4894
  }
4826
4895
  });
4827
4896
  liveTurn.content = result.content;
@@ -335,6 +335,8 @@ export async function runChatLoop(opts) {
335
335
  // Called when a tool result is dropped to make room, so the panel can say
336
336
  // so rather than the context quietly thinning.
337
337
  onContextEvicted,
338
+ // Called when one oversized result is shortened, so the panel can say so.
339
+ onContextTruncated,
338
340
  // "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
339
341
  // Ollama, with no server component of ours in between. The loop is the
340
342
  // same either way — only the call is swapped.
@@ -377,6 +379,7 @@ export async function runChatLoop(opts) {
377
379
 
378
380
  const rounds = []
379
381
  const evicted = []
382
+ const truncatedResults = []
380
383
  // Fixed for the turn: the declared tool set does not change between rounds.
381
384
  const toolsTokens = estimateToolsTokens([].concat(
382
385
  singleOpts.localTools || [], remoteTools || [], hostWideTools || []))
@@ -422,6 +425,16 @@ export async function runChatLoop(opts) {
422
425
  onRoundStart?.(round)
423
426
 
424
427
  // The loop is the same whoever answers; only the call differs.
428
+ // One oversized result is capped first: it frees hundreds of times more
429
+ // than dropping old results, and keeps what the model asked for.
430
+ const capped = truncateOversizedToolResults({
431
+ messages, window: contextWindow, truncated: truncatedResults
432
+ });
433
+ if (capped.messages !== messages) {
434
+ messages = capped.messages;
435
+ onContextTruncated?.(truncatedResults.slice());
436
+ }
437
+
425
438
  // Make room before sending, rather than after being refused. Only old
426
439
  // tool results are given up; see evictOldToolResults.
427
440
  const room = evictOldToolResults({
@@ -431,7 +444,7 @@ export async function runChatLoop(opts) {
431
444
  });
432
445
  if (room.messages !== messages) {
433
446
  messages = room.messages;
434
- onContextEvicted?.(evicted.slice());
447
+ onContextEvicted?.({ dropped: evicted.slice(), needed: room.needed, window: room.window });
435
448
  }
436
449
 
437
450
  const call = provider === "ollama" ? ollamaChatCall : singleLlmCall
@@ -937,23 +950,53 @@ export const PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json"
937
950
  // is already generous and a 1M-token model does not need 250 KB of it.
938
951
  export const RESOURCE_SHARE_OF_WINDOW = 0.0625
939
952
 
953
+ // Bytes per token for SIZING that budget — not the estimator above, which
954
+ // measures real text. Kept at four so the budget stays exactly what it was
955
+ // (8 KB on a 32k model): how many tokens those bytes become depends on the
956
+ // resource, and re-sizing budgets is not this constant's job.
957
+ export const RESOURCE_BYTES_PER_TOKEN = 4
958
+
940
959
  export function resourceBudgetBytes(window, { floor = 8000, cap = 64_000 } = {}) {
941
960
  if (!window || !Number.isFinite(window)) return floor
942
- const share = Math.round(window * RESOURCE_SHARE_OF_WINDOW * CHARS_PER_TOKEN)
961
+ const share = Math.round(window * RESOURCE_SHARE_OF_WINDOW * RESOURCE_BYTES_PER_TOKEN)
943
962
  return Math.min(cap, Math.max(floor, share))
944
963
  }
945
964
 
946
- // A rough token count, the same shape the hub's own estimator uses: characters
947
- // over four, plus a fixed cost per message. Deliberately approximate — it is
948
- // used to decide "drop the oldest tool result", never to fill the window
949
- // exactly, and it errs high so the decision is made early rather than late.
950
- export const CHARS_PER_TOKEN = 4
965
+ // A rough token count, the same three classes the hub's estimator uses.
966
+ //
967
+ // One ratio cannot describe both prose and a tool result. Letters and spaces
968
+ // tokenize at roughly 3.5 characters each; digits, punctuation and symbols go
969
+ // much denser, and a JSON tool result is mostly those.
970
+ //
971
+ // Measured against a real refusal (2026-10-17): a 289,101-character request
972
+ // the provider counted as 135,291 tokens was 33% letters, 16% digits and 45%
973
+ // punctuation. At a flat four characters per token that is 72,275 — 53% of the
974
+ // truth — so nothing was evicted and the provider refused the turn instead.
975
+ // These classes put it at 106%: high, which is the safe direction here, since
976
+ // underestimating means the turn dies while overestimating only gives up an
977
+ // old tool result slightly early.
978
+ export const PROSE_CHARS_PER_TOKEN = 3.5
979
+ export const DENSE_CHARS_PER_TOKEN = 1.6
980
+ export const CJK_CHARS_PER_TOKEN = 1
951
981
  export const MESSAGE_OVERHEAD_TOKENS = 4
952
982
 
983
+ const CJK_PATTERN = /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g
984
+ const PROSE_PATTERN = /[A-Za-z \n\t]/g
985
+
986
+ export function estimateTextTokens(text) {
987
+ const str = typeof text === "string" ? text : String(text ?? "")
988
+ const cjk = (str.match(CJK_PATTERN) || []).length
989
+ const prose = (str.match(PROSE_PATTERN) || []).length
990
+ const dense = str.length - cjk - prose
991
+ return Math.ceil(cjk / CJK_CHARS_PER_TOKEN +
992
+ prose / PROSE_CHARS_PER_TOKEN +
993
+ dense / DENSE_CHARS_PER_TOKEN)
994
+ }
995
+
953
996
  export function estimateTokens(messages) {
954
997
  return (messages || []).reduce((sum, m) => {
955
998
  const content = typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? "")
956
- return sum + Math.ceil(content.length / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD_TOKENS
999
+ return sum + estimateTextTokens(content) + MESSAGE_OVERHEAD_TOKENS
957
1000
  }, 0)
958
1001
  }
959
1002
 
@@ -969,7 +1012,57 @@ export function estimateToolsTokens(tools) {
969
1012
  const text = (tools || []).map((t) => JSON.stringify({
970
1013
  name: t?.name, description: t?.description, schema: t?.input_schema || t?.inputSchema
971
1014
  })).join("")
972
- return Math.ceil(text.length / CHARS_PER_TOKEN)
1015
+ // Counted with the same classes: a schema is JSON, which is the dense case.
1016
+ return estimateTextTokens(text)
1017
+ }
1018
+
1019
+ // A single tool result may use at most this share of the window.
1020
+ //
1021
+ // Measured over 179 real tool results (2026-10-17): the median is 2.1% of the
1022
+ // window and the 90th percentile 20%, but one `togovar_search_variant` result
1023
+ // came to 94% — 120,667 tokens — and alone could not coexist with a
1024
+ // conversation. All six over-budget requests in that sample were caused by one
1025
+ // oversized result, so capping it clears every one of them while touching only
1026
+ // 4.5% of results at all.
1027
+ //
1028
+ // 40% keeps a useful prefix of even the worst result (42% of it) and still
1029
+ // leaves most of the window for the conversation, the schemas and the answer.
1030
+ export const MAX_TOOL_RESULT_SHARE = 0.4
1031
+
1032
+ // Shortens any single tool result that exceeds that share.
1033
+ //
1034
+ // This runs BEFORE eviction, because it is strictly the better move: on the
1035
+ // audited turn it frees ~69,000 tokens from one result, where dropping the
1036
+ // oldest results freed 160 and lost two results the model had asked for. The
1037
+ // model is told what happened, in the result itself, so it can narrow its
1038
+ // query instead of assuming it received everything.
1039
+ export function truncateOversizedToolResults({ messages, window,
1040
+ share = MAX_TOOL_RESULT_SHARE,
1041
+ truncated = [] } = {}) {
1042
+ if (!window || !Number.isFinite(window)) return { messages, truncated }
1043
+
1044
+ const cap = Math.floor(window * share)
1045
+ let out = null
1046
+
1047
+ ;(messages || []).forEach((m, i) => {
1048
+ if (m?.role !== "tool" || typeof m.content !== "string") return
1049
+ const tokens = estimateTextTokens(m.content)
1050
+ if (tokens <= cap) return
1051
+
1052
+ // Scale the cut by the measured ratio rather than a fixed chars-per-token,
1053
+ // so a dense result is cut by what it actually costs.
1054
+ const keep = Math.max(1, Math.floor(m.content.length * cap / tokens))
1055
+ out = out || messages.slice()
1056
+ truncated.push({ name: m.name, fromTokens: tokens, toTokens: cap })
1057
+ out[i] = Object.assign({}, m, {
1058
+ content: m.content.slice(0, keep) +
1059
+ "\n\n[truncated: " + keep + " of " + m.content.length + " characters shown. " +
1060
+ "One tool result may use at most " + Math.round(share * 100) +
1061
+ "% of the context window. Call the tool again with a narrower query to see more.]"
1062
+ })
1063
+ })
1064
+
1065
+ return { messages: out || messages, truncated }
973
1066
  }
974
1067
 
975
1068
  // Makes room for the next round by dropping the OLDEST tool results, which is
@@ -990,7 +1083,12 @@ export function evictOldToolResults({ messages, window, reserve = 0, dropped = [
990
1083
  if (!window || !Number.isFinite(window)) return { messages, dropped }
991
1084
 
992
1085
  const budget = window - reserve
993
- if (estimateTokens(messages) <= budget) return { messages, dropped }
1086
+ // What the whole turn would have needed, against what the model holds. The
1087
+ // panel shows both, because "an old result was dropped" on its own tells a
1088
+ // reader nothing about whether to retry, shorten the question, or change
1089
+ // model.
1090
+ const needed = estimateTokens(messages) + reserve
1091
+ if (estimateTokens(messages) <= budget) return { messages, dropped, needed, window }
994
1092
 
995
1093
  const out = messages.slice()
996
1094
  for (let i = 0; i < out.length; i++) {
@@ -1005,7 +1103,7 @@ export function evictOldToolResults({ messages, window, reserve = 0, dropped = [
1005
1103
  "if you still need it, with a narrower query.]"
1006
1104
  })
1007
1105
  }
1008
- return { messages: out, dropped }
1106
+ return { messages: out, dropped, needed, window }
1009
1107
  }
1010
1108
 
1011
1109
  export function modelWindowFrom(payload, modelId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibranch/llm-meta-widget",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "//version": "Must stay in step with lib/llm_meta_widget/version.rb. Two independently versioned copies of the same client is the drift docs/custom-element-distribution.md warns about: bump both, release both.",
5
5
  "description": "Embeddable browser chat widget: a custom element that discovers tools at page, origin and hub scope and dispatches each tool call to the right one. Two lines of HTML on any host, Rails or not.",
6
6
  "keywords": [