@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 +16 -0
- package/dist/index.js +185 -11
- package/package.json +2 -2
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
|
-
|
|
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.
|
|
408
|
-
if (
|
|
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 ${
|
|
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
|
|
499
|
-
//
|
|
500
|
-
//
|
|
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
|
-
|
|
646
|
+
const st = statSync(full);
|
|
647
|
+
if (st.nlink > 1) {
|
|
536
648
|
multiLink += 1;
|
|
537
649
|
return;
|
|
538
650
|
}
|
|
539
|
-
|
|
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.
|
|
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.
|
|
24
|
+
"@vincemakes/kiso-core": "0.24.5"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|