@barivia/barmesh-mcp 0.4.0 → 0.5.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.
@@ -4,54 +4,140 @@ 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, 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, } from "../shared.js";
8
+ import { GZIP_UPLOAD_HINT } from "../upload_hints.js";
9
+ /**
10
+ * Normalize a nullable string field from the API. Returns "" for absent values,
11
+ * empty strings, and the literal SQL/serialization sentinels "missing"/"null".
12
+ */
13
+ function cleanNullable(v) {
14
+ if (v == null)
15
+ return "";
16
+ const s = String(v).trim();
17
+ return s === "" || s.toLowerCase() === "missing" || s.toLowerCase() === "null" ? "" : s;
18
+ }
8
19
  export function registerDatasetsTool(server) {
9
- registerAuditedTool(server, "barmesh_datasets", `Upload, preview, or list the combined per-cell mesh CSV used for convergence analysis.
20
+ registerAuditedTool(server, "barmesh_datasets", `Upload, preview, list, get, subset, or delete the combined per-cell mesh CSV used for convergence analysis.
21
+
22
+ Formats: plain CSV/TSV or gzip (.csv.gz / .tsv.gz). For files above ~64 MB, prefer .csv.gz (often 2–3× smaller); large uploads use presigned direct-to-storage PUT and accept gzip bodies.
10
23
 
11
24
  | Action | Use when |
12
25
  |--------|----------|
13
26
  | upload | You have prepared a combined per-cell CSV (mesh_id + feature columns + cell volume V). Do this first. |
14
27
  | 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). |
15
29
  | list | Find dataset IDs for analysis. |
30
+ | subset | Shrink a huge per-cell table server-side (row_range, filters, or sample_n). |
31
+ | delete | Remove a dataset permanently. |
32
+
33
+ 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).
16
34
 
17
35
  BEST FOR: One combined CSV holding all meshes of a refinement study (one row per cell, a mesh label column, the physical channels, and a cell-volume column).
18
36
  NOT FOR: Raw OpenFOAM case directories — extract a per-cell CSV first (see barmesh_prepare_mesh_data).
19
37
  COMMON MISTAKES: omitting the cell-volume column (defaults to equal weights, which weakens the fingerprint); inconsistent feature columns across meshes.
