@theokit/sdk-tools 0.19.0 → 0.20.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.cts CHANGED
@@ -6,17 +6,21 @@ import { InteractiveProvider } from '@theokit/sdk/interactive';
6
6
  /**
7
7
  * `apply_patch` — built-in tool for coding agents.
8
8
  *
9
- * Parses a unified diff string and applies it to the project files.
10
- * Creates `.bak` backups before modifying each file.
9
+ * Codex's V4A patch grammar (`*** Begin Patch` `*** End Patch`): `*** Add/Update/Delete File:`, an
10
+ * optional `*** Move to:`, and `@@`-anchored `+`/`-`/context hunks matched with a context-tolerant ladder
11
+ * (exact → rstrip → trim → unicode). See `internal/v4a-patch.ts` for the parser + matcher.
12
+ *
13
+ * Applied STRICTLY atomically: the whole patch is planned (every file read + new content computed + path
14
+ * security-checked) before ANY write. A parse error, context mismatch, or path violation anywhere ⇒ typed
15
+ * error and ZERO writes (stronger than Codex, which writes file-by-file and can leave partial writes).
11
16
  *
12
17
  * Return shape (always a JSON string):
13
18
  * - `{ ok: true, files_patched: string[] }`
14
- * - `{ ok: false, error: 'parse_error' | 'path_traversal' |
15
- * 'forbidden_path' | 'patch_failed' }`
19
+ * - `{ ok: false, error: 'parse_error' | 'path_traversal' | 'forbidden_path' | 'not_found' | 'patch_failed' }`
16
20
  */
17
21
 
18
22
  interface CreateApplyPatchToolOptions {
19
- /** Absolute path to the project root. */
23
+ /** Absolute path to the project root. Every hunk path is gated against this boundary. */
20
24
  projectRoot: string;
21
25
  }
22
26
  declare function createApplyPatchTool(opts: CreateApplyPatchToolOptions): CustomTool;
package/dist/index.d.ts CHANGED
@@ -6,17 +6,21 @@ import { InteractiveProvider } from '@theokit/sdk/interactive';
6
6
  /**
7
7
  * `apply_patch` — built-in tool for coding agents.
8
8
  *
9
- * Parses a unified diff string and applies it to the project files.
10
- * Creates `.bak` backups before modifying each file.
9
+ * Codex's V4A patch grammar (`*** Begin Patch` `*** End Patch`): `*** Add/Update/Delete File:`, an
10
+ * optional `*** Move to:`, and `@@`-anchored `+`/`-`/context hunks matched with a context-tolerant ladder
11
+ * (exact → rstrip → trim → unicode). See `internal/v4a-patch.ts` for the parser + matcher.
12
+ *
13
+ * Applied STRICTLY atomically: the whole patch is planned (every file read + new content computed + path
14
+ * security-checked) before ANY write. A parse error, context mismatch, or path violation anywhere ⇒ typed
15
+ * error and ZERO writes (stronger than Codex, which writes file-by-file and can leave partial writes).
11
16
  *
12
17
  * Return shape (always a JSON string):
13
18
  * - `{ ok: true, files_patched: string[] }`
14
- * - `{ ok: false, error: 'parse_error' | 'path_traversal' |
15
- * 'forbidden_path' | 'patch_failed' }`
19
+ * - `{ ok: false, error: 'parse_error' | 'path_traversal' | 'forbidden_path' | 'not_found' | 'patch_failed' }`
16
20
  */
17
21
 
18
22
  interface CreateApplyPatchToolOptions {
19
- /** Absolute path to the project root. */
23
+ /** Absolute path to the project root. Every hunk path is gated against this boundary. */
20
24
  projectRoot: string;
21
25
  }
22
26
  declare function createApplyPatchTool(opts: CreateApplyPatchToolOptions): CustomTool;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile, copyFile, mkdir, writeFile, readdir, open, stat } from 'fs/promises';
