@vincemakes/kiso-tools-node 0.40.2 → 0.40.4

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
@@ -31,7 +31,7 @@ import { strippedShellEnv } from "./secret-env.js";
31
31
  import { contentRevision, normalizeRevision, postEffectEscape, precondition, publishNewFile, revalidateBeforeRename } from "./wr1.js";
32
32
  import { isProtectedPath, protectedIdentity, protectedRefusalText, readUnlessProtected } from "./protected.js";
33
33
  import { CORPUS_MAX_DEPTH, globToRegExp, walkCorpus } from "./corpus.js";
34
- import { describeSearchMiss } from "./search-miss.js";
34
+ import { describeSearchMiss, regionForSearch } from "./search-miss.js";
35
35
  /**
36
36
  * TUI2-R1 (C) — THE SHELL PROGRESS SIDECAR.
37
37
  *
@@ -979,10 +979,49 @@ function describeMatchLines(text, offsets, exhaustive) {
979
979
  const tail = !exhaustive ? " and more" : hidden > 0 ? ` and ${hidden} more` : "";
980
980
  return `${shown.length === 1 && tail === "" ? "line" : "lines"} ${shown.join(", ")}${tail}`;
981
981
  }
982
+ /**
983
+ * ACI-3 — a stale citation's refusal, made retry-ready: where each hunk
984
+ * stands in the file AS IT IS, applied in order the way the edit would
985
+ * apply them. On the owner's disk 102 refusals were "changed since": 41
986
+ * cited a revision older than one they had already been shown, 40 cited
987
+ * one no tool had returned in the session, 21 met a real outside change.
988
+ * Every one was followed by a read. A hunk that still matches once needs
989
+ * nothing more than the new revision; one that does not gets the text
990
+ * where it should land. At most three regions: the rest are named.
991
+ */
992
+ function staleReport(text, hunks, single) {
993
+ const out = ["the file as it is now:"];
994
+ let running = text;
995
+ let regions = 0;
996
+ for (let i = 0; i < hunks.length; i += 1) {
997
+ const h = hunks[i];
998
+ const name = single ? "the search" : `hunk ${i + 1}`;
999
+ const { count, offsets } = occurrencesOf(running, h.search);
1000
+ if (count === 1) {
1001
+ out.push(` ${name}: matches once, ${describeMatchLines(running, offsets, true)}`);
1002
+ running = running.slice(0, offsets[0]) + h.replace + running.slice(offsets[0] + h.search.length);
1003
+ continue;
1004
+ }
1005
+ if (count > 1) {
1006
+ out.push(` ${name}: matches ${count} places (${describeMatchLines(running, offsets, count <= ACI2_OFFSETS_KEPT)}) — make it unique`);
1007
+ continue;
1008
+ }
1009
+ const miss = describeSearchMiss(running, h.search);
1010
+ out.push(` ${name}: not found${miss ? `\n${miss.replace(/^/gm, " ")}` : ""}`);
1011
+ if (regions >= 3)
1012
+ continue;
1013
+ const region = regionForSearch(running, h.search);
1014
+ if (region === null)
1015
+ continue;
1016
+ regions += 1;
1017
+ out.push(` lines ${region.from}–${region.to}:\n${region.body}`);
1018
+ }
1019
+ return out.join("\n");
1020
+ }
982
1021
  export function editFileTool(opts) {
983
1022
  return defineTool({
984
1023
  name: "edit_file",
985
- description: "Edit a workspace file at its latest revision (expectedRevision). ONE of: search+replace (must match exactly once), or edits (1-32 disjoint hunks resolved against the same snapshot, applied atomically).",
1024
+ description: "Edit a file at its latest revision (expectedRevision): search+replace (must match once) OR edits (1-32 hunks, applied in order, all or nothing). A refusal shows the current text and ends on its [rev:X].",
986
1025
  parameters: {
987
1026
  type: "object",
988
1027
  properties: {
@@ -991,10 +1030,13 @@ export function editFileTool(opts) {
991
1030
  replace: { type: "string", description: "Replacement text (single-hunk form)" },
992
1031
  edits: {
993
1032
  type: "array",
994
- description: "Batch form: 1-32 {search, replace} hunks, every search resolved against the SAME snapshot",
1033
+ description: "Batch form: 1-32 {search, replace} hunks, applied in order",
995
1034
  items: {
996
1035
  type: "object",
997
- properties: { search: { type: "string" }, replace: { type: "string" } },
1036
+ // ACI-3: `expectedRevision` inside a hunk is tolerated and ignored —
1037
+ // the call's own governs (5 refusals on the owner's disk, every
1038
+ // one with the top-level token present too).
1039
+ properties: { search: { type: "string" }, replace: { type: "string" }, expectedRevision: { type: "string" } },
998
1040
  required: ["search", "replace"],
999
1041
  additionalProperties: false,
1000
1042
  },
@@ -1052,7 +1094,7 @@ export function editFileTool(opts) {
1052
1094
  let preservedMode;
1053
1095
  try {
1054
1096
  if (!existsSync(full)) {
1055
- return precondition(`edit_file: ${path} no longer exists — it was deleted since you read it`);
1097
+ return precondition(`edit_file: ${path} does not exist — to create it, use write_file with expectedRevision:"absent" (if you read it before, it was deleted since)`);
1056
1098
  }
1057
1099
  // WR-1: ONE byte snapshot feeds BOTH the validation and the
1058
1100
  // mutation — no internal window between what was checked and
@@ -1076,31 +1118,37 @@ export function editFileTool(opts) {
1076
1118
  if (bytes === null)
1077
1119
  return precondition(protectedRefusalText("edit_file", path));
1078
1120
  const current = contentRevision(bytes);
1121
+ const text = bytes.toString("utf8");
1079
1122
  if (current !== expectedRevision) {
1080
- return precondition(`edit_file: ${path} changed since ${expectedRevision} — read it again and cite its [rev:…] line, then re-apply the change`);
1123
+ // ACI-3: the refusal carries the file as it is where each hunk
1124
+ // should land, and its current revision last — the retry needs
1125
+ // no read. The headline is unchanged (bench parsers read it).
1126
+ return precondition(`edit_file: ${path} changed since ${expectedRevision} — the file as it is now is below; re-apply the change against it, citing the [rev:…] on the last line\n${staleReport(text, hunks, edits === undefined)}\n[${current}]`);
1081
1127
  }
1082
- const text = bytes.toString("utf8");
1083
- // WR-1E2: EVERY hunk resolves against THIS snapshot — never the
1084
- // output of an earlier hunk. All spans are known before any
1085
- // staging; overlaps refuse. Since ACI-2 a non-unique search is
1086
- // already refused above, so the overlap left to catch is two
1087
- // hunks aimed at the same unique text — never retargeted.
1088
- const spans = [];
1128
+ // ACI-3 (superseding WR-1E2's one-snapshot rule): the hunks apply
1129
+ // IN ORDER, each against the result of the ones before it — the
1130
+ // way a person writes a second change after the first, and the
1131
+ // reference implementation's multi-edit. 57 refusals on the
1132
+ // owner's disk were "two hunks overlap": a second hunk written
1133
+ // against the first one's output. Still all or nothing: nothing is
1134
+ // written unless every hunk resolves, and each must match exactly
1135
+ // once in the text it meets (ACI-2).
1136
+ let edited = text;
1089
1137
  for (let i = 0; i < hunks.length; i += 1) {
1090
1138
  const h = hunks[i];
1091
- const { count, offsets } = occurrencesOf(text, h.search);
1139
+ const { count, offsets } = occurrencesOf(edited, h.search);
1140
+ const which = hunks.length === 1 && edits === undefined ? "" : i === 0 ? " (hunk 1)" : ` (hunk ${i + 1}, after ${i === 1 ? "hunk 1" : `hunks 1–${i}`} applied)`;
1092
1141
  if (count === 0) {
1093
1142
  // WR-1A ④: the WORLD lacks the pattern (the input is
1094
1143
  // fine) and nothing ran — precondition; the note never
1095
- // rides an edit that wrote nothing.
1096
- // The headline says WHAT failed; the detail says WHERE.
1097
- // A refusal that names the divergence costs one line
1098
- // here and saves a whole file read at the caller.
1099
- const headline = hunks.length === 1 && edits === undefined
1100
- ? `edit_file: pattern not found in ${path}`
1101
- : `edit_file: pattern not found in ${path} (hunk ${i + 1})`;
1102
- const detail = describeSearchMiss(text, h.search);
1103
- return precondition(detail ? `${headline}\n${detail}` : headline);
1144
+ // rides an edit that wrote nothing. The headline says WHAT
1145
+ // failed; the detail says WHERE; ACI-3: the region is the
1146
+ // text the hunk met, and the file's revision ends it.
1147
+ const detail = describeSearchMiss(edited, h.search);
1148
+ const region = regionForSearch(edited, h.search);
1149
+ const seen = i === 0 ? "the file now has" : `the text hunk ${i + 1} met`;
1150
+ const block = region === null ? "" : `\n${seen}, lines ${region.from}–${region.to}:\n${region.body}`;
1151
+ return precondition(`edit_file: pattern not found in ${path}${which}${detail ? `\n${detail}` : ""}${block}\n[${current}]`);
1104
1152
  }
1105
1153
  // ACI-2: more than one resolution is a QUESTION, not an edit.
1106
1154
  // Taking the first one wrote the wrong place and reported
@@ -1108,28 +1156,15 @@ export function editFileTool(opts) {
1108
1156
  // have. The refusal carries the count and the lines so the
1109
1157
  // call can be fixed without reading the file again.
1110
1158
  if (count > 1) {
1111
- const lines = describeMatchLines(text, offsets, count <= ACI2_OFFSETS_KEPT);
1112
- const where = hunks.length === 1 && edits === undefined ? lines : `hunk ${i + 1}, ${lines}`;
1113
- return precondition(`edit_file: pattern matches ${count} places in ${path} (${where}) — include enough surrounding text to make it unique`);
1114
- }
1115
- spans.push({ start: offsets[0], end: offsets[0] + h.search.length, replace: h.replace });
1116
- }
1117
- const bySpan = [...spans].sort((a, b) => a.start - b.start);
1118
- for (let i = 1; i < bySpan.length; i += 1) {
1119
- if (bySpan[i].start < bySpan[i - 1].end) {
1120
- return precondition(`edit_file: two hunks overlap in ${path} — make the searches disjoint`);
1159
+ const lines = describeMatchLines(edited, offsets, count <= ACI2_OFFSETS_KEPT);
1160
+ const where = which === "" ? lines : `${which.slice(2, -1)}, ${lines}`;
1161
+ return precondition(`edit_file: pattern matches ${count} places in ${path} (${where}) — include enough surrounding text to make it unique\n[${current}]`);
1121
1162
  }
1163
+ edited = edited.slice(0, offsets[0]) + h.replace + edited.slice(offsets[0] + h.search.length);
1122
1164
  }
1123
1165
  // E group: safe replacement — never rewrite a shared external inode via a hard link.
1124
1166
  // round 8: the edited file keeps its mode.
1125
1167
  preservedMode = statSync(full).mode & 0o7777;
1126
- // The postimage: replacements applied highest-offset-first, so
1127
- // earlier spans never shift later coordinates — deterministic
1128
- // under any hunk ORDER (one observed state, one postimage).
1129
- let edited = text;
1130
- for (const sp of [...bySpan].reverse()) {
1131
- edited = edited.slice(0, sp.start) + sp.replace + edited.slice(sp.end);
1132
- }
1133
1168
  writeFileSync(tmp, edited, "utf8");
1134
1169
  chmodSync(tmp, preservedMode);
1135
1170
  // WR-1A ③: revalidate against the citation right before the
@@ -22,3 +22,26 @@
22
22
  * to say. The caller owns the headline; this is what follows it.
23
23
  */
24
24
  export declare function describeSearchMiss(text: string, search: string): string;
25
+ /**
26
+ * ACI-3 — the text a refused edit needs to be retried WITHOUT reading the
27
+ * file again. On the owner's disk every edit_file refusal was followed by
28
+ * a read before the retry: two extra requests per failure, 319 failures.
29
+ * The refusal already knows the file; showing the few lines where the
30
+ * search should land costs a couple of KB once instead of a whole read.
31
+ *
32
+ * Whole lines only (a cut mid-line is a lie about the file), `context`
33
+ * lines each side of the search's own span, cut at a line boundary under
34
+ * `maxChars`. Centred on the best partial match — the longest matching
35
+ * prefix at its earliest occurrence, the place describeSearchMiss names —
36
+ * and null when that prefix is too short to say where the caller aimed.
37
+ */
38
+ export interface Region {
39
+ /** 1-based, inclusive. */
40
+ readonly from: number;
41
+ readonly to: number;
42
+ readonly body: string;
43
+ }
44
+ export declare function regionForSearch(text: string, search: string, context?: number, maxChars?: number): Region | null;
45
+ /** The whole lines from `context` before `offset`'s line to `context` after
46
+ * the `span` lines that start there. */
47
+ export declare function regionAt(text: string, offset: number, span: number, context?: number, maxChars?: number): Region;
@@ -92,3 +92,32 @@ export function describeSearchMiss(text, search) {
92
92
  ` your search wanted: "${fragment(rest)}"`,
93
93
  ].join("\n");
94
94
  }
95
+ export function regionForSearch(text, search, context = 6, maxChars = 1500) {
96
+ if (search.length === 0)
97
+ return null;
98
+ const matched = longestMatchingPrefix(text, search);
99
+ // A prefix of a character or two lands anywhere: its region would be
100
+ // noise that costs context. A quarter of the search (at most 16 chars)
101
+ // is where the match starts to say WHERE the caller was aiming.
102
+ if (matched === 0 || matched < Math.min(16, Math.ceil(search.length / 4)))
103
+ return null;
104
+ return regionAt(text, text.indexOf(search.slice(0, matched)), search.split("\n").length, context, maxChars);
105
+ }
106
+ /** The whole lines from `context` before `offset`'s line to `context` after
107
+ * the `span` lines that start there. */
108
+ export function regionAt(text, offset, span, context = 6, maxChars = 1500) {
109
+ const lines = text.split("\n");
110
+ const last = text.endsWith("\n") ? lines.length - 1 : lines.length;
111
+ const at = lineAt(text, offset);
112
+ const from = Math.max(1, at - context);
113
+ let to = Math.max(from, Math.min(last, at + Math.max(span, 1) - 1 + context));
114
+ let body = lines.slice(from - 1, to).join("\n");
115
+ while (body.length > maxChars && to > from) {
116
+ to -= 1;
117
+ body = lines.slice(from - 1, to).join("\n");
118
+ }
119
+ // one line longer than the cap on its own: the honest answer is the cut, said
120
+ if (body.length > maxChars)
121
+ body = `${body.slice(0, maxChars)}… (line ${from} continues)`;
122
+ return { from, to, body };
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.40.2",
3
+ "version": "0.40.4",
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.40.2",
28
+ "@vincemakes/kiso-core": "0.40.4",
29
29
  "ignore": "^7.0.9"
30
30
  },
31
31
  "devDependencies": {