@vincemakes/kiso-tools-node 0.24.3 → 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 +286 -27
- 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
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* states what was dropped (deterministic per file state), so the model
|
|
18
18
|
* always has a path to the full content.
|
|
19
19
|
*/
|
|
20
|
-
import { execFileSync, spawn } from "node:child_process";
|
|
20
|
+
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
21
|
+
import { promisify } from "node:util";
|
|
21
22
|
import { chmodSync, existsSync, linkSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, appendFileSync, mkdirSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
22
|
-
import { readdir } from "node:fs/promises";
|
|
23
|
+
import { open, readdir } from "node:fs/promises";
|
|
23
24
|
import { createHash } from "node:crypto";
|
|
24
25
|
import { tmpdir } from "node:os";
|
|
25
26
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -79,6 +80,36 @@ const DEFAULT_READ_LINES = 200;
|
|
|
79
80
|
const YIELD_EVERY = 64;
|
|
80
81
|
const MAX_SEARCH_MATCHES = 50;
|
|
81
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
|
+
}
|
|
82
113
|
function cap(text) {
|
|
83
114
|
return text.length > OUTPUT_CAP ? `${text.slice(0, OUTPUT_CAP)}\n…[truncated]` : text;
|
|
84
115
|
}
|
|
@@ -186,12 +217,41 @@ export function canonicalTargetPath(input) {
|
|
|
186
217
|
* - non-regular files (sockets, devices, fifos): refused.
|
|
187
218
|
* Returns a denial reason, or null when the file is safe to read.
|
|
188
219
|
*/
|
|
189
|
-
|
|
220
|
+
/** DC-52 — the guard's verdict, per (dev, ino), for this process. The
|
|
221
|
+
* scan is the expensive thing; the answer is a fact about an inode and
|
|
222
|
+
* does not change under us within a call. */
|
|
223
|
+
const inodeVerdict = new Map();
|
|
224
|
+
/**
|
|
225
|
+
* DC-52 — BOUNDED, ASYNCHRONOUS, AND SILENT.
|
|
226
|
+
*
|
|
227
|
+
* This was `execFileSync("find", [root, "-xdev", "-inum", …])` with no
|
|
228
|
+
* `stdio`, run once per multi-link file. Three faults in one line:
|
|
229
|
+
*
|
|
230
|
+
* 1. no `stdio` gives the child the PARENT'S stderr, which is the
|
|
231
|
+
* terminal — `find: …: Operation not permitted` went straight past
|
|
232
|
+
* the compositor's frame and over the composer;
|
|
233
|
+
* 2. the scan is unbounded, and SYNCHRONOUS: with the workspace root
|
|
234
|
+
* at `~` a single call is tens of seconds with the event loop
|
|
235
|
+
* frozen, so `esc` does nothing and the whole product looks hung;
|
|
236
|
+
* 3. it ran for `search_text` too, which returns a 160-character
|
|
237
|
+
* excerpt — a disk traversal to decide whether a line may be
|
|
238
|
+
* quoted is not a trade anyone would make.
|
|
239
|
+
*
|
|
240
|
+
* Now: `execFile` with a 2s budget, stderr discarded, verdict cached.
|
|
241
|
+
* A scan that does not finish inside the budget is fail-closed exactly
|
|
242
|
+
* as an unverifiable one always was — refused, never hung. Fault 3 is
|
|
243
|
+
* answered by `search_text` not calling this at all.
|
|
244
|
+
*/
|
|
245
|
+
async function inodeReadPolicy(root, full) {
|
|
190
246
|
const st = statSync(full);
|
|
191
247
|
if (!st.isFile())
|
|
192
248
|
return `not a regular file — refusing to read (${full})`;
|
|
193
249
|
if (st.nlink <= 1)
|
|
194
250
|
return null;
|
|
251
|
+
const key = `${st.dev}:${st.ino}`;
|
|
252
|
+
const cached = inodeVerdict.get(key);
|
|
253
|
+
if (cached !== undefined)
|
|
254
|
+
return cached;
|
|
195
255
|
// round 4: the link count is verified STRUCTURALLY, never by counting
|
|
196
256
|
// newline-split text. `find -print0` emits NUL-separated paths — a file
|
|
197
257
|
// named "inside\nspoof" is ONE path, not two — and every match is then
|
|
@@ -205,7 +265,15 @@ function inodeReadPolicy(root, full) {
|
|
|
205
265
|
let inside = 0;
|
|
206
266
|
try {
|
|
207
267
|
const rootReal = realpathSync(root);
|
|
208
|
-
const out =
|
|
268
|
+
const { stdout: out } = await promisify(execFile)("find", [rootReal, "-xdev", "-inum", String(st.ino), "-print0"],
|
|
269
|
+
// DC-52: the child never outlives the budget, and its stderr
|
|
270
|
+
// never reaches the terminal. The second is the ASYNC form's
|
|
271
|
+
// own doing and is the whole reason to prefer it here:
|
|
272
|
+
// `execFileSync` without an explicit `stdio` gives the child
|
|
273
|
+
// the PARENT'S stderr, which is how `find: … Operation not
|
|
274
|
+
// permitted` got over the composer. `execFile` pipes both
|
|
275
|
+
// streams into the callback; there is nowhere for it to go.
|
|
276
|
+
{ encoding: "utf8", maxBuffer: 1 << 20, timeout: INODE_SCAN_MS });
|
|
209
277
|
for (const path of out.split("\0")) {
|
|
210
278
|
if (path === "")
|
|
211
279
|
continue;
|
|
@@ -223,12 +291,15 @@ function inodeReadPolicy(root, full) {
|
|
|
223
291
|
catch {
|
|
224
292
|
inside = -1; // cannot verify — refuse (fail-closed)
|
|
225
293
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return
|
|
294
|
+
const verdict = inside < 0 || inside < st.nlink
|
|
295
|
+
? `file has hard links outside the workspace (${inside < 0 ? "unverifiable" : `${inside}/${st.nlink}`} inside) — refusing to read (${full})`
|
|
296
|
+
: null;
|
|
297
|
+
inodeVerdict.set(key, verdict);
|
|
298
|
+
return verdict;
|
|
231
299
|
}
|
|
300
|
+
/** DC-52 — the guard's budget. A scan that outruns it is fail-closed,
|
|
301
|
+
* which is what an unverifiable scan has always been. */
|
|
302
|
+
const INODE_SCAN_MS = 2_000;
|
|
232
303
|
/** The "… N more lines" note — the actionable continuation: the exact
|
|
233
304
|
* line the next read must start at, so the model can always reach the
|
|
234
305
|
* full content in ranges (the red line). */
|
|
@@ -260,13 +331,44 @@ export function readFileTool(opts) {
|
|
|
260
331
|
promptSnippet: "read_file — whole files or offset/limit ranges, workspace-relative paths",
|
|
261
332
|
promptGuidelines: ["read only the range you need — offset/limit beat whole-file reads"],
|
|
262
333
|
execute: async ({ path, offset, limit }) => {
|
|
334
|
+
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
263
335
|
try {
|
|
264
336
|
const full = resolveWithinRoot(opts.workspaceRoot, path);
|
|
265
|
-
const denied = inodeReadPolicy(opts.workspaceRoot, full);
|
|
337
|
+
const denied = await inodeReadPolicy(opts.workspaceRoot, full);
|
|
266
338
|
if (denied !== null)
|
|
267
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
|
+
}
|
|
268
354
|
// WR-1: hash the raw bytes BEFORE decoding — the revision is a
|
|
269
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.
|
|
270
372
|
const bytes = readFileSync(full);
|
|
271
373
|
const content = bytes.toString("utf8");
|
|
272
374
|
// The lines the file DISPLAYS: a trailing newline's empty split
|
|
@@ -359,15 +461,26 @@ export function listDirTool(opts) {
|
|
|
359
461
|
execute: async ({ path }) => {
|
|
360
462
|
try {
|
|
361
463
|
const dir = resolveWithinRoot(opts.workspaceRoot, path ?? ".");
|
|
362
|
-
|
|
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) => {
|
|
363
476
|
const isDir = e.isDirectory();
|
|
364
477
|
return `${isDir ? "dir " : "file"} ${e.name}${isDir ? "/" : ""}`;
|
|
365
478
|
});
|
|
366
|
-
let content = entries.length ? cap(entries.
|
|
367
|
-
if (
|
|
479
|
+
let content = entries.length ? cap(entries.join("\n")) : "(empty directory)";
|
|
480
|
+
if (dirents.length > MAX_DIR_ENTRIES) {
|
|
368
481
|
// R-C item 2: the N of M form — the cap names its
|
|
369
482
|
// continuation (narrow to a subdirectory for more).
|
|
370
|
-
content += `\n… ${MAX_DIR_ENTRIES} of ${
|
|
483
|
+
content += `\n… ${MAX_DIR_ENTRIES} of ${dirents.length} entries shown (narrow to a subdirectory for more)`;
|
|
371
484
|
}
|
|
372
485
|
return { content, isError: false };
|
|
373
486
|
}
|
|
@@ -442,6 +555,39 @@ export function searchTextTool(opts) {
|
|
|
442
555
|
// depth cap and the node_modules/dotfile skip stay.
|
|
443
556
|
const matches = [];
|
|
444
557
|
let totalMatches = 0;
|
|
558
|
+
// DC-52 — what the search did NOT look at, so the note can say so.
|
|
559
|
+
let multiLink = 0;
|
|
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
|
+
};
|
|
445
591
|
// R3 — the walk YIELDS. It was `readdirSync` + `readFileSync` all
|
|
446
592
|
// the way down inside an `async` body, which is the shape that
|
|
447
593
|
// blocks Node's event loop for the whole traversal: measured at
|
|
@@ -451,9 +597,15 @@ export function searchTextTool(opts) {
|
|
|
451
597
|
//
|
|
452
598
|
// The fix is not "make it faster" — a big tree is legitimately
|
|
453
599
|
// slow. It is to stop OWNING the loop: `fs.promises.readdir`,
|
|
454
|
-
// and a yield every YIELD_EVERY files
|
|
455
|
-
//
|
|
456
|
-
//
|
|
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.
|
|
457
609
|
let sinceYield = 0;
|
|
458
610
|
const breathe = async () => {
|
|
459
611
|
sinceYield += 1;
|
|
@@ -468,17 +620,85 @@ export function searchTextTool(opts) {
|
|
|
468
620
|
// Two copies would be two answers to "what does searching this
|
|
469
621
|
// file mean".
|
|
470
622
|
const scanFile = async (full) => {
|
|
623
|
+
if (outOfBudget())
|
|
624
|
+
return;
|
|
625
|
+
filesSeen += 1;
|
|
471
626
|
await breathe();
|
|
472
627
|
try {
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
628
|
+
// DC-52 — SEARCH DOES NOT RUN THE INODE GUARD.
|
|
629
|
+
//
|
|
630
|
+
// Round 8 gave search the same inode boundary read_file
|
|
631
|
+
// has, and the boundary is right; what was wrong is the
|
|
632
|
+
// price. The guard's verification is a `find` over the
|
|
633
|
+
// whole workspace root, once per multi-link file — with
|
|
634
|
+
// the root at `~` that is tens of seconds each, and it
|
|
635
|
+
// ran on the owner's machine for eight minutes without
|
|
636
|
+
// finishing. A search returns a 160-character excerpt of
|
|
637
|
+
// a line. No excerpt is worth a disk traversal.
|
|
638
|
+
//
|
|
639
|
+
// So a multi-link file is SKIPPED, and counted, and the
|
|
640
|
+
// count is said. That is fail-closed at zero cost: the
|
|
641
|
+
// external-link case the guard exists for is refused
|
|
642
|
+
// exactly as before, and the legal case is refused too,
|
|
643
|
+
// which is a loss of coverage rather than of safety.
|
|
644
|
+
// read_file keeps the guard (bounded, above), and that
|
|
645
|
+
// is where the file's contents can actually be had.
|
|
646
|
+
const st = statSync(full);
|
|
647
|
+
if (st.nlink > 1) {
|
|
648
|
+
multiLink += 1;
|
|
480
649
|
return;
|
|
481
|
-
|
|
650
|
+
}
|
|
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
|
+
}
|
|
482
702
|
for (const [i, line] of text.split("\n").entries()) {
|
|
483
703
|
if (regex.test(line)) {
|
|
484
704
|
totalMatches += 1;
|
|
@@ -493,9 +713,32 @@ export function searchTextTool(opts) {
|
|
|
493
713
|
}
|
|
494
714
|
};
|
|
495
715
|
const walk = async (dir, depth) => {
|
|
496
|
-
if (depth > 8)
|
|
716
|
+
if (depth > 8 || outOfBudget())
|
|
497
717
|
return;
|
|
498
|
-
|
|
718
|
+
// DC-52 — a directory the OS REFUSES is skipped, not fatal.
|
|
719
|
+
//
|
|
720
|
+
// An unreadable FILE has always been skipped (the catch in
|
|
721
|
+
// scanFile); an unreadable DIRECTORY threw out of the walk
|
|
722
|
+
// and failed the whole tool. On macOS that is not an edge
|
|
723
|
+
// case — `~/Library/Accounts` and its neighbours are TCC
|
|
724
|
+
// protected, so a search anywhere under `~` died on one of
|
|
725
|
+
// them. The asymmetry was the defect: same fact, same
|
|
726
|
+
// remedy.
|
|
727
|
+
let entries;
|
|
728
|
+
try {
|
|
729
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
730
|
+
}
|
|
731
|
+
catch (err) {
|
|
732
|
+
const code = err.code;
|
|
733
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
734
|
+
unreadableDirs += 1;
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
throw err;
|
|
738
|
+
}
|
|
739
|
+
for (const entry of entries) {
|
|
740
|
+
if (outOfBudget())
|
|
741
|
+
return;
|
|
499
742
|
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
500
743
|
continue;
|
|
501
744
|
const full = join(dir, entry.name);
|
|
@@ -515,6 +758,22 @@ export function searchTextTool(opts) {
|
|
|
515
758
|
return { content: `search_text failed: ${err.message}`, isError: true, errorKind: "fatal" };
|
|
516
759
|
}
|
|
517
760
|
let content = matches.length ? cap(matches.join("\n")) : "(no matches)";
|
|
761
|
+
// DC-52: what was NOT searched is said. A silent skip is a
|
|
762
|
+
// result the model cannot tell is incomplete.
|
|
763
|
+
if (multiLink > 0)
|
|
764
|
+
content += `\n… ${multiLink} multi-link ${multiLink === 1 ? "file" : "files"} skipped (read_file verifies them individually)`;
|
|
765
|
+
if (unreadableDirs > 0)
|
|
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
|
+
}
|
|
518
777
|
if (totalMatches > matches.length) {
|
|
519
778
|
// R-C item 2: the N of M form — the cap names its
|
|
520
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",
|