1
+ import { rm, mkdir, writeFile, readFile, readdir, open, stat, copyFile } from 'fs/promises';
2
2
  import { dirname, join, relative, isAbsolute, resolve, sep } from 'path';
3
3
  import { Tool, ConfigurationError } from '@theokit/sdk';
4
4
  import { z } from 'zod';
@@ -102,129 +102,304 @@ function isForbiddenPath(input) {
102
102
  return false;
103
103
  }
104
104
 
105
+ // src/internal/v4a-patch.ts
106
+ var BEGIN = "*** Begin Patch";
107
+ var END = "*** End Patch";
108
+ var ADD = "*** Add File: ";
109
+ var DELETE = "*** Delete File: ";
110
+ var UPDATE = "*** Update File: ";
111
+ var MOVE = "*** Move to: ";
112
+ var EOF_MARK = "*** End of File";
113
+ var CTX = "@@ ";
114
+ var CTX_EMPTY = "@@";
115
+ var V4APatchError = class extends Error {
116
+ constructor(message) {
117
+ super(message);
118
+ this.name = "V4APatchError";
119
+ }
120
+ };
121
+ function parseV4A(patch) {
122
+ const lines = patch.split("\n").map((l) => l.endsWith("\r") ? l.slice(0, -1) : l);
123
+ let i = 0;
124
+ while (i < lines.length && lines[i].trim() !== BEGIN) i++;
125
+ if (i >= lines.length) throw new V4APatchError("Invalid patch: missing '*** Begin Patch'");
126
+ i++;
127
+ const hunks = [];
128
+ while (i < lines.length) {
129
+ if (lines[i].trim() === END) return hunks;
130
+ const step = parseNextHunk(lines, i);
131
+ if (step.hunk) hunks.push(step.hunk);
132
+ i = step.next;
133
+ }
134
+ throw new V4APatchError("Invalid patch: missing '*** End Patch'");
135
+ }
136
+ function parseNextHunk(lines, i) {
137
+ const line = lines[i];
138
+ if (line.startsWith(ADD)) {
139
+ const [hunk, next] = parseAdd(lines, i);
140
+ return { hunk, next };
141
+ }
142
+ if (line.startsWith(DELETE)) {
143
+ return { hunk: { kind: "delete", path: line.slice(DELETE.length).trim() }, next: i + 1 };
144
+ }
145
+ if (line.startsWith(UPDATE)) {
146
+ const [hunk, next] = parseUpdate(lines, i);
147
+ return { hunk, next };
148
+ }
149
+ if (line.trim() === "") return { next: i + 1 };
150
+ throw new V4APatchError(`Invalid patch hunk on line ${i + 1}: unexpected '${line}'`);
151
+ }
152
+ function parseAdd(lines, start) {
153
+ const path = lines[start].slice(ADD.length).trim();
154
+ let i = start + 1;
155
+ const body = [];
156
+ while (i < lines.length && lines[i].startsWith("+")) {
157
+ body.push(lines[i].slice(1));
158
+ i++;
159
+ }
160
+ const content = body.length > 0 ? `${body.join("\n")}
161
+ ` : "";
162
+ return [{ kind: "add", path, content }, i];
163
+ }
164
+ function parseUpdate(lines, start) {
165
+ const path = lines[start].slice(UPDATE.length).trim();
166
+ let i = start + 1;
167
+ let movePath = null;
168
+ if (i < lines.length && lines[i].startsWith(MOVE)) {
169
+ movePath = lines[i].slice(MOVE.length).trim();
170
+ i++;
171
+ }
172
+ const chunks = [];
173
+ let cur = null;
174
+ for (; i < lines.length; i++) {
175
+ const line = lines[i];
176
+ if (isHunkBoundary(line)) break;
177
+ cur = feedLine(chunks, cur, line, path, i + 1);
178
+ }
179
+ pushChunk(chunks, cur, path);
180
+ if (chunks.length === 0) throw new V4APatchError(`Update file hunk for '${path}' is empty`);
181
+ return [{ kind: "update", path, movePath, chunks }, i];
182
+ }
183
+ function isHunkBoundary(line) {
184
+ return line.trim() === END || line.startsWith(ADD) || line.startsWith(DELETE) || line.startsWith(UPDATE);
185
+ }
186
+ function emptyChunk() {
187
+ return { context: null, oldLines: [], newLines: [], eof: false };
188
+ }
189
+ function pushChunk(chunks, cur, path) {
190
+ if (!cur) return;
191
+ if (cur.oldLines.length === 0 && cur.newLines.length === 0) {
192
+ throw new V4APatchError(`Update hunk for '${path}' has an empty change block`);
193
+ }
194
+ chunks.push(cur);
195
+ }
196
+ function feedLine(chunks, cur, line, path, lineNum) {
197
+ if (line === CTX_EMPTY || line.startsWith(CTX)) {
198
+ pushChunk(chunks, cur, path);
199
+ return {
200
+ context: line === CTX_EMPTY ? null : line.slice(CTX.length),
201
+ oldLines: [],
202
+ newLines: [],
203
+ eof: false
204
+ };
205
+ }
206
+ if (line.trim() === EOF_MARK) {
207
+ if (!cur) throw new V4APatchError(`'*** End of File' before any change in '${path}'`);
208
+ cur.eof = true;
209
+ return cur;
210
+ }
211
+ return appendChangeLine(cur, line, path, lineNum);
212
+ }
213
+ function appendChangeLine(cur, line, path, lineNum) {
214
+ const c = cur ?? emptyChunk();
215
+ if (line.startsWith("+")) {
216
+ c.newLines.push(line.slice(1));
217
+ return c;
218
+ }
219
+ if (line.startsWith("-")) {
220
+ c.oldLines.push(line.slice(1));
221
+ return c;
222
+ }
223
+ if (line === "" || line.startsWith(" ")) {
224
+ const content = line === "" ? "" : line.slice(1);
225
+ c.oldLines.push(content);
226
+ c.newLines.push(content);
227
+ return c;
228
+ }
229
+ throw new V4APatchError(
230
+ `Unexpected line in update hunk for '${path}' (line ${lineNum}): every line must start with ' ', '+', or '-': '${line}'`
231
+ );
232
+ }
233
+ var UNICODE_MAP = {
234
+ "\u2010": "-",
235
+ "\u2011": "-",
236
+ "\u2012": "-",
237
+ "\u2013": "-",
238
+ "\u2014": "-",
239
+ "\u2015": "-",
240
+ "\u2018": "'",
241
+ "\u2019": "'",
242
+ "\u201C": '"',
243
+ "\u201D": '"',
244
+ "\xA0": " ",
245
+ "\u2007": " ",
246
+ "\u202F": " "
247
+ };
248
+ function normalise(s) {
249
+ let out = "";
250
+ for (const ch of s) out += UNICODE_MAP[ch] ?? ch;
251
+ return out.trim();
252
+ }
253
+ var LADDER = [
254
+ (a, b) => a === b,
255
+ (a, b) => a.replace(/\s+$/, "") === b.replace(/\s+$/, ""),
256
+ (a, b) => a.trim() === b.trim(),
257
+ (a, b) => normalise(a) === normalise(b)
258
+ ];
259
+ function matchesAt(lines, pattern, at, eq) {
260
+ for (let k = 0; k < pattern.length; k++) {
261
+ if (!eq(lines[at + k], pattern[k])) return false;
262
+ }
263
+ return true;
264
+ }
265
+ function seekSequence(lines, pattern, searchStart, eof) {
266
+ if (pattern.length === 0) return searchStart;
267
+ if (pattern.length > lines.length) return null;
268
+ const start = eof ? Math.max(searchStart, lines.length - pattern.length) : searchStart;
269
+ for (const eq of LADDER) {
270
+ for (let i = start; i <= lines.length - pattern.length; i++) {
271
+ if (matchesAt(lines, pattern, i, eq)) return i;
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+ function applyUpdateChunks(content, path, chunks) {
277
+ const lines = content.split("\n");
278
+ const edits = [];
279
+ let cursor = 0;
280
+ for (const chunk of chunks) {
281
+ if (chunk.context !== null) {
282
+ const ctxAt = seekSequence(lines, [chunk.context], cursor, false);
283
+ if (ctxAt === null) {
284
+ throw new V4APatchError(`Failed to find context '${chunk.context}' in ${path}`);
285
+ }
286
+ cursor = ctxAt + 1;
287
+ }
288
+ const at = seekSequence(lines, chunk.oldLines, cursor, chunk.eof);
289
+ if (at === null) {
290
+ throw new V4APatchError(
291
+ `Failed to find expected lines in ${path}:
292
+ ${chunk.oldLines.join("\n")}`
293
+ );
294
+ }
295
+ edits.push({ start: at, oldLen: chunk.oldLines.length, newLines: chunk.newLines });
296
+ cursor = at + chunk.oldLines.length;
297
+ }
298
+ edits.sort((a, b) => b.start - a.start);
299
+ for (const e of edits) {
300
+ lines.splice(e.start, e.oldLen, ...e.newLines);
301
+ }
302
+ return lines.join("\n");
303
+ }
304
+
105
305
  // src/apply-patch.ts
106
306
  function createApplyPatchTool(opts) {
107
307
  const { projectRoot } = opts;
108
308
  return Tool.create({
109
309
  name: "apply_patch",
110
- description: "Apply a unified diff patch to project files. Each file in the diff is security-checked against the project root. Creates .bak backups before modifying. Returns { ok, files_patched } or { ok: false, error }.",
310
+ description: "Apply a Codex-style V4A patch. The patch is `*** Begin Patch` \u2026 `*** End Patch` wrapping one or more hunks: `*** Add File: <path>` (then `+`lines), `*** Delete File: <path>`, or `*** Update File: <path>` (optional `*** Move to: <path>`) with `@@`-anchored `+` (add) / `-` (remove) / ` ` (context) lines. Read a file first so your context/removed lines match. Applied atomically \u2014 a mismatch anywhere aborts the whole patch with zero writes; each path is security-checked. Returns { ok, files_patched } or { ok: false, error }.",
111
311
  inputSchema: z.object({
112
- patch: z.string().min(1).describe("Unified diff content.")
312
+ patch: z.string().min(1).describe("V4A patch: *** Begin Patch \u2026 *** End Patch.")
113
313
  }),
114
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: unified diff parsing is inherently complex
115
- handler: async ({ patch }) => {
116
- const hunks = parsePatch(patch);
117
- if (hunks.length === 0) {
118
- return JSON.stringify({ ok: false, error: "parse_error", detail: "no file hunks found" });
119
- }
120
- for (const hunk of hunks) {
121
- if (isForbiddenPath(hunk.file)) {
122
- return JSON.stringify({ ok: false, error: "forbidden_path", path: hunk.file });
123
- }
124
- try {
125
- const abs = safePathJoin(projectRoot, hunk.file);
126
- assertNoSymlinkEscape(abs, projectRoot);
127
- } catch (err) {
128
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
129
- return JSON.stringify({ ok: false, error: "path_traversal", path: hunk.file });
130
- }
131
- throw err;
132
- }
133
- }
134
- const patched = [];
135
- for (const hunk of hunks) {
136
- const absolutePath = safePathJoin(projectRoot, hunk.file);
137
- let content;
138
- try {
139
- content = await readFile(absolutePath, "utf-8");
140
- } catch (err) {
141
- const e = err;
142
- if (e.code === "ENOENT") {
143
- content = "";
144
- } else {
145
- throw err;
146
- }
147
- }
148
- const result = applyHunks(content, hunk.changes);
149
- if (result === null) {
150
- return JSON.stringify({
151
- ok: false,
152
- error: "patch_failed",
153
- path: hunk.file,
154
- detail: "hunk context mismatch"
155
- });
156
- }
157
- if (content !== "") {
158
- await copyFile(absolutePath, `${absolutePath}.bak`);
159
- }
160
- await mkdir(dirname(absolutePath), { recursive: true });
161
- await writeFile(absolutePath, result, "utf-8");
162
- patched.push(hunk.file);
163
- }
164
- return JSON.stringify({ ok: true, files_patched: patched });
165
- }
314
+ handler: async ({ patch }) => applyV4APatch(projectRoot, patch)
166
315
  });
167
316
  }
168
- function parsePatch(patch) {
169
- const lines = patch.split("\n");
170
- const hunks = [];
171
- let current = null;
172
- for (const line of lines) {
173
- if (line.startsWith("+++ ")) {
174
- const filePath = line.slice(4).replace(/^b\//, "").trim();
175
- if (filePath && filePath !== "/dev/null") {
176
- current = { file: filePath, changes: [] };
177
- hunks.push(current);
178
- }
179
- continue;
180
- }
181
- if (line.startsWith("--- ")) continue;
182
- if (line.startsWith("@@ ")) continue;
183
- if (current === null) continue;
184
- if (line.startsWith("+")) {
185
- current.changes.push({ type: "add", content: line.slice(1) });
186
- } else if (line.startsWith("-")) {
187
- current.changes.push({ type: "remove", content: line.slice(1) });
188
- } else if (line.startsWith(" ")) {
189
- current.changes.push({ type: "context", content: line.slice(1) });
317
+ async function applyV4APatch(projectRoot, patch) {
318
+ let hunks;
319
+ try {
320
+ hunks = parseV4A(patch);
321
+ } catch (err) {
322
+ const detail = err instanceof V4APatchError ? err.message : String(err);
323
+ return JSON.stringify({ ok: false, error: "parse_error", detail });
324
+ }
325
+ const plan = await buildPlan(projectRoot, hunks);
326
+ if ("error" in plan) return plan.error;
327
+ await executePlan(plan.ops);
328
+ return JSON.stringify({ ok: true, files_patched: plan.patched });
329
+ }
330
+ async function buildPlan(projectRoot, hunks) {
331
+ const ops = [];
332
+ const patched = [];
333
+ for (const hunk of hunks) {
334
+ const planned = await planHunk(projectRoot, hunk);
335
+ if ("error" in planned) return planned;
336
+ ops.push(...planned.ops);
337
+ patched.push(hunk.kind === "update" ? hunk.movePath ?? hunk.path : hunk.path);
338
+ }
339
+ return { ops, patched };
340
+ }
341
+ async function executePlan(ops) {
342
+ for (const op of ops) {
343
+ if ("rm" in op) {
344
+ await rm(op.rm, { force: true });
345
+ } else {
346
+ await mkdir(dirname(op.write.abs), { recursive: true });
347
+ await writeFile(op.write.abs, op.write.content, "utf-8");
190
348
  }
191
349
  }
192
- return hunks;
193
350
  }
194
- function applyHunks(content, changes) {
195
- const originalLines = content.split("\n");
196
- const result = [];
197
- let origIdx = 0;
198
- const firstContext = changes.find((c) => c.type === "context" || c.type === "remove");
199
- if (firstContext) {
200
- const startIdx = originalLines.indexOf(firstContext.content, origIdx);
201
- if (startIdx === -1) return null;
202
- for (let i = 0; i < startIdx; i++) {
203
- result.push(originalLines[i]);
351
+ async function planHunk(projectRoot, hunk) {
352
+ const scope = v4aScope(projectRoot, hunk.path);
353
+ if ("error" in scope) return scope;
354
+ if (hunk.kind === "add") return { ops: [{ write: { abs: scope.abs, content: hunk.content } }] };
355
+ if (hunk.kind === "delete") return { ops: [{ rm: scope.abs }] };
356
+ return planUpdate(projectRoot, hunk, scope.abs);
357
+ }
358
+ async function planUpdate(projectRoot, hunk, abs) {
359
+ let content;
360
+ try {
361
+ content = await readFile(abs, "utf-8");
362
+ } catch (err) {
363
+ if (err.code === "ENOENT") {
364
+ return { error: JSON.stringify({ ok: false, error: "not_found", path: hunk.path }) };
204
365
  }
205
- origIdx = startIdx;
366
+ throw err;
206
367
  }
207
- for (const change of changes) {
208
- if (change.type === "context") {
209
- if (origIdx >= originalLines.length || originalLines[origIdx] !== change.content) {
210
- return null;
211
- }
212
- result.push(change.content);
213
- origIdx++;
214
- } else if (change.type === "remove") {
215
- if (origIdx >= originalLines.length || originalLines[origIdx] !== change.content) {
216
- return null;
217
- }
218
- origIdx++;
219
- } else if (change.type === "add") {
220
- result.push(change.content);
368
+ let updated;
369
+ try {
370
+ updated = applyUpdateChunks(content, hunk.path, hunk.chunks);
371
+ } catch (err) {
372
+ if (err instanceof V4APatchError) {
373
+ return {
374
+ error: JSON.stringify({
375
+ ok: false,
376
+ error: "patch_failed",
377
+ path: hunk.path,
378
+ detail: err.message
379
+ })
380
+ };
221
381
  }
382
+ throw err;
222
383
  }
223
- while (origIdx < originalLines.length) {
224
- result.push(originalLines[origIdx]);
225
- origIdx++;
384
+ if (!hunk.movePath) return { ops: [{ write: { abs, content: updated } }] };
385
+ const dest = v4aScope(projectRoot, hunk.movePath);
386
+ if ("error" in dest) return dest;
387
+ return { ops: [{ write: { abs: dest.abs, content: updated } }, { rm: abs }] };
388
+ }
389
+ function v4aScope(projectRoot, file) {
390
+ if (isForbiddenPath(file)) {
391
+ return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
392
+ }
393
+ try {
394
+ const abs = safePathJoin(projectRoot, file);
395
+ assertNoSymlinkEscape(abs, projectRoot);
396
+ return { abs };
397
+ } catch (err) {
398
+ if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
399
+ return { error: JSON.stringify({ ok: false, error: "path_traversal", path: file }) };
400
+ }
401
+ throw err;
226
402
  }
227
- return result.join("\n");
228
403
  }
229
404
  function createSessionArtifactStore(options) {
230
405
  const { dir } = options;
@@ -328,7 +503,7 @@ var LINE_MATCH_LADDER = [
328
503
  (s) => normalizeUnicode(s).trim()
329
504
  // unicode + trim (loosest)
330
505
  ];
331
- function matchesAt(lines, pattern, i, norm) {
506
+ function matchesAt2(lines, pattern, i, norm) {
332
507
  for (let p = 0; p < pattern.length; p++) {
333
508
  const lineAt = lines[i + p];
334
509
  const patAt = pattern[p];
@@ -339,7 +514,7 @@ function matchesAt(lines, pattern, i, norm) {
339
514
  function findHits(lines, pattern, norm) {
340
515
  const hits = [];
341
516
  for (let i = 0; i + pattern.length <= lines.length; i++) {
342
- if (matchesAt(lines, pattern, i, norm)) hits.push(i);
517
+ if (matchesAt2(lines, pattern, i, norm)) hits.push(i);
343
518
  }
344
519
  return hits;
345
520
  }