@vincemakes/kiso-tools-node 0.49.0 → 0.50.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 CHANGED
@@ -61,7 +61,8 @@ export declare function shellProgressPath(sessionId: string | undefined, command
61
61
  export declare function escapeResult(reason: string): ToolResult;
62
62
  /**
63
63
  * Resolve an input path strictly inside the workspace (Area 5):
64
- * - absolute inputs are refused (paths are workspace-relative);
64
+ * - an absolute input is taken as the workspace-relative path it names when
65
+ * it lies inside the root, and refused otherwise;
65
66
  * - `..` components cannot escape because the joined path is re-checked
66
67
  * against the canonical root;
67
68
  * - SYMLINKS cannot escape: the deepest existing ancestor is realpath'd
@@ -117,6 +118,12 @@ export interface WorkspaceToolsOptions {
117
118
  * serves the session's task outputs. Absent: today's shell, its schema
118
119
  * byte for byte. */
119
120
  readonly tasks?: (sessionId: string | undefined) => ShellTasks | undefined;
121
+ /** O1 — the hidden observed revision. When present, edit_file and
122
+ * write_file take no expectedRevision (the model-facing schema drops
123
+ * it): the host returns the revision the model last OBSERVED for the
124
+ * file (undefined: never observed — the call is refused). Absent:
125
+ * the explicit expectedRevision protocol, its schema byte for byte. */
126
+ readonly observedRevision?: (sessionId: string | undefined, full: string, kind?: "edit" | "write") => string | undefined;
120
127
  /**
121
128
  * DC-54 — the bounds that keep a tool call finite. Every field is
122
129
  * optional and defaults to the constant beside it; a host embedding
@@ -160,6 +167,11 @@ export interface WorkspaceToolsOptions {
160
167
  * the default wait). A shorter `foregroundMs` is raised to it; 0
161
168
  * keeps the model's value as asked (ADR-0058 Amendment 10). */
162
169
  readonly minForegroundMs?: number;
170
+ /** shell, with tasks wired: the longest foreground wait (default 5
171
+ * min). A longer `foregroundMs` is lowered to it — the command is
172
+ * promoted to a task, never killed; it never goes below the floor;
173
+ * 0 keeps the model's value as asked (ADR-0058 Amendment 15). */
174
+ readonly maxForegroundMs?: number;
163
175
  };
164
176
  }
