llm_meta_widget 0.1.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f20be01e9b1ac0bfc27351f7c1583478eaf3f1c9c5a9ef0739f95e71f965b03
4
- data.tar.gz: cb74a7a835799fb21b18ef51637f03320da067b37b955532705aae48ceb46df8
3
+ metadata.gz: 6d7a58b9b553be09b66ca6e9aa01f8264a280bb725f80820ec3de506573701ae
4
+ data.tar.gz: 7b171f0c86c5f45a6250975ab02da586d620602d8fc1bea95261dd963d17e67a
5
5
  SHA512:
6
- metadata.gz: 21a3107c99ecb9d7f27aee4378f9f9e816bcdaf140421cddc32967d4df6703d577972c5087cb0efe17eeb2ad5a924f162b406c30dd50f2fcc07a1935f98ff3c7
7
- data.tar.gz: 0fec2f2cf2ad99af3cf4c932f7c35028ddd0017e331a559d9d90b339a4ad8d3dc920a492fdb2dcdf21a050f7f7dd5a3722968598ff2dbc1c15906604bd613499
6
+ metadata.gz: e90d55856a37217816264730d1a65fd32af2ad4783ad0c1a09ed298fb5dfe35a618a1ca149bf77265aab346dba4437b960a98399e91b3b15695aa601ed9d2025
7
+ data.tar.gz: 042b6872987730e708758fee7c6043ac27b0a83b27935e33f0751a910307cd901e746f28a2557dc54dfc32f63d5a4039f43cefcbc1952d93613cc59568e4c44a
@@ -594,19 +594,20 @@ export async function fetchMcpManifest(manifestUrl) {
594
594
  // `result` value (or throws on JSON-RPC error / HTTP failure). Supports
595
595
  // both JSON and SSE responses (MCP over HTTP allows either).
596
596
  let _mcpReqId = 0
597
- export async function callMcpTool({ endpoint, name, args, signal }) {
597
+
598
+ // One JSON-RPC round trip to an MCP endpoint. Extracted from callMcpTool so
599
+ // prompts/* and resources/* reuse the same transport — MCP over HTTP may
600
+ // answer with either JSON or SSE, and duplicating that handling per method
601
+ // is how the two drift apart.
602
+ export async function mcpRpc({ endpoint, method, params, signal, label }) {
603
+ const what = label || method
598
604
  const response = await fetch(endpoint, {
599
605
  method: "POST",
600
606
  headers: {
601
607
  "Content-Type": "application/json",
602
608
  "Accept": "application/json, text/event-stream"
603
609
  },
604
- body: JSON.stringify({
605
- jsonrpc: "2.0",
606
- id: ++_mcpReqId,
607
- method: "tools/call",
608
- params: { name, arguments: args || {} }
609
- }),
610
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++_mcpReqId, method, params: params || {} }),
610
611
  signal,
611
612
  // Send session cookies for same-origin MCP endpoints. Cross-origin CORS
612
613
  // with credentials requires the server to echo Access-Control-Allow-
@@ -617,16 +618,14 @@ export async function callMcpTool({ endpoint, name, args, signal }) {
617
618
  })
618
619
  if (!response.ok) {
619
620
  const text = await response.text().catch(() => "")
620
- throw new Error(`callMcpTool(${name}): HTTP ${response.status}${text ? " — " + text.slice(0, 200) : ""}`)
621
+ throw new Error(`${what}: HTTP ${response.status}${text ? " — " + text.slice(0, 200) : ""}`)
621
622
  }
622
623
 
623
624
  const contentType = response.headers.get("content-type") || ""
624
625
 
625
626
  if (contentType.includes("application/json")) {
626
627
  const body = await response.json()
627
- if (body?.error) {
628
- throw new Error(`callMcpTool(${name}): ${body.error.message || "JSON-RPC error"}`)
629
- }
628
+ if (body?.error) throw new Error(`${what}: ${body.error.message || "JSON-RPC error"}`)
630
629
  return body?.result
631
630
  }
632
631
 
@@ -635,15 +634,77 @@ export async function callMcpTool({ endpoint, name, args, signal }) {
635
634
  for await (const evt of parseSseStream(response.body, signal)) {
636
635
  const payload = evt.data
637
636
  if (!payload || typeof payload !== "object") continue
638
- if (payload.error) {
639
- throw new Error(`callMcpTool(${name}): ${payload.error.message || "JSON-RPC error"}`)
640
- }
637
+ if (payload.error) throw new Error(`${what}: ${payload.error.message || "JSON-RPC error"}`)
641
638
  if ("result" in payload) return payload.result
642
639
  }
643
- throw new Error(`callMcpTool(${name}): SSE stream ended without result`)
640
+ throw new Error(`${what}: SSE stream ended without result`)
644
641
  }
645
642
 
646
- throw new Error(`callMcpTool(${name}): unexpected content-type ${contentType}`)
643
+ throw new Error(`${what}: unexpected content-type ${contentType}`)
644
+ }
645
+
646
+ export async function callMcpTool({ endpoint, name, args, signal }) {
647
+ return mcpRpc({
648
+ endpoint, signal, method: "tools/call",
649
+ params: { name, arguments: args || {} },
650
+ label: `callMcpTool(${name})`
651
+ })
652
+ }
653
+
654
+ // ---------------------------------------------------------------------------
655
+ // Prompt and resource primitives
656
+ // ---------------------------------------------------------------------------
657
+ //
658
+ // Optional in MCP, so every one of these returns empty rather than throwing
659
+ // when a server doesn't implement them: the widget must stay usable against a
660
+ // tools-only server. A server that answers `prompts/list` with -32601 is
661
+ // telling us "not supported"; one that errors some other way is broken, and
662
+ // either way the widget carries on with whatever it did get.
663
+
664
+ export async function listMcpPrompts({ endpoint, signal }) {
665
+ try {
666
+ const result = await mcpRpc({ endpoint, method: "prompts/list", signal })
667
+ return (result?.prompts || []).map((p) => ({ ...p, endpoint }))
668
+ } catch { return [] }
669
+ }
670
+
671
+ export async function getMcpPrompt({ endpoint, name, args, signal }) {
672
+ return mcpRpc({
673
+ endpoint, signal, method: "prompts/get",
674
+ params: { name, arguments: args || {} },
675
+ label: `getMcpPrompt(${name})`
676
+ })
677
+ }
678
+
679
+ export async function listMcpResources({ endpoint, signal }) {
680
+ try {
681
+ const result = await mcpRpc({ endpoint, method: "resources/list", signal })
682
+ return (result?.resources || []).map((r) => ({ ...r, endpoint }))
683
+ } catch { return [] }
684
+ }
685
+
686
+ export async function readMcpResource({ endpoint, uri, signal }) {
687
+ return mcpRpc({
688
+ endpoint, signal, method: "resources/read",
689
+ params: { uri }, label: `readMcpResource(${uri})`
690
+ })
691
+ }
692
+
693
+ // Flatten a prompts/get result into the plain string the hub's messages take.
694
+ // The spec allows `content` to be an object or an array of content blocks;
695
+ // anything non-text is dropped, which is worth knowing — a prompt carrying an
696
+ // image would silently lose it on this path.
697
+ export function promptMessagesToText(result) {
698
+ return (result?.messages || [])
699
+ .map((m) => {
700
+ const c = m.content
701
+ if (typeof c === "string") return c
702
+ if (Array.isArray(c)) return c.filter((b) => b?.type === "text").map((b) => b.text).join("\n")
703
+ if (c && c.type === "text") return c.text
704
+ return ""
705
+ })
706
+ .filter(Boolean)
707
+ .join("\n\n")
647
708
  }
648
709
 
649
710
  // Standalone remote dispatcher — POSTs one tool_call to the meta-server's
@@ -715,3 +776,205 @@ function parseSseFrame(raw) {
715
776
  }
716
777
  return { name, data }
717
778
  }
