@barivia/barsom-mcp 0.22.4 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/audit.js CHANGED
@@ -110,10 +110,12 @@ function stripAuditMeta(result) {
110
110
  function errorCodeFrom(err) {
111
111
  if (err && typeof err === "object") {
112
112
  const e = err;
113
+ if (e.errorCode)
114
+ return e.errorCode;
113
115
  if (e.httpStatus !== undefined)
114
116
  return `http_${e.httpStatus}`;
115
117
  const m = e.message ?? "";
116
- const codeMatch = m.match(/error_code:\s*(\w+)/);
118
+ const codeMatch = m.match(/error_code[=:]\s*(\w+)/);
117
119
  if (codeMatch)
118
120
  return codeMatch[1];
119
121
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Local offline queue for send_feedback when the API or object storage is down.
3
+ * Persists under ~/.barivia/deferred-feedback (or BARIVIA_FEEDBACK_QUEUE_DIR).
4
+ * Proxies flush the queue on the next successful feedback submission.
5
+ */
6
+ import fs from "node:fs/promises";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ export function deferredFeedbackQueueDir() {
11
+ const override = process.env.BARIVIA_FEEDBACK_QUEUE_DIR?.trim();
12
+ if (override)
13
+ return path.resolve(override);
14
+ return path.join(os.homedir(), ".barivia", "deferred-feedback");
15
+ }
16
+ async function ensureQueueDir() {
17
+ const dir = deferredFeedbackQueueDir();
18
+ await fs.mkdir(dir, { recursive: true });
19
+ return dir;
20
+ }
21
+ function entryPath(dir, id) {
22
+ return path.join(dir, `${id}.json`);
23
+ }
24
+ export async function enqueueDeferredFeedback(product, body, lastError) {
25
+ const dir = await ensureQueueDir();
26
+ const id = randomUUID();
27
+ const entry = {
28
+ id,
29
+ product,
30
+ created_at: new Date().toISOString(),
31
+ body,
32
+ ...(lastError ? { last_error: lastError } : {}),
33
+ };
34
+ const filePath = entryPath(dir, id);
35
+ await fs.writeFile(filePath, JSON.stringify(entry, null, 2), "utf8");
36
+ return { id, path: filePath };
37
+ }
38
+ export async function listDeferredFeedback(product) {
39
+ const dir = deferredFeedbackQueueDir();
40
+ let names;
41
+ try {
42
+ names = await fs.readdir(dir);
43
+ }
44
+ catch (err) {
45
+ if (err.code === "ENOENT")
46
+ return [];
47
+ throw err;
48
+ }
49
+ const out = [];
50
+ for (const name of names) {
51
+ if (!name.endsWith(".json"))
52
+ continue;
53
+ try {
54
+ const raw = await fs.readFile(path.join(dir, name), "utf8");
55
+ const entry = JSON.parse(raw);
56
+ if (product && entry.product !== product)
57
+ continue;
58
+ out.push(entry);
59
+ }
60
+ catch {
61
+ /* skip corrupt */
62
+ }
63
+ }
64
+ out.sort((a, b) => a.created_at.localeCompare(b.created_at));
65
+ return out;
66
+ }
67
+ /**
68
+ * Attempt to submit each queued entry via `submit`. Successful entries are deleted.
69
+ * Failures leave the file in place for a later retry.
70
+ */
71
+ export async function flushDeferredFeedback(product, submit) {
72
+ const entries = await listDeferredFeedback(product);
73
+ let flushed = 0;
74
+ const errors = [];
75
+ const dir = deferredFeedbackQueueDir();
76
+ for (const entry of entries) {
77
+ try {
78
+ await submit(entry.body);
79
+ await fs.unlink(entryPath(dir, entry.id)).catch(() => undefined);
80
+ flushed += 1;
81
+ }
82
+ catch (err) {
83
+ errors.push(err instanceof Error ? err.message : String(err));
84
+ }
85
+ }
86
+ const remaining = (await listDeferredFeedback(product)).length;
87
+ return { flushed, remaining, errors };
88
+ }
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 a}from"./viz-server.js";import{API_KEY as i,apiCall as l,apiRawCall as p,loadViewHtml as c,setVizPort as u,setClientSupportsMcpApps as d,CLIENT_VERSION as m}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 y,RESULTS_EXPLORER_URI as w}from"./tools/explore_map.js";import{registerAccountTool as v}from"./tools/account.js";import{registerInferenceTool as j}from"./tools/inference.js";import{registerGuideBarsomTool as x}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as k}from"./tools/training_guidance.js";import{registerFeedbackTool as P}from"./tools/feedback.js";import{registerTrainingMonitorTool as A,TRAINING_MONITOR_URI as I}from"./tools/training_monitor.js";import{resolvePrepareTrainingPrompt as C}from"./prepare_training_prompt.js";i||(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:m,instructions:'# Barivia Mapping Analytics Engine\n\nSelf-organizing map (SOM) analytics: project high-dimensional data to a 2D grid for clusters, gradients, and anomalies.\n\n## Workflow (short)\n\nUpload (`datasets(upload)`) → `datasets(preview)` and `datasets(analyze)` before train → submit one of `train(action=map)`, `train(action=siom_map)`, or `train(action=floop_siom)` (only if plan allows FLooP) → poll `jobs(status)` every 10–15s until `completed` → `results(get)` for metrics and figures (there is no separate analyze tool), then `results_explorer(job_id)` to browse the figures interactively. Then `jobs(compare)`, `results(download/recolor)`, or `inference` (predict / compare / project_columns / impute_column / transition_flow) as needed.\n\n**Full detail:** Call `guide_barsom_workflow` for plan-scoped tool map, training modes, async rules, optional MCP App UIs, and step-by-step SOP (from the Barivia API when online).\n\n## Tool taxonomy (one tool per stage — do not mix)\n\n| Stage | Tool(s) | Contract |\n|-------|---------|----------|\n| Data | `datasets` | upload, preview, analyze, list, **get** (status/staging), subset, add_expression, reduce_spectral, delete — ingest + feature engineering, no map |\n| Train submit | `train` | map, siom_map, impute (sparse → map + dense imputed.csv), floop_siom (entitled). Async, returns job_id. Submit only |\n| Lifecycle | `jobs` | status, list, compare (**training runs**), cancel, delete. No training/scoring here |\n| Artifacts | `results` | get (default=combined only; figures="none" for metrics-only sweeps; figures="all" or results_explorer/download for every plot), export, download, recolor (async). Read/render completed jobs |\n| Frozen-map ops | `inference` | predict (regime-aware; "compact"|"annotated"), batch_predict, impute_column (neighbor-pool fill for a non-training column), compare (cohort density-diff), project_columns, transition_flow (time-ordered rows only), report. No weight updates |\n| Account | `account` | status, history, add_funds |\n| Bootstrap | `guide_barsom_workflow`, `train`, `training_guidance`, `prepare_training` prompt | orientation, submit map/siom/impute/floop, parameter hints, narrative checklist (API/tier-scoped) |\n| Explore (UI) | `results_explorer`, `training_monitor` | richer UX over the same API; `jobs(status)`+`results(get)` still suffice headless |\n| Other | `send_feedback` | only after user agrees |\n\n### Avoid these mis-routes\n- `jobs(compare)` = metrics table across **training runs** (pick best in a sweep). `inference(compare)` = density-diff of one map vs a **second dataset** (drift/cohort). Different tools.\n- Post-training map analysis (predict, compare, project_columns, impute_column, **transition_flow**) is all **inference** — not `results`. `results` is presentation/artifacts only (`recolor` stays here: it re-renders, it doesn\'t touch data).\n- Project a quantity onto the map: existing columns → `inference(project_columns)`; a formula → `datasets(add_expression, project_onto_job=<job>)`.\n\n## Async pattern\n\n- **Manual poll:** Training submits return `job_id` immediately — poll `jobs(status)` every 10–15s. **Running is not failed**; large grids or FLooP-SIOM can take many minutes. `max_nodes` (FLooP) is a total node budget, not grid side length.\n- **Often auto-polled:** `inference` actions (incl. `transition_flow`) and `results(recolor)` may wait in-proxy; if you get a `job_id`, poll `jobs(status)` the same way.\n\nCredits: jobs consume compute credits; check `account(status)` before big runs. Per-request HTTP timeout defaults to 60s (`datasets(analyze)` is async + auto-polled, so it is not bound by it); on slow networks raise `BARIVIA_FETCH_TIMEOUT_MS`. Timeouts are NOT auto-retried (avoids duplicating slow/expensive work).\n\n## Constraints\n\n- **Do not auto-subset:** Never call `datasets(subset)` or `sample_n` unless the user explicitly asks for a filter, slice, or random sample. Train and analyze on the uploaded `dataset_id` at full row count — GPU staging/prepare handles hundreds of thousands of rows quickly; silent downsampling hides real prepare/train behavior and wastes scale demos. Use small named fixtures (e.g. sample.csv, periodic_load) only when the user or runbook names them.\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.\n\n## UI delivery (heterogeneous MCP clients)\n\nHosts differ (Cursor, Claude Desktop, CLI agents): some embed MCP App panels; others list `ui://` but never mount them (expected on some Cursor builds); others are text-only. Tool results **always lead with a standalone localhost URL** — that is the intended fallback, not a broken App.\n\n| Tier | When | Agent should |\n|------|------|--------------|\n| **embedded** | Client advertises MCP Apps | Let the panel render **and** surface the standalone localhost URL from tool output / `ui_delivery.urls` (hosts often list Apps but do not mount). |\n| **localhost** | No MCP Apps; viz server running | Post the markdown link from tool output to the user (never broken workspace paths). Re-post after job completes if needed. |\n| **inline_image** | No Apps / override `inline` | Summarize metrics; inline raster (combined.png, learning curve) is attached automatically — show it in the reply. |\n| **text_only** | Viz server down | Use `jobs(status)` + `results(get)`; no panel or localhost link. |\n\nEvery `training_monitor` / `results_explorer` response includes a structured `ui_delivery` block (`delivery`, `urls`, `hint_for_agent`). Follow `hint_for_agent` without user prompting. Standalone viz pages cross-link monitor ↔ results for the same `job_id`.\n\n**Env overrides:** `BARIVIA_UI_DELIVERY=auto|apps|localhost|inline` (default `auto`). `BARIVIA_VIZ_PORT` pins the localhost viz port across proxy restarts. **Diagnostics:** `account(action=status)` reports detected MCP Apps support, viz port, and active tier.'});s(M,w,w,{mimeType:n},async()=>{const e=await c("results-explorer");return{contents:[{uri:w,mimeType:n,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),s(M,I,I,{mimeType:n},async()=>{const e=await c("training-monitor");return{contents:[{uri:I,mimeType:n,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),x(M),y(M),A(M),f(M),g(M),h(M,_),b(M),v(M),j(M),k(M),P(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 auto-subset — never call `datasets(subset)` / `sample_n` unless the user explicitly asks; train on the full uploaded `dataset_id`. 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})=>{const t=await C(e);return{messages:[{role:"user",content:{type:"text",text:(t.used_fallback?`[offline fallback ${t.fallback_version}]\n\n`:"")+t.text}}]}});const T=new t;(async function(){try{const e=await a(l,p,c);u(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(T)})().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 s,registerAppResource as r,RESOURCE_MIME_TYPE as n}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as a}from"./viz-server.js";import{API_KEY as i,apiCall as l,apiRawCall as p,loadViewHtml as c,setVizPort as u,setClientSupportsMcpApps as d,CLIENT_VERSION as m}from"./shared.js";import{registerDatasetsTool as f}from"./tools/datasets.js";import{registerTrainTool as h}from"./tools/train.js";import{registerJobsTool as g,JOBS_DESCRIPTION_BASE as b}from"./tools/jobs.js";import{registerResultsTool as _}from"./tools/results.js";import{registerExploreMapTool as y,RESULTS_EXPLORER_URI as w}from"./tools/explore_map.js";import{registerAccountTool as v}from"./tools/account.js";import{registerInferenceTool as j}from"./tools/inference.js";import{registerGuideBarsomTool as k}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as x}from"./tools/training_guidance.js";import{registerFeedbackTool as P}from"./tools/feedback.js";import{registerTrainingMonitorTool as A,TRAINING_MONITOR_URI as I}from"./tools/training_monitor.js";import{resolvePrepareTrainingPrompt as C}from"./prepare_training_prompt.js";i||(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:m,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 (slim + limit/cursor/status/job_type/has_results), update (label/description/tags), 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. Results catalog = `jobs(list, has_results=true)` |\n| Frozen-map ops | `inference` | predict (regime-aware; "compact"|"annotated"), batch_predict, impute_column (neighbor-pool fill for a non-training column), compare (cohort density-diff), project_columns, transition_flow (time-ordered rows only), report. No weight updates |\n| Account | `account` | status, history, **inventory** (markdown datasets+jobs overview), add_funds, **health** (API liveness via GET /health — no R2) |\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; queues locally if API/storage is down |\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## Org inventory recipe\n\nTo rebuild a durable markdown overview of an org (datasets + jobs):\n1. `account(action=inventory)` — preferred one-shot (datasets name/id/rows/cols/description/tags + slim recent jobs).\n2. Or compose: `datasets(list)` + `jobs(action=list, limit=50)` (page with `cursor` / filter with `status` / `job_type` / `has_results=true` for a results catalog).\n3. Set `description` + `tags` on `datasets(upload|update)` and `train(...)` / `jobs(update)` so duplicates stay navigable.\n\n## API briefly unavailable (pause, do not crash)\nIf tools return `api_unreachable` / gateway HTML / slim `error_code` lines: summarize in **one calm sentence** — never paste Cloudflare HTML. The proxy already burst-retried (**3** tries, ~**2s** apart); honor `retry_after_sec` (~**30**) before another round. Use `account(action=health)` (no R2). Local downloads and prior results stay usable; `send_feedback` queues under `~/.barivia/deferred-feedback` and flushes when the API returns. Do not start large uploads/trains until `account(status)` or health looks healthy again.\n\n## Constraints\n\n- **Do not auto-subset:** Never call `datasets(subset)` or `sample_n` unless the user explicitly asks for a filter, slice, or random sample. Train and analyze on the uploaded `dataset_id` at full row count — GPU staging/prepare handles hundreds of thousands of rows quickly; silent downsampling hides real prepare/train behavior and wastes scale demos. Use small named fixtures (e.g. sample.csv, periodic_load) only when the user or runbook names them.\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.\n\n## UI delivery (heterogeneous MCP clients)\n\nHosts differ (Cursor, Claude Desktop, CLI agents): some embed MCP App panels; others list `ui://` but never mount them (expected on some Cursor builds); others are text-only. Tool results **always lead with a standalone localhost URL** — that is the intended fallback, not a broken App.\n\n| Tier | When | Agent should |\n|------|------|--------------|\n| **embedded** | Client advertises MCP Apps | Let the panel render **and** surface the standalone localhost URL from tool output / `ui_delivery.urls` (hosts often list Apps but do not mount). |\n| **localhost** | No MCP Apps; viz server running | Post the markdown link from tool output to the user (never broken workspace paths). Re-post after job completes if needed. |\n| **inline_image** | No Apps / override `inline` | Summarize metrics; inline raster (combined.png, learning curve) is attached automatically — show it in the reply. |\n| **text_only** | Viz server down | Use `jobs(status)` + `results(get)`; no panel or localhost link. |\n\nEvery `training_monitor` / `results_explorer` response includes a structured `ui_delivery` block (`delivery`, `urls`, `hint_for_agent`). Follow `hint_for_agent` without user prompting. Standalone viz pages cross-link monitor ↔ results for the same `job_id`.\n\n**Env overrides:** `BARIVIA_UI_DELIVERY=auto|apps|localhost|inline` (default `auto`). `BARIVIA_VIZ_PORT` pins the localhost viz port across proxy restarts. **Diagnostics:** `account(action=status)` reports detected MCP Apps support, viz port, and active tier.'});r(T,w,w,{mimeType:n},async()=>{const e=await c("results-explorer");return{contents:[{uri:w,mimeType:n,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),r(T,I,I,{mimeType:n},async()=>{const e=await c("training-monitor");return{contents:[{uri:I,mimeType:n,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),k(T),y(T),A(T),f(T),h(T),g(T,b),_(T),v(T),j(T),x(T),P(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 auto-subset — never call `datasets(subset)` / `sample_n` unless the user explicitly asks; train on the full uploaded `dataset_id`. Do not call `_fetch_figure` from chat (host/UI only); use `results(get)` or `results_explorer`.","","Offer `send_feedback` only after asking the user."].join("\n")}}]})),T.prompt("prepare_training","Narrative pre-training checklist (prompt). Use after upload and before train. Content is tier-scoped from the API when online. Prep ladder: this prompt = story checklist; training_guidance tool = JSON presets/parameter hints. Review, then submit with train.",{dataset_id:o.string().describe("Dataset ID to prepare for training")},async({dataset_id:e})=>{const t=await C(e);return{messages:[{role:"user",content:{type:"text",text:(t.used_fallback?`[offline fallback ${t.fallback_version}]\n\n`:"")+t.text}}]}});const M=new t;(async function(){try{const e=await a(l,p,c);u(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=s(t);d(!!o?.mimeTypes?.includes(n))},await T.connect(M)})().catch(console.error);
@@ -0,0 +1,69 @@
1
+ /** Shared markdown inventory formatting for account(inventory) / jobs list. */
2
+ export function formatTags(tags) {
3
+ if (!Array.isArray(tags) || tags.length === 0)
4
+ return "";
5
+ return tags.map((t) => String(t)).filter(Boolean).join(", ");
6
+ }
7
+ export function formatDatasetInventoryLine(ds) {
8
+ const id = String(ds.id ?? "");
9
+ const name = String(ds.name ?? "");
10
+ const rows = ds.rows != null ? Number(ds.rows) : "?";
11
+ const cols = ds.cols != null ? Number(ds.cols) : "?";
12
+ const st = ds.status != null ? String(ds.status) : "ready";
13
+ const statusBit = st !== "ready" ? ` | status=${st}` : "";
14
+ const desc = ds.description != null && String(ds.description).trim() !== ""
15
+ ? ` — ${String(ds.description).trim()}`
16
+ : "";
17
+ const tags = formatTags(ds.tags);
18
+ const tagsBit = tags ? ` [${tags}]` : "";
19
+ return `${name} (${id}) — ${rows}×${cols}${statusBit}${tagsBit}${desc}`;
20
+ }
21
+ export function formatJobInventoryLine(job) {
22
+ const id = String(job.id ?? "");
23
+ const st = String(job.status ?? "");
24
+ const label = job.label != null && job.label !== "" ? String(job.label) : null;
25
+ const jt = job.job_type != null ? String(job.job_type) : "?";
26
+ const ds = job.dataset_id != null ? String(job.dataset_id) : "";
27
+ const created = job.created_at != null ? String(job.created_at) : "";
28
+ const result = job.result_ref != null && String(job.result_ref) !== "" ? "yes" : "no";
29
+ const tags = formatTags(job.tags);
30
+ const tagsBit = tags ? ` [${tags}]` : "";
31
+ const desc = job.description != null && String(job.description).trim() !== ""
32
+ ? ` — ${String(job.description).trim()}`
33
+ : "";
34
+ const head = label ? `${label} (id: ${id})` : `id: ${id}`;
35
+ return `${head} — ${st} | type=${jt} | dataset=${ds} | created=${created} | result=${result}${tagsBit}${desc}`;
36
+ }
37
+ export function extractJobsList(data) {
38
+ if (Array.isArray(data)) {
39
+ return { jobs: data, next_cursor: null };
40
+ }
41
+ if (data && typeof data === "object") {
42
+ const obj = data;
43
+ const jobs = Array.isArray(obj.jobs) ? obj.jobs : [];
44
+ const next = obj.next_cursor != null && String(obj.next_cursor) !== ""
45
+ ? String(obj.next_cursor)
46
+ : null;
47
+ return { jobs, next_cursor: next };
48
+ }
49
+ return { jobs: [], next_cursor: null };
50
+ }
51
+ export function buildJobsListQuery(opts) {
52
+ const qs = new URLSearchParams();
53
+ if (opts.dataset_id)
54
+ qs.set("dataset_id", opts.dataset_id);
55
+ if (opts.status)
56
+ qs.set("status", opts.status);
57
+ if (opts.job_type)
58
+ qs.set("job_type", opts.job_type);
59
+ if (opts.has_results)
60
+ qs.set("has_results", "true");
61
+ if (opts.limit != null)
62
+ qs.set("limit", String(opts.limit));
63
+ if (opts.cursor)
64
+ qs.set("cursor", opts.cursor);
65
+ if (opts.fields)
66
+ qs.set("fields", opts.fields);
67
+ const s = qs.toString();
68
+ return s ? `/v1/jobs?${s}` : "/v1/jobs";
69
+ }
package/dist/shared.js CHANGED
@@ -20,12 +20,16 @@ export const API_URL = process.env.BARIVIA_API_URL ??
20
20
  "https://api.barivia.se";
21
21
  export const API_KEY = process.env.BARIVIA_API_KEY ?? process.env.BARSOM_API_KEY ?? "";
22
22
  export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "60000", 10);
23
- export const MAX_RETRIES = 2;
24
- export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "1000", 10);
23
+ /** Retries after the first attempt; default 2 → 3 total tries. Override with BARIVIA_MAX_RETRIES. */
24
+ export const MAX_RETRIES = parseInt(process.env.BARIVIA_MAX_RETRIES ?? "2", 10);
25
+ /** Delay between burst retries (ms). Default 2000 → ~2s between each of the 3 attempts. */
26
+ export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "2000", 10);
25
27
  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;
28
+ /** Cool-down hint after the burst is exhausted agents should wait before another round. */
29
+ export const UNAVAILABLE_RETRY_AFTER_SEC = 30;
30
+ /** Fixed delay between burst retries (not exponential). */
31
+ export function retryDelayMs(_attempt) {
32
+ return RETRY_BASE_MS;
29
33
  }
30
34
  function newRequestId() {
31
35
  return randomUUID();
@@ -35,7 +39,7 @@ function newRequestId() {
35
39
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
36
40
  * wrapper version each action requires. Keep in sync with package.json on bump.
37
41
  */
38
- export const CLIENT_VERSION = "0.22.4";
42
+ export const CLIENT_VERSION = "0.23.0";
39
43
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
40
44
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
41
45
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
@@ -374,52 +378,237 @@ export async function resolveFilePathForUpload(filePath, mcpServer) {
374
378
  `"C:\\\\Users\\\\you\\\\data.csv") or a file:// URI. For relative paths, set BARIVIA_WORKSPACE_ROOT in your MCP config.`);
375
379
  }
376
380
  }
377
- /** User-visible API error line (includes request id for support). Exported for tests. */
378
- export function formatApiErrorMessage(status, bodyText, requestId) {
381
+ function looksLikeHtml(bodyText) {
382
+ const t = bodyText.trim().slice(0, 200).toLowerCase();
383
+ return t.startsWith("<!doctype") || t.startsWith("<html") || /<\s*html[\s>]/.test(t);
384
+ }
385
+ function isStorageErrorCode(code) {
386
+ if (!code)
387
+ return false;
388
+ const c = code.toLowerCase();
389
+ return c === "storage_upload_failed" || (c.includes("storage") && !c.includes("unavailable"));
390
+ }
391
+ /**
392
+ * Classify an HTTP API failure into a compact structured error.
393
+ * Gateway HTML 502/503 bodies are never echoed — they become api_unreachable.
394
+ */
395
+ export function classifyApiError(status, bodyText, requestId) {
379
396
  let parsed = null;
380
- try {
381
- const j = JSON.parse(bodyText);
382
- if (j && typeof j === "object")
383
- parsed = j;
384
- }
385
- catch {
386
- /* ignore */
397
+ const html = looksLikeHtml(bodyText);
398
+ if (!html && bodyText.trim()) {
399
+ try {
400
+ const j = JSON.parse(bodyText);
401
+ if (j && typeof j === "object")
402
+ parsed = j;
403
+ }
404
+ catch {
405
+ /* non-JSON */
406
+ }
387
407
  }
388
- const detail = (parsed?.error != null && String(parsed.error)) ||
389
- (bodyText.trim() ? bodyText.trim() : `HTTP ${status}`);
390
- const code = parsed?.error_code != null ? ` (error_code: ${parsed.error_code})` : "";
408
+ const apiCode = parsed?.error_code != null ? String(parsed.error_code) : "";
409
+ const apiMsg = parsed?.error != null && String(parsed.error).trim()
410
+ ? String(parsed.error).trim()
411
+ : "";
412
+ let cause = "server";
413
+ let error_code = apiCode || `http_${status}`;
414
+ let message = apiMsg || `HTTP ${status}`;
415
+ let hint = "";
416
+ let retryable = status === 502 || status === 503 || status === 504;
391
417
  const accountHint = ` Check your email or manage your key at ${PUBLIC_DASHBOARD_URL} if needed.`;
392
- const hint = status === 400
393
- ? " Check parameter types and required fields."
394
- : status === 401
395
- ? ` Check BARIVIA_API_KEY in your MCP config.${accountHint}`
396
- : status === 403
397
- ? ` Access denied for this operation (plan or permissions).${accountHint}`
398
- : status === 402
399
- ? ` Credits or subscription may be insufficient — upgrade at ${PUBLIC_DASHBOARD_URL}.`
400
- : status === 404
401
- ? " The resource may not exist or may have been deleted."
402
- : status === 409
403
- ? " The job may not be in the expected state."
404
- : status === 429
405
- ? " Plan limit (e.g. dataset cap) or rate limit — read the error above; delete unused datasets or wait and retry."
406
- : status === 502
407
- ? " Object storage (R2/S3) error from API — retry later."
408
- : status === 503
409
- ? parsed?.error_code === "system_info_unavailable"
410
- ? " System info (plan/queue) temporarily unavailable — retry later."
411
- : " API or database temporarily unavailableretry later."
412
- : status >= 500
413
- ? " Server error — retry later."
414
- : "";
415
- const rid = ` (request id: ${requestId} — include if contacting support)`;
416
- return `${detail}${code}${hint}${rid}`;
418
+ if (status === 400) {
419
+ cause = "client";
420
+ hint = "Check parameter types and required fields.";
421
+ retryable = false;
422
+ }
423
+ else if (status === 401) {
424
+ cause = "auth";
425
+ error_code = apiCode || "unauthorized";
426
+ hint = `Check BARIVIA_API_KEY in your MCP config.${accountHint}`;
427
+ retryable = false;
428
+ }
429
+ else if (status === 403) {
430
+ cause = "auth";
431
+ error_code = apiCode || "forbidden";
432
+ hint = `Access denied for this operation (plan or permissions).${accountHint}`;
433
+ retryable = false;
434
+ }
435
+ else if (status === 402) {
436
+ cause = "client";
437
+ hint = `Credits or subscription may be insufficient upgrade at ${PUBLIC_DASHBOARD_URL}.`;
438
+ retryable = false;
439
+ }
440
+ else if (status === 404) {
441
+ cause = "client";
442
+ hint = "The resource may not exist or may have been deleted.";
443
+ retryable = false;
444
+ }
445
+ else if (status === 409) {
446
+ cause = "client";
447
+ hint = "The job may not be in the expected state.";
448
+ retryable = false;
449
+ }
450
+ else if (status === 429) {
451
+ cause = "rate_limit";
452
+ hint = "Plan limit (e.g. dataset cap) or rate limit — delete unused datasets or wait and retry.";
453
+ retryable = true;
454
+ }
455
+ else if (status === 502 || status === 503 || status === 504) {
456
+ if (isStorageErrorCode(apiCode) || /object storage|r2\/s3|storage upload/i.test(apiMsg)) {
457
+ cause = "storage";
458
+ error_code = apiCode || "storage_upload_failed";
459
+ message = apiMsg || "Object storage (R2/S3) error from API.";
460
+ hint = "Retry later; feedback may still succeed via DB when storage is degraded.";
461
+ }
462
+ else if (html || !parsed) {
463
+ cause = "api_unreachable";
464
+ error_code = apiCode || "api_unreachable";
465
+ message = "API temporarily unavailable (gateway/tunnel returned a non-JSON error).";
466
+ hint =
467
+ `Proxy already tried 3 times (~2s apart). Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. ` +
468
+ "Use account(action=health) — /health does not depend on R2. Pause — do not paste HTML.";
469
+ }
470
+ else if (apiCode === "system_info_unavailable") {
471
+ cause = "server";
472
+ message = apiMsg || "System info (plan/queue) temporarily unavailable.";
473
+ hint = `Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
474
+ }
475
+ else if (apiCode === "feedback_unavailable" || apiCode === "database_error") {
476
+ cause = "server";
477
+ error_code = apiCode;
478
+ message = apiMsg || "API or database temporarily unavailable.";
479
+ hint =
480
+ `Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s; send_feedback queues locally if the API stays down.`;
481
+ }
482
+ else {
483
+ cause = "server";
484
+ message = apiMsg || `HTTP ${status}`;
485
+ hint = `API or database temporarily unavailable — wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
486
+ }
487
+ }
488
+ else if (status >= 500) {
489
+ cause = "server";
490
+ hint = `Server error — wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
491
+ retryable = true;
492
+ }
493
+ else if (!apiMsg && html) {
494
+ cause = "api_unreachable";
495
+ error_code = "api_unreachable";
496
+ message = `HTTP ${status} (non-JSON gateway body)`;
497
+ hint = `Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. Do not paste HTML.`;
498
+ retryable = status >= 500;
499
+ }
500
+ const retry_after_sec = retryable ? UNAVAILABLE_RETRY_AFTER_SEC : undefined;
501
+ return {
502
+ status,
503
+ error_code,
504
+ message,
505
+ hint,
506
+ request_id: requestId,
507
+ retryable,
508
+ cause,
509
+ ...(retry_after_sec !== undefined ? { retry_after_sec } : {}),
510
+ };
511
+ }
512
+ /** User-visible API error line (slim; includes request id). Exported for tests. */
513
+ export function formatApiErrorMessage(status, bodyText, requestId) {
514
+ const c = classifyApiError(status, bodyText, requestId);
515
+ const parts = [
516
+ c.message,
517
+ `error_code=${c.error_code}`,
518
+ `cause=${c.cause}`,
519
+ c.retry_after_sec != null ? `retry_after_sec=${c.retry_after_sec}` : "",
520
+ c.hint,
521
+ `request_id=${c.request_id}`,
522
+ ].filter((p) => p && String(p).trim());
523
+ return parts.join(" | ");
417
524
  }
418
525
  function throwApiError(status, bodyText, requestId) {
526
+ const classified = classifyApiError(status, bodyText, requestId);
419
527
  const err = new Error(formatApiErrorMessage(status, bodyText, requestId));
420
528
  err.httpStatus = status;
529
+ err.errorCode = classified.error_code;
530
+ err.requestId = requestId;
531
+ err.retryable = classified.retryable;
532
+ err.cause = classified.cause;
533
+ err.retryAfterSec = classified.retry_after_sec;
534
+ err.classified = classified;
421
535
  throw err;
422
536
  }
537
+ /** Final network failure after the burst — same calm shape as gateway HTML 502. */
538
+ function throwNetworkUnreachable(requestId, err) {
539
+ const detail = err instanceof Error ? err.message : String(err);
540
+ const classified = {
541
+ status: 0,
542
+ error_code: "api_unreachable",
543
+ message: "API temporarily unavailable (network error).",
544
+ hint: `Proxy already tried 3 times (~2s apart). Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. ` +
545
+ `Detail: ${detail.slice(0, 120)}`,
546
+ request_id: requestId,
547
+ retryable: true,
548
+ cause: "api_unreachable",
549
+ retry_after_sec: UNAVAILABLE_RETRY_AFTER_SEC,
550
+ };
551
+ const msg = [
552
+ classified.message,
553
+ `error_code=${classified.error_code}`,
554
+ `cause=${classified.cause}`,
555
+ `retry_after_sec=${classified.retry_after_sec}`,
556
+ classified.hint,
557
+ `request_id=${classified.request_id}`,
558
+ ].join(" | ");
559
+ const out = new Error(msg);
560
+ out.httpStatus = 0;
561
+ out.errorCode = classified.error_code;
562
+ out.requestId = requestId;
563
+ out.retryable = true;
564
+ out.cause = "api_unreachable";
565
+ out.retryAfterSec = UNAVAILABLE_RETRY_AFTER_SEC;
566
+ out.classified = classified;
567
+ throw out;
568
+ }
569
+ /**
570
+ * Liveness (+ optional readiness) probe against the API origin.
571
+ * Uses GET /health which never touches R2 — safe during storage outages.
572
+ */
573
+ export async function probeApiHealth() {
574
+ const base = { storage: "not_probed" };
575
+ try {
576
+ const live = await fetchWithTimeout(`${API_URL}/health`, { method: "GET" }, 8_000);
577
+ const liveText = await live.text();
578
+ if (!live.ok) {
579
+ return { ...base, liveness: "down", readiness: "unknown", detail: `health HTTP ${live.status}` };
580
+ }
581
+ let readiness = "unknown";
582
+ let db;
583
+ let redis;
584
+ try {
585
+ const ready = await fetchWithTimeout(`${API_URL}/ready`, { method: "GET" }, 8_000);
586
+ const readyText = await ready.text();
587
+ try {
588
+ const j = JSON.parse(readyText);
589
+ readiness = ready.ok && j.status === "ready" ? "ready" : "not_ready";
590
+ db = j.db;
591
+ redis = j.redis;
592
+ }
593
+ catch {
594
+ readiness = ready.ok ? "ready" : "not_ready";
595
+ }
596
+ }
597
+ catch {
598
+ readiness = "unknown";
599
+ }
600
+ void liveText;
601
+ return { ...base, liveness: "ok", readiness, db, redis };
602
+ }
603
+ catch (err) {
604
+ return {
605
+ ...base,
606
+ liveness: "down",
607
+ readiness: "unknown",
608
+ detail: err instanceof Error ? err.message : String(err),
609
+ };
610
+ }
611
+ }
423
612
  /**
424
613
  * @param requestTimeoutMs Optional per-request timeout (default `BARIVIA_FETCH_TIMEOUT_MS`).
425
614
  */
@@ -532,9 +721,13 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
532
721
  !err.httpStatus) {
533
722
  throw new Error(`Request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS in your MCP env (e.g. 120000) for slow or large requests. (request id: ${requestId})`);
534
723
  }
724
+ if (err instanceof TypeError)
725
+ throwNetworkUnreachable(requestId, err);
535
726
  throw err;
536
727
  }
537
728
  }
729
+ if (lastError instanceof TypeError)
730
+ throwNetworkUnreachable(requestId, lastError);
538
731
  throw lastError;
539
732
  }
540
733
  /** Fetch raw bytes from the API (for image downloads). */
@@ -596,9 +789,13 @@ export async function apiRawCall(path, requestTimeoutMs) {
596
789
  !err.httpStatus) {
597
790
  throw new Error(`Request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS (e.g. 120000) for large images. (request id: ${requestId})`);
598
791
  }
792
+ if (err instanceof TypeError)
793
+ throwNetworkUnreachable(requestId, err);
599
794
  throw err;
600
795
  }
601
796
  }
797
+ if (lastError instanceof TypeError)
798
+ throwNetworkUnreachable(requestId, lastError);
602
799
  throw lastError;
603
800
  }
604
801
  // ---------------------------------------------------------------------------
@@ -1,27 +1,95 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall, CLIENT_VERSION } from "../shared.js";
3
+ import { apiCall, CLIENT_VERSION, probeApiHealth, structuredTextResult, UNAVAILABLE_RETRY_AFTER_SEC, } from "../shared.js";
4
4
  import { getMcpClientDiagnostics } from "../ui-delivery.js";
5
+ import { listDeferredFeedback } from "../deferred_feedback.js";
6
+ import { buildJobsListQuery, extractJobsList, formatDatasetInventoryLine, formatJobInventoryLine, } from "../inventory_format.js";
7
+ function degradedStatusResult(opts) {
8
+ const retry_after_sec = UNAVAILABLE_RETRY_AFTER_SEC;
9
+ const payload = {
10
+ ok: false,
11
+ retry_after_sec,
12
+ api_reachable: false,
13
+ message: "Barivia is temporarily unavailable (we could not reach the API). " +
14
+ "Your local files and prior downloads are still fine. Try again in about 30 seconds.",
15
+ offline_ok: [
16
+ "already-downloaded results",
17
+ "local CSVs",
18
+ "draft notes / deferred feedback queue",
19
+ ],
20
+ needs_api: ["upload", "train", "feedback flush", "list/status", "other cloud tools"],
21
+ request_id: opts.requestId ?? null,
22
+ hint_for_host: "Barivia: reconnecting…",
23
+ detail: opts.detail ?? null,
24
+ };
25
+ const rid = opts.requestId
26
+ ? ` (request id: ${opts.requestId} — include if contacting support)`
27
+ : "";
28
+ const text = [
29
+ payload.message + rid,
30
+ `retry_after_sec=${retry_after_sec} | api_reachable=false`,
31
+ `AGENT: Calm banner ("${payload.hint_for_host}"); do not dump HTML. Proxy already burst-retried; wait ~${retry_after_sec}s before another round. Do not start large uploads/trains until ok.`,
32
+ ].join("\n");
33
+ return structuredTextResult(payload, text);
34
+ }
5
35
  export function registerAccountTool(server) {
6
- registerAuditedTool(server, "account", `Manage your Barivia account — check plan/license info, view usage history, add funds.
36
+ registerAuditedTool(server, "account", `Manage your Barivia account — check plan/license info, view usage history, rebuild an org inventory, add funds, or probe API health.
7
37
 
8
38
  | Action | Use when |
9
39
  |--------|----------|
10
- | status | Before large jobs — see plan tier, GPU availability, queue depth, training time estimates, credit balance |
40
+ | status | Before large jobs — see plan tier, GPU availability, queue depth, training time estimates, credit balance. When the API is down, returns calm \`{ok:false, retry_after_sec}\` instead of HTML. |
11
41
  | history | Viewing recent compute usage and credit spend |
42
+ | inventory | Rebuild a durable markdown overview of datasets + recent jobs (agent-friendly org catalog) |
12
43
  | add_funds | Getting instructions to add credits |
44
+ | health | Diagnose API reachability during outages — GET /health + /ready; does **not** probe R2/object storage |
13
45
 
14
46
  action=status: Returns plan tier, compute class (CPU/GPU), usage limits, live queue state, training time estimates, and credit balance.
15
47
  Use BEFORE large jobs to check GPU availability and estimate wait time.
48
+ When the API/gateway is down, returns structured \`{ok:false, retry_after_sec, …}\` — treat as a pause, not a crash.
49
+ action=inventory: Lists datasets (name/id/rows/cols/description/tags) and slim recent jobs (label/id/status/job_type/dataset_id/created_at/result). Prefer this over hand-assembling datasets(list)+jobs(list). Page jobs with jobs(action=list, cursor=…).
50
+ action=health: Lightweight liveness/readiness (no R2). Use when tools return api_unreachable or before retrying deferred send_feedback.
16
51
  Capacity is shared LOCAL/GKE worker pools — there is no per-key cloud burst lease.
17
52
  NOT FOR: Training itself — use train(action=map). This tool only manages the account.`, {
18
53
  action: z
19
- .enum(["status", "history", "add_funds"])
20
- .describe("status: plan/license/queue info; history: recent compute usage; add_funds: instructions"),
21
- limit: z.number().optional().describe("action=history: number of records to return (default: 10)"),
54
+ .enum(["status", "history", "inventory", "add_funds", "health"])
55
+ .describe("status: plan/license/queue info; history: recent compute usage; inventory: datasets+jobs overview; add_funds: instructions; health: API liveness without R2"),
56
+ limit: z.number().optional().describe("action=history or inventory: number of job records to return (default: 10 history / 50 inventory)"),
22
57
  }, async ({ action, limit }) => {
58
+ if (action === "health") {
59
+ const health = await probeApiHealth();
60
+ const deferred = await listDeferredFeedback("barsom").catch(() => []);
61
+ const lines = [
62
+ `API liveness: ${health.liveness}`,
63
+ `API readiness: ${health.readiness}`,
64
+ health.db ? `DB: ${health.db}` : null,
65
+ health.redis ? `Redis: ${health.redis}` : null,
66
+ `Object storage (R2): ${health.storage} (intentionally not checked)`,
67
+ health.detail ? `Detail: ${health.detail}` : null,
68
+ `Deferred feedback drafts (barsom): ${deferred.length}`,
69
+ health.liveness === "down"
70
+ ? `retry_after_sec=${UNAVAILABLE_RETRY_AFTER_SEC} (wait before another tool round)`
71
+ : null,
72
+ ].filter(Boolean);
73
+ return structuredTextResult({
74
+ ...health,
75
+ deferred_feedback_pending: deferred.length,
76
+ ...(health.liveness === "down"
77
+ ? { ok: false, retry_after_sec: UNAVAILABLE_RETRY_AFTER_SEC }
78
+ : { ok: true }),
79
+ }, lines.join("\n"));
80
+ }
23
81
  if (action === "status") {
24
- const data = (await apiCall("GET", "/v1/system/info"));
82
+ let data;
83
+ try {
84
+ data = (await apiCall("GET", "/v1/system/info"));
85
+ }
86
+ catch (err) {
87
+ const e = err;
88
+ return degradedStatusResult({
89
+ requestId: e.requestId,
90
+ detail: e.message,
91
+ });
92
+ }
25
93
  const plan = data.plan ?? {};
26
94
  const backend = data.backend ?? {};
27
95
  const status = data.status ?? {};
@@ -95,6 +163,42 @@ NOT FOR: Training itself — use train(action=map). This tool only manages the a
95
163
  content: [{ type: "text", text: `Credit Balance: $${(data.credit_balance_cents / 100).toFixed(2)}\n\nRecent Usage:\n${history}` }]
96
164
  };
97
165
  }
166
+ if (action === "inventory") {
167
+ const jobLimit = limit && limit > 0 ? Math.min(Math.floor(limit), 100) : 50;
168
+ const [datasetsRaw, jobsRaw] = await Promise.all([
169
+ apiCall("GET", "/v1/datasets"),
170
+ apiCall("GET", buildJobsListQuery({ limit: jobLimit })),
171
+ ]);
172
+ const datasets = Array.isArray(datasetsRaw) ? datasetsRaw : [];
173
+ const { jobs, next_cursor } = extractJobsList(jobsRaw);
174
+ const lines = [
175
+ `# Org inventory`,
176
+ ``,
177
+ `## Datasets (${datasets.length})`,
178
+ ];
179
+ if (datasets.length === 0) {
180
+ lines.push("(none)");
181
+ }
182
+ else {
183
+ for (const ds of datasets)
184
+ lines.push(`- ${formatDatasetInventoryLine(ds)}`);
185
+ }
186
+ lines.push(``, `## Jobs (latest ${jobs.length}${next_cursor ? ", more available" : ""})`);
187
+ if (jobs.length === 0) {
188
+ lines.push("(none)");
189
+ }
190
+ else {
191
+ for (const job of jobs)
192
+ lines.push(`- ${formatJobInventoryLine(job)}`);
193
+ }
194
+ if (next_cursor) {
195
+ lines.push(``);
196
+ lines.push(`More jobs: jobs(action=list, cursor="${next_cursor}", limit=${jobLimit})`);
197
+ }
198
+ lines.push(``);
199
+ lines.push(`Tips: set description/tags on datasets(upload|update) and train(...)/jobs(update). Filter jobs with status/job_type/has_results.`);
200
+ return { content: [{ type: "text", text: lines.join("\n") }] };
201
+ }
98
202
  if (action === "add_funds") {
99
203
  return {
100
204
  content: [{ type: "text", text: `To add funds to your account, please visit the Barivia Billing Portal (integration pending) or ask your administrator to use the CLI tool:
@@ -102,7 +206,7 @@ bash scripts/billing/manage-credits.sh add <org_id> <amount_usd>` }]
102
206
  };
103
207
  }
104
208
  return {
105
- content: [{ type: "text", text: `Unknown action: ${action}. Valid: status, history, add_funds.` }]
209
+ content: [{ type: "text", text: `Unknown action: ${action}. Valid: status, history, inventory, add_funds, health.` }]
106
210
  };
107
211
  });
108
212
  }
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  import { registerAuditedTool } from "../audit.js";
7
7
  import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, structuredTextResult, 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
+ import { formatDatasetInventoryLine, formatTags } from "../inventory_format.js";
9
10
  /**
10
11
  * Normalize a nullable string field from the API. Returns "" for absent values,
11
12
  * empty strings, and the literal SQL/serialization sentinels "missing"/"null"
@@ -137,8 +138,9 @@ Formats: plain CSV/TSV or gzip (.csv.gz / .tsv.gz). For files above ~100 MB, pre
137
138
  | upload | You have a CSV or .csv.gz file to add — do this first |
138
139
  | preview | Before train(action=map) — always preview an unfamiliar dataset to spot cyclics, nulls, column types |
139
140
  | analyze | Pre-training column analysis: correlation matrix, periodicity ranking, column recommendations (train / drop / project later). Run after preview, before train_map. |
140
- | list | Finding dataset IDs for train_map, preview, or subset — see all available datasets |
141
- | get | Fetch one dataset by id — status, staging fields, ingest_error (use after upload or when staging is slow) |
141
+ | list | Finding dataset IDs for train_map, preview, or subset — see all available datasets (includes description/tags) |
142
+ | get | Fetch one dataset by id — status, staging fields, ingest_error, description/tags (use after upload or when staging is slow) |
143
+ | update | Edit name / description / tags on an existing dataset (helps navigate duplicates) |
142
144
  | subset | ONLY when the user asks for row_range, filters, or sample_n — never auto-shrink before train/analyze |
143
145
  | add_expression | Add a computed column from an expression (dataset_id + name + expression). With project_onto_job=<training_job_id>, instead projects the computed expression onto that map as a component plane in one step (no column added). |
144
146
  | reduce_spectral | Collapse a long ordered numeric block (e.g. 1000-point spectrum, time series, sensor fingerprint) into a small per-row feature set so the SOM can train on signal-dense features. Methods: pca, log_sample, uniform_sample, stats. |
@@ -151,9 +153,12 @@ Step 1 (after upload): always datasets(action=preview) to verify column types an
151
153
 
152
154
  action=preview: Show columns, stats, sample rows, cyclic/datetime detections. ALWAYS preview before train(action=map) on an unfamiliar dataset.
153
155
  action=analyze: Pre-training analysis on numeric columns — Pearson correlation, autocorrelation periodicity scores, and column recommendations (train, consider_dropping, project_later, low_variance). Use to choose which columns to include in training and which to project onto the map after training.
154
- action=list: List all datasets belonging to the organisation with id, name, rows, cols, created_at (use created_at or id to distinguish datasets with the same name).
155
- action=get: Fetch one dataset by dataset_id — returns status, staged_prefix, staged_version, ingest_error, and stage_job_id when staging is pending.
156
+ action=list: List all datasets belonging to the organisation with id, name, rows, cols, created_at, description, tags (use tags/description or id to distinguish datasets with the same name).
157
+ action=get: Fetch one dataset by dataset_id — returns status, staged_prefix, staged_version, ingest_error, description/tags, and stage_job_id when staging is pending.
158
+ action=update: PATCH name/description/tags on an existing dataset.
159
+ action=upload: Optional description and tags help later inventory (also settable via update).
156
160
  DO NOT AUTO-SUBSET: Call action=subset only when the user explicitly requests a filter, slice, or random sample. Default path is upload → preview/analyze → train on the full dataset_id (GPU handles scale). Never downsample to "save time" or "be helpful".
161
+ Org overview: account(action=inventory) for a markdown datasets+jobs catalog.
157
162
 
158
163
  action=subset: Create a new dataset from a subset of an existing one. Requires name and at least one of row_range, filters, or sample_n.
159
164
  - row_range: [start, end] 1-based inclusive (e.g. [1, 2000] for first 2000 rows)
@@ -175,9 +180,11 @@ BEST FOR: Tabular numeric data. CSV with header required.
175
180
  NOT FOR: Real-time data streams or binary files — upload a snapshot CSV instead.
176
181
  ESCALATION: If upload fails with column errors, open the file locally and verify the header row. If preview shows nulls in training columns: use train(action=impute) for sparse data, or clean/subset for train(action=map).`, {
177
182
  action: z
178
- .enum(["upload", "preview", "analyze", "list", "get", "subset", "delete", "add_expression", "reduce_spectral"])
179
- .describe("upload: add CSV or .csv.gz; preview: inspect columns/stats; analyze: pre-training correlation and periodicity; list: see all datasets; get: fetch one dataset metadata (status/staging); subset: create filtered subset; delete: remove dataset; add_expression: add derived column from expression (or, with project_onto_job, project the expression onto a trained map as a component plane); reduce_spectral: collapse a long ordered numeric block (e.g. spectrum, time series) into a small per-row feature set via PCA / log_sample / uniform_sample / stats"),
180
- name: z.string().optional().describe("Dataset name (required for action=upload and subset)"),
183
+ .enum(["upload", "preview", "analyze", "list", "get", "update", "subset", "delete", "add_expression", "reduce_spectral"])
184
+ .describe("upload: add CSV or .csv.gz; preview: inspect columns/stats; analyze: pre-training correlation and periodicity; list: see all datasets; get: fetch one dataset metadata (status/staging); update: edit name/description/tags; subset: create filtered subset; delete: remove dataset; add_expression: add derived column from expression (or, with project_onto_job, project the expression onto a trained map as a component plane); reduce_spectral: collapse a long ordered numeric block (e.g. spectrum, time series) into a small per-row feature set via PCA / log_sample / uniform_sample / stats"),
185
+ name: z.string().optional().describe("Dataset name (required for action=upload and subset; optional for update)"),
186
+ description: z.string().optional().describe("Optional free-text description (upload/update). Pass \"\" on update to clear."),
187
+ tags: z.array(z.string()).optional().describe("Optional tags for inventory/navigation (upload/update). Pass [] on update to clear."),
181
188
  file_path: z.string().optional().describe("Path to local CSV or .csv.gz (PREFERRED): absolute path, file:// URI, or path relative to the workspace root. Token-efficient; server reads file. NOTE: relative paths resolve against the MCP workspace root — in Cursor/IDE clients that root is often the MCP install dir, not your project, so set BARIVIA_WORKSPACE_ROOT in the MCP config env (or just pass an absolute path) if a relative path is 'not accessible'. Use .csv.gz for large scientific tables."),
182
189
  csv_data: z.string().optional().describe("Inline CSV string for small pastes only (<10KB). Avoid for large files — use file_path instead to avoid token explosion."),
183
190
  dataset_id: z.string().optional().describe("Dataset ID (required for preview, get, subset, and delete)"),
@@ -266,7 +273,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
266
273
  .int()
267
274
  .optional()
268
275
  .describe("action=subset: RNG seed for sample_n (default 42; same seed + dataset reproduces the same sample)."),
269
- }, 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 }) => {
276
+ }, async ({ action, name, description, tags, 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 }) => {
270
277
  if (action === "upload") {
271
278
  if (!name)
272
279
  throw new Error("datasets(upload) requires name");
@@ -306,7 +313,12 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
306
313
  const idem = await streamFileSha256(resolved);
307
314
  let init;
308
315
  try {
309
- init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
316
+ const uploadUrlBody = { name, size_bytes: stat.size };
317
+ if (description !== undefined)
318
+ uploadUrlBody.description = description;
319
+ if (tags !== undefined)
320
+ uploadUrlBody.tags = tags;
321
+ init = (await apiCall("POST", "/v1/datasets/upload-url", uploadUrlBody, { "Idempotency-Key": idem }));
310
322
  }
311
323
  catch (e) {
312
324
  const msg = e instanceof Error ? e.message : String(e);
@@ -344,12 +356,17 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
344
356
  // Content-Encoding: gzip), never decoding them as UTF-8 text.
345
357
  if (isGzipInput) {
346
358
  const gzBytes = await fs.readFile(resolved);
347
- const data = (await apiCall("POST", "/v1/datasets", gzBytes, {
359
+ const gzHeaders = {
348
360
  "X-Dataset-Name": name,
349
361
  "Content-Type": uploadContentType,
350
362
  "Content-Encoding": "gzip",
351
363
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
352
- }, UPLOAD_DATASET_TIMEOUT_MS));
364
+ };
365
+ if (description !== undefined)
366
+ gzHeaders["X-Dataset-Description"] = description;
367
+ if (tags !== undefined)
368
+ gzHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
369
+ const data = (await apiCall("POST", "/v1/datasets", gzBytes, gzHeaders, UPLOAD_DATASET_TIMEOUT_MS));
353
370
  const gid = data.id ?? data.dataset_id;
354
371
  if (gid != null)
355
372
  data.suggested_next_step = `${suggestDatasetPreview(String(gid))} to inspect columns before training.`;
@@ -373,6 +390,10 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
373
390
  // the original dataset server-side instead of creating a duplicate.
374
391
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
375
392
  };
393
+ if (description !== undefined)
394
+ uploadHeaders["X-Dataset-Description"] = description;
395
+ if (tags !== undefined)
396
+ uploadHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
376
397
  let uploadBody = body;
377
398
  if (Buffer.byteLength(body, "utf-8") > GZIP_THRESHOLD) {
378
399
  uploadBody = gzipSync(Buffer.from(body, "utf-8"));
@@ -681,15 +702,9 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
681
702
  const data = (await apiCall("GET", "/v1/datasets"));
682
703
  if (Array.isArray(data)) {
683
704
  const lines = data.map((ds) => {
684
- const id = String(ds.id ?? "");
685
- const name = String(ds.name ?? "");
686
- const rows = ds.rows != null ? Number(ds.rows) : "?";
687
- const cols = ds.cols != null ? Number(ds.cols) : "?";
688
- const st = ds.status != null ? String(ds.status) : "ready";
689
- const statusBit = st !== "ready" ? ` | status=${st}` : "";
705
+ const base = formatDatasetInventoryLine(ds);
690
706
  const ingestErr = cleanNullable(ds.ingest_error);
691
- const err = ingestErr ? ` | ingest_error=${ingestErr}` : "";
692
- return `${name} (${id}) — ${rows}×${cols}${statusBit}${err}`;
707
+ return ingestErr ? `${base} | ingest_error=${ingestErr}` : base;
693
708
  });
694
709
  return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No datasets." }] };
695
710
  }
@@ -699,11 +714,14 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
699
714
  if (!dataset_id)
700
715
  throw new Error("datasets(get) requires dataset_id");
701
716
  const ds = (await apiCall("GET", `/v1/datasets/${dataset_id}`));
717
+ const tagStr = formatTags(ds.tags);
702
718
  const lines = [
703
719
  `Dataset: ${ds.name ?? "?"} (${ds.id ?? dataset_id})`,
704
720
  `Status: ${ds.status ?? "ready"}`,
705
721
  `Rows × cols: ${ds.rows ?? "?"} × ${ds.cols ?? "?"}`,
706
722
  ds.size_bytes != null ? `Size: ${Number(ds.size_bytes).toLocaleString()} bytes` : "",
723
+ ds.description != null && String(ds.description).trim() !== "" ? `Description: ${String(ds.description)}` : "",
724
+ tagStr ? `Tags: ${tagStr}` : "",
707
725
  ds.staged_prefix != null ? `Staged prefix: ${String(ds.staged_prefix)}` : "",
708
726
  ds.staged_version != null ? `Staged version: ${String(ds.staged_version)}` : "",
709
727
  ds.stage_job_id != null ? `Stage job: ${String(ds.stage_job_id)} (poll jobs(action=status))` : "",
@@ -712,6 +730,22 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
712
730
  ].filter(Boolean);
713
731
  return { content: [{ type: "text", text: lines.join("\n") }] };
714
732
  }
733
+ if (action === "update") {
734
+ if (!dataset_id)
735
+ throw new Error("datasets(update) requires dataset_id");
736
+ const body = {};
737
+ if (name !== undefined)
738
+ body.name = name;
739
+ if (description !== undefined)
740
+ body.description = description;
741
+ if (tags !== undefined)
742
+ body.tags = tags;
743
+ if (Object.keys(body).length === 0) {
744
+ throw new Error("datasets(update) requires at least one of name, description, tags");
745
+ }
746
+ const data = await apiCall("PATCH", `/v1/datasets/${dataset_id}`, body);
747
+ return textResult(data);
748
+ }
715
749
  if (action === "delete") {
716
750
  if (!dataset_id)
717
751
  throw new Error("datasets(delete) requires dataset_id");
@@ -1,10 +1,29 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall, API_URL, fetchWithTimeout, textResult } from "../shared.js";
3
+ import { apiCall, API_URL, fetchWithTimeout, probeApiHealth, structuredTextResult, } from "../shared.js";
4
+ import { enqueueDeferredFeedback, flushDeferredFeedback, listDeferredFeedback, } from "../deferred_feedback.js";
5
+ function isDeferworthy(err) {
6
+ const e = err;
7
+ if (e?.retryable)
8
+ return true;
9
+ if (e?.cause === "api_unreachable" || e?.cause === "storage" || e?.cause === "network")
10
+ return true;
11
+ const status = e?.httpStatus;
12
+ if (status === 502 || status === 503 || status === 504)
13
+ return true;
14
+ if (err instanceof TypeError)
15
+ return true;
16
+ if (err instanceof Error && /fetch failed|ECONNREFUSED|ENOTFOUND|ECONNRESET|network|timed out/i.test(err.message)) {
17
+ return true;
18
+ }
19
+ return false;
20
+ }
4
21
  export function registerFeedbackTool(server) {
5
22
  registerAuditedTool(server, "send_feedback", `Send feedback or feature requests to Barivia developers. Use when the user has suggestions, ran into issues, or wants something improved. Do NOT call without asking the user first — but after any group of actions or downloading of results, you SHOULD prepare feedback based on the user's workflow or errors encountered, show it to them, and ask for permission to send it. Once they accept, call this tool.
6
23
 
7
- For a substantial issue, prefer feedback_items: submit several focused instances (each max 1400 chars) covering, e.g., symptoms, exact reproduction steps, environment, and concrete asks — they are stored together as one batch so developers see the full picture. Use the single feedback field for a short one-off note.`, {
24
+ For a substantial issue, prefer feedback_items: submit several focused instances (each max 1400 chars) covering, e.g., symptoms, exact reproduction steps, environment, and concrete asks — they are stored together as one batch so developers see the full picture. Use the single feedback field for a short one-off note.
25
+
26
+ If the API or object storage is unavailable, this tool queues feedback locally (~/.barivia/deferred-feedback) and returns deferred=true with request_id / api_health — call again later to flush. Probe API liveness with account(action=health) (GET /health; does not touch R2).`, {
8
27
  feedback: z.string().max(1400).optional().describe("Single feedback note (max 1400 characters). Use feedback_items instead for a multi-part report."),
9
28
  feedback_items: z.array(z.string().max(1400)).max(10).optional().describe("Several feedback instances (each max 1400 characters, up to 10), stored together as one batch. Prefer this for a detailed issue: split it into focused parts (symptoms, reproduction, environment, asks)."),
10
29
  }, async ({ feedback, feedback_items }) => {
@@ -15,9 +34,20 @@ For a substantial issue, prefer feedback_items: submit several focused instances
15
34
  throw new Error("send_feedback requires feedback or feedback_items (at least one non-empty entry).");
16
35
  }
17
36
  const body = items.length === 1 ? { feedback: items[0] } : { feedback_items: items };
37
+ const submit = async (b) => apiCall("POST", "/v1/feedback", b);
38
+ // Best-effort flush of earlier offline drafts before sending the new one.
39
+ const flushed = await flushDeferredFeedback("barsom", submit).catch(() => null);
18
40
  try {
19
- const data = await apiCall("POST", "/v1/feedback", body);
20
- return textResult(data);
41
+ const data = (await submit(body));
42
+ return structuredTextResult({
43
+ ...data,
44
+ deferred: false,
45
+ ...(flushed && flushed.flushed > 0
46
+ ? { flushed_deferred: flushed.flushed, deferred_remaining: flushed.remaining }
47
+ : {}),
48
+ }, typeof data.message === "string"
49
+ ? data.message
50
+ : "Feedback received. Thank you.");
21
51
  }
22
52
  catch (err) {
23
53
  if (err?.httpStatus === 401) {
@@ -31,10 +61,46 @@ For a substantial issue, prefer feedback_items: submit several focused instances
31
61
  const text = await resp.text();
32
62
  if (resp.ok) {
33
63
  const parsed = JSON.parse(text);
34
- return textResult({ ...parsed, note: "Feedback sent anonymously (API key invalid or expired)." });
64
+ return structuredTextResult({ ...parsed, deferred: false, note: "Feedback sent anonymously (API key invalid or expired)." }, "Feedback sent anonymously (API key invalid or expired).");
35
65
  }
36
66
  }
37
- catch { /* fall through to original error */ }
67
+ catch { /* fall through */ }
68
+ }
69
+ if (isDeferworthy(err)) {
70
+ const e = err;
71
+ const queued = await enqueueDeferredFeedback("barsom", body, {
72
+ status: e.httpStatus,
73
+ error_code: e.errorCode,
74
+ request_id: e.requestId,
75
+ message: e.message,
76
+ });
77
+ const api_health = await probeApiHealth().catch(() => ({
78
+ liveness: "down",
79
+ readiness: "unknown",
80
+ storage: "not_probed",
81
+ }));
82
+ const pending = await listDeferredFeedback("barsom");
83
+ return structuredTextResult({
84
+ ok: true,
85
+ deferred: true,
86
+ deferred_id: queued.id,
87
+ deferred_path: queued.path,
88
+ deferred_pending: pending.length,
89
+ error_code: e.errorCode ?? "api_unreachable",
90
+ cause: e.cause ?? "api_unreachable",
91
+ request_id: e.requestId ?? null,
92
+ api_health,
93
+ message: "API/storage unavailable — feedback saved locally and will be sent on the next successful send_feedback call.",
94
+ }, [
95
+ "Feedback deferred (API/storage unavailable).",
96
+ `Saved to ${queued.path}`,
97
+ e.requestId ? `request_id=${e.requestId}` : null,
98
+ e.errorCode ? `error_code=${e.errorCode}` : null,
99
+ `api_health.liveness=${api_health.liveness} (storage not_probed)`,
100
+ "Retry send_feedback later to flush the local queue.",
101
+ ]
102
+ .filter(Boolean)
103
+ .join(" | "));
38
104
  }
39
105
  throw err;
40
106
  }
@@ -1,15 +1,20 @@
1
1
  import { registerAuditedTool } from "../audit.js";
2
- import { fetchWorkflowGuideFromApi } from "../shared.js";
2
+ import { fetchWorkflowGuideFromApi, UNAVAILABLE_RETRY_AFTER_SEC } from "../shared.js";
3
3
  /** Minimal orientation when the API is unreachable; full SOP lives on GET /v1/docs/workflow. */
4
4
  const OFFLINE_STUB = `## Offline / API unavailable
5
5
 
6
- Configure \`BARIVIA_API_KEY\` and optional \`BARIVIA_API_URL\`, then call **guide_barsom_workflow** again. Full tool map, async rules, training modes, and step-by-step SOP are loaded from the Barivia API (authenticated) and scoped to your plan.
6
+ Configure \`BARIVIA_API_KEY\` and optional \`BARIVIA_API_URL\`, then call **guide_barsom_workflow** again when online. Full tool map, async rules, training modes, and step-by-step SOP are loaded from the Barivia API (authenticated) and scoped to your plan.
7
7
 
8
- **Core tools:** \`datasets\`, \`train\` (map, siom_map, impute, floop_siom where entitled), \`jobs\` (status/list/compare lifecycle only), \`results\`, \`inference\`, \`account\`, \`training_guidance\`, \`guide_barsom_workflow\`.
8
+ **Still usable offline:** already-downloaded results, local CSVs, draft notes / feedback drafts (queued under \`~/.barivia/deferred-feedback\`).
9
+ **Needs API:** upload, train, list/status, feedback flush, other cloud tools.
10
+
11
+ **Core tools (when healthy):** \`datasets\`, \`train\` (map, siom_map, impute, floop_siom where entitled), \`jobs\` (status/list/compare — lifecycle only), \`results\`, \`inference\`, \`account\`, \`training_guidance\`, \`guide_barsom_workflow\`.
12
+
13
+ **Degraded-session rules:** Do not paste Cloudflare/HTML into chat — one-sentence summary. The proxy already burst-retried (3×~2s); wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s (see \`retry_after_sec\`) before another round, then ask the user. Check \`account(health)\` or \`account(status)\` before large uploads/trains (\`ok:false\` + \`retry_after_sec\` = pause, not a crash). Save feedback drafts locally; resend via \`send_feedback\` when healthy.
9
14
 
10
15
  **Parameter hints:** call \`training_guidance\` (also API-scoped). **Async:** poll \`jobs(action=status)\` every 10–15s after submit.`;
11
16
  export function registerGuideBarsomTool(server) {
12
- registerAuditedTool(server, "guide_barsom_workflow", "Plan-scoped orientation: proxy model, tool categories, async rules, training modes, and step-by-step SOP — loaded from the Barivia API when online. Call at the start of mapping work. Offline: short stub. For field-level parameters, use training_guidance; for a narrative pre-train checklist, use the prepare_training prompt.", {}, async () => {
17
+ registerAuditedTool(server, "guide_barsom_workflow", "Plan-scoped orientation: proxy model, tool categories, async rules, training modes, and step-by-step SOP — loaded from the Barivia API when online. Call at the start of mapping work. Offline: short stub with offline-vs-API capabilities and degraded-session rules. For field-level parameters, use training_guidance; for a narrative pre-train checklist, use the prepare_training prompt.", {}, async () => {
13
18
  const md = await fetchWorkflowGuideFromApi();
14
19
  if (md) {
15
20
  const text = "Plan-scoped workflow (from Barivia API). Text below reflects your API key / plan.\n\n" + md;
@@ -3,13 +3,15 @@ import { registerAuditedTool } from "../audit.js";
3
3
  import { apiCall, textResult } from "../shared.js";
4
4
  import { formatJobCancelText, formatJobStatusText } from "../job_status_format.js";
5
5
  import { DEFAULT_BLOCK_UNTIL_SEC, DEFAULT_POLL_INTERVAL_SEC, runJobMonitor } from "../job_monitor.js";
6
+ import { buildJobsListQuery, extractJobsList, formatJobInventoryLine, } from "../inventory_format.js";
6
7
  export const JOBS_DESCRIPTION_BASE = `Manage and inspect jobs (lifecycle only).
7
8
 
8
9
  | Action | Use when |
9
10
  |--------|----------|
10
11
  | monitor | After async submit — blocks server-side with 5s snapshots until terminal (preferred for agents) |
11
12
  | status | One-shot progress check (manual poll every 10–15s if not using monitor) |
12
- | list | Finding job IDs / reviewing pipeline state (optionally filter by dataset_id) |
13
+ | list | Finding job IDs / reviewing pipeline state (slim by default; filter by dataset_id/status/job_type; paginate with limit+cursor) |
14
+ | update | Edit label / description / tags on an existing job |
13
15
  | compare | Picking the best TRAINING run from a set (metrics table). NOT inference(compare), which diffs one dataset's distribution against the training set. |
14
16
  | cancel | Stopping a running or pending job to free the worker |
15
17
  | delete | Permanently removing a job and all its S3 result files |
@@ -28,9 +30,13 @@ ASYNC POLLING PROTOCOL (action=status):
28
30
  - Map training typical times: 10×10 ~30s | 20×20 ~3–5 min | 40×40 ~15–30 min.
29
31
  - completed → results(action=get); failed → read error + optional failure_stage (preprocessing/training/metrics/visualization/upload): memory error → smaller grid/batch and retrain; column missing → datasets(action=preview); NaN on a standard map → train(action=impute) for sparse columns or clean the CSV.
30
32
 
33
+ action=list: Slim rows by default (label,id,status,job_type,dataset_id,created_at,result_ref,description,tags — no fat params). Use status/job_type/has_results/limit/cursor to page. has_results=true is the results catalog. Pass fields="full" only when you need params/error.
34
+ action=update: PATCH label/description/tags (pass at train submit when possible).
31
35
  action=compare: metrics table (QE, TE, explained variance, silhouette) for 2+ COMPLETED TRAINING jobs — pick the best run in a sweep. NOT inference(action=compare) (single trained job + second dataset → density-diff heatmap). Only hyperparameter columns that differ across runs are shown; pass label="…" at train time for readable rows.
32
36
  action=cancel: Not instant — worker checks between phases. Expect up to 30s delay.
33
- action=delete: WARNING — job ID will no longer work with results or any other tool.`;
37
+ action=delete: WARNING — job ID will no longer work with results or any other tool.
38
+
39
+ Org overview: prefer account(action=inventory) for a markdown datasets+jobs inventory.`;
34
40
  /**
35
41
  * Render the jobs(compare) markdown table. Strategy: always show job_id (or label
36
42
  * if present) + the four core metrics (QE, TE, Expl.Var, Silhouette); add any
@@ -143,12 +149,12 @@ export function renderCompareTable(comparisons) {
143
149
  export function registerJobsTool(server, description) {
144
150
  registerAuditedTool(server, "jobs", description, {
145
151
  action: z
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.)"),
152
+ .enum(["status", "monitor", "list", "update", "compare", "cancel", "delete"])
153
+ .describe("status: one-shot check; monitor: block until terminal with snapshots; list/update/compare/cancel/delete as labeled. (Training via train; scoring via inference.)"),
148
154
  job_id: z
149
155
  .string()
150
156
  .optional()
151
- .describe("Job ID — required for action=status, monitor, cancel, delete."),
157
+ .describe("Job ID — required for action=status, monitor, update, cancel, delete."),
152
158
  block_until_sec: z
153
159
  .number()
154
160
  .int()
@@ -173,8 +179,38 @@ export function registerJobsTool(server, description) {
173
179
  .string()
174
180
  .optional()
175
181
  .describe("action=list: filter jobs by this dataset ID."),
182
+ status: z
183
+ .enum(["pending", "running", "completed", "failed", "cancelled"])
184
+ .optional()
185
+ .describe("action=list: filter by job status."),
186
+ job_type: z
187
+ .string()
188
+ .optional()
189
+ .describe("action=list: filter by MCP job_type label (e.g. train_map, train_impute, predict, cfd_mesh_convergence)."),
190
+ has_results: z
191
+ .boolean()
192
+ .optional()
193
+ .describe("action=list: true = completed jobs with result_ref (results catalog)."),
194
+ limit: z
195
+ .number()
196
+ .int()
197
+ .min(1)
198
+ .max(100)
199
+ .optional()
200
+ .describe("action=list: page size (default 50, max 100)."),
201
+ cursor: z
202
+ .string()
203
+ .optional()
204
+ .describe("action=list: next_cursor from a prior list response for keyset pagination."),
205
+ fields: z
206
+ .enum(["slim", "full"])
207
+ .optional()
208
+ .describe("action=list: slim (default, no params) or full (includes params/error)."),
209
+ label: z.string().optional().describe("action=update: new job label (≤120 chars)."),
210
+ description: z.string().optional().describe("action=update: free-text description (pass \"\" to clear)."),
211
+ tags: z.array(z.string()).optional().describe("action=update: replace tags (pass [] to clear)."),
176
212
  }, async (args) => {
177
- const { action, job_id, job_ids, dataset_id, block_until_sec, poll_interval_sec, wait_finalize } = args;
213
+ const { action, job_id, job_ids, dataset_id, status, job_type, has_results, limit, cursor, fields, block_until_sec, poll_interval_sec, wait_finalize, label, description, tags, } = args;
178
214
  // ---- Lifecycle ----
179
215
  if (action === "monitor") {
180
216
  if (!job_id)
@@ -188,17 +224,38 @@ export function registerJobsTool(server, description) {
188
224
  return { content: [{ type: "text", text: formatJobStatusText(job_id, data) }] };
189
225
  }
190
226
  if (action === "list") {
191
- const listPath = dataset_id ? `/v1/jobs?dataset_id=${dataset_id}` : "/v1/jobs";
192
- const data = (await apiCall("GET", listPath));
193
- if (Array.isArray(data)) {
194
- const lines = data.map((job) => {
195
- const id = String(job.id ?? "");
196
- const st = String(job.status ?? "");
197
- const label = job.label != null && job.label !== "" ? String(job.label) : null;
198
- return label ? `${label} (id: ${id}) — ${st}` : `id: ${id} — ${st}`;
199
- });
200
- return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No jobs found." }] };
227
+ const listPath = buildJobsListQuery({
228
+ dataset_id, status, job_type, has_results, limit, cursor, fields,
229
+ });
230
+ const data = await apiCall("GET", listPath);
231
+ const { jobs, next_cursor } = extractJobsList(data);
232
+ const lines = jobs.map((job) => formatJobInventoryLine(job));
233
+ if (next_cursor) {
234
+ lines.push("");
235
+ lines.push(`next_cursor: ${next_cursor}`);
236
+ lines.push(`Tip: jobs(action=list, cursor="${next_cursor}", limit=${limit ?? 50}) for the next page.`);
237
+ }
238
+ return {
239
+ content: [{
240
+ type: "text",
241
+ text: lines.length > 0 ? lines.join("\n") : "No jobs found.",
242
+ }],
243
+ };
244
+ }
245
+ if (action === "update") {
246
+ if (!job_id)
247
+ throw new Error("jobs(update) requires job_id");
248
+ const body = {};
249
+ if (label !== undefined)
250
+ body.label = label;
251
+ if (description !== undefined)
252
+ body.description = description;
253
+ if (tags !== undefined)
254
+ body.tags = tags;
255
+ if (Object.keys(body).length === 0) {
256
+ throw new Error("jobs(update) requires at least one of label, description, tags");
201
257
  }
258
+ const data = await apiCall("PATCH", `/v1/jobs/${job_id}`, body);
202
259
  return textResult(data);
203
260
  }
204
261
  if (action === "compare") {
@@ -55,6 +55,10 @@ async function submitTrainJob(opts) {
55
55
  const submitBody = { dataset_id: opts.dataset_id, params: opts.params };
56
56
  if (opts.label && opts.label.trim() !== "")
57
57
  submitBody.label = opts.label;
58
+ if (opts.description !== undefined)
59
+ submitBody.description = opts.description;
60
+ if (opts.tags !== undefined)
61
+ submitBody.tags = opts.tags;
58
62
  const data = (await apiCall("POST", "/v1/jobs", submitBody));
59
63
  const newJobId = data.id;
60
64
  data.effective_params = `${opts.variantPrefix}, ${opts.paramSummary}`;
@@ -104,7 +108,7 @@ async function submitTrainJob(opts) {
104
108
  export async function runTrain(action, args) {
105
109
  const dataset_id = args.dataset_id;
106
110
  if (action === "map" || action === "siom_map" || action === "impute") {
107
- const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, cyclic_pairs, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, normalization_methods, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, gamma, gamma_f, siom_decay, siom_penalty, penalty_alpha, reset_per_epoch, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, label, cv_folds, viz_mode, viz_top_components, viz_upload_cyclic_components, emit_cell_uncertainty, } = args;
111
+ const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, cyclic_pairs, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, normalization_methods, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, gamma, gamma_f, siom_decay, siom_penalty, penalty_alpha, reset_per_epoch, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, label, description, tags, cv_folds, viz_mode, viz_top_components, viz_upload_cyclic_components, emit_cell_uncertainty, } = args;
108
112
  let PRESETS = {};
109
113
  try {
110
114
  PRESETS = await fetchTrainingPresets();
@@ -200,6 +204,8 @@ export async function runTrain(action, args) {
200
204
  dataset_id,
201
205
  params,
202
206
  label,
207
+ description,
208
+ tags,
203
209
  totalRows,
204
210
  hasTransforms,
205
211
  variantPrefix,
@@ -213,7 +219,7 @@ export async function runTrain(action, args) {
213
219
  if (!dataset_id)
214
220
  throw new Error("train(floop_siom) requires dataset_id");
215
221
  assertValidNormalizationArgs(args);
216
- const { transforms, label } = args;
222
+ const { transforms, label, description, tags } = args;
217
223
  const { params, paramSummary } = buildFloopParams(args);
218
224
  let totalRows = 0;
219
225
  try {
@@ -226,6 +232,8 @@ export async function runTrain(action, args) {
226
232
  dataset_id,
227
233
  params,
228
234
  label,
235
+ description,
236
+ tags,
229
237
  totalRows,
230
238
  hasTransforms,
231
239
  variantPrefix: "variant=floop",
@@ -246,6 +254,14 @@ export function registerTrainTool(server) {
246
254
  .max(120)
247
255
  .optional()
248
256
  .describe("Optional run label (≤120 chars); appears in jobs(list) and the jobs(compare) table; sanitized server-side. Useful for sweeps (e.g. label=\"sweep_periodic_true\")."),
257
+ description: z
258
+ .string()
259
+ .optional()
260
+ .describe("Optional free-text description for inventory/navigation (shown in jobs(list) and account(inventory))."),
261
+ tags: z
262
+ .array(z.string())
263
+ .optional()
264
+ .describe("Optional tags for inventory/navigation (e.g. [\"sweep\",\"baseline\"])."),
249
265
  ...TRAINING_INPUT_SCHEMA,
250
266
  }, async (args) => runTrain(args.action, args));
251
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barivia/barsom-mcp",
3
- "version": "0.22.4",
3
+ "version": "0.23.0",
4
4
  "description": "barSOM MCP proxy — connect any MCP client to the barSOM cloud API for Self-Organizing Map analytics",
5
5
  "keywords": [
6
6
  "mcp",