@barivia/barmesh-mcp 0.7.0 → 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.
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 } from "../shared.js";
3
+ import { apiCall, 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.
@@ -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,12 +49,16 @@ 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;
53
59
  if (id != null) {
54
60
  const prep = data.prepare_job_id != null ? " (dataset prepare complete)" : "";
55
- data.suggested_next_step = `Run barmesh_training_monitor(job_id="${id}") or barmesh_jobs(action=monitor, job_id="${id}")${prep}; on completion call barmesh_results(action=get, job_id="${id}").`;
61
+ data.suggested_next_step = suggestAfterCfdSubmit(String(id), prep);
56
62
  }
57
63
  return textResult(data);
58
64
  });
@@ -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,10 +90,14 @@ 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)
88
- data.suggested_next_step = `Run barmesh_training_monitor(job_id="${id}") or barmesh_jobs(action=monitor, job_id="${id}"); on completion call barmesh_results(action=get, job_id="${id}").`;
100
+ data.suggested_next_step = suggestAfterCfdSubmit(String(id));
89
101
  return textResult(data);
90
102
  });
91
103
  }
@@ -4,8 +4,9 @@ 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, } 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, 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,14 +91,16 @@ 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.");
93
98
  let body;
99
+ let uploadContentType = "text/csv";
94
100
  if (file_path && file_path.length > 0) {
95
101
  await apiCall("GET", "/v1/system/info");
96
102
  const resolved = await resolveFilePathForUpload(file_path, server);
103
+ uploadContentType = resolveUploadContentType(resolved);
97
104
  const lower = resolved.toLowerCase();
98
105
  const isGzipInput = lower.endsWith(".gz");
99
106
  const baseExt = path.extname(isGzipInput ? lower.slice(0, -3) : lower);
@@ -119,7 +126,12 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
119
126
  const idem = await streamFileSha256(resolved);
120
127
  let init;
121
128
  try {
122
- 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 }));
123
135
  }
124
136
  catch (e) {
125
137
  const msg = e instanceof Error ? e.message : String(e);
@@ -134,10 +146,10 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
134
146
  id: datasetId,
135
147
  status: init.status,
136
148
  idempotent_replay: true,
137
- suggested_next_step: `barmesh_datasets(action=preview, dataset_id=${datasetId})`,
149
+ suggested_next_step: suggestMeshDatasetPreview(datasetId),
138
150
  });
139
151
  }
140
- await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS, isGzipInput);
152
+ await putPresignedStream(init.upload_url, resolved, init.content_type ?? uploadContentType, PRESIGNED_PUT_TIMEOUT_MS, isGzipInput);
141
153
  const fin = (await apiCall("POST", `/v1/datasets/${datasetId}/finalize`, {}));
142
154
  const jobId = (fin.id ?? fin.job_id);
143
155
  const poll = await pollUntilComplete(jobId, POLL_STAGE_MAX_MS);
@@ -150,21 +162,26 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
150
162
  status: ready ? "ready" : "staging",
151
163
  job_id: jobId,
152
164
  suggested_next_step: ready
153
- ? `barmesh_datasets(action=preview, dataset_id=${datasetId}) to verify mesh, feature, and volume columns.`
154
- : `Still staging; poll barmesh_jobs(action=status, job_id="${jobId}") then barmesh_datasets(action=preview, dataset_id=${datasetId}).`,
165
+ ? suggestMeshDatasetPreview(datasetId)
166
+ : `Still staging; poll barmesh_jobs(action=status, job_id="${jobId}") then ${suggestMeshDatasetPreview(datasetId)}.`,
155
167
  });
156
168
  }