20
38
  ESCALATION: If preview shows a feature column as non-numeric, fix the extraction and re-upload.`, {
21
- action: z.enum(["upload", "preview", "list"]).describe("upload: add the combined CSV; preview: inspect columns; list: see datasets"),
22
- name: z.string().optional().describe("Dataset name (required for upload)"),
23
- file_path: z.string().optional().describe("Path to the combined CSV (PREFERRED): absolute, file:// URI, or relative to the workspace root"),
39
+ 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
+ file_path: z
44
+ .string()
45
+ .optional()
46
+ .describe("Path to local CSV or .csv.gz (PREFERRED): absolute path, file:// URI, or path relative to the workspace root. NOTE: relative paths resolve against the MCP workspace root — in Cursor/IDE clients that root is often the MCP install dir, not your project, so set BARIVIA_WORKSPACE_ROOT in the MCP config env (or pass an absolute path) if a relative path is 'not accessible'. Use .csv.gz for large mesh tables."),
24
47
  csv_data: z.string().optional().describe("Inline CSV string for small pastes only (<10KB). Prefer file_path."),
25
- dataset_id: z.string().optional().describe("Dataset ID (required for preview)"),
48
+ dataset_id: z.string().optional().describe("Dataset ID (required for preview, get, subset, and delete)"),
26
49
  n_rows: z.number().int().optional().default(5).describe("Sample rows to return (preview only)"),
27
- }, async (args) => {
28
- const { action, name, file_path, csv_data, dataset_id, n_rows } = args;
50
+ row_range: z
51
+ .tuple([z.number().int(), z.number().int()])
52
+ .optional()
53
+ .describe("For subset: [start, end] 1-based inclusive row range (e.g. [1, 2000])"),
54
+ filters: z.preprocess((v) => {
55
+ if (v === undefined || v === null)
56
+ return v;
57
+ if (Array.isArray(v))
58
+ return v;
59
+ if (typeof v === "object" && v !== null && "column" in v)
60
+ return [v];
61
+ return v;
62
+ }, z
63
+ .array(z.object({
64
+ column: z.string(),
65
+ op: z.enum(["eq", "ne", "in", "gt", "lt", "gte", "lte", "between"]),
66
+ value: z.union([z.string(), z.number(), z.array(z.union([z.string(), z.number()]))]),
67
+ }))
68
+ .optional()
69
+ .describe("For subset: filter conditions (AND logic). Single object or array.")),
70
+ filter: z
71
+ .object({
72
+ column: z.string(),
73
+ op: z.enum(["eq", "ne", "in", "gt", "lt", "gte", "lte", "between"]),
74
+ value: z.union([z.string(), z.number(), z.array(z.union([z.string(), z.number()]))]),
75
+ })
76
+ .optional()
77
+ .describe("Deprecated — use filters instead. Single filter condition."),
78
+ sample_n: z
79
+ .number()
80
+ .int()
81
+ .min(1)
82
+ .optional()
83
+ .describe("action=subset: keep a random N-row sample (seeded, row order preserved). Use to shrink a huge table server-side."),
84
+ sample_seed: z
85
+ .number()
86
+ .int()
87
+ .optional()
88
+ .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, }) => {
29
90
  if (action === "upload") {
30
91
  if (!name)
31
92
  throw new Error("barmesh_datasets(upload) requires name.");
32
93
  let body;
33
94
  if (file_path && file_path.length > 0) {
34
- // Preflight: warm plan/limits and reject over-limit uploads before reading the file.
35
95
  await apiCall("GET", "/v1/system/info");
36
96
  const resolved = await resolveFilePathForUpload(file_path, server);
37
- const ext = path.extname(resolved).toLowerCase();
38
- if (ext !== ".csv" && ext !== ".tsv") {
39
- throw new Error("Only .csv and .tsv files can be uploaded as datasets.");
97
+ const lower = resolved.toLowerCase();
98
+ const isGzipInput = lower.endsWith(".gz");
99
+ const baseExt = path.extname(isGzipInput ? lower.slice(0, -3) : lower);
100
+ if (baseExt !== ".csv" && baseExt !== ".tsv") {
101
+ throw new Error("Only .csv, .tsv, .csv.gz, or .tsv.gz files can be uploaded as datasets.");
40
102
  }
41
103
  const HARD_MAX_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
42
- const stat = await fs.stat(resolved);
104
+ let stat;
105
+ try {
106
+ stat = await fs.stat(resolved);
107
+ }
108
+ catch {
109
+ throw new Error(`File not accessible at resolved path. Easiest fix: pass an ABSOLUTE path ` +
110
+ `(e.g. "/home/you/project/data.csv" or "C:\\\\Users\\\\you\\\\data.csv") or a file:// URI. ` +
111
+ `Relative paths resolve against the MCP workspace root (current: ${await getWorkspaceRootAsync(server)}); ` +
112
+ `set BARIVIA_WORKSPACE_ROOT in your MCP config env to your project directory to use them.`);
113
+ }
43
114
  if (stat.size > HARD_MAX_BYTES) {
44
- throw new Error(`File too large (${(stat.size / 1024 / 1024 / 1024).toFixed(2)} GB). Maximum upload size is 5 GB.`);
115
+ const gzipHint = isGzipInput ? "" : ` ${GZIP_UPLOAD_HINT}`;
116
+ throw new Error(`File too large (${(stat.size / 1024 / 1024 / 1024).toFixed(2)} GB). Maximum upload size is 5 GB.${gzipHint}`);
45
117
  }
46
118
  if (stat.size >= LARGE_UPLOAD_BYTES) {
47
119
  const idem = await streamFileSha256(resolved);
48
- const init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
120
+ let init;
121
+ try {
122
+ init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
123
+ }
124
+ catch (e) {
125
+ const msg = e instanceof Error ? e.message : String(e);
126
+ if (msg.includes("dataset_too_large") && !isGzipInput) {
127
+ throw new Error(`${msg} ${GZIP_UPLOAD_HINT}`);
128
+ }
129
+ throw e;
130
+ }
49
131
  const datasetId = (init.dataset_id ?? init.id);
50
- if (init.idempotent_replay) {
51
- return textResult({ id: datasetId, status: init.status, idempotent_replay: true,
52
- suggested_next_step: `barmesh_datasets(action=preview, dataset_id=${datasetId})` });
132
+ if (init.idempotent_replay && !init.upload_url) {
133
+ return textResult({
134
+ id: datasetId,
135
+ status: init.status,
136
+ idempotent_replay: true,
137
+ suggested_next_step: `barmesh_datasets(action=preview, dataset_id=${datasetId})`,
138
+ });
53
139
  }
54
- await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS);
140
+ await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS, isGzipInput);
55
141
  const fin = (await apiCall("POST", `/v1/datasets/${datasetId}/finalize`, {}));
