@aibranch/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.
package/README.md CHANGED
@@ -61,7 +61,7 @@ Option 3 if it does not.
61
61
 
62
62
  ```ruby
63
63
  # Gemfile
64
- gem "llm_meta_widget", "~> 0.12.0"
64
+ gem "llm_meta_widget", "~> 0.12.1"
65
65
  ```
66
66
 
67
67
  ```bash
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibranch/llm-meta-widget",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "//version": "Must stay in step with lib/llm_meta_widget/version.rb. Two independently versioned copies of the same client is the drift docs/custom-element-distribution.md warns about: bump both, release both.",
5
5
  "description": "Embeddable browser chat widget: a custom element that discovers tools at page, origin and hub scope and dispatches each tool call to the right one. Two lines of HTML on any host, Rails or not.",
6
6
  "keywords": [