llm_meta_widget 0.1.0 → 0.2.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: f32625e105ab751f473e79517d113c8b84465e676cddaab0f3db861c8f3e563e
4
+ data.tar.gz: 249d09f198b93e1b8382b2f5ef719736b260fc4c0c2f53d77e68c203b8e236b2
5
5
  SHA512:
6
- metadata.gz: 21a3107c99ecb9d7f27aee4378f9f9e816bcdaf140421cddc32967d4df6703d577972c5087cb0efe17eeb2ad5a924f162b406c30dd50f2fcc07a1935f98ff3c7
7
- data.tar.gz: 0fec2f2cf2ad99af3cf4c932f7c35028ddd0017e331a559d9d90b339a4ad8d3dc920a492fdb2dcdf21a050f7f7dd5a3722968598ff2dbc1c15906604bd613499
6
+ metadata.gz: 841f81296bed31dbe0ebd7c67253b08990be954a0e5b6939f0563c3d97cb525b8826654149350dbae4afd74ce5b61750058f289c9df25ed4fca5b577783db72b
7
+ data.tar.gz: ced44aaf75dc424014f878360a4f769247cda1856abb7ffde7e3d66e829e8ae953e1c5b0ba21aea6521abab19b10ac821631e490d9ca47cba7876ea41b5ef8d1
@@ -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,170 @@ 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): two optional fields a server may declare on a resources/list
784
+ // entry. `sizeBytes` is the byte length of the payload resources/read would
785
+ // return, so a client can decide whether to attach it BEFORE fetching it.
786
+ // `attachmentHint` says how often attaching is worth it.
787
+ //
788
+ // Both are optional, and a server that declares neither must behave exactly
789
+ // as it did before the extension existed: fetch, trim to budget, attach once.
790
+ export const STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives"
791
+
792
+ const ATTACHMENT_HINTS = [ "once", "each-turn", "on-demand" ]
793
+
794
+ export function resourceHints(entry) {
795
+ const meta = (entry && entry._meta && entry._meta[STATIC_PRIMITIVES_META]) || {}
796
+ const hint = meta.attachmentHint
797
+ return {
798
+ // A non-numeric or absent size means "unknown", never 0 — 0 would read
799
+ // as a free resource and sail through every budget check.
800
+ sizeBytes: typeof meta.sizeBytes === "number" && isFinite(meta.sizeBytes) ? meta.sizeBytes : null,
801
+ attachmentHint: ATTACHMENT_HINTS.indexOf(hint) === -1 ? "once" : hint
802
+ }
803
+ }
804
+
805
+ // The pre-flight decision, made from the resources/list entry alone.
806
+ // `fetch: false` means the bytes never cross the wire at all.
807
+ export function planResourceAttachment(entry, budgetBytes) {
808
+ const { sizeBytes, attachmentHint } = resourceHints(entry)
809
+ const base = { sizeBytes, attachmentHint, uri: entry && entry.uri }
810
+
811
+ if (attachmentHint === "on-demand") {
812
+ return { ...base, fetch: false, autoAttach: false, reason: "on-demand" }
813
+ }
814
+ if (sizeBytes !== null && sizeBytes > budgetBytes) {
815
+ return { ...base, fetch: false, autoAttach: false, reason: "over-budget" }
816
+ }
817
+ return {
818
+ ...base,
819
+ fetch: true,
820
+ autoAttach: true,
821
+ // Unknown size still gets fetched, then trimmed — the trim is the
822
+ // safety net for servers that do not declare a size.
823
+ reason: sizeBytes === null ? "size-unknown" : "within-budget"
824
+ }
825
+ }
826
+
827
+ // Per-turn gate. Replaces a single `sent` boolean, which silently assumed
828
+ // every resource was "once" and would have re-sent nothing for a resource
829
+ // whose content actually varies between turns.
830
+ export function createResourceAttacher(plan) {
831
+ let attachedOnce = false
832
+ return {
833
+ // Returns whether to attach on THIS turn, and records the answer.
834
+ take() {
835
+ if (!plan || !plan.autoAttach) return false
836
+ if (plan.attachmentHint === "each-turn") return true
837
+ if (attachedOnce) return false
838
+ attachedOnce = true
839
+ return true
840
+ },
841
+ get attachmentHint() { return plan ? plan.attachmentHint : null }
842
+ }
843
+ }
844
+
845
+ // Safety net for a server that declares no size: shrink an oversized
846
+ // catalog to its names rather than dropping it, and hard-truncate anything
847
+ // that is not a recognised catalog shape.
848
+ export function trimResourceText(text, maxBytes) {
849
+ if (text.length <= maxBytes) return text
850
+ try {
851
+ const parsed = JSON.parse(text)
852
+ const rows = parsed && parsed.dictionaries
853
+ if (Array.isArray(rows)) {
854
+ const names = rows.map((d) => d && d.name).filter(Boolean)
855
+ return JSON.stringify({ dictionaries: names, note: "names only — full catalog too large to inline" })
856
+ }
857
+ } catch (e) { /* fall through to a hard truncation */ }
858
+ return text.slice(0, maxBytes) + "\n…truncated"
859
+ }
860
+
861
+ // The whole discovery sequence for one endpoint's reference resource, kept
862
+ // here rather than in the panel so the decision AND the call site are
863
+ // testable together: a gate that nothing consults is the failure mode this
864
+ // replaces. `list` and `read` are injected so a test can assert that an
865
+ // over-budget resource is never read.
866
+ export async function loadHostResource({ endpoint, budgetBytes, list, read, onSkip }) {
867
+ const resources = await list({ endpoint })
868
+ const candidate = (resources || []).filter((r) => (r.mimeType || "") === "application/json")[0]
869
+ if (!candidate) return null
870
+
871
+ const plan = planResourceAttachment(candidate, budgetBytes)
872
+ if (!plan.fetch) {
873
+ if (onSkip) onSkip(plan)
874
+ return { plan, context: null }
875
+ }
876
+
877
+ try {
878
+ const result = await read({ endpoint, uri: candidate.uri })
879
+ const entry = ((result && result.contents) || [])[0]
880
+ if (!entry || !entry.text) return { plan, context: null }
881
+ return {
882
+ plan,
883
+ context: {
884
+ uri: candidate.uri,
885
+ name: candidate.name || candidate.uri,
886
+ text: trimResourceText(entry.text, budgetBytes)
887
+ }
888
+ }
889
+ } catch (e) {
890
+ // Optional context — a failed read must never block the widget.
891
+ return { plan, context: null }
892
+ }
893
+ }
894
+
895
+ // ---- prompt templates ---------------------------------------------------
896
+ //
897
+ // A server-declared prompt is only useful if the HOST PAGE can fill its
898
+ // arguments, so an argument is matched against the page's own state readers
899
+ // (window.aiState) by name. `dictionaries` is read from a
900
+ // `selected_dictionaries` reader: the page selects a LIST, and the prompt
901
+ // declares the CSV shape the text_annotation tool already takes.
902
+ const PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" }
903
+
904
+ export function promptArgFromState(argName, state) {
905
+ const reader = (state && state[argName]) || (state && state[PROMPT_ARG_ALIASES[argName]])
906
+ if (typeof reader !== "function") return ""
907
+ let value
908
+ try {
909
+ value = reader()
910
+ } catch (e) {
911
+ // A throwing page reader is the page's bug, not a reason to break the
912
+ // widget; treat it as "nothing to fill with".
913
+ return ""
914
+ }
915
+ if (value === null || value === undefined) return ""
916
+ return Array.isArray(value) ? value.join(",") : String(value)
917
+ }
918
+
919
+ // Splits a prompt's declared arguments into what the page can supply and
920
+ // what it cannot. Naming the missing ones lets the widget point at the empty
921
+ // field on screen instead of relaying the server's -32602.
922
+ export function resolvePromptArguments(prompt, state) {
923
+ const args = {}
924
+ const missing = []
925
+ for (const arg of (prompt && prompt.arguments) || []) {
926
+ const value = promptArgFromState(arg.name, state)
927
+ if (value) args[arg.name] = value
928
+ else if (arg.required) missing.push(arg.name)
929
+ }
930
+ return { args, missing }
931
+ }
932
+
933
+ export function promptButtonProps(prompt) {
934
+ return {
935
+ label: prompt.title || prompt.name,
936
+ title: prompt.description || ("Run the " + prompt.name + " prompt")
937
+ }
938
+ }
939
+
940
+ // The per-turn attach decision AND its formatting, so the two cannot drift
941
+ // apart: asking whether to attach is what consumes the turn.
942
+ export function nextResourceContextLines(context, attacher) {
943
+ if (!context || !attacher || !attacher.take()) return []
944
+ return [ "", "Reference data — " + context.name + " (" + context.uri + "):", context.text ]
945
+ }
@@ -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, resolvePromptArguments,
402
+ promptButtonProps, nextResourceContextLines } 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,100 @@
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 (resourceContext === 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
+ if (loaded && loaded.context) {
808
+ resourceContext = loaded.context;
809
+ resourcePlan = loaded.plan;
810
+ resourceAttacher = createResourceAttacher(loaded.plan);
811
+ }
812
+ }
813
+ }
814
+ renderPromptButtons();
732
815
  })();
