@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 CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 68d0e7f: `createApplyPatchTool` now parses Codex's **V4A patch grammar** (`*** Begin Patch` … `*** End Patch`;
8
+ `*** Add/Update/Delete File:`, optional `*** Move to:`, `@@`-anchored `+`/`-`/context hunks) instead of a
9
+ unified diff — the format the model actually emits in a Codex-style agent. **BREAKING:** the `{ patch }`
10
+ input is now a V4A patch, not a unified diff. Matching uses a context-tolerant ladder (exact → rstrip →
11
+ trim → unicode) mirroring Codex `seek_sequence`; `@@ <ctx>` anchors the search; `*** End of File` anchors
12
+ to the tail. Applied **strictly atomically** — the whole patch is planned (every file read + new content
13
+ computed + path security-checked) before any write, so a parse error / context mismatch / path violation
14
+ anywhere yields a typed error and ZERO writes (stronger than Codex, which can leave partial writes).
15
+ Add File strips one `+` and always ends with a trailing newline; Update+Move transforms the old content
16
+ and renames. New `internal/v4a-patch.ts` parser/matcher.
17
+
18
+ ## 0.19.0
19
+
20
+ ### Minor Changes
21
+
22
+ - `createSearchTextTool` gains two ADDITIVE, opt-in options (both default OFF ⇒ existing literal, project-
23
+ scoped behavior unchanged): `regex` — match `query` as a JavaScript RegExp (grep semantics; an invalid
24
+ pattern returns `{ ok: false, error: 'invalid_regex' }` before walking), and `allowAbsolute` — honor an
25
+ absolute `path` scope outside `projectRoot` (Codex read-only "reads-anywhere"; forbidden dirs still
26
+ skipped). Together they let one built-in cover both literal content search and grep-style regex search.
27
+
3
28
  ## 0.18.0
4
29
 
5
30
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -104,129 +104,304 @@ function isForbiddenPath(input) {
104
104
  return false;
105
105
  }
106
106
 
