@barivia/barsom-mcp 0.20.4 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
 
@@ -162,7 +173,7 @@ All actions use a frozen trained map — no retraining. Derived columns use **`d
162
173
 
163
174
  | Tool | Role |
164
175
  |------|------|
165
- | `training_monitor` | Live QE + topographic-error curves (ordering/convergence, dual y-axis), FLooP step progress when applicable; optional — `jobs(action=status)` is enough |
176
+ | `training_monitor` | Live QE + **panel** topographic-error curves (ordering/convergence, dual y-axis); **Panel TE** and **Map TE** scalars at kernel end; FLooP step progress when applicable; optional — `jobs(action=status)` is enough |
166
177
  | `results_explorer` | Browse metrics and figures after training completes |
167
178
  | `_fetch_figure` | **Host / App only** — Results Explorer invokes this for one raster figure; not for agent chat |
168
179
 
@@ -180,6 +191,7 @@ The right viewer depends on **(MCP App support)** **and** **(can the human reach
180
191
 
181
192
  ### Migration notes
182
193
 
194
+ - **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.
183
195
  - **Features (0.20.0, non-breaking):**
184
196
  - **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.
185
197
  - **`datasets(action=reduce_spectral)` column selectors.** New optional `columns_range` (`[first, last]`, the server expands the inclusive numeric block — preferred for wide spectra over sending thousands of names) and `columns_except` (drop non-frequency columns like id/label/target from the block, or use alone for "all numeric except these"). `columns_except` composes with `columns_block` or `columns_range`; `columns_block` and `columns_range` are mutually exclusive. Existing `columns_block` callers are unaffected.
package/dist/audit.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * MCP tool audit wrapper — tool name, action, latency, outcome; no secrets.
3
3
  */
4
4
  import { logAudit } from "./logger.js";
5
+ import { runWithTrace } from "./shared.js";
5
6
  /** Keys safe to include in audit param summaries (no paths, keys, or bodies). */
6
7
  const AUDIT_PARAM_KEYS = new Set([
7
8
  "action",
@@ -160,7 +161,9 @@ export function registerAuditedTool(server, name, description, schema, handler)
160
161
  server.tool(name, description, schema, (async (args) => {
161
162
  const rec = args;
162
163
  const action = typeof rec.action === "string" && rec.action.length > 0 ? rec.action : "default";
163
- return runMcpToolAudit(name, action, rec, () => handler(args));
164
+ // One trace per tool invocation: every apiCall inside this handler shares one
165
+ // trace_id, so the API + job chain reconstruct the whole logical action.
166
+ return runWithTrace(() => runMcpToolAudit(name, action, rec, () => handler(args)));
164
167
  }));
165
168
  }
166
169
  /** Attach audit flags to a tool result (stripped before returning to MCP client). */
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{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`, `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})=>({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);
@@ -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}").`;
@@ -1,8 +1,16 @@
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
+ const FALLBACK_LINES = [
3
+ "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).",
4
+ '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.',
5
+ "Choose the training path:",
6
+ '- train(action=map, dataset_id="{id}", ...) for complete-case data',
7
+ '- train(action=impute, dataset_id="{id}", ...) for sparse matrices',
8
+ '- train(action=siom_map, dataset_id="{id}", ...) for a fixed-grid SIOM',
9
+ '- train(action=floop_siom, dataset_id="{id}", ...) for FLooP-SIOM (default topology=free / CHL; optional topology=chain)',
10
+ ];
3
11
  /** Used by the `prepare_training` MCP prompt; prefers tier-scoped text from the API when online. */
