@barivia/barmesh-mcp 0.5.1 → 0.5.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.
package/README.md CHANGED
@@ -56,26 +56,28 @@ API key; otherwise the analysis calls return HTTP 403. Contact Barivia to enable
56
56
  | `barmesh_mesh_convergence` | SOM fingerprint distances (async job). |
57
57
  | `barmesh_richardson` | Richardson/GCI on scalar QoIs (async job). |
58
58
  | `barmesh_jobs` | Poll job status / list jobs (auto-polls CFD prepare + finalize when applicable). Reports phase, epoch/total, elapsed, ETA, and QE live during the SOM training. |
59
- | `barmesh_results` | Distances, convergence reading, and figures. `action=get` inlines a single `combined.png` overview by default; `action=render` produces publication PDFs on demand. |
60
- | `barmesh_results_explorer` | Interactive MCP App (or standalone localhost page) to browse the distances, convergence reading, and every figure after a job completes. |
59
+ | `barmesh_results` | Distances, convergence reading, and figures. `action=get` inlines headline PNGs; `action=download` saves artifacts to disk; `action=render` produces publication PDFs on demand. |
60
+ | `barmesh_results_explorer` | Interactive MCP App with a **figure dropdown above the plot** (or standalone localhost page) to browse every figure after a job completes. |
61
61
  | `barmesh_send_feedback` | Send a short note or bug report to the Barivia team. |
62
62
 
63
- ### Figures and progress (0.5.0)
63
+ ### Figures and progress (0.5.2)
64
64
 
65
- - **Combined overview:** every completed `mesh_convergence` job produces a single
66
- `combined.png` (coarse vs reference volume fingerprint, KL-vs-reference, EMD-vs-reference)
67
- as the headline artifact, alongside per-panel PNG previews.
65
+ - **Combined overview (`combined.png`):** barsom-style **grid of all component planes**
66
+ on the trained SOM the primary headline artifact. A separate
67
+ `overview_distances.png` holds the KL/EMD/volume diagnostic mosaic.
68
+ - **Learning curve:** every job produces `learning_curve.png` (QE by epoch) for training
69
+ quality inspection; listed in the results explorer dropdown.
68
70
  - **PDFs on demand:** publication vector PDFs are NOT generated by default. Render them
69
- after completion with `barmesh_results(action=render, format=pdf)`, then download each
70
- with `barmesh_results(action=image, filename="KL_ref.pdf")`. This avoids generating
71
- ~2× the artifacts (and storage/finalize cost) on every run.
72
- - **Adaptive layout:** the stepwise volume panels use a single row for small studies and
73
- auto-wrap into a near-square grid for many meshes; figures are not cavity-specific.
71
+ after completion with `barmesh_results(action=render, format=pdf)`, then download with
72
+ `barmesh_results(action=download, folder=...)` or `action=image`.
73
+ - **Headless download:** `barmesh_results(action=download, folder=..., include_json=true)`
74
+ writes PNGs, `summary.json`, and text/CSV artifacts to a per-job subfolder (barsom parity).
75
+ - **Explorer UX:** figure `<select>` above the plot (no scrolling past metrics to switch
76
+ figures). PNG previews inline; PDF/SVG offered for download when rendered.
77
+ - **Uploads:** large CSVs use presigned PUT with explicit `Content-Length`; `.csv.gz` /
78
+ `.tsv.gz` accepted. Pin `@barivia/barmesh-mcp@0.5.2` (clear `~/.npm/_npx` if stale).
74
79
  - **Live progress:** `barmesh_jobs(action=status)` exposes phase, epoch/total, elapsed,
75
- ETA, and quantization error during the SOM training (barsom parity).
76
- - **Results explorer:** `barmesh_results_explorer(job_id)` opens an interactive figure
77
- browser. It embeds as an MCP App in supporting hosts (Claude, VS Code, etc.) and falls
78
- back to a standalone `localhost` page otherwise (set `BARIVIA_VIZ_PORT` for a stable port).
80
+ ETA, QE during SOM training, plus cfd_finalize sub-status when figures are deferred.
79
81
 
80
82
  ### Migration notes
81
83
 