157
169
  if (isGzipInput) {
158
170
  const gzBytes = await fs.readFile(resolved);
159
- const data = (await apiCall("POST", "/v1/datasets", gzBytes, {
171
+ const gzHeaders = {
160
172
  "X-Dataset-Name": name,
161
- "Content-Type": "text/csv",
173
+ "Content-Type": uploadContentType,
162
174
  "Content-Encoding": "gzip",
163
175
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
164
- }, 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));
165
182
  const gid = data.id ?? data.dataset_id;
166
183
  if (gid != null) {
167
- data.suggested_next_step = `Next: barmesh_datasets(action=preview, dataset_id=${gid}) to verify the mesh, feature, and volume columns.`;
184
+ data.suggested_next_step = suggestMeshDatasetPreview(String(gid));
168
185
  }
169
186
  return textResult(data);
170
187
  }
@@ -179,9 +196,13 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
179
196
  const GZIP_THRESHOLD = 1024 * 1024;
180
197
  const uploadHeaders = {
181
198
  "X-Dataset-Name": name,
182
- "Content-Type": "text/csv",
199
+ "Content-Type": uploadContentType,
183
200
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
184
201
  };
202
+ if (description !== undefined)
203
+ uploadHeaders["X-Dataset-Description"] = description;
204
+ if (tags !== undefined)
205
+ uploadHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
185
206
  let uploadBody = body;
186
207
  if (Buffer.byteLength(body, "utf-8") > GZIP_THRESHOLD) {
187
208
  uploadBody = gzipSync(Buffer.from(body, "utf-8"));
@@ -190,7 +211,7 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
190
211
  const data = (await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS));
191
212
  const id = data.id ?? data.dataset_id;
192
213
  if (id != null) {
193
- data.suggested_next_step = `Next: barmesh_datasets(action=preview, dataset_id=${id}) to verify the mesh, feature, and volume columns.`;
214
+ data.suggested_next_step = suggestMeshDatasetPreview(String(id));
194
215
  }
195
216
  return textResult(data);
196
217
  }
@@ -225,15 +246,9 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
225
246
  const data = (await apiCall("GET", "/v1/datasets"));
226
247
  if (Array.isArray(data)) {
227
248
  const lines = data.map((ds) => {
228
- const id = String(ds.id ?? "");
229
- const dsName = String(ds.name ?? "");
230
- const rows = ds.rows != null ? Number(ds.rows) : "?";
231
- const cols = ds.cols != null ? Number(ds.cols) : "?";
232
- const st = ds.status != null ? String(ds.status) : "ready";
233
- const statusBit = st !== "ready" ? ` | status=${st}` : "";
249
+ const base = formatDatasetInventoryLine(ds);
234
250
  const ingestErr = cleanNullable(ds.ingest_error);
235
- const err = ingestErr ? ` | ingest_error=${ingestErr}` : "";
236
- return `${dsName} (${id}) — ${rows}×${cols}${statusBit}${err}`;
251
+ return ingestErr ? `${base} | ingest_error=${ingestErr}` : base;
237
252
  });
238
253
  return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No datasets." }] };
239
254
  }
@@ -243,11 +258,14 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
243
258
  if (!dataset_id)
244
259
  throw new Error("barmesh_datasets(get) requires dataset_id.");
245
260
  const ds = (await apiCall("GET", `/v1/datasets/${dataset_id}`));