4
12
  export async function resolvePrepareTrainingPromptText(datasetId) {
5
- let promptText = FALLBACK_PREFIX.replaceAll("{id}", datasetId);
13
+ let promptText = FALLBACK_LINES.join("\n").replaceAll("{id}", datasetId);
6
14
  try {
7
15
  const data = (await apiCall("GET", `/v1/docs/prepare_training?dataset_id=${datasetId}`));
8
16
  if (typeof data.prompt === "string" && data.prompt.trim()) {
package/dist/shared.js CHANGED
@@ -5,12 +5,13 @@ import fs from "node:fs/promises";
5
5
  import { createReadStream, createWriteStream } from "node:fs";
6
6
  import { createGzip } from "node:zlib";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
+ import { AsyncLocalStorage } from "node:async_hooks";
8
9
  import { Readable } from "node:stream";
9
10
  import { pipeline } from "node:stream/promises";
10
11
  import os from "node:os";
11
12
  import path from "node:path";
12
13
  import { fileURLToPath } from "node:url";
13
- import { logInfo } from "./logger.js";
14
+ import { logInfo, logWarn } from "./logger.js";
14
15
  // ---------------------------------------------------------------------------
15
16
  // Config
16
17
  // ---------------------------------------------------------------------------
@@ -20,19 +21,50 @@ export const API_URL = process.env.BARIVIA_API_URL ??
20
21
  export const API_KEY = process.env.BARIVIA_API_KEY ?? process.env.BARSOM_API_KEY ?? "";
21
22
  export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "60000", 10);
22
23
  export const MAX_RETRIES = 2;
24
+ export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "1000", 10);
23
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
+ }
24
33
  /**
25
34
  * Single source of truth for the proxy version. Sent to the API as
26
35
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
27
36
  * wrapper version each action requires. Keep in sync with package.json on bump.
28
37
  */
29
- export const CLIENT_VERSION = "0.20.3";
38
+ export const CLIENT_VERSION = "0.22.1";
30
39
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
31
40
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
32
41
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
33
42
  export const PUBLIC_DASHBOARD_URL = `${PUBLIC_SITE_ORIGIN}/dashboard`;
34
43
  /** Poll window for datasets(add_expression) / derive jobs (server-side work can exceed 30s). */
35
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
+ }
36
68
  /** Large CSV uploads may exceed default FETCH_TIMEOUT_MS. */
37
69
  export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
38
70
  /** Files at/above this size use the presigned direct-to-R2 streaming upload path. */
