llm_meta_widget 0.8.6 → 0.9.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: 83ad21e8631c42b769a30bc71493cf7b73590f16e62a6bdd5747d147e4aeb9c5
4
- data.tar.gz: e65e1289d90bff0db65c8905f4018f2eddaa48e680f1d17afe709a9ac2a59a5f
3
+ metadata.gz: f6dba4c1cd88e26bde9f0179009ae2f47cdd07a30aae6035e619f0ca5b83d32e
4
+ data.tar.gz: 95cca5b804adab95a89dfbdf23b7624cb43304462d4cf6c72a605a7ebef170de
5
5
  SHA512:
6
- metadata.gz: de457ef4d34b201e3d0cfd5e53b3171fa1e9ed88aaa99da68a92fe00a0fe82769942ac25783982ba9397544e2018e645755b6a5f3cc5cfa62d11d2e173648c3b
7
- data.tar.gz: 3d28002e0c0d593cf6b287943e32863a341aaeee82da14b0fd01aa23e46cad77722c434bc06e6b19c792c30dd95c61bd90e0ccb6de694565c845a4ab69ed223a
6
+ metadata.gz: d2c499f1d83a689e52fff3fabb0cc08f359c4d34571295085718f3fa5b8c13ccf565f6f47c9ae73b210f1bea350581e7e1c4ae47ac27a759116364518fce2bd2
7
+ data.tar.gz: bb45042dcae5849cf88750d8d6235416fda8e531f0852ed454405c59c4358ff3c31137b78349b6b87be8fc490c3235de62b5b9bebd4f0a61cea790c0cf0925ba
data/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,59 @@ 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/json" id="ai-page">
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 optional.** A page that declares nothing keeps working exactly as
470
+ before, and a page that declares no `tools` still uses its `ai-actions` block.
471
+
415
472
  ## Surviving navigation
416
473
 
417
474
  A page action that navigates — submitting a form, following a link — no longer
