llm_meta_widget 0.12.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 845c38baf1aca6cc874a77c9f324895c6001664dffe4d891d8b31ed2057a3b2b
4
- data.tar.gz: 825acda8a84c599e09e8d11cad9fe738092aa807b41911a99889e76dab15c800
3
+ metadata.gz: 9d3172a4471dd7326d3455cbb4d39ed5f19092df52e81828b98d71a0c541da57
4
+ data.tar.gz: 1ec235057d668445bcb40cdb6b584cc73c1c579ee54c2cf36b7bcef699efb5b9
5
5
  SHA512:
6
- metadata.gz: 0244dc664c60f88e1cf29e9cb52ebc9e33bf61e8ab2ab180a8e0873d587bf1cf96d8865eeb9d97b0fe325cec00c43e6e154cef74c511f7f16894a2112d17ad23
7
- data.tar.gz: 5584f49e2c7e848ef93fdb5bf6cb1270fa3154f77beee255eb64e221aab20ce8e4828b4b8381c5268e1503b1b0967d4fab81811be718f23f247951ccdf1c142a
6
+ metadata.gz: 1dcda012841f3a4c1f253c837d1149292f0c19cdc6a5396cb7ab7064bfb0ac0dbae5414bc0e33529fb925a4d5b15de28a78cda13bba26bf4856fca84f13a94e2
7
+ data.tar.gz: 338955619e9f6712e6889bbe62a295de75b7cf9cb6571d7cd9aab0ac227b52caf5d6c891b14ef136bcc115e6da0f721f441b4a9a7bd3be96248d55cde441870e
data/CHANGELOG.md CHANGED
@@ -3,6 +3,59 @@
3
3
  This file starts at 0.8.0, the first release with a breaking change worth
4
4
  calling out. Earlier releases are in the git history (`git log --oneline v0.7.6`).
5
5
 
6
+ ## 0.12.2
7
+
8
+ ### Changed: one oversized tool result is shortened, not paid for by the others
9
+
10
+ A single tool result may now use at most 40% of the model's context window. A
11
+ result above that is cut to the cap, with a note in the result itself telling
12
+ the model how much was shown and to call the tool again with a narrower query.
13
+
14
+ This replaces giving up older results as the first response to a turn that will
15
+ not fit. Dropping the oldest results is a sound rule when results are of
16
+ similar size, and a poor one otherwise: on a real turn (2026-10-17) it gave up
17
+ two results of 80 tokens each — 160 tokens against a shortfall of about 2,600 —
18
+ and left untouched the one result that was causing the problem, at 120,667
19
+ tokens, or 94% of the window. The two results it discarded were ones the model
20
+ had asked for.
21
+
22
+ Measured over 179 real tool results: the median is 2.1% of the window, so the
23
+ cap touches 4.5% of results. All six over-budget requests in that sample were
24
+ caused by one oversized result, and the cap clears every one of them. On the
25
+ turn above it frees about 69,000 tokens and nothing has to be given up at all.
26
+
27
+ Giving up older results remains, for a conversation that is too long in
28
+ aggregate rather than because of one result.
29
+
30
+ The panel reports both, with the numbers: what was shortened or given up, how
31
+ many tokens the turn needed, and how many the model holds.
32
+
33
+ ### Fixed: scrolling the panel after a turn with thinking blocks (Chrome)
34
+
35
+ After a turn that showed thinking blocks, the panel could not be scrolled. The
36
+ scrollbar moved a few tens of pixels and stopped, by wheel, trackpad and
37
+ keyboard alike, while `scrollTop` set from script still worked and held. This
38
+ was reported from two different deployments and took several wrong diagnoses
39
+ from us before the mechanism was found.
40
+
41
+ A thinking block is a `<details>` holding its own scroller. Closing one with
42
+ `open = false`, at a moment when that block is the only reason the message list
43
+ overflows, leaves Chrome with a stale wheel range — the list's overflow from
44
+ that instant, often only tens of pixels. `scrollTop` and `scrollHeight` remain
45
+ correct, which is why nothing in the page looked wrong. Chrome 154 reproduces
46
+ it with no widget code involved at all; Firefox 158 does not, and it has been
47
+ reported upstream.
48
+
49
+ The widget now takes the scroller off the inner box before closing it, and
50
+ closes on the second animation frame, because one frame is not enough for
51
+ Chrome to pick up the new overflow. Measured on a real deployment: 6 of 6 turns
52
+ healthy after the change, against 3 of 4 faulting before, with the triggering
53
+ condition confirmed present 2 to 4 times in every passing run.
54
+
55
+ One consequence: a finished thinking block that a reader reopens is shown at
56
+ full height rather than as a 10em scrolling box. Putting the scroller back when
57
+ it reopens would let a manual close trigger the same Chrome state again.
58
+
6
59
  ## 0.12.1
