@molecule/api-ai-tools 1.5.14 → 1.5.16

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 CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-10-16T03:58:22.247Z
6
+ Generated: 2026-10-16T07:29:43.225Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-ai-tools
@@ -572,6 +572,22 @@ function redactSecretsInCode(s: string): string
572
572
 
573
573
  **Returns:** A redacted copy that preserves ordinary code verbatim.
574
574
 
575
+ #### `printsOnlySource(command)`
576
+
577
+ Whether a shell command only PRINTS source files (`cat a.tsx`, `sed -n '1,40p' x.ts`,
578
+ `head -50 b.hurl`): its output is file content and gets the code-safe redaction, like
579
+ read_file. Through the env-dump grade a printed `type AuthState = 'authenticated' | …`
580
+ came back as `'[REDACTED]'`, and the executor wrote the mask into a new file (X1
581
+ flash-m58). Any segment that is not such a print, or any env file named, keeps the full grade.
582
+
583
+ ```typescript
584
+ function printsOnlySource(command: string): boolean
585
+ ```
586
+
587
+ - `command` — The shell command.
588
+
589
+ **Returns:** True when every segment prints source files only.
590
+
575
591
  #### `isEnvFilePath(path)`
576
592
 
577
593
  Whether a path is an env file, for which {@link redactSecrets}' full env-dump
@@ -646,9 +662,16 @@ killed (X1 run flash-m15 lost 20 of its 60 minutes this way, 2026-10-15):
646
662
  - a trailing `| tail -N`, which holds everything back until the command ends.
647
663
  It is dropped: `wait_for_task` already returns the end of the log.
648
664
  - `playwright test` with no `--reporter`, which runs the config's reporter —
649
- often `html`, which prints nothing per test. `--reporter=line` is added
665
+ often `html`, which prints nothing per test. `--reporter=list` is added
650
666
  when the playwright run is the command's last step and nothing is piped or
651
- redirected from it ( aside); the results are the same, printed as they happen.
667
+ redirected from it: it writes each result as it finishes, and it is the
668
+ shape the platform reads a background suite back in (`suite-fail-fast.ts`'s
669
+ fail-fast note and the Tests bar both parse `list` lines). A run that names
670
+ `--reporter=line` is switched to `list` for the same reason — `line` writes
671
+ `[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
672
+ 8.3-minute walk of one suite could never trip the fail-fast note.
673
+
674
+ Only the command's last step is touched, and only its reporter.
652
675
 
653
676
  ```typescript
654
677
  function streamingBackgroundCommand(command: string): { command: string; changes: string[] }
@@ -827,6 +850,25 @@ function failureBlocks(s: string): string[]
827
850
 
828
851
  **Returns:** Each block, in order (empty when there are none).
829
852
 
853
+ #### `runningOutputSliceCommand(log, own)`
854
+
855
+ The shell command whose stdout is the interesting part of a log that is still being written:
856
+ the failure lines so far (capped at {@link RUNNING_FAILURE_LINES}), then the end of the log.
857
+
858
+ A Playwright `list` run prints one line per test as it finishes and its error blocks only at
859
+ the end, so the LAST bytes of a running log are its least informative part — in X1 flash-m61
860
+ the last 3 000 bytes of a 139-test walk were the skip list, and all 61 failures were above
861
+ them, which cost the run the grep it had to make anyway.
862
+
863
+ ```typescript
864
+ function runningOutputSliceCommand(log: string, own?: string): string
865
+ ```
866
+
867
+ - `log` — The task log path.
868
+ - `own` — A file the command redirects its own output to, if any.
869
+
870
+ **Returns:** The shell command, safe to hand to the execution backend as-is.
871
+
830
872
  #### `whitespaceTolerantReplace(content, oldString, newString)`
831
873
 
832
874
  Attempt a whitespace-tolerant replacement when an exact `old_string` match
