@crewhaus/tool-data 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
- return { ok: true, value: parseToml(text) };
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
- 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
- });
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 JSON.stringify(patched, null, input.indent ?? 0);
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 JSON.stringify(diffMergePatch(doc.value, other.value), null, indent);
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 JSON.stringify(applyMergePatch(doc.value, patch.value), null, indent);
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.string().length(1).optional().describe("for CSV, on either side; default ,"),
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
- return writeDocument(doc.value, input.to, {
340
- indent: input.indent ?? 2,
341
- delimiter,
342
- header,
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 JSON.stringify(value, null, options.indent) ?? "null";
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
- return writeCsvRows(records.map((r) => columns.map((c) => r[c])), writeOptions);
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.string().length(1).optional().describe("default , — use \\t for TSV"),
402
- quote: z.string().length(1).optional().describe('default "'),
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([z.record(z.unknown()), z.array(z.unknown())]))
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.string().length(1).optional(),
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
- if (rowMode) {
485
- return writeCsvRows(input.records, {
486
- ...options,
487
- header: input.columns ?? null,
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
- if (!input.records.every(isPlainObject)) {
491
- return "mix of records and rows — every element must be an object, or every element an array";
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(z.record(z.unknown())).max(200_000),
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
- rows = rows.filter((r) => testPredicate(r, input.where ?? {}));
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(z.record(z.unknown())).max(200_000),
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(z.record(z.unknown())).max(100_000),
621
- right: z.array(z.record(z.unknown())).max(100_000),
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(z.record(z.unknown())).max(200_000),
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[c] = all[c] ?? input.records.map(() => null);
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: z.record(z.array(z.unknown())).describe("field name to array of values"),
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
- const flat = flattenObjectFn(doc.value, input.separator ?? ".", input.expandArrays ?? true, input.maxDepth ?? 32);
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: z.record(z.unknown()).describe("dotted key to value"),
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 JSON.stringify(value, null, input.indent ?? 0);
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
- const result = writeJsonl(input.values, input.trailingNewline ?? true);
773
- return result.text;
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 JSON.stringify(sortKeysDeep(doc.value), null, input.indent ?? 2) ?? "null";
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. */