llm_meta_widget 0.12.0 → 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: b2ba425d2cce6f582baf43df6ca5512bd344ccd78f1e74b56887eb6f3ccdaff6
4
- data.tar.gz: a0127db4a9a59f6a88c052e9f6d54d1d0664c168c4de6cd16fe480fa95a73327
3
+ metadata.gz: 845c38baf1aca6cc874a77c9f324895c6001664dffe4d891d8b31ed2057a3b2b
4
+ data.tar.gz: 825acda8a84c599e09e8d11cad9fe738092aa807b41911a99889e76dab15c800
5
5
  SHA512:
6
- metadata.gz: 7b3d3ddd173ef207ccd651ef5a455ffbdc2941fd0d00bc9e1bce687d824cf789b14b8e0d09f898fbfc0db132d4e384cdb7637289467c3259d180f7e71fdd1c16
7
- data.tar.gz: eea0ceefa6b8cecc7472d754c16d0de51a04ad92afb69dfe237c31c1448873761d7a1c7fa90c317fc9b29c568c4c2eecf7fe3f7dead0b467e1e9d68b93dafd57
6
+ metadata.gz: 0244dc664c60f88e1cf29e9cb52ebc9e33bf61e8ab2ab180a8e0873d587bf1cf96d8865eeb9d97b0fe325cec00c43e6e154cef74c511f7f16894a2112d17ad23
7
+ data.tar.gz: 5584f49e2c7e848ef93fdb5bf6cb1270fa3154f77beee255eb64e221aab20ce8e4828b4b8381c5268e1503b1b0967d4fab81811be718f23f247951ccdf1c142a
data/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
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
+
6
35
  ## 0.12.0
7
36
 
8
37
  Nothing a page must provide changes in this release: no new attribute, no
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.0"
64
+ gem "llm_meta_widget", "~> 0.12.1"
65
65
  ```
66
66
 
67
67
  ```bash