779
+
780
+ // ---- static-primitives extension ---------------------------------------
781
+ //
782
+ // Prototype of `io.modelcontextprotocol/static-primitives` (the SEP-2127
783
+ // follow-on). Three optional fields a server may declare on a resources/list
784
+ // entry:
785
+ //
786
+ // sizeBytes - byte length of the payload resources/read would return, so a
787
+ // client can decide whether to attach it BEFORE fetching it.
788
+ // volatility - "stable" (content fixed) or "volatile" (varies between
789
+ // turns, so a client re-reads it each turn).
790
+ // autoAttach - may a client attach this without the user asking for it?
791
+ //
792
+ // volatility and autoAttach are separate on purpose. Whether content changes
793
+ // says nothing about whether it may be attached unasked, and the client needs
794
+ // autoAttach as a boolean anyway, because its own byte budget can withhold a
795
+ // resource the server was happy to hand over.
796
+ //
797
+ // All three are optional, and a server declaring none must behave exactly as
798
+ // it did before the extension existed: fetch once, trim to budget, attach on
799
+ // the first turn only.
800
+ export const STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives"
801
+
802
+ const VOLATILITIES = [ "stable", "volatile" ]
803
+
804
+ export function resourceHints(entry) {
805
+ const meta = (entry && entry._meta && entry._meta[STATIC_PRIMITIVES_META]) || {}
806
+ return {
807
+ // A non-numeric or absent size means "unknown", never 0 — 0 would read
808
+ // as a free resource and sail through every budget check.
809
+ sizeBytes: typeof meta.sizeBytes === "number" && isFinite(meta.sizeBytes) ? meta.sizeBytes : null,
810
+ volatility: VOLATILITIES.indexOf(meta.volatility) === -1 ? "stable" : meta.volatility,
811
+ autoAttach: typeof meta.autoAttach === "boolean" ? meta.autoAttach : true
812
+ }
813
+ }
814
+
815
+ // The pre-flight decision, made from the resources/list entry alone.
816
+ // `fetch: false` means the bytes never cross the wire at all.
817
+ export function planResourceAttachment(entry, budgetBytes) {
818
+ const { sizeBytes, volatility, autoAttach } = resourceHints(entry)
819
+ const base = { sizeBytes, volatility, uri: entry && entry.uri }
820
+
821
+ if (!autoAttach) {
822
+ return { ...base, fetch: false, autoAttach: false, reason: "not-auto-attach" }
823
+ }
824
+ if (sizeBytes !== null && sizeBytes > budgetBytes) {
825
+ // The client's budget overrides the server's willingness — which is why
826
+ // autoAttach has to be a boolean here rather than a restatement of a hint.
827
+ return { ...base, fetch: false, autoAttach: false, reason: "over-budget" }
828
+ }
829
+ return {
830
+ ...base,
831
+ fetch: true,
832
+ autoAttach: true,
833
+ // Unknown size still gets fetched, then trimmed — the trim is the
834
+ // safety net for servers that do not declare a size.
835
+ reason: sizeBytes === null ? "size-unknown" : "within-budget"
836
+ }
837
+ }
838
+
839
+ // Per-turn gate. A stable resource is attached once and re-used; a volatile
840
+ // one is owed a fresh copy every turn.
841
+ export function createResourceAttacher(plan) {
842
+ let attachedOnce = false
843
+ return {
844
+ take() {
845
+ if (!plan || !plan.autoAttach) return false
846
+ if (plan.volatility === "volatile") return true
847
+ if (attachedOnce) return false
848
+ attachedOnce = true
849
+ return true
850
+ },
851
+ get volatility() { return plan ? plan.volatility : null }
852
+ }
853
+ }
854
+
855
+ // Safety net for a server that declares no size: shrink an oversized
856
+ // catalog to its names rather than dropping it, and hard-truncate anything
857
+ // that is not a recognised catalog shape.
858
+ export function trimResourceText(text, maxBytes) {
859
+ if (text.length <= maxBytes) return text
860
+ try {
861
+ const parsed = JSON.parse(text)
862
+ const rows = parsed && parsed.dictionaries
863
+ if (Array.isArray(rows)) {
864
+ const names = rows.map((d) => d && d.name).filter(Boolean)
865
+ return JSON.stringify({ dictionaries: names, note: "names only — full catalog too large to inline" })
866
+ }
867
+ } catch (e) { /* fall through to a hard truncation */ }
868
+ return text.slice(0, maxBytes) + "\n…truncated"
869
+ }
870
+
871
+ // Read one resource and shape it for the system prompt. The trim is applied
872
+ // on every read, not just the first: a declared size can go stale, and a
873
+ // volatile resource is a fresh gamble each turn.
874
+ async function readResourceContext({ endpoint, uri, name, read, budgetBytes }) {
875
+ try {
876
+ const result = await read({ endpoint, uri })
877
+ const entry = ((result && result.contents) || [])[0]
878
+ if (!entry || !entry.text) return null
879
+ return { uri, name: name || uri, text: trimResourceText(entry.text, budgetBytes) }
880
+ } catch (e) {
881
+ // Optional context — a failed read must never block the widget.
882
+ return null
883
+ }
884
+ }
885
+
886
+ // The discovery sequence for one endpoint's reference resource, kept here
887
+ // rather than in the panel so the decision AND the call site are testable
888
+ // together: a gate nothing consults is exactly the bug this shape prevents.
889
+ //
890
+ // A volatile resource is NOT read here. Its content is only meaningful for
891
+ // the turn it is attached to, so reading it at boot would buy a copy that is
892
+ // already suspect by the time anyone sends a message.
893
+ export async function loadHostResource({ endpoint, budgetBytes, list, read, onSkip }) {
894
+ const resources = await list({ endpoint })
895
+ const candidate = (resources || []).filter((r) => (r.mimeType || "") === "application/json")[0]
896
+ if (!candidate) return null
897
+
898
+ const plan = planResourceAttachment(candidate, budgetBytes)
899
+ plan.name = candidate.name || candidate.uri
900
+ // Carried so a volatile re-read knows where to go: discovery happens once
901
+ // at boot, but the fetch it authorises happens on every later turn.
902
+ plan.endpoint = endpoint
903
+ if (!plan.fetch) {
904
+ if (onSkip) onSkip(plan)
905
+ return { plan, context: null }
906
+ }
907
+ if (plan.volatility === "volatile") return { plan, context: null }
908
+
909
+ const context = await readResourceContext({
910
+ endpoint, uri: candidate.uri, name: plan.name, read, budgetBytes
911
+ })
912
+ return { plan, context }
913
+ }
914
+
915
+ export function resourceContextLines(context) {
916
+ if (!context) return []
917
+ return [ "", "Reference data — " + context.name + " (" + context.uri + "):", context.text ]
918
+ }
919
+
920
+ // What to attach on THIS turn. Asking consumes the turn, so the decision and
921
+ // the fetch live together: a stable resource re-uses the copy read at boot,
922
+ // a volatile one is read again right now.
923
+ export async function resourceLinesForTurn({ plan, attacher, cached, endpoint, read, budgetBytes }) {
924
+ if (!attacher || !attacher.take()) return { lines: [], cached }
925
+
926
+ if (plan && plan.volatility === "volatile") {
927
+ const fresh = await readResourceContext({
928
+ endpoint, uri: plan.uri, name: plan.name, read, budgetBytes
929
+ })
930
+ return fresh ? { lines: resourceContextLines(fresh), cached: fresh } : { lines: [], cached }
931
+ }
932
+
933
+ return { lines: resourceContextLines(cached), cached }
934
+ }
935
+
936
+ // ---- prompt templates ---------------------------------------------------
937
+ //
938
+ // A server-declared prompt is only useful if the HOST PAGE can fill its
939
+ // arguments, so an argument is matched against the page's own state readers
940
+ // (window.aiState) by name. `dictionaries` is read from a
941
+ // `selected_dictionaries` reader: the page selects a LIST, and the prompt
942
+ // declares the CSV shape the text_annotation tool already takes.
943
+ const PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" }
944
+
945
+ export function promptArgFromState(argName, state) {
946
+ const reader = (state && state[argName]) || (state && state[PROMPT_ARG_ALIASES[argName]])
947
+ if (typeof reader !== "function") return ""
948
+ let value
949
+ try {
950
+ value = reader()
951
+ } catch (e) {
952
+ // A throwing page reader is the page's bug, not a reason to break the
953
+ // widget; treat it as "nothing to fill with".
954
+ return ""
955
+ }
956
+ if (value === null || value === undefined) return ""
957
+ return Array.isArray(value) ? value.join(",") : String(value)
958
+ }
959
+
960
+ // Splits a prompt's declared arguments into what the page can supply and
961
+ // what it cannot. Naming the missing ones lets the widget point at the empty
962
+ // field on screen instead of relaying the server's -32602.
963
+ export function resolvePromptArguments(prompt, state) {
964
+ const args = {}
965
+ const missing = []
966
+ for (const arg of (prompt && prompt.arguments) || []) {
967
+ const value = promptArgFromState(arg.name, state)
968
+ if (value) args[arg.name] = value
969
+ else if (arg.required) missing.push(arg.name)
970
+ }
971
+ return { args, missing }
972
+ }
973
+
974
+ export function promptButtonProps(prompt) {
975
+ return {
976
+ label: prompt.title || prompt.name,
977
+ title: prompt.description || ("Run the " + prompt.name + " prompt")
978
+ }
979
+ }
980
+
@@ -268,6 +268,31 @@
268
268
  border-radius: 8px;
