@crewhaus/tool-data 0.7.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/README.md +193 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +988 -0
- package/dist/lib/csv.d.ts +80 -0
- package/dist/lib/csv.js +240 -0
- package/dist/lib/diff.d.ts +44 -0
- package/dist/lib/diff.js +166 -0
- package/dist/lib/json.d.ts +65 -0
- package/dist/lib/json.js +133 -0
- package/dist/lib/jsonl.d.ts +39 -0
- package/dist/lib/jsonl.js +63 -0
- package/dist/lib/jsonpath.d.ts +91 -0
- package/dist/lib/jsonpath.js +430 -0
- package/dist/lib/patch.d.ts +72 -0
- package/dist/lib/patch.js +272 -0
- package/dist/lib/table.d.ts +156 -0
- package/dist/lib/table.js +571 -0
- package/dist/lib/toml.d.ts +59 -0
- package/dist/lib/toml.js +530 -0
- package/dist/lib/xml.d.ts +72 -0
- package/dist/lib/xml.js +344 -0
- package/dist/lib/yaml.d.ts +61 -0
- package/dist/lib/yaml.js +598 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/tool-data — deterministic structured-data tools.
|
|
3
|
+
*
|
|
4
|
+
* Every tool here is pure: no filesystem, no network, no clock, no
|
|
5
|
+
* randomness. The same input always produces the same bytes. That is what
|
|
6
|
+
* lets a harness call them freely, and what makes their output safe to
|
|
7
|
+
* cache, retry and diff between runs.
|
|
8
|
+
*
|
|
9
|
+
* Two input conventions, applied consistently:
|
|
10
|
+
*
|
|
11
|
+
* - A whole *document* (JSON, YAML, TOML, CSV, XML, JSONL) arrives as text,
|
|
12
|
+
* because that is how it comes off disk or off the wire.
|
|
13
|
+
* - A *record array* arrives as a real array of objects, because that is how
|
|
14
|
+
* it comes out of the previous tool in the chain.
|
|
15
|
+
*
|
|
16
|
+
* Each tool is a thin wrapper over a function in `./lib`, which is where the
|
|
17
|
+
* behaviour is tested. Tools return compact JSON, or the document text when
|
|
18
|
+
* the caller will paste it onward, because every byte returned is a byte in
|
|
19
|
+
* somebody's context window.
|
|
20
|
+
*/
|
|
21
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { CsvError, cellToString, normalizeHeader, parseCsv, rowsToRecords, unionKeys, writeCsvRows, } from "./lib/csv";
|
|
24
|
+
import { deepDiff } from "./lib/diff";
|
|
25
|
+
import { canonicalStringify, isPlainObject, parseJson, sortKeysDeep, stableHash } from "./lib/json";
|
|
26
|
+
import { parseJsonl, writeJsonl } from "./lib/jsonl";
|
|
27
|
+
import { PathError, parsePath, queryPath } from "./lib/jsonpath";
|
|
28
|
+
import { PatchError, applyJsonPatch, applyMergePatch, diffMergePatch, resolvePointer, } from "./lib/patch";
|
|
29
|
+
import { PredicateError, aggregate, columnsToRecords as columnsToRecordsFn, dedupeRecords as dedupeRecordsFn, flattenObject as flattenObjectFn, joinRecords, omitFields, recordsToColumns as recordsToColumnsFn, sampleRecords as sampleRecordsFn, selectFields, sortRecords as sortRecordsFn, testPredicate, unflattenObject as unflattenObjectFn, } from "./lib/table";
|
|
30
|
+
import { TomlError, parseToml, stringifyToml } from "./lib/toml";
|
|
31
|
+
import { XmlError, parseXml, toCompact } from "./lib/xml";
|
|
32
|
+
import { YamlError, parseYaml, stringifyYaml } from "./lib/yaml";
|
|
33
|
+
/** Compact JSON — no indentation, since the reader is a model, not a person. */
|
|
34
|
+
const json = (value) => JSON.stringify(value);
|
|
35
|
+
/**
|
|
36
|
+
* Guard for every tool that accepts a whole document. Keeps one pathological
|
|
37
|
+
* input from filling a context window or exhausting memory. Callers hitting
|
|
38
|
+
* it should narrow the input rather than raise it.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_INPUT_CHARS = 4_000_000;
|
|
41
|
+
function tooLarge(text, field) {
|
|
42
|
+
if (text.length <= MAX_INPUT_CHARS)
|
|
43
|
+
return null;
|
|
44
|
+
return `${field} is ${text.length} characters, over the ${MAX_INPUT_CHARS} limit — narrow the input first`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read a document in any of the formats this package understands. Returns a
|
|
48
|
+
* readable message rather than throwing, because a malformed file is a
|
|
49
|
+
* caller's mistake and belongs in the result, not in a stack trace.
|
|
50
|
+
*/
|
|
51
|
+
function readDocument(text, format, csv) {
|
|
52
|
+
try {
|
|
53
|
+
switch (format) {
|
|
54
|
+
case "json": {
|
|
55
|
+
const parsed = parseJson(text);
|
|
56
|
+
return parsed.ok
|
|
57
|
+
? { ok: true, value: parsed.value }
|
|
58
|
+
: { ok: false, error: `invalid JSON: ${parsed.error}` };
|
|
59
|
+
}
|
|
60
|
+
case "yaml":
|
|
61
|
+
return { ok: true, value: parseYaml(text) };
|
|
62
|
+
case "toml":
|
|
63
|
+
return { ok: true, value: parseToml(text) };
|
|
64
|
+
case "jsonl": {
|
|
65
|
+
const out = parseJsonl(text, 100_000, true);
|
|
66
|
+
if (out.failures.length > 0) {
|
|
67
|
+
const first = out.failures[0];
|
|
68
|
+
return { ok: false, error: `invalid JSONL on line ${first.line}: ${first.error}` };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, value: out.records.map((r) => r.value) };
|
|
71
|
+
}
|
|
72
|
+
default: {
|
|
73
|
+
const options = {
|
|
74
|
+
delimiter: csv.delimiter,
|
|
75
|
+
quote: '"',
|
|
76
|
+
skipEmptyLines: true,
|
|
77
|
+
trim: false,
|
|
78
|
+
maxRows: 100_000,
|
|
79
|
+
};
|
|
80
|
+
const parsed = parseCsv(text, options);
|
|
81
|
+
if (parsed.rows.length === 0)
|
|
82
|
+
return { ok: true, value: [] };
|
|
83
|
+
if (!csv.header)
|
|
84
|
+
return { ok: true, value: parsed.rows };
|
|
85
|
+
const header = normalizeHeader(parsed.rows[0]);
|
|
86
|
+
const { records } = rowsToRecords(parsed.rows.slice(1), header, csv.inferTypes, []);
|
|
87
|
+
return { ok: true, value: records };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (err instanceof YamlError)
|
|
93
|
+
return { ok: false, error: `invalid YAML on line ${err.line}: ${err.message}` };
|
|
94
|
+
if (err instanceof TomlError)
|
|
95
|
+
return { ok: false, error: `invalid TOML on line ${err.line}: ${err.message}` };
|
|
96
|
+
if (err instanceof CsvError)
|
|
97
|
+
return { ok: false, error: `invalid CSV on line ${err.line}: ${err.message}` };
|
|
98
|
+
return { ok: false, error: err.message };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Parse a JSON document argument, returning a message on failure. */
|
|
102
|
+
function readJson(text, field) {
|
|
103
|
+
const size = tooLarge(text, field);
|
|
104
|
+
if (size !== null)
|
|
105
|
+
return { ok: false, error: size };
|
|
106
|
+
const parsed = parseJson(text);
|
|
107
|
+
return parsed.ok
|
|
108
|
+
? { ok: true, value: parsed.value }
|
|
109
|
+
: { ok: false, error: `${field} is not valid JSON: ${parsed.error}` };
|
|
110
|
+
}
|
|
111
|
+
const FORMATS = ["json", "yaml", "toml", "csv", "jsonl"];
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
export const jsonQuery = buildTool({
|
|
114
|
+
name: "JsonQuery",
|
|
115
|
+
description: "Select values out of a JSON document with a JSONPath-like expression supporting dotted and bracketed keys, array indexes and slices, wildcards, recursive descent and a single-comparison filter. Use to pull the two fields you need out of a large API response instead of reading the whole thing into context.",
|
|
116
|
+
inputSchema: z.object({
|
|
117
|
+
json: z.string().describe("the document, as JSON text"),
|
|
118
|
+
path: z
|
|
119
|
+
.string()
|
|
120
|
+
.min(1)
|
|
121
|
+
.describe("e.g. $.items[0].id, $.users[*].email, $..name, $.rows[2:8], $.users[?(@.age >= 30)]"),
|
|
122
|
+
maxResults: z.number().int().positive().max(10_000).optional(),
|
|
123
|
+
valuesOnly: z.boolean().optional().describe("return just the values, dropping their paths"),
|
|
124
|
+
}),
|
|
125
|
+
readOnly: true,
|
|
126
|
+
concurrencySafe: true,
|
|
127
|
+
execute: async (input) => {
|
|
128
|
+
const doc = readJson(input.json, "json");
|
|
129
|
+
if (!doc.ok)
|
|
130
|
+
return doc.error;
|
|
131
|
+
const limit = input.maxResults ?? 200;
|
|
132
|
+
try {
|
|
133
|
+
const steps = parsePath(input.path);
|
|
134
|
+
const result = queryPath(doc.value, steps, limit);
|
|
135
|
+
return json({
|
|
136
|
+
count: result.matches.length,
|
|
137
|
+
truncated: result.truncated,
|
|
138
|
+
...(input.valuesOnly === true
|
|
139
|
+
? { values: result.matches.map((m) => m.value) }
|
|
140
|
+
: { matches: result.matches }),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (err instanceof PathError)
|
|
145
|
+
return `invalid path: ${err.message}`;
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
export const jsonPatch = buildTool({
|
|
151
|
+
name: "JsonPatch",
|
|
152
|
+
description: "Apply an RFC 6902 JSON Patch (add, remove, replace, move, copy, test) to a JSON document, all-or-nothing. Use to make a precise, reviewable edit to config or state without a model rewriting the whole file and changing something it did not mean to.",
|
|
153
|
+
inputSchema: z.object({
|
|
154
|
+
json: z.string().describe("the document to patch, as JSON text"),
|
|
155
|
+
patch: z
|
|
156
|
+
.array(z.object({
|
|
157
|
+
op: z.enum(["add", "remove", "replace", "move", "copy", "test"]),
|
|
158
|
+
path: z.string().describe("a JSON Pointer, e.g. /users/0/name"),
|
|
159
|
+
from: z.string().optional().describe("source pointer, for move and copy"),
|
|
160
|
+
value: z.unknown().describe("the new value, for add, replace and test"),
|
|
161
|
+
}))
|
|
162
|
+
.min(1)
|
|
163
|
+
.max(1000),
|
|
164
|
+
indent: z.number().int().min(0).max(8).optional(),
|
|
165
|
+
}),
|
|
166
|
+
readOnly: true,
|
|
167
|
+
concurrencySafe: true,
|
|
168
|
+
execute: async (input) => {
|
|
169
|
+
const doc = readJson(input.json, "json");
|
|
170
|
+
if (!doc.ok)
|
|
171
|
+
return doc.error;
|
|
172
|
+
for (const [i, op] of input.patch.entries()) {
|
|
173
|
+
if ((op.op === "move" || op.op === "copy") && op.from === undefined) {
|
|
174
|
+
return `operation ${i} (${op.op}) needs a "from" pointer`;
|
|
175
|
+
}
|
|
176
|
+
if ((op.op === "add" || op.op === "replace" || op.op === "test") && !("value" in op)) {
|
|
177
|
+
return `operation ${i} (${op.op}) needs a "value"`;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const patched = applyJsonPatch(doc.value, input.patch);
|
|
182
|
+
return JSON.stringify(patched, null, input.indent ?? 0);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
if (err instanceof PatchError) {
|
|
186
|
+
return `patch failed at operation ${err.opIndex}: ${err.message} — the document is unchanged`;
|
|
187
|
+
}
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
export const jsonMergePatch = buildTool({
|
|
193
|
+
name: "JsonMergePatch",
|
|
194
|
+
description: "Apply an RFC 7386 JSON Merge Patch, where an object merges key by key and a null deletes a key, or derive the merge patch between two documents. Use for a shallow config override, or to describe what changed between two states in the smallest form that reproduces it.",
|
|
195
|
+
inputSchema: z.object({
|
|
196
|
+
json: z.string().describe("the target document, as JSON text"),
|
|
197
|
+
patch: z
|
|
198
|
+
.string()
|
|
199
|
+
.optional()
|
|
200
|
+
.describe("the merge patch, as JSON text; required unless deriving"),
|
|
201
|
+
against: z
|
|
202
|
+
.string()
|
|
203
|
+
.optional()
|
|
204
|
+
.describe("a second document; supplying it derives the patch that turns json into it"),
|
|
205
|
+
indent: z.number().int().min(0).max(8).optional(),
|
|
206
|
+
}),
|
|
207
|
+
readOnly: true,
|
|
208
|
+
concurrencySafe: true,
|
|
209
|
+
execute: async (input) => {
|
|
210
|
+
const doc = readJson(input.json, "json");
|
|
211
|
+
if (!doc.ok)
|
|
212
|
+
return doc.error;
|
|
213
|
+
const indent = input.indent ?? 0;
|
|
214
|
+
if (input.against !== undefined) {
|
|
215
|
+
const other = readJson(input.against, "against");
|
|
216
|
+
if (!other.ok)
|
|
217
|
+
return other.error;
|
|
218
|
+
return JSON.stringify(diffMergePatch(doc.value, other.value), null, indent);
|
|
219
|
+
}
|
|
220
|
+
if (input.patch === undefined)
|
|
221
|
+
return "supply either patch (to apply) or against (to derive)";
|
|
222
|
+
const patch = readJson(input.patch, "patch");
|
|
223
|
+
if (!patch.ok)
|
|
224
|
+
return patch.error;
|
|
225
|
+
return JSON.stringify(applyMergePatch(doc.value, patch.value), null, indent);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
export const jsonFormat = buildTool({
|
|
229
|
+
name: "JsonFormat",
|
|
230
|
+
description: "Re-serialize JSON with a chosen indentation and, optionally, every object's keys sorted, plus a stable content hash of the value. Use to make two documents byte-comparable before diffing them, or to minify a payload before it goes into a prompt.",
|
|
231
|
+
inputSchema: z.object({
|
|
232
|
+
json: z.string().describe("the document, as JSON text"),
|
|
233
|
+
indent: z
|
|
234
|
+
.number()
|
|
235
|
+
.int()
|
|
236
|
+
.min(0)
|
|
237
|
+
.max(8)
|
|
238
|
+
.optional()
|
|
239
|
+
.describe("0 minifies; 2 is the usual pretty form"),
|
|
240
|
+
sortKeys: z.boolean().optional().describe("sort object keys at every depth, for stable output"),
|
|
241
|
+
pointer: z.string().optional().describe("a JSON Pointer; format only the value it addresses"),
|
|
242
|
+
withHash: z.boolean().optional().describe("also return a stable 64-bit content hash"),
|
|
243
|
+
}),
|
|
244
|
+
readOnly: true,
|
|
245
|
+
concurrencySafe: true,
|
|
246
|
+
execute: async (input) => {
|
|
247
|
+
const doc = readJson(input.json, "json");
|
|
248
|
+
if (!doc.ok)
|
|
249
|
+
return doc.error;
|
|
250
|
+
let value = doc.value;
|
|
251
|
+
if (input.pointer !== undefined && input.pointer !== "") {
|
|
252
|
+
const found = resolvePointer(value, input.pointer);
|
|
253
|
+
if (!found.found)
|
|
254
|
+
return `pointer "${input.pointer}" does not resolve: ${found.reason}`;
|
|
255
|
+
value = found.value;
|
|
256
|
+
}
|
|
257
|
+
const indent = input.indent ?? 2;
|
|
258
|
+
const text = input.sortKeys === true
|
|
259
|
+
? canonicalStringify(value, indent)
|
|
260
|
+
: JSON.stringify(value, null, indent);
|
|
261
|
+
if (input.withHash !== true)
|
|
262
|
+
return text ?? "undefined";
|
|
263
|
+
return json({ text, hash: stableHash(value), bytes: (text ?? "").length });
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
export const dataDiff = buildTool({
|
|
267
|
+
name: "DataDiff",
|
|
268
|
+
description: "Compare two JSON documents structurally and report every added, removed and changed path with both values. Use to tell exactly what an edit, a migration or a deploy changed, instead of eyeballing two files side by side.",
|
|
269
|
+
inputSchema: z.object({
|
|
270
|
+
before: z.string().describe("the original document, as JSON text"),
|
|
271
|
+
after: z.string().describe("the changed document, as JSON text"),
|
|
272
|
+
keyArraysBy: z
|
|
273
|
+
.string()
|
|
274
|
+
.optional()
|
|
275
|
+
.describe("match array elements by this field instead of by position — use for record lists"),
|
|
276
|
+
maxDepth: z.number().int().positive().max(64).optional(),
|
|
277
|
+
maxEntries: z.number().int().positive().max(5000).optional(),
|
|
278
|
+
countsOnly: z.boolean().optional(),
|
|
279
|
+
}),
|
|
280
|
+
readOnly: true,
|
|
281
|
+
concurrencySafe: true,
|
|
282
|
+
execute: async (input) => {
|
|
283
|
+
const before = readJson(input.before, "before");
|
|
284
|
+
if (!before.ok)
|
|
285
|
+
return before.error;
|
|
286
|
+
const after = readJson(input.after, "after");
|
|
287
|
+
if (!after.ok)
|
|
288
|
+
return after.error;
|
|
289
|
+
const result = deepDiff(before.value, after.value, {
|
|
290
|
+
keyArraysBy: input.keyArraysBy,
|
|
291
|
+
maxDepth: input.maxDepth ?? 32,
|
|
292
|
+
maxEntries: input.maxEntries ?? 500,
|
|
293
|
+
});
|
|
294
|
+
const identical = result.entries.length === 0 && !result.truncated;
|
|
295
|
+
if (input.countsOnly === true || identical) {
|
|
296
|
+
return json({ identical, ...result.counts, truncated: result.truncated });
|
|
297
|
+
}
|
|
298
|
+
return json({
|
|
299
|
+
identical: false,
|
|
300
|
+
...result.counts,
|
|
301
|
+
truncated: result.truncated,
|
|
302
|
+
entries: result.entries,
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
export const dataConvert = buildTool({
|
|
307
|
+
name: "DataConvert",
|
|
308
|
+
description: "Convert a document between JSON, YAML, TOML, CSV and JSONL using this package's own hand-written readers and writers. Use to normalize whatever format a file arrived in before querying it — but read the README first, because each format supports a documented subset, not the whole specification.",
|
|
309
|
+
inputSchema: z.object({
|
|
310
|
+
text: z.string().describe("the document to convert"),
|
|
311
|
+
from: z.enum(FORMATS),
|
|
312
|
+
to: z.enum(FORMATS),
|
|
313
|
+
indent: z.number().int().min(0).max(8).optional().describe("for JSON output"),
|
|
314
|
+
delimiter: z.string().length(1).optional().describe("for CSV, on either side; default ,"),
|
|
315
|
+
header: z
|
|
316
|
+
.boolean()
|
|
317
|
+
.optional()
|
|
318
|
+
.describe("for CSV, treat the first row as a header; default true"),
|
|
319
|
+
inferTypes: z
|
|
320
|
+
.boolean()
|
|
321
|
+
.optional()
|
|
322
|
+
.describe("for CSV input, read numbers and booleans as such rather than strings"),
|
|
323
|
+
}),
|
|
324
|
+
readOnly: true,
|
|
325
|
+
concurrencySafe: true,
|
|
326
|
+
execute: async (input) => {
|
|
327
|
+
const size = tooLarge(input.text, "text");
|
|
328
|
+
if (size !== null)
|
|
329
|
+
return size;
|
|
330
|
+
const delimiter = input.delimiter ?? ",";
|
|
331
|
+
const header = input.header ?? true;
|
|
332
|
+
const doc = readDocument(input.text, input.from, {
|
|
333
|
+
delimiter,
|
|
334
|
+
header,
|
|
335
|
+
inferTypes: input.inferTypes ?? true,
|
|
336
|
+
});
|
|
337
|
+
if (!doc.ok)
|
|
338
|
+
return doc.error;
|
|
339
|
+
return writeDocument(doc.value, input.to, {
|
|
340
|
+
indent: input.indent ?? 2,
|
|
341
|
+
delimiter,
|
|
342
|
+
header,
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
function writeDocument(value, format, options) {
|
|
347
|
+
switch (format) {
|
|
348
|
+
case "json":
|
|
349
|
+
return JSON.stringify(value, null, options.indent) ?? "null";
|
|
350
|
+
case "yaml":
|
|
351
|
+
return stringifyYaml(value);
|
|
352
|
+
case "toml": {
|
|
353
|
+
if (!isPlainObject(value)) {
|
|
354
|
+
return "cannot write TOML: a TOML document must be an object at the top level, and this value is not";
|
|
355
|
+
}
|
|
356
|
+
const out = stringifyToml(value);
|
|
357
|
+
return out.skipped.length === 0
|
|
358
|
+
? out.text
|
|
359
|
+
: `${out.text}\n\n# dropped (TOML has no null): ${out.skipped.join(", ")}`;
|
|
360
|
+
}
|
|
361
|
+
case "jsonl": {
|
|
362
|
+
if (!Array.isArray(value))
|
|
363
|
+
return "cannot write JSONL: the value is not an array";
|
|
364
|
+
return writeJsonl(value, true).text;
|
|
365
|
+
}
|
|
366
|
+
default: {
|
|
367
|
+
if (!Array.isArray(value))
|
|
368
|
+
return "cannot write CSV: the value is not an array of records";
|
|
369
|
+
if (value.length === 0)
|
|
370
|
+
return "";
|
|
371
|
+
if (value.every((r) => Array.isArray(r))) {
|
|
372
|
+
return writeCsvRows(value, {
|
|
373
|
+
delimiter: options.delimiter,
|
|
374
|
+
quote: '"',
|
|
375
|
+
newline: "\n",
|
|
376
|
+
quoteAll: false,
|
|
377
|
+
header: null,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (!value.every(isPlainObject)) {
|
|
381
|
+
return "cannot write CSV: every element must be an object (a record) or an array (a row)";
|
|
382
|
+
}
|
|
383
|
+
const records = value;
|
|
384
|
+
const columns = unionKeys(records);
|
|
385
|
+
const writeOptions = {
|
|
386
|
+
delimiter: options.delimiter,
|
|
387
|
+
quote: '"',
|
|
388
|
+
newline: "\n",
|
|
389
|
+
quoteAll: false,
|
|
390
|
+
header: options.header ? columns : null,
|
|
391
|
+
};
|
|
392
|
+
return writeCsvRows(records.map((r) => columns.map((c) => r[c])), writeOptions);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
export const csvParse = buildTool({
|
|
397
|
+
name: "CsvParse",
|
|
398
|
+
description: "Parse RFC 4180 CSV into records or rows, handling quoted fields, embedded commas, newlines and doubled quotes, with a custom delimiter and optional type inference. Use to read a spreadsheet export correctly instead of splitting on commas and corrupting every quoted address.",
|
|
399
|
+
inputSchema: z.object({
|
|
400
|
+
text: z.string().describe("the CSV document"),
|
|
401
|
+
delimiter: z.string().length(1).optional().describe("default , — use \\t for TSV"),
|
|
402
|
+
quote: z.string().length(1).optional().describe('default "'),
|
|
403
|
+
header: z.boolean().optional().describe("treat the first row as a header; default true"),
|
|
404
|
+
inferTypes: z
|
|
405
|
+
.boolean()
|
|
406
|
+
.optional()
|
|
407
|
+
.describe("read numbers, booleans and blanks as such; leading zeros stay strings"),
|
|
408
|
+
trim: z.boolean().optional().describe("trim unquoted fields; quoted fields are never trimmed"),
|
|
409
|
+
nullTokens: z
|
|
410
|
+
.array(z.string())
|
|
411
|
+
.optional()
|
|
412
|
+
.describe("extra cell values to read as null, e.g. NA, NULL, -"),
|
|
413
|
+
maxRows: z.number().int().positive().max(200_000).optional(),
|
|
414
|
+
}),
|
|
415
|
+
readOnly: true,
|
|
416
|
+
concurrencySafe: true,
|
|
417
|
+
execute: async (input) => {
|
|
418
|
+
const size = tooLarge(input.text, "text");
|
|
419
|
+
if (size !== null)
|
|
420
|
+
return size;
|
|
421
|
+
const options = {
|
|
422
|
+
delimiter: input.delimiter ?? ",",
|
|
423
|
+
quote: input.quote ?? '"',
|
|
424
|
+
skipEmptyLines: true,
|
|
425
|
+
trim: input.trim ?? false,
|
|
426
|
+
maxRows: input.maxRows ?? 50_000,
|
|
427
|
+
};
|
|
428
|
+
let parsed;
|
|
429
|
+
try {
|
|
430
|
+
parsed = parseCsv(input.text, options);
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
if (err instanceof CsvError)
|
|
434
|
+
return `invalid CSV on line ${err.line}: ${err.message}`;
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
if (parsed.rows.length === 0) {
|
|
438
|
+
return json({ rowCount: 0, columns: [], records: [], truncated: parsed.truncated });
|
|
439
|
+
}
|
|
440
|
+
if (input.header === false) {
|
|
441
|
+
return json({ rowCount: parsed.rows.length, rows: parsed.rows, truncated: parsed.truncated });
|
|
442
|
+
}
|
|
443
|
+
const columns = normalizeHeader(parsed.rows[0]);
|
|
444
|
+
const { records, ragged } = rowsToRecords(parsed.rows.slice(1), columns, input.inferTypes ?? false, input.nullTokens ?? []);
|
|
445
|
+
return json({
|
|
446
|
+
rowCount: records.length,
|
|
447
|
+
columns,
|
|
448
|
+
truncated: parsed.truncated,
|
|
449
|
+
...(ragged.length > 0 ? { raggedRows: ragged.slice(0, 50) } : {}),
|
|
450
|
+
records,
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
export const csvWrite = buildTool({
|
|
455
|
+
name: "CsvWrite",
|
|
456
|
+
description: "Render records or rows as RFC 4180 CSV, quoting any field that contains the delimiter, a quote or a newline. Use to hand a spreadsheet, a ticketing import or a colleague a file that will not break on the first address field.",
|
|
457
|
+
inputSchema: z.object({
|
|
458
|
+
records: z
|
|
459
|
+
.array(z.union([z.record(z.unknown()), z.array(z.unknown())]))
|
|
460
|
+
.max(200_000)
|
|
461
|
+
.describe("objects (a header is derived from their keys) or arrays (written as raw rows)"),
|
|
462
|
+
columns: z
|
|
463
|
+
.array(z.string())
|
|
464
|
+
.optional()
|
|
465
|
+
.describe("column order; defaults to keys as first seen"),
|
|
466
|
+
delimiter: z.string().length(1).optional(),
|
|
467
|
+
header: z.boolean().optional().describe("write a header row; default true for records"),
|
|
468
|
+
quoteAll: z.boolean().optional(),
|
|
469
|
+
crlf: z.boolean().optional().describe("use CRLF line endings, as RFC 4180 specifies"),
|
|
470
|
+
}),
|
|
471
|
+
readOnly: true,
|
|
472
|
+
concurrencySafe: true,
|
|
473
|
+
execute: async (input) => {
|
|
474
|
+
if (input.records.length === 0)
|
|
475
|
+
return "";
|
|
476
|
+
const rowMode = input.records.every((r) => Array.isArray(r));
|
|
477
|
+
const options = {
|
|
478
|
+
delimiter: input.delimiter ?? ",",
|
|
479
|
+
quote: '"',
|
|
480
|
+
newline: input.crlf === true ? "\r\n" : "\n",
|
|
481
|
+
quoteAll: input.quoteAll ?? false,
|
|
482
|
+
header: null,
|
|
483
|
+
};
|
|
484
|
+
if (rowMode) {
|
|
485
|
+
return writeCsvRows(input.records, {
|
|
486
|
+
...options,
|
|
487
|
+
header: input.columns ?? null,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
if (!input.records.every(isPlainObject)) {
|
|
491
|
+
return "mix of records and rows — every element must be an object, or every element an array";
|
|
492
|
+
}
|
|
493
|
+
const records = input.records;
|
|
494
|
+
const columns = input.columns ?? unionKeys(records);
|
|
495
|
+
return writeCsvRows(records.map((r) => columns.map((c) => cellToString(r[c] ?? null))), { ...options, header: input.header === false ? null : columns });
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
const conditionSchema = z.object({
|
|
499
|
+
field: z.string().describe("a dotted path, e.g. user.email"),
|
|
500
|
+
op: z.enum([
|
|
501
|
+
"eq",
|
|
502
|
+
"ne",
|
|
503
|
+
"lt",
|
|
504
|
+
"lte",
|
|
505
|
+
"gt",
|
|
506
|
+
"gte",
|
|
507
|
+
"contains",
|
|
508
|
+
"startsWith",
|
|
509
|
+
"endsWith",
|
|
510
|
+
"in",
|
|
511
|
+
"matches",
|
|
512
|
+
"exists",
|
|
513
|
+
"empty",
|
|
514
|
+
]),
|
|
515
|
+
value: z.unknown().describe("the value to compare against; omitted for exists and empty"),
|
|
516
|
+
});
|
|
517
|
+
export const tableQuery = buildTool({
|
|
518
|
+
name: "TableQuery",
|
|
519
|
+
description: "Filter, project, sort and page an array of records with a small declarative predicate over dotted field paths. Use to narrow a large result set to the rows and columns that matter before any of it reaches a model.",
|
|
520
|
+
inputSchema: z.object({
|
|
521
|
+
records: z.array(z.record(z.unknown())).max(200_000),
|
|
522
|
+
where: z
|
|
523
|
+
.object({
|
|
524
|
+
all: z.array(conditionSchema).optional().describe("every condition must hold"),
|
|
525
|
+
any: z.array(conditionSchema).optional().describe("at least one must hold"),
|
|
526
|
+
none: z.array(conditionSchema).optional().describe("none may hold"),
|
|
527
|
+
})
|
|
528
|
+
.optional(),
|
|
529
|
+
select: z.array(z.string()).optional().describe("keep only these fields"),
|
|
530
|
+
omit: z.array(z.string()).optional().describe("drop these fields"),
|
|
531
|
+
sort: z
|
|
532
|
+
.array(z.object({
|
|
533
|
+
field: z.string(),
|
|
534
|
+
direction: z.enum(["asc", "desc"]).optional(),
|
|
535
|
+
caseInsensitive: z.boolean().optional(),
|
|
536
|
+
nulls: z.enum(["first", "last"]).optional(),
|
|
537
|
+
}))
|
|
538
|
+
.optional(),
|
|
539
|
+
offset: z.number().int().min(0).optional(),
|
|
540
|
+
limit: z.number().int().positive().max(100_000).optional(),
|
|
541
|
+
countOnly: z.boolean().optional(),
|
|
542
|
+
}),
|
|
543
|
+
readOnly: true,
|
|
544
|
+
concurrencySafe: true,
|
|
545
|
+
execute: async (input) => {
|
|
546
|
+
let rows = input.records;
|
|
547
|
+
if (input.where !== undefined) {
|
|
548
|
+
try {
|
|
549
|
+
rows = rows.filter((r) => testPredicate(r, input.where ?? {}));
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
if (err instanceof PredicateError)
|
|
553
|
+
return `invalid filter: ${err.message}`;
|
|
554
|
+
throw err;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const matched = rows.length;
|
|
558
|
+
if (input.sort !== undefined && input.sort.length > 0) {
|
|
559
|
+
rows = sortRecordsFn(rows, input.sort);
|
|
560
|
+
}
|
|
561
|
+
const offset = input.offset ?? 0;
|
|
562
|
+
const limit = input.limit ?? 1000;
|
|
563
|
+
rows = rows.slice(offset, offset + limit);
|
|
564
|
+
if (input.countOnly === true)
|
|
565
|
+
return json({ matched, returned: 0 });
|
|
566
|
+
if (input.select !== undefined && input.select.length > 0) {
|
|
567
|
+
const fields = input.select;
|
|
568
|
+
rows = rows.map((r) => selectFields(r, fields));
|
|
569
|
+
}
|
|
570
|
+
if (input.omit !== undefined && input.omit.length > 0) {
|
|
571
|
+
const fields = input.omit;
|
|
572
|
+
rows = rows.map((r) => omitFields(r, fields));
|
|
573
|
+
}
|
|
574
|
+
return json({ matched, returned: rows.length, records: rows });
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
export const tableAggregate = buildTool({
|
|
578
|
+
name: "TableAggregate",
|
|
579
|
+
description: "Group records by one or more fields and reduce each group with count, sum, min, max, avg, first, last or distinct. Use to turn thousands of rows into the handful of numbers a decision actually rests on, without a model doing arithmetic.",
|
|
580
|
+
inputSchema: z.object({
|
|
581
|
+
records: z.array(z.record(z.unknown())).max(200_000),
|
|
582
|
+
groupBy: z
|
|
583
|
+
.array(z.string())
|
|
584
|
+
.max(8)
|
|
585
|
+
.describe("dotted field paths; an empty list aggregates the whole set"),
|
|
586
|
+
aggregations: z
|
|
587
|
+
.array(z.object({
|
|
588
|
+
as: z.string().min(1).describe("the output field name"),
|
|
589
|
+
fn: z.enum(["count", "sum", "min", "max", "avg", "first", "last", "distinct"]),
|
|
590
|
+
field: z.string().optional().describe("the field to reduce; omit for a plain count"),
|
|
591
|
+
}))
|
|
592
|
+
.max(32)
|
|
593
|
+
.optional(),
|
|
594
|
+
sort: z
|
|
595
|
+
.array(z.object({ field: z.string(), direction: z.enum(["asc", "desc"]).optional() }))
|
|
596
|
+
.optional(),
|
|
597
|
+
limit: z.number().int().positive().max(50_000).optional(),
|
|
598
|
+
}),
|
|
599
|
+
readOnly: true,
|
|
600
|
+
concurrencySafe: true,
|
|
601
|
+
execute: async (input) => {
|
|
602
|
+
const result = aggregate(input.records, input.groupBy, (input.aggregations ?? []));
|
|
603
|
+
let groups = result.groups;
|
|
604
|
+
if (input.sort !== undefined && input.sort.length > 0) {
|
|
605
|
+
groups = sortRecordsFn(groups, input.sort);
|
|
606
|
+
}
|
|
607
|
+
const limit = input.limit ?? 1000;
|
|
608
|
+
return json({
|
|
609
|
+
groupCount: result.groups.length,
|
|
610
|
+
...(result.skipped > 0 ? { nonNumericSkipped: result.skipped } : {}),
|
|
611
|
+
truncated: result.groups.length > limit,
|
|
612
|
+
groups: groups.slice(0, limit),
|
|
613
|
+
});
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
export const tableJoin = buildTool({
|
|
617
|
+
name: "TableJoin",
|
|
618
|
+
description: "Join two record arrays on a key with inner, left, right or full semantics, reporting how many rows on each side found no match. Use to stitch two API results together without a model pairing them up by eye and quietly dropping the ones that did not line up.",
|
|
619
|
+
inputSchema: z.object({
|
|
620
|
+
left: z.array(z.record(z.unknown())).max(100_000),
|
|
621
|
+
right: z.array(z.record(z.unknown())).max(100_000),
|
|
622
|
+
leftKey: z.string().min(1).describe("dotted path to the join key on the left"),
|
|
623
|
+
rightKey: z
|
|
624
|
+
.string()
|
|
625
|
+
.min(1)
|
|
626
|
+
.optional()
|
|
627
|
+
.describe("dotted path on the right; defaults to leftKey"),
|
|
628
|
+
kind: z.enum(["inner", "left", "right", "full"]).optional().describe("default inner"),
|
|
629
|
+
rightPrefix: z
|
|
630
|
+
.string()
|
|
631
|
+
.optional()
|
|
632
|
+
.describe("prefix for right-hand fields that collide with a left one; default right_"),
|
|
633
|
+
maxRows: z.number().int().positive().max(200_000).optional(),
|
|
634
|
+
}),
|
|
635
|
+
readOnly: true,
|
|
636
|
+
concurrencySafe: true,
|
|
637
|
+
execute: async (input) => {
|
|
638
|
+
const result = joinRecords(input.left, input.right, {
|
|
639
|
+
kind: (input.kind ?? "inner"),
|
|
640
|
+
leftKey: input.leftKey,
|
|
641
|
+
rightKey: input.rightKey ?? input.leftKey,
|
|
642
|
+
rightPrefix: input.rightPrefix ?? "right_",
|
|
643
|
+
maxRows: input.maxRows ?? 10_000,
|
|
644
|
+
});
|
|
645
|
+
return json({
|
|
646
|
+
rowCount: result.rows.length,
|
|
647
|
+
truncated: result.truncated,
|
|
648
|
+
unmatchedLeft: result.unmatchedLeft,
|
|
649
|
+
unmatchedRight: result.unmatchedRight,
|
|
650
|
+
rows: result.rows,
|
|
651
|
+
});
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
export const recordsToColumns = buildTool({
|
|
655
|
+
name: "RecordsToColumns",
|
|
656
|
+
description: "Turn an array of records into a column-oriented object, one array per field, with missing values filled as null. Use to feed a plotting or statistics step that wants columns, or to shrink a repetitive payload before it goes into context.",
|
|
657
|
+
inputSchema: z.object({
|
|
658
|
+
records: z.array(z.record(z.unknown())).max(200_000),
|
|
659
|
+
columns: z.array(z.string()).optional().describe("restrict and order the output columns"),
|
|
660
|
+
}),
|
|
661
|
+
readOnly: true,
|
|
662
|
+
concurrencySafe: true,
|
|
663
|
+
execute: async (input) => {
|
|
664
|
+
const all = recordsToColumnsFn(input.records);
|
|
665
|
+
if (input.columns === undefined || input.columns.length === 0) {
|
|
666
|
+
return json({ rowCount: input.records.length, columns: all });
|
|
667
|
+
}
|
|
668
|
+
const picked = {};
|
|
669
|
+
for (const c of input.columns)
|
|
670
|
+
picked[c] = all[c] ?? input.records.map(() => null);
|
|
671
|
+
return json({ rowCount: input.records.length, columns: picked });
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
export const columnsToRecords = buildTool({
|
|
675
|
+
name: "ColumnsToRecords",
|
|
676
|
+
description: "Turn a column-oriented object back into an array of records, padding short columns with null and naming any that were short. Use to convert a columnar API response into the row shape every other table tool here expects.",
|
|
677
|
+
inputSchema: z.object({
|
|
678
|
+
columns: z.record(z.array(z.unknown())).describe("field name to array of values"),
|
|
679
|
+
}),
|
|
680
|
+
readOnly: true,
|
|
681
|
+
concurrencySafe: true,
|
|
682
|
+
execute: async (input) => {
|
|
683
|
+
const result = columnsToRecordsFn(input.columns);
|
|
684
|
+
return json({
|
|
685
|
+
rowCount: result.records.length,
|
|
686
|
+
...(result.ragged.length > 0 ? { paddedColumns: result.ragged } : {}),
|
|
687
|
+
records: result.records,
|
|
688
|
+
});
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
export const flattenObject = buildTool({
|
|
692
|
+
name: "FlattenObject",
|
|
693
|
+
description: "Flatten a nested JSON value into a single-level object keyed by dotted paths, optionally expanding array elements by index. Use to make a deep config greppable, comparable line by line, or writable as a flat CSV row.",
|
|
694
|
+
inputSchema: z.object({
|
|
695
|
+
json: z.string().describe("the value to flatten, as JSON text"),
|
|
696
|
+
separator: z.string().min(1).max(4).optional().describe("default ."),
|
|
697
|
+
expandArrays: z.boolean().optional().describe("index into arrays too; default true"),
|
|
698
|
+
maxDepth: z.number().int().positive().max(64).optional(),
|
|
699
|
+
}),
|
|
700
|
+
readOnly: true,
|
|
701
|
+
concurrencySafe: true,
|
|
702
|
+
execute: async (input) => {
|
|
703
|
+
const doc = readJson(input.json, "json");
|
|
704
|
+
if (!doc.ok)
|
|
705
|
+
return doc.error;
|
|
706
|
+
const flat = flattenObjectFn(doc.value, input.separator ?? ".", input.expandArrays ?? true, input.maxDepth ?? 32);
|
|
707
|
+
return json(flat);
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
export const unflattenObject = buildTool({
|
|
711
|
+
name: "UnflattenObject",
|
|
712
|
+
description: "Rebuild a nested JSON value from an object of dotted-path keys, turning a contiguous run of numeric keys back into an array. Use to reverse FlattenObject, or to build a config object from flat environment-style settings.",
|
|
713
|
+
inputSchema: z.object({
|
|
714
|
+
flat: z.record(z.unknown()).describe("dotted key to value"),
|
|
715
|
+
separator: z.string().min(1).max(4).optional().describe("default ."),
|
|
716
|
+
arraysFromNumericKeys: z
|
|
717
|
+
.boolean()
|
|
718
|
+
.optional()
|
|
719
|
+
.describe("rebuild arrays where numeric keys run 0..n-1; default true"),
|
|
720
|
+
indent: z.number().int().min(0).max(8).optional(),
|
|
721
|
+
}),
|
|
722
|
+
readOnly: true,
|
|
723
|
+
concurrencySafe: true,
|
|
724
|
+
execute: async (input) => {
|
|
725
|
+
const value = unflattenObjectFn(input.flat, input.separator ?? ".", input.arraysFromNumericKeys ?? true);
|
|
726
|
+
return JSON.stringify(value, null, input.indent ?? 0);
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
export const jsonlParse = buildTool({
|
|
730
|
+
name: "JsonlParse",
|
|
731
|
+
description: "Parse line-delimited JSON into an array, tolerating blank lines, CRLF and a trailing newline, and reporting the line number and message for any record that does not parse. Use to read a log or dataset file without one malformed line silently costing you records.",
|
|
732
|
+
inputSchema: z.object({
|
|
733
|
+
text: z.string().describe("the JSONL document"),
|
|
734
|
+
maxRecords: z.number().int().positive().max(200_000).optional(),
|
|
735
|
+
stopOnError: z
|
|
736
|
+
.boolean()
|
|
737
|
+
.optional()
|
|
738
|
+
.describe("stop at the first bad line rather than skipping it"),
|
|
739
|
+
withLineNumbers: z
|
|
740
|
+
.boolean()
|
|
741
|
+
.optional()
|
|
742
|
+
.describe("wrap each record as {line, value} so a bad row can be traced back"),
|
|
743
|
+
}),
|
|
744
|
+
readOnly: true,
|
|
745
|
+
concurrencySafe: true,
|
|
746
|
+
execute: async (input) => {
|
|
747
|
+
const size = tooLarge(input.text, "text");
|
|
748
|
+
if (size !== null)
|
|
749
|
+
return size;
|
|
750
|
+
const result = parseJsonl(input.text, input.maxRecords ?? 50_000, input.stopOnError ?? false);
|
|
751
|
+
return json({
|
|
752
|
+
count: result.records.length,
|
|
753
|
+
truncated: result.truncated,
|
|
754
|
+
...(result.failures.length > 0 ? { failures: result.failures.slice(0, 50) } : {}),
|
|
755
|
+
records: input.withLineNumbers === true ? result.records : result.records.map((r) => r.value),
|
|
756
|
+
});
|
|
757
|
+
},
|
|
758
|
+
});
|
|
759
|
+
export const jsonlWrite = buildTool({
|
|
760
|
+
name: "JsonlWrite",
|
|
761
|
+
description: "Serialize an array of values as line-delimited JSON, one compact record per line. Use to append to a dataset, an audit file or an eval fixture in the format the rest of the toolchain reads.",
|
|
762
|
+
inputSchema: z.object({
|
|
763
|
+
values: z.array(z.unknown()).max(200_000).describe("one value per output line"),
|
|
764
|
+
trailingNewline: z
|
|
765
|
+
.boolean()
|
|
766
|
+
.optional()
|
|
767
|
+
.describe("end the document with a newline; default true"),
|
|
768
|
+
}),
|
|
769
|
+
readOnly: true,
|
|
770
|
+
concurrencySafe: true,
|
|
771
|
+
execute: async (input) => {
|
|
772
|
+
const result = writeJsonl(input.values, input.trailingNewline ?? true);
|
|
773
|
+
return result.text;
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
export const xmlParse = buildTool({
|
|
777
|
+
name: "XmlParse",
|
|
778
|
+
description: "Parse an XML document or HTML fragment into a JSON tree of elements, attributes, text and CDATA, in either an explicit or a compact shape. Use to read a feed, an SVG or a config file with a real parser instead of a regex — but read the README, because this handles well-formed markup, not the whole XML specification.",
|
|
779
|
+
inputSchema: z.object({
|
|
780
|
+
text: z.string().describe("the XML or HTML fragment"),
|
|
781
|
+
mode: z
|
|
782
|
+
.enum(["xml", "html"])
|
|
783
|
+
.optional()
|
|
784
|
+
.describe("html tolerates void elements, bare attributes and mismatched close tags"),
|
|
785
|
+
shape: z
|
|
786
|
+
.enum(["compact", "tree"])
|
|
787
|
+
.optional()
|
|
788
|
+
.describe("compact puts attributes under @name and text under #text; default compact"),
|
|
789
|
+
trimWhitespace: z
|
|
790
|
+
.boolean()
|
|
791
|
+
.optional()
|
|
792
|
+
.describe("drop whitespace-only text nodes; default true"),
|
|
793
|
+
maxDepth: z.number().int().positive().max(256).optional(),
|
|
794
|
+
}),
|
|
795
|
+
readOnly: true,
|
|
796
|
+
concurrencySafe: true,
|
|
797
|
+
execute: async (input) => {
|
|
798
|
+
const size = tooLarge(input.text, "text");
|
|
799
|
+
if (size !== null)
|
|
800
|
+
return size;
|
|
801
|
+
try {
|
|
802
|
+
const nodes = parseXml(input.text, {
|
|
803
|
+
mode: input.mode ?? "xml",
|
|
804
|
+
trimWhitespace: input.trimWhitespace ?? true,
|
|
805
|
+
maxDepth: input.maxDepth ?? 128,
|
|
806
|
+
});
|
|
807
|
+
return json(input.shape === "tree" ? nodes : toCompact(nodes));
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
if (err instanceof XmlError)
|
|
811
|
+
return `invalid markup on line ${err.line}: ${err.message}`;
|
|
812
|
+
throw err;
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
export const sortRecords = buildTool({
|
|
817
|
+
name: "SortRecords",
|
|
818
|
+
description: "Sort an array of records by one or more dotted field paths, with per-key direction, case folding and null placement. The order is stable and total, so equal rows keep their original order and mixed types never sort arbitrarily. Use to put a result set in a defined order before paging, diffing or displaying it.",
|
|
819
|
+
inputSchema: z.object({
|
|
820
|
+
records: z.array(z.unknown()).max(200_000),
|
|
821
|
+
keys: z
|
|
822
|
+
.array(z.object({
|
|
823
|
+
field: z.string().describe("a dotted path; the empty string sorts by the value itself"),
|
|
824
|
+
direction: z.enum(["asc", "desc"]).optional(),
|
|
825
|
+
caseInsensitive: z.boolean().optional(),
|
|
826
|
+
nulls: z.enum(["first", "last"]).optional(),
|
|
827
|
+
}))
|
|
828
|
+
.min(1)
|
|
829
|
+
.max(8),
|
|
830
|
+
}),
|
|
831
|
+
readOnly: true,
|
|
832
|
+
concurrencySafe: true,
|
|
833
|
+
execute: async (input) => json(sortRecordsFn(input.records, input.keys)),
|
|
834
|
+
});
|
|
835
|
+
export const dedupeRecords = buildTool({
|
|
836
|
+
name: "DedupeRecords",
|
|
837
|
+
description: "Remove duplicate records, either by one or more key fields or by the whole value in canonical key-sorted form, keeping the first or the last occurrence. Use to clean a merged result set before counting it, so a row that arrived twice is not counted twice.",
|
|
838
|
+
inputSchema: z.object({
|
|
839
|
+
records: z.array(z.unknown()).max(200_000),
|
|
840
|
+
keyFields: z
|
|
841
|
+
.array(z.string())
|
|
842
|
+
.optional()
|
|
843
|
+
.describe("dotted paths forming the identity; omit to compare whole values"),
|
|
844
|
+
keep: z.enum(["first", "last"]).optional().describe("default first"),
|
|
845
|
+
countOnly: z.boolean().optional(),
|
|
846
|
+
}),
|
|
847
|
+
readOnly: true,
|
|
848
|
+
concurrencySafe: true,
|
|
849
|
+
execute: async (input) => {
|
|
850
|
+
const result = dedupeRecordsFn(input.records, input.keyFields ?? [], input.keep ?? "first");
|
|
851
|
+
if (input.countOnly === true) {
|
|
852
|
+
return json({ kept: result.records.length, removed: result.removed });
|
|
853
|
+
}
|
|
854
|
+
return json({ kept: result.records.length, removed: result.removed, records: result.records });
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
export const sampleRecords = buildTool({
|
|
858
|
+
name: "SampleRecords",
|
|
859
|
+
description: "Take a deterministic sample of an array: the head, the tail, every nth element, or a set spread evenly across the whole range. There is no random mode, so the same input always yields the same sample. Use to show a model a representative slice of a large dataset at a bounded cost.",
|
|
860
|
+
inputSchema: z.object({
|
|
861
|
+
records: z.array(z.unknown()).max(500_000),
|
|
862
|
+
mode: z.enum(["head", "tail", "everyNth", "evenly"]).optional().describe("default head"),
|
|
863
|
+
count: z.number().int().positive().max(10_000).optional().describe("default 10"),
|
|
864
|
+
step: z.number().int().positive().max(100_000).optional().describe("for everyNth; default 10"),
|
|
865
|
+
}),
|
|
866
|
+
readOnly: true,
|
|
867
|
+
concurrencySafe: true,
|
|
868
|
+
execute: async (input) => {
|
|
869
|
+
const sample = sampleRecordsFn(input.records, input.mode ?? "head", input.count ?? 10, input.step ?? 10);
|
|
870
|
+
return json({ total: input.records.length, returned: sample.length, records: sample });
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
export const dataShape = buildTool({
|
|
874
|
+
name: "DataShape",
|
|
875
|
+
description: "Describe the structure of a JSON value: every field's dotted path, the types seen there, how often it is present and populated, and a few example values. Use to learn what an unfamiliar API response or dataset actually contains before writing a query against it.",
|
|
876
|
+
inputSchema: z.object({
|
|
877
|
+
json: z.string().describe("the value to describe, as JSON text"),
|
|
878
|
+
maxFields: z.number().int().positive().max(2000).optional(),
|
|
879
|
+
examples: z
|
|
880
|
+
.number()
|
|
881
|
+
.int()
|
|
882
|
+
.min(0)
|
|
883
|
+
.max(5)
|
|
884
|
+
.optional()
|
|
885
|
+
.describe("example values per field; default 2"),
|
|
886
|
+
}),
|
|
887
|
+
readOnly: true,
|
|
888
|
+
concurrencySafe: true,
|
|
889
|
+
execute: async (input) => {
|
|
890
|
+
const doc = readJson(input.json, "json");
|
|
891
|
+
if (!doc.ok)
|
|
892
|
+
return doc.error;
|
|
893
|
+
const root = doc.value;
|
|
894
|
+
const rows = Array.isArray(root) ? root : [root];
|
|
895
|
+
const fields = new Map();
|
|
896
|
+
const exampleCount = input.examples ?? 2;
|
|
897
|
+
const maxFields = input.maxFields ?? 200;
|
|
898
|
+
let truncated = false;
|
|
899
|
+
for (const row of rows) {
|
|
900
|
+
const flat = flattenObjectFn(row, ".", false, 12);
|
|
901
|
+
for (const key of Object.keys(flat)) {
|
|
902
|
+
const value = flat[key];
|
|
903
|
+
let entry = fields.get(key);
|
|
904
|
+
if (entry === undefined) {
|
|
905
|
+
if (fields.size >= maxFields) {
|
|
906
|
+
truncated = true;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
entry = { types: new Set(), present: 0, nonNull: 0, examples: [] };
|
|
910
|
+
fields.set(key, entry);
|
|
911
|
+
}
|
|
912
|
+
entry.present += 1;
|
|
913
|
+
entry.types.add(value === null ? "null" : Array.isArray(value) ? "array" : typeof value);
|
|
914
|
+
if (value !== null)
|
|
915
|
+
entry.nonNull += 1;
|
|
916
|
+
if (entry.examples.length < exampleCount &&
|
|
917
|
+
value !== null &&
|
|
918
|
+
!entry.examples.some((e) => e === value)) {
|
|
919
|
+
entry.examples.push(value);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return json({
|
|
924
|
+
rootType: Array.isArray(root) ? "array" : root === null ? "null" : typeof root,
|
|
925
|
+
rowCount: rows.length,
|
|
926
|
+
fieldCount: fields.size,
|
|
927
|
+
truncated,
|
|
928
|
+
fields: [...fields.entries()].map(([path, e]) => ({
|
|
929
|
+
path,
|
|
930
|
+
types: [...e.types].sort(),
|
|
931
|
+
present: e.present,
|
|
932
|
+
nonNull: e.nonNull,
|
|
933
|
+
...(e.examples.length > 0 ? { examples: e.examples } : {}),
|
|
934
|
+
})),
|
|
935
|
+
});
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
export const jsonSortKeys = buildTool({
|
|
939
|
+
name: "JsonSortKeys",
|
|
940
|
+
description: "Return a JSON value with every object's keys sorted at every depth, leaving array order untouched. Use before storing or committing generated JSON so that re-running the generator produces no spurious diff.",
|
|
941
|
+
inputSchema: z.object({
|
|
942
|
+
json: z.string().describe("the document, as JSON text"),
|
|
943
|
+
indent: z.number().int().min(0).max(8).optional().describe("default 2"),
|
|
944
|
+
}),
|
|
945
|
+
readOnly: true,
|
|
946
|
+
concurrencySafe: true,
|
|
947
|
+
execute: async (input) => {
|
|
948
|
+
const doc = readJson(input.json, "json");
|
|
949
|
+
if (!doc.ok)
|
|
950
|
+
return doc.error;
|
|
951
|
+
return JSON.stringify(sortKeysDeep(doc.value), null, input.indent ?? 2) ?? "null";
|
|
952
|
+
},
|
|
953
|
+
});
|
|
954
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
955
|
+
export const DATA_TOOLS = Object.freeze([
|
|
956
|
+
columnsToRecords,
|
|
957
|
+
csvParse,
|
|
958
|
+
csvWrite,
|
|
959
|
+
dataConvert,
|
|
960
|
+
dataDiff,
|
|
961
|
+
dataShape,
|
|
962
|
+
dedupeRecords,
|
|
963
|
+
flattenObject,
|
|
964
|
+
jsonFormat,
|
|
965
|
+
jsonMergePatch,
|
|
966
|
+
jsonPatch,
|
|
967
|
+
jsonQuery,
|
|
968
|
+
jsonSortKeys,
|
|
969
|
+
jsonlParse,
|
|
970
|
+
jsonlWrite,
|
|
971
|
+
recordsToColumns,
|
|
972
|
+
sampleRecords,
|
|
973
|
+
sortRecords,
|
|
974
|
+
tableAggregate,
|
|
975
|
+
tableJoin,
|
|
976
|
+
tableQuery,
|
|
977
|
+
unflattenObject,
|
|
978
|
+
xmlParse,
|
|
979
|
+
]);
|
|
980
|
+
/**
|
|
981
|
+
* The CSV and record helpers, re-exported for other tool packages.
|
|
982
|
+
*
|
|
983
|
+
* `@crewhaus/tool-table` profiles and reconciles the same files this package
|
|
984
|
+
* parses, and two CSV readers in one repository would disagree about
|
|
985
|
+
* quoting, embedded newlines and what counts as a null token — on the same
|
|
986
|
+
* file, in different tools, which is the worst way to disagree.
|
|
987
|
+
*/
|
|
988
|
+
export { inferScalar, normalizeHeader, parseCsv, rowsToRecords, unionKeys, } from "./lib/csv";
|