@barivia/barsom-mcp 0.22.4 → 0.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/dist/audit.js +3 -1
- package/dist/deferred_feedback.js +88 -0
- package/dist/index.js +1 -1
- package/dist/inventory_format.js +69 -0
- package/dist/prepare_training_prompt.js +3 -2
- package/dist/shared.js +240 -43
- package/dist/siom_occupation_format.js +57 -0
- package/dist/tools/account.js +112 -8
- package/dist/tools/datasets.js +74 -23
- package/dist/tools/feedback.js +72 -6
- package/dist/tools/guide_barsom.js +9 -4
- package/dist/tools/jobs.js +78 -17
- package/dist/tools/results.js +12 -12
- package/dist/tools/train.js +43 -6
- package/dist/tools/training_core.js +6 -6
- package/dist/training_scale_guidance.js +73 -0
- package/package.json +1 -1
package/dist/tools/datasets.js
CHANGED
|
@@ -6,6 +6,8 @@ import { z } from "zod";
|
|
|
6
6
|
import { registerAuditedTool } from "../audit.js";
|
|
7
7
|
import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, structuredTextResult, pollUntilComplete, POLL_DERIVE_MAX_MS, POLL_ANALYZE_MAX_MS, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, resolveUploadContentType, suggestDatasetPreview, } from "../shared.js";
|
|
8
8
|
import { GZIP_UPLOAD_HINT } from "../job_status_format.js";
|
|
9
|
+
import { formatDatasetInventoryLine, formatTags } from "../inventory_format.js";
|
|
10
|
+
import { PERIODICITY_ROW_ORDER_NOTE, SMALL_N_SOFT, buildCpuScaleGuidanceLines, buildSmallNGuardrailLines, } from "../training_scale_guidance.js";
|
|
9
11
|
/**
|
|
10
12
|
* Normalize a nullable string field from the API. Returns "" for absent values,
|
|
11
13
|
* empty strings, and the literal SQL/serialization sentinels "missing"/"null"
|
|
@@ -110,6 +112,16 @@ function formatAnalyzeResult(data, dataset_id) {
|
|
|
110
112
|
lines.push(``);
|
|
111
113
|
}
|
|
112
114
|
const nextSteps = [];
|
|
115
|
+
const nRows = typeof totalRows === "number" && Number.isFinite(totalRows) ? totalRows : 0;
|
|
116
|
+
if (nRows > 0 && nRows < SMALL_N_SOFT) {
|
|
117
|
+
nextSteps.push(...buildSmallNGuardrailLines({ n: nRows, nFeatures: trainList.length || columns.length }));
|
|
118
|
+
}
|
|
119
|
+
if (nRows > 0) {
|
|
120
|
+
nextSteps.push(...buildCpuScaleGuidanceLines(nRows));
|
|
121
|
+
}
|
|
122
|
+
if (considerDropping.length > 0) {
|
|
123
|
+
nextSteps.push(`Drop redundant correlated columns before train (e.g. keep distance OR lat/lon, not all three when |r|>0.9): ${considerDropping.join(", ")}.`);
|
|
124
|
+
}
|
|
113
125
|
if (trainList.length > 0) {
|
|
114
126
|
nextSteps.push(`Train with columns: [${trainList.join(", ")}].`);
|
|
115
127
|
}
|
|
@@ -119,11 +131,11 @@ function formatAnalyzeResult(data, dataset_id) {
|
|
|
119
131
|
const periodicStrong = periodicity.filter((p) => p.dominant_score > 0.4);
|
|
120
132
|
if (periodicStrong.length > 0) {
|
|
121
133
|
const lags = [...new Set(periodicStrong.map((p) => p.dominant_lag))].sort((a, b) => a - b);
|
|
122
|
-
nextSteps.push(`Note:
|
|
134
|
+
nextSteps.push(`Note: ${PERIODICITY_ROW_ORDER_NOTE}`);
|
|
123
135
|
nextSteps.push(`Columns show periodic behavior at lags ${lags.join(", ")} (assuming row order is meaningful). Consider cyclic_features or temporal_features with matching periods.`);
|
|
124
136
|
nextSteps.push(`For datetime columns: run datasets(action=preview) for temporal_suggestions; match lags (e.g. 365→day_of_year, 12→month, 7→day_of_week, 24→hour_of_day) to choose which temporal components to extract.`);
|
|
125
137
|
}
|
|
126
|
-
nextSteps.push(`Then call train(action=map, dataset_id=${dataset_id}, columns=[...], ...).`);
|
|
138
|
+
nextSteps.push(`Then call train(action=map, dataset_id=${dataset_id}, columns=[...], ...) — omit grid_x/grid_y for auto-size unless you intentionally override.`);
|
|
127
139
|
lines.push(`Next steps:`, ...nextSteps.map((s) => ` ${s}`));
|
|
128
140
|
return lines.join("\n");
|
|
129
141
|
}
|
|
@@ -137,9 +149,10 @@ Formats: plain CSV/TSV or gzip (.csv.gz / .tsv.gz). For files above ~100 MB, pre
|
|
|
137
149
|
| upload | You have a CSV or .csv.gz file to add — do this first |
|
|
138
150
|
| preview | Before train(action=map) — always preview an unfamiliar dataset to spot cyclics, nulls, column types |
|
|
139
151
|
| analyze | Pre-training column analysis: correlation matrix, periodicity ranking, column recommendations (train / drop / project later). Run after preview, before train_map. |
|
|
140
|
-
| list | Finding dataset IDs for train_map, preview, or subset — see all available datasets |
|
|
141
|
-
| get | Fetch one dataset by id — status, staging fields, ingest_error (use after upload or when staging is slow) |
|
|
142
|
-
|
|
|
152
|
+
| list | Finding dataset IDs for train_map, preview, or subset — see all available datasets (includes description/tags) |
|
|
153
|
+
| get | Fetch one dataset by id — status, staging fields, ingest_error, description/tags (use after upload or when staging is slow) |
|
|
154
|
+
| update | Edit name / description / tags on an existing dataset (helps navigate duplicates) |
|
|
155
|
+
| subset | ONLY when the user asks for row_range, filters, or sample_n — never auto-shrink before train/analyze (CPU quick is fast to ~100k; do not sample_n by default below that) |
|
|
143
156
|
| add_expression | Add a computed column from an expression (dataset_id + name + expression). With project_onto_job=<training_job_id>, instead projects the computed expression onto that map as a component plane in one step (no column added). |
|
|
144
157
|
| reduce_spectral | Collapse a long ordered numeric block (e.g. 1000-point spectrum, time series, sensor fingerprint) into a small per-row feature set so the SOM can train on signal-dense features. Methods: pca, log_sample, uniform_sample, stats. |
|
|
145
158
|
| delete | Cleaning up after experiments or freeing the dataset slot |
|
|
@@ -151,9 +164,12 @@ Step 1 (after upload): always datasets(action=preview) to verify column types an
|
|
|
151
164
|
|
|
152
165
|
action=preview: Show columns, stats, sample rows, cyclic/datetime detections. ALWAYS preview before train(action=map) on an unfamiliar dataset.
|
|
153
166
|
action=analyze: Pre-training analysis on numeric columns — Pearson correlation, autocorrelation periodicity scores, and column recommendations (train, consider_dropping, project_later, low_variance). Use to choose which columns to include in training and which to project onto the map after training.
|
|
154
|
-
action=list: List all datasets belonging to the organisation with id, name, rows, cols, created_at (use
|
|
155
|
-
action=get: Fetch one dataset by dataset_id — returns status, staged_prefix, staged_version, ingest_error, and stage_job_id when staging is pending.
|
|
167
|
+
action=list: List all datasets belonging to the organisation with id, name, rows, cols, created_at, description, tags (use tags/description or id to distinguish datasets with the same name).
|
|
168
|
+
action=get: Fetch one dataset by dataset_id — returns status, staged_prefix, staged_version, ingest_error, description/tags, and stage_job_id when staging is pending.
|
|
169
|
+
action=update: PATCH name/description/tags on an existing dataset.
|
|
170
|
+
action=upload: Optional description and tags help later inventory (also settable via update).
|
|
156
171
|
DO NOT AUTO-SUBSET: Call action=subset only when the user explicitly requests a filter, slice, or random sample. Default path is upload → preview/analyze → train on the full dataset_id (GPU handles scale). Never downsample to "save time" or "be helpful".
|
|
172
|
+
Org overview: account(action=inventory) for a markdown datasets+jobs catalog.
|
|
157
173
|
|
|
158
174
|
action=subset: Create a new dataset from a subset of an existing one. Requires name and at least one of row_range, filters, or sample_n.
|
|
159
175
|
- row_range: [start, end] 1-based inclusive (e.g. [1, 2000] for first 2000 rows)
|
|
@@ -175,9 +191,11 @@ BEST FOR: Tabular numeric data. CSV with header required.
|
|
|
175
191
|
NOT FOR: Real-time data streams or binary files — upload a snapshot CSV instead.
|
|
176
192
|
ESCALATION: If upload fails with column errors, open the file locally and verify the header row. If preview shows nulls in training columns: use train(action=impute) for sparse data, or clean/subset for train(action=map).`, {
|
|
177
193
|
action: z
|
|
178
|
-
.enum(["upload", "preview", "analyze", "list", "get", "subset", "delete", "add_expression", "reduce_spectral"])
|
|
179
|
-
.describe("upload: add CSV or .csv.gz; preview: inspect columns/stats; analyze: pre-training correlation and periodicity; list: see all datasets; get: fetch one dataset metadata (status/staging); subset: create filtered subset; delete: remove dataset; add_expression: add derived column from expression (or, with project_onto_job, project the expression onto a trained map as a component plane); reduce_spectral: collapse a long ordered numeric block (e.g. spectrum, time series) into a small per-row feature set via PCA / log_sample / uniform_sample / stats"),
|
|
180
|
-
name: z.string().optional().describe("Dataset name (required for action=upload and subset)"),
|
|
194
|
+
.enum(["upload", "preview", "analyze", "list", "get", "update", "subset", "delete", "add_expression", "reduce_spectral"])
|
|
195
|
+
.describe("upload: add CSV or .csv.gz; preview: inspect columns/stats; analyze: pre-training correlation and periodicity; list: see all datasets; get: fetch one dataset metadata (status/staging); update: edit name/description/tags; subset: create filtered subset; delete: remove dataset; add_expression: add derived column from expression (or, with project_onto_job, project the expression onto a trained map as a component plane); reduce_spectral: collapse a long ordered numeric block (e.g. spectrum, time series) into a small per-row feature set via PCA / log_sample / uniform_sample / stats"),
|
|
196
|
+
name: z.string().optional().describe("Dataset name (required for action=upload and subset; optional for update)"),
|
|
197
|
+
description: z.string().optional().describe("Optional free-text description (upload/update). Pass \"\" on update to clear."),
|
|
198
|
+
tags: z.array(z.string()).optional().describe("Optional tags for inventory/navigation (upload/update). Pass [] on update to clear."),
|
|
181
199
|
file_path: z.string().optional().describe("Path to local CSV or .csv.gz (PREFERRED): absolute path, file:// URI, or path relative to the workspace root. Token-efficient; server reads file. 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 just pass an absolute path) if a relative path is 'not accessible'. Use .csv.gz for large scientific tables."),
|
|
182
200
|
csv_data: z.string().optional().describe("Inline CSV string for small pastes only (<10KB). Avoid for large files — use file_path instead to avoid token explosion."),
|
|
183
201
|
dataset_id: z.string().optional().describe("Dataset ID (required for preview, get, subset, and delete)"),
|
|
@@ -266,7 +284,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
266
284
|
.int()
|
|
267
285
|
.optional()
|
|
268
286
|
.describe("action=subset: RNG seed for sample_n (default 42; same seed + dataset reproduces the same sample)."),
|
|
269
|
-
}, async ({ action, name, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, expression, options, project_onto_job, aggregation, output_format, method, columns_block, columns_range, columns_except, k, sample_n, sample_seed }) => {
|
|
287
|
+
}, async ({ action, name, description, tags, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, expression, options, project_onto_job, aggregation, output_format, method, columns_block, columns_range, columns_except, k, sample_n, sample_seed }) => {
|
|
270
288
|
if (action === "upload") {
|
|
271
289
|
if (!name)
|
|
272
290
|
throw new Error("datasets(upload) requires name");
|
|
@@ -306,7 +324,12 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
306
324
|
const idem = await streamFileSha256(resolved);
|
|
307
325
|
let init;
|
|
308
326
|
try {
|
|
309
|
-
|
|
327
|
+
const uploadUrlBody = { name, size_bytes: stat.size };
|
|
328
|
+
if (description !== undefined)
|
|
329
|
+
uploadUrlBody.description = description;
|
|
330
|
+
if (tags !== undefined)
|
|
331
|
+
uploadUrlBody.tags = tags;
|
|
332
|
+
init = (await apiCall("POST", "/v1/datasets/upload-url", uploadUrlBody, { "Idempotency-Key": idem }));
|
|
310
333
|
}
|
|
311
334
|
catch (e) {
|
|
312
335
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -344,12 +367,17 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
344
367
|
// Content-Encoding: gzip), never decoding them as UTF-8 text.
|
|
345
368
|
if (isGzipInput) {
|
|
346
369
|
const gzBytes = await fs.readFile(resolved);
|
|
347
|
-
const
|
|
370
|
+
const gzHeaders = {
|
|
348
371
|
"X-Dataset-Name": name,
|
|
349
372
|
"Content-Type": uploadContentType,
|
|
350
373
|
"Content-Encoding": "gzip",
|
|
351
374
|
"Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
|
|
352
|
-
}
|
|
375
|
+
};
|
|
376
|
+
if (description !== undefined)
|
|
377
|
+
gzHeaders["X-Dataset-Description"] = description;
|
|
378
|
+
if (tags !== undefined)
|
|
379
|
+
gzHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
|
|
380
|
+
const data = (await apiCall("POST", "/v1/datasets", gzBytes, gzHeaders, UPLOAD_DATASET_TIMEOUT_MS));
|
|
353
381
|
const gid = data.id ?? data.dataset_id;
|
|
354
382
|
if (gid != null)
|
|
355
383
|
data.suggested_next_step = `${suggestDatasetPreview(String(gid))} to inspect columns before training.`;
|
|
@@ -373,6 +401,10 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
373
401
|
// the original dataset server-side instead of creating a duplicate.
|
|
374
402
|
"Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
|
|
375
403
|
};
|
|
404
|
+
if (description !== undefined)
|
|
405
|
+
uploadHeaders["X-Dataset-Description"] = description;
|
|
406
|
+
if (tags !== undefined)
|
|
407
|
+
uploadHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
|
|
376
408
|
let uploadBody = body;
|
|
377
409
|
if (Buffer.byteLength(body, "utf-8") > GZIP_THRESHOLD) {
|
|
378
410
|
uploadBody = gzipSync(Buffer.from(body, "utf-8"));
|
|
@@ -466,7 +498,13 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
466
498
|
lines.push(`| ${cols.map((c) => String(row[c] ?? "")).join(" | ")} |`);
|
|
467
499
|
}
|
|
468
500
|
}
|
|
469
|
-
|
|
501
|
+
if (totalRows > 0 && totalRows < SMALL_N_SOFT) {
|
|
502
|
+
lines.push(``, ...buildSmallNGuardrailLines({ n: totalRows, nFeatures: cols.length }));
|
|
503
|
+
}
|
|
504
|
+
if (totalRows > 0) {
|
|
505
|
+
lines.push(``, ...buildCpuScaleGuidanceLines(totalRows));
|
|
506
|
+
}
|
|
507
|
+
lines.push(``, `Suggested next step: datasets(action=analyze) then train(action=map, dataset_id=${dataset_id}, ...) or use the prepare_training prompt; to add derived columns use datasets(action=add_expression, dataset_id=${dataset_id}, name=..., expression=...).`);
|
|
470
508
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
471
509
|
}
|
|
472
510
|
if (action === "analyze") {
|
|
@@ -681,15 +719,9 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
681
719
|
const data = (await apiCall("GET", "/v1/datasets"));
|
|
682
720
|
if (Array.isArray(data)) {
|
|
683
721
|
const lines = data.map((ds) => {
|
|
684
|
-
const
|
|
685
|
-
const name = String(ds.name ?? "");
|
|
686
|
-
const rows = ds.rows != null ? Number(ds.rows) : "?";
|
|
687
|
-
const cols = ds.cols != null ? Number(ds.cols) : "?";
|
|
688
|
-
const st = ds.status != null ? String(ds.status) : "ready";
|
|
689
|
-
const statusBit = st !== "ready" ? ` | status=${st}` : "";
|
|
722
|
+
const base = formatDatasetInventoryLine(ds);
|
|
690
723
|
const ingestErr = cleanNullable(ds.ingest_error);
|
|
691
|
-
|
|
692
|
-
return `${name} (${id}) — ${rows}×${cols}${statusBit}${err}`;
|
|
724
|
+
return ingestErr ? `${base} | ingest_error=${ingestErr}` : base;
|
|
693
725
|
});
|
|
694
726
|
return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No datasets." }] };
|
|
695
727
|
}
|
|
@@ -699,11 +731,14 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
699
731
|
if (!dataset_id)
|
|
700
732
|
throw new Error("datasets(get) requires dataset_id");
|
|
701
733
|
const ds = (await apiCall("GET", `/v1/datasets/${dataset_id}`));
|
|
734
|
+
const tagStr = formatTags(ds.tags);
|
|
702
735
|
const lines = [
|
|
703
736
|
`Dataset: ${ds.name ?? "?"} (${ds.id ?? dataset_id})`,
|
|
704
737
|
`Status: ${ds.status ?? "ready"}`,
|
|
705
738
|
`Rows × cols: ${ds.rows ?? "?"} × ${ds.cols ?? "?"}`,
|
|
706
739
|
ds.size_bytes != null ? `Size: ${Number(ds.size_bytes).toLocaleString()} bytes` : "",
|
|
740
|
+
ds.description != null && String(ds.description).trim() !== "" ? `Description: ${String(ds.description)}` : "",
|
|
741
|
+
tagStr ? `Tags: ${tagStr}` : "",
|
|
707
742
|
ds.staged_prefix != null ? `Staged prefix: ${String(ds.staged_prefix)}` : "",
|
|
708
743
|
ds.staged_version != null ? `Staged version: ${String(ds.staged_version)}` : "",
|
|
709
744
|
ds.stage_job_id != null ? `Stage job: ${String(ds.stage_job_id)} (poll jobs(action=status))` : "",
|
|
@@ -712,6 +747,22 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
712
747
|
].filter(Boolean);
|
|
713
748
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
714
749
|
}
|
|
750
|
+
if (action === "update") {
|
|
751
|
+
if (!dataset_id)
|
|
752
|
+
throw new Error("datasets(update) requires dataset_id");
|
|
753
|
+
const body = {};
|
|
754
|
+
if (name !== undefined)
|
|
755
|
+
body.name = name;
|
|
756
|
+
if (description !== undefined)
|
|
757
|
+
body.description = description;
|
|
758
|
+
if (tags !== undefined)
|
|
759
|
+
body.tags = tags;
|
|
760
|
+
if (Object.keys(body).length === 0) {
|
|
761
|
+
throw new Error("datasets(update) requires at least one of name, description, tags");
|
|
762
|
+
}
|
|
763
|
+
const data = await apiCall("PATCH", `/v1/datasets/${dataset_id}`, body);
|
|
764
|
+
return textResult(data);
|
|
765
|
+
}
|
|
715
766
|
if (action === "delete") {
|
|
716
767
|
if (!dataset_id)
|
|
717
768
|
throw new Error("datasets(delete) requires dataset_id");
|
package/dist/tools/feedback.js
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { registerAuditedTool } from "../audit.js";
|
|
3
|
-
import { apiCall, API_URL, fetchWithTimeout,
|
|
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, "send_feedback", `Send feedback or feature requests to Barivia developers. Use when the user has suggestions, ran into issues, or wants something improved. Do NOT call without asking the user first — but after any group of actions or downloading of results, 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, this tool queues feedback locally (~/.barivia/deferred-feedback) and returns deferred=true with request_id / api_health — call again later to flush. Probe API liveness with account(action=health) (GET /health; does not touch R2).`, {
|
|
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,20 @@ For a substantial issue, prefer feedback_items: submit several focused instances
|
|
|
15
34
|
throw new Error("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
|
+
// Best-effort flush of earlier offline drafts before sending the new one.
|
|
39
|
+
const flushed = await flushDeferredFeedback("barsom", submit).catch(() => null);
|
|
18
40
|
try {
|
|
19
|
-
const data = await
|
|
20
|
-
return
|
|
41
|
+
const data = (await submit(body));
|
|
42
|
+
return structuredTextResult({
|
|
43
|
+
...data,
|
|
44
|
+
deferred: false,
|
|
45
|
+
...(flushed && flushed.flushed > 0
|
|
46
|
+
? { flushed_deferred: flushed.flushed, deferred_remaining: flushed.remaining }
|
|
47
|
+
: {}),
|
|
48
|
+
}, typeof data.message === "string"
|
|
49
|
+
? data.message
|
|
50
|
+
: "Feedback received. Thank you.");
|
|
21
51
|
}
|
|
22
52
|
catch (err) {
|
|
23
53
|
if (err?.httpStatus === 401) {
|
|
@@ -31,10 +61,46 @@ For a substantial issue, prefer feedback_items: submit several focused instances
|
|
|
31
61
|
const text = await resp.text();
|
|
32
62
|
if (resp.ok) {
|
|
33
63
|
const parsed = JSON.parse(text);
|
|
34
|
-
return
|
|
64
|
+
return structuredTextResult({ ...parsed, deferred: false, note: "Feedback sent anonymously (API key invalid or expired)." }, "Feedback sent anonymously (API key invalid or expired).");
|
|
35
65
|
}
|
|
36
66
|
}
|
|
37
|
-
catch { /* fall through
|
|
67
|
+
catch { /* fall through */ }
|
|
68
|
+
}
|
|
69
|
+
if (isDeferworthy(err)) {
|
|
70
|
+
const e = err;
|
|
71
|
+
const queued = await enqueueDeferredFeedback("barsom", body, {
|
|
72
|
+
status: e.httpStatus,
|
|
73
|
+
error_code: e.errorCode,
|
|
74
|
+
request_id: e.requestId,
|
|
75
|
+
message: e.message,
|
|
76
|
+
});
|
|
77
|
+
const api_health = await probeApiHealth().catch(() => ({
|
|
78
|
+
liveness: "down",
|
|
79
|
+
readiness: "unknown",
|
|
80
|
+
storage: "not_probed",
|
|
81
|
+
}));
|
|
82
|
+
const pending = await listDeferredFeedback("barsom");
|
|
83
|
+
return structuredTextResult({
|
|
84
|
+
ok: true,
|
|
85
|
+
deferred: true,
|
|
86
|
+
deferred_id: queued.id,
|
|
87
|
+
deferred_path: queued.path,
|
|
88
|
+
deferred_pending: pending.length,
|
|
89
|
+
error_code: e.errorCode ?? "api_unreachable",
|
|
90
|
+
cause: e.cause ?? "api_unreachable",
|
|
91
|
+
request_id: e.requestId ?? null,
|
|
92
|
+
api_health,
|
|
93
|
+
message: "API/storage unavailable — feedback saved locally and will be sent on the next successful send_feedback call.",
|
|
94
|
+
}, [
|
|
95
|
+
"Feedback deferred (API/storage unavailable).",
|
|
96
|
+
`Saved to ${queued.path}`,
|
|
97
|
+
e.requestId ? `request_id=${e.requestId}` : null,
|
|
98
|
+
e.errorCode ? `error_code=${e.errorCode}` : null,
|
|
99
|
+
`api_health.liveness=${api_health.liveness} (storage not_probed)`,
|
|
100
|
+
"Retry send_feedback later to flush the local queue.",
|
|
101
|
+
]
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.join(" | "));
|
|
38
104
|
}
|
|
39
105
|
throw err;
|
|
40
106
|
}
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { registerAuditedTool } from "../audit.js";
|
|
2
|
-
import { fetchWorkflowGuideFromApi } from "../shared.js";
|
|
2
|
+
import { fetchWorkflowGuideFromApi, UNAVAILABLE_RETRY_AFTER_SEC } from "../shared.js";
|
|
3
3
|
/** Minimal orientation when the API is unreachable; full SOP lives on GET /v1/docs/workflow. */
|
|
4
4
|
const OFFLINE_STUB = `## Offline / API unavailable
|
|
5
5
|
|
|
6
|
-
Configure \`BARIVIA_API_KEY\` and optional \`BARIVIA_API_URL\`, then call **guide_barsom_workflow** again. Full tool map, async rules, training modes, and step-by-step SOP are loaded from the Barivia API (authenticated) and scoped to your plan.
|
|
6
|
+
Configure \`BARIVIA_API_KEY\` and optional \`BARIVIA_API_URL\`, then call **guide_barsom_workflow** again when online. Full tool map, async rules, training modes, and step-by-step SOP are loaded from the Barivia API (authenticated) and scoped to your plan.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**Still usable offline:** already-downloaded results, local CSVs, draft notes / feedback drafts (queued under \`~/.barivia/deferred-feedback\`).
|
|
9
|
+
**Needs API:** upload, train, list/status, feedback flush, other cloud tools.
|
|
10
|
+
|
|
11
|
+
**Core tools (when healthy):** \`datasets\`, \`train\` (map, siom_map, impute, floop_siom where entitled), \`jobs\` (status/list/compare — lifecycle only), \`results\`, \`inference\`, \`account\`, \`training_guidance\`, \`guide_barsom_workflow\`.
|
|
12
|
+
|
|
13
|
+
**Degraded-session rules:** Do not paste Cloudflare/HTML into chat — one-sentence summary. The proxy already burst-retried (3×~2s); wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s (see \`retry_after_sec\`) before another round, then ask the user. Check \`account(health)\` or \`account(status)\` before large uploads/trains (\`ok:false\` + \`retry_after_sec\` = pause, not a crash). Save feedback drafts locally; resend via \`send_feedback\` when healthy.
|
|
9
14
|
|
|
10
15
|
**Parameter hints:** call \`training_guidance\` (also API-scoped). **Async:** poll \`jobs(action=status)\` every 10–15s after submit.`;
|
|
11
16
|
export function registerGuideBarsomTool(server) {
|
|
12
|
-
registerAuditedTool(server, "guide_barsom_workflow", "Plan-scoped orientation: proxy model, tool categories, async rules, training modes, and step-by-step SOP — loaded from the Barivia API when online. Call at the start of mapping work. Offline: short stub. For field-level parameters, use training_guidance; for a narrative pre-train checklist, use the prepare_training prompt.", {}, async () => {
|
|
17
|
+
registerAuditedTool(server, "guide_barsom_workflow", "Plan-scoped orientation: proxy model, tool categories, async rules, training modes, and step-by-step SOP — loaded from the Barivia API when online. Call at the start of mapping work. Offline: short stub with offline-vs-API capabilities and degraded-session rules. For field-level parameters, use training_guidance; for a narrative pre-train checklist, use the prepare_training prompt.", {}, async () => {
|
|
13
18
|
const md = await fetchWorkflowGuideFromApi();
|
|
14
19
|
if (md) {
|
|
15
20
|
const text = "Plan-scoped workflow (from Barivia API). Text below reflects your API key / plan.\n\n" + md;
|
package/dist/tools/jobs.js
CHANGED
|
@@ -3,13 +3,15 @@ import { registerAuditedTool } from "../audit.js";
|
|
|
3
3
|
import { apiCall, textResult } from "../shared.js";
|
|
4
4
|
import { formatJobCancelText, formatJobStatusText } from "../job_status_format.js";
|
|
5
5
|
import { DEFAULT_BLOCK_UNTIL_SEC, DEFAULT_POLL_INTERVAL_SEC, runJobMonitor } from "../job_monitor.js";
|
|
6
|
+
import { buildJobsListQuery, extractJobsList, formatJobInventoryLine, } from "../inventory_format.js";
|
|
6
7
|
export const JOBS_DESCRIPTION_BASE = `Manage and inspect jobs (lifecycle only).
|
|
7
8
|
|
|
8
9
|
| Action | Use when |
|
|
9
10
|
|--------|----------|
|
|
10
11
|
| monitor | After async submit — blocks server-side with 5s snapshots until terminal (preferred for agents) |
|
|
11
12
|
| status | One-shot progress check (manual poll every 10–15s if not using monitor) |
|
|
12
|
-
| list | Finding job IDs / reviewing pipeline state (
|
|
13
|
+
| list | Finding job IDs / reviewing pipeline state (slim by default; filter by dataset_id/status/job_type; paginate with limit+cursor) |
|
|
14
|
+
| update | Edit label / description / tags on an existing job |
|
|
13
15
|
| compare | Picking the best TRAINING run from a set (metrics table). NOT inference(compare), which diffs one dataset's distribution against the training set. |
|
|
14
16
|
| cancel | Stopping a running or pending job to free the worker |
|
|
15
17
|
| delete | Permanently removing a job and all its S3 result files |
|
|
@@ -28,9 +30,13 @@ ASYNC POLLING PROTOCOL (action=status):
|
|
|
28
30
|
- Map training typical times: 10×10 ~30s | 20×20 ~3–5 min | 40×40 ~15–30 min.
|
|
29
31
|
- completed → results(action=get); failed → read error + optional failure_stage (preprocessing/training/metrics/visualization/upload): memory error → smaller grid/batch and retrain; column missing → datasets(action=preview); NaN on a standard map → train(action=impute) for sparse columns or clean the CSV.
|
|
30
32
|
|
|
31
|
-
action=
|
|
33
|
+
action=list: Slim rows by default (label,id,status,job_type,dataset_id,created_at,result_ref,description,tags — no fat params). Use status/job_type/has_results/limit/cursor to page. has_results=true is the results catalog. Pass fields="full" only when you need params/error.
|
|
34
|
+
action=update: PATCH label/description/tags (pass at train submit when possible).
|
|
35
|
+
action=compare: metrics table (QE, TE, explained variance, silhouette) for 2+ COMPLETED TRAINING jobs — pick the best run in a sweep. NOT inference(action=compare) (single trained job + second dataset → density-diff heatmap). Only hyperparameter columns that differ across runs are shown (including SIOM knobs gamma/gamma_f/siom_decay/siom_penalty/reset_per_epoch when they vary); pass label="…" at train time for readable rows.
|
|
32
36
|
action=cancel: Not instant — worker checks between phases. Expect up to 30s delay.
|
|
33
|
-
action=delete: WARNING — job ID will no longer work with results or any other tool
|
|
37
|
+
action=delete: WARNING — job ID will no longer work with results or any other tool.
|
|
38
|
+
|
|
39
|
+
Org overview: prefer account(action=inventory) for a markdown datasets+jobs inventory.`;
|
|
34
40
|
/**
|
|
35
41
|
* Render the jobs(compare) markdown table. Strategy: always show job_id (or label
|
|
36
42
|
* if present) + the four core metrics (QE, TE, Expl.Var, Silhouette); add any
|
|
@@ -74,7 +80,11 @@ export function renderCompareTable(comparisons) {
|
|
|
74
80
|
{ key: "n_initial", label: "NInitial", render: fmtAny },
|
|
75
81
|
{ key: "growth_interval", label: "GrowthInt", render: fmtAny },
|
|
76
82
|
{ key: "gamma", label: "Gamma", render: fmtAny },
|
|
83
|
+
{ key: "gamma_f", label: "GammaF", render: fmtAny },
|
|
77
84
|
{ key: "siom_decay", label: "SIOMDecay", render: fmtAny },
|
|
85
|
+
{ key: "siom_penalty", label: "SIOMPenalty", render: fmtAny },
|
|
86
|
+
{ key: "penalty_alpha", label: "Penaltyα", render: fmtAny },
|
|
87
|
+
{ key: "reset_per_epoch", label: "Reset/Epoch", render: fmtAny },
|
|
78
88
|
{ key: "elastic_lambda", label: "Elasticλ", render: fmtAny },
|
|
79
89
|
{ key: "elastic_mu", label: "Elasticμ", render: fmtAny },
|
|
80
90
|
{ key: "elastic_anchor", label: "ElasticAnchor", render: fmtAny },
|
|
@@ -143,12 +153,12 @@ export function renderCompareTable(comparisons) {
|
|
|
143
153
|
export function registerJobsTool(server, description) {
|
|
144
154
|
registerAuditedTool(server, "jobs", description, {
|
|
145
155
|
action: z
|
|
146
|
-
.enum(["status", "monitor", "list", "compare", "cancel", "delete"])
|
|
147
|
-
.describe("status: one-shot check; monitor: block until terminal with snapshots; list/compare/cancel/delete as labeled. (Training via train; scoring via inference.)"),
|
|
156
|
+
.enum(["status", "monitor", "list", "update", "compare", "cancel", "delete"])
|
|
157
|
+
.describe("status: one-shot check; monitor: block until terminal with snapshots; list/update/compare/cancel/delete as labeled. (Training via train; scoring via inference.)"),
|
|
148
158
|
job_id: z
|
|
149
159
|
.string()
|
|
150
160
|
.optional()
|
|
151
|
-
.describe("Job ID — required for action=status, monitor, cancel, delete."),
|
|
161
|
+
.describe("Job ID — required for action=status, monitor, update, cancel, delete."),
|
|
152
162
|
block_until_sec: z
|
|
153
163
|
.number()
|
|
154
164
|
.int()
|
|
@@ -173,8 +183,38 @@ export function registerJobsTool(server, description) {
|
|
|
173
183
|
.string()
|
|
174
184
|
.optional()
|
|
175
185
|
.describe("action=list: filter jobs by this dataset ID."),
|
|
186
|
+
status: z
|
|
187
|
+
.enum(["pending", "running", "completed", "failed", "cancelled"])
|
|
188
|
+
.optional()
|
|
189
|
+
.describe("action=list: filter by job status."),
|
|
190
|
+
job_type: z
|
|
191
|
+
.string()
|
|
192
|
+
.optional()
|
|
193
|
+
.describe("action=list: filter by MCP job_type label (e.g. train_map, train_impute, predict, cfd_mesh_convergence)."),
|
|
194
|
+
has_results: z
|
|
195
|
+
.boolean()
|
|
196
|
+
.optional()
|
|
197
|
+
.describe("action=list: true = completed jobs with result_ref (results catalog)."),
|
|
198
|
+
limit: z
|
|
199
|
+
.number()
|
|
200
|
+
.int()
|
|
201
|
+
.min(1)
|
|
202
|
+
.max(100)
|
|
203
|
+
.optional()
|
|
204
|
+
.describe("action=list: page size (default 50, max 100)."),
|
|
205
|
+
cursor: z
|
|
206
|
+
.string()
|
|
207
|
+
.optional()
|
|
208
|
+
.describe("action=list: next_cursor from a prior list response for keyset pagination."),
|
|
209
|
+
fields: z
|
|
210
|
+
.enum(["slim", "full"])
|
|
211
|
+
.optional()
|
|
212
|
+
.describe("action=list: slim (default, no params) or full (includes params/error)."),
|
|
213
|
+
label: z.string().optional().describe("action=update: new job label (≤120 chars)."),
|
|
214
|
+
description: z.string().optional().describe("action=update: free-text description (pass \"\" to clear)."),
|
|
215
|
+
tags: z.array(z.string()).optional().describe("action=update: replace tags (pass [] to clear)."),
|
|
176
216
|
}, async (args) => {
|
|
177
|
-
const { action, job_id, job_ids, dataset_id, block_until_sec, poll_interval_sec, wait_finalize } = args;
|
|
217
|
+
const { action, job_id, job_ids, dataset_id, status, job_type, has_results, limit, cursor, fields, block_until_sec, poll_interval_sec, wait_finalize, label, description, tags, } = args;
|
|
178
218
|
// ---- Lifecycle ----
|
|
179
219
|
if (action === "monitor") {
|
|
180
220
|
if (!job_id)
|
|
@@ -188,17 +228,38 @@ export function registerJobsTool(server, description) {
|
|
|
188
228
|
return { content: [{ type: "text", text: formatJobStatusText(job_id, data) }] };
|
|
189
229
|
}
|
|
190
230
|
if (action === "list") {
|
|
191
|
-
const listPath =
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
});
|
|
200
|
-
|
|
231
|
+
const listPath = buildJobsListQuery({
|
|
232
|
+
dataset_id, status, job_type, has_results, limit, cursor, fields,
|
|
233
|
+
});
|
|
234
|
+
const data = await apiCall("GET", listPath);
|
|
235
|
+
const { jobs, next_cursor } = extractJobsList(data);
|
|
236
|
+
const lines = jobs.map((job) => formatJobInventoryLine(job));
|
|
237
|
+
if (next_cursor) {
|
|
238
|
+
lines.push("");
|
|
239
|
+
lines.push(`next_cursor: ${next_cursor}`);
|
|
240
|
+
lines.push(`Tip: jobs(action=list, cursor="${next_cursor}", limit=${limit ?? 50}) for the next page.`);
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
content: [{
|
|
244
|
+
type: "text",
|
|
245
|
+
text: lines.length > 0 ? lines.join("\n") : "No jobs found.",
|
|
246
|
+
}],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (action === "update") {
|
|
250
|
+
if (!job_id)
|
|
251
|
+
throw new Error("jobs(update) requires job_id");
|
|
252
|
+
const body = {};
|
|
253
|
+
if (label !== undefined)
|
|
254
|
+
body.label = label;
|
|
255
|
+
if (description !== undefined)
|
|
256
|
+
body.description = description;
|
|
257
|
+
if (tags !== undefined)
|
|
258
|
+
body.tags = tags;
|
|
259
|
+
if (Object.keys(body).length === 0) {
|
|
260
|
+
throw new Error("jobs(update) requires at least one of label, description, tags");
|
|
201
261
|
}
|
|
262
|
+
const data = await apiCall("PATCH", `/v1/jobs/${job_id}`, body);
|
|
202
263
|
return textResult(data);
|
|
203
264
|
}
|
|
204
265
|
if (action === "compare") {
|
package/dist/tools/results.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { registerAuditedTool } from "../audit.js";
|
|
5
5
|
import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, pollUntilComplete, tryAttachImage, resetInlineAttachBudget, attachResultsImages, resolveOutputDpi, POLL_RECOLOR_MAX_MS, fmtDensityDiffNode, getCaptionForImage, shouldFetchAllRemainingFigures, mimeForFilename, structuredTextResult, } from "../shared.js";
|
|
6
|
+
import { formatSiomOccupationSummary } from "../siom_occupation_format.js";
|
|
6
7
|
export function registerResultsTool(server) {
|
|
7
8
|
registerAuditedTool(server, "results", `Retrieve, recolor, download, or export figures and metrics for a completed map job. Presentation and artifact access only — operations on the frozen map (predict, compare, project, impute_column, transition_flow) live in **inference**.
|
|
8
9
|
|
|
@@ -225,7 +226,10 @@ NOT FOR: Jobs that haven't completed (use jobs(action=status) first), or tempora
|
|
|
225
226
|
hitStats.active_node_fraction !== undefined
|
|
226
227
|
? `Grid BMU occupancy: ${(Number(hitStats.active_node_fraction) * 100).toFixed(1)}% active nodes on the map grid`
|
|
227
228
|
: "",
|
|
228
|
-
...(siom
|
|
229
|
+
...formatSiomOccupationSummary(siom, {
|
|
230
|
+
files: summary.files ?? [],
|
|
231
|
+
compact: true,
|
|
232
|
+
}),
|
|
229
233
|
hitStats.grid_suggestion ? `Grid hint: ${String(hitStats.grid_suggestion)}` : "",
|
|
230
234
|
...(policy.auto_rules_applied && Array.isArray(policy.auto_rules_applied) && policy.auto_rules_applied.length > 0
|
|
231
235
|
? [`Auto policy: ${policy.auto_rules_applied.join(", ")} (${policy.viz_mode ?? "viz"}, metrics=${policy.quality_metrics ?? "?"})`]
|
|
@@ -296,19 +300,15 @@ NOT FOR: Jobs that haven't completed (use jobs(action=status) first), or tempora
|
|
|
296
300
|
`Coverage Assessment:`,
|
|
297
301
|
` Status: ${coverageStatus}${isDegenerate ? " *** NON-COMPETITIVE ***" : isWarning ? " * CAUTION *" : ""}`,
|
|
298
302
|
` Diagnosis: ${String(coverage.diagnosis ?? "No plain-language diagnosis available.")}`,
|
|
299
|
-
|
|
300
|
-
` SIOM dead fraction (layer coverage, not grid BMU hits): ${siom.dead_fraction !== undefined ? Number(siom.dead_fraction).toFixed(4) : "N/A"}`,
|
|
303
|
+
...formatSiomOccupationSummary(siom, { files }),
|
|
301
304
|
` Occupied nodes:${hitStats.occupied_nodes !== undefined ? ` ${hitStats.occupied_nodes}` : " N/A"}`,
|
|
302
305
|
` Top hit share: ${hitStats.top_hit_share !== undefined ? Number(hitStats.top_hit_share).toFixed(4) : "N/A"}`,
|
|
303
|
-
` Gini: ${siom.gini !== undefined ? Number(siom.gini).toFixed(4) : "N/A"}`,
|
|
304
|
-
` Entropy: ${siom.entropy !== undefined ? Number(siom.entropy).toFixed(4) : "N/A"}`,
|
|
305
|
-
` SIOM gamma: ${siom.gamma !== undefined ? Number(siom.gamma).toFixed(3) : "N/A"} | decay: ${siom.decay !== undefined ? Number(siom.decay).toFixed(4) : "N/A"}`,
|
|
306
306
|
...(summary.elastic ? (() => {
|
|
307
307
|
const el = summary.elastic;
|
|
308
308
|
return [` Elastic: λ=${el.lambda !== undefined ? Number(el.lambda).toFixed(3) : "0"} μ=${el.mu !== undefined ? Number(el.mu).toFixed(3) : "0"} anchor=${el.anchor !== undefined ? Number(el.anchor).toFixed(3) : "0"}`];
|
|
309
309
|
})() : []),
|
|
310
310
|
``,
|
|
311
|
-
`Layout tuning: if Voronoi cells look crowded in weight space (high top_hit_share), reduce max_nodes and/or raise gamma/siom_decay before growth_interval. See training_guidance keys floop_voronoi_crowding, floop_siom_params, floop_elastic_params.`,
|
|
311
|
+
`Layout tuning: if Voronoi cells look crowded in weight space (high top_hit_share), reduce max_nodes and/or raise gamma/siom_decay before growth_interval. See training_guidance keys floop_voronoi_crowding, floop_siom_params, floop_elastic_params, siom_gamma.`,
|
|
312
312
|
``,
|
|
313
313
|
`Quality Metrics:`,
|
|
314
314
|
` Quantization Error: ${summary.quantization_error !== undefined ? Number(summary.quantization_error).toFixed(4) : "N/A"}${qeNote}`,
|
|
@@ -386,11 +386,11 @@ NOT FOR: Jobs that haven't completed (use jobs(action=status) first), or tempora
|
|
|
386
386
|
...(compLines ? [`Component value ranges:\n${compLines}`] : []),
|
|
387
387
|
...(clusterLines ? ["", "Cluster map (defining features):", clusterLines] : []),
|
|
388
388
|
...(topCorrLine ? ["", topCorrLine] : []),
|
|
389
|
-
...(siom
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
389
|
+
...(siom
|
|
390
|
+
? ["", ...formatSiomOccupationSummary(siom, {
|
|
391
|
+
files: summary.files ?? [],
|
|
392
|
+
})]
|
|
393
|
+
: []),
|
|
394
394
|
``, `Features: ${features.join(", ")}`,
|
|
395
395
|
summary.selected_columns ? `Selected columns: ${summary.selected_columns.join(", ")}` : "",
|
|
396
396
|
summary.transforms ? `Transforms: ${Object.entries(summary.transforms).map(([k, v]) => `${k}=${v}`).join(", ")}` : "",
|