@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 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
- return `\n… ${remaining} more ${remaining === 1 ? "line" : "lines"} (call again with offset=${nextOffset})`;
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 (offset/limit; default: the first 200 lines, with a continuation note). The final [rev:X] line identifies the version read.",
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
- const end = limit === undefined ? total : Math.min(start + limit - 1, total);
402
- // DEFAULT: the head 200 lines; a larger file ends with the
403
- // honest continuation note (small files ≤ 200 lines are
404
- // byte-identical to the pre-token-round behavior).
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 (offset === undefined && limit === undefined) {
408
- text = total <= DEFAULT_READ_LINES ? content : parts.slice(0, DEFAULT_READ_LINES).join("\n");
409
- if (total > DEFAULT_READ_LINES)
410
- note = moreLinesNote(DEFAULT_READ_LINES + 1, total - DEFAULT_READ_LINES);
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({ token: 0, root: searchRootReal, single, pattern, flags, excluded, maxFileBytes, maxFiles, deadline, maxMatches: MAX_SEARCH_MATCHES, sniffBytes: BINARY_SNIFF_BYTES }, deadline, ctx.signal);
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
- return precondition(hunks.length === 1 && edits === undefined ? `edit_file: pattern not found in ${path}` : `edit_file: pattern not found in ${path} (hunk ${i + 1})`);
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
+ }
@@ -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;
@@ -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.36.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.36.0"
28
+ "@vincemakes/kiso-core": "0.37.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^26.1.2",