269
269
  padding: 8px;
270
270
  }
271
+ /* Server-offered prompt templates. The row is display:none in markup and
272
+ * gets its display back only when a prompt actually exists, so the inline
273
+ * style and this rule have to agree on "flex". */
274
+ #llm-meta-widget-chat .lmw-prompts {
275
+ display: flex;
276
+ flex-wrap: wrap;
277
+ gap: 6px;
278
+ margin-bottom: 8px;
279
+ }
280
+ #llm-meta-widget-chat .lmw-prompt {
281
+ background-color: #eff6ff;
282
+ color: #1d4ed8;
283
+ border: 1px solid #bfdbfe;
284
+ border-radius: 999px;
285
+ padding: 4px 12px;
286
+ font-size: 12px;
287
+ font-family: inherit;
288
+ cursor: pointer;
289
+ transition: background-color 0.2s, border-color 0.2s;
290
+ }
291
+ #llm-meta-widget-chat .lmw-prompt:hover:not(:disabled) {
292
+ background-color: #dbeafe;
293
+ border-color: #93c5fd;
294
+ }
295
+ #llm-meta-widget-chat .lmw-prompt:disabled { opacity: 0.5; cursor: default; }
271
296
  #llm-meta-widget-chat .lmw-form { display: flex; flex-direction: column; gap: 8px; }
