@vincemakes/kiso-tools-node 0.48.0 → 0.49.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.d.ts +13 -6
- package/dist/index.js +83 -44
- package/dist/search-miss.js +2 -2
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -187,16 +187,23 @@ export declare function writeFileTool(opts: WorkspaceToolsOptions): Tool<{
|
|
|
187
187
|
content: string;
|
|
188
188
|
expectedRevision?: string;
|
|
189
189
|
}>;
|
|
190
|
-
|
|
190
|
+
/** One hunk as a caller may send it: the current vocabulary, or the
|
|
191
|
+
* legacy `search`/`replace` a durable log or a host may still hold. */
|
|
192
|
+
type EditHunkInput = {
|
|
193
|
+
readonly oldText?: string;
|
|
194
|
+
readonly newText?: string;
|
|
195
|
+
readonly search?: string;
|
|
196
|
+
readonly replace?: string;
|
|
197
|
+
readonly expectedRevision?: string;
|
|
198
|
+
};
|
|
199
|
+
type EditFileInput = {
|
|
191
200
|
path: string;
|
|
201
|
+
edits?: readonly EditHunkInput[];
|
|
192
202
|
search?: string;
|
|
193
203
|
replace?: string;
|
|
194
|
-
edits?: readonly {
|
|
195
|
-
search: string;
|
|
196
|
-
replace: string;
|
|
197
|
-
}[];
|
|
198
204
|
expectedRevision?: string;
|
|
199
|
-
}
|
|
205
|
+
};
|
|
206
|
+
export declare function editFileTool(opts: WorkspaceToolsOptions): Tool<EditFileInput>;
|
|
200
207
|
export { SHELL_STRIP_EXACT, strippedShellEnv } from "./secret-env.js";
|
|
201
208
|
export { PROTECTED_REFUSAL, diskPath, isProtectedPath, protectedIdentity, protectedRefusalText, readUnlessProtected, type ProtectedIdentity } from "./protected.js";
|
|
202
209
|
/** The search corpus's credential rule, by name — exported so the CLI's
|
package/dist/index.js
CHANGED
|
@@ -382,7 +382,7 @@ export function readFileTool(opts) {
|
|
|
382
382
|
// safety claim: the kernel enforces both, and had this line been
|
|
383
383
|
// omitted the tool would simply have been slower, never less correct.
|
|
384
384
|
effects: { precommitSafe: true, concurrency: "shared" },
|
|
385
|
-
promptSnippet: "read_file —
|
|
385
|
+
promptSnippet: "read_file — read a file or a line range",
|
|
386
386
|
promptGuidelines: ["read only the range you need — offset/limit beat whole-file reads"],
|
|
387
387
|
execute: async ({ path, offset, limit }, ctx) => {
|
|
388
388
|
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
@@ -544,7 +544,7 @@ export function listDirTool(opts) {
|
|
|
544
544
|
idempotent: true,
|
|
545
545
|
// EC-1 ②: read-only, free, local — see read_file above.
|
|
546
546
|
effects: { precommitSafe: true, concurrency: "shared" },
|
|
547
|
-
promptSnippet: "list_dir —
|
|
547
|
+
promptSnippet: "list_dir — list a directory",
|
|
548
548
|
promptGuidelines: ["narrow to a subdirectory when the listing caps at 200 entries"],
|
|
549
549
|
execute: async ({ path, glob }, ctx) => {
|
|
550
550
|
try {
|
|
@@ -761,7 +761,7 @@ export function searchTextTool(opts) {
|
|
|
761
761
|
// commit-required and exclusive, which is what closes the same-path
|
|
762
762
|
// write race without asking their authors to remember anything.
|
|
763
763
|
effects: { precommitSafe: true, concurrency: "shared" },
|
|
764
|
-
promptSnippet: "search_text — regex search
|
|
764
|
+
promptSnippet: "search_text — regex search in files",
|
|
765
765
|
promptGuidelines: ["narrow the pattern when the result caps — never re-run a broad search"],
|
|
766
766
|
execute: async ({ pattern, path, caseSensitive }, ctx) => {
|
|
767
767
|
let root;
|
|
@@ -868,8 +868,8 @@ export function writeFileTool(opts) {
|
|
|
868
868
|
required: ["path", "content", "expectedRevision"],
|
|
869
869
|
additionalProperties: false,
|
|
870
870
|
},
|
|
871
|
-
promptSnippet: "write_file —
|
|
872
|
-
promptGuidelines: ["
|
|
871
|
+
promptSnippet: "write_file — write a whole file",
|
|
872
|
+
promptGuidelines: ["cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"],
|
|
873
873
|
execute: async ({ path, content, expectedRevision: citedRevision }) => {
|
|
874
874
|
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
875
875
|
// WR-1-F2: normalize every plausible copy of the token FIRST —
|
|
@@ -1086,26 +1086,64 @@ function staleReport(text, hunks, single) {
|
|
|
1086
1086
|
}
|
|
1087
1087
|
return out.join("\n");
|
|
1088
1088
|
}
|
|
1089
|
+
/**
|
|
1090
|
+
* ADR-0061 — compatibility belongs in the executor; vocabulary belongs in
|
|
1091
|
+
* the schema. The model is shown one form (`edits` of `{oldText, newText}`):
|
|
1092
|
+
* `search`/`replace` was written SWAPPED in 80 of 86 failed T6 edits (the
|
|
1093
|
+
* 2026-10-08 replay), and old/new cannot be read backwards. Everything a
|
|
1094
|
+
* caller that skips schema validation may send — an approved call persisted
|
|
1095
|
+
* before the upgrade, a host, a test — is still taken here: legacy hunks,
|
|
1096
|
+
* the legacy top-level pair, a hunk-level `expectedRevision` (ignored, the
|
|
1097
|
+
* call's own governs). A hunk that MIXES the two vocabularies takes neither
|
|
1098
|
+
* half. The schema stays in the small portable subset by choice (no
|
|
1099
|
+
* `oneOf`), so the shape rules live here.
|
|
1100
|
+
*/
|
|
1101
|
+
function editHunksOf(input) {
|
|
1102
|
+
const pair = input.search !== undefined || input.replace !== undefined;
|
|
1103
|
+
if (pair && input.edits !== undefined)
|
|
1104
|
+
return "edit_file: pass edits — never edits and a top-level search/replace together";
|
|
1105
|
+
if (pair) {
|
|
1106
|
+
if (input.search === undefined || input.replace === undefined)
|
|
1107
|
+
return "edit_file: the legacy top-level form needs BOTH search and replace";
|
|
1108
|
+
return { hunks: [{ search: input.search, replace: input.replace }], single: true };
|
|
1109
|
+
}
|
|
1110
|
+
if (input.edits === undefined)
|
|
1111
|
+
return "edit_file: pass edits \u2014 1\u201332 {oldText, newText} replacements";
|
|
1112
|
+
if (input.edits.length === 0 || input.edits.length > 32)
|
|
1113
|
+
return `edit_file: edits must carry 1\u201332 replacements (got ${input.edits.length})`;
|
|
1114
|
+
const hunks = [];
|
|
1115
|
+
for (let i = 0; i < input.edits.length; i += 1) {
|
|
1116
|
+
const h = input.edits[i];
|
|
1117
|
+
const current = h.oldText !== undefined || h.newText !== undefined;
|
|
1118
|
+
const legacy = h.search !== undefined || h.replace !== undefined;
|
|
1119
|
+
if (current && legacy)
|
|
1120
|
+
return `edit_file: hunk ${i + 1} mixes oldText/newText with search/replace \u2014 send oldText and newText`;
|
|
1121
|
+
const oldText = current ? h.oldText : h.search;
|
|
1122
|
+
const newText = current ? h.newText : h.replace;
|
|
1123
|
+
if (typeof oldText !== "string" || typeof newText !== "string")
|
|
1124
|
+
return `edit_file: hunk ${i + 1} needs both oldText and newText`;
|
|
1125
|
+
hunks.push({ search: oldText, replace: newText });
|
|
1126
|
+
}
|
|
1127
|
+
return { hunks, single: false };
|
|
1128
|
+
}
|
|
1089
1129
|
export function editFileTool(opts) {
|
|
1090
1130
|
return defineTool({
|
|
1091
1131
|
name: "edit_file",
|
|
1092
|
-
description: "Edit a file at its latest revision (expectedRevision):
|
|
1132
|
+
description: "Edit a file at its latest revision (expectedRevision): exact replacements, applied in order, all or nothing. Batch changes to this file that are already known into one call. A refusal ends on its [rev:X].",
|
|
1093
1133
|
parameters: {
|
|
1094
1134
|
type: "object",
|
|
1095
1135
|
properties: {
|
|
1096
1136
|
path: { type: "string", description: "Workspace-relative file" },
|
|
1097
|
-
search: { type: "string", description: "Exact literal text to find (single-hunk form)" },
|
|
1098
|
-
replace: { type: "string", description: "Replacement text (single-hunk form)" },
|
|
1099
1137
|
edits: {
|
|
1100
1138
|
type: "array",
|
|
1101
|
-
description: "
|
|
1139
|
+
description: "1-32 replacements",
|
|
1102
1140
|
items: {
|
|
1103
1141
|
type: "object",
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
required: ["
|
|
1142
|
+
properties: {
|
|
1143
|
+
oldText: { type: "string", description: "Exact text to replace at this step; must match exactly once after the earlier edits in this call" },
|
|
1144
|
+
newText: { type: "string", description: "Replacement text for oldText" },
|
|
1145
|
+
},
|
|
1146
|
+
required: ["oldText", "newText"],
|
|
1109
1147
|
additionalProperties: false,
|
|
1110
1148
|
},
|
|
1111
1149
|
minItems: 1,
|
|
@@ -1113,31 +1151,20 @@ export function editFileTool(opts) {
|
|
|
1113
1151
|
},
|
|
1114
1152
|
expectedRevision: { type: "string", description: "The file's latest revision token" },
|
|
1115
1153
|
},
|
|
1116
|
-
required: ["path", "expectedRevision"],
|
|
1154
|
+
required: ["path", "edits", "expectedRevision"],
|
|
1117
1155
|
additionalProperties: false,
|
|
1118
1156
|
},
|
|
1119
|
-
promptSnippet: "edit_file — replace
|
|
1120
|
-
promptGuidelines: ["
|
|
1121
|
-
execute: async (
|
|
1157
|
+
promptSnippet: "edit_file — replace oldText with newText",
|
|
1158
|
+
promptGuidelines: ["cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"],
|
|
1159
|
+
execute: async (input) => {
|
|
1160
|
+
const { path, expectedRevision: citedRevision } = input;
|
|
1122
1161
|
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
1123
1162
|
// WR-1-F2: normalize the citation (see write_file).
|
|
1124
1163
|
const expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
return { content: "edit_file: pass EITHER search+replace OR edits — never both", isError: true, errorKind: "invalid_input" };
|
|
1130
|
-
}
|
|
1131
|
-
if (!single && edits === undefined) {
|
|
1132
|
-
return { content: "edit_file: pass search+replace, or edits (1\u201332 hunks)", isError: true, errorKind: "invalid_input" };
|
|
1133
|
-
}
|
|
1134
|
-
if (single && (search === undefined || replace === undefined)) {
|
|
1135
|
-
return { content: "edit_file: the single-hunk form needs BOTH search and replace", isError: true, errorKind: "invalid_input" };
|
|
1136
|
-
}
|
|
1137
|
-
if (edits !== undefined && (edits.length === 0 || edits.length > 32)) {
|
|
1138
|
-
return { content: `edit_file: edits must carry 1\u201332 hunks (got ${edits.length})`, isError: true, errorKind: "invalid_input" };
|
|
1139
|
-
}
|
|
1140
|
-
const hunks = edits ?? [{ search: search, replace: replace }];
|
|
1164
|
+
const shape = editHunksOf(input);
|
|
1165
|
+
if (typeof shape === "string")
|
|
1166
|
+
return { content: shape, isError: true, errorKind: "invalid_input" };
|
|
1167
|
+
const { hunks, single } = shape;
|
|
1141
1168
|
let full;
|
|
1142
1169
|
try {
|
|
1143
1170
|
full = resolveWithinRoot(opts.workspaceRoot, path);
|
|
@@ -1191,16 +1218,18 @@ export function editFileTool(opts) {
|
|
|
1191
1218
|
// ACI-3: the refusal carries the file as it is where each hunk
|
|
1192
1219
|
// should land, and its current revision last — the retry needs
|
|
1193
1220
|
// no read. The headline is unchanged (bench parsers read it).
|
|
1194
|
-
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,
|
|
1221
|
+
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, single)}\n[${current}]`);
|
|
1195
1222
|
}
|
|
1196
1223
|
// ACI-3 (superseding WR-1E2's one-snapshot rule): the hunks apply
|
|
1197
1224
|
// IN ORDER, each against the result of the ones before it — the
|
|
1198
|
-
// way a person writes a second change after the first
|
|
1199
|
-
// reference implementation
|
|
1225
|
+
// way a person writes a second change after the first. (The
|
|
1226
|
+
// reference implementation does NOT do this: it matches every edit
|
|
1227
|
+
// against the original and refuses overlaps.) 57 refusals on the
|
|
1200
1228
|
// owner's disk were "two hunks overlap": a second hunk written
|
|
1201
|
-
// against the first one's output.
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
1229
|
+
// against the first one's output. The 2026-10-08 replay found 0 of
|
|
1230
|
+
// 86 failures caused by this rule (ADR-0061). Still all or nothing:
|
|
1231
|
+
// nothing is written unless every hunk resolves, and each must
|
|
1232
|
+
// match exactly once in the text it meets (ACI-2).
|
|
1204
1233
|
// Windows P3: a uniformly CRLF file is matched on its LF form and
|
|
1205
1234
|
// written back CRLF — the model's search text uses LF, and an edit
|
|
1206
1235
|
// never changes the file's endings. A file mixing the two keeps
|
|
@@ -1211,7 +1240,7 @@ export function editFileTool(opts) {
|
|
|
1211
1240
|
for (let i = 0; i < hunks.length; i += 1) {
|
|
1212
1241
|
const h = { search: lf(hunks[i].search), replace: lf(hunks[i].replace) };
|
|
1213
1242
|
const { count, offsets } = occurrencesOf(edited, h.search);
|
|
1214
|
-
const which =
|
|
1243
|
+
const which = single ? "" : i === 0 ? " (hunk 1)" : ` (hunk ${i + 1}, after ${i === 1 ? "hunk 1" : `hunks 1–${i}`} applied)`;
|
|
1215
1244
|
if (count === 0) {
|
|
1216
1245
|
// WR-1A ④: the WORLD lacks the pattern (the input is
|
|
1217
1246
|
// fine) and nothing ran — precondition; the note never
|
|
@@ -1222,7 +1251,13 @@ export function editFileTool(opts) {
|
|
|
1222
1251
|
const region = regionForSearch(edited, h.search);
|
|
1223
1252
|
const seen = i === 0 ? "the file now has" : `the text hunk ${i + 1} met`;
|
|
1224
1253
|
const block = region === null ? "" : `\n${seen}, lines ${region.from}–${region.to}:\n${region.body}`;
|
|
1225
|
-
|
|
1254
|
+
// ADR-0061: the replay's shape — oldText nowhere, newText exactly
|
|
1255
|
+
// once. Two readings fit it and the tool cannot tell them apart, so
|
|
1256
|
+
// it names both and applies nothing (ACI-2: never a guess).
|
|
1257
|
+
const swap = h.replace.length > 0 && occurrencesOf(edited, h.replace).count === 1
|
|
1258
|
+
? "\n newText is already in the file once and oldText is not: either this change is already applied, or oldText and newText are swapped"
|
|
1259
|
+
: "";
|
|
1260
|
+
return precondition(`edit_file: pattern not found in ${path}${which}${detail ? `\n${detail}` : ""}${swap}${block}\n[${current}]`);
|
|
1226
1261
|
}
|
|
1227
1262
|
// ACI-2: more than one resolution is a QUESTION, not an edit.
|
|
1228
1263
|
// Taking the first one wrote the wrong place and reported
|
|
@@ -1348,7 +1383,10 @@ export function shellTool(opts) {
|
|
|
1348
1383
|
description: "Run independently as a task. Use for services/watchers or work whose exit result is not needed next. If you need the result, keep it foreground and raise foregroundMs. You are notified when it ends; do not sleep/poll.",
|
|
1349
1384
|
},
|
|
1350
1385
|
readyWhen: { type: "string", description: "For a continuing service, wait up to foregroundMs for this literal output before returning; the task keeps running afterward." },
|
|
1351
|
-
|
|
1386
|
+
// Plan B: timeoutMs (deprecated here since ADR-0058) leaves the
|
|
1387
|
+
// model-facing schema; the executor still reads it from a caller
|
|
1388
|
+
// that skips validation. The schema WITHOUT tasks keeps it — there
|
|
1389
|
+
// it is the shell's only timeout.
|
|
1352
1390
|
},
|
|
1353
1391
|
required: ["command"],
|
|
1354
1392
|
additionalProperties: false,
|
|
@@ -1362,6 +1400,7 @@ export function shellTool(opts) {
|
|
|
1362
1400
|
required: ["command"],
|
|
1363
1401
|
additionalProperties: false,
|
|
1364
1402
|
},
|
|
1403
|
+
// PR-1d row 3: this exact snippet is part of a MEASURED combination (weather 1/5 -> 5/5); Plan B leaves it as pinned
|
|
1365
1404
|
promptSnippet: "shell — any command the task needs (builds, tests, git, curl, system queries)",
|
|
1366
1405
|
promptGuidelines: ["commands run in the workspace root; on failure read the error and adjust — never repeat blindly"],
|
|
1367
1406
|
execute: async ({ command, timeoutMs, foregroundMs, background, readyWhen }, ctx) => {
|
|
@@ -1687,7 +1726,7 @@ export function taskStopTool(opts) {
|
|
|
1687
1726
|
required: ["id"],
|
|
1688
1727
|
additionalProperties: false,
|
|
1689
1728
|
},
|
|
1690
|
-
promptSnippet: "task_stop — stop a background task
|
|
1729
|
+
promptSnippet: "task_stop — stop a background task",
|
|
1691
1730
|
execute: async ({ id }, ctx) => {
|
|
1692
1731
|
const tasks = opts.tasks?.(ctx.sessionId);
|
|
1693
1732
|
const info = tasks?.get(id);
|
package/dist/search-miss.js
CHANGED
|
@@ -83,13 +83,13 @@ export function describeSearchMiss(text, search) {
|
|
|
83
83
|
if (endOfMatch >= text.length) {
|
|
84
84
|
return [
|
|
85
85
|
head,
|
|
86
|
-
` the file ENDS there — your
|
|
86
|
+
` the file ENDS there — your oldText continues for ${rest.length} more characters: "${fragment(rest)}"`,
|
|
87
87
|
].join("\n");
|
|
88
88
|
}
|
|
89
89
|
return [
|
|
90
90
|
head,
|
|
91
91
|
` the file then has: "${fragment(text.slice(endOfMatch))}"`,
|
|
92
|
-
` your
|
|
92
|
+
` your oldText wanted: "${fragment(rest)}"`,
|
|
93
93
|
].join("\n");
|
|
94
94
|
}
|
|
95
95
|
export function regionForSearch(text, search, context = 6, maxChars = 1500) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tools-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.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.49.0",
|
|
29
29
|
"ignore": "^7.0.9"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|