@@ -3,6 +3,7 @@
3
3
  * Surfaces phase, elapsed, ETA, and epoch N/M during the SOM training of a
4
4
  * cfd_mesh_convergence job, plus CFD finalize (figure render) sub-status.
5
5
  */
6
+ import { apiCall } from "./shared.js";
6
7
  function formatElapsedPart(data, status) {
7
8
  const wall = data.wall_elapsed_sec != null && !Number.isNaN(Number(data.wall_elapsed_sec))
8
9
  ? Number(data.wall_elapsed_sec)
@@ -32,7 +33,27 @@ function formatElapsedPart(data, status) {
32
33
  }
33
34
  return null;
34
35
  }
35
- export function formatJobStatusText(job_id, data) {
36
+ async function finalizeStatusLine(finalizeId) {
37
+ try {
38
+ const fin = (await apiCall("GET", `/v1/jobs/${finalizeId}`));
39
+ const st = String(fin.status ?? "unknown");
40
+ const prog = fin.progress != null ? `${(Number(fin.progress) * 100).toFixed(0)}%` : null;
41
+ const phase = fin.progress_phase != null ? String(fin.progress_phase) : null;
42
+ const elapsed = formatElapsedPart(fin, st);
43
+ const bits = [`cfd_finalize ${finalizeId}: ${st}`];
44
+ if (prog)
45
+ bits.push(prog);
46
+ if (phase)
47
+ bits.push(`phase ${phase}`);
48
+ if (elapsed)
49
+ bits.push(elapsed);
50
+ return bits.join(", ");
51
+ }
52
+ catch {
53
+ return `cfd_finalize ${finalizeId} still running (poll barmesh_jobs(action=status, job_id="${finalizeId}"))`;
54
+ }
55
+ }
56
+ export async function formatJobStatusText(job_id, data) {
36
57
  const status = String(data.status ?? "unknown");
37
58
  const progress = (data.progress ?? 0) * 100;
38
59
  const label = data.label != null && data.label !== "" ? String(data.label) : null;
@@ -72,7 +93,8 @@ export function formatJobStatusText(job_id, data) {
72
93
  if (status === "completed") {
73
94
  const finalizeId = data.finalize_job_id != null && String(data.finalize_job_id) !== "" ? String(data.finalize_job_id) : null;
74
95
  if (finalizeId) {
75
- parts.push(`Compute done; cfd_finalize still rendering figures (finalize_job_id=${finalizeId}). Poll barmesh_jobs(action=status) again to auto-wait before barmesh_results(action=get).`);
96
+ parts.push(await finalizeStatusLine(finalizeId));
97
+ parts.push(`Compute done; figures may still be uploading. Poll barmesh_jobs(status) until finalize completes before barmesh_results(get).`);
76
98
  }
77
99
  else {
78
100
  parts.push(`Results ready. Use barmesh_results(action=get, job_id="${job_id}") for distances and figures, or barmesh_results_explorer(job_id="${job_id}") to browse interactively.`);
package/dist/shared.js CHANGED
@@ -22,7 +22,7 @@ export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ??
22
22
  export const MAX_RETRIES = 2;
23
23
  export const RETRYABLE_STATUS = new Set([502, 503, 504]);
24
24
  /** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
25
- export const CLIENT_VERSION = "0.5.1";
25
+ export const CLIENT_VERSION = "0.5.2";
26
26
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
27
27
  /** Large per-cell CSV uploads may exceed the default fetch timeout. */
28
28
  export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
@@ -157,6 +157,14 @@ export function getSandboxRoot() {
157
157
  return pwd;
158
158
  return process.cwd();
159
159
  }
160
+ export function sandboxPath(userPath, root) {
161
+ const resolved = path.resolve(root, userPath);
162
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
163
+ throw new Error("Path must be within the workspace directory. " +
164
+ "Set BARIVIA_WORKSPACE_ROOT to your project path if needed.");
165
+ }
166
+ return resolved;
167
+ }
160
168
  export async function getWorkspaceRootAsync(mcpServer) {
161
169
  try {
162
170
  const result = await mcpServer.server.listRoots();
@@ -378,6 +386,12 @@ export function mimeForFilename(fname) {
378
386
  /** Per-image caption for multimodal LLM context (what to look for). */
379
387
  export function getCaptionForImage(filename) {
380
388
  const base = filename.replace(/\.(png|pdf|svg)$/i, "");
389
+ if (base === "combined")
390
+ return "All feature component planes on the trained SOM in one grid — primary comparison artifact (barsom-style combined view).";
391
+ if (base === "overview_distances")
392
+ return "Diagnostic mosaic: volume fingerprints plus KL and EMD vs the reference mesh.";
393
+ if (base === "learning_curve")
394
+ return "Quantization error by epoch: ordering phase (steel blue) then convergence (coral).";
381
395
  if (base === "KL_ref")
382
396
  return "KL divergence to the reference mesh: steel blue is from the reference, coral is to the reference. Lower and converging toward the finest mesh indicates sufficiency.";
383
397
  if (base === "KL_stepwise")
@@ -3,10 +3,47 @@ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
3
3
  import { registerAuditedTool, runMcpToolAudit } from "../audit.js";
4
4
  import { apiCall, apiRawCall, getClientSupportsMcpApps, getVizPort, getCaptionForImage, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
5
5
  export const RESULTS_EXPLORER_URI = "ui://barmesh/results-explorer";
6
+ const FIGURE_SORT_RANK = {
7
+ combined: 0,
8
+ overview_distances: 1,
9
+ KL_ref: 10,
10
+ KL_stepwise: 11,
11
+ EMD_ref: 12,
12
+ EMD_stepwise: 13,
13
+ learning_curve: 14,
14
+ plot_vol_coarse_fine: 20,
15
+ };
16
+ function sortRank(key) {
17
+ if (key in FIGURE_SORT_RANK)
18
+ return FIGURE_SORT_RANK[key];
19
+ if (key.startsWith("plot_vol"))
20
+ return 25;
21
+ if (key.startsWith("plot_"))
22
+ return 30;
23
+ return 50;
24
+ }
25
+ export function sortFigures(figures) {
26
+ return [...figures].sort((a, b) => sortRank(a.key) - sortRank(b.key) || a.label.localeCompare(b.label));
27
+ }
28
+ export function resolveDefaultFigureKey(figures) {
29
+ const byKey = (k) => figures.find((f) => f.key === k);
30
+ if (byKey("combined"))
31
+ return "combined";
32
+ const raster = figures.find((f) => /\.(png|svg)$/i.test(f.filename));
33
+ if (raster)
34
+ return raster.key;
35
+ if (byKey("KL_ref"))
36
+ return "KL_ref";
37
+ return figures[0]?.key;
38
+ }
6
39
  function labelForFigure(filename) {
7
40
  const base = filename.replace(/\.(png|pdf|svg)$/i, "");
8
41
  if (base === "combined")
9
- return "Combined overview";
42
+ return "Component planes (overview)";
43
+ if (base === "overview_distances")
44
+ return "Distances & volume overview";
45
+ if (base === "learning_curve")
46
+ return "Learning curve (QE by epoch)";
10
47
  if (base === "KL_ref")
11
48
  return "KL divergence vs reference";
12
49
  if (base === "KL_stepwise")
@@ -65,10 +102,9 @@ function buildHighlights(summary) {
65
102
  /**
66
103
  * Group `<base>.<ext>` figure files into one logical figure per base, collecting the
67
104
  * available formats. Preview is the inline-displayable raster (PNG, else SVG); the
68
- * download target is the best quality (SVG > PDF > PNG). Order follows first appearance
69
- * in `files` (combined first, then diagnostics, then component planes).
105
+ * download target is the best quality (SVG > PDF > PNG).
70
106
  */
71
- function groupFigures(files) {
107
+ export function groupFigures(files) {
72
108
  const byBase = new Map();
73
109
  for (const f of files) {
74
110
  const m = f.match(/^(.*)\.(png|pdf|svg)$/i);
@@ -85,10 +121,14 @@ function groupFigures(files) {
85
121
  const preview = extSet.has("png") ? `${base}.png` : extSet.has("svg") ? `${base}.svg` : `${base}.${formats[0]}`;
86
122
  const downloadFilename = extSet.has("svg") ? `${base}.svg` : extSet.has("pdf") ? `${base}.pdf` : `${base}.png`;
87
123
  const vector = formats.filter((e) => e === "pdf" || e === "svg");
124
+ const vectorOnly = !/\.(png|svg)$/i.test(preview);
88
125
  const baseCaption = getCaptionForImage(preview) || "";
89
126
  const vectorNote = vector.length > 0
90
127
  ? `High-quality ${vector.join("/").toUpperCase()} available — download it (standalone page) or fetch with barmesh_results(action=image, filename="${downloadFilename}").`
91
128
  : "";
129
+ const emptyNote = vectorOnly
130
+ ? "Vector figure — no inline preview. Use Download or barmesh_results(action=render, format=png) for a raster."
131
+ : "";
92
132
  return {
93
133
  key: base,
94
134
  label: labelForFigure(preview),
@@ -96,17 +136,14 @@ function groupFigures(files) {
96
136
  downloadFilename,
97
137
  formats,
98
138
  kind: figureKind(base),
99
- caption: [baseCaption, vectorNote].filter(Boolean).join(" ") || undefined,
139
+ caption: [baseCaption, vectorNote, emptyNote].filter(Boolean).join(" ") || undefined,
100
140
  };
101
141
  });
102
142
  }
103
143
  export function buildPayload(jobId, data) {
104
144
  const summary = (data.summary ?? {});
105
145
  const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
106
- // Embedded view: never set `url` (sandboxed iframe cannot load localhost) — figures
107
- // lazy-load as base64 through the _barmesh_fetch_figure bridge tool. One entry per
108
- // logical figure; PNG previews inline, vector (PDF/SVG) is offered for download.
109
- const figures = groupFigures(files);
146
+ const figures = sortFigures(groupFigures(files));
110
147
  const port = getVizPort();
111
148
  return {
112
149
  type: "barmesh-results-explorer",
@@ -116,9 +153,7 @@ export function buildPayload(jobId, data) {
116
153
  metrics: buildMetrics(summary),
117
154
  highlights: buildHighlights(summary),
118
155
  availableFigures: figures,
119
- defaultFigureKey: figures.find((f) => f.key === "combined")?.key ??
120
- figures.find((f) => f.key === "KL_ref")?.key ??
121
- figures[0]?.key,
156
+ defaultFigureKey: resolveDefaultFigureKey(figures),
122
157
  standaloneUrl: port
123
158
  ? `http://localhost:${port}/viz/barmesh-results-explorer?mode=standalone&job_id=${jobId}`
124
159
  : undefined,
@@ -140,7 +175,7 @@ async function handleResultsExplorer(job_id) {
140
175
  },
141
176
  ];
142
177
  const defaultFigure = payload.availableFigures.find((f) => f.key === payload.defaultFigureKey);
143
- if (defaultFigure) {
178
+ if (defaultFigure && /\.(png|svg|jpe?g|webp)$/i.test(defaultFigure.filename)) {
144
179
  await tryAttachImage(content, job_id, defaultFigure.filename);
145
180
  }
146
181
  if (!getClientSupportsMcpApps() && payload.standaloneUrl) {
@@ -156,16 +191,13 @@ async function handleResultsExplorer(job_id) {
156
191
  export function registerResultsExplorerTool(server) {
157
192
  const toolConfig = {
158
193
  title: "Mesh Convergence Results Explorer",
159
- description: "PREFERRED way to browse a completed mesh_convergence job after a first barmesh_results(get) glance. Interactive explorer of the distances, convergence reading, and every figure (combined overview, KL/EMD vs reference and stepwise, volume fingerprints, component planes). Each figure shows its PNG preview inline (PDFs cannot render in the sandboxed panel) and offers the best-quality vector (PDF/SVG) for download when it has been rendered (barmesh_results(action=render, format=pdf)). Embeds as an MCP App or falls back to a standalone localhost page. barmesh_results(action=get) remains the headless/metrics path.",
194
+ description: "PREFERRED way to browse a completed mesh_convergence job after a first barmesh_results(get) glance. Interactive explorer with a figure dropdown above the plot (component-plane mosaic headline, distances, volume fingerprints, learning curve, per-feature planes). Each figure shows its PNG preview inline (PDFs cannot render in the sandboxed panel) and offers the best-quality vector (PDF/SVG) for download when rendered (barmesh_results(action=render, format=pdf)). Embeds as an MCP App or falls back to a standalone localhost page. barmesh_results(action=get) remains the headless/metrics path.",
160
195
  inputSchema: {
161
196
  job_id: z.string().describe("Job ID of a completed cfd_mesh_convergence job"),
162
197
  },
163
198
  _meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
164
199
  };
165
200
  registerAppTool(server, "barmesh_results_explorer", toolConfig, async (args) => runMcpToolAudit("barmesh_results_explorer", "default", args, () => handleResultsExplorer(String(args.job_id))));
166
- // MUST stay a registered tool: the explorer App view lazy-loads non-default raster
167
- // figures through the App bridge (mcpApp.callServerTool), which can only reach
168
- // registered server tools. Agents must not call it directly from chat.
169
201
  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.", {
170
202
  job_id: z.string(),
171
203
  filename: z.string(),
@@ -18,13 +18,13 @@ ESCALATION: status=failed returns an error message and (when available) a failur
18
18
  throw new Error("barmesh_jobs(status) requires job_id.");
19
19
  let data = (await apiCall("GET", `/v1/jobs/${job_id}`));
20
20
  const status = String(data.status ?? "");
21
+ let note = null;
21
22
  if (status === "completed" && data.finalize_job_id) {
22
- const { note } = await pollCfdFinalizeIfPresent(job_id, data);
23
+ ({ note } = await pollCfdFinalizeIfPresent(job_id, data));
23
24
  data = await refreshJobAfterFinalize(job_id);
24
- const statusText = note ? `${formatJobStatusText(job_id, data)}\n${note}` : formatJobStatusText(job_id, data);
25
- return textResult({ ...data, status_text: statusText });
26
25
  }
27
- return textResult({ ...data, status_text: formatJobStatusText(job_id, data) });
26
+ const statusText = await formatJobStatusText(job_id, data);
27
+ return textResult({ ...data, status_text: note ? `${statusText}\n${note}` : statusText });
28
28
  }
29
29
  const data = await apiCall("GET", "/v1/jobs");
30
30
  return textResult(data);
@@ -1,32 +1,44 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { z } from "zod";
2
4
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall, apiRawCall, textResult, tryAttachImage, pollUntilComplete, POLL_STAGE_MAX_MS } from "../shared.js";
4
- // Headline figures when present, best first: the combined overview (barsom parity),
5
- // then KL/EMD-vs-reference and the coarse/fine volume pair.
6
- const MESH_DEFAULT_FIGURES = ["combined", "KL_ref", "EMD_ref", "plot_vol_coarse_fine"];
5
+ import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, textResult, tryAttachImage, pollUntilComplete, POLL_STAGE_MAX_MS, } from "../shared.js";
6
+ const MESH_DEFAULT_FIGURES = ["combined", "overview_distances", "KL_ref", "EMD_ref", "plot_vol_coarse_fine", "learning_curve"];
7
+ const TEXT_ARTIFACTS = [
8
+ "summary.json",
9
+ "distances.csv",
10
+ "training_metrics.txt",
11
+ "distances_to_ref.txt",
12
+ "emd_stepwise.txt",
13
+ "cfd_metrics.json",
14
+ ];
7
15
  export function registerResultsTool(server) {
8
16
  registerAuditedTool(server, "barmesh_results", `Fetch results of a completed CFD job: distances, convergence reading, and figures.
9
17
 
10
18
  | Action | Use when |
11
19
  |--------|----------|
12
20
  | get | Read the summary (distances per mesh, stepwise, convergence reading) and inline key figures. |
13
- | image | Download one figure by filename (e.g. KL_ref.png, EMD_stepwise.pdf). |
21
+ | image | Download one figure by filename (e.g. KL_ref.png, combined.pdf). |
14
22
  | render | Re-render the figures as publication PDFs (or SVG) on demand — PDFs are NOT generated by default. |
23
+ | download | Save figures and metrics to a local folder (headless / agent path). |
15
24
 
16
- BEST FOR: After barmesh_jobs(action=status) shows completed.
17
- FIGURES: Default job output is metrics + PNG previews, including a single combined.png overview. action=get inlines the combined headline plus KL_ref/EMD_ref/volume PNGs with captions; pass figures="all" for every PNG, "none" for metrics only, or a list of names.
18
- PDFs ON DEMAND: vector PDFs are not produced by default. Use action=render (format=pdf) once, then action=image to download each <name>.pdf. For an interactive browser use barmesh_results_explorer.
25
+ BEST FOR: After barmesh_jobs(action=status) shows completed (and finalize finished if defer_figures was used).
26
+ FIGURES: Default output includes combined.png (component-plane mosaic), overview_distances.png, learning_curve.png, and per-panel PNGs. action=get inlines headline panels; pass figures="all" for every PNG, "none" for metrics only.
27
+ PDFs ON DEMAND: vector PDFs are not produced by default. Use action=render (format=pdf) once, then action=image or action=download.
28
+ If GET /v1/results returns 404 shortly after status=completed, cfd_finalize may still be rendering — poll barmesh_jobs(status) until finalize_job_id completes.
19
29
  NOT FOR: Submitting jobs.`, {
20
- action: z.enum(["get", "image", "render"]).describe("get: summary + figures; image: download one figure file; render: re-render figures as PDF/SVG on demand"),
30
+ action: z.enum(["get", "image", "render", "download"]).describe("get: summary + figures; image: one file; render: lazy PDF/SVG; download: save to disk"),
21
31
  job_id: z.string().describe("Job ID"),
22
32
  filename: z.string().optional().describe("For action=image: the figure filename (e.g. KL_ref.png or, after render, KL_ref.pdf)"),
23
33
  format: z.enum(["pdf", "png", "svg"]).optional().describe("For action=render: output format to generate (default pdf). PNG previews already exist by default."),
24
34
  figures: z
25
35
  .union([z.enum(["default", "all", "none"]), z.array(z.string())])
26
36
  .optional()
27
- .describe("For action=get: which figures to inline (default headline panels; 'all'; 'none'; or a list of names)"),
37
+ .describe("For action=get/download: which figures (default headline panels; 'all'; 'none'; or a list of base names)"),
38
+ folder: z.string().optional().describe("For action=download: directory to save into (relative to workspace). Files land in a per-job subfolder."),
39
+ include_json: z.boolean().optional().describe("For action=download: also save summary.json and text/CSV artifacts"),
28
40
  }, async (args) => {
29
- const { action, job_id, filename, format, figures } = args;
41
+ const { action, job_id, filename, format, figures, folder, include_json } = args;
30
42
  if (action === "render") {
31
43
  const fmt = format ?? "pdf";
32
44
  const submit = (await apiCall("POST", "/v1/cfd/render", { job_id, format: fmt }));
@@ -44,7 +56,7 @@ NOT FOR: Submitting jobs.`, {
44
56
  status: "completed",
45
57
  render_job_id: renderId,
46
58
  format: fmt,
47
- suggested_next_step: `Figures rendered as ${fmt}. Download one with barmesh_results(action=image, job_id="${job_id}", filename="KL_ref.${fmt}"), or list filenames via barmesh_results(action=get, job_id="${job_id}").`,
59
+ suggested_next_step: `Figures rendered as ${fmt}. Download with barmesh_results(action=download, job_id="${job_id}") or fetch one file with action=image.`,
48
60
  });
49
61
  }
50
62
  if (action === "image") {
@@ -61,7 +73,87 @@ NOT FOR: Submitting jobs.`, {
61
73
  }
62
74
  return textResult(`${filename} is ${contentType}; saved out-of-band. For vector PDFs, open the downloaded file directly.`);
63
75
  }
64
- const summary = (await apiCall("GET", `/v1/results/${job_id}`));
76
+ if (action === "download") {
77
+ if (!folder)
78
+ throw new Error("barmesh_results(download) requires folder");
79
+ let data;
80
+ try {
81
+ data = (await apiCall("GET", `/v1/results/${job_id}`));
82
+ }
83
+ catch (err) {
84
+ const msg = err instanceof Error ? err.message : String(err);
85
+ if (/404|not found/i.test(msg)) {
86
+ return textResult({
87
+ error: "results_not_ready",
88
+ job_id,
89
+ note: `Results not found yet. If barmesh_jobs(status) shows completed with a finalize_job_id, poll status until finalize completes, then retry download.`,
90
+ });
91
+ }
92
+ throw err;
93
+ }
94
+ const summary = (data.summary ?? data);
95
+ const jobLabel = data.label != null && data.label !== "" ? String(data.label) : null;
96
+ const files = summary.files ?? [];
97
+ const isImage = (f) => /\.(png|svg|pdf)$/i.test(f);
98
+ let toDownload;
99
+ if (figures === "all" || figures === undefined) {
100
+ toDownload = include_json ? files : files.filter(isImage);
101
+ }
102
+ else if (Array.isArray(figures)) {
103
+ toDownload = figures.flatMap((key) => {
104
+ if (/\.(png|pdf|svg|csv|txt|json)$/i.test(key))
105
+ return [key];
106
+ return files.filter((f) => f.replace(/\.[^.]+$/, "") === key || f === key);
107
+ });
108
+ }
109
+ else if (figures === "none") {
110
+ toDownload = TEXT_ARTIFACTS.filter((f) => files.includes(f));
111
+ }
112
+ else {
113
+ toDownload = MESH_DEFAULT_FIGURES.flatMap((b) => files.filter((f) => f.startsWith(`${b}.`)));
114
+ }
115
+ if (include_json) {
116
+ for (const t of TEXT_ARTIFACTS) {
117
+ if (files.includes(t) && !toDownload.includes(t))
118
+ toDownload.push(t);
119
+ }
120
+ }
121
+ toDownload = [...new Set(toDownload)];
122
+ let resolvedDir = sandboxPath(folder, await getWorkspaceRootAsync(server));
123
+ const jobSubfolder = `cfd_mesh_convergence_${jobLabel ?? job_id}`.replace(/[^a-zA-Z0-9_.-]/g, "_");
124
+ resolvedDir = path.join(resolvedDir, jobSubfolder);
125
+ await fs.mkdir(resolvedDir, { recursive: true });
126
+ const saved = [];
127
+ for (const fname of toDownload) {
128
+ try {
129
+ const { data: buf } = await apiRawCall(`/v1/results/${job_id}/image/${fname}`);
130
+ await fs.writeFile(path.join(resolvedDir, fname), buf);
131
+ saved.push(fname);
132
+ }
133
+ catch {
134
+ /* skip missing */
135
+ }
136
+ }
137
+ const savedDir = path.join(folder, jobSubfolder);
138
+ return textResult(saved.length > 0
139
+ ? `Saved ${saved.length} file(s) to ${savedDir}: ${saved.join(", ")}`
140
+ : `No files saved. Check job_id and that the job (and cfd_finalize, if any) is completed.`);
141
+ }
142
+ let summary;
143
+ try {
144
+ summary = (await apiCall("GET", `/v1/results/${job_id}`));
145
+ }
146
+ catch (err) {
147
+ const msg = err instanceof Error ? err.message : String(err);
148
+ if (/404|not found/i.test(msg)) {
149
+ return textResult({
150
+ error: "results_not_ready",
151
+ job_id,
152
+ note: `Results not available yet (HTTP 404). Compute may be done but cfd_finalize is still rendering figures — poll barmesh_jobs(action=status, job_id="${job_id}") until finalize_job_id completes, then retry barmesh_results(action=get).`,
153
+ });
154
+ }
155
+ throw err;
156
+ }
65
157
  const content = [{ type: "text", text: JSON.stringify(summary, null, 2) }];
66
158
  if (figures === "none")
67
159
  return { content };
@@ -82,7 +174,6 @@ NOT FOR: Submitting jobs.`, {
82
174
  .filter((f) => f != null);
83
175
  }
84
176
  else {
85
- // default headline panels (PNG) when present
86
177
  toFetch = MESH_DEFAULT_FIGURES.map((b) => `${b}.png`).filter((f) => imageFiles.includes(f));
87
178
  }
88
179
  for (const f of toFetch) {