@zenera/neo 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/dist/agent.d.ts +66 -0
- package/dist/agent.js +97 -0
- package/dist/architecture.d.ts +74 -0
- package/dist/architecture.js +105 -0
- package/dist/embedding.d.ts +60 -0
- package/dist/embedding.js +21 -0
- package/dist/embeddings/gemini.d.ts +24 -0
- package/dist/embeddings/gemini.js +71 -0
- package/dist/embeddings/index.d.ts +4 -0
- package/dist/embeddings/index.js +4 -0
- package/dist/embeddings/openai.d.ts +18 -0
- package/dist/embeddings/openai.js +27 -0
- package/dist/embeddings/openrouter.d.ts +21 -0
- package/dist/embeddings/openrouter.js +54 -0
- package/dist/events.d.ts +144 -0
- package/dist/events.js +110 -0
- package/dist/ids.d.ts +14 -0
- package/dist/ids.js +33 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- package/dist/inspect/client.d.ts +2 -0
- package/dist/inspect/client.js +1326 -0
- package/dist/inspect/css.d.ts +2 -0
- package/dist/inspect/css.js +164 -0
- package/dist/inspect/index.d.ts +9 -0
- package/dist/inspect/index.js +12 -0
- package/dist/inspect/page.d.ts +3 -0
- package/dist/inspect/page.js +93 -0
- package/dist/inspect/report.d.ts +54 -0
- package/dist/inspect/report.js +62 -0
- package/dist/kernel.d.ts +178 -0
- package/dist/kernel.js +929 -0
- package/dist/memory-stores/factory.d.ts +15 -0
- package/dist/memory-stores/factory.js +29 -0
- package/dist/memory-stores/file.d.ts +37 -0
- package/dist/memory-stores/file.js +174 -0
- package/dist/memory-stores/in-memory.d.ts +17 -0
- package/dist/memory-stores/in-memory.js +80 -0
- package/dist/memory-stores/index.d.ts +4 -0
- package/dist/memory-stores/index.js +5 -0
- package/dist/memory.d.ts +115 -0
- package/dist/memory.js +213 -0
- package/dist/model.d.ts +25 -0
- package/dist/model.js +2 -0
- package/dist/models/anthropic.d.ts +36 -0
- package/dist/models/anthropic.js +293 -0
- package/dist/models/factory.d.ts +202 -0
- package/dist/models/factory.js +533 -0
- package/dist/models/gemini.d.ts +45 -0
- package/dist/models/gemini.js +368 -0
- package/dist/models/index.d.ts +7 -0
- package/dist/models/index.js +7 -0
- package/dist/models/openai-chat.d.ts +19 -0
- package/dist/models/openai-chat.js +203 -0
- package/dist/models/openai-responses.d.ts +31 -0
- package/dist/models/openai-responses.js +258 -0
- package/dist/models/openrouter.d.ts +44 -0
- package/dist/models/openrouter.js +230 -0
- package/dist/payload-stores/factory.d.ts +15 -0
- package/dist/payload-stores/factory.js +30 -0
- package/dist/payload-stores/file.d.ts +29 -0
- package/dist/payload-stores/file.js +112 -0
- package/dist/payload-stores/in-memory.d.ts +17 -0
- package/dist/payload-stores/in-memory.js +40 -0
- package/dist/payload-stores/index.d.ts +4 -0
- package/dist/payload-stores/index.js +5 -0
- package/dist/payload.d.ts +89 -0
- package/dist/payload.js +181 -0
- package/dist/project/config.d.ts +302 -0
- package/dist/project/config.js +290 -0
- package/dist/project/index.d.ts +4 -0
- package/dist/project/index.js +4 -0
- package/dist/project/load.d.ts +142 -0
- package/dist/project/load.js +430 -0
- package/dist/project/refs.d.ts +23 -0
- package/dist/project/refs.js +70 -0
- package/dist/prompt.d.ts +66 -0
- package/dist/prompt.js +62 -0
- package/dist/runner.d.ts +103 -0
- package/dist/runner.js +537 -0
- package/dist/services.d.ts +23 -0
- package/dist/services.js +44 -0
- package/dist/skill-providers/factory.d.ts +16 -0
- package/dist/skill-providers/factory.js +26 -0
- package/dist/skill-providers/file.d.ts +44 -0
- package/dist/skill-providers/file.js +209 -0
- package/dist/skill-providers/index.d.ts +4 -0
- package/dist/skill-providers/index.js +5 -0
- package/dist/skill-providers/static.d.ts +13 -0
- package/dist/skill-providers/static.js +47 -0
- package/dist/skills.d.ts +102 -0
- package/dist/skills.js +232 -0
- package/dist/state.d.ts +86 -0
- package/dist/state.js +49 -0
- package/dist/tools/exa.d.ts +23 -0
- package/dist/tools/exa.js +389 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/sandbox.d.ts +172 -0
- package/dist/tools/sandbox.js +602 -0
- package/dist/tools/workspace.d.ts +53 -0
- package/dist/tools/workspace.js +1034 -0
- package/dist/trajectory.d.ts +213 -0
- package/dist/trajectory.js +232 -0
- package/dist/types.d.ts +195 -0
- package/dist/types.js +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { 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
|
+
// Workspace 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 `workspaceTools` 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
|
+
export class Workspace {
|
|
32
|
+
root;
|
|
33
|
+
readOnly;
|
|
34
|
+
mount;
|
|
35
|
+
constructor(opts) {
|
|
36
|
+
// Resolve the root's own symlinks once, so a workspace that *is* a
|
|
37
|
+
// symlink does not fail every containment check against itself.
|
|
38
|
+
this.root = realpathSync(resolve(opts.root));
|
|
39
|
+
this.readOnly = opts.readOnly ?? false;
|
|
40
|
+
// A trailing slash — or a mount of `/`, which would make everything on
|
|
41
|
+
// the machine look like it was inside — is not a mount point.
|
|
42
|
+
const mount = opts.mount?.replace(/\/+$/, '');
|
|
43
|
+
this.mount = mount ? mount : undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The one gate. Returns an absolute path inside the workspace, or throws.
|
|
47
|
+
*
|
|
48
|
+
* A path that does not exist yet cannot be realpath'd, so the nearest
|
|
49
|
+
* existing ancestor is resolved instead and the remainder appended — which
|
|
50
|
+
* is exactly what a create needs and closes the same hole a create opens.
|
|
51
|
+
*/
|
|
52
|
+
within(input) {
|
|
53
|
+
if (typeof input !== 'string' || input.length === 0) {
|
|
54
|
+
throw new Error('path is required');
|
|
55
|
+
}
|
|
56
|
+
if (input.includes('\0')) {
|
|
57
|
+
throw new Error('path contains a null byte');
|
|
58
|
+
}
|
|
59
|
+
// A lone `/` is the model's other name for "the top of what I can see".
|
|
60
|
+
// There is nothing above the root for it to mean, so it means the root
|
|
61
|
+
// — refusing it teaches nothing and costs a turn. This is the only
|
|
62
|
+
// absolute path treated as rooted: `/etc/passwd` still names the
|
|
63
|
+
// machine's file and still fails containment below.
|
|
64
|
+
const wanted = resolve(this.root, /^[\\/]+$/.test(input) ? '.' : this.#unmount(input));
|
|
65
|
+
const real = this.#realish(wanted);
|
|
66
|
+
if (real !== this.root && !real.startsWith(this.root + sep)) {
|
|
67
|
+
throw new Error(`outside the workspace: ${input}`);
|
|
68
|
+
}
|
|
69
|
+
return real;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `/workspace/src/a.ts` and `src/a.ts` are one file under two names, and
|
|
73
|
+
* only the second resolves against the root, so the mounted spelling is
|
|
74
|
+
* rewritten before anything else looks at it.
|
|
75
|
+
*
|
|
76
|
+
* This is renaming, not permission. What comes out is still relative, still
|
|
77
|
+
* resolved, still symlink-followed and still checked against the root
|
|
78
|
+
* above: `/workspace/../etc/passwd` loses its prefix and then fails
|
|
79
|
+
* containment exactly as `../etc/passwd` does.
|
|
80
|
+
*/
|
|
81
|
+
#unmount(input) {
|
|
82
|
+
if (this.mount === undefined) {
|
|
83
|
+
return input;
|
|
84
|
+
}
|
|
85
|
+
const at = input.replace(/\\/g, '/');
|
|
86
|
+
if (at === this.mount) {
|
|
87
|
+
return '.';
|
|
88
|
+
}
|
|
89
|
+
if (!at.startsWith(this.mount + '/')) {
|
|
90
|
+
return input;
|
|
91
|
+
}
|
|
92
|
+
return at.slice(this.mount.length).replace(/^\/+/, '') || '.';
|
|
93
|
+
}
|
|
94
|
+
#realish(path) {
|
|
95
|
+
const missing = [];
|
|
96
|
+
let at = path;
|
|
97
|
+
for (;;) {
|
|
98
|
+
try {
|
|
99
|
+
return join(realpathSync(at), ...missing.reverse());
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (err.code !== 'ENOENT') {
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
const parent = dirname(at);
|
|
106
|
+
if (parent === at) {
|
|
107
|
+
// Walked to the filesystem root without finding anything
|
|
108
|
+
// real; nothing here can be inside the workspace.
|
|
109
|
+
throw new Error(`cannot resolve: ${path}`);
|
|
110
|
+
}
|
|
111
|
+
missing.push(at.slice(parent.length + 1));
|
|
112
|
+
at = parent;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Where a path sits under the root, in posix form. `.` is the root. */
|
|
117
|
+
rel(path) {
|
|
118
|
+
return relative(this.root, path).split(sep).join('/') || '.';
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* How a path is written back to the model, and the reason `mount` is not
|
|
122
|
+
* only about what comes in. A shell in the sandbox prints the mounted name
|
|
123
|
+
* — `find` returns /workspace/src/a.ts — so if the file tools answered with
|
|
124
|
+
* `src/a.ts` the model would be holding two vocabularies for one tree and
|
|
125
|
+
* would have to translate between them to feed one tool's output to the
|
|
126
|
+
* other. With a mount configured every path in and every path out is the
|
|
127
|
+
* mounted one, and copying a path from a command's output into read_file
|
|
128
|
+
* is exact. Without one, everything stays relative as before.
|
|
129
|
+
*/
|
|
130
|
+
show(path) {
|
|
131
|
+
const rel = this.rel(path);
|
|
132
|
+
if (this.mount === undefined) {
|
|
133
|
+
return rel;
|
|
134
|
+
}
|
|
135
|
+
return rel === '.' ? this.mount : `${this.mount}/${rel}`;
|
|
136
|
+
}
|
|
137
|
+
mutable() {
|
|
138
|
+
if (this.readOnly) {
|
|
139
|
+
throw new Error('the workspace is read-only for this run');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Formats that are decided by name alone. Everything absent from here is
|
|
145
|
+
* sniffed, so an unknown extension still lands on text or binary correctly.
|
|
146
|
+
* `.svg` is deliberately *not* an image: it is editable text, and calling it an
|
|
147
|
+
* image would tell the model not to open it.
|
|
148
|
+
*/
|
|
149
|
+
const BY_EXTENSION = {
|
|
150
|
+
'.png': 'image',
|
|
151
|
+
'.jpg': 'image',
|
|
152
|
+
'.jpeg': 'image',
|
|
153
|
+
'.gif': 'image',
|
|
154
|
+
'.webp': 'image',
|
|
155
|
+
'.avif': 'image',
|
|
156
|
+
'.bmp': 'image',
|
|
157
|
+
'.ico': 'image',
|
|
158
|
+
'.tif': 'image',
|
|
159
|
+
'.tiff': 'image',
|
|
160
|
+
'.heic': 'image',
|
|
161
|
+
'.mp3': 'audio',
|
|
162
|
+
'.wav': 'audio',
|
|
163
|
+
'.flac': 'audio',
|
|
164
|
+
'.ogg': 'audio',
|
|
165
|
+
'.m4a': 'audio',
|
|
166
|
+
'.aac': 'audio',
|
|
167
|
+
'.mp4': 'video',
|
|
168
|
+
'.mov': 'video',
|
|
169
|
+
'.avi': 'video',
|
|
170
|
+
'.mkv': 'video',
|
|
171
|
+
'.webm': 'video',
|
|
172
|
+
'.pdf': 'pdf',
|
|
173
|
+
'.zip': 'archive',
|
|
174
|
+
'.tar': 'archive',
|
|
175
|
+
'.gz': 'archive',
|
|
176
|
+
'.tgz': 'archive',
|
|
177
|
+
'.bz2': 'archive',
|
|
178
|
+
'.xz': 'archive',
|
|
179
|
+
'.7z': 'archive',
|
|
180
|
+
'.rar': 'archive',
|
|
181
|
+
'.jar': 'archive',
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* A NUL byte is decisive; a replacement character means the bytes are not
|
|
185
|
+
* UTF-8. When the buffer is a prefix its tail may be half of a multi-byte
|
|
186
|
+
* character, so the last three bytes are dropped before decoding.
|
|
187
|
+
*/
|
|
188
|
+
function looksBinary(buf, partial) {
|
|
189
|
+
const view = partial ? buf.subarray(0, Math.max(0, buf.length - 3)) : buf;
|
|
190
|
+
if (view.includes(0)) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
return view.toString('utf8').includes('\uFFFD');
|
|
194
|
+
}
|
|
195
|
+
function countLines(buf) {
|
|
196
|
+
if (buf.length === 0) {
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
let lines = 0;
|
|
200
|
+
for (const byte of buf) {
|
|
201
|
+
if (byte === 0x0a) {
|
|
202
|
+
lines++;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return buf[buf.length - 1] === 0x0a ? lines : lines + 1;
|
|
206
|
+
}
|
|
207
|
+
/** Same arithmetic as `countLines`, for content already decoded. */
|
|
208
|
+
function lineCount(text) {
|
|
209
|
+
if (text.length === 0) {
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
let lines = 1;
|
|
213
|
+
for (let i = 0; i < text.length; i++) {
|
|
214
|
+
if (text.charCodeAt(i) === 0x0a) {
|
|
215
|
+
lines++;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return text.endsWith('\n') ? lines - 1 : lines;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* `scan` is the caller's permission to read the file through — a listing hands
|
|
222
|
+
* it out from a budget so that one `list_dir` of a build output directory does
|
|
223
|
+
* not read a gigabyte to report line counts nobody asked for.
|
|
224
|
+
*/
|
|
225
|
+
async function inspectFile(at, bytes, scan) {
|
|
226
|
+
const known = BY_EXTENSION[extname(at).toLowerCase()];
|
|
227
|
+
if (known) {
|
|
228
|
+
return { format: known };
|
|
229
|
+
}
|
|
230
|
+
if (bytes === 0) {
|
|
231
|
+
return { format: 'text', lines: 0 };
|
|
232
|
+
}
|
|
233
|
+
if (scan && bytes <= MAX_SCAN) {
|
|
234
|
+
const buf = await readFile(at);
|
|
235
|
+
return looksBinary(buf, false)
|
|
236
|
+
? { format: 'binary' }
|
|
237
|
+
: { format: 'text', lines: countLines(buf) };
|
|
238
|
+
}
|
|
239
|
+
const handle = await open(at, 'r');
|
|
240
|
+
try {
|
|
241
|
+
const buf = Buffer.alloc(Math.min(bytes, SNIFF_BYTES));
|
|
242
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
|
|
243
|
+
return {
|
|
244
|
+
format: looksBinary(buf.subarray(0, bytesRead), bytesRead < bytes) ? 'binary' : 'text',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
await handle.close();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Reads a file as text, taking only the head of one too large to hold. */
|
|
252
|
+
async function readText(at, bytes) {
|
|
253
|
+
if (bytes <= MAX_OPEN) {
|
|
254
|
+
return { body: await readFile(at, 'utf8'), whole: true };
|
|
255
|
+
}
|
|
256
|
+
const handle = await open(at, 'r');
|
|
257
|
+
try {
|
|
258
|
+
const buf = Buffer.alloc(MAX_OPEN);
|
|
259
|
+
const { bytesRead } = await handle.read(buf, 0, MAX_OPEN, 0);
|
|
260
|
+
return { body: buf.subarray(0, bytesRead).toString('utf8'), whole: false };
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
await handle.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Splits into lines without inventing a trailing empty one. */
|
|
267
|
+
function toLines(body) {
|
|
268
|
+
const lines = body.split('\n');
|
|
269
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
270
|
+
lines.pop();
|
|
271
|
+
}
|
|
272
|
+
return lines;
|
|
273
|
+
}
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// The patch format
|
|
276
|
+
//
|
|
277
|
+
// A patch, not a rewrite, and not a set of line numbers either. Line numbers
|
|
278
|
+
// are the thing a model gets wrong: they drift the moment an earlier hunk
|
|
279
|
+
// changes the length of the file, and a wrong count in a `@@ -12,7 +12,9 @@`
|
|
280
|
+
// header either corrupts the file or forces the applier to guess. So the format
|
|
281
|
+
// below carries no offsets at all — a change is located by the unchanged text
|
|
282
|
+
// around it, and `@@ <heading>` narrows the search to the right block when the
|
|
283
|
+
// same lines occur more than once.
|
|
284
|
+
//
|
|
285
|
+
// *** Begin Patch
|
|
286
|
+
// *** Update File: src/server.ts
|
|
287
|
+
// @@ class Server
|
|
288
|
+
// start() {
|
|
289
|
+
// - this.port = 80;
|
|
290
|
+
// + this.port = 8080;
|
|
291
|
+
// }
|
|
292
|
+
// *** End Patch
|
|
293
|
+
//
|
|
294
|
+
// Whole files are added, deleted and moved by the same document, which is the
|
|
295
|
+
// real reason to have a patch at all rather than a per-file editor: a rename
|
|
296
|
+
// plus the edits that follow from it either both land or neither does.
|
|
297
|
+
//
|
|
298
|
+
// The grammar is the one Codex's `apply_patch` uses (sometimes called V4A), on
|
|
299
|
+
// purpose: frontier models have seen a great deal of it and emit it well. That
|
|
300
|
+
// lineage is deliberately *not* mentioned in the tool description — the sentinel
|
|
301
|
+
// lines and the worked example are what a model actually keys on, whereas naming
|
|
302
|
+
// a vendor in a prompt sent to three of them buys nothing and invites the model
|
|
303
|
+
// to assume behaviour of a reference implementation this parser does not share.
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
const BEGIN = '*** Begin Patch';
|
|
306
|
+
const END = '*** End Patch';
|
|
307
|
+
const ADD = '*** Add File: ';
|
|
308
|
+
const DELETE = '*** Delete File: ';
|
|
309
|
+
const UPDATE = '*** Update File: ';
|
|
310
|
+
const MOVE = '*** Move to: ';
|
|
311
|
+
const EOF_MARKER = '*** End of File';
|
|
312
|
+
/** A failure the model can act on: reported as a tool error, never thrown out. */
|
|
313
|
+
class PatchError extends Error {
|
|
314
|
+
}
|
|
315
|
+
/** Only a bare `*** ` line can open a section; body lines are always prefixed. */
|
|
316
|
+
function isSection(line) {
|
|
317
|
+
return (line === END ||
|
|
318
|
+
line.startsWith(ADD) ||
|
|
319
|
+
line.startsWith(DELETE) ||
|
|
320
|
+
line.startsWith(UPDATE) ||
|
|
321
|
+
line === BEGIN);
|
|
322
|
+
}
|
|
323
|
+
function parsePatch(text) {
|
|
324
|
+
// A stray CR would otherwise end up inside every added line.
|
|
325
|
+
const lines = text.split('\n').map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
|
|
326
|
+
let i = 0;
|
|
327
|
+
while (i < lines.length && lines[i].trim() === '') {
|
|
328
|
+
i++;
|
|
329
|
+
}
|
|
330
|
+
if (i >= lines.length || lines[i].trim() !== BEGIN) {
|
|
331
|
+
throw new PatchError(`the patch must start with '${BEGIN}'`);
|
|
332
|
+
}
|
|
333
|
+
i++;
|
|
334
|
+
const ops = [];
|
|
335
|
+
// The closing sentinel is not decoration. A blank line inside a chunk is
|
|
336
|
+
// meaningful context, so without something to end the last chunk on, the
|
|
337
|
+
// newline a model leaves at the end of the string becomes a blank line the
|
|
338
|
+
// file has to contain — and the failure surfaces as a baffling "context not
|
|
339
|
+
// found" instead of the missing marker it is.
|
|
340
|
+
let closed = false;
|
|
341
|
+
while (i < lines.length) {
|
|
342
|
+
const line = lines[i];
|
|
343
|
+
if (line.trim() === END) {
|
|
344
|
+
closed = true;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
if (line.trim() === '') {
|
|
348
|
+
i++;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (line.startsWith(ADD)) {
|
|
352
|
+
const at = i + 1;
|
|
353
|
+
const path = line.slice(ADD.length).trim();
|
|
354
|
+
i++;
|
|
355
|
+
const body = [];
|
|
356
|
+
while (i < lines.length && !isSection(lines[i])) {
|
|
357
|
+
if (!lines[i].startsWith('+')) {
|
|
358
|
+
throw new PatchError(`line ${i + 1}: every line of an added file must start with '+'`);
|
|
359
|
+
}
|
|
360
|
+
body.push(lines[i].slice(1));
|
|
361
|
+
i++;
|
|
362
|
+
}
|
|
363
|
+
ops.push({ kind: 'add', path, lines: body, at });
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (line.startsWith(DELETE)) {
|
|
367
|
+
ops.push({ kind: 'delete', path: line.slice(DELETE.length).trim(), at: i + 1 });
|
|
368
|
+
i++;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (line.startsWith(UPDATE)) {
|
|
372
|
+
const at = i + 1;
|
|
373
|
+
const path = line.slice(UPDATE.length).trim();
|
|
374
|
+
i++;
|
|
375
|
+
let moveTo;
|
|
376
|
+
if (i < lines.length && lines[i].startsWith(MOVE)) {
|
|
377
|
+
moveTo = lines[i].slice(MOVE.length).trim();
|
|
378
|
+
i++;
|
|
379
|
+
}
|
|
380
|
+
const chunks = [];
|
|
381
|
+
let chunk;
|
|
382
|
+
const started = (c) => c.before.length > 0 || c.after.length > 0;
|
|
383
|
+
const open = () => (chunk ??= { headings: [], before: [], after: [], eof: false, at: i + 1 });
|
|
384
|
+
while (i < lines.length && !isSection(lines[i])) {
|
|
385
|
+
const body = lines[i];
|
|
386
|
+
if (body.startsWith('@@')) {
|
|
387
|
+
if (chunk && started(chunk)) {
|
|
388
|
+
chunks.push(chunk);
|
|
389
|
+
chunk = undefined;
|
|
390
|
+
}
|
|
391
|
+
const heading = body.slice(2).trim();
|
|
392
|
+
if (heading) {
|
|
393
|
+
open().headings.push(heading);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
open();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else if (body === EOF_MARKER) {
|
|
400
|
+
open().eof = true;
|
|
401
|
+
}
|
|
402
|
+
else if (body === '') {
|
|
403
|
+
// A blank context line: models drop the leading space.
|
|
404
|
+
open().before.push('');
|
|
405
|
+
open().after.push('');
|
|
406
|
+
}
|
|
407
|
+
else if (body.startsWith('+')) {
|
|
408
|
+
open().after.push(body.slice(1));
|
|
409
|
+
}
|
|
410
|
+
else if (body.startsWith('-')) {
|
|
411
|
+
open().before.push(body.slice(1));
|
|
412
|
+
}
|
|
413
|
+
else if (body.startsWith(' ')) {
|
|
414
|
+
open().before.push(body.slice(1));
|
|
415
|
+
open().after.push(body.slice(1));
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
throw new PatchError(`line ${i + 1}: expected a line starting with ' ', '+' or '-': ${body}`);
|
|
419
|
+
}
|
|
420
|
+
i++;
|
|
421
|
+
}
|
|
422
|
+
if (chunk && started(chunk)) {
|
|
423
|
+
chunks.push(chunk);
|
|
424
|
+
}
|
|
425
|
+
if (chunks.length === 0) {
|
|
426
|
+
throw new PatchError(`${path}: '${UPDATE.trim()}' with nothing to change`);
|
|
427
|
+
}
|
|
428
|
+
ops.push({ kind: 'update', path, moveTo, chunks, at });
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
throw new PatchError(`line ${i + 1}: unexpected line outside a file section: ${line}`);
|
|
432
|
+
}
|
|
433
|
+
if (!closed) {
|
|
434
|
+
throw new PatchError(`the patch must end with '${END}'`);
|
|
435
|
+
}
|
|
436
|
+
if (ops.length === 0) {
|
|
437
|
+
throw new PatchError('the patch changes nothing');
|
|
438
|
+
}
|
|
439
|
+
return ops;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Finds `want` as a run of lines at or after `from`. Exact first; then ignoring
|
|
443
|
+
* trailing whitespace; then ignoring indentation entirely. The looser passes
|
|
444
|
+
* exist because a model reproducing context by hand gets whitespace wrong far
|
|
445
|
+
* more often than it gets the code wrong — but each is a whole separate sweep,
|
|
446
|
+
* so an exact match anywhere always beats a sloppy one earlier in the file.
|
|
447
|
+
*/
|
|
448
|
+
function locate(lines, want, from) {
|
|
449
|
+
if (want.length === 0) {
|
|
450
|
+
return { index: from, fuzz: 0 };
|
|
451
|
+
}
|
|
452
|
+
const passes = [
|
|
453
|
+
(a, b) => a === b,
|
|
454
|
+
(a, b) => a.trimEnd() === b.trimEnd(),
|
|
455
|
+
(a, b) => a.trim() === b.trim(),
|
|
456
|
+
];
|
|
457
|
+
for (const [fuzz, same] of passes.entries()) {
|
|
458
|
+
for (let start = from; start + want.length <= lines.length; start++) {
|
|
459
|
+
let ok = true;
|
|
460
|
+
for (let k = 0; k < want.length; k++) {
|
|
461
|
+
if (!same(lines[start + k], want[k])) {
|
|
462
|
+
ok = false;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (ok) {
|
|
467
|
+
return { index: start, fuzz };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return { index: -1, fuzz: 0 };
|
|
472
|
+
}
|
|
473
|
+
function findHeading(lines, heading, from) {
|
|
474
|
+
for (let i = from; i < lines.length; i++) {
|
|
475
|
+
if (lines[i] === heading || lines[i].trim() === heading.trim()) {
|
|
476
|
+
return i;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return -1;
|
|
480
|
+
}
|
|
481
|
+
/** Applies one file's chunks to its lines. Throws `PatchError` on a miss. */
|
|
482
|
+
function patchLines(lines, chunks, file) {
|
|
483
|
+
const out = lines.slice();
|
|
484
|
+
let cursor = 0;
|
|
485
|
+
let fuzz = 0;
|
|
486
|
+
for (const chunk of chunks) {
|
|
487
|
+
for (const heading of chunk.headings) {
|
|
488
|
+
const at = findHeading(out, heading, cursor);
|
|
489
|
+
if (at < 0) {
|
|
490
|
+
throw new PatchError(`${file}: no line matching '@@ ${heading}' after the previous chunk`);
|
|
491
|
+
}
|
|
492
|
+
cursor = at + 1;
|
|
493
|
+
}
|
|
494
|
+
// An end-of-file chunk is tried against the tail first; failing that it
|
|
495
|
+
// is an ordinary search, because the marker is often optimistic.
|
|
496
|
+
let found = { index: -1, fuzz: 0 };
|
|
497
|
+
if (chunk.eof) {
|
|
498
|
+
const tail = out.length - chunk.before.length;
|
|
499
|
+
if (tail >= cursor) {
|
|
500
|
+
const hit = locate(out.slice(tail), chunk.before, 0);
|
|
501
|
+
if (hit.index === 0) {
|
|
502
|
+
found = { index: tail, fuzz: hit.fuzz };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else if (chunk.before.length === 0) {
|
|
506
|
+
found = { index: out.length, fuzz: 0 };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (found.index < 0) {
|
|
510
|
+
found = locate(out, chunk.before, cursor);
|
|
511
|
+
}
|
|
512
|
+
if (found.index < 0) {
|
|
513
|
+
const first = chunk.before[0] ?? '';
|
|
514
|
+
throw new PatchError(`${file}: the context of the chunk at patch line ${chunk.at} is not in the file` +
|
|
515
|
+
(first ? ` — looked for: ${first.trim()}` : ''));
|
|
516
|
+
}
|
|
517
|
+
fuzz += found.fuzz > 0 ? 1 : 0;
|
|
518
|
+
out.splice(found.index, chunk.before.length, ...chunk.after);
|
|
519
|
+
cursor = found.index + chunk.after.length;
|
|
520
|
+
}
|
|
521
|
+
return { lines: out, fuzz };
|
|
522
|
+
}
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// The tools
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
/** The family name config selects the whole set by: `tools: [workspace:*]`. */
|
|
527
|
+
const GROUP = 'workspace';
|
|
528
|
+
export function workspaceTools(opts) {
|
|
529
|
+
const ws = new Workspace(opts);
|
|
530
|
+
// What the model is told about naming a file. The long form goes in
|
|
531
|
+
// `read_file`, which is the first of these any model reaches for; the short
|
|
532
|
+
// one is repeated on every `path` argument, which is where the value is
|
|
533
|
+
// actually written and where a wrong guess costs a turn.
|
|
534
|
+
const scope = ws.mount
|
|
535
|
+
? `The workspace is mounted at ${ws.mount}, which is the name commands running in the ` +
|
|
536
|
+
`sandbox print and the name these tools answer with, so a path from a command's ` +
|
|
537
|
+
`output can be used here unchanged. A path relative to the workspace root names the ` +
|
|
538
|
+
`same file: ${ws.mount}/src/a.ts and src/a.ts are one file. That directory is the ` +
|
|
539
|
+
'whole of what can be reached: nothing outside it can be read or written.'
|
|
540
|
+
: 'Paths are relative to the workspace root, and that directory is the whole of what ' +
|
|
541
|
+
'can be reached: nothing outside it can be read or written.';
|
|
542
|
+
const inside = ws.mount
|
|
543
|
+
? `Path in the workspace: ${ws.mount}/src/a.ts, or src/a.ts relative to the root — the ` +
|
|
544
|
+
`same file either way. The root itself is "${ws.mount}", "/" or ".".`
|
|
545
|
+
: 'Path in the workspace, relative to its root: src/a.ts. The root itself is "." or "/".';
|
|
546
|
+
/**
|
|
547
|
+
* `.`, `/`, the mount and nothing at all are four spellings of the root, and
|
|
548
|
+
* a tool whose path is optional takes all four. A model that means "the top"
|
|
549
|
+
* writes whichever one it has in mind, and being told `path is required`
|
|
550
|
+
* because it sent "" rather than omitting the argument teaches it nothing.
|
|
551
|
+
*/
|
|
552
|
+
const root = (path) => ws.within(path?.trim() || '.');
|
|
553
|
+
const readFileTool = tool({
|
|
554
|
+
name: 'read_file',
|
|
555
|
+
group: GROUP,
|
|
556
|
+
description: 'Reads a UTF-8 text file from the workspace. ' +
|
|
557
|
+
scope +
|
|
558
|
+
' Give start_line/end_line (1-based, inclusive) to read a range; without them ' +
|
|
559
|
+
'the file is read from the top until the size cap, and `truncated` says whether ' +
|
|
560
|
+
'anything was left. Read before patching: apply_patch matches on the exact text.',
|
|
561
|
+
parameters: {
|
|
562
|
+
type: 'object',
|
|
563
|
+
properties: {
|
|
564
|
+
path: { type: 'string', description: inside },
|
|
565
|
+
start_line: { type: 'integer', description: 'First line to return, 1-based.' },
|
|
566
|
+
end_line: { type: 'integer', description: 'Last line to return, inclusive.' },
|
|
567
|
+
},
|
|
568
|
+
required: ['path'],
|
|
569
|
+
additionalProperties: false,
|
|
570
|
+
},
|
|
571
|
+
execute: async ({ path, start_line, end_line }) => {
|
|
572
|
+
const at = ws.within(path);
|
|
573
|
+
const info = await stat(at);
|
|
574
|
+
if (info.isDirectory()) {
|
|
575
|
+
return { error: `${ws.show(at)} is a directory`, hint: 'use list_dir' };
|
|
576
|
+
}
|
|
577
|
+
const shape = await inspectFile(at, info.size, false);
|
|
578
|
+
if (shape.format !== 'text') {
|
|
579
|
+
return {
|
|
580
|
+
error: `${ws.show(at)} is not a text file`,
|
|
581
|
+
format: shape.format,
|
|
582
|
+
bytes: info.size,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const { body, whole } = await readText(at, info.size);
|
|
586
|
+
const all = toLines(body);
|
|
587
|
+
const total = all.length;
|
|
588
|
+
const from = Math.max(1, Math.trunc(start_line ?? 1));
|
|
589
|
+
const to = Math.min(total, end_line === undefined ? total : Math.trunc(end_line));
|
|
590
|
+
if (from > total) {
|
|
591
|
+
return { path: ws.show(at), lines: total, error: `file has ${total} lines` };
|
|
592
|
+
}
|
|
593
|
+
// The cap is spent line by line, so a truncated read still ends on a
|
|
594
|
+
// line boundary and can be resumed from `end_line + 1`.
|
|
595
|
+
const taken = [];
|
|
596
|
+
let used = 0;
|
|
597
|
+
let cut = false;
|
|
598
|
+
for (const line of all.slice(from - 1, to)) {
|
|
599
|
+
if (taken.length > 0 && used + line.length + 1 > MAX_READ) {
|
|
600
|
+
cut = true;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
taken.push(line);
|
|
604
|
+
used += line.length + 1;
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
path: ws.show(at),
|
|
608
|
+
bytes: info.size,
|
|
609
|
+
lines: total,
|
|
610
|
+
start_line: from,
|
|
611
|
+
end_line: from + taken.length - 1,
|
|
612
|
+
truncated: cut || !whole || to < total || from > 1 ? true : undefined,
|
|
613
|
+
content: taken.join('\n'),
|
|
614
|
+
};
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
const listDir = tool({
|
|
618
|
+
name: 'list_dir',
|
|
619
|
+
group: GROUP,
|
|
620
|
+
description: 'Lists a directory in the workspace. Each entry reports its kind, and files also ' +
|
|
621
|
+
'report format (text, binary, image, audio, video, pdf, archive), size in bytes ' +
|
|
622
|
+
'and, for text files that are small enough to scan, their line count.',
|
|
623
|
+
parameters: {
|
|
624
|
+
type: 'object',
|
|
625
|
+
properties: {
|
|
626
|
+
path: {
|
|
627
|
+
type: 'string',
|
|
628
|
+
description: `${inside} Omit it to list the root.`,
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
required: [],
|
|
632
|
+
additionalProperties: false,
|
|
633
|
+
},
|
|
634
|
+
execute: async ({ path }) => {
|
|
635
|
+
const at = root(path);
|
|
636
|
+
const entries = await readdir(at, { withFileTypes: true });
|
|
637
|
+
const listed = [];
|
|
638
|
+
let budget = LIST_SCAN_BUDGET;
|
|
639
|
+
for (const entry of entries.slice(0, MAX_ENTRIES)) {
|
|
640
|
+
if (entry.isDirectory()) {
|
|
641
|
+
listed.push({ name: entry.name, kind: 'dir' });
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (entry.isSymbolicLink()) {
|
|
645
|
+
listed.push({ name: entry.name, kind: 'link' });
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (!entry.isFile()) {
|
|
649
|
+
listed.push({ name: entry.name, kind: 'other' });
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
// A file can vanish between the readdir and the stat; that is a
|
|
653
|
+
// race, not a failure of the listing.
|
|
654
|
+
try {
|
|
655
|
+
const info = await stat(join(at, entry.name));
|
|
656
|
+
const shape = await inspectFile(join(at, entry.name), info.size, info.size <= budget);
|
|
657
|
+
budget -= info.size;
|
|
658
|
+
listed.push({ name: entry.name, kind: 'file', bytes: info.size, ...shape });
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
listed.push({ name: entry.name, kind: 'file' });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
path: ws.show(at),
|
|
666
|
+
entries: listed,
|
|
667
|
+
truncated: entries.length > MAX_ENTRIES ? entries.length : undefined,
|
|
668
|
+
};
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
const writeFileTool = tool({
|
|
672
|
+
name: 'write_file',
|
|
673
|
+
group: GROUP,
|
|
674
|
+
description: 'Creates or overwrites a UTF-8 text file in the workspace, making parent ' +
|
|
675
|
+
'directories as needed. To change part of an existing file use apply_patch instead.',
|
|
676
|
+
parameters: {
|
|
677
|
+
type: 'object',
|
|
678
|
+
properties: {
|
|
679
|
+
path: { type: 'string', description: inside },
|
|
680
|
+
content: { type: 'string' },
|
|
681
|
+
},
|
|
682
|
+
required: ['path', 'content'],
|
|
683
|
+
additionalProperties: false,
|
|
684
|
+
},
|
|
685
|
+
execute: async ({ path, content }) => {
|
|
686
|
+
ws.mutable();
|
|
687
|
+
if (content.length > MAX_WRITE) {
|
|
688
|
+
return { error: `content exceeds ${MAX_WRITE} bytes` };
|
|
689
|
+
}
|
|
690
|
+
const at = ws.within(path);
|
|
691
|
+
await mkdir(dirname(at), { recursive: true });
|
|
692
|
+
await writeFile(at, content, 'utf8');
|
|
693
|
+
return {
|
|
694
|
+
path: ws.show(at),
|
|
695
|
+
bytes: Buffer.byteLength(content),
|
|
696
|
+
lines: lineCount(content),
|
|
697
|
+
written: true,
|
|
698
|
+
};
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
const applyPatch = tool({
|
|
702
|
+
name: 'apply_patch',
|
|
703
|
+
group: GROUP,
|
|
704
|
+
description: 'Applies a patch to the workspace. The patch names the text it changes instead of ' +
|
|
705
|
+
'line numbers, so read the file first and copy the surrounding lines exactly.\n\n' +
|
|
706
|
+
'*** Begin Patch\n' +
|
|
707
|
+
'*** Update File: src/server.ts\n' +
|
|
708
|
+
'@@ class Server\n' +
|
|
709
|
+
' start() {\n' +
|
|
710
|
+
'- this.port = 80;\n' +
|
|
711
|
+
'+ this.port = 8080;\n' +
|
|
712
|
+
' }\n' +
|
|
713
|
+
'*** End Patch\n\n' +
|
|
714
|
+
'Sections: "*** Update File: <path>", optionally followed by "*** Move to: <path>" ' +
|
|
715
|
+
'to rename it; "*** Add File: <path>" with every line of the new file prefixed by ' +
|
|
716
|
+
'"+"; "*** Delete File: <path>". Inside an update, a line starting with a space is ' +
|
|
717
|
+
'context that must already be there, "-" removes a line and "+" adds one. ' +
|
|
718
|
+
'"@@ <text>" names the enclosing block when the same lines occur more than once, ' +
|
|
719
|
+
'and "*** End of File" anchors a chunk to the end. Give at least three lines of ' +
|
|
720
|
+
'context around each change. One patch may touch several files, and the whole of ' +
|
|
721
|
+
'it is checked before anything is written: if any part fails, nothing changes.',
|
|
722
|
+
parameters: {
|
|
723
|
+
type: 'object',
|
|
724
|
+
properties: {
|
|
725
|
+
patch: {
|
|
726
|
+
type: 'string',
|
|
727
|
+
description: 'The patch, from *** Begin Patch to *** End Patch.',
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
required: ['patch'],
|
|
731
|
+
additionalProperties: false,
|
|
732
|
+
},
|
|
733
|
+
execute: async ({ patch }) => {
|
|
734
|
+
ws.mutable();
|
|
735
|
+
if (typeof patch !== 'string' || patch.trim() === '') {
|
|
736
|
+
return { error: 'patch is required' };
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
return await runPatch(ws, patch);
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
if (err instanceof PatchError) {
|
|
743
|
+
return {
|
|
744
|
+
error: err.message,
|
|
745
|
+
hint: 'nothing was written — read the file again and rebuild the patch',
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
throw err;
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
const movePath = tool({
|
|
753
|
+
name: 'move_file',
|
|
754
|
+
group: GROUP,
|
|
755
|
+
description: 'Moves or renames a file or directory inside the workspace, making the ' +
|
|
756
|
+
'destination parent directories as needed. Refuses to clobber an existing ' +
|
|
757
|
+
'destination unless overwrite is set.',
|
|
758
|
+
parameters: {
|
|
759
|
+
type: 'object',
|
|
760
|
+
properties: {
|
|
761
|
+
from: { type: 'string', description: `What to move. ${inside}` },
|
|
762
|
+
to: { type: 'string', description: `Where it should end up. ${inside}` },
|
|
763
|
+
overwrite: {
|
|
764
|
+
type: 'boolean',
|
|
765
|
+
description: 'Replace the destination if it exists.',
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
required: ['from', 'to'],
|
|
769
|
+
additionalProperties: false,
|
|
770
|
+
},
|
|
771
|
+
execute: async ({ from, to, overwrite }) => {
|
|
772
|
+
ws.mutable();
|
|
773
|
+
const source = ws.within(from);
|
|
774
|
+
const target = ws.within(to);
|
|
775
|
+
if (source === ws.root) {
|
|
776
|
+
return { error: 'refusing to move the workspace root' };
|
|
777
|
+
}
|
|
778
|
+
if (target === ws.root) {
|
|
779
|
+
return { error: 'refusing to overwrite the workspace root' };
|
|
780
|
+
}
|
|
781
|
+
if (source === target) {
|
|
782
|
+
return { error: 'the source and the destination are the same path' };
|
|
783
|
+
}
|
|
784
|
+
if (target.startsWith(source + sep)) {
|
|
785
|
+
return { error: `cannot move ${ws.show(source)} into itself` };
|
|
786
|
+
}
|
|
787
|
+
await stat(source); // ENOENT here is the honest error
|
|
788
|
+
if (await statOrNull(target)) {
|
|
789
|
+
if (!overwrite) {
|
|
790
|
+
return {
|
|
791
|
+
error: `${ws.show(target)} already exists`,
|
|
792
|
+
hint: 'pass overwrite to replace it',
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
// rename() will not replace a non-empty directory on its own.
|
|
796
|
+
await rm(target, { recursive: true, force: true });
|
|
797
|
+
}
|
|
798
|
+
await mkdir(dirname(target), { recursive: true });
|
|
799
|
+
await rename(source, target);
|
|
800
|
+
return { from: ws.show(source), to: ws.show(target), moved: true };
|
|
801
|
+
},
|
|
802
|
+
});
|
|
803
|
+
const deleteFile = tool({
|
|
804
|
+
name: 'delete_file',
|
|
805
|
+
group: GROUP,
|
|
806
|
+
description: 'Deletes a file from the workspace. A directory is only removed, with everything ' +
|
|
807
|
+
'in it, when recursive is set.',
|
|
808
|
+
parameters: {
|
|
809
|
+
type: 'object',
|
|
810
|
+
properties: {
|
|
811
|
+
path: { type: 'string', description: inside },
|
|
812
|
+
recursive: {
|
|
813
|
+
type: 'boolean',
|
|
814
|
+
description: 'Required to delete a directory and its contents.',
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
required: ['path'],
|
|
818
|
+
additionalProperties: false,
|
|
819
|
+
},
|
|
820
|
+
execute: async ({ path, recursive }) => {
|
|
821
|
+
ws.mutable();
|
|
822
|
+
const at = ws.within(path);
|
|
823
|
+
if (at === ws.root) {
|
|
824
|
+
return { error: 'refusing to delete the workspace root' };
|
|
825
|
+
}
|
|
826
|
+
const info = await stat(at);
|
|
827
|
+
if (info.isDirectory()) {
|
|
828
|
+
if (!recursive) {
|
|
829
|
+
return {
|
|
830
|
+
error: `${ws.show(at)} is a directory`,
|
|
831
|
+
hint: 'pass recursive to delete it and everything in it',
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
await rm(at, { recursive: true });
|
|
835
|
+
return { path: ws.show(at), deleted: true, directory: true };
|
|
836
|
+
}
|
|
837
|
+
await rm(at);
|
|
838
|
+
return { path: ws.show(at), deleted: true };
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
const findFiles = tool({
|
|
842
|
+
name: 'find_files',
|
|
843
|
+
group: GROUP,
|
|
844
|
+
description: 'Finds files in the workspace whose path contains the given substring. ' +
|
|
845
|
+
'Case-insensitive.',
|
|
846
|
+
parameters: {
|
|
847
|
+
type: 'object',
|
|
848
|
+
properties: {
|
|
849
|
+
pattern: { type: 'string', description: 'Substring to match against the path.' },
|
|
850
|
+
path: {
|
|
851
|
+
type: 'string',
|
|
852
|
+
description: `Directory to search under; omit it to search the whole ` +
|
|
853
|
+
`workspace. ${inside}`,
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
required: ['pattern'],
|
|
857
|
+
additionalProperties: false,
|
|
858
|
+
},
|
|
859
|
+
execute: async ({ pattern, path }) => {
|
|
860
|
+
const at = root(path);
|
|
861
|
+
const needle = pattern.toLowerCase();
|
|
862
|
+
const hits = [];
|
|
863
|
+
await walk(at, ws, (file) => {
|
|
864
|
+
// Matched on the path within the workspace, reported under the
|
|
865
|
+
// name the model uses: a mount prefix is on every candidate, so
|
|
866
|
+
// matching it would mean nothing.
|
|
867
|
+
if (ws.rel(file).toLowerCase().includes(needle)) {
|
|
868
|
+
hits.push(ws.show(file));
|
|
869
|
+
}
|
|
870
|
+
return hits.length < MAX_ENTRIES;
|
|
871
|
+
});
|
|
872
|
+
return { pattern, matches: hits, truncated: hits.length >= MAX_ENTRIES };
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
return opts.readOnly
|
|
876
|
+
? [readFileTool, listDir, findFiles]
|
|
877
|
+
: [readFileTool, listDir, findFiles, writeFileTool, applyPatch, movePath, deleteFile];
|
|
878
|
+
}
|
|
879
|
+
async function runPatch(ws, patch) {
|
|
880
|
+
const ops = parsePatch(patch);
|
|
881
|
+
const steps = [];
|
|
882
|
+
const touched = [];
|
|
883
|
+
const seen = new Set();
|
|
884
|
+
let fuzz = 0;
|
|
885
|
+
for (const op of ops) {
|
|
886
|
+
const at = ws.within(op.path);
|
|
887
|
+
if (at === ws.root) {
|
|
888
|
+
throw new PatchError(`${op.path} is the workspace root`);
|
|
889
|
+
}
|
|
890
|
+
if (seen.has(at)) {
|
|
891
|
+
throw new PatchError(`${ws.show(at)} appears twice in the patch`);
|
|
892
|
+
}
|
|
893
|
+
seen.add(at);
|
|
894
|
+
if (op.kind === 'delete') {
|
|
895
|
+
const info = await statOrNull(at);
|
|
896
|
+
if (!info) {
|
|
897
|
+
throw new PatchError(`${ws.show(at)} does not exist`);
|
|
898
|
+
}
|
|
899
|
+
if (info.isDirectory()) {
|
|
900
|
+
throw new PatchError(`${ws.show(at)} is a directory`);
|
|
901
|
+
}
|
|
902
|
+
steps.push({ kind: 'delete', at });
|
|
903
|
+
touched.push({ path: ws.show(at), action: 'deleted' });
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
if (op.kind === 'add') {
|
|
907
|
+
if (await statOrNull(at)) {
|
|
908
|
+
throw new PatchError(`${ws.show(at)} already exists — update it instead of adding it`);
|
|
909
|
+
}
|
|
910
|
+
const content = op.lines.length > 0 ? op.lines.join('\n') + '\n' : '';
|
|
911
|
+
if (Buffer.byteLength(content) > MAX_WRITE) {
|
|
912
|
+
throw new PatchError(`${ws.show(at)} would exceed ${MAX_WRITE} bytes`);
|
|
913
|
+
}
|
|
914
|
+
steps.push({ kind: 'write', at, content });
|
|
915
|
+
touched.push({
|
|
916
|
+
path: ws.show(at),
|
|
917
|
+
action: 'added',
|
|
918
|
+
bytes: Buffer.byteLength(content),
|
|
919
|
+
lines: op.lines.length,
|
|
920
|
+
});
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
const info = await statOrNull(at);
|
|
924
|
+
if (!info) {
|
|
925
|
+
throw new PatchError(`${ws.show(at)} does not exist`);
|
|
926
|
+
}
|
|
927
|
+
if (info.isDirectory()) {
|
|
928
|
+
throw new PatchError(`${ws.show(at)} is a directory`);
|
|
929
|
+
}
|
|
930
|
+
if (info.size > MAX_WRITE) {
|
|
931
|
+
throw new PatchError(`${ws.show(at)} is too large to patch`);
|
|
932
|
+
}
|
|
933
|
+
const shape = await inspectFile(at, info.size, false);
|
|
934
|
+
if (shape.format !== 'text') {
|
|
935
|
+
throw new PatchError(`${ws.show(at)} is a ${shape.format} file, not text`);
|
|
936
|
+
}
|
|
937
|
+
const body = await readFile(at, 'utf8');
|
|
938
|
+
const patched = patchLines(toLines(body), op.chunks, ws.show(at));
|
|
939
|
+
fuzz += patched.fuzz;
|
|
940
|
+
// Whether the file ended in a newline is a property of the file, not of
|
|
941
|
+
// the patch, so it survives the edit.
|
|
942
|
+
const trailing = body === '' || body.endsWith('\n');
|
|
943
|
+
const content = patched.lines.length === 0 ? '' : patched.lines.join('\n') + (trailing ? '\n' : '');
|
|
944
|
+
if (Buffer.byteLength(content) > MAX_WRITE) {
|
|
945
|
+
throw new PatchError(`${ws.show(at)} would exceed ${MAX_WRITE} bytes`);
|
|
946
|
+
}
|
|
947
|
+
steps.push({ kind: 'write', at, content });
|
|
948
|
+
const entry = {
|
|
949
|
+
path: ws.show(at),
|
|
950
|
+
action: 'updated',
|
|
951
|
+
chunks: op.chunks.length,
|
|
952
|
+
bytes: Buffer.byteLength(content),
|
|
953
|
+
lines: patched.lines.length,
|
|
954
|
+
};
|
|
955
|
+
if (op.moveTo) {
|
|
956
|
+
const to = ws.within(op.moveTo);
|
|
957
|
+
if (to === ws.root) {
|
|
958
|
+
throw new PatchError('cannot move a file onto the workspace root');
|
|
959
|
+
}
|
|
960
|
+
if (to !== at) {
|
|
961
|
+
if (await statOrNull(to)) {
|
|
962
|
+
throw new PatchError(`${ws.show(to)} already exists`);
|
|
963
|
+
}
|
|
964
|
+
steps.push({ kind: 'move', from: at, to });
|
|
965
|
+
entry.action = 'moved';
|
|
966
|
+
entry.to = ws.show(to);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
touched.push(entry);
|
|
970
|
+
}
|
|
971
|
+
for (const step of steps) {
|
|
972
|
+
switch (step.kind) {
|
|
973
|
+
case 'write':
|
|
974
|
+
await mkdir(dirname(step.at), { recursive: true });
|
|
975
|
+
await writeFile(step.at, step.content, 'utf8');
|
|
976
|
+
break;
|
|
977
|
+
case 'move':
|
|
978
|
+
await mkdir(dirname(step.to), { recursive: true });
|
|
979
|
+
await rename(step.from, step.to);
|
|
980
|
+
break;
|
|
981
|
+
case 'delete':
|
|
982
|
+
await rm(step.at);
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
applied: touched.length,
|
|
988
|
+
files: touched,
|
|
989
|
+
// Worth reporting: a chunk that only matched once whitespace was
|
|
990
|
+
// ignored landed where the tool thinks it should, not where the patch
|
|
991
|
+
// said, and that is a thing to check.
|
|
992
|
+
fuzzy: fuzz > 0 ? fuzz : undefined,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
async function statOrNull(at) {
|
|
996
|
+
try {
|
|
997
|
+
return await stat(at);
|
|
998
|
+
}
|
|
999
|
+
catch {
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/** Depth-first, skipping the noise nobody means to search. */
|
|
1004
|
+
const SKIP = new Set(['.git', 'node_modules', '.data', 'dist', '.venv', '__pycache__']);
|
|
1005
|
+
async function walk(dir, ws, visit) {
|
|
1006
|
+
let entries;
|
|
1007
|
+
try {
|
|
1008
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
for (const entry of entries) {
|
|
1014
|
+
if (SKIP.has(entry.name)) {
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
const child = join(dir, entry.name);
|
|
1018
|
+
// Symlinks are not followed: a link out of the workspace would
|
|
1019
|
+
// otherwise let a listing report paths the tools cannot open anyway.
|
|
1020
|
+
if (entry.isSymbolicLink()) {
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if (entry.isDirectory()) {
|
|
1024
|
+
if (!(await walk(child, ws, visit))) {
|
|
1025
|
+
return false;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
else if (entry.isFile() && !visit(child)) {
|
|
1029
|
+
return false;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return true;
|
|
1033
|
+
}
|
|
1034
|
+
//# sourceMappingURL=workspace.js.map
|