261
+ const tagStr = formatTags(ds.tags);
246
262
  const lines = [
247
263
  `Dataset: ${ds.name ?? "?"} (${ds.id ?? dataset_id})`,
248
264
  `Status: ${ds.status ?? "ready"}`,
249
265
  `Rows × cols: ${ds.rows ?? "?"} × ${ds.cols ?? "?"}`,
250
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}` : "",
251
269
  ds.staged_prefix != null ? `Staged prefix: ${String(ds.staged_prefix)}` : "",
252
270
  ds.staged_version != null ? `Staged version: ${String(ds.staged_version)}` : "",
253
271
  ds.stage_job_id != null ? `Stage job: ${String(ds.stage_job_id)} (poll barmesh_jobs(action=status))` : "",
@@ -256,6 +274,22 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
256
274
  ].filter(Boolean);
257
275
  return { content: [{ type: "text", text: lines.join("\n") }] };
258
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
+ }
259
293
  if (action === "delete") {
260
294
  if (!dataset_id)
261
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,20 +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.
4
- Two tracks: barmesh_mesh_convergence (SOM fingerprint distances) and barmesh_richardson (classical GCI).
5
- Workflow: barmesh_prepare_mesh_data -> barmesh_datasets(upload) -> barmesh_mesh_convergence / barmesh_richardson -> barmesh_training_monitor (default visual MCP App; post-hoc review when already done) or barmesh_jobs(monitor/status) headless fallback -> barmesh_results(get) -> barmesh_results_explorer for figures.
6
- (API unreachable; this is the offline summary. Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
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.
5
+
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):
9
+ - barmesh_mesh_convergence — SOM volume-fingerprint distances (symmetric KL + Wasserstein-1 vs reference and stepwise)
10
+ - barmesh_richardson — classical Richardson / GCI on scalar QoIs (one mesh family per CSV: uniform OR graded, not mixed)
11
+
12
+ Workflow (when API returns):
13
+ 1. barmesh_prepare_mesh_data — recipe for the combined per-cell CSV (mesh_id + features + cell volume V)
14
+ 2. barmesh_datasets(upload) → barmesh_datasets(preview)
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
17
+ 4. barmesh_training_monitor (visual MCP App) or barmesh_jobs(action=monitor) headless — poll until complete
18
+ 5. barmesh_results(action=get) → barmesh_results_explorer for figures (mesh_convergence) or richardson_gci.csv summary (Richardson)
19
+
20
+ (Set BARIVIA_API_KEY / BARIVIA_API_URL when reconnecting.)`;
7
21
  const OFFLINE_PREP = `barmesh mesh-data prep (offline summary; API unreachable):
8
22
  Build ONE combined per-cell CSV across all meshes of the refinement study:
9
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.
10
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.
11
26
  Then: barmesh_datasets(upload) -> barmesh_mesh_convergence. (Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
12
27
  export function registerGuideTool(server) {
13
28
  registerAuditedTool(server, "barmesh_guide_workflow", `Get the barmesh CFD mesh-convergence workflow and tool map from the API (tier-scoped).
14
29
 
15
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.
16
31
  NOT FOR: Step-by-step mesh-data preparation — use barmesh_prepare_mesh_data for that.
