@aibranch/llm-meta-widget 0.9.0 → 0.10.0

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
@@ -216,6 +216,7 @@ easy to get wrong:
216
216
  | `llm-provider` | `llm_meta_server` (default) or `ollama` |
217
217
  | `api-key-uuid`, `greeting`, `max-rounds` | as the helper options |
218
218
  | `actions-schema-id`, `state-global`, `actions-global`, `remote-tools-schema-id` | as the helper options |
219
+ | `page-card-id`, `prompts-global`, `resources-global` | as the helper options |
219
220
  | `models`, `hub-tools` | comma-separated. Omitted **or empty** means no allowlist — an allowlist permitting nothing is never what anyone meant |
220
221
  | `enable-model-picker`, `enable-tool-picker` | **value attributes, not boolean attributes.** They default to true, so presence cannot mean true. Disable with `enable-tool-picker="false"`; any other value is true |
221
222
  | `well-known-urls` | **Three different states**, because an attribute cannot tell "not set" from "set to empty": omitted = auto-discover same-origin `/.well-known/mcp.json`; `""` = discovery off; `"a,b"` = fetch those |
@@ -401,6 +402,9 @@ translate directly.
401
402
  | `api_key_uuid:` | `"ollama-local"` | Hub API-key uuid to invoke |
402
403
  | `element_path:` | `"/llm_meta_widget_assets/llm-meta-widget.js"` | The bundled element the page loads. Served by the gem's engine; override to load it from elsewhere |
403
404
  | `actions_schema_id:` | `"ai-actions"` | DOM id of the Class-3 schema block |
405
+ | `page_card_id:` | `"ai-page"` | DOM id of the page card (see "The page card"). Its tools replace the `actions_schema_id` block |
406
+ | `prompts_global:` | `"aiPrompts"` | Global object holding page prompt handlers, keyed by prompt name |
407
+ | `resources_global:` | `"aiResources"` | Global object holding page resource readers, keyed by resource URI |
404
408
  | `state_global:` | `"aiState"` | Global window object holding Class-3 state readers, each `{ description, read }` (see the breaking change in 0.8.0) |
405
409
  | `actions_global:` | `"aiActions"` | Global window object holding Class-3 implementations |
406
410
  | `remote_tools_schema_id:` | `"remote-mcp-tools"` | DOM id of a JSON block listing Class-1 tools (`{id, name, description, input_schema}`) to start with. They seed the picker rather than bypassing it — shown ticked, and the visitor may untick them. Read once, at boot |
@@ -412,6 +416,74 @@ translate directly.
412
416
  | `models:` | `nil` | Model-name allowlist; `nil` = all anon-available |
413
417
  | `hub_tools:` | `nil` | MCP-server-name allowlist; `nil` = all anon-public |
414
418
 