733
816
 
817
+ // ---- server-offered prompt templates --------------------------------
818
+ //
819
+ // One button per prompt the host's MCP server offers. Clicking it fills
820
+ // the prompt's arguments from page state, asks the server to materialise
821
+ // the message (prompts/get), then drops the text into the textarea and
822
+ // submits — so it travels the exact path a typed message does.
823
+
824
+ var promptsEl = root.querySelector(".lmw-prompts");
825
+
826
+ function renderPromptButtons() {
827
+ if (!promptsEl) return;
828
+ promptsEl.textContent = "";
829
+ if (!hostWidePrompts.length) { promptsEl.style.display = "none"; return; }
830
+
831
+ hostWidePrompts.forEach(function(prompt) {
832
+ var props = promptButtonProps(prompt);
833
+ var button = document.createElement("button");
834
+ button.type = "button";
835
+ button.className = "lmw-prompt";
836
+ button.textContent = props.label;
837
+ button.title = props.title;
838
+ button.addEventListener("click", function() { runPromptTemplate(prompt, button); });
839
+ promptsEl.appendChild(button);
840
+ });
841
+ promptsEl.style.display = "";
842
+ }
843
+
844
+ async function runPromptTemplate(prompt, button) {
845
+ var resolved = resolvePromptArguments(prompt, window[STATE_GLOBAL] || {});
846
+ var args = resolved.args;
847
+ var missing = resolved.missing;
848
+
849
+ // Say which page field is empty rather than letting the server answer
850
+ // -32602 for something the user can actually fix on screen.
851
+ if (missing.length) {
852
+ appendTurn("error", "Cannot run \"" + (prompt.title || prompt.name) + "\" yet — nothing to use for: " +
853
+ missing.join(", ") + ". Fill that in on the page first.");
854
+ return;
855
+ }
856
+
857
+ button.disabled = true;
858
+ try {
859
+ var result = await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args: args });
860
+ var text = promptMessagesToText(result);
861
+ if (!text) { appendTurn("error", "The server returned an empty prompt."); return; }
862
+ inputEl.value = text;
863
+ if (typeof formEl.requestSubmit === "function") formEl.requestSubmit();
864
+ else formEl.dispatchEvent(new Event("submit", { cancelable: true }));
865
+ } catch (e) {
866
+ appendTurn("error", "Prompt failed: " + (e && e.message ? e.message : e));
867
+ } finally {
868
+ button.disabled = false;
869
+ }
870
+ }
871
+
734
872
  function appendTurn(role, text) {
735
873
  // Class names mirror llm_meta_chat's chats/_message.html.erb —
736
874
  // `.message.<role>`, `.message-role`, `.message-content` — so the
@@ -834,7 +972,16 @@
834
972
  "",
835
973
  "Current page state:",
836
974
  JSON.stringify(currentPageState(), null, 2)
837
- ].join("\n");
975
+ ].concat(resourceContextLines()).join("\n");
976
+ }
977
+
978
+ // How often the resource is attached is the SERVER's call, via the
979
+ // extension's attachmentHint: 'once' for static reference data (the
980
+ // default, and what a hint-unaware server gets), 'each-turn' for content
981
+ // that varies. The attacher records each turn's answer, replacing a
982
+ // client-side boolean that assumed every resource was static.
983
+ function resourceContextLines() {
984
+ return nextResourceContextLines(resourceContext, resourceAttacher);
838
985
  }
