@vincemakes/kiso-tools-node 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +60 -14
- package/dist/search-miss.d.ts +24 -0
- package/dist/search-miss.js +89 -0
- package/dist/search-worker.d.ts +6 -0
- package/dist/search-worker.js +5 -2
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { defineTool } from "@vincemakes/kiso-core";
|
|
|
29
29
|
// WR-1/WR-1A — the revision-guard primitives (unit-tested in wr1a-coda):
|
|
30
30
|
import { strippedShellEnv } from "./secret-env.js";
|
|
31
31
|
import { contentRevision, normalizeRevision, postEffectEscape, precondition, publishNewFile, revalidateBeforeRename } from "./wr1.js";
|
|
32
|
+
import { describeSearchMiss } from "./search-miss.js";
|
|
32
33
|
/**
|
|
33
34
|
* TUI2-R1 (C) — THE SHELL PROGRESS SIDECAR.
|
|
34
35
|
*
|
|
@@ -76,6 +77,12 @@ const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
|
|
|
76
77
|
// red line: every truncation names its continuation — the model always
|
|
77
78
|
// has a path to the full content.
|
|
78
79
|
const DEFAULT_READ_LINES = 200;
|
|
80
|
+
/** The default window's SECOND bound, and the one lines cannot express: 200
|
|
81
|
+
* lines of minified source is megabytes, 200 lines of prose is a few KB.
|
|
82
|
+
* Whichever binds first wins, and the cut is always at a line boundary. An
|
|
83
|
+
* explicit `limit` is the caller saying what they want and is not capped
|
|
84
|
+
* here — the 100k output cap still applies to it. */
|
|
85
|
+
const DEFAULT_READ_CHARS = 16_000;
|
|
79
86
|
/** R3: how many files a search may read before it hands the event loop
|
|
80
87
|
* back. Small enough that the 200ms motion cadence never misses a beat,
|
|
81
88
|
* large enough that the yield costs nothing on a small tree. */
|
|
@@ -305,13 +312,17 @@ const INODE_SCAN_MS = 2_000;
|
|
|
305
312
|
/** The "… N more lines" note — the actionable continuation: the exact
|
|
306
313
|
* line the next read must start at, so the model can always reach the
|
|
307
314
|
* full content in ranges (the red line). */
|
|
308
|
-
function moreLinesNote(nextOffset, remaining) {
|
|
309
|
-
|
|
315
|
+
function moreLinesNote(nextOffset, remaining, limit) {
|
|
316
|
+
// BOTH parameters. Naming only `offset` was an instruction to read the
|
|
317
|
+
// rest of the file: with `limit` absent the read runs to EOF, so a model
|
|
318
|
+
// following its own continuation note defeated the window from the second
|
|
319
|
+
// read onward. Measured before the fix: 7.3% of real reads took that path.
|
|
320
|
+
return `\n… ${remaining} more ${remaining === 1 ? "line" : "lines"} (call again with offset=${nextOffset} limit=${limit})`;
|
|
310
321
|
}
|
|
311
322
|
export function readFileTool(opts) {
|
|
312
323
|
return defineTool({
|
|
313
324
|
name: "read_file",
|
|
314
|
-
description: "Read a workspace file or a range
|
|
325
|
+
description: "Read a workspace file or a range. Without `limit`: 200 lines or 16000 chars from `offset` (default 1), whichever binds, then a note naming the next offset and limit. The final [rev:X] line identifies the version read.",
|
|
315
326
|
parameters: {
|
|
316
327
|
type: "object",
|
|
317
328
|
properties: {
|
|
@@ -398,21 +409,44 @@ export function readFileTool(opts) {
|
|
|
398
409
|
errorKind: "invalid_input",
|
|
399
410
|
};
|
|
400
411
|
}
|
|
401
|
-
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
412
|
+
// THE DEFAULT WINDOW applies whenever `limit` is ABSENT, from
|
|
413
|
+
// `offset ?? 1`. It used to apply only when BOTH were absent,
|
|
414
|
+
// so an offset alone read to the end of the file — and since
|
|
415
|
+
// the note named only `offset`, a model following its own
|
|
416
|
+
// continuation note left the window behind after one read.
|
|
405
417
|
let text;
|
|
406
418
|
let note = "";
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
419
|
+
if (limit === undefined) {
|
|
420
|
+
const lastLine = Math.min(start + DEFAULT_READ_LINES - 1, total);
|
|
421
|
+
const slice = parts.slice(start - 1, lastLine);
|
|
422
|
+
let body = slice.join("\n");
|
|
423
|
+
let shown = slice.length;
|
|
424
|
+
if (body.length > DEFAULT_READ_CHARS) {
|
|
425
|
+
const cut = body.lastIndexOf("\n", DEFAULT_READ_CHARS);
|
|
426
|
+
if (cut > 0) {
|
|
427
|
+
body = body.slice(0, cut);
|
|
428
|
+
shown = body.split("\n").length;
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
// No newline inside the budget: the first line alone
|
|
432
|
+
// is over it. One WHOLE line is the smallest honest
|
|
433
|
+
// answer — a cut mid-line is a lie about the file.
|
|
434
|
+
body = slice[0] ?? "";
|
|
435
|
+
shown = 1;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// The whole file from line 1 is returned VERBATIM, trailing
|
|
439
|
+
// newline included: `parts.join` would drop it.
|
|
440
|
+
text = start === 1 && shown === total ? content : body;
|
|
441
|
+
const next = start + shown;
|
|
442
|
+
if (next <= total)
|
|
443
|
+
note = moreLinesNote(next, total - next + 1, DEFAULT_READ_LINES);
|
|
411
444
|
}
|
|
412
445
|
else {
|
|
446
|
+
const end = Math.min(start + limit - 1, total);
|
|
413
447
|
text = parts.slice(start - 1, end).join("\n");
|
|
414
448
|
if (end < total)
|
|
415
|
-
note = moreLinesNote(end + 1, total - end);
|
|
449
|
+
note = moreLinesNote(end + 1, total - end, limit);
|
|
416
450
|
}
|
|
417
451
|
// The output cap's cut must STAY actionable: cut at a line
|
|
418
452
|
// boundary and name the exact next offset (the generic cap()
|
|
@@ -663,7 +697,12 @@ export function searchTextTool(opts) {
|
|
|
663
697
|
// CX-1 F4 (audit F4): the walk-and-match runs on its OWN thread, which
|
|
664
698
|
// the deadline and the abort both TERMINATE. A catastrophic regex used
|
|
665
699
|
// to block this loop — no budget check, timer or abort could run.
|
|
666
|
-
const outcome = await runSearchWorker(
|
|
700
|
+
const outcome = await runSearchWorker(
|
|
701
|
+
// The workspace root is realpath'd with the SAME helper the search
|
|
702
|
+
// root uses: `full` is walked from a realpath'd root, and making
|
|
703
|
+
// a path relative between a resolved and an unresolved base
|
|
704
|
+
// yields `../..` the moment a symlink sits between them.
|
|
705
|
+
{ token: 0, root: searchRootReal, workspaceRoot: realOrSelf(opts.workspaceRoot), single, pattern, flags, excluded, maxFileBytes, maxFiles, deadline, maxMatches: MAX_SEARCH_MATCHES, sniffBytes: BINARY_SNIFF_BYTES }, deadline, ctx.signal);
|
|
667
706
|
if (outcome.kind === "aborted")
|
|
668
707
|
return { content: "search_text aborted", isError: true, errorKind: "fatal" };
|
|
669
708
|
if (outcome.kind === "error")
|
|
@@ -946,7 +985,14 @@ export function editFileTool(opts) {
|
|
|
946
985
|
// WR-1A ④: the WORLD lacks the pattern (the input is
|
|
947
986
|
// fine) and nothing ran — precondition; the note never
|
|
948
987
|
// rides an edit that wrote nothing.
|
|
949
|
-
|
|
988
|
+
// The headline says WHAT failed; the detail says WHERE.
|
|
989
|
+
// A refusal that names the divergence costs one line
|
|
990
|
+
// here and saves a whole file read at the caller.
|
|
991
|
+
const headline = hunks.length === 1 && edits === undefined
|
|
992
|
+
? `edit_file: pattern not found in ${path}`
|
|
993
|
+
: `edit_file: pattern not found in ${path} (hunk ${i + 1})`;
|
|
994
|
+
const detail = describeSearchMiss(text, h.search);
|
|
995
|
+
return precondition(detail ? `${headline}\n${detail}` : headline);
|
|
950
996
|
}
|
|
951
997
|
spans.push({ start: at, end: at + h.search.length, replace: h.replace });
|
|
952
998
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a search does not match, say WHERE it stopped matching.
|
|
3
|
+
*
|
|
4
|
+
* `edit_file: pattern not found in src/report.js (hunk 2)` is true and
|
|
5
|
+
* useless. The tool has already established, by failing, that the text is
|
|
6
|
+
* not there — but it also knows, or can cheaply find out, how much of the
|
|
7
|
+
* search DID match and what the file has instead. Withholding that leaves
|
|
8
|
+
* one recourse: read the whole file again.
|
|
9
|
+
*
|
|
10
|
+
* This is not a guess about what callers need. Ten refused edits in one
|
|
11
|
+
* measured session were all `pattern not found`, none stale, none
|
|
12
|
+
* overlapping, and in every one of them a long prefix matched before the
|
|
13
|
+
* search ran into text the caller had not written yet — 93 of 223
|
|
14
|
+
* characters, 94 of 286, 306 of 913. Four more searched for an import
|
|
15
|
+
* line with the new symbol ALREADY IN IT. The failure has one shape: the
|
|
16
|
+
* search describes the file as it will be, not as it is. A message that
|
|
17
|
+
* names the divergence answers that in one line; the current one costs a
|
|
18
|
+
* whole file read to discover.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The detail lines for a failed search, or "" when there is nothing useful
|
|
22
|
+
* to say. The caller owns the headline; this is what follows it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function describeSearchMiss(text: string, search: string): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a search does not match, say WHERE it stopped matching.
|
|
3
|
+
*
|
|
4
|
+
* `edit_file: pattern not found in src/report.js (hunk 2)` is true and
|
|
5
|
+
* useless. The tool has already established, by failing, that the text is
|
|
6
|
+
* not there — but it also knows, or can cheaply find out, how much of the
|
|
7
|
+
* search DID match and what the file has instead. Withholding that leaves
|
|
8
|
+
* one recourse: read the whole file again.
|
|
9
|
+
*
|
|
10
|
+
* This is not a guess about what callers need. Ten refused edits in one
|
|
11
|
+
* measured session were all `pattern not found`, none stale, none
|
|
12
|
+
* overlapping, and in every one of them a long prefix matched before the
|
|
13
|
+
* search ran into text the caller had not written yet — 93 of 223
|
|
14
|
+
* characters, 94 of 286, 306 of 913. Four more searched for an import
|
|
15
|
+
* line with the new symbol ALREADY IN IT. The failure has one shape: the
|
|
16
|
+
* search describes the file as it will be, not as it is. A message that
|
|
17
|
+
* names the divergence answers that in one line; the current one costs a
|
|
18
|
+
* whole file read to discover.
|
|
19
|
+
*/
|
|
20
|
+
/** The longest prefix of `needle` that occurs in `hay`, by length.
|
|
21
|
+
*
|
|
22
|
+
* Monotone — if a prefix occurs then so does every shorter one — so this
|
|
23
|
+
* binary-searches instead of walking. A linear walk is O(m) substring
|
|
24
|
+
* searches, which on a large file and a long search is the kind of cost
|
|
25
|
+
* that turns a better error message into a worse tool.
|
|
26
|
+
*/
|
|
27
|
+
function longestMatchingPrefix(hay, needle) {
|
|
28
|
+
let lo = 0;
|
|
29
|
+
let hi = needle.length;
|
|
30
|
+
while (lo < hi) {
|
|
31
|
+
const mid = (lo + hi + 1) >> 1;
|
|
32
|
+
if (hay.includes(needle.slice(0, mid)))
|
|
33
|
+
lo = mid;
|
|
34
|
+
else
|
|
35
|
+
hi = mid - 1;
|
|
36
|
+
}
|
|
37
|
+
return lo;
|
|
38
|
+
}
|
|
39
|
+
/** 1-based line number of an offset. */
|
|
40
|
+
function lineAt(text, offset) {
|
|
41
|
+
let n = 1;
|
|
42
|
+
for (let i = 0; i < offset && i < text.length; i += 1)
|
|
43
|
+
if (text.charCodeAt(i) === 10)
|
|
44
|
+
n += 1;
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
/** A fragment for a one-line message: escaped, and bounded. */
|
|
48
|
+
function fragment(s, max = 60) {
|
|
49
|
+
const cut = s.slice(0, max);
|
|
50
|
+
const shown = JSON.stringify(cut).slice(1, -1); // drop the quotes, keep \n and \t visible
|
|
51
|
+
return s.length > max ? `${shown}…` : shown;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The detail lines for a failed search, or "" when there is nothing useful
|
|
55
|
+
* to say. The caller owns the headline; this is what follows it.
|
|
56
|
+
*/
|
|
57
|
+
export function describeSearchMiss(text, search) {
|
|
58
|
+
if (search.length === 0)
|
|
59
|
+
return "";
|
|
60
|
+
const matched = longestMatchingPrefix(text, search);
|
|
61
|
+
if (matched === 0) {
|
|
62
|
+
const firstLine = search.split("\n", 1)[0] ?? "";
|
|
63
|
+
return ` no part of it appears in the file — it begins "${fragment(firstLine)}"`;
|
|
64
|
+
}
|
|
65
|
+
// First occurrence, matching the tool's own first-occurrence semantics.
|
|
66
|
+
const at = text.indexOf(search.slice(0, matched));
|
|
67
|
+
const endOfMatch = at + matched;
|
|
68
|
+
const line = lineAt(text, at);
|
|
69
|
+
const endLine = lineAt(text, endOfMatch);
|
|
70
|
+
const head = ` ${matched} of ${search.length} characters matched, from line ${line} to line ${endLine}`;
|
|
71
|
+
const rest = search.slice(matched);
|
|
72
|
+
// RUNNING PAST THE END IS THE COMMON CASE AND ITS OWN SENTENCE. Four of
|
|
73
|
+
// the ten refusals in the measured session ended exactly here, and
|
|
74
|
+
// rendering that as `the file then has: ""` buries the one fact worth
|
|
75
|
+
// having: there is no more file. A caller that appended what it meant
|
|
76
|
+
// to ADD onto the end of what it meant to FIND reads its own mistake
|
|
77
|
+
// off this line.
|
|
78
|
+
if (endOfMatch >= text.length) {
|
|
79
|
+
return [
|
|
80
|
+
head,
|
|
81
|
+
` the file ENDS there — your search continues for ${rest.length} more characters: "${fragment(rest)}"`,
|
|
82
|
+
].join("\n");
|
|
83
|
+
}
|
|
84
|
+
return [
|
|
85
|
+
head,
|
|
86
|
+
` the file then has: "${fragment(text.slice(endOfMatch))}"`,
|
|
87
|
+
` your search wanted: "${fragment(rest)}"`,
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
package/dist/search-worker.d.ts
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
export interface SearchRequest {
|
|
19
19
|
readonly token: number;
|
|
20
20
|
readonly root: string;
|
|
21
|
+
/** The WORKSPACE root, which is not always the search root: a search under
|
|
22
|
+
* `packages/runtime` must still name `packages/runtime/src/run.ts` so the
|
|
23
|
+
* result can be handed to `read_file` unchanged. Realpath'd by the
|
|
24
|
+
* caller, because `full` is walked from a realpath'd root and a mixed
|
|
25
|
+
* pair produces `../..` the moment a symlink is involved. */
|
|
26
|
+
readonly workspaceRoot: string;
|
|
21
27
|
/** a single file to scan instead of walking `root` */
|
|
22
28
|
readonly single: string | null;
|
|
23
29
|
readonly pattern: string;
|
package/dist/search-worker.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* message from a superseded worker is ignored.
|
|
17
17
|
*/
|
|
18
18
|
import { open, readdir } from "node:fs/promises";
|
|
19
|
-
import { join, relative } from "node:path";
|
|
19
|
+
import { basename, join, relative } from "node:path";
|
|
20
20
|
import { isMainThread, parentPort } from "node:worker_threads";
|
|
21
21
|
export async function runSearch(req) {
|
|
22
22
|
const regex = new RegExp(req.pattern, req.flags);
|
|
@@ -77,8 +77,11 @@ export async function runSearch(req) {
|
|
|
77
77
|
for (const [i, line] of text.split("\n").entries()) {
|
|
78
78
|
if (regex.test(line)) {
|
|
79
79
|
totalMatches += 1;
|
|
80
|
+
// WORKSPACE-RELATIVE, not absolute: `read_file` refuses an
|
|
81
|
+
// absolute path, so an absolute hit here is a result the
|
|
82
|
+
// model cannot feed back without rewriting it by hand.
|
|
80
83
|
if (matches.length < req.maxMatches)
|
|
81
|
-
matches.push(`${full}:${i + 1}: ${line.trim().slice(0, 160)}`);
|
|
84
|
+
matches.push(`${relative(req.workspaceRoot, full) || basename(full)}:${i + 1}: ${line.trim().slice(0, 160)}`);
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tools-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "kiso coding tools for Node hosts \u2014 read file, list directory, search text, write/edit file, shell command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"test": "vitest run"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@vincemakes/kiso-core": "0.
|
|
28
|
+
"@vincemakes/kiso-core": "0.37.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^26.1.2",
|