419
+ ## The page card
420
+
421
+ Since 0.9.0 a page can declare what it offers in one block of JSON, instead of
422
+ using a separate channel for each kind of thing. The block uses the same envelope
423
+ an MCP server puts in its Server Card, so the page and the server describe
424
+ themselves the same way.
425
+
426
+ ```html
427
+ <script type="application/mcp-static-primitives+json">
428
+ { "_meta": { "io.modelcontextprotocol/static-primitives": {
429
+ "tools": [ { "name": "set_text", "description": "…", "inputSchema": { } } ],
430
+ "prompts": [ { "name": "annotate", "description": "Annotate this page" } ],
431
+ "resources": [ { "uri": "page:text", "name": "Text", "description": "the text to annotate",
432
+ "volatility": "volatile" } ]
433
+ } } }
434
+ </script>
435
+ ```
436
+
437
+ The card says what exists. Three global objects say what it does, and each one is
438
+ keyed by the identifier that the matching MCP operation takes:
439
+
440
+ | declared in the card | implemented in | keyed by |
441
+ |---|---|---|
442
+ | `tools` | `window.aiActions` | tool `name`, as `tools/call` takes |
443
+ | `prompts` | `window.aiPrompts` | prompt `name`, as `prompts/get` takes |
444
+ | `resources` | `window.aiResources` | resource `uri`, as `resources/read` takes |
445
+
446
+ ```js
447
+ window.aiActions = { set_text: (args) => { /* change the page */ } }
448
+ window.aiPrompts = { annotate: (args) => "Annotate this: " + args.text }
449
+ window.aiResources = { "page:text": () => document.querySelector("#text").value }
450
+ ```
451
+
452
+ A prompt handler may return a plain string or the full `messages` array that
453
+ `prompts/get` returns. A resource reader may return a string or any value that
454
+ can be turned into JSON.
455
+
456
+ **Resources carry two optional hints.** `volatility` is `"volatile"` when the
457
+ value changes between turns, so the widget reads it again every turn; the default
458
+ is `"stable"`, which is read once and reused. `autoAttach: false` keeps a resource
459
+ out of the turn unless something asks for it. A `size` in bytes lets the widget
460
+ decide whether the value fits its budget before reading it.
461
+
462
+ **The `page:` scheme is experimental.** It is not a registered URI scheme, and
463
+ nothing outside this widget gives it meaning. It is used because the protocol
464
+ asks for a URI, and because `https://` is reserved for resources a client can
465
+ fetch from the web by itself — which page resources are not. The form is
466
+ `page:<name>` with no path, because a page has only one namespace. Expect this to
467
+ change.
468
+
469
+ **The card is found by its media type.** A client matches
470
+ `script[type="application/mcp-static-primitives+json"]`, so the element needs no
471
+ `id`. This is how a client that was never told about your page can still read
472
+ it, and it follows SEP-0000's "HTML-delivery mode". The `page_card_id:` option
473
+ is kept only for a host that cannot set the type; it is read when no block with
474
+ the media type is present.
475
+
476
+ **A document may hold several blocks.** A page built from components can let
477
+ each component declare its own primitives, and the arrays join by primitive
478
+ type. Declaring the same tool or prompt `name`, or the same resource `uri`,
479
+ twice is an error: every copy is dropped and a message is written to the
480
+ console. The first one does not win, because which block comes first depends on
481
+ the order the page's parts are rendered in, and that is not something the author
482
+ chose.
483
+
484
+ **The card is optional.** A page that declares nothing keeps working exactly as
485
+ before, and a page that declares no `tools` still uses its `ai-actions` block.
486
+
415
487
  ## Surviving navigation
416
488
 
417
489
  A page action that navigates — submitting a form, following a link — no longer
@@ -560,6 +560,25 @@ function parseSseFrame(raw) {
560
560
  return { name, data };
561
561
  }
562
562
  var STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives";
563
+ var PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json";
564
+ function mergeStaticPrimitivesCards(cards, warn = console.warn) {
565
+ const merged = { tools: [], prompts: [], resources: [] };
566
+ const keyOf = { tools: "name", prompts: "name", resources: "uri" };
567
+ for (const kind of ["tools", "prompts", "resources"]) {
568
+ const key = keyOf[kind];
569
+ const all = [];
570
+ for (const card of cards || []) all.push(...card && card[kind] || []);
571
+ const counts = /* @__PURE__ */ new Map();
572
+ for (const entry of all) counts.set(entry[key], (counts.get(entry[key]) || 0) + 1);
573
+ for (const [id, n] of counts) {
574
+ if (n > 1) {
575
+ warn(`[llm-meta-widget] the page declares ${kind} "${id}" ${n} times; dropping every copy \u2014 remove the duplicate to say which one you mean`);
576
+ }
577
+ }
578
+ merged[kind] = all.filter((entry) => counts.get(entry[key]) === 1);
579
+ }
580
+ return merged;
581
+ }
563
582
  function readStaticPrimitivesCard(text, warn = console.warn) {
564
583
  const empty = { tools: [], prompts: [], resources: [] };
565
584
  if (typeof text !== "string" || text.trim() === "") return empty;
@@ -737,7 +756,20 @@ async function resourceLinesForTurn({ plan, cached, endpoint, read, budgetBytes
737
756
  }
738
757
  return { lines: resourceContextLines(cached), cached };
739
758
  }
759
+ var PAGE_URI_PREFIX = "page:";
740
760
  var PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" };
761
+ function promptArgState({ entries = [], resources = {}, state = {} } = {}) {
762
+ const merged = Object.assign({}, state);
763
+ for (const entry of entries) {
764
+ const uri = entry && entry.uri;
765
+ if (!uri || uri.indexOf(PAGE_URI_PREFIX) !== 0) continue;
766
+ const name = uri.slice(PAGE_URI_PREFIX.length);
767
+ const reader = resources[uri];
768
+ if (!name || merged[name] || typeof reader !== "function") continue;
769
+ merged[name] = { description: entry.description || "", read: reader };
770
+ }
771
+ return merged;
772
+ }
741
773
  function promptArgFromState(argName, state) {
742
774
  const entry = state && state[argName] || state && state[PROMPT_ARG_ALIASES[argName]];
743
775
  const reader = entry && typeof entry.read === "function" ? entry.read : null;
@@ -3887,8 +3919,18 @@ function boot(cfg) {
3887
3919
  model: MODEL
3888
3920
  });
3889
3921
  }
