@barivia/barsom-mcp 0.22.4 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,8 @@ 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";
10
+ import { PERIODICITY_ROW_ORDER_NOTE, SMALL_N_SOFT, buildCpuScaleGuidanceLines, buildSmallNGuardrailLines, } from "../training_scale_guidance.js";
9
11
  /**
10
12
  * Normalize a nullable string field from the API. Returns "" for absent values,
11
13
  * empty strings, and the literal SQL/serialization sentinels "missing"/"null"
@@ -110,6 +112,16 @@ function formatAnalyzeResult(data, dataset_id) {
110
112
  lines.push(``);
111
113
  }
112
114
  const nextSteps = [];
115
+ const nRows = typeof totalRows === "number" && Number.isFinite(totalRows) ? totalRows : 0;
116
+ if (nRows > 0 && nRows < SMALL_N_SOFT) {
117
+ nextSteps.push(...buildSmallNGuardrailLines({ n: nRows, nFeatures: trainList.length || columns.length }));
118
+ }
119
+ if (nRows > 0) {
120
+ nextSteps.push(...buildCpuScaleGuidanceLines(nRows));
121
+ }
122
+ if (considerDropping.length > 0) {
123
+ nextSteps.push(`Drop redundant correlated columns before train (e.g. keep distance OR lat/lon, not all three when |r|>0.9): ${considerDropping.join(", ")}.`);
124
+ }
113
125
  if (trainList.length > 0) {
114
126
  nextSteps.push(`Train with columns: [${trainList.join(", ")}].`);
115
127
  }
@@ -119,11 +131,11 @@ function formatAnalyzeResult(data, dataset_id) {
119
131
  const periodicStrong = periodicity.filter((p) => p.dominant_score > 0.4);
120
132
  if (periodicStrong.length > 0) {
121
133
  const lags = [...new Set(periodicStrong.map((p) => p.dominant_lag))].sort((a, b) => a - b);
122
- nextSteps.push(`Note: periodicity is computed on row order. If your CSV is not sorted by time, ignore lag-based suggestions; only use cyclic_features when the column is genuinely cyclic (e.g. hour, month, angle).`);
134
+ nextSteps.push(`Note: ${PERIODICITY_ROW_ORDER_NOTE}`);
123
135
  nextSteps.push(`Columns show periodic behavior at lags ${lags.join(", ")} (assuming row order is meaningful). Consider cyclic_features or temporal_features with matching periods.`);
124
136
  nextSteps.push(`For datetime columns: run datasets(action=preview) for temporal_suggestions; match lags (e.g. 365→day_of_year, 12→month, 7→day_of_week, 24→hour_of_day) to choose which temporal components to extract.`);
125
137
  }
126
- nextSteps.push(`Then call train(action=map, dataset_id=${dataset_id}, columns=[...], ...).`);
138
+ nextSteps.push(`Then call train(action=map, dataset_id=${dataset_id}, columns=[...], ...) — omit grid_x/grid_y for auto-size unless you intentionally override.`);
127
139
  lines.push(`Next steps:`, ...nextSteps.map((s) => ` ${s}`));
128
140
  return lines.join("\n");
129
141
  }
@@ -137,9 +149,10 @@ Formats: plain CSV/TSV or gzip (.csv.gz / .tsv.gz). For files above ~100 MB, pre
137
149
  | upload | You have a CSV or .csv.gz file to add — do this first |
138
150
  | preview | Before train(action=map) — always preview an unfamiliar dataset to spot cyclics, nulls, column types |
139
151
  | 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) |
142
- | subset | ONLY when the user asks for row_range, filters, or sample_n never auto-shrink before train/analyze |
152
+ | list | Finding dataset IDs for train_map, preview, or subset — see all available datasets (includes description/tags) |
153
+ | get | Fetch one dataset by id — status, staging fields, ingest_error, description/tags (use after upload or when staging is slow) |
154
+ | update | Edit name / description / tags on an existing dataset (helps navigate duplicates) |
155
+ | subset | ONLY when the user asks for row_range, filters, or sample_n — never auto-shrink before train/analyze (CPU quick is fast to ~100k; do not sample_n by default below that) |
143
156
  | 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
157
  | 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. |
145
158
  | delete | Cleaning up after experiments or freeing the dataset slot |
@@ -151,9 +164,12 @@ Step 1 (after upload): always datasets(action=preview) to verify column types an
151
164
 
152
165
  action=preview: Show columns, stats, sample rows, cyclic/datetime detections. ALWAYS preview before train(action=map) on an unfamiliar dataset.
153
166
  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.
167
+ 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).
168
+ 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.
169
+ action=update: PATCH name/description/tags on an existing dataset.
170
+ action=upload: Optional description and tags help later inventory (also settable via update).
156
171
  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".
172
+ Org overview: account(action=inventory) for a markdown datasets+jobs catalog.
157
173
 
158
174
  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
175
  - row_range: [start, end] 1-based inclusive (e.g. [1, 2000] for first 2000 rows)
@@ -175,9 +191,11 @@ BEST FOR: Tabular numeric data. CSV with header required.
175
191
  NOT FOR: Real-time data streams or binary files — upload a snapshot CSV instead.
176
192
  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
193
  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)"),
194
+ .enum(["upload", "preview", "analyze", "list", "get", "update", "subset", "delete", "add_expression", "reduce_spectral"])
195
+ .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"),
196
+ name: z.string().optional().describe("Dataset name (required for action=upload and subset; optional for update)"),
197
+ description: z.string().optional().describe("Optional free-text description (upload/update). Pass \"\" on update to clear."),
198
+ tags: z.array(z.string()).optional().describe("Optional tags for inventory/navigation (upload/update). Pass [] on update to clear."),
181
199
  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
200
  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
201
  dataset_id: z.string().optional().describe("Dataset ID (required for preview, get, subset, and delete)"),
@@ -266,7 +284,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
266
284
  .int()
267
285
  .optional()
268
286
  .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 }) => {
287
+ }, 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
288
  if (action === "upload") {
271
289
  if (!name)
272
290
  throw new Error("datasets(upload) requires name");
@@ -306,7 +324,12 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
306
324
  const idem = await streamFileSha256(resolved);
307
325
  let init;
308
326
  try {
309
- init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
327
+ const uploadUrlBody = { name, size_bytes: stat.size };
328
+ if (description !== undefined)
329
+ uploadUrlBody.description = description;
330
+ if (tags !== undefined)
331
+ uploadUrlBody.tags = tags;
332
+ init = (await apiCall("POST", "/v1/datasets/upload-url", uploadUrlBody, { "Idempotency-Key": idem }));
310
333
  }
311
334
  catch (e) {
312
335
  const msg = e instanceof Error ? e.message : String(e);
@@ -344,12 +367,17 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
344
367
  // Content-Encoding: gzip), never decoding them as UTF-8 text.
345
368
  if (isGzipInput) {
346
369
  const gzBytes = await fs.readFile(resolved);
347
- const data = (await apiCall("POST", "/v1/datasets", gzBytes, {
370
+ const gzHeaders = {
348
371
  "X-Dataset-Name": name,
349
372
  "Content-Type": uploadContentType,
350
373
  "Content-Encoding": "gzip",
351
374
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
352
- }, UPLOAD_DATASET_TIMEOUT_MS));
375
+ };
376
+ if (description !== undefined)
377
+ gzHeaders["X-Dataset-Description"] = description;
378
+ if (tags !== undefined)
379
+ gzHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
380
+ const data = (await apiCall("POST", "/v1/datasets", gzBytes, gzHeaders, UPLOAD_DATASET_TIMEOUT_MS));
353
381
  const gid = data.id ?? data.dataset_id;
354
382
  if (gid != null)
355
383
  data.suggested_next_step = `${suggestDatasetPreview(String(gid))} to inspect columns before training.`;
@@ -373,6 +401,10 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
373
401
  // the original dataset server-side instead of creating a duplicate.
374
402
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
375
403
  };
404
+ if (description !== undefined)
405
+ uploadHeaders["X-Dataset-Description"] = description;
406
+ if (tags !== undefined)
407
+ uploadHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
376
408
  let uploadBody = body;
377
409
  if (Buffer.byteLength(body, "utf-8") > GZIP_THRESHOLD) {
378
410
  uploadBody = gzipSync(Buffer.from(body, "utf-8"));
@@ -466,7 +498,13 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
466
498
  lines.push(`| ${cols.map((c) => String(row[c] ?? "")).join(" | ")} |`);
467
499
  }
468
500
  }
469
- lines.push(``, `Suggested next step: train(action=map, dataset_id=${dataset_id}, ...) or use the prepare_training prompt; to add derived columns use datasets(action=add_expression, dataset_id=${dataset_id}, name=..., expression=...).`);
501
+ if (totalRows > 0 && totalRows < SMALL_N_SOFT) {
502
+ lines.push(``, ...buildSmallNGuardrailLines({ n: totalRows, nFeatures: cols.length }));
503
+ }
504
+ if (totalRows > 0) {
505
+ lines.push(``, ...buildCpuScaleGuidanceLines(totalRows));
506
+ }
507
+ lines.push(``, `Suggested next step: datasets(action=analyze) then train(action=map, dataset_id=${dataset_id}, ...) or use the prepare_training prompt; to add derived columns use datasets(action=add_expression, dataset_id=${dataset_id}, name=..., expression=...).`);
470
508
  return { content: [{ type: "text", text: lines.join("\n") }] };
471
509
  }
472
510
  if (action === "analyze") {
@@ -681,15 +719,9 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
681
719
  const data = (await apiCall("GET", "/v1/datasets"));
682
720
  if (Array.isArray(data)) {
683
721
  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}` : "";
722
+ const base = formatDatasetInventoryLine(ds);
690
723
  const ingestErr = cleanNullable(ds.ingest_error);
691
- const err = ingestErr ? ` | ingest_error=${ingestErr}` : "";
692
- return `${name} (${id}) — ${rows}×${cols}${statusBit}${err}`;
724
+ return ingestErr ? `${base} | ingest_error=${ingestErr}` : base;
693
725
  });
694
726
  return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No datasets." }] };
695
727
  }
@@ -699,11 +731,14 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
699
731
  if (!dataset_id)
700
732
  throw new Error("datasets(get) requires dataset_id");
701
733
  const ds = (await apiCall("GET", `/v1/datasets/${dataset_id}`));
734
+ const tagStr = formatTags(ds.tags);
702
735
  const lines = [
703
736
  `Dataset: ${ds.name ?? "?"} (${ds.id ?? dataset_id})`,
704
737
  `Status: ${ds.status ?? "ready"}`,
705
738
  `Rows × cols: ${ds.rows ?? "?"} × ${ds.cols ?? "?"}`,
706
739
  ds.size_bytes != null ? `Size: ${Number(ds.size_bytes).toLocaleString()} bytes` : "",
740
+ ds.description != null && String(ds.description).trim() !== "" ? `Description: ${String(ds.description)}` : "",
741
+ tagStr ? `Tags: ${tagStr}` : "",
707
742
  ds.staged_prefix != null ? `Staged prefix: ${String(ds.staged_prefix)}` : "",
708
743
  ds.staged_version != null ? `Staged version: ${String(ds.staged_version)}` : "",
709
744
  ds.stage_job_id != null ? `Stage job: ${String(ds.stage_job_id)} (poll jobs(action=status))` : "",
@@ -712,6 +747,22 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
712
747
  ].filter(Boolean);
713
748
  return { content: [{ type: "text", text: lines.join("\n") }] };
714
749
  }
750
+ if (action === "update") {
751
+ if (!dataset_id)
752
+ throw new Error("datasets(update) requires dataset_id");
753
+ const body = {};
754
+ if (name !== undefined)
755
+ body.name = name;
756
+ if (description !== undefined)
757
+ body.description = description;
758
+ if (tags !== undefined)
759
+ body.tags = tags;
760
+ if (Object.keys(body).length === 0) {
761
+ throw new Error("datasets(update) requires at least one of name, description, tags");
762
+ }
763
+ const data = await apiCall("PATCH", `/v1/datasets/${dataset_id}`, body);
764
+ return textResult(data);
765
+ }
715
766
  if (action === "delete") {
716
767
  if (!dataset_id)
717
768
  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
 
31
- 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.
33
+ action=list: Slim rows by default (label,id,status,job_type,dataset_id,created_at,result_ref,description,tagsno 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).
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 (including SIOM knobs gamma/gamma_f/siom_decay/siom_penalty/reset_per_epoch when they vary); 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
@@ -74,7 +80,11 @@ export function renderCompareTable(comparisons) {
74
80
  { key: "n_initial", label: "NInitial", render: fmtAny },
75
81
  { key: "growth_interval", label: "GrowthInt", render: fmtAny },
76
82
  { key: "gamma", label: "Gamma", render: fmtAny },
83
+ { key: "gamma_f", label: "GammaF", render: fmtAny },
77
84
  { key: "siom_decay", label: "SIOMDecay", render: fmtAny },
85
+ { key: "siom_penalty", label: "SIOMPenalty", render: fmtAny },
86
+ { key: "penalty_alpha", label: "Penaltyα", render: fmtAny },
87
+ { key: "reset_per_epoch", label: "Reset/Epoch", render: fmtAny },
78
88
  { key: "elastic_lambda", label: "Elasticλ", render: fmtAny },
79
89
  { key: "elastic_mu", label: "Elasticμ", render: fmtAny },
80
90
  { key: "elastic_anchor", label: "ElasticAnchor", render: fmtAny },
@@ -143,12 +153,12 @@ export function renderCompareTable(comparisons) {
143
153
  export function registerJobsTool(server, description) {
144
154
  registerAuditedTool(server, "jobs", description, {
145
155
  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.)"),
156
+ .enum(["status", "monitor", "list", "update", "compare", "cancel", "delete"])
157
+ .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
158
  job_id: z
149
159
  .string()
150
160
  .optional()
151
- .describe("Job ID — required for action=status, monitor, cancel, delete."),
161
+ .describe("Job ID — required for action=status, monitor, update, cancel, delete."),
152
162
  block_until_sec: z
153
163
  .number()
154
164
  .int()
@@ -173,8 +183,38 @@ export function registerJobsTool(server, description) {
173
183
  .string()
174
184
  .optional()
175
185
  .describe("action=list: filter jobs by this dataset ID."),
186
+ status: z
187
+ .enum(["pending", "running", "completed", "failed", "cancelled"])
188
+ .optional()
189
+ .describe("action=list: filter by job status."),
190
+ job_type: z
191
+ .string()
192
+ .optional()
193
+ .describe("action=list: filter by MCP job_type label (e.g. train_map, train_impute, predict, cfd_mesh_convergence)."),
194
+ has_results: z
195
+ .boolean()
196
+ .optional()
197
+ .describe("action=list: true = completed jobs with result_ref (results catalog)."),
198
+ limit: z
199
+ .number()
200
+ .int()
201
+ .min(1)
202
+ .max(100)
203
+ .optional()
204
+ .describe("action=list: page size (default 50, max 100)."),
205
+ cursor: z
206
+ .string()
207
+ .optional()
208
+ .describe("action=list: next_cursor from a prior list response for keyset pagination."),
209
+ fields: z
210
+ .enum(["slim", "full"])
211
+ .optional()
212
+ .describe("action=list: slim (default, no params) or full (includes params/error)."),
213
+ label: z.string().optional().describe("action=update: new job label (≤120 chars)."),
214
+ description: z.string().optional().describe("action=update: free-text description (pass \"\" to clear)."),
215
+ tags: z.array(z.string()).optional().describe("action=update: replace tags (pass [] to clear)."),
176
216
  }, async (args) => {
177
- const { action, job_id, job_ids, dataset_id, block_until_sec, poll_interval_sec, wait_finalize } = args;
217
+ 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
218
  // ---- Lifecycle ----
179
219
  if (action === "monitor") {
180
220
  if (!job_id)
@@ -188,17 +228,38 @@ export function registerJobsTool(server, description) {
188
228
  return { content: [{ type: "text", text: formatJobStatusText(job_id, data) }] };
189
229
  }
190
230
  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." }] };
231
+ const listPath = buildJobsListQuery({
232
+ dataset_id, status, job_type, has_results, limit, cursor, fields,
233
+ });
234
+ const data = await apiCall("GET", listPath);
235
+ const { jobs, next_cursor } = extractJobsList(data);
236
+ const lines = jobs.map((job) => formatJobInventoryLine(job));
237
+ if (next_cursor) {
238
+ lines.push("");
239
+ lines.push(`next_cursor: ${next_cursor}`);
240
+ lines.push(`Tip: jobs(action=list, cursor="${next_cursor}", limit=${limit ?? 50}) for the next page.`);
241
+ }
242
+ return {
243
+ content: [{
244
+ type: "text",
245
+ text: lines.length > 0 ? lines.join("\n") : "No jobs found.",
246
+ }],
247
+ };
248
+ }
249
+ if (action === "update") {
250
+ if (!job_id)
251
+ throw new Error("jobs(update) requires job_id");
252
+ const body = {};
253
+ if (label !== undefined)
254
+ body.label = label;
255
+ if (description !== undefined)
256
+ body.description = description;
257
+ if (tags !== undefined)
258
+ body.tags = tags;
259
+ if (Object.keys(body).length === 0) {
260
+ throw new Error("jobs(update) requires at least one of label, description, tags");
201
261
  }
262
+ const data = await apiCall("PATCH", `/v1/jobs/${job_id}`, body);
202
263
  return textResult(data);
203
264
  }
204
265
  if (action === "compare") {
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
3
3
  import { z } from "zod";
4
4
  import { registerAuditedTool } from "../audit.js";
5
5
  import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, pollUntilComplete, tryAttachImage, resetInlineAttachBudget, attachResultsImages, resolveOutputDpi, POLL_RECOLOR_MAX_MS, fmtDensityDiffNode, getCaptionForImage, shouldFetchAllRemainingFigures, mimeForFilename, structuredTextResult, } from "../shared.js";
6
+ import { formatSiomOccupationSummary } from "../siom_occupation_format.js";
6
7
  export function registerResultsTool(server) {
7
8
  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
9
 
@@ -225,7 +226,10 @@ NOT FOR: Jobs that haven't completed (use jobs(action=status) first), or tempora
225
226
  hitStats.active_node_fraction !== undefined
226
227
  ? `Grid BMU occupancy: ${(Number(hitStats.active_node_fraction) * 100).toFixed(1)}% active nodes on the map grid`
227
228
  : "",
228
- ...(siom ? [`SIOM layer coverage (distinct from grid dead nodes) — utilization: ${fmt(siom.utilization)} | dead_fraction: ${fmt(siom.dead_fraction)} | siom_qe: ${fmt(siom.siom_qe)}`] : []),
229
+ ...formatSiomOccupationSummary(siom, {
230
+ files: summary.files ?? [],
231
+ compact: true,
232
+ }),
229
233
  hitStats.grid_suggestion ? `Grid hint: ${String(hitStats.grid_suggestion)}` : "",
230
234
  ...(policy.auto_rules_applied && Array.isArray(policy.auto_rules_applied) && policy.auto_rules_applied.length > 0
231
235
  ? [`Auto policy: ${policy.auto_rules_applied.join(", ")} (${policy.viz_mode ?? "viz"}, metrics=${policy.quality_metrics ?? "?"})`]
@@ -296,19 +300,15 @@ NOT FOR: Jobs that haven't completed (use jobs(action=status) first), or tempora
296
300
  `Coverage Assessment:`,
297
301
  ` Status: ${coverageStatus}${isDegenerate ? " *** NON-COMPETITIVE ***" : isWarning ? " * CAUTION *" : ""}`,
298
302
  ` Diagnosis: ${String(coverage.diagnosis ?? "No plain-language diagnosis available.")}`,
299
- ` Utilization: ${siom.utilization !== undefined ? Number(siom.utilization).toFixed(4) : "N/A"}`,
300
- ` SIOM dead fraction (layer coverage, not grid BMU hits): ${siom.dead_fraction !== undefined ? Number(siom.dead_fraction).toFixed(4) : "N/A"}`,
303
+ ...formatSiomOccupationSummary(siom, { files }),
301
304
  ` Occupied nodes:${hitStats.occupied_nodes !== undefined ? ` ${hitStats.occupied_nodes}` : " N/A"}`,
302
305
  ` Top hit share: ${hitStats.top_hit_share !== undefined ? Number(hitStats.top_hit_share).toFixed(4) : "N/A"}`,
303
- ` Gini: ${siom.gini !== undefined ? Number(siom.gini).toFixed(4) : "N/A"}`,
304
- ` Entropy: ${siom.entropy !== undefined ? Number(siom.entropy).toFixed(4) : "N/A"}`,
305
- ` SIOM gamma: ${siom.gamma !== undefined ? Number(siom.gamma).toFixed(3) : "N/A"} | decay: ${siom.decay !== undefined ? Number(siom.decay).toFixed(4) : "N/A"}`,
306
306
  ...(summary.elastic ? (() => {
307
307
  const el = summary.elastic;
308
308
  return [` Elastic: λ=${el.lambda !== undefined ? Number(el.lambda).toFixed(3) : "0"} μ=${el.mu !== undefined ? Number(el.mu).toFixed(3) : "0"} anchor=${el.anchor !== undefined ? Number(el.anchor).toFixed(3) : "0"}`];
309
309
  })() : []),
310
310
  ``,
311
- `Layout tuning: if Voronoi cells look crowded in weight space (high top_hit_share), reduce max_nodes and/or raise gamma/siom_decay before growth_interval. See training_guidance keys floop_voronoi_crowding, floop_siom_params, floop_elastic_params.`,
311
+ `Layout tuning: if Voronoi cells look crowded in weight space (high top_hit_share), reduce max_nodes and/or raise gamma/siom_decay before growth_interval. See training_guidance keys floop_voronoi_crowding, floop_siom_params, floop_elastic_params, siom_gamma.`,
312
312
  ``,
313
313
  `Quality Metrics:`,
314
314
  ` Quantization Error: ${summary.quantization_error !== undefined ? Number(summary.quantization_error).toFixed(4) : "N/A"}${qeNote}`,
@@ -386,11 +386,11 @@ NOT FOR: Jobs that haven't completed (use jobs(action=status) first), or tempora
386
386
  ...(compLines ? [`Component value ranges:\n${compLines}`] : []),
387
387
  ...(clusterLines ? ["", "Cluster map (defining features):", clusterLines] : []),
388
388
  ...(topCorrLine ? ["", topCorrLine] : []),
389
- ...(siom ? ["", "SIOM layer coverage (distinct from grid dead nodes):",
390
- ` Utilization: ${siom.utilization !== undefined ? Number(siom.utilization).toFixed(4) : "N/A"}`,
391
- ` SIOM dead fraction: ${siom.dead_fraction !== undefined ? Number(siom.dead_fraction).toFixed(4) : "N/A"}`,
392
- ` Gini: ${siom.gini !== undefined ? Number(siom.gini).toFixed(4) : "N/A"}`,
393
- ` Entropy: ${siom.entropy !== undefined ? Number(siom.entropy).toFixed(4) : "N/A"}`] : []),
389
+ ...(siom
390
+ ? ["", ...formatSiomOccupationSummary(siom, {
391
+ files: summary.files ?? [],
392
+ })]
393
+ : []),
394
394
  ``, `Features: ${features.join(", ")}`,
395
395
  summary.selected_columns ? `Selected columns: ${summary.selected_columns.join(", ")}` : "",
396
396
  summary.transforms ? `Transforms: ${Object.entries(summary.transforms).map(([k, v]) => `${k}=${v}`).join(", ")}` : "",