@zenera/neo 1.1.22 → 1.1.24
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/embeddings/rate-limit.d.ts +7 -0
- package/dist/embeddings/rate-limit.js +33 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/inspect/client.d.ts +1 -1
- package/dist/inspect/client.js +7 -2
- package/dist/memory/graph.d.ts +14 -0
- package/dist/memory/graph.js +28 -0
- package/dist/memory/grep.d.ts +57 -0
- package/dist/memory/grep.js +182 -0
- package/dist/memory/index.d.ts +12 -2
- package/dist/memory/index.js +16 -5
- package/dist/memory/match.d.ts +18 -0
- package/dist/memory/match.js +61 -0
- package/dist/memory/merge.d.ts +65 -0
- package/dist/memory/merge.js +379 -0
- package/dist/memory/recall.d.ts +1 -1
- package/dist/memory/recall.js +1 -1
- package/dist/memory/store.d.ts +18 -0
- package/dist/memory/store.js +31 -4
- package/dist/memory/tools.js +105 -9
- package/dist/memory/types.d.ts +7 -2
- package/dist/memory/vectors.d.ts +2 -0
- package/dist/memory/vectors.js +7 -0
- package/dist/models/factory.d.ts +43 -2
- package/dist/models/factory.js +108 -1
- package/dist/project/config.js +1 -1
- package/dist/project/load.d.ts +7 -0
- package/dist/project/load.js +29 -6
- package/dist/tools/exa.js +1 -1
- package/dist/tools/files.d.ts +81 -0
- package/dist/tools/files.js +1472 -0
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +1 -1
- package/dist/tools/sandbox.d.ts +44 -0
- package/dist/tools/sandbox.js +122 -3
- package/dist/tools/workspace.js +81 -3
- package/dist/trajectory.d.ts +19 -1
- package/dist/trajectory.js +53 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1472 @@
|
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { tool } from "../types.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// File tools
|
|
7
|
+
//
|
|
8
|
+
// Nothing here is wired in by default: what an agent may touch is a deployment
|
|
9
|
+
// decision, not a runtime one, so a host that wants filesystem access asks for
|
|
10
|
+
// it by calling `fileTools` and handing the result to a project. What the
|
|
11
|
+
// library does provide is the containment, once — every tool below resolves
|
|
12
|
+
// through `within`, so there is a single place where a path is allowed to
|
|
13
|
+
// become real.
|
|
14
|
+
//
|
|
15
|
+
// Containment is enforced on the *resolved, symlink-followed* path. Checking
|
|
16
|
+
// the string before resolving is the classic hole: `notes/../../.ssh/id_rsa`
|
|
17
|
+
// and a symlink called `notes` both pass a prefix test and both escape.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/** Reads are capped so one `cat` of a log file cannot fill the context. */
|
|
20
|
+
const MAX_READ = 256 * 1024;
|
|
21
|
+
const MAX_WRITE = 1024 * 1024;
|
|
22
|
+
const MAX_ENTRIES = 500;
|
|
23
|
+
/** Nothing is pulled into memory whole beyond this; bigger files are read by the head. */
|
|
24
|
+
const MAX_OPEN = 8 * 1024 * 1024;
|
|
25
|
+
/** How much of a file is examined to decide text vs binary. */
|
|
26
|
+
const SNIFF_BYTES = 8 * 1024;
|
|
27
|
+
/** A file is only read through to count its lines when it is at most this big. */
|
|
28
|
+
const MAX_SCAN = 1024 * 1024;
|
|
29
|
+
/** ...and one listing only spends this much on line counting in total. */
|
|
30
|
+
const LIST_SCAN_BUDGET = 8 * 1024 * 1024;
|
|
31
|
+
/** Where a project's `assets/` directory appears in the agent's namespace. */
|
|
32
|
+
export const ASSETS_MOUNT = '/assets';
|
|
33
|
+
/** ...and where its skill catalog does, so a skill's scripts can be run. */
|
|
34
|
+
export const SKILLS_MOUNT = '/skills';
|
|
35
|
+
export class Workspace {
|
|
36
|
+
root;
|
|
37
|
+
readOnly;
|
|
38
|
+
mount;
|
|
39
|
+
/** the extra trees, resolved — the primary root is not among them */
|
|
40
|
+
mounts;
|
|
41
|
+
/** primary first */
|
|
42
|
+
#roots;
|
|
43
|
+
/** the ones with a name, longest name first */
|
|
44
|
+
#named;
|
|
45
|
+
/** every root, deepest host path first, for attributing a resolved path to one */
|
|
46
|
+
#deep;
|
|
47
|
+
constructor(opts) {
|
|
48
|
+
// Resolve the root's own symlinks once, so a workspace that *is* a
|
|
49
|
+
// symlink does not fail every containment check against itself.
|
|
50
|
+
this.root = realpathSync(resolve(opts.root));
|
|
51
|
+
this.readOnly = opts.readOnly ?? false;
|
|
52
|
+
this.mount = mountPoint(opts.mount);
|
|
53
|
+
const roots = [{ path: this.root, at: this.mount, readOnly: this.readOnly }];
|
|
54
|
+
const mounts = [];
|
|
55
|
+
for (const m of opts.mounts ?? []) {
|
|
56
|
+
const at = mountPoint(m.at);
|
|
57
|
+
if (at === undefined) {
|
|
58
|
+
throw new Error(`a workspace mount needs an absolute name of its own: ${m.at}`);
|
|
59
|
+
}
|
|
60
|
+
// Overlapping names would make one path mean two directories, and
|
|
61
|
+
// which one won would depend on the order they were passed in.
|
|
62
|
+
for (const taken of roots) {
|
|
63
|
+
if (taken.at && (at === taken.at || under(at, taken.at) || under(taken.at, at))) {
|
|
64
|
+
throw new Error(`workspace mounts overlap: ${at} and ${taken.at}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const host = directory(m.host, at);
|
|
68
|
+
const readOnly = m.readOnly ?? true;
|
|
69
|
+
roots.push({ path: host, at, readOnly });
|
|
70
|
+
mounts.push({ host, at, readOnly });
|
|
71
|
+
}
|
|
72
|
+
this.mounts = mounts;
|
|
73
|
+
this.#roots = roots;
|
|
74
|
+
this.#named = roots
|
|
75
|
+
.filter((r) => r.at !== undefined)
|
|
76
|
+
.sort((a, b) => b.at.length - a.at.length);
|
|
77
|
+
// A mount may sit inside the workspace root; the deeper one owns the path.
|
|
78
|
+
this.#deep = [...roots].sort((a, b) => b.path.length - a.path.length);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The one gate. Returns an absolute path inside one of the roots, or throws.
|
|
82
|
+
*
|
|
83
|
+
* A path that does not exist yet cannot be realpath'd, so the nearest
|
|
84
|
+
* existing ancestor is resolved instead and the remainder appended — which
|
|
85
|
+
* is exactly what a create needs and closes the same hole a create opens.
|
|
86
|
+
*/
|
|
87
|
+
within(input, opts = {}) {
|
|
88
|
+
if (typeof input !== 'string' || input.length === 0) {
|
|
89
|
+
throw new Error('path is required');
|
|
90
|
+
}
|
|
91
|
+
if (input.includes('\0')) {
|
|
92
|
+
throw new Error('path contains a null byte');
|
|
93
|
+
}
|
|
94
|
+
const { root, rest } = this.#locate(input);
|
|
95
|
+
const real = this.#realish(resolve(root.path, rest));
|
|
96
|
+
if (real !== root.path && !real.startsWith(root.path + sep)) {
|
|
97
|
+
throw new Error(`outside the workspace: ${input}`);
|
|
98
|
+
}
|
|
99
|
+
if (opts.write && root.readOnly) {
|
|
100
|
+
throw new Error(root.path === this.root
|
|
101
|
+
? 'the workspace is read-only for this run'
|
|
102
|
+
: `${root.at} is read-only`);
|
|
103
|
+
}
|
|
104
|
+
return real;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Which tree a path names, and where in it. `/workspace/src/a.ts` and
|
|
108
|
+
* `src/a.ts` are one file under two names, and only the second resolves
|
|
109
|
+
* against the root, so the mounted spelling is rewritten before anything
|
|
110
|
+
* else looks at it.
|
|
111
|
+
*
|
|
112
|
+
* This is renaming, not permission. What comes out is still resolved, still
|
|
113
|
+
* symlink-followed and still checked against its root by the caller:
|
|
114
|
+
* `/workspace/../etc/passwd` loses its prefix and then fails containment
|
|
115
|
+
* exactly as `../etc/passwd` does.
|
|
116
|
+
*/
|
|
117
|
+
#locate(input) {
|
|
118
|
+
const primary = this.#roots[0];
|
|
119
|
+
// A lone `/` is the model's other name for "the top of what I can see".
|
|
120
|
+
// There is nothing above the root for it to mean, so it means the root
|
|
121
|
+
// — refusing it teaches nothing and costs a turn. This is the only
|
|
122
|
+
// absolute path treated as rooted: `/etc/passwd` still names the
|
|
123
|
+
// machine's file and still fails containment.
|
|
124
|
+
if (/^[\\/]+$/.test(input)) {
|
|
125
|
+
return { root: primary, rest: '.' };
|
|
126
|
+
}
|
|
127
|
+
const at = input.replace(/\\/g, '/');
|
|
128
|
+
for (const root of this.#named) {
|
|
129
|
+
if (at === root.at) {
|
|
130
|
+
return { root, rest: '.' };
|
|
131
|
+
}
|
|
132
|
+
if (at.startsWith(root.at + '/')) {
|
|
133
|
+
return { root, rest: at.slice(root.at.length + 1).replace(/^\/+/, '') || '.' };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { root: primary, rest: input };
|
|
137
|
+
}
|
|
138
|
+
/** The root a resolved path belongs to; the primary when nothing else claims it. */
|
|
139
|
+
#owner(path) {
|
|
140
|
+
return (this.#deep.find((r) => path === r.path || path.startsWith(r.path + sep)) ??
|
|
141
|
+
this.#roots[0]);
|
|
142
|
+
}
|
|
143
|
+
#realish(path) {
|
|
144
|
+
const missing = [];
|
|
145
|
+
let at = path;
|
|
146
|
+
for (;;) {
|
|
147
|
+
try {
|
|
148
|
+
return join(realpathSync(at), ...missing.reverse());
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err.code !== 'ENOENT') {
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
const parent = dirname(at);
|
|
155
|
+
if (parent === at) {
|
|
156
|
+
// Walked to the filesystem root without finding anything
|
|
157
|
+
// real; nothing here can be inside the workspace.
|
|
158
|
+
throw new Error(`cannot resolve: ${path}`);
|
|
159
|
+
}
|
|
160
|
+
missing.push(at.slice(parent.length + 1));
|
|
161
|
+
at = parent;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Where a path sits under its own root, in posix form. `.` is that root. */
|
|
166
|
+
rel(path) {
|
|
167
|
+
return relative(this.#owner(path).path, path).split(sep).join('/') || '.';
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* How a path is written back to the model, and the reason `mount` is not
|
|
171
|
+
* only about what comes in. A shell in the sandbox prints the mounted name
|
|
172
|
+
* — `find` returns /workspace/src/a.ts — so if the file tools answered with
|
|
173
|
+
* `src/a.ts` the model would be holding two vocabularies for one tree and
|
|
174
|
+
* would have to translate between them to feed one tool's output to the
|
|
175
|
+
* other. With a mount configured every path in and every path out is the
|
|
176
|
+
* mounted one, and copying a path from a command's output into read_file
|
|
177
|
+
* is exact. Without one, everything stays relative as before.
|
|
178
|
+
*/
|
|
179
|
+
show(path) {
|
|
180
|
+
const root = this.#owner(path);
|
|
181
|
+
const rel = relative(root.path, path).split(sep).join('/') || '.';
|
|
182
|
+
if (root.at === undefined) {
|
|
183
|
+
return rel;
|
|
184
|
+
}
|
|
185
|
+
return rel === '.' ? root.at : `${root.at}/${rel}`;
|
|
186
|
+
}
|
|
187
|
+
/** Whether a resolved path *is* one of the roots. Nothing may move or delete one. */
|
|
188
|
+
isRoot(path) {
|
|
189
|
+
return this.#roots.some((r) => r.path === path);
|
|
190
|
+
}
|
|
191
|
+
mutable() {
|
|
192
|
+
if (this.readOnly) {
|
|
193
|
+
throw new Error('the workspace is read-only for this run');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* An absolute name a tree is known by, with any trailing slash removed. A mount
|
|
199
|
+
* of `/` would make everything on the machine look like it was inside, so it is
|
|
200
|
+
* not a mount point; nor is a relative one, which would resolve against nothing.
|
|
201
|
+
*/
|
|
202
|
+
function mountPoint(at) {
|
|
203
|
+
const clean = at?.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
204
|
+
return clean && clean.startsWith('/') ? clean : undefined;
|
|
205
|
+
}
|
|
206
|
+
function under(inner, outer) {
|
|
207
|
+
return inner.startsWith(outer + '/');
|
|
208
|
+
}
|
|
209
|
+
/** The host side of a mount, resolved. It has to exist: a mount of nothing is a typo. */
|
|
210
|
+
function directory(host, at) {
|
|
211
|
+
let path;
|
|
212
|
+
try {
|
|
213
|
+
path = realpathSync(resolve(host));
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
throw new Error(`workspace mount ${at}: no such directory: ${host}`);
|
|
217
|
+
}
|
|
218
|
+
if (!statSync(path).isDirectory()) {
|
|
219
|
+
throw new Error(`workspace mount ${at}: not a directory: ${host}`);
|
|
220
|
+
}
|
|
221
|
+
return path;
|
|
222
|
+
}
|
|
223
|
+
/** `a`, `a and b`, `a, b and c` — these end up in sentences the model reads. */
|
|
224
|
+
function nameList(items) {
|
|
225
|
+
if (items.length < 2) {
|
|
226
|
+
return items[0] ?? '';
|
|
227
|
+
}
|
|
228
|
+
return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Formats that are decided by name alone. Everything absent from here is
|
|
232
|
+
* sniffed, so an unknown extension still lands on text or binary correctly.
|
|
233
|
+
* `.svg` is deliberately *not* an image: it is editable text, and calling it an
|
|
234
|
+
* image would tell the model not to open it.
|
|
235
|
+
*/
|
|
236
|
+
const BY_EXTENSION = {
|
|
237
|
+
'.png': 'image',
|
|
238
|
+
'.jpg': 'image',
|
|
239
|
+
'.jpeg': 'image',
|
|
240
|
+
'.gif': 'image',
|
|
241
|
+
'.webp': 'image',
|
|
242
|
+
'.avif': 'image',
|
|
243
|
+
'.bmp': 'image',
|
|
244
|
+
'.ico': 'image',
|
|
245
|
+
'.tif': 'image',
|
|
246
|
+
'.tiff': 'image',
|
|
247
|
+
'.heic': 'image',
|
|
248
|
+
'.mp3': 'audio',
|
|
249
|
+
'.wav': 'audio',
|
|
250
|
+
'.flac': 'audio',
|
|
251
|
+
'.ogg': 'audio',
|
|
252
|
+
'.m4a': 'audio',
|
|
253
|
+
'.aac': 'audio',
|
|
254
|
+
'.mp4': 'video',
|
|
255
|
+
'.mov': 'video',
|
|
256
|
+
'.avi': 'video',
|
|
257
|
+
'.mkv': 'video',
|
|
258
|
+
'.webm': 'video',
|
|
259
|
+
'.pdf': 'pdf',
|
|
260
|
+
'.zip': 'archive',
|
|
261
|
+
'.tar': 'archive',
|
|
262
|
+
'.gz': 'archive',
|
|
263
|
+
'.tgz': 'archive',
|
|
264
|
+
'.bz2': 'archive',
|
|
265
|
+
'.xz': 'archive',
|
|
266
|
+
'.7z': 'archive',
|
|
267
|
+
'.rar': 'archive',
|
|
268
|
+
'.jar': 'archive',
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* A NUL byte is decisive; a replacement character means the bytes are not
|
|
272
|
+
* UTF-8. When the buffer is a prefix its tail may be half of a multi-byte
|
|
273
|
+
* character, so the last three bytes are dropped before decoding.
|
|
274
|
+
*/
|
|
275
|
+
function looksBinary(buf, partial) {
|
|
276
|
+
const view = partial ? buf.subarray(0, Math.max(0, buf.length - 3)) : buf;
|
|
277
|
+
if (view.includes(0)) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
return view.toString('utf8').includes('\uFFFD');
|
|
281
|
+
}
|
|
282
|
+
function countLines(buf) {
|
|
283
|
+
if (buf.length === 0) {
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
let lines = 0;
|
|
287
|
+
for (const byte of buf) {
|
|
288
|
+
if (byte === 0x0a) {
|
|
289
|
+
lines++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return buf[buf.length - 1] === 0x0a ? lines : lines + 1;
|
|
293
|
+
}
|
|
294
|
+
/** Same arithmetic as `countLines`, for content already decoded. */
|
|
295
|
+
function lineCount(text) {
|
|
296
|
+
if (text.length === 0) {
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
let lines = 1;
|
|
300
|
+
for (let i = 0; i < text.length; i++) {
|
|
301
|
+
if (text.charCodeAt(i) === 0x0a) {
|
|
302
|
+
lines++;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return text.endsWith('\n') ? lines - 1 : lines;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* `scan` is the caller's permission to read the file through — a listing hands
|
|
309
|
+
* it out from a budget so that one `list_dir` of a build output directory does
|
|
310
|
+
* not read a gigabyte to report line counts nobody asked for.
|
|
311
|
+
*/
|
|
312
|
+
async function inspectFile(at, bytes, scan) {
|
|
313
|
+
const known = BY_EXTENSION[extname(at).toLowerCase()];
|
|
314
|
+
if (known) {
|
|
315
|
+
return { format: known };
|
|
316
|
+
}
|
|
317
|
+
if (bytes === 0) {
|
|
318
|
+
return { format: 'text', lines: 0 };
|
|
319
|
+
}
|
|
320
|
+
if (scan && bytes <= MAX_SCAN) {
|
|
321
|
+
const buf = await readFile(at);
|
|
322
|
+
return looksBinary(buf, false)
|
|
323
|
+
? { format: 'binary' }
|
|
324
|
+
: { format: 'text', lines: countLines(buf) };
|
|
325
|
+
}
|
|
326
|
+
const handle = await open(at, 'r');
|
|
327
|
+
try {
|
|
328
|
+
const buf = Buffer.alloc(Math.min(bytes, SNIFF_BYTES));
|
|
329
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
|
|
330
|
+
return {
|
|
331
|
+
format: looksBinary(buf.subarray(0, bytesRead), bytesRead < bytes) ? 'binary' : 'text',
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
await handle.close();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** Reads a file as text, taking only the head of one too large to hold. */
|
|
339
|
+
async function readText(at, bytes) {
|
|
340
|
+
if (bytes <= MAX_OPEN) {
|
|
341
|
+
return { body: await readFile(at, 'utf8'), whole: true };
|
|
342
|
+
}
|
|
343
|
+
const handle = await open(at, 'r');
|
|
344
|
+
try {
|
|
345
|
+
const buf = Buffer.alloc(MAX_OPEN);
|
|
346
|
+
const { bytesRead } = await handle.read(buf, 0, MAX_OPEN, 0);
|
|
347
|
+
return { body: buf.subarray(0, bytesRead).toString('utf8'), whole: false };
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
await handle.close();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Splits into lines without inventing a trailing empty one. */
|
|
354
|
+
function toLines(body) {
|
|
355
|
+
const lines = body.split('\n');
|
|
356
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
357
|
+
lines.pop();
|
|
358
|
+
}
|
|
359
|
+
return lines;
|
|
360
|
+
}
|
|
361
|
+
/** Whether every line of the file ends CRLF, so converting the lot is lossless. */
|
|
362
|
+
function isCrlf(body) {
|
|
363
|
+
return body.includes('\r\n') && !/(^|[^\r])\n/.test(body);
|
|
364
|
+
}
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// The patch format
|
|
367
|
+
//
|
|
368
|
+
// A patch, not a rewrite, and not a set of line numbers either. Line numbers
|
|
369
|
+
// are the thing a model gets wrong: they drift the moment an earlier hunk
|
|
370
|
+
// changes the length of the file, and a wrong count in a `@@ -12,7 +12,9 @@`
|
|
371
|
+
// header either corrupts the file or forces the applier to guess. So the format
|
|
372
|
+
// below carries no offsets at all — a change is located by the unchanged text
|
|
373
|
+
// around it, and `@@ <heading>` narrows the search to the right block when the
|
|
374
|
+
// same lines occur more than once.
|
|
375
|
+
//
|
|
376
|
+
// *** Begin Patch
|
|
377
|
+
// *** Update File: src/server.ts
|
|
378
|
+
// @@ class Server
|
|
379
|
+
// start() {
|
|
380
|
+
// - this.port = 80;
|
|
381
|
+
// + this.port = 8080;
|
|
382
|
+
// }
|
|
383
|
+
// *** End Patch
|
|
384
|
+
//
|
|
385
|
+
// Whole files are added, deleted and moved by the same document, which is the
|
|
386
|
+
// real reason to have a patch at all rather than a per-file editor: a rename
|
|
387
|
+
// plus the edits that follow from it either both land or neither does.
|
|
388
|
+
//
|
|
389
|
+
// The grammar is the one Codex's `apply_patch` uses (sometimes called V4A), on
|
|
390
|
+
// purpose: frontier models have seen a great deal of it and emit it well. That
|
|
391
|
+
// lineage is deliberately *not* mentioned in the tool description — the sentinel
|
|
392
|
+
// lines and the worked example are what a model actually keys on, whereas naming
|
|
393
|
+
// a vendor in a prompt sent to three of them buys nothing and invites the model
|
|
394
|
+
// to assume behaviour of a reference implementation this parser does not share.
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
const BEGIN = '*** Begin Patch';
|
|
397
|
+
const END = '*** End Patch';
|
|
398
|
+
const ADD = '*** Add File: ';
|
|
399
|
+
const DELETE = '*** Delete File: ';
|
|
400
|
+
const UPDATE = '*** Update File: ';
|
|
401
|
+
const MOVE = '*** Move to: ';
|
|
402
|
+
const EOF_MARKER = '*** End of File';
|
|
403
|
+
/** A failure the model can act on: reported as a tool error, never thrown out. */
|
|
404
|
+
class PatchError extends Error {
|
|
405
|
+
}
|
|
406
|
+
/** Only a bare `*** ` line can open a section; body lines are always prefixed. */
|
|
407
|
+
function isSection(line) {
|
|
408
|
+
return (line === END ||
|
|
409
|
+
line.startsWith(ADD) ||
|
|
410
|
+
line.startsWith(DELETE) ||
|
|
411
|
+
line.startsWith(UPDATE) ||
|
|
412
|
+
line === BEGIN);
|
|
413
|
+
}
|
|
414
|
+
function parsePatch(text) {
|
|
415
|
+
// A stray CR would otherwise end up inside every added line.
|
|
416
|
+
const lines = text.split('\n').map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
|
|
417
|
+
let i = 0;
|
|
418
|
+
while (i < lines.length && lines[i].trim() === '') {
|
|
419
|
+
i++;
|
|
420
|
+
}
|
|
421
|
+
if (i >= lines.length || lines[i].trim() !== BEGIN) {
|
|
422
|
+
throw new PatchError(`the patch must start with '${BEGIN}'`);
|
|
423
|
+
}
|
|
424
|
+
i++;
|
|
425
|
+
const ops = [];
|
|
426
|
+
// The closing sentinel is not decoration. A blank line inside a chunk is
|
|
427
|
+
// meaningful context, so without something to end the last chunk on, the
|
|
428
|
+
// newline a model leaves at the end of the string becomes a blank line the
|
|
429
|
+
// file has to contain — and the failure surfaces as a baffling "context not
|
|
430
|
+
// found" instead of the missing marker it is.
|
|
431
|
+
let closed = false;
|
|
432
|
+
while (i < lines.length) {
|
|
433
|
+
const line = lines[i];
|
|
434
|
+
if (line.trim() === END) {
|
|
435
|
+
closed = true;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
if (line.trim() === '') {
|
|
439
|
+
i++;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (line.startsWith(ADD)) {
|
|
443
|
+
const at = i + 1;
|
|
444
|
+
const path = line.slice(ADD.length).trim();
|
|
445
|
+
i++;
|
|
446
|
+
const body = [];
|
|
447
|
+
while (i < lines.length && !isSection(lines[i])) {
|
|
448
|
+
if (!lines[i].startsWith('+')) {
|
|
449
|
+
throw new PatchError(`line ${i + 1}: every line of an added file must start with '+'`);
|
|
450
|
+
}
|
|
451
|
+
body.push(lines[i].slice(1));
|
|
452
|
+
i++;
|
|
453
|
+
}
|
|
454
|
+
ops.push({ kind: 'add', path, lines: body, at });
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (line.startsWith(DELETE)) {
|
|
458
|
+
ops.push({ kind: 'delete', path: line.slice(DELETE.length).trim(), at: i + 1 });
|
|
459
|
+
i++;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (line.startsWith(UPDATE)) {
|
|
463
|
+
const at = i + 1;
|
|
464
|
+
const path = line.slice(UPDATE.length).trim();
|
|
465
|
+
i++;
|
|
466
|
+
let moveTo;
|
|
467
|
+
if (i < lines.length && lines[i].startsWith(MOVE)) {
|
|
468
|
+
moveTo = lines[i].slice(MOVE.length).trim();
|
|
469
|
+
i++;
|
|
470
|
+
}
|
|
471
|
+
const chunks = [];
|
|
472
|
+
let chunk;
|
|
473
|
+
const started = (c) => c.before.length > 0 || c.after.length > 0;
|
|
474
|
+
const open = () => (chunk ??= { headings: [], before: [], after: [], eof: false, at: i + 1 });
|
|
475
|
+
while (i < lines.length && !isSection(lines[i])) {
|
|
476
|
+
const body = lines[i];
|
|
477
|
+
if (body.startsWith('@@')) {
|
|
478
|
+
if (chunk && started(chunk)) {
|
|
479
|
+
chunks.push(chunk);
|
|
480
|
+
chunk = undefined;
|
|
481
|
+
}
|
|
482
|
+
const heading = body.slice(2).trim();
|
|
483
|
+
// `@@ ...` is a model eliding the lines between chunks, not
|
|
484
|
+
// naming a block, and there is no such line to find.
|
|
485
|
+
if (heading && !/^[.\u2026]+$/.test(heading)) {
|
|
486
|
+
open().headings.push(heading);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
open();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
else if (body === EOF_MARKER) {
|
|
493
|
+
open().eof = true;
|
|
494
|
+
}
|
|
495
|
+
else if (body === '') {
|
|
496
|
+
// A blank context line: models drop the leading space.
|
|
497
|
+
open().before.push('');
|
|
498
|
+
open().after.push('');
|
|
499
|
+
}
|
|
500
|
+
else if (body.startsWith('+')) {
|
|
501
|
+
open().after.push(body.slice(1));
|
|
502
|
+
}
|
|
503
|
+
else if (body.startsWith('-')) {
|
|
504
|
+
open().before.push(body.slice(1));
|
|
505
|
+
}
|
|
506
|
+
else if (body.startsWith(' ')) {
|
|
507
|
+
open().before.push(body.slice(1));
|
|
508
|
+
open().after.push(body.slice(1));
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
throw new PatchError(`line ${i + 1}: expected a line starting with ' ', '+' or '-': ${body}`);
|
|
512
|
+
}
|
|
513
|
+
i++;
|
|
514
|
+
}
|
|
515
|
+
if (chunk && started(chunk)) {
|
|
516
|
+
chunks.push(chunk);
|
|
517
|
+
}
|
|
518
|
+
if (chunks.length === 0) {
|
|
519
|
+
throw new PatchError(`${path}: '${UPDATE.trim()}' with nothing to change`);
|
|
520
|
+
}
|
|
521
|
+
ops.push({ kind: 'update', path, moveTo, chunks, at });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
throw new PatchError(`line ${i + 1}: unexpected line outside a file section: ${line}`);
|
|
525
|
+
}
|
|
526
|
+
if (!closed) {
|
|
527
|
+
throw new PatchError(`the patch must end with '${END}'`);
|
|
528
|
+
}
|
|
529
|
+
if (ops.length === 0) {
|
|
530
|
+
throw new PatchError('the patch changes nothing');
|
|
531
|
+
}
|
|
532
|
+
return ops;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Finds `want` as a run of lines at or after `from`. Exact first; then ignoring
|
|
536
|
+
* trailing whitespace; then ignoring indentation entirely. The looser passes
|
|
537
|
+
* exist because a model reproducing context by hand gets whitespace wrong far
|
|
538
|
+
* more often than it gets the code wrong — but each is a whole separate sweep,
|
|
539
|
+
* so an exact match anywhere always beats a sloppy one earlier in the file.
|
|
540
|
+
*/
|
|
541
|
+
function locate(lines, want, from) {
|
|
542
|
+
if (want.length === 0) {
|
|
543
|
+
return { index: from, fuzz: 0 };
|
|
544
|
+
}
|
|
545
|
+
const passes = [
|
|
546
|
+
(a, b) => a === b,
|
|
547
|
+
(a, b) => a.trimEnd() === b.trimEnd(),
|
|
548
|
+
(a, b) => a.trim() === b.trim(),
|
|
549
|
+
];
|
|
550
|
+
for (const [fuzz, same] of passes.entries()) {
|
|
551
|
+
for (let start = from; start + want.length <= lines.length; start++) {
|
|
552
|
+
let ok = true;
|
|
553
|
+
for (let k = 0; k < want.length; k++) {
|
|
554
|
+
if (!same(lines[start + k], want[k])) {
|
|
555
|
+
ok = false;
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (ok) {
|
|
560
|
+
return { index: start, fuzz };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return { index: -1, fuzz: 0 };
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Finds the line a `@@ heading` names. Exact, then trimmed, then any line that
|
|
568
|
+
* contains it: a model writes `@@ check_risk` where the file says
|
|
569
|
+
* `def check_risk(entry):`, and the worked example in the tool description —
|
|
570
|
+
* `@@ class Server` — teaches exactly that. Separate sweeps, so an exact match
|
|
571
|
+
* anywhere beats a loose one earlier.
|
|
572
|
+
*/
|
|
573
|
+
function findHeading(lines, heading, from) {
|
|
574
|
+
const want = heading.trim();
|
|
575
|
+
const passes = [
|
|
576
|
+
(line) => line === heading,
|
|
577
|
+
(line) => line.trim() === want,
|
|
578
|
+
(line) => line.includes(want),
|
|
579
|
+
];
|
|
580
|
+
for (const same of passes) {
|
|
581
|
+
for (let i = from; i < lines.length; i++) {
|
|
582
|
+
if (same(lines[i])) {
|
|
583
|
+
return i;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return -1;
|
|
588
|
+
}
|
|
589
|
+
function indentOf(line) {
|
|
590
|
+
return line.slice(0, line.length - line.trimStart().length);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The single shift that turns the patch's indentation into the file's, replayed
|
|
594
|
+
* onto `after`. Null when the lines disagree about it: that is not a
|
|
595
|
+
* mis-indented chunk, it is a chunk matched in the wrong place.
|
|
596
|
+
*
|
|
597
|
+
* Prefix arithmetic rather than column counts, because a tab and four spaces
|
|
598
|
+
* are different bytes and Python does not treat them alike.
|
|
599
|
+
*/
|
|
600
|
+
function reindent(after, file, want, at) {
|
|
601
|
+
let add;
|
|
602
|
+
let drop;
|
|
603
|
+
for (let k = 0; k < want.length; k++) {
|
|
604
|
+
if (want[k].trim() === '') {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const have = indentOf(file[at + k]);
|
|
608
|
+
const said = indentOf(want[k]);
|
|
609
|
+
const step = have.startsWith(said)
|
|
610
|
+
? { add: have.slice(said.length), drop: '' }
|
|
611
|
+
: said.startsWith(have)
|
|
612
|
+
? { add: '', drop: said.slice(have.length) }
|
|
613
|
+
: undefined;
|
|
614
|
+
if (!step || (add !== undefined && (add !== step.add || drop !== step.drop))) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
add = step.add;
|
|
618
|
+
drop = step.drop;
|
|
619
|
+
}
|
|
620
|
+
if (!add && !drop) {
|
|
621
|
+
return after;
|
|
622
|
+
}
|
|
623
|
+
return after.map((line) => {
|
|
624
|
+
if (line.trim() === '') {
|
|
625
|
+
return line;
|
|
626
|
+
}
|
|
627
|
+
const bare = drop && line.startsWith(drop) ? line.slice(drop.length) : line;
|
|
628
|
+
return add ? add + bare : bare;
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* How many lines of `want` the closest near-miss got through, so an error can
|
|
633
|
+
* name the line that actually diverged. Naming the first line of the block
|
|
634
|
+
* instead sends the model to verify a line that was never the problem.
|
|
635
|
+
*/
|
|
636
|
+
function matchedUpTo(lines, want, from) {
|
|
637
|
+
let best = 0;
|
|
638
|
+
for (let start = from; start < lines.length; start++) {
|
|
639
|
+
let k = 0;
|
|
640
|
+
while (k < want.length && start + k < lines.length) {
|
|
641
|
+
if (lines[start + k].trim() !== want[k].trim()) {
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
k++;
|
|
645
|
+
}
|
|
646
|
+
if (k > best) {
|
|
647
|
+
best = k;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return best;
|
|
651
|
+
}
|
|
652
|
+
/** How many places `want` occurs at or after `from`, counted no further than two. */
|
|
653
|
+
function countMatches(lines, want, from) {
|
|
654
|
+
if (want.length === 0) {
|
|
655
|
+
return 2;
|
|
656
|
+
}
|
|
657
|
+
let found = 0;
|
|
658
|
+
let at = from;
|
|
659
|
+
while (found < 2) {
|
|
660
|
+
const hit = locate(lines, want, at);
|
|
661
|
+
if (hit.index < 0) {
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
found++;
|
|
665
|
+
at = hit.index + 1;
|
|
666
|
+
}
|
|
667
|
+
return found;
|
|
668
|
+
}
|
|
669
|
+
/** Applies one file's chunks to its lines. Throws `PatchError` on a miss. */
|
|
670
|
+
function patchLines(lines, chunks, file) {
|
|
671
|
+
const out = lines.slice();
|
|
672
|
+
let cursor = 0;
|
|
673
|
+
let fuzz = 0;
|
|
674
|
+
let ignored = 0;
|
|
675
|
+
for (const chunk of chunks) {
|
|
676
|
+
// Nothing to search for and no marker to append after: the top of the file
|
|
677
|
+
// is a guess, and a guess that reports success is worse than a refusal.
|
|
678
|
+
if (chunk.before.length === 0 && !chunk.eof && chunk.headings.length === 0) {
|
|
679
|
+
throw new PatchError(`${file}: the chunk at patch line ${chunk.at} adds lines without any context, ` +
|
|
680
|
+
'so there is nowhere to put them — keep a line of the file above the ' +
|
|
681
|
+
'insertion, or end the chunk with *** End of File to append');
|
|
682
|
+
}
|
|
683
|
+
for (const heading of chunk.headings) {
|
|
684
|
+
const at = findHeading(out, heading, cursor);
|
|
685
|
+
if (at < 0) {
|
|
686
|
+
// A heading only narrows the search. Models invent them —
|
|
687
|
+
// `@@ class RiskScanner` for a file of plain functions — and
|
|
688
|
+
// when the context occurs once the heading was never needed, so
|
|
689
|
+
// refusing spends a turn to learn nothing.
|
|
690
|
+
if (countMatches(out, chunk.before, cursor) === 1) {
|
|
691
|
+
ignored++;
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
throw new PatchError(`${file}: no line matching '@@ ${heading}' after the previous chunk` +
|
|
695
|
+
(countMatches(out, chunk.before, cursor) === 0
|
|
696
|
+
? ", and the chunk's context is not in the file either"
|
|
697
|
+
: ", and the chunk's context occurs more than once, so a heading " +
|
|
698
|
+
'that matches a line of the file is needed'));
|
|
699
|
+
}
|
|
700
|
+
// Not `at + 1`: models routinely repeat the heading as the first
|
|
701
|
+
// context line, and consuming it makes that line unfindable.
|
|
702
|
+
cursor = at;
|
|
703
|
+
}
|
|
704
|
+
// An end-of-file chunk is tried against the tail first; failing that it
|
|
705
|
+
// is an ordinary search, because the marker is often optimistic.
|
|
706
|
+
let found = { index: -1, fuzz: 0 };
|
|
707
|
+
if (chunk.eof) {
|
|
708
|
+
const tail = out.length - chunk.before.length;
|
|
709
|
+
if (tail >= cursor) {
|
|
710
|
+
const hit = locate(out.slice(tail), chunk.before, 0);
|
|
711
|
+
if (hit.index === 0) {
|
|
712
|
+
found = { index: tail, fuzz: hit.fuzz };
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
else if (chunk.before.length === 0) {
|
|
716
|
+
found = { index: out.length, fuzz: 0 };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (found.index < 0) {
|
|
720
|
+
found = locate(out, chunk.before, cursor);
|
|
721
|
+
}
|
|
722
|
+
if (found.index < 0) {
|
|
723
|
+
// Saying "not in the file" about a line that is plainly in it, just
|
|
724
|
+
// above the cursor, sends the model back to re-read and retry the
|
|
725
|
+
// same patch. Look once more from the top to tell the two apart.
|
|
726
|
+
if (cursor > 0 && locate(out, chunk.before, 0).index >= 0) {
|
|
727
|
+
throw new PatchError(`${file}: the chunk at patch line ${chunk.at} matches earlier in the file ` +
|
|
728
|
+
`than the previous chunk — chunks must be in file order`);
|
|
729
|
+
}
|
|
730
|
+
const k = matchedUpTo(out, chunk.before, cursor);
|
|
731
|
+
const missed = chunk.before[k] ?? chunk.before[0] ?? '';
|
|
732
|
+
throw new PatchError(`${file}: the chunk at patch line ${chunk.at} does not match — line ${k + 1} of ` +
|
|
733
|
+
`its context is not in the file` +
|
|
734
|
+
(missed ? `: ${missed.trim()}` : '') +
|
|
735
|
+
// A model that has been reading escaped tool output writes
|
|
736
|
+
// one patch line where the file has two.
|
|
737
|
+
(missed.includes('\\n')
|
|
738
|
+
? " — that line holds a literal '\\n'; each line of a patch is one line " +
|
|
739
|
+
'of the file'
|
|
740
|
+
: ''));
|
|
741
|
+
}
|
|
742
|
+
fuzz += found.fuzz > 0 ? 1 : 0;
|
|
743
|
+
// Pass 2 matched by ignoring indentation, so writing `after` as the
|
|
744
|
+
// patch spelled it would put the model's wrong indentation in the file
|
|
745
|
+
// — a syntax error in Python, and silent, since the chunk "applied".
|
|
746
|
+
let after = chunk.after;
|
|
747
|
+
if (found.fuzz === 2 && after.length > 0) {
|
|
748
|
+
const fixed = reindent(after, out, chunk.before, found.index);
|
|
749
|
+
if (!fixed) {
|
|
750
|
+
throw new PatchError(`${file}: the chunk at patch line ${chunk.at} matches only when indentation ` +
|
|
751
|
+
`is ignored, and its lines disagree about how far out they are`);
|
|
752
|
+
}
|
|
753
|
+
after = fixed;
|
|
754
|
+
}
|
|
755
|
+
out.splice(found.index, chunk.before.length, ...after);
|
|
756
|
+
cursor = found.index + after.length;
|
|
757
|
+
}
|
|
758
|
+
return { lines: out, fuzz, ignored };
|
|
759
|
+
}
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
// The tools
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
/** The family name config selects the whole set by: `tools: [files:*]` (or legacy `workspace:*`). */
|
|
764
|
+
const GROUP = 'files';
|
|
765
|
+
export function fileTools(opts) {
|
|
766
|
+
const ws = new Workspace(opts);
|
|
767
|
+
// What the model is told about naming a file. The long form goes in
|
|
768
|
+
// `read_file`, which is the first of these any model reaches for; the short
|
|
769
|
+
// one is repeated on every `path` argument, which is where the value is
|
|
770
|
+
// actually written and where a wrong guess costs a turn.
|
|
771
|
+
//
|
|
772
|
+
// This is also the only place an extra tree is named. Nothing else in the
|
|
773
|
+
// prompt mentions one, and a directory the model has not been told about is
|
|
774
|
+
// a directory it never opens.
|
|
775
|
+
const extras = ws.mounts.map((m) => m.at);
|
|
776
|
+
const bounds = extras.length === 0
|
|
777
|
+
? ' That directory is the whole of what can be reached: nothing outside it can be ' +
|
|
778
|
+
'read or written.'
|
|
779
|
+
: ` ${nameList(extras)} ${extras.length > 1 ? 'are' : 'is'} mounted alongside it: ` +
|
|
780
|
+
`read, list and search ${extras.length > 1 ? 'them' : 'it'} like anything else, ` +
|
|
781
|
+
'but nothing there can be written, moved or deleted. Nothing outside those ' +
|
|
782
|
+
'directories can be reached at all.';
|
|
783
|
+
const scope = (ws.mount
|
|
784
|
+
? `The workspace is mounted at ${ws.mount}, which is the name commands running in ` +
|
|
785
|
+
`the sandbox print and the name these tools answer with, so a path from a ` +
|
|
786
|
+
`command's output can be used here unchanged. A path relative to the workspace ` +
|
|
787
|
+
`root names the same file: ${ws.mount}/src/a.ts and src/a.ts are one file.`
|
|
788
|
+
: 'Paths are relative to the workspace root.') + bounds;
|
|
789
|
+
const inside = (ws.mount
|
|
790
|
+
? `Path in the workspace: ${ws.mount}/src/a.ts, or src/a.ts relative to the root — ` +
|
|
791
|
+
`the same file either way. The root itself is "${ws.mount}" or ".".`
|
|
792
|
+
: 'Path in the workspace, relative to its root: src/a.ts. The root itself is "." ' +
|
|
793
|
+
'or "/".') + (extras.length === 0 ? '' : ` Read-only: ${nameList(extras)}.`);
|
|
794
|
+
// With more than one tree in reach, "/" is not another name for the
|
|
795
|
+
// workspace root: it is where the trees hang, and listing it names them.
|
|
796
|
+
// One tree has nothing above it, so there "/" stays the root.
|
|
797
|
+
const trees = ws.mount && ws.mounts.length > 0
|
|
798
|
+
? [ws.mount, ...extras].map((name) => ({ name, kind: 'dir' }))
|
|
799
|
+
: undefined;
|
|
800
|
+
/**
|
|
801
|
+
* `.`, `/`, the mount and nothing at all are four spellings of the root, and
|
|
802
|
+
* a tool whose path is optional takes all four. A model that means "the top"
|
|
803
|
+
* writes whichever one it has in mind, and being told `path is required`
|
|
804
|
+
* because it sent "" rather than omitting the argument teaches it nothing.
|
|
805
|
+
*/
|
|
806
|
+
const root = (path) => ws.within(path?.trim() || '.');
|
|
807
|
+
const readFileTool = tool({
|
|
808
|
+
name: 'read_file',
|
|
809
|
+
group: GROUP,
|
|
810
|
+
description: 'Reads a UTF-8 text file from the workspace. ' +
|
|
811
|
+
scope +
|
|
812
|
+
' Give start_line/end_line (1-based, inclusive) to read a range; without them ' +
|
|
813
|
+
'the file is read from the top until the size cap, and `truncated` says whether ' +
|
|
814
|
+
'anything was left. Read before patching: apply_patch matches on the exact text.',
|
|
815
|
+
parameters: {
|
|
816
|
+
type: 'object',
|
|
817
|
+
properties: {
|
|
818
|
+
path: { type: 'string', description: inside },
|
|
819
|
+
start_line: { type: 'integer', description: 'First line to return, 1-based.' },
|
|
820
|
+
end_line: { type: 'integer', description: 'Last line to return, inclusive.' },
|
|
821
|
+
},
|
|
822
|
+
required: ['path'],
|
|
823
|
+
additionalProperties: false,
|
|
824
|
+
},
|
|
825
|
+
execute: async ({ path, start_line, end_line }) => {
|
|
826
|
+
const at = ws.within(path);
|
|
827
|
+
const info = await stat(at);
|
|
828
|
+
if (info.isDirectory()) {
|
|
829
|
+
return { error: `${ws.show(at)} is a directory`, hint: 'use list_dir' };
|
|
830
|
+
}
|
|
831
|
+
const shape = await inspectFile(at, info.size, false);
|
|
832
|
+
if (shape.format !== 'text') {
|
|
833
|
+
return {
|
|
834
|
+
error: `${ws.show(at)} is not a text file`,
|
|
835
|
+
format: shape.format,
|
|
836
|
+
bytes: info.size,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
const { body, whole } = await readText(at, info.size);
|
|
840
|
+
const all = toLines(body);
|
|
841
|
+
const total = all.length;
|
|
842
|
+
const from = Math.max(1, Math.trunc(start_line ?? 1));
|
|
843
|
+
const to = Math.min(total, end_line === undefined ? total : Math.trunc(end_line));
|
|
844
|
+
if (from > total) {
|
|
845
|
+
return { path: ws.show(at), lines: total, error: `file has ${total} lines` };
|
|
846
|
+
}
|
|
847
|
+
// The cap is spent line by line, so a truncated read still ends on a
|
|
848
|
+
// line boundary and can be resumed from `end_line + 1`.
|
|
849
|
+
const taken = [];
|
|
850
|
+
let used = 0;
|
|
851
|
+
let cut = false;
|
|
852
|
+
for (const line of all.slice(from - 1, to)) {
|
|
853
|
+
if (taken.length > 0 && used + line.length + 1 > MAX_READ) {
|
|
854
|
+
cut = true;
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
taken.push(line);
|
|
858
|
+
used += line.length + 1;
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
path: ws.show(at),
|
|
862
|
+
bytes: info.size,
|
|
863
|
+
lines: total,
|
|
864
|
+
start_line: from,
|
|
865
|
+
end_line: from + taken.length - 1,
|
|
866
|
+
// Where the range started is not truncation: a read that ends
|
|
867
|
+
// on the last line withheld nothing, and saying otherwise
|
|
868
|
+
// teaches the model to distrust every ranged read it makes.
|
|
869
|
+
truncated: cut || !whole || to < total ? true : undefined,
|
|
870
|
+
content: taken.join('\n'),
|
|
871
|
+
};
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
const listDir = tool({
|
|
875
|
+
name: 'list_dir',
|
|
876
|
+
group: GROUP,
|
|
877
|
+
description: 'Lists a directory in the workspace. Each entry reports its kind, and files also ' +
|
|
878
|
+
'report format (text, binary, image, audio, video, pdf, archive), size in bytes ' +
|
|
879
|
+
'and, for text files that are small enough to scan, their line count.',
|
|
880
|
+
parameters: {
|
|
881
|
+
type: 'object',
|
|
882
|
+
properties: {
|
|
883
|
+
path: {
|
|
884
|
+
type: 'string',
|
|
885
|
+
description: trees
|
|
886
|
+
? `${inside} Omit it, or give "/", to list the directories that can be ` +
|
|
887
|
+
`reached: ${nameList(trees.map((t) => t.name))}.`
|
|
888
|
+
: `${inside} Omit it to list the root.`,
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
required: [],
|
|
892
|
+
additionalProperties: false,
|
|
893
|
+
},
|
|
894
|
+
execute: async ({ path }) => {
|
|
895
|
+
const asked = path?.trim() ?? '';
|
|
896
|
+
if (trees && (asked === '' || /^[\\/]+$/.test(asked))) {
|
|
897
|
+
return { path: '/', entries: trees };
|
|
898
|
+
}
|
|
899
|
+
const at = root(path);
|
|
900
|
+
const entries = await readdir(at, { withFileTypes: true });
|
|
901
|
+
const listed = [];
|
|
902
|
+
let budget = LIST_SCAN_BUDGET;
|
|
903
|
+
for (const entry of entries.slice(0, MAX_ENTRIES)) {
|
|
904
|
+
if (entry.isDirectory()) {
|
|
905
|
+
listed.push({ name: entry.name, kind: 'dir' });
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (entry.isSymbolicLink()) {
|
|
909
|
+
listed.push({ name: entry.name, kind: 'link' });
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
if (!entry.isFile()) {
|
|
913
|
+
listed.push({ name: entry.name, kind: 'other' });
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
// A file can vanish between the readdir and the stat; that is a
|
|
917
|
+
// race, not a failure of the listing.
|
|
918
|
+
try {
|
|
919
|
+
const info = await stat(join(at, entry.name));
|
|
920
|
+
const shape = await inspectFile(join(at, entry.name), info.size, info.size <= budget);
|
|
921
|
+
budget -= info.size;
|
|
922
|
+
listed.push({ name: entry.name, kind: 'file', bytes: info.size, ...shape });
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
listed.push({ name: entry.name, kind: 'file' });
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return {
|
|
929
|
+
path: ws.show(at),
|
|
930
|
+
entries: listed,
|
|
931
|
+
truncated: entries.length > MAX_ENTRIES ? entries.length : undefined,
|
|
932
|
+
};
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
const writeFileTool = tool({
|
|
936
|
+
name: 'write_file',
|
|
937
|
+
group: GROUP,
|
|
938
|
+
description: 'Creates or overwrites a UTF-8 text file in the workspace, making parent ' +
|
|
939
|
+
'directories as needed. To change part of an existing file use apply_patch instead.',
|
|
940
|
+
parameters: {
|
|
941
|
+
type: 'object',
|
|
942
|
+
properties: {
|
|
943
|
+
path: { type: 'string', description: inside },
|
|
944
|
+
content: { type: 'string' },
|
|
945
|
+
},
|
|
946
|
+
required: ['path', 'content'],
|
|
947
|
+
additionalProperties: false,
|
|
948
|
+
},
|
|
949
|
+
execute: async ({ path, content }) => {
|
|
950
|
+
ws.mutable();
|
|
951
|
+
if (content.length > MAX_WRITE) {
|
|
952
|
+
return { error: `content exceeds ${MAX_WRITE} bytes` };
|
|
953
|
+
}
|
|
954
|
+
const at = ws.within(path, { write: true });
|
|
955
|
+
await mkdir(dirname(at), { recursive: true });
|
|
956
|
+
await writeFile(at, content, 'utf8');
|
|
957
|
+
return {
|
|
958
|
+
path: ws.show(at),
|
|
959
|
+
bytes: Buffer.byteLength(content),
|
|
960
|
+
lines: lineCount(content),
|
|
961
|
+
written: true,
|
|
962
|
+
};
|
|
963
|
+
},
|
|
964
|
+
});
|
|
965
|
+
const applyPatch = tool({
|
|
966
|
+
name: 'apply_patch',
|
|
967
|
+
group: GROUP,
|
|
968
|
+
description: 'Applies a patch to the workspace. The patch names the text it changes instead of ' +
|
|
969
|
+
'line numbers, so read the file first and copy the surrounding lines exactly.\n\n' +
|
|
970
|
+
'*** Begin Patch\n' +
|
|
971
|
+
'*** Update File: src/server.ts\n' +
|
|
972
|
+
'@@ class Server\n' +
|
|
973
|
+
' start() {\n' +
|
|
974
|
+
'- this.port = 80;\n' +
|
|
975
|
+
'+ this.port = 8080;\n' +
|
|
976
|
+
' }\n' +
|
|
977
|
+
'*** End Patch\n\n' +
|
|
978
|
+
'Sections: "*** Update File: <path>", optionally followed by "*** Move to: <path>" ' +
|
|
979
|
+
'to rename it; "*** Add File: <path>" with every line of the new file prefixed by ' +
|
|
980
|
+
'"+"; "*** Delete File: <path>". Inside an update, a line starting with a space is ' +
|
|
981
|
+
'context that must already be there, "-" removes a line and "+" adds one. ' +
|
|
982
|
+
'"@@ <text>" names the enclosing block when the same lines occur more than once, ' +
|
|
983
|
+
'and "*** End of File" anchors a chunk to the end. Give at least three lines of ' +
|
|
984
|
+
'context around each change. One patch may touch several files, and the whole of ' +
|
|
985
|
+
'it is checked before anything is written: if any part fails, nothing changes.',
|
|
986
|
+
parameters: {
|
|
987
|
+
type: 'object',
|
|
988
|
+
properties: {
|
|
989
|
+
patch: {
|
|
990
|
+
type: 'string',
|
|
991
|
+
description: 'The patch, from *** Begin Patch to *** End Patch.',
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
required: ['patch'],
|
|
995
|
+
additionalProperties: false,
|
|
996
|
+
},
|
|
997
|
+
execute: async ({ patch }) => {
|
|
998
|
+
ws.mutable();
|
|
999
|
+
if (typeof patch !== 'string' || patch.trim() === '') {
|
|
1000
|
+
return { error: 'patch is required' };
|
|
1001
|
+
}
|
|
1002
|
+
try {
|
|
1003
|
+
return await runPatch(ws, patch);
|
|
1004
|
+
}
|
|
1005
|
+
catch (err) {
|
|
1006
|
+
if (err instanceof PatchError) {
|
|
1007
|
+
return {
|
|
1008
|
+
error: err.message,
|
|
1009
|
+
hint: 'nothing was written — read the file again and rebuild the patch',
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
throw err;
|
|
1013
|
+
}
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
1016
|
+
const movePath = tool({
|
|
1017
|
+
name: 'move_file',
|
|
1018
|
+
group: GROUP,
|
|
1019
|
+
description: 'Moves or renames a file or directory inside the workspace, making the ' +
|
|
1020
|
+
'destination parent directories as needed. Refuses to clobber an existing ' +
|
|
1021
|
+
'destination unless overwrite is set.',
|
|
1022
|
+
parameters: {
|
|
1023
|
+
type: 'object',
|
|
1024
|
+
properties: {
|
|
1025
|
+
from: { type: 'string', description: `What to move. ${inside}` },
|
|
1026
|
+
to: { type: 'string', description: `Where it should end up. ${inside}` },
|
|
1027
|
+
overwrite: {
|
|
1028
|
+
type: 'boolean',
|
|
1029
|
+
description: 'Replace the destination if it exists.',
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
required: ['from', 'to'],
|
|
1033
|
+
additionalProperties: false,
|
|
1034
|
+
},
|
|
1035
|
+
execute: async ({ from, to, overwrite }) => {
|
|
1036
|
+
ws.mutable();
|
|
1037
|
+
const source = ws.within(from, { write: true });
|
|
1038
|
+
const target = ws.within(to, { write: true });
|
|
1039
|
+
if (ws.isRoot(source)) {
|
|
1040
|
+
return { error: 'refusing to move the workspace root' };
|
|
1041
|
+
}
|
|
1042
|
+
if (ws.isRoot(target)) {
|
|
1043
|
+
return { error: 'refusing to overwrite the workspace root' };
|
|
1044
|
+
}
|
|
1045
|
+
if (source === target) {
|
|
1046
|
+
return { error: 'the source and the destination are the same path' };
|
|
1047
|
+
}
|
|
1048
|
+
if (target.startsWith(source + sep)) {
|
|
1049
|
+
return { error: `cannot move ${ws.show(source)} into itself` };
|
|
1050
|
+
}
|
|
1051
|
+
await stat(source); // ENOENT here is the honest error
|
|
1052
|
+
if (await statOrNull(target)) {
|
|
1053
|
+
if (!overwrite) {
|
|
1054
|
+
return {
|
|
1055
|
+
error: `${ws.show(target)} already exists`,
|
|
1056
|
+
hint: 'pass overwrite to replace it',
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
// rename() will not replace a non-empty directory on its own.
|
|
1060
|
+
await rm(target, { recursive: true, force: true });
|
|
1061
|
+
}
|
|
1062
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1063
|
+
await rename(source, target);
|
|
1064
|
+
return { from: ws.show(source), to: ws.show(target), moved: true };
|
|
1065
|
+
},
|
|
1066
|
+
});
|
|
1067
|
+
// The one mutating tool whose source may sit in a read-only tree, and the
|
|
1068
|
+
// reason it earns its place: reaching a mounted file otherwise means
|
|
1069
|
+
// reading it into the context and writing it back out, which costs a turn,
|
|
1070
|
+
// truncates past the read cap and cannot carry a binary at all.
|
|
1071
|
+
const copyPath = tool({
|
|
1072
|
+
name: 'copy_file',
|
|
1073
|
+
group: GROUP,
|
|
1074
|
+
description: 'Copies a file, making the destination parent directories as needed. The source ' +
|
|
1075
|
+
'may be in a read-only tree, so this is how something mounted for reference is ' +
|
|
1076
|
+
'brought into the workspace; the destination must be writable. The bytes are ' +
|
|
1077
|
+
'copied directly and are not read into the conversation, so size is no object ' +
|
|
1078
|
+
'and a binary copies as faithfully as text. Refuses to clobber an existing ' +
|
|
1079
|
+
'destination unless overwrite is set, and only copies a directory, with ' +
|
|
1080
|
+
'everything in it, when recursive is set.',
|
|
1081
|
+
parameters: {
|
|
1082
|
+
type: 'object',
|
|
1083
|
+
properties: {
|
|
1084
|
+
from: { type: 'string', description: `What to copy. ${inside}` },
|
|
1085
|
+
to: { type: 'string', description: `Where the copy should land. ${inside}` },
|
|
1086
|
+
overwrite: {
|
|
1087
|
+
type: 'boolean',
|
|
1088
|
+
description: 'Replace the destination if it exists.',
|
|
1089
|
+
},
|
|
1090
|
+
recursive: {
|
|
1091
|
+
type: 'boolean',
|
|
1092
|
+
description: 'Required to copy a directory and its contents.',
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
required: ['from', 'to'],
|
|
1096
|
+
additionalProperties: false,
|
|
1097
|
+
},
|
|
1098
|
+
execute: async ({ from, to, overwrite, recursive }) => {
|
|
1099
|
+
ws.mutable();
|
|
1100
|
+
const source = ws.within(from);
|
|
1101
|
+
const target = ws.within(to, { write: true });
|
|
1102
|
+
if (ws.isRoot(target)) {
|
|
1103
|
+
return { error: 'refusing to overwrite the workspace root' };
|
|
1104
|
+
}
|
|
1105
|
+
if (source === target) {
|
|
1106
|
+
return { error: 'the source and the destination are the same path' };
|
|
1107
|
+
}
|
|
1108
|
+
const info = await stat(source); // ENOENT here is the honest error
|
|
1109
|
+
if (info.isDirectory()) {
|
|
1110
|
+
if (!recursive) {
|
|
1111
|
+
return {
|
|
1112
|
+
error: `${ws.show(source)} is a directory`,
|
|
1113
|
+
hint: 'pass recursive to copy it and everything in it',
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
if (target.startsWith(source + sep)) {
|
|
1117
|
+
return { error: `cannot copy ${ws.show(source)} into itself` };
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (await statOrNull(target)) {
|
|
1121
|
+
if (!overwrite) {
|
|
1122
|
+
return {
|
|
1123
|
+
error: `${ws.show(target)} already exists`,
|
|
1124
|
+
hint: 'pass overwrite to replace it',
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
// cp() will not replace a directory with a file, or the reverse.
|
|
1128
|
+
await rm(target, { recursive: true, force: true });
|
|
1129
|
+
}
|
|
1130
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1131
|
+
await cp(source, target, { recursive: recursive === true });
|
|
1132
|
+
const out = { from: ws.show(source), to: ws.show(target), copied: true };
|
|
1133
|
+
return info.isDirectory() ? { ...out, directory: true } : { ...out, bytes: info.size };
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
const deleteFile = tool({
|
|
1137
|
+
name: 'delete_file',
|
|
1138
|
+
group: GROUP,
|
|
1139
|
+
description: 'Deletes a file from the workspace. A directory is only removed, with everything ' +
|
|
1140
|
+
'in it, when recursive is set.',
|
|
1141
|
+
parameters: {
|
|
1142
|
+
type: 'object',
|
|
1143
|
+
properties: {
|
|
1144
|
+
path: { type: 'string', description: inside },
|
|
1145
|
+
recursive: {
|
|
1146
|
+
type: 'boolean',
|
|
1147
|
+
description: 'Required to delete a directory and its contents.',
|
|
1148
|
+
},
|
|
1149
|
+
},
|
|
1150
|
+
required: ['path'],
|
|
1151
|
+
additionalProperties: false,
|
|
1152
|
+
},
|
|
1153
|
+
execute: async ({ path, recursive }) => {
|
|
1154
|
+
ws.mutable();
|
|
1155
|
+
const at = ws.within(path, { write: true });
|
|
1156
|
+
if (ws.isRoot(at)) {
|
|
1157
|
+
return { error: 'refusing to delete the workspace root' };
|
|
1158
|
+
}
|
|
1159
|
+
const info = await stat(at);
|
|
1160
|
+
if (info.isDirectory()) {
|
|
1161
|
+
if (!recursive) {
|
|
1162
|
+
return {
|
|
1163
|
+
error: `${ws.show(at)} is a directory`,
|
|
1164
|
+
hint: 'pass recursive to delete it and everything in it',
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
await rm(at, { recursive: true });
|
|
1168
|
+
return { path: ws.show(at), deleted: true, directory: true };
|
|
1169
|
+
}
|
|
1170
|
+
await rm(at);
|
|
1171
|
+
return { path: ws.show(at), deleted: true };
|
|
1172
|
+
},
|
|
1173
|
+
});
|
|
1174
|
+
const findFiles = tool({
|
|
1175
|
+
name: 'find_files',
|
|
1176
|
+
group: GROUP,
|
|
1177
|
+
description: 'Finds files in the workspace whose path contains the given substring. ' +
|
|
1178
|
+
'Case-insensitive.',
|
|
1179
|
+
parameters: {
|
|
1180
|
+
type: 'object',
|
|
1181
|
+
properties: {
|
|
1182
|
+
pattern: { type: 'string', description: 'Substring to match against the path.' },
|
|
1183
|
+
path: {
|
|
1184
|
+
type: 'string',
|
|
1185
|
+
description: `Directory to search under; omit it to search the whole ` +
|
|
1186
|
+
`workspace. ${inside}`,
|
|
1187
|
+
},
|
|
1188
|
+
},
|
|
1189
|
+
required: ['pattern'],
|
|
1190
|
+
additionalProperties: false,
|
|
1191
|
+
},
|
|
1192
|
+
execute: async ({ pattern, path }) => {
|
|
1193
|
+
// Omitting the path means "everywhere I can see", and what the
|
|
1194
|
+
// model can see now includes the mounted trees. Searching only the
|
|
1195
|
+
// workspace would leave them behind a path it has to know to ask
|
|
1196
|
+
// for. A tree nested inside the workspace is walked twice, so the
|
|
1197
|
+
// hits are a set: `show` gives one file one name either way.
|
|
1198
|
+
const where = path?.trim()
|
|
1199
|
+
? [ws.within(path)]
|
|
1200
|
+
: [ws.root, ...ws.mounts.map((m) => m.host)];
|
|
1201
|
+
const needle = pattern.toLowerCase();
|
|
1202
|
+
const hits = new Set();
|
|
1203
|
+
for (const dir of where) {
|
|
1204
|
+
const room = await walk(dir, ws, (file) => {
|
|
1205
|
+
// Matched on the path within its own tree, reported under
|
|
1206
|
+
// the name the model uses: a mount prefix is on every
|
|
1207
|
+
// candidate, so matching it would mean nothing.
|
|
1208
|
+
if (ws.rel(file).toLowerCase().includes(needle)) {
|
|
1209
|
+
hits.add(ws.show(file));
|
|
1210
|
+
}
|
|
1211
|
+
return hits.size < MAX_ENTRIES;
|
|
1212
|
+
});
|
|
1213
|
+
if (!room) {
|
|
1214
|
+
break;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return {
|
|
1218
|
+
pattern,
|
|
1219
|
+
matches: [...hits],
|
|
1220
|
+
truncated: hits.size >= MAX_ENTRIES,
|
|
1221
|
+
};
|
|
1222
|
+
},
|
|
1223
|
+
});
|
|
1224
|
+
return named(ws, opts.readOnly
|
|
1225
|
+
? [readFileTool, listDir, findFiles]
|
|
1226
|
+
: [
|
|
1227
|
+
readFileTool,
|
|
1228
|
+
listDir,
|
|
1229
|
+
findFiles,
|
|
1230
|
+
writeFileTool,
|
|
1231
|
+
applyPatch,
|
|
1232
|
+
copyPath,
|
|
1233
|
+
movePath,
|
|
1234
|
+
deleteFile,
|
|
1235
|
+
]);
|
|
1236
|
+
}
|
|
1237
|
+
/** @deprecated The group has been `files` for a while; this is the old spelling. */
|
|
1238
|
+
export const workspaceTools = fileTools;
|
|
1239
|
+
/** What each errno means, said the way the rest of these tools say things. */
|
|
1240
|
+
const ERRNO = {
|
|
1241
|
+
ENOENT: 'does not exist',
|
|
1242
|
+
ENOTDIR: 'is not a directory',
|
|
1243
|
+
EISDIR: 'is a directory',
|
|
1244
|
+
EEXIST: 'already exists',
|
|
1245
|
+
ENOTEMPTY: 'is not empty',
|
|
1246
|
+
EACCES: 'cannot be reached',
|
|
1247
|
+
EPERM: 'cannot be reached',
|
|
1248
|
+
ELOOP: 'is a loop of symlinks',
|
|
1249
|
+
};
|
|
1250
|
+
/**
|
|
1251
|
+
* Every path these tools are given comes back under the name the model wrote
|
|
1252
|
+
* it — except when the filesystem itself fails, because Node's errors name the
|
|
1253
|
+
* host path: `ENOENT: no such file or directory, scandir '/Users/me/proj/src'`.
|
|
1254
|
+
* That is a name the model has never been shown, cannot pass to another tool,
|
|
1255
|
+
* and will read as evidence about where it is running. So a failure is
|
|
1256
|
+
* rewritten into the same vocabulary as a success before it leaves.
|
|
1257
|
+
*/
|
|
1258
|
+
function named(ws, tools) {
|
|
1259
|
+
return tools.map((t) => ({
|
|
1260
|
+
...t,
|
|
1261
|
+
execute: async (args, tc) => {
|
|
1262
|
+
try {
|
|
1263
|
+
return await t.execute(args, tc);
|
|
1264
|
+
}
|
|
1265
|
+
catch (err) {
|
|
1266
|
+
const e = err;
|
|
1267
|
+
if (typeof e?.code !== 'string' || typeof e.path !== 'string') {
|
|
1268
|
+
throw err;
|
|
1269
|
+
}
|
|
1270
|
+
throw new Error(`${ws.show(e.path)} ${ERRNO[e.code] ?? `failed: ${e.code}`}`, {
|
|
1271
|
+
cause: err,
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
},
|
|
1275
|
+
}));
|
|
1276
|
+
}
|
|
1277
|
+
async function runPatch(ws, patch) {
|
|
1278
|
+
const ops = parsePatch(patch);
|
|
1279
|
+
const steps = [];
|
|
1280
|
+
const touched = [];
|
|
1281
|
+
const seen = new Set();
|
|
1282
|
+
// Where a move lands is a path this patch writes as surely as one it names,
|
|
1283
|
+
// and `rename` overwrites: without this, the last step to touch a path wins
|
|
1284
|
+
// and the others are lost with the patch still reporting them applied.
|
|
1285
|
+
const targets = new Set();
|
|
1286
|
+
// Steps run in patch order, so a path deleted by an earlier op is free by the
|
|
1287
|
+
// time a later move lands on it, however occupied it still looks on disk.
|
|
1288
|
+
const removed = new Set();
|
|
1289
|
+
let fuzz = 0;
|
|
1290
|
+
let ignored = 0;
|
|
1291
|
+
for (const op of ops) {
|
|
1292
|
+
const at = patchPath(ws, op.path);
|
|
1293
|
+
if (ws.isRoot(at)) {
|
|
1294
|
+
throw new PatchError(`${op.path} is the workspace root`);
|
|
1295
|
+
}
|
|
1296
|
+
if (seen.has(at)) {
|
|
1297
|
+
throw new PatchError(`${ws.show(at)} appears twice in the patch`);
|
|
1298
|
+
}
|
|
1299
|
+
if (targets.has(at)) {
|
|
1300
|
+
throw new PatchError(`${ws.show(at)} is where an earlier part of this patch moves a file`);
|
|
1301
|
+
}
|
|
1302
|
+
seen.add(at);
|
|
1303
|
+
if (op.kind === 'delete') {
|
|
1304
|
+
const info = await statOrNull(at);
|
|
1305
|
+
if (!info) {
|
|
1306
|
+
throw new PatchError(`${ws.show(at)} does not exist`);
|
|
1307
|
+
}
|
|
1308
|
+
if (info.isDirectory()) {
|
|
1309
|
+
throw new PatchError(`${ws.show(at)} is a directory`);
|
|
1310
|
+
}
|
|
1311
|
+
steps.push({ kind: 'delete', at });
|
|
1312
|
+
removed.add(at);
|
|
1313
|
+
touched.push({ path: ws.show(at), action: 'deleted' });
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
if (op.kind === 'add') {
|
|
1317
|
+
if (await statOrNull(at)) {
|
|
1318
|
+
throw new PatchError(`${ws.show(at)} already exists — update it instead of adding it`);
|
|
1319
|
+
}
|
|
1320
|
+
const content = op.lines.length > 0 ? op.lines.join('\n') + '\n' : '';
|
|
1321
|
+
if (Buffer.byteLength(content) > MAX_WRITE) {
|
|
1322
|
+
throw new PatchError(`${ws.show(at)} would exceed ${MAX_WRITE} bytes`);
|
|
1323
|
+
}
|
|
1324
|
+
steps.push({ kind: 'write', at, content });
|
|
1325
|
+
touched.push({
|
|
1326
|
+
path: ws.show(at),
|
|
1327
|
+
action: 'added',
|
|
1328
|
+
bytes: Buffer.byteLength(content),
|
|
1329
|
+
lines: op.lines.length,
|
|
1330
|
+
});
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
const info = await statOrNull(at);
|
|
1334
|
+
if (!info) {
|
|
1335
|
+
throw new PatchError(`${ws.show(at)} does not exist`);
|
|
1336
|
+
}
|
|
1337
|
+
if (info.isDirectory()) {
|
|
1338
|
+
throw new PatchError(`${ws.show(at)} is a directory`);
|
|
1339
|
+
}
|
|
1340
|
+
if (info.size > MAX_WRITE) {
|
|
1341
|
+
throw new PatchError(`${ws.show(at)} is too large to patch`);
|
|
1342
|
+
}
|
|
1343
|
+
const shape = await inspectFile(at, info.size, false);
|
|
1344
|
+
if (shape.format !== 'text') {
|
|
1345
|
+
throw new PatchError(`${ws.show(at)} is a ${shape.format} file, not text`);
|
|
1346
|
+
}
|
|
1347
|
+
const body = await readFile(at, 'utf8');
|
|
1348
|
+
// Matched against LF-only lines. A patch never carries a CR, so a CRLF
|
|
1349
|
+
// file would miss exactly, match on the trailing-whitespace pass, and be
|
|
1350
|
+
// spliced with bare LF — mixed endings behind a successful apply.
|
|
1351
|
+
const eol = isCrlf(body) ? '\r\n' : '\n';
|
|
1352
|
+
const flat = eol === '\n' ? body : body.split(eol).join('\n');
|
|
1353
|
+
const patched = patchLines(toLines(flat), op.chunks, ws.show(at));
|
|
1354
|
+
fuzz += patched.fuzz;
|
|
1355
|
+
ignored += patched.ignored;
|
|
1356
|
+
// Whether the file ended in a newline is a property of the file, not of
|
|
1357
|
+
// the patch, so it survives the edit.
|
|
1358
|
+
const trailing = flat === '' || flat.endsWith('\n');
|
|
1359
|
+
const content = patched.lines.length === 0 ? '' : patched.lines.join(eol) + (trailing ? eol : '');
|
|
1360
|
+
if (Buffer.byteLength(content) > MAX_WRITE) {
|
|
1361
|
+
throw new PatchError(`${ws.show(at)} would exceed ${MAX_WRITE} bytes`);
|
|
1362
|
+
}
|
|
1363
|
+
steps.push({ kind: 'write', at, content });
|
|
1364
|
+
const entry = {
|
|
1365
|
+
path: ws.show(at),
|
|
1366
|
+
action: 'updated',
|
|
1367
|
+
chunks: op.chunks.length,
|
|
1368
|
+
bytes: Buffer.byteLength(content),
|
|
1369
|
+
lines: patched.lines.length,
|
|
1370
|
+
};
|
|
1371
|
+
if (op.moveTo) {
|
|
1372
|
+
const to = patchPath(ws, op.moveTo);
|
|
1373
|
+
if (ws.isRoot(to)) {
|
|
1374
|
+
throw new PatchError('cannot move a file onto the workspace root');
|
|
1375
|
+
}
|
|
1376
|
+
if (to !== at) {
|
|
1377
|
+
if (targets.has(to) || (seen.has(to) && !removed.has(to))) {
|
|
1378
|
+
throw new PatchError(`${ws.show(to)} is already written by another part of this patch`);
|
|
1379
|
+
}
|
|
1380
|
+
if (!removed.has(to) && (await statOrNull(to))) {
|
|
1381
|
+
throw new PatchError(`${ws.show(to)} already exists`);
|
|
1382
|
+
}
|
|
1383
|
+
targets.add(to);
|
|
1384
|
+
steps.push({ kind: 'move', from: at, to });
|
|
1385
|
+
entry.action = 'moved';
|
|
1386
|
+
entry.to = ws.show(to);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
touched.push(entry);
|
|
1390
|
+
}
|
|
1391
|
+
for (const step of steps) {
|
|
1392
|
+
switch (step.kind) {
|
|
1393
|
+
case 'write':
|
|
1394
|
+
await mkdir(dirname(step.at), { recursive: true });
|
|
1395
|
+
await writeFile(step.at, step.content, 'utf8');
|
|
1396
|
+
break;
|
|
1397
|
+
case 'move':
|
|
1398
|
+
await mkdir(dirname(step.to), { recursive: true });
|
|
1399
|
+
await rename(step.from, step.to);
|
|
1400
|
+
break;
|
|
1401
|
+
case 'delete':
|
|
1402
|
+
await rm(step.at);
|
|
1403
|
+
break;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
return {
|
|
1407
|
+
applied: touched.length,
|
|
1408
|
+
files: touched,
|
|
1409
|
+
// Worth reporting: a chunk that only matched once whitespace was
|
|
1410
|
+
// ignored landed where the tool thinks it should, not where the patch
|
|
1411
|
+
// said, and that is a thing to check.
|
|
1412
|
+
fuzzy: fuzz > 0 ? fuzz : undefined,
|
|
1413
|
+
// A heading that named nothing, applied anyway because the context was
|
|
1414
|
+
// unique. Says the heading was wrong without having refused the edit.
|
|
1415
|
+
ignoredHeadings: ignored > 0 ? ignored : undefined,
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
async function statOrNull(at) {
|
|
1419
|
+
try {
|
|
1420
|
+
return await stat(at);
|
|
1421
|
+
}
|
|
1422
|
+
catch {
|
|
1423
|
+
return null;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* A path in a patch, checked for containment and for write permission before
|
|
1428
|
+
* the first byte is written. Both refusals become patch errors, because the
|
|
1429
|
+
* promise apply_patch makes is that a patch it will not finish leaves nothing
|
|
1430
|
+
* behind — a read-only file named halfway down is a reason to reject the whole
|
|
1431
|
+
* patch, not to write the files above it and then throw.
|
|
1432
|
+
*/
|
|
1433
|
+
function patchPath(ws, path) {
|
|
1434
|
+
try {
|
|
1435
|
+
return ws.within(path, { write: true });
|
|
1436
|
+
}
|
|
1437
|
+
catch (err) {
|
|
1438
|
+
throw new PatchError(err instanceof Error ? err.message : String(err));
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
/** Depth-first, skipping the noise nobody means to search. */
|
|
1442
|
+
const SKIP = new Set(['.git', 'node_modules', '.data', 'dist', '.venv', '__pycache__']);
|
|
1443
|
+
async function walk(dir, ws, visit) {
|
|
1444
|
+
let entries;
|
|
1445
|
+
try {
|
|
1446
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
return true;
|
|
1450
|
+
}
|
|
1451
|
+
for (const entry of entries) {
|
|
1452
|
+
if (SKIP.has(entry.name)) {
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
const child = join(dir, entry.name);
|
|
1456
|
+
// Symlinks are not followed: a link out of the workspace would
|
|
1457
|
+
// otherwise let a listing report paths the tools cannot open anyway.
|
|
1458
|
+
if (entry.isSymbolicLink()) {
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (entry.isDirectory()) {
|
|
1462
|
+
if (!(await walk(child, ws, visit))) {
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
else if (entry.isFile() && !visit(child)) {
|
|
1467
|
+
return false;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
//# sourceMappingURL=files.js.map
|