3890
- var pageCardEl = document.getElementById(PAGE_CARD_ID);
3891
- var pageCard = readStaticPrimitivesCard(pageCardEl ? pageCardEl.textContent : null);
3922
+ var typedCards = document.querySelectorAll('script[type="' + PAGE_CARD_MEDIA_TYPE + '"]');
3923
+ var pageCard;
3924
+ if (typedCards.length) {
3925
+ pageCard = mergeStaticPrimitivesCards(
3926
+ Array.prototype.map.call(typedCards, function(el) {
3927
+ return readStaticPrimitivesCard(el.textContent);
3928
+ })
3929
+ );
3930
+ } else {
3931
+ var pageCardEl = document.getElementById(PAGE_CARD_ID);
3932
+ pageCard = readStaticPrimitivesCard(pageCardEl ? pageCardEl.textContent : null);
3933
+ }
3892
3934
  var actionsSchemaEl = document.getElementById(ACTIONS_SCHEMA_ID);
3893
3935
  var localTools = pageCard.tools.length ? pageCard.tools : actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
3894
3936
  var remoteToolsEl = document.getElementById(REMOTE_TOOLS_SCHEMA_ID);
@@ -4162,6 +4204,13 @@ function boot(cfg) {
4162
4204
  renderPromptButtons();
4163
4205
  renderWelcome();
4164
4206
  })();