56
142
  const jobId = (fin.id ?? fin.job_id);
57
143
  const poll = await pollUntilComplete(jobId, POLL_STAGE_MAX_MS);
@@ -59,10 +145,28 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
59
145
  return textResult({ id: datasetId, status: "failed", error: poll.error ?? "staging failed" });
60
146
  }
61
147
  const ready = poll.status === "completed";
62
- return textResult({ id: datasetId, status: ready ? "ready" : "staging", job_id: jobId,
148
+ return textResult({
149
+ id: datasetId,
150
+ status: ready ? "ready" : "staging",
151
+ job_id: jobId,
63
152
  suggested_next_step: ready
64
- ? `barmesh_datasets(action=preview, dataset_id=${datasetId})`
65
- : `Still staging; poll barmesh_jobs(action=status, job_id="${jobId}").` });
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}).`,
155
+ });
156
+ }
157
+ if (isGzipInput) {
158
+ const gzBytes = await fs.readFile(resolved);
159
+ const data = (await apiCall("POST", "/v1/datasets", gzBytes, {
160
+ "X-Dataset-Name": name,
161
+ "Content-Type": "text/csv",
162
+ "Content-Encoding": "gzip",
163
+ "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
164
+ }, UPLOAD_DATASET_TIMEOUT_MS));
165
+ const gid = data.id ?? data.dataset_id;
166
+ if (gid != null) {
167
+ data.suggested_next_step = `Next: barmesh_datasets(action=preview, dataset_id=${gid}) to verify the mesh, feature, and volume columns.`;
168
+ }
169
+ return textResult(data);
66
170
  }
67
171
  body = await fs.readFile(resolved, "utf-8");
68
172
  }
@@ -76,8 +180,6 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
76
180
  const uploadHeaders = {
77
181
  "X-Dataset-Name": name,
78
182
  "Content-Type": "text/csv",
79
- // Deterministic key so a timed-out retry of the SAME upload reconciles to
80
- // the original dataset server-side instead of creating a duplicate.
81
183
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
82
184
  };
83
185
  let uploadBody = body;
@@ -87,8 +189,9 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
87
189
  }
88
190
  const data = (await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS));
89
191
  const id = data.id ?? data.dataset_id;
90
- if (id != null)
192
+ if (id != null) {
91
193
  data.suggested_next_step = `Next: barmesh_datasets(action=preview, dataset_id=${id}) to verify the mesh, feature, and volume columns.`;
194
+ }
92
195
  return textResult(data);
93
196
  }
94
197
  if (action === "preview") {
@@ -97,8 +200,68 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
97
200
  const data = await apiCall("GET", `/v1/datasets/${dataset_id}/preview?n_rows=${n_rows ?? 5}`);
98
201
  return textResult(data);
99
202
  }
100
- // list
101
- const data = await apiCall("GET", "/v1/datasets");
102
- return textResult(data);
203
+ if (action === "subset") {
204
+ if (!dataset_id)
205
+ throw new Error("barmesh_datasets(subset) requires dataset_id.");
206
+ if (!name)
207
+ throw new Error("barmesh_datasets(subset) requires name.");
208
+ const allFilters = filters ?? (filter ? [filter] : undefined);
209
+ if (row_range === undefined && allFilters === undefined && sample_n === undefined) {
210
+ throw new Error("barmesh_datasets(subset) requires at least one of row_range, filters, or sample_n.");
211
+ }
212
+ const body = { name };
213
+ if (row_range !== undefined)
214
+ body.row_range = row_range;
215
+ if (allFilters !== undefined)
216
+ body.filters = allFilters;
217
+ if (sample_n !== undefined)
218
+ body.sample_n = sample_n;
219
+ if (sample_seed !== undefined)
220
+ body.sample_seed = sample_seed;
221
+ const data = await apiCall("POST", `/v1/datasets/${dataset_id}/subset`, body);
222
+ return textResult(data);
223
+ }
224
+ if (action === "list") {
225
+ const data = (await apiCall("GET", "/v1/datasets"));
226
+ if (Array.isArray(data)) {
227
+ 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}` : "";
234
+ const ingestErr = cleanNullable(ds.ingest_error);
235
+ const err = ingestErr ? ` | ingest_error=${ingestErr}` : "";
236
+ return `${dsName} (${id}) — ${rows}×${cols}${statusBit}${err}`;
237
+ });
238
+ return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No datasets." }] };
239
+ }
240
+ return textResult(data);
241
+ }
242
+ if (action === "get") {
243
+ if (!dataset_id)
244
+ throw new Error("barmesh_datasets(get) requires dataset_id.");
245
+ const ds = (await apiCall("GET", `/v1/datasets/${dataset_id}`));
246
+ const lines = [
247
+ `Dataset: ${ds.name ?? "?"} (${ds.id ?? dataset_id})`,
248
+ `Status: ${ds.status ?? "ready"}`,
249
+ `Rows × cols: ${ds.rows ?? "?"} × ${ds.cols ?? "?"}`,
250
+ ds.size_bytes != null ? `Size: ${Number(ds.size_bytes).toLocaleString()} bytes` : "",
251
+ ds.staged_prefix != null ? `Staged prefix: ${String(ds.staged_prefix)}` : "",
252
+ ds.staged_version != null ? `Staged version: ${String(ds.staged_version)}` : "",
253
+ ds.stage_job_id != null ? `Stage job: ${String(ds.stage_job_id)} (poll barmesh_jobs(action=status))` : "",
254
+ cleanNullable(ds.ingest_error) ? `Ingest error: ${cleanNullable(ds.ingest_error)}` : "",
255
+ ds.created_at != null ? `Created: ${String(ds.created_at)}` : "",
256
+ ].filter(Boolean);
257
+ return { content: [{ type: "text", text: lines.join("\n") }] };
258
+ }
259
+ if (action === "delete") {
260
+ if (!dataset_id)
261
+ throw new Error("barmesh_datasets(delete) requires dataset_id.");
262
+ const data = await apiCall("DELETE", `/v1/datasets/${dataset_id}`);
263
+ return textResult(data);
264
+ }
265
+ throw new Error("Invalid action");
103
266
  });
