@barivia/barmesh-mcp 0.6.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,12 +79,13 @@ API key; otherwise the analysis calls return HTTP 403. Contact Barivia to enable
79
79
  - **Uploads:** large CSVs use presigned PUT with explicit `Content-Length`; `.csv.gz` /
80
80
  `.tsv.gz` accepted. Pin `@barivia/barmesh-mcp@0.5.2` (clear `~/.npm/_npx` if stale).
81
81
  - **Live progress:** `barmesh_training_monitor(job_id)` or `barmesh_jobs(action=monitor)` block
82
- server-side with compact snapshots (phase, epoch, QE/TE, ETA, ordering_errors tail) until
82
+ server-side with compact snapshots (phase, epoch, QE, **panel/map TE**, ETA, ordering_errors tail) until
83
83
  terminal or `block_until_sec` (default 900). Waits for `cfd_finalize` by default. One-shot:
84
84
  `barmesh_jobs(action=status)`.
85
85
 
86
86
  ### Migration notes
87
87
 
88
+ - **Fixed-panel live TE (0.6.3 / barsom 0.20.4):** mid-training TE uses a fixed evaluation panel (`te_panel_size`; `te_inner_samples` alias). Curves stay on panel TE; monitors show **Panel TE** and **Map TE** separately — no snap-to-map on the curve tail.
88
89
  - **`barmesh_training_monitor` (0.5.3):** server-side blocking monitor with throttled snapshots — preferred after job submit instead of manual `barmesh_jobs(status)` loops. Equivalent to `barmesh_jobs(action=monitor)`.
89
90
  - **`send_feedback` → `barmesh_send_feedback` (0.3.0):** the feedback tool was renamed so it no longer collides with the `@barivia/barsom-mcp` tool of the same name when both servers are enabled in one client. Update any direct call sites; the behavior is unchanged.
90
91
 
package/dist/audit.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * MCP tool audit wrapper — tool name, action, latency, outcome; no secrets.
3
3
  */
4
4
  import { logAudit } from "./logger.js";