165
177
  export declare function readFileTool(opts: WorkspaceToolsOptions): Tool<{
package/dist/index.js CHANGED
@@ -82,6 +82,9 @@ const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
82
82
  /** ADR-0058 §3: with tasks, how long the shell waits before a command
83
83
  * continues as a task (the model's own value wins). */
84
84
  const DEFAULT_FOREGROUND_MS = 60_000;
85
+ /** ADR-0058 Amendment 15: with tasks, the longest foreground wait. A longer
86
+ * `foregroundMs` still promotes here; the command is never killed. */
87
+ const DEFAULT_MAX_FOREGROUND_MS = 300_000;
85
88
  /** A promoted task's output file rotates here, as a runner's does. */
86
89
  const TASK_OUTPUT_CAP = 64 * 1024 * 1024;
87
90
  /** A task's stop: SIGTERM, this long, then the confirmed sweep. */
@@ -164,7 +167,8 @@ function isWithin(root, target) {
164
167
  }
165
168
  /**
166
169
  * Resolve an input path strictly inside the workspace (Area 5):
167
- * - absolute inputs are refused (paths are workspace-relative);
170
+ * - an absolute input is taken as the workspace-relative path it names when
171
+ * it lies inside the root, and refused otherwise;
168
172
  * - `..` components cannot escape because the joined path is re-checked
169
173
  * against the canonical root;
170
174
  * - SYMLINKS cannot escape: the deepest existing ancestor is realpath'd
@@ -198,10 +202,18 @@ function resolveReadable(opts, input, sessionId) {
198
202
  return { full: resolveWithinRoot(opts.workspaceRoot, input), root: opts.workspaceRoot };
199
203
  }
200
204
  export function resolveWithinRoot(root, input) {
205
+ const rootReal = realpathSync(root);
201
206
  if (isAbsolute(input)) {
202
- throw new PathEscapeError(`absolute paths are not allowed — use workspace-relative paths: ${input}`);
207
+ // an absolute path that names a place inside the workspace — as the
208
+ // root is written or as it resolves — is that workspace-relative
209
+ // path, and takes the same canonical checks below (symlinks, `..`)
210
+ for (const base of [root, rootReal]) {
211
+ const rel = relative(base, input);
212
+ if (!rel.startsWith("..") && !isAbsolute(rel))
213
+ return resolveWithinRoot(root, rel === "" ? "." : rel);
214
+ }
215
+ throw new PathEscapeError(`absolute path outside the workspace: ${input} — the workspace root is ${rootReal}; use a path inside it`);
203
216
  }
204
- const rootReal = realpathSync(root);
205
217
  const candidate = resolve(root, input);
206
218
  // Walk up to the deepest EXISTING ancestor (files to be created have a
207
219
  // not-yet-existing tail), then canonicalize it.
@@ -369,7 +381,7 @@ export function readFileTool(opts) {
369
381
  properties: {
370
382
  path: { type: "string", description: "Workspace-relative path of the file to read" },
371
383
  offset: { type: "number", description: "1-based first line to read (default: 1)" },
372
- limit: { type: "number", description: "Maximum number of lines to read (default: to the end of the file)" },
384
+ limit: { type: "number", description: "Maximum lines; omit for the default bounded window" },
373
385
  },
374
386
  required: ["path"],
375
387
  additionalProperties: false,
@@ -854,27 +866,29 @@ export function searchTextTool(opts) {
854
866
  export function writeFileTool(opts) {
855
867
  return defineTool({
856
868
  name: "write_file",
857
- description: "Create or replace a whole workspace file. expectedRevision is the file's latest revision token, or \"absent\" to create a new file.",
869
+ description: opts.observedRevision !== undefined
870
+ ? "Create or replace a whole workspace file. Replacing a file needs an earlier read of it."
871
+ : "Create or replace a whole workspace file. expectedRevision is the file's latest revision token, or \"absent\" to create a new file.",
858
872
  parameters: {
859
873
  type: "object",
860
874
  properties: {
861
875
  path: { type: "string", description: "Workspace-relative file to write" },
862
876
  content: { type: "string", description: "Full new content" },
863
- expectedRevision: {
864
- type: "string",
865
- description: 'The latest revision token, or "absent" to create',
866
- },
877
+ ...(opts.observedRevision !== undefined ? {} : { expectedRevision: {
878
+ type: "string",
879
+ description: 'The latest revision token, or "absent" to create',
880
+ } }),
867
881
  },
868
- required: ["path", "content", "expectedRevision"],
882
+ required: opts.observedRevision !== undefined ? ["path", "content"] : ["path", "content", "expectedRevision"],
869
883
  additionalProperties: false,
870
884
  },
871
885
  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
- execute: async ({ path, content, expectedRevision: citedRevision }) => {
886
+ ...(opts.observedRevision !== undefined ? {} : { promptGuidelines: ["cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"] }),
887
+ execute: async ({ path, content, expectedRevision: citedRevision }, ctx) => {
874
888
  const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
875
889
  // WR-1-F2: normalize every plausible copy of the token FIRST —
876
890
  // tolerance in the reader, strictness in the comparison.
877
- const expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
891
+ let expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
878
892
  let full;
879
893
  try {
880
894
  full = resolveWithinRoot(opts.workspaceRoot, path);
@@ -887,6 +901,14 @@ export function writeFileTool(opts) {
887
901
  const guard = protectedIdentity(opts.protectedFiles);
888
902
  if (isProtectedPath(full, guard))
889
903
  return precondition(protectedRefusalText("write_file", path));
904
+ // O1: bind the observed revision (never the disk's latest)
905
+ if (expectedRevision === undefined && opts.observedRevision !== undefined && existsSync(full)) {
906
+ expectedRevision = opts.observedRevision(ctx?.sessionId, full, "write");
907
+ // the trajectory audit: after ranged reads the model retried this
908
+ // in a loop — say that only a WHOLE read lets it replace the file
909
+ if (expectedRevision === undefined)
910
+ return precondition(`write_file: ${path} already exists — to replace it, first read the whole file (read_file with no offset or limit; a ranged read is not enough), or change part of it with edit_file`);
911
+ }
890
912
  // WR-1 — the observed-revision stale-write guard. The decision
891
913
  // lattice runs BEFORE any bytes move; every refusal is a
892
914
  // precondition with one actionable sentence. What it proves:
@@ -928,7 +950,7 @@ export function writeFileTool(opts) {
928
950
  return precondition(protectedRefusalText("write_file", path));
929
951
  const current = contentRevision(held);
930
952
  if (current !== expectedRevision) {
931
- return precondition(`write_file: ${path} changed since ${expectedRevision} — read it again and cite its [rev:…] line, then re-apply the change`);
953
+ return precondition(opts.observedRevision !== undefined ? `write_file: ${path} changed since you last observed it — read it again, then re-apply the change` : `write_file: ${path} changed since ${expectedRevision} — read it again and cite its [rev:…] line, then re-apply the change`);
932
954
  }
933
955
  }
934
956
  const tmp = `${full}.kiso-tmp-${process.pid}-${crypto.randomUUID()}`;
@@ -977,7 +999,14 @@ export function writeFileTool(opts) {
977
999
  // WR-1: the result returns the NEW revision — the model just
978
1000
  // authored these bytes; a legal new world witness for chained
979
1001
  // edits without a wasted re-read.
980
- return { content: `wrote ${path} (${content.length} chars)\n[${contentRevision(Buffer.from(content, "utf8"))}]`, isError: false };
1002
+ // O1: with no "absent" in the input, the "created" tag tells
1003
+ // the host's card a creation from a rewrite — a product tag,
1004
+ // never sent to the model.
1005
+ return {
1006
+ content: `wrote ${path} (${content.length} chars)\n[${contentRevision(Buffer.from(content, "utf8"))}]`,
1007
+ isError: false,
1008
+ ...(opts.observedRevision !== undefined && !exists ? { tags: ["created"] } : {}),
1009
+ };
981
1010
  }
982
1011
  catch (err) {
983
1012
  // round 8: a failed write never leaves a temp file with the FULL
@@ -1129,7 +1158,9 @@ function editHunksOf(input) {
1129
1158
  export function editFileTool(opts) {
1130
1159
  return defineTool({
1131
1160
  name: "edit_file",
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].",
1161
+ description: opts.observedRevision !== undefined
1162
+ ? "Edit a file you have read: exact replacements, applied in order, all or nothing. Batch changes to this file that are already known into one call."
1163
+ : "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].",
1133
1164
  parameters: {
1134
1165
  type: "object",
1135
1166
  properties: {
@@ -1149,18 +1180,18 @@ export function editFileTool(opts) {
1149
1180
  minItems: 1,
1150
1181
  maxItems: 32,
1151
1182
  },
1152
- expectedRevision: { type: "string", description: "The file's latest revision token" },
1183
+ ...(opts.observedRevision !== undefined ? {} : { expectedRevision: { type: "string", description: "The file's latest revision token" } }),
1153
1184
  },
1154
- required: ["path", "edits", "expectedRevision"],
1185
+ required: opts.observedRevision !== undefined ? ["path", "edits"] : ["path", "edits", "expectedRevision"],
1155
1186
  additionalProperties: false,
1156
1187
  },
1157
1188
  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) => {
1189
+ ...(opts.observedRevision !== undefined ? {} : { promptGuidelines: ["cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"] }),
1190
+ execute: async (input, ctx) => {
1160
1191
  const { path, expectedRevision: citedRevision } = input;
1161
1192
  const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
1162
1193
  // WR-1-F2: normalize the citation (see write_file).
1163
- const expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
1194
+ let expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
1164
1195
  const shape = editHunksOf(input);
1165
1196
  if (typeof shape === "string")
1166
1197
  return { content: shape, isError: true, errorKind: "invalid_input" };
@@ -1177,6 +1208,12 @@ export function editFileTool(opts) {
1177
1208
  const guard = protectedIdentity(opts.protectedFiles);
1178
1209
  if (isProtectedPath(full, guard))
1179
1210
  return precondition(protectedRefusalText("edit_file", path));
1211
+ // O1: bind the observed revision (never the disk's latest)
1212
+ if (expectedRevision === undefined && opts.observedRevision !== undefined) {
1213
+ expectedRevision = opts.observedRevision(ctx?.sessionId, full);
1214
+ if (expectedRevision === undefined)
1215
+ return precondition(`edit_file: ${path} — read it first; there is no observation of it in this session`);
1216
+ }
1180
1217
  // WR-1: edits always target an existing file — the revision is
1181
1218
  // not optional here, and the refusal teaches the protocol.
1182
1219
  if (expectedRevision === undefined) {
@@ -1218,7 +1255,9 @@ export function editFileTool(opts) {
1218
1255
  // ACI-3: the refusal carries the file as it is where each hunk
1219
1256
  // should land, and its current revision last — the retry needs
1220
1257
  // no read. The headline is unchanged (bench parsers read it).
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}]`);
1258
+ return precondition(opts.observedRevision !== undefined
1259
+ ? `edit_file: ${path} changed since you last observed it — the file as it is now is below; re-apply the change against it\n${staleReport(text, hunks, single)}\n[${current}]`
1260
+ : `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}]`);
1222
1261
  }
1223
1262
  // ACI-3 (superseding WR-1E2's one-snapshot rule): the hunks apply
1224
1263
  // IN ORDER, each against the result of the ones before it — the
@@ -1240,7 +1279,12 @@ export function editFileTool(opts) {
1240
1279
  for (let i = 0; i < hunks.length; i += 1) {
1241
1280
  const h = { search: lf(hunks[i].search), replace: lf(hunks[i].replace) };
1242
1281
  const { count, offsets } = occurrencesOf(edited, h.search);
1243
- const which = single ? "" : i === 0 ? " (hunk 1)" : ` (hunk ${i + 1}, after ${i === 1 ? "hunk 1" : `hunks 1–${i}`} applied)`;
1282
+ // a batch's later hunk is matched against the text the earlier
1283
+ // ones made IN MEMORY; "after hunk 1 applied" read as written,
1284
+ // and the model repaired a hunk that never landed (the TB20
1285
+ // trajectory audit) — so a batch refusal says nothing changed
1286
+ const which = single ? "" : i === 0 ? " (hunk 1)" : ` (hunk ${i + 1}, matched after ${i === 1 ? "hunk 1" : `hunks 1–${i}`})`;
1287
+ const untouched = hunks.length > 1 ? ` — nothing was written, ${path} is unchanged` : "";
1244
1288
  if (count === 0) {
1245
1289
  // WR-1A ④: the WORLD lacks the pattern (the input is
1246
1290
  // fine) and nothing ran — precondition; the note never
@@ -1257,7 +1301,7 @@ export function editFileTool(opts) {
1257
1301
  const swap = h.replace.length > 0 && occurrencesOf(edited, h.replace).count === 1
1258
1302
  ? "\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
1303
  : "";
1260
- return precondition(`edit_file: pattern not found in ${path}${which}${detail ? `\n${detail}` : ""}${swap}${block}\n[${current}]`);
1304
+ return precondition(`edit_file: pattern not found in ${path}${which}${untouched}${detail ? `\n${detail}` : ""}${swap}${block}\n[${current}]`);
1261
1305
  }
1262
1306
  // ACI-2: more than one resolution is a QUESTION, not an edit.
1263
1307
  // Taking the first one wrote the wrong place and reported
@@ -1267,7 +1311,7 @@ export function editFileTool(opts) {
1267
1311
  if (count > 1) {
1268
1312
  const lines = describeMatchLines(edited, offsets, count <= ACI2_OFFSETS_KEPT);
1269
1313
  const where = which === "" ? lines : `${which.slice(2, -1)}, ${lines}`;
1270
- return precondition(`edit_file: pattern matches ${count} places in ${path} (${where}) — include enough surrounding text to make it unique\n[${current}]`);
1314
+ return precondition(`edit_file: pattern matches ${count} places in ${path} (${where}) — include enough surrounding text to make it unique${untouched !== "" ? `; nothing was written, ${path} is unchanged` : ""}\n[${current}]`);
1271
1315
  }
1272
1316
  edited = edited.slice(0, offsets[0]) + h.replace + edited.slice(offsets[0] + h.search.length);
1273
1317
  }
@@ -1409,11 +1453,14 @@ export function shellTool(opts) {
1409
1453
  // ADR-0058 Amendment 10: with tasks wired a wait shorter than the
1410
1454
  // floor only moves the command to the background sooner, so the
1411
1455
  // model may lengthen the wait, never shorten it. Without tasks the
1412
- // wait is a kill timeout and is kept as asked.
1456
+ // wait is a kill timeout and is kept as asked. Amendment 15: a
1457
+ // wait past the ceiling only holds the turn — the command is
1458
+ // promoted there and the model is notified when it ends.
1413
1459
  const asked = [foregroundMs, timeoutMs].find((v) => typeof v === "number" && Number.isFinite(v));
1414
- const timeout = tasks !== undefined
1415
- ? Math.max(asked ?? DEFAULT_FOREGROUND_MS, opts.limits?.minForegroundMs ?? DEFAULT_FOREGROUND_MS)
1416
- : (asked ?? DEFAULT_SHELL_TIMEOUT_MS);
1460
+ const floor = opts.limits?.minForegroundMs ?? DEFAULT_FOREGROUND_MS;
1461
+ const ceiling = opts.limits?.maxForegroundMs ?? DEFAULT_MAX_FOREGROUND_MS;
1462
+ const raised = Math.max(asked ?? DEFAULT_FOREGROUND_MS, floor);
1463
+ const timeout = tasks !== undefined ? (ceiling > 0 ? Math.min(raised, Math.max(ceiling, floor)) : raised) : (asked ?? DEFAULT_SHELL_TIMEOUT_MS);
1417
1464
  // E group: a PRE-aborted signal never spawns the command.
1418
1465
  if (ctx.signal.aborted) {
1419
1466
  return { content: "shell aborted before start", isError: true, errorKind: "fatal" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.49.0",
3
+ "version": "0.50.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.49.0",
28
+ "@vincemakes/kiso-core": "0.50.0",
29
29
  "ignore": "^7.0.9"
30
30
  },
31
31
  "devDependencies": {