17
- 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 () => {
18
34
  try {
19
35
  const data = (await apiCall("GET", "/v1/cfd/guide"));
20
36
  return textResult({
@@ -33,7 +49,7 @@ ESCALATION: If the response says your plan does not include CFD tools, the analy
33
49
 
34
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.
35
51
  NOT FOR: Submitting jobs — after preparing the CSV, use barmesh_datasets(upload) then barmesh_mesh_convergence.
36
- 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 () => {
37
53
  try {
38
54
  const data = (await apiCall("GET", "/v1/cfd/prep"));
39
55
  return textResult({ recipe: data.recipe, entitled: data.entitled });
@@ -44,4 +60,22 @@ COMMON MISTAKES: forgetting the per-cell cell-volume column; using different cha
44
60
  return textResult(OFFLINE_PREP);
45
61
  }
46
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
+ });
47
81
  }
@@ -5,17 +5,28 @@ import { pollCfdFinalizeIfPresent, refreshJobAfterFinalize } from "../cfd_finali
5
5
  import { formatJobStatusText } from "../job_status_format.js";
6
6
  import { DEFAULT_BLOCK_UNTIL_SEC, DEFAULT_POLL_INTERVAL_SEC } from "../job_monitor.js";
7
7
  import { runBlockingMonitor } from "../blocking_monitor.js";
8
+ import { buildJobsListQuery, extractJobsList, formatDatasetInventoryLine, formatJobInventoryLine, } from "../inventory_format.js";
8
9
  export function registerJobsTool(server) {
9
- registerAuditedTool(server, "barmesh_jobs", `Check job status, block until terminal, or list jobs.
10
+ registerAuditedTool(server, "barmesh_jobs", `Check job status, block until terminal, list/update jobs, or rebuild an org inventory.
10
11
 
11
- BEST FOR: action=monitor after submit (one call, throttled snapshots — preferred for agents). action=status for a single one-shot check.
12
+ | Action | Use when |
13
+ |--------|----------|
14
+ | monitor | After async submit — blocks with snapshots until terminal (preferred for agents) |
15
+ | status | One-shot progress check |
16
+ | list | Slim recent jobs (filter/page with limit/cursor/status/job_type/has_results) |
17
+ | update | Edit label / description / tags |
18
+ | inventory | Markdown overview of datasets + recent jobs |
19
+
20
+ BEST FOR: action=monitor after submit (one call, throttled snapshots — preferred for agents). action=status for a single one-shot check. action=inventory for a durable org catalog.
12
21
  MONITOR MODES: barmesh_training_monitor — default visual MCP App (live curves, post-hoc review on completed jobs). barmesh_jobs(action=monitor) — headless blocking snapshots for agents (live or post-hoc review; attaches learning_curve.png when already completed).
13
22
  ASYNC PROTOCOL: monitor blocks server-side until completed/failed or block_until timeout (default ${DEFAULT_BLOCK_UNTIL_SEC}s, poll every ${DEFAULT_POLL_INTERVAL_SEC}s). status is one-shot; poll every 10-20s manually if not using monitor. When status=completed, call barmesh_results(action=get, job_id=...) then barmesh_results_explorer(job_id=...).
14
- ESCALATION: status=failed returns an error message and (when available) a failure_stage; read it before retrying.`, {
23
+ LIST: slim by default (label,id,status,job_type,dataset_id,created_at,result_ref,description,tags no fat params). has_results=true is the results catalog.
24
+ ESCALATION: status=failed returns an error message and (when available) a failure_stage; read it before retrying.
25
+ UNAVAILABLE: If the mesh analysis API is temporarily unavailable, say so plainly, pause (proxy already burst-retried; wait ~retry_after_sec), and keep reviewing the last good distances/figures — do not treat the demo as failed. Suggest retry later.`, {
15
26
  action: z
16
- .enum(["status", "monitor", "list"])
17
- .describe("status: one-shot check; monitor: block until terminal with snapshots; list: recent jobs"),
18
- job_id: z.string().optional().describe("Job ID (required for status and monitor)"),
27
+ .enum(["status", "monitor", "list", "update", "inventory"])
28
+ .describe("status: one-shot check; monitor: block until terminal; list: slim paginated jobs; update: metadata; inventory: datasets+jobs overview"),
29
+ job_id: z.string().optional().describe("Job ID (required for status, monitor, update)"),
19
30
  block_until_sec: z
20
31
  .number()
21
32
  .int()
@@ -32,8 +43,33 @@ ESCALATION: status=failed returns an error message and (when available) a failur
32
43
  .boolean()
33
44
  .optional()
34
45
  .describe("action=monitor only: wait for cfd_finalize (default true)"),
46
+ dataset_id: z.string().optional().describe("action=list: filter by dataset ID"),
47
+ status: z
48
+ .enum(["pending", "running", "completed", "failed", "cancelled"])
49
+ .optional()
50
+ .describe("action=list: filter by status"),
51
+ job_type: z
52
+ .string()
53
+ .optional()
54
+ .describe("action=list: MCP job_type label (e.g. cfd_mesh_convergence, cfd_richardson)"),
55
+ has_results: z
56
+ .boolean()
57
+ .optional()
58
+ .describe("action=list: true = completed jobs with result_ref"),
59
+ limit: z
60
+ .number()
61
+ .int()
62
+ .min(1)
63
+ .max(100)
64
+ .optional()
65
+ .describe("action=list/inventory: page size (default 50)"),
66
+ cursor: z.string().optional().describe("action=list: next_cursor from a prior page"),
67
+ fields: z.enum(["slim", "full"]).optional().describe("action=list: slim (default) or full"),
68
+ label: z.string().optional().describe("action=update: new label"),
69
+ description: z.string().optional().describe("action=update: free-text description"),
70
+ tags: z.array(z.string()).optional().describe("action=update: replace tags"),
35
71
  }, async (args) => {
36
- const { action, job_id, block_until_sec, poll_interval_sec, wait_finalize } = args;
72
+ const { action, job_id, block_until_sec, poll_interval_sec, wait_finalize, dataset_id, status, job_type, has_results, limit, cursor, fields, label, description, tags, } = args;
37
73
  if (action === "monitor") {
38
74
  if (!job_id)
39
75
  throw new Error("barmesh_jobs(monitor) requires job_id.");
@@ -43,16 +79,86 @@ ESCALATION: status=failed returns an error message and (when available) a failur
43
79
  if (!job_id)
44
80
  throw new Error("barmesh_jobs(status) requires job_id.");
45
81
  let data = (await apiCall("GET", `/v1/jobs/${job_id}`));
46
- const status = String(data.status ?? "");
82
+ const statusVal = String(data.status ?? "");
47
83
  let note = null;
48
- if (status === "completed" && data.finalize_job_id) {
84
+ if (statusVal === "completed" && data.finalize_job_id) {
49
85
  ({ note } = await pollCfdFinalizeIfPresent(job_id, data));
50
86
  data = await refreshJobAfterFinalize(job_id);
51
87
  }
52
88
  const statusText = await formatJobStatusText(job_id, data);
53
89
  return textResult({ ...data, status_text: note ? `${statusText}\n${note}` : statusText });
54
90
  }
55
- const data = await apiCall("GET", "/v1/jobs");
56
- return textResult(data);
91
+ if (action === "list") {
92
+ const listPath = buildJobsListQuery({
93
+ dataset_id, status, job_type, has_results, limit, cursor, fields,
94
+ });
95
+ const data = await apiCall("GET", listPath);
96
+ const { jobs, next_cursor } = extractJobsList(data);
97
+ const lines = jobs.map((job) => formatJobInventoryLine(job));
98
+ if (next_cursor) {
99
+ lines.push("");
100
+ lines.push(`next_cursor: ${next_cursor}`);
101
+ lines.push(`Tip: barmesh_jobs(action=list, cursor="${next_cursor}", limit=${limit ?? 50}) for the next page.`);
102
+ }
103
+ return {
104
+ content: [{
105
+ type: "text",
106
+ text: lines.length > 0 ? lines.join("\n") : "No jobs found.",
107
+ }],
108
+ };
109
+ }
110
+ if (action === "update") {
111
+ if (!job_id)
112
+ throw new Error("barmesh_jobs(update) requires job_id.");
113
+ const body = {};
114
+ if (label !== undefined)
115
+ body.label = label;
116
+ if (description !== undefined)
117
+ body.description = description;
118
+ if (tags !== undefined)
119
+ body.tags = tags;
120
+ if (Object.keys(body).length === 0) {
121
+ throw new Error("barmesh_jobs(update) requires at least one of label, description, tags");
122
+ }
123
+ const data = await apiCall("PATCH", `/v1/jobs/${job_id}`, body);
124
+ return textResult(data);
125
+ }
126
+ if (action === "inventory") {
127
+ const jobLimit = limit && limit > 0 ? Math.min(Math.floor(limit), 100) : 50;
128
+ const [datasetsRaw, jobsRaw] = await Promise.all([
129
+ apiCall("GET", "/v1/datasets"),
130
+ apiCall("GET", buildJobsListQuery({ limit: jobLimit })),
131
+ ]);
132
+ const datasets = Array.isArray(datasetsRaw) ? datasetsRaw : [];
133
+ const { jobs, next_cursor } = extractJobsList(jobsRaw);
134
+ const lines = [
135
+ `# Org inventory (barmesh)`,
136
+ ``,
137
+ `## Datasets (${datasets.length})`,
138
+ ];
139
+ if (datasets.length === 0) {
140
+ lines.push("(none)");
141
+ }
142
+ else {
143
+ for (const ds of datasets)
144
+ lines.push(`- ${formatDatasetInventoryLine(ds)}`);
145
+ }
146
+ lines.push(``, `## Jobs (latest ${jobs.length}${next_cursor ? ", more available" : ""})`);
147
+ if (jobs.length === 0) {
148
+ lines.push("(none)");
149
+ }
150
+ else {
151
+ for (const job of jobs)
152
+ lines.push(`- ${formatJobInventoryLine(job)}`);
153
+ }
154
+ if (next_cursor) {
155
+ lines.push(``);
156
+ lines.push(`More jobs: barmesh_jobs(action=list, cursor="${next_cursor}", limit=${jobLimit})`);
157
+ }
158
+ lines.push(``);
159
+ lines.push(`Tips: set description/tags on barmesh_datasets(upload|update) and barmesh_mesh_convergence / barmesh_jobs(update).`);
160
+ return { content: [{ type: "text", text: lines.join("\n") }] };
161
+ }
162
+ throw new Error("Invalid action");
57
163
  });
58
164
  }