@zenera/neo 1.1.0 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/dist/models/factory.d.ts +2 -1
- package/dist/models/factory.js +10 -2
- package/dist/project/config.d.ts +29 -0
- package/dist/project/config.js +59 -12
- package/dist/project/index.d.ts +2 -2
- package/dist/project/index.js +1 -1
- package/dist/project/load.d.ts +40 -0
- package/dist/project/load.js +67 -10
- package/dist/skill-providers/file.d.ts +20 -5
- package/dist/skill-providers/file.js +24 -27
- package/dist/skills.d.ts +8 -1
- package/dist/skills.js +11 -1
- package/dist/tools/sandbox.d.ts +11 -0
- package/dist/tools/sandbox.js +15 -0
- package/dist/tools/workspace.d.ts +29 -3
- package/dist/tools/workspace.js +253 -73
- package/dist/types.d.ts +4 -3
- package/dist/types.js +21 -3
- package/package.json +1 -1
package/dist/skills.js
CHANGED
|
@@ -6,7 +6,17 @@ export function skillContentHash(s) {
|
|
|
6
6
|
}
|
|
7
7
|
export function renderSkills(skills) {
|
|
8
8
|
return skills
|
|
9
|
-
.map((s) =>
|
|
9
|
+
.map((s) => {
|
|
10
|
+
const head = `## Skill: ${s.name}${s.version ? ` (${s.version})` : ''}`;
|
|
11
|
+
// One line, and only when the files are reachable. Without it the
|
|
12
|
+
// model has the instructions and no way to act on the half of them
|
|
13
|
+
// that names a file.
|
|
14
|
+
const where = s.path
|
|
15
|
+
? `\nThis skill's files are at ${s.path}, read-only. Paths written in it are ` +
|
|
16
|
+
'relative to that directory.\n'
|
|
17
|
+
: '';
|
|
18
|
+
return `${head}${where}\n${s.content}`;
|
|
19
|
+
})
|
|
10
20
|
.join('\n\n');
|
|
11
21
|
}
|
|
12
22
|
export function renderSkillIndex(summaries, max) {
|
package/dist/tools/sandbox.d.ts
CHANGED
|
@@ -29,6 +29,17 @@ export interface SandboxSpec {
|
|
|
29
29
|
persist?: boolean;
|
|
30
30
|
/** environment inside the container — resolved values, never a passthrough */
|
|
31
31
|
env?: Record<string, string>;
|
|
32
|
+
/**
|
|
33
|
+
* Variables forwarded from this process by *name*, for credentials.
|
|
34
|
+
*
|
|
35
|
+
* Separate from `env` for two reasons, both about the value never being
|
|
36
|
+
* written down. `--env NAME` without an `=` tells podman to read the value
|
|
37
|
+
* out of its own environment, so the secret never appears on a command
|
|
38
|
+
* line, in `ps`, or in `podman inspect`. And the container's identity is
|
|
39
|
+
* computed from the names alone, so rotating a key does not abandon a
|
|
40
|
+
* persisted container's filesystem.
|
|
41
|
+
*/
|
|
42
|
+
secrets?: readonly string[];
|
|
32
43
|
}
|
|
33
44
|
export type SandboxNetwork = 'bridge' | 'none' | 'host';
|
|
34
45
|
export interface SandboxMount {
|
package/dist/tools/sandbox.js
CHANGED
|
@@ -212,6 +212,14 @@ export class Sandbox {
|
|
|
212
212
|
for (const [k, v] of Object.entries(s.env)) {
|
|
213
213
|
args.push('--env', `${k}=${v}`);
|
|
214
214
|
}
|
|
215
|
+
// No `=`, and so no value: podman reads each one from its own
|
|
216
|
+
// environment. A key passed this way is not in the argv of a process
|
|
217
|
+
// any user on the machine can list.
|
|
218
|
+
for (const name of s.secrets) {
|
|
219
|
+
if (process.env[name]) {
|
|
220
|
+
args.push('--env', name);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
215
223
|
args.push(s.image, 'sleep', 'infinity');
|
|
216
224
|
return args;
|
|
217
225
|
}
|
|
@@ -422,6 +430,7 @@ function resolveSpec(opts) {
|
|
|
422
430
|
user: opts.user,
|
|
423
431
|
persist: opts.persist ?? false,
|
|
424
432
|
env: opts.env ?? {},
|
|
433
|
+
secrets: opts.secrets ?? [],
|
|
425
434
|
readOnly: opts.readOnly ?? false,
|
|
426
435
|
mounts: opts.mounts ?? [],
|
|
427
436
|
engine: opts.engine ?? 'podman',
|
|
@@ -431,6 +440,11 @@ function resolveSpec(opts) {
|
|
|
431
440
|
* The container's name is a function of its configuration. Change the image
|
|
432
441
|
* and a new container appears rather than an old one quietly persisting with
|
|
433
442
|
* the wrong rootfs — which is the failure mode `persist` would otherwise have.
|
|
443
|
+
*
|
|
444
|
+
* Secrets contribute their names and not their values: what a container is
|
|
445
|
+
* depends on which credentials reach it, not on what they happen to say today,
|
|
446
|
+
* and hashing the values would throw away a persisted container's filesystem
|
|
447
|
+
* every time a key was rotated.
|
|
434
448
|
*/
|
|
435
449
|
function containerName(spec) {
|
|
436
450
|
const digest = createHash('sha256')
|
|
@@ -445,6 +459,7 @@ function containerName(spec) {
|
|
|
445
459
|
spec.readOnly,
|
|
446
460
|
spec.mounts,
|
|
447
461
|
Object.entries(spec.env).sort(),
|
|
462
|
+
[...spec.secrets].sort(),
|
|
448
463
|
]))
|
|
449
464
|
.digest('hex')
|
|
450
465
|
.slice(0, 10);
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { type AnyTool } from '../types.ts';
|
|
2
|
+
/** Where a project's `assets/` directory appears in the agent's namespace. */
|
|
3
|
+
export declare const ASSETS_MOUNT = "/assets";
|
|
4
|
+
/** ...and where its skill catalog does, so a skill's scripts can be run. */
|
|
5
|
+
export declare const SKILLS_MOUNT = "/skills";
|
|
6
|
+
/**
|
|
7
|
+
* A second tree the agent can reach, under a name of its own. Structurally the
|
|
8
|
+
* same as a `SandboxMount` on purpose: one directory is one mount, and the host
|
|
9
|
+
* hands the identical array to the file tools and to the container so that both
|
|
10
|
+
* call it by the same name.
|
|
11
|
+
*/
|
|
12
|
+
export interface WorkspaceMount {
|
|
13
|
+
/** absolute host path; must already exist */
|
|
14
|
+
host: string;
|
|
15
|
+
/** absolute path the agent names it by, e.g. `/assets` */
|
|
16
|
+
at: string;
|
|
17
|
+
/** defaults to true — an extra tree is reference material unless said otherwise */
|
|
18
|
+
readOnly?: boolean;
|
|
19
|
+
}
|
|
2
20
|
export interface WorkspaceOptions {
|
|
3
21
|
root: string;
|
|
4
22
|
/** refuse every mutating tool; `zen run --read-only` */
|
|
@@ -11,22 +29,28 @@ export interface WorkspaceOptions {
|
|
|
11
29
|
* it and both spellings reach the same file.
|
|
12
30
|
*/
|
|
13
31
|
mount?: string;
|
|
32
|
+
/** further trees, each under its own name; read-only unless one says otherwise */
|
|
33
|
+
mounts?: readonly WorkspaceMount[];
|
|
14
34
|
}
|
|
15
35
|
export declare class Workspace {
|
|
16
36
|
#private;
|
|
17
37
|
readonly root: string;
|
|
18
38
|
readonly readOnly: boolean;
|
|
19
39
|
readonly mount: string | undefined;
|
|
40
|
+
/** the extra trees, resolved — the primary root is not among them */
|
|
41
|
+
readonly mounts: readonly Required<WorkspaceMount>[];
|
|
20
42
|
constructor(opts: WorkspaceOptions);
|
|
21
43
|
/**
|
|
22
|
-
* The one gate. Returns an absolute path inside the
|
|
44
|
+
* The one gate. Returns an absolute path inside one of the roots, or throws.
|
|
23
45
|
*
|
|
24
46
|
* A path that does not exist yet cannot be realpath'd, so the nearest
|
|
25
47
|
* existing ancestor is resolved instead and the remainder appended — which
|
|
26
48
|
* is exactly what a create needs and closes the same hole a create opens.
|
|
27
49
|
*/
|
|
28
|
-
within(input: string
|
|
29
|
-
|
|
50
|
+
within(input: string, opts?: {
|
|
51
|
+
write?: boolean;
|
|
52
|
+
}): string;
|
|
53
|
+
/** Where a path sits under its own root, in posix form. `.` is that root. */
|
|
30
54
|
rel(path: string): string;
|
|
31
55
|
/**
|
|
32
56
|
* How a path is written back to the model, and the reason `mount` is not
|
|
@@ -39,6 +63,8 @@ export declare class Workspace {
|
|
|
39
63
|
* is exact. Without one, everything stays relative as before.
|
|
40
64
|
*/
|
|
41
65
|
show(path: string): string;
|
|
66
|
+
/** Whether a resolved path *is* one of the roots. Nothing may move or delete one. */
|
|
67
|
+
isRoot(path: string): boolean;
|
|
42
68
|
mutable(): void;
|
|
43
69
|
}
|
|
44
70
|
export type FileFormat = 'text' | 'binary' | 'image' | 'audio' | 'video' | 'pdf' | 'archive';
|
package/dist/tools/workspace.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { realpathSync } from 'node:fs';
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { tool } from "../types.js";
|
|
@@ -28,68 +28,117 @@ const SNIFF_BYTES = 8 * 1024;
|
|
|
28
28
|
const MAX_SCAN = 1024 * 1024;
|
|
29
29
|
/** ...and one listing only spends this much on line counting in total. */
|
|
30
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';
|
|
31
35
|
export class Workspace {
|
|
32
36
|
root;
|
|
33
37
|
readOnly;
|
|
34
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;
|
|
35
47
|
constructor(opts) {
|
|
36
48
|
// Resolve the root's own symlinks once, so a workspace that *is* a
|
|
37
49
|
// symlink does not fail every containment check against itself.
|
|
38
50
|
this.root = realpathSync(resolve(opts.root));
|
|
39
51
|
this.readOnly = opts.readOnly ?? false;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
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);
|
|
44
79
|
}
|
|
45
80
|
/**
|
|
46
|
-
* The one gate. Returns an absolute path inside the
|
|
81
|
+
* The one gate. Returns an absolute path inside one of the roots, or throws.
|
|
47
82
|
*
|
|
48
83
|
* A path that does not exist yet cannot be realpath'd, so the nearest
|
|
49
84
|
* existing ancestor is resolved instead and the remainder appended — which
|
|
50
85
|
* is exactly what a create needs and closes the same hole a create opens.
|
|
51
86
|
*/
|
|
52
|
-
within(input) {
|
|
87
|
+
within(input, opts = {}) {
|
|
53
88
|
if (typeof input !== 'string' || input.length === 0) {
|
|
54
89
|
throw new Error('path is required');
|
|
55
90
|
}
|
|
56
91
|
if (input.includes('\0')) {
|
|
57
92
|
throw new Error('path contains a null byte');
|
|
58
93
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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)) {
|
|
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)) {
|
|
67
97
|
throw new Error(`outside the workspace: ${input}`);
|
|
68
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
|
+
}
|
|
69
104
|
return real;
|
|
70
105
|
}
|
|
71
106
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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.
|
|
75
111
|
*
|
|
76
|
-
* This is renaming, not permission. What comes out is still
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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.
|
|
80
116
|
*/
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
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: '.' };
|
|
84
126
|
}
|
|
85
127
|
const at = input.replace(/\\/g, '/');
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|
|
91
135
|
}
|
|
92
|
-
return
|
|
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]);
|
|
93
142
|
}
|
|
94
143
|
#realish(path) {
|
|
95
144
|
const missing = [];
|
|
@@ -113,9 +162,9 @@ export class Workspace {
|
|
|
113
162
|
}
|
|
114
163
|
}
|
|
115
164
|
}
|
|
116
|
-
/** Where a path sits under
|
|
165
|
+
/** Where a path sits under its own root, in posix form. `.` is that root. */
|
|
117
166
|
rel(path) {
|
|
118
|
-
return relative(this.
|
|
167
|
+
return relative(this.#owner(path).path, path).split(sep).join('/') || '.';
|
|
119
168
|
}
|
|
120
169
|
/**
|
|
121
170
|
* How a path is written back to the model, and the reason `mount` is not
|
|
@@ -128,11 +177,16 @@ export class Workspace {
|
|
|
128
177
|
* is exact. Without one, everything stays relative as before.
|
|
129
178
|
*/
|
|
130
179
|
show(path) {
|
|
131
|
-
const
|
|
132
|
-
|
|
180
|
+
const root = this.#owner(path);
|
|
181
|
+
const rel = relative(root.path, path).split(sep).join('/') || '.';
|
|
182
|
+
if (root.at === undefined) {
|
|
133
183
|
return rel;
|
|
134
184
|
}
|
|
135
|
-
return rel === '.' ?
|
|
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);
|
|
136
190
|
}
|
|
137
191
|
mutable() {
|
|
138
192
|
if (this.readOnly) {
|
|
@@ -140,6 +194,39 @@ export class Workspace {
|
|
|
140
194
|
}
|
|
141
195
|
}
|
|
142
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
|
+
}
|
|
143
230
|
/**
|
|
144
231
|
* Formats that are decided by name alone. Everything absent from here is
|
|
145
232
|
* sniffed, so an unknown extension still lands on text or binary correctly.
|
|
@@ -531,18 +618,35 @@ export function workspaceTools(opts) {
|
|
|
531
618
|
// `read_file`, which is the first of these any model reaches for; the short
|
|
532
619
|
// one is repeated on every `path` argument, which is where the value is
|
|
533
620
|
// actually written and where a wrong guess costs a turn.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
'
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
621
|
+
//
|
|
622
|
+
// This is also the only place an extra tree is named. Nothing else in the
|
|
623
|
+
// prompt mentions one, and a directory the model has not been told about is
|
|
624
|
+
// a directory it never opens.
|
|
625
|
+
const extras = ws.mounts.map((m) => m.at);
|
|
626
|
+
const bounds = extras.length === 0
|
|
627
|
+
? ' That directory is the whole of what can be reached: nothing outside it can be ' +
|
|
628
|
+
'read or written.'
|
|
629
|
+
: ` ${nameList(extras)} ${extras.length > 1 ? 'are' : 'is'} mounted alongside it: ` +
|
|
630
|
+
`read, list and search ${extras.length > 1 ? 'them' : 'it'} like anything else, ` +
|
|
631
|
+
'but nothing there can be written, moved or deleted. Nothing outside those ' +
|
|
632
|
+
'directories can be reached at all.';
|
|
633
|
+
const scope = (ws.mount
|
|
634
|
+
? `The workspace is mounted at ${ws.mount}, which is the name commands running in ` +
|
|
635
|
+
`the sandbox print and the name these tools answer with, so a path from a ` +
|
|
636
|
+
`command's output can be used here unchanged. A path relative to the workspace ` +
|
|
637
|
+
`root names the same file: ${ws.mount}/src/a.ts and src/a.ts are one file.`
|
|
638
|
+
: 'Paths are relative to the workspace root.') + bounds;
|
|
639
|
+
const inside = (ws.mount
|
|
640
|
+
? `Path in the workspace: ${ws.mount}/src/a.ts, or src/a.ts relative to the root — ` +
|
|
641
|
+
`the same file either way. The root itself is "${ws.mount}" or ".".`
|
|
642
|
+
: 'Path in the workspace, relative to its root: src/a.ts. The root itself is "." ' +
|
|
643
|
+
'or "/".') + (extras.length === 0 ? '' : ` Read-only: ${nameList(extras)}.`);
|
|
644
|
+
// With more than one tree in reach, "/" is not another name for the
|
|
645
|
+
// workspace root: it is where the trees hang, and listing it names them.
|
|
646
|
+
// One tree has nothing above it, so there "/" stays the root.
|
|
647
|
+
const trees = ws.mount && ws.mounts.length > 0
|
|
648
|
+
? [ws.mount, ...extras].map((name) => ({ name, kind: 'dir' }))
|
|
649
|
+
: undefined;
|
|
546
650
|
/**
|
|
547
651
|
* `.`, `/`, the mount and nothing at all are four spellings of the root, and
|
|
548
652
|
* a tool whose path is optional takes all four. A model that means "the top"
|
|
@@ -625,13 +729,20 @@ export function workspaceTools(opts) {
|
|
|
625
729
|
properties: {
|
|
626
730
|
path: {
|
|
627
731
|
type: 'string',
|
|
628
|
-
description:
|
|
732
|
+
description: trees
|
|
733
|
+
? `${inside} Omit it, or give "/", to list the directories that can be ` +
|
|
734
|
+
`reached: ${nameList(trees.map((t) => t.name))}.`
|
|
735
|
+
: `${inside} Omit it to list the root.`,
|
|
629
736
|
},
|
|
630
737
|
},
|
|
631
738
|
required: [],
|
|
632
739
|
additionalProperties: false,
|
|
633
740
|
},
|
|
634
741
|
execute: async ({ path }) => {
|
|
742
|
+
const asked = path?.trim() ?? '';
|
|
743
|
+
if (trees && (asked === '' || /^[\\/]+$/.test(asked))) {
|
|
744
|
+
return { path: '/', entries: trees };
|
|
745
|
+
}
|
|
635
746
|
const at = root(path);
|
|
636
747
|
const entries = await readdir(at, { withFileTypes: true });
|
|
637
748
|
const listed = [];
|
|
@@ -687,7 +798,7 @@ export function workspaceTools(opts) {
|
|
|
687
798
|
if (content.length > MAX_WRITE) {
|
|
688
799
|
return { error: `content exceeds ${MAX_WRITE} bytes` };
|
|
689
800
|
}
|
|
690
|
-
const at = ws.within(path);
|
|
801
|
+
const at = ws.within(path, { write: true });
|
|
691
802
|
await mkdir(dirname(at), { recursive: true });
|
|
692
803
|
await writeFile(at, content, 'utf8');
|
|
693
804
|
return {
|
|
@@ -770,12 +881,12 @@ export function workspaceTools(opts) {
|
|
|
770
881
|
},
|
|
771
882
|
execute: async ({ from, to, overwrite }) => {
|
|
772
883
|
ws.mutable();
|
|
773
|
-
const source = ws.within(from);
|
|
774
|
-
const target = ws.within(to);
|
|
775
|
-
if (
|
|
884
|
+
const source = ws.within(from, { write: true });
|
|
885
|
+
const target = ws.within(to, { write: true });
|
|
886
|
+
if (ws.isRoot(source)) {
|
|
776
887
|
return { error: 'refusing to move the workspace root' };
|
|
777
888
|
}
|
|
778
|
-
if (
|
|
889
|
+
if (ws.isRoot(target)) {
|
|
779
890
|
return { error: 'refusing to overwrite the workspace root' };
|
|
780
891
|
}
|
|
781
892
|
if (source === target) {
|
|
@@ -819,8 +930,8 @@ export function workspaceTools(opts) {
|
|
|
819
930
|
},
|
|
820
931
|
execute: async ({ path, recursive }) => {
|
|
821
932
|
ws.mutable();
|
|
822
|
-
const at = ws.within(path);
|
|
823
|
-
if (
|
|
933
|
+
const at = ws.within(path, { write: true });
|
|
934
|
+
if (ws.isRoot(at)) {
|
|
824
935
|
return { error: 'refusing to delete the workspace root' };
|
|
825
936
|
}
|
|
826
937
|
const info = await stat(at);
|
|
@@ -857,24 +968,78 @@ export function workspaceTools(opts) {
|
|
|
857
968
|
additionalProperties: false,
|
|
858
969
|
},
|
|
859
970
|
execute: async ({ pattern, path }) => {
|
|
860
|
-
|
|
971
|
+
// Omitting the path means "everywhere I can see", and what the
|
|
972
|
+
// model can see now includes the mounted trees. Searching only the
|
|
973
|
+
// workspace would leave them behind a path it has to know to ask
|
|
974
|
+
// for. A tree nested inside the workspace is walked twice, so the
|
|
975
|
+
// hits are a set: `show` gives one file one name either way.
|
|
976
|
+
const where = path?.trim()
|
|
977
|
+
? [ws.within(path)]
|
|
978
|
+
: [ws.root, ...ws.mounts.map((m) => m.host)];
|
|
861
979
|
const needle = pattern.toLowerCase();
|
|
862
|
-
const hits =
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
980
|
+
const hits = new Set();
|
|
981
|
+
for (const dir of where) {
|
|
982
|
+
const room = await walk(dir, ws, (file) => {
|
|
983
|
+
// Matched on the path within its own tree, reported under
|
|
984
|
+
// the name the model uses: a mount prefix is on every
|
|
985
|
+
// candidate, so matching it would mean nothing.
|
|
986
|
+
if (ws.rel(file).toLowerCase().includes(needle)) {
|
|
987
|
+
hits.add(ws.show(file));
|
|
988
|
+
}
|
|
989
|
+
return hits.size < MAX_ENTRIES;
|
|
990
|
+
});
|
|
991
|
+
if (!room) {
|
|
992
|
+
break;
|
|
869
993
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
994
|
+
}
|
|
995
|
+
return {
|
|
996
|
+
pattern,
|
|
997
|
+
matches: [...hits],
|
|
998
|
+
truncated: hits.size >= MAX_ENTRIES,
|
|
999
|
+
};
|
|
873
1000
|
},
|
|
874
1001
|
});
|
|
875
|
-
return opts.readOnly
|
|
1002
|
+
return named(ws, opts.readOnly
|
|
876
1003
|
? [readFileTool, listDir, findFiles]
|
|
877
|
-
: [readFileTool, listDir, findFiles, writeFileTool, applyPatch, movePath, deleteFile];
|
|
1004
|
+
: [readFileTool, listDir, findFiles, writeFileTool, applyPatch, movePath, deleteFile]);
|
|
1005
|
+
}
|
|
1006
|
+
/** What each errno means, said the way the rest of these tools say things. */
|
|
1007
|
+
const ERRNO = {
|
|
1008
|
+
ENOENT: 'does not exist',
|
|
1009
|
+
ENOTDIR: 'is not a directory',
|
|
1010
|
+
EISDIR: 'is a directory',
|
|
1011
|
+
EEXIST: 'already exists',
|
|
1012
|
+
ENOTEMPTY: 'is not empty',
|
|
1013
|
+
EACCES: 'cannot be reached',
|
|
1014
|
+
EPERM: 'cannot be reached',
|
|
1015
|
+
ELOOP: 'is a loop of symlinks',
|
|
1016
|
+
};
|
|
1017
|
+
/**
|
|
1018
|
+
* Every path these tools are given comes back under the name the model wrote
|
|
1019
|
+
* it — except when the filesystem itself fails, because Node's errors name the
|
|
1020
|
+
* host path: `ENOENT: no such file or directory, scandir '/Users/me/proj/src'`.
|
|
1021
|
+
* That is a name the model has never been shown, cannot pass to another tool,
|
|
1022
|
+
* and will read as evidence about where it is running. So a failure is
|
|
1023
|
+
* rewritten into the same vocabulary as a success before it leaves.
|
|
1024
|
+
*/
|
|
1025
|
+
function named(ws, tools) {
|
|
1026
|
+
return tools.map((t) => ({
|
|
1027
|
+
...t,
|
|
1028
|
+
execute: async (args, tc) => {
|
|
1029
|
+
try {
|
|
1030
|
+
return await t.execute(args, tc);
|
|
1031
|
+
}
|
|
1032
|
+
catch (err) {
|
|
1033
|
+
const e = err;
|
|
1034
|
+
if (typeof e?.code !== 'string' || typeof e.path !== 'string') {
|
|
1035
|
+
throw err;
|
|
1036
|
+
}
|
|
1037
|
+
throw new Error(`${ws.show(e.path)} ${ERRNO[e.code] ?? `failed: ${e.code}`}`, {
|
|
1038
|
+
cause: err,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
},
|
|
1042
|
+
}));
|
|
878
1043
|
}
|
|
879
1044
|
async function runPatch(ws, patch) {
|
|
880
1045
|
const ops = parsePatch(patch);
|
|
@@ -883,8 +1048,8 @@ async function runPatch(ws, patch) {
|
|
|
883
1048
|
const seen = new Set();
|
|
884
1049
|
let fuzz = 0;
|
|
885
1050
|
for (const op of ops) {
|
|
886
|
-
const at = ws
|
|
887
|
-
if (
|
|
1051
|
+
const at = patchPath(ws, op.path);
|
|
1052
|
+
if (ws.isRoot(at)) {
|
|
888
1053
|
throw new PatchError(`${op.path} is the workspace root`);
|
|
889
1054
|
}
|
|
890
1055
|
if (seen.has(at)) {
|
|
@@ -953,8 +1118,8 @@ async function runPatch(ws, patch) {
|
|
|
953
1118
|
lines: patched.lines.length,
|
|
954
1119
|
};
|
|
955
1120
|
if (op.moveTo) {
|
|
956
|
-
const to = ws
|
|
957
|
-
if (
|
|
1121
|
+
const to = patchPath(ws, op.moveTo);
|
|
1122
|
+
if (ws.isRoot(to)) {
|
|
958
1123
|
throw new PatchError('cannot move a file onto the workspace root');
|
|
959
1124
|
}
|
|
960
1125
|
if (to !== at) {
|
|
@@ -1000,6 +1165,21 @@ async function statOrNull(at) {
|
|
|
1000
1165
|
return null;
|
|
1001
1166
|
}
|
|
1002
1167
|
}
|
|
1168
|
+
/**
|
|
1169
|
+
* A path in a patch, checked for containment and for write permission before
|
|
1170
|
+
* the first byte is written. Both refusals become patch errors, because the
|
|
1171
|
+
* promise apply_patch makes is that a patch it will not finish leaves nothing
|
|
1172
|
+
* behind — a read-only file named halfway down is a reason to reject the whole
|
|
1173
|
+
* patch, not to write the files above it and then throw.
|
|
1174
|
+
*/
|
|
1175
|
+
function patchPath(ws, path) {
|
|
1176
|
+
try {
|
|
1177
|
+
return ws.within(path, { write: true });
|
|
1178
|
+
}
|
|
1179
|
+
catch (err) {
|
|
1180
|
+
throw new PatchError(err instanceof Error ? err.message : String(err));
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1003
1183
|
/** Depth-first, skipping the noise nobody means to search. */
|
|
1004
1184
|
const SKIP = new Set(['.git', 'node_modules', '.data', 'dist', '.venv', '__pycache__']);
|
|
1005
1185
|
async function walk(dir, ws, visit) {
|
package/dist/types.d.ts
CHANGED
|
@@ -174,9 +174,10 @@ export interface ToolSelection {
|
|
|
174
174
|
}
|
|
175
175
|
/**
|
|
176
176
|
* Resolves selectors against the tools a host provided, in the order written
|
|
177
|
-
* and without duplicates. A selector is a tool name, `<group
|
|
178
|
-
* in
|
|
179
|
-
* what it matches from
|
|
177
|
+
* and without duplicates. A selector is a tool name, `<group>:<name>` for the
|
|
178
|
+
* same tool written out in full, `<group>:*` for every tool in a group, or `*`
|
|
179
|
+
* for everything; any of them prefixed with `-` removes what it matches from
|
|
180
|
+
* the selection so far.
|
|
180
181
|
*/
|
|
181
182
|
export declare function selectTools<TCtx>(available: AnyTool<TCtx>[], selectors: string[], opts: ToolSelection): AnyTool<TCtx>[];
|
|
182
183
|
export declare const HANDOFF_PREFIX = "transfer_to_";
|