@barivia/barmesh-mcp 0.7.1 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -13
- package/dist/audit.js +3 -1
- package/dist/deferred_feedback.js +88 -0
- package/dist/figure_sections.js +11 -3
- package/dist/index.js +1 -1
- package/dist/inventory_format.js +69 -0
- package/dist/richardson_results.js +35 -0
- package/dist/shared.js +281 -38
- package/dist/tools/barmesh_results_explorer.js +41 -29
- package/dist/tools/cfd.js +17 -5
- package/dist/tools/datasets.js +50 -18
- package/dist/tools/feedback.js +71 -6
- package/dist/tools/guide.js +33 -9
- package/dist/tools/jobs.js +117 -11
- package/dist/tools/results.js +9 -3
- package/dist/tools/training_monitor.js +26 -21
- package/dist/training_monitor_curve.js +42 -0
- package/dist/ui-delivery.js +155 -0
- package/dist/views/src/views/barmesh-results-explorer/index.html +18 -16
- package/dist/views/src/views/barmesh-training-monitor/index.html +48 -26
- package/dist/viz_links.js +55 -0
- package/package.json +1 -1
package/dist/shared.js
CHANGED
|
@@ -20,18 +20,22 @@ import { logInfo, logWarn } from "./logger.js";
|
|
|
20
20
|
export const API_URL = process.env.BARIVIA_API_URL ?? "https://api.barivia.se";
|
|
21
21
|
export const API_KEY = process.env.BARIVIA_API_KEY ?? "";
|
|
22
22
|
export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "60000", 10);
|
|
23
|
-
|
|
24
|
-
export const
|
|
23
|
+
/** Retries after the first attempt; default 2 → 3 total tries. Override with BARIVIA_MAX_RETRIES. */
|
|
24
|
+
export const MAX_RETRIES = parseInt(process.env.BARIVIA_MAX_RETRIES ?? "2", 10);
|
|
25
|
+
/** Delay between burst retries (ms). Default 2000 → ~2s between each of the 3 attempts. */
|
|
26
|
+
export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "2000", 10);
|
|
25
27
|
export const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
|
26
|
-
/**
|
|
27
|
-
export
|
|
28
|
-
|
|
28
|
+
/** Cool-down hint after the burst is exhausted — agents should wait before another round. */
|
|
29
|
+
export const UNAVAILABLE_RETRY_AFTER_SEC = 30;
|
|
30
|
+
/** Fixed delay between burst retries (not exponential). */
|
|
31
|
+
export function retryDelayMs(_attempt) {
|
|
32
|
+
return RETRY_BASE_MS;
|
|
29
33
|
}
|
|
30
34
|
function newRequestId() {
|
|
31
35
|
return randomUUID();
|
|
32
36
|
}
|
|
33
37
|
/** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
|
|
34
|
-
export const CLIENT_VERSION = "0.
|
|
38
|
+
export const CLIENT_VERSION = "0.8.2";
|
|
35
39
|
export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
|
|
36
40
|
/** Large per-cell CSV uploads may exceed the default fetch timeout. */
|
|
37
41
|
export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
|
|
@@ -139,6 +143,51 @@ export function getClientSupportsMcpApps() {
|
|
|
139
143
|
export function setClientSupportsMcpApps(value) {
|
|
140
144
|
_clientSupportsMcpApps = value;
|
|
141
145
|
}
|
|
146
|
+
export const BARMESH_VIZ_VIEWS = {
|
|
147
|
+
trainingMonitor: "barmesh-training-monitor",
|
|
148
|
+
resultsExplorer: "barmesh-results-explorer",
|
|
149
|
+
};
|
|
150
|
+
export function buildStandaloneVizPageUrl(port, viewPath, jobId) {
|
|
151
|
+
if (!port)
|
|
152
|
+
return undefined;
|
|
153
|
+
return `http://localhost:${port}/viz/${viewPath}?mode=standalone&job_id=${encodeURIComponent(jobId)}`;
|
|
154
|
+
}
|
|
155
|
+
export function buildVizRelatedUrls(port, jobId, views) {
|
|
156
|
+
if (!port)
|
|
157
|
+
return undefined;
|
|
158
|
+
return {
|
|
159
|
+
trainingMonitor: buildStandaloneVizPageUrl(port, views.trainingMonitor, jobId),
|
|
160
|
+
resultsExplorer: buildStandaloneVizPageUrl(port, views.resultsExplorer, jobId),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Prominent standalone URL blocks for tool results — always emitted when a viz port exists. */
|
|
164
|
+
export function buildVizUrlContentBlocks(options) {
|
|
165
|
+
const { label, toolName, standaloneUrl, port, jobId, clientSupportsMcpApps } = options;
|
|
166
|
+
if (!standaloneUrl)
|
|
167
|
+
return [];
|
|
168
|
+
const embedNote = clientSupportsMcpApps
|
|
169
|
+
? "Embedded panel if your host supports MCP Apps — otherwise open the standalone URL below (expected on some Cursor builds)."
|
|
170
|
+
: "Open the standalone URL below when no inline panel appears.";
|
|
171
|
+
const health = port > 0
|
|
172
|
+
? ` Health check: http://localhost:${port}/api/health?job_id=${encodeURIComponent(jobId)}`
|
|
173
|
+
: "";
|
|
174
|
+
const blocks = [
|
|
175
|
+
{
|
|
176
|
+
type: "text",
|
|
177
|
+
text: `${embedNote}\n` +
|
|
178
|
+
`Standalone ${label} URL (copy if Open is blocked):\n${standaloneUrl}\n` +
|
|
179
|
+
`AGENT: surface this URL prominently in your reply — use a markdown link "[Open ${label}](${standaloneUrl})" or tell the user to copy/paste into a browser. Do not bury it after metrics.\n` +
|
|
180
|
+
`This localhost port is per MCP session and goes stale if the proxy restarts — re-run ${toolName} for a fresh URL, or set BARIVIA_VIZ_PORT for a persistent port.${health}`,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
if (!clientSupportsMcpApps) {
|
|
184
|
+
blocks.push({
|
|
185
|
+
type: "text",
|
|
186
|
+
text: `[Open ${label}](${standaloneUrl})`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return blocks;
|
|
190
|
+
}
|
|
142
191
|
/** Tool result carrying both a structured payload (for the MCP App view) and text/image content. */
|
|
143
192
|
export function structuredTextResult(structuredContent, text, content) {
|
|
144
193
|
return {
|
|
@@ -247,47 +296,227 @@ export async function resolveFilePathForUpload(filePath, mcpServer) {
|
|
|
247
296
|
const resolved = path.resolve(root, trimmed);
|
|
248
297
|
return checkUnder(resolved);
|
|
249
298
|
}
|
|
250
|
-
|
|
299
|
+
function looksLikeHtml(bodyText) {
|
|
300
|
+
const t = bodyText.trim().slice(0, 200).toLowerCase();
|
|
301
|
+
return t.startsWith("<!doctype") || t.startsWith("<html") || /<\s*html[\s>]/.test(t);
|
|
302
|
+
}
|
|
303
|
+
function isStorageErrorCode(code) {
|
|
304
|
+
if (!code)
|
|
305
|
+
return false;
|
|
306
|
+
const c = code.toLowerCase();
|
|
307
|
+
return c === "storage_upload_failed" || (c.includes("storage") && !c.includes("unavailable"));
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Classify an HTTP API failure into a compact structured error.
|
|
311
|
+
* Gateway HTML 502/503 bodies are never echoed — they become api_unreachable.
|
|
312
|
+
*/
|
|
313
|
+
export function classifyApiError(status, bodyText, requestId) {
|
|
251
314
|
let parsed = null;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
315
|
+
const html = looksLikeHtml(bodyText);
|
|
316
|
+
if (!html && bodyText.trim()) {
|
|
317
|
+
try {
|
|
318
|
+
const j = JSON.parse(bodyText);
|
|
319
|
+
if (j && typeof j === "object")
|
|
320
|
+
parsed = j;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
/* non-JSON */
|
|
324
|
+
}
|
|
259
325
|
}
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
326
|
+
const apiCode = parsed?.error_code != null ? String(parsed.error_code) : "";
|
|
327
|
+
const apiMsg = parsed?.error != null && String(parsed.error).trim()
|
|
328
|
+
? String(parsed.error).trim()
|
|
329
|
+
: "";
|
|
330
|
+
let cause = "server";
|
|
331
|
+
let error_code = apiCode || `http_${status}`;
|
|
332
|
+
let message = apiMsg || `HTTP ${status}`;
|
|
333
|
+
let hint = "";
|
|
334
|
+
let retryable = status === 502 || status === 503 || status === 504;
|
|
263
335
|
const accountHint = ` Regenerate or verify your key via ${PUBLIC_SITE_ORIGIN} if needed.`;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
336
|
+
if (status === 400) {
|
|
337
|
+
cause = "client";
|
|
338
|
+
hint = "Check parameter types and required fields.";
|
|
339
|
+
retryable = false;
|
|
340
|
+
}
|
|
341
|
+
else if (status === 401) {
|
|
342
|
+
cause = "auth";
|
|
343
|
+
error_code = apiCode || "unauthorized";
|
|
344
|
+
hint = `Check BARIVIA_API_KEY in your MCP config.${accountHint}`;
|
|
345
|
+
retryable = false;
|
|
346
|
+
}
|
|
347
|
+
else if (status === 403) {
|
|
348
|
+
cause = "auth";
|
|
349
|
+
error_code = apiCode || "forbidden";
|
|
350
|
+
hint = `Access denied — your plan does not include the CFD capability. Add the CFD capability at ${PUBLIC_SITE_ORIGIN}/dashboard to enable the barmesh tools.`;
|
|
351
|
+
retryable = false;
|
|
352
|
+
}
|
|
353
|
+
else if (status === 404) {
|
|
354
|
+
cause = "client";
|
|
355
|
+
hint = "The resource may not exist or may have been deleted.";
|
|
356
|
+
retryable = false;
|
|
357
|
+
}
|
|
358
|
+
else if (status === 409) {
|
|
359
|
+
cause = "client";
|
|
360
|
+
hint = "The job may not be in the expected state.";
|
|
361
|
+
retryable = false;
|
|
362
|
+
}
|
|
363
|
+
else if (status === 429) {
|
|
364
|
+
cause = "rate_limit";
|
|
365
|
+
hint = "Plan limit or rate limit — delete unused datasets or wait and retry.";
|
|
366
|
+
retryable = true;
|
|
367
|
+
}
|
|
368
|
+
else if (status === 502 || status === 503 || status === 504) {
|
|
369
|
+
if (isStorageErrorCode(apiCode) || /object storage|r2\/s3|storage upload/i.test(apiMsg)) {
|
|
370
|
+
cause = "storage";
|
|
371
|
+
error_code = apiCode || "storage_upload_failed";
|
|
372
|
+
message = apiMsg || "Object storage (R2/S3) error from API.";
|
|
373
|
+
hint = "Retry later; feedback may still succeed via DB when storage is degraded.";
|
|
374
|
+
}
|
|
375
|
+
else if (html || !parsed) {
|
|
376
|
+
cause = "api_unreachable";
|
|
377
|
+
error_code = apiCode || "api_unreachable";
|
|
378
|
+
message = "Mesh analysis API temporarily unavailable (gateway/tunnel returned a non-JSON error).";
|
|
379
|
+
hint =
|
|
380
|
+
`Proxy already tried 3 times (~2s apart). Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. ` +
|
|
381
|
+
"Use barmesh_api_health — /health does not depend on R2. Keep reviewing last good distances/figures; do not paste HTML.";
|
|
382
|
+
}
|
|
383
|
+
else if (apiCode === "feedback_unavailable" || apiCode === "database_error") {
|
|
384
|
+
cause = "server";
|
|
385
|
+
error_code = apiCode;
|
|
386
|
+
message = apiMsg || "API or database temporarily unavailable.";
|
|
387
|
+
hint =
|
|
388
|
+
`Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s; barmesh_send_feedback queues locally if the API stays down.`;
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
cause = "server";
|
|
392
|
+
message = apiMsg || `HTTP ${status}`;
|
|
393
|
+
hint = `API or database temporarily unavailable — wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else if (status >= 500) {
|
|
397
|
+
cause = "server";
|
|
398
|
+
hint = `Server error — wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
|
|
399
|
+
retryable = true;
|
|
400
|
+
}
|
|
401
|
+
else if (!apiMsg && html) {
|
|
402
|
+
cause = "api_unreachable";
|
|
403
|
+
error_code = "api_unreachable";
|
|
404
|
+
message = `HTTP ${status} (non-JSON gateway body)`;
|
|
405
|
+
hint = `Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. Do not paste HTML.`;
|
|
406
|
+
retryable = status >= 500;
|
|
407
|
+
}
|
|
408
|
+
const retry_after_sec = retryable ? UNAVAILABLE_RETRY_AFTER_SEC : undefined;
|
|
409
|
+
return {
|
|
410
|
+
status,
|
|
411
|
+
error_code,
|
|
412
|
+
message,
|
|
413
|
+
hint,
|
|
414
|
+
request_id: requestId,
|
|
415
|
+
retryable,
|
|
416
|
+
cause,
|
|
417
|
+
...(retry_after_sec !== undefined ? { retry_after_sec } : {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/** User-visible API error line (slim; includes request id). Exported for tests. */
|
|
421
|
+
export function formatApiErrorMessage(status, bodyText, requestId) {
|
|
422
|
+
const c = classifyApiError(status, bodyText, requestId);
|
|
423
|
+
const parts = [
|
|
424
|
+
c.message,
|
|
425
|
+
`error_code=${c.error_code}`,
|
|
426
|
+
`cause=${c.cause}`,
|
|
427
|
+
c.retry_after_sec != null ? `retry_after_sec=${c.retry_after_sec}` : "",
|
|
428
|
+
c.hint,
|
|
429
|
+
`request_id=${c.request_id}`,
|
|
430
|
+
].filter((p) => p && String(p).trim());
|
|
431
|
+
return parts.join(" | ");
|
|
285
432
|
}
|
|
286
433
|
function throwApiError(status, bodyText, requestId) {
|
|
434
|
+
const classified = classifyApiError(status, bodyText, requestId);
|
|
287
435
|
const err = new Error(formatApiErrorMessage(status, bodyText, requestId));
|
|
288
436
|
err.httpStatus = status;
|
|
437
|
+
err.errorCode = classified.error_code;
|
|
438
|
+
err.requestId = requestId;
|
|
439
|
+
err.retryable = classified.retryable;
|
|
440
|
+
err.cause = classified.cause;
|
|
441
|
+
err.retryAfterSec = classified.retry_after_sec;
|
|
442
|
+
err.classified = classified;
|
|
289
443
|
throw err;
|
|
290
444
|
}
|
|
445
|
+
/** Final network failure after the burst — same calm shape as gateway HTML 502. */
|
|
446
|
+
function throwNetworkUnreachable(requestId, err) {
|
|
447
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
448
|
+
const classified = {
|
|
449
|
+
status: 0,
|
|
450
|
+
error_code: "api_unreachable",
|
|
451
|
+
message: "Mesh analysis API temporarily unavailable (network error).",
|
|
452
|
+
hint: `Proxy already tried 3 times (~2s apart). Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. ` +
|
|
453
|
+
`Detail: ${detail.slice(0, 120)}`,
|
|
454
|
+
request_id: requestId,
|
|
455
|
+
retryable: true,
|
|
456
|
+
cause: "api_unreachable",
|
|
457
|
+
retry_after_sec: UNAVAILABLE_RETRY_AFTER_SEC,
|
|
458
|
+
};
|
|
459
|
+
const msg = [
|
|
460
|
+
classified.message,
|
|
461
|
+
`error_code=${classified.error_code}`,
|
|
462
|
+
`cause=${classified.cause}`,
|
|
463
|
+
`retry_after_sec=${classified.retry_after_sec}`,
|
|
464
|
+
classified.hint,
|
|
465
|
+
`request_id=${classified.request_id}`,
|
|
466
|
+
].join(" | ");
|
|
467
|
+
const out = new Error(msg);
|
|
468
|
+
out.httpStatus = 0;
|
|
469
|
+
out.errorCode = classified.error_code;
|
|
470
|
+
out.requestId = requestId;
|
|
471
|
+
out.retryable = true;
|
|
472
|
+
out.cause = "api_unreachable";
|
|
473
|
+
out.retryAfterSec = UNAVAILABLE_RETRY_AFTER_SEC;
|
|
474
|
+
out.classified = classified;
|
|
475
|
+
throw out;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Liveness (+ optional readiness) probe against the API origin.
|
|
479
|
+
* Uses GET /health which never touches R2 — safe during storage outages.
|
|
480
|
+
*/
|
|
481
|
+
export async function probeApiHealth() {
|
|
482
|
+
const base = { storage: "not_probed" };
|
|
483
|
+
try {
|
|
484
|
+
const live = await fetchWithTimeout(`${API_URL}/health`, { method: "GET" }, 8_000);
|
|
485
|
+
const liveText = await live.text();
|
|
486
|
+
if (!live.ok) {
|
|
487
|
+
return { ...base, liveness: "down", readiness: "unknown", detail: `health HTTP ${live.status}` };
|
|
488
|
+
}
|
|
489
|
+
let readiness = "unknown";
|
|
490
|
+
let db;
|
|
491
|
+
let redis;
|
|
492
|
+
try {
|
|
493
|
+
const ready = await fetchWithTimeout(`${API_URL}/ready`, { method: "GET" }, 8_000);
|
|
494
|
+
const readyText = await ready.text();
|
|
495
|
+
try {
|
|
496
|
+
const j = JSON.parse(readyText);
|
|
497
|
+
readiness = ready.ok && j.status === "ready" ? "ready" : "not_ready";
|
|
498
|
+
db = j.db;
|
|
499
|
+
redis = j.redis;
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
readiness = ready.ok ? "ready" : "not_ready";
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
readiness = "unknown";
|
|
507
|
+
}
|
|
508
|
+
void liveText;
|
|
509
|
+
return { ...base, liveness: "ok", readiness, db, redis };
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
return {
|
|
513
|
+
...base,
|
|
514
|
+
liveness: "down",
|
|
515
|
+
readiness: "unknown",
|
|
516
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
291
520
|
// ---- Distributed-trace context (W3C traceparent) ----
|
|
292
521
|
// One trace per logical tool action (scoped via AsyncLocalStorage in registerAuditedTool),
|
|
293
522
|
// so the API + job chain reconstruct end-to-end. Fresh span per API call; falls back to a
|
|
@@ -375,9 +604,13 @@ export async function apiCall(method, pathPart, body, extraHeaders, requestTimeo
|
|
|
375
604
|
if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
|
|
376
605
|
throw new Error(`Request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS (e.g. 120000) for slow or large requests. (request id: ${requestId})`);
|
|
377
606
|
}
|
|
607
|
+
if (err instanceof TypeError)
|
|
608
|
+
throwNetworkUnreachable(requestId, err);
|
|
378
609
|
throw err;
|
|
379
610
|
}
|
|
380
611
|
}
|
|
612
|
+
if (lastError instanceof TypeError)
|
|
613
|
+
throwNetworkUnreachable(requestId, lastError);
|
|
381
614
|
throw lastError;
|
|
382
615
|
}
|
|
383
616
|
/** Fetch raw bytes from the API (for image downloads). */
|
|
@@ -430,9 +663,13 @@ export async function apiRawCall(pathPart, requestTimeoutMs) {
|
|
|
430
663
|
if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
|
|
431
664
|
throw new Error(`Image request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS (e.g. 120000) for large images. (request id: ${requestId})`);
|
|
432
665
|
}
|
|
666
|
+
if (err instanceof TypeError)
|
|
667
|
+
throwNetworkUnreachable(requestId, err);
|
|
433
668
|
throw err;
|
|
434
669
|
}
|
|
435
670
|
}
|
|
671
|
+
if (lastError instanceof TypeError)
|
|
672
|
+
throwNetworkUnreachable(requestId, lastError);
|
|
436
673
|
throw lastError;
|
|
437
674
|
}
|
|
438
675
|
// ---------------------------------------------------------------------------
|
|
@@ -485,6 +722,12 @@ export function getCaptionForImage(filename) {
|
|
|
485
722
|
return "Wasserstein-1 (EMD) distance from each mesh to the reference. A monotone decrease toward the finest mesh indicates field-level convergence.";
|
|
486
723
|
if (base === "EMD_stepwise")
|
|
487
724
|
return "Stepwise EMD between consecutive meshes. Small, plateauing values on the finest pairs indicate the fingerprint has stopped changing.";
|
|
725
|
+
if (base === "KL_asymmetric")
|
|
726
|
+
return "Asymmetric pairwise mesh-fingerprint KL D_KL(P‖Q): above-diagonal cells are coarse→fine, below-diagonal are fine→coarse (meshes ordered coarse→fine). Use when SKL line plots hide directional disagreement.";
|
|
727
|
+
if (base === "divergence_kl")
|
|
728
|
+
return "Feature-plane symmetric KL heatmap (min-shift sum-normalized physical component planes). Measures association between SOM features — not mesh-vs-mesh fingerprint KL (see KL_ref / KL_asymmetric).";
|
|
729
|
+
if (base === "divergence_w1")
|
|
730
|
+
return "Feature-plane 1-Wasserstein heatmap on the same physical component-plane masses. Opt-in via barmesh_results(action=feature_divergences, metrics=[\"wasserstein\"]).";
|
|
488
731
|
if (base === "plot_vol_all_meshes")
|
|
489
732
|
return "Volume-weighted SOM fingerprints P_vol for every mesh in mesh_order (shared color scale).";
|
|
490
733
|
if (base === "plot_vol_coarse_fine")
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
3
3
|
import { runMcpToolAudit } from "../audit.js";
|
|
4
|
-
import { apiCall, apiRawCall,
|
|
4
|
+
import { apiCall, apiRawCall, BARMESH_VIZ_VIEWS, buildStandaloneVizPageUrl, buildVizRelatedUrls, getVizPort, getCaptionForImage, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
|
|
5
5
|
import { FIGURE_SECTION_LABELS, FIGURE_SECTION_ORDER, figureSection, sortRank, } from "../figure_sections.js";
|
|
6
6
|
import { buildMetadataStrip } from "../results_metadata.js";
|
|
7
|
+
import { appendUiDeliveryContent, buildStandaloneVizUrl, resolveUiDelivery, } from "../ui-delivery.js";
|
|
8
|
+
import { buildBarmeshVizStandaloneUrl } from "../viz_links.js";
|
|
7
9
|
export const RESULTS_EXPLORER_URI = "ui://barmesh/results-explorer";
|
|
8
10
|
export function sortFigures(figures) {
|
|
9
11
|
return [...figures].sort((a, b) => FIGURE_SECTION_ORDER.indexOf(a.section ?? "diagnostics") -
|
|
@@ -38,6 +40,12 @@ function labelForFigure(filename) {
|
|
|
38
40
|
return "Wasserstein (EMD) vs reference";
|
|
39
41
|
if (base === "EMD_stepwise")
|
|
40
42
|
return "Wasserstein (EMD) stepwise";
|
|
43
|
+
if (base === "KL_asymmetric")
|
|
44
|
+
return "Asymmetric mesh KL matrix (coarse↔fine)";
|
|
45
|
+
if (base === "divergence_kl")
|
|
46
|
+
return "Feature-plane symmetric KL (not mesh KL)";
|
|
47
|
+
if (base === "divergence_w1")
|
|
48
|
+
return "Feature-plane Wasserstein-1";
|
|
41
49
|
if (base === "plot_vol_all_meshes")
|
|
42
50
|
return "Volume fingerprint: all meshes";
|
|
43
51
|
if (base === "plot_vol_coarse_fine")
|
|
@@ -111,6 +119,10 @@ export function buildPayload(jobId, data, jobMeta) {
|
|
|
111
119
|
const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
|
|
112
120
|
const figures = sortFigures(groupFigures(files));
|
|
113
121
|
const port = getVizPort();
|
|
122
|
+
const standaloneUrl = buildStandaloneVizPageUrl(port, BARMESH_VIZ_VIEWS.resultsExplorer, jobId) ??
|
|
123
|
+
buildBarmeshVizStandaloneUrl("barmesh-results-explorer", jobId);
|
|
124
|
+
const trainingMonitorUrl = buildStandaloneVizPageUrl(port, BARMESH_VIZ_VIEWS.trainingMonitor, jobId) ??
|
|
125
|
+
buildBarmeshVizStandaloneUrl("barmesh-training-monitor", jobId);
|
|
114
126
|
return {
|
|
115
127
|
type: "barmesh-results-explorer",
|
|
116
128
|
jobId,
|
|
@@ -120,9 +132,9 @@ export function buildPayload(jobId, data, jobMeta) {
|
|
|
120
132
|
metrics: buildMetrics(summary),
|
|
121
133
|
availableFigures: figures,
|
|
122
134
|
defaultFigureKey: resolveDefaultFigureKey(figures),
|
|
123
|
-
standaloneUrl
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
standaloneUrl,
|
|
136
|
+
trainingMonitorUrl,
|
|
137
|
+
relatedUrls: buildVizRelatedUrls(port, jobId, BARMESH_VIZ_VIEWS),
|
|
126
138
|
};
|
|
127
139
|
}
|
|
128
140
|
async function handleResultsExplorer(job_id) {
|
|
@@ -131,40 +143,40 @@ async function handleResultsExplorer(job_id) {
|
|
|
131
143
|
apiCall("GET", `/v1/jobs/${job_id}`).catch(() => ({})),
|
|
132
144
|
]);
|
|
133
145
|
const payload = buildPayload(job_id, data, jobMeta);
|
|
134
|
-
const
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.join("\n"),
|
|
144
|
-
},
|
|
145
|
-
];
|
|
146
|
+
const summaryText = [
|
|
147
|
+
`Mesh convergence results explorer ready for job ${job_id}.`,
|
|
148
|
+
payload.subtitle ? `Label: ${payload.subtitle}` : "",
|
|
149
|
+
payload.metadataStrip?.[0] ?? "",
|
|
150
|
+
]
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.join("\n");
|
|
153
|
+
const content = [{ type: "text", text: summaryText }];
|
|
154
|
+
let inlineImageAttached = false;
|
|
146
155
|
const defaultFigure = payload.availableFigures.find((f) => f.key === payload.defaultFigureKey);
|
|
147
156
|
if (defaultFigure && /\.(png|svg|jpe?g|webp)$/i.test(defaultFigure.filename)) {
|
|
157
|
+
const before = content.length;
|
|
148
158
|
await tryAttachImage(content, job_id, defaultFigure.filename);
|
|
159
|
+
inlineImageAttached = content.length > before && content.some((c) => c.type === "image");
|
|
149
160
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
const uiDelivery = resolveUiDelivery({
|
|
162
|
+
tool: "results_explorer",
|
|
163
|
+
jobId: job_id,
|
|
164
|
+
jobStatus: "completed",
|
|
165
|
+
inlineImageAttached,
|
|
166
|
+
});
|
|
167
|
+
appendUiDeliveryContent(content, uiDelivery, {
|
|
168
|
+
linkLabel: "Open results explorer",
|
|
169
|
+
primaryUrl: payload.standaloneUrl ?? buildStandaloneVizUrl("results_explorer", job_id),
|
|
170
|
+
});
|
|
171
|
+
const monitorUrl = payload.trainingMonitorUrl ?? buildStandaloneVizUrl("training_monitor", job_id);
|
|
172
|
+
if (monitorUrl) {
|
|
159
173
|
content.push({
|
|
160
174
|
type: "text",
|
|
161
|
-
text: `
|
|
162
|
-
`AGENT: surface this to the user as a clickable markdown link in your reply. ` +
|
|
163
|
-
`This localhost port is assigned per MCP session and changes if the proxy restarts — re-run barmesh_results_explorer for a fresh URL, or set BARIVIA_VIZ_PORT for a persistent port.`,
|
|
175
|
+
text: `Review training curves: [Open training monitor](${monitorUrl})`,
|
|
164
176
|
});
|
|
165
177
|
}
|
|
166
178
|
return {
|
|
167
|
-
...structuredTextResult(payload, undefined, content),
|
|
179
|
+
...structuredTextResult({ ...payload, ui_delivery: uiDelivery }, undefined, content),
|
|
168
180
|
_meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
|
|
169
181
|
};
|
|
170
182
|
}
|
package/dist/tools/cfd.js
CHANGED
|
@@ -10,14 +10,14 @@ What it does (one call): joint-normalizes all meshes, trains one SOM on a strati
|
|
|
10
10
|
BEST FOR: A mesh-refinement study where you want a field-level (not just scalar) view of whether the mesh has converged.
|
|
11
11
|
NOT FOR: Single scalar quantities of interest — use barmesh_richardson for classical GCI.
|
|
12
12
|
ASYNC: This is a queued job. It returns a job id immediately. Then poll barmesh_jobs(action=status) every 10-20s; datacenter-scale grids plus EMD can take a few minutes — keep polling. On completion call barmesh_results(action=get).
|
|
13
|
-
COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh label that is not present; forgetting the cell-volume column at upload time.`, {
|
|
13
|
+
COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh label that is not present; forgetting the cell-volume column at upload time; multi-topology studies (uniform U + graded G meshes) without explicit mesh_order — equal cell counts sort U-then-G and invent bogus stepwise pairs (U160|G20). Pass mesh_order coarse→fine interleaved by refinement (U20,G20,U40,G40,…) or use preset=cavity which defaults interleaved when U/G labels are detected.`, {
|
|
14
14
|
dataset_id: z.string().describe("Dataset ID from barmesh_datasets(upload)"),
|
|
15
15
|
feature_columns: z.array(z.string()).describe("Per-cell numeric feature columns to train the SOM on (e.g. [\"p\",\"U_mag\",\"k\",\"log_epsilon\"])"),
|
|
16
16
|
preset: z.enum(["generic", "cavity", "pitz", "datacenter"]).optional().describe("Hyperparameter preset; defaults to generic. cavity/pitz/datacenter reproduce the published settings."),
|
|
17
17
|
mesh_column: z.string().optional().describe("Column with the mesh label (default mesh_id)"),
|
|
18
18
|
volume_column: z.string().optional().describe("Cell-volume column for fingerprint weights (default V; equal weights if absent)"),
|
|
19
19
|
reference_mesh: z.string().optional().describe("Mesh label used as reference (default: the mesh with the most cells = finest)"),
|
|
20
|
-
mesh_order: z.array(z.string()).optional().describe("Explicit coarse-to-fine mesh order (default: ascending cell count)"),
|
|
20
|
+
mesh_order: z.array(z.string()).optional().describe("Explicit coarse-to-fine mesh order (default: ascending cell count; cavity preset and tied U/G cell counts auto-interleave U20→G20→U40→G40→…)"),
|
|
21
21
|
grid: z.array(z.number().int()).optional().describe("SOM grid [rows, cols] (overrides preset)"),
|
|
22
22
|
epochs: z.array(z.number().int()).optional().describe("[ordering, convergence] epochs (overrides preset)"),
|
|
23
23
|
batch_size: z.number().int().optional().describe("SOM batch size (overrides preset)"),
|
|
@@ -34,8 +34,10 @@ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh
|
|
|
34
34
|
normalization_methods: z.record(z.enum(["zscore", "mad", "sigmoidal", "sepd", "none"])).optional().describe("Per-feature normalization override; keys must be in feature_columns."),
|
|
35
35
|
row_range: z.tuple([z.number().int().min(1), z.number().int().min(1)]).optional().describe("1-based inclusive [start, end] row slice applied during preprocessing (and to mesh labels / cell volumes)."),
|
|
36
36
|
label: z.string().optional().describe("Optional job label"),
|
|
37
|
+
description: z.string().optional().describe("Optional free-text description for inventory"),
|
|
38
|
+
tags: z.array(z.string()).optional().describe("Optional tags for inventory/navigation"),
|
|
37
39
|
}, async (args) => {
|
|
38
|
-
const { dataset_id, label, ...rest } = args;
|
|
40
|
+
const { dataset_id, label, description, tags, ...rest } = args;
|
|
39
41
|
const params = {};
|
|
40
42
|
for (const [k, v] of Object.entries(rest)) {
|
|
41
43
|
if (v !== undefined && v !== null)
|
|
@@ -47,6 +49,10 @@ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh
|
|
|
47
49
|
const body = { dataset_id, params };
|
|
48
50
|
if (typeof label === "string" && label.length > 0)
|
|
49
51
|
body.label = label;
|
|
52
|
+
if (description !== undefined)
|
|
53
|
+
body.description = description;
|
|
54
|
+
if (tags !== undefined)
|
|
55
|
+
body.tags = tags;
|
|
50
56
|
const data = (await apiCall("POST", "/v1/cfd/mesh-convergence", body));
|
|
51
57
|
await pollCfdPrepareIfPresent(data, "barmesh_mesh_convergence");
|
|
52
58
|
const id = data.id;
|
|
@@ -63,7 +69,7 @@ What it does: from a small CSV with one row per mesh (a mesh label, a representa
|
|
|
63
69
|
BEST FOR: Scalar benchmarks (reattachment length, centerline values, probe readings) where a classical asymptotic-convergence check is expected.
|
|
64
70
|
NOT FOR: High-dimensional field comparison — use barmesh_mesh_convergence (complementary).
|
|
65
71
|
ASYNC: queued job; poll barmesh_jobs(action=status), then barmesh_results(action=get).
|
|
66
|
-
COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with different mesh sets in one CSV.`, {
|
|
72
|
+
COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with different mesh sets in one CSV; mixing uniform (U…) and graded (G…) mesh families in one CSV — classical GCI assumes one topology per job; run separate Richardson jobs per family (e.g. G160|G80|G40 graded, U160|U80|U40 uniform).`, {
|
|
67
73
|
dataset_id: z.string().describe("Dataset ID (one row per mesh)"),
|
|
68
74
|
qoi_columns: z.array(z.string()).describe("Scalar QoI column names to extrapolate"),
|
|
69
75
|
mesh_column: z.string().optional().describe("Mesh label column (default mesh_id)"),
|
|
@@ -72,8 +78,10 @@ COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with diff
|
|
|
72
78
|
dimension: z.number().int().optional().describe("Spatial dimension d for deriving h from cell count (default 3)"),
|
|
73
79
|
safety_factor: z.number().optional().describe("GCI safety factor Fs (default 1.25, Roache)"),
|
|
74
80
|
label: z.string().optional().describe("Optional job label"),
|
|
81
|
+
description: z.string().optional().describe("Optional free-text description for inventory"),
|
|
82
|
+
tags: z.array(z.string()).optional().describe("Optional tags for inventory/navigation"),
|
|
75
83
|
}, async (args) => {
|
|
76
|
-
const { dataset_id, label, ...rest } = args;
|
|
84
|
+
const { dataset_id, label, description, tags, ...rest } = args;
|
|
77
85
|
const params = {};
|
|
78
86
|
for (const [k, v] of Object.entries(rest)) {
|
|
79
87
|
if (v !== undefined && v !== null)
|
|
@@ -82,6 +90,10 @@ COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with diff
|
|
|
82
90
|
const body = { dataset_id, params };
|
|
83
91
|
if (typeof label === "string" && label.length > 0)
|
|
84
92
|
body.label = label;
|
|
93
|
+
if (description !== undefined)
|
|
94
|
+
body.description = description;
|
|
95
|
+
if (tags !== undefined)
|
|
96
|
+
body.tags = tags;
|
|
85
97
|
const data = (await apiCall("POST", "/v1/cfd/richardson", body));
|
|
86
98
|
const id = data.id;
|
|
87
99
|
if (id != null)
|