5
+ import { runWithTrace } from "./shared.js";
5
6
  const AUDIT_PARAM_KEYS = new Set([
6
7
  "action",
7
8
  "preset",
@@ -87,6 +88,7 @@ export function registerAuditedTool(server, name, description, schema, handler)
87
88
  server.tool(name, description, schema, (async (args) => {
88
89
  const rec = args;
89
90
  const action = typeof rec.action === "string" && rec.action.length > 0 ? rec.action : "default";
90
- return runMcpToolAudit(name, action, rec, () => handler(args));
91
+ // One trace per tool invocation (every apiCall inside shares one trace_id).
92
+ return runWithTrace(() => runMcpToolAudit(name, action, rec, () => handler(args)));
91
93
  }));
92
94
  }
@@ -55,6 +55,14 @@ export function snapshotFromJob(data, elapsedSec, note) {
55
55
  snap.qe = Math.round(qe * 10_000) / 10_000;
56
56
  if (te != null)
57
57
  snap.te = Math.round(te * 10_000) / 10_000;
58
+ const panelTe = num(data, "panel_topographic_error");
59
+ if (panelTe != null)
60
+ snap.panel_te = Math.round(panelTe * 10_000) / 10_000;
61
+ if (data.kernel_complete === true)
62
+ snap.kernel_complete = true;
63
+ const failureStage = str(data, "failure_stage");
64
+ if (failureStage)
65
+ snap.failure_stage = failureStage;
58
66
  const eta = num(data, "training_eta_sec");
59
67
  if (eta != null && eta > 0)
60
68
  snap.eta_sec = Math.round(eta);
@@ -79,7 +87,11 @@ export function shouldRecordSnapshot(prev, next) {
79
87
  return true;
80
88
  if (Math.abs(prev.progress_pct - next.progress_pct) >= 1)
81
89
  return true;
82
- if (prev.qe !== next.qe || prev.te !== next.te)
90
+ if (prev.qe !== next.qe || prev.te !== next.te || prev.panel_te !== next.panel_te)
91
+ return true;
92
+ if (prev.kernel_complete !== next.kernel_complete)
93
+ return true;
94
+ if (prev.failure_stage !== next.failure_stage)
83
95
  return true;
84
96
  return false;
85
97
  }
@@ -93,6 +105,12 @@ export function formatSnapshotLine(s) {
93
105
  parts.push(`QE ${s.qe.toFixed(4)}`);
94
106
  if (s.te != null)
95
107
  parts.push(`Epoch TE ${s.te.toFixed(4)}`);
108
+ if (s.panel_te != null)
109
+ parts.push(`Panel TE ${s.panel_te.toFixed(4)}`);
110
+ if (s.kernel_complete)
111
+ parts.push("kernel complete");
112
+ if (s.failure_stage)
113
+ parts.push(`failure_stage ${s.failure_stage}`);
96
114
  if (s.eta_sec != null)
97
115
  parts.push(`ETA ~${s.eta_sec}s`);
98
116
  if (s.ordering_errors_tail?.length) {
@@ -119,6 +137,22 @@ export function formatMonitorText(result, opts) {
119
137
  lines.push(result.suggested_next_step);
120
138
  return lines.join("\n");
121
139
  }
140
+ function failureStageHint(stage, error) {
141
+ const err = (error ?? "").toLowerCase();
142
+ if (stage === "preprocessing") {
143
+ return "Check barmesh_datasets(action=preview) and prepare job status.";
144
+ }
145
+ if (stage === "training") {
146
+ if (err.includes("memory") || err.includes("readonlymemory")) {
147
+ return "Reduce grid size or batch_size and retrain.";
148
+ }
149
+ return "Review mesh convergence parameters (grid, batch_size, features).";
150
+ }
151
+ if (stage === "visualization" || stage === "upload" || stage === "metrics") {
152
+ return "Re-run barmesh_training_monitor with wait_finalize=true or barmesh_results(action=render) for figures.";
153
+ }
154
+ return "Read the error above before retrying.";
155
+ }
122
156
  function suggestedNextStep(job_id, data) {
123
157
  const status = String(data.status ?? "");
124
158
  if (status === "completed") {
@@ -130,7 +164,9 @@ function suggestedNextStep(job_id, data) {
130
164
  }
131
165
  if (status === "failed") {
132
166
  const stage = str(data, "failure_stage");
133
- return `Job failed${stage ? ` at ${stage}` : ""}. Read the error above before retrying.`;
167
+ const err = str(data, "error");
168
+ const hint = stage ? failureStageHint(stage, err) : "Read the error above before retrying.";
169
+ return `Job failed${stage ? ` at ${stage}` : ""}. ${hint}`;
134
170
  }
135
171
  if (status === "cancelled")
136
172
  return `Job cancelled. Confirm with barmesh_jobs(action=status, job_id="${job_id}").`;
package/dist/logger.js CHANGED
@@ -29,6 +29,9 @@ function emit(fields) {
29
29
  export function logInfo(msg, fields = {}) {
30
30
  emit({ ...fields, level: "info", msg });
31
31
  }
32
+ export function logWarn(msg, fields = {}) {
33
+ emit({ ...fields, level: "warn", msg });
34
+ }
32
35
  export function logAudit(fields) {
33
36
  emit({ ...fields, event: "mcp_tool_call", level: "info", msg: "mcp_tool_call" });
34
37
  }
package/dist/shared.js CHANGED
@@ -7,22 +7,31 @@ import fs from "node:fs/promises";
7
7
  import { createReadStream, createWriteStream } from "node:fs";
8
8
  import { createGzip } from "node:zlib";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
+ import { AsyncLocalStorage } from "node:async_hooks";
10
11
  import { Readable } from "node:stream";
11
12
  import { pipeline } from "node:stream/promises";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
- import { logInfo } from "./logger.js";
16
+ import { logInfo, logWarn } from "./logger.js";
16
17
  // ---------------------------------------------------------------------------
17
18
  // Config
18
19
  // ---------------------------------------------------------------------------
19
- export const API_URL = process.env.BARIVIA_API_URL ?? process.env.BARSOM_API_URL ?? "https://api.barivia.se";
20
- export const API_KEY = process.env.BARIVIA_API_KEY ?? process.env.BARSOM_API_KEY ?? "";
20
+ export const API_URL = process.env.BARIVIA_API_URL ?? "https://api.barivia.se";
21
+ export const API_KEY = process.env.BARIVIA_API_KEY ?? "";
21
22
  export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "60000", 10);
22
23
  export const MAX_RETRIES = 2;
24
+ export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "1000", 10);
23
25
  export const RETRYABLE_STATUS = new Set([502, 503, 504]);
26
+ /** Exponential backoff for transient API retries (attempt 0 → base, 1 → 2×, …). */
27
+ export function retryDelayMs(attempt) {
28
+ return RETRY_BASE_MS * 2 ** attempt;
29
+ }
30
+ function newRequestId() {
31
+ return randomUUID();
32
+ }
24
33
  /** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
25
- export const CLIENT_VERSION = "0.6.2";
34
+ export const CLIENT_VERSION = "0.7.1";
26
35
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
27
36
  /** Large per-cell CSV uploads may exceed the default fetch timeout. */
28
37
  export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
@@ -32,6 +41,21 @@ export const LARGE_UPLOAD_BYTES = 64 * 1024 * 1024; // 64 MB
32
41
  export const PRESIGNED_PUT_TIMEOUT_MS = 30 * 60_000; // 30 min
33
42
  /** Poll window for the async stage_dataset job. */
34
43
  export const POLL_STAGE_MAX_MS = 30 * 60_000; // 30 min
44
+ /** Map a local upload path to the API Content-Type (csv vs tsv; ignores .gz suffix). */
45
+ export function resolveUploadContentType(filePath) {
46
+ const lower = filePath.toLowerCase();
47
+ const base = lower.endsWith(".gz") ? lower.slice(0, -3) : lower;
48
+ return path.extname(base) === ".tsv" ? "text/tab-separated-values" : "text/csv";
49
+ }
50
+ export function suggestMeshDatasetPreview(datasetId) {
51
+ return `barmesh_datasets(action=preview, dataset_id=${datasetId}) to verify mesh, feature, and volume columns.`;
52
+ }
53
+ export function suggestAfterCfdSubmit(jobId, prepSuffix = "") {
54
+ return `Run barmesh_training_monitor(job_id="${jobId}") or barmesh_jobs(action=monitor, job_id="${jobId}")${prepSuffix}; on completion call barmesh_results(action=get, job_id="${jobId}").`;
55
+ }
56
+ export function suggestAfterRender(parentJobId, fmt) {
57
+ return `Figures rendered as ${fmt}. Download with barmesh_results(action=download, job_id="${parentJobId}") or barmesh_results(action=image, job_id="${parentJobId}", filename=<figure>.${fmt}).`;
58
+ }
35
59
  /** Streaming SHA-256 of a file (idempotency key) without reading it into memory. */
36
60
  export async function streamFileSha256(srcPath) {
37
61
  return new Promise((resolve, reject) => {
@@ -79,7 +103,11 @@ export async function putPresignedStream(url, srcPath, contentType, timeoutMs =
79
103
  const resp = await fetch(url, {
80
104
  method: "PUT",
81
105
  body: webStream,
82
- headers: { "Content-Type": contentType, "Content-Length": String(contentLength) },
106
+ headers: {
107
+ "Content-Type": contentType,
108
+ "Content-Length": String(contentLength),
109
+ "Content-Encoding": "gzip",
110
+ },
83
111
  duplex: "half",
84
112
  signal: controller.signal,
85
113
  });
@@ -146,7 +174,7 @@ export async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS)
146
174
  // Path / sandbox helpers
147
175
  // ---------------------------------------------------------------------------
148
176
  export function getSandboxRoot() {
149
- const raw = process.env.BARIVIA_WORKSPACE_ROOT ?? process.env.BARSOM_WORKSPACE_ROOT;
177
+ const raw = process.env.BARIVIA_WORKSPACE_ROOT;
150
178
  if (raw)
151
179
  return path.resolve(process.cwd(), raw);
152
180
  const workspaceFolder = process.env.CURSOR_WORKSPACE_FOLDER ?? process.env.WORKSPACE_FOLDER;
@@ -260,14 +288,33 @@ function throwApiError(status, bodyText, requestId) {
260
288
  err.httpStatus = status;
261
289
  throw err;
262
290
  }
291
+ // ---- Distributed-trace context (W3C traceparent) ----
292
+ // One trace per logical tool action (scoped via AsyncLocalStorage in registerAuditedTool),
293
+ // so the API + job chain reconstruct end-to-end. Fresh span per API call; falls back to a
294
+ // per-call trace if no scope is set. Mirrors the barsom proxy.
295
+ const _traceStore = new AsyncLocalStorage();
296
+ function _newTraceId() {
297
+ return randomUUID().replace(/-/g, "");
298
+ }
299
+ function _newSpanId() {
300
+ return randomUUID().replace(/-/g, "").slice(0, 16);
301
+ }
302
+ /** Run `fn` within a fresh trace scope so all apiCall()s inside share one trace_id. */
303
+ export function runWithTrace(fn) {
304
+ return _traceStore.run(_newTraceId(), fn);
305
+ }
306
+ function _traceparentHeader() {
307
+ return `00-${_traceStore.getStore() ?? _newTraceId()}-${_newSpanId()}-01`;
308
+ }
263
309
  export async function apiCall(method, pathPart, body, extraHeaders, requestTimeoutMs) {
264
310
  const url = `${API_URL}${pathPart}`;
265
311
  const contentType = extraHeaders?.["Content-Type"] ?? "application/json";
266
- const requestId = Math.random().toString(36).slice(2, 10);
312
+ const requestId = newRequestId();
267
313
  const headers = {
268
314
  Authorization: `Bearer ${API_KEY}`,
269
315
  "Content-Type": contentType,
270
316
  "X-Request-ID": requestId,
317
+ traceparent: _traceparentHeader(),
271
318
  "X-Barmesh-Client-Version": CLIENT_VERSION,
272
319
  ...extraHeaders,
273
320
  };
@@ -290,7 +337,17 @@ export async function apiCall(method, pathPart, body, extraHeaders, requestTimeo
290
337
  const text = await resp.text();
291
338
  if (!resp.ok) {
292
339
  if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
293
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
340
+ const delayMs = retryDelayMs(attempt);
341
+ logWarn("API retry", {
342
+ rid: requestId,
343
+ action: method,
344
+ path: pathPart,
345
+ attempt: attempt + 1,
346
+ max_retries: MAX_RETRIES,
347
+ delay_ms: delayMs,
348
+ error_code: `http_${resp.status}`,
349
+ });
350
+ await new Promise((r) => setTimeout(r, delayMs));
294
351
  continue;
295
352
  }
296
353
  logInfo("API response", { rid: requestId, outcome: "error", duration_ms: Date.now() - t0, error_code: `http_${resp.status}` });
@@ -302,7 +359,17 @@ export async function apiCall(method, pathPart, body, extraHeaders, requestTimeo
302
359
  catch (err) {
303
360
  lastError = err;
304
361
  if (attempt < MAX_RETRIES && isTransientError(err)) {
305
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
362
+ const delayMs = retryDelayMs(attempt);
363
+ logWarn("API retry", {
364
+ rid: requestId,
365
+ action: method,
366
+ path: pathPart,
367
+ attempt: attempt + 1,
368
+ max_retries: MAX_RETRIES,
369
+ delay_ms: delayMs,
370
+ error_code: err instanceof Error ? err.name : "fetch_error",
371
+ });
372
+ await new Promise((r) => setTimeout(r, delayMs));
306
373
  continue;
307
374
  }
308
375
  if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
@@ -316,15 +383,24 @@ export async function apiCall(method, pathPart, body, extraHeaders, requestTimeo
316
383
  /** Fetch raw bytes from the API (for image downloads). */
317
384
  export async function apiRawCall(pathPart, requestTimeoutMs) {
318
385
  const url = `${API_URL}${pathPart}`;
319
- const requestId = Math.random().toString(36).slice(2, 10);
386
+ const requestId = newRequestId();
320
387
  const effectiveTimeout = requestTimeoutMs ?? FETCH_TIMEOUT_MS;
321
388
  let lastError;
322
389
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
323
390
  try {
324
- const resp = await fetchWithTimeout(url, { method: "GET", headers: { Authorization: `Bearer ${API_KEY}`, "X-Request-ID": requestId } }, effectiveTimeout);
391
+ const resp = await fetchWithTimeout(url, { method: "GET", headers: { Authorization: `Bearer ${API_KEY}`, "X-Request-ID": requestId, traceparent: _traceparentHeader() } }, effectiveTimeout);
325
392
  if (!resp.ok) {
326
393
  if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
327
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
394
+ const delayMs = retryDelayMs(attempt);
395
+ logWarn("API retry", {
396
+ rid: requestId,
397
+ path: pathPart,
398
+ attempt: attempt + 1,
399
+ max_retries: MAX_RETRIES,
400
+ delay_ms: delayMs,
401
+ error_code: `http_${resp.status}`,
402
+ });
403
+ await new Promise((r) => setTimeout(r, delayMs));
328
404
  continue;
329
405
  }
330
406
  const text = await resp.text();
@@ -339,7 +415,16 @@ export async function apiRawCall(pathPart, requestTimeoutMs) {
339
415
  catch (err) {
340
416
  lastError = err;
341
417
  if (attempt < MAX_RETRIES && isTransientError(err)) {
342
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
418
+ const delayMs = retryDelayMs(attempt);
419
+ logWarn("API retry", {
420
+ rid: requestId,
421
+ path: pathPart,
422
+ attempt: attempt + 1,
423
+ max_retries: MAX_RETRIES,
424
+ delay_ms: delayMs,
425
+ error_code: err instanceof Error ? err.name : "fetch_error",
426
+ });
427
+ await new Promise((r) => setTimeout(r, delayMs));
343
428
  continue;
344
429
  }
345
430
  if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
3
- import { registerAuditedTool, runMcpToolAudit } from "../audit.js";
3
+ import { runMcpToolAudit } from "../audit.js";
4
4
  import { apiCall, apiRawCall, getClientSupportsMcpApps, getVizPort, getCaptionForImage, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
5
5
  import { FIGURE_SECTION_LABELS, FIGURE_SECTION_ORDER, figureSection, sortRank, } from "../figure_sections.js";
6
6
  import { buildMetadataStrip } from "../results_metadata.js";
@@ -178,9 +178,14 @@ export function registerResultsExplorerTool(server) {
178
178
  _meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
179
179
  };
180
180
  registerAppTool(server, "barmesh_results_explorer", toolConfig, async (args) => runMcpToolAudit("barmesh_results_explorer", "default", args, () => handleResultsExplorer(String(args.job_id))));
181
- registerAuditedTool(server, "_barmesh_fetch_figure", "[INTERNAL — host / MCP App only; agents MUST NOT call this.] The Mesh Convergence Results Explorer view uses it to lazy-load one raster figure as base64. From chat, use barmesh_results(action=get) or barmesh_results_explorer instead.", {
182
- job_id: z.string(),
183
- filename: z.string(),
181
+ registerAppTool(server, "_barmesh_fetch_figure", {
182
+ title: "Fetch figure (internal)",
183
+ description: "[INTERNAL — MCP App only; agents MUST NOT call.] Lazy-loads one raster figure as base64 for the Mesh Convergence Results Explorer.",
184
+ inputSchema: {
185
+ job_id: z.string(),
186
+ filename: z.string(),
187
+ },
188
+ _meta: { ui: { resourceUri: RESULTS_EXPLORER_URI, visibility: ["app"] } },
184
189
  }, async ({ job_id, filename }) => {
185
190
  const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "");
186
191
  const mime = mimeForFilename(safeFilename);
package/dist/tools/cfd.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall, textResult } from "../shared.js";
3
+ import { apiCall, textResult, suggestAfterCfdSubmit } from "../shared.js";
4
4
  import { pollCfdPrepareIfPresent } from "../cfd_prepare.js";
5
5
  export function registerCfdTools(server) {
6
6
  registerAuditedTool(server, "barmesh_mesh_convergence", `Run SOM-based mesh-convergence analysis on an uploaded combined per-cell CSV.
@@ -52,7 +52,7 @@ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh
52
52
  const id = data.id;
53
53
  if (id != null) {
54
54
  const prep = data.prepare_job_id != null ? " (dataset prepare complete)" : "";
55
- data.suggested_next_step = `Run barmesh_training_monitor(job_id="${id}") or barmesh_jobs(action=monitor, job_id="${id}")${prep}; on completion call barmesh_results(action=get, job_id="${id}").`;
55
+ data.suggested_next_step = suggestAfterCfdSubmit(String(id), prep);
56
56
  }
57
57
  return textResult(data);
58
58
  });
@@ -85,7 +85,7 @@ COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with diff
85
85
  const data = (await apiCall("POST", "/v1/cfd/richardson", body));
86
86
  const id = data.id;
87
87
  if (id != null)
88
- data.suggested_next_step = `Run barmesh_training_monitor(job_id="${id}") or barmesh_jobs(action=monitor, job_id="${id}"); on completion call barmesh_results(action=get, job_id="${id}").`;
88
+ data.suggested_next_step = suggestAfterCfdSubmit(String(id));
89
89
  return textResult(data);
90
90
  });
91
91
  }
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { registerAuditedTool } from "../audit.js";
7
- import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, } from "../shared.js";
7
+ import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, resolveUploadContentType, suggestMeshDatasetPreview, } from "../shared.js";
8
8
  import { GZIP_UPLOAD_HINT } from "../upload_hints.js";
9
9
  /**
10
10
  * Normalize a nullable string field from the API. Returns "" for absent values,
@@ -91,9 +91,11 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
91
91
  if (!name)
92
92
  throw new Error("barmesh_datasets(upload) requires name.");
93
93
  let body;
94
+ let uploadContentType = "text/csv";
94
95
  if (file_path && file_path.length > 0) {
95
96
  await apiCall("GET", "/v1/system/info");
96
97
  const resolved = await resolveFilePathForUpload(file_path, server);
98
+ uploadContentType = resolveUploadContentType(resolved);
97
99
  const lower = resolved.toLowerCase();
98
100
  const isGzipInput = lower.endsWith(".gz");
99
101
  const baseExt = path.extname(isGzipInput ? lower.slice(0, -3) : lower);
@@ -134,10 +136,10 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
134
136
  id: datasetId,
135
137
  status: init.status,
136
138
  idempotent_replay: true,
137
- suggested_next_step: `barmesh_datasets(action=preview, dataset_id=${datasetId})`,
139
+ suggested_next_step: suggestMeshDatasetPreview(datasetId),
138
140
  });
139
141
  }
140
- await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS, isGzipInput);
142
+ await putPresignedStream(init.upload_url, resolved, init.content_type ?? uploadContentType, PRESIGNED_PUT_TIMEOUT_MS, isGzipInput);
141
143
  const fin = (await apiCall("POST", `/v1/datasets/${datasetId}/finalize`, {}));
142
144
  const jobId = (fin.id ?? fin.job_id);
143
145
  const poll = await pollUntilComplete(jobId, POLL_STAGE_MAX_MS);
@@ -150,21 +152,21 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
150
152
  status: ready ? "ready" : "staging",
151
153
  job_id: jobId,
152
154
  suggested_next_step: ready
153
- ? `barmesh_datasets(action=preview, dataset_id=${datasetId}) to verify mesh, feature, and volume columns.`
154
- : `Still staging; poll barmesh_jobs(action=status, job_id="${jobId}") then barmesh_datasets(action=preview, dataset_id=${datasetId}).`,
155
+ ? suggestMeshDatasetPreview(datasetId)
156
+ : `Still staging; poll barmesh_jobs(action=status, job_id="${jobId}") then ${suggestMeshDatasetPreview(datasetId)}.`,
155
157
  });
156
158
  }
157
159
  if (isGzipInput) {
158
160
  const gzBytes = await fs.readFile(resolved);
159
161
  const data = (await apiCall("POST", "/v1/datasets", gzBytes, {
160
162
  "X-Dataset-Name": name,
161
- "Content-Type": "text/csv",
163
+ "Content-Type": uploadContentType,
162
164
  "Content-Encoding": "gzip",
163
165
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
164
166
  }, UPLOAD_DATASET_TIMEOUT_MS));
165
167
  const gid = data.id ?? data.dataset_id;
166
168
  if (gid != null) {
167
- data.suggested_next_step = `Next: barmesh_datasets(action=preview, dataset_id=${gid}) to verify the mesh, feature, and volume columns.`;
169
+ data.suggested_next_step = suggestMeshDatasetPreview(String(gid));
168
170
  }
169
171
  return textResult(data);
170
172
  }
@@ -179,7 +181,7 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
179
181
  const GZIP_THRESHOLD = 1024 * 1024;
180
182
  const uploadHeaders = {
181
183
  "X-Dataset-Name": name,
182
- "Content-Type": "text/csv",
184
+ "Content-Type": uploadContentType,
183
185
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
184
186
  };
185
187
  let uploadBody = body;
@@ -190,7 +192,7 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
190
192
  const data = (await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS));
191
193
  const id = data.id ?? data.dataset_id;
192
194
  if (id != null) {
193
- data.suggested_next_step = `Next: barmesh_datasets(action=preview, dataset_id=${id}) to verify the mesh, feature, and volume columns.`;
195
+ data.suggested_next_step = suggestMeshDatasetPreview(String(id));
194
196
  }
195
197
  return textResult(data);
196
198
  }
@@ -1,9 +1,19 @@
1
1
  import { registerAuditedTool } from "../audit.js";
2
2
  import { apiCall, textResult } from "../shared.js";
3
3
  const OFFLINE_GUIDE = `barmesh: CFD mesh-convergence on the Barivia API.
4
- Two tracks: barmesh_mesh_convergence (SOM fingerprint distances) and barmesh_richardson (classical GCI).
5
- Workflow: barmesh_prepare_mesh_data -> barmesh_datasets(upload) -> barmesh_mesh_convergence / barmesh_richardson -> barmesh_training_monitor (default visual MCP App; post-hoc review when already done) or barmesh_jobs(monitor/status) headless fallback -> barmesh_results(get) -> barmesh_results_explorer for figures.
6
- (API unreachable; this is the offline summary. Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
4
+
5
+ Two tracks:
6
+ - barmesh_mesh_convergence SOM volume-fingerprint distances (symmetric KL + Wasserstein-1 vs reference and stepwise)
7
+ - barmesh_richardson — classical Richardson / GCI on scalar QoIs
8
+
9
+ Workflow:
10
+ 1. barmesh_prepare_mesh_data — recipe for the combined per-cell CSV (mesh_id + features + cell volume V)
11
+ 2. barmesh_datasets(upload) → barmesh_datasets(preview)
12
+ 3. barmesh_mesh_convergence and/or barmesh_richardson — returns job_id
13
+ 4. barmesh_training_monitor (visual MCP App) or barmesh_jobs(action=monitor) headless — poll until complete
14
+ 5. barmesh_results(action=get) → barmesh_results_explorer for figures
15
+
16
+ (API unreachable; offline summary. Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
7
17
  const OFFLINE_PREP = `barmesh mesh-data prep (offline summary; API unreachable):
8
18
  Build ONE combined per-cell CSV across all meshes of the refinement study:
9
19
  - one row per cell; a mesh label column (mesh_id); the physical channels you want compared (e.g. p, U_mag, k, log_epsilon — log-compress turbulence quantities); and a cell-volume column (V) for fingerprint weighting.
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { registerAuditedTool } from "../audit.js";
5
- import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, textResult, tryAttachImage, resetInlineAttachBudget, pollUntilComplete, POLL_STAGE_MAX_MS, } from "../shared.js";
5
+ import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, textResult, tryAttachImage, resetInlineAttachBudget, pollUntilComplete, POLL_STAGE_MAX_MS, suggestAfterRender, } from "../shared.js";
6
6
  import { formatConvergenceReading } from "../convergence_reading.js";
7
7
  const MESH_DEFAULT_FIGURES = ["combined", "overview_distances", "plot_vol_all_meshes", "plot_vol_steps", "learning_curve"];
8
8
  function formatMeshResultsSummary(jobId, data, summary) {
@@ -84,7 +84,7 @@ NOT FOR: Submitting jobs.`, {
84
84
  status: "completed",
85
85
  render_job_id: renderId,
86
86
  format: fmt,
87
- suggested_next_step: `Figures rendered as ${fmt}. Download with barmesh_results(action=download, job_id="${job_id}") or fetch one file with action=image.`,
87
+ suggested_next_step: suggestAfterRender(job_id, fmt),
88
88
  });
89
89
  }
90
90
  if (action === "image") {
@@ -16,7 +16,7 @@ export function isKernelTrainingComplete(data, status) {
16
16
  return true;
17
17
  return status === "completed";
18
18
  }
19
- export function teCurveLabel(base, _kernelComplete = false) {
19
+ export function teCurveLabel(base) {
20
20
  return `${base} (panel)`;
21
21
  }
22
22
  export function formatCurveSourceNote(data) {