@@ -887,6 +929,22 @@ function parseCheckProblem(result: {
887
929
 
888
930
  **Returns:** The message for the tool result, or null.
889
931
 
932
+ #### `fusedLine(content, newString)`
933
+
934
+ The line a replacement fused when it dropped the newline its `old_string`
935
+ ended with: `new_string`'s text is followed on the same line by what used to
936
+ be the next line. Read off the file as it now is, so the line shown is the
937
+ line the parser is actually complaining about.
938
+
939
+ ```typescript
940
+ function fusedLine(content: string, newString: string): { line: number; text: string } | null
941
+ ```
942
+
943
+ - `content` — The file's content after the edit.
944
+ - `newString` — A replacement's `new_string`, whose `old_string` ended with a newline.
945
+
946
+ **Returns:** The fused line's 1-based number and its text, or null when the replacement's text is not followed by more of the same line (it ends the file, the following newline survived, or another replacement rewrote it).
947
+
890
948
  ### Constants
891
949
 
892
950
  #### `TOOL_SCHEMAS`
@@ -1015,6 +1073,31 @@ const DEFAULT_SEARCH_EXCLUDED_DIRS: readonly [
1015
1073
  ]
1016
1074
  ```
1017
1075
 
1076
+ #### `RUNNING_FAILURE_LINES`
1077
+
1078
+ Failure lines shown from a log that is still being written.
1079
+
1080
+ ```typescript
1081
+ const RUNNING_FAILURE_LINES: 40
1082
+ ```
1083
+
1084
+ #### `RUNNING_TAIL_BYTES`
1085
+
1086
+ Bytes of a running log's end appended after its failure lines.
1087
+
1088
+ ```typescript
1089
+ const RUNNING_TAIL_BYTES: 1000
1090
+ ```
1091
+
1092
+ #### `RUNNING_FAILURE_LINE_RE`
1093
+
1094
+ The lines worth showing from a log that is still being written: a failed test's mark or
1095
+ numbered block header, and a runner's own error lines. Matched with `grep -E`.
1096
+
1097
+ ```typescript
1098
+ const RUNNING_FAILURE_LINE_RE: '^[[:space:]]*(✘|✗|×|✖|✕|[0-9]+\\)|[0-9]+ (passed|failed|flaky|skipped|did not run)|FAIL|FAILED|ERROR|Error:|error:|Traceback|AssertionError)'
1099
+ ```
1100
+
1018
1101
  #### `PARSE_CHECKABLE`
1019
1102
 
1020
1103
  Files a parse check can vouch for cheaply right after a write.
@@ -1061,6 +1144,15 @@ Peer dependencies:
1061
1144
  Line numbers count the way `read_file` does, so they are valid offsets. The
1062
1145
  excerpt is redacted like a read. The existing fields (`ok`,
1063
1146
  `replacementsApplied`, `alreadyApplied`, `syntaxError`) are unchanged.
1147
+ - **A dropped line break and the parse error it causes are reported as ONE
1148
+ thing.** When a replacement's `old_string` ended with a newline and its
1149
+ `new_string` does not (so the next line is pulled up onto it) and the file
1150
+ then fails its parse check, `edit_file` returns a single `syntaxError` that
1151
+ names the replacement, shows the fused line with its number, and says to
1152
+ re-send that replacement with the newline restored — instead of the
1153
+ `newlineNote` hint beside an unrelated-looking parse error, which an executor
1154
+ repairs by rewriting the fused line's text and fuses the next one. The
1155
+ `newlineNote` alone is still returned when the file parses.
1064
1156
  - **A parameter under another name is not a broken tool.** Every tool's input
1065
1157
  is normalized first (`cmd` → `command`, `file` → `path`, camelCase ⇄
1066
1158
  snake_case), and a handler that throws returns an error naming the missing
package/dist/index.d.ts CHANGED
@@ -38,6 +38,15 @@
38
38
  * Line numbers count the way `read_file` does, so they are valid offsets. The
39
39
  * excerpt is redacted like a read. The existing fields (`ok`,
40
40
  * `replacementsApplied`, `alreadyApplied`, `syntaxError`) are unchanged.
41
+ * - **A dropped line break and the parse error it causes are reported as ONE
42
+ * thing.** When a replacement's `old_string` ended with a newline and its
43
+ * `new_string` does not (so the next line is pulled up onto it) and the file
44
+ * then fails its parse check, `edit_file` returns a single `syntaxError` that
45
+ * names the replacement, shows the fused line with its number, and says to
46
+ * re-send that replacement with the newline restored — instead of the
47
+ * `newlineNote` hint beside an unrelated-looking parse error, which an executor
48
+ * repairs by rewriting the fused line's text and fuses the next one. The
49
+ * `newlineNote` alone is still returned when the file parses.
41
50
  * - **A parameter under another name is not a broken tool.** Every tool's input
42
51
  * is normalized first (`cmd` → `command`, `file` → `path`, camelCase ⇄
43
52
  * snake_case), and a handler that throws returns an error naming the missing
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAEH,cAAc,qBAAqB,CAAA;AACnC,cAAc,uBAAuB,CAAA;AACrC,cAAc,oBAAoB,CAAA;AAClC,cAAc,uBAAuB,CAAA;AACrC,cAAc,cAAc,CAAA;AAC5B,cAAc,oBAAoB,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkHG;AAEH,cAAc,qBAAqB,CAAA;AACnC,cAAc,uBAAuB,CAAA;AACrC,cAAc,oBAAoB,CAAA;AAClC,cAAc,uBAAuB,CAAA;AACrC,cAAc,cAAc,CAAA;AAC5B,cAAc,oBAAoB,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA"}
package/dist/index.js CHANGED
@@ -38,6 +38,15 @@
38
38
  * Line numbers count the way `read_file` does, so they are valid offsets. The
39
39
  * excerpt is redacted like a read. The existing fields (`ok`,
40
40
  * `replacementsApplied`, `alreadyApplied`, `syntaxError`) are unchanged.
41
+ * - **A dropped line break and the parse error it causes are reported as ONE
42
+ * thing.** When a replacement's `old_string` ended with a newline and its
43
+ * `new_string` does not (so the next line is pulled up onto it) and the file
44
+ * then fails its parse check, `edit_file` returns a single `syntaxError` that
45
+ * names the replacement, shows the fused line with its number, and says to
46
+ * re-send that replacement with the newline restored — instead of the
47
+ * `newlineNote` hint beside an unrelated-looking parse error, which an executor
48
+ * repairs by rewriting the fused line's text and fuses the next one. The
49
+ * `newlineNote` alone is still returned when the file parses.
41
50
  * - **A parameter under another name is not a broken tool.** Every tool's input
42
51
  * is normalized first (`cmd` → `command`, `file` → `path`, camelCase ⇄
43
52
  * snake_case), and a handler that throws returns an error naming the missing
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AA8B9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAyCnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CA6lDxF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AA8B9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AA6CnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAupDxF"}
package/dist/tools.js CHANGED
@@ -25,7 +25,7 @@ const waitedByTask = new Map();
25
25
  const logBytesByTask = new Map();
26
26
  /** The file a background command writes its own output to, when it redirects it. */
27
27
  const outputFileByTask = new Map();
28
- import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, isEnvFilePath, isOutsideRoot, isValidGlob, MAX_BATCH_READ_BYTES, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_OUTPUT_SIZE, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, redactSecrets, redactSecretsInCode, resolvePath, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, truncateMiddle, whitespaceTolerantReplace, } from './utilities.js';
28
+ import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, fusedLine, isEnvFilePath, isOutsideRoot, isValidGlob, MAX_BATCH_READ_BYTES, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_OUTPUT_SIZE, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, redactSecrets, redactSecretsInCode, resolvePath, RUNNING_FAILURE_LINES, runningOutputSliceCommand, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, truncateMiddle, whitespaceTolerantReplace, } from './utilities.js';
29
29
  /**
30
30
  * How long exec_command waits for a command it backgrounded only because the
31
31
  * command asked for more than the tool's ceiling, before handing back a task id.
@@ -876,24 +876,69 @@ export function buildTools(backend, config) {
876
876
  // A replacement whose old text ended in a newline and whose new text does not
877
877
  // joins the line after it onto its last line. Applied as written, but said:
878
878
  // flash-m28 saw two lines merged only one round trip later.
879
- const joined = replacements.filter((r) => typeof r.old_string === 'string' &&
879
+ const dropped = replacements
880
+ .map((r, i) => ({ ...r, position: i + 1 }))
881
+ .filter((r) => typeof r.old_string === 'string' &&
880
882
  typeof r.new_string === 'string' &&
881
883
  r.old_string.endsWith('\n') &&
882
884
  r.new_string !== '' &&
883
- !r.new_string.endsWith('\n')).length;
885
+ !r.new_string.endsWith('\n'));
886
+ const syntax = await syntaxErrorAfterWrite(path);
887
+ // Those two facts are ONE accident, and reported apart the model repairs the
888
+ // wrong one: flash-m55 dropped the boundary on two consecutive edit_file calls,
889
+ // both left `handlers/conduit.ts` unparseable, and it fixed the fused LINE's
890
+ // text — which re-fused the next line in the same batch. When the parse error
891
+ // follows a dropped newline, name the fused line and the single edit that
892
+ // undoes it, instead of a style note beside an unrelated-looking parse error.
893
+ const which = dropped.length === 1
894
+ ? `replacement ${dropped[0].position}`
895
+ : `replacements ${dropped.map((r) => r.position).join(', ')}`;
896
+ const located = dropped.flatMap((r) => {
897
+ const line = fusedLine(finalContent, r.new_string);
898
+ return line ? [line] : [];
899
+ });
900
+ const fused = located.slice(0, 3);
901
+ const fusedOmitted = located.length - fused.length;
902
+ const shownFused = fused
903
+ .map((l) => ` ${l.line}: ${l.text.length > 200 ? `${l.text.slice(0, 200)}…` : l.text}`)
904
+ .join('\n');
905
+ const fusedNote = syntax.syntaxError && dropped.length > 0
906
+ ? 'The file no longer parses after this change, and ' +
907
+ `${which} removed the line break that followed the replaced text — so the next line ` +
908
+ 'now continues on the line before it, and that fused line is what the parser rejects:\n' +
909
+ (shownFused || ` (not located as the file now reads — read_file ${path})`) +
910
+ (fusedOmitted > 0
911
+ ? `\n (${fusedOmitted} more fused line(s) — read_file ${path})`
912
+ : '') +
913
+ `\nRe-send ${which} with the trailing newline restored on new_string (change nothing ` +
914
+ "else about it). Editing the FUSED LINE's text instead fuses the next line the same " +
915
+ `way (flash-m55: three edits in a row).\n${syntax.syntaxError}`
916
+ : null;
884
917
  return {
885
918
  path,
886
919
  ok: true,
887
920
  replacementsApplied: replacements.length - alreadyApplied,
888
- ...(joined > 0
889
- ? {
890
- newlineNote: `${joined === 1 ? 'A replacement' : `${joined} replacements`} removed the line break after the replaced text (old_string ended with a newline, new_string does not), so the next line now continues on the same line. Check the excerpt; add the newline back if that was not intended.`,
891
- }
892
- : {}),
921
+ ...(fusedNote
922
+ ? { syntaxError: fusedNote }
923
+ : {
924
+ // The evidence for this note is `located`, not `dropped`: a drop joins
925
+ // two lines only when something other than a newline (or EOF) follows
926
+ // the replaced text. A weaker model writes one line's change as two
927
+ // replacements — the line including its trailing newline, then the
928
+ // line's own text — and the newline it consumed ended that line with a
929
+ // BLANK line after it, so nothing joined while the model was told it
930
+ // had: it spent a new round trip putting the blank line back
931
+ // (flash-m62). Say nothing when nothing was joined.
932
+ ...(dropped.length > 0 && located.length > 0
933
+ ? {
934
+ newlineNote: `${dropped.length === 1 ? 'A replacement' : `${dropped.length} replacements`} removed the line break after the replaced text (old_string ended with a newline, new_string does not), so the next line now continues on the same line. Check the excerpt; add the newline back if that was not intended.`,
935
+ }
936
+ : {}),
937
+ ...syntax,
938
+ }),
893
939
  ...(alreadyApplied > 0 ? { alreadyApplied, note: alreadyNote } : {}),
894
940
  totalLines: countLines(finalContent),
895
941
  ...(excerpt ? { excerpt: sanitizeFileContent(excerpt, path) } : {}),
896
- ...(await syntaxErrorAfterWrite(path)),
897
942
  };
898
943
  }
899
944
  catch (e) {
@@ -929,7 +974,9 @@ export function buildTools(backend, config) {
929
974
  // search found nothing (X1 run sol-m07). A pattern that is not a valid
930
975
  // extended regex (`foo(` — an unbalanced paren) makes grep exit 2; it is
931
976
  // then searched as the plain text it was meant as.
932
- const grepArgs = `-rn ${globArg} ${contextArg} ${grepExcludeArgs(path)} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)}`;
977
+ // -H: a search scoped to one file printed `line:content` with no file, and every match
978
+ // came back `{file: "", line: 0}` (X1 flash-m62).
979
+ const grepArgs = `-rnH ${globArg} ${contextArg} ${grepExcludeArgs(path)} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)}`;
933
980
  const result = await backend.run(`{ grep -E ${grepArgs}; s=$?; if [ "$s" -eq 2 ]; then grep -F ${grepArgs}; fi; } 2>/dev/null || true`, { timeout: 10000 });
934
981
  // grep emits `<file>:<line>:<content>`, so redaction runs PER MATCH on the
935
982
  // content alone: the file prefix would otherwise hide an env assignment from
@@ -1231,7 +1278,12 @@ export function buildTools(backend, config) {
1231
1278
  // truncateMiddle (not truncate): a failing build/test/migration puts its
1232
1279
  // error at the TAIL, so keep the head AND the tail — head-only truncation
1233
1280
  // strands the executor with passing progress and no failure reason.
1234
- const stdout = sanitizeOutput(truncateMiddle(result.stdout, MAX_OUTPUT_SIZE));
1281
+ // A command that only prints source files returns file content: the code-safe grade,
1282
+ // as read_file uses (utilities.ts printsOnlySource).
1283
+ const grade = printsOnlySource(command)
1284
+ ? (s) => sanitizeFileContent(s, 'printed.ts')
1285
+ : sanitizeOutput;
1286
+ const stdout = grade(truncateMiddle(result.stdout, MAX_OUTPUT_SIZE));
1235
1287
  const stderr = sanitizeOutput(truncateMiddle(result.stderr, MAX_OUTPUT_SIZE));
1236
1288
  if (budgetSeconds && result.exitCode === 124) {
1237
1289
  return {
@@ -1419,15 +1471,17 @@ export function buildTools(backend, config) {
1419
1471
  };
1420
1472
  }
1421
1473
  // The output so far rides the result: a wait that shows nothing was waited blind
1422
- // (X1 flash-m50: 4m42s across two waits on an 11-minute run).
1474
+ // (X1 flash-m50: 4m42s across two waits on an 11-minute run). The slice leads with the
1475
+ // failure lines, not the last bytes — a suite's tail is its progress lines
1476
+ // (X1 flash-m61: the last 3 000 bytes of a 139-test walk were the skip list).
1423
1477
  const own = outputFileByTask.get(taskId);
1424
1478
  let soFar;
1425
1479
  try {
1426
- const r = await backend.run(`cat ${log}${own ? ` ${shellQuote(own)}` : ''} 2>/dev/null | tail -c 3000`, { timeout: 5_000 });
1480
+ const r = await backend.run(runningOutputSliceCommand(log, own), { timeout: 5_000 });
1427
1481
  soFar = r.stdout ?? '';
1428
1482
  }
1429
1483
  catch (_error) {
1430
- // No tail: the note still says how to peek.
1484
+ // No slice: the note still says how to peek.
1431
1485
  soFar = '';
1432
1486
  }
1433
1487
  return {
@@ -1436,8 +1490,13 @@ export function buildTools(backend, config) {
1436
1490
  waitedMs,
1437
1491
  ...(soFar.trim() ? { outputSoFar: sanitizeOutput(soFar) } : {}),
1438
1492
  note: `Still running after ${Math.round(waitedMs / 1000)}s. Call wait_for_task again to ` +
1439
- `keep waiting — its result carries the log output${soFar.trim() ? '; outputSoFar is the end of the log as it stands, so there is no need to tail it' : ''}. ` +
1440
- `Do other work in the meantime (another suite, a fix you have already found) rather than waiting.`,
1493
+ `keep waiting — its result carries the log output. ` +
1494
+ (soFar.trim()
1495
+ ? `outputSoFar is the failure lines so far (up to ${RUNNING_FAILURE_LINES}) and then the end of ` +
1496
+ `the log, not the whole run — \`grep -n '✘' ${log} | head -40\` lists every failure, ` +
1497
+ `\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error. `
1498
+ : `Nothing has been printed yet — \`grep -n '✘' ${log} | head -40\` lists failures as they appear. `) +
1499
+ `Meanwhile do work that leaves the code under test alone (read the failures so far, plan the fixes): editing app/src or api/src now changes the code the run is testing, and its result no longer counts.`,
1441
1500
  };