@@ -168,15 +168,11 @@ function boot(cfg) {
168
168
  // The reader's own scrolling says which they are doing: leave the bottom
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
- var followingOutput = true;
172
171
 
173
172
  function atBottom() {
174
173
  return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
175
174
  }
176
175
  function pinToBottom() { historyEl.scrollTop = historyEl.scrollHeight; }
177
- // Called wherever content is added mid-turn.
178
- function pinIfFollowing() { if (followingOutput) pinToBottom(); }
179
- historyEl.addEventListener("scroll", function() { followingOutput = atBottom(); });
180
176
  var formEl = root.querySelector(".lmw-form");
181
177
  var inputEl = root.querySelector(".lmw-input");
182
178
  var clearBtn = root.querySelector(".lmw-clear");
@@ -935,6 +931,8 @@ function boot(cfg) {
935
931
  var pendingTurnLabel = null;
936
932
 
937
933
  function appendTurn(role, text, turnLabel) {
934
+ // Captured BEFORE anything is added: see atBottom.
935
+ var following = atBottom(); // a question always wins
938
936
  // Class names mirror llm_meta_chat's chats/_message.html.erb —
939
937
  // `.message.<role>`, `.message-role`, `.message-content` — so the
940
938
  // shared conversation.css styles apply directly (see the <link>
@@ -973,7 +971,9 @@ function boot(cfg) {
973
971
  div.appendChild(document.createTextNode(" "));
974
972
  div.appendChild(body);
975
973
  historyEl.appendChild(div);
976
- pinIfFollowing();
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();
977
977
  body.roleLabel = label; // so a turn in flight can show it is working
978
978
  return body;
979
979
  }
@@ -1061,6 +1061,8 @@ function boot(cfg) {
1061
1061
  }
1062
1062
 
1063
1063
  function announceToolCall(toolCall) {
1064
+ // Captured BEFORE anything is added: see atBottom.
1065
+ var following = atBottom();
1064
1066
  var row = chipRow();
1065
1067
  if (!row) return;
1066
1068
  var chip = document.createElement("span");
@@ -1069,7 +1071,7 @@ function boot(cfg) {
1069
1071
  chip.dataset.tool = toolCall.name;
1070
1072
  row.appendChild(chip);
1071
1073
  pendingChips.push(chip);
1072
- pinIfFollowing();
1074
+ if (following) pinToBottom();
1073
1075
  }
1074
1076
 
1075
1077
  function resolveToolCall(outcome) {
@@ -1091,6 +1093,8 @@ function boot(cfg) {
1091
1093
  var activeAssistantBody = null;
1092
1094
 
1093
1095
  function ensureThinkingBlock() {
1096
+ // Captured BEFORE anything is added: see atBottom.
1097
+ var following = atBottom();
1094
1098
  if (currentThinkingBody) return currentThinkingBody;
1095
1099
  // Class names match llm_meta_chat's .message-thinking pattern so
1096
1100
  // the shared conversation.css applies directly.
@@ -1117,7 +1121,7 @@ function boot(cfg) {
1117
1121
  } else {
1118
1122
  historyEl.appendChild(details);
1119
1123
  }
1120
- pinIfFollowing();
1124
+ if (following) pinToBottom();
1121
1125
  currentThinkingBlock = details;
1122
1126
  currentThinkingBody = body;
1123
1127
  return body;
@@ -1266,9 +1270,6 @@ function boot(cfg) {
1266
1270
  var userText = inputEl.value.trim();
1267
1271
  if (!userText) return;
1268
1272
  inputEl.value = "";
1269
- // Asking something is a request to see the answer: whatever the reader was
1270
- // looking at before, they are following the output again from here.
1271
- followingOutput = true;
1272
1273
  appendTurn("user", userText, pendingTurnLabel);
1273
1274
  pendingTurnLabel = null;
1274
1275
 
@@ -1356,19 +1357,23 @@ function boot(cfg) {
1356
1357
  collapseThinkingBlock();
1357
1358
  },
1358
1359
  onThinkingDelta: function(delta) {
1360
+ // Captured BEFORE anything is added: see atBottom.
1361
+ var following = atBottom();
1359
1362
  var body = ensureThinkingBlock();
1360
1363
  body.appendChild(document.createTextNode(delta));
1361
1364
  body.scrollTop = body.scrollHeight;
1362
- pinIfFollowing();
1365
+ if (following) pinToBottom();
1363
1366
  },
1364
1367
  onTextDelta: function(delta) {
1368
+ // Captured BEFORE anything is added: see atBottom.
1369
+ var following = atBottom();
1365
1370
  markDone(assistantBody.roleLabel);
1366
1371
  collapseThinkingBlock();
1367
1372
  assistantMarkdown += delta;
1368
1373
  renderMarkdownInto(assistantBody, assistantMarkdown);
1369
1374
  liveTurn.content = assistantMarkdown;
1370
1375
  persistSoon();
1371
- pinIfFollowing();
1376
+ if (following) pinToBottom();
1372
1377
  }
1373
1378
  });
1374
1379
  // The turn was recorded here until 0.11.1, after the loop returned — which
@@ -589,17 +589,29 @@ function parseSseFrame(raw) {
589
589
  var STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives";
590
590
  var PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json";
591
591
  var RESOURCE_SHARE_OF_WINDOW = 0.0625;
592
+ var RESOURCE_BYTES_PER_TOKEN = 4;
592
593
  function resourceBudgetBytes(window2, { floor = 8e3, cap = 64e3 } = {}) {
593
594
  if (!window2 || !Number.isFinite(window2)) return floor;
594
- const share = Math.round(window2 * RESOURCE_SHARE_OF_WINDOW * CHARS_PER_TOKEN);
595
+ const share = Math.round(window2 * RESOURCE_SHARE_OF_WINDOW * RESOURCE_BYTES_PER_TOKEN);
595
596
  return Math.min(cap, Math.max(floor, share));
596
597
  }
597
- var CHARS_PER_TOKEN = 4;
598
+ var PROSE_CHARS_PER_TOKEN = 3.5;
599
+ var DENSE_CHARS_PER_TOKEN = 1.6;
600
+ var CJK_CHARS_PER_TOKEN = 1;
598
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
+ }
599
611
  function estimateTokens(messages) {
600
612
  return (messages || []).reduce((sum, m) => {
601
613
  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;
614
+ return sum + estimateTextTokens(content) + MESSAGE_OVERHEAD_TOKENS;
603
615
  }, 0);
604
616
  }
605
617
  function estimateToolsTokens(tools) {
@@ -608,7 +620,7 @@ function estimateToolsTokens(tools) {
608
620
  description: t?.description,
609
621
  schema: t?.input_schema || t?.inputSchema
610
622
  })).join("");
611
- return Math.ceil(text.length / CHARS_PER_TOKEN);
623
+ return estimateTextTokens(text);
612
624
  }
613
625
  function evictOldToolResults({ messages, window: window2, reserve = 0, dropped = [] }) {
614
626
  if (!window2 || !Number.isFinite(window2)) return { messages, dropped };
@@ -3940,19 +3952,12 @@ function boot(cfg) {
3940
3952
  var toggleBtn = document.getElementById("llm-meta-widget-toggle");
3941
3953
  var historyEl = root.querySelector(".lmw-messages");
3942
3954
  var SCROLL_PIN_SLACK = 40;
3943
- var followingOutput = true;
3944
3955
  function atBottom() {
3945
3956
  return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
3946
3957
  }
3947
3958
  function pinToBottom() {
3948
3959
  historyEl.scrollTop = historyEl.scrollHeight;
3949
3960
  }
3950
- function pinIfFollowing() {
3951
- if (followingOutput) pinToBottom();
3952
- }
3953
- historyEl.addEventListener("scroll", function() {
3954
- followingOutput = atBottom();
3955
- });
3956
3961
  var formEl = root.querySelector(".lmw-form");
3957
3962
  var inputEl = root.querySelector(".lmw-input");
3958
3963
  var clearBtn = root.querySelector(".lmw-clear");
@@ -4505,6 +4510,7 @@ function boot(cfg) {
4505
4510
  }
4506
4511
  var pendingTurnLabel = null;
4507
4512
  function appendTurn(role, text, turnLabel) {
4513
+ var following = atBottom();
4508
4514
  var welcome = historyEl.querySelector(".lmw-welcome");
4509
4515
  if (welcome) welcome.remove();
4510
4516
  var div = document.createElement("div");
@@ -4531,7 +4537,7 @@ function boot(cfg) {
4531
4537
  div.appendChild(document.createTextNode(" "));
4532
4538
  div.appendChild(body);
4533
4539
  historyEl.appendChild(div);
4534
- pinIfFollowing();
4540
+ if (role === "user" || following) pinToBottom();
4535
4541
  body.roleLabel = label;
4536
4542
  return body;
4537
4543
  }
@@ -4581,6 +4587,7 @@ function boot(cfg) {
4581
4587
  return liveChips;
4582
4588
  }
4583
4589
  function announceToolCall(toolCall) {
4590
+ var following = atBottom();
4584
4591
  var row = chipRow();
4585
4592
  if (!row) return;
4586
4593
  var chip = document.createElement("span");
@@ -4589,7 +4596,7 @@ function boot(cfg) {
4589
4596
  chip.dataset.tool = toolCall.name;
4590
4597
  row.appendChild(chip);
4591
4598
  pendingChips.push(chip);
4592
- pinIfFollowing();
4599
+ if (following) pinToBottom();
4593
4600
  }
4594
4601
  function resolveToolCall(outcome) {
4595
4602
  var idx = pendingChips.findIndex(function(c) {
@@ -4605,6 +4612,7 @@ function boot(cfg) {
4605
4612
  var currentThinkingBody = null;
4606
4613
  var activeAssistantBody = null;
4607
4614
  function ensureThinkingBlock() {
4615
+ var following = atBottom();
4608
4616
  if (currentThinkingBody) return currentThinkingBody;
4609
4617
  var details = document.createElement("details");
4610
4618
  details.className = "message-thinking";
@@ -4629,7 +4637,7 @@ function boot(cfg) {
4629
4637
  } else {
4630
4638
  historyEl.appendChild(details);
4631
4639
  }
4632
- pinIfFollowing();
4640
+ if (following) pinToBottom();
4633
4641
  currentThinkingBlock = details;
4634
4642
  currentThinkingBody = body;
4635
4643
  return body;
@@ -4746,7 +4754,6 @@ function boot(cfg) {
4746
4754
  var userText = inputEl.value.trim();
4747
4755
  if (!userText) return;
4748
4756
  inputEl.value = "";
4749
- followingOutput = true;
4750
4757
  appendTurn("user", userText, pendingTurnLabel);
4751
4758
  pendingTurnLabel = null;
4752
4759
  if (currentAbort) {
@@ -4809,19 +4816,21 @@ function boot(cfg) {
4809
4816
  collapseThinkingBlock();
4810
4817
  },
4811
4818
  onThinkingDelta: function(delta) {
4819
+ var following = atBottom();
4812
4820
  var body = ensureThinkingBlock();
4813
4821
  body.appendChild(document.createTextNode(delta));
4814
4822
  body.scrollTop = body.scrollHeight;
4815
- pinIfFollowing();
4823
+ if (following) pinToBottom();
4816
4824
  },
4817
4825
  onTextDelta: function(delta) {
4826
+ var following = atBottom();
4818
4827
  markDone(assistantBody.roleLabel);
4819
4828
  collapseThinkingBlock();
4820
4829
  assistantMarkdown += delta;
4821
4830
  renderMarkdownInto(assistantBody, assistantMarkdown);
4822
4831
  liveTurn.content = assistantMarkdown;
4823
4832
  persistSoon();
4824
- pinIfFollowing();
4833
+ if (following) pinToBottom();
4825
4834
  }
4826
4835
  });
4827
4836
  liveTurn.content = result.content;
@@ -937,23 +937,53 @@ export const PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json"
937
937
  // is already generous and a 1M-token model does not need 250 KB of it.
938
938
  export const RESOURCE_SHARE_OF_WINDOW = 0.0625
939
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
+
940
946
  export function resourceBudgetBytes(window, { floor = 8000, cap = 64_000 } = {}) {
941
947
  if (!window || !Number.isFinite(window)) return floor
942
- const share = Math.round(window * RESOURCE_SHARE_OF_WINDOW * CHARS_PER_TOKEN)
948
+ const share = Math.round(window * RESOURCE_SHARE_OF_WINDOW * RESOURCE_BYTES_PER_TOKEN)
943
949
  return Math.min(cap, Math.max(floor, share))
944
950
  }
945
951
 
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
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
951
968
  export const MESSAGE_OVERHEAD_TOKENS = 4
952
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
+
953
983
  export function estimateTokens(messages) {
954
984
  return (messages || []).reduce((sum, m) => {
955
985
  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
986
+ return sum + estimateTextTokens(content) + MESSAGE_OVERHEAD_TOKENS
957
987
  }, 0)
958
988
  }
959
989
 
@@ -969,7 +999,8 @@ export function estimateToolsTokens(tools) {
969
999
  const text = (tools || []).map((t) => JSON.stringify({
970
1000
  name: t?.name, description: t?.description, schema: t?.input_schema || t?.inputSchema
971
1001
  })).join("")
972
- return Math.ceil(text.length / CHARS_PER_TOKEN)
1002
+ // Counted with the same classes: a schema is JSON, which is the dense case.
1003
+ return estimateTextTokens(text)
973
1004
  }
974
1005
 
975
1006
  // Makes room for the next round by dropping the OLDEST tool results, which is
@@ -1,3 +1,3 @@
1
1
  module LlmMetaWidget
2
- VERSION = "0.12.0"
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.12.0
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - jdkim