272
297
  #llm-meta-widget-chat .lmw-input-wrapper { position: relative; flex: 1; }
273
298
  #llm-meta-widget-chat .lmw-input {
@@ -326,6 +351,10 @@
326
351
  </div>
327
352
  <div class="lmw-messages"></div>
328
353
  <div class="lmw-input-container">
354
+ <%# Server-offered prompt templates (MCP prompts/list). Hidden until a
355
+ host MCP server actually offers one, so a tools-only server leaves
356
+ the widget exactly as it was. %>
357
+ <div class="lmw-prompts" style="display:none"></div>
329
358
  <form class="lmw-form">
330
359
  <div class="lmw-input-wrapper">
331
360
  <textarea class="lmw-input" placeholder="Enter your message..." rows="2" autocomplete="off"></textarea>
@@ -367,7 +396,10 @@
367
396
  </div>
368
397
 
369
398
  <script type="module">
370
- import { runChatLoop, fetchMcpManifest } from "<%= orchestrator_path %>";
399
+ import { runChatLoop, fetchMcpManifest, listMcpPrompts, getMcpPrompt,
400
+ listMcpResources, readMcpResource, promptMessagesToText,
401
+ loadHostResource, createResourceAttacher, resourceLinesForTurn, resolvePromptArguments,
402
+ promptButtonProps } from "<%= orchestrator_path %>";
371
403
  import { marked } from "/llm_meta_widget_assets/marked.esm.js";