104
267
  }
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
3
  import { apiCall, textResult } from "../shared.js";
4
4
  import { pollCfdFinalizeIfPresent, refreshJobAfterFinalize } from "../cfd_finalize.js";
5
+ import { formatJobStatusText } from "../job_status_format.js";
5
6
  export function registerJobsTool(server) {
6
7
  registerAuditedTool(server, "barmesh_jobs", `Check job status or list jobs.
7
8
 
@@ -20,16 +21,10 @@ ESCALATION: status=failed returns an error message and (when available) a failur
20
21
  if (status === "completed" && data.finalize_job_id) {
21
22
  const { note } = await pollCfdFinalizeIfPresent(job_id, data);
22
23
  data = await refreshJobAfterFinalize(job_id);
23
- const lines = [
24
- `Job ${job_id}: ${String(data.status ?? "unknown")}`,
25
- data.label != null ? `Label: ${String(data.label)}` : null,
26
- data.progress != null ? `Progress: ${String(data.progress)}` : null,
27
- data.result_ref != null ? `Results: ${String(data.result_ref)}` : null,
28
- note,
29
- ].filter(Boolean);
30
- return textResult({ ...data, status_text: lines.join("\n") });
24
+ const statusText = note ? `${formatJobStatusText(job_id, data)}\n${note}` : formatJobStatusText(job_id, data);
25
+ return textResult({ ...data, status_text: statusText });
31
26
  }
32
- return textResult(data);
27
+ return textResult({ ...data, status_text: formatJobStatusText(job_id, data) });
33
28
  }
34
29
  const data = await apiCall("GET", "/v1/jobs");
35
30
  return textResult(data);
@@ -1,27 +1,52 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall, apiRawCall, textResult, tryAttachImage } from "../shared.js";
4
- const MESH_DEFAULT_FIGURES = ["KL_ref", "EMD_ref", "plot_vol_coarse_fine"];
3
+ import { apiCall, apiRawCall, textResult, tryAttachImage, pollUntilComplete, POLL_STAGE_MAX_MS } from "../shared.js";
4
+ // Headline figures when present, best first: the combined overview (barsom parity),
5
+ // then KL/EMD-vs-reference and the coarse/fine volume pair.
6
+ const MESH_DEFAULT_FIGURES = ["combined", "KL_ref", "EMD_ref", "plot_vol_coarse_fine"];
5
7
  export function registerResultsTool(server) {
6
8
  registerAuditedTool(server, "barmesh_results", `Fetch results of a completed CFD job: distances, convergence reading, and figures.
7
9
 
8
10
  | Action | Use when |
9
11
  |--------|----------|
10
12
  | get | Read the summary (distances per mesh, stepwise, convergence reading) and inline key figures. |
11
- | image | Download one figure by filename (e.g. KL_ref.pdf, EMD_stepwise.png). |
13
+ | image | Download one figure by filename (e.g. KL_ref.png, EMD_stepwise.pdf). |
14
+ | render | Re-render the figures as publication PDFs (or SVG) on demand — PDFs are NOT generated by default. |
12
15
 
13
16
  BEST FOR: After barmesh_jobs(action=status) shows completed.
14
- FIGURES: For mesh_convergence, action=get inlines the headline panels (KL_ref, EMD_ref, volume coarse/fine) as PNGs with captions; pass figures="all" for every figure, "none" for metrics only, or a list of names. PDFs are publication vector copies — fetch with action=image.
17
+ FIGURES: Default job output is metrics + PNG previews, including a single combined.png overview. action=get inlines the combined headline plus KL_ref/EMD_ref/volume PNGs with captions; pass figures="all" for every PNG, "none" for metrics only, or a list of names.
18
+ PDFs ON DEMAND: vector PDFs are not produced by default. Use action=render (format=pdf) once, then action=image to download each <name>.pdf. For an interactive browser use barmesh_results_explorer.
15
19
  NOT FOR: Submitting jobs.`, {
16
- action: z.enum(["get", "image"]).describe("get: summary + figures; image: download one figure file"),
20
+ action: z.enum(["get", "image", "render"]).describe("get: summary + figures; image: download one figure file; render: re-render figures as PDF/SVG on demand"),
17
21
  job_id: z.string().describe("Job ID"),
18
- filename: z.string().optional().describe("For action=image: the figure filename (e.g. KL_ref.pdf)"),
22
+ filename: z.string().optional().describe("For action=image: the figure filename (e.g. KL_ref.png or, after render, KL_ref.pdf)"),
23
+ format: z.enum(["pdf", "png", "svg"]).optional().describe("For action=render: output format to generate (default pdf). PNG previews already exist by default."),
19
24
  figures: z
20
25
  .union([z.enum(["default", "all", "none"]), z.array(z.string())])
21
26
  .optional()
22
27
  .describe("For action=get: which figures to inline (default headline panels; 'all'; 'none'; or a list of names)"),
23
28
  }, async (args) => {
24
- const { action, job_id, filename, figures } = args;
29
+ const { action, job_id, filename, format, figures } = args;
30
+ if (action === "render") {
31
+ const fmt = format ?? "pdf";
32
+ const submit = (await apiCall("POST", "/v1/cfd/render", { job_id, format: fmt }));
33
+ const renderId = (submit.id ?? submit.job_id);
34
+ if (!renderId)
35
+ throw new Error("cfd render did not return a job id");
36
+ const poll = await pollUntilComplete(renderId, POLL_STAGE_MAX_MS);
37
+ if (poll.status === "failed") {
38
+ return textResult({ status: "failed", render_job_id: renderId, error: poll.error ?? "render failed" });
39
+ }
40
+ if (poll.status !== "completed") {
41
+ return textResult({ status: poll.status, render_job_id: renderId, note: `Still rendering; poll barmesh_jobs(action=status, job_id="${renderId}").` });
42
+ }
43
+ return textResult({
44
+ status: "completed",
45
+ render_job_id: renderId,
46
+ format: fmt,
47
+ suggested_next_step: `Figures rendered as ${fmt}. Download one with barmesh_results(action=image, job_id="${job_id}", filename="KL_ref.${fmt}"), or list filenames via barmesh_results(action=get, job_id="${job_id}").`,
48
+ });
49
+ }
25
50
  if (action === "image") {
26
51
  if (!filename)
27
52
  throw new Error("barmesh_results(image) requires filename.");
@@ -0,0 +1 @@
1
+ export const GZIP_UPLOAD_HINT = "Try gzip: save as .csv.gz (often 2–3× smaller). The upload limit applies to the compressed file size on presigned/large uploads.";