@barivia/barmesh-mcp 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,7 +65,8 @@ API key; otherwise the analysis calls return HTTP 403. Contact Barivia to enable
65
65
 
66
66
  - **Combined overview (`combined.png`):** barsom-style **grid of all component planes**
67
67
  on the trained SOM — the primary headline artifact. A separate
68
- `overview_distances.png` holds the KL/EMD/volume diagnostic mosaic.
68
+ `overview_distances.png` holds the KL/EMD diagnostic mosaic (all four distance panels).
69
+ `plot_vol_all_meshes.png` shows volume fingerprints for every mesh in mesh_order.
69
70
  - **Learning curve:** every job produces `learning_curve.png` (QE by epoch) for training
70
71
  quality inspection; listed in the results explorer dropdown.
71
72
  - **PDFs on demand:** publication vector PDFs are NOT generated by default. Render them
@@ -0,0 +1,34 @@
1
+ import { resetInlineAttachBudget, structuredTextResult, tryAttachImage, } from "./shared.js";
2
+ import { DEFAULT_BLOCK_UNTIL_SEC, DEFAULT_POLL_INTERVAL_SEC, formatMonitorText, monitorJob, } from "./job_monitor.js";
3
+ /** Headless blocking monitor for barmesh_jobs(action=monitor). */
4
+ export async function runBlockingMonitor(args) {
5
+ const block_until_sec = args.block_until_sec ?? DEFAULT_BLOCK_UNTIL_SEC;
6
+ const poll_interval_sec = args.poll_interval_sec ?? DEFAULT_POLL_INTERVAL_SEC;
7
+ const result = await monitorJob(args.job_id, {
8
+ block_until_sec,
9
+ poll_interval_sec,
10
+ wait_finalize: args.wait_finalize,
11
+ });
12
+ const reviewMode = result.data.monitor_mode === "review";
13
+ const text = reviewMode
14
+ ? String(result.data.status_text ?? "")
15
+ : formatMonitorText(result, { block_until_sec, poll_interval_sec });
16
+ resetInlineAttachBudget();
17
+ const content = [{ type: "text", text }];
18
+ if (reviewMode && result.terminal && String(result.data.status) === "completed") {
19
+ await tryAttachImage(content, args.job_id, "learning_curve.png");
20
+ }
21
+ return structuredTextResult({
22
+ ...result.data,
23
+ monitor: {
24
+ job_id: result.job_id,
25
+ terminal: result.terminal,
26
+ timed_out: result.timed_out,
27
+ mode: reviewMode ? "review" : "live",
28
+ snapshots: result.snapshots,
29
+ status_text: result.status_text,
30
+ suggested_next_step: result.suggested_next_step,
31
+ },
32
+ status_text: text,
33
+ }, undefined, content);
34
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Single canonical convergence reading formatter (summary.json / cfd_metrics fields).
3
+ * Used by barmesh_results(get) only — explorer does not repeat this prose.
4
+ */
5
+ export function formatConvergenceReading(summary) {
6
+ const convergence = summary.convergence;
7
+ if (!convergence)
8
+ return null;
9
+ const emd = convergence.emd;
10
+ const skl = convergence.skl;
11
+ const ref = String(summary.reference_mesh ?? "reference");
12
+ const parts = [];
13
+ if (emd) {
14
+ const plateau = emd.plateau === true ? "plateauing" : "not plateauing";
15
+ const mono = emd.monotonic_decrease === true ? "monotonic decrease" : "non-monotonic";
16
+ parts.push(`EMD vs ${ref}: ${mono}, ${plateau}`);
17
+ }
18
+ if (skl) {
19
+ const plateau = skl.plateau === true ? "plateauing" : "not plateauing";
20
+ parts.push(`SKL vs ${ref}: ${plateau}`);
21
+ }
22
+ if (parts.length === 0)
23
+ return null;
24
+ return parts.join("; ");
25
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Canonical figure taxonomy for barmesh_results_explorer dropdown sections.
3
+ */
4
+ export const FIGURE_SECTION_ORDER = [
5
+ "overview",
6
+ "distances",
7
+ "diagnostics",
8
+ "component",
9
+ ];
10
+ export const FIGURE_SECTION_LABELS = {
11
+ overview: "Overview",
12
+ distances: "Distances",
13
+ diagnostics: "Diagnostics",
14
+ component: "Component planes",
15
+ };
16
+ /** Map a figure base key into one of four explorer sections. */
17
+ export function figureSection(key) {
18
+ if (key === "combined")
19
+ return "overview";
20
+ if (key === "overview_distances" ||
21
+ key.startsWith("KL_") ||
22
+ key.startsWith("EMD_")) {
23
+ return "distances";
24
+ }
25
+ if (key === "learning_curve" || key.startsWith("plot_vol"))
26
+ return "diagnostics";
27
+ if (key.startsWith("plot_"))
28
+ return "component";
29
+ return "diagnostics";
30
+ }
31
+ export const FIGURE_SORT_RANK = {
32
+ combined: 0,
33
+ overview_distances: 1,
34
+ KL_ref: 10,
35
+ KL_stepwise: 11,
36
+ EMD_ref: 12,
37
+ EMD_stepwise: 13,
38
+ learning_curve: 14,
39
+ plot_vol_all_meshes: 19,
40
+ plot_vol_coarse_fine: 20,
41
+ };
42
+ export function sortRank(key) {
43
+ if (key in FIGURE_SORT_RANK)
44
+ return FIGURE_SORT_RANK[key];
45
+ if (key.startsWith("plot_vol"))
46
+ return 25;
47
+ if (key.startsWith("plot_"))
48
+ return 30;
49
+ return 50;
50
+ }
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as r}from"@modelcontextprotocol/sdk/server/stdio.js";import{getUiCapability as s,registerAppResource as o,RESOURCE_MIME_TYPE as t}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as n}from"./viz-server.js";import{API_KEY as i,CLIENT_VERSION as a,apiCall as c,apiRawCall as m,loadViewHtml as l,setVizPort as d,setClientSupportsMcpApps as h}from"./shared.js";import{registerGuideTool as p}from"./tools/guide.js";import{registerDatasetsTool as b}from"./tools/datasets.js";import{registerCfdTools as f}from"./tools/cfd.js";import{registerJobsTool as u}from"./tools/jobs.js";import{registerResultsTool as _}from"./tools/results.js";import{registerResultsExplorerTool as v,RESULTS_EXPLORER_URI as g}from"./tools/barmesh_results_explorer.js";import{registerTrainingMonitorTool as y}from"./tools/training_monitor.js";import{registerFeedbackTool as j}from"./tools/feedback.js";i||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));(async function(){const i=new e({name:"barmesh",version:a},{instructions:"# Barivia barmesh — CFD mesh-convergence analytics\n\nSOM-based mesh-convergence verification: compare CFD meshes of a refinement study by the\nvolume-weighted distribution their cells form on a shared self-organizing map, plus\nclassical Richardson/GCI on scalar quantities.\n\n## Two tracks\n- barmesh_mesh_convergence: high-dimensional field comparison. Symmetric KL and\n Wasserstein-1 (EMD) distances between each mesh's SOM fingerprint and a reference,\n and between consecutive meshes. Decreasing, plateauing distances toward the finest\n mesh indicate sufficiency. Complements (does not replace) numerical uncertainty analysis.\n- barmesh_richardson: classical grid-convergence index on scalar QoIs.\n\n## Workflow (read-only first)\n1. barmesh_guide_workflow — orient and confirm your plan includes CFD tools.\n2. barmesh_prepare_mesh_data — recipe for the combined per-cell CSV (mesh_id + features + cell volume V).\n3. barmesh_datasets(action=upload) then preview.\n4. barmesh_mesh_convergence (and/or barmesh_richardson) — returns a job id.\n5. barmesh_training_monitor(job_id) or barmesh_jobs(action=monitor) — blocks with throttled snapshots until done (preferred); or barmesh_jobs(status) for one-shot polls every 10-20s.\n6. barmesh_results(action=get) — distances, convergence reading, and figures; then\n barmesh_results_explorer(job_id) to browse every figure interactively.\n\nThese tools are gated by the 'cfd' entitlement; analysis calls return 403 if your plan\ndoes not include it."});o(i,g,g,{mimeType:t},async()=>{const e=await l("barmesh-results-explorer");return{contents:[{uri:g,mimeType:t,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),p(i),v(i),b(i),f(i),u(i),y(i),_(i),j(i);try{const e=await n(c,m,l);d(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("barmesh viz server failed to start:",e)}const w=i.server;w.oninitialized=()=>{const e=w.getClientCapabilities(),r=s(e);h(!!r?.mimeTypes?.includes(t))};const x=new r;await i.connect(x),console.error(`barmesh-mcp ${a} ready (API: ${process.env.BARIVIA_API_URL??"https://api.barivia.se"})`)})().catch(e=>{console.error("Fatal error starting barmesh-mcp:",e),process.exit(1)});
2
+ import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as r}from"@modelcontextprotocol/sdk/server/stdio.js";import{getUiCapability as s,registerAppResource as o,RESOURCE_MIME_TYPE as n}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as t}from"./viz-server.js";import{API_KEY as i,CLIENT_VERSION as a,apiCall as c,apiRawCall as m,loadViewHtml as l,setVizPort as d,setClientSupportsMcpApps as p}from"./shared.js";import{registerGuideTool as h}from"./tools/guide.js";import{registerDatasetsTool as u}from"./tools/datasets.js";import{registerCfdTools as b}from"./tools/cfd.js";import{registerJobsTool as f}from"./tools/jobs.js";import{registerResultsTool as v}from"./tools/results.js";import{registerResultsExplorerTool as y,RESULTS_EXPLORER_URI as _}from"./tools/barmesh_results_explorer.js";import{registerTrainingMonitorTool as g,TRAINING_MONITOR_URI as w}from"./tools/training_monitor.js";import{registerFeedbackTool as j}from"./tools/feedback.js";i||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));(async function(){const i=new e({name:"barmesh",version:a},{instructions:"# Barivia barmesh — CFD mesh-convergence analytics\n\nSOM-based mesh-convergence verification: compare CFD meshes of a refinement study by the\nvolume-weighted distribution their cells form on a shared self-organizing map, plus\nclassical Richardson/GCI on scalar quantities.\n\n## Two tracks\n- barmesh_mesh_convergence: high-dimensional field comparison. Symmetric KL and\n Wasserstein-1 (EMD) distances between each mesh's SOM fingerprint and a reference,\n and between consecutive meshes. Decreasing, plateauing distances toward the finest\n mesh indicate sufficiency. Complements (does not replace) numerical uncertainty analysis.\n- barmesh_richardson: classical grid-convergence index on scalar QoIs.\n\n## Workflow (read-only first)\n1. barmesh_guide_workflow — orient and confirm your plan includes CFD tools.\n2. barmesh_prepare_mesh_data — recipe for the combined per-cell CSV (mesh_id + features + cell volume V).\n3. barmesh_datasets(action=upload) then preview.\n4. barmesh_mesh_convergence (and/or barmesh_richardson) — returns a job id.\n5. barmesh_training_monitor(job_id) visual MCP App with live curves (default); barmesh_jobs(action=monitor) — headless blocking snapshots; or barmesh_jobs(status) for one-shot polls every 10-20s.\n6. barmesh_results(action=get) — distances, convergence reading, and figures; then\n barmesh_results_explorer(job_id) to browse every figure interactively.\n\nThese tools are gated by the 'cfd' entitlement; analysis calls return 403 if your plan\ndoes not include it."});o(i,_,_,{mimeType:n},async()=>{const e=await l("barmesh-results-explorer");return{contents:[{uri:_,mimeType:n,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),o(i,w,w,{mimeType:n},async()=>{const e=await l("barmesh-training-monitor");return{contents:[{uri:w,mimeType:n,text:e??"<html><body>Training Monitor view not built yet. Run: npm run build:views</body></html>"}]}}),h(i),y(i),u(i),b(i),f(i),g(i),v(i),j(i);try{const e=await t(c,m,l);d(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("barmesh viz server failed to start:",e)}const x=i.server;x.oninitialized=()=>{const e=x.getClientCapabilities(),r=s(e);p(!!r?.mimeTypes?.includes(n))};const I=new r;await i.connect(I),console.error(`barmesh-mcp ${a} ready (API: ${process.env.BARIVIA_API_URL??"https://api.barivia.se"})`)})().catch(e=>{console.error("Fatal error starting barmesh-mcp:",e),process.exit(1)});
@@ -6,6 +6,7 @@
6
6
  import { apiCall } from "./shared.js";
7
7
  import { formatJobStatusText } from "./job_status_format.js";
8
8
  import { pollCfdFinalizeIfPresent, refreshJobAfterFinalize } from "./cfd_finalize.js";
9
+ import { buildPostHocReview, isTerminalStatus } from "./training_review.js";
9
10
  export const DEFAULT_BLOCK_UNTIL_SEC = 900;
10
11
  export const DEFAULT_POLL_INTERVAL_SEC = 5;
11
12
  export const MIN_POLL_INTERVAL_SEC = 5;
@@ -156,10 +157,20 @@ export async function monitorJob(job_id, options = {}) {
156
157
  const start = Date.now();
157
158
  const snapshots = [];
158
159
  let lastSnap = null;
159
- let data = {};
160
+ let data = (await apiCall("GET", `/v1/jobs/${job_id}`));
161
+ const initialStatus = String(data.status ?? "");
162
+ if (isTerminalStatus(initialStatus)) {
163
+ const suggested = suggestedNextStep(job_id, data);
164
+ return buildPostHocReview(job_id, data, suggested);
165
+ }
160
166
  let heartbeat = 0;
161
167
  while (Date.now() - start < blockMs) {
162
- data = (await apiCall("GET", `/v1/jobs/${job_id}`));
168
+ if (snapshots.length === 0) {
169
+ /* first fetch already done above */
170
+ }
171
+ else {
172
+ data = (await apiCall("GET", `/v1/jobs/${job_id}`));
173
+ }
163
174
  const elapsedSec = Math.round((Date.now() - start) / 1000);
164
175
  const snap = snapshotFromJob(data, elapsedSec);
165
176
  heartbeat += 1;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Compact metadata strip for barmesh_results_explorer header.
3
+ */
4
+ function fmtNum(v, digits = 4) {
5
+ if (typeof v === "number" && Number.isFinite(v))
6
+ return v.toFixed(digits);
7
+ return v != null ? String(v) : "N/A";
8
+ }
9
+ function truncId(jobId) {
10
+ return jobId.length > 8 ? `${jobId.slice(0, 8)}…` : jobId;
11
+ }
12
+ function meshCellsSummary(summary) {
13
+ const meshes = summary.meshes ?? [];
14
+ if (meshes.length === 0)
15
+ return "N/A";
16
+ return meshes
17
+ .map((m) => `${String(m.mesh ?? "?")}:${m.n_cells ?? "?"}`)
18
+ .join(", ");
19
+ }
20
+ export function buildMetadataStrip(jobId, data, jobMeta) {
21
+ const summary = (data.summary ?? {});
22
+ const label = data.label != null && data.label !== ""
23
+ ? String(data.label)
24
+ : jobMeta?.label != null
25
+ ? String(jobMeta.label)
26
+ : null;
27
+ const jobType = String(summary.job_type ?? "cfd_mesh_convergence");
28
+ const datasetName = jobMeta?.dataset_name != null ? String(jobMeta.dataset_name) : null;
29
+ const grid = summary.grid ?? [];
30
+ const epochs = summary.epochs ?? [];
31
+ const epochStr = epochs.length >= 2 ? `[${epochs[0]},${epochs[1]}]` : epochs.length === 1 ? String(epochs[0]) : "N/A";
32
+ const line1 = [
33
+ label ? `label ${label}` : null,
34
+ `job ${truncId(jobId)}`,
35
+ jobType,
36
+ datasetName ? `dataset ${datasetName}` : null,
37
+ ]
38
+ .filter(Boolean)
39
+ .join(" · ");
40
+ const nTrain = summary.n_train_total != null ? String(summary.n_train_total) : null;
41
+ const ref = summary.reference_mesh != null ? String(summary.reference_mesh) : null;
42
+ const meshOrder = Array.isArray(summary.mesh_order) && summary.mesh_order.length > 0
43
+ ? summary.mesh_order.map(String).join("→")
44
+ : null;
45
+ const line2 = [
46
+ nTrain != null ? `n_train ${nTrain}` : null,
47
+ meshOrder ? `meshes ${meshOrder}` : `cells ${meshCellsSummary(summary)}`,
48
+ ref ? `ref ${ref}` : null,
49
+ ]
50
+ .filter(Boolean)
51
+ .join(" · ");
52
+ const wall = jobMeta?.wall_elapsed_sec != null ? Math.round(Number(jobMeta.wall_elapsed_sec)) : null;
53
+ const kernel = jobMeta?.kernel_elapsed_sec != null
54
+ ? Math.round(Number(jobMeta.kernel_elapsed_sec))
55
+ : jobMeta?.training_elapsed_sec != null
56
+ ? Math.round(Number(jobMeta.training_elapsed_sec))
57
+ : null;
58
+ const line3 = [
59
+ `preset ${String(summary.preset ?? "generic")}`,
60
+ grid.length >= 2 ? `grid ${grid[0]}×${grid[1]}` : null,
61
+ `epochs ${epochStr}`,
62
+ summary.batch_size != null ? `batch ${summary.batch_size}` : null,
63
+ summary.stratify_scale != null ? `stratify ${summary.stratify_scale}` : null,
64
+ summary.backend != null ? `backend ${summary.backend}` : null,
65
+ ]
66
+ .filter(Boolean)
67
+ .join(" · ");
68
+ const line4 = [
69
+ wall != null ? `wall ${wall}s` : null,
70
+ kernel != null ? `kernel ${kernel}s` : null,
71
+ summary.quantization_error != null ? `QE ${fmtNum(summary.quantization_error)}` : null,
72
+ summary.topographic_error != null ? `TE ${fmtNum(summary.topographic_error)}` : null,
73
+ summary.emd_method != null ? `emd ${summary.emd_method}` : null,
74
+ ]
75
+ .filter(Boolean)
76
+ .join(" · ");
77
+ return [line1, line2, line3, line4].filter((l) => l.length > 0);
78
+ }
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.3";
25
+ export const CLIENT_VERSION = "0.5.4";
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;
@@ -389,7 +389,7 @@ export function getCaptionForImage(filename) {
389
389
  if (base === "combined")
390
390
  return "All feature component planes on the trained SOM in one grid — primary comparison artifact (barsom-style combined view).";
391
391
  if (base === "overview_distances")
392
- return "Diagnostic mosaic: volume fingerprints plus KL and EMD vs the reference mesh.";
392
+ return "Diagnostic mosaic: KL and EMD vs reference and stepwise (all distance panels).";
393
393
  if (base === "learning_curve")
394
394
  return "Quantization error by epoch: ordering phase (steel blue) then convergence (coral).";
395
395
  if (base === "KL_ref")
@@ -400,6 +400,8 @@ export function getCaptionForImage(filename) {
400
400
  return "Wasserstein-1 (EMD) distance from each mesh to the reference. A monotone decrease toward the finest mesh indicates field-level convergence.";
401
401
  if (base === "EMD_stepwise")
402
402
  return "Stepwise EMD between consecutive meshes. Small, plateauing values on the finest pairs indicate the fingerprint has stopped changing.";
403
+ if (base === "plot_vol_all_meshes")
404
+ return "Volume-weighted SOM fingerprints P_vol for every mesh in mesh_order (shared color scale).";
403
405
  if (base === "plot_vol_coarse_fine")
404
406
  return "Volume-weighted SOM fingerprints P_vol for the coarsest and reference meshes (shared color scale).";
405
407
  if (base.startsWith("plot_vol_steps") || base.startsWith("plot_vol_seven_steps"))
@@ -428,6 +430,12 @@ export async function loadViewHtml(viewName) {
428
430
  }
429
431
  return null;
430
432
  }
433
+ export const MAX_INLINE_BYTES = parseInt(process.env.BARIVIA_MAX_INLINE_BYTES ?? "8000000", 10);
434
+ let _inlineAttachBytesUsed = 0;
435
+ /** Reset inline image byte budget (call at the start of each MCP tool response). */
436
+ export function resetInlineAttachBudget() {
437
+ _inlineAttachBytesUsed = 0;
438
+ }
431
439
  export async function tryAttachImage(content, jobId, filename) {
432
440
  if (filename.endsWith(".pdf") || filename.endsWith(".svg")) {
433
441
  content.push({
@@ -441,6 +449,14 @@ export async function tryAttachImage(content, jobId, filename) {
441
449
  content.push({ type: "text", text: `${filename}: ${caption}` });
442
450
  try {
443
451
  const { data: imgBuf } = await apiRawCall(`/v1/results/${jobId}/image/${filename}`);
452
+ if (_inlineAttachBytesUsed + imgBuf.length > MAX_INLINE_BYTES) {
453
+ content.push({
454
+ type: "text",
455
+ text: `(${filename} omitted — inline image budget exceeded; use barmesh_results(action=image, job_id="${jobId}", filename="${filename}") or action=download)`,
456
+ });
457
+ return;
458
+ }
459
+ _inlineAttachBytesUsed += imgBuf.length;
444
460
  content.push({
445
461
  type: "image",
446
462
  data: imgBuf.toString("base64"),
@@ -2,28 +2,14 @@ import { z } from "zod";
2
2
  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
+ import { FIGURE_SECTION_LABELS, FIGURE_SECTION_ORDER, figureSection, sortRank, } from "../figure_sections.js";
6
+ import { buildMetadataStrip } from "../results_metadata.js";
5
7
  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
8
  export function sortFigures(figures) {
26
- return [...figures].sort((a, b) => sortRank(a.key) - sortRank(b.key) || a.label.localeCompare(b.label));
9
+ return [...figures].sort((a, b) => FIGURE_SECTION_ORDER.indexOf(a.section ?? "diagnostics") -
10
+ FIGURE_SECTION_ORDER.indexOf(b.section ?? "diagnostics") ||
11
+ sortRank(a.key) - sortRank(b.key) ||
12
+ a.label.localeCompare(b.label));
27
13
  }
28
14
  export function resolveDefaultFigureKey(figures) {
29
15
  const byKey = (k) => figures.find((f) => f.key === k);
@@ -52,6 +38,8 @@ function labelForFigure(filename) {
52
38
  return "Wasserstein (EMD) vs reference";
53
39
  if (base === "EMD_stepwise")
54
40
  return "Wasserstein (EMD) stepwise";
41
+ if (base === "plot_vol_all_meshes")
42
+ return "Volume fingerprint: all meshes";
55
43
  if (base === "plot_vol_coarse_fine")
56
44
  return "Volume fingerprint: coarse vs reference";
57
45
  if (base.startsWith("plot_vol_"))
@@ -60,45 +48,23 @@ function labelForFigure(filename) {
60
48
  return `Component plane: ${base.replace(/^plot_/, "").replace(/_/g, " ")}`;
61
49
  return base.replace(/_/g, " ");
62
50
  }
63
- function figureKind(base) {
64
- if (base === "combined")
65
- return "summary";
66
- if (base.startsWith("plot_") && !base.startsWith("plot_vol_"))
67
- return "component";
68
- return "diagnostic";
69
- }
70
51
  function buildMetrics(summary) {
71
52
  const grid = summary.grid ?? [];
72
53
  const qe = summary.quantization_error != null ? Number(summary.quantization_error).toFixed(4) : "N/A";
73
54
  const te = summary.topographic_error != null ? Number(summary.topographic_error).toFixed(4) : "N/A";
74
55
  const meshes = summary.meshes ?? [];
56
+ const epochs = summary.epochs ?? [];
75
57
  return [
76
58
  { label: "Grid", value: grid.length >= 2 ? `${grid[0]}×${grid[1]}` : "N/A" },
77
59
  { label: "Preset", value: String(summary.preset ?? "generic") },
78
60
  { label: "Reference", value: String(summary.reference_mesh ?? "N/A") },
79
61
  { label: "Meshes", value: String(meshes.length || "N/A") },
62
+ { label: "Epochs", value: epochs.length >= 2 ? `${epochs[0]}+${epochs[1]}` : "N/A" },
80
63
  { label: "QE", value: qe },
81
64
  { label: "TE", value: te },
82
65
  { label: "EMD method", value: String(summary.emd_method ?? "exact") },
83
66
  ];
84
67
  }
85
- function buildHighlights(summary) {
86
- const out = [];
87
- const convergence = summary.convergence;
88
- const emd = convergence?.emd;
89
- const skl = convergence?.skl;
90
- if (emd) {
91
- const plateau = emd.plateau === true ? "plateauing" : "not yet plateauing";
92
- const mono = emd.monotonic_decrease === true ? "monotonic decrease" : "non-monotonic";
93
- out.push(`EMD vs reference: ${mono}, ${plateau}.`);
94
- }
95
- if (skl) {
96
- const plateau = skl.plateau === true ? "plateauing" : "not yet plateauing";
97
- out.push(`Symmetric KL vs reference: ${plateau}.`);
98
- }
99
- out.push("Advisory: SOM distances complement, not replace, numerical uncertainty analysis (Richardson/GCI).");
100
- return out;
101
- }
102
68
  /**
103
69
  * Group `<base>.<ext>` figure files into one logical figure per base, collecting the
104
70
  * available formats. Preview is the inline-displayable raster (PNG, else SVG); the
@@ -135,12 +101,12 @@ export function groupFigures(files) {
135
101
  filename: preview,
136
102
  downloadFilename,
137
103
  formats,
138
- kind: figureKind(base),
104
+ section: figureSection(base),
139
105
  caption: [baseCaption, vectorNote, emptyNote].filter(Boolean).join(" ") || undefined,
140
106
  };
141
107
  });
142
108
  }
143
- export function buildPayload(jobId, data) {
109
+ export function buildPayload(jobId, data, jobMeta) {
144
110
  const summary = (data.summary ?? {});
145
111
  const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
146
112
  const figures = sortFigures(groupFigures(files));
@@ -150,8 +116,8 @@ export function buildPayload(jobId, data) {
150
116
  jobId,
151
117
  title: "Mesh Convergence Results",
152
118
  subtitle: String(data.label ?? ""),
119
+ metadataStrip: buildMetadataStrip(jobId, data, jobMeta),
153
120
  metrics: buildMetrics(summary),
154
- highlights: buildHighlights(summary),
155
121
  availableFigures: figures,
156
122
  defaultFigureKey: resolveDefaultFigureKey(figures),
157
123
  standaloneUrl: port
@@ -160,15 +126,18 @@ export function buildPayload(jobId, data) {
160
126
  };
161
127
  }
162
128
  async function handleResultsExplorer(job_id) {
163
- const data = (await apiCall("GET", `/v1/results/${job_id}`));
164
- const payload = buildPayload(job_id, data);
129
+ const [data, jobMeta] = await Promise.all([
130
+ apiCall("GET", `/v1/results/${job_id}`),
131
+ apiCall("GET", `/v1/jobs/${job_id}`).catch(() => ({})),
132
+ ]);
133
+ const payload = buildPayload(job_id, data, jobMeta);
165
134
  const content = [
166
135
  {
167
136
  type: "text",
168
137
  text: [
169
138
  `Mesh convergence results explorer ready for job ${job_id}.`,
170
139
  payload.subtitle ? `Label: ${payload.subtitle}` : "",
171
- payload.highlights[0] ?? "",
140
+ payload.metadataStrip?.[0] ?? "",
172
141
  ]
173
142
  .filter(Boolean)
174
143
  .join("\n"),
@@ -178,6 +147,14 @@ async function handleResultsExplorer(job_id) {
178
147
  if (defaultFigure && /\.(png|svg|jpe?g|webp)$/i.test(defaultFigure.filename)) {
179
148
  await tryAttachImage(content, job_id, defaultFigure.filename);
180
149
  }
150
+ if (payload.standaloneUrl) {
151
+ content.push({
152
+ type: "text",
153
+ text: `Standalone results explorer URL (copy if Open is blocked):\n${payload.standaloneUrl}\n` +
154
+ `AGENT: surface this URL to the user — markdown link or copy/paste into a browser when the inline MCP panel cannot open localhost. ` +
155
+ `This localhost port is per MCP session and goes stale if the proxy restarts — re-run barmesh_results_explorer for a fresh URL, or set BARIVIA_VIZ_PORT for a persistent port.`,
156
+ });
157
+ }
181
158
  if (!getClientSupportsMcpApps() && payload.standaloneUrl) {
182
159
  content.push({
183
160
  type: "text",
@@ -191,7 +168,7 @@ async function handleResultsExplorer(job_id) {
191
168
  export function registerResultsExplorerTool(server) {
192
169
  const toolConfig = {
193
170
  title: "Mesh Convergence Results Explorer",
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.",
171
+ description: "PREFERRED way to browse a completed mesh_convergence job after a first barmesh_results(get) glance. Interactive explorer with a grouped figure dropdown (Overview Distances Diagnostics → Component planes): combined mosaic headline, overview_distances, KL/EMD panels, learning curve, volume fingerprints, per-feature planes. Header shows job/dataset/training metadata from status + summary.json. Convergence reading lives in barmesh_results(get) / summary.convergence only — not repeated here. Each figure shows its PNG preview inline (PDFs cannot render in the sandboxed panel) and offers vector download when rendered. Embeds as an MCP App or falls back to a standalone localhost page.",
195
172
  inputSchema: {
196
173
  job_id: z.string().describe("Job ID of a completed cfd_mesh_convergence job"),
197
174
  },
@@ -216,3 +193,4 @@ export function registerResultsExplorerTool(server) {
216
193
  }
217
194
  });
218
195
  }
196
+ export { FIGURE_SECTION_LABELS, FIGURE_SECTION_ORDER, figureSection };
@@ -2,7 +2,7 @@ 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
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 (or barmesh_jobs monitor/status) -> barmesh_results(get).
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
6
  (API unreachable; this is the offline summary. Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
7
7
  const OFFLINE_PREP = `barmesh mesh-data prep (offline summary; API unreachable):
8
8
  Build ONE combined per-cell CSV across all meshes of the refinement study:
@@ -4,12 +4,13 @@ import { apiCall, textResult } from "../shared.js";
4
4
  import { pollCfdFinalizeIfPresent, refreshJobAfterFinalize } from "../cfd_finalize.js";
5
5
  import { formatJobStatusText } from "../job_status_format.js";
6
6
  import { DEFAULT_BLOCK_UNTIL_SEC, DEFAULT_POLL_INTERVAL_SEC } from "../job_monitor.js";
7
- import { runMonitor } from "./training_monitor.js";
7
+ import { runBlockingMonitor } from "../blocking_monitor.js";
8
8
  export function registerJobsTool(server) {
9
9
  registerAuditedTool(server, "barmesh_jobs", `Check job status, block until terminal, or list jobs.
10
10
 
11
11
  BEST FOR: action=monitor after submit (one call, throttled snapshots — preferred for agents). action=status for a single one-shot check.
12
- ASYNC PROTOCOL: monitor blocks server-side until completed/failed or block_until timeout (default ${DEFAULT_BLOCK_UNTIL_SEC}s, poll every ${DEFAULT_POLL_INTERVAL_SEC}s). status is one-shot; poll every 10-20s manually if not using monitor. When status=completed, call barmesh_results(action=get, job_id=...).
12
+ MONITOR MODES: barmesh_training_monitor default visual MCP App (live curves, post-hoc review on completed jobs). barmesh_jobs(action=monitor) headless blocking snapshots for agents (live or post-hoc review; attaches learning_curve.png when already completed).
13
+ ASYNC PROTOCOL: monitor blocks server-side until completed/failed or block_until timeout (default ${DEFAULT_BLOCK_UNTIL_SEC}s, poll every ${DEFAULT_POLL_INTERVAL_SEC}s). status is one-shot; poll every 10-20s manually if not using monitor. When status=completed, call barmesh_results(action=get, job_id=...) then barmesh_results_explorer(job_id=...).
13
14
  ESCALATION: status=failed returns an error message and (when available) a failure_stage; read it before retrying.`, {
14
15
  action: z
15
16
  .enum(["status", "monitor", "list"])
@@ -36,7 +37,7 @@ ESCALATION: status=failed returns an error message and (when available) a failur
36
37
  if (action === "monitor") {
37
38
  if (!job_id)
38
39
  throw new Error("barmesh_jobs(monitor) requires job_id.");
39
- return runMonitor({ job_id, block_until_sec, poll_interval_sec, wait_finalize });
40
+ return runBlockingMonitor({ job_id, block_until_sec, poll_interval_sec, wait_finalize });
40
41
  }
41
42
  if (action === "status") {
42
43
  if (!job_id)
@@ -2,8 +2,36 @@ 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, 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"];
5
+ import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, textResult, tryAttachImage, resetInlineAttachBudget, pollUntilComplete, POLL_STAGE_MAX_MS, } from "../shared.js";
6
+ import { formatConvergenceReading } from "../convergence_reading.js";
7
+ const MESH_DEFAULT_FIGURES = ["combined", "overview_distances", "plot_vol_all_meshes", "plot_vol_steps", "learning_curve"];
8
+ function formatMeshResultsSummary(jobId, data, summary) {
9
+ const label = data.label != null && data.label !== "" ? String(data.label) : null;
10
+ const header = label ? `Mesh convergence results for ${label} (job_id: ${jobId})` : `Mesh convergence results for job_id: ${jobId}`;
11
+ const grid = summary.grid ?? [];
12
+ const meshes = summary.meshes ?? [];
13
+ const stepwise = summary.stepwise ?? [];
14
+ const fmt = (v) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(4) : String(v ?? "N/A"));
15
+ const meshLines = meshes.map((m) => ` ${String(m.mesh ?? "?")} (n=${m.n_cells ?? "?"}): SKL→ref=${fmt(m.skl_to_ref)} EMD→ref=${fmt(m.emd_to_ref)}`);
16
+ const stepLines = stepwise.map((s) => ` ${String(s.pair ?? "?")}: EMD=${fmt(s.emd)} KL↓=${fmt(s.kl_coarsen)} KL↑=${fmt(s.kl_refine)}`);
17
+ const readingLine = formatConvergenceReading(summary);
18
+ const ord = summary.ordering_errors;
19
+ const conv = summary.convergence_errors;
20
+ const curveNote = (ord?.length ?? 0) > 0 || (conv?.length ?? 0) > 0
21
+ ? `Training curves: ${ord?.length ?? 0} ordering + ${conv?.length ?? 0} convergence batch samples (≤1000 each in API/MCP responses).`
22
+ : "";
23
+ return [
24
+ header,
25
+ `Preset: ${String(summary.preset ?? "generic")} | Grid: ${grid.length >= 2 ? `${grid[0]}×${grid[1]}` : "N/A"} | Reference: ${String(summary.reference_mesh ?? "N/A")}`,
26
+ `QE: ${fmt(summary.quantization_error)} | TE: ${fmt(summary.topographic_error)} | EMD method: ${String(summary.emd_method ?? "exact")}`,
27
+ meshes.length > 0 ? `\nDistances vs reference:\n${meshLines.join("\n")}` : "",
28
+ stepLines.length > 0 ? `\nStepwise:\n${stepLines.join("\n")}` : "",
29
+ readingLine ? `\nConvergence: ${readingLine}` : "",
30
+ curveNote,
31
+ "\nAdvisory: SOM distances complement, not replace, numerical uncertainty analysis (use barmesh_richardson for classical GCI).",
32
+ "For every figure: barmesh_results_explorer(job_id) or barmesh_results(action=download). Use figures=\"none\" to skip inline images.",
33
+ ].filter(Boolean).join("\n");
34
+ }
7
35
  const TEXT_ARTIFACTS = [
8
36
  "summary.json",
9
37
  "distances.csv",
@@ -23,7 +51,7 @@ export function registerResultsTool(server) {
23
51
  | download | Save figures and metrics to a local folder (headless / agent path). |
24
52
 
25
53
  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.
54
+ FIGURES: Default bundle is combined.png, overview_distances.png (all KL/EMD panels), plot_vol_all_meshes.png, plot_vol_steps.png, and learning_curve.png — not redundant per-feature PNGs. action=get inlines the headline set; pass figures="all" for every artifact listed in summary.files, figures="none" for metrics only (recommended for agents).
27
55
  PDFs ON DEMAND: vector PDFs are not produced by default. Use action=render (format=pdf) once, then action=image or action=download.
28
56
  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.
29
57
  NOT FOR: Submitting jobs.`, {
@@ -96,9 +124,18 @@ NOT FOR: Submitting jobs.`, {
96
124
  const files = summary.files ?? [];
97
125
  const isImage = (f) => /\.(png|svg|pdf)$/i.test(f);
98
126
  let toDownload;
99
- if (figures === "all" || figures === undefined) {
127
+ if (figures === "all") {
100
128
  toDownload = include_json ? files : files.filter(isImage);
101
129
  }
130
+ else if (figures === undefined) {
131
+ toDownload = MESH_DEFAULT_FIGURES.flatMap((b) => files.filter((f) => f.replace(/\.[^.]+$/, "") === b || f.startsWith(`${b}.`)));
132
+ if (include_json) {
133
+ for (const t of TEXT_ARTIFACTS) {
134
+ if (files.includes(t) && !toDownload.includes(t))
135
+ toDownload.push(t);
136
+ }
137
+ }
138
+ }
102
139
  else if (Array.isArray(figures)) {
103
140
  toDownload = figures.flatMap((key) => {
104
141
  if (/\.(png|pdf|svg|csv|txt|json)$/i.test(key))
@@ -139,9 +176,10 @@ NOT FOR: Submitting jobs.`, {
139
176
  ? `Saved ${saved.length} file(s) to ${savedDir}: ${saved.join(", ")}`
140
177
  : `No files saved. Check job_id and that the job (and cfd_finalize, if any) is completed.`);
141
178
  }
142
- let summary;
179
+ resetInlineAttachBudget();
180
+ let data;
143
181
  try {
144
- summary = (await apiCall("GET", `/v1/results/${job_id}`));
182
+ data = (await apiCall("GET", `/v1/results/${job_id}`));
145
183
  }
146
184
  catch (err) {
147
185
  const msg = err instanceof Error ? err.message : String(err);
@@ -154,7 +192,8 @@ NOT FOR: Submitting jobs.`, {
154
192
  }
155
193
  throw err;
156
194
  }
157
- const content = [{ type: "text", text: JSON.stringify(summary, null, 2) }];
195
+ const summary = (data.summary ?? {});
196
+ const content = [{ type: "text", text: formatMeshResultsSummary(job_id, data, summary) }];
158
197
  if (figures === "none")
159
198
  return { content };
160
199
  const allFiles = summary.files ?? [];