@theokit/sdk-tools 0.18.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/CHANGELOG.md +25 -0
- package/dist/index.cjs +320 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -5
- package/dist/index.d.ts +15 -5
- package/dist/index.js +317 -120
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
*
|
|
10
|
-
*
|
|
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;
|
|
@@ -780,6 +784,12 @@ interface CreateSearchTextToolOptions {
|
|
|
780
784
|
/** Optional injected filesystem (`@theokit/sdk/filesystem`) — when provided, the recursive walk reads
|
|
781
785
|
* through the backend (surface-agnostic); omitted ⇒ the local `readdir`/`readFile` walk (unchanged). */
|
|
782
786
|
filesystem?: FilesystemProvider;
|
|
787
|
+
/** M17 — treat `query` as a JavaScript RegExp instead of a literal substring (grep semantics). An
|
|
788
|
+
* invalid pattern returns `{ ok: false, error: 'invalid_regex' }`. Default `false` ⇒ literal (unchanged). */
|
|
789
|
+
regex?: boolean;
|
|
790
|
+
/** M17 — opt-in "reads-anywhere": honor an ABSOLUTE `path` scope outside `projectRoot` (Codex read-only
|
|
791
|
+
* sandbox). Forbidden dirs are still skipped. Default `false` ⇒ absolute scope rejected (unchanged). */
|
|
792
|
+
allowAbsolute?: boolean;
|
|
783
793
|
}
|
|
784
794
|
declare function createSearchTextTool(opts: CreateSearchTextToolOptions): CustomTool;
|
|
785
795
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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;
|
|
@@ -780,6 +784,12 @@ interface CreateSearchTextToolOptions {
|
|
|
780
784
|
/** Optional injected filesystem (`@theokit/sdk/filesystem`) — when provided, the recursive walk reads
|
|
781
785
|
* through the backend (surface-agnostic); omitted ⇒ the local `readdir`/`readFile` walk (unchanged). */
|
|
782
786
|
filesystem?: FilesystemProvider;
|
|
787
|
+
/** M17 — treat `query` as a JavaScript RegExp instead of a literal substring (grep semantics). An
|
|
788
|
+
* invalid pattern returns `{ ok: false, error: 'invalid_regex' }`. Default `false` ⇒ literal (unchanged). */
|
|
789
|
+
regex?: boolean;
|
|
790
|
+
/** M17 — opt-in "reads-anywhere": honor an ABSOLUTE `path` scope outside `projectRoot` (Codex read-only
|
|
791
|
+
* sandbox). Forbidden dirs are still skipped. Default `false` ⇒ absolute scope rejected (unchanged). */
|
|
792
|
+
allowAbsolute?: boolean;
|
|
783
793
|
}
|
|
784
794
|
declare function createSearchTextTool(opts: CreateSearchTextToolOptions): CustomTool;
|
|
785
795
|
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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("
|
|
312
|
+
patch: z.string().min(1).describe("V4A patch: *** Begin Patch \u2026 *** End Patch.")
|
|
113
313
|
}),
|
|
114
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
366
|
+
throw err;
|
|
206
367
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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 (
|
|
517
|
+
if (matchesAt2(lines, pattern, i, norm)) hits.push(i);
|
|
343
518
|
}
|
|
344
519
|
return hits;
|
|
345
520
|
}
|
|
@@ -1891,27 +2066,36 @@ function createSearchTextTool(opts) {
|
|
|
1891
2066
|
projectRoot,
|
|
1892
2067
|
maxMatches = DEFAULT_MAX_MATCHES,
|
|
1893
2068
|
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
|
1894
|
-
filesystem
|
|
2069
|
+
filesystem,
|
|
2070
|
+
regex = false,
|
|
2071
|
+
allowAbsolute = false
|
|
1895
2072
|
} = opts;
|
|
2073
|
+
const queryKind = regex ? "a JavaScript REGULAR EXPRESSION" : "LITERAL, CASE-SENSITIVE text";
|
|
2074
|
+
const queryMatch = regex ? "matched as a regex" : "matched as a substring, not a regex";
|
|
1896
2075
|
return Tool.create({
|
|
1897
2076
|
name: "search_text",
|
|
1898
|
-
description: `Search file CONTENTS for
|
|
2077
|
+
description: `Search file CONTENTS for ${queryKind} across the project tree (the query is ${queryMatch}). Use search_text when you know the content; use glob_files when you know the filename shape; use read_file when you know the exact path. Skips sensitive dirs (.env/.git/node_modules/.theo), binary files, and files over 1 MB; 'path' scopes the search to a subdirectory. Returns up to ${String(maxMatches)} matches as { file, line, preview } \u2014 cite locations to the user as file:line. Returns { ok, matches } or { ok: false, error }.`,
|
|
1899
2078
|
inputSchema: z.object({
|
|
1900
|
-
query: z.string().min(1).describe("Literal text to search for. Case-sensitive."),
|
|
1901
|
-
path: z.string().optional().describe(
|
|
2079
|
+
query: regex ? z.string().min(1).describe("A JavaScript regular expression, e.g. 'function\\\\s+main'.") : z.string().min(1).describe("Literal text to search for. Case-sensitive."),
|
|
2080
|
+
path: z.string().optional().describe(
|
|
2081
|
+
"Optional directory to scope the search (project-relative; absolute when allowed)."
|
|
2082
|
+
)
|
|
1902
2083
|
}),
|
|
1903
2084
|
handler: async ({ query, path }, ctx) => {
|
|
2085
|
+
const built = buildMatcher(query, regex);
|
|
2086
|
+
if ("error" in built) return built.error;
|
|
2087
|
+
const matcher = built.matcher;
|
|
1904
2088
|
const state = {
|
|
1905
2089
|
matches: [],
|
|
1906
2090
|
totalMatches: 0,
|
|
1907
2091
|
truncated: false,
|
|
1908
|
-
|
|
2092
|
+
matcher,
|
|
1909
2093
|
maxMatches,
|
|
1910
2094
|
maxFileSize,
|
|
1911
2095
|
projectRoot
|
|
1912
2096
|
};
|
|
1913
2097
|
if (filesystem !== void 0) {
|
|
1914
|
-
const scopeRel = resolveScopeRel(path, projectRoot);
|
|
2098
|
+
const scopeRel = resolveScopeRel(path, projectRoot, allowAbsolute);
|
|
1915
2099
|
if ("error" in scopeRel) return scopeRel.error;
|
|
1916
2100
|
const backend = await resolveFilesystem(filesystem, ctx ?? {});
|
|
1917
2101
|
await walkBackend(backend, scopeRel.rel, state, 0);
|
|
@@ -1922,7 +2106,7 @@ function createSearchTextTool(opts) {
|
|
|
1922
2106
|
totalMatches: state.totalMatches
|
|
1923
2107
|
});
|
|
1924
2108
|
}
|
|
1925
|
-
const scope = resolveSearchScope(path, projectRoot);
|
|
2109
|
+
const scope = resolveSearchScope(path, projectRoot, allowAbsolute);
|
|
1926
2110
|
if ("error" in scope) return scope.error;
|
|
1927
2111
|
await walk(scope.scopeAbs, state);
|
|
1928
2112
|
return JSON.stringify({
|
|
@@ -1934,8 +2118,20 @@ function createSearchTextTool(opts) {
|
|
|
1934
2118
|
}
|
|
1935
2119
|
});
|
|
1936
2120
|
}
|
|
1937
|
-
function
|
|
2121
|
+
function buildMatcher(query, regex) {
|
|
2122
|
+
if (!regex) return { matcher: (line) => line.includes(query) };
|
|
2123
|
+
try {
|
|
2124
|
+
const re = new RegExp(query);
|
|
2125
|
+
return { matcher: (line) => re.test(line) };
|
|
2126
|
+
} catch {
|
|
2127
|
+
return { error: JSON.stringify({ ok: false, error: "invalid_regex", query }) };
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
function resolveSearchScope(path, projectRoot, allowAbsolute) {
|
|
1938
2131
|
const scopeRel = path === void 0 || path === "" || path === "." ? "." : path;
|
|
2132
|
+
if (allowAbsolute && isAbsolute(scopeRel)) {
|
|
2133
|
+
return { scopeAbs: scopeRel };
|
|
2134
|
+
}
|
|
1939
2135
|
try {
|
|
1940
2136
|
const scopeAbs = scopeRel === "." ? projectRoot : safePathJoin(projectRoot, scopeRel);
|
|
1941
2137
|
assertNoSymlinkEscape(scopeAbs, projectRoot);
|
|
@@ -2007,13 +2203,14 @@ async function scanFile(absPath, relPath, state) {
|
|
|
2007
2203
|
const lines = buffer.toString("utf-8").split("\n");
|
|
2008
2204
|
for (let i = 0; i < lines.length; i += 1) {
|
|
2009
2205
|
const line = lines[i];
|
|
2010
|
-
if (!
|
|
2206
|
+
if (!state.matcher(line)) continue;
|
|
2011
2207
|
if (!recordMatch(state, relPath, i + 1, line)) return;
|
|
2012
2208
|
}
|
|
2013
2209
|
}
|
|
2014
|
-
function resolveScopeRel(path, projectRoot) {
|
|
2210
|
+
function resolveScopeRel(path, projectRoot, allowAbsolute) {
|
|
2015
2211
|
const scopeRel = path === void 0 || path === "" || path === "." ? "" : path;
|
|
2016
2212
|
if (scopeRel === "") return { rel: "" };
|
|
2213
|
+
if (allowAbsolute && isAbsolute(scopeRel)) return { rel: scopeRel };
|
|
2017
2214
|
try {
|
|
2018
2215
|
assertNoSymlinkEscape(safePathJoin(projectRoot, scopeRel), projectRoot);
|
|
2019
2216
|
return { rel: scopeRel };
|
|
@@ -2065,7 +2262,7 @@ async function scanFileBackend(backend, relPath, size, state) {
|
|
|
2065
2262
|
const lines = content.split("\n");
|
|
2066
2263
|
for (let i = 0; i < lines.length; i += 1) {
|
|
2067
2264
|
const line = lines[i];
|
|
2068
|
-
if (!
|
|
2265
|
+
if (!state.matcher(line)) continue;
|
|
2069
2266
|
if (!recordMatch(state, relPath, i + 1, line)) return;
|
|
2070
2267
|
}
|
|
2071
2268
|
}
|