@zenera/neo 1.1.0 → 1.1.2
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/project/config.d.ts +26 -0
- package/dist/project/config.js +32 -0
- 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/workspace.d.ts +29 -3
- package/dist/tools/workspace.js +199 -70
- package/package.json +1 -1
package/dist/project/config.d.ts
CHANGED
|
@@ -112,8 +112,24 @@ declare const embeddingSpec: z.ZodObject<{
|
|
|
112
112
|
baseURL: z.ZodOptional<z.ZodString>;
|
|
113
113
|
baseURLEnv: z.ZodOptional<z.ZodString>;
|
|
114
114
|
}, z.core.$strict>;
|
|
115
|
+
/**
|
|
116
|
+
* A Dockerfile to build instead of an image to pull, for the ordinary case the
|
|
117
|
+
* `image:` key cannot serve: a project that needs two runtimes, or a pinned
|
|
118
|
+
* toolchain, and would otherwise have its agents reinstall it on every run.
|
|
119
|
+
*
|
|
120
|
+
* Building is a host concern, so nothing in this library does it — the front
|
|
121
|
+
* end resolves this to an image reference before a container is ever named.
|
|
122
|
+
*/
|
|
123
|
+
declare const build: z.ZodObject<{
|
|
124
|
+
dockerfile: z.ZodString;
|
|
125
|
+
context: z.ZodOptional<z.ZodString>;
|
|
126
|
+
}, z.core.$strict>;
|
|
115
127
|
declare const sandbox: z.ZodObject<{
|
|
116
128
|
image: z.ZodOptional<z.ZodString>;
|
|
129
|
+
build: z.ZodOptional<z.ZodObject<{
|
|
130
|
+
dockerfile: z.ZodString;
|
|
131
|
+
context: z.ZodOptional<z.ZodString>;
|
|
132
|
+
}, z.core.$strict>>;
|
|
117
133
|
cpus: z.ZodOptional<z.ZodNumber>;
|
|
118
134
|
memory: z.ZodOptional<z.ZodInt>;
|
|
119
135
|
network: z.ZodOptional<z.ZodEnum<{
|
|
@@ -230,8 +246,13 @@ export declare const projectSchema: z.ZodObject<{
|
|
|
230
246
|
}, z.core.$strict>]>>>;
|
|
231
247
|
embedding: z.ZodOptional<z.ZodString>;
|
|
232
248
|
skills: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
249
|
+
assets: z.ZodOptional<z.ZodString>;
|
|
233
250
|
sandbox: z.ZodOptional<z.ZodObject<{
|
|
234
251
|
image: z.ZodOptional<z.ZodString>;
|
|
252
|
+
build: z.ZodOptional<z.ZodObject<{
|
|
253
|
+
dockerfile: z.ZodString;
|
|
254
|
+
context: z.ZodOptional<z.ZodString>;
|
|
255
|
+
}, z.core.$strict>>;
|
|
235
256
|
cpus: z.ZodOptional<z.ZodNumber>;
|
|
236
257
|
memory: z.ZodOptional<z.ZodInt>;
|
|
237
258
|
network: z.ZodOptional<z.ZodEnum<{
|
|
@@ -269,6 +290,10 @@ export declare const projectSchema: z.ZodObject<{
|
|
|
269
290
|
}, z.core.$strict>]>>;
|
|
270
291
|
sandbox: z.ZodOptional<z.ZodObject<{
|
|
271
292
|
image: z.ZodOptional<z.ZodString>;
|
|
293
|
+
build: z.ZodOptional<z.ZodObject<{
|
|
294
|
+
dockerfile: z.ZodString;
|
|
295
|
+
context: z.ZodOptional<z.ZodString>;
|
|
296
|
+
}, z.core.$strict>>;
|
|
272
297
|
cpus: z.ZodOptional<z.ZodNumber>;
|
|
273
298
|
memory: z.ZodOptional<z.ZodInt>;
|
|
274
299
|
network: z.ZodOptional<z.ZodEnum<{
|
|
@@ -291,6 +316,7 @@ export type ProviderConfig = z.infer<typeof provider>;
|
|
|
291
316
|
export type ModelConfig = z.infer<typeof modelSpec>;
|
|
292
317
|
export type EmbeddingConfig = z.infer<typeof embeddingSpec>;
|
|
293
318
|
export type SandboxConfig = z.infer<typeof sandbox>;
|
|
319
|
+
export type SandboxBuildConfig = z.infer<typeof build>;
|
|
294
320
|
/**
|
|
295
321
|
* Re-renders a zod failure as `agents.yaml: agents[1].skills.discovery — …`.
|
|
296
322
|
*
|
package/dist/project/config.js
CHANGED
|
@@ -170,10 +170,28 @@ const forkBinding = z
|
|
|
170
170
|
* hand every model key to whatever the agent decided to run.
|
|
171
171
|
*/
|
|
172
172
|
const SECRETISH = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)/i;
|
|
173
|
+
/**
|
|
174
|
+
* A Dockerfile to build instead of an image to pull, for the ordinary case the
|
|
175
|
+
* `image:` key cannot serve: a project that needs two runtimes, or a pinned
|
|
176
|
+
* toolchain, and would otherwise have its agents reinstall it on every run.
|
|
177
|
+
*
|
|
178
|
+
* Building is a host concern, so nothing in this library does it — the front
|
|
179
|
+
* end resolves this to an image reference before a container is ever named.
|
|
180
|
+
*/
|
|
181
|
+
const build = z
|
|
182
|
+
.object({
|
|
183
|
+
/** path to the Dockerfile, relative to the project root */
|
|
184
|
+
dockerfile: z.string().min(1),
|
|
185
|
+
/** what the build may COPY from; the Dockerfile's own directory by default */
|
|
186
|
+
context: z.string().min(1).optional(),
|
|
187
|
+
})
|
|
188
|
+
.strict();
|
|
173
189
|
const sandbox = z
|
|
174
190
|
.object({
|
|
175
191
|
/** the base image, e.g. `docker.io/library/python:3.13-slim` */
|
|
176
192
|
image: z.string().min(1).optional(),
|
|
193
|
+
/** a Dockerfile to build, instead of an image to pull */
|
|
194
|
+
build: build.optional(),
|
|
177
195
|
/** fractional cores the container may use */
|
|
178
196
|
cpus: z.number().positive().optional(),
|
|
179
197
|
/** MiB the container may use */
|
|
@@ -192,6 +210,14 @@ const sandbox = z
|
|
|
192
210
|
})
|
|
193
211
|
.strict()
|
|
194
212
|
.superRefine((value, ctx) => {
|
|
213
|
+
if (value.image !== undefined && value.build !== undefined) {
|
|
214
|
+
ctx.addIssue({
|
|
215
|
+
code: 'custom',
|
|
216
|
+
path: ['build'],
|
|
217
|
+
message: 'image and build cannot both be set — a Dockerfile names its own base ' +
|
|
218
|
+
'in its FROM line, so one of the two would be silently ignored',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
195
221
|
for (const [i, name] of (value.env ?? []).entries()) {
|
|
196
222
|
if (SECRETISH.test(name)) {
|
|
197
223
|
ctx.addIssue({
|
|
@@ -255,6 +281,12 @@ export const projectSchema = z
|
|
|
255
281
|
embedding: modelRef.optional(),
|
|
256
282
|
/** one directory, or several merged into one catalog */
|
|
257
283
|
skills: z.union([z.string().min(1), z.array(z.string().min(1))]).optional(),
|
|
284
|
+
/**
|
|
285
|
+
* Reference material every agent can read and none can write, mounted
|
|
286
|
+
* at /assets. One directory, project-wide: an agent that may only see
|
|
287
|
+
* some of it is a different project, not a different key.
|
|
288
|
+
*/
|
|
289
|
+
assets: z.string().min(1).optional(),
|
|
258
290
|
/** the container `run_command` and friends execute in */
|
|
259
291
|
sandbox: sandbox.optional(),
|
|
260
292
|
agents: z.array(agent).min(1),
|
package/dist/project/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { parseConfig, projectSchema, type AgentConfig, type EmbeddingConfig, type ModelConfig, type ProjectConfig, type ProviderConfig, type SandboxConfig, } from './config.ts';
|
|
2
|
-
export { AgentProject, loadProject, projectRegistry, readProjectConfig, type ProjectOptions, type ProjectSource, } from './load.ts';
|
|
1
|
+
export { parseConfig, projectSchema, type AgentConfig, type EmbeddingConfig, type ModelConfig, type ProjectConfig, type ProviderConfig, type SandboxBuildConfig, type SandboxConfig, } from './config.ts';
|
|
2
|
+
export { AgentProject, assetsDir, loadProject, projectRegistry, readProjectConfig, skillDirs, skillMounts, type ProjectOptions, type ProjectSource, } from './load.ts';
|
|
3
3
|
export { projectDir, projectFile, projectPath, projectRoot } from './refs.ts';
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/project/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { parseConfig, projectSchema, } from "./config.js";
|
|
2
|
-
export { AgentProject, loadProject, projectRegistry, readProjectConfig, } from "./load.js";
|
|
2
|
+
export { AgentProject, assetsDir, loadProject, projectRegistry, readProjectConfig, skillDirs, skillMounts, } from "./load.js";
|
|
3
3
|
export { projectDir, projectFile, projectPath, projectRoot } from "./refs.js";
|
|
4
4
|
//# sourceMappingURL=index.js.map
|
package/dist/project/load.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { MemoryStore } from '../memory.ts';
|
|
|
5
5
|
import { ModelRegistry, type EmbeddingRef, type ModelRef, type ProviderSpec } from '../models/factory.ts';
|
|
6
6
|
import type { PayloadStore } from '../payload.ts';
|
|
7
7
|
import { AgentRunner, type RunOptions, type RunnerOptions } from '../runner.ts';
|
|
8
|
+
import { type SkillDir } from '../skill-providers/file.ts';
|
|
8
9
|
import type { SkillProvider } from '../skills.ts';
|
|
9
10
|
import { type AnyTool, type Input } from '../types.ts';
|
|
10
11
|
import { type ProjectConfig } from './config.ts';
|
|
@@ -16,6 +17,7 @@ import { type ProjectConfig } from './config.ts';
|
|
|
16
17
|
* agents.yaml who exists, what they may reach for
|
|
17
18
|
* agents/prompts/<role>.md one agent's own instructions
|
|
18
19
|
* agents/skills/<name>/SKILL.md
|
|
20
|
+
* assets/ reference material, mounted read-only at /assets
|
|
19
21
|
* ```
|
|
20
22
|
*
|
|
21
23
|
* The loader turns that into the objects an author would otherwise write by
|
|
@@ -56,6 +58,16 @@ export interface ProjectOptions<TCtx = unknown> {
|
|
|
56
58
|
payloads?: PayloadStore;
|
|
57
59
|
/** extra providers merged with the ones the project declares */
|
|
58
60
|
skills?: SkillProvider[];
|
|
61
|
+
/**
|
|
62
|
+
* Where the host has mounted the skill catalog for the agent to reach —
|
|
63
|
+
* `/skills` from the CLI, which bind-mounts it into the container.
|
|
64
|
+
*
|
|
65
|
+
* Set it and a loaded skill carries the path of its own folder, so one that
|
|
66
|
+
* ships a script can say where the script is. Left unset, a skill is text
|
|
67
|
+
* and nothing claims otherwise: a promise of a directory that is not there
|
|
68
|
+
* is worse than no directory.
|
|
69
|
+
*/
|
|
70
|
+
skillsAt?: string;
|
|
59
71
|
}
|
|
60
72
|
/** A project's declaration, before anything is assembled from it. */
|
|
61
73
|
export interface ProjectSource {
|
|
@@ -85,6 +97,7 @@ interface ProjectParts<TCtx> {
|
|
|
85
97
|
entry: string;
|
|
86
98
|
registry: AgentRegistry<TCtx>;
|
|
87
99
|
skillProviders: SkillProvider[];
|
|
100
|
+
assets: string | undefined;
|
|
88
101
|
models: ModelRegistry;
|
|
89
102
|
embedders: (ref: string | undefined, where: string) => Embedder | undefined;
|
|
90
103
|
options: ProjectOptions<TCtx>;
|
|
@@ -112,6 +125,8 @@ export declare class AgentProject<TCtx = unknown> {
|
|
|
112
125
|
readonly entry: string;
|
|
113
126
|
readonly registry: AgentRegistry<TCtx>;
|
|
114
127
|
readonly skillProviders: readonly SkillProvider[];
|
|
128
|
+
/** reference material to mount read-only, if the project has any */
|
|
129
|
+
readonly assets: string | undefined;
|
|
115
130
|
/** the providers this project declared, and the clients behind them */
|
|
116
131
|
readonly models: ModelRegistry;
|
|
117
132
|
constructor(parts: ProjectParts<TCtx>);
|
|
@@ -138,5 +153,30 @@ export declare class AgentProject<TCtx = unknown> {
|
|
|
138
153
|
* for, and only pay for it if an agent actually reaches for it.
|
|
139
154
|
*/
|
|
140
155
|
export declare function projectRegistry<TCtx>(config: ProjectConfig, opts?: ProjectOptions<TCtx>): ModelRegistry;
|
|
156
|
+
/**
|
|
157
|
+
* Where each skill directory appears in the agent's namespace.
|
|
158
|
+
*
|
|
159
|
+
* One catalog is the whole of `/skills`, which is what a project normally has.
|
|
160
|
+
* Several cannot all be it, so each takes its own last segment underneath — and
|
|
161
|
+
* two directories ending in the same name would mean one hid the other, which
|
|
162
|
+
* is a load error rather than a catalog that silently went missing.
|
|
163
|
+
*/
|
|
164
|
+
export declare function skillMounts(dirs: readonly string[], at: string): SkillDir[];
|
|
165
|
+
/**
|
|
166
|
+
* The directories the skill catalog is built from, resolved. `skills:` if it is
|
|
167
|
+
* there, otherwise the conventional folder if it exists, otherwise none — a
|
|
168
|
+
* project without skills is a project, not an error.
|
|
169
|
+
*/
|
|
170
|
+
export declare function skillDirs(root: string, config: ProjectConfig): string[];
|
|
171
|
+
/**
|
|
172
|
+
* The project's reference material, resolved, or undefined if it has none.
|
|
173
|
+
*
|
|
174
|
+
* Convention first — `assets/` next to `agents.yaml` is enough, and a project
|
|
175
|
+
* that only wants somewhere to put a handbook writes no config at all. The key
|
|
176
|
+
* exists for the project that keeps its material elsewhere in the tree; a key
|
|
177
|
+
* naming a directory that is not there is a mistake in the file and fails at
|
|
178
|
+
* load, where the conventional folder merely being absent does not.
|
|
179
|
+
*/
|
|
180
|
+
export declare function assetsDir(root: string, config: ProjectConfig): string | undefined;
|
|
141
181
|
export {};
|
|
142
182
|
//# sourceMappingURL=load.d.ts.map
|
package/dist/project/load.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
3
|
import { Agent, AgentRegistry } from "../agent.js";
|
|
4
4
|
import { RunStream } from "../events.js";
|
|
5
5
|
import { ModelRegistry, } from "../models/factory.js";
|
|
@@ -11,6 +11,8 @@ import { parseConfig } from "./config.js";
|
|
|
11
11
|
import { projectDir, projectFile, projectRoot } from "./refs.js";
|
|
12
12
|
const PROMPTS_DIR = 'agents/prompts';
|
|
13
13
|
const SKILLS_DIR = 'agents/skills';
|
|
14
|
+
/** Reference material, by convention, when `assets:` says nothing. */
|
|
15
|
+
const ASSETS_DIR = 'assets';
|
|
14
16
|
const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents/agents.yml'];
|
|
15
17
|
// Deliberately not `AGENTS.md`: every coding assistant now reads that name out
|
|
16
18
|
// of an open folder, and `zen open` opens exactly this directory. The house
|
|
@@ -42,6 +44,9 @@ export async function loadProject(dir, opts = {}) {
|
|
|
42
44
|
const providers = [...skillProviders(root, config, opts), ...(opts.skills ?? [])];
|
|
43
45
|
const catalogs = await indexOf(providers);
|
|
44
46
|
const models = projectRegistry(config, opts);
|
|
47
|
+
// Resolved here rather than where it is mounted, so an `assets:` naming a
|
|
48
|
+
// directory that is not there fails at load like every other bad path.
|
|
49
|
+
const assets = assetsDir(root, config);
|
|
45
50
|
const registry = new AgentRegistry();
|
|
46
51
|
const resolve = modelResolver(config, opts, models);
|
|
47
52
|
for (const spec of config.agents) {
|
|
@@ -68,6 +73,7 @@ export async function loadProject(dir, opts = {}) {
|
|
|
68
73
|
entry: entrypoint(config),
|
|
69
74
|
registry,
|
|
70
75
|
skillProviders: providers,
|
|
76
|
+
assets,
|
|
71
77
|
models,
|
|
72
78
|
embedders,
|
|
73
79
|
options: opts,
|
|
@@ -95,6 +101,8 @@ export class AgentProject {
|
|
|
95
101
|
entry;
|
|
96
102
|
registry;
|
|
97
103
|
skillProviders;
|
|
104
|
+
/** reference material to mount read-only, if the project has any */
|
|
105
|
+
assets;
|
|
98
106
|
/** the providers this project declared, and the clients behind them */
|
|
99
107
|
models;
|
|
100
108
|
#embedders;
|
|
@@ -107,6 +115,7 @@ export class AgentProject {
|
|
|
107
115
|
this.entry = parts.entry;
|
|
108
116
|
this.registry = parts.registry;
|
|
109
117
|
this.skillProviders = parts.skillProviders;
|
|
118
|
+
this.assets = parts.assets;
|
|
110
119
|
this.models = parts.models;
|
|
111
120
|
this.#embedders = parts.embedders;
|
|
112
121
|
this.#options = parts.options;
|
|
@@ -307,13 +316,7 @@ function forkFor(spec) {
|
|
|
307
316
|
return spec.fork === true ? {} : spec.fork;
|
|
308
317
|
}
|
|
309
318
|
function skillProviders(root, config, opts) {
|
|
310
|
-
const declared = config
|
|
311
|
-
? Array.isArray(config.skills)
|
|
312
|
-
? config.skills
|
|
313
|
-
: [config.skills]
|
|
314
|
-
: existsSync(join(root, SKILLS_DIR))
|
|
315
|
-
? [SKILLS_DIR]
|
|
316
|
-
: [];
|
|
319
|
+
const declared = skillDirs(root, config);
|
|
317
320
|
if (!declared.length) {
|
|
318
321
|
return [];
|
|
319
322
|
}
|
|
@@ -323,11 +326,65 @@ function skillProviders(root, config, opts) {
|
|
|
323
326
|
return [
|
|
324
327
|
new FileSkillProvider({
|
|
325
328
|
id: 'project',
|
|
326
|
-
dir:
|
|
329
|
+
dir: opts.skillsAt ? skillMounts(declared, opts.skillsAt) : declared,
|
|
327
330
|
tools: opts.tools,
|
|
328
331
|
}),
|
|
329
332
|
];
|
|
330
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Where each skill directory appears in the agent's namespace.
|
|
336
|
+
*
|
|
337
|
+
* One catalog is the whole of `/skills`, which is what a project normally has.
|
|
338
|
+
* Several cannot all be it, so each takes its own last segment underneath — and
|
|
339
|
+
* two directories ending in the same name would mean one hid the other, which
|
|
340
|
+
* is a load error rather than a catalog that silently went missing.
|
|
341
|
+
*/
|
|
342
|
+
export function skillMounts(dirs, at) {
|
|
343
|
+
if (dirs.length === 1) {
|
|
344
|
+
return [{ path: dirs[0], at }];
|
|
345
|
+
}
|
|
346
|
+
const taken = new Map();
|
|
347
|
+
return dirs.map((dir) => {
|
|
348
|
+
const name = basename(dir);
|
|
349
|
+
const clash = taken.get(name);
|
|
350
|
+
if (clash) {
|
|
351
|
+
throw new Error(`skills: ${dir} and ${clash} would both be mounted at ${at}/${name}`);
|
|
352
|
+
}
|
|
353
|
+
taken.set(name, dir);
|
|
354
|
+
return { path: dir, at: `${at}/${name}` };
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The directories the skill catalog is built from, resolved. `skills:` if it is
|
|
359
|
+
* there, otherwise the conventional folder if it exists, otherwise none — a
|
|
360
|
+
* project without skills is a project, not an error.
|
|
361
|
+
*/
|
|
362
|
+
export function skillDirs(root, config) {
|
|
363
|
+
const declared = config.skills
|
|
364
|
+
? Array.isArray(config.skills)
|
|
365
|
+
? config.skills
|
|
366
|
+
: [config.skills]
|
|
367
|
+
: existsSync(join(root, SKILLS_DIR))
|
|
368
|
+
? [SKILLS_DIR]
|
|
369
|
+
: [];
|
|
370
|
+
return declared.map((d, i) => projectDir(root, d, `skills[${i}]`));
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The project's reference material, resolved, or undefined if it has none.
|
|
374
|
+
*
|
|
375
|
+
* Convention first — `assets/` next to `agents.yaml` is enough, and a project
|
|
376
|
+
* that only wants somewhere to put a handbook writes no config at all. The key
|
|
377
|
+
* exists for the project that keeps its material elsewhere in the tree; a key
|
|
378
|
+
* naming a directory that is not there is a mistake in the file and fails at
|
|
379
|
+
* load, where the conventional folder merely being absent does not.
|
|
380
|
+
*/
|
|
381
|
+
export function assetsDir(root, config) {
|
|
382
|
+
if (config.assets) {
|
|
383
|
+
return projectDir(root, config.assets, 'assets');
|
|
384
|
+
}
|
|
385
|
+
const at = join(root, ASSETS_DIR);
|
|
386
|
+
return existsSync(at) && statSync(at).isDirectory() ? at : undefined;
|
|
387
|
+
}
|
|
331
388
|
// ---------------------------------------------------------------------------
|
|
332
389
|
// Validation
|
|
333
390
|
// ---------------------------------------------------------------------------
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import type { Skill, SkillProvider, SkillSummary } from '../skills.ts';
|
|
2
2
|
import { type AnyTool } from '../types.ts';
|
|
3
|
+
/**
|
|
4
|
+
* A directory of skills, and the name the *agent* reaches it by — which is not
|
|
5
|
+
* the host path when the folder is bind-mounted into a container. Give the
|
|
6
|
+
* plain string and a skill is text; give the pair and a skill that ships a
|
|
7
|
+
* script can say where the script is.
|
|
8
|
+
*/
|
|
9
|
+
export interface SkillDir {
|
|
10
|
+
path: string;
|
|
11
|
+
at: string;
|
|
12
|
+
}
|
|
3
13
|
export interface FileSkillProviderOptions {
|
|
4
|
-
/** one or more root directories holding `<name
|
|
5
|
-
dir: string | string[];
|
|
14
|
+
/** one or more root directories holding `<name>/SKILL.md` folders, or bare `<name>.md` files */
|
|
15
|
+
dir: string | SkillDir | (string | SkillDir)[];
|
|
6
16
|
/** logical provider id agents bind to; defaults to `file` */
|
|
7
17
|
id?: string;
|
|
8
18
|
/**
|
|
@@ -14,13 +24,18 @@ export interface FileSkillProviderOptions {
|
|
|
14
24
|
tools?: AnyTool<any>[];
|
|
15
25
|
}
|
|
16
26
|
/**
|
|
17
|
-
* Reads skills from disk.
|
|
27
|
+
* Reads skills from disk. A skill is a directory holding `SKILL.md`, and the
|
|
28
|
+
* directory is what lets it ship a rate table or a script beside its text:
|
|
18
29
|
*
|
|
19
30
|
* ```
|
|
20
|
-
* <dir>/budget_travel.md
|
|
21
|
-
* <dir>/budget_travel/
|
|
31
|
+
* <dir>/budget_travel/SKILL.md the instructions
|
|
32
|
+
* <dir>/budget_travel/cities.md whatever they refer to
|
|
22
33
|
* ```
|
|
23
34
|
*
|
|
35
|
+
* A bare `<dir>/budget_travel.md` is indexed too, for a host with nothing but
|
|
36
|
+
* prose to offer. `zen check` refuses it in a project, since a skill with no
|
|
37
|
+
* directory of its own can never grow one.
|
|
38
|
+
*
|
|
24
39
|
* Frontmatter is a small subset of YAML — `key: value`, plus `[a, b]` lists
|
|
25
40
|
* for `tags` and `tools`. `name` defaults to the file or folder name and
|
|
26
41
|
* `description` to the first line of the body, so the minimum viable skill is
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
-
import { join, resolve } from 'node:path';
|
|
2
|
+
import { basename, join, resolve } from 'node:path';
|
|
3
3
|
import { selectTools } from "../types.js";
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
// Filesystem-backed skill provider
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
const SKILL_FILE = 'SKILL.md';
|
|
8
8
|
/**
|
|
9
|
-
* Reads skills from disk.
|
|
9
|
+
* Reads skills from disk. A skill is a directory holding `SKILL.md`, and the
|
|
10
|
+
* directory is what lets it ship a rate table or a script beside its text:
|
|
10
11
|
*
|
|
11
12
|
* ```
|
|
12
|
-
* <dir>/budget_travel.md
|
|
13
|
-
* <dir>/budget_travel/
|
|
13
|
+
* <dir>/budget_travel/SKILL.md the instructions
|
|
14
|
+
* <dir>/budget_travel/cities.md whatever they refer to
|
|
14
15
|
* ```
|
|
15
16
|
*
|
|
17
|
+
* A bare `<dir>/budget_travel.md` is indexed too, for a host with nothing but
|
|
18
|
+
* prose to offer. `zen check` refuses it in a project, since a skill with no
|
|
19
|
+
* directory of its own can never grow one.
|
|
20
|
+
*
|
|
16
21
|
* Frontmatter is a small subset of YAML — `key: value`, plus `[a, b]` lists
|
|
17
22
|
* for `tags` and `tools`. `name` defaults to the file or folder name and
|
|
18
23
|
* `description` to the first line of the body, so the minimum viable skill is
|
|
@@ -26,7 +31,9 @@ export class FileSkillProvider {
|
|
|
26
31
|
constructor(opts) {
|
|
27
32
|
const o = typeof opts === 'string' ? { dir: opts } : opts;
|
|
28
33
|
const raw = Array.isArray(o.dir) ? o.dir : [o.dir];
|
|
29
|
-
this.#dirs = raw.map((d) =>
|
|
34
|
+
this.#dirs = raw.map((d) => typeof d === 'string'
|
|
35
|
+
? { path: resolve(d), at: '' }
|
|
36
|
+
: { path: resolve(d.path), at: d.at });
|
|
30
37
|
this.id = o.id ?? 'file';
|
|
31
38
|
for (const t of o.tools ?? []) {
|
|
32
39
|
this.#tools.set(t.name, t);
|
|
@@ -34,11 +41,11 @@ export class FileSkillProvider {
|
|
|
34
41
|
}
|
|
35
42
|
/** The first configured directory (kept for backwards compatibility). */
|
|
36
43
|
get dir() {
|
|
37
|
-
return this.#dirs[0];
|
|
44
|
+
return this.#dirs[0].path;
|
|
38
45
|
}
|
|
39
46
|
/** All configured directories. */
|
|
40
47
|
get dirs() {
|
|
41
|
-
return
|
|
48
|
+
return this.#dirs.map((d) => d.path);
|
|
42
49
|
}
|
|
43
50
|
/** Drops the cached scan; call after editing skills on disk. */
|
|
44
51
|
refresh() {
|
|
@@ -77,7 +84,7 @@ export class FileSkillProvider {
|
|
|
77
84
|
content: entry.content,
|
|
78
85
|
file: entry.file,
|
|
79
86
|
...(tools.length ? { tools } : {}),
|
|
80
|
-
...(entry.
|
|
87
|
+
...(entry.at ? { path: entry.at } : {}),
|
|
81
88
|
};
|
|
82
89
|
}
|
|
83
90
|
tool(name) {
|
|
@@ -93,7 +100,7 @@ export class FileSkillProvider {
|
|
|
93
100
|
for (const dir of this.#dirs) {
|
|
94
101
|
let items;
|
|
95
102
|
try {
|
|
96
|
-
items = await readdir(dir, { withFileTypes: true });
|
|
103
|
+
items = await readdir(dir.path, { withFileTypes: true });
|
|
97
104
|
}
|
|
98
105
|
catch (err) {
|
|
99
106
|
if (err.code === 'ENOENT') {
|
|
@@ -102,8 +109,8 @@ export class FileSkillProvider {
|
|
|
102
109
|
throw err;
|
|
103
110
|
}
|
|
104
111
|
for (const item of items) {
|
|
105
|
-
const folder = item.isDirectory() ? join(dir, item.name) : undefined;
|
|
106
|
-
const file = folder ? join(folder, SKILL_FILE) : join(dir, item.name);
|
|
112
|
+
const folder = item.isDirectory() ? join(dir.path, item.name) : undefined;
|
|
113
|
+
const file = folder ? join(folder, SKILL_FILE) : join(dir.path, item.name);
|
|
107
114
|
if (!folder && !(item.isFile() && item.name.endsWith('.md'))) {
|
|
108
115
|
continue;
|
|
109
116
|
}
|
|
@@ -112,20 +119,24 @@ export class FileSkillProvider {
|
|
|
112
119
|
continue;
|
|
113
120
|
}
|
|
114
121
|
const base = folder ? item.name : item.name.slice(0, -'.md'.length);
|
|
115
|
-
|
|
122
|
+
// Only a folder skill has files of its own to point at, and
|
|
123
|
+
// only when the host said where the directory can be reached.
|
|
124
|
+
const at = folder && dir.at ? `${dir.at}/${basename(folder)}` : undefined;
|
|
125
|
+
const entry = parse(raw, base, file, folder, at);
|
|
116
126
|
index.set(entry.summary.name, entry);
|
|
117
127
|
}
|
|
118
128
|
}
|
|
119
129
|
return index;
|
|
120
130
|
}
|
|
121
131
|
}
|
|
122
|
-
function parse(raw, base, file, folder) {
|
|
132
|
+
function parse(raw, base, file, folder, at) {
|
|
123
133
|
const { data, body } = frontmatter(raw);
|
|
124
134
|
const tags = toList(data.tags);
|
|
125
135
|
const tools = toList(data.tools);
|
|
126
136
|
return {
|
|
127
137
|
file,
|
|
128
138
|
folder,
|
|
139
|
+
...(at ? { at } : {}),
|
|
129
140
|
content: body,
|
|
130
141
|
summary: {
|
|
131
142
|
name: data.name || base,
|
|
@@ -136,20 +147,6 @@ function parse(raw, base, file, folder) {
|
|
|
136
147
|
},
|
|
137
148
|
};
|
|
138
149
|
}
|
|
139
|
-
/** Sibling files of a `SKILL.md`, top level only, keyed by filename. */
|
|
140
|
-
async function readResources(folder) {
|
|
141
|
-
const out = {};
|
|
142
|
-
for (const item of await readdir(folder, { withFileTypes: true })) {
|
|
143
|
-
if (!item.isFile() || item.name === SKILL_FILE) {
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
const value = await readOptional(join(folder, item.name));
|
|
147
|
-
if (value !== undefined) {
|
|
148
|
-
out[item.name] = value;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return out;
|
|
152
|
-
}
|
|
153
150
|
async function readOptional(path) {
|
|
154
151
|
try {
|
|
155
152
|
return await readFile(path, 'utf8');
|
package/dist/skills.d.ts
CHANGED
|
@@ -23,7 +23,14 @@ export interface Skill extends SkillSummary {
|
|
|
23
23
|
content: string;
|
|
24
24
|
/** tools unlocked while the skill is active; the context type is the host agent's */
|
|
25
25
|
tools?: AnyTool<any>[];
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Where the skill's own files are, *under a name the agent can use* — not a
|
|
28
|
+
* host path. A skill that ships a script is instructions plus that script,
|
|
29
|
+
* and its text says "run scripts/fill.py" without saying where `scripts/`
|
|
30
|
+
* is; this is the answer, and it is only set when the host has actually put
|
|
31
|
+
* the folder somewhere the agent can reach.
|
|
32
|
+
*/
|
|
33
|
+
path?: string;
|
|
27
34
|
/** absolute path of the source file, if known (set by file-backed providers) */
|
|
28
35
|
file?: string;
|
|
29
36
|
}
|
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) {
|
|
@@ -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,29 @@ 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)}.`);
|
|
546
644
|
/**
|
|
547
645
|
* `.`, `/`, the mount and nothing at all are four spellings of the root, and
|
|
548
646
|
* a tool whose path is optional takes all four. A model that means "the top"
|
|
@@ -687,7 +785,7 @@ export function workspaceTools(opts) {
|
|
|
687
785
|
if (content.length > MAX_WRITE) {
|
|
688
786
|
return { error: `content exceeds ${MAX_WRITE} bytes` };
|
|
689
787
|
}
|
|
690
|
-
const at = ws.within(path);
|
|
788
|
+
const at = ws.within(path, { write: true });
|
|
691
789
|
await mkdir(dirname(at), { recursive: true });
|
|
692
790
|
await writeFile(at, content, 'utf8');
|
|
693
791
|
return {
|
|
@@ -770,12 +868,12 @@ export function workspaceTools(opts) {
|
|
|
770
868
|
},
|
|
771
869
|
execute: async ({ from, to, overwrite }) => {
|
|
772
870
|
ws.mutable();
|
|
773
|
-
const source = ws.within(from);
|
|
774
|
-
const target = ws.within(to);
|
|
775
|
-
if (
|
|
871
|
+
const source = ws.within(from, { write: true });
|
|
872
|
+
const target = ws.within(to, { write: true });
|
|
873
|
+
if (ws.isRoot(source)) {
|
|
776
874
|
return { error: 'refusing to move the workspace root' };
|
|
777
875
|
}
|
|
778
|
-
if (
|
|
876
|
+
if (ws.isRoot(target)) {
|
|
779
877
|
return { error: 'refusing to overwrite the workspace root' };
|
|
780
878
|
}
|
|
781
879
|
if (source === target) {
|
|
@@ -819,8 +917,8 @@ export function workspaceTools(opts) {
|
|
|
819
917
|
},
|
|
820
918
|
execute: async ({ path, recursive }) => {
|
|
821
919
|
ws.mutable();
|
|
822
|
-
const at = ws.within(path);
|
|
823
|
-
if (
|
|
920
|
+
const at = ws.within(path, { write: true });
|
|
921
|
+
if (ws.isRoot(at)) {
|
|
824
922
|
return { error: 'refusing to delete the workspace root' };
|
|
825
923
|
}
|
|
826
924
|
const info = await stat(at);
|
|
@@ -857,19 +955,35 @@ export function workspaceTools(opts) {
|
|
|
857
955
|
additionalProperties: false,
|
|
858
956
|
},
|
|
859
957
|
execute: async ({ pattern, path }) => {
|
|
860
|
-
|
|
958
|
+
// Omitting the path means "everywhere I can see", and what the
|
|
959
|
+
// model can see now includes the mounted trees. Searching only the
|
|
960
|
+
// workspace would leave them behind a path it has to know to ask
|
|
961
|
+
// for. A tree nested inside the workspace is walked twice, so the
|
|
962
|
+
// hits are a set: `show` gives one file one name either way.
|
|
963
|
+
const where = path?.trim()
|
|
964
|
+
? [ws.within(path)]
|
|
965
|
+
: [ws.root, ...ws.mounts.map((m) => m.host)];
|
|
861
966
|
const needle = pattern.toLowerCase();
|
|
862
|
-
const hits =
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
967
|
+
const hits = new Set();
|
|
968
|
+
for (const dir of where) {
|
|
969
|
+
const room = await walk(dir, ws, (file) => {
|
|
970
|
+
// Matched on the path within its own tree, reported under
|
|
971
|
+
// the name the model uses: a mount prefix is on every
|
|
972
|
+
// candidate, so matching it would mean nothing.
|
|
973
|
+
if (ws.rel(file).toLowerCase().includes(needle)) {
|
|
974
|
+
hits.add(ws.show(file));
|
|
975
|
+
}
|
|
976
|
+
return hits.size < MAX_ENTRIES;
|
|
977
|
+
});
|
|
978
|
+
if (!room) {
|
|
979
|
+
break;
|
|
869
980
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
pattern,
|
|
984
|
+
matches: [...hits],
|
|
985
|
+
truncated: hits.size >= MAX_ENTRIES,
|
|
986
|
+
};
|
|
873
987
|
},
|
|
874
988
|
});
|
|
875
989
|
return opts.readOnly
|
|
@@ -883,8 +997,8 @@ async function runPatch(ws, patch) {
|
|
|
883
997
|
const seen = new Set();
|
|
884
998
|
let fuzz = 0;
|
|
885
999
|
for (const op of ops) {
|
|
886
|
-
const at = ws
|
|
887
|
-
if (
|
|
1000
|
+
const at = patchPath(ws, op.path);
|
|
1001
|
+
if (ws.isRoot(at)) {
|
|
888
1002
|
throw new PatchError(`${op.path} is the workspace root`);
|
|
889
1003
|
}
|
|
890
1004
|
if (seen.has(at)) {
|
|
@@ -953,8 +1067,8 @@ async function runPatch(ws, patch) {
|
|
|
953
1067
|
lines: patched.lines.length,
|
|
954
1068
|
};
|
|
955
1069
|
if (op.moveTo) {
|
|
956
|
-
const to = ws
|
|
957
|
-
if (
|
|
1070
|
+
const to = patchPath(ws, op.moveTo);
|
|
1071
|
+
if (ws.isRoot(to)) {
|
|
958
1072
|
throw new PatchError('cannot move a file onto the workspace root');
|
|
959
1073
|
}
|
|
960
1074
|
if (to !== at) {
|
|
@@ -1000,6 +1114,21 @@ async function statOrNull(at) {
|
|
|
1000
1114
|
return null;
|
|
1001
1115
|
}
|
|
1002
1116
|
}
|
|
1117
|
+
/**
|
|
1118
|
+
* A path in a patch, checked for containment and for write permission before
|
|
1119
|
+
* the first byte is written. Both refusals become patch errors, because the
|
|
1120
|
+
* promise apply_patch makes is that a patch it will not finish leaves nothing
|
|
1121
|
+
* behind — a read-only file named halfway down is a reason to reject the whole
|
|
1122
|
+
* patch, not to write the files above it and then throw.
|
|
1123
|
+
*/
|
|
1124
|
+
function patchPath(ws, path) {
|
|
1125
|
+
try {
|
|
1126
|
+
return ws.within(path, { write: true });
|
|
1127
|
+
}
|
|
1128
|
+
catch (err) {
|
|
1129
|
+
throw new PatchError(err instanceof Error ? err.message : String(err));
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1003
1132
|
/** Depth-first, skipping the noise nobody means to search. */
|
|
1004
1133
|
const SKIP = new Set(['.git', 'node_modules', '.data', 'dist', '.venv', '__pycache__']);
|
|
1005
1134
|
async function walk(dir, ws, visit) {
|