372
404
 
373
405
  // Standard prose settings — GFM (tables, autolinks, strikethrough),
@@ -721,6 +753,20 @@
721
753
  // Fetch well-known MCP manifests at boot. Auto-discovers same origin
722
754
  // if WELL_KNOWN_URLS is null; empty array disables entirely.
723
755
  var hostWideTools = [];
756
+ var hostWidePrompts = [];
757
+ var resourceContext = null; // payload of the host's reference resource
758
+ var resourceAttacher = null; // per-turn gate, built from the server's hint
759
+ var resourcePlan = null; // the pre-flight decision, kept so Clear can re-arm the gate
760
+
761
+ // Beyond ~2k tokens a reference resource starts crowding out the
762
+ // conversation on a 32k-context local model — production PubDictionaries
763
+ // is 218 dictionaries / ~35KB / ~10k tokens.
764
+ var RESOURCE_BUDGET_BYTES = 8000;
765
+
766
+ // Set when a server declared a resource we deliberately did not attach,
767
+ // so the reason is inspectable rather than a silent absence.
768
+ var resourceSkip = null;
769
+
724
770
  var wellKnownReady = (async function() {
725
771
  var urls = WELL_KNOWN_URLS === null
726
772
  ? [ window.location.origin + "/.well-known/mcp.json" ]
@@ -729,8 +775,103 @@
729
775
  var tools = await fetchMcpManifest(urls[i]);
730
776
  hostWideTools = hostWideTools.concat(tools);
731
777
  }
778
+
779
+ // The manifest publishes TOOLS only, so the endpoints are learned from
780
+ // them. A server offering only prompts or resources is invisible here —
781
+ // worth knowing when deciding what a static manifest should carry.
782
+ var endpoints = [];
783
+ hostWideTools.forEach(function(t) {
784
+ if (t.endpoint && endpoints.indexOf(t.endpoint) === -1) endpoints.push(t.endpoint);
785
+ });
786
+
787
+ for (var j = 0; j < endpoints.length; j++) {
788
+ var endpoint = endpoints[j];
789
+ var prompts = await listMcpPrompts({ endpoint: endpoint });
790
+ hostWidePrompts = hostWidePrompts.concat(prompts);
791
+
792
+ if (resourcePlan === null) {
793
+ // Listing, size gate and read all live in the orchestrator, where
794
+ // they are tested together — a gate nothing consults is exactly
795
+ // the bug this shape prevents.
796
+ var loaded = await loadHostResource({
797
+ endpoint: endpoint,
798
+ budgetBytes: RESOURCE_BUDGET_BYTES,
799
+ list: listMcpResources,
800
+ read: readMcpResource,
801
+ onSkip: function(plan) {
802
+ resourceSkip = plan;
803
+ console.info("[llm_meta_widget] not attaching " + plan.uri +
804
+ " (" + plan.reason + ", sizeBytes=" + plan.sizeBytes + ")");
805
+ }
806
+ });
807
+ // A volatile resource comes back with no context — it is read
808
+ // per turn instead — so the plan, not the payload, is what says
809
+ // whether this endpoint offered anything worth attaching.
810
+ if (loaded && loaded.plan && loaded.plan.fetch) {
811
+ resourceContext = loaded.context;
812
+ resourcePlan = loaded.plan;
813
+ resourceAttacher = createResourceAttacher(loaded.plan);
814
+ }
815
+ }
816
+ }
817
+ renderPromptButtons();
732
818
  })();