@@ -26,8 +26,11 @@
26
26
  export const DEFAULTS = Object.freeze({
27
27
  API_KEY_UUID: "ollama-local",
28
28
  ACTIONS_SCHEMA_ID: "ai-actions",
29
+ PAGE_CARD_ID: "ai-page",
29
30
  STATE_GLOBAL: "aiState",
30
31
  ACTIONS_GLOBAL: "aiActions",
32
+ PROMPTS_GLOBAL: "aiPrompts",
33
+ RESOURCES_GLOBAL: "aiResources",
31
34
  REMOTE_TOOLS_SCHEMA_ID: "remote-mcp-tools",
32
35
  LLM_PROVIDER: "llm_meta_server",
33
36
  MAX_ROUNDS: 3,
@@ -59,9 +62,12 @@ export function readConfig(el) {
59
62
  API_KEY_UUID: str("api-key-uuid", DEFAULTS.API_KEY_UUID),
60
63
  MODEL: str("model", null),
61
64
  ACTIONS_SCHEMA_ID: str("actions-schema-id", DEFAULTS.ACTIONS_SCHEMA_ID),
65
+ PAGE_CARD_ID: str("page-card-id", DEFAULTS.PAGE_CARD_ID),
62
66
  GREETING: str("greeting", null),
63
67
  STATE_GLOBAL: str("state-global", DEFAULTS.STATE_GLOBAL),
64
68
  ACTIONS_GLOBAL: str("actions-global", DEFAULTS.ACTIONS_GLOBAL),
69
+ PROMPTS_GLOBAL: str("prompts-global", DEFAULTS.PROMPTS_GLOBAL),
70
+ RESOURCES_GLOBAL: str("resources-global", DEFAULTS.RESOURCES_GLOBAL),
65
71
  REMOTE_TOOLS_SCHEMA_ID: str("remote-tools-schema-id", DEFAULTS.REMOTE_TOOLS_SCHEMA_ID),
66
72
  MAX_ROUNDS: int("max-rounds", DEFAULTS.MAX_ROUNDS),
67
73
  WELL_KNOWN_URLS: wellKnown === null
@@ -22,7 +22,10 @@ import { runChatLoop, fetchMcpManifest, listMcpPrompts, getMcpPrompt,
22
22
  listMcpResources, readMcpResource, promptMessagesToText,
23
23
  loadHostResource, resourceLinesForTurn, resolvePromptArguments,
24
24
  promptButtonProps, promptArgumentSummary, fetchOllamaModels,
25
- createConversationStore, conversationKeyFor } from "./orchestrator.js";
25
+ createConversationStore, conversationKeyFor,
26
+ readStaticPrimitivesCard, getPagePrompt,
27
+ planPageResources, pageResourceLinesForTurn,
28
+ promptArgState } from "./orchestrator.js";
26
29
  import { marked } from "./marked.esm.js";
27
30
  import { readConfig } from "./config.js";
28
31
  import conversationCss from "../../stylesheets/llm_meta_widget/conversation.css";
@@ -132,9 +135,12 @@ function boot(cfg) {
132
135
  var API_KEY_UUID = cfg.API_KEY_UUID;
133
136
  var MODEL = cfg.MODEL;
134
137
  var ACTIONS_SCHEMA_ID = cfg.ACTIONS_SCHEMA_ID;
138
+ var PAGE_CARD_ID = cfg.PAGE_CARD_ID;
135
139
  var GREETING = cfg.GREETING;
136
140
  var STATE_GLOBAL = cfg.STATE_GLOBAL;
137
141
  var ACTIONS_GLOBAL = cfg.ACTIONS_GLOBAL;
142
+ var PROMPTS_GLOBAL = cfg.PROMPTS_GLOBAL;
143
+ var RESOURCES_GLOBAL = cfg.RESOURCES_GLOBAL;
138
144
  var REMOTE_TOOLS_SCHEMA_ID = cfg.REMOTE_TOOLS_SCHEMA_ID;
139
145
  var MAX_ROUNDS = cfg.MAX_ROUNDS;
140
146
  var WELL_KNOWN_URLS = cfg.WELL_KNOWN_URLS;
@@ -295,8 +301,17 @@ function boot(cfg) {
295
301
  model: MODEL
296
302
  });
297
303
  }
304
+ // The page card is the same static-primitives envelope a server puts in
305
+ // its Server Card, delivered in the document. Its `tools` REPLACE the
306
+ // older #ai-actions schema tag rather than merging with it: two sources
307
+ // for one list means a reader has to work out which the page meant, and a
308
+ // handler in window.aiActions answers to a name either way.
309
+ var pageCardEl = document.getElementById(PAGE_CARD_ID);
310
+ var pageCard = readStaticPrimitivesCard(pageCardEl ? pageCardEl.textContent : null);
298
311
  var actionsSchemaEl = document.getElementById(ACTIONS_SCHEMA_ID);
299
- var localTools = actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
312
+ var localTools = pageCard.tools.length
313
+ ? pageCard.tools
314
+ : (actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : []);
300
315
  var remoteToolsEl = document.getElementById(REMOTE_TOOLS_SCHEMA_ID);
301
316
  // remoteTools is a mutable live list: starts from the schema-provided
302
317
  // baseline (if any), then the level-1 picker adds/removes entries as
@@ -576,7 +591,12 @@ function boot(cfg) {
576
591
  // Fetch well-known MCP manifests at boot. Auto-discovers same origin
577
592
  // if WELL_KNOWN_URLS is null; empty array disables entirely.
578
593
  var hostWideTools = [];
579
- var hostWidePrompts = [];
594
+ // The page's own prompts sit in the same list as the hub's: to a visitor
595
+ // they are the same affordance. `source` is what routes prompts/get —
596
+ // a page prompt has no endpoint to ask.
597
+ var hostWidePrompts = pageCard.prompts.map(function(p) {
598
+ return Object.assign({}, p, { source: "page" });
599
+ });
580
600
  var resourceContext = null; // payload of the host's reference resource
581
601
  var resourcePlan = null; // the pre-flight decision, kept so Clear can re-arm the gate
582
602
 
@@ -585,11 +605,30 @@ function boot(cfg) {
585
605
  // is 218 dictionaries / ~35KB / ~10k tokens.
586
606
  var RESOURCE_BUDGET_BYTES = 8000;
587
607
 
608
+ // The page's own resources, planned once from the card. Unlike the hub's
609
+ // single reference resource these are a list: a page declares one per
610
+ // thing it knows, each with its own description.
611
+ var pageResourcePlans = planPageResources({
612
+ entries: pageCard.resources,
613
+ budgetBytes: RESOURCE_BUDGET_BYTES,
614
+ onSkip: function(plan) {
615
+ console.info("[llm_meta_widget] not attaching " + plan.uri +
616
+ " (" + plan.reason + ", size=" + plan.size + ")");
617
+ }
618
+ });
619
+ var pageResourceCache = {};
620
+
588
621
 
589
622
  // Before anything asynchronous: if this page was navigated away from and
590
623
  // back, the visitor should see their conversation immediately.
591
624
  restoreConversation();
592
625
 
626
+ // Looked up BEFORE the discovery IIFE, not after it. That block calls
627
+ // renderPromptButtons(), and with no well-known URLs to fetch it has no
628
+ // await at all — so it ran while this was still undefined and the render
629
+ // bailed at its own guard. A page that declares its own prompts is
630
+ // exactly the case with nothing to await.
631
+ var promptsEl = root.querySelector(".lmw-prompts");
593
632
  var wellKnownReady = (async function() {
594
633
  var urls = WELL_KNOWN_URLS === null
595
634
  ? [ window.location.origin + "/.well-known/mcp.json" ]
@@ -646,7 +685,15 @@ function boot(cfg) {
646
685
  // the message (prompts/get), then drops the text into the textarea and
647
686
  // submits — so it travels the exact path a typed message does.
648
687
 
649
- var promptsEl = root.querySelector(".lmw-prompts");
688
+
689
+ // Where a prompt's declared arguments are filled from. A card-only page keeps
690
+ // its state in aiResources under `page:<name>` URIs, so the names the resolver
691
+ // looks up live there, not in aiState.
692
+ function argState() {
693
+ return promptArgState({ entries: pageCard.resources,
694
+ resources: window[RESOURCES_GLOBAL] || {},
695
+ state: window[STATE_GLOBAL] || {} });
696
+ }
650
697
 
651
698
  function renderPromptButtons() {
652
699
  if (!promptsEl) return;
@@ -712,7 +759,7 @@ function boot(cfg) {
712
759
  (hostWidePrompts.length ? ", or start with one of these:" : ".");
713
760
  box.appendChild(hello);
714
761
 
715
- var state = window[STATE_GLOBAL] || {};
762
+ var state = argState();
716
763
  hostWidePrompts.forEach(function(prompt) {
717
764
  var props = promptButtonProps(prompt);
718
765
  var card = document.createElement("div");
@@ -755,7 +802,7 @@ function boot(cfg) {
755
802
  }
756
803
 
757
804
  async function runPromptTemplate(prompt, button) {
758
- var resolved = resolvePromptArguments(prompt, window[STATE_GLOBAL] || {});
805
+ var resolved = resolvePromptArguments(prompt, argState());
759
806
  var args = resolved.args;
760
807
  var missing = resolved.missing;
761
808
 
@@ -769,7 +816,9 @@ function boot(cfg) {
769
816
 
770
817
  button.disabled = true;
771
818
  try {
772
- var result = await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args: args });
819
+ var result = prompt.source === "page"
820
+ ? await getPagePrompt({ name: prompt.name, args: args, prompts: window[PROMPTS_GLOBAL] })
821
+ : await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args: args });
773
822
  var text = promptMessagesToText(result);
774
823
  if (!text) { appendTurn("error", "The server returned an empty prompt."); return; }
775
824
  inputEl.value = text;
@@ -1054,7 +1103,18 @@ function boot(cfg) {
1054
1103
  budgetBytes: RESOURCE_BUDGET_BYTES
1055
1104
  });
1056
1105
  resourceContext = turn.cached;
1057
- return turn.lines;
1106
+
1107
+ // The page's own resources ride in the same per-turn slot as the hub's.
1108
+ // Volatile ones are re-read here, which is the whole point: a form field
1109
+ // read at boot is already stale by the time anyone sends a message.
1110
+ var page = await pageResourceLinesForTurn({
1111
+ plans: pageResourcePlans,
1112
+ resources: window[RESOURCES_GLOBAL],
1113
+ budgetBytes: RESOURCE_BUDGET_BYTES,
1114
+ cache: pageResourceCache
1115
+ });
1116
+ pageResourceCache = page.cache;
1117
+ return turn.lines.concat(page.lines);
1058
1118
  }
1059
1119
 
1060
1120
  // Per-turn AbortController — lets the Clear button (or a new submit)
@@ -483,6 +483,17 @@ async function readMcpResource({ endpoint, uri, signal }) {
483
483
  label: `readMcpResource(${uri})`
484
484
  });
485
485
  }
486
+ async function getPagePrompt({ name, args, prompts }) {
487
+ const handler = prompts && prompts[name];
488
+ if (typeof handler !== "function") {
489
+ throw new Error('the page declares prompt "' + name + '" but no handler answers to that name');
490
+ }
491
+ const result = await handler(args || {});
492
+ if (typeof result === "string") {
493
+ return { messages: [{ role: "user", content: { type: "text", text: result } }] };
494
+ }
495
+ return result || { messages: [] };
496
+ }
486
497
  function promptMessagesToText(result) {
487
498
  return (result?.messages || []).map((m) => {
488
499
  const c = m.content;
@@ -549,8 +560,40 @@ function parseSseFrame(raw) {
549
560
  return { name, data };
550
561
  }
551
562
  var STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives";
563
+ function readStaticPrimitivesCard(text, warn = console.warn) {
564
+ const empty = { tools: [], prompts: [], resources: [] };
565
+ if (typeof text !== "string" || text.trim() === "") return empty;
566
+ let doc;
567
+ try {
568
+ doc = JSON.parse(text);
569
+ } catch (e) {
570
+ warn("[llm-meta-widget] page card is not valid JSON; ignoring it:", e.message);
571
+ return empty;
572
+ }
573
+ const ext = doc && doc._meta && doc._meta[STATIC_PRIMITIVES_META] || null;
574
+ if (!ext) {
575
+ warn(`[llm-meta-widget] page card has no _meta["${STATIC_PRIMITIVES_META}"]; ignoring it`);
576
+ return empty;
577
+ }
578
+ const named = (arr, kind) => (Array.isArray(arr) ? arr : []).filter((e) => {
579
+ const ok = e && typeof e.name === "string" && e.name.trim() !== "";
580
+ if (!ok) warn(`[llm-meta-widget] page card: dropping a ${kind} with no name`);
581
+ return ok;
582
+ });
583
+ return {
584
+ tools: named(ext.tools, "tool"),
585
+ prompts: named(ext.prompts, "prompt"),
586
+ // Resources are addressed by uri, not name — resources/read takes the uri.
587
+ resources: (Array.isArray(ext.resources) ? ext.resources : []).filter((e) => {
588
+ const ok = e && typeof e.uri === "string" && e.uri.trim() !== "";
589
+ if (!ok) warn("[llm-meta-widget] page card: dropping a resource with no uri");
590
+ return ok;
591
+ })
592
+ };
593
+ }
552
594
  var VOLATILITIES = ["stable", "volatile"];
553
595
  var usableSize = (n) => typeof n === "number" && isFinite(n) ? n : null;
596
+ var pickDeclared = (own, fromMeta) => own === void 0 ? fromMeta : own;
554
597
  function resourceHints(entry) {
555
598
  const meta = entry && entry._meta && entry._meta[STATIC_PRIMITIVES_META] || {};
556
599
  return {
@@ -566,8 +609,13 @@ function resourceHints(entry) {
566
609
  get sizeBytes() {
567
610
  return this.size;
568
611
  },
569
- volatility: VOLATILITIES.indexOf(meta.volatility) === -1 ? "stable" : meta.volatility,
570
- autoAttach: typeof meta.autoAttach === "boolean" ? meta.autoAttach : true
612
+ // Same rule as `size`: the entry's own field wins, `_meta` is the fallback.
613
+ // SEP-0000 defines these ON the Resource object; a server puts them in
614
+ // `_meta` only because the extension is not merged yet. A PAGE card's
615
+ // entries already sit inside the extension block, so for them the fields
616
+ // are simply top-level — reading both is what lets one planner serve both.
617
+ volatility: VOLATILITIES.indexOf(pickDeclared(entry && entry.volatility, meta.volatility)) === -1 ? "stable" : pickDeclared(entry && entry.volatility, meta.volatility),
618
+ autoAttach: typeof pickDeclared(entry && entry.autoAttach, meta.autoAttach) === "boolean" ? pickDeclared(entry && entry.autoAttach, meta.autoAttach) : true
571
619
  };
572
620
  }
573
621
  function planResourceAttachment(entry, budgetBytes) {
@@ -611,6 +659,45 @@ async function readResourceContext({ endpoint, uri, name, read, budgetBytes }) {
611
659
  return null;
612
660
  }
613
661
  }
662
+ function planPageResources({ entries, budgetBytes, onSkip }) {
663
+ return (entries || []).map((entry) => {
664
+ const plan = planResourceAttachment(entry, budgetBytes);
665
+ plan.name = entry.name || entry.uri;
666
+ plan.description = entry.description || "";
667
+ if (!plan.fetch && onSkip) onSkip(plan);
668
+ return plan;
669
+ }).filter((plan) => plan.fetch);
670
+ }
671
+ function pageResourceLine(plan, text) {
672
+ return "- " + plan.name + (plan.description ? " (" + plan.description + ")" : "") + ": " + text;
673
+ }
674
+ async function pageResourceLinesForTurn({ plans, resources, budgetBytes, cache = {} }) {
675
+ const lines = [];
676
+ for (const plan of plans || []) {
677
+ let text = plan.volatility === "volatile" ? null : cache[plan.uri];
678
+ if (text === null || text === void 0) {
679
+ text = await readPageResource({ uri: plan.uri, resources, budgetBytes });
680
+ if (plan.volatility !== "volatile" && text !== null) cache[plan.uri] = text;
681
+ }
682
+ if (text !== null) lines.push(pageResourceLine(plan, text));
683
+ }
684
+ return { lines, cache };
685
+ }
686
+ async function readPageResource({ uri, resources, budgetBytes }) {
687
+ const handler = resources && resources[uri];
688
+ if (typeof handler !== "function") {
689
+ console.warn('[llm-meta-widget] the page declares resource "' + uri + '" but no handler answers to that uri');
690
+ return null;
691
+ }
692
+ try {
693
+ const value = await handler();
694
+ const text = typeof value === "string" ? value : JSON.stringify(value);
695
+ return trimResourceText(text === void 0 ? "" : text, budgetBytes);
696
+ } catch (e) {
697
+ console.warn('[llm-meta-widget] reading "' + uri + '" failed:', e && e.message);
698
+ return null;
699
+ }
700
+ }
614
701
  async function loadHostResource({ endpoint, budgetBytes, list: list2, read, onSkip }) {
615
702
  const resources = await list2({ endpoint });
616
703
  const candidate = (resources || []).filter((r) => (r.mimeType || "") === "application/json")[0];
@@ -650,7 +737,20 @@ async function resourceLinesForTurn({ plan, cached, endpoint, read, budgetBytes
650
737
  }
651
738
  return { lines: resourceContextLines(cached), cached };
652
739
  }
740
+ var PAGE_URI_PREFIX = "page:";
653
741
  var PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" };
742
+ function promptArgState({ entries = [], resources = {}, state = {} } = {}) {
743
+ const merged = Object.assign({}, state);
744
+ for (const entry of entries) {
745
+ const uri = entry && entry.uri;
746
+ if (!uri || uri.indexOf(PAGE_URI_PREFIX) !== 0) continue;
747
+ const name = uri.slice(PAGE_URI_PREFIX.length);
748
+ const reader = resources[uri];
749
+ if (!name || merged[name] || typeof reader !== "function") continue;
750
+ merged[name] = { description: entry.description || "", read: reader };
751
+ }
752
+ return merged;
753
+ }
654
754
  function promptArgFromState(argName, state) {
655
755
  const entry = state && state[argName] || state && state[PROMPT_ARG_ALIASES[argName]];
656
756
  const reader = entry && typeof entry.read === "function" ? entry.read : null;
@@ -2904,8 +3004,11 @@ var lexer = _Lexer.lex;
2904
3004
  var DEFAULTS = Object.freeze({
2905
3005
  API_KEY_UUID: "ollama-local",
2906
3006
  ACTIONS_SCHEMA_ID: "ai-actions",
3007
+ PAGE_CARD_ID: "ai-page",
2907
3008
  STATE_GLOBAL: "aiState",
2908
3009
  ACTIONS_GLOBAL: "aiActions",
3010
+ PROMPTS_GLOBAL: "aiPrompts",
3011
+ RESOURCES_GLOBAL: "aiResources",
2909
3012
  REMOTE_TOOLS_SCHEMA_ID: "remote-mcp-tools",
2910
3013
  LLM_PROVIDER: "llm_meta_server",
2911
3014
  MAX_ROUNDS: 3
@@ -2939,9 +3042,12 @@ function readConfig(el) {
2939
3042
  API_KEY_UUID: str("api-key-uuid", DEFAULTS.API_KEY_UUID),
2940
3043
  MODEL: str("model", null),
2941
3044
  ACTIONS_SCHEMA_ID: str("actions-schema-id", DEFAULTS.ACTIONS_SCHEMA_ID),
3045
+ PAGE_CARD_ID: str("page-card-id", DEFAULTS.PAGE_CARD_ID),
2942
3046
  GREETING: str("greeting", null),
2943
3047
  STATE_GLOBAL: str("state-global", DEFAULTS.STATE_GLOBAL),
2944
3048
  ACTIONS_GLOBAL: str("actions-global", DEFAULTS.ACTIONS_GLOBAL),
3049
+ PROMPTS_GLOBAL: str("prompts-global", DEFAULTS.PROMPTS_GLOBAL),
3050
+ RESOURCES_GLOBAL: str("resources-global", DEFAULTS.RESOURCES_GLOBAL),
2945
3051
  REMOTE_TOOLS_SCHEMA_ID: str("remote-tools-schema-id", DEFAULTS.REMOTE_TOOLS_SCHEMA_ID),
2946
3052
  MAX_ROUNDS: int("max-rounds", DEFAULTS.MAX_ROUNDS),
2947
3053
  WELL_KNOWN_URLS: wellKnown === null ? null : wellKnown.trim() === "" ? [] : splitList(wellKnown),
@@ -3658,9 +3764,12 @@ function boot(cfg) {
3658
3764
  var API_KEY_UUID = cfg.API_KEY_UUID;
3659
3765
  var MODEL = cfg.MODEL;
3660
3766
  var ACTIONS_SCHEMA_ID = cfg.ACTIONS_SCHEMA_ID;
3767
+ var PAGE_CARD_ID = cfg.PAGE_CARD_ID;
3661
3768
  var GREETING = cfg.GREETING;
3662
3769
  var STATE_GLOBAL = cfg.STATE_GLOBAL;
3663
3770
  var ACTIONS_GLOBAL = cfg.ACTIONS_GLOBAL;
3771
+ var PROMPTS_GLOBAL = cfg.PROMPTS_GLOBAL;
3772
+ var RESOURCES_GLOBAL = cfg.RESOURCES_GLOBAL;
3664
3773
  var REMOTE_TOOLS_SCHEMA_ID = cfg.REMOTE_TOOLS_SCHEMA_ID;
3665
3774
  var MAX_ROUNDS = cfg.MAX_ROUNDS;
3666
3775
  var WELL_KNOWN_URLS = cfg.WELL_KNOWN_URLS;
@@ -3791,8 +3900,10 @@ function boot(cfg) {
3791
3900
  model: MODEL
3792
3901
  });
3793
3902
  }
3903
+ var pageCardEl = document.getElementById(PAGE_CARD_ID);
3904
+ var pageCard = readStaticPrimitivesCard(pageCardEl ? pageCardEl.textContent : null);
3794
3905
  var actionsSchemaEl = document.getElementById(ACTIONS_SCHEMA_ID);
3795
- var localTools = actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
3906
+ var localTools = pageCard.tools.length ? pageCard.tools : actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
3796
3907
  var remoteToolsEl = document.getElementById(REMOTE_TOOLS_SCHEMA_ID);
3797
3908
  var baselineRemoteTools = remoteToolsEl ? JSON.parse(remoteToolsEl.textContent) : [];
3798
3909
  var remoteTools = baselineRemoteTools.slice();
@@ -4015,11 +4126,22 @@ function boot(cfg) {
4015
4126
  }
4016
4127
  }
4017
4128
  var hostWideTools = [];
4018
- var hostWidePrompts = [];
4129
+ var hostWidePrompts = pageCard.prompts.map(function(p) {
4130
+ return Object.assign({}, p, { source: "page" });
4131
+ });
4019
4132
  var resourceContext = null;
4020
4133
  var resourcePlan = null;
4021
4134
  var RESOURCE_BUDGET_BYTES = 8e3;
4135
+ var pageResourcePlans = planPageResources({
4136
+ entries: pageCard.resources,
4137
+ budgetBytes: RESOURCE_BUDGET_BYTES,
4138
+ onSkip: function(plan) {
4139
+ console.info("[llm_meta_widget] not attaching " + plan.uri + " (" + plan.reason + ", size=" + plan.size + ")");
4140
+ }
4141
+ });
4142
+ var pageResourceCache = {};
4022
4143
  restoreConversation();
4144
+ var promptsEl = root.querySelector(".lmw-prompts");
4023
4145
  var wellKnownReady = (async function() {
4024
4146
  var urls = WELL_KNOWN_URLS === null ? [window.location.origin + "/.well-known/mcp.json"] : WELL_KNOWN_URLS;
4025
4147
  for (var i = 0; i < urls.length; i++) {
@@ -4053,7 +4175,13 @@ function boot(cfg) {
4053
4175
  renderPromptButtons();
4054
4176
  renderWelcome();
4055
4177
  })();
4056
- var promptsEl = root.querySelector(".lmw-prompts");
4178
+ function argState() {
4179
+ return promptArgState({
4180
+ entries: pageCard.resources,
4181
+ resources: window[RESOURCES_GLOBAL] || {},
4182
+ state: window[STATE_GLOBAL] || {}
4183
+ });
4184
+ }
4057
4185
  function renderPromptButtons() {
4058
4186
  if (!promptsEl) return;
4059
4187
  promptsEl.textContent = "";
@@ -4105,7 +4233,7 @@ function boot(cfg) {
4105
4233
  hello.className = "lmw-welcome-hello";
4106
4234
  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:" : ".");
4107
4235
  box.appendChild(hello);
4108
- var state = window[STATE_GLOBAL] || {};
4236
+ var state = argState();
4109
4237
  hostWidePrompts.forEach(function(prompt) {
4110
4238
  var props = promptButtonProps(prompt);
4111
4239
  var card = document.createElement("div");
@@ -4141,7 +4269,7 @@ function boot(cfg) {
4141
4269
  historyEl.appendChild(box);
4142
4270
  }
4143
4271
  async function runPromptTemplate(prompt, button) {
4144
- var resolved = resolvePromptArguments(prompt, window[STATE_GLOBAL] || {});
4272
+ var resolved = resolvePromptArguments(prompt, argState());
4145
4273
  var args = resolved.args;
4146
4274
  var missing = resolved.missing;
4147
4275
  if (missing.length) {
@@ -4150,7 +4278,7 @@ function boot(cfg) {
4150
4278
  }
4151
4279
  button.disabled = true;
4152
4280
  try {
4153
- var result = await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args });
4281
+ var result = prompt.source === "page" ? await getPagePrompt({ name: prompt.name, args, prompts: window[PROMPTS_GLOBAL] }) : await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args });
4154
4282
  var text = promptMessagesToText(result);
4155
4283
  if (!text) {
4156
4284
  appendTurn("error", "The server returned an empty prompt.");
@@ -4364,7 +4492,14 @@ function boot(cfg) {
4364
4492
  budgetBytes: RESOURCE_BUDGET_BYTES
4365
4493
  });
4366
4494
  resourceContext = turn.cached;
4367
- return turn.lines;
4495
+ var page = await pageResourceLinesForTurn({
4496
+ plans: pageResourcePlans,
4497
+ resources: window[RESOURCES_GLOBAL],
4498
+ budgetBytes: RESOURCE_BUDGET_BYTES,
4499
+ cache: pageResourceCache
4500
+ });
4501
+ pageResourceCache = page.cache;
4502
+ return turn.lines.concat(page.lines);
4368
4503
  }
4369
4504
  var currentAbort = null;
4370
4505
  clearBtn.addEventListener("click", function() {
@@ -730,6 +730,28 @@ export async function readMcpResource({ endpoint, uri, signal }) {
730
730
  // The spec allows `content` to be an object or an array of content blocks;
731
731
  // anything non-text is dropped, which is worth knowing — a prompt carrying an
732
732
  // image would silently lose it on this path.
733
+ // A prompt the PAGE declared, resolved against its handler map. This is
734
+ // prompts/get for a surface with no transport: the card carries the metadata
735
+ // (name, description, arguments — what prompts/list returns) and the handler
736
+ // returns the filled messages, exactly as a server's prompts/get would.
737
+ //
738
+ // Keyed by `name` because that is what prompts/get takes. Accepts a plain
739
+ // string as well as a messages array: a page prompt is usually one block of
740
+ // text, and making every host write the envelope by hand would be a tax with
741
+ // no benefit — promptMessagesToText flattens both to the same thing.
742
+ export async function getPagePrompt({ name, args, prompts }) {
743
+ const handler = prompts && prompts[name]
744
+ if (typeof handler !== "function") {
745
+ throw new Error('the page declares prompt "' + name + '" but no handler answers to that name')
746
+ }
747
+
748
+ const result = await handler(args || {})
749
+ if (typeof result === "string") {
750
+ return { messages: [ { role: "user", content: { type: "text", text: result } } ] }
751
+ }
752
+ return result || { messages: [] }
753
+ }
754
+
733
755
  export function promptMessagesToText(result) {
734
756
  return (result?.messages || [])
735
757
  .map((m) => {
@@ -840,9 +862,63 @@ function parseSseFrame(raw) {
840
862
  // the first turn only.
841
863
  export const STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives"
842
864
 
865
+ // ---- page card --------------------------------------------------------
866
+ //
867
+ // The same `io.modelcontextprotocol/static-primitives` envelope a server puts
868
+ // in its Server Card, delivered in the DOCUMENT instead: one <script> of JSON
869
+ // declaring the page's own tools, prompts and resources. SEP-2575 already says
870
+ // the Server Card and server/discover are two deliveries of one content; a page
871
+ // is a third, with no transport at all.
872
+ //
873
+ // Parsing only. What a declaration MEANS is resolved elsewhere: tool handlers
874
+ // live in window.aiActions keyed by `name`, and reads will be keyed by the
875
+ // protocol's own identifier — `name` for prompts, `uri` for resources, because
876
+ // that is what prompts/get and resources/read take.
877
+ //
878
+ // Malformed input must never take the page down: a card is host-authored and a
879
+ // typo in it should cost that one entry, not the assistant. Everything
880
+ // unparseable returns empty arrays and says so on the console, which is how
881
+ // aiState already treats a reader missing its description.
882
+ export function readStaticPrimitivesCard(text, warn = console.warn) {
883
+ const empty = { tools: [], prompts: [], resources: [] }
884
+ if (typeof text !== "string" || text.trim() === "") return empty
885
+
886
+ let doc
887
+ try {
888
+ doc = JSON.parse(text)
889
+ } catch (e) {
890
+ warn("[llm-meta-widget] page card is not valid JSON; ignoring it:", e.message)
891
+ return empty
892
+ }
893
+
894
+ const ext = (doc && doc._meta && doc._meta[STATIC_PRIMITIVES_META]) || null
895
+ if (!ext) {
896
+ warn(`[llm-meta-widget] page card has no _meta["${STATIC_PRIMITIVES_META}"]; ignoring it`)
897
+ return empty
898
+ }
899
+
900
+ const named = (arr, kind) => (Array.isArray(arr) ? arr : []).filter((e) => {
901
+ const ok = e && typeof e.name === "string" && e.name.trim() !== ""
902
+ if (!ok) warn(`[llm-meta-widget] page card: dropping a ${kind} with no name`)
903
+ return ok
904
+ })
905
+
906
+ return {
907
+ tools: named(ext.tools, "tool"),
908
+ prompts: named(ext.prompts, "prompt"),
909
+ // Resources are addressed by uri, not name — resources/read takes the uri.
910
+ resources: (Array.isArray(ext.resources) ? ext.resources : []).filter((e) => {
911
+ const ok = e && typeof e.uri === "string" && e.uri.trim() !== ""
912
+ if (!ok) warn("[llm-meta-widget] page card: dropping a resource with no uri")
913
+ return ok
914
+ })
915
+ }
916
+ }
917
+
843
918
  const VOLATILITIES = [ "stable", "volatile" ]
844
919
 
845
920
  const usableSize = (n) => (typeof n === "number" && isFinite(n) ? n : null)
921
+ const pickDeclared = (own, fromMeta) => (own === undefined ? fromMeta : own)
846
922
 
847
923
  export function resourceHints(entry) {
848
924
  const meta = (entry && entry._meta && entry._meta[STATIC_PRIMITIVES_META]) || {}
@@ -857,8 +933,17 @@ export function resourceHints(entry) {
857
933
  // it returns is public API. Dropped at 0.9.0 together with the
858
934
  // `_meta.sizeBytes` fallback above — one deprecation, one bump.
859
935
  get sizeBytes() { return this.size },
860
- volatility: VOLATILITIES.indexOf(meta.volatility) === -1 ? "stable" : meta.volatility,
861
- autoAttach: typeof meta.autoAttach === "boolean" ? meta.autoAttach : true
936
+ // Same rule as `size`: the entry's own field wins, `_meta` is the fallback.
937
+ // SEP-0000 defines these ON the Resource object; a server puts them in
938
+ // `_meta` only because the extension is not merged yet. A PAGE card's
939
+ // entries already sit inside the extension block, so for them the fields
940
+ // are simply top-level — reading both is what lets one planner serve both.
941
+ volatility: VOLATILITIES.indexOf(pickDeclared(entry && entry.volatility, meta.volatility)) === -1
942
+ ? "stable"
943
+ : pickDeclared(entry && entry.volatility, meta.volatility),
944
+ autoAttach: typeof pickDeclared(entry && entry.autoAttach, meta.autoAttach) === "boolean"
945
+ ? pickDeclared(entry && entry.autoAttach, meta.autoAttach)
946
+ : true
862
947
  }
863
948
  }
864
949
 
@@ -920,6 +1005,65 @@ async function readResourceContext({ endpoint, uri, name, read, budgetBytes }) {
920
1005
  }
921
1006
  }
922
1007
 
1008
+ // ---- page resources ----------------------------------------------------
1009
+ //
1010
+ // resources/read for a surface with no transport. The card lists them, a
1011
+ // handler map keyed by URI reads them — URI because that is what
1012
+ // resources/read takes, and because the spec defines it as the unique
1013
+ // identifier while `name` is only required, never unique.
1014
+ //
1015
+ // Unlike a server endpoint, a page declares SEVERAL: PubDictionaries has six
1016
+ // readers, each with its own description. So these are planned and rendered as
1017
+ // a list, not as the single reference resource loadHostResource assumes.
1018
+ export function planPageResources({ entries, budgetBytes, onSkip }) {
1019
+ return (entries || []).map((entry) => {
1020
+ const plan = planResourceAttachment(entry, budgetBytes)
1021
+ plan.name = entry.name || entry.uri
1022
+ plan.description = entry.description || ""
1023
+ if (!plan.fetch && onSkip) onSkip(plan)
1024
+ return plan
1025
+ }).filter((plan) => plan.fetch)
1026
+ }
1027
+
1028
+ // One compact line per resource — the same shape the page-state annex uses,
1029
+ // NOT the block form a hub resource gets. Six block-form resources would cost
1030
+ // three lines each and repeat a header the model does not need; how to render
1031
+ // attached context is the client's decision, which the spec leaves open.
1032
+ export function pageResourceLine(plan, text) {
1033
+ return "- " + plan.name + (plan.description ? " (" + plan.description + ")" : "") + ": " + text
1034
+ }
1035
+
1036
+ export async function pageResourceLinesForTurn({ plans, resources, budgetBytes, cache = {} }) {
1037
+ const lines = []
1038
+ for (const plan of plans || []) {
1039
+ let text = plan.volatility === "volatile" ? null : cache[plan.uri]
1040
+ if (text === null || text === undefined) {
1041
+ text = await readPageResource({ uri: plan.uri, resources, budgetBytes })
1042
+ if (plan.volatility !== "volatile" && text !== null) cache[plan.uri] = text
1043
+ }
1044
+ if (text !== null) lines.push(pageResourceLine(plan, text))
1045
+ }
1046
+ return { lines, cache }
1047
+ }
1048
+
1049
+ async function readPageResource({ uri, resources, budgetBytes }) {
1050
+ const handler = resources && resources[uri]
1051
+ if (typeof handler !== "function") {
1052
+ console.warn('[llm-meta-widget] the page declares resource "' + uri + '" but no handler answers to that uri')
1053
+ return null
1054
+ }
1055
+ try {
1056
+ const value = await handler()
1057
+ const text = typeof value === "string" ? value : JSON.stringify(value)
1058
+ return trimResourceText(text === undefined ? "" : text, budgetBytes)
1059
+ } catch (e) {
1060
+ // Optional context — one broken reader must not cost the turn, exactly as
1061
+ // a failed hub read does not.
1062
+ console.warn('[llm-meta-widget] reading "' + uri + '" failed:', e && e.message)
1063
+ return null
1064
+ }
1065
+ }
1066
+
923
1067
  // The discovery sequence for one endpoint's reference resource, kept here
924
1068
  // rather than in the panel so the decision AND the call site are testable
925
1069
  // together: a gate nothing consults is exactly the bug this shape prevents.
@@ -985,8 +1129,35 @@ export async function resourceLinesForTurn({ plan, cached, endpoint, read, budge
985
1129
  // (window.aiState) by name. `dictionaries` is read from a
986
1130
  // `selected_dictionaries` reader: the page selects a LIST, and the prompt
987
1131
  // declares the CSV shape the text_annotation tool already takes.
1132
+ // Page resources are addressed `page:<name>`: a custom scheme because the
1133
+ // protocol asks for a URI and `https:` is reserved for what a client can fetch
1134
+ // itself, and with no path because a page has one namespace.
1135
+ const PAGE_URI_PREFIX = "page:"
1136
+
988
1137
  const PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" }
989
1138
 
1139
+ // Prompt arguments are declared by plain name (`text`); page resources are
1140
+ // addressed by URI (`page:text`). So on a page that declares its state as
1141
+ // resources and keeps no `aiState`, the resolver below finds nothing under the
1142
+ // names it looks up and every argument arrives empty — the visitor is asked to
1143
+ // retype what is already on screen. This presents those resources in the shape
1144
+ // the resolver consumes, keyed by the name inside the URI.
1145
+ //
1146
+ // An explicit `aiState` entry wins: a page running both channels has said what
1147
+ // it wants under that name.
1148
+ export function promptArgState({ entries = [], resources = {}, state = {} } = {}) {
1149
+ const merged = Object.assign({}, state)
1150
+ for (const entry of entries) {
1151
+ const uri = entry && entry.uri
1152
+ if (!uri || uri.indexOf(PAGE_URI_PREFIX) !== 0) continue
1153
+ const name = uri.slice(PAGE_URI_PREFIX.length)
1154
+ const reader = resources[uri]
1155
+ if (!name || merged[name] || typeof reader !== "function") continue
1156
+ merged[name] = { description: entry.description || "", read: reader }
1157
+ }
1158
+ return merged
1159
+ }
1160
+
990
1161
  export function promptArgFromState(argName, state) {
991
1162
  // Same object shape the system-prompt annex consumes: { description, read }.
992
1163
  // The bare-function form was removed in 0.8.0, so an entry without `read` is
@@ -39,8 +39,18 @@ module LlmMetaWidget
39
39
  # instead of this gem (same file, published to npm).
40
40
  element_path: "/llm_meta_widget_assets/llm-meta-widget.js",
41
41
  actions_schema_id: "ai-actions",
42
+ # The page card: the same static-primitives envelope a server puts in
43
+ # its Server Card, delivered in the document. Its `tools` replace the
44
+ # actions_schema_id block when the card declares any.
45
+ page_card_id: "ai-page",
42
46
  state_global: "aiState",
43
47
  actions_global: "aiActions",
48
+ # Page-declared prompts resolve their messages here, keyed by name —
49
+ # prompts/get for a surface with no transport.
50
+ prompts_global: "aiPrompts",
51
+ # Page-declared resources are read here, keyed by URI — resources/read
52
+ # for the same surface. URI, because that is what resources/read takes.
53
+ resources_global: "aiResources",
44
54
  remote_tools_schema_id: "remote-mcp-tools",
45
55
  # nil → widget auto-discovers same-origin /.well-known/mcp.json at boot;
46
56
  # explicit array → fetch those URLs; empty array → disable entirely.
@@ -28,8 +28,11 @@
28
28
  <%= tag_attr("greeting", greeting) %>
29
29
  <%= tag_attr("max-rounds", max_rounds) %>
30
30
  <%= tag_attr("actions-schema-id", actions_schema_id) %>
31
+ <%= tag_attr("page-card-id", page_card_id) %>
31
32
  <%= tag_attr("state-global", state_global) %>
32
33
  <%= tag_attr("actions-global", actions_global) %>
34
+ <%= tag_attr("prompts-global", prompts_global) %>
35
+ <%= tag_attr("resources-global", resources_global) %>
33
36
  <%= tag_attr("remote-tools-schema-id", remote_tools_schema_id) %>
34
37
  <%# Pickers default to true in the element, so only emit the disabling value. %>
35
38
  <%= enable_model_picker ? "" : 'enable-model-picker="false"'.html_safe %>
@@ -1,3 +1,3 @@
1
1
  module LlmMetaWidget
2
- VERSION = "0.8.6"
2
+ VERSION = "0.9.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.8.6
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - jdkim