llm_meta_widget 0.11.1 → 0.12.1

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: a7169f9db8dacb339f604b908494879c8db5c32635cbcc76d5cda807eb1e4dfd
4
- data.tar.gz: ad8200955697be3b0d5e9f31cc0cde13882213537cf93582e36a612f348b5d4f
3
+ metadata.gz: 845c38baf1aca6cc874a77c9f324895c6001664dffe4d891d8b31ed2057a3b2b
4
+ data.tar.gz: 825acda8a84c599e09e8d11cad9fe738092aa807b41911a99889e76dab15c800
5
5
  SHA512:
6
- metadata.gz: 95b8f1577f51acd48d50fff55c6c28534656ab448041fa3b247230ae6cee3ba06b1046b4e9e8d94af20c570f06bcebaa519a9caac1e81fedd1bd17c433349762
7
- data.tar.gz: 6cb12977d3457b7c288242cb1244e0af36dea798df12104a0f1ec6fc5c64acdee95bfb04ed59a5e53e1d9e9e71632bf9e7367bfa8bd10a1e323734f3b9ea2eef
6
+ metadata.gz: 0244dc664c60f88e1cf29e9cb52ebc9e33bf61e8ab2ab180a8e0873d587bf1cf96d8865eeb9d97b0fe325cec00c43e6e154cef74c511f7f16894a2112d17ad23
7
+ data.tar.gz: 5584f49e2c7e848ef93fdb5bf6cb1270fa3154f77beee255eb64e221aab20ce8e4828b4b8381c5268e1503b1b0967d4fab81811be718f23f247951ccdf1c142a
data/CHANGELOG.md CHANGED
@@ -3,6 +3,99 @@
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.1
7
+
8
+ ### Fixed: a tool result is counted as JSON, not as prose
9
+
10
+ The turn was sized at roughly four characters per token, which is right for
11
+ prose and badly wrong for a tool result. A request of 289,101 characters that
12
+ the provider counted as 135,291 tokens was estimated at 72,275 — 53% of its
13
+ real size — so nothing was given up and the provider refused the turn with its
14
+ own error text. That request was 33% letters, 16% digits and 45% punctuation:
15
+ JSON, which tokenizes about twice as densely as prose.
16
+
17
+ Characters are now counted in three classes — letters and spaces, CJK, and
18
+ everything else — which puts that request at 106% of its real size. Erring
19
+ high is deliberate here: underestimating ends the turn, while overestimating
20
+ gives up an old tool result slightly early. Tool schemas are counted the same
21
+ way, since a schema is JSON too.
22
+
23
+ ### Fixed: following the output no longer fights a reader who scrolls
24
+
25
+ 0.12.0 decided whether to follow the output from the `scroll` event. A browser
26
+ dispatches that event a frame after the position changes, while deltas arrive
27
+ every few milliseconds, so a delta could pin the view before the listener
28
+ heard that the reader had moved — and the pin put them back at the end, so the
29
+ decision never flipped. The question is now asked synchronously, immediately
30
+ before anything is added, which has no such window.
31
+
32
+ A separate report of the panel jumping to the TOP after a turn ends is not
33
+ explained by this and remains open.
34
+
35
+ ## 0.12.0
36
+
37
+ Nothing a page must provide changes in this release: no new attribute, no
38
+ change to how the widget finds anything a page declares. A host moving a CDN
39
+ pin from `@0.11` to `@0.12` has nothing to edit.
40
+
41
+
42
+ ### Fixed: the panel can be read while a turn is still running
43
+
44
+ Adding content mid-turn always scrolled the panel back to the end — a tool
45
+ chip, a thinking block, or any delta. On a short answer nobody notices. On a
46
+ long tool-using turn the deltas keep coming for minutes, so scrolling back
47
+ through the answer was impossible by any means: wheel, trackpad and keyboard
48
+ all appeared dead, because each attempt was undone within milliseconds.
49
+
50
+ The panel now follows the output only while the reader is at the end of it.
51
+ Scroll away and it leaves you alone; come back to the end and it resumes.
52
+ Asking a new question always returns you to the end, and a conversation
53
+ restored after a page load still opens at its end.
54
+
55
+ ### Fixed: a wide table is readable instead of one letter per column
56
+
57
+ A markdown table was itself the scrolling box (`display: block;
58
+ overflow-x: auto`), so it was capped at the bubble width, squeezed its columns,
59
+ and `word-wrap: break-word` then broke every word one character per line. A
60
+ six-column table of genomic notation became a column of single letters. Tables
61
+ now sit in a wrapper that scrolls sideways while the table keeps its natural
62
+ width: the same answer went from 3,155px to 1,426px of message height in
63
+ Chromium, and the columns are readable.
64
+
65
+
66
+ ### Added: a turn is sized against the model's real context window
67
+
68
+ The widget now reads each model's context window from the hub (`/api/llms`
69
+ reports `max_input_tokens`) and uses it for two decisions it used to make from
70
+ fixed guesses.
71
+
72
+ **Old tool results are given up rather than letting the round be refused.**
73
+ When the next round would not fit, the oldest tool results are replaced — one
74
+ at a time, stopping as soon as the round fits — with a short note saying the
75
+ result was dropped and may be fetched again. Nothing else is touched: not the
76
+ system prompt, not the questions, and not what the assistant said about those
77
+ results, so the model keeps its own summary of what it read. The panel says
78
+ which tool's result went, so a thinner context is visible rather than silent.
79
+
80
+ A tool message is replaced, never removed, because a provider rejects a
81
+ `tool_call` that has no matching result.
82
+
83
+ The declared tools are counted too, not only the messages. Their names,
84
+ descriptions and schemas are part of every request — around twenty tools is a
85
+ few thousand tokens before the conversation starts — so a budget that counted
86
+ messages alone would evict too late to help.
87
+
88
+ **The reference-resource budget scales with the window.** It was a fixed 8 KB
89
+ whatever the model. It is now about 6% of the window — the same share 8 KB was
90
+ of the 32,000-token model it was chosen for — so a 128,000-token model may
91
+ attach about 32 KB, up to a 64 KB cap. A model whose window the hub does not
92
+ report keeps the old 8 KB.
93
+
94
+ Nothing changes for a host that does not want this: there is no new attribute,
95
+ and a hub that does not report a window makes the widget behave exactly as
96
+ before. It is a release of llm_meta_server, not the widget, that turns the
97
+ behaviour on.
98
+
6
99
  ## 0.11.1
7
100
 
8
101
  ### Fixed: a page action that navigates no longer loses the turn
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.11.1"
64
+ gem "llm_meta_widget", "~> 0.12.1"
65
65
  ```
66
66
 
67
67
  ```bash
@@ -152,17 +152,17 @@ The widget is a custom element in a single self-contained ES module, so the
152
152
  whole integration is two lines of HTML:
153
153
 