1442
1501
  }
1443
1502
  let output;
@@ -55,6 +55,17 @@ export declare function redactSecrets(s: string): string;
55
55
  * @returns A redacted copy that preserves ordinary code verbatim.
56
56
  */
57
57
  export declare function redactSecretsInCode(s: string): string;
58
+ /**
59
+ * Whether a shell command only PRINTS source files (`cat a.tsx`, `sed -n '1,40p' x.ts`,
60
+ * `head -50 b.hurl`): its output is file content and gets the code-safe redaction, like
61
+ * read_file. Through the env-dump grade a printed `type AuthState = 'authenticated' | …`
62
+ * came back as `'[REDACTED]'`, and the executor wrote the mask into a new file (X1
63
+ * flash-m58). Any segment that is not such a print, or any env file named, keeps the full grade.
64
+ *
65
+ * @param command - The shell command.
66
+ * @returns True when every segment prints source files only.
67
+ */
68
+ export declare function printsOnlySource(command: string): boolean;
58
69
  /**
59
70
  * Whether a path is an env file, for which {@link redactSecrets}' full env-dump
60
71
  * treatment is appropriate rather than {@link redactSecretsInCode}. Matches
@@ -108,9 +119,16 @@ export declare function outputRedirectTarget(command: string): string | null;
108
119
  * - a trailing `| tail -N`, which holds everything back until the command ends.
109
120
  * It is dropped: `wait_for_task` already returns the end of the log.
110
121
  * - `playwright test` with no `--reporter`, which runs the config's reporter —
111
- * often `html`, which prints nothing per test. `--reporter=line` is added
122
+ * often `html`, which prints nothing per test. `--reporter=list` is added
112
123
  * when the playwright run is the command's last step and nothing is piped or
113
- * redirected from it ( aside); the results are the same, printed as they happen.
124
+ * redirected from it: it writes each result as it finishes, and it is the
125
+ * shape the platform reads a background suite back in (`suite-fail-fast.ts`'s
126
+ * fail-fast note and the Tests bar both parse `list` lines). A run that names
127
+ * `--reporter=line` is switched to `list` for the same reason — `line` writes
128
+ * `[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
129
+ * 8.3-minute walk of one suite could never trip the fail-fast note.
130
+ *
131
+ * Only the command's last step is touched, and only its reporter.
114
132
  *
115
133
  * @param command - The command as the executor sent it.
116
134
  * @returns The command to run and one sentence per change made (empty when none).
@@ -296,6 +314,29 @@ export declare function truncateMiddle(s: string, maxLength: number): string;
296
314
  * @returns Each block, in order (empty when there are none).
297
315
  */
