@zenera/cli 1.1.23 → 1.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/dist/ask.d.ts +51 -0
- package/dist/ask.js +143 -0
- package/dist/batch.d.ts +77 -0
- package/dist/batch.js +338 -0
- package/dist/command.d.ts +6 -0
- package/dist/commands/inspect.d.ts +11 -0
- package/dist/commands/inspect.js +388 -89
- package/dist/commands/list.js +128 -19
- package/dist/commands/memory.js +342 -12
- package/dist/commands/models.js +14 -2
- package/dist/commands/run.js +139 -34
- package/dist/commands/sandbox.js +72 -1
- package/dist/engine.d.ts +19 -0
- package/dist/engine.js +60 -8
- package/dist/liveness.js +6 -3
- package/dist/main.js +5 -2
- package/dist/podman.d.ts +16 -0
- package/dist/podman.js +115 -2
- package/dist/progress.d.ts +33 -0
- package/dist/progress.js +402 -0
- package/dist/request.d.ts +45 -0
- package/dist/request.js +300 -0
- package/dist/sandbox.js +10 -0
- package/dist/session.d.ts +26 -1
- package/dist/session.js +42 -3
- package/dist/term.d.ts +3 -0
- package/dist/term.js +17 -1
- package/dist/trace.d.ts +84 -0
- package/dist/trace.js +686 -0
- package/dist/validate.js +25 -3
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +11 -6
- package/templates/editor/.github/prompts/finetune.prompt.md +251 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +9 -3
- package/templates/editor/.github/skills/zen-cli/references/check.md +7 -5
- package/templates/editor/.github/skills/zen-cli/references/inspect.md +262 -21
- package/templates/editor/.github/skills/zen-cli/references/memory.md +89 -19
- package/templates/editor/.github/skills/zen-cli/references/models.md +1 -1
- package/templates/editor/.github/skills/zen-cli/references/projects.md +12 -5
- package/templates/editor/.github/skills/zen-cli/references/run.md +201 -4
- package/templates/editor/.github/skills/zen-finetune/SKILL.md +1000 -0
- package/templates/editor/.github/skills/zen-finetune/scripts/collect.sh +432 -0
- package/templates/editor/.github/skills/zen-finetune/scripts/sample.sh +199 -0
- package/templates/editor/.github/skills/zen-inspect/SKILL.md +396 -0
- package/templates/editor/.github/skills/zen-memory/SKILL.md +74 -29
- package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +363 -531
- package/templates/editor/.github/skills/zen-sandbox/SKILL.md +19 -0
- package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +334 -0
- package/templates/editor/.github/skills/zen-sandbox-capacity/references/loadtest.md +181 -0
- package/templates/editor/.github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh +457 -0
- package/templates/editor/.vscode/agents.schema.json +1 -1
- package/templates/editor/.vscode/settings.json +1 -0
- package/templates/project/agents/files-instructions.md +13 -0
- package/templates/project/agents/fork-instructions.md +27 -0
- package/templates/project/agents/memory-instructions.md +80 -7
- package/templates/project/gitignore +3 -0
package/dist/commands/run.js
CHANGED
|
@@ -1,29 +1,37 @@
|
|
|
1
1
|
import { writeFile } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { parse } from "../args.js";
|
|
4
|
+
import { concurrency, runBatch } from "../batch.js";
|
|
4
5
|
import * as Engine from "../engine.js";
|
|
5
6
|
import { duration, Narrator, stopMark, summary } from "../narrate.js";
|
|
6
7
|
import * as Projects from "../projects.js";
|
|
8
|
+
import { readBatch, readRequest } from "../request.js";
|
|
7
9
|
import { target } from "../resolve.js";
|
|
8
10
|
import { display } from "../session.js";
|
|
9
|
-
import { bold, cyan, dim, json, note, readStdin, usageError, write } from "../term.js";
|
|
11
|
+
import { bold, cyan, dim, json, jsonText, note, readStdin, usageError, write } from "../term.js";
|
|
10
12
|
import { parseChoice } from "../tui/theme.js";
|
|
11
13
|
const USAGE = 'zen run [project] [prompt] [options]';
|
|
12
14
|
export const run = {
|
|
13
15
|
summary: 'Run the project — the TUI on a terminal, one shot otherwise.',
|
|
14
16
|
usage: USAGE,
|
|
15
17
|
banner: { head: 'Zenera', accent: 'Neo', subtitle: 'Agentic Runtime', hue: 'orange' },
|
|
18
|
+
// A batch prints the path of its directory, and a banner above a path is a
|
|
19
|
+
// banner inside `$(zen run batch ...)`. True whether or not --json is on.
|
|
20
|
+
quiet: (args) => args[0] === 'batch',
|
|
16
21
|
details: [
|
|
17
22
|
'Arguments:',
|
|
18
23
|
' [project] Name of a project. Default: the one you are in.',
|
|
19
24
|
' [prompt] Your question, in quotes. Without one, the TUI opens.',
|
|
25
|
+
' batch Run a file full of questions at once. See below.',
|
|
20
26
|
'',
|
|
21
27
|
'Options:',
|
|
22
28
|
' --project <name|dir> Which project to run. Default: the one you are in.',
|
|
23
29
|
' --session <id> Continue this session.',
|
|
24
30
|
' --new Start a new session without asking which one.',
|
|
31
|
+
' --input <file> Read the whole request from JSON. `-` is stdin.',
|
|
25
32
|
' --workspace <dir> Directory the agent can read and write.',
|
|
26
33
|
' --memory <dir> Directory the agents remember into.',
|
|
34
|
+
' --memory-read-only Recall from it, write nothing back.',
|
|
27
35
|
' --model <ref> Use this model instead of the default.',
|
|
28
36
|
' --image <ref> Use this container image to run commands in.',
|
|
29
37
|
' --read-only Take away every tool that can write.',
|
|
@@ -31,9 +39,14 @@ export const run = {
|
|
|
31
39
|
' --plain Never open the TUI. Needs a prompt.',
|
|
32
40
|
' --theme <dark|light> Colours for the TUI. Default: auto.',
|
|
33
41
|
' --out <file> Put the answer in this file instead of on screen.',
|
|
42
|
+
' With --json, the file gets the whole JSON envelope.',
|
|
34
43
|
' --yes Answer yes to every question.',
|
|
35
44
|
' --json Print machine-readable JSON (session, run, mounts, output, etc...).',
|
|
36
45
|
'',
|
|
46
|
+
'Batch options:',
|
|
47
|
+
' --batch-dir <dir> Where the runs live. Default: <project>/batches/<stamp>.',
|
|
48
|
+
' --concurrency <n> How many at a time. Default: 16, most: 32.',
|
|
49
|
+
'',
|
|
37
50
|
'The first word is the project when it names one, otherwise the prompt.',
|
|
38
51
|
'The answer goes to stdout and the progress to stderr, so `> out.md` keeps',
|
|
39
52
|
'the answer alone and `2>/dev/null` hides the progress.',
|
|
@@ -41,6 +54,39 @@ export const run = {
|
|
|
41
54
|
'A prompt always starts a new session, so --new is for the TUI, where the',
|
|
42
55
|
'alternative is being asked which session to continue.',
|
|
43
56
|
'',
|
|
57
|
+
'--input names a file holding the whole request - project, input, workspace,',
|
|
58
|
+
'memory - and what it says wins over the same flag on the line. Its paths are',
|
|
59
|
+
'relative to the file, so a case travels with the images beside it. It is the',
|
|
60
|
+
'way to ask about a picture, and it never opens the TUI:',
|
|
61
|
+
'',
|
|
62
|
+
' { "input": [{ "text": "what is this?" }, { "image": "./shot.png" }] }',
|
|
63
|
+
'',
|
|
64
|
+
'`zen run batch --input cases.json` asks a whole file of them at once. The',
|
|
65
|
+
'file is the same shape, pluralised, and nothing else is allowed in it:',
|
|
66
|
+
'',
|
|
67
|
+
' { "batch": [{ "id": "vat", "input": "what is VAT?" }, { "input": "..." }] }',
|
|
68
|
+
'',
|
|
69
|
+
'Every item is its own session, in its own directory under the batch dir:',
|
|
70
|
+
'',
|
|
71
|
+
' <batch-dir>/<id>/workspace/ what that item could read and write',
|
|
72
|
+
' <batch-dir>/<id>/memory/ its own copy, in the copying mode',
|
|
73
|
+
' <batch-dir>/<id>/output.json exactly what `zen run --json` prints',
|
|
74
|
+
' <batch-dir>/batch.json the index: every item, ok, and its file',
|
|
75
|
+
' <batch-dir>/README.md a dashboard, rewritten every second',
|
|
76
|
+
'',
|
|
77
|
+
'Memory comes in two modes, because a memory is a locked directory and',
|
|
78
|
+
'sixteen runs cannot hold one lock. With --memory-read-only they all recall',
|
|
79
|
+
'from the one graph and write nothing. Without it, each gets a copy, and the',
|
|
80
|
+
"project's own memory is never touched - fold them back in afterwards with",
|
|
81
|
+
'`zen memory merge <batch-dir>/*/memory`. An item that committed nothing',
|
|
82
|
+
'leaves no memory behind, and a --memory that does not exist yet starts every',
|
|
83
|
+
'item from an empty graph - which is how a cold project is warmed.',
|
|
84
|
+
'',
|
|
85
|
+
'A batch prints the path of its directory and nothing else; --json prints',
|
|
86
|
+
'the index there instead. An item that fails is written down like any other',
|
|
87
|
+
'and the exit code says how many did, so one bad question costs one answer.',
|
|
88
|
+
'For a project or a prompt actually called "batch", say --project batch.',
|
|
89
|
+
'',
|
|
44
90
|
'Examples:',
|
|
45
91
|
' zen run open the TUI in this project',
|
|
46
92
|
' zen run acme open the TUI in the acme project',
|
|
@@ -49,17 +95,24 @@ export const run = {
|
|
|
49
95
|
' zen run "what changed?" > out.md redirect the answer into a file',
|
|
50
96
|
' zen run --out out.md "what changed?" write the answer to out.md only',
|
|
51
97
|
' zen run --json "what changed?" output JSON with workspace, run paths, state, etc...',
|
|
98
|
+
' zen run --input case.json --json run a request from a file, answer as JSON',
|
|
99
|
+
' zen run --input case.json --json --out result.json ... into a file',
|
|
52
100
|
' zen run --read-only "what changed?" let it read but not write',
|
|
53
101
|
' zen run --memory ./mem remember into ./mem, not the project',
|
|
54
102
|
' zen run --new open the TUI in a new session',
|
|
103
|
+
' zen run batch --input cases.json ask them all, 16 at a time',
|
|
104
|
+
' zen run batch --input cases.json --memory-read-only ... sharing one memory',
|
|
105
|
+
' zen run batch --input cases.json --json | jq .batch_results',
|
|
55
106
|
],
|
|
56
107
|
run: async (ctx) => {
|
|
57
108
|
const { values, positionals } = parse(ctx.args, {
|
|
58
109
|
project: { type: 'string' },
|
|
59
110
|
session: { type: 'string' },
|
|
60
111
|
new: { type: 'boolean' },
|
|
112
|
+
input: { type: 'string' },
|
|
61
113
|
workspace: { type: 'string' },
|
|
62
114
|
memory: { type: 'string' },
|
|
115
|
+
'memory-read-only': { type: 'boolean' },
|
|
63
116
|
model: { type: 'string' },
|
|
64
117
|
image: { type: 'string' },
|
|
65
118
|
'no-keys': { type: 'boolean' },
|
|
@@ -68,8 +121,16 @@ export const run = {
|
|
|
68
121
|
plain: { type: 'boolean' },
|
|
69
122
|
theme: { type: 'string' },
|
|
70
123
|
out: { type: 'string' },
|
|
124
|
+
'batch-dir': { type: 'string' },
|
|
125
|
+
concurrency: { type: 'string' },
|
|
71
126
|
}, USAGE);
|
|
72
127
|
const piped = await readStdin();
|
|
128
|
+
// Before the first positional is read as a project name, because in a
|
|
129
|
+
// batch it is not one and `batch` is not a prompt either.
|
|
130
|
+
if (positionals[0] === 'batch') {
|
|
131
|
+
await batch(ctx, values, positionals.slice(1), piped);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
73
134
|
// `zen run acme` is what everyone types before finding --project, and a
|
|
74
135
|
// project name is a bare word where a prompt is a sentence. So the
|
|
75
136
|
// first positional is read as a project when it names one and as the
|
|
@@ -78,23 +139,31 @@ export const run = {
|
|
|
78
139
|
// there for the day a project is called "why".
|
|
79
140
|
const [head, ...rest] = positionals;
|
|
80
141
|
const named = !values.project && head ? await Projects.find(head) : undefined;
|
|
81
|
-
const
|
|
142
|
+
const typed = (named ? rest : positionals).join(' ').trim();
|
|
143
|
+
if (values.input && typed) {
|
|
144
|
+
throw usageError('a prompt and --input both say what to ask', 'put the prompt in the file, or drop --input');
|
|
145
|
+
}
|
|
82
146
|
if (values.theme !== undefined && !parseChoice(values.theme)) {
|
|
83
147
|
throw usageError(`unknown theme: ${values.theme}`, 'dark, light or auto');
|
|
84
148
|
}
|
|
149
|
+
// With `--input -` the pipe carries the request, not the prompt, so it
|
|
150
|
+
// is read here and never looked at again below.
|
|
151
|
+
const request = values.input ? await readRequest(values.input, ctx.cwd, piped) : undefined;
|
|
152
|
+
const prompt = typed || (request ? '' : (piped ?? ''));
|
|
85
153
|
// A prompt on the command line is a request for an answer, not a
|
|
86
154
|
// conversation to pick up. So it answers the three questions itself:
|
|
87
155
|
// a fresh session, the directory it was typed in as the workspace,
|
|
88
156
|
// and no confirmation for it — `zen run acme "what changed?"` should
|
|
89
157
|
// read the code that is right there. Every flag still wins, and the
|
|
90
158
|
// TUI, where there is someone to ask, still asks.
|
|
91
|
-
const
|
|
159
|
+
const input = request ? request.input : prompt;
|
|
160
|
+
const shot = Boolean(prompt) || Boolean(request);
|
|
92
161
|
const where = await target({
|
|
93
162
|
cwd: ctx.cwd,
|
|
94
|
-
project: values.project ?? (named ? head : undefined),
|
|
163
|
+
project: request?.project ?? values.project ?? (named ? head : undefined),
|
|
95
164
|
session: values.session,
|
|
96
165
|
fresh: values.new || (shot && !values.session),
|
|
97
|
-
workspace: values.workspace ?? (shot ? ctx.cwd : undefined),
|
|
166
|
+
workspace: request?.workspace ?? values.workspace ?? (shot ? ctx.cwd : undefined),
|
|
98
167
|
yes: values.yes || shot,
|
|
99
168
|
});
|
|
100
169
|
const engine = await Engine.open({
|
|
@@ -104,8 +173,10 @@ export const run = {
|
|
|
104
173
|
model: values.model,
|
|
105
174
|
image: values.image,
|
|
106
175
|
// Relative to where it was typed, like every other path on the line
|
|
107
|
-
// — the project root is not the cwd.
|
|
108
|
-
|
|
176
|
+
// — the project root is not the cwd. A request's paths are already
|
|
177
|
+
// absolute, resolved against the file rather than against this.
|
|
178
|
+
memoryDir: request?.memory ?? (values.memory ? resolve(ctx.cwd, values.memory) : undefined),
|
|
179
|
+
memoryReadOnly: values['memory-read-only'],
|
|
109
180
|
keys: values['no-keys'] ? false : undefined,
|
|
110
181
|
yes: values.yes || ctx.json,
|
|
111
182
|
});
|
|
@@ -114,7 +185,7 @@ export const run = {
|
|
|
114
185
|
// terminal, with nothing to say yet. A prompt on the command line
|
|
115
186
|
// is a request for an answer, and drawing a full-screen interface
|
|
116
187
|
// over it would be worse than not drawing one.
|
|
117
|
-
const drawing = !
|
|
188
|
+
const drawing = !shot &&
|
|
118
189
|
!values.plain &&
|
|
119
190
|
!ctx.json &&
|
|
120
191
|
Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
@@ -127,10 +198,10 @@ export const run = {
|
|
|
127
198
|
});
|
|
128
199
|
return;
|
|
129
200
|
}
|
|
130
|
-
if (!
|
|
201
|
+
if (!shot) {
|
|
131
202
|
throw usageError('nothing to ask', 'give a prompt, or pipe one in');
|
|
132
203
|
}
|
|
133
|
-
await once(engine,
|
|
204
|
+
await once(engine, input, values, ctx.json, ctx.cwd, where);
|
|
134
205
|
}
|
|
135
206
|
finally {
|
|
136
207
|
await engine.close();
|
|
@@ -138,9 +209,54 @@ export const run = {
|
|
|
138
209
|
},
|
|
139
210
|
};
|
|
140
211
|
// ---------------------------------------------------------------------------
|
|
212
|
+
// Many shots
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
/**
|
|
215
|
+
* Flags that mean something to one run and nothing to a hundred. Refused by
|
|
216
|
+
* name rather than ignored: a `--workspace` that was quietly dropped is a batch
|
|
217
|
+
* whose answers came from somewhere else.
|
|
218
|
+
*/
|
|
219
|
+
const NOT_IN_A_BATCH = [
|
|
220
|
+
['session', 'every item is a new session of its own'],
|
|
221
|
+
['new', 'every item is new already'],
|
|
222
|
+
['workspace', 'they cannot share one; each item gets its own, or names one in the file'],
|
|
223
|
+
['plain', 'a batch never draws the TUI'],
|
|
224
|
+
['theme', 'a batch never draws the TUI'],
|
|
225
|
+
];
|
|
226
|
+
async function batch(ctx, values, rest, piped) {
|
|
227
|
+
if (rest.length > 0) {
|
|
228
|
+
throw usageError(`zen run batch: nothing goes after "batch" (got "${rest[0]}")`, 'the questions live in the file named by --input');
|
|
229
|
+
}
|
|
230
|
+
for (const [flag, why] of NOT_IN_A_BATCH) {
|
|
231
|
+
if (values[flag] !== undefined && values[flag] !== false) {
|
|
232
|
+
throw usageError(`--${flag} means nothing in a batch`, why);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!values.input) {
|
|
236
|
+
throw usageError('zen run batch needs --input <file>', 'a JSON file holding the batch');
|
|
237
|
+
}
|
|
238
|
+
await runBatch({
|
|
239
|
+
request: await readBatch(values.input, ctx.cwd, piped),
|
|
240
|
+
input: values.input === '-' ? '<stdin>' : resolve(ctx.cwd, values.input),
|
|
241
|
+
dir: values['batch-dir'],
|
|
242
|
+
cwd: ctx.cwd,
|
|
243
|
+
project: values.project,
|
|
244
|
+
memory: values.memory ? resolve(ctx.cwd, values.memory) : undefined,
|
|
245
|
+
memoryReadOnly: values['memory-read-only'],
|
|
246
|
+
concurrency: concurrency(values.concurrency),
|
|
247
|
+
model: values.model,
|
|
248
|
+
image: values.image,
|
|
249
|
+
readOnly: values['read-only'],
|
|
250
|
+
keys: values['no-keys'] ? false : undefined,
|
|
251
|
+
yes: values.yes,
|
|
252
|
+
out: values.out,
|
|
253
|
+
json: ctx.json,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
141
257
|
// One shot
|
|
142
258
|
// ---------------------------------------------------------------------------
|
|
143
|
-
async function once(engine,
|
|
259
|
+
async function once(engine, input, values, asJson, cwd, where) {
|
|
144
260
|
const narrator = new Narrator({
|
|
145
261
|
quiet: asJson,
|
|
146
262
|
live: Boolean(process.stderr.isTTY),
|
|
@@ -162,39 +278,28 @@ async function once(engine, prompt, values, asJson, cwd, where) {
|
|
|
162
278
|
}
|
|
163
279
|
let outcome;
|
|
164
280
|
try {
|
|
165
|
-
outcome = await Engine.run(engine,
|
|
281
|
+
outcome = await Engine.run(engine, input, narrator.handle, stopping.signal);
|
|
166
282
|
}
|
|
167
283
|
finally {
|
|
168
284
|
narrator.done();
|
|
169
285
|
process.off('SIGINT', onInterrupt);
|
|
170
286
|
}
|
|
171
|
-
if (values.out) {
|
|
172
|
-
await writeFile(values.out, `${outcome.text}\n`, 'utf8');
|
|
173
|
-
}
|
|
174
287
|
if (asJson) {
|
|
175
|
-
json
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
...(outcome.report ? { report: outcome.report } : {}),
|
|
185
|
-
},
|
|
186
|
-
mounts: Engine.mounts(engine),
|
|
187
|
-
agent: outcome.result.agent,
|
|
188
|
-
stopReason: outcome.result.stopReason,
|
|
189
|
-
durationMs: outcome.durationMs,
|
|
190
|
-
usage: outcome.result.usage,
|
|
191
|
-
output: outcome.text,
|
|
192
|
-
});
|
|
288
|
+
// --out is a destination, not a copy, and with --json the answer *is*
|
|
289
|
+
// the envelope — so that is what lands in the file, not the prose.
|
|
290
|
+
const body = Engine.envelope(engine, outcome);
|
|
291
|
+
if (values.out) {
|
|
292
|
+
await writeFile(values.out, jsonText(body), 'utf8');
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
json(body);
|
|
296
|
+
}
|
|
193
297
|
return;
|
|
194
298
|
}
|
|
195
|
-
//
|
|
299
|
+
// The same rule for prose: the answer goes to the file instead of to
|
|
196
300
|
// stdout, so a redirect and a file cannot both end up holding it.
|
|
197
301
|
if (values.out) {
|
|
302
|
+
await writeFile(values.out, `${outcome.text}\n`, 'utf8');
|
|
198
303
|
note(dim(`answer: ${cyan(values.out)}`));
|
|
199
304
|
}
|
|
200
305
|
else {
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readProjectConfig } from '@zenera/neo';
|
|
1
|
+
import { readProjectConfig, sandboxLimits, } from '@zenera/neo';
|
|
2
2
|
import { parse } from "../args.js";
|
|
3
3
|
import { resolveBuild } from "../image.js";
|
|
4
4
|
import { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "../podman.js";
|
|
@@ -9,6 +9,8 @@ const USAGE = 'zen sandbox [status|up|pull|clean|disk] [options]';
|
|
|
9
9
|
const LISTED = 6;
|
|
10
10
|
/** Under the labels, which is where the eye already is. */
|
|
11
11
|
const INDENT = ' '.repeat(11);
|
|
12
|
+
/** `agents.yaml` states memory in MiB; `bytes` prints bytes. */
|
|
13
|
+
const MIB = 1024 * 1024;
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
13
15
|
// The container engine, on its own
|
|
14
16
|
//
|
|
@@ -97,6 +99,7 @@ async function projectSandbox(cwd, values) {
|
|
|
97
99
|
name: found.name,
|
|
98
100
|
image: build?.tag ?? config.sandbox?.image,
|
|
99
101
|
build,
|
|
102
|
+
limits: widest(config),
|
|
100
103
|
};
|
|
101
104
|
}
|
|
102
105
|
catch {
|
|
@@ -118,6 +121,8 @@ async function status(project, image, asJson) {
|
|
|
118
121
|
...found,
|
|
119
122
|
project: project?.name ?? null,
|
|
120
123
|
dockerfile: build?.dockerfile ?? null,
|
|
124
|
+
limits: project?.limits ?? null,
|
|
125
|
+
fits: fits(found.capacity?.memory, project?.limits?.memory) ?? null,
|
|
121
126
|
containers,
|
|
122
127
|
});
|
|
123
128
|
return;
|
|
@@ -128,7 +133,17 @@ async function status(project, image, asJson) {
|
|
|
128
133
|
const state = found.machine.starting ? yellow('starting') : mark(found.machine.running);
|
|
129
134
|
write(`${bold('machine')} ${found.machine.name} ${state}`);
|
|
130
135
|
}
|
|
136
|
+
if (found.connection) {
|
|
137
|
+
// A concurrency limit wearing a connection string: `ssh://` opens one
|
|
138
|
+
// connection per call and sshd refuses them past ten, which stays
|
|
139
|
+
// invisible until a batch is wide enough to lose tasks at random.
|
|
140
|
+
const how = found.connection.shared
|
|
141
|
+
? dim('· one shared connection, no limit on parallel calls')
|
|
142
|
+
: yellow('· a new ssh connection per call — about 10 at a time');
|
|
143
|
+
write(`${bold('connection')} ${dim(found.connection.uri)} ${how}`);
|
|
144
|
+
}
|
|
131
145
|
write(`${bold('responds')} ${mark(found.ready)}`);
|
|
146
|
+
writeAll(limitLines(found, project?.limits));
|
|
132
147
|
if (project) {
|
|
133
148
|
write(`${bold('project')} ${project.name} ${dim(project.dir)}`);
|
|
134
149
|
}
|
|
@@ -144,6 +159,62 @@ async function status(project, image, asJson) {
|
|
|
144
159
|
note(dim('run `zen sandbox up` to fix what can be fixed.'));
|
|
145
160
|
}
|
|
146
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* The hungriest container this project can start. An agent may override the
|
|
164
|
+
* project's `sandbox:` block, and it is the largest of them that decides
|
|
165
|
+
* whether a batch fits — reporting the project's own figure would understate
|
|
166
|
+
* the machine by however much an agent asked for on top.
|
|
167
|
+
*
|
|
168
|
+
* The overrides are merged onto the project's block rather than read alone,
|
|
169
|
+
* because that is what `SandboxPool.for` does when it builds the container: an
|
|
170
|
+
* agent that names only `memory:` still runs with the project's `cpus:`.
|
|
171
|
+
*/
|
|
172
|
+
function widest(config) {
|
|
173
|
+
const base = config.sandbox ?? {};
|
|
174
|
+
return config.agents
|
|
175
|
+
.map((a) => sandboxLimits(a.sandbox ? { ...base, ...a.sandbox } : base))
|
|
176
|
+
.reduce((most, one) => (one.memory > most.memory ? one : most), sandboxLimits(base));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* How many containers of this size the engine could hold at once.
|
|
180
|
+
*
|
|
181
|
+
* Nothing enforces it — `--memory` caps one container and the kernel kills
|
|
182
|
+
* whatever overruns the sum, so this is what the machine can honour, not a
|
|
183
|
+
* gate anything checks. It is worth printing because neither figure gives it
|
|
184
|
+
* alone: off Linux the memory is the *machine's*, which has nothing to do with
|
|
185
|
+
* the host, so a few containers at the 4096 MiB default can be past it while
|
|
186
|
+
* the laptop sits nearly empty.
|
|
187
|
+
*/
|
|
188
|
+
function fits(capacity, container) {
|
|
189
|
+
if (!capacity || !container) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
return Math.max(0, Math.floor(capacity / container));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Two ceilings, in the order they bite: what the engine has, and what one
|
|
196
|
+
* container is permitted to take of it.
|
|
197
|
+
*/
|
|
198
|
+
function limitLines(found, limits) {
|
|
199
|
+
const lines = [];
|
|
200
|
+
const where = found.machine ? 'machine' : 'host';
|
|
201
|
+
if (found.capacity) {
|
|
202
|
+
const { memory, swap, cpus } = found.capacity;
|
|
203
|
+
const swapped = swap > 0 ? `${bytes(swap * MIB)} swap` : 'no swap';
|
|
204
|
+
lines.push(`${bold('capacity')} ${bytes(memory * MIB)} ${dim(`· ${swapped} · ${cpus} cpus · the ${where}'s, not the host's`)}`);
|
|
205
|
+
}
|
|
206
|
+
if (limits) {
|
|
207
|
+
const room = fits(found.capacity?.memory, limits.memory);
|
|
208
|
+
const how = limits.declared ? limits.hardening : `${limits.hardening} defaults`;
|
|
209
|
+
const tail = room === undefined
|
|
210
|
+
? how
|
|
211
|
+
: room > 0
|
|
212
|
+
? `· ${how} · the ${where} has room for ${room}`
|
|
213
|
+
: `· ${how} · more than the ${where} has`;
|
|
214
|
+
lines.push(`${bold('container')} ${bytes(limits.memory * MIB)} ${dim(`· ${limits.cpus} cpus ${tail}`)}`);
|
|
215
|
+
}
|
|
216
|
+
return lines;
|
|
217
|
+
}
|
|
147
218
|
/**
|
|
148
219
|
* The containers this project's sessions made. A container carries the session
|
|
149
220
|
* id that made it in a label, and a session id is a directory name under the
|
package/dist/engine.d.ts
CHANGED
|
@@ -12,10 +12,18 @@ export interface EngineOptions {
|
|
|
12
12
|
image?: string;
|
|
13
13
|
/** another directory to remember into — `--memory` */
|
|
14
14
|
memoryDir?: string;
|
|
15
|
+
/** recall only: nothing is written and several runs may share one graph */
|
|
16
|
+
memoryReadOnly?: boolean;
|
|
15
17
|
/** whether credentials reach the sandbox — `--no-keys` sets this false */
|
|
16
18
|
keys?: boolean;
|
|
17
19
|
/** answer the sandbox's install question without asking — `--yes` */
|
|
18
20
|
yes?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Hold back the warnings about the project rather than the run. A batch
|
|
23
|
+
* opens one project a hundred times, and a hundred copies of the same
|
|
24
|
+
* complaint is a worse report than one.
|
|
25
|
+
*/
|
|
26
|
+
quiet?: boolean;
|
|
19
27
|
}
|
|
20
28
|
export interface Engine {
|
|
21
29
|
project: AgentProject;
|
|
@@ -27,6 +35,8 @@ export interface Engine {
|
|
|
27
35
|
/** whether the agent was denied every writing tool */
|
|
28
36
|
readOnly: boolean;
|
|
29
37
|
session: SessionPaths;
|
|
38
|
+
/** the graph this session remembers into, when any agent has one */
|
|
39
|
+
memory?: string;
|
|
30
40
|
/** the session's accumulated state, when it has one */
|
|
31
41
|
state?: AgentState;
|
|
32
42
|
/** the containers this session may start; present whether or not it does */
|
|
@@ -56,7 +66,16 @@ export interface RunOutcome {
|
|
|
56
66
|
durationMs: number;
|
|
57
67
|
/** where the report landed, when one could be rendered */
|
|
58
68
|
report?: string;
|
|
69
|
+
/** where the Mermaid graph landed, when one could be emitted */
|
|
70
|
+
graph?: string;
|
|
59
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* What one finished run looks like to a program: `zen run --json` prints it,
|
|
74
|
+
* and a batch writes one beside every item. The same function on purpose — a
|
|
75
|
+
* batch whose items were shaped differently would make every consumer of a
|
|
76
|
+
* single run a special case.
|
|
77
|
+
*/
|
|
78
|
+
export declare function envelope(engine: Engine, outcome: RunOutcome): Record<string, unknown>;
|
|
60
79
|
/**
|
|
61
80
|
* One turn. The session's state decides whether this starts a run or continues
|
|
62
81
|
* one — which is the whole of what `resume` used to be, and why it is not a
|
package/dist/engine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AgentRunner, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns,
|
|
1
|
+
import { AgentRunner, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, exaTools, fileTools, lastText, loadProject, memoryDir, readProjectConfig, renderReportHtml, sandboxTools, turns, } from '@zenera/neo';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { writeFile } from 'node:fs/promises';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
@@ -8,8 +8,9 @@ import { readJson, writeJson } from "./home.js";
|
|
|
8
8
|
import { KeyStore, assertUsable } from "./keys.js";
|
|
9
9
|
import { projectMounts } from "./projects.js";
|
|
10
10
|
import { buildSandbox, preflight, teardown, usesSandbox } from "./sandbox.js";
|
|
11
|
-
import { acquire, createRun, readSessionMeta, writeSessionMeta, } from "./session.js";
|
|
11
|
+
import { acquire, createRun, readSessionMeta, writeRunMeta, writeSessionMeta, } from "./session.js";
|
|
12
12
|
import { CliError, EXIT, invalidError, warn } from "./term.js";
|
|
13
|
+
import { traceMermaid } from "./trace.js";
|
|
13
14
|
/**
|
|
14
15
|
* Wires the library to a session directory. Every store is file-backed and
|
|
15
16
|
* rooted inside `.data/`, so a session is self-contained: copy the directory
|
|
@@ -27,7 +28,9 @@ export async function open(opts) {
|
|
|
27
28
|
// build: one SDK's words about one model, when the useful answer is which
|
|
28
29
|
// of the project's models are reachable and which are not.
|
|
29
30
|
for (const issue of auditModels(opts.project.dir, keys)) {
|
|
30
|
-
|
|
31
|
+
if (!opts.quiet) {
|
|
32
|
+
warn(describeIssue(issue));
|
|
33
|
+
}
|
|
31
34
|
}
|
|
32
35
|
const meta = await readSessionMeta(opts.session);
|
|
33
36
|
const workspace = resolve(meta.workspace);
|
|
@@ -40,13 +43,15 @@ export async function open(opts) {
|
|
|
40
43
|
let sandbox;
|
|
41
44
|
let project;
|
|
42
45
|
let files;
|
|
46
|
+
let memory;
|
|
43
47
|
try {
|
|
44
48
|
const { root, config } = readProjectConfig(opts.project.dir);
|
|
45
49
|
// Naming a directory declares the graph, but it cannot bind anyone to
|
|
46
50
|
// it — so say when the run is pointed at a memory no agent will touch.
|
|
47
|
-
if (opts.memoryDir && !config.agents.some((a) => a.memory)) {
|
|
51
|
+
if (opts.memoryDir && !opts.quiet && !config.agents.some((a) => a.memory)) {
|
|
48
52
|
warn('--memory names a directory no agent uses — none has `memory: true`');
|
|
49
53
|
}
|
|
54
|
+
memory = memoryDir(root, config, opts.memoryDir);
|
|
50
55
|
// Assets and the skill catalog are mounted for both, under one name.
|
|
51
56
|
const mounts = projectMounts(root, config, opts.memoryDir);
|
|
52
57
|
sandbox = buildSandbox({
|
|
@@ -74,7 +79,7 @@ export async function open(opts) {
|
|
|
74
79
|
// Both groups are pointed at one directory, so both are told the
|
|
75
80
|
// one name for it: whatever `run_command` prints a path as,
|
|
76
81
|
// `read_file` accepts.
|
|
77
|
-
...
|
|
82
|
+
...fileTools(workspaceOptions),
|
|
78
83
|
...sandboxTools(sandbox.pool),
|
|
79
84
|
// Registered whether or not a key exists: the credential is
|
|
80
85
|
// read when a tool is called, so a project that names `exa:*`
|
|
@@ -84,6 +89,7 @@ export async function open(opts) {
|
|
|
84
89
|
],
|
|
85
90
|
skillsAt: SKILLS_MOUNT,
|
|
86
91
|
memoryDir: opts.memoryDir,
|
|
92
|
+
memoryReadOnly: opts.memoryReadOnly,
|
|
87
93
|
payloads,
|
|
88
94
|
resolveFile: (path) => files.within(path),
|
|
89
95
|
});
|
|
@@ -118,6 +124,7 @@ export async function open(opts) {
|
|
|
118
124
|
workspaceMount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
|
|
119
125
|
readOnly: Boolean(opts.readOnly),
|
|
120
126
|
session: opts.session,
|
|
127
|
+
memory,
|
|
121
128
|
state,
|
|
122
129
|
sandbox,
|
|
123
130
|
lock,
|
|
@@ -151,6 +158,33 @@ export function mounts(engine) {
|
|
|
151
158
|
})),
|
|
152
159
|
];
|
|
153
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* What one finished run looks like to a program: `zen run --json` prints it,
|
|
163
|
+
* and a batch writes one beside every item. The same function on purpose — a
|
|
164
|
+
* batch whose items were shaped differently would make every consumer of a
|
|
165
|
+
* single run a special case.
|
|
166
|
+
*/
|
|
167
|
+
export function envelope(engine, outcome) {
|
|
168
|
+
return {
|
|
169
|
+
session: { id: engine.session.id, dir: engine.session.dir },
|
|
170
|
+
run: {
|
|
171
|
+
id: outcome.run.id,
|
|
172
|
+
dir: outcome.run.dir,
|
|
173
|
+
input: outcome.run.input,
|
|
174
|
+
output: outcome.run.output,
|
|
175
|
+
state: outcome.run.state,
|
|
176
|
+
meta: outcome.run.meta,
|
|
177
|
+
...(outcome.report ? { report: outcome.report } : {}),
|
|
178
|
+
...(outcome.graph ? { graph: outcome.graph } : {}),
|
|
179
|
+
},
|
|
180
|
+
mounts: mounts(engine),
|
|
181
|
+
agent: outcome.result.agent,
|
|
182
|
+
stopReason: outcome.result.stopReason,
|
|
183
|
+
durationMs: outcome.durationMs,
|
|
184
|
+
usage: outcome.result.usage,
|
|
185
|
+
output: outcome.text,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
154
188
|
/**
|
|
155
189
|
* One turn. The session's state decides whether this starts a run or continues
|
|
156
190
|
* one — which is the whole of what `resume` used to be, and why it is not a
|
|
@@ -205,7 +239,24 @@ async function record(engine, input, result, startedAt, durationMs) {
|
|
|
205
239
|
catch {
|
|
206
240
|
// A report is a convenience. Losing it must not lose the run.
|
|
207
241
|
}
|
|
208
|
-
|
|
242
|
+
// The same trajectory for the other reader. `report.html` is for a person;
|
|
243
|
+
// this is what `zen inspect graph` prints, written down while the state is
|
|
244
|
+
// already in hand — a batch of a hundred items is otherwise a hundred more
|
|
245
|
+
// commands to run before anything can be read.
|
|
246
|
+
let graph;
|
|
247
|
+
try {
|
|
248
|
+
await writeFile(run.graph, traceMermaid(result.state, {
|
|
249
|
+
runId: run.id,
|
|
250
|
+
dir: run.dir,
|
|
251
|
+
workspace: engine.workspace,
|
|
252
|
+
memory: engine.memory,
|
|
253
|
+
}), 'utf8');
|
|
254
|
+
graph = run.graph;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// Same rule as the report: a view of the run is not the run.
|
|
258
|
+
}
|
|
259
|
+
writeRunMeta(run, {
|
|
209
260
|
version: 1,
|
|
210
261
|
id: run.id,
|
|
211
262
|
session: session.id,
|
|
@@ -217,9 +268,10 @@ async function record(engine, input, result, startedAt, durationMs) {
|
|
|
217
268
|
turns: turns(result.state),
|
|
218
269
|
usage: result.usage,
|
|
219
270
|
workspace: engine.workspace,
|
|
271
|
+
memory: engine.memory,
|
|
220
272
|
error: result.state.error,
|
|
221
|
-
}
|
|
222
|
-
return { run, result, text, durationMs, report };
|
|
273
|
+
});
|
|
274
|
+
return { run, result, text, durationMs, report, graph };
|
|
223
275
|
}
|
|
224
276
|
function asText(input) {
|
|
225
277
|
if (typeof input === 'string') {
|
package/dist/liveness.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EXA_BASE_URL, ModelRegistry, text, } from '@zenera/neo';
|
|
1
|
+
import { EXA_BASE_URL, isUnfunded, ModelRegistry, text, } from '@zenera/neo';
|
|
2
2
|
import { envOf, SHAPES, } from "./keys.js";
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// Liveness
|
|
@@ -89,8 +89,11 @@ export function classify(err) {
|
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
// A 429 is this minute's rate limit, not an empty account, and the two
|
|
92
|
-
// share vocabulary — so a status that says "slow down" wins.
|
|
93
|
-
|
|
92
|
+
// share vocabulary — so a status that says "slow down" wins. Unless the
|
|
93
|
+
// vendor named the refusal itself: `credit_balance_exhausted` is a 429 too,
|
|
94
|
+
// and it is the account.
|
|
95
|
+
if (isUnfunded(err) ||
|
|
96
|
+
(status !== 429 && UNFUNDED.some((needle) => haystack.includes(needle)))) {
|
|
94
97
|
return {
|
|
95
98
|
state: 'blocked',
|
|
96
99
|
at,
|
package/dist/main.js
CHANGED
|
@@ -6,7 +6,7 @@ import { NEO_BANNER, printBanner } from "./banner.js";
|
|
|
6
6
|
import { ALIASES, COMMANDS, EXTERNAL } from "./commands/index.js";
|
|
7
7
|
import { cliManifest, versionOf } from "./commands/version.js";
|
|
8
8
|
import { hasExternal, loadExternal } from "./external.js";
|
|
9
|
-
import { CliError, EXIT, bold, cyan, dim, fail, note, pad, title, write } from "./term.js";
|
|
9
|
+
import { CliError, EXIT, bold, cyan, deferBanner, dim, fail, note, pad, title, write, } from "./term.js";
|
|
10
10
|
/** What the user typed: `zen`, `zn` or `zenera` all arrive here. */
|
|
11
11
|
const NAME = invokedAs('zen');
|
|
12
12
|
/** Usage lines are written against `zen`. Say them back in the reader's word. */
|
|
@@ -66,9 +66,12 @@ async function main(argv) {
|
|
|
66
66
|
// actually there — a banner over "not installed" is a claim about nothing.
|
|
67
67
|
const ragSearch = name === 'rag' && ['docs', 'schema'].includes(rest[0]) && rest[1] === 'search';
|
|
68
68
|
const interactive = rest.includes('--interactive');
|
|
69
|
+
const answers = command?.quiet?.(rest) === true;
|
|
69
70
|
if (!json && (!ragSearch || interactive)) {
|
|
70
71
|
const brand = external && hasExternal(external) ? external.banner : command?.banner;
|
|
71
|
-
printBanner(brand ?? NEO_BANNER, { full: ragSearch && interactive });
|
|
72
|
+
const show = () => printBanner(brand ?? NEO_BANNER, { full: ragSearch && interactive });
|
|
73
|
+
// A command that answers holds its banner back until it has to ask.
|
|
74
|
+
answers ? deferBanner(show) : show();
|
|
72
75
|
}
|
|
73
76
|
if (parts.name === 'help') {
|
|
74
77
|
await usage(rest[0]);
|