7
60
 
8
61
  ### Fixed: a tool result is counted as JSON, not as prose
@@ -29,8 +82,8 @@ heard that the reader had moved — and the pin put them back at the end, so the
29
82
  decision never flipped. The question is now asked synchronously, immediately
30
83
  before anything is added, which has no such window.
31
84
 
32
- A separate report of the panel jumping to the TOP after a turn ends is not
33
- explained by this and remains open.
85
+ A separate report of the panel jumping to the TOP after a turn ends had a
86
+ different cause, fixed in 0.12.2 below.
34
87
 
35
88
  ## 0.12.0
36
89
 
data/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.1"
64
+ gem "llm_meta_widget", "~> 0.12.2"
65
65
  ```
66
66
 
67
67
  ```bash
@@ -169,6 +169,12 @@ function boot(cfg) {
169
169
  // and we stop following; come back to it and we resume.
170
170
  var SCROLL_PIN_SLACK = 40; // px from the end still counts as "at the end"
171
171
 
172
+ // Rounded to the nearest thousand and grouped, so the numbers read as a
173
+ // scale rather than as a precise count the estimate cannot support.
174
+ function thousands(n) {
175
+ return (Math.round(n / 1000) * 1000).toLocaleString("en-US");
176
+ }
177
+
172
178
  function atBottom() {
173
179
  return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
174
180
  }
@@ -1127,15 +1133,45 @@ function boot(cfg) {
1127
1133
  return body;
1128
1134
  }
1129
1135
 
1136
+ // Collapsing a thinking block is what broke scrolling in Chrome.
1137
+ //
1138
+ // A thinking block is a <details> holding its own scroller (max-height +
1139
+ // overflow-y on .message-thinking-content). Closing it with `open = false`
1140
+ // at a moment when that block is the only reason .lmw-messages overflows
1141
+ // makes the list briefly non-scrollable. When later content makes it
1142
+ // scrollable again, Chrome keeps the WHEEL range from that moment — tens of
1143
+ // pixels — while scrollTop and scrollHeight stay correct. So the reader can
1144
+ // scroll a few pixels and no further, by any input method, while
1145
+ // `scrollTop = 600` set from script still works. Chrome 154 reproduces it
1146
+ // with no widget code at all; Firefox 158 never does (reported 2026-10-17).
1147
+ //
1148
+ // Taking the scroller off the inner box BEFORE the close avoids the state.
1149
+ // Two frames, because one is not enough for Chrome to pick up the new
1150
+ // overflow before the close lands.
1151
+ //
1152
+ // The box stays a non-scroller afterwards, so a reader who reopens a
1153
+ // finished block sees it at full height instead of a 10em window. Putting
1154
+ // the scroller back on reopen would let a manual close trigger the bug
1155
+ // again, which is a worse trade.
1130
1156
  function collapseThinkingBlock() {
1131
- if (currentThinkingBlock) {
1132
- currentThinkingBlock.classList.remove("thinking-active");
1133
- currentThinkingBlock.open = false;
1134
- var summary = currentThinkingBlock.querySelector("summary");
1135
- if (summary) summary.textContent = "🤔 thinking (finished)";
1136
- }
1157
+ var block = currentThinkingBlock;
1137
1158
  currentThinkingBlock = null;
1138
1159
  currentThinkingBody = null;
1160
+ if (!block) return;
1161
+
1162
+ block.classList.remove("thinking-active");
1163
+ var summary = block.querySelector("summary");
1164
+ if (summary) summary.textContent = "🤔 thinking (finished)";
1165
+
1166
+ var body = block.querySelector(".message-thinking-content");
1167
+ if (body) {
1168
+ body.style.overflow = "visible";
1169
+ body.style.maxHeight = "none";
1170
+ }
1171
+ if (typeof requestAnimationFrame !== "function") { block.open = false; return; }
1172
+ requestAnimationFrame(function() {
1173
+ requestAnimationFrame(function() { block.open = false; });
1174
+ });
1139
1175
  }
1140
1176
 
1141
1177
  // Every declaration channel the widget hands the model carries a
@@ -1317,12 +1353,31 @@ function boot(cfg) {
1317
1353
  contextWindow: modelWindow,
1318
1354
  provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
1319
1355
  signal: currentAbort.signal,
1320
- onContextEvicted: function(dropped) {
1321
- // Said out loud: a context that quietly thins out makes a later answer
1322
- // look inexplicably worse, with nothing on screen to explain it.
1323
- var last = dropped[dropped.length - 1];
1324
- var name = (last && last.name) ? last.name : "tool";
1325
- appendTurn("system", "Dropped an earlier " + name + " result to make room in the model's context. Ask again if you need it.");
1356
+ onContextTruncated: function(list) {
1357
+ // A shortened result is not a failure, but the reader has to know the
1358
+ // model answered from part of it — and the numbers say whether a narrower
1359
+ // question would help.
1360
+ var cut = list[list.length - 1] || {};
1361
+ appendTurn("system", "Shortened an oversized " + (cut.name || "tool") + " result to fit the context: " +
1362
+ thousands(cut.fromTokens || 0) + " tokens, where one result may use at most " +
1363
+ thousands(cut.toTokens || 0) + ". Ask again with a narrower question for the complete result.");
1364
+ },
1365
+ onContextEvicted: function(info) {
1366
+ // Said out loud, with the numbers. A context that quietly thins out makes
1367
+ // a later answer look inexplicably worse, and "an old result was dropped"
1368
+ // alone does not tell a reader whether to retry, shorten the question, or
1369
+ // pick a model with more room.
1370
+ var dropped = info.dropped || [];
1371
+ var names = dropped.map(function(d) { return d.name || "a tool"; });
1372
+ var what = names.length === 1
1373
+ ? "the earliest tool result (" + names[0] + ")"
1374
+ : "the " + names.length + " earliest tool results (" + names.join(", ") + ")";
1375
+ var sizes = (info.needed && info.window)
1376
+ ? " About " + thousands(info.needed) + " tokens were needed and " +
1377
+ thousands(info.window) + " fit in " + MODEL + "'s context."
1378
+ : "";
1379
+ appendTurn("system", "Dropped " + what + " to make room." + sizes +
1380
+ " Ask again, or narrow the question, for an answer based on complete tool results.");
1326
1381
  },
1327
1382
  onToolCall: function(toolCall) {
1328
1383
  announceToolCall(toolCall);
@@ -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({
@@ -622,10 +634,34 @@ function estimateToolsTokens(tools) {
622
634
  })).join("");
623
635
  return estimateTextTokens(text);
624
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 };
659
+ }
625
660
  function evictOldToolResults({ messages, window: window2, reserve = 0, dropped = [] }) {
626
661
  if (!window2 || !Number.isFinite(window2)) return { messages, dropped };
627
662
  const budget = window2 - reserve;
628
- if (estimateTokens(messages) <= budget) return { messages, dropped };
663
+ const needed = estimateTokens(messages) + reserve;
664
+ if (estimateTokens(messages) <= budget) return { messages, dropped, needed, window: window2 };
629
665
  const out = messages.slice();
630
666
  for (let i = 0; i < out.length; i++) {
631
667
  if (estimateTokens(out) <= budget) break;
@@ -637,7 +673,7 @@ function evictOldToolResults({ messages, window: window2, reserve = 0, dropped =
637
673
  content: "[dropped to make room in the context window. Call the tool again if you still need it, with a narrower query.]"
638
674
  });
639
675
  }
640
- return { messages: out, dropped };
676
+ return { messages: out, dropped, needed, window: window2 };
641
677
  }
642
678
  function modelWindowFrom(payload, modelId) {
643
679
  for (const family of payload && payload.llms || []) {
@@ -3952,6 +3988,9 @@ function boot(cfg) {
3952
3988
  var toggleBtn = document.getElementById("llm-meta-widget-toggle");
3953
3989
  var historyEl = root.querySelector(".lmw-messages");
3954
3990
  var SCROLL_PIN_SLACK = 40;
3991
+ function thousands(n) {
3992
+ return (Math.round(n / 1e3) * 1e3).toLocaleString("en-US");
3993
+ }
3955
3994
  function atBottom() {
3956
3995
  return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
3957
3996
  }
@@ -4643,14 +4682,27 @@ function boot(cfg) {
4643
4682
  return body;
4644
4683
  }
4645
4684
  function collapseThinkingBlock() {
4646
- if (currentThinkingBlock) {
4647
- currentThinkingBlock.classList.remove("thinking-active");
4648
- currentThinkingBlock.open = false;
4649
- var summary = currentThinkingBlock.querySelector("summary");
4650
- if (summary) summary.textContent = "\u{1F914} thinking (finished)";
4651
- }
4685
+ var block2 = currentThinkingBlock;
4652
4686
  currentThinkingBlock = null;
4653
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
+ });
4654
4706
  }
4655
4707
  function stateReaders() {
4656
4708
  var declared = window[STATE_GLOBAL] || {};
@@ -4793,10 +4845,18 @@ function boot(cfg) {
4793
4845
  contextWindow: modelWindow,
4794
4846
  provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
4795
4847
  signal: currentAbort.signal,
4796
- onContextEvicted: function(dropped) {
4797
- var last = dropped[dropped.length - 1];
4798
- var name = last && last.name ? last.name : "tool";
4799
- 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.");
4800
4860
  },
4801
4861
  onToolCall: function(toolCall) {
4802
4862
  announceToolCall(toolCall);
@@ -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
@@ -1003,6 +1016,55 @@ export function estimateToolsTokens(tools) {
1003
1016
  return estimateTextTokens(text)
1004
1017
  }
1005
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 }
1066
+ }
1067
+
1006
1068
  // Makes room for the next round by dropping the OLDEST tool results, which is
1007
1069
  // the one eviction a chat loop can justify: the model has already read them and
1008
1070
  // said what it made of them in the assistant turn that follows, so its own
@@ -1021,7 +1083,12 @@ export function evictOldToolResults({ messages, window, reserve = 0, dropped = [
1021
1083
  if (!window || !Number.isFinite(window)) return { messages, dropped }
1022
1084
 
1023
1085
  const budget = window - reserve
1024
- 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 }
1025
1092
 
1026
1093
  const out = messages.slice()
1027
1094
  for (let i = 0; i < out.length; i++) {
@@ -1036,7 +1103,7 @@ export function evictOldToolResults({ messages, window, reserve = 0, dropped = [
1036
1103
  "if you still need it, with a narrower query.]"
1037
1104
  })
1038
1105
  }
1039
- return { messages: out, dropped }
1106
+ return { messages: out, dropped, needed, window }
1040
1107
  }
1041
1108
 
1042
1109
  export function modelWindowFrom(payload, modelId) {
@@ -1,3 +1,3 @@
1
1
  module LlmMetaWidget
2
- VERSION = "0.12.1"
2
+ VERSION = "0.12.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_meta_widget
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.12.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - jdkim