ravensight-playtest 0.1.0 → 0.2.3
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/README.md +90 -3
- package/package.json +12 -1
- package/src/api/client.js +25 -2
- package/src/cli.js +40 -1
- package/src/commands/fakeCore.js +68 -3
- package/src/commands/fix.js +1059 -0
- package/src/commands/run.js +12 -3
- package/src/dashboard.js +15 -5
- package/src/packs/index.js +17 -2
- package/src/run/args.js +6 -1
- package/src/run/fixLoop.js +489 -0
- package/src/run/git.js +331 -0
- package/src/run/index.js +7 -3
- package/src/run/model.js +7 -1
- package/src/run/repoTools.js +373 -12
package/src/run/repoTools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { scanForSecrets } from './secretScan.js';
|
|
4
5
|
|
|
@@ -8,8 +9,16 @@ import { scanForSecrets } from './secretScan.js';
|
|
|
8
9
|
* `routing.json`'s `game_profile.triage` allows (and `read_file` alone on
|
|
9
10
|
* `game_profile.synthesis` and `aggregate.synthesis`).
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* `fix.apply` is the one step that also gets `write_file`, and it gets it
|
|
13
|
+
* through `WRITE_TOOLS` rather than through `PROFILE_TOOLS`: a step that is
|
|
14
|
+
* allowed to read a repository should not gain the ability to write to it
|
|
15
|
+
* because somebody appended to the wrong array. `write_file` is refused
|
|
16
|
+
* outright unless the caller asked for it by name, so `createRepoTools()`
|
|
17
|
+
* with its default tool set answers "there is no tool called write_file",
|
|
18
|
+
* exactly as it did before that tool existed.
|
|
19
|
+
*
|
|
20
|
+
* Read-only is enforced here, not asked for in a prompt. There is no shell,
|
|
21
|
+
* no network, and:
|
|
13
22
|
*
|
|
14
23
|
* - every path is resolved and refused if it leaves the root, so `../../`
|
|
15
24
|
* and a symlink out of the tree both fail;
|
|
@@ -33,6 +42,16 @@ export const SKIP_DIRS = Object.freeze([
|
|
|
33
42
|
'.venv', 'venv', '__pycache__', '.next', '.cache', 'coverage'
|
|
34
43
|
]);
|
|
35
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The two names refused at any position in a path, not just as a directory.
|
|
47
|
+
* Both hold the machinery rather than the game: git's own database, and this
|
|
48
|
+
* CLI's journals and reports.
|
|
49
|
+
*/
|
|
50
|
+
export const NEVER_TOUCHED = Object.freeze(['.git', '.ravensight']);
|
|
51
|
+
|
|
52
|
+
/** `SKIP_DIRS`, lowercased once, because the comparison is case insensitive. */
|
|
53
|
+
const SKIP_DIRS_LOWER = Object.freeze(SKIP_DIRS.map(name => name.toLowerCase()));
|
|
54
|
+
|
|
36
55
|
/** Files never read, whatever asks for them. */
|
|
37
56
|
export const SECRET_FILE_PATTERNS = Object.freeze([
|
|
38
57
|
/(^|\/)\.env($|\.)/i,
|
|
@@ -49,7 +68,13 @@ export const MAX_READ_BYTES = 120000;
|
|
|
49
68
|
export const MAX_GLOB_RESULTS = 400;
|
|
50
69
|
|
|
51
70
|
/** How many matching lines a grep answers with. */
|
|
52
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Sixty, not two hundred: at three hundred characters a line the old cap
|
|
73
|
+
* let one grep put 60 KB into the context, and forty greps in one apply loop
|
|
74
|
+
* ate the whole model budget without a write. A capped answer says so, so
|
|
75
|
+
* the model narrows rather than repeats.
|
|
76
|
+
*/
|
|
77
|
+
export const MAX_GREP_RESULTS = 60;
|
|
53
78
|
|
|
54
79
|
/** The tool definitions for `game_profile.triage`. */
|
|
55
80
|
export const PROFILE_TOOLS = Object.freeze([
|
|
@@ -95,6 +120,60 @@ export const PROFILE_TOOLS = Object.freeze([
|
|
|
95
120
|
/** The subset allowed on the synthesis steps. */
|
|
96
121
|
export const READ_ONLY_TOOLS = Object.freeze(PROFILE_TOOLS.filter(tool => tool.name === 'read_file'));
|
|
97
122
|
|
|
123
|
+
/**
|
|
124
|
+
* A NUL byte, built rather than written, so this source file stays plain text
|
|
125
|
+
* that a diff and a code review can read.
|
|
126
|
+
*/
|
|
127
|
+
const NUL = String.fromCharCode(0);
|
|
128
|
+
|
|
129
|
+
/** How many bytes one `write_file` may put on disk. */
|
|
130
|
+
export const MAX_WRITE_BYTES = 200000;
|
|
131
|
+
|
|
132
|
+
/** How many distinct paths one run may write. A fix is a patch, not a rewrite. */
|
|
133
|
+
export const MAX_WRITES_PER_RUN = 20;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The two writing tools, defined here so nothing else can invent a third.
|
|
137
|
+
*
|
|
138
|
+
* `edit_file` exists because whole-file writes do not survive contact with a
|
|
139
|
+
* real codebase: the first production fix attempt read an 84 KB C++ file and
|
|
140
|
+
* then could not send it back inside the step's output ceiling, so its one
|
|
141
|
+
* write arrived as malformed input and the attempt ended with nothing on the
|
|
142
|
+
* branch. An exact-text replacement is a few hundred bytes for the same edit.
|
|
143
|
+
*/
|
|
144
|
+
export const EDIT_FILE_TOOL = Object.freeze({
|
|
145
|
+
name: 'edit_file',
|
|
146
|
+
description: 'Replace one exact passage of a text file in the repository with new text. Paths are relative to the repository root. old_text must appear exactly once in the file (or set replace_all for every occurrence); copy it verbatim from read_file, whitespace included. This is the tool for changing a file that already exists.',
|
|
147
|
+
input_schema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
additionalProperties: false,
|
|
150
|
+
required: ['path', 'old_text', 'new_text'],
|
|
151
|
+
properties: {
|
|
152
|
+
path: { type: 'string' },
|
|
153
|
+
old_text: { type: 'string' },
|
|
154
|
+
new_text: { type: 'string' },
|
|
155
|
+
replace_all: { type: 'boolean' }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export const WRITE_FILE_TOOL = Object.freeze({
|
|
161
|
+
name: 'write_file',
|
|
162
|
+
description: 'Write a text file in the repository, replacing it whole. Paths are relative to the repository root. Use it for a new file or a small one; for a change to an existing file use edit_file, because the content you send here is the whole file.',
|
|
163
|
+
input_schema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
additionalProperties: false,
|
|
166
|
+
required: ['path', 'content'],
|
|
167
|
+
properties: {
|
|
168
|
+
path: { type: 'string' },
|
|
169
|
+
content: { type: 'string' }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/** The tool definitions for `fix.apply`: the read-only three, plus the two writes. */
|
|
175
|
+
export const WRITE_TOOLS = Object.freeze([...PROFILE_TOOLS, EDIT_FILE_TOOL, WRITE_FILE_TOOL]);
|
|
176
|
+
|
|
98
177
|
/**
|
|
99
178
|
* Compile a glob into a regular expression. Supports `**`, `*` and `?`,
|
|
100
179
|
* which covers every pattern the profile skill actually asks for
|
|
@@ -133,6 +212,58 @@ export function globToRegExp(pattern) {
|
|
|
133
212
|
return new RegExp(`^${source}$`);
|
|
134
213
|
}
|
|
135
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Whether a path lives inside a directory the walk never enters.
|
|
217
|
+
*
|
|
218
|
+
* `listFiles` already skips these, so a read of one answers nothing useful;
|
|
219
|
+
* a write to one is worse than useless. `.git` is the reason this check exists
|
|
220
|
+
* at all: a model that can write into `.git` can rewrite history without ever
|
|
221
|
+
* running git, and this CLI never rewrites history.
|
|
222
|
+
*
|
|
223
|
+
* Two rules, not one:
|
|
224
|
+
*
|
|
225
|
+
* - The names are compared **case insensitively**, because macOS and Windows
|
|
226
|
+
* filesystems are. `.GIT/hooks/pre-commit` is `.git/hooks/pre-commit` on the
|
|
227
|
+
* developer's disk, and a case-sensitive check let exactly that through:
|
|
228
|
+
* the hook was written, and the diff then reported no change, so the unit
|
|
229
|
+
* was refunded while the repository had been modified.
|
|
230
|
+
* - `.git` and `.ravensight` are refused at **any** position, the last one
|
|
231
|
+
* included. A file called `.git` is a gitfile whose contents repoint the
|
|
232
|
+
* repository at another directory, so writing one is as bad as writing
|
|
233
|
+
* inside `.git` itself. Every other name only matters as a directory: a
|
|
234
|
+
* source file called `build` is ordinary.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} relativePath
|
|
237
|
+
* @returns {string|null} the offending component, or null
|
|
238
|
+
*/
|
|
239
|
+
export function skippedDirIn(relativePath) {
|
|
240
|
+
const raw = String(relativePath).split(path.sep).join('/');
|
|
241
|
+
// A trailing slash means the caller is naming a directory, which is how
|
|
242
|
+
// `git status --porcelain` reports an untracked one (`?? node_modules/`).
|
|
243
|
+
// Then every segment is a directory; otherwise the last one is a file name.
|
|
244
|
+
const namesADirectory = raw.endsWith('/');
|
|
245
|
+
const segments = raw.split('/').filter(Boolean);
|
|
246
|
+
const lowered = segments.map(segment => segment.toLowerCase());
|
|
247
|
+
|
|
248
|
+
const always = lowered.findIndex(segment => NEVER_TOUCHED.includes(segment));
|
|
249
|
+
if (always !== -1) return segments[always];
|
|
250
|
+
|
|
251
|
+
const directories = namesADirectory ? lowered : lowered.slice(0, -1);
|
|
252
|
+
const at = directories.findIndex(segment => SKIP_DIRS_LOWER.includes(segment));
|
|
253
|
+
return at === -1 ? null : segments[at];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Whether one directory name is one the walk skips. Used by `listFiles`, which
|
|
258
|
+
* has the name on its own and needs the same case rules.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} name
|
|
261
|
+
* @returns {boolean}
|
|
262
|
+
*/
|
|
263
|
+
export function isSkippedDirName(name) {
|
|
264
|
+
return SKIP_DIRS_LOWER.includes(String(name).toLowerCase());
|
|
265
|
+
}
|
|
266
|
+
|
|
136
267
|
/**
|
|
137
268
|
* @param {string} relativePath
|
|
138
269
|
* @returns {boolean}
|
|
@@ -144,7 +275,11 @@ export function isSecretPath(relativePath) {
|
|
|
144
275
|
|
|
145
276
|
/**
|
|
146
277
|
* Resolve a model-supplied path against the root, refusing anything that
|
|
147
|
-
* escapes it
|
|
278
|
+
* escapes it **lexically**.
|
|
279
|
+
*
|
|
280
|
+
* This is the cheap half of the check and not the whole of it: `../` and an
|
|
281
|
+
* absolute path are caught here, a symbolic link is not. Every caller that
|
|
282
|
+
* then touches the filesystem goes through `resolveInsideReal` as well.
|
|
148
283
|
*
|
|
149
284
|
* @param {string} root
|
|
150
285
|
* @param {string} candidate
|
|
@@ -159,6 +294,54 @@ export function resolveInside(root, candidate) {
|
|
|
159
294
|
return target;
|
|
160
295
|
}
|
|
161
296
|
|
|
297
|
+
/**
|
|
298
|
+
* The other half: refuse a path that leaves the repository through a link.
|
|
299
|
+
*
|
|
300
|
+
* `resolveInside` compares strings, and a string comparison cannot see that
|
|
301
|
+
* `shared/` is a link to somewhere else entirely. A repository with such a
|
|
302
|
+
* link, which is ordinary in a monorepo or a shared-asset layout, would
|
|
303
|
+
* otherwise let a write land outside the tree with every lexical check
|
|
304
|
+
* passing. So every component from the root down that already exists is
|
|
305
|
+
* checked with `lstat` and refused by name if it is a link, the target
|
|
306
|
+
* included; components that do not exist yet are safe, because `mkdir` creates
|
|
307
|
+
* real directories and never links. The deepest component that does exist is
|
|
308
|
+
* then compared against the root's own real path, which also covers a root
|
|
309
|
+
* reached through a link itself (every temp directory on macOS is one).
|
|
310
|
+
*
|
|
311
|
+
* @param {string} root
|
|
312
|
+
* @param {string} candidate
|
|
313
|
+
* @returns {Promise<{absolute: string, relative: string}>}
|
|
314
|
+
*/
|
|
315
|
+
export async function resolveInsideReal(root, candidate) {
|
|
316
|
+
const base = path.resolve(root);
|
|
317
|
+
const target = resolveInside(base, candidate);
|
|
318
|
+
const realRoot = await realpath(base);
|
|
319
|
+
|
|
320
|
+
const parts = path.relative(base, target).split(path.sep).filter(Boolean);
|
|
321
|
+
let walked = base;
|
|
322
|
+
for (const part of parts) {
|
|
323
|
+
const next = path.join(walked, part);
|
|
324
|
+
let info;
|
|
325
|
+
try {
|
|
326
|
+
info = await lstat(next);
|
|
327
|
+
} catch {
|
|
328
|
+
// This component does not exist yet, so nothing below it does either.
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
if (info.isSymbolicLink()) {
|
|
332
|
+
const named = path.relative(base, next).split(path.sep).join('/');
|
|
333
|
+
throw new Error(`${candidate} goes through ${named}, which is a symbolic link, and a link can point out of the repository`);
|
|
334
|
+
}
|
|
335
|
+
walked = next;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const real = await realpath(walked);
|
|
339
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
|
|
340
|
+
throw new Error(`${candidate} resolves outside the repository`);
|
|
341
|
+
}
|
|
342
|
+
return { absolute: target, relative: path.relative(base, target).split(path.sep).join('/') };
|
|
343
|
+
}
|
|
344
|
+
|
|
162
345
|
/**
|
|
163
346
|
* @param {string} root
|
|
164
347
|
* @param {Object} [opts]
|
|
@@ -180,7 +363,7 @@ export async function listFiles(root, { limit = 20000 } = {}) {
|
|
|
180
363
|
if (entry.isSymbolicLink()) continue;
|
|
181
364
|
const absolute = path.join(dir, entry.name);
|
|
182
365
|
if (entry.isDirectory()) {
|
|
183
|
-
if (
|
|
366
|
+
if (isSkippedDirName(entry.name) || NEVER_TOUCHED.includes(entry.name.toLowerCase())) continue;
|
|
184
367
|
await walk(absolute);
|
|
185
368
|
continue;
|
|
186
369
|
}
|
|
@@ -200,6 +383,9 @@ export async function listFiles(root, { limit = 20000 } = {}) {
|
|
|
200
383
|
*/
|
|
201
384
|
export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
202
385
|
let cachedFiles = null;
|
|
386
|
+
const canWrite = tools.some(tool => tool.name === 'write_file');
|
|
387
|
+
/** The distinct paths this run has written, which is what the cap counts. */
|
|
388
|
+
const written = new Set();
|
|
203
389
|
|
|
204
390
|
async function files() {
|
|
205
391
|
if (!cachedFiles) cachedFiles = await listFiles(root);
|
|
@@ -209,6 +395,11 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
|
209
395
|
return {
|
|
210
396
|
tools,
|
|
211
397
|
|
|
398
|
+
/** Repository-relative paths this tool set has written, in write order. */
|
|
399
|
+
get written() {
|
|
400
|
+
return [...written];
|
|
401
|
+
},
|
|
402
|
+
|
|
212
403
|
/**
|
|
213
404
|
* @param {Object} toolUse
|
|
214
405
|
*/
|
|
@@ -216,6 +407,25 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
|
216
407
|
const input = toolUse.input || {};
|
|
217
408
|
try {
|
|
218
409
|
if (toolUse.name === 'read_file') return { result: ok(toolUse.id, await doRead(root, input)) };
|
|
410
|
+
if (toolUse.name === 'write_file' && canWrite) {
|
|
411
|
+
// A refusal comes back as an `is_error` result naming the rule,
|
|
412
|
+
// never as a thrown error: the model has to be able to read why and
|
|
413
|
+
// try a legal write instead, and a thrown error would end the run.
|
|
414
|
+
const planned = await planWrite(root, input, written);
|
|
415
|
+
if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
|
|
416
|
+
await doWrite(planned.absolute, input.content);
|
|
417
|
+
written.add(planned.relative);
|
|
418
|
+
const left = MAX_WRITES_PER_RUN - written.size;
|
|
419
|
+
return { result: ok(toolUse.id, `Wrote ${planned.relative} (${planned.bytes} bytes). ${left} more file(s) may be written in this run.`) };
|
|
420
|
+
}
|
|
421
|
+
if (toolUse.name === 'edit_file' && canWrite) {
|
|
422
|
+
const planned = await planEdit(root, input, written);
|
|
423
|
+
if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
|
|
424
|
+
await doWrite(planned.absolute, planned.content);
|
|
425
|
+
written.add(planned.relative);
|
|
426
|
+
const left = MAX_WRITES_PER_RUN - written.size;
|
|
427
|
+
return { result: ok(toolUse.id, `Edited ${planned.relative}: ${planned.replaced} occurrence(s) replaced, now ${planned.bytes} bytes. ${left} more file(s) may be written in this run.`) };
|
|
428
|
+
}
|
|
219
429
|
if (toolUse.name === 'glob') {
|
|
220
430
|
const matcher = globToRegExp(input.pattern);
|
|
221
431
|
const matched = (await files()).filter(file => matcher.test(file)).slice(0, MAX_GLOB_RESULTS);
|
|
@@ -231,11 +441,18 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
|
231
441
|
}
|
|
232
442
|
|
|
233
443
|
async function doRead(root, input) {
|
|
234
|
-
const
|
|
235
|
-
if (isSecretPath(
|
|
236
|
-
return `Refused: ${
|
|
444
|
+
const asked = String(input.path || '');
|
|
445
|
+
if (isSecretPath(asked)) {
|
|
446
|
+
return `Refused: ${asked} is a credential file and is never read. Note in warnings that credentials are checked into the repository, without quoting anything.`;
|
|
447
|
+
}
|
|
448
|
+
// The same real-path check the write path makes, for the same reason: a
|
|
449
|
+
// symlinked directory would otherwise let a read leave the repository, and
|
|
450
|
+
// `/etc/passwd` read through `shared/` is still a read of `/etc/passwd`.
|
|
451
|
+
const { absolute, relative } = await resolveInsideReal(root, asked);
|
|
452
|
+
const skipped = skippedDirIn(relative);
|
|
453
|
+
if (skipped) {
|
|
454
|
+
return `Refused: ${relative} is under ${skipped}, which is not part of the game's source.`;
|
|
237
455
|
}
|
|
238
|
-
const absolute = resolveInside(root, relative);
|
|
239
456
|
const info = await stat(absolute);
|
|
240
457
|
if (!info.isFile()) throw new Error(`${relative} is not a file`);
|
|
241
458
|
const text = await readFile(absolute, 'utf8');
|
|
@@ -253,6 +470,131 @@ async function doRead(root, input) {
|
|
|
253
470
|
return slice.length > MAX_READ_BYTES ? `${slice.slice(0, MAX_READ_BYTES)}\n... [truncated]` : slice;
|
|
254
471
|
}
|
|
255
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Every rule a `write_file` has to pass, and the sentence that names the one
|
|
475
|
+
* it failed. The rules are checked in the order that is cheapest to answer
|
|
476
|
+
* and hardest to argue with: where the file is, then what is in it, then how
|
|
477
|
+
* many files this run has already touched.
|
|
478
|
+
*
|
|
479
|
+
* The path is canonicalised before any rule is applied and before it counts
|
|
480
|
+
* against the twenty file cap, so `./src/a.gd`, `src/./a.gd` and
|
|
481
|
+
* `src/ui/../a.gd` are one file rather than three.
|
|
482
|
+
*
|
|
483
|
+
* @param {string} root
|
|
484
|
+
* @param {Object} input the model's tool input
|
|
485
|
+
* @param {Set<string>} written the distinct paths written so far
|
|
486
|
+
* @returns {Promise<{relative: string, absolute: string, bytes: number}|{refusal: string}>}
|
|
487
|
+
*/
|
|
488
|
+
export async function planWrite(root, input, written = new Set()) {
|
|
489
|
+
const asked = String((input && input.path) || '').trim();
|
|
490
|
+
if (!asked) return { refusal: 'Refused: write_file needs a path relative to the repository root.' };
|
|
491
|
+
|
|
492
|
+
let absolute;
|
|
493
|
+
let relative;
|
|
494
|
+
try {
|
|
495
|
+
// The real check rather than the lexical one: a link is how a write leaves
|
|
496
|
+
// a tree that every string comparison says it is inside.
|
|
497
|
+
({ absolute, relative } = await resolveInsideReal(root, asked));
|
|
498
|
+
} catch (error) {
|
|
499
|
+
return { refusal: `Refused: ${String(error && error.message ? error.message : error)}. A fix only ever writes inside the repository itself.` };
|
|
500
|
+
}
|
|
501
|
+
if (!relative) return { refusal: 'Refused: that is the repository root, not a file in it.' };
|
|
502
|
+
|
|
503
|
+
if (isSecretPath(relative)) {
|
|
504
|
+
return { refusal: `Refused: ${relative} is a credential file, and those are never read or written.` };
|
|
505
|
+
}
|
|
506
|
+
const skipped = skippedDirIn(relative);
|
|
507
|
+
if (skipped) {
|
|
508
|
+
return { refusal: `Refused: ${relative} is under ${skipped}, which is generated, vendored or this tool's own bookkeeping, and is never written.` };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const content = input && input.content;
|
|
512
|
+
if (typeof content !== 'string') {
|
|
513
|
+
return { refusal: 'Refused: content has to be a string. Send the whole file as text.' };
|
|
514
|
+
}
|
|
515
|
+
if (content.includes(NUL)) {
|
|
516
|
+
return { refusal: 'Refused: content holds a NUL byte, so that is not a text file. Only text files are written.' };
|
|
517
|
+
}
|
|
518
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
519
|
+
if (bytes > MAX_WRITE_BYTES) {
|
|
520
|
+
return { refusal: `Refused: ${relative} would be ${bytes} bytes and one write may be at most ${MAX_WRITE_BYTES}. Change less of it, or change a smaller file.` };
|
|
521
|
+
}
|
|
522
|
+
if (!written.has(relative) && written.size >= MAX_WRITES_PER_RUN) {
|
|
523
|
+
return { refusal: `Refused: this run has already written ${written.size} files and may write at most ${MAX_WRITES_PER_RUN}. Finish with the files you have already changed.` };
|
|
524
|
+
}
|
|
525
|
+
return { relative, absolute, bytes };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Temp file then rename, in the target's own directory, so an interrupted
|
|
530
|
+
* write leaves the developer's file exactly as it was rather than half of a
|
|
531
|
+
* new one. Rename inside one directory is atomic everywhere this CLI runs.
|
|
532
|
+
*
|
|
533
|
+
* @param {string} absolute
|
|
534
|
+
* @param {string} content
|
|
535
|
+
*/
|
|
536
|
+
/**
|
|
537
|
+
* Everything `planWrite` checks about the path, then the replacement itself:
|
|
538
|
+
* the file has to exist and be text, `old_text` has to be found exactly once
|
|
539
|
+
* unless `replace_all`, and the result has to fit the same byte ceiling a
|
|
540
|
+
* whole write does. Refusals name the count found, because "not found" and
|
|
541
|
+
* "found three times" call for different next moves.
|
|
542
|
+
*
|
|
543
|
+
* @param {string} root
|
|
544
|
+
* @param {Object} input
|
|
545
|
+
* @param {Set<string>} written
|
|
546
|
+
* @returns {Promise<{refusal: string}|{relative: string, absolute: string, content: string, bytes: number, replaced: number}>}
|
|
547
|
+
*/
|
|
548
|
+
export async function planEdit(root, input, written = new Set()) {
|
|
549
|
+
const oldText = input && input.old_text;
|
|
550
|
+
const newText = input && input.new_text;
|
|
551
|
+
if (typeof oldText !== 'string' || oldText.length === 0) {
|
|
552
|
+
return { refusal: 'Refused: edit_file needs old_text, the exact passage to replace, copied verbatim from read_file.' };
|
|
553
|
+
}
|
|
554
|
+
if (typeof newText !== 'string') return { refusal: 'Refused: new_text has to be a string (it may be empty to delete the passage).' };
|
|
555
|
+
// The path rules are the write rules; the placeholder content only has to be
|
|
556
|
+
// a string so the shared checks run.
|
|
557
|
+
const planned = await planWrite(root, { path: input && input.path, content: '' }, written);
|
|
558
|
+
if (planned.refusal) return { refusal: planned.refusal.replace('write_file', 'edit_file') };
|
|
559
|
+
|
|
560
|
+
let current;
|
|
561
|
+
try {
|
|
562
|
+
current = await readFile(planned.absolute, 'utf8');
|
|
563
|
+
} catch {
|
|
564
|
+
return { refusal: `Refused: ${planned.relative} does not exist yet. Use write_file to create a new file.` };
|
|
565
|
+
}
|
|
566
|
+
if (current.includes(NUL)) return { refusal: `Refused: ${planned.relative} is not a text file.` };
|
|
567
|
+
|
|
568
|
+
const occurrences = current.split(oldText).length - 1;
|
|
569
|
+
if (occurrences === 0) {
|
|
570
|
+
return { refusal: `Refused: old_text was not found in ${planned.relative}. Read the file again and copy the passage exactly, whitespace included.` };
|
|
571
|
+
}
|
|
572
|
+
if (occurrences > 1 && !(input && input.replace_all)) {
|
|
573
|
+
return { refusal: `Refused: old_text appears ${occurrences} times in ${planned.relative}. Include more surrounding lines so it is unique, or set replace_all.` };
|
|
574
|
+
}
|
|
575
|
+
const content = input && input.replace_all ? current.split(oldText).join(newText) : current.replace(oldText, () => newText);
|
|
576
|
+
if (content.includes(NUL)) return { refusal: 'Refused: new_text holds a NUL byte, so that is not text.' };
|
|
577
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
578
|
+
if (bytes > MAX_WRITE_BYTES) {
|
|
579
|
+
return { refusal: `Refused: ${planned.relative} would be ${bytes} bytes and one write may be at most ${MAX_WRITE_BYTES}.` };
|
|
580
|
+
}
|
|
581
|
+
return { relative: planned.relative, absolute: planned.absolute, content, bytes, replaced: input && input.replace_all ? occurrences : 1 };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function doWrite(absolute, content) {
|
|
585
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
586
|
+
const tmp = `${absolute}.${process.pid}.${randomUUID()}.tmp`;
|
|
587
|
+
await writeFile(tmp, content, 'utf8');
|
|
588
|
+
try {
|
|
589
|
+
await rename(tmp, absolute);
|
|
590
|
+
} catch (error) {
|
|
591
|
+
// A failed rename would otherwise leave the half-named temp file in the
|
|
592
|
+
// developer's source tree, where the next glob would find it.
|
|
593
|
+
await unlink(tmp).catch(() => {});
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
256
598
|
async function doGrep(root, all, input) {
|
|
257
599
|
const regex = new RegExp(String(input.pattern), 'i');
|
|
258
600
|
const matcher = input.glob ? globToRegExp(input.glob) : null;
|
|
@@ -276,11 +618,30 @@ async function doGrep(root, all, input) {
|
|
|
276
618
|
if (regex.test(lines[i])) hits.push(`${relative}:${i + 1}: ${lines[i].trim().slice(0, 300)}`);
|
|
277
619
|
}
|
|
278
620
|
}
|
|
279
|
-
|
|
621
|
+
if (hits.length === 0) return '(no matches)';
|
|
622
|
+
const capped = hits.length >= MAX_GREP_RESULTS;
|
|
623
|
+
return capped
|
|
624
|
+
? `${hits.join('\n')}\n(stopped at ${MAX_GREP_RESULTS} matches; narrow the pattern or add a glob)`
|
|
625
|
+
: hits.join('\n');
|
|
280
626
|
}
|
|
281
627
|
|
|
282
628
|
function ok(id, text, isError = false) {
|
|
283
629
|
return { type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text }], ...(isError ? { is_error: true } : {}) };
|
|
284
630
|
}
|
|
285
631
|
|
|
286
|
-
export default {
|
|
632
|
+
export default {
|
|
633
|
+
createRepoTools,
|
|
634
|
+
PROFILE_TOOLS,
|
|
635
|
+
READ_ONLY_TOOLS,
|
|
636
|
+
WRITE_TOOLS,
|
|
637
|
+
EDIT_FILE_TOOL,
|
|
638
|
+
planEdit,
|
|
639
|
+
globToRegExp,
|
|
640
|
+
listFiles,
|
|
641
|
+
planWrite,
|
|
642
|
+
resolveInside,
|
|
643
|
+
resolveInsideReal,
|
|
644
|
+
isSecretPath,
|
|
645
|
+
isSkippedDirName,
|
|
646
|
+
skippedDirIn
|
|
647
|
+
};
|