839
986
 
840
987
  // Per-turn AbortController — lets the Clear button (or a new submit)
@@ -845,6 +992,9 @@
845
992
  if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
846
993
  conversation = [];
847
994
  historyEl.innerHTML = "";
995
+ // Clear starts a new conversation, so a 'once' resource is owed to it
996
+ // again — the old boolean stayed latched and silently withheld it.
997
+ if (resourcePlan) resourceAttacher = createResourceAttacher(resourcePlan);
848
998
  });
849
999
 
850
1000
  // Enter submits, Shift+Enter inserts a newline — matches llm_meta_chat's
@@ -868,16 +1018,20 @@
868
1018
  if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
869
1019
  currentAbort = new AbortController();
870
1020
 
871
- var messages = [{ role: "system", content: currentSystemPrompt() }]
872
- .concat(conversation)
873
- .concat([{ role: "user", content: userText }]);
874
-
875
1021
  var assistantBody = appendTurn("assistant", "");
876
1022
  var assistantMarkdown = ""; // accumulate raw markdown, re-render on each delta
877
1023
 
878
1024
  try {
1025
+ // Discovery has to finish before the system prompt is built: the
1026
+ // resource catalog is attached to the first turn only, so building
1027
+ // messages first would silently ship that turn without it AND mark
1028
+ // it as sent.
879
1029
  await wellKnownReady;
880
1030
 
1031
+ var messages = [{ role: "system", content: currentSystemPrompt() }]
1032
+ .concat(conversation)
1033
+ .concat([{ role: "user", content: userText }]);
1034
+
881
1035
  var result = await runChatLoop({
882
1036
  baseUrl: META_BASE,
883
1037
  apiKeyUuid: API_KEY_UUID,
@@ -1,3 +1,3 @@
1
1
  module LlmMetaWidget
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - jdkim