298
316
  export declare function failureBlocks(s: string): string[];
317
+ /** Failure lines shown from a log that is still being written. */
318
+ export declare const RUNNING_FAILURE_LINES = 40;
319
+ /** Bytes of a running log's end appended after its failure lines. */
320
+ export declare const RUNNING_TAIL_BYTES = 1000;
321
+ /**
322
+ * The lines worth showing from a log that is still being written: a failed test's mark or
323
+ * numbered block header, and a runner's own error lines. Matched with `grep -E`.
324
+ */
325
+ export declare const RUNNING_FAILURE_LINE_RE = "^[[:space:]]*(\u2718|\u2717|\u00D7|\u2716|\u2715|[0-9]+\\)|[0-9]+ (passed|failed|flaky|skipped|did not run)|FAIL|FAILED|ERROR|Error:|error:|Traceback|AssertionError)";
326
+ /**
327
+ * The shell command whose stdout is the interesting part of a log that is still being written:
328
+ * the failure lines so far (capped at {@link RUNNING_FAILURE_LINES}), then the end of the log.
329
+ *
330
+ * A Playwright `list` run prints one line per test as it finishes and its error blocks only at
331
+ * the end, so the LAST bytes of a running log are its least informative part — in X1 flash-m61
332
+ * the last 3 000 bytes of a 139-test walk were the skip list, and all 61 failures were above
333
+ * them, which cost the run the grep it had to make anyway.
334
+ *
335
+ * @param log - The task log path.
336
+ * @param own - A file the command redirects its own output to, if any.
337
+ * @returns The shell command, safe to hand to the execution backend as-is.
338
+ */
339
+ export declare function runningOutputSliceCommand(log: string, own?: string): string;
299
340
  /**
300
341
  * Attempt a whitespace-tolerant replacement when an exact `old_string` match
301
342
  * failed. Finds a contiguous run of lines in `content` whose per-line
@@ -339,4 +380,20 @@ export declare function parseCheckProblem(result: {
339
380
  stderr: string;
340
381
  exitCode: number;
341
382
  }): string | null;
383
+ /**
384
+ * The line a replacement fused when it dropped the newline its `old_string`
385
+ * ended with: `new_string`'s text is followed on the same line by what used to
386
+ * be the next line. Read off the file as it now is, so the line shown is the
387
+ * line the parser is actually complaining about.
388
+ *
389
+ * @param content - The file's content after the edit.
390
+ * @param newString - A replacement's `new_string`, whose `old_string` ended with a newline.
391
+ * @returns The fused line's 1-based number and its text, or null when the
392
+ * replacement's text is not followed by more of the same line (it ends the
393
+ * file, the following newline survived, or another replacement rewrote it).
394
+ */
395
+ export declare function fusedLine(content: string, newString: string): {
396
+ line: number;
397
+ text: string;
398
+ } | null;
342
399
  //# sourceMappingURL=utilities.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"utilities.d.ts","sourceRoot":"","sources":["../src/utilities.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"utilities.d.ts","sourceRoot":"","sources":["../src/utilities.ts"],"names":[],"mappings":"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"}
