@vincemakes/kiso-tools-node 0.24.4 → 0.24.5

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.d.ts CHANGED
@@ -93,6 +93,22 @@ export interface WorkspaceToolsOptions {
93
93
  * the keys are an exposure surface for any command).
94
94
  */
95
95
  readonly shellEnv?: "inherit";
96
+ /**
97
+ * DC-54 — the bounds that keep a tool call finite. Every field is
98
+ * optional and defaults to the constant beside it; a host embedding
99
+ * kiso over an unusually large or unusually small tree can move them,
100
+ * and the gate can set them low enough to observe the stop.
101
+ */
102
+ readonly limits?: {
103
+ /** search_text: skip a file larger than this (default 1 MiB). */
104
+ readonly searchMaxFileBytes?: number;
105
+ /** search_text: stop the walk after this many files (default 20,000). */
106
+ readonly searchMaxFiles?: number;
107
+ /** search_text: stop the walk after this long (default 10s). */
108
+ readonly searchMaxMs?: number;
109
+ /** read_file: refuse a file larger than this (default 64 MiB). */
110
+ readonly readMaxFileBytes?: number;
111
+ };
96
112
  }
97
113
  export declare function readFileTool(opts: WorkspaceToolsOptions): Tool<{
98
114
  path: string;
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
  import { execFile, execFileSync, spawn } from "node:child_process";
21
21
  import { promisify } from "node:util";
22
22
  import { chmodSync, existsSync, linkSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, appendFileSync, mkdirSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
23
- import { readdir } from "node:fs/promises";
23
+ import { open, readdir } from "node:fs/promises";
24
24
  import { createHash } from "node:crypto";
25
25
  import { tmpdir } from "node:os";
26
26
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -80,6 +80,36 @@ const DEFAULT_READ_LINES = 200;
80
80
  const YIELD_EVERY = 64;
81
81
  const MAX_SEARCH_MATCHES = 50;
82
82
  const MAX_DIR_ENTRIES = 200;
83
+ /**
84
+ * DC-54 — the bounds. The defect they close: `search_text` read every
85
+ * file WHOLE and SYNCHRONOUSLY, so one 994 GB sparse disk image under a
86
+ * workspace rooted at `~` stopped the event loop and never restarted it.
87
+ *
88
+ * Each constant answers a different unbounded thing, and all four are
89
+ * needed — per-file bounds still leave 296,924 files to traverse under
90
+ * `~`, and a call that traverses forever is a freeze whatever it does
91
+ * per file. The tool must ALWAYS return.
92
+ */
93
+ /** Skip a file larger than this. Not a PREFIX read: a partial match is a
94
+ * result that has to be explained, and a source file over 1 MiB is
95
+ * almost never what the model was looking for. */
96
+ const SEARCH_MAX_FILE_BYTES = 1024 * 1024;
97
+ /** Stop the walk after this many files, whatever it has found. */
98
+ const SEARCH_MAX_FILES = 20_000;
99
+ /** Stop the walk after this long, whatever it has found. */
100
+ const SEARCH_MAX_MS = 10_000;
101
+ /** read_file refuses above this rather than freezing on it. The ceiling
102
+ * is a REFUSAL, not a truncation, because the `[rev:…]` token hashes
103
+ * the whole file: a revision issued over a prefix would never match the
104
+ * full-file hash write_file computes, and every later write would be
105
+ * refused as stale. No read, no revision, no lie. */
106
+ const READ_MAX_FILE_BYTES = 64 * 1024 * 1024;
107
+ /** How much of a file's head decides whether it is text. */
108
+ const BINARY_SNIFF_BYTES = 8 * 1024;
109
+ /** Bytes as the refusal should say them: "128.0 MiB". */
110
+ function mib(bytes) {
111
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
112
+ }
83
113
  function cap(text) {
84
114
  return text.length > OUTPUT_CAP ? `${text.slice(0, OUTPUT_CAP)}\n…[truncated]` : text;
85
115
  }
@@ -301,13 +331,44 @@ export function readFileTool(opts) {
301
331
  promptSnippet: "read_file — whole files or offset/limit ranges, workspace-relative paths",
302
332
  promptGuidelines: ["read only the range you need — offset/limit beat whole-file reads"],
303
333
  execute: async ({ path, offset, limit }) => {
334
+ const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
304
335
  try {
305
336
  const full = resolveWithinRoot(opts.workspaceRoot, path);
306
337
  const denied = await inodeReadPolicy(opts.workspaceRoot, full);
307
338
  if (denied !== null)
308
339
  return escapeResult(denied);
340
+ // DC-54 — the ceiling. `read_file` had the same unbounded
341
+ // `readFileSync` that froze `search_text`, and the same 994 GB
342
+ // `Docker.raw` would have frozen it identically.
343
+ //
344
+ // It REFUSES rather than truncating, because the `[rev:…]`
345
+ // token this tool issues hashes the whole file: a revision
346
+ // computed over a prefix would never equal the full-file hash
347
+ // `write_file` computes, so every later write would be refused
348
+ // as stale. A bounded read here would buy a freeze-free read
349
+ // at the price of a write path that silently stops working.
350
+ const size = statSync(full).size;
351
+ if (size > maxReadBytes) {
352
+ return precondition(`read_file: ${path} is ${mib(size)} — too large to read (ceiling ${mib(maxReadBytes)}); use shell with sed/head to take a range`);
353
+ }
309
354
  // WR-1: hash the raw bytes BEFORE decoding — the revision is a
310
355
  // fact about the world, not about UTF-8 replacement semantics.
356
+ //
357
+ // DC-54, and this read stays SYNCHRONOUS on purpose. The
358
+ // ruling said to make it async; the first build did, and
359
+ // `tui2-r1-visibility` went red on an invariant worth more
360
+ // than the microseconds: the durable log of three concurrent
361
+ // `read_file` calls stopped being deterministic, because
362
+ // completion order became the libuv threadpool's to decide.
363
+ // That test compares a PTY session's log to a pipe session's
364
+ // to prove the rollup is display-side only, and it cannot do
365
+ // that job over a log that varies run to run.
366
+ //
367
+ // The freeze came from UNBOUNDED work, not from synchronous
368
+ // work. With the ceiling above, the worst case here is a
369
+ // 64 MiB read — about 100 ms, measured — against the 180+
370
+ // seconds this finding is named for. Determinism is worth
371
+ // more than that hitch.
311
372
  const bytes = readFileSync(full);
312
373
  const content = bytes.toString("utf8");
313
374
  // The lines the file DISPLAYS: a trailing newline's empty split
@@ -400,15 +461,26 @@ export function listDirTool(opts) {
400
461
  execute: async ({ path }) => {
401
462
  try {
402
463
  const dir = resolveWithinRoot(opts.workspaceRoot, path ?? ".");
403
- const entries = readdirSync(dir, { withFileTypes: true }).map((e) => {
464
+ // DC-54 TRUNCATED BEFORE IT BUILDS. It was a `.map` over
465
+ // EVERY entry with the 200-entry slice only after: a directory
466
+ // of 200,000 entries built 200,000 strings to show 200 of
467
+ // them. Milder than the `search_text` freeze and the same
468
+ // mistake — unbounded work before the bound.
469
+ //
470
+ // `readdirSync`, still: same reason as read_file above. The
471
+ // listing is one syscall whose cost is the directory's size,
472
+ // which no bound of ours can shrink, and making it async buys
473
+ // nothing while costing the durable log its determinism.
474
+ const dirents = readdirSync(dir, { withFileTypes: true });
475
+ const entries = dirents.slice(0, MAX_DIR_ENTRIES).map((e) => {
404
476
  const isDir = e.isDirectory();
405
477
  return `${isDir ? "dir " : "file"} ${e.name}${isDir ? "/" : ""}`;
406
478
  });
407
- let content = entries.length ? cap(entries.slice(0, MAX_DIR_ENTRIES).join("\n")) : "(empty directory)";
408
- if (entries.length > MAX_DIR_ENTRIES) {
479
+ let content = entries.length ? cap(entries.join("\n")) : "(empty directory)";
480
+ if (dirents.length > MAX_DIR_ENTRIES) {
409
481
  // R-C item 2: the N of M form — the cap names its
410
482
  // continuation (narrow to a subdirectory for more).
411
- content += `\n… ${MAX_DIR_ENTRIES} of ${entries.length} entries shown (narrow to a subdirectory for more)`;
483
+ content += `\n… ${MAX_DIR_ENTRIES} of ${dirents.length} entries shown (narrow to a subdirectory for more)`;
412
484
  }
413
485
  return { content, isError: false };
414
486
  }
@@ -486,6 +558,36 @@ export function searchTextTool(opts) {
486
558
  // DC-52 — what the search did NOT look at, so the note can say so.
487
559
  let multiLink = 0;
488
560
  let unreadableDirs = 0;
561
+ // DC-54 — the same discipline for the two new refusals, and for
562
+ // the call budget: a silent skip is a result the model cannot
563
+ // tell is incomplete.
564
+ let skippedFiles = 0;
565
+ let filesSeen = 0;
566
+ // An explicit flag, NOT `stoppedAt > 0`: a wall-clock budget can
567
+ // expire before the first file is scanned, and a zero-valued
568
+ // sentinel would then read as "never stopped" — the walk would
569
+ // still end, but silently, which is the one thing every note in
570
+ // this function exists to prevent.
571
+ let stopped = false;
572
+ let stoppedAt = 0;
573
+ const maxFileBytes = opts.limits?.searchMaxFileBytes ?? SEARCH_MAX_FILE_BYTES;
574
+ const maxFiles = opts.limits?.searchMaxFiles ?? SEARCH_MAX_FILES;
575
+ const deadline = Date.now() + (opts.limits?.searchMaxMs ?? SEARCH_MAX_MS);
576
+ /** DC-54 ④ — the CALL budget. Per-file bounds are not enough:
577
+ * under `~` the walk still reaches 296,924 files, and 10 seconds
578
+ * of traversal with nothing on screen is the same freeze from
579
+ * the outside. Whichever bound trips first stops the walk, and
580
+ * the note names the continuation. */
581
+ const outOfBudget = () => {
582
+ if (stopped)
583
+ return true;
584
+ if (filesSeen >= maxFiles || Date.now() > deadline) {
585
+ stopped = true;
586
+ stoppedAt = filesSeen;
587
+ return true;
588
+ }
589
+ return false;
590
+ };
489
591
  // R3 — the walk YIELDS. It was `readdirSync` + `readFileSync` all
490
592
  // the way down inside an `async` body, which is the shape that
491
593
  // blocks Node's event loop for the whole traversal: measured at
@@ -495,9 +597,15 @@ export function searchTextTool(opts) {
495
597
  //
496
598
  // The fix is not "make it faster" — a big tree is legitimately
497
599
  // slow. It is to stop OWNING the loop: `fs.promises.readdir`,
498
- // and a yield every YIELD_EVERY files so a long read stretch
499
- // cannot monopolise it either. Same traversal, same order, same
500
- // caps, same results; only the loop is shared now.
600
+ // and a yield every YIELD_EVERY files.
601
+ //
602
+ // DC-54 corrects what this comment used to claim next — that
603
+ // the yield also stopped "a long read stretch" monopolising the
604
+ // loop. It never did and could not: `breathe()` runs BETWEEN
605
+ // files, and nothing preempts a single synchronous read once
606
+ // entered. R3 bounded the traversal; the FILE stayed unbounded
607
+ // until DC-54 ①–③ above, and one 994 GB image was enough to
608
+ // stop the process dead with this yield fully in place.
501
609
  let sinceYield = 0;
502
610
  const breathe = async () => {
503
611
  sinceYield += 1;
@@ -512,6 +620,9 @@ export function searchTextTool(opts) {
512
620
  // Two copies would be two answers to "what does searching this
513
621
  // file mean".
514
622
  const scanFile = async (full) => {
623
+ if (outOfBudget())
624
+ return;
625
+ filesSeen += 1;
515
626
  await breathe();
516
627
  try {
517
628
  // DC-52 — SEARCH DOES NOT RUN THE INODE GUARD.
@@ -532,11 +643,62 @@ export function searchTextTool(opts) {
532
643
  // which is a loss of coverage rather than of safety.
533
644
  // read_file keeps the guard (bounded, above), and that
534
645
  // is where the file's contents can actually be had.
535
- if (statSync(full).nlink > 1) {
646
+ const st = statSync(full);
647
+ if (st.nlink > 1) {
536
648
  multiLink += 1;
537
649
  return;
538
650
  }
539
- const text = readFileSync(full, "utf8");
651
+ // DC-54 — SIZE, decided before anything is opened.
652
+ //
653
+ // This is the line that was missing when a workspace
654
+ // rooted at `~` met `Docker.raw`: 994 GB went into
655
+ // `readFileSync(full, "utf8")` and the process never came
656
+ // back. Measured on the owner's machine, without it: a
657
+ // 467 MB `.mov` read SUCCESSFULLY, 3,817 ms of dead loop
658
+ // and 2.06 GB of RSS, split into 1,823,112 "lines".
659
+ if (st.size > maxFileBytes) {
660
+ skippedFiles += 1;
661
+ return;
662
+ }
663
+ // DC-54 ② — BINARY, decided from the head alone.
664
+ //
665
+ // One open serves both the sniff and the read. The sniff
666
+ // passes an explicit position, which by contract leaves
667
+ // the handle's own position at 0, so `readFile()` below
668
+ // still sees the whole file.
669
+ const fh = await open(full, "r");
670
+ let text;
671
+ try {
672
+ const headLen = Math.min(BINARY_SNIFF_BYTES, st.size);
673
+ const head = Buffer.alloc(headLen);
674
+ if (headLen > 0) {
675
+ // An explicit `position` leaves the handle's own
676
+ // position at 0, so the whole-file read below still
677
+ // starts at byte 0. The gate proves it with a
678
+ // needle placed PAST the sniff window: were the
679
+ // position to advance, every line number in this
680
+ // file's matches would be silently wrong.
681
+ await fh.read(head, 0, headLen, 0);
682
+ if (head.includes(0)) {
683
+ skippedFiles += 1;
684
+ return;
685
+ }
686
+ }
687
+ // DC-54 ③ — the read is ASYNCHRONOUS. R3 made the walk
688
+ // yield and its comment claimed that stopped a long
689
+ // read owning the loop; it never could. `breathe()`
690
+ // runs BETWEEN files, and nothing preempts a
691
+ // `readFileSync` once entered. Bounded above by ①, so
692
+ // this is at most a 1 MiB read that shares the loop.
693
+ //
694
+ // A file that fits inside the sniff window is already
695
+ // entirely in `head`: reading it a second time would
696
+ // double the syscalls for the commonest small file.
697
+ text = (st.size <= headLen ? head : await fh.readFile()).toString("utf8");
698
+ }
699
+ finally {
700
+ await fh.close();
701
+ }
540
702
  for (const [i, line] of text.split("\n").entries()) {
541
703
  if (regex.test(line)) {
542
704
  totalMatches += 1;
@@ -551,7 +713,7 @@ export function searchTextTool(opts) {
551
713
  }
552
714
  };
553
715
  const walk = async (dir, depth) => {
554
- if (depth > 8)
716
+ if (depth > 8 || outOfBudget())
555
717
  return;
556
718
  // DC-52 — a directory the OS REFUSES is skipped, not fatal.
557
719
  //
@@ -575,6 +737,8 @@ export function searchTextTool(opts) {
575
737
  throw err;
576
738
  }
577
739
  for (const entry of entries) {
740
+ if (outOfBudget())
741
+ return;
578
742
  if (entry.name.startsWith(".") || entry.name === "node_modules")
579
743
  continue;
580
744
  const full = join(dir, entry.name);
@@ -600,6 +764,16 @@ export function searchTextTool(opts) {
600
764
  content += `\n… ${multiLink} multi-link ${multiLink === 1 ? "file" : "files"} skipped (read_file verifies them individually)`;
601
765
  if (unreadableDirs > 0)
602
766
  content += `\n… ${unreadableDirs} unreadable ${unreadableDirs === 1 ? "directory" : "directories"} skipped`;
767
+ // DC-54 — one merged sentence, and only when it happened. A note
768
+ // that always fires says nothing.
769
+ if (skippedFiles > 0 || stopped) {
770
+ const parts = [];
771
+ if (skippedFiles > 0)
772
+ parts.push(`${skippedFiles} ${skippedFiles === 1 ? "file" : "files"} skipped (large or binary)`);
773
+ if (stopped)
774
+ parts.push(`stopped after ${stoppedAt} ${stoppedAt === 1 ? "file" : "files"} — narrow the path`);
775
+ content += `\n… ${parts.join(" · ")}`;
776
+ }
603
777
  if (totalMatches > matches.length) {
604
778
  // R-C item 2: the N of M form — the cap names its
605
779
  // continuation (narrow the pattern for more).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.24.4",
3
+ "version": "0.24.5",
4
4
  "description": "kiso coding tools for Node hosts — read file, list directory, search text, write/edit file, shell command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@vincemakes/kiso-core": "0.24.4"
24
+ "@vincemakes/kiso-core": "0.24.5"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",