@barivia/barmesh-mcp 0.5.4 → 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.
@@ -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
+ }
@@ -2,29 +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_all_meshes: 19,
15
- plot_vol_coarse_fine: 20,
16
- };
17
- function sortRank(key) {
18
- if (key in FIGURE_SORT_RANK)
19
- return FIGURE_SORT_RANK[key];
20
- if (key.startsWith("plot_vol"))
21
- return 25;
22
- if (key.startsWith("plot_"))
23
- return 30;
24
- return 50;
25
- }
26
8
  export function sortFigures(figures) {
27
- 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));
28
13
  }
29
14
  export function resolveDefaultFigureKey(figures) {
30
15
  const byKey = (k) => figures.find((f) => f.key === k);
@@ -63,45 +48,23 @@ function labelForFigure(filename) {
63
48
  return `Component plane: ${base.replace(/^plot_/, "").replace(/_/g, " ")}`;
64
49
  return base.replace(/_/g, " ");
65
50
  }
66
- function figureKind(base) {
67
- if (base === "combined")
68
- return "summary";
69
- if (base.startsWith("plot_") && !base.startsWith("plot_vol_"))
70
- return "component";
71
- return "diagnostic";
72
- }
73
51
  function buildMetrics(summary) {
74
52
  const grid = summary.grid ?? [];
75
53
  const qe = summary.quantization_error != null ? Number(summary.quantization_error).toFixed(4) : "N/A";
76
54
  const te = summary.topographic_error != null ? Number(summary.topographic_error).toFixed(4) : "N/A";
77
55
  const meshes = summary.meshes ?? [];
56
+ const epochs = summary.epochs ?? [];
78
57
  return [
79
58
  { label: "Grid", value: grid.length >= 2 ? `${grid[0]}×${grid[1]}` : "N/A" },
80
59
  { label: "Preset", value: String(summary.preset ?? "generic") },
81
60
  { label: "Reference", value: String(summary.reference_mesh ?? "N/A") },
82
61
  { label: "Meshes", value: String(meshes.length || "N/A") },
62
+ { label: "Epochs", value: epochs.length >= 2 ? `${epochs[0]}+${epochs[1]}` : "N/A" },
83
63
  { label: "QE", value: qe },
84
64
  { label: "TE", value: te },
85
65
  { label: "EMD method", value: String(summary.emd_method ?? "exact") },
86
66
  ];
87
67
  }
88
- function buildHighlights(summary) {
89
- const out = [];
90
- const convergence = summary.convergence;
91
- const emd = convergence?.emd;
92
- const skl = convergence?.skl;
93
- if (emd) {
94
- const plateau = emd.plateau === true ? "plateauing" : "not yet plateauing";
95
- const mono = emd.monotonic_decrease === true ? "monotonic decrease" : "non-monotonic";
96
- out.push(`EMD vs reference: ${mono}, ${plateau}.`);
97
- }
98
- if (skl) {
99
- const plateau = skl.plateau === true ? "plateauing" : "not yet plateauing";
100
- out.push(`Symmetric KL vs reference: ${plateau}.`);
101
- }
102
- out.push("Advisory: SOM distances complement, not replace, numerical uncertainty analysis (Richardson/GCI).");
103
- return out;
104
- }
105
68
  /**
106
69
  * Group `<base>.<ext>` figure files into one logical figure per base, collecting the
107
70
  * available formats. Preview is the inline-displayable raster (PNG, else SVG); the
@@ -138,12 +101,12 @@ export function groupFigures(files) {
138
101
  filename: preview,
139
102
  downloadFilename,
140
103
  formats,
141
- kind: figureKind(base),
104
+ section: figureSection(base),
142
105
  caption: [baseCaption, vectorNote, emptyNote].filter(Boolean).join(" ") || undefined,
143
106
  };
144
107
  });
145
108
  }
146
- export function buildPayload(jobId, data) {
109
+ export function buildPayload(jobId, data, jobMeta) {
147
110
  const summary = (data.summary ?? {});
148
111
  const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
149
112
  const figures = sortFigures(groupFigures(files));
@@ -153,8 +116,8 @@ export function buildPayload(jobId, data) {
153
116
  jobId,
154
117
  title: "Mesh Convergence Results",
155
118
  subtitle: String(data.label ?? ""),
119
+ metadataStrip: buildMetadataStrip(jobId, data, jobMeta),
156
120
  metrics: buildMetrics(summary),
157
- highlights: buildHighlights(summary),
158
121
  availableFigures: figures,
159
122
  defaultFigureKey: resolveDefaultFigureKey(figures),
160
123
  standaloneUrl: port
@@ -163,15 +126,18 @@ export function buildPayload(jobId, data) {
163
126
  };
164
127
  }
165
128
  async function handleResultsExplorer(job_id) {
166
- const data = (await apiCall("GET", `/v1/results/${job_id}`));
167
- 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);
168
134
  const content = [
169
135
  {
170
136
  type: "text",
171
137
  text: [
172
138
  `Mesh convergence results explorer ready for job ${job_id}.`,
173
139
  payload.subtitle ? `Label: ${payload.subtitle}` : "",
174
- payload.highlights[0] ?? "",
140
+ payload.metadataStrip?.[0] ?? "",
175
141
  ]
176
142
  .filter(Boolean)
177
143
  .join("\n"),
@@ -181,6 +147,14 @@ async function handleResultsExplorer(job_id) {
181
147
  if (defaultFigure && /\.(png|svg|jpe?g|webp)$/i.test(defaultFigure.filename)) {
182
148
  await tryAttachImage(content, job_id, defaultFigure.filename);
183
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
+ }
184
158
  if (!getClientSupportsMcpApps() && payload.standaloneUrl) {
185
159
  content.push({
186
160
  type: "text",
@@ -194,7 +168,7 @@ async function handleResultsExplorer(job_id) {
194
168
  export function registerResultsExplorerTool(server) {
195
169
  const toolConfig = {
196
170
  title: "Mesh Convergence Results Explorer",
197
- 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.",
198
172
  inputSchema: {
199
173
  job_id: z.string().describe("Job ID of a completed cfd_mesh_convergence job"),
200
174
  },
@@ -219,3 +193,4 @@ export function registerResultsExplorerTool(server) {
219
193
  }
220
194
  });
221
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)
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { registerAuditedTool } from "../audit.js";
5
5
  import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, textResult, tryAttachImage, resetInlineAttachBudget, pollUntilComplete, POLL_STAGE_MAX_MS, } from "../shared.js";
6
+ import { formatConvergenceReading } from "../convergence_reading.js";
6
7
  const MESH_DEFAULT_FIGURES = ["combined", "overview_distances", "plot_vol_all_meshes", "plot_vol_steps", "learning_curve"];
7
8
  function formatMeshResultsSummary(jobId, data, summary) {
8
9
  const label = data.label != null && data.label !== "" ? String(data.label) : null;
@@ -13,18 +14,7 @@ function formatMeshResultsSummary(jobId, data, summary) {
13
14
  const fmt = (v) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(4) : String(v ?? "N/A"));
14
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)}`);
15
16
  const stepLines = stepwise.map((s) => ` ${String(s.pair ?? "?")}: EMD=${fmt(s.emd)} KL↓=${fmt(s.kl_coarsen)} KL↑=${fmt(s.kl_refine)}`);
16
- const convergence = summary.convergence;
17
- const emd = convergence?.emd;
18
- const skl = convergence?.skl;
19
- const reading = [];
20
- if (emd) {
21
- const plateau = emd.plateau === true ? "plateauing" : "not yet plateauing";
22
- const mono = emd.monotonic_decrease === true ? "monotonic decrease toward reference" : "non-monotonic vs reference";
23
- reading.push(`EMD vs ${String(summary.reference_mesh ?? "reference")}: ${mono}, ${plateau}.`);
24
- }
25
- if (skl) {
26
- reading.push(`Symmetric KL vs reference: ${skl.plateau === true ? "plateauing" : "not yet plateauing"}.`);
27
- }
17
+ const readingLine = formatConvergenceReading(summary);
28
18
  const ord = summary.ordering_errors;
29
19
  const conv = summary.convergence_errors;
30
20
  const curveNote = (ord?.length ?? 0) > 0 || (conv?.length ?? 0) > 0
@@ -36,7 +26,7 @@ function formatMeshResultsSummary(jobId, data, summary) {
36
26
  `QE: ${fmt(summary.quantization_error)} | TE: ${fmt(summary.topographic_error)} | EMD method: ${String(summary.emd_method ?? "exact")}`,
37
27
  meshes.length > 0 ? `\nDistances vs reference:\n${meshLines.join("\n")}` : "",
38
28
  stepLines.length > 0 ? `\nStepwise:\n${stepLines.join("\n")}` : "",
39
- reading.length > 0 ? `\nConvergence reading:\n${reading.map((l) => ` ${l}`).join("\n")}` : "",
29
+ readingLine ? `\nConvergence: ${readingLine}` : "",
40
30
  curveNote,
41
31
  "\nAdvisory: SOM distances complement, not replace, numerical uncertainty analysis (use barmesh_richardson for classical GCI).",
42
32
  "For every figure: barmesh_results_explorer(job_id) or barmesh_results(action=download). Use figures=\"none\" to skip inline images.",
@@ -1,56 +1,76 @@
1
1
  import { z } from "zod";
2
- import { registerAuditedTool } from "../audit.js";
3
- import { textResult } from "../shared.js";
4
- import { DEFAULT_BLOCK_UNTIL_SEC, DEFAULT_POLL_INTERVAL_SEC, formatMonitorText, monitorJob, } from "../job_monitor.js";
5
- const MONITOR_DESCRIPTION = `Block until a barmesh_mesh_convergence or barmesh_richardson job reaches a terminal state, emitting compact progress snapshots along the way.
6
-
7
- BEST FOR: After job submit — one call replaces manual barmesh_jobs(status) poll loops (10–20s × several minutes).
8
- ASYNC PROTOCOL: Server-side poll every ~5s (configurable) until completed/failed/cancelled or block_until timeout. Snapshots include phase, epoch/total, QE/TE, ETA, and ordering_errors tail when live.
9
- FINALIZE: When defer_figures is used, waits for cfd_finalize and adds a snapshot when figure render starts/completes.
10
- NOT REQUIRED: barmesh_jobs(action=status) still works for one-shot checks; this tool is the agent-friendly blocking variant (barsom training_monitor parity for headless workflows).
11
- ESCALATION: On timeout, re-run with a higher block_until_sec; on failed, read failure_stage in the final snapshot.`;
12
- const monitorSchema = {
13
- job_id: z.string().describe("Job ID from barmesh_mesh_convergence or barmesh_richardson"),
14
- block_until_sec: z
15
- .number()
16
- .int()
17
- .min(30)
18
- .optional()
19
- .describe(`Max seconds to wait (default ${DEFAULT_BLOCK_UNTIL_SEC}; mesh jobs often need 6–10 min)`),
20
- poll_interval_sec: z
21
- .number()
22
- .int()
23
- .min(5)
24
- .optional()
25
- .describe(`Seconds between status polls (default ${DEFAULT_POLL_INTERVAL_SEC}; do not go below 5)`),
26
- wait_finalize: z
27
- .boolean()
28
- .optional()
29
- .describe("When true (default), wait for cfd_finalize after compute completes before returning"),
30
- };
31
- async function runMonitor(args) {
32
- const block_until_sec = args.block_until_sec ?? DEFAULT_BLOCK_UNTIL_SEC;
33
- const poll_interval_sec = args.poll_interval_sec ?? DEFAULT_POLL_INTERVAL_SEC;
34
- const result = await monitorJob(args.job_id, {
35
- block_until_sec,
36
- poll_interval_sec,
37
- wait_finalize: args.wait_finalize,
38
- });
39
- const text = formatMonitorText(result, { block_until_sec, poll_interval_sec });
40
- return textResult({
41
- ...result.data,
42
- monitor: {
43
- job_id: result.job_id,
44
- terminal: result.terminal,
45
- timed_out: result.timed_out,
46
- snapshots: result.snapshots,
47
- status_text: result.status_text,
48
- suggested_next_step: result.suggested_next_step,
49
- },
50
- status_text: text,
51
- });
2
+ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
3
+ import { runMcpToolAudit } from "../audit.js";
4
+ import { apiCall, getClientSupportsMcpApps, getVizPort, structuredTextResult, } from "../shared.js";
5
+ import { enrichWithTrainingLog, hasCurveArrays } from "../training_review.js";
6
+ export const TRAINING_MONITOR_URI = "ui://barmesh/training-monitor";
7
+ export const TRAINING_MONITOR_REFRESH_MS = 5000;
8
+ function buildStructuredContent(job_id, data) {
9
+ const id = String(data.id ?? job_id);
10
+ return {
11
+ ...data,
12
+ type: "barmesh-training-monitor",
13
+ jobId: id,
14
+ id,
15
+ job_id: id,
16
+ refresh_interval_ms: TRAINING_MONITOR_REFRESH_MS,
17
+ };
52
18
  }
53
19
  export function registerTrainingMonitorTool(server) {
54
- registerAuditedTool(server, "barmesh_training_monitor", MONITOR_DESCRIPTION, monitorSchema, runMonitor);
20
+ registerAppTool(server, "barmesh_training_monitor", {
21
+ title: "Mesh Convergence Training Monitor",
22
+ description: "Default visual monitor for mesh_convergence jobs after submit. Embedded MCP App auto-refreshes every 5s: epoch progress bar, phase, ETA, live QE/TE curves (uniformly subsampled to ≤1000 batch samples per phase), and a per-epoch hit-grid heatmap when available. On already-completed jobs, replays training-log curves in review mode (same entry point). Also exposes a standalone localhost URL — copy it if window.open is blocked in your MCP host. Headless fallback: barmesh_jobs(action=monitor) blocks with compact text snapshots (live or post-hoc review). barmesh_jobs(action=status) remains a one-shot poll. After completion, use barmesh_results_explorer for figures.",
23
+ inputSchema: {
24
+ job_id: z.string().describe("Job ID from barmesh_mesh_convergence or barmesh_richardson"),
25
+ fetch_training_log: z
26
+ .boolean()
27
+ .optional()
28
+ .describe("Internal: merge completed-job training-log arrays when live progress is empty"),
29
+ },
30
+ _meta: { ui: { resourceUri: TRAINING_MONITOR_URI } },
31
+ }, async (args) => runMcpToolAudit("barmesh_training_monitor", "default", args, async () => {
32
+ const { job_id, fetch_training_log } = args;
33
+ let data = (await apiCall("GET", `/v1/jobs/${job_id}`));
34
+ const jobStatus = String(data.status ?? "");
35
+ const terminal = jobStatus === "completed" || jobStatus === "failed";
36
+ if (fetch_training_log || (terminal && !hasCurveArrays(data))) {
37
+ data = await enrichWithTrainingLog(job_id, data);
38
+ }
39
+ const structuredContent = buildStructuredContent(job_id, data);
40
+ const progress = (data.progress ?? 0) * 100;
41
+ const etaSec = data.training_eta_sec != null ? Number(data.training_eta_sec) : null;
42
+ const elapsedSec = data.training_elapsed_sec != null ? Number(data.training_elapsed_sec) : null;
43
+ const epoch = data.epoch != null ? Number(data.epoch) : null;
44
+ const totalEpochs = data.total_epochs != null ? Number(data.total_epochs) : null;
45
+ const timingParts = [];
46
+ if (elapsedSec != null && elapsedSec >= 0)
47
+ timingParts.push(`elapsed ${Math.round(elapsedSec)}s`);
48
+ if (etaSec != null && etaSec > 0)
49
+ timingParts.push(`ETA ~${Math.round(etaSec)}s`);
50
+ if (epoch != null && totalEpochs != null)
51
+ timingParts.push(`epoch ${epoch}/${totalEpochs}`);
52
+ const timingNote = timingParts.length > 0 ? ` ${timingParts.join(", ")}.` : "";
53
+ const modeNote = terminal ? " (post-hoc review — training-log curves)" : "";
54
+ const text = `Training monitor (visual MCP App, refreshes every ${TRAINING_MONITOR_REFRESH_MS / 1000}s): job ${job_id} — ${jobStatus} (${progress.toFixed(1)}%).${modeNote}${timingNote}`;
55
+ const content = [{ type: "text", text }];
56
+ const port = getVizPort();
57
+ const standaloneUrl = port
58
+ ? `http://localhost:${port}/viz/barmesh-training-monitor?mode=standalone&job_id=${encodeURIComponent(job_id)}`
59
+ : undefined;
60
+ if (standaloneUrl) {
61
+ content.push({
62
+ type: "text",
63
+ text: `Standalone training monitor URL (copy if Open is blocked):\n${standaloneUrl}\n` +
64
+ `AGENT: surface this URL to the user — use a markdown link or tell them to copy/paste into a browser when the inline panel cannot open localhost. Re-post after the job completes so they can review curves.\n` +
65
+ `This localhost port is per MCP session and goes stale if the proxy restarts — re-run barmesh_training_monitor for a fresh URL, or set BARIVIA_VIZ_PORT for a persistent port. Health check: http://localhost:${port}/api/health?job_id=${encodeURIComponent(job_id)}`,
66
+ });
67
+ }
68
+ if (!getClientSupportsMcpApps() && standaloneUrl) {
69
+ content.push({
70
+ type: "text",
71
+ text: `Interactive training monitor: [Open training monitor](${standaloneUrl})`,
72
+ });
73
+ }
74
+ return structuredTextResult(structuredContent, text, content);
75
+ }));
55
76
  }
56
- export { runMonitor, MONITOR_DESCRIPTION, monitorSchema };
@@ -0,0 +1,30 @@
1
+ /** Batch-sample x-axis helpers for the barmesh_training_monitor MCP App chart. */
2
+ export function batchSampleAxis(n, offset = 0) {
3
+ if (n <= 0)
4
+ return [];
5
+ return Array.from({ length: n }, (_, i) => offset + i + 1);
6
+ }
7
+ export function combinedBatchAxis(nOrd, nConv) {
8
+ if (nOrd <= 0 && nConv <= 0)
9
+ return [];
10
+ return [...batchSampleAxis(nOrd, 0), ...batchSampleAxis(nConv, nOrd)];
11
+ }
12
+ export function formatCurveSourceNote(data) {
13
+ const src = data.training_curve_source_batches;
14
+ if (!src)
15
+ return null;
16
+ const parts = [];
17
+ const ordTotal = typeof src.ordering === "number" ? src.ordering : null;
18
+ const convTotal = typeof src.convergence === "number" ? src.convergence : null;
19
+ const ordShown = Array.isArray(data.ordering_errors) ? data.ordering_errors.length : 0;
20
+ const convShown = Array.isArray(data.convergence_errors) ? data.convergence_errors.length : 0;
21
+ if (ordTotal != null && ordTotal > ordShown) {
22
+ parts.push(`${ordShown} of ${ordTotal.toLocaleString()} ordering batch samples`);
23
+ }
24
+ if (convTotal != null && convTotal > convShown) {
25
+ parts.push(`${convShown} of ${convTotal.toLocaleString()} convergence batch samples`);
26
+ }
27
+ if (parts.length === 0)
28
+ return null;
29
+ return `Displaying uniformly subsampled curves (≤1000 points/phase): ${parts.join("; ")}.`;
30
+ }