733
819
 
820
+ // ---- server-offered prompt templates --------------------------------
821
+ //
822
+ // One button per prompt the host's MCP server offers. Clicking it fills
823
+ // the prompt's arguments from page state, asks the server to materialise
824
+ // the message (prompts/get), then drops the text into the textarea and
825
+ // submits — so it travels the exact path a typed message does.
826
+
827
+ var promptsEl = root.querySelector(".lmw-prompts");
828
+
829
+ function renderPromptButtons() {
830
+ if (!promptsEl) return;
831
+ promptsEl.textContent = "";
832
+ if (!hostWidePrompts.length) { promptsEl.style.display = "none"; return; }
833
+
834
+ hostWidePrompts.forEach(function(prompt) {
835
+ var props = promptButtonProps(prompt);
836
+ var button = document.createElement("button");
837
+ button.type = "button";
838
+ button.className = "lmw-prompt";
839
+ button.textContent = props.label;
840
+ button.title = props.title;
841
+ button.addEventListener("click", function() { runPromptTemplate(prompt, button); });
842
+ promptsEl.appendChild(button);
843
+ });
844
+ promptsEl.style.display = "";
845
+ }
846
+
847
+ async function runPromptTemplate(prompt, button) {
848
+ var resolved = resolvePromptArguments(prompt, window[STATE_GLOBAL] || {});
849
+ var args = resolved.args;
850
+ var missing = resolved.missing;
851
+
852
+ // Say which page field is empty rather than letting the server answer
853
+ // -32602 for something the user can actually fix on screen.
854
+ if (missing.length) {
855
+ appendTurn("error", "Cannot run \"" + (prompt.title || prompt.name) + "\" yet — nothing to use for: " +
856
+ missing.join(", ") + ". Fill that in on the page first.");
857
+ return;
858
+ }
859
+
860
+ button.disabled = true;
861
+ try {
862
+ var result = await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args: args });
863
+ var text = promptMessagesToText(result);
864
+ if (!text) { appendTurn("error", "The server returned an empty prompt."); return; }
865
+ inputEl.value = text;
866
+ if (typeof formEl.requestSubmit === "function") formEl.requestSubmit();
867
+ else formEl.dispatchEvent(new Event("submit", { cancelable: true }));
868
+ } catch (e) {
869
+ appendTurn("error", "Prompt failed: " + (e && e.message ? e.message : e));
870
+ } finally {
871
+ button.disabled = false;
872
+ }
873
+ }
874
+
734
875
  function appendTurn(role, text) {
735
876
  // Class names mirror llm_meta_chat's chats/_message.html.erb —
736
877
  // `.message.<role>`, `.message-role`, `.message-content` — so the
@@ -822,7 +963,7 @@
822
963
  return out;
823
964
  }