4207
+ function argState() {
4208
+ return promptArgState({
4209
+ entries: pageCard.resources,
4210
+ resources: window[RESOURCES_GLOBAL] || {},
4211
+ state: window[STATE_GLOBAL] || {}
4212
+ });
4213
+ }
4165
4214
  function renderPromptButtons() {
4166
4215
  if (!promptsEl) return;
4167
4216
  promptsEl.textContent = "";
@@ -4213,7 +4262,7 @@ function boot(cfg) {
4213
4262
  hello.className = "lmw-welcome-hello";
4214
4263
  hello.textContent = GREETING || "Hi \u2014 I can work this page for you. Tell me what you need in your own words" + (hostWidePrompts.length ? ", or start with one of these:" : ".");
4215
4264
  box.appendChild(hello);
4216
- var state = window[STATE_GLOBAL] || {};
4265
+ var state = argState();
4217
4266
  hostWidePrompts.forEach(function(prompt) {
4218
4267
  var props = promptButtonProps(prompt);
4219
4268
  var card = document.createElement("div");
@@ -4249,7 +4298,7 @@ function boot(cfg) {
4249
4298
  historyEl.appendChild(box);
4250
4299
  }
4251
4300
  async function runPromptTemplate(prompt, button) {
4252
- var resolved = resolvePromptArguments(prompt, window[STATE_GLOBAL] || {});
4301
+ var resolved = resolvePromptArguments(prompt, argState());
4253
4302
  var args = resolved.args;
4254
4303
  var missing = resolved.missing;
4255
4304
  if (missing.length) {
@@ -879,6 +879,43 @@ export const STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives
879
879
  // typo in it should cost that one entry, not the assistant. Everything
880
880
  // unparseable returns empty arrays and says so on the console, which is how
881
881
  // aiState already treats a reader missing its description.
882
+ // The media type that locates a card inside an HTML document. It is the
883
+ // locator, so it is NOT configurable: a client that has to be told where to
884
+ // look cannot discover anything. SEP-0000, "HTML-delivery mode".
885
+ export const PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json"
886
+
887
+ // Several blocks may appear in one document, so that a page assembled from
888
+ // components can let each component declare its own primitives. The arrays
889
+ // concatenate by primitive type.
890
+ //
891
+ // A duplicate identifier across blocks is an ERROR, not a precedence puzzle:
892
+ // the spec says a client should report it and should not silently pick one. So
893
+ // every copy of a duplicated identifier is dropped and the author is told.
894
+ // Picking the first would make which component wins depend on DOM order, which
895
+ // is not something a page author controls when the components are partials.
896
+ export function mergeStaticPrimitivesCards(cards, warn = console.warn) {
897
+ const merged = { tools: [], prompts: [], resources: [] }
898
+ const keyOf = { tools: "name", prompts: "name", resources: "uri" }
899
+
900
+ for (const kind of [ "tools", "prompts", "resources" ]) {
901
+ const key = keyOf[kind]
902
+ const all = []
903
+ for (const card of cards || []) all.push(...((card && card[kind]) || []))
904
+
905
+ const counts = new Map()
906
+ for (const entry of all) counts.set(entry[key], (counts.get(entry[key]) || 0) + 1)
907
+
908
+ for (const [ id, n ] of counts) {
909
+ if (n > 1) {
910
+ warn(`[llm-meta-widget] the page declares ${kind} "${id}" ${n} times; ` +
911
+ "dropping every copy — remove the duplicate to say which one you mean")
912
+ }
913
+ }
914
+ merged[kind] = all.filter((entry) => counts.get(entry[key]) === 1)
915
+ }
916
+ return merged
917
+ }
918
+
882
919
  export function readStaticPrimitivesCard(text, warn = console.warn) {
883
920
  const empty = { tools: [], prompts: [], resources: [] }
884
921
  if (typeof text !== "string" || text.trim() === "") return empty
@@ -1129,8 +1166,35 @@ export async function resourceLinesForTurn({ plan, cached, endpoint, read, budge
1129
1166
  // (window.aiState) by name. `dictionaries` is read from a
1130
1167
  // `selected_dictionaries` reader: the page selects a LIST, and the prompt
1131
1168
  // declares the CSV shape the text_annotation tool already takes.
1169
+ // Page resources are addressed `page:<name>`: a custom scheme because the
1170
+ // protocol asks for a URI and `https:` is reserved for what a client can fetch
1171
+ // itself, and with no path because a page has one namespace.
1172
+ const PAGE_URI_PREFIX = "page:"
1173
+
1132
1174
  const PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" }
1133
1175
 
1176
+ // Prompt arguments are declared by plain name (`text`); page resources are
1177
+ // addressed by URI (`page:text`). So on a page that declares its state as
1178
+ // resources and keeps no `aiState`, the resolver below finds nothing under the
1179
+ // names it looks up and every argument arrives empty — the visitor is asked to
1180
+ // retype what is already on screen. This presents those resources in the shape
1181
+ // the resolver consumes, keyed by the name inside the URI.
1182
+ //
1183
+ // An explicit `aiState` entry wins: a page running both channels has said what
1184
+ // it wants under that name.
1185
+ export function promptArgState({ entries = [], resources = {}, state = {} } = {}) {
1186
+ const merged = Object.assign({}, state)
1187
+ for (const entry of entries) {
1188
+ const uri = entry && entry.uri
1189
+ if (!uri || uri.indexOf(PAGE_URI_PREFIX) !== 0) continue
1190
+ const name = uri.slice(PAGE_URI_PREFIX.length)
1191
+ const reader = resources[uri]
1192
+ if (!name || merged[name] || typeof reader !== "function") continue
1193
+ merged[name] = { description: entry.description || "", read: reader }
1194
+ }
1195
+ return merged
1196
+ }
1197
+
1134
1198
  export function promptArgFromState(argName, state) {
1135
1199
  // Same object shape the system-prompt annex consumes: { description, read }.
1136
1200
  // The bare-function form was removed in 0.8.0, so an entry without `read` is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibranch/llm-meta-widget",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
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": [