@barivia/barsom-mcp 0.21.0 → 0.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,17 @@ MCP clients typically run it with **`npx`** (downloads on first use):
27
27
 
28
28
  **Verify:** `npm view @barivia/barsom-mcp version`
29
29
 
30
+ ## 2-minute quickstart
31
+
32
+ 1. **Get an API key** — Sign up at [barivia.se/dashboard](https://barivia.se/dashboard) (self-serve) or ask your admin for a `bv_…` key.
33
+ 2. **Add the MCP server** — Paste the JSON block above into your client’s MCP config (`mcp.json` in Cursor). Set `BARIVIA_API_KEY` and keep `BARIVIA_API_URL=https://api.barivia.se`.
34
+ 3. **Orient** — In chat, invoke **`guide_barsom_workflow`** (no parameters). It returns your plan-scoped tool map and async rules.
35
+ 4. **Upload data** — `datasets(action=upload, file_path="your.csv")` → note `dataset_id` (and `stage_job_id` if staging is enqueued; poll until complete).
36
+ 5. **Train** — `train(action=map, dataset_id=…, preset=quick)` → returns `job_id`. Poll with `jobs(action=status, job_id=…)` every 60–120s, or use **`training_monitor(job_id=…)`** for live curves.
37
+ 6. **Results** — When status is `completed`, `results(action=get, job_id=…)` for metrics + headline figure; **`results_explorer(job_id=…)`** to browse all figures.
38
+
39
+ **Troubleshooting:** Upload fails with “paths outside the workspace” → set `BARIVIA_WORKSPACE_ROOT` to your project folder. Timeouts on slow calls → raise `BARIVIA_FETCH_TIMEOUT_MS`. Tool “server does not exist” in Cursor → use the resolved server id from the MCP tool list, not necessarily the key in `mcp.json`. Hosted HTTP MCP at `mcp.barivia.se` is paused — use this npm package only.
40
+
30
41
  **Audit logging (0.10.3+):** Each tool invocation emits a structured **`mcp_tool_call`** line on stderr for support correlation — not sent to the Barivia API. Fields: `tool`, `action`, `duration_ms`, `outcome`, **`rid`** (matches API `X-Request-ID`), **`job_id`** / **`dataset_id`** when the action returns or accepts them, scale hints **`grid_x`** / **`grid_y`** on training submits, and **`timeout_hit`** / **`output_truncated`** when proxy fetch/output limits apply. No API keys or file paths. Grep client stderr alongside API/worker logs by `rid`; see `docs/OPERATIONS_MANUAL.md` §5b.
31
42
 
32
43
  **Hosted HTTP MCP paused (2026-06):** `https://mcp.barivia.se/mcp` returns **503** (`hosted_mcp_paused`). Use this npm package only.
@@ -62,7 +73,7 @@ Legacy `BARSOM_API_KEY` / `BARSOM_API_URL` / `BARSOM_WORKSPACE_ROOT` are also ac
62
73
 
63
74
  All multi-action tools follow the `datasets` / `jobs` / `results` / `inference` / `account` pattern: a required `action` enum routes to the correct operation.
64
75
 
65
- **Prompts (not tools):** `info` (short orientation; prefer `guide_barsom_workflow` for depth), `prepare_training` (requires `dataset_id`; checklist text from API when online).
76
+ **Bootstrap:** `guide_barsom_workflow` (no params; plan-scoped orientation from the API — call first). **Prompts (not tools):** `info` (short orientation), `prepare_training` (requires `dataset_id`; checklist text from API when online).
66
77
 
67
78
  ### Flattened catalog (optional grouping in UIs)
68
79
 
@@ -70,12 +81,12 @@ All multi-action tools follow the `datasets` / `jobs` / `results` / `inference`
70
81
  |--------|--------|
71
82
  | Bootstrap | `guide_barsom_workflow`, prompts `info`, `prepare_training` |
72
83
  | Data | `datasets` |
84
+ | Train prep | `training_guidance` |
73
85
  | Train & poll | `train`, `jobs`, `training_monitor` (optional) |
74
86
  | Results | `results`, `results_explorer` (optional) |
75
87
  | Inference | `inference` |
76
88
  | Account | `account` |
77
89
  | Optional UI | `training_monitor`, `results_explorer` |
78
- | Advanced | `training_guidance` |
79
90
 
80
91
  Agents should not call **`_fetch_figure`** from chat; it exists for the Results Explorer MCP App host. Use `results(action=get)` or `results_explorer` instead.
81
92
 
@@ -152,12 +163,11 @@ All actions use a frozen trained map — no retraining. Derived columns use **`d
152
163
  | Action | Use when |
153
164
  |--------|----------|
154
165
  | `status` | Before large jobs — plan tier, GPU availability, queue depth, credit balance, training time estimates |
155
- | `request_compute` | Upgrading to cloud burst. Leave tier blank to list options. |
156
- | `compute_status` | Checking active lease time remaining |
157
- | `release_compute` | Manually stopping a lease to stop billing |
158
166
  | `history` | Viewing recent compute usage and spend |
159
167
  | `add_funds` | Getting instructions to add credits |
160
168
 
169
+ Capacity is shared LOCAL/GKE worker pools (no per-key cloud burst lease).
170
+
161
171
  ### MCP App tools (optional UIs)
162
172
 
163
173
  | Tool | Role |
@@ -180,6 +190,10 @@ The right viewer depends on **(MCP App support)** **and** **(can the human reach
180
190
 
181
191
  ### Migration notes
182
192
 
193
+ - **Account surface (0.22.0, breaking):** `account(request_compute|compute_status|release_compute)` removed — AWS cloud burst leases are gone. Use `account(status|history|add_funds)`. Capacity is shared LOCAL/GKE pools.
194
+ - **Structured results + train hints (0.21.x, non-breaking):**
195
+ - **`results(action=get)`** returns **`structuredContent`** (job id/type, label, summary metrics, file list) alongside the text summary — same pattern as `training_monitor` / `results_explorer`. Agent hosts can read fields without parsing prose.
196
+ - **`train`** submit responses may include a **`results_hint`** string pointing agents to the next post-train step.
183
197
  - **Fixed-panel live TE (0.20.4):** mid-training TE curves use a fixed evaluation panel (`te_panel_size` on `train`). Monitors show **Panel TE** and **Map TE** separately; curve tail no longer snaps to map TE.
184
198
  - **Features (0.20.0, non-breaking):**
185
199
  - **FLooP-SIOM maps are now projectable.** `inference(action=project_columns)`, `inference(action=predict)`, and `datasets(action=add_expression, project_onto_job=<floop_job>)` work on FLooP-SIOM (free/chain) maps — values render onto the FLooP Voronoi layout instead of failing. Grid-only ops (`inference(action=transition_flow | impute_column | render_variant)`) return a clear `unsupported_topology_for_inference` message; use a fixed-grid SOM/SIOM for those. No client change required.
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as t}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as o}from"zod";import{getUiCapability as r,registerAppResource as s,RESOURCE_MIME_TYPE as n}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as i}from"./viz-server.js";import{API_KEY as a,apiCall as l,apiRawCall as c,loadViewHtml as p,setVizPort as m,setClientSupportsMcpApps as u,CLIENT_VERSION as d}from"./shared.js";import{registerDatasetsTool as f}from"./tools/datasets.js";import{registerTrainTool as g}from"./tools/train.js";import{registerJobsTool as h,JOBS_DESCRIPTION_BASE as _}from"./tools/jobs.js";import{registerResultsTool as b}from"./tools/results.js";import{registerExploreMapTool as w,RESULTS_EXPLORER_URI as y}from"./tools/explore_map.js";import{registerAccountTool as j}from"./tools/account.js";import{registerInferenceTool as v}from"./tools/inference.js";import{registerGuideBarsomTool as x}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as P}from"./tools/training_guidance.js";import{registerFeedbackTool as k}from"./tools/feedback.js";import{registerTrainingMonitorTool as I,TRAINING_MONITOR_URI as A}from"./tools/training_monitor.js";import{resolvePrepareTrainingPromptText as M}from"./prepare_training_prompt.js";a||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));const T=new e({name:"analytics-engine",version:d,instructions:'# Barivia Mapping Analytics Engine\n\nSelf-organizing map (SOM) analytics: project high-dimensional data to a 2D grid for clusters, gradients, and anomalies.\n\n## Workflow (short)\n\nUpload (`datasets(upload)`) → `datasets(preview)` and `datasets(analyze)` before train → submit one of `train(action=map)`, `train(action=siom_map)`, or `train(action=floop_siom)` (only if plan allows FLooP) → poll `jobs(status)` every 10–15s until `completed` → `results(get)` for metrics and figures (there is no separate analyze tool), then `results_explorer(job_id)` to browse the figures interactively. Then `jobs(compare)`, `results(download/recolor)`, or `inference` (predict / compare / project_columns / impute_column / transition_flow) as needed.\n\n**Full detail:** Call `guide_barsom_workflow` for plan-scoped tool map, training modes, async rules, optional MCP App UIs, and step-by-step SOP (from the Barivia API when online).\n\n## Tool taxonomy (one tool per stage — do not mix)\n\n| Stage | Tool(s) | Contract |\n|-------|---------|----------|\n| Data | `datasets` | upload, preview, analyze, list, **get** (status/staging), subset, add_expression, reduce_spectral, delete — ingest + feature engineering, no map |\n| Train submit | `train` | map, siom_map, impute (sparse → map + dense imputed.csv), floop_siom (entitled). Async, returns job_id. Submit only |\n| Lifecycle | `jobs` | status, list, compare (**training runs**), cancel, delete. No training/scoring here |\n| Artifacts | `results` | get (default=combined only; figures="none" for metrics-only sweeps; figures="all" or results_explorer/download for every plot), export, download, recolor (async). Read/render completed jobs |\n| Frozen-map ops | `inference` | predict (regime-aware; "compact"|"annotated"), batch_predict, impute_column (neighbor-pool fill for a non-training column), compare (cohort density-diff), project_columns, transition_flow (time-ordered rows only), report. No weight updates |\n| Account | `account` | status, burst/compute actions, history, add_funds |\n| Bootstrap | `guide_barsom_workflow`, `training_guidance`, `prepare_training` prompt | orientation, parameter hints, narrative checklist (API/tier-scoped) |\n| Explore (UI) | `results_explorer`, `training_monitor` | richer UX over the same API; `jobs(status)`+`results(get)` still suffice headless |\n| Other | `send_feedback` | only after user agrees |\n\n### Avoid these mis-routes\n- `jobs(compare)` = metrics table across **training runs** (pick best in a sweep). `inference(compare)` = density-diff of one map vs a **second dataset** (drift/cohort). Different tools.\n- Post-training map analysis (predict, compare, project_columns, impute_column, **transition_flow**) is all **inference** — not `results`. `results` is presentation/artifacts only (`recolor` stays here: it re-renders, it doesn\'t touch data).\n- Project a quantity onto the map: existing columns → `inference(project_columns)`; a formula → `datasets(add_expression, project_onto_job=<job>)`.\n\n## Async pattern\n\n- **Manual poll:** Training submits return `job_id` immediately — poll `jobs(status)` every 10–15s. **Running is not failed**; large grids or FLooP-SIOM can take many minutes. `max_nodes` (FLooP) is a total node budget, not grid side length.\n- **Often auto-polled:** `inference` actions (incl. `transition_flow`) and `results(recolor)` may wait in-proxy; if you get a `job_id`, poll `jobs(status)` the same way.\n\nCredits: jobs consume compute credits; check `account(status)` before big runs. Per-request HTTP timeout defaults to 60s (`datasets(analyze)` is async + auto-polled, so it is not bound by it); on slow networks raise `BARIVIA_FETCH_TIMEOUT_MS`. Timeouts are NOT auto-retried (avoids duplicating slow/expensive work).\n\n## Constraints\n\n- Prep ladder: `prepare_training` prompt = narrative checklist; `training_guidance` = structured hints (presets, resolved normalization/transforms, grid/epochs). Review these, then submit with `train`. Do not guess tiers or FLooP entitlement.\n- `inference(predict)`: prefer `dataset_id` for batch and for SIOM/irregular maps; single-row `rows` uses a fast path that can fail on some topologies — retry with `dataset_id`. FLooP-SIOM: if predict jobs fail while grid SIOM works, capture errors + `job_id`.\n- Column names are case-sensitive — match `datasets(preview)`.\n- Default training path is numeric/cyclic/temporal; use explicit `categorical_features` for baseline categoricals. `predict` must match the model contract.\n- After `results(recolor)`, `inference(transition_flow)`, or `inference(project_columns)`, use the **new** `job_id` returned for follow-up `results` if applicable.\n- **Large results / MCP timeouts:** For sweeps, `jobs(compare)`, or metrics-only reads use `results(action=get, figures="none")`. Default `get` inlines combined only. For every plot use `results_explorer` or `results(action=download)`, not `figures="all"` in chat unless the user wants all images inline.'});s(T,y,y,{mimeType:n},async()=>{const e=await p("results-explorer");return{contents:[{uri:y,mimeType:n,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),s(T,A,A,{mimeType:n},async()=>{const e=await p("training-monitor");return{contents:[{uri:A,mimeType:n,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),x(T),w(T),I(T),f(T),g(T),h(T,_),b(T),j(T),v(T),P(T),k(T),T.prompt("info","Short orientation for the Barivia Mapping MCP. For full plan-scoped workflow, tool map, and SOP, the model should call guide_barsom_workflow. Use when the user asks what this MCP can do or how to get started.",{},()=>({messages:[{role:"user",content:{type:"text",text:["Give a concise, scannable answer (headers + bullets):","","**What it is:** MCP client to the Barivia mapping engine (2D SOM / SIOM / FLooP-SIOM when entitled) over HTTPS.","","**First step:** Call `guide_barsom_workflow` for plan-scoped bootstrap (full tool list, async rules, training modes, optional MCP Apps, SOP).","","**Core path:** `datasets(upload)` → `datasets(preview)` + `datasets(analyze)` → choose training action → poll `jobs(status)` every 10–15s until completed → `results(get)` (all main figures/metrics; no separate analyze tool) → `results_explorer(job_id)` to browse figures.","",'**Key tools:** `datasets` (data; reduce_spectral for spectra/long blocks), `train` (map/siom_map/impute/floop_siom; pass `label` for readable compare rows; preset=quick for a fast first map), `jobs` (status/list/compare/cancel/delete — lifecycle only), `results` (get/download/export/recolor; figures="none" for metrics-only), `inference` (predict; batch_predict; impute_column for topology-neighbor pool fill; compare; project_columns; transition_flow; report), `account` (status/credits/queue).',"","**Prep help:** `prepare_training` prompt (checklist) · `training_guidance` (presets/JSON hints + resolved normalization/params) — review, then submit with `train`.","","**Browse results:** `results_explorer(job_id)` is the preferred way to explore figures after a first `results(get)` glance; `training_monitor` shows live progress. Both optional — `results` + `jobs(status)` suffice headless.","","**After training:** `jobs(compare)` across runs, `results(recolor)`, `inference(project_columns)` for variables not in training, `inference(transition_flow)` only if rows are time-ordered.","","**Rules:** Running ≠ failed. Column names must match `datasets(preview)` exactly. Do not call `_fetch_figure` from chat (host/UI only); use `results(get)` or `results_explorer`.","","Offer `send_feedback` only after asking the user."].join("\n")}}]})),T.prompt("prepare_training","Narrative pre-training checklist (prompt). Use after upload and before train. Content is tier-scoped from the API when online. Prep ladder: this prompt = story checklist; training_guidance tool = JSON presets/parameter hints. Review, then submit with train.",{dataset_id:o.string().describe("Dataset ID to prepare for training")},async({dataset_id:e})=>({messages:[{role:"user",content:{type:"text",text:await M(e)}}]}));const S=new t;(async function(){try{const e=await i(l,c,p);m(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("Barivia viz server failed to start:",e)}const e=T.server;e.oninitialized=()=>{const t=e.getClientCapabilities(),o=r(t);u(!!o?.mimeTypes?.includes(n))},await T.connect(S)})().catch(console.error);
2
+ import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as t}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as o}from"zod";import{getUiCapability as r,registerAppResource as s,RESOURCE_MIME_TYPE as n}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as i}from"./viz-server.js";import{API_KEY as a,apiCall as l,apiRawCall as c,loadViewHtml as p,setVizPort as m,setClientSupportsMcpApps as u,CLIENT_VERSION as d}from"./shared.js";import{registerDatasetsTool as f}from"./tools/datasets.js";import{registerTrainTool as g}from"./tools/train.js";import{registerJobsTool as h,JOBS_DESCRIPTION_BASE as _}from"./tools/jobs.js";import{registerResultsTool as b}from"./tools/results.js";import{registerExploreMapTool as w,RESULTS_EXPLORER_URI as y}from"./tools/explore_map.js";import{registerAccountTool as j}from"./tools/account.js";import{registerInferenceTool as v}from"./tools/inference.js";import{registerGuideBarsomTool as x}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as P}from"./tools/training_guidance.js";import{registerFeedbackTool as k}from"./tools/feedback.js";import{registerTrainingMonitorTool as I,TRAINING_MONITOR_URI as A}from"./tools/training_monitor.js";import{resolvePrepareTrainingPrompt as M}from"./prepare_training_prompt.js";a||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));const T=new e({name:"analytics-engine",version:d,instructions:'# Barivia Mapping Analytics Engine\n\nSelf-organizing map (SOM) analytics: project high-dimensional data to a 2D grid for clusters, gradients, and anomalies.\n\n## Workflow (short)\n\nUpload (`datasets(upload)`) → `datasets(preview)` and `datasets(analyze)` before train → submit one of `train(action=map)`, `train(action=siom_map)`, or `train(action=floop_siom)` (only if plan allows FLooP) → poll `jobs(status)` every 10–15s until `completed` → `results(get)` for metrics and figures (there is no separate analyze tool), then `results_explorer(job_id)` to browse the figures interactively. Then `jobs(compare)`, `results(download/recolor)`, or `inference` (predict / compare / project_columns / impute_column / transition_flow) as needed.\n\n**Full detail:** Call `guide_barsom_workflow` for plan-scoped tool map, training modes, async rules, optional MCP App UIs, and step-by-step SOP (from the Barivia API when online).\n\n## Tool taxonomy (one tool per stage — do not mix)\n\n| Stage | Tool(s) | Contract |\n|-------|---------|----------|\n| Data | `datasets` | upload, preview, analyze, list, **get** (status/staging), subset, add_expression, reduce_spectral, delete — ingest + feature engineering, no map |\n| Train submit | `train` | map, siom_map, impute (sparse → map + dense imputed.csv), floop_siom (entitled). Async, returns job_id. Submit only |\n| Lifecycle | `jobs` | status, list, compare (**training runs**), cancel, delete. No training/scoring here |\n| Artifacts | `results` | get (default=combined only; figures="none" for metrics-only sweeps; figures="all" or results_explorer/download for every plot), export, download, recolor (async). Read/render completed jobs |\n| Frozen-map ops | `inference` | predict (regime-aware; "compact"|"annotated"), batch_predict, impute_column (neighbor-pool fill for a non-training column), compare (cohort density-diff), project_columns, transition_flow (time-ordered rows only), report. No weight updates |\n| Account | `account` | status, history, add_funds |\n| Bootstrap | `guide_barsom_workflow`, `train`, `training_guidance`, `prepare_training` prompt | orientation, submit map/siom/impute/floop, parameter hints, narrative checklist (API/tier-scoped) |\n| Explore (UI) | `results_explorer`, `training_monitor` | richer UX over the same API; `jobs(status)`+`results(get)` still suffice headless |\n| Other | `send_feedback` | only after user agrees |\n\n### Avoid these mis-routes\n- `jobs(compare)` = metrics table across **training runs** (pick best in a sweep). `inference(compare)` = density-diff of one map vs a **second dataset** (drift/cohort). Different tools.\n- Post-training map analysis (predict, compare, project_columns, impute_column, **transition_flow**) is all **inference** — not `results`. `results` is presentation/artifacts only (`recolor` stays here: it re-renders, it doesn\'t touch data).\n- Project a quantity onto the map: existing columns → `inference(project_columns)`; a formula → `datasets(add_expression, project_onto_job=<job>)`.\n\n## Async pattern\n\n- **Manual poll:** Training submits return `job_id` immediately — poll `jobs(status)` every 10–15s. **Running is not failed**; large grids or FLooP-SIOM can take many minutes. `max_nodes` (FLooP) is a total node budget, not grid side length.\n- **Often auto-polled:** `inference` actions (incl. `transition_flow`) and `results(recolor)` may wait in-proxy; if you get a `job_id`, poll `jobs(status)` the same way.\n\nCredits: jobs consume compute credits; check `account(status)` before big runs. Per-request HTTP timeout defaults to 60s (`datasets(analyze)` is async + auto-polled, so it is not bound by it); on slow networks raise `BARIVIA_FETCH_TIMEOUT_MS`. Timeouts are NOT auto-retried (avoids duplicating slow/expensive work).\n\n## Constraints\n\n- Prep ladder: `prepare_training` prompt = narrative checklist; `training_guidance` = structured hints (presets, resolved normalization/transforms, grid/epochs). Review these, then submit with `train`. Do not guess tiers or FLooP entitlement.\n- `inference(predict)`: prefer `dataset_id` for batch and for SIOM/irregular maps; single-row `rows` uses a fast path that can fail on some topologies — retry with `dataset_id`. FLooP-SIOM: if predict jobs fail while grid SIOM works, capture errors + `job_id`.\n- Column names are case-sensitive — match `datasets(preview)`.\n- Default training path is numeric/cyclic/temporal; use explicit `categorical_features` for baseline categoricals. `predict` must match the model contract.\n- After `results(recolor)`, `inference(transition_flow)`, or `inference(project_columns)`, use the **new** `job_id` returned for follow-up `results` if applicable.\n- **Large results / MCP timeouts:** For sweeps, `jobs(compare)`, or metrics-only reads use `results(action=get, figures="none")`. Default `get` inlines combined only. For every plot use `results_explorer` or `results(action=download)`, not `figures="all"` in chat unless the user wants all images inline.'});s(T,y,y,{mimeType:n},async()=>{const e=await p("results-explorer");return{contents:[{uri:y,mimeType:n,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),s(T,A,A,{mimeType:n},async()=>{const e=await p("training-monitor");return{contents:[{uri:A,mimeType:n,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),x(T),w(T),I(T),f(T),g(T),h(T,_),b(T),j(T),v(T),P(T),k(T),T.prompt("info","Short orientation for the Barivia Mapping MCP. For full plan-scoped workflow, tool map, and SOP, the model should call guide_barsom_workflow. Use when the user asks what this MCP can do or how to get started.",{},()=>({messages:[{role:"user",content:{type:"text",text:["Give a concise, scannable answer (headers + bullets):","","**What it is:** MCP client to the Barivia mapping engine (2D SOM / SIOM / FLooP-SIOM when entitled) over HTTPS.","","**First step:** Call `guide_barsom_workflow` for plan-scoped bootstrap (full tool list, async rules, training modes, optional MCP Apps, SOP).","","**Core path:** `datasets(upload)` → `datasets(preview)` + `datasets(analyze)` → choose training action → poll `jobs(status)` every 10–15s until completed → `results(get)` (all main figures/metrics; no separate analyze tool) → `results_explorer(job_id)` to browse figures.","",'**Key tools:** `datasets` (data; reduce_spectral for spectra/long blocks), `train` (map/siom_map/impute/floop_siom; pass `label` for readable compare rows; preset=quick for a fast first map), `jobs` (status/list/compare/cancel/delete — lifecycle only), `results` (get/download/export/recolor; figures="none" for metrics-only), `inference` (predict; batch_predict; impute_column for topology-neighbor pool fill; compare; project_columns; transition_flow; report), `account` (status/credits/queue).',"","**Prep help:** `prepare_training` prompt (checklist) · `training_guidance` (presets/JSON hints + resolved normalization/params) — review, then submit with `train`.","","**Browse results:** `results_explorer(job_id)` is the preferred way to explore figures after a first `results(get)` glance; `training_monitor` shows live progress. Both optional — `results` + `jobs(status)` suffice headless.","","**After training:** `jobs(compare)` across runs, `results(recolor)`, `inference(project_columns)` for variables not in training, `inference(transition_flow)` only if rows are time-ordered.","","**Rules:** Running ≠ failed. Column names must match `datasets(preview)` exactly. Do not call `_fetch_figure` from chat (host/UI only); use `results(get)` or `results_explorer`.","","Offer `send_feedback` only after asking the user."].join("\n")}}]})),T.prompt("prepare_training","Narrative pre-training checklist (prompt). Use after upload and before train. Content is tier-scoped from the API when online. Prep ladder: this prompt = story checklist; training_guidance tool = JSON presets/parameter hints. Review, then submit with train.",{dataset_id:o.string().describe("Dataset ID to prepare for training")},async({dataset_id:e})=>{const t=await M(e);return{messages:[{role:"user",content:{type:"text",text:(t.used_fallback?`[offline fallback ${t.fallback_version}]\n\n`:"")+t.text}}]}});const S=new t;(async function(){try{const e=await i(l,c,p);m(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("Barivia viz server failed to start:",e)}const e=T.server;e.oninitialized=()=>{const t=e.getClientCapabilities(),o=r(t);u(!!o?.mimeTypes?.includes(n))},await T.connect(S)})().catch(console.error);
@@ -1,9 +1,9 @@
1
- import { pollUntilComplete } from "./shared.js";
1
+ import { pollUntilComplete, POLL_FINALIZE_MAX_MS } from "./shared.js";
2
2
  /**
3
3
  * When the API enqueues prepare_training_matrix for cross-dataset scoring,
4
4
  * poll it to completion before polling the main inference job.
5
5
  */
6
- export async function pollPrepareIfPresent(data, label, timeoutMs = 600_000) {
6
+ export async function pollPrepareIfPresent(data, label, timeoutMs = POLL_FINALIZE_MAX_MS) {
7
7
  const prepareJobId = data.prepare_job_id;
8
8
  if (!prepareJobId)
9
9
  return null;
@@ -54,6 +54,14 @@ export function snapshotFromJob(data, elapsedSec, note) {
54
54
  snap.qe = Math.round(qe * 10_000) / 10_000;
55
55
  if (te != null)
56
56
  snap.te = Math.round(te * 10_000) / 10_000;
57
+ const panelTe = num(data, "panel_topographic_error");
58
+ if (panelTe != null)
59
+ snap.panel_te = Math.round(panelTe * 10_000) / 10_000;
60
+ if (data.kernel_complete === true)
61
+ snap.kernel_complete = true;
62
+ const failureStage = str(data, "failure_stage");
63
+ if (failureStage)
64
+ snap.failure_stage = failureStage;
57
65
  const eta = num(data, "training_eta_sec");
58
66
  if (eta != null && eta > 0)
59
67
  snap.eta_sec = Math.round(eta);
@@ -78,7 +86,11 @@ export function shouldRecordSnapshot(prev, next) {
78
86
  return true;
79
87
  if (Math.abs(prev.progress_pct - next.progress_pct) >= 1)
80
88
  return true;
81
- if (prev.qe !== next.qe || prev.te !== next.te)
89
+ if (prev.qe !== next.qe || prev.te !== next.te || prev.panel_te !== next.panel_te)
90
+ return true;
91
+ if (prev.kernel_complete !== next.kernel_complete)
92
+ return true;
93
+ if (prev.failure_stage !== next.failure_stage)
82
94
  return true;
83
95
  return false;
84
96
  }
@@ -92,6 +104,12 @@ export function formatSnapshotLine(s) {
92
104
  parts.push(`QE ${s.qe.toFixed(4)}`);
93
105
  if (s.te != null)
94
106
  parts.push(`Epoch TE ${s.te.toFixed(4)}`);
107
+ if (s.panel_te != null)
108
+ parts.push(`Panel TE ${s.panel_te.toFixed(4)}`);
109
+ if (s.kernel_complete)
110
+ parts.push("kernel complete");
111
+ if (s.failure_stage)
112
+ parts.push(`failure_stage ${s.failure_stage}`);
95
113
  if (s.eta_sec != null)
96
114
  parts.push(`ETA ~${s.eta_sec}s`);
97
115
  if (s.ordering_errors_tail?.length) {
@@ -118,6 +136,22 @@ export function formatMonitorText(result, opts) {
118
136
  lines.push(result.suggested_next_step);
119
137
  return lines.join("\n");
120
138
  }
139
+ function failureStageHint(stage, error) {
140
+ const err = (error ?? "").toLowerCase();
141
+ if (stage === "preprocessing") {
142
+ return "Check datasets(action=preview) and prepare job status; missing columns → subset or train(action=impute).";
143
+ }
144
+ if (stage === "training") {
145
+ if (err.includes("memory") || err.includes("readonlymemory")) {
146
+ return "Reduce grid size or batch_size and retrain.";
147
+ }
148
+ return "Review train hyperparameters (sigma_f, learning_rate, batch_size).";
149
+ }
150
+ if (stage === "visualization" || stage === "upload" || stage === "metrics") {
151
+ return "Re-run jobs(action=monitor, wait_finalize=true) or results(action=get) after finalize completes.";
152
+ }
153
+ return "Read the error above before retrying.";
154
+ }
121
155
  function suggestedNextStep(job_id, data) {
122
156
  const status = String(data.status ?? "");
123
157
  if (status === "completed") {
@@ -129,7 +163,9 @@ function suggestedNextStep(job_id, data) {
129
163
  }
130
164
  if (status === "failed") {
131
165
  const stage = str(data, "failure_stage");
132
- return `Job failed${stage ? ` at ${stage}` : ""}. Read the error above before retrying.`;
166
+ const err = str(data, "error");
167
+ const hint = stage ? failureStageHint(stage, err) : "Read the error above before retrying.";
168
+ return `Job failed${stage ? ` at ${stage}` : ""}. ${hint}`;
133
169
  }
134
170
  if (status === "cancelled")
135
171
  return `Job cancelled. Confirm with jobs(action=status, job_id="${job_id}").`;
@@ -70,6 +70,17 @@ export function formatJobStatusText(job_id, data) {
70
70
  if (epoch != null && totalEpochs != null && totalEpochs > 0) {
71
71
  parts.push(`epoch ${epoch}/${totalEpochs}`);
72
72
  }
73
+ const panelTe = data.panel_te != null && !Number.isNaN(Number(data.panel_te))
74
+ ? Number(data.panel_te)
75
+ : data.panel_topographic_error != null && !Number.isNaN(Number(data.panel_topographic_error))
76
+ ? Number(data.panel_topographic_error)
77
+ : null;
78
+ if (panelTe != null) {
79
+ parts.push(`panel_te: ${panelTe.toFixed(4)}`);
80
+ }
81
+ if (data.kernel_complete === true) {
82
+ parts.push("kernel_complete");
83
+ }
73
84
  }
74
85
  const datasetRows = data.dataset_rows != null ? Number(data.dataset_rows) : null;
75
86
  const datasetName = data.dataset_name != null ? String(data.dataset_name) : null;
@@ -120,6 +131,18 @@ export function formatJobStatusText(job_id, data) {
120
131
  else if (status === "cancelled") {
121
132
  parts.push(`Cancelled. Poll again to confirm; dataset and partial worker state are unchanged.`);
122
133
  }
134
+ const suggested = data.suggested_next_step != null && String(data.suggested_next_step) !== ""
135
+ ? String(data.suggested_next_step)
136
+ : status === "completed"
137
+ ? `results(action=get, job_id="${job_id}") then results_explorer(job_id="${job_id}")`
138
+ : status === "failed"
139
+ ? `Inspect failure_stage/error; fix data or params; resubmit train or jobs.`
140
+ : status === "running" || status === "pending"
141
+ ? `jobs(action=status, job_id="${job_id}") again in 10–15s (or training_monitor).`
142
+ : null;
143
+ if (suggested) {
144
+ parts.push(`suggested_next_step: ${suggested}`);
145
+ }
123
146
  return parts.join(" | ");
124
147
  }
125
148
  export function formatJobCancelText(job_id, data) {
@@ -1,16 +1,33 @@
1
1
  import { apiCall } from "./shared.js";
2
- const FALLBACK_PREFIX = "Before upload: for train(action=map) ensure training columns have no NaNs; for train(action=impute) missing cells in training columns are OK (plain numeric only). Please run datasets(action=preview, dataset_id=\"{id}\") to inspect columns, then datasets(action=analyze, dataset_id=\"{id}\") to see which columns and temporal periods are most informative. Then choose the training path: train(action=map, dataset_id=\"{id}\", ...) for complete-case data, train(action=impute, dataset_id=\"{id}\", ...) for sparse matrices, train(action=siom_map, dataset_id=\"{id}\", ...) for a fixed-grid SIOM, or train(action=floop_siom, dataset_id=\"{id}\", ...) for FLooP-SIOM (default topology=free / CHL; optional topology=chain).";
2
+ export const PREPARE_TRAINING_FALLBACK_VERSION = "2026.07-offline";
3
+ const FALLBACK_LINES = [
4
+ "Before upload: for train(action=map) ensure training columns have no NaNs; for train(action=impute) missing cells in training columns are OK (plain numeric only).",
5
+ 'Please run datasets(action=preview, dataset_id="{id}") to inspect columns, then datasets(action=analyze, dataset_id="{id}") to see which columns and temporal periods are most informative.',
6
+ "Choose the training path:",
7
+ '- train(action=map, dataset_id="{id}", ...) for complete-case data',
8
+ '- train(action=impute, dataset_id="{id}", ...) for sparse matrices',
9
+ '- train(action=siom_map, dataset_id="{id}", ...) for a fixed-grid SIOM',
10
+ '- train(action=floop_siom, dataset_id="{id}", ...) for FLooP-SIOM (default topology=free / CHL; optional topology=chain)',
11
+ ];
3
12
  /** Used by the `prepare_training` MCP prompt; prefers tier-scoped text from the API when online. */
4
- export async function resolvePrepareTrainingPromptText(datasetId) {
5
- let promptText = FALLBACK_PREFIX.replaceAll("{id}", datasetId);
13
+ export async function resolvePrepareTrainingPrompt(datasetId) {
14
+ const fallbackText = FALLBACK_LINES.join("\n").replaceAll("{id}", datasetId);
6
15
  try {
7
16
  const data = (await apiCall("GET", `/v1/docs/prepare_training?dataset_id=${datasetId}`));
8
17
  if (typeof data.prompt === "string" && data.prompt.trim()) {
9
- promptText = data.prompt.trim();
18
+ return { text: data.prompt.trim(), used_fallback: false, fallback_version: null };
10
19
  }
11
20
  }
12
21
  catch {
13
- // fallback
22
+ // fall through
14
23
  }
15
- return promptText;
24
+ return {
25
+ text: fallbackText,
26
+ used_fallback: true,
27
+ fallback_version: PREPARE_TRAINING_FALLBACK_VERSION,
28
+ };
29
+ }
30
+ /** @deprecated Prefer resolvePrepareTrainingPrompt for structured fallback metadata. */
31
+ export async function resolvePrepareTrainingPromptText(datasetId) {
32
+ return (await resolvePrepareTrainingPrompt(datasetId)).text;
16
33
  }
package/dist/shared.js CHANGED
@@ -11,7 +11,7 @@ import { pipeline } from "node:stream/promises";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
- import { logInfo } from "./logger.js";
14
+ import { logInfo, logWarn } from "./logger.js";
15
15
  // ---------------------------------------------------------------------------
16
16
  // Config
17
17
  // ---------------------------------------------------------------------------
@@ -21,19 +21,50 @@ export const API_URL = process.env.BARIVIA_API_URL ??
21
21
  export const API_KEY = process.env.BARIVIA_API_KEY ?? process.env.BARSOM_API_KEY ?? "";
22
22
  export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "60000", 10);
23
23
  export const MAX_RETRIES = 2;
24
+ export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "1000", 10);
24
25
  export const RETRYABLE_STATUS = new Set([502, 503, 504]);
26
+ /** Exponential backoff for transient API retries (attempt 0 → base, 1 → 2×, …). */
27
+ export function retryDelayMs(attempt) {
28
+ return RETRY_BASE_MS * 2 ** attempt;
29
+ }
30
+ function newRequestId() {
31
+ return randomUUID();
32
+ }
25
33
  /**
26
34
  * Single source of truth for the proxy version. Sent to the API as
27
35
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
28
36
  * wrapper version each action requires. Keep in sync with package.json on bump.
29
37
  */
30
- export const CLIENT_VERSION = "0.21.0";
38
+ export const CLIENT_VERSION = "0.22.2";
31
39
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
32
40
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
33
41
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
34
42
  export const PUBLIC_DASHBOARD_URL = `${PUBLIC_SITE_ORIGIN}/dashboard`;
35
43
  /** Poll window for datasets(add_expression) / derive jobs (server-side work can exceed 30s). */
36
44
  export const POLL_DERIVE_MAX_MS = 120_000;
45
+ /** Poll window for async datasets(analyze) jobs (large tables can exceed derive timeout). */
46
+ export const POLL_ANALYZE_MAX_MS = 300_000; // 5 min
47
+ /** Poll windows for inference/results async jobs (named for grep/tuning). */
48
+ export const POLL_INFERENCE_MAX_MS = 120_000;
49
+ export const POLL_PROJECT_MAX_MS = 90_000;
50
+ export const POLL_RECOLOR_MAX_MS = 60_000;
51
+ export const POLL_FINALIZE_MAX_MS = 600_000;
52
+ export const DPI_MAP = { standard: 1, retina: 2, print: 4 };
53
+ export function resolveOutputDpi(dpi, defaultDpi = "retina") {
54
+ return DPI_MAP[dpi ?? defaultDpi] ?? DPI_MAP[defaultDpi] ?? 2;
55
+ }
56
+ export function fmtDensityDiffNode(n) {
57
+ return ` node ${n.node_index ?? "?"} [${(n.coords ?? [0, 0]).map((v) => Number(v).toFixed(1)).join(",")}] Δ=${Number(n.density_diff ?? 0).toFixed(4)}`;
58
+ }
59
+ /** Map a local upload path to the API Content-Type (csv vs tsv; ignores .gz suffix). */
60
+ export function resolveUploadContentType(filePath) {
61
+ const lower = filePath.toLowerCase();
62
+ const base = lower.endsWith(".gz") ? lower.slice(0, -3) : lower;
63
+ return path.extname(base) === ".tsv" ? "text/tab-separated-values" : "text/csv";
64
+ }
65
+ export function suggestDatasetPreview(datasetId) {
66
+ return `datasets(action=preview, dataset_id=${datasetId})`;
67
+ }
37
68
  /** Large CSV uploads may exceed default FETCH_TIMEOUT_MS. */
38
69
  export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
39
70
  /** Files at/above this size use the presigned direct-to-R2 streaming upload path. */
@@ -127,7 +158,11 @@ export async function putPresignedStream(url, srcPath, contentType, timeoutMs =
127
158
  const resp = await fetch(url, {
128
159
  method: "PUT",
129
160
  body: webStream,
130
- headers: { "Content-Type": contentType, "Content-Length": String(contentLength) },
161
+ headers: {
162
+ "Content-Type": contentType,
163
+ "Content-Length": String(contentLength),
164
+ "Content-Encoding": "gzip",
165
+ },
131
166
  // Required by Node's fetch to send a streaming request body.
132
167
  duplex: "half",
133
168
  signal: controller.signal,
@@ -370,7 +405,7 @@ function _traceparentHeader() {
370
405
  export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs) {
371
406
  const url = `${API_URL}${path}`;
372
407
  const contentType = extraHeaders?.["Content-Type"] ?? "application/json";
373
- const requestId = Math.random().toString(36).slice(2, 10);
408
+ const requestId = newRequestId();
374
409
  const headers = {
375
410
  Authorization: `Bearer ${API_KEY}`,
376
411
  "Content-Type": contentType,
@@ -403,7 +438,17 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
403
438
  const text = await resp.text();
404
439
  if (!resp.ok) {
405
440
  if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
406
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
441
+ const delayMs = retryDelayMs(attempt);
442
+ logWarn("API retry", {
443
+ rid: requestId,
444
+ method,
445
+ path,
446
+ attempt: attempt + 1,
447
+ max_retries: MAX_RETRIES,
448
+ delay_ms: delayMs,
449
+ error_code: `http_${resp.status}`,
450
+ });
451
+ await new Promise((r) => setTimeout(r, delayMs));
407
452
  continue;
408
453
  }
409
454
  logInfo("API response", {
@@ -424,7 +469,17 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
424
469
  catch (err) {
425
470
  lastError = err;
426
471
  if (attempt < MAX_RETRIES && isTransientError(err)) {
427
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
472
+ const delayMs = retryDelayMs(attempt);
473
+ logWarn("API retry", {
474
+ rid: requestId,
475
+ method,
476
+ path,
477
+ attempt: attempt + 1,
478
+ max_retries: MAX_RETRIES,
479
+ delay_ms: delayMs,
480
+ error_code: err instanceof Error ? err.name : "fetch_error",
481
+ });
482
+ await new Promise((r) => setTimeout(r, delayMs));
428
483
  continue;
429
484
  }
430
485
  if (err instanceof DOMException &&
@@ -440,7 +495,7 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
440
495
  /** Fetch raw bytes from the API (for image downloads). */
441
496
  export async function apiRawCall(path, requestTimeoutMs) {
442
497
  const url = `${API_URL}${path}`;
443
- const requestId = Math.random().toString(36).slice(2, 10);
498
+ const requestId = newRequestId();
444
499
  const effectiveTimeout = requestTimeoutMs ?? FETCH_TIMEOUT_MS;
445
500
  let lastError;
446
501
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -455,7 +510,16 @@ export async function apiRawCall(path, requestTimeoutMs) {
455
510
  }, effectiveTimeout);
456
511
  if (!resp.ok) {
457
512
  if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
458
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
513
+ const delayMs = retryDelayMs(attempt);
514
+ logWarn("API retry", {
515
+ rid: requestId,
516
+ path,
517
+ attempt: attempt + 1,
518
+ max_retries: MAX_RETRIES,
519
+ delay_ms: delayMs,
520
+ error_code: `http_${resp.status}`,
521
+ });
522
+ await new Promise((r) => setTimeout(r, delayMs));
459
523
  continue;
460
524
  }
461
525
  const text = await resp.text();
@@ -470,7 +534,16 @@ export async function apiRawCall(path, requestTimeoutMs) {
470
534
  catch (err) {
471
535
  lastError = err;
472
536
  if (attempt < MAX_RETRIES && isTransientError(err)) {
473
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
537
+ const delayMs = retryDelayMs(attempt);
538
+ logWarn("API retry", {
539
+ rid: requestId,
540
+ path,
541
+ attempt: attempt + 1,
542
+ max_retries: MAX_RETRIES,
543
+ delay_ms: delayMs,
544
+ error_code: err instanceof Error ? err.name : "fetch_error",
545
+ });
546
+ await new Promise((r) => setTimeout(r, delayMs));
474
547
  continue;
475
548
  }
476
549
  if (err instanceof DOMException &&
@@ -545,7 +618,13 @@ export async function fetchNormalizationGuideFromApi() {
545
618
  }
546
619
  }
547
620
  /** Fetch training guidance from API (used by training_guidance tool). Domain knowledge returned when asked; valid license required. Hints are filtered by plan (allowed_job_types). */
621
+ const GUIDE_CACHE_TTL_MS = 60_000;
622
+ let _trainingGuidanceCache = null;
623
+ let _workflowGuideCache = null;
548
624
  export async function fetchTrainingGuidanceFromApi() {
625
+ if (_trainingGuidanceCache && Date.now() - _trainingGuidanceCache.at < GUIDE_CACHE_TTL_MS) {
626
+ return _trainingGuidanceCache.text;
627
+ }
549
628
  try {
550
629
  const config = (await apiCall("GET", "/v1/training/config"));
551
630
  const scopeSuffix = formatGuidanceScopeSuffix(config);
@@ -558,36 +637,46 @@ export async function fetchTrainingGuidanceFromApi() {
558
637
  ? "\n\n--- Normalization guide (MAD vs SEPD, decision ladder) ---\n\n" + normGuide
559
638
  : "\n\n(Normalization guide: GET /v1/docs/normalization — MAD vs SEPD decision ladder.)";
560
639
  const hints = config?.training_hints;
640
+ let text;
561
641
  if (Array.isArray(hints) && hints.length > 0) {
562
- return presetBlock + "Parameter guidance (from API):\n- " + hints.join("\n- ") + scopeSuffix + normBlock;
642
+ text = presetBlock + "Parameter guidance (from API):\n- " + hints.join("\n- ") + scopeSuffix + normBlock;
563
643
  }
564
- const pg = config?.parameter_guidance;
565
- if (pg && typeof pg === "object") {
566
- const lines = Object.entries(pg).map(([k, v]) => `${k}: ${v}`);
567
- return presetBlock + "Parameter guidance (from API):\n- " + lines.join("\n- ") + scopeSuffix + normBlock;
644
+ else {
645
+ const pg = config?.parameter_guidance;
646
+ if (pg && typeof pg === "object") {
647
+ const lines = Object.entries(pg).map(([k, v]) => `${k}: ${v}`);
648
+ text = presetBlock + "Parameter guidance (from API):\n- " + lines.join("\n- ") + scopeSuffix + normBlock;
649
+ }
650
+ else if (presetBlock) {
651
+ text = presetBlock + "No additional parameter guidance in config." + scopeSuffix + normBlock;
652
+ }
653
+ else {
654
+ text = "No parameter guidance available.";
655
+ }
568
656
  }
569
- if (presetBlock)
570
- return presetBlock + "No additional parameter guidance in config." + scopeSuffix + normBlock;
657
+ _trainingGuidanceCache = { text, at: Date.now() };
658
+ return text;
571
659
  }
572
660
  catch (e) {
573
661
  if (e?.httpStatus === 401 || e?.httpStatus === 403)
574
662
  throw e;
575
663
  return "Could not fetch parameter guidance. Check API key and connectivity.";
576
664
  }
577
- return "No parameter guidance available.";
578
665
  }
579
666
  /**
580
667
  * Full MCP workflow + bootstrap text from API (tier-scoped). Null on network/parse failure (caller may show offline stub).
581
668
  * Re-throws 401/403 so the MCP host surfaces auth errors.
582
669
  */
583
670
  export async function fetchWorkflowGuideFromApi() {
671
+ if (_workflowGuideCache && Date.now() - _workflowGuideCache.at < GUIDE_CACHE_TTL_MS) {
672
+ return _workflowGuideCache.text;
673
+ }
584
674
  try {
585
675
  const data = (await apiCall("GET", "/v1/docs/workflow"));
586
676
  const md = data?.workflow_markdown;
587
- if (typeof md === "string" && md.trim().length > 0) {
588
- return md.trim();
589
- }
590
- return null;
677
+ const text = typeof md === "string" && md.trim().length > 0 ? md.trim() : null;
678
+ _workflowGuideCache = { text, at: Date.now() };
679
+ return text;
591
680
  }
592
681
  catch (e) {
593
682
  if (e?.httpStatus === 401 || e?.httpStatus === 403)
@@ -651,6 +740,16 @@ export function inlineAttachBytesUsed() {
651
740
  export function shouldFetchAllRemainingFigures(figures) {
652
741
  return figures === "all" || figures === "images";
653
742
  }
743
+ /** Attach result figures with captions; tracks inlined names for dedup. */
744
+ export async function attachResultsImages(content, jobId, jobType, summary, figures, includeIndividual, inlinedImages) {
745
+ for (const name of getResultsImagesToFetch(jobType, summary, figures, includeIndividual)) {
746
+ const cap = getCaptionForImage(name);
747
+ if (cap)
748
+ content.push({ type: "text", text: cap });
749
+ await tryAttachImage(content, jobId, name);
750
+ inlinedImages.add(name);
751
+ }
752
+ }
654
753
  export async function tryAttachImage(content, jobId, filename) {
655
754
  // MCP image content only supports raster MIME types (png/jpeg/gif/webp).
656
755
  // PDF and SVG must be retrieved out-of-band via get_result_image.