154
154
  ```html
155
- <script type="module" src="https://cdn.jsdelivr.net/npm/@aibranch/llm-meta-widget@0.11"></script>
155
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@aibranch/llm-meta-widget@0.12"></script>
156
156
  <llm-meta-widget llm-url="https://your-meta-server.example"
157
157
  model="gpt-oss-120b"
158
158
  greeting="Hi — ask me anything about this page."></llm-meta-widget>
159
159
  ```
160
160
 
161
- **`@0.11` is a version range, and it stops at 0.11.x on purpose.** You receive
161
+ **`@0.12` is a version range, and it stops at 0.12.x on purpose.** You receive
162
162
  bug fixes without editing your page, and a release that breaks your page cannot
163
163
  arrive on its own.
164
164
 
165
- Moving to 0.12 is therefore a change you make yourself. Read
165
+ Moving to 0.13 is therefore a change you make yourself. Read
166
166
  [CHANGELOG.md](CHANGELOG.md) before you do, because this is where what your page
167
167
  must provide can change. For example, 0.10.0 changed how the widget finds a page
168
168
  card: by media type rather than by element id. A page that declared its card the
@@ -698,8 +698,12 @@ the model's context. The widget reads two optional fields the
698
698
  `_meta`; the widget still reads that when `size` is absent, but it will be
699
699
  removed, so emit `size`.
700
700
  The widget decides whether it can afford the resource *before* fetching it. If
701
- the resource is over its budget of 8 KB, the bytes never cross the wire, and
702
- the LLM asks for what it needs through your tools instead.
701
+ the resource is over that budget, the bytes never cross the wire, and the LLM
702
+ asks for what it needs through your tools instead.
703
+ The budget is a share of the model's own context window, which the widget
704
+ reads from the hub: about 8 KB for a 32,000-token model, about 32 KB for a
705
+ 128,000-token one, and at most 64 KB. When the hub does not report a window,
706
+ the budget is 8 KB.
703
707
  - **`volatility`** — `"stable"` (read once, re-used) or `"volatile"` (re-read
704
708
  on every turn that uses it).
705
709
  - **`autoAttach`** — may the widget put this in the model's context without
@@ -26,7 +26,8 @@ import { runChatLoop, fetchMcpManifest, listMcpPrompts, getMcpPrompt,
26
26
  readStaticPrimitivesCard, getPagePrompt,
27
27
  planPageResources, pageResourceLinesForTurn,
28
28
  promptArgState, mergeStaticPrimitivesCards,
29
- PAGE_CARD_MEDIA_TYPE, pageHandlers } from "./orchestrator.js";
29
+ PAGE_CARD_MEDIA_TYPE, pageHandlers,
30
+ modelWindowFrom, resourceBudgetBytes } from "./orchestrator.js";
30
31
  import { marked } from "./marked.esm.js";
31
32
  import { readConfig } from "./config.js";
32
33
  import conversationCss from "../../stylesheets/llm_meta_widget/conversation.css";
