@vincemakes/kiso-tools-node 0.40.2 → 0.40.3
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 +75 -40
- package/dist/search-miss.d.ts +23 -0
- package/dist/search-miss.js +29 -0
- package/package.json +2 -2
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
|
|
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,
|
|
1033
|
+
description: "Batch form: 1-32 {search, replace} hunks, applied in order",
|
|
995
1034
|
items: {
|
|
996
1035
|
type: "object",
|
|
997
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
|
|
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(
|
|
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
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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(
|
|
1112
|
-
const where =
|
|
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
|
package/dist/search-miss.d.ts
CHANGED
|
@@ -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;
|
package/dist/search-miss.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.40.3",
|
|
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.
|
|
28
|
+
"@vincemakes/kiso-core": "0.40.3",
|
|
29
29
|
"ignore": "^7.0.9"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|