@@ -126,7 +158,11 @@ export async function putPresignedStream(url, srcPath, contentType, timeoutMs =
126
158
  const resp = await fetch(url, {
127
159
  method: "PUT",
128
160
  body: webStream,
129
- headers: { "Content-Type": contentType, "Content-Length": String(contentLength) },
161
+ headers: {
162
+ "Content-Type": contentType,
163
+ "Content-Length": String(contentLength),
164
+ "Content-Encoding": "gzip",
165
+ },
130
166
  // Required by Node's fetch to send a streaming request body.
131
167
  duplex: "half",
132
168
  signal: controller.signal,
@@ -342,14 +378,39 @@ function throwApiError(status, bodyText, requestId) {
342
378
  /**
343
379
  * @param requestTimeoutMs Optional per-request timeout (default `BARIVIA_FETCH_TIMEOUT_MS`).
344
380
  */
381
+ // ---- Distributed-trace context (W3C traceparent) ----
382
+ //
383
+ // One logical user action (a tool call that may make several API requests, e.g.
384
+ // upload + analyze + train + poll) shares ONE trace_id so the API and the whole job
385
+ // chain (stage -> prepare -> train -> finalize) can be reconstructed end-to-end. The
386
+ // trace id is scoped per tool invocation via AsyncLocalStorage (see registerAuditedTool);
387
+ // each API call mints a fresh span id. Falls back to a per-call trace if no scope is set.
388
+ const _traceStore = new AsyncLocalStorage();
389
+ function _newTraceId() {
390
+ return randomUUID().replace(/-/g, ""); // 32 hex
391
+ }
392
+ function _newSpanId() {
393
+ return randomUUID().replace(/-/g, "").slice(0, 16); // 16 hex
394
+ }
395
+ /** Run `fn` within a fresh trace scope so all apiCall()s inside share one trace_id. */
396
+ export function runWithTrace(fn) {
397
+ return _traceStore.run(_newTraceId(), fn);
398
+ }
399
+ function _currentTraceId() {
400
+ return _traceStore.getStore() ?? _newTraceId();
401
+ }
402
+ function _traceparentHeader() {
403
+ return `00-${_currentTraceId()}-${_newSpanId()}-01`;
404
+ }
345
405
  export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs) {
346
406
  const url = `${API_URL}${path}`;
347
407
  const contentType = extraHeaders?.["Content-Type"] ?? "application/json";
348
- const requestId = Math.random().toString(36).slice(2, 10);
408
+ const requestId = newRequestId();
349
409
  const headers = {
350
410
  Authorization: `Bearer ${API_KEY}`,
351
411
  "Content-Type": contentType,
352
412
  "X-Request-ID": requestId,
413
+ traceparent: _traceparentHeader(),
353
414
  "X-Barsom-Client-Version": CLIENT_VERSION,
354
415
  ...extraHeaders,
355
416
  };
@@ -377,7 +438,17 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
377
438
  const text = await resp.text();
378
439
  if (!resp.ok) {
379
440
  if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
380
- 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));
381
452
  continue;
382
453
  }
383
454
  logInfo("API response", {
@@ -398,7 +469,17 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
398
469
  catch (err) {
399
470
  lastError = err;
400
471
  if (attempt < MAX_RETRIES && isTransientError(err)) {
401
- 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));
402
483
  continue;
403
484
  }
404
485
  if (err instanceof DOMException &&
@@ -414,7 +495,7 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
414
495
  /** Fetch raw bytes from the API (for image downloads). */
415
496
  export async function apiRawCall(path, requestTimeoutMs) {
416
497
  const url = `${API_URL}${path}`;
417
- const requestId = Math.random().toString(36).slice(2, 10);
498
+ const requestId = newRequestId();
418
499
  const effectiveTimeout = requestTimeoutMs ?? FETCH_TIMEOUT_MS;
419
500
  let lastError;
420
501
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -424,11 +505,21 @@ export async function apiRawCall(path, requestTimeoutMs) {
424
505
  headers: {
425
506
  Authorization: `Bearer ${API_KEY}`,
426
507
  "X-Request-ID": requestId,
508
+ traceparent: _traceparentHeader(),
427
509
  },
428
510
  }, effectiveTimeout);
429
511
  if (!resp.ok) {
430
512
  if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
431
- 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));
432
523
  continue;
433
524
  }
434
525
  const text = await resp.text();
@@ -443,7 +534,16 @@ export async function apiRawCall(path, requestTimeoutMs) {
443
534
  catch (err) {
444
535
  lastError = err;
445
536
  if (attempt < MAX_RETRIES && isTransientError(err)) {
446
- 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));
447
547
  continue;
448
548
  }
449
549
  if (err instanceof DOMException &&
@@ -518,7 +618,13 @@ export async function fetchNormalizationGuideFromApi() {
518
618
  }
519
619
  }
520
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;
521
624
  export async function fetchTrainingGuidanceFromApi() {
625
+ if (_trainingGuidanceCache && Date.now() - _trainingGuidanceCache.at < GUIDE_CACHE_TTL_MS) {
626
+ return _trainingGuidanceCache.text;
627
+ }
522
628
  try {
523
629
  const config = (await apiCall("GET", "/v1/training/config"));
524
630
  const scopeSuffix = formatGuidanceScopeSuffix(config);
@@ -531,36 +637,46 @@ export async function fetchTrainingGuidanceFromApi() {
531
637
  ? "\n\n--- Normalization guide (MAD vs SEPD, decision ladder) ---\n\n" + normGuide
532
638
  : "\n\n(Normalization guide: GET /v1/docs/normalization — MAD vs SEPD decision ladder.)";
533
639
  const hints = config?.training_hints;
640
+ let text;
534
641
  if (Array.isArray(hints) && hints.length > 0) {
535
- 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;
536
643
  }
537
- const pg = config?.parameter_guidance;
538
- if (pg && typeof pg === "object") {
539
- const lines = Object.entries(pg).map(([k, v]) => `${k}: ${v}`);
540
- 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
+ }
541
656
  }
542
- if (presetBlock)
543
- return presetBlock + "No additional parameter guidance in config." + scopeSuffix + normBlock;
657
+ _trainingGuidanceCache = { text, at: Date.now() };
658
+ return text;
544
659
  }
545
660
  catch (e) {
546
661
  if (e?.httpStatus === 401 || e?.httpStatus === 403)
547
662
  throw e;
548
663
  return "Could not fetch parameter guidance. Check API key and connectivity.";
549
664
  }
550
- return "No parameter guidance available.";
551
665
  }
552
666
  /**
553
667
  * Full MCP workflow + bootstrap text from API (tier-scoped). Null on network/parse failure (caller may show offline stub).
554
668
  * Re-throws 401/403 so the MCP host surfaces auth errors.
555
669
  */
556
670
  export async function fetchWorkflowGuideFromApi() {
671
+ if (_workflowGuideCache && Date.now() - _workflowGuideCache.at < GUIDE_CACHE_TTL_MS) {
672
+ return _workflowGuideCache.text;
673
+ }
557
674
  try {
558
675
  const data = (await apiCall("GET", "/v1/docs/workflow"));
559
676
  const md = data?.workflow_markdown;
560
- if (typeof md === "string" && md.trim().length > 0) {
561
- return md.trim();
562
- }
563
- return null;
677
+ const text = typeof md === "string" && md.trim().length > 0 ? md.trim() : null;
678
+ _workflowGuideCache = { text, at: Date.now() };
679
+ return text;
564
680
  }
565
681
  catch (e) {
566
682
  if (e?.httpStatus === 401 || e?.httpStatus === 403)
@@ -624,6 +740,16 @@ export function inlineAttachBytesUsed() {
624
740
  export function shouldFetchAllRemainingFigures(figures) {
625
741
  return figures === "all" || figures === "images";
626
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
+ }
627
753
  export async function tryAttachImage(content, jobId, filename) {
628
754
  // MCP image content only supports raster MIME types (png/jpeg/gif/webp).
629
755
  // PDF and SVG must be retrieved out-of-band via get_result_image.
@@ -4,7 +4,7 @@ import { gzipSync } from "node:zlib";
4
4
  import { createHash } from "node:crypto";
5
5
  import { z } from "zod";
6
6
  import { registerAuditedTool } from "../audit.js";
7
- import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, POLL_DERIVE_MAX_MS, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, } from "../shared.js";
7
+ import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, POLL_DERIVE_MAX_MS, POLL_ANALYZE_MAX_MS, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, resolveUploadContentType, suggestDatasetPreview, } from "../shared.js";
8
8
  import { GZIP_UPLOAD_HINT } from "../job_status_format.js";
9
9
  /**
10
10
  * Normalize a nullable string field from the API. Returns "" for absent values,
@@ -269,9 +269,11 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
269
269
  if (!name)
270
270
  throw new Error("datasets(upload) requires name");
271
271
  let body;
272
+ let uploadContentType = "text/csv";
272
273
  if (file_path) {
273
274
  await apiCall("GET", "/v1/system/info");
274
275
  const resolved = await resolveFilePathForUpload(file_path, server);
276
+ uploadContentType = resolveUploadContentType(resolved);
275
277
  const lower = resolved.toLowerCase();
276
278
  // Accept pre-gzipped CSV/TSV (.csv.gz / .tsv.gz) so large tables transfer
277
279
  // ~3x smaller. Already-gzipped files are stored as-is (the platform stores
@@ -317,9 +319,9 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
317
319
  // a fresh upload_url and no idempotent_replay flag, so we re-PUT below.
318
320
  if (init.idempotent_replay && !init.upload_url) {
319
321
  return textResult({ id: datasetId, status: init.status, idempotent_replay: true,
320
- suggested_next_step: `datasets(action=preview, dataset_id=${datasetId})` });
322
+ suggested_next_step: suggestDatasetPreview(datasetId) });
321
323
  }
322
- await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS, isGzipInput);
324
+ await putPresignedStream(init.upload_url, resolved, init.content_type ?? uploadContentType, PRESIGNED_PUT_TIMEOUT_MS, isGzipInput);
323
325
  const fin = (await apiCall("POST", `/v1/datasets/${datasetId}/finalize`, {}));
324
326
  const jobId = (fin.id ?? fin.job_id);
325
327
  const poll = await pollUntilComplete(jobId, POLL_STAGE_MAX_MS);
@@ -332,8 +334,8 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
332
334
  status: ready ? "ready" : "staging",
333
335
  job_id: jobId,
334
336
  suggested_next_step: ready
335
- ? `datasets(action=preview, dataset_id=${datasetId}) to inspect columns before training.`
336
- : `Still staging; poll jobs(action=status, job_id="${jobId}") then datasets(action=preview, dataset_id=${datasetId}).`,
337
+ ? `${suggestDatasetPreview(datasetId)} to inspect columns before training.`
338
+ : `Still staging; poll jobs(action=status, job_id="${jobId}") then ${suggestDatasetPreview(datasetId)}.`,
337
339
  });
338
340
  }
339
341
  // Small pre-gzipped files: send the bytes as-is (the API decompresses
@@ -342,13 +344,13 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
342
344
  const gzBytes = await fs.readFile(resolved);
343
345
  const data = (await apiCall("POST", "/v1/datasets", gzBytes, {
344
346
  "X-Dataset-Name": name,
345
- "Content-Type": "text/csv",
347
+ "Content-Type": uploadContentType,
346
348
  "Content-Encoding": "gzip",
347
349
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
348
350
  }, UPLOAD_DATASET_TIMEOUT_MS));
349
351
  const gid = data.id ?? data.dataset_id;
350
352
  if (gid != null)
351
- data.suggested_next_step = `Suggested next step: datasets(action=preview, dataset_id=${gid}) to inspect columns before training.`;
353
+ data.suggested_next_step = `${suggestDatasetPreview(String(gid))} to inspect columns before training.`;
352
354
  return textResult(data);
353
355
  }
354
356
  body = await fs.readFile(resolved, "utf-8");
@@ -364,7 +366,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
364
366
  const GZIP_THRESHOLD = 1024 * 1024; // 1 MB
365
367
  const uploadHeaders = {
366
368
  "X-Dataset-Name": name,
367
- "Content-Type": "text/csv",
369
+ "Content-Type": uploadContentType,
368
370
  // Deterministic key so a timed-out retry of the SAME upload reconciles to
369
371
  // the original dataset server-side instead of creating a duplicate.
370
372
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
@@ -377,7 +379,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
377
379
  const data = (await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS));
378
380
  const id = data.id ?? data.dataset_id;
379
381
  if (id != null)
380
- data.suggested_next_step = `Suggested next step: datasets(action=preview, dataset_id=${id}) to inspect columns before training.`;
382
+ data.suggested_next_step = `${suggestDatasetPreview(String(id))} to inspect columns before training.`;
381
383
  return textResult(data);
382
384
  }
383
385
  if (action === "preview") {
@@ -477,7 +479,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
477
479
  const jobId = (submit.id ?? submit.job_id);
478
480
  if (!jobId)
479
481
  throw new Error("analyze did not return a job id");
480
- const poll = await pollUntilComplete(jobId, POLL_DERIVE_MAX_MS);
482
+ const poll = await pollUntilComplete(jobId, POLL_ANALYZE_MAX_MS);
481
483
  if (poll.status === "failed") {
482
484
  return { content: [{ type: "text", text: `datasets(analyze) job ${jobId} failed: ${poll.error ?? "unknown error"}` }] };
483
485
  }
@@ -492,7 +494,8 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
492
494
  data = (await apiCall("GET", `/v1/datasets/${dataset_id}/analyze`));
493
495
  }
494
496
  else {
495
- throw err;
497
+ const msg = err instanceof Error ? err.message : String(err);
498
+ throw new Error(`datasets(analyze) failed: ${msg}. If the dataset is very large, try datasets(action=subset, sample_n=10000) first.`);
496
499
  }
497
500
  }
498
501
  return { content: [{ type: "text", text: formatAnalyzeResult(data, dataset_id) }] };