107
+ // src/internal/v4a-patch.ts
108
+ var BEGIN = "*** Begin Patch";
109
+ var END = "*** End Patch";
110
+ var ADD = "*** Add File: ";
111
+ var DELETE = "*** Delete File: ";
112
+ var UPDATE = "*** Update File: ";
113
+ var MOVE = "*** Move to: ";
114
+ var EOF_MARK = "*** End of File";
115
+ var CTX = "@@ ";
116
+ var CTX_EMPTY = "@@";
117
+ var V4APatchError = class extends Error {
118
+ constructor(message) {
119
+ super(message);
120
+ this.name = "V4APatchError";
121
+ }
122
+ };
123
+ function parseV4A(patch) {
124
+ const lines = patch.split("\n").map((l) => l.endsWith("\r") ? l.slice(0, -1) : l);
125
+ let i = 0;
126
+ while (i < lines.length && lines[i].trim() !== BEGIN) i++;
127
+ if (i >= lines.length) throw new V4APatchError("Invalid patch: missing '*** Begin Patch'");
128
+ i++;
129
+ const hunks = [];
130
+ while (i < lines.length) {
131
+ if (lines[i].trim() === END) return hunks;
132
+ const step = parseNextHunk(lines, i);
133
+ if (step.hunk) hunks.push(step.hunk);
134
+ i = step.next;
135
+ }
136
+ throw new V4APatchError("Invalid patch: missing '*** End Patch'");
137
+ }
138
+ function parseNextHunk(lines, i) {
139
+ const line = lines[i];
140
+ if (line.startsWith(ADD)) {
141
+ const [hunk, next] = parseAdd(lines, i);
142
+ return { hunk, next };
143
+ }
144
+ if (line.startsWith(DELETE)) {
145
+ return { hunk: { kind: "delete", path: line.slice(DELETE.length).trim() }, next: i + 1 };
146
+ }
147
+ if (line.startsWith(UPDATE)) {
148
+ const [hunk, next] = parseUpdate(lines, i);
149
+ return { hunk, next };
150
+ }
151
+ if (line.trim() === "") return { next: i + 1 };
152
+ throw new V4APatchError(`Invalid patch hunk on line ${i + 1}: unexpected '${line}'`);
153
+ }
154
+ function parseAdd(lines, start) {
155
+ const path = lines[start].slice(ADD.length).trim();
156
+ let i = start + 1;
157
+ const body = [];
158
+ while (i < lines.length && lines[i].startsWith("+")) {
159
+ body.push(lines[i].slice(1));
160
+ i++;
161
+ }
162
+ const content = body.length > 0 ? `${body.join("\n")}
163
+ ` : "";
164
+ return [{ kind: "add", path, content }, i];
165
+ }
166
+ function parseUpdate(lines, start) {
167
+ const path = lines[start].slice(UPDATE.length).trim();
168
+ let i = start + 1;
169
+ let movePath = null;
170
+ if (i < lines.length && lines[i].startsWith(MOVE)) {
171
+ movePath = lines[i].slice(MOVE.length).trim();
172
+ i++;
173
+ }
174
+ const chunks = [];
175
+ let cur = null;
176
+ for (; i < lines.length; i++) {
177
+ const line = lines[i];
178
+ if (isHunkBoundary(line)) break;
179
+ cur = feedLine(chunks, cur, line, path, i + 1);
180
+ }
181
+ pushChunk(chunks, cur, path);
182
+ if (chunks.length === 0) throw new V4APatchError(`Update file hunk for '${path}' is empty`);
183
+ return [{ kind: "update", path, movePath, chunks }, i];
184
+ }
185
+ function isHunkBoundary(line) {
186
+ return line.trim() === END || line.startsWith(ADD) || line.startsWith(DELETE) || line.startsWith(UPDATE);
187
+ }
188
+ function emptyChunk() {
189
+ return { context: null, oldLines: [], newLines: [], eof: false };
190
+ }
191
+ function pushChunk(chunks, cur, path) {
192
+ if (!cur) return;
193
+ if (cur.oldLines.length === 0 && cur.newLines.length === 0) {
194
+ throw new V4APatchError(`Update hunk for '${path}' has an empty change block`);
195
+ }
196
+ chunks.push(cur);
197
+ }
198
+ function feedLine(chunks, cur, line, path, lineNum) {
199
+ if (line === CTX_EMPTY || line.startsWith(CTX)) {
200
+ pushChunk(chunks, cur, path);
201
+ return {
202
+ context: line === CTX_EMPTY ? null : line.slice(CTX.length),
203
+ oldLines: [],
204
+ newLines: [],
205
+ eof: false
206
+ };
207
+ }
208
+ if (line.trim() === EOF_MARK) {
209
+ if (!cur) throw new V4APatchError(`'*** End of File' before any change in '${path}'`);
210
+ cur.eof = true;
211
+ return cur;
212
+ }
213
+ return appendChangeLine(cur, line, path, lineNum);
214
+ }
215
+ function appendChangeLine(cur, line, path, lineNum) {
216
+ const c = cur ?? emptyChunk();
217
+ if (line.startsWith("+")) {
218
+ c.newLines.push(line.slice(1));
219
+ return c;
220
+ }
221
+ if (line.startsWith("-")) {
222
+ c.oldLines.push(line.slice(1));
223
+ return c;
224
+ }
225
+ if (line === "" || line.startsWith(" ")) {
226
+ const content = line === "" ? "" : line.slice(1);
227
+ c.oldLines.push(content);
228
+ c.newLines.push(content);
229
+ return c;
230
+ }
231
+ throw new V4APatchError(
232
+ `Unexpected line in update hunk for '${path}' (line ${lineNum}): every line must start with ' ', '+', or '-': '${line}'`
233
+ );
234
+ }
235
+ var UNICODE_MAP = {
236
+ "\u2010": "-",
237
+ "\u2011": "-",
238
+ "\u2012": "-",
239
+ "\u2013": "-",
240
+ "\u2014": "-",
241
+ "\u2015": "-",
242
+ "\u2018": "'",
243
+ "\u2019": "'",
244
+ "\u201C": '"',
245
+ "\u201D": '"',
246
+ "\xA0": " ",
247
+ "\u2007": " ",
248
+ "\u202F": " "
249
+ };
250
+ function normalise(s) {
251
+ let out = "";
252
+ for (const ch of s) out += UNICODE_MAP[ch] ?? ch;
253
+ return out.trim();
254
+ }
255
+ var LADDER = [
256
+ (a, b) => a === b,
257
+ (a, b) => a.replace(/\s+$/, "") === b.replace(/\s+$/, ""),
258
+ (a, b) => a.trim() === b.trim(),
259
+ (a, b) => normalise(a) === normalise(b)
260
+ ];
261
+ function matchesAt(lines, pattern, at, eq) {
262
+ for (let k = 0; k < pattern.length; k++) {
263
+ if (!eq(lines[at + k], pattern[k])) return false;
264
+ }
265
+ return true;
266
+ }
267
+ function seekSequence(lines, pattern, searchStart, eof) {
268
+ if (pattern.length === 0) return searchStart;
269
+ if (pattern.length > lines.length) return null;
270
+ const start = eof ? Math.max(searchStart, lines.length - pattern.length) : searchStart;
271
+ for (const eq of LADDER) {
272
+ for (let i = start; i <= lines.length - pattern.length; i++) {
273
+ if (matchesAt(lines, pattern, i, eq)) return i;
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+ function applyUpdateChunks(content, path, chunks) {
279
+ const lines = content.split("\n");
280
+ const edits = [];
281
+ let cursor = 0;
282
+ for (const chunk of chunks) {
283
+ if (chunk.context !== null) {
284
+ const ctxAt = seekSequence(lines, [chunk.context], cursor, false);
285
+ if (ctxAt === null) {
286
+ throw new V4APatchError(`Failed to find context '${chunk.context}' in ${path}`);
287
+ }
288
+ cursor = ctxAt + 1;
289
+ }
290
+ const at = seekSequence(lines, chunk.oldLines, cursor, chunk.eof);
291
+ if (at === null) {
292
+ throw new V4APatchError(
293
+ `Failed to find expected lines in ${path}:
294
+ ${chunk.oldLines.join("\n")}`
295
+ );
296
+ }
297
+ edits.push({ start: at, oldLen: chunk.oldLines.length, newLines: chunk.newLines });
298
+ cursor = at + chunk.oldLines.length;
299
+ }
300
+ edits.sort((a, b) => b.start - a.start);
301
+ for (const e of edits) {
302
+ lines.splice(e.start, e.oldLen, ...e.newLines);
303
+ }
304
+ return lines.join("\n");
305
+ }
306
+
107
307
  // src/apply-patch.ts
108
308
  function createApplyPatchTool(opts) {
109
309
  const { projectRoot } = opts;
110
310
  return sdk.Tool.create({
111
311
  name: "apply_patch",
112
- 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 }.",
312
+ 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 }.",
113
313
  inputSchema: zod.z.object({
114
- patch: zod.z.string().min(1).describe("Unified diff content.")
314
+ patch: zod.z.string().min(1).describe("V4A patch: *** Begin Patch \u2026 *** End Patch.")
115
315
  }),
116
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: unified diff parsing is inherently complex
117
- handler: async ({ patch }) => {
118
- const hunks = parsePatch(patch);
119
- if (hunks.length === 0) {
120
- return JSON.stringify({ ok: false, error: "parse_error", detail: "no file hunks found" });
121
- }
122
- for (const hunk of hunks) {
123
- if (isForbiddenPath(hunk.file)) {
124
- return JSON.stringify({ ok: false, error: "forbidden_path", path: hunk.file });
125
- }
126
- try {
127
- const abs = safePathJoin(projectRoot, hunk.file);
128
- assertNoSymlinkEscape(abs, projectRoot);
129
- } catch (err) {
130
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
131
- return JSON.stringify({ ok: false, error: "path_traversal", path: hunk.file });
132
- }
133
- throw err;
134
- }
135
- }
136
- const patched = [];
137
- for (const hunk of hunks) {
138
- const absolutePath = safePathJoin(projectRoot, hunk.file);
139
- let content;
140
- try {
141
- content = await promises.readFile(absolutePath, "utf-8");
142
- } catch (err) {
143
- const e = err;
144
- if (e.code === "ENOENT") {
145
- content = "";
146
- } else {
147
- throw err;
148
- }
149
- }
150
- const result = applyHunks(content, hunk.changes);
151
- if (result === null) {
152
- return JSON.stringify({
153
- ok: false,
154
- error: "patch_failed",
155
- path: hunk.file,
156
- detail: "hunk context mismatch"
157
- });
158
- }
159
- if (content !== "") {
160
- await promises.copyFile(absolutePath, `${absolutePath}.bak`);
161
- }
162
- await promises.mkdir(path.dirname(absolutePath), { recursive: true });
163
- await promises.writeFile(absolutePath, result, "utf-8");
164
- patched.push(hunk.file);
165
- }
166
- return JSON.stringify({ ok: true, files_patched: patched });
167
- }
316
+ handler: async ({ patch }) => applyV4APatch(projectRoot, patch)
168
317
  });
169
318
  }
