@barivia/barmesh-mcp 0.8.3 → 0.8.7

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.
@@ -35,14 +35,25 @@ function tailOrderingErrors(data, n = 4) {
35
35
  export function snapshotFromJob(data, elapsedSec, note) {
36
36
  const status = String(data.status ?? "unknown");
37
37
  const progress_pct = (data.progress ?? 0) * 100;
38
+ const kernelComplete = data.kernel_complete === true;
39
+ const awaitingFinalize = kernelComplete &&
40
+ status !== "completed" &&
41
+ status !== "failed" &&
42
+ status !== "cancelled";
38
43
  const snap = {
39
44
  elapsed_sec: elapsedSec,
40
45
  status,
41
- progress_pct: Math.round(progress_pct * 10) / 10,
46
+ progress_pct: awaitingFinalize
47
+ ? Math.min(Math.round(progress_pct * 10) / 10, 99.0)
48
+ : Math.round(progress_pct * 10) / 10,
42
49
  };
43
50
  const phase = str(data, "progress_phase");
44
- if (phase)
51
+ if (awaitingFinalize) {
52
+ snap.phase = phase && phase !== "complete" ? `${phase}+awaiting_finalize` : "awaiting_finalize";
53
+ }
54
+ else if (phase) {
45
55
  snap.phase = phase;
56
+ }
46
57
  const epoch = num(data, "epoch");
47
58
  const total = num(data, "total_epochs");
48
59
  if (epoch != null)
@@ -58,7 +69,7 @@ export function snapshotFromJob(data, elapsedSec, note) {
58
69
  const panelTe = num(data, "panel_topographic_error");
59
70
  if (panelTe != null)
60
71
  snap.panel_te = Math.round(panelTe * 10_000) / 10_000;
61
- if (data.kernel_complete === true)
72
+ if (kernelComplete)
62
73
  snap.kernel_complete = true;
63
74
  const failureStage = str(data, "failure_stage");
64
75
  if (failureStage)
@@ -69,8 +80,12 @@ export function snapshotFromJob(data, elapsedSec, note) {
69
80
  const tail = tailOrderingErrors(data);
70
81
  if (tail && tail.length > 0)
71
82
  snap.ordering_errors_tail = tail;
72
- if (note)
83
+ if (note) {
73
84
  snap.note = note;
85
+ }
86
+ else if (awaitingFinalize) {
87
+ snap.note = "kernel complete — awaiting finalize (not fully done)";
88
+ }
74
89
  return snap;
75
90
  }
76
91
  /** True when a new snapshot is worth recording (phase/epoch/progress/status change). */
@@ -96,7 +111,10 @@ export function shouldRecordSnapshot(prev, next) {
96
111
  return false;
97
112
  }
98
113
  export function formatSnapshotLine(s) {
99
- const parts = [`[+${s.elapsed_sec}s] ${s.status} ${s.progress_pct.toFixed(1)}%`];
114
+ const progressLabel = s.kernel_complete && s.status !== "completed" && s.status !== "failed" && s.status !== "cancelled"
115
+ ? `kernel ${s.progress_pct.toFixed(1)}% (awaiting finalize)`
116
+ : `${s.progress_pct.toFixed(1)}%`;
117
+ const parts = [`[+${s.elapsed_sec}s] ${s.status} ${progressLabel}`];
100
118
  if (s.phase)
101
119
  parts.push(`phase ${s.phase}`);
102
120
  if (s.epoch != null && s.total_epochs != null)
@@ -107,8 +125,9 @@ export function formatSnapshotLine(s) {
107
125
  parts.push(`Epoch TE ${s.te.toFixed(4)}`);
108
126
  if (s.panel_te != null)
109
127
  parts.push(`Panel TE ${s.panel_te.toFixed(4)}`);
110
- if (s.kernel_complete)
128
+ if (s.kernel_complete && (s.status === "completed" || s.status === "failed" || s.status === "cancelled")) {
111
129
  parts.push("kernel complete");
130
+ }
112
131
  if (s.failure_stage)
113
132
  parts.push(`failure_stage ${s.failure_stage}`);
114
133
  if (s.eta_sec != null)
@@ -42,7 +42,12 @@ export async function formatJobStatusText(job_id, data) {
42
42
  const runConfig = formatRunConfigTable(data);
43
43
  if (runConfig)
44
44
  parts.push(runConfig);
45
- parts.push(`${jobDesc}: ${status} (${progress.toFixed(1)}%)`);
45
+ const kernelComplete = data.kernel_complete === true;
46
+ const awaitingFinalize = kernelComplete && status !== "completed" && status !== "failed" && status !== "cancelled";
47
+ const progressBit = awaitingFinalize
48
+ ? `kernel ${Math.min(progress, 99).toFixed(1)}% — awaiting finalize`
49
+ : `${progress.toFixed(1)}%`;
50
+ parts.push(`${jobDesc}: ${status} (${progressBit})`);
46
51
  const attempt = data.attempt != null ? Number(data.attempt) : null;
47
52
  if (attempt != null && attempt > 1) {
48
53
  parts.push(`attempt ${attempt} (job was requeued after worker timeout — not stuck from scratch)`);
package/dist/shared.js CHANGED
@@ -35,7 +35,7 @@ function newRequestId() {
35
35
  return randomUUID();
36
36
  }
37
37
  /** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
38
- export const CLIENT_VERSION = "0.8.3";
38
+ export const CLIENT_VERSION = "0.8.7";
39
39
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
40
40
  /** Large per-cell CSV uploads may exceed the default fetch timeout. */
41
41
  export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
@@ -45,6 +45,94 @@ export const LARGE_UPLOAD_BYTES = 64 * 1024 * 1024; // 64 MB
45
45
  export const PRESIGNED_PUT_TIMEOUT_MS = 30 * 60_000; // 30 min
46
46
  /** Poll window for the async stage_dataset job. */
47
47
  export const POLL_STAGE_MAX_MS = 30 * 60_000; // 30 min
48
+ /** Normalize upload JSON after staging completes — never leave staging:true with train_ready. */
49
+ function finalizeStagedUploadResponse(data, ready) {
50
+ const merged = ready
51
+ ? { ...data, ...ready }
52
+ : { ...data };
53
+ const trainReady = merged.train_ready === true || String(merged.status ?? "") === "ready";
54
+ if (trainReady) {
55
+ merged.status = ready?.status ?? merged.status ?? "ready";
56
+ merged.train_ready = true;
57
+ merged.staging = false;
58
+ if (ready?.stage_job_id == null)
59
+ delete merged.stage_job_id;
60
+ }
61
+ return merged;
62
+ }
63
+ /**
64
+ * After a small POST /v1/datasets upload, poll stage_job_id until the dataset is
65
+ * train-ready (mirrors the large-upload finalize path).
66
+ */
67
+ export async function awaitDatasetStageIfNeeded(data) {
68
+ const stageJobId = data.stage_job_id != null ? String(data.stage_job_id) : "";
69
+ if (!stageJobId) {
70
+ if (data.train_ready === true || data.status === "ready") {
71
+ return finalizeStagedUploadResponse(data);
72
+ }
73
+ return data;
74
+ }
75
+ if (data.train_ready === true || data.status === "ready") {
76
+ return finalizeStagedUploadResponse(data);
77
+ }
78
+ const poll = await pollUntilComplete(stageJobId, POLL_STAGE_MAX_MS);
79
+ if (poll.status === "failed") {
80
+ throw new Error(`Dataset staging failed (stage_job_id=${stageJobId}): ${poll.error ?? "unknown error"}`);
81
+ }
82
+ if (poll.status === "timeout") {
83
+ throw new Error(`Dataset staging timed out after ${POLL_STAGE_MAX_MS / 60000} min (stage_job_id=${stageJobId}). Poll barmesh_jobs(action=status, job_id="${stageJobId}").`);
84
+ }
85
+ const datasetId = String(data.id ?? data.dataset_id ?? "");
86
+ if (datasetId) {
87
+ try {
88
+ const ready = (await apiCall("GET", `/v1/datasets/${datasetId}`));
89
+ return finalizeStagedUploadResponse(data, ready);
90
+ }
91
+ catch {
92
+ /* fall through */
93
+ }
94
+ }
95
+ return finalizeStagedUploadResponse({ ...data, status: "ready", train_ready: true });
96
+ }
97
+ /**
98
+ * Before mesh_convergence submit: ensure the dataset has finished staging.
99
+ */
100
+ export async function ensureDatasetTrainReady(datasetId) {
101
+ const ds = (await apiCall("GET", `/v1/datasets/${datasetId}`));
102
+ if (ds.train_ready === true)
103
+ return;
104
+ if (String(ds.status) === "failed") {
105
+ const err = ds.ingest_error != null ? String(ds.ingest_error) : "staging failed";
106
+ throw new Error(`Dataset ${datasetId} staging failed: ${err}`);
107
+ }
108
+ const stageJobId = ds.stage_job_id != null ? String(ds.stage_job_id) : "";
109
+ if (stageJobId) {
110
+ const poll = await pollUntilComplete(stageJobId, POLL_STAGE_MAX_MS);
111
+ if (poll.status === "failed") {
112
+ throw new Error(`Dataset staging failed (stage_job_id=${stageJobId}): ${poll.error ?? "unknown error"}`);
113
+ }
114
+ if (poll.status === "timeout") {
115
+ throw new Error(`Dataset staging timed out (stage_job_id=${stageJobId}). Poll barmesh_jobs(action=status, job_id="${stageJobId}").`);
116
+ }
117
+ return;
118
+ }
119
+ const deadline = Date.now() + 60_000;
120
+ while (Date.now() < deadline) {
121
+ await new Promise((r) => setTimeout(r, 1000));
122
+ const again = (await apiCall("GET", `/v1/datasets/${datasetId}`));
123
+ if (again.train_ready === true)
124
+ return;
125
+ if (String(again.status) === "failed") {
126
+ const err = again.ingest_error != null ? String(again.ingest_error) : "staging failed";
127
+ throw new Error(`Dataset ${datasetId} staging failed: ${err}`);
128
+ }
129
+ if (again.stage_job_id != null) {
130
+ await ensureDatasetTrainReady(datasetId);
131
+ return;
132
+ }
133
+ }
134
+ throw new Error(`Dataset ${datasetId} is not train_ready (status=${ds.status ?? "?"}). Wait for staging or poll barmesh_datasets(action=get).`);
135
+ }
48
136
  /** Map a local upload path to the API Content-Type (csv vs tsv; ignores .gz suffix). */
49
137
  export function resolveUploadContentType(filePath) {
50
138
  const lower = filePath.toLowerCase();
@@ -261,9 +349,48 @@ function enforceWorkspaceSandboxUpload() {
261
349
  return false;
262
350
  return true;
263
351
  }
352
+ /** Extra realpath roots allowed for uploads (comma- or colon-separated absolute paths). */
353
+ export function getWorkspaceExtraRoots() {
354
+ const raw = process.env.BARIVIA_WORKSPACE_EXTRA_ROOTS ?? "";
355
+ if (!raw.trim())
356
+ return [];
357
+ return raw
358
+ .split(/[,:]/)
359
+ .map((s) => s.trim())
360
+ .filter(Boolean)
361
+ .map((s) => path.resolve(s));
362
+ }
363
+ async function realpathIfExists(p) {
364
+ try {
365
+ return await fs.realpath(p);
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ }
371
+ export async function isPathUnderSandboxRoots(realPath, workspaceRoot) {
372
+ const candidates = [workspaceRoot, ...getWorkspaceExtraRoots()];
373
+ for (const root of candidates) {
374
+ const realRoot = await realpathIfExists(root);
375
+ if (!realRoot)
376
+ continue;
377
+ if (realPath === realRoot || realPath.startsWith(realRoot + path.sep))
378
+ return true;
379
+ }
380
+ return false;
381
+ }
382
+ function sandboxEscapeError(realPath, workspaceRoot) {
383
+ const extras = getWorkspaceExtraRoots();
384
+ const extraHint = extras.length > 0
385
+ ? ` Also checked BARIVIA_WORKSPACE_EXTRA_ROOTS (${extras.join(", ")}).`
386
+ : " Set BARIVIA_WORKSPACE_EXTRA_ROOTS to allow shared fixture dirs, or copy the file into the workspace.";
387
+ return new Error(`Resolved path escapes workspace — symlink resolves outside BARIVIA_WORKSPACE_ROOT` +
388
+ ` (${workspaceRoot}). Target: ${realPath}.${extraHint}`);
389
+ }
264
390
  /**
265
391
  * Resolve file_path for dataset upload. Rejects "..", enforces workspace sandbox
266
392
  * by default (set BARIVIA_ENFORCE_WORKSPACE_SANDBOX=0 to allow absolute paths).
393
+ * Symlink targets under BARIVIA_WORKSPACE_EXTRA_ROOTS are allowed when sandbox is on.
267
394
  */
268
395
  export async function resolveFilePathForUpload(filePath, mcpServer) {
269
396
  const trimmed = filePath.trim();
@@ -272,9 +399,8 @@ export async function resolveFilePathForUpload(filePath, mcpServer) {
272
399
  const checkUnder = async (p) => {
273
400
  const root = await getWorkspaceRootAsync(mcpServer);
274
401
  const real = await fs.realpath(p);
275
- const realRoot = await fs.realpath(root);
276
- if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
277
- throw new Error(`Path outside the workspace is disabled (BARIVIA_ENFORCE_WORKSPACE_SANDBOX). Workspace root: ${realRoot}`);
402
+ if (!(await isPathUnderSandboxRoots(real, root))) {
403
+ throw sandboxEscapeError(real, root);
278
404
  }
279
405
  const stat = await fs.stat(real);
280
406
  if (!stat.isFile())
@@ -294,6 +420,9 @@ export async function resolveFilePathForUpload(filePath, mcpServer) {
294
420
  }
295
421
  const root = await getWorkspaceRootAsync(mcpServer);
296
422
  const resolved = path.resolve(root, trimmed);
423
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
424
+ throw new Error("Path must be within the workspace directory.");
425
+ }
297
426
  return checkUnder(resolved);
298
427
  }
299
428
  function looksLikeHtml(bodyText) {
@@ -357,7 +486,15 @@ export function classifyApiError(status, bodyText, requestId) {
357
486
  }
358
487
  else if (status === 409) {
359
488
  cause = "client";
360
- hint = "The job may not be in the expected state.";
489
+ if (apiCode === "staging_not_ready") {
490
+ const stageId = parsed?.stage_job_id != null ? String(parsed.stage_job_id) : "";
491
+ hint = stageId
492
+ ? `Wait for stage_dataset to finish: barmesh_jobs(action=monitor, job_id="${stageId}"), then retry. Prefer barmesh_datasets(action=get) train_ready=true.`
493
+ : "Wait for dataset staging (status=ready with staged_prefix / train_ready=true), then retry.";
494
+ }
495
+ else {
496
+ hint = "The job may not be in the expected state.";
497
+ }
361
498
  retryable = false;
362
499
  }
363
500
  else if (status === 429) {
@@ -685,20 +822,65 @@ export function textResult(data) {
685
822
  }
686
823
  export async function pollUntilComplete(jobId, maxWaitMs = 30_000, intervalMs = 2000) {
687
824
  const start = Date.now();
825
+ /** Brief 404s happen when finalize_job_id is visible before the child row is readable. */
826
+ let notFoundStreak = 0;
827
+ const maxNotFoundRetries = 5;
688
828
  while (Date.now() - start < maxWaitMs) {
689
- const data = (await apiCall("GET", `/v1/jobs/${jobId}`));
690
- const status = data.status;
691
- if (status === "completed" || status === "failed" || status === "cancelled") {
692
- return {
693
- status,
694
- result_ref: data.result_ref,
695
- error: data.error,
696
- };
829
+ try {
830
+ const data = (await apiCall("GET", `/v1/jobs/${jobId}`));
831
+ notFoundStreak = 0;
832
+ const status = data.status;
833
+ if (status === "completed" || status === "failed" || status === "cancelled") {
834
+ return {
835
+ status,
836
+ result_ref: data.result_ref,
837
+ error: data.error,
838
+ };
839
+ }
840
+ }
841
+ catch (err) {
842
+ const status = err?.httpStatus;
843
+ if (status === 404 && notFoundStreak < maxNotFoundRetries) {
844
+ notFoundStreak += 1;
845
+ await new Promise((r) => setTimeout(r, intervalMs));
846
+ continue;
847
+ }
848
+ throw err;
697
849
  }
698
850
  await new Promise((r) => setTimeout(r, intervalMs));
699
851
  }
700
852
  return { status: "timeout" };
701
853
  }
854
+ const RESULTS_FIGURE_ENUMS = new Set(["default", "all", "none"]);
855
+ /**
856
+ * Coerce host-stringified figures args (e.g. `'["all"]'`) and singleton enum arrays
857
+ * (`["all"]` → `"all"`) before Zod validation.
858
+ */
859
+ export function coerceFiguresArg(v) {
860
+ if (v === undefined || v === null)
861
+ return v;
862
+ let val = v;
863
+ if (typeof val === "string") {
864
+ const trimmed = val.trim();
865
+ if ((trimmed.startsWith("[") && trimmed.endsWith("]")) ||
866
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
867
+ try {
868
+ val = JSON.parse(trimmed);
869
+ }
870
+ catch {
871
+ return v;
872
+ }
873
+ }
874
+ }
875
+ if (Array.isArray(val)) {
876
+ const asStrings = val.map((x) => String(x));
877
+ if (asStrings.length === 1 && RESULTS_FIGURE_ENUMS.has(asStrings[0])) {
878
+ return asStrings[0];
879
+ }
880
+ return asStrings;
881
+ }
882
+ return val;
883
+ }
702
884
  // ---------------------------------------------------------------------------
703
885
  // Image helpers
704
886
  // ---------------------------------------------------------------------------
package/dist/tools/cfd.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall, textResult, suggestAfterCfdSubmit } from "../shared.js";
3
+ import { apiCall, ensureDatasetTrainReady, textResult, suggestAfterCfdSubmit } from "../shared.js";
4
4
  import { pollCfdPrepareIfPresent } from "../cfd_prepare.js";
5
5
  export function registerCfdTools(server) {
6
6
  registerAuditedTool(server, "barmesh_mesh_convergence", `Run SOM-based mesh-convergence analysis on an uploaded combined per-cell CSV.
@@ -53,6 +53,7 @@ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh
53
53
  body.description = description;
54
54
  if (tags !== undefined)
55
55
  body.tags = tags;
56
+ await ensureDatasetTrainReady(String(dataset_id));
56
57
  const data = (await apiCall("POST", "/v1/cfd/mesh-convergence", body));
57
58
  await pollCfdPrepareIfPresent(data, "barmesh_mesh_convergence");
58
59
  const id = data.id;
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { registerAuditedTool } from "../audit.js";
7
- import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, resolveUploadContentType, suggestMeshDatasetPreview, } from "../shared.js";
7
+ import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, awaitDatasetStageIfNeeded, streamFileSha256, putPresignedStream, resolveUploadContentType, suggestMeshDatasetPreview, } from "../shared.js";
8
8
  import { GZIP_UPLOAD_HINT } from "../upload_hints.js";
9
9
  import { formatDatasetInventoryLine, formatTags } from "../inventory_format.js";
10
10
  /**
@@ -178,7 +178,7 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
178
178
  gzHeaders["X-Dataset-Description"] = description;
179
179
  if (tags !== undefined)
180
180
  gzHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
181
- const data = (await apiCall("POST", "/v1/datasets", gzBytes, gzHeaders, UPLOAD_DATASET_TIMEOUT_MS));
181
+ const data = await awaitDatasetStageIfNeeded((await apiCall("POST", "/v1/datasets", gzBytes, gzHeaders, UPLOAD_DATASET_TIMEOUT_MS)));
182
182
  const gid = data.id ?? data.dataset_id;
183
183
  if (gid != null) {
184
184
  data.suggested_next_step = suggestMeshDatasetPreview(String(gid));
@@ -208,7 +208,7 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
208
208
  uploadBody = gzipSync(Buffer.from(body, "utf-8"));
209
209
  uploadHeaders["Content-Encoding"] = "gzip";
210
210
  }
211
- const data = (await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS));
211
+ const data = await awaitDatasetStageIfNeeded((await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS)));
212
212
  const id = data.id ?? data.dataset_id;
213
213
  if (id != null) {
214
214
  data.suggested_next_step = suggestMeshDatasetPreview(String(id));
@@ -259,16 +259,20 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
259
259
  throw new Error("barmesh_datasets(get) requires dataset_id.");
260
260
  const ds = (await apiCall("GET", `/v1/datasets/${dataset_id}`));
261
261
  const tagStr = formatTags(ds.tags);
262
+ const trainReady = ds.train_ready === true;
262
263
  const lines = [
263
264
  `Dataset: ${ds.name ?? "?"} (${ds.id ?? dataset_id})`,
264
- `Status: ${ds.status ?? "ready"}`,
265
+ `Status: ${ds.status ?? "unknown"}`,
266
+ `Train ready: ${trainReady ? "yes" : "no"}`,
265
267
  `Rows × cols: ${ds.rows ?? "?"} × ${ds.cols ?? "?"}`,
266
268
  ds.size_bytes != null ? `Size: ${Number(ds.size_bytes).toLocaleString()} bytes` : "",
267
269
  ds.description != null && String(ds.description).trim() !== "" ? `Description: ${String(ds.description)}` : "",
268
270
  tagStr ? `Tags: ${tagStr}` : "",
269
271
  ds.staged_prefix != null ? `Staged prefix: ${String(ds.staged_prefix)}` : "",
270
272
  ds.staged_version != null ? `Staged version: ${String(ds.staged_version)}` : "",
271
- ds.stage_job_id != null ? `Stage job: ${String(ds.stage_job_id)} (poll barmesh_jobs(action=status))` : "",
273
+ !trainReady && ds.stage_job_id != null
274
+ ? `Stage job: ${String(ds.stage_job_id)} (poll barmesh_jobs(action=status) until train_ready)`
275
+ : "",
272
276
  cleanNullable(ds.ingest_error) ? `Ingest error: ${cleanNullable(ds.ingest_error)}` : "",
273
277
  ds.created_at != null ? `Created: ${String(ds.created_at)}` : "",
274
278
  ].filter(Boolean);
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { registerAuditedTool } from "../audit.js";
5
- import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, textResult, tryAttachImage, resetInlineAttachBudget, pollUntilComplete, POLL_STAGE_MAX_MS, suggestAfterRender, } from "../shared.js";
5
+ import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, textResult, tryAttachImage, resetInlineAttachBudget, pollUntilComplete, POLL_STAGE_MAX_MS, suggestAfterRender, coerceFiguresArg, } from "../shared.js";
6
6
  import { formatConvergenceReading } from "../convergence_reading.js";
7
7
  import { formatRichardsonResultsSummary, isRichardsonJob } from "../richardson_results.js";
8
8
  const MESH_DEFAULT_FIGURES = ["combined", "overview_distances", "plot_vol_all_meshes", "plot_vol_steps", "learning_curve"];
@@ -21,6 +21,18 @@ function formatMeshResultsSummary(jobId, data, summary) {
21
21
  const curveNote = (ord?.length ?? 0) > 0 || (conv?.length ?? 0) > 0
22
22
  ? `Training curves: ${ord?.length ?? 0} ordering + ${conv?.length ?? 0} convergence batch samples (≤1000 each in API/MCP responses).`
23
23
  : "";
24
+ const topDivs = summary.top_divergences;
25
+ const klComp = summary.feature_kl_complexity;
26
+ const topDivLine = topDivs && topDivs.length > 0
27
+ ? `Top feature-plane symmetric KL pairs (component association — not mesh SKL/EMD): ${topDivs
28
+ .map((t) => `${t.f1}&${t.f2}=${typeof t.symmetric_kl === "number" ? Number(t.symmetric_kl).toFixed(3) : t.symmetric_kl}`)
29
+ .join(", ")}`
30
+ : "";
31
+ const klComplexityLine = klComp && klComp.length > 0
32
+ ? `Feature KL complexity (mean outgoing): ${klComp
33
+ .map((t) => `${t.feature}=${typeof t.mean_outgoing_kl === "number" ? Number(t.mean_outgoing_kl).toFixed(3) : t.mean_outgoing_kl}`)
34
+ .join(", ")}`
35
+ : "";
24
36
  return [
25
37
  header,
26
38
  `Preset: ${String(summary.preset ?? "generic")} | Grid: ${grid.length >= 2 ? `${grid[0]}×${grid[1]}` : "N/A"} | Reference: ${String(summary.reference_mesh ?? "N/A")}`,
@@ -28,8 +40,13 @@ function formatMeshResultsSummary(jobId, data, summary) {
28
40
  meshes.length > 0 ? `\nDistances vs reference:\n${meshLines.join("\n")}` : "",
29
41
  stepLines.length > 0 ? `\nStepwise:\n${stepLines.join("\n")}` : "",
30
42
  readingLine ? `\nConvergence: ${readingLine}` : "",
43
+ topDivLine,
44
+ klComplexityLine,
31
45
  curveNote,
32
46
  "\nAdvisory: SOM distances complement, not replace, numerical uncertainty analysis (use barmesh_richardson for classical GCI).",
47
+ topDivLine || klComplexityLine
48
+ ? "Feature-plane KL (divergence_kl / feature_divergences.json) measures association between SOM component planes; mesh SKL/EMD measure fingerprint distance between meshes. Refresh or add W1 via barmesh_results(action=feature_divergences, metrics=[\"wasserstein\"])."
49
+ : "",
33
50
  "For every figure: barmesh_results_explorer(job_id) or barmesh_results(action=download). Use figures=\"none\" to skip inline images.",
34
51
  ].filter(Boolean).join("\n");
35
52
  }
@@ -40,6 +57,8 @@ const TEXT_ARTIFACTS = [
40
57
  "distances_to_ref.txt",
41
58
  "emd_stepwise.txt",
42
59
  "cfd_metrics.json",
60
+ "feature_divergences.json",
61
+ "weights.json",
43
62
  "richardson_gci.csv",
44
63
  ];
45
64
  export function registerResultsTool(server) {
@@ -47,29 +66,85 @@ export function registerResultsTool(server) {
47
66
 
48
67
  | Action | Use when |
49
68
  |--------|----------|
50
- | get | Read the summary. mesh_convergence: distances + inline figures. richardson: triplet p/GCI table + topology_warning; see richardson_gci.csv. |
51
- | image | Download one figure by filename (e.g. KL_ref.png, combined.pdf). |
69
+ | get | Read the summary. mesh_convergence: distances + inline figures + feature-plane KL tops when present. richardson: triplet p/GCI table + topology_warning; see richardson_gci.csv. |
70
+ | image | Download one figure by filename (e.g. KL_ref.png, combined.pdf, divergence_kl.png). |
52
71
  | render | Re-render the figures as publication PDFs (or SVG) on demand — PDFs are NOT generated by default. |
53
72
  | download | Save figures and metrics to a local folder (headless / agent path). |
73
+ | feature_divergences | Refresh feature-plane KL matrices / optional Wasserstein on SOM component planes (async; not mesh fingerprint KL). |
54
74
 
55
75
  BEST FOR: After barmesh_jobs(action=status) shows completed (and finalize finished if defer_figures was used).
56
- 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).
76
+ 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 or divergence_kl. Prefer string figures="all" / "none" (not an array); arrays are for specific base names or filenames. Hosts that stringify arrays are coerced. action=get inlines the headline set; pass figures="all" for every artifact listed in summary.files, figures="none" for metrics only (recommended for agents).
77
+ FEATURE-PLANE KL: mesh SKL/EMD (KL_ref, EMD_*) compare meshes; divergence_kl / feature_divergences.json compare SOM component planes (barsom-parallel). Finalize already writes symmetric KL; use action=feature_divergences for W1 or a refresh.
57
78
  PDFs ON DEMAND: vector PDFs are not produced by default. Use action=render (format=pdf) once, then action=image or action=download.
58
79
  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.
59
80
  UNAVAILABLE: If the mesh analysis API is temporarily unavailable, say so plainly and suggest retry later (~retry_after_sec). Figures already downloaded stay usable.
60
81
  NOT FOR: Submitting jobs.`, {
61
- action: z.enum(["get", "image", "render", "download"]).describe("get: summary + figures; image: one file; render: lazy PDF/SVG; download: save to disk"),
82
+ action: z
83
+ .enum(["get", "image", "render", "download", "feature_divergences"])
84
+ .describe("get: summary + figures; image: one file; render: lazy PDF/SVG; download: save to disk; feature_divergences: feature-plane KL/W1 (async)."),
62
85
  job_id: z.string().describe("Job ID"),
63
86
  filename: z.string().optional().describe("For action=image: the figure filename (e.g. KL_ref.png or, after render, KL_ref.pdf)"),
64
87
  format: z.enum(["pdf", "png", "svg"]).optional().describe("For action=render: output format to generate (default pdf). PNG previews already exist by default."),
65
- figures: z
88
+ figures: z.preprocess(coerceFiguresArg, z
66
89
  .union([z.enum(["default", "all", "none"]), z.array(z.string())])
67
90
  .optional()
68
- .describe("For action=get/download: which figures (default headline panels; 'all'; 'none'; or a list of base names)"),
91
+ .describe("Prefer string 'all'/'none'. get/download: omit=headline panels; 'all'; 'none'; or array of base names/filenames. Stringified JSON arrays from hosts are accepted.")),
69
92
  folder: z.string().optional().describe("For action=download: directory to save into (relative to workspace). Files land in a per-job subfolder."),
70
93
  include_json: z.boolean().optional().describe("For action=download: also save summary.json and text/CSV artifacts"),
94
+ metrics: z
95
+ .array(z.enum(["symmetric_kl", "directed_kl", "wasserstein"]))
96
+ .optional()
97
+ .describe("action=feature_divergences: which matrices (default symmetric_kl+directed_kl). Add wasserstein for W1."),
98
+ wasserstein_method: z
99
+ .enum(["sinkhorn", "exact"])
100
+ .optional()
101
+ .describe("action=feature_divergences: OT solver when metrics includes wasserstein (default sinkhorn)."),
102
+ output_format: z.enum(["png", "pdf", "svg"]).optional().describe("action=feature_divergences: heatmap format (default png)."),
103
+ output_dpi: z.number().int().min(1).max(4).optional().describe("action=feature_divergences: PNG scale factor (default 2)."),
71
104
  }, async (args) => {
72
- const { action, job_id, filename, format, figures, folder, include_json } = args;
105
+ const { action, job_id, filename, format, figures, folder, include_json, metrics: divergenceMetrics, wasserstein_method: wassersteinMethod, output_format, output_dpi, } = args;
106
+ if (action === "feature_divergences") {
107
+ const body = {
108
+ metrics: divergenceMetrics ?? ["symmetric_kl", "directed_kl"],
109
+ output_format: output_format ?? "png",
110
+ output_dpi: output_dpi ?? 2,
111
+ };
112
+ if (wassersteinMethod != null)
113
+ body.wasserstein_method = wassersteinMethod;
114
+ const data = (await apiCall("POST", `/v1/results/${job_id}/feature_divergences`, body));
115
+ const newJobId = data.id;
116
+ const poll = await pollUntilComplete(newJobId, POLL_STAGE_MAX_MS);
117
+ if (poll.status !== "completed") {
118
+ return textResult(`Feature divergences job ${newJobId} submitted (parent mesh job ${job_id}). Poll with barmesh_jobs(action=status, job_id="${newJobId}"), then barmesh_results(action=get, job_id="${job_id}").`);
119
+ }
120
+ const res = (await apiCall("GET", `/v1/results/${newJobId}`));
121
+ const sum = (res.summary ?? {});
122
+ const topDivs = sum.top_divergences ?? [];
123
+ const klComp = sum.feature_kl_complexity ?? [];
124
+ const topW1 = sum.top_wasserstein ?? null;
125
+ const lines = [
126
+ `Feature-plane divergences complete — job_id: ${newJobId} (parent mesh job: ${job_id})`,
127
+ `Method: ${sum.divergence_method ?? "symmetric_kl_min_shift_sum_norm_denormalized_component_planes"}`,
128
+ `Weight space: ${sum.weight_space ?? "denormalized"} (physical component planes)`,
129
+ `Metrics: ${JSON.stringify(sum.metrics ?? body.metrics)}`,
130
+ topDivs.length
131
+ ? `Top symmetric KL pairs: ${topDivs.map((t) => `${t.f1}&${t.f2}=${typeof t.symmetric_kl === "number" ? Number(t.symmetric_kl).toFixed(3) : t.symmetric_kl}`).join(", ")}`
132
+ : "",
133
+ klComp.length
134
+ ? `KL complexity ranking: ${klComp.map((t) => `${t.feature}=${typeof t.mean_outgoing_kl === "number" ? Number(t.mean_outgoing_kl).toFixed(3) : t.mean_outgoing_kl}`).join(", ")}`
135
+ : "",
136
+ topW1 && topW1.length
137
+ ? `Top Wasserstein pairs: ${topW1.map((t) => `${t.f1}&${t.f2}=${typeof t.wasserstein === "number" ? Number(t.wasserstein).toFixed(3) : t.wasserstein}`).join(", ")}`
138
+ : "",
139
+ `Artifacts refreshed on parent result (divergence_kl / feature_divergences.json). This is feature-plane association — distinct from mesh fingerprint SKL/EMD (KL_ref, EMD_*).`,
140
+ ].filter(Boolean);
141
+ const content = [{ type: "text", text: lines.join("\n") }];
142
+ const files = sum.files ?? [];
143
+ for (const f of files.filter((x) => /\.(png|svg)$/i.test(x))) {
144
+ await tryAttachImage(content, job_id, f);
145
+ }
146
+ return { content };
147
+ }
73
148
  if (action === "render") {
74
149
  const fmt = format ?? "pdf";
75
150
  const submit = (await apiCall("POST", "/v1/cfd/render", { job_id, format: fmt }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barivia/barmesh-mcp",
3
- "version": "0.8.3",
3
+ "version": "0.8.7",
4
4
  "description": "barmesh MCP proxy — SOM-based CFD mesh-convergence and Richardson/GCI analysis on the Barivia cloud API",
5
5
  "keywords": [
6
6
  "mcp",