@crewhaus/tool-data 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +38 -1
- package/dist/index.js +280 -63
- package/dist/lib/csv.d.ts +27 -2
- package/dist/lib/csv.js +77 -14
- package/dist/lib/diff.js +3 -3
- package/dist/lib/json.d.ts +51 -0
- package/dist/lib/json.js +174 -2
- package/dist/lib/jsonl.d.ts +1 -10
- package/dist/lib/jsonl.js +15 -1
- package/dist/lib/jsonpath.d.ts +38 -2
- package/dist/lib/jsonpath.js +166 -53
- package/dist/lib/own-record.d.ts +22 -0
- package/dist/lib/own-record.js +66 -0
- package/dist/lib/patch.d.ts +18 -2
- package/dist/lib/patch.js +72 -21
- package/dist/lib/table.d.ts +45 -8
- package/dist/lib/table.js +164 -59
- package/dist/lib/toml.d.ts +8 -1
- package/dist/lib/toml.js +77 -48
- package/dist/lib/xml.js +23 -10
- package/dist/lib/yaml.d.ts +19 -2
- package/dist/lib/yaml.js +88 -42
- package/package.json +11 -6
package/dist/index.js
CHANGED
|
@@ -19,19 +19,19 @@
|
|
|
19
19
|
* somebody's context window.
|
|
20
20
|
*/
|
|
21
21
|
import { buildTool } from "@crewhaus/tool-builder";
|
|
22
|
+
import { RegexAnswers, regexRunContext, withRegexAnswers } from "@crewhaus/tool-schema";
|
|
22
23
|
import { z } from "zod";
|
|
23
|
-
import { CsvError, cellToString, normalizeHeader, parseCsv, rowsToRecords, unionKeys, writeCsvRows, } from "./lib/csv";
|
|
24
|
+
import { CsvError, cellToString, csvDialectError, normalizeHeader, parseCsv, rowsToRecords, unionKeys, writeCsvRows, } from "./lib/csv";
|
|
24
25
|
import { deepDiff } from "./lib/diff";
|
|
25
|
-
import { canonicalStringify, isPlainObject, parseJson, sortKeysDeep, stableHash } from "./lib/json";
|
|
26
|
+
import { MAX_NESTING_DEPTH, OutputLimitError, canonicalStringify, getOwn, isPlainObject, jsonTextLength, jsonTextNestsDeeper, nestingDepthExceeds, parseJson, setOwn, sortKeysDeep, stableHash, } from "./lib/json";
|
|
26
27
|
import { parseJsonl, writeJsonl } from "./lib/jsonl";
|
|
27
28
|
import { PathError, parsePath, queryPath } from "./lib/jsonpath";
|
|
29
|
+
import { ownRecord } from "./lib/own-record";
|
|
28
30
|
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 { PredicateError, aggregate, askPredicatePatterns, 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";
|
|
32
|
+
import { TomlError, TomlTooDeepError, parseToml, stringifyToml } from "./lib/toml";
|
|
31
33
|
import { XmlError, parseXml, toCompact } from "./lib/xml";
|
|
32
34
|
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
35
|
/**
|
|
36
36
|
* Guard for every tool that accepts a whole document. Keeps one pathological
|
|
37
37
|
* input from filling a context window or exhausting memory. Callers hitting
|
|
@@ -43,6 +43,39 @@ function tooLarge(text, field) {
|
|
|
43
43
|
return null;
|
|
44
44
|
return `${field} is ${text.length} characters, over the ${MAX_INPUT_CHARS} limit — narrow the input first`;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The most characters one result may be: four times the input limit, so a
|
|
48
|
+
* document at the input limit can still be pretty-printed. A result is
|
|
49
|
+
* measured before it is built and refused past this, because its size is
|
|
50
|
+
* not the input's: indentation writes depth x indent on every line, a
|
|
51
|
+
* record set repeats every key once per row, and a join repeats a matched
|
|
52
|
+
* row once per partner.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_OUTPUT_CHARS = 4 * MAX_INPUT_CHARS;
|
|
55
|
+
/** The refusal for a result over MAX_OUTPUT_CHARS. Nothing was built. */
|
|
56
|
+
function outputRefusal(what, hint) {
|
|
57
|
+
return `${what} would be more than ${MAX_OUTPUT_CHARS} characters, the most one result may be, so nothing was built — ${hint}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Compact JSON — no indentation, since the reader is a model, not a person
|
|
61
|
+
* — measured first and refused past MAX_OUTPUT_CHARS.
|
|
62
|
+
*/
|
|
63
|
+
const json = (value) => jsonTextLength(value, 0, MAX_OUTPUT_CHARS) > MAX_OUTPUT_CHARS
|
|
64
|
+
? outputRefusal("the result", "narrow the input or ask for less")
|
|
65
|
+
: JSON.stringify(value);
|
|
66
|
+
/** JSON.stringify with an indent, measured first and refused past MAX_OUTPUT_CHARS. */
|
|
67
|
+
function pretty(value, indent, fallback) {
|
|
68
|
+
if (jsonTextLength(value, indent, MAX_OUTPUT_CHARS) > MAX_OUTPUT_CHARS) {
|
|
69
|
+
return outputRefusal(`the document at indent ${indent}`, "use indent 0, or select part of it first (a pointer or JsonQuery)");
|
|
70
|
+
}
|
|
71
|
+
return JSON.stringify(value, null, indent) ?? fallback;
|
|
72
|
+
}
|
|
73
|
+
/** The refusal a writer's OutputLimitError becomes. */
|
|
74
|
+
function writerRefusal(err) {
|
|
75
|
+
return outputRefusal(err.message.replace(/ would be more than \d+ characters$/, ""), "narrow the input first");
|
|
76
|
+
}
|
|
77
|
+
/** The refusal for a document nested past MAX_NESTING_DEPTH. */
|
|
78
|
+
const tooDeep = (field) => `${field} nests deeper than ${MAX_NESTING_DEPTH} levels — narrow the input first`;
|
|
46
79
|
/**
|
|
47
80
|
* Read a document in any of the formats this package understands. Returns a
|
|
48
81
|
* readable message rather than throwing, because a malformed file is a
|
|
@@ -52,16 +85,27 @@ function readDocument(text, format, csv) {
|
|
|
52
85
|
try {
|
|
53
86
|
switch (format) {
|
|
54
87
|
case "json": {
|
|
88
|
+
// Before parsing, so a hostile document never becomes a deep tree.
|
|
89
|
+
if (jsonTextNestsDeeper(text, MAX_NESTING_DEPTH))
|
|
90
|
+
return { ok: false, error: tooDeep("json") };
|
|
55
91
|
const parsed = parseJson(text);
|
|
56
92
|
return parsed.ok
|
|
57
93
|
? { ok: true, value: parsed.value }
|
|
58
94
|
: { ok: false, error: `invalid JSON: ${parsed.error}` };
|
|
59
95
|
}
|
|
60
96
|
case "yaml":
|
|
97
|
+
// The YAML reader caps its own nesting, at 64.
|
|
61
98
|
return { ok: true, value: parseYaml(text) };
|
|
62
|
-
case "toml":
|
|
63
|
-
|
|
99
|
+
case "toml": {
|
|
100
|
+
// Dotted keys, arrays and inline tables can all nest, and TOML has no
|
|
101
|
+
// cheap pre-scan, so the parsed value is checked.
|
|
102
|
+
const value = parseToml(text);
|
|
103
|
+
if (nestingDepthExceeds(value, MAX_NESTING_DEPTH))
|
|
104
|
+
return { ok: false, error: tooDeep("toml") };
|
|
105
|
+
return { ok: true, value };
|
|
106
|
+
}
|
|
64
107
|
case "jsonl": {
|
|
108
|
+
// parseJsonl refuses a line nested past the cap, before parsing it.
|
|
65
109
|
const out = parseJsonl(text, 100_000, true);
|
|
66
110
|
if (out.failures.length > 0) {
|
|
67
111
|
const first = out.failures[0];
|
|
@@ -91,10 +135,17 @@ function readDocument(text, format, csv) {
|
|
|
91
135
|
catch (err) {
|
|
92
136
|
if (err instanceof YamlError)
|
|
93
137
|
return { ok: false, error: `invalid YAML on line ${err.line}: ${err.message}` };
|
|
138
|
+
if (err instanceof TomlTooDeepError)
|
|
139
|
+
return { ok: false, error: tooDeep("toml") };
|
|
94
140
|
if (err instanceof TomlError)
|
|
95
141
|
return { ok: false, error: `invalid TOML on line ${err.line}: ${err.message}` };
|
|
96
142
|
if (err instanceof CsvError)
|
|
97
143
|
return { ok: false, error: `invalid CSV on line ${err.line}: ${err.message}` };
|
|
144
|
+
// A parser that recursed past the engine's stack on a pathological
|
|
145
|
+
// document: the same answer as the depth check, not an engine message.
|
|
146
|
+
if (err instanceof RangeError && /call stack/i.test(err.message)) {
|
|
147
|
+
return { ok: false, error: tooDeep(format) };
|
|
148
|
+
}
|
|
98
149
|
return { ok: false, error: err.message };
|
|
99
150
|
}
|
|
100
151
|
}
|
|
@@ -103,12 +154,26 @@ function readJson(text, field) {
|
|
|
103
154
|
const size = tooLarge(text, field);
|
|
104
155
|
if (size !== null)
|
|
105
156
|
return { ok: false, error: size };
|
|
157
|
+
if (jsonTextNestsDeeper(text, MAX_NESTING_DEPTH))
|
|
158
|
+
return { ok: false, error: tooDeep(field) };
|
|
106
159
|
const parsed = parseJson(text);
|
|
107
160
|
return parsed.ok
|
|
108
161
|
? { ok: true, value: parsed.value }
|
|
109
162
|
: { ok: false, error: `${field} is not valid JSON: ${parsed.error}` };
|
|
110
163
|
}
|
|
111
164
|
const FORMATS = ["json", "yaml", "toml", "csv", "jsonl"];
|
|
165
|
+
/**
|
|
166
|
+
* The refusal for a CSV dialect the reader or writer cannot honour, or null.
|
|
167
|
+
* Checked before any work, so a bad delimiter is a plain sentence naming the
|
|
168
|
+
* choice, never a file that cannot be read back or a parse that silently
|
|
169
|
+
* turns every value into a column name.
|
|
170
|
+
*/
|
|
171
|
+
function csvDialectRefusal(delimiter, quote) {
|
|
172
|
+
const why = csvDialectError(delimiter, quote);
|
|
173
|
+
return why === null
|
|
174
|
+
? null
|
|
175
|
+
: `cannot use delimiter ${JSON.stringify(delimiter)} with quote ${JSON.stringify(quote)}: ${why}`;
|
|
176
|
+
}
|
|
112
177
|
// ---------------------------------------------------------------------------
|
|
113
178
|
export const jsonQuery = buildTool({
|
|
114
179
|
name: "JsonQuery",
|
|
@@ -124,21 +189,59 @@ export const jsonQuery = buildTool({
|
|
|
124
189
|
}),
|
|
125
190
|
readOnly: true,
|
|
126
191
|
concurrencySafe: true,
|
|
127
|
-
execute: async (input) => {
|
|
192
|
+
execute: async (input, ctx) => {
|
|
128
193
|
const doc = readJson(input.json, "json");
|
|
129
194
|
if (!doc.ok)
|
|
130
195
|
return doc.error;
|
|
131
196
|
const limit = input.maxResults ?? 200;
|
|
132
197
|
try {
|
|
133
198
|
const steps = parsePath(input.path);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
199
|
+
// A `=~` filter's pattern is answered in the regex worker, never run on
|
|
200
|
+
// this thread (C073): the query runs once to learn what each filter
|
|
201
|
+
// asks, and again with the answers (a filter under a filter takes one
|
|
202
|
+
// more round).
|
|
203
|
+
const run = await withRegexAnswers((regex) => queryPath(doc.value, steps, limit, undefined, regex), regexRunContext(ctx));
|
|
204
|
+
if ("undetermined" in run)
|
|
205
|
+
return `could not finish the query: ${run.undetermined}`;
|
|
206
|
+
const result = run.value;
|
|
207
|
+
// Built by hand, one match at a time, so the result stops at the output
|
|
208
|
+
// limit instead of being built whole and refused: `$..*` over a deep
|
|
209
|
+
// document returns each subtree once per ancestor.
|
|
210
|
+
const parts = [];
|
|
211
|
+
let chars = 0;
|
|
212
|
+
let cut = false;
|
|
213
|
+
for (const m of result.matches) {
|
|
214
|
+
const item = input.valuesOnly === true ? m.value : m;
|
|
215
|
+
const size = jsonTextLength(item, 0, MAX_OUTPUT_CHARS - chars);
|
|
216
|
+
if (chars + size + 1 > MAX_OUTPUT_CHARS) {
|
|
217
|
+
cut = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
parts.push(JSON.stringify(item) ?? "null");
|
|
221
|
+
chars += size + 1;
|
|
222
|
+
}
|
|
223
|
+
const truncatedBy = result.stoppedAtVisits !== undefined
|
|
224
|
+
? "visits"
|
|
225
|
+
: cut
|
|
226
|
+
? "outputChars"
|
|
227
|
+
: result.truncated
|
|
228
|
+
? "maxResults"
|
|
229
|
+
: null;
|
|
230
|
+
// Nodes a filter could not answer on may be matches: not a complete list.
|
|
231
|
+
const head = `{"count":${parts.length},"truncated":${truncatedBy !== null || result.undetermined !== undefined}`;
|
|
232
|
+
const why = truncatedBy === null
|
|
233
|
+
? ""
|
|
234
|
+
: `,"truncatedBy":${JSON.stringify(truncatedBy)}${truncatedBy === "visits"
|
|
235
|
+
? `,"note":${JSON.stringify(`the query stopped after visiting ${result.stoppedAtVisits} nodes; these are the first matches in document order — narrow the path`)}`
|
|
236
|
+
: ""}`;
|
|
237
|
+
const key = input.valuesOnly === true ? "values" : "matches";
|
|
238
|
+
const open = result.undetermined === undefined
|
|
239
|
+
? ""
|
|
240
|
+
: `,"undetermined":${JSON.stringify({
|
|
241
|
+
...result.undetermined,
|
|
242
|
+
reason: "a =~ filter could not be run to an answer on these nodes (the deadline, or the regex engine giving up), so they are neither matched nor ruled out",
|
|
243
|
+
})}`;
|
|
244
|
+
return `${head}${why}${open},"${key}":[${parts.join(",")}]}`;
|
|
142
245
|
}
|
|
143
246
|
catch (err) {
|
|
144
247
|
if (err instanceof PathError)
|
|
@@ -176,10 +279,13 @@ export const jsonPatch = buildTool({
|
|
|
176
279
|
if ((op.op === "add" || op.op === "replace" || op.op === "test") && !("value" in op)) {
|
|
177
280
|
return `operation ${i} (${op.op}) needs a "value"`;
|
|
178
281
|
}
|
|
282
|
+
if (nestingDepthExceeds(op.value, MAX_NESTING_DEPTH)) {
|
|
283
|
+
return `operation ${i} (${op.op}): its value nests deeper than ${MAX_NESTING_DEPTH} levels`;
|
|
284
|
+
}
|
|
179
285
|
}
|
|
180
286
|
try {
|
|
181
287
|
const patched = applyJsonPatch(doc.value, input.patch);
|
|
182
|
-
return
|
|
288
|
+
return pretty(patched, input.indent ?? 0, "null");
|
|
183
289
|
}
|
|
184
290
|
catch (err) {
|
|
185
291
|
if (err instanceof PatchError) {
|
|
@@ -215,14 +321,14 @@ export const jsonMergePatch = buildTool({
|
|
|
215
321
|
const other = readJson(input.against, "against");
|
|
216
322
|
if (!other.ok)
|
|
217
323
|
return other.error;
|
|
218
|
-
return
|
|
324
|
+
return pretty(diffMergePatch(doc.value, other.value), indent, "null");
|
|
219
325
|
}
|
|
220
326
|
if (input.patch === undefined)
|
|
221
327
|
return "supply either patch (to apply) or against (to derive)";
|
|
222
328
|
const patch = readJson(input.patch, "patch");
|
|
223
329
|
if (!patch.ok)
|
|
224
330
|
return patch.error;
|
|
225
|
-
return
|
|
331
|
+
return pretty(applyMergePatch(doc.value, patch.value), indent, "null");
|
|
226
332
|
},
|
|
227
333
|
});
|
|
228
334
|
export const jsonFormat = buildTool({
|
|
@@ -255,6 +361,10 @@ export const jsonFormat = buildTool({
|
|
|
255
361
|
value = found.value;
|
|
256
362
|
}
|
|
257
363
|
const indent = input.indent ?? 2;
|
|
364
|
+
// Sorting keys does not change the length, so one measurement serves both.
|
|
365
|
+
if (jsonTextLength(value, indent, MAX_OUTPUT_CHARS) > MAX_OUTPUT_CHARS) {
|
|
366
|
+
return outputRefusal(`the document at indent ${indent}`, "use indent 0, or format part of it with pointer");
|
|
367
|
+
}
|
|
258
368
|
const text = input.sortKeys === true
|
|
259
369
|
? canonicalStringify(value, indent)
|
|
260
370
|
: JSON.stringify(value, null, indent);
|
|
@@ -311,7 +421,11 @@ export const dataConvert = buildTool({
|
|
|
311
421
|
from: z.enum(FORMATS),
|
|
312
422
|
to: z.enum(FORMATS),
|
|
313
423
|
indent: z.number().int().min(0).max(8).optional().describe("for JSON output"),
|
|
314
|
-
delimiter: z
|
|
424
|
+
delimiter: z
|
|
425
|
+
.string()
|
|
426
|
+
.length(1)
|
|
427
|
+
.optional()
|
|
428
|
+
.describe("for CSV, on either side; default , — not a line break or a double quote"),
|
|
315
429
|
header: z
|
|
316
430
|
.boolean()
|
|
317
431
|
.optional()
|
|
@@ -328,6 +442,11 @@ export const dataConvert = buildTool({
|
|
|
328
442
|
if (size !== null)
|
|
329
443
|
return size;
|
|
330
444
|
const delimiter = input.delimiter ?? ",";
|
|
445
|
+
if (input.from === "csv" || input.to === "csv") {
|
|
446
|
+
const dialect = csvDialectRefusal(delimiter, '"');
|
|
447
|
+
if (dialect !== null)
|
|
448
|
+
return dialect;
|
|
449
|
+
}
|
|
331
450
|
const header = input.header ?? true;
|
|
332
451
|
const doc = readDocument(input.text, input.from, {
|
|
333
452
|
delimiter,
|
|
@@ -336,24 +455,31 @@ export const dataConvert = buildTool({
|
|
|
336
455
|
});
|
|
337
456
|
if (!doc.ok)
|
|
338
457
|
return doc.error;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
458
|
+
try {
|
|
459
|
+
return writeDocument(doc.value, input.to, {
|
|
460
|
+
indent: input.indent ?? 2,
|
|
461
|
+
delimiter,
|
|
462
|
+
header,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
if (err instanceof OutputLimitError)
|
|
467
|
+
return writerRefusal(err);
|
|
468
|
+
throw err;
|
|
469
|
+
}
|
|
344
470
|
},
|
|
345
471
|
});
|
|
346
472
|
function writeDocument(value, format, options) {
|
|
347
473
|
switch (format) {
|
|
348
474
|
case "json":
|
|
349
|
-
return
|
|
475
|
+
return pretty(value, options.indent, "null");
|
|
350
476
|
case "yaml":
|
|
351
|
-
return stringifyYaml(value);
|
|
477
|
+
return stringifyYaml(value, 0, MAX_OUTPUT_CHARS);
|
|
352
478
|
case "toml": {
|
|
353
479
|
if (!isPlainObject(value)) {
|
|
354
480
|
return "cannot write TOML: a TOML document must be an object at the top level, and this value is not";
|
|
355
481
|
}
|
|
356
|
-
const out = stringifyToml(value);
|
|
482
|
+
const out = stringifyToml(value, MAX_OUTPUT_CHARS);
|
|
357
483
|
return out.skipped.length === 0
|
|
358
484
|
? out.text
|
|
359
485
|
: `${out.text}\n\n# dropped (TOML has no null): ${out.skipped.join(", ")}`;
|
|
@@ -361,7 +487,7 @@ function writeDocument(value, format, options) {
|
|
|
361
487
|
case "jsonl": {
|
|
362
488
|
if (!Array.isArray(value))
|
|
363
489
|
return "cannot write JSONL: the value is not an array";
|
|
364
|
-
return writeJsonl(value, true).text;
|
|
490
|
+
return writeJsonl(value, true, MAX_OUTPUT_CHARS).text;
|
|
365
491
|
}
|
|
366
492
|
default: {
|
|
367
493
|
if (!Array.isArray(value))
|
|
@@ -375,6 +501,7 @@ function writeDocument(value, format, options) {
|
|
|
375
501
|
newline: "\n",
|
|
376
502
|
quoteAll: false,
|
|
377
503
|
header: null,
|
|
504
|
+
maxChars: MAX_OUTPUT_CHARS,
|
|
378
505
|
});
|
|
379
506
|
}
|
|
380
507
|
if (!value.every(isPlainObject)) {
|
|
@@ -388,8 +515,21 @@ function writeDocument(value, format, options) {
|
|
|
388
515
|
newline: "\n",
|
|
389
516
|
quoteAll: false,
|
|
390
517
|
header: options.header ? columns : null,
|
|
518
|
+
maxChars: MAX_OUTPUT_CHARS,
|
|
391
519
|
};
|
|
392
|
-
|
|
520
|
+
// Every row carries a delimiter per column, so a sparse record set (a
|
|
521
|
+
// column per distinct key) is refused on arithmetic alone; the rows are
|
|
522
|
+
// built lazily in any case, so the writer's own count stops the rest.
|
|
523
|
+
if (records.length * Math.max(columns.length - 1, 0) > MAX_OUTPUT_CHARS) {
|
|
524
|
+
return outputRefusal(`${records.length} rows of ${columns.length} columns as CSV`, "the records do not share their keys; select the fields first");
|
|
525
|
+
}
|
|
526
|
+
const rows = {
|
|
527
|
+
*[Symbol.iterator]() {
|
|
528
|
+
for (const r of records)
|
|
529
|
+
yield columns.map((c) => getOwn(r, c));
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
return writeCsvRows(rows, writeOptions);
|
|
393
533
|
}
|
|
394
534
|
}
|
|
395
535
|
}
|
|
@@ -398,8 +538,12 @@ export const csvParse = buildTool({
|
|
|
398
538
|
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
539
|
inputSchema: z.object({
|
|
400
540
|
text: z.string().describe("the CSV document"),
|
|
401
|
-
delimiter: z
|
|
402
|
-
|
|
541
|
+
delimiter: z
|
|
542
|
+
.string()
|
|
543
|
+
.length(1)
|
|
544
|
+
.optional()
|
|
545
|
+
.describe("default , — use \\t for TSV; not a line break or the quote character"),
|
|
546
|
+
quote: z.string().length(1).optional().describe('default " — not a line break'),
|
|
403
547
|
header: z.boolean().optional().describe("treat the first row as a header; default true"),
|
|
404
548
|
inferTypes: z
|
|
405
549
|
.boolean()
|
|
@@ -418,6 +562,9 @@ export const csvParse = buildTool({
|
|
|
418
562
|
const size = tooLarge(input.text, "text");
|
|
419
563
|
if (size !== null)
|
|
420
564
|
return size;
|
|
565
|
+
const dialect = csvDialectRefusal(input.delimiter ?? ",", input.quote ?? '"');
|
|
566
|
+
if (dialect !== null)
|
|
567
|
+
return dialect;
|
|
421
568
|
const options = {
|
|
422
569
|
delimiter: input.delimiter ?? ",",
|
|
423
570
|
quote: input.quote ?? '"',
|
|
@@ -456,14 +603,18 @@ export const csvWrite = buildTool({
|
|
|
456
603
|
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
604
|
inputSchema: z.object({
|
|
458
605
|
records: z
|
|
459
|
-
.array(z.union([
|
|
606
|
+
.array(z.union([ownRecord(z.unknown()), z.array(z.unknown())]))
|
|
460
607
|
.max(200_000)
|
|
461
608
|
.describe("objects (a header is derived from their keys) or arrays (written as raw rows)"),
|
|
462
609
|
columns: z
|
|
463
610
|
.array(z.string())
|
|
464
611
|
.optional()
|
|
465
612
|
.describe("column order; defaults to keys as first seen"),
|
|
466
|
-
delimiter: z
|
|
613
|
+
delimiter: z
|
|
614
|
+
.string()
|
|
615
|
+
.length(1)
|
|
616
|
+
.optional()
|
|
617
|
+
.describe("default , — not a line break or a double quote"),
|
|
467
618
|
header: z.boolean().optional().describe("write a header row; default true for records"),
|
|
468
619
|
quoteAll: z.boolean().optional(),
|
|
469
620
|
crlf: z.boolean().optional().describe("use CRLF line endings, as RFC 4180 specifies"),
|
|
@@ -471,6 +622,9 @@ export const csvWrite = buildTool({
|
|
|
471
622
|
readOnly: true,
|
|
472
623
|
concurrencySafe: true,
|
|
473
624
|
execute: async (input) => {
|
|
625
|
+
const dialect = csvDialectRefusal(input.delimiter ?? ",", '"');
|
|
626
|
+
if (dialect !== null)
|
|
627
|
+
return dialect;
|
|
474
628
|
if (input.records.length === 0)
|
|
475
629
|
return "";
|
|
476
630
|
const rowMode = input.records.every((r) => Array.isArray(r));
|
|
@@ -480,19 +634,39 @@ export const csvWrite = buildTool({
|
|
|
480
634
|
newline: input.crlf === true ? "\r\n" : "\n",
|
|
481
635
|
quoteAll: input.quoteAll ?? false,
|
|
482
636
|
header: null,
|
|
637
|
+
maxChars: MAX_OUTPUT_CHARS,
|
|
483
638
|
};
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
639
|
+
try {
|
|
640
|
+
if (rowMode) {
|
|
641
|
+
return writeCsvRows(input.records, {
|
|
642
|
+
...options,
|
|
643
|
+
header: input.columns ?? null,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (!input.records.every(isPlainObject)) {
|
|
647
|
+
return "mix of records and rows — every element must be an object, or every element an array";
|
|
648
|
+
}
|
|
649
|
+
const records = input.records;
|
|
650
|
+
const columns = input.columns ?? unionKeys(records);
|
|
651
|
+
// A delimiter per column per row: a sparse record set (a column per
|
|
652
|
+
// distinct key) is refused on arithmetic alone, and the rows are built
|
|
653
|
+
// lazily in any case, so the writer's own count stops the rest.
|
|
654
|
+
if (records.length * Math.max(columns.length - 1, 0) > MAX_OUTPUT_CHARS) {
|
|
655
|
+
return outputRefusal(`${records.length} rows of ${columns.length} columns as CSV`, "the records do not share their keys; pass columns to select the fields");
|
|
656
|
+
}
|
|
657
|
+
const rows = {
|
|
658
|
+
*[Symbol.iterator]() {
|
|
659
|
+
for (const r of records)
|
|
660
|
+
yield columns.map((c) => cellToString(getOwn(r, c) ?? null));
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
return writeCsvRows(rows, { ...options, header: input.header === false ? null : columns });
|
|
489
664
|
}
|
|
490
|
-
|
|
491
|
-
|
|
665
|
+
catch (err) {
|
|
666
|
+
if (err instanceof OutputLimitError)
|
|
667
|
+
return writerRefusal(err);
|
|
668
|
+
throw err;
|
|
492
669
|
}
|
|
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
670
|
},
|
|
497
671
|
});
|
|
498
672
|
const conditionSchema = z.object({
|
|
@@ -518,7 +692,7 @@ export const tableQuery = buildTool({
|
|
|
518
692
|
name: "TableQuery",
|
|
519
693
|
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
694
|
inputSchema: z.object({
|
|
521
|
-
records: z.array(
|
|
695
|
+
records: z.array(ownRecord(z.unknown())).max(200_000),
|
|
522
696
|
where: z
|
|
523
697
|
.object({
|
|
524
698
|
all: z.array(conditionSchema).optional().describe("every condition must hold"),
|
|
@@ -542,11 +716,28 @@ export const tableQuery = buildTool({
|
|
|
542
716
|
}),
|
|
543
717
|
readOnly: true,
|
|
544
718
|
concurrencySafe: true,
|
|
545
|
-
execute: async (input) => {
|
|
719
|
+
execute: async (input, ctx) => {
|
|
546
720
|
let rows = input.records;
|
|
721
|
+
// Rows a `matches` pattern could not be answered on: neither returned
|
|
722
|
+
// nor excluded, and named, so the result is never read as complete.
|
|
723
|
+
const undeterminedRows = [];
|
|
547
724
|
if (input.where !== undefined) {
|
|
725
|
+
const where = input.where;
|
|
548
726
|
try {
|
|
549
|
-
|
|
727
|
+
// Every `matches` pattern is answered in the regex worker first, for
|
|
728
|
+
// every row, then the filter reads the answers (C073).
|
|
729
|
+
const regex = new RegexAnswers();
|
|
730
|
+
askPredicatePatterns(rows, where, regex);
|
|
731
|
+
await regex.resolve(regexRunContext(ctx));
|
|
732
|
+
const kept = [];
|
|
733
|
+
for (const [index, row] of rows.entries()) {
|
|
734
|
+
const verdict = testPredicate(row, where, regex);
|
|
735
|
+
if (verdict === true)
|
|
736
|
+
kept.push(row);
|
|
737
|
+
else if (verdict === "undetermined")
|
|
738
|
+
undeterminedRows.push(index);
|
|
739
|
+
}
|
|
740
|
+
rows = kept;
|
|
550
741
|
}
|
|
551
742
|
catch (err) {
|
|
552
743
|
if (err instanceof PredicateError)
|
|
@@ -554,6 +745,15 @@ export const tableQuery = buildTool({
|
|
|
554
745
|
throw err;
|
|
555
746
|
}
|
|
556
747
|
}
|
|
748
|
+
const undetermined = undeterminedRows.length === 0
|
|
749
|
+
? {}
|
|
750
|
+
: {
|
|
751
|
+
undetermined: {
|
|
752
|
+
count: undeterminedRows.length,
|
|
753
|
+
rows: undeterminedRows.slice(0, 50),
|
|
754
|
+
reason: "a matches pattern could not be run to an answer on these rows (the deadline, or the regex engine giving up), so they are neither in the result nor ruled out",
|
|
755
|
+
},
|
|
756
|
+
};
|
|
557
757
|
const matched = rows.length;
|
|
558
758
|
if (input.sort !== undefined && input.sort.length > 0) {
|
|
559
759
|
rows = sortRecordsFn(rows, input.sort);
|
|
@@ -562,7 +762,7 @@ export const tableQuery = buildTool({
|
|
|
562
762
|
const limit = input.limit ?? 1000;
|
|
563
763
|
rows = rows.slice(offset, offset + limit);
|
|
564
764
|
if (input.countOnly === true)
|
|
565
|
-
return json({ matched, returned: 0 });
|
|
765
|
+
return json({ matched, returned: 0, ...undetermined });
|
|
566
766
|
if (input.select !== undefined && input.select.length > 0) {
|
|
567
767
|
const fields = input.select;
|
|
568
768
|
rows = rows.map((r) => selectFields(r, fields));
|
|
@@ -571,14 +771,14 @@ export const tableQuery = buildTool({
|
|
|
571
771
|
const fields = input.omit;
|
|
572
772
|
rows = rows.map((r) => omitFields(r, fields));
|
|
573
773
|
}
|
|
574
|
-
return json({ matched, returned: rows.length, records: rows });
|
|
774
|
+
return json({ matched, returned: rows.length, ...undetermined, records: rows });
|
|
575
775
|
},
|
|
576
776
|
});
|
|
577
777
|
export const tableAggregate = buildTool({
|
|
578
778
|
name: "TableAggregate",
|
|
579
779
|
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
780
|
inputSchema: z.object({
|
|
581
|
-
records: z.array(
|
|
781
|
+
records: z.array(ownRecord(z.unknown())).max(200_000),
|
|
582
782
|
groupBy: z
|
|
583
783
|
.array(z.string())
|
|
584
784
|
.max(8)
|
|
@@ -617,8 +817,8 @@ export const tableJoin = buildTool({
|
|
|
617
817
|
name: "TableJoin",
|
|
618
818
|
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
819
|
inputSchema: z.object({
|
|
620
|
-
left: z.array(
|
|
621
|
-
right: z.array(
|
|
820
|
+
left: z.array(ownRecord(z.unknown())).max(100_000),
|
|
821
|
+
right: z.array(ownRecord(z.unknown())).max(100_000),
|
|
622
822
|
leftKey: z.string().min(1).describe("dotted path to the join key on the left"),
|
|
623
823
|
rightKey: z
|
|
624
824
|
.string()
|
|
@@ -645,6 +845,7 @@ export const tableJoin = buildTool({
|
|
|
645
845
|
return json({
|
|
646
846
|
rowCount: result.rows.length,
|
|
647
847
|
truncated: result.truncated,
|
|
848
|
+
...(result.truncated ? { totalRows: result.totalRows } : {}),
|
|
648
849
|
unmatchedLeft: result.unmatchedLeft,
|
|
649
850
|
unmatchedRight: result.unmatchedRight,
|
|
650
851
|
rows: result.rows,
|
|
@@ -655,7 +856,7 @@ export const recordsToColumns = buildTool({
|
|
|
655
856
|
name: "RecordsToColumns",
|
|
656
857
|
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
858
|
inputSchema: z.object({
|
|
658
|
-
records: z.array(
|
|
859
|
+
records: z.array(ownRecord(z.unknown())).max(200_000),
|
|
659
860
|
columns: z.array(z.string()).optional().describe("restrict and order the output columns"),
|
|
660
861
|
}),
|
|
661
862
|
readOnly: true,
|
|
@@ -666,8 +867,9 @@ export const recordsToColumns = buildTool({
|
|
|
666
867
|
return json({ rowCount: input.records.length, columns: all });
|
|
667
868
|
}
|
|
668
869
|
const picked = {};
|
|
669
|
-
for (const c of input.columns)
|
|
670
|
-
picked
|
|
870
|
+
for (const c of input.columns) {
|
|
871
|
+
setOwn(picked, c, getOwn(all, c) ?? input.records.map(() => null));
|
|
872
|
+
}
|
|
671
873
|
return json({ rowCount: input.records.length, columns: picked });
|
|
672
874
|
},
|
|
673
875
|
});
|
|
@@ -675,7 +877,7 @@ export const columnsToRecords = buildTool({
|
|
|
675
877
|
name: "ColumnsToRecords",
|
|
676
878
|
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
879
|
inputSchema: z.object({
|
|
678
|
-
columns:
|
|
880
|
+
columns: ownRecord(z.array(z.unknown())).describe("field name to array of values"),
|
|
679
881
|
}),
|
|
680
882
|
readOnly: true,
|
|
681
883
|
concurrencySafe: true,
|
|
@@ -703,7 +905,16 @@ export const flattenObject = buildTool({
|
|
|
703
905
|
const doc = readJson(input.json, "json");
|
|
704
906
|
if (!doc.ok)
|
|
705
907
|
return doc.error;
|
|
706
|
-
|
|
908
|
+
let flat;
|
|
909
|
+
try {
|
|
910
|
+
flat = flattenObjectFn(doc.value, input.separator ?? ".", input.expandArrays ?? true, input.maxDepth ?? 32, MAX_OUTPUT_CHARS);
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
if (err instanceof OutputLimitError) {
|
|
914
|
+
return outputRefusal("the flattened keys", "flatten part of the document, or lower maxDepth or turn off expandArrays");
|
|
915
|
+
}
|
|
916
|
+
throw err;
|
|
917
|
+
}
|
|
707
918
|
return json(flat);
|
|
708
919
|
},
|
|
709
920
|
});
|
|
@@ -711,7 +922,7 @@ export const unflattenObject = buildTool({
|
|
|
711
922
|
name: "UnflattenObject",
|
|
712
923
|
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
924
|
inputSchema: z.object({
|
|
714
|
-
flat:
|
|
925
|
+
flat: ownRecord(z.unknown()).describe("dotted key to value"),
|
|
715
926
|
separator: z.string().min(1).max(4).optional().describe("default ."),
|
|
716
927
|
arraysFromNumericKeys: z
|
|
717
928
|
.boolean()
|
|
@@ -723,7 +934,7 @@ export const unflattenObject = buildTool({
|
|
|
723
934
|
concurrencySafe: true,
|
|
724
935
|
execute: async (input) => {
|
|
725
936
|
const value = unflattenObjectFn(input.flat, input.separator ?? ".", input.arraysFromNumericKeys ?? true);
|
|
726
|
-
return
|
|
937
|
+
return pretty(value, input.indent ?? 0, "undefined");
|
|
727
938
|
},
|
|
728
939
|
});
|
|
729
940
|
export const jsonlParse = buildTool({
|
|
@@ -769,8 +980,14 @@ export const jsonlWrite = buildTool({
|
|
|
769
980
|
readOnly: true,
|
|
770
981
|
concurrencySafe: true,
|
|
771
982
|
execute: async (input) => {
|
|
772
|
-
|
|
773
|
-
|
|
983
|
+
try {
|
|
984
|
+
return writeJsonl(input.values, input.trailingNewline ?? true, MAX_OUTPUT_CHARS).text;
|
|
985
|
+
}
|
|
986
|
+
catch (err) {
|
|
987
|
+
if (err instanceof OutputLimitError)
|
|
988
|
+
return writerRefusal(err);
|
|
989
|
+
throw err;
|
|
990
|
+
}
|
|
774
991
|
},
|
|
775
992
|
});
|
|
776
993
|
export const xmlParse = buildTool({
|
|
@@ -948,7 +1165,7 @@ export const jsonSortKeys = buildTool({
|
|
|
948
1165
|
const doc = readJson(input.json, "json");
|
|
949
1166
|
if (!doc.ok)
|
|
950
1167
|
return doc.error;
|
|
951
|
-
return
|
|
1168
|
+
return pretty(sortKeysDeep(doc.value), input.indent ?? 2, "null");
|
|
952
1169
|
},
|
|
953
1170
|
});
|
|
954
1171
|
/** Every tool this package registers, in the order a catalog should list them. */
|