170
- function parsePatch(patch) {
171
- const lines = patch.split("\n");
172
- const hunks = [];
173
- let current = null;
174
- for (const line of lines) {
175
- if (line.startsWith("+++ ")) {
176
- const filePath = line.slice(4).replace(/^b\//, "").trim();
177
- if (filePath && filePath !== "/dev/null") {
178
- current = { file: filePath, changes: [] };
179
- hunks.push(current);
180
- }
181
- continue;
182
- }
183
- if (line.startsWith("--- ")) continue;
184
- if (line.startsWith("@@ ")) continue;
185
- if (current === null) continue;
186
- if (line.startsWith("+")) {
187
- current.changes.push({ type: "add", content: line.slice(1) });
188
- } else if (line.startsWith("-")) {
189
- current.changes.push({ type: "remove", content: line.slice(1) });
190
- } else if (line.startsWith(" ")) {
191
- current.changes.push({ type: "context", content: line.slice(1) });
319
+ async function applyV4APatch(projectRoot, patch) {
320
+ let hunks;
321
+ try {
322
+ hunks = parseV4A(patch);
323
+ } catch (err) {
324
+ const detail = err instanceof V4APatchError ? err.message : String(err);
325
+ return JSON.stringify({ ok: false, error: "parse_error", detail });
326
+ }
327
+ const plan = await buildPlan(projectRoot, hunks);
328
+ if ("error" in plan) return plan.error;
329
+ await executePlan(plan.ops);
330
+ return JSON.stringify({ ok: true, files_patched: plan.patched });
331
+ }
332
+ async function buildPlan(projectRoot, hunks) {
333
+ const ops = [];
334
+ const patched = [];
335
+ for (const hunk of hunks) {
336
+ const planned = await planHunk(projectRoot, hunk);
337
+ if ("error" in planned) return planned;
338
+ ops.push(...planned.ops);
339
+ patched.push(hunk.kind === "update" ? hunk.movePath ?? hunk.path : hunk.path);
340
+ }
341
+ return { ops, patched };
342
+ }
343
+ async function executePlan(ops) {
344
+ for (const op of ops) {
345
+ if ("rm" in op) {
346
+ await promises.rm(op.rm, { force: true });
347
+ } else {
348
+ await promises.mkdir(path.dirname(op.write.abs), { recursive: true });
349
+ await promises.writeFile(op.write.abs, op.write.content, "utf-8");
192
350
  }
193
351
  }
194
- return hunks;
195
352
  }
196
- function applyHunks(content, changes) {
197
- const originalLines = content.split("\n");
198
- const result = [];
199
- let origIdx = 0;
200
- const firstContext = changes.find((c) => c.type === "context" || c.type === "remove");
201
- if (firstContext) {
202
- const startIdx = originalLines.indexOf(firstContext.content, origIdx);
203
- if (startIdx === -1) return null;
204
- for (let i = 0; i < startIdx; i++) {
205
- result.push(originalLines[i]);
353
+ async function planHunk(projectRoot, hunk) {
354
+ const scope = v4aScope(projectRoot, hunk.path);
355
+ if ("error" in scope) return scope;
356
+ if (hunk.kind === "add") return { ops: [{ write: { abs: scope.abs, content: hunk.content } }] };
357
+ if (hunk.kind === "delete") return { ops: [{ rm: scope.abs }] };
358
+ return planUpdate(projectRoot, hunk, scope.abs);
359
+ }
360
+ async function planUpdate(projectRoot, hunk, abs) {
361
+ let content;
362
+ try {
363
+ content = await promises.readFile(abs, "utf-8");
364
+ } catch (err) {
365
+ if (err.code === "ENOENT") {
366
+ return { error: JSON.stringify({ ok: false, error: "not_found", path: hunk.path }) };
206
367
  }
207
- origIdx = startIdx;
368
+ throw err;
208
369
  }
209
- for (const change of changes) {
210
- if (change.type === "context") {
211
- if (origIdx >= originalLines.length || originalLines[origIdx] !== change.content) {
212
- return null;
213
- }
214
- result.push(change.content);
215
- origIdx++;
216
- } else if (change.type === "remove") {
217
- if (origIdx >= originalLines.length || originalLines[origIdx] !== change.content) {
218
- return null;
219
- }
220
- origIdx++;
221
- } else if (change.type === "add") {
222
- result.push(change.content);
370
+ let updated;
371
+ try {
372
+ updated = applyUpdateChunks(content, hunk.path, hunk.chunks);
373
+ } catch (err) {
374
+ if (err instanceof V4APatchError) {
375
+ return {
376
+ error: JSON.stringify({
377
+ ok: false,
378
+ error: "patch_failed",
379
+ path: hunk.path,
380
+ detail: err.message
381
+ })
382
+ };
223
383
  }
384
+ throw err;
224
385
  }
225
- while (origIdx < originalLines.length) {
226
- result.push(originalLines[origIdx]);
227
- origIdx++;
386
+ if (!hunk.movePath) return { ops: [{ write: { abs, content: updated } }] };
387
+ const dest = v4aScope(projectRoot, hunk.movePath);
388
+ if ("error" in dest) return dest;
389
+ return { ops: [{ write: { abs: dest.abs, content: updated } }, { rm: abs }] };
390
+ }
391
+ function v4aScope(projectRoot, file) {
392
+ if (isForbiddenPath(file)) {
393
+ return { error: JSON.stringify({ ok: false, error: "forbidden_path", path: file }) };
394
+ }
395
+ try {
396
+ const abs = safePathJoin(projectRoot, file);
397
+ assertNoSymlinkEscape(abs, projectRoot);
398
+ return { abs };
399
+ } catch (err) {
400
+ if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
401
+ return { error: JSON.stringify({ ok: false, error: "path_traversal", path: file }) };
402
+ }
403
+ throw err;
228
404
  }
229
- return result.join("\n");
230
405
  }
231
406
  function createSessionArtifactStore(options) {
232
407
  const { dir } = options;
@@ -330,7 +505,7 @@ var LINE_MATCH_LADDER = [
330
505
  (s) => normalizeUnicode(s).trim()
331
506
  // unicode + trim (loosest)
332
507
  ];
333
- function matchesAt(lines, pattern, i, norm) {
508
+ function matchesAt2(lines, pattern, i, norm) {
334
509
  for (let p = 0; p < pattern.length; p++) {
335
510
  const lineAt = lines[i + p];
336
511
  const patAt = pattern[p];
@@ -341,7 +516,7 @@ function matchesAt(lines, pattern, i, norm) {
341
516
  function findHits(lines, pattern, norm) {
342
517
  const hits = [];
343
518
  for (let i = 0; i + pattern.length <= lines.length; i++) {
344
- if (matchesAt(lines, pattern, i, norm)) hits.push(i);
519
+ if (matchesAt2(lines, pattern, i, norm)) hits.push(i);
345
520
  }
346
521
  return hits;
347
522
  }
@@ -1893,27 +2068,36 @@ function createSearchTextTool(opts) {
1893
2068
  projectRoot,
1894
2069
  maxMatches = DEFAULT_MAX_MATCHES,
1895
2070
  maxFileSize = DEFAULT_MAX_FILE_SIZE,
1896
- filesystem: filesystem$1
2071
+ filesystem: filesystem$1,
2072
+ regex = false,
2073
+ allowAbsolute = false
1897
2074
  } = opts;
2075
+ const queryKind = regex ? "a JavaScript REGULAR EXPRESSION" : "LITERAL, CASE-SENSITIVE text";
2076
+ const queryMatch = regex ? "matched as a regex" : "matched as a substring, not a regex";
1898
2077
  return sdk.Tool.create({
1899
2078
  name: "search_text",
1900
- description: `Search file CONTENTS for a LITERAL, CASE-SENSITIVE query across the project tree (the query is matched as a substring, not a regex). 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 }.`,
2079
+ 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 }.`,
1901
2080
  inputSchema: zod.z.object({
1902
- query: zod.z.string().min(1).describe("Literal text to search for. Case-sensitive."),
1903
- path: zod.z.string().optional().describe("Optional project-relative directory to scope the search.")
2081
+ query: regex ? zod.z.string().min(1).describe("A JavaScript regular expression, e.g. 'function\\\\s+main'.") : zod.z.string().min(1).describe("Literal text to search for. Case-sensitive."),
2082
+ path: zod.z.string().optional().describe(
2083
+ "Optional directory to scope the search (project-relative; absolute when allowed)."
2084
+ )
1904
2085
  }),
1905
2086
  handler: async ({ query, path }, ctx) => {
2087
+ const built = buildMatcher(query, regex);
2088
+ if ("error" in built) return built.error;
2089
+ const matcher = built.matcher;
1906
2090
  const state = {
1907
2091
  matches: [],
1908
2092
  totalMatches: 0,
1909
2093
  truncated: false,
1910
- query,
2094
+ matcher,
1911
2095
  maxMatches,
1912
2096
  maxFileSize,
1913
2097
  projectRoot
1914
2098
  };
1915
2099
  if (filesystem$1 !== void 0) {
1916
- const scopeRel = resolveScopeRel(path, projectRoot);
2100
+ const scopeRel = resolveScopeRel(path, projectRoot, allowAbsolute);
1917
2101
  if ("error" in scopeRel) return scopeRel.error;
1918
2102
  const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
1919
2103
  await walkBackend(backend, scopeRel.rel, state, 0);
@@ -1924,7 +2108,7 @@ function createSearchTextTool(opts) {
1924
2108
  totalMatches: state.totalMatches
1925
2109
  });
1926
2110
  }
1927
- const scope = resolveSearchScope(path, projectRoot);
2111
+ const scope = resolveSearchScope(path, projectRoot, allowAbsolute);
1928
2112
  if ("error" in scope) return scope.error;
1929
2113
  await walk(scope.scopeAbs, state);
1930
2114
  return JSON.stringify({
@@ -1936,15 +2120,27 @@ function createSearchTextTool(opts) {
1936
2120
  }
1937
2121
  });
1938
2122
  }
1939
- function resolveSearchScope(path, projectRoot) {
1940
- const scopeRel = path === void 0 || path === "" || path === "." ? "." : path;
2123
+ function buildMatcher(query, regex) {
2124
+ if (!regex) return { matcher: (line) => line.includes(query) };
2125
+ try {
2126
+ const re = new RegExp(query);
2127
+ return { matcher: (line) => re.test(line) };
2128
+ } catch {
2129
+ return { error: JSON.stringify({ ok: false, error: "invalid_regex", query }) };
2130
+ }
2131
+ }
2132
+ function resolveSearchScope(path$1, projectRoot, allowAbsolute) {
2133
+ const scopeRel = path$1 === void 0 || path$1 === "" || path$1 === "." ? "." : path$1;
2134
+ if (allowAbsolute && path.isAbsolute(scopeRel)) {
2135
+ return { scopeAbs: scopeRel };
2136
+ }
1941
2137
  try {
1942
2138
  const scopeAbs = scopeRel === "." ? projectRoot : safePathJoin(projectRoot, scopeRel);
1943
2139
  assertNoSymlinkEscape(scopeAbs, projectRoot);
1944
2140
  return { scopeAbs };
1945
2141
  } catch (err) {
1946
2142
  if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
1947
- return { error: JSON.stringify({ ok: false, error: "path_traversal", path }) };
2143
+ return { error: JSON.stringify({ ok: false, error: "path_traversal", path: path$1 }) };
1948
2144
  }
1949
2145
  throw err;
1950
2146
  }
@@ -2009,19 +2205,20 @@ async function scanFile(absPath, relPath, state) {
2009
2205
  const lines = buffer.toString("utf-8").split("\n");
2010
2206
  for (let i = 0; i < lines.length; i += 1) {
2011
2207
  const line = lines[i];
2012
- if (!line.includes(state.query)) continue;
2208
+ if (!state.matcher(line)) continue;
2013
2209
  if (!recordMatch(state, relPath, i + 1, line)) return;
2014
2210
  }
2015
2211
  }
2016
- function resolveScopeRel(path, projectRoot) {
2017
- const scopeRel = path === void 0 || path === "" || path === "." ? "" : path;
2212
+ function resolveScopeRel(path$1, projectRoot, allowAbsolute) {
2213
+ const scopeRel = path$1 === void 0 || path$1 === "" || path$1 === "." ? "" : path$1;
2018
2214
  if (scopeRel === "") return { rel: "" };
2215
+ if (allowAbsolute && path.isAbsolute(scopeRel)) return { rel: scopeRel };
2019
2216
  try {
2020
2217
  assertNoSymlinkEscape(safePathJoin(projectRoot, scopeRel), projectRoot);
2021
2218
  return { rel: scopeRel };
2022
2219
  } catch (err) {
2023
2220
  if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
2024
- return { error: JSON.stringify({ ok: false, error: "path_traversal", path }) };
2221
+ return { error: JSON.stringify({ ok: false, error: "path_traversal", path: path$1 }) };
2025
2222
  }
2026
2223
  throw err;
2027
2224
  }
@@ -2067,7 +2264,7 @@ async function scanFileBackend(backend, relPath, size, state) {
2067
2264
  const lines = content.split("\n");
2068
2265
  for (let i = 0; i < lines.length; i += 1) {
2069
2266
  const line = lines[i];
2070
- if (!line.includes(state.query)) continue;
2267
+ if (!state.matcher(line)) continue;
2071
2268
  if (!recordMatch(state, relPath, i + 1, line)) return;
2072
2269
  }
2073
2270
  }