@barivia/barmesh-mcp 0.7.1 → 0.8.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.
@@ -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, getClientSupportsMcpApps, getVizPort, getCaptionForImage, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
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") -
@@ -111,6 +113,10 @@ export function buildPayload(jobId, data, jobMeta) {
111
113
  const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
112
114
  const figures = sortFigures(groupFigures(files));
113
115
  const port = getVizPort();
116
+ const standaloneUrl = buildStandaloneVizPageUrl(port, BARMESH_VIZ_VIEWS.resultsExplorer, jobId) ??
117
+ buildBarmeshVizStandaloneUrl("barmesh-results-explorer", jobId);
118
+ const trainingMonitorUrl = buildStandaloneVizPageUrl(port, BARMESH_VIZ_VIEWS.trainingMonitor, jobId) ??
119
+ buildBarmeshVizStandaloneUrl("barmesh-training-monitor", jobId);
114
120
  return {
115
121
  type: "barmesh-results-explorer",
116
122
  jobId,
@@ -120,9 +126,9 @@ export function buildPayload(jobId, data, jobMeta) {
120
126
  metrics: buildMetrics(summary),
121
127
  availableFigures: figures,
122
128
  defaultFigureKey: resolveDefaultFigureKey(figures),
123
- standaloneUrl: port
124
- ? `http://localhost:${port}/viz/barmesh-results-explorer?mode=standalone&job_id=${jobId}`
125
- : undefined,
129
+ standaloneUrl,
130
+ trainingMonitorUrl,
131
+ relatedUrls: buildVizRelatedUrls(port, jobId, BARMESH_VIZ_VIEWS),
126
132
  };
127
133
  }
128
134
  async function handleResultsExplorer(job_id) {
@@ -131,40 +137,40 @@ async function handleResultsExplorer(job_id) {
131
137
  apiCall("GET", `/v1/jobs/${job_id}`).catch(() => ({})),
132
138
  ]);
133
139
  const payload = buildPayload(job_id, data, jobMeta);
134
- const content = [
135
- {
136
- type: "text",
137
- text: [
138
- `Mesh convergence results explorer ready for job ${job_id}.`,
139
- payload.subtitle ? `Label: ${payload.subtitle}` : "",
140
- payload.metadataStrip?.[0] ?? "",
141
- ]
142
- .filter(Boolean)
143
- .join("\n"),
144
- },
145
- ];
140
+ const summaryText = [
141
+ `Mesh convergence results explorer ready for job ${job_id}.`,
142
+ payload.subtitle ? `Label: ${payload.subtitle}` : "",
143
+ payload.metadataStrip?.[0] ?? "",
144
+ ]
145
+ .filter(Boolean)
146
+ .join("\n");
147
+ const content = [{ type: "text", text: summaryText }];
148
+ let inlineImageAttached = false;
146
149
  const defaultFigure = payload.availableFigures.find((f) => f.key === payload.defaultFigureKey);
147
150
  if (defaultFigure && /\.(png|svg|jpe?g|webp)$/i.test(defaultFigure.filename)) {
151
+ const before = content.length;
148
152
  await tryAttachImage(content, job_id, defaultFigure.filename);
153
+ inlineImageAttached = content.length > before && content.some((c) => c.type === "image");
149
154
  }
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
- }
158
- if (!getClientSupportsMcpApps() && payload.standaloneUrl) {
155
+ const uiDelivery = resolveUiDelivery({
156
+ tool: "results_explorer",
157
+ jobId: job_id,
158
+ jobStatus: "completed",
159
+ inlineImageAttached,
160
+ });
161
+ appendUiDeliveryContent(content, uiDelivery, {
162
+ linkLabel: "Open results explorer",
163
+ primaryUrl: payload.standaloneUrl ?? buildStandaloneVizUrl("results_explorer", job_id),
164
+ });
165
+ const monitorUrl = payload.trainingMonitorUrl ?? buildStandaloneVizUrl("training_monitor", job_id);
166
+ if (monitorUrl) {
159
167
  content.push({
160
168
  type: "text",
161
- text: `Interactive results explorer: [Open results explorer](${payload.standaloneUrl})\n` +
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.`,
169
+ text: `Review training curves: [Open training monitor](${monitorUrl})`,
164
170
  });
165
171
  }
166
172
  return {
167
- ...structuredTextResult(payload, undefined, content),
173
+ ...structuredTextResult({ ...payload, ui_delivery: uiDelivery }, undefined, content),
168
174
  _meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
169
175
  };
170
176
  }
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)
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { registerAuditedTool } from "../audit.js";
7
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";
8
8
  import { GZIP_UPLOAD_HINT } from "../upload_hints.js";
9
+ import { formatDatasetInventoryLine, formatTags } from "../inventory_format.js";
9
10
  /**
10
11
  * Normalize a nullable string field from the API. Returns "" for absent values,
11
12
  * empty strings, and the literal SQL/serialization sentinels "missing"/"null".
@@ -23,12 +24,14 @@ Formats: plain CSV/TSV or gzip (.csv.gz / .tsv.gz). For files above ~64 MB, pref
23
24
 
24
25
  | Action | Use when |
25
26
  |--------|----------|
26
- | upload | You have prepared a combined per-cell CSV (mesh_id + feature columns + cell volume V). Do this first. |
27
+ | upload | You have prepared a combined per-cell CSV (mesh_id + feature columns + cell volume V). Do this first. Optional description/tags help inventory. |
27
28
  | preview | After upload — verify the mesh column, feature columns, and volume column are present and numeric. |
28
- | get | Fetch one dataset by id — status, staging fields, ingest_error (use after upload or when staging is slow). |
29
- | list | Find dataset IDs for analysis. |
29
+ | get | Fetch one dataset by id — status, staging fields, ingest_error, description/tags (use after upload or when staging is slow). |
30
+ | list | Find dataset IDs for analysis (includes description/tags). |
31
+ | update | Edit name / description / tags on an existing dataset. |
30
32
  | subset | Shrink a huge per-cell table server-side (row_range, filters, or sample_n). |
31
33
  | delete | Remove a dataset permanently. |
34
+ | inventory | Prefer barmesh_jobs(action=inventory) for a full datasets+jobs markdown overview. |
32
35
 
33
36
  action=upload: PREFER file_path — server reads from workspace root (token-efficient). Accepts .csv, .tsv, .csv.gz, .tsv.gz. Use csv_data only for small inline pastes (<10KB). If plain CSV exceeds the 5 GB upload cap, gzip it first (.csv.gz).
34
37
 
@@ -37,9 +40,11 @@ NOT FOR: Raw OpenFOAM case directories — extract a per-cell CSV first (see bar
37
40
  COMMON MISTAKES: omitting the cell-volume column (defaults to equal weights, which weakens the fingerprint); inconsistent feature columns across meshes.
38
41
  ESCALATION: If preview shows a feature column as non-numeric, fix the extraction and re-upload.`, {
39
42
  action: z
40
- .enum(["upload", "preview", "list", "get", "subset", "delete"])
41
- .describe("upload: add CSV or .csv.gz; preview: inspect columns; list: see all datasets; get: fetch one dataset metadata (status/staging); subset: create filtered subset; delete: remove dataset"),
42
- name: z.string().optional().describe("Dataset name (required for upload and subset)"),
43
+ .enum(["upload", "preview", "list", "get", "update", "subset", "delete"])
44
+ .describe("upload: add CSV or .csv.gz; preview: inspect columns; list: see all datasets; get: fetch one dataset metadata (status/staging); update: edit name/description/tags; subset: create filtered subset; delete: remove dataset"),
45
+ name: z.string().optional().describe("Dataset name (required for upload and subset; optional for update)"),
46
+ description: z.string().optional().describe("Optional free-text description (upload/update)"),
47
+ tags: z.array(z.string()).optional().describe("Optional tags for inventory/navigation (upload/update)"),
43
48
  file_path: z
44
49
  .string()
45
50
  .optional()
@@ -86,7 +91,7 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
86
91
  .int()
87
92
  .optional()
88
93
  .describe("action=subset: RNG seed for sample_n (default 42)."),
89
- }, async ({ action, name, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, sample_n, sample_seed, }) => {
94
+ }, async ({ action, name, description, tags, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, sample_n, sample_seed, }) => {
90
95
  if (action === "upload") {
91
96
  if (!name)
92
97
  throw new Error("barmesh_datasets(upload) requires name.");
@@ -121,7 +126,12 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
121
126
  const idem = await streamFileSha256(resolved);
122
127
  let init;
123
128
  try {
124
- init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
129
+ const uploadUrlBody = { name, size_bytes: stat.size };
130
+ if (description !== undefined)
131
+ uploadUrlBody.description = description;
132
+ if (tags !== undefined)
133
+ uploadUrlBody.tags = tags;
134
+ init = (await apiCall("POST", "/v1/datasets/upload-url", uploadUrlBody, { "Idempotency-Key": idem }));
125
135
  }
126
136
  catch (e) {
127
137
  const msg = e instanceof Error ? e.message : String(e);
@@ -158,12 +168,17 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
158
168
  }
159
169
  if (isGzipInput) {
160
170
  const gzBytes = await fs.readFile(resolved);
161
- const data = (await apiCall("POST", "/v1/datasets", gzBytes, {
171
+ const gzHeaders = {
162
172
  "X-Dataset-Name": name,
163
173
  "Content-Type": uploadContentType,
164
174
  "Content-Encoding": "gzip",
165
175
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
166
- }, UPLOAD_DATASET_TIMEOUT_MS));
176
+ };
177
+ if (description !== undefined)
178
+ gzHeaders["X-Dataset-Description"] = description;
179
+ if (tags !== undefined)
180
+ gzHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
181
+ const data = (await apiCall("POST", "/v1/datasets", gzBytes, gzHeaders, UPLOAD_DATASET_TIMEOUT_MS));
167
182
  const gid = data.id ?? data.dataset_id;
168
183
  if (gid != null) {
169
184
  data.suggested_next_step = suggestMeshDatasetPreview(String(gid));
@@ -184,6 +199,10 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
184
199
  "Content-Type": uploadContentType,
185
200
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
186
201
  };
202
+ if (description !== undefined)
203
+ uploadHeaders["X-Dataset-Description"] = description;
204
+ if (tags !== undefined)
205
+ uploadHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
187
206
  let uploadBody = body;
188
207
  if (Buffer.byteLength(body, "utf-8") > GZIP_THRESHOLD) {
189
208
  uploadBody = gzipSync(Buffer.from(body, "utf-8"));
@@ -227,15 +246,9 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
227
246
  const data = (await apiCall("GET", "/v1/datasets"));
228
247
  if (Array.isArray(data)) {
229
248
  const lines = data.map((ds) => {
230
- const id = String(ds.id ?? "");
231
- const dsName = String(ds.name ?? "");
232
- const rows = ds.rows != null ? Number(ds.rows) : "?";
233
- const cols = ds.cols != null ? Number(ds.cols) : "?";
234
- const st = ds.status != null ? String(ds.status) : "ready";
235
- const statusBit = st !== "ready" ? ` | status=${st}` : "";
249
+ const base = formatDatasetInventoryLine(ds);
236
250
  const ingestErr = cleanNullable(ds.ingest_error);
237
- const err = ingestErr ? ` | ingest_error=${ingestErr}` : "";
238
- return `${dsName} (${id}) — ${rows}×${cols}${statusBit}${err}`;
251
+ return ingestErr ? `${base} | ingest_error=${ingestErr}` : base;
239
252
  });
240
253
  return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No datasets." }] };
241
254
  }
@@ -245,11 +258,14 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
245
258
  if (!dataset_id)
246
259
  throw new Error("barmesh_datasets(get) requires dataset_id.");
247
260
  const ds = (await apiCall("GET", `/v1/datasets/${dataset_id}`));
261
+ const tagStr = formatTags(ds.tags);
248
262
  const lines = [
249
263
  `Dataset: ${ds.name ?? "?"} (${ds.id ?? dataset_id})`,
250
264
  `Status: ${ds.status ?? "ready"}`,
251
265
  `Rows × cols: ${ds.rows ?? "?"} × ${ds.cols ?? "?"}`,
252
266
  ds.size_bytes != null ? `Size: ${Number(ds.size_bytes).toLocaleString()} bytes` : "",
267
+ ds.description != null && String(ds.description).trim() !== "" ? `Description: ${String(ds.description)}` : "",
268
+ tagStr ? `Tags: ${tagStr}` : "",
253
269
  ds.staged_prefix != null ? `Staged prefix: ${String(ds.staged_prefix)}` : "",
254
270
  ds.staged_version != null ? `Staged version: ${String(ds.staged_version)}` : "",
255
271
  ds.stage_job_id != null ? `Stage job: ${String(ds.stage_job_id)} (poll barmesh_jobs(action=status))` : "",
@@ -258,6 +274,22 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
258
274
  ].filter(Boolean);
259
275
  return { content: [{ type: "text", text: lines.join("\n") }] };
260
276
  }
277
+ if (action === "update") {
278
+ if (!dataset_id)
279
+ throw new Error("barmesh_datasets(update) requires dataset_id.");
280
+ const body = {};
281
+ if (name !== undefined)
282
+ body.name = name;
283
+ if (description !== undefined)
284
+ body.description = description;
285
+ if (tags !== undefined)
286
+ body.tags = tags;
287
+ if (Object.keys(body).length === 0) {
288
+ throw new Error("barmesh_datasets(update) requires at least one of name, description, tags");
289
+ }
290
+ const data = await apiCall("PATCH", `/v1/datasets/${dataset_id}`, body);
291
+ return textResult(data);
292
+ }
261
293
  if (action === "delete") {
262
294
  if (!dataset_id)
263
295
  throw new Error("barmesh_datasets(delete) requires dataset_id.");
@@ -1,10 +1,29 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall, API_URL, fetchWithTimeout, textResult } from "../shared.js";
3
+ import { apiCall, API_URL, fetchWithTimeout, probeApiHealth, structuredTextResult, } from "../shared.js";
4
+ import { enqueueDeferredFeedback, flushDeferredFeedback, listDeferredFeedback, } from "../deferred_feedback.js";
5
+ function isDeferworthy(err) {
6
+ const e = err;
7
+ if (e?.retryable)
8
+ return true;
9
+ if (e?.cause === "api_unreachable" || e?.cause === "storage" || e?.cause === "network")
10
+ return true;
11
+ const status = e?.httpStatus;
12
+ if (status === 502 || status === 503 || status === 504)
13
+ return true;
14
+ if (err instanceof TypeError)
15
+ return true;
16
+ if (err instanceof Error && /fetch failed|ECONNREFUSED|ENOTFOUND|ECONNRESET|network|timed out/i.test(err.message)) {
17
+ return true;
18
+ }
19
+ return false;
20
+ }
4
21
  export function registerFeedbackTool(server) {
5
22
  registerAuditedTool(server, "barmesh_send_feedback", `Send feedback or feature requests to Barivia developers (CFD mesh-convergence / barmesh workflow). Use when the user has suggestions, ran into issues, or wants something improved. Do NOT call without asking the user first — but after a mesh-convergence or Richardson session, you SHOULD prepare feedback based on the user's workflow or errors encountered, show it to them, and ask for permission to send it. Once they accept, call this tool.
6
23
 
7
- For a substantial issue, prefer feedback_items: submit several focused instances (each max 1400 chars) covering, e.g., symptoms, exact reproduction steps, environment, and concrete asks — they are stored together as one batch so developers see the full picture. Use the single feedback field for a short one-off note.`, {
24
+ For a substantial issue, prefer feedback_items: submit several focused instances (each max 1400 chars) covering, e.g., symptoms, exact reproduction steps, environment, and concrete asks — they are stored together as one batch so developers see the full picture. Use the single feedback field for a short one-off note.
25
+
26
+ If the API or object storage is unavailable (e.g. gateway HTML 502 mid-demo), this tool queues feedback locally (~/.barivia/deferred-feedback, shared with barsom send_feedback) and returns deferred=true with request_id + api_health from GET /health (no R2). Call again later to flush.`, {
8
27
  feedback: z.string().max(1400).optional().describe("Single feedback note (max 1400 characters). Use feedback_items instead for a multi-part report."),
9
28
  feedback_items: z.array(z.string().max(1400)).max(10).optional().describe("Several feedback instances (each max 1400 characters, up to 10), stored together as one batch. Prefer this for a detailed issue: split it into focused parts (symptoms, reproduction, environment, asks)."),
10
29
  }, async ({ feedback, feedback_items }) => {
@@ -15,9 +34,19 @@ For a substantial issue, prefer feedback_items: submit several focused instances
15
34
  throw new Error("barmesh_send_feedback requires feedback or feedback_items (at least one non-empty entry).");
16
35
  }
17
36
  const body = items.length === 1 ? { feedback: items[0] } : { feedback_items: items };
37
+ const submit = async (b) => apiCall("POST", "/v1/feedback", b);
38
+ const flushed = await flushDeferredFeedback("barmesh", submit).catch(() => null);
18
39
  try {
19
- const data = await apiCall("POST", "/v1/feedback", body);
20
- return textResult(data);
40
+ const data = (await submit(body));
41
+ return structuredTextResult({
42
+ ...data,
43
+ deferred: false,
44
+ ...(flushed && flushed.flushed > 0
45
+ ? { flushed_deferred: flushed.flushed, deferred_remaining: flushed.remaining }
46
+ : {}),
47
+ }, typeof data.message === "string"
48
+ ? data.message
49
+ : "Feedback received. Thank you.");
21
50
  }
22
51
  catch (err) {
23
52
  if (err?.httpStatus === 401) {
@@ -31,10 +60,46 @@ For a substantial issue, prefer feedback_items: submit several focused instances
31
60
  const text = await resp.text();
32
61
  if (resp.ok) {
33
62
  const parsed = JSON.parse(text);
34
- return textResult({ ...parsed, note: "Feedback sent anonymously (API key invalid or expired)." });
63
+ return structuredTextResult({ ...parsed, deferred: false, note: "Feedback sent anonymously (API key invalid or expired)." }, "Feedback sent anonymously (API key invalid or expired).");
35
64
  }
36
65
  }
37
- catch { /* fall through to original error */ }
66
+ catch { /* fall through */ }
67
+ }
68
+ if (isDeferworthy(err)) {
69
+ const e = err;
70
+ const queued = await enqueueDeferredFeedback("barmesh", body, {
71
+ status: e.httpStatus,
72
+ error_code: e.errorCode,
73
+ request_id: e.requestId,
74
+ message: e.message,
75
+ });
76
+ const api_health = await probeApiHealth().catch(() => ({
77
+ liveness: "down",
78
+ readiness: "unknown",
79
+ storage: "not_probed",
80
+ }));
81
+ const pending = await listDeferredFeedback("barmesh");
82
+ return structuredTextResult({
83
+ ok: true,
84
+ deferred: true,
85
+ deferred_id: queued.id,
86
+ deferred_path: queued.path,
87
+ deferred_pending: pending.length,
88
+ error_code: e.errorCode ?? "api_unreachable",
89
+ cause: e.cause ?? "api_unreachable",
90
+ request_id: e.requestId ?? null,
91
+ api_health,
92
+ message: "API/storage unavailable — feedback saved locally and will be sent on the next successful barmesh_send_feedback call.",
93
+ }, [
94
+ "Feedback deferred (API/storage unavailable).",
95
+ `Saved to ${queued.path}`,
96
+ e.requestId ? `request_id=${e.requestId}` : null,
97
+ e.errorCode ? `error_code=${e.errorCode}` : null,
98
+ `api_health.liveness=${api_health.liveness} (storage not_probed)`,
99
+ "Retry barmesh_send_feedback later to flush the local queue.",
100
+ ]
101
+ .filter(Boolean)
102
+ .join(" | "));
38
103
  }
39
104
  throw err;
40
105
  }
@@ -1,30 +1,36 @@
1
1
  import { registerAuditedTool } from "../audit.js";
2
- import { apiCall, textResult } from "../shared.js";
3
- const OFFLINE_GUIDE = `barmesh: CFD mesh-convergence on the Barivia API.
2
+ import { apiCall, probeApiHealth, structuredTextResult, textResult } from "../shared.js";
3
+ import { listDeferredFeedback } from "../deferred_feedback.js";
4
+ const OFFLINE_GUIDE = `Mesh analysis API temporarily unavailable — figures already on disk stay available; new jobs/feedback will wait.
4
5
 
5
- Two tracks:
6
+ barmesh offline summary (demo-safe): keep reviewing the last good distances, GCI CSV, and downloaded figures. Say the API is unavailable plainly; the proxy already burst-retried (~3×2s) — wait ~30s before another round. Do not treat the session as failed.
7
+
8
+ Two tracks (when API returns):
6
9
  - barmesh_mesh_convergence — SOM volume-fingerprint distances (symmetric KL + Wasserstein-1 vs reference and stepwise)
7
- - barmesh_richardson — classical Richardson / GCI on scalar QoIs
10
+ - barmesh_richardson — classical Richardson / GCI on scalar QoIs (one mesh family per CSV: uniform OR graded, not mixed)
8
11
 
9
- Workflow:
12
+ Workflow (when API returns):
10
13
  1. barmesh_prepare_mesh_data — recipe for the combined per-cell CSV (mesh_id + features + cell volume V)
11
14
  2. barmesh_datasets(upload) → barmesh_datasets(preview)
12
15
  3. barmesh_mesh_convergence and/or barmesh_richardson — returns job_id
16
+ - Multi-topology cavity studies: pass mesh_order interleaved (U20,G20,U40,G40,…) or preset=cavity
13
17
  4. barmesh_training_monitor (visual MCP App) or barmesh_jobs(action=monitor) headless — poll until complete
14
- 5. barmesh_results(action=get) → barmesh_results_explorer for figures
18
+ 5. barmesh_results(action=get) → barmesh_results_explorer for figures (mesh_convergence) or richardson_gci.csv summary (Richardson)
15
19
 
16
- (API unreachable; offline summary. Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
20
+ (Set BARIVIA_API_KEY / BARIVIA_API_URL when reconnecting.)`;
17
21
  const OFFLINE_PREP = `barmesh mesh-data prep (offline summary; API unreachable):
18
22
  Build ONE combined per-cell CSV across all meshes of the refinement study:
19
23
  - one row per cell; a mesh label column (mesh_id); the physical channels you want compared (e.g. p, U_mag, k, log_epsilon — log-compress turbulence quantities); and a cell-volume column (V) for fingerprint weighting.
20
24
  - keep the SAME feature columns across every mesh; pick the finest mesh as the reference.
25
+ - multi-topology (U + G): pass mesh_order coarse→fine interleaved by refinement (U20,G20,U40,G40,U80,G80,U160,G160) or use preset=cavity.
21
26
  Then: barmesh_datasets(upload) -> barmesh_mesh_convergence. (Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
22
27
  export function registerGuideTool(server) {
23
28
  registerAuditedTool(server, "barmesh_guide_workflow", `Get the barmesh CFD mesh-convergence workflow and tool map from the API (tier-scoped).
24
29
 
25
30
  BEST FOR: First call in a session — orients you on the two tracks (SOM fingerprint distances and Richardson/GCI), the upload->submit->poll->results flow, and what your plan allows.
26
31
  NOT FOR: Step-by-step mesh-data preparation — use barmesh_prepare_mesh_data for that.
27
- ESCALATION: If the response says your plan does not include CFD tools, the analysis tools will return 403; contact Barivia to enable the cfd entitlement.`, {}, async () => {
32
+ ESCALATION: If the response says your plan does not include CFD tools, the analysis tools will return 403; contact Barivia to enable the cfd entitlement.
33
+ UNAVAILABLE: If the API is down, this tool returns a calm offline summary — say so plainly, keep reviewing last good results, and suggest retry later (~30s).`, {}, async () => {
28
34
  try {
29
35
  const data = (await apiCall("GET", "/v1/cfd/guide"));
30
36
  return textResult({
@@ -43,7 +49,7 @@ ESCALATION: If the response says your plan does not include CFD tools, the analy
43
49
 
44
50
  BEST FOR: Before barmesh_datasets(upload) — tells you which physical channels to extract, how to label meshes (mesh_id), the cell-volume column (V), and how to pick the reference mesh.
45
51
  NOT FOR: Submitting jobs — after preparing the CSV, use barmesh_datasets(upload) then barmesh_mesh_convergence.
46
- COMMON MISTAKES: forgetting the per-cell cell-volume column; using different channels across meshes; not log-compressing turbulence quantities (k, epsilon, omega).`, {}, async () => {
52
+ COMMON MISTAKES: forgetting the per-cell cell-volume column; using different channels across meshes; not log-compressing turbulence quantities (k, epsilon, omega); multi-topology U+G studies without mesh_order (use interleaved U20,G20,U40,G40,… or preset=cavity).`, {}, async () => {
47
53
  try {
48
54
  const data = (await apiCall("GET", "/v1/cfd/prep"));
49
55
  return textResult({ recipe: data.recipe, entitled: data.entitled });
@@ -54,4 +60,22 @@ COMMON MISTAKES: forgetting the per-cell cell-volume column; using different cha
54
60
  return textResult(OFFLINE_PREP);
55
61
  }
56
62
  });
63
+ registerAuditedTool(server, "barmesh_api_health", `Probe Barivia API liveness/readiness without touching object storage (R2).
64
+
65
+ BEST FOR: Mid-demo outages — distinguish "API process up" from storage/gateway failures when barmesh_send_feedback or uploads fail.
66
+ Uses GET /health (always 200 if process alive) and GET /ready (DB; Redis best-effort). storage is always not_probed.
67
+ Also reports how many deferred barmesh feedback drafts are queued locally.`, {}, async () => {
68
+ const health = await probeApiHealth();
69
+ const deferred = await listDeferredFeedback("barmesh").catch(() => []);
70
+ const lines = [
71
+ `API liveness: ${health.liveness}`,
72
+ `API readiness: ${health.readiness}`,
73
+ health.db ? `DB: ${health.db}` : null,
74
+ health.redis ? `Redis: ${health.redis}` : null,
75
+ `Object storage (R2): ${health.storage} (intentionally not checked)`,
76
+ health.detail ? `Detail: ${health.detail}` : null,
77
+ `Deferred feedback drafts (barmesh): ${deferred.length}`,
78
+ ].filter(Boolean);
79
+ return structuredTextResult({ ...health, deferred_feedback_pending: deferred.length }, lines.join("\n"));
80
+ });
57
81
  }