@barivia/barsom-mcp 0.19.0 → 0.20.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
@@ -180,6 +180,10 @@ The right viewer depends on **(MCP App support)** **and** **(can the human reach
180
180
 
181
181
  ### Migration notes
182
182
 
183
+ - **Features (0.20.0, non-breaking):**
184
+ - **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
+ - **`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.
186
+ - Minimum dataset size for train/analyze relaxed from 50 to **10 rows** (10–49 allowed with a small-N stability caveat).
183
187
  - **Surface cleanup (0.19.0, breaking):**
184
188
  - `training_prep` and `submit_prepared_training` **removed**. The interactive prep UI is retired; for pre-train help use the `prepare_training` prompt (narrative checklist) or `training_guidance` (presets + resolved per-column normalization), then submit directly with `train(action=map | siom_map | impute | floop_siom)`.
185
189
  - The unused `data-preview` and `map-explorer` view scaffolds were deleted (they were never wired to a tool). The two shipped MCP App UIs are now `training_monitor` and `results_explorer`, both enhanced (auto-resize, earlier render, and silent model-context sync of what the user is viewing).
@@ -227,7 +231,7 @@ MCP Client (Cursor/Claude) ←stdio→ @barivia/barsom-mcp ←HTTPS→ api.bariv
227
231
  | `401` / invalid key | `BARIVIA_API_KEY` in MCP config; check your email or manage your key in the [account dashboard](https://barivia.se/dashboard). Error text includes a **request id** for support. |
228
232
  | Request timed out | Raise `BARIVIA_FETCH_TIMEOUT_MS` (e.g. `120000`). Large uploads already use an extended timeout; `datasets(analyze)` is async (auto-polled). Timeouts are not auto-retried, so re-running a timed-out upload is safe — an idempotency key reconciles it to the original dataset instead of duplicating. |
229
233
  | `Path must be within the workspace` / upload can’t find file | Set `BARIVIA_WORKSPACE_ROOT` to your project directory, or use an absolute path / `file:///...` URI. |
230
- | Job stuck “running” | Poll `jobs(action=status)` every 10–15s; large grids or FLooP-SIOM can take several minutes—not an MCP error. Staged datasets enqueue **`prepare_training_matrix`** (or impute prepare) first — train submit returns `prepare_job_id`; MCP auto-polls prepare when present. CFD mesh submit may return `prepare_job_id` for **`cfd_prepare`** (barmesh auto-polls). |
234
+ | Job stuck “running” | Poll `jobs(action=status)` every 10–15s; large grids or FLooP-SIOM can take several minutes—not an MCP error. Staged datasets enqueue **`prepare_training_matrix`** (or impute prepare) first — train submit returns `prepare_job_id`; MCP auto-polls prepare when present. CFD mesh submit may return `prepare_job_id` for **`prepare_training_matrix`** (barmesh auto-polls). |
231
235
  | `429` | Rate limit—wait and retry. |
232
236
  | Standalone `…/viz/…` page stuck at "running 0%" or "Loading…" | The link is stale: the viz port is assigned per MCP session and changes when the proxy restarts. Re-run `training_monitor` / `results_explorer` for a fresh URL, or set `BARIVIA_VIZ_PORT` for a persistent port. Confirm with `GET /api/health?job_id=<id>`. |
233
237
  | Embedded results explorer: switching figures shows nothing | Fixed in `0.18.0` — earlier versions pointed embedded figures at a localhost URL the webview can't load. Update the package; the embedded view now lazy-loads figures over the MCP App bridge. |
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 d,CLIENT_VERSION as u}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 T}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 M=new e({name:"analytics-engine",version:u,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 (figures="none" for metrics-only), 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.'});s(M,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(M,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(M),w(M),I(M),f(M),g(M),h(M,_),b(M),j(M),v(M),P(M),k(M),M.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")}}]})),M.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 T(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=M.server;e.oninitialized=()=>{const t=e.getClientCapabilities(),o=r(t);d(!!o?.mimeTypes?.includes(n))},await M.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`, `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);
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Server-side job monitor for barsom async training workflows.
3
+ * Polls GET /v1/jobs/:id until terminal (or block_until timeout) and emits
4
+ * compact throttled snapshots so agents avoid manual status poll loops.
5
+ */
6
+ import { apiCall, textResult } from "./shared.js";
7
+ import { formatJobStatusText } from "./job_status_format.js";
8
+ import { pollFinalizeIfPresent, refreshJobAfterFinalize } from "./train_finalize.js";
9
+ export const DEFAULT_BLOCK_UNTIL_SEC = 900;
10
+ export const DEFAULT_POLL_INTERVAL_SEC = 5;
11
+ export const MIN_POLL_INTERVAL_SEC = 5;
12
+ export const HEARTBEAT_POLLS = 1;
13
+ function sleep(ms) {
14
+ return new Promise((r) => setTimeout(r, ms));
15
+ }
16
+ function num(data, key) {
17
+ const v = data[key];
18
+ if (v == null || Number.isNaN(Number(v)))
19
+ return undefined;
20
+ return Number(v);
21
+ }
22
+ function str(data, key) {
23
+ const v = data[key];
24
+ if (v == null || String(v) === "")
25
+ return undefined;
26
+ return String(v);
27
+ }
28
+ function tailOrderingErrors(data, n = 4) {
29
+ const raw = data.ordering_errors;
30
+ if (!Array.isArray(raw) || raw.length === 0)
31
+ return undefined;
32
+ return raw.slice(-n).map((x) => Number(x)).filter((x) => !Number.isNaN(x));
33
+ }
34
+ export function snapshotFromJob(data, elapsedSec, note) {
35
+ const status = String(data.status ?? "unknown");
36
+ const progress_pct = (data.progress ?? 0) * 100;
37
+ const snap = {
38
+ elapsed_sec: elapsedSec,
39
+ status,
40
+ progress_pct: Math.round(progress_pct * 10) / 10,
41
+ };
42
+ const phase = str(data, "progress_phase");
43
+ if (phase)
44
+ snap.phase = phase;
45
+ const epoch = num(data, "epoch");
46
+ const total = num(data, "total_epochs");
47
+ if (epoch != null)
48
+ snap.epoch = epoch;
49
+ if (total != null)
50
+ snap.total_epochs = total;
51
+ const qe = num(data, "quantization_error");
52
+ const te = num(data, "topographic_error");
53
+ if (qe != null)
54
+ snap.qe = Math.round(qe * 10_000) / 10_000;
55
+ if (te != null)
56
+ snap.te = Math.round(te * 10_000) / 10_000;
57
+ const eta = num(data, "training_eta_sec");
58
+ if (eta != null && eta > 0)
59
+ snap.eta_sec = Math.round(eta);
60
+ const tail = tailOrderingErrors(data);
61
+ if (tail && tail.length > 0)
62
+ snap.ordering_errors_tail = tail;
63
+ if (note)
64
+ snap.note = note;
65
+ return snap;
66
+ }
67
+ /** True when a new snapshot is worth recording (phase/epoch/progress/status change). */
68
+ export function shouldRecordSnapshot(prev, next) {
69
+ if (!prev)
70
+ return true;
71
+ if (prev.status !== next.status)
72
+ return true;
73
+ if (next.note)
74
+ return true;
75
+ if (prev.phase !== next.phase)
76
+ return true;
77
+ if (prev.epoch !== next.epoch)
78
+ return true;
79
+ if (Math.abs(prev.progress_pct - next.progress_pct) >= 1)
80
+ return true;
81
+ if (prev.qe !== next.qe || prev.te !== next.te)
82
+ return true;
83
+ return false;
84
+ }
85
+ export function formatSnapshotLine(s) {
86
+ const parts = [`[+${s.elapsed_sec}s] ${s.status} ${s.progress_pct.toFixed(1)}%`];
87
+ if (s.phase)
88
+ parts.push(`phase ${s.phase}`);
89
+ if (s.epoch != null && s.total_epochs != null)
90
+ parts.push(`epoch ${s.epoch}/${s.total_epochs}`);
91
+ if (s.qe != null)
92
+ parts.push(`QE ${s.qe.toFixed(4)}`);
93
+ if (s.te != null)
94
+ parts.push(`TE ${s.te.toFixed(4)}`);
95
+ if (s.eta_sec != null)
96
+ parts.push(`ETA ~${s.eta_sec}s`);
97
+ if (s.ordering_errors_tail?.length) {
98
+ parts.push(`ordering_errors tail [${s.ordering_errors_tail.map((x) => x.toFixed(4)).join(", ")}]`);
99
+ }
100
+ if (s.note)
101
+ parts.push(s.note);
102
+ return parts.join(" | ");
103
+ }
104
+ export function formatMonitorText(result, opts) {
105
+ const lines = [
106
+ `Job ${result.job_id} monitor (block_until=${opts.block_until_sec}s, poll=${opts.poll_interval_sec}s):`,
107
+ ];
108
+ for (const s of result.snapshots)
109
+ lines.push(formatSnapshotLine(s));
110
+ lines.push("");
111
+ if (result.timed_out) {
112
+ lines.push(`Timed out before terminal state. Last status: ${result.status_text}`);
113
+ lines.push(`Re-run jobs(action=monitor, job_id="${result.job_id}") or training_monitor(job_id="${result.job_id}").`);
114
+ }
115
+ else {
116
+ lines.push(`Terminal: ${result.status_text}`);
117
+ }
118
+ lines.push(result.suggested_next_step);
119
+ return lines.join("\n");
120
+ }
121
+ function suggestedNextStep(job_id, data) {
122
+ const status = String(data.status ?? "");
123
+ if (status === "completed") {
124
+ const finalizeId = str(data, "finalize_job_id");
125
+ if (finalizeId) {
126
+ return `Compute done; finalize_training may still be running. Re-run jobs(action=monitor, job_id="${job_id}") with wait_finalize=true, then results(action=get, job_id="${job_id}").`;
127
+ }
128
+ return `Next: results(action=get, job_id="${job_id}") or results_explorer(job_id="${job_id}").`;
129
+ }
130
+ if (status === "failed") {
131
+ const stage = str(data, "failure_stage");
132
+ return `Job failed${stage ? ` at ${stage}` : ""}. Read the error above before retrying.`;
133
+ }
134
+ if (status === "cancelled")
135
+ return `Job cancelled. Confirm with jobs(action=status, job_id="${job_id}").`;
136
+ return `Still running — re-run jobs(action=monitor, job_id="${job_id}") to continue waiting.`;
137
+ }
138
+ async function fetchTrainingLogHint(job_id, data) {
139
+ const status = String(data.status ?? "");
140
+ if (status !== "completed" && status !== "failed")
141
+ return undefined;
142
+ try {
143
+ await apiCall("GET", `/v1/results/${job_id}/training-log`);
144
+ return `Training log available at GET /v1/results/${job_id}/training-log.`;
145
+ }
146
+ catch {
147
+ return undefined;
148
+ }
149
+ }
150
+ export async function monitorJob(job_id, options = {}) {
151
+ const block_until_sec = Math.max(30, options.block_until_sec ?? DEFAULT_BLOCK_UNTIL_SEC);
152
+ const poll_interval_sec = Math.max(MIN_POLL_INTERVAL_SEC, options.poll_interval_sec ?? DEFAULT_POLL_INTERVAL_SEC);
153
+ const wait_finalize = options.wait_finalize !== false;
154
+ const blockMs = block_until_sec * 1000;
155
+ const pollMs = poll_interval_sec * 1000;
156
+ const start = Date.now();
157
+ const snapshots = [];
158
+ let lastSnap = null;
159
+ let data = {};
160
+ let heartbeat = 0;
161
+ while (Date.now() - start < blockMs) {
162
+ data = (await apiCall("GET", `/v1/jobs/${job_id}`));
163
+ const elapsedSec = Math.round((Date.now() - start) / 1000);
164
+ const snap = snapshotFromJob(data, elapsedSec);
165
+ heartbeat += 1;
166
+ const heartbeatDue = heartbeat >= HEARTBEAT_POLLS;
167
+ if (shouldRecordSnapshot(lastSnap, snap) || heartbeatDue) {
168
+ snapshots.push(snap);
169
+ lastSnap = snap;
170
+ heartbeat = 0;
171
+ }
172
+ const status = String(data.status ?? "");
173
+ if (status === "completed" || status === "failed" || status === "cancelled") {
174
+ break;
175
+ }
176
+ await sleep(pollMs);
177
+ }
178
+ const status = String(data.status ?? "");
179
+ const terminal = status === "completed" || status === "failed" || status === "cancelled";
180
+ const timed_out = !terminal;
181
+ if (terminal && status === "completed" && wait_finalize && data.finalize_job_id) {
182
+ const finalizeId = String(data.finalize_job_id);
183
+ const elapsedSec = Math.round((Date.now() - start) / 1000);
184
+ snapshots.push(snapshotFromJob(data, elapsedSec, `finalize_training ${finalizeId} started — waiting for figure render`));
185
+ try {
186
+ const { note } = await pollFinalizeIfPresent(job_id, data, Math.max(0, blockMs - (Date.now() - start)));
187
+ data = await refreshJobAfterFinalize(job_id);
188
+ snapshots.push(snapshotFromJob(data, Math.round((Date.now() - start) / 1000), note ?? `finalize_training ${finalizeId} completed`));
189
+ }
190
+ catch (err) {
191
+ snapshots.push(snapshotFromJob(data, Math.round((Date.now() - start) / 1000), `finalize_training failed: ${err.message}`));
192
+ }
193
+ }
194
+ const statusText = formatJobStatusText(job_id, data);
195
+ const logHint = terminal ? await fetchTrainingLogHint(job_id, data) : undefined;
196
+ let suggested = suggestedNextStep(job_id, data);
197
+ if (logHint)
198
+ suggested += ` ${logHint}`;
199
+ return {
200
+ job_id,
201
+ terminal,
202
+ timed_out,
203
+ snapshots,
204
+ status_text: statusText,
205
+ data,
206
+ suggested_next_step: suggested,
207
+ };
208
+ }
209
+ export async function runJobMonitor(args) {
210
+ const block_until_sec = args.block_until_sec ?? DEFAULT_BLOCK_UNTIL_SEC;
211
+ const poll_interval_sec = args.poll_interval_sec ?? DEFAULT_POLL_INTERVAL_SEC;
212
+ const result = await monitorJob(args.job_id, {
213
+ block_until_sec,
214
+ poll_interval_sec,
215
+ wait_finalize: args.wait_finalize,
216
+ });
217
+ const text = formatMonitorText(result, { block_until_sec, poll_interval_sec });
218
+ return textResult({
219
+ ...result.data,
220
+ monitor: {
221
+ job_id: result.job_id,
222
+ terminal: result.terminal,
223
+ timed_out: result.timed_out,
224
+ snapshots: result.snapshots,
225
+ status_text: result.status_text,
226
+ suggested_next_step: result.suggested_next_step,
227
+ },
228
+ status_text: text,
229
+ });
230
+ }
package/dist/shared.js CHANGED
@@ -26,7 +26,7 @@ export const RETRYABLE_STATUS = new Set([502, 503, 504]);
26
26
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
27
27
  * wrapper version each action requires. Keep in sync with package.json on bump.
28
28
  */
29
- export const CLIENT_VERSION = "0.19.0";
29
+ export const CLIENT_VERSION = "0.20.1";
30
30
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
31
31
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
32
32
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
@@ -611,6 +611,19 @@ export function getCaptionForImage(filename) {
611
611
  return "Projected variable — per-node aggregation on the map.";
612
612
  return "";
613
613
  }
614
+ export const MAX_INLINE_BYTES = parseInt(process.env.BARIVIA_MAX_INLINE_BYTES ?? "8000000", 10);
615
+ let _inlineAttachBytesUsed = 0;
616
+ /** Reset inline image byte budget (call at the start of each MCP tool response). */
617
+ export function resetInlineAttachBudget() {
618
+ _inlineAttachBytesUsed = 0;
619
+ }
620
+ export function inlineAttachBytesUsed() {
621
+ return _inlineAttachBytesUsed;
622
+ }
623
+ /** When true, results(get) should attach every image in summary.files not already inlined. */
624
+ export function shouldFetchAllRemainingFigures(figures) {
625
+ return figures === "all" || figures === "images";
626
+ }
614
627
  export async function tryAttachImage(content, jobId, filename) {
615
628
  // MCP image content only supports raster MIME types (png/jpeg/gif/webp).
616
629
  // PDF and SVG must be retrieved out-of-band via get_result_image.
@@ -624,6 +637,16 @@ export async function tryAttachImage(content, jobId, filename) {
624
637
  }
625
638
  try {
626
639
  const { data: imgBuf } = await apiRawCall(`/v1/results/${jobId}/image/${filename}`);
640
+ const nextTotal = _inlineAttachBytesUsed + imgBuf.length;
641
+ if (nextTotal > MAX_INLINE_BYTES) {
642
+ content.push({
643
+ type: "text",
644
+ text: `Skipped inline ${filename} (${imgBuf.length} bytes would exceed inline budget). ` +
645
+ `Use results(action=download, job_id="${jobId}") or results_explorer(job_id="${jobId}").`,
646
+ });
647
+ return;
648
+ }
649
+ _inlineAttachBytesUsed = nextTotal;
627
650
  content.push({
628
651
  type: "image",
629
652
  data: imgBuf.toString("base64"),
@@ -166,7 +166,7 @@ action=reduce_spectral: Run a pre-training reducer over an ordered block of nume
166
166
  - log_sample: keep k columns at log-spaced indices — SAXS/WAXS & powder diffraction, log-frequency / octave-like audio, attenuation vs wavelength (UV–Vis–IR stacks), depth profiling, chromatography retention ladders — anywhere column order is exponential, logarithmic, or perceptually log-spaced.
167
167
  - uniform_sample: keep k columns at evenly-spaced indices — regularly-sampled time series, frame-by-frame features, evenly-binned histograms.
168
168
  - stats: 6 fixed per-row statistics (mean, std, min, max, skew, integral) — cheap baseline for any sequenced numeric block; k is ignored.
169
- Required params: name (prefix for derived columns), method, columns_block (ordered source column names ≥ 2), k ( 1, < length(columns_block); ignored for stats).
169
+ Required params: name (prefix for derived columns), method, a column selector, k (≥ 1, < #columns; ignored for stats). Column selector — pick a base block then optionally exclude non-frequency columns: base = columns_block (explicit ordered names ≥ 2) OR columns_range ([first, last], server expands the inclusive numeric block — preferred for wide spectra); columns_except removes columns that are NOT frequency data (id/label/target/metadata) from that block (combine with a base), or used alone means "all numeric columns except these". Only frequency columns are reduced; excluded columns remain in the dataset.
170
170
  action=delete: Remove a dataset and all S3 data permanently.
171
171
 
172
172
  BEST FOR: Tabular numeric data. CSV with header required.
@@ -237,7 +237,16 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
237
237
  columns_block: z
238
238
  .array(z.string())
239
239
  .optional()
240
- .describe("action=reduce_spectral: ordered list of source column names representing the block (≥ 2; ≥ k+1 for non-stats methods)"),
240
+ .describe("action=reduce_spectral: explicit ordered list of source frequency/spectral column names (≥ 2; ≥ k+1 for non-stats methods). For a large contiguous block prefer columns_range to avoid sending thousands of names. Mutually exclusive with columns_range."),
241
+ columns_range: z
242
+ .array(z.string())
243
+ .length(2)
244
+ .optional()
245
+ .describe("action=reduce_spectral: [first_column, last_column] — server expands to the inclusive block of numeric columns in dataset order (e.g. [\"wl_188\", \"wl_441\"]). Mutually exclusive with columns_block. If non-frequency columns (id/label/target) fall inside the range, drop them with columns_except."),
246
+ columns_except: z
247
+ .array(z.string())
248
+ .optional()
249
+ .describe("action=reduce_spectral: column names to EXCLUDE from the reduction — the non-frequency columns (id/label/target/metadata) that must NOT be dimensionally reduced. Combine with columns_block or columns_range to subtract them from the block, or use alone to mean 'all numeric columns except these'. Excluded columns stay in the dataset untouched."),
241
250
  k: z
242
251
  .number()
243
252
  .int()
@@ -255,7 +264,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
255
264
  .int()
256
265
  .optional()
257
266
  .describe("action=subset: RNG seed for sample_n (default 42; same seed + dataset reproduces the same sample)."),
258
- }, async ({ action, name, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, expression, options, project_onto_job, aggregation, output_format, method, columns_block, k, sample_n, sample_seed }) => {
267
+ }, async ({ action, name, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, expression, options, project_onto_job, aggregation, output_format, method, columns_block, columns_range, columns_except, k, sample_n, sample_seed }) => {
259
268
  if (action === "upload") {
260
269
  if (!name)
261
270
  throw new Error("datasets(upload) requires name");
@@ -569,16 +578,34 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
569
578
  throw new Error("datasets(reduce_spectral) requires name (prefix for derived columns)");
570
579
  if (!method)
571
580
  throw new Error("datasets(reduce_spectral) requires method: pca | log_sample | uniform_sample | stats");
572
- if (!columns_block || columns_block.length < 2) {
573
- throw new Error("datasets(reduce_spectral) requires columns_block: ordered array of ≥ 2 source column names");
581
+ // Base selector: columns_block OR columns_range (mutually exclusive).
582
+ // columns_except is an independent filter that removes non-frequency
583
+ // columns (id/label/target/metadata) from the base block, so it may be
584
+ // combined with either base — or used alone to mean "all numeric except".
585
+ if (columns_block && columns_range) {
586
+ throw new Error("datasets(reduce_spectral): provide either columns_block or columns_range, not both");
587
+ }
588
+ if (!columns_block && !columns_range && !columns_except) {
589
+ throw new Error("datasets(reduce_spectral) requires a column selector: columns_block (ordered names ≥ 2), columns_range ([first, last]), and/or columns_except (drop non-frequency columns; alone = all numeric except these)");
590
+ }
591
+ if (columns_block && columns_block.length < 2) {
592
+ throw new Error("datasets(reduce_spectral) columns_block must list ≥ 2 source column names");
574
593
  }
575
594
  if (method !== "stats" && (k === undefined || k < 1)) {
576
595
  throw new Error(`datasets(reduce_spectral) requires k ≥ 1 for method '${method}'`);
577
596
  }
578
- if (method !== "stats" && k !== undefined && k >= columns_block.length) {
597
+ // Upper bound on k (k < #columns) is enforced server-side after range/except
598
+ // resolution; for an explicit columns_block with no exclusion we can also check locally.
599
+ if (method !== "stats" && k !== undefined && columns_block && !columns_except && k >= columns_block.length) {
579
600
  throw new Error(`datasets(reduce_spectral) requires k < length(columns_block); got k=${k}, columns_block=${columns_block.length}`);
580
601
  }
581
- const body = { name, method, columns: columns_block };
602
+ const body = { name, method };
603
+ if (columns_block)
604
+ body.columns = columns_block;
605
+ if (columns_range)
606
+ body.column_range = columns_range;
607
+ if (columns_except)
608
+ body.columns_except = columns_except;
582
609
  if (method !== "stats")
583
610
  body.k = k;
584
611
  const data = (await apiCall("POST", `/v1/datasets/${dataset_id}/reduce_spectral`, body));
@@ -2,12 +2,13 @@ import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
3
  import { apiCall, textResult } from "../shared.js";
4
4
  import { formatJobCancelText, formatJobStatusText } from "../job_status_format.js";
5
- import { pollFinalizeIfPresent, refreshJobAfterFinalize } from "../train_finalize.js";
5
+ import { DEFAULT_BLOCK_UNTIL_SEC, DEFAULT_POLL_INTERVAL_SEC, runJobMonitor } from "../job_monitor.js";
6
6
  export const JOBS_DESCRIPTION_BASE = `Manage and inspect jobs (lifecycle only).
7
7
 
8
8
  | Action | Use when |
9
9
  |--------|----------|
10
- | status | Polling after any async submissioncall every 10–15s |
10
+ | monitor | After async submitblocks server-side with 5s snapshots until terminal (preferred for agents) |
11
+ | status | One-shot progress check (manual poll every 10–15s if not using monitor) |
11
12
  | list | Finding job IDs / reviewing pipeline state (optionally filter by dataset_id) |
12
13
  | compare | Picking the best TRAINING run from a set (metrics table). NOT inference(compare), which diffs one dataset's distribution against the training set. |
13
14
  | cancel | Stopping a running or pending job to free the worker |
@@ -15,8 +16,13 @@ export const JOBS_DESCRIPTION_BASE = `Manage and inspect jobs (lifecycle only).
15
16
 
16
17
  To SUBMIT training, use the **train** tool (train(action=map | siom_map | impute | floop_siom)); to score data or run post-training map operations (predict, batch_predict, compare, project_columns, impute_column, transition_flow), use **inference**.
17
18
 
19
+ ASYNC POLLING PROTOCOL (action=monitor):
20
+ - Preferred after train/inference submit: one call blocks until completed/failed or block_until timeout (default ${DEFAULT_BLOCK_UNTIL_SEC}s, poll every ${DEFAULT_POLL_INTERVAL_SEC}s).
21
+ - Snapshots include phase, epoch/total, QE/TE, ETA, ordering_errors tail when live; waits for finalize_training by default.
22
+ - Optional UI: training_monitor(job_id) for embedded charts; jobs(action=monitor) is the headless equivalent.
23
+
18
24
  ASYNC POLLING PROTOCOL (action=status):
19
- - Poll every 10-15 seconds. Do NOT poll faster it wastes context.
25
+ - One-shot check only. If not using monitor, poll every 10-15 seconds manually.
20
26
  - Progress increases through phases (ordering then convergence for map training); other job types differ.
21
27
  - For large grids (40×40+), do not assume failure before 3 minutes on CPU.
22
28
  - Map training typical times: 10×10 ~30s | 20×20 ~3–5 min | 40×40 ~15–30 min.
@@ -137,12 +143,28 @@ export function renderCompareTable(comparisons) {
137
143
  export function registerJobsTool(server, description) {
138
144
  registerAuditedTool(server, "jobs", description, {
139
145
  action: z
140
- .enum(["status", "list", "compare", "cancel", "delete"])
141
- .describe("status: check progress; list: see all jobs; compare: metrics table; cancel: stop job; delete: remove job + files. (Training is submitted via the train tool; scoring / post-training map ops via inference.)"),
146
+ .enum(["status", "monitor", "list", "compare", "cancel", "delete"])
147
+ .describe("status: one-shot check; monitor: block until terminal with snapshots; list/compare/cancel/delete as labeled. (Training via train; scoring via inference.)"),
142
148
  job_id: z
143
149
  .string()
144
150
  .optional()
145
- .describe("Job ID — required for action=status, cancel, delete."),
151
+ .describe("Job ID — required for action=status, monitor, cancel, delete."),
152
+ block_until_sec: z
153
+ .number()
154
+ .int()
155
+ .min(30)
156
+ .optional()
157
+ .describe(`action=monitor only: max wait seconds (default ${DEFAULT_BLOCK_UNTIL_SEC})`),
158
+ poll_interval_sec: z
159
+ .number()
160
+ .int()
161
+ .min(5)
162
+ .optional()
163
+ .describe(`action=monitor only: poll interval seconds (default ${DEFAULT_POLL_INTERVAL_SEC})`),
164
+ wait_finalize: z
165
+ .boolean()
166
+ .optional()
167
+ .describe("action=monitor only: wait for finalize_training (default true)"),
146
168
  job_ids: z
147
169
  .array(z.string())
148
170
  .optional()
@@ -152,24 +174,17 @@ export function registerJobsTool(server, description) {
152
174
  .optional()
153
175
  .describe("action=list: filter jobs by this dataset ID."),
154
176
  }, async (args) => {
155
- const { action, job_id, job_ids, dataset_id } = args;
177
+ const { action, job_id, job_ids, dataset_id, block_until_sec, poll_interval_sec, wait_finalize } = args;
156
178
  // ---- Lifecycle ----
179
+ if (action === "monitor") {
180
+ if (!job_id)
181
+ throw new Error("jobs(monitor) requires job_id");
182
+ return runJobMonitor({ job_id, block_until_sec, poll_interval_sec, wait_finalize });
183
+ }
157
184
  if (action === "status") {
158
185
  if (!job_id)
159
186
  throw new Error("jobs(status) requires job_id");
160
- let data = (await apiCall("GET", `/v1/jobs/${job_id}`));
161
- const status = String(data.status ?? "");
162
- if (status === "completed" && data.finalize_job_id) {
163
- const { note } = await pollFinalizeIfPresent(job_id, data);
164
- data = await refreshJobAfterFinalize(job_id);
165
- const text = formatJobStatusText(job_id, data);
166
- return {
167
- content: [{
168
- type: "text",
169
- text: note ? `${text}\n${note}` : text,
170
- }],
171
- };
172
- }
187
+ const data = (await apiCall("GET", `/v1/jobs/${job_id}`));
173
188
  return { content: [{ type: "text", text: formatJobStatusText(job_id, data) }] };
174
189
  }
175
190
  if (action === "list") {
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import fs from "node:fs/promises";
3
3
  import { z } from "zod";
4
4
  import { registerAuditedTool } from "../audit.js";
5
- import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, pollUntilComplete, tryAttachImage, getResultsImagesToFetch, getCaptionForImage, mimeForFilename, } from "../shared.js";
5
+ import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, pollUntilComplete, tryAttachImage, resetInlineAttachBudget, getResultsImagesToFetch, getCaptionForImage, shouldFetchAllRemainingFigures, mimeForFilename, } from "../shared.js";
6
6
  export function registerResultsTool(server) {
7
7
  registerAuditedTool(server, "results", `Retrieve, recolor, download, or export figures and metrics for a completed map job. Presentation and artifact access only — operations on the frozen map (predict, compare, project, impute_column, transition_flow) live in **inference**.
8
8
 
@@ -87,6 +87,7 @@ NOT FOR: Jobs that haven't completed (use jobs(action=status) first), or tempora
87
87
  }, async ({ action, job_id, figures, include_individual, include_json, folder, colormap, output_format, output_dpi, recolor_figures, export_type: exportType }) => {
88
88
  const dpiMap = { standard: 1, retina: 2, print: 4 };
89
89
  if (action === "get") {
90
+ resetInlineAttachBudget();
90
91
  const data = (await apiCall("GET", `/v1/results/${job_id}`));
91
92
  const summary = (data.summary ?? {});
92
93
  const jobLabel = data.label != null && data.label !== "" ? String(data.label) : null;
@@ -453,13 +454,14 @@ NOT FOR: Jobs that haven't completed (use jobs(action=status) first), or tempora
453
454
  // figures="none": metrics-only mode. Skip any further image attaches and the
454
455
  // "Available figures…" hint; keep the structured-data export hint since it
455
456
  // points at non-image artifacts.
456
- if (figures !== "none") {
457
+ if (shouldFetchAllRemainingFigures(figures)) {
457
458
  for (const fname of files) {
458
459
  if (isImage(fname) && !inlinedImages.has(fname)) {
459
460
  const cap = getCaptionForImage(fname);
460
461
  if (cap)
461
462
  content.push({ type: "text", text: cap });
462
463
  await tryAttachImage(content, job_id, fname);
464
+ inlinedImages.add(fname);
463
465
  }
464
466
  }
465
467
  }
@@ -54,7 +54,7 @@ async function enrichWithTrainingLog(job_id, data) {
54
54
  export function registerTrainingMonitorTool(server) {
55
55
  registerAppTool(server, "training_monitor", {
56
56
  title: "Training Monitor",
57
- description: "Optional embedded or standalone view of training progress for a job_id. When embedded in a client that supports MCP Apps, the panel auto-refreshes every 5s until the job completes: live QE/TE learning curves plus a per-epoch hit-distribution heatmap (sampled BMU counts across the map, showing where observations are landing as training proceeds). A standalone browser URL is also available — its localhost port is per MCP session and goes stale if the proxy restarts (set BARIVIA_VIZ_PORT for a persistent port; re-run this tool for a fresh link). The same scalar fields are available via jobs(action=status)—not required to complete training or read results.",
57
+ description: "Optional embedded or standalone view of training progress for a job_id. When embedded in a client that supports MCP Apps, the panel auto-refreshes every 5s until the job completes: live QE/TE learning curves (uniformly subsampled to ≤1000 batch samples per phase, not one point per epoch) plus a per-epoch hit-distribution heatmap (sampled BMU counts across the map, showing where observations are landing as training proceeds). A standalone browser URL is also available — its localhost port is per MCP session and goes stale if the proxy restarts (set BARIVIA_VIZ_PORT for a persistent port; re-run this tool for a fresh link). The same scalar fields are available via jobs(action=status)—not required to complete training or read results.",
58
58
  inputSchema: {
59
59
  job_id: z.string().describe("Training job ID to monitor"),
60
60
  fetch_training_log: z
@@ -0,0 +1,30 @@
1
+ /** Batch-sample x-axis helpers for the training_monitor MCP App chart. */
2
+ export function batchSampleAxis(n, offset = 0) {
3
+ if (n <= 0)
4
+ return [];
5
+ return Array.from({ length: n }, (_, i) => offset + i + 1);
6
+ }
7
+ export function combinedBatchAxis(nOrd, nConv) {
8
+ if (nOrd <= 0 && nConv <= 0)
9
+ return [];
10
+ return [...batchSampleAxis(nOrd, 0), ...batchSampleAxis(nConv, nOrd)];
11
+ }
12
+ export function formatCurveSourceNote(data) {
13
+ const src = data.training_curve_source_batches;
14
+ if (!src)
15
+ return null;
16
+ const parts = [];
17
+ const ordTotal = typeof src.ordering === "number" ? src.ordering : null;
18
+ const convTotal = typeof src.convergence === "number" ? src.convergence : null;
19
+ const ordShown = Array.isArray(data.ordering_errors) ? data.ordering_errors.length : 0;
20
+ const convShown = Array.isArray(data.convergence_errors) ? data.convergence_errors.length : 0;
21
+ if (ordTotal != null && ordTotal > ordShown) {
22
+ parts.push(`${ordShown} of ${ordTotal.toLocaleString()} ordering batch samples`);
23
+ }
24
+ if (convTotal != null && convTotal > convShown) {
25
+ parts.push(`${convShown} of ${convTotal.toLocaleString()} convergence batch samples`);
26
+ }
27
+ if (parts.length === 0)
28
+ return null;
29
+ return `Displaying uniformly subsampled curves (≤1000 points/phase): ${parts.join("; ")}.`;
30
+ }