package/dist/utilities.js CHANGED
@@ -98,11 +98,21 @@ const NON_SECRET_VALUES = new Set([
98
98
  * @param quote - The quote character the value uses (`"` or `'`).
99
99
  * @returns A String.replace replacer preserving allowlisted values.
100
100
  */
101
- const jsonValueReplacer = (quote) => (match, prefix) => {
101
+ const jsonValueReplacer = (quote) => (match, prefix, offset, whole) => {
102
102
  const value = match.slice(prefix.length);
103
103
  const inner = value.slice(1, -1);
104
+ // A TypeScript type literal is never a credential: `type AuthState = 'authenticated' | …`
105
+ // (X1 flash-m58). A union member is followed by `|`; a declaration starts `type`.
106
+ if (/^\s*\|/.test(whole.slice(offset + match.length)))
107
+ return match;
108
+ if (/\btype\s+$/.test(whole.slice(Math.max(0, offset - 16), offset)))
109
+ return match;
104
110
  // An empty value hides nothing, and a suite fixture's `"password": ""` is the fact a
105
111
  // failing assertion turns on (X1 flash-m52).
112
+ // A version (`"jsonwebtoken": "9.0.2"`, a package name that holds a keyword) is not a
113
+ // credential, and a masked one hid which version is installed (X1 flash-m57).
114
+ if (/^[\^~]?\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(inner))
115
+ return match;
106
116
  if (inner === '' || NON_SECRET_VALUES.has(inner.toLowerCase()))
107
117
  return match;
108
118
  return `${prefix}${quote}[REDACTED]${quote}`;
@@ -149,6 +159,61 @@ export function redactSecrets(s) {
149
159
  export function redactSecretsInCode(s) {
150
160
  return s.replace(SECRET_ENV_ASSIGNMENT, '$1=[REDACTED]');
151
161
  }
162
+ /** Source-file extensions a printing command's output is read as code for. */
163
+ const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?|css|scss|html|vue|svelte|md|sql|hurl)$/;
164
+ /**
165
+ * Whether a shell command only PRINTS source files (`cat a.tsx`, `sed -n '1,40p' x.ts`,
166
+ * `head -50 b.hurl`): its output is file content and gets the code-safe redaction, like
167
+ * read_file. Through the env-dump grade a printed `type AuthState = 'authenticated' | …`
168
+ * came back as `'[REDACTED]'`, and the executor wrote the mask into a new file (X1
169
+ * flash-m58). Any segment that is not such a print, or any env file named, keeps the full grade.
170
+ *
171
+ * @param command - The shell command.
172
+ * @returns True when every segment prints source files only.
173
+ */
174
+ export function printsOnlySource(command) {
175
+ if (/\.env\b/.test(command))
176
+ return false;
177
+ const segments = command
178
+ .split(/\s*(?:&&|;|\n)\s*/)
179
+ .map((s) => s.trim())
180
+ .filter((s) => s && !/^cd\s/.test(s));
181
+ if (segments.length === 0)
182
+ return false;
183
+ return segments.every((seg) => {
184
+ // Words, quotes kept whole: a grep pattern like "sign\\|verify" is one word, not a pipe.
185
+ const words = seg.match(/"(?:\\.|[^"\\])*"|'[^']*'|\S+/g) ?? [];
186
+ const stages = [[]];
187
+ for (const w of words) {
188
+ if (w === '|')
189
+ stages.push([]);
190
+ else if (!/^\d?>&\d$|^2>\/dev\/null$/.test(w))
191
+ stages[stages.length - 1].push(w);
192
+ }
193
+ const [first, ...rest] = stages;
194
+ const head = first[0];
195
+ // A print of named source files: cat/head/tail/nl/sed -n, or a non-recursive grep/rg
196
+ // (X1 flash-m61 read a fixture's password masked through `grep -n … auth.hurl`).
197
+ let args = first.slice(1);
198
+ if (head === 'sed' && args[0] !== '-n')
199
+ return false;
200
+ if (head === 'grep' || head === 'rg') {
201
+ if (args.some((a) => /^-[a-zA-Z]*[rR]/.test(a) || a === '--recursive'))
202
+ return false;
203
+ const flags = args.filter((a) => a.startsWith('-'));
204
+ const rest2 = args.filter((a) => !a.startsWith('-'));
205
+ args = flags.includes('-e') ? rest2 : rest2.slice(1);
206
+ }
207
+ else if (!/^(?:cat|head|tail|nl|sed)$/.test(head ?? ''))
208
+ return false;
209
+ const files = args.filter((t) => !t.startsWith('-') && !/^'[^']*'$|^"[^"]*"$|^\d+$|^\d+,\d+p$/.test(t));
210
+ if (files.length === 0 || !files.every((f) => SOURCE_FILE_RE.test(f)))
211
+ return false;
212
+ // After it, only text filters that read no file of their own.
213
+ return rest.every((stage) => /^(?:head|tail|sort|uniq|wc|cut|nl|grep)$/.test(stage[0] ?? '') &&
214
+ !stage.slice(1).some((a) => !a.startsWith('-') && /\.[a-z]+$/i.test(a) && !/^['"]/.test(a)));
215
+ });
216
+ }
152
217
  /**
153
218
  * Whether a path is an env file, for which {@link redactSecrets}' full env-dump
154
219
  * treatment is appropriate rather than {@link redactSecretsInCode}. Matches
@@ -222,6 +287,8 @@ export function outputRedirectTarget(command) {
222
287
  }
223
288
  /** A trailing `| tail -N` / `| tail -n N` (stderr redirects before it stay). */
224
289
  const TRAILING_TAIL = /\s*\|\s*tail(?:\s+-n)?\s+[-+]?\d+\s*$/;
290
+ /** `--reporter=line` / `--reporter line`, the one reporter the platform cannot read back. */
291
+ const LINE_REPORTER_FLAG = /--reporter([= ])line\b/;
225
292
  /**
226
293
  * Rewrite a command about to run in the background so its log grows while it
227
294
  * runs. Two shapes left the log empty until exit, and an empty log is what
@@ -231,9 +298,16 @@ const TRAILING_TAIL = /\s*\|\s*tail(?:\s+-n)?\s+[-+]?\d+\s*$/;
231
298
  * - a trailing `| tail -N`, which holds everything back until the command ends.
232
299
  * It is dropped: `wait_for_task` already returns the end of the log.
233
300
  * - `playwright test` with no `--reporter`, which runs the config's reporter —
234
- * often `html`, which prints nothing per test. `--reporter=line` is added
301
+ * often `html`, which prints nothing per test. `--reporter=list` is added
235
302
  * when the playwright run is the command's last step and nothing is piped or
236
- * redirected from it ( aside); the results are the same, printed as they happen.
303
+ * redirected from it: it writes each result as it finishes, and it is the
304
+ * shape the platform reads a background suite back in (`suite-fail-fast.ts`'s
305
+ * fail-fast note and the Tests bar both parse `list` lines). A run that names
306
+ * `--reporter=line` is switched to `list` for the same reason — `line` writes
307
+ * `[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
308
+ * 8.3-minute walk of one suite could never trip the fail-fast note.
309
+ *
310
+ * Only the command's last step is touched, and only its reporter.
237
311
  *
238
312
  * @param command - The command as the executor sent it.
239
313
  * @returns The command to run and one sentence per change made (empty when none).
@@ -255,12 +329,20 @@ export function streamingBackgroundCommand(command) {
255
329
  changes.push(`Dropped the trailing \`${head[0].trim()}\`: Playwright prints its pass/fail count last, and it would be cut.`);
256
330
  }
257
331
  const lastStep = out.split(/&&|\|\||;/).at(-1) ?? '';
258
- if (/\bplaywright\s+test\b/.test(lastStep) &&
259
- !/--reporter\b/.test(lastStep) &&
332
+ const finalPlaywrightRun = /\bplaywright\s+test\b/.test(lastStep) &&
260
333
  // `2>&1` only joins stderr to the log; any other pipe or redirect sends the output elsewhere.
261
- !/[|>]/.test(lastStep.replace(/\d?>&\d/g, ''))) {
262
- out = `${out.trimEnd()} --reporter=line`;
263
- changes.push('Added `--reporter=line` so each test result is written to the log as it finishes ' +
334
+ !/[|>]/.test(lastStep.replace(/\d?>&\d/g, ''));
335
+ if (finalPlaywrightRun && LINE_REPORTER_FLAG.test(lastStep)) {
336
+ // Only the last step is rewritten, and `lastStep` is a suffix of `out`.
337
+ out =
338
+ out.slice(0, out.length - lastStep.length) +
339
+ lastStep.replace(LINE_REPORTER_FLAG, '--reporter$1list');
340
+ changes.push('Switched `--reporter=line` to `--reporter=list`: it also writes each test result as it ' +
341
+ 'finishes, and it is the shape the platform reads back from a background run.');
342
+ }
343
+ else if (finalPlaywrightRun && !/--reporter\b/.test(lastStep)) {
344
+ out = `${out.trimEnd()} --reporter=list`;
345
+ changes.push('Added `--reporter=list` so each test result is written to the log as it finishes ' +
264
346
  '(the config reporter may print nothing until the end).');
265
347
  }
266
348
  return { command: out, changes };
@@ -279,7 +361,8 @@ export function streamingBackgroundCommand(command) {
279
361
  * a keyless build still succeeds, and blocking those sent it looking for
280
362
  * workarounds (X0 rehearsal 14).
281
363
  */
282
- const BLOCKED_COMMANDS = /(?:^|[;&|`]\s*|(?:sh|bash|zsh|dash)\s+-c\s+['"]?\s*)(?:\/usr\/bin\/)?(?:\benv(?:\s+(?:-0|--null|--))?\s*(?:$|[;&|>)`'"])|\bprintenv\b|\bexport\s*$|\bset\s*$|\bdeclare\s+-x|cat\s+\/etc\/environment|cat\s+\/root\/\.bashrc|cat\s+\/proc\/\d+\/environ|cat\s+\/proc\/self\/environ|strings\s+\/proc|xargs[^;&|\n]*\/proc\/[^;&|\n]*environ|less\s+\/proc|head\s+\/proc|tail\s+\/proc|xxd\s+\/proc|od\s+\/proc|base64\s+\/proc|dd\s[^\n]*\/proc|sed\s[^\n]*\/proc\/[^\n]*environ|awk\s[^\n]*\/proc\/[^\n]*environ|cp\s[^\n]*\/proc\/[^\n]*environ|(?:cat|less|head|tail|xxd|od|base64|strings|dd|cp|sed|awk)\s[^\n;&|]*\/etc\/mol(?:\/|\b))/i;
364
+ // `(?<!\\)`: a backslash-escaped `\|` is a literal in the shell (grep "a\|env"), not a pipe (X1 flash-m58).
365
+ const BLOCKED_COMMANDS = /(?<!\\)(?:^|[;&|`]\s*|(?:sh|bash|zsh|dash)\s+-c\s+['"]?\s*)(?:\/usr\/bin\/)?(?:\benv(?:\s+(?:-0|--null|--))?\s*(?:$|[;&|>)`'"])|\bprintenv\b|\bexport\s*$|\bset\s*$|\bdeclare\s+-x|cat\s+\/etc\/environment|cat\s+\/root\/\.bashrc|cat\s+\/proc\/\d+\/environ|cat\s+\/proc\/self\/environ|strings\s+\/proc|xargs[^;&|\n]*\/proc\/[^;&|\n]*environ|less\s+\/proc|head\s+\/proc|tail\s+\/proc|xxd\s+\/proc|od\s+\/proc|base64\s+\/proc|dd\s[^\n]*\/proc|sed\s[^\n]*\/proc\/[^\n]*environ|awk\s[^\n]*\/proc\/[^\n]*environ|cp\s[^\n]*\/proc\/[^\n]*environ|(?:cat|less|head|tail|xxd|od|base64|strings|dd|cp|sed|awk)\s[^\n;&|]*\/etc\/mol(?:\/|\b))/i;
283
366
  /** Block shell redirects from /proc environ. */
284
367
  const BLOCKED_PROC_REDIRECT = /(?:<\s*\/proc\/(?:\d+|self)\/environ)/i;
285
368
  /** Block shell redirects from the molecule platform secrets dir (`/etc/mol`). */
@@ -550,6 +633,33 @@ export function truncate(s, maxLength) {
550
633
  export function truncateMiddle(s, maxLength) {
551
634
  if (s.length <= maxLength)
552
635
  return s;
636
+ // Room for the tally line it may append, so the result stays near the cap.
637
+ return withRunTally(s, truncateMiddleBody(s, Math.max(Math.floor(maxLength / 2), maxLength - 200)));
638
+ }
639
+ /**
640
+ * A test run's tally lines (` 3 failed`, ` 71 passed (4.6m)`) the cut text lost, put back at
641
+ * its end: Playwright lists every failed test after its "N failed" line, so a long list pushed
642
+ * the line itself into the cut middle and the run read as having no failures (X1 flash-m60).
643
+ *
644
+ * @param original - The whole output.
645
+ * @param cut - The truncated output.
646
+ * @returns The cut output, with any lost tally lines appended.
647
+ */
648
+ function withRunTally(original, cut) {
649
+ const tally = [
650
+ ...original.matchAll(/^\s*\d+ (?:failed|passed|flaky|skipped|did not run|interrupted)\b[^\n]{0,40}$/gm),
651
+ ].map((m) => m[0].trim());
652
+ const lost = tally.filter((line) => !cut.includes(line));
653
+ return lost.length ? `${cut}\n[run tally] ${lost.join(' · ')}` : cut;
654
+ }
655
+ /**
656
+ * {@link truncateMiddle} without the tally guard.
657
+ *
658
+ * @param s - The text.
659
+ * @param maxLength - The budget.
660
+ * @returns The cut text.
661
+ */
662
+ function truncateMiddleBody(s, maxLength) {
553
663
  // Reserve room for the elision notice so head + notice + tail stays WITHIN
554
664
  // maxLength — the cap is a real token budget the caller relies on (a head-only
555
665
  // truncate that overshoots by its suffix length breaks that contract). 140 =
@@ -620,6 +730,33 @@ export function failureBlocks(s) {
620
730
  blocks.push(current.join('\n').trimEnd());
621
731
  return blocks;
622
732
  }
733
+ /** Failure lines shown from a log that is still being written. */
734
+ export const RUNNING_FAILURE_LINES = 40;
735
+ /** Bytes of a running log's end appended after its failure lines. */
736
+ export const RUNNING_TAIL_BYTES = 1000;
737
+ /**
738
+ * The lines worth showing from a log that is still being written: a failed test's mark or
739
+ * numbered block header, and a runner's own error lines. Matched with `grep -E`.
740
+ */
741
+ export const RUNNING_FAILURE_LINE_RE = '^[[:space:]]*(✘|✗|×|✖|✕|[0-9]+\\)|[0-9]+ (passed|failed|flaky|skipped|did not run)|FAIL|FAILED|ERROR|Error:|error:|Traceback|AssertionError)';
742
+ /**
743
+ * The shell command whose stdout is the interesting part of a log that is still being written:
744
+ * the failure lines so far (capped at {@link RUNNING_FAILURE_LINES}), then the end of the log.
745
+ *
746
+ * A Playwright `list` run prints one line per test as it finishes and its error blocks only at
747
+ * the end, so the LAST bytes of a running log are its least informative part — in X1 flash-m61
748
+ * the last 3 000 bytes of a 139-test walk were the skip list, and all 61 failures were above
749
+ * them, which cost the run the grep it had to make anyway.
750
+ *
751
+ * @param log - The task log path.
752
+ * @param own - A file the command redirects its own output to, if any.
753
+ * @returns The shell command, safe to hand to the execution backend as-is.
754
+ */
755
+ export function runningOutputSliceCommand(log, own) {
756
+ const read = `cat ${log}${own ? ` ${shellQuote(own)}` : ''} 2>/dev/null`;
757
+ return (`{ ${read} | grep -aE ${shellQuote(RUNNING_FAILURE_LINE_RE)} | head -${RUNNING_FAILURE_LINES} | cut -c1-300; ` +
758
+ `echo; ${read} | tail -c ${RUNNING_TAIL_BYTES}; }`);
759
+ }
623
760
  /**
624
761
  * Attempt a whitespace-tolerant replacement when an exact `old_string` match
625
762
  * failed. Finds a contiguous run of lines in `content` whose per-line
@@ -720,3 +857,33 @@ export function parseCheckProblem(result) {
720
857
  return null;
721
858
  return text.slice(0, 1200);
722
859
  }
860
+ /**
861
+ * The line a replacement fused when it dropped the newline its `old_string`
862
+ * ended with: `new_string`'s text is followed on the same line by what used to
863
+ * be the next line. Read off the file as it now is, so the line shown is the
864
+ * line the parser is actually complaining about.
865
+ *
866
+ * @param content - The file's content after the edit.
867
+ * @param newString - A replacement's `new_string`, whose `old_string` ended with a newline.
868
+ * @returns The fused line's 1-based number and its text, or null when the
869
+ * replacement's text is not followed by more of the same line (it ends the
870
+ * file, the following newline survived, or another replacement rewrote it).
871
+ */
872
+ export function fusedLine(content, newString) {
873
+ for (let at = content.indexOf(newString); at >= 0; at = content.indexOf(newString, at + 1)) {
874
+ const endsAt = at + newString.length;
875
+ const next = content[endsAt];
876
+ // End of file (nothing was joined) or a newline (the boundary survived) are
877
+ // both "not fused". A later replacement in the same batch may have rewritten
878
+ // this text, so keep looking rather than giving up on the first occurrence.
879
+ if (next === undefined || next === '\n')
880
+ continue;
881
+ const lineStart = content.lastIndexOf('\n', at) + 1;
882
+ const lineEnd = content.indexOf('\n', endsAt);
883
+ return {
884
+ line: content.slice(0, lineStart).split('\n').length,
885
+ text: content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd),
886
+ };
887
+ }
888
+ return null;
889
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.5.14",
3
+ "version": "1.5.16",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",