@@ -154,6 +155,24 @@ function boot(cfg) {
154
155
  var root = document.getElementById("llm-meta-widget-chat");
155
156
  var toggleBtn = document.getElementById("llm-meta-widget-toggle");
156
157
  var historyEl = root.querySelector(".lmw-messages");
158
+
159
+ // Following the output, or reading back through it?
160
+ //
161
+ // Every mid-turn pin used to be unconditional: a tool chip, a thinking
162
+ // block, or any delta slammed the view back to the end. On a short answer
163
+ // nobody notices. On a long tool turn the deltas keep coming for minutes,
164
+ // and the reader cannot scroll anywhere by any means — wheel, trackpad,
165
+ // keyboard — because each attempt is undone within milliseconds. Reported
166
+ // from TogoMCP and reproduced on PubDictionaries (2026-10-16).
167
+ //
168
+ // The reader's own scrolling says which they are doing: leave the bottom
169
+ // and we stop following; come back to it and we resume.
170
+ var SCROLL_PIN_SLACK = 40; // px from the end still counts as "at the end"
171
+
172
+ function atBottom() {
173
+ return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
174
+ }
175
+ function pinToBottom() { historyEl.scrollTop = historyEl.scrollHeight; }
157
176
  var formEl = root.querySelector(".lmw-form");
158
177
  var inputEl = root.querySelector(".lmw-input");
159
178
  var clearBtn = root.querySelector(".lmw-clear");
@@ -379,8 +398,6 @@ function boot(cfg) {
379
398
  }
380
399
 
381
400
  function refreshRemoteToolsFromPicker() {
382
- // Flatten every SELECTED tool (across all servers) into remoteTools,
383
- // adapted to runChatLoop's expected { id, name, description, input_schema }.
384
401
  var flat = [];
385
402
  var seen = {};
386
403
  hubMcpServers.forEach(function(s) {
@@ -456,6 +473,10 @@ function boot(cfg) {
456
473
  // - Ollama: `available_models: [{ value, label, ... }]`
457
474
  // Anon widget uses api_key_uuid = "ollama-local" so only Ollama models
458
475
  // are invocable; filter to that family and use its subshape.
476
+ // Learned from every family, not only the one the picker lists: the
477
+ // configured model is often hosted while the picker shows local ones.
478
+ llmsPayload = payload;
479
+ modelWindow = modelWindowFrom(payload, MODEL);
459
480
  var flat = [];
460
481
  (payload.llms || []).forEach(function(llm) {
461
482
  if (llm.family !== "ollama") return;
@@ -601,7 +622,12 @@ function boot(cfg) {
601
622
  }
602
623
 
603
624
  if (modelPicker) {
604
- modelPicker.addEventListener("change", function() { MODEL = modelPicker.value; });
625
+ modelPicker.addEventListener("change", function() {
626
+ MODEL = modelPicker.value;
627
+ // The window belongs to the model, not to the session: a switch has to
628
+ // move it, or every budget below is sized for the model left behind.
629
+ modelWindow = modelWindowFrom(llmsPayload, MODEL);
630
+ });
605
631
  }
606
632
 
607
633
  // Close the tools dropdown on any click outside its area. Native
@@ -634,25 +660,52 @@ function boot(cfg) {
634
660
  var hostWidePrompts = pageCard.prompts.map(function(p) {
635
661
  return Object.assign({}, p, { source: "page" });
636
662
  });
663
+ // How much room the selected model has, from /api/llms. null until that
664
+ // call answers, and null forever if the hub is older than the field — in
665
+ // which case the loop budgets nothing, exactly as it always did.
666
+ var modelWindow = null;
667
+ // The /api/llms answer, kept so switching model in the picker can resolve
668
+ // that model's window. Without it a visitor who switches from a 262k model
669
+ // to a 32k one keeps the big window — no eviction, and a resource budget
670
+ // far larger than the small model can hold.
671
+ var llmsPayload = null;
637
672
  var resourceContext = null; // payload of the host's reference resource
638
673
  var resourcePlan = null; // the pre-flight decision, kept so Clear can re-arm the gate
639
674
 
640
675
  // Beyond ~2k tokens a reference resource starts crowding out the
641
676
  // conversation on a 32k-context local model — production PubDictionaries
642
- // is 218 dictionaries / ~35KB / ~10k tokens.
643
- var RESOURCE_BUDGET_BYTES = 8000;
644
-
645
- // The page's own resources, planned once from the card. Unlike the hub's
646
- // single reference resource these are a list: a page declares one per
647
- // thing it knows, each with its own description.
648
- var pageResourcePlans = planPageResources({
649
- entries: pageCard.resources,
650
- budgetBytes: RESOURCE_BUDGET_BYTES,
651
- onSkip: function(plan) {
652
- console.info("[llm_meta_widget] not attaching " + plan.uri +
653
- " (" + plan.reason + ", size=" + plan.size + ")");
677
+ // is 218 dictionaries / ~35KB / ~10k tokens. That share of the window is
678
+ // what scales now the window is known: 8000 remains the 32k case, and the
679
+ // floor for a model whose window the hub does not report.
680
+ function resourceBudget() { return resourceBudgetBytes(modelWindow); }
681
+
682
+ // The page's own resources, planned from the card — but NOT here. Unlike
683
+ // the hub's single reference resource these are a list: a page declares one
684
+ // per thing it knows, each with its own description.
685
+ //
686
+ // Planning them at this point would pin every one of them to the floor
687
+ // budget: the budget follows the model's window, which arrives with
688
+ // /api/llms a moment after this runs. The budget was derived and then
689
+ // never used, so a 128k model could attach nothing a 32k one could not.
690
+ // Re-planned whenever the budget changes, which is at most once per model
691
+ // switch.
692
+ var pageResourcePlans = null;
693
+ var pageResourcePlansBudget = null;
694
+ function pageResourcePlansNow() {
695
+ var budget = resourceBudget();
696
+ if (pageResourcePlans === null || budget !== pageResourcePlansBudget) {
697
+ pageResourcePlansBudget = budget;
698
+ pageResourcePlans = planPageResources({
699
+ entries: pageCard.resources,
700
+ budgetBytes: budget,
701
+ onSkip: function(plan) {
702
+ console.info("[llm_meta_widget] not attaching " + plan.uri +
703
+ " (" + plan.reason + ", size=" + plan.size + ")");
704
+ }
705
+ });
654
706
  }
655
- });
707
+ return pageResourcePlans;
708
+ }
656
709
  var pageResourceCache = {};
657
710
 
658
711
 
@@ -694,7 +747,7 @@ function boot(cfg) {
694
747
  // the bug this shape prevents.
695
748
  var loaded = await loadHostResource({
696
749
  endpoint: endpoint,
697
- budgetBytes: RESOURCE_BUDGET_BYTES,
750
+ budgetBytes: resourceBudget(),
698
751
  list: listMcpResources,
699
752
  read: readMcpResource,
700
753
  onSkip: function(plan) {
@@ -772,7 +825,9 @@ function boot(cfg) {
772
825
  });
773
826
  if (body.parentNode) body.parentNode.appendChild(row);
774
827
  });
775
- historyEl.scrollTop = historyEl.scrollHeight;
828
+ // Unconditional: a restored conversation opens at its end, which is where
829
+ // the reader left off. Nothing is streaming yet, so nobody is being yanked.
830
+ pinToBottom();
776
831
  return true;
777
832
  }
778
833
 
@@ -876,6 +931,8 @@ function boot(cfg) {
876
931
  var pendingTurnLabel = null;
877
932
 
878
933
  function appendTurn(role, text, turnLabel) {
934
+ // Captured BEFORE anything is added: see atBottom.
935
+ var following = atBottom(); // a question always wins
879
936
  // Class names mirror llm_meta_chat's chats/_message.html.erb —
880
937
  // `.message.<role>`, `.message-role`, `.message-content` — so the
881
938
  // shared conversation.css styles apply directly (see the <link>
@@ -914,7 +971,9 @@ function boot(cfg) {
914
971
  div.appendChild(document.createTextNode(" "));
915
972
  div.appendChild(body);
916
973
  historyEl.appendChild(div);
917
- historyEl.scrollTop = historyEl.scrollHeight;
974
+ // A question the visitor just asked always goes to the end; anything
975
+ // else respects where they were reading.
976
+ if (role === "user" || following) pinToBottom();
918
977
  body.roleLabel = label; // so a turn in flight can show it is working
919
978
  return body;
920
979
  }
@@ -926,6 +985,31 @@ function boot(cfg) {
926
985
  function renderMarkdownInto(el, mdText) {
927
986
  try { el.innerHTML = marked.parse(mdText); }
928
987
  catch (e) { el.textContent = mdText; } // fall back to raw on parse error
988
+ wrapTablesForScrolling(el);
989
+ }
990
+
991
+ // A markdown table gets its own horizontal scroller.
992
+ //
993
+ // `display: block; overflow-x: auto; max-width: 100%` on the table alone
994
+ // does NOT do this: the box is capped at the bubble width, so the table
995
+ // re-lays out to fit and squeezes its columns, and `word-wrap: break-word`
996
+ // from .message then breaks every word one character per line. A six-column
997
+ // table of genomic notation became a column of single letters thousands of
998
+ // pixels tall (seen on PubDictionaries, 2026-10-16).
999
+ //
1000
+ // The table has to sit INSIDE a capped box and keep its own natural width,
1001
+ // which is what a wrapper gives it.
1002
+ function wrapTablesForScrolling(el) {
1003
+ var tables = el.querySelectorAll("table");
1004
+ for (var i = 0; i < tables.length; i++) {
1005
+ var table = tables[i];
1006
+ // No already-wrapped guard: renderMarkdownInto replaces the whole
1007
+ // innerHTML first, so every table reaching here is freshly parsed.
1008
+ var wrap = document.createElement("div");
1009
+ wrap.className = "lmw-table-scroll";
1010
+ table.parentNode.insertBefore(wrap, table);
1011
+ wrap.appendChild(table);
1012
+ }
929
1013
  }
930
1014
 
931
1015
  // Role labels mirror llm_meta_chat's chats/_message.html.erb:
@@ -977,6 +1061,8 @@ function boot(cfg) {
977
1061
  }
978
1062
 
979
1063
  function announceToolCall(toolCall) {
1064
+ // Captured BEFORE anything is added: see atBottom.
1065
+ var following = atBottom();
980
1066
  var row = chipRow();
981
1067
  if (!row) return;
982
1068
  var chip = document.createElement("span");
@@ -985,7 +1071,7 @@ function boot(cfg) {
985
1071
  chip.dataset.tool = toolCall.name;
986
1072
  row.appendChild(chip);
987
1073
  pendingChips.push(chip);
988
- historyEl.scrollTop = historyEl.scrollHeight;
1074
+ if (following) pinToBottom();
989
1075
  }
990
1076
 
991
1077
  function resolveToolCall(outcome) {
@@ -1007,6 +1093,8 @@ function boot(cfg) {
1007
1093
  var activeAssistantBody = null;
1008
1094
 
1009
1095
  function ensureThinkingBlock() {
1096
+ // Captured BEFORE anything is added: see atBottom.
1097
+ var following = atBottom();
1010
1098
  if (currentThinkingBody) return currentThinkingBody;
1011
1099
  // Class names match llm_meta_chat's .message-thinking pattern so
1012
1100
  // the shared conversation.css applies directly.
@@ -1033,7 +1121,7 @@ function boot(cfg) {
1033
1121
  } else {
1034
1122
  historyEl.appendChild(details);
1035
1123
  }
1036
- historyEl.scrollTop = historyEl.scrollHeight;
1124
+ if (following) pinToBottom();
1037
1125
  currentThinkingBlock = details;
1038
1126
  currentThinkingBody = body;
1039
1127
  return body;
@@ -1137,7 +1225,7 @@ function boot(cfg) {
1137
1225
  cached: resourceContext,
1138
1226
  endpoint: resourcePlan && resourcePlan.endpoint,
1139
1227
  read: readMcpResource,
1140
- budgetBytes: RESOURCE_BUDGET_BYTES
1228
+ budgetBytes: resourceBudget()
1141
1229
  });
1142
1230
  resourceContext = turn.cached;
1143
1231
 
@@ -1145,9 +1233,9 @@ function boot(cfg) {
1145
1233
  // Volatile ones are re-read here, which is the whole point: a form field
1146
1234
  // read at boot is already stale by the time anyone sends a message.
1147
1235
  var page = await pageResourceLinesForTurn({
1148
- plans: pageResourcePlans,
1236
+ plans: pageResourcePlansNow(),
1149
1237
  resources: handlers.resources,
1150
- budgetBytes: RESOURCE_BUDGET_BYTES,
1238
+ budgetBytes: resourceBudget(),
1151
1239
  cache: pageResourceCache
1152
1240
  });
1153
1241
  pageResourceCache = page.cache;
@@ -1226,8 +1314,16 @@ function boot(cfg) {
1226
1314
  hostWideTools: hostWideTools,
1227
1315
  aiActions: handlers.tools,
1228
1316
  maxRounds: MAX_ROUNDS,
1317
+ contextWindow: modelWindow,
1229
1318
  provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
1230
1319
  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.");
1326
+ },
1231
1327
  onToolCall: function(toolCall) {
1232
1328
  announceToolCall(toolCall);
1233
1329
  // At once, not throttled: this call may be the one that navigates, and
@@ -1261,19 +1357,23 @@ function boot(cfg) {
1261
1357
  collapseThinkingBlock();
1262
1358
  },
1263
1359
  onThinkingDelta: function(delta) {
1360
+ // Captured BEFORE anything is added: see atBottom.
1361
+ var following = atBottom();
1264
1362
  var body = ensureThinkingBlock();
1265
1363
  body.appendChild(document.createTextNode(delta));
1266
1364
  body.scrollTop = body.scrollHeight;
1267
- historyEl.scrollTop = historyEl.scrollHeight;
1365
+ if (following) pinToBottom();
1268
1366
  },
1269
1367
  onTextDelta: function(delta) {
1368
+ // Captured BEFORE anything is added: see atBottom.
1369
+ var following = atBottom();
1270
1370
  markDone(assistantBody.roleLabel);
1271
1371
  collapseThinkingBlock();
1272
1372
  assistantMarkdown += delta;
1273
1373
  renderMarkdownInto(assistantBody, assistantMarkdown);
1274
1374
  liveTurn.content = assistantMarkdown;
1275
1375
  persistSoon();
1276
- historyEl.scrollTop = historyEl.scrollHeight;
1376
+ if (following) pinToBottom();
1277
1377
  }
1278
1378
  });
1279
1379
  // The turn was recorded here until 0.11.1, after the loop returned — which
@@ -168,6 +168,16 @@ async function runChatLoop(opts) {
168
168
  remoteTools = [],
169
169
  hostWideTools = [],
170
170
  maxRounds = 10,
171
+ // The model's own context window, from /api/llms. Without it the loop
172
+ // cannot know how much room is left, so it budgets nothing and a long
173
+ // tool-heavy turn is refused by the provider instead of degrading.
174
+ contextWindow = null,
175
+ // Tokens held back for the answer: a request that fills the window leaves
176
+ // nothing to generate into.
177
+ outputReserve = 2048,
178
+ // Called when a tool result is dropped to make room, so the panel can say
179
+ // so rather than the context quietly thinning.
180
+ onContextEvicted,
171
181
  // "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
172
182
  // Ollama, with no server component of ours in between. The loop is the
173
183
  // same either way — only the call is swapped.
@@ -189,7 +199,7 @@ async function runChatLoop(opts) {
189
199
  onPhase,
190
200
  ...singleOpts
191
201
  } = opts;
192
- const messages = [...singleOpts.messages || []];
202
+ let messages = [...singleOpts.messages || []];
193
203
  const remoteByName = Object.fromEntries((remoteTools || []).map((t) => [t.name, t]));
194
204
  const hostWideByName = Object.fromEntries((hostWideTools || []).map((t) => [t.name, t]));
195
205
  const toolIds = [...singleOpts.toolIds || [], ...(remoteTools || []).map((t) => t.id)];
@@ -200,6 +210,12 @@ async function runChatLoop(opts) {
200
210
  }
201
211
  const mergedLocalTools = Object.values(inlineByName);
202
212
  const rounds = [];
213
+ const evicted = [];
214
+ const toolsTokens = estimateToolsTokens([].concat(
215
+ singleOpts.localTools || [],
216
+ remoteTools || [],
217
+ hostWideTools || []
218
+ ));
203
219
  const allDispatched = [];
204
220
  const allSkipped = [];
205
221
  let lastResult = null;
@@ -230,6 +246,17 @@ async function runChatLoop(opts) {
230
246
  for (let round = 0; round < maxRounds; round++) {
231
247
  if (signal?.aborted) throw new DOMException("aborted", "AbortError");
232
248
  onRoundStart?.(round);
249
+ const room = evictOldToolResults({
250
+ messages,
251
+ window: contextWindow,
252
+ dropped: evicted,
253
+ // The tools take their share of the window before any message does.
254
+ reserve: outputReserve + toolsTokens
255
+ });
256
+ if (room.messages !== messages) {
257
+ messages = room.messages;
258
+ onContextEvicted?.(evicted.slice());
259
+ }
233
260
  const call = provider === "ollama" ? ollamaChatCall : singleLlmCall;
234
261
  const turnResult = await call({
235
262
  ...singleOpts,
@@ -561,6 +588,70 @@ function parseSseFrame(raw) {
561
588
  }
562
589
  var STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives";
563
590
  var PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json";
591
+ var RESOURCE_SHARE_OF_WINDOW = 0.0625;
592
+ var RESOURCE_BYTES_PER_TOKEN = 4;
593
+ function resourceBudgetBytes(window2, { floor = 8e3, cap = 64e3 } = {}) {
594
+ if (!window2 || !Number.isFinite(window2)) return floor;
595
+ const share = Math.round(window2 * RESOURCE_SHARE_OF_WINDOW * RESOURCE_BYTES_PER_TOKEN);
596
+ return Math.min(cap, Math.max(floor, share));
597
+ }
598
+ var PROSE_CHARS_PER_TOKEN = 3.5;
599
+ var DENSE_CHARS_PER_TOKEN = 1.6;
600
+ var CJK_CHARS_PER_TOKEN = 1;
601
+ var MESSAGE_OVERHEAD_TOKENS = 4;
602
+ var CJK_PATTERN = /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g;
603
+ var PROSE_PATTERN = /[A-Za-z \n\t]/g;
604
+ function estimateTextTokens(text) {
605
+ const str = typeof text === "string" ? text : String(text ?? "");
606
+ const cjk = (str.match(CJK_PATTERN) || []).length;
607
+ const prose = (str.match(PROSE_PATTERN) || []).length;
608
+ const dense = str.length - cjk - prose;
609
+ return Math.ceil(cjk / CJK_CHARS_PER_TOKEN + prose / PROSE_CHARS_PER_TOKEN + dense / DENSE_CHARS_PER_TOKEN);
610
+ }
611
+ function estimateTokens(messages) {
612
+ return (messages || []).reduce((sum, m) => {
613
+ const content = typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? "");
614
+ return sum + estimateTextTokens(content) + MESSAGE_OVERHEAD_TOKENS;
615
+ }, 0);
616
+ }
617
+ function estimateToolsTokens(tools) {
618
+ const text = (tools || []).map((t) => JSON.stringify({
619
+ name: t?.name,
620
+ description: t?.description,
621
+ schema: t?.input_schema || t?.inputSchema
622
+ })).join("");
623
+ return estimateTextTokens(text);
624
+ }
625
+ function evictOldToolResults({ messages, window: window2, reserve = 0, dropped = [] }) {
626
+ if (!window2 || !Number.isFinite(window2)) return { messages, dropped };
627
+ const budget = window2 - reserve;
628
+ if (estimateTokens(messages) <= budget) return { messages, dropped };
629
+ const out = messages.slice();
630
+ for (let i = 0; i < out.length; i++) {
631
+ if (estimateTokens(out) <= budget) break;
632
+ const m = out[i];
633
+ if (m?.role !== "tool") continue;
634
+ if (typeof m.content === "string" && m.content.startsWith("[dropped")) continue;
635
+ dropped.push({ name: m.name, tokens: estimateTokens([m]) });
636
+ out[i] = Object.assign({}, m, {
637
+ content: "[dropped to make room in the context window. Call the tool again if you still need it, with a narrower query.]"
638
+ });
639
+ }
640
+ return { messages: out, dropped };
641
+ }
642
+ function modelWindowFrom(payload, modelId) {
643
+ for (const family of payload && payload.llms || []) {
644
+ const entries = [].concat(family.available_models || [], family.models || []);
645
+ for (const entry of entries) {
646
+ if (!entry) continue;
647
+ const id = entry.value || entry.name;
648
+ if (id !== modelId) continue;
649
+ const window2 = Number(entry.max_input_tokens);
650
+ return Number.isFinite(window2) && window2 > 0 ? window2 : null;
651
+ }
652
+ }
653
+ return null;
654
+ }
564
655
  var PAGE_GLOBAL_TRANSPORT = "page-global";
565
656
  function declaredPageGlobal(card) {
566
657
  for (const remote of card && card.remotes || []) {
@@ -3237,12 +3328,28 @@ var conversation_default = `/*
3237
3328
  white-space: pre;
3238
3329
  }
3239
3330
  .llm-meta-conversation .message-content pre code { background: none; padding: 0; }
3331
+ /* The scroller is the WRAPPER, not the table: a table that is itself the
3332
+ * scrolling box gets capped at the bubble width and squeezes its columns
3333
+ * instead of overflowing, and .message's word-wrap then breaks every word
3334
+ * one character per line. The wrapper is capped; the table inside keeps its
3335
+ * natural width and scrolls sideways. */
3336
+ .llm-meta-conversation .message-content .lmw-table-scroll {
3337
+ overflow-x: auto;
3338
+ margin: 0.5em 0;
3339
+ }
3240
3340
  .llm-meta-conversation .message-content table {
3241
3341
  border-collapse: collapse;
3242
- margin: 0.5em 0;
3243
- display: block;
3244
- overflow-x: auto;
3245
- max-width: 100%;
3342
+ /* min-width so a narrow table still fills the bubble, width:max-content so
3343
+ * a wide one is laid out at its natural size and the wrapper scrolls. */
3344
+ width: max-content;
3345
+ min-width: 100%;
3346
+ }
3347
+ /* Inside a table, breaking mid-token is what produced the one-letter columns;
3348
+ * cells keep their words whole and the wrapper scrolls instead. */
3349
+ .llm-meta-conversation .message-content th,
3350
+ .llm-meta-conversation .message-content td {
3351
+ word-wrap: normal;
3352
+ overflow-wrap: normal;
3246
3353
  }
3247
3354
  .llm-meta-conversation .message-content th,
3248
3355
  .llm-meta-conversation .message-content td {
@@ -3844,6 +3951,13 @@ function boot(cfg) {
3844
3951
  var root = document.getElementById("llm-meta-widget-chat");
3845
3952
  var toggleBtn = document.getElementById("llm-meta-widget-toggle");
3846
3953
  var historyEl = root.querySelector(".lmw-messages");
3954
+ var SCROLL_PIN_SLACK = 40;
3955
+ function atBottom() {
3956
+ return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
3957
+ }
3958
+ function pinToBottom() {
3959
+ historyEl.scrollTop = historyEl.scrollHeight;
3960
+ }
3847
3961
  var formEl = root.querySelector(".lmw-form");
3848
3962
  var inputEl = root.querySelector(".lmw-input");
3849
3963
  var clearBtn = root.querySelector(".lmw-clear");
@@ -4069,6 +4183,8 @@ function boot(cfg) {
4069
4183
  tasks.push(fetch(LLM_BASE + "/api/llms", { headers: { "Accept": "application/json" } }).then(function(r) {
4070
4184
  return r.ok ? r.json() : { llms: [] };
4071
4185
  }).then(function(payload) {
4186
+ llmsPayload = payload;
4187
+ modelWindow = modelWindowFrom(payload, MODEL);
4072
4188
  var flat = [];
4073
4189
  (payload.llms || []).forEach(function(llm) {
4074
4190
  if (llm.family !== "ollama") return;
@@ -4189,6 +4305,7 @@ function boot(cfg) {
4189
4305
  if (modelPicker) {
4190
4306
  modelPicker.addEventListener("change", function() {
4191
4307
  MODEL = modelPicker.value;
4308
+ modelWindow = modelWindowFrom(llmsPayload, MODEL);
4192
4309
  });
4193
4310
  }
4194
4311
  if (toolsPicker) {
@@ -4213,16 +4330,29 @@ function boot(cfg) {
4213
4330
  var hostWidePrompts = pageCard.prompts.map(function(p) {
4214
4331
  return Object.assign({}, p, { source: "page" });
4215
4332
  });
4333
+ var modelWindow = null;
4334
+ var llmsPayload = null;
4216
4335
  var resourceContext = null;
4217
4336
  var resourcePlan = null;
4218
- var RESOURCE_BUDGET_BYTES = 8e3;
4219
- var pageResourcePlans = planPageResources({
4220
- entries: pageCard.resources,
4221
- budgetBytes: RESOURCE_BUDGET_BYTES,
4222
- onSkip: function(plan) {
4223
- console.info("[llm_meta_widget] not attaching " + plan.uri + " (" + plan.reason + ", size=" + plan.size + ")");
4337
+ function resourceBudget() {
4338
+ return resourceBudgetBytes(modelWindow);
4339
+ }
4340
+ var pageResourcePlans = null;
4341
+ var pageResourcePlansBudget = null;
4342
+ function pageResourcePlansNow() {
4343
+ var budget = resourceBudget();
4344
+ if (pageResourcePlans === null || budget !== pageResourcePlansBudget) {
4345
+ pageResourcePlansBudget = budget;
4346
+ pageResourcePlans = planPageResources({
4347
+ entries: pageCard.resources,
4348
+ budgetBytes: budget,
4349
+ onSkip: function(plan) {
4350
+ console.info("[llm_meta_widget] not attaching " + plan.uri + " (" + plan.reason + ", size=" + plan.size + ")");
4351
+ }
4352
+ });
4224
4353
  }
4225
- });
4354
+ return pageResourcePlans;
4355
+ }
4226
4356
  var pageResourceCache = {};
4227
4357
  restoreConversation();
4228
4358
  var promptsEl = root.querySelector(".lmw-prompts");
@@ -4243,7 +4373,7 @@ function boot(cfg) {
4243
4373
  if (resourcePlan === null) {
4244
4374
  var loaded = await loadHostResource({
4245
4375
  endpoint,
4246
- budgetBytes: RESOURCE_BUDGET_BYTES,
4376
+ budgetBytes: resourceBudget(),
4247
4377
  list: listMcpResources,
4248
4378
  read: readMcpResource,
4249
4379
  onSkip: function(plan) {
@@ -4305,7 +4435,7 @@ function boot(cfg) {
4305
4435
  });
4306
4436
  if (body.parentNode) body.parentNode.appendChild(row);
4307
4437
  });
4308
- historyEl.scrollTop = historyEl.scrollHeight;
4438
+ pinToBottom();
4309
4439
  return true;
4310
4440
  }
4311
4441
  function renderWelcome() {
@@ -4380,6 +4510,7 @@ function boot(cfg) {
4380
4510
  }
4381
4511
  var pendingTurnLabel = null;
4382
4512
  function appendTurn(role, text, turnLabel) {
4513
+ var following = atBottom();
4383
4514
  var welcome = historyEl.querySelector(".lmw-welcome");
4384
4515
  if (welcome) welcome.remove();
4385
4516
  var div = document.createElement("div");
@@ -4406,7 +4537,7 @@ function boot(cfg) {
4406
4537
  div.appendChild(document.createTextNode(" "));
4407
4538
  div.appendChild(body);
4408
4539
  historyEl.appendChild(div);
4409
- historyEl.scrollTop = historyEl.scrollHeight;
4540
+ if (role === "user" || following) pinToBottom();
4410
4541
  body.roleLabel = label;
4411
4542
  return body;
4412
4543
  }
@@ -4416,6 +4547,17 @@ function boot(cfg) {
4416
4547
  } catch (e) {
4417
4548
  el.textContent = mdText;
4418
4549
  }
4550
+ wrapTablesForScrolling(el);
4551
+ }
4552
+ function wrapTablesForScrolling(el) {
4553
+ var tables = el.querySelectorAll("table");
4554
+ for (var i = 0; i < tables.length; i++) {
4555
+ var table = tables[i];
4556
+ var wrap = document.createElement("div");
4557
+ wrap.className = "lmw-table-scroll";
4558
+ table.parentNode.insertBefore(wrap, table);
4559
+ wrap.appendChild(table);
4560
+ }
4419
4561
  }
4420
4562
  function roleLabel(role) {
4421
4563
  if (role === "user") return "\u{1F464} You";
@@ -4445,6 +4587,7 @@ function boot(cfg) {
4445
4587
  return liveChips;
4446
4588
  }
4447
4589
  function announceToolCall(toolCall) {
4590
+ var following = atBottom();
4448
4591
  var row = chipRow();
4449
4592
  if (!row) return;
4450
4593
  var chip = document.createElement("span");
@@ -4453,7 +4596,7 @@ function boot(cfg) {
4453
4596
  chip.dataset.tool = toolCall.name;
4454
4597
  row.appendChild(chip);
4455
4598
  pendingChips.push(chip);
4456
- historyEl.scrollTop = historyEl.scrollHeight;
4599
+ if (following) pinToBottom();
4457
4600
  }
4458
4601
  function resolveToolCall(outcome) {
4459
4602
  var idx = pendingChips.findIndex(function(c) {
@@ -4469,6 +4612,7 @@ function boot(cfg) {
4469
4612
  var currentThinkingBody = null;
4470
4613
  var activeAssistantBody = null;
4471
4614
  function ensureThinkingBlock() {
4615
+ var following = atBottom();
4472
4616
  if (currentThinkingBody) return currentThinkingBody;
4473
4617
  var details = document.createElement("details");
4474
4618
  details.className = "message-thinking";
@@ -4493,7 +4637,7 @@ function boot(cfg) {
4493
4637
  } else {
4494
4638
  historyEl.appendChild(details);
4495
4639
  }
4496
- historyEl.scrollTop = historyEl.scrollHeight;
4640
+ if (following) pinToBottom();
4497
4641
  currentThinkingBlock = details;
4498
4642
  currentThinkingBody = body;
4499
4643
  return body;
@@ -4573,13 +4717,13 @@ function boot(cfg) {
4573
4717
  cached: resourceContext,
4574
4718
  endpoint: resourcePlan && resourcePlan.endpoint,
4575
4719
  read: readMcpResource,
4576
- budgetBytes: RESOURCE_BUDGET_BYTES
4720
+ budgetBytes: resourceBudget()
4577
4721
  });
4578
4722
  resourceContext = turn.cached;
4579
4723
  var page = await pageResourceLinesForTurn({
4580
- plans: pageResourcePlans,
4724
+ plans: pageResourcePlansNow(),
4581
4725
  resources: handlers.resources,
4582
- budgetBytes: RESOURCE_BUDGET_BYTES,
4726
+ budgetBytes: resourceBudget(),
4583
4727
  cache: pageResourceCache
4584
4728
  });
4585
4729
  pageResourceCache = page.cache;
@@ -4646,8 +4790,14 @@ function boot(cfg) {
4646
4790
  hostWideTools,
4647
4791
  aiActions: handlers.tools,
4648
4792
  maxRounds: MAX_ROUNDS,
4793
+ contextWindow: modelWindow,
4649
4794
  provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
4650
4795
  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.");
4800
+ },
4651
4801
  onToolCall: function(toolCall) {
4652
4802
  announceToolCall(toolCall);
4653
4803
  persistConversation();
@@ -4666,19 +4816,21 @@ function boot(cfg) {
4666
4816
  collapseThinkingBlock();
4667
4817
  },
4668
4818
  onThinkingDelta: function(delta) {
4819
+ var following = atBottom();
4669
4820
  var body = ensureThinkingBlock();
4670
4821
  body.appendChild(document.createTextNode(delta));
4671
4822
  body.scrollTop = body.scrollHeight;
4672
- historyEl.scrollTop = historyEl.scrollHeight;
4823
+ if (following) pinToBottom();
4673
4824
  },
4674
4825
  onTextDelta: function(delta) {
4826
+ var following = atBottom();
4675
4827
  markDone(assistantBody.roleLabel);
4676
4828
  collapseThinkingBlock();
4677
4829
  assistantMarkdown += delta;
4678
4830
  renderMarkdownInto(assistantBody, assistantMarkdown);
4679
4831
  liveTurn.content = assistantMarkdown;
4680
4832
  persistSoon();
4681
- historyEl.scrollTop = historyEl.scrollHeight;
4833
+ if (following) pinToBottom();
4682
4834
  }
4683
4835
  });
4684
4836
  liveTurn.content = result.content;
@@ -325,6 +325,16 @@ export async function runChatLoop(opts) {
325
325
  remoteTools = [],
326
326
  hostWideTools = [],
327
327
  maxRounds = 10,
328
+ // The model's own context window, from /api/llms. Without it the loop
329
+ // cannot know how much room is left, so it budgets nothing and a long
330
+ // tool-heavy turn is refused by the provider instead of degrading.
331
+ contextWindow = null,
332
+ // Tokens held back for the answer: a request that fills the window leaves
333
+ // nothing to generate into.
334
+ outputReserve = 2048,
335
+ // Called when a tool result is dropped to make room, so the panel can say
336
+ // so rather than the context quietly thinning.
337
+ onContextEvicted,
328
338
  // "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
329
339
  // Ollama, with no server component of ours in between. The loop is the
330
340
  // same either way — only the call is swapped.
@@ -347,7 +357,7 @@ export async function runChatLoop(opts) {
347
357
  ...singleOpts
348
358
  } = opts
349
359
 
350
- const messages = [ ...(singleOpts.messages || []) ]
360
+ let messages = [ ...(singleOpts.messages || []) ]
351
361
  const remoteByName = Object.fromEntries((remoteTools || []).map((t) => [ t.name, t ]))
352
362
  // Class 2: host-wide MCP tools (well-known). Map name→{endpoint, ...} for
353
363
  // dispatch. If the same name appears in aiActions, Class 3 wins there
@@ -366,6 +376,10 @@ export async function runChatLoop(opts) {
366
376
  const mergedLocalTools = Object.values(inlineByName)
367
377
 
368
378
  const rounds = []
379
+ const evicted = []
380
+ // Fixed for the turn: the declared tool set does not change between rounds.
381
+ const toolsTokens = estimateToolsTokens([].concat(
382
+ singleOpts.localTools || [], remoteTools || [], hostWideTools || []))
369
383
  const allDispatched = []
370
384
  const allSkipped = []
371
385
  let lastResult = null
@@ -408,6 +422,18 @@ export async function runChatLoop(opts) {
408
422
  onRoundStart?.(round)
409
423
 
410
424
  // The loop is the same whoever answers; only the call differs.
425
+ // Make room before sending, rather than after being refused. Only old
426
+ // tool results are given up; see evictOldToolResults.
427
+ const room = evictOldToolResults({
428
+ messages, window: contextWindow, dropped: evicted,
429
+ // The tools take their share of the window before any message does.
430
+ reserve: outputReserve + toolsTokens
431
+ });
432
+ if (room.messages !== messages) {
433
+ messages = room.messages;
434
+ onContextEvicted?.(evicted.slice());
435
+ }
436
+
411
437
  const call = provider === "ollama" ? ollamaChatCall : singleLlmCall
412
438
  const turnResult = await call({
413
439
  ...singleOpts,
@@ -891,6 +917,142 @@ export const PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json"
891
917
  // card, find the handlers and dispatch. The three legacy globals
892
918
  // (aiActions/aiPrompts/aiResources) are names only this widget knows, and are
893
919
  // configurable per page, so nothing outside could rely on them.
920
+ // How much room a model has, read from /api/llms — the one call the widget
921
+ // already makes before a turn. Scans every family, because the picker shows
922
+ // only the local ones while the configured model is often hosted, and the two
923
+ // families use different shapes for the same thing (`value` vs `name`).
924
+ //
925
+ // Returns null when the hub does not say. Null means "no budget to work with",
926
+ // which is not the same as zero, and every caller below treats it as "do not
927
+ // guess" rather than "no room".
928
+ // How many bytes of reference resources a turn may attach, derived from the
929
+ // model's window instead of guessed. The old fixed 8000 was chosen as "~2k
930
+ // tokens, beyond which a resource crowds out the conversation on a 32k local
931
+ // model" — which is 6.25% of that window, so the same share reproduces 8000 at
932
+ // 32k exactly and gives a 128k model the 32 KB it can afford.
933
+ //
934
+ // The floor keeps the previous behaviour whenever the window is unknown. The
935
+ // cap is there because these bytes are fetched and inlined on every turn: the
936
+ // largest real catalog (PubDictionaries, 218 dictionaries) is ~35 KB, so 64 KB
937
+ // is already generous and a 1M-token model does not need 250 KB of it.
938
+ export const RESOURCE_SHARE_OF_WINDOW = 0.0625
939
+
940
+ // Bytes per token for SIZING that budget — not the estimator above, which
941
+ // measures real text. Kept at four so the budget stays exactly what it was
942
+ // (8 KB on a 32k model): how many tokens those bytes become depends on the
943
+ // resource, and re-sizing budgets is not this constant's job.
944
+ export const RESOURCE_BYTES_PER_TOKEN = 4
945
+
946
+ export function resourceBudgetBytes(window, { floor = 8000, cap = 64_000 } = {}) {
947
+ if (!window || !Number.isFinite(window)) return floor
948
+ const share = Math.round(window * RESOURCE_SHARE_OF_WINDOW * RESOURCE_BYTES_PER_TOKEN)
949
+ return Math.min(cap, Math.max(floor, share))
950
+ }
951
+
952
+ // A rough token count, the same three classes the hub's estimator uses.
953
+ //
954
+ // One ratio cannot describe both prose and a tool result. Letters and spaces
955
+ // tokenize at roughly 3.5 characters each; digits, punctuation and symbols go
956
+ // much denser, and a JSON tool result is mostly those.
957
+ //
958
+ // Measured against a real refusal (2026-10-17): a 289,101-character request
959
+ // the provider counted as 135,291 tokens was 33% letters, 16% digits and 45%
960
+ // punctuation. At a flat four characters per token that is 72,275 — 53% of the
961
+ // truth — so nothing was evicted and the provider refused the turn instead.
962
+ // These classes put it at 106%: high, which is the safe direction here, since
963
+ // underestimating means the turn dies while overestimating only gives up an
964
+ // old tool result slightly early.
965
+ export const PROSE_CHARS_PER_TOKEN = 3.5
966
+ export const DENSE_CHARS_PER_TOKEN = 1.6
967
+ export const CJK_CHARS_PER_TOKEN = 1
968
+ export const MESSAGE_OVERHEAD_TOKENS = 4
969
+
970
+ const CJK_PATTERN = /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g
971
+ const PROSE_PATTERN = /[A-Za-z \n\t]/g
972
+
973
+ export function estimateTextTokens(text) {
974
+ const str = typeof text === "string" ? text : String(text ?? "")
975
+ const cjk = (str.match(CJK_PATTERN) || []).length
976
+ const prose = (str.match(PROSE_PATTERN) || []).length
977
+ const dense = str.length - cjk - prose
978
+ return Math.ceil(cjk / CJK_CHARS_PER_TOKEN +
979
+ prose / PROSE_CHARS_PER_TOKEN +
980
+ dense / DENSE_CHARS_PER_TOKEN)
981
+ }
982
+
983
+ export function estimateTokens(messages) {
984
+ return (messages || []).reduce((sum, m) => {
985
+ const content = typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? "")
986
+ return sum + estimateTextTokens(content) + MESSAGE_OVERHEAD_TOKENS
987
+ }, 0)
988
+ }
989
+
990
+ // What the declared tools cost in the window. They are part of every request:
991
+ // names, descriptions and JSON schemas. On the hub path the browser sends only
992
+ // `tool_ids` and the hub expands the schemas, but the browser listed those
993
+ // tools to build its picker, so it still has what it needs to price them.
994
+ //
995
+ // This is not a detail. TogoMCP declares around twenty tools, which is a few
996
+ // thousand tokens standing in the window before the conversation starts; a
997
+ // budget counting only messages runs low and evicts too late to help.
998
+ export function estimateToolsTokens(tools) {
999
+ const text = (tools || []).map((t) => JSON.stringify({
1000
+ name: t?.name, description: t?.description, schema: t?.input_schema || t?.inputSchema
1001
+ })).join("")
1002
+ // Counted with the same classes: a schema is JSON, which is the dense case.
1003
+ return estimateTextTokens(text)
1004
+ }
1005
+
1006
+ // Makes room for the next round by dropping the OLDEST tool results, which is
1007
+ // the one eviction a chat loop can justify: the model has already read them and
1008
+ // said what it made of them in the assistant turn that follows, so its own
1009
+ // summary survives. Nothing else is touched — not the system prompt, not the
1010
+ // questions, not the assistant's words.
1011
+ //
1012
+ // Each dropped result leaves a marker in its place, because a tool message must
1013
+ // still answer its tool_call_id: a provider that sees a call with no result
1014
+ // rejects the request, and a model that sees no mention of the result would
1015
+ // happily call the same tool again.
1016
+ //
1017
+ // Returns the messages unchanged when the window is unknown. Guessing a window
1018
+ // would be worse than not budgeting: too low throws away context the turn
1019
+ // needed, and too high fails at the provider anyway.
1020
+ export function evictOldToolResults({ messages, window, reserve = 0, dropped = [] }) {
1021
+ if (!window || !Number.isFinite(window)) return { messages, dropped }
1022
+
1023
+ const budget = window - reserve
1024
+ if (estimateTokens(messages) <= budget) return { messages, dropped }
1025
+
1026
+ const out = messages.slice()
1027
+ for (let i = 0; i < out.length; i++) {
1028
+ if (estimateTokens(out) <= budget) break
1029
+ const m = out[i]
1030
+ if (m?.role !== "tool") continue
1031
+ if (typeof m.content === "string" && m.content.startsWith("[dropped")) continue
1032
+
1033
+ dropped.push({ name: m.name, tokens: estimateTokens([ m ]) })
1034
+ out[i] = Object.assign({}, m, {
1035
+ content: "[dropped to make room in the context window. Call the tool again " +
1036
+ "if you still need it, with a narrower query.]"
1037
+ })
1038
+ }
1039
+ return { messages: out, dropped }
1040
+ }
1041
+
1042
+ export function modelWindowFrom(payload, modelId) {
1043
+ for (const family of (payload && payload.llms) || []) {
1044
+ const entries = [].concat(family.available_models || [], family.models || [])
1045
+ for (const entry of entries) {
1046
+ if (!entry) continue
1047
+ const id = entry.value || entry.name
1048
+ if (id !== modelId) continue
1049
+ const window = Number(entry.max_input_tokens)
1050
+ return Number.isFinite(window) && window > 0 ? window : null
1051
+ }
1052
+ }
1053
+ return null
1054
+ }
1055
+
894
1056
  export const PAGE_GLOBAL_TRANSPORT = "page-global"
895
1057
 
896
1058
  export function declaredPageGlobal(card) {
@@ -109,12 +109,28 @@
109
109
  white-space: pre;
110
110
  }
111
111
  .llm-meta-conversation .message-content pre code { background: none; padding: 0; }
112
+ /* The scroller is the WRAPPER, not the table: a table that is itself the
113
+ * scrolling box gets capped at the bubble width and squeezes its columns
114
+ * instead of overflowing, and .message's word-wrap then breaks every word
115
+ * one character per line. The wrapper is capped; the table inside keeps its
116
+ * natural width and scrolls sideways. */
117
+ .llm-meta-conversation .message-content .lmw-table-scroll {
118
+ overflow-x: auto;
119
+ margin: 0.5em 0;
120
+ }
112
121
  .llm-meta-conversation .message-content table {
113
122
  border-collapse: collapse;
114
- margin: 0.5em 0;
115
- display: block;
116
- overflow-x: auto;
117
- max-width: 100%;
123
+ /* min-width so a narrow table still fills the bubble, width:max-content so
124
+ * a wide one is laid out at its natural size and the wrapper scrolls. */
125
+ width: max-content;
126
+ min-width: 100%;
127
+ }
128
+ /* Inside a table, breaking mid-token is what produced the one-letter columns;
129
+ * cells keep their words whole and the wrapper scrolls instead. */
130
+ .llm-meta-conversation .message-content th,
131
+ .llm-meta-conversation .message-content td {
132
+ word-wrap: normal;
133
+ overflow-wrap: normal;
118
134
  }
119
135
  .llm-meta-conversation .message-content th,
120
136
  .llm-meta-conversation .message-content td {
@@ -1,3 +1,3 @@
1
1
  module LlmMetaWidget
2
- VERSION = "0.11.1"
2
+ VERSION = "0.12.1"
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.11.1
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - jdkim