824
965
 
825
- function currentSystemPrompt() {
966
+ function currentSystemPrompt(resourceLines) {
826
967
  return [
827
968
  "You are integrated into a web page as an AI assistant. You have tools available to change page state or fetch information.",
828
969
  "",
@@ -834,7 +975,27 @@
834
975
  "",
835
976
  "Current page state:",
836
977
  JSON.stringify(currentPageState(), null, 2)
837
- ].join("\n");
978
+ ].concat(resourceLines || []).join("\n");
979
+ }
980
+
981
+ // Whether to attach, and how often, is the SERVER's call — via the
982
+ // extension's autoAttach and volatility fields, which default to
983
+ // attach-once for a server that declares neither.
984
+ //
985
+ // Asking consumes the turn, and a volatile resource is re-read here rather
986
+ // than re-using the boot-time copy — so this runs once per send, before
987
+ // the system prompt is built.
988
+ async function resourceLinesForThisTurn() {
989
+ var turn = await resourceLinesForTurn({
990
+ plan: resourcePlan,
991
+ attacher: resourceAttacher,
992
+ cached: resourceContext,
993
+ endpoint: resourcePlan && resourcePlan.endpoint,
994
+ read: readMcpResource,
995
+ budgetBytes: RESOURCE_BUDGET_BYTES
996
+ });
997
+ resourceContext = turn.cached;
998
+ return turn.lines;
838
999
  }
839
1000
 
840
1001
  // Per-turn AbortController — lets the Clear button (or a new submit)
@@ -845,6 +1006,9 @@
845
1006
  if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
846
1007
  conversation = [];
847
1008
  historyEl.innerHTML = "";
1009
+ // Clear starts a new conversation, so a 'once' resource is owed to it
1010
+ // again — the old boolean stayed latched and silently withheld it.
1011
+ if (resourcePlan) resourceAttacher = createResourceAttacher(resourcePlan);
848
1012
  });
849
1013
 
850
1014
  // Enter submits, Shift+Enter inserts a newline — matches llm_meta_chat's
@@ -868,16 +1032,21 @@
868
1032
  if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
869
1033
  currentAbort = new AbortController();
870
1034
 
871
- var messages = [{ role: "system", content: currentSystemPrompt() }]
872
- .concat(conversation)
873
- .concat([{ role: "user", content: userText }]);
874
-
875
1035
  var assistantBody = appendTurn("assistant", "");
876
1036
  var assistantMarkdown = ""; // accumulate raw markdown, re-render on each delta
877
1037
 
878
1038
  try {
1039
+ // Discovery has to finish before the system prompt is built: the
1040
+ // resource catalog is attached to the first turn only, so building
1041
+ // messages first would silently ship that turn without it AND mark
1042
+ // it as sent.
879
1043
  await wellKnownReady;
880
1044
 
1045
+ var resourceLines = await resourceLinesForThisTurn();
1046
+ var messages = [{ role: "system", content: currentSystemPrompt(resourceLines) }]
1047
+ .concat(conversation)
1048
+ .concat([{ role: "user", content: userText }]);
1049
+
881
1050
  var result = await runChatLoop({
882
1051
  baseUrl: META_BASE,
883
1052
  apiKeyUuid: API_KEY_UUID,
@@ -1,3 +1,3 @@
1
1
  module LlmMetaWidget
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - jdkim