@zenera/cli 1.1.22 → 1.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +72 -2
  2. package/dist/archive.d.ts +139 -0
  3. package/dist/archive.js +376 -0
  4. package/dist/ask.d.ts +51 -0
  5. package/dist/ask.js +143 -0
  6. package/dist/batch.d.ts +77 -0
  7. package/dist/batch.js +338 -0
  8. package/dist/command.d.ts +6 -0
  9. package/dist/commands/export.d.ts +3 -0
  10. package/dist/commands/export.js +255 -0
  11. package/dist/commands/import.d.ts +3 -0
  12. package/dist/commands/import.js +173 -0
  13. package/dist/commands/index.d.ts +5 -1
  14. package/dist/commands/index.js +13 -1
  15. package/dist/commands/inspect.d.ts +11 -0
  16. package/dist/commands/inspect.js +388 -89
  17. package/dist/commands/list.js +128 -19
  18. package/dist/commands/memory.js +453 -16
  19. package/dist/commands/models.js +14 -2
  20. package/dist/commands/run.js +139 -34
  21. package/dist/commands/sandbox.js +72 -1
  22. package/dist/engine.d.ts +19 -0
  23. package/dist/engine.js +60 -8
  24. package/dist/liveness.js +6 -3
  25. package/dist/main.js +5 -2
  26. package/dist/podman.d.ts +16 -0
  27. package/dist/podman.js +115 -2
  28. package/dist/progress.d.ts +33 -0
  29. package/dist/progress.js +402 -0
  30. package/dist/request.d.ts +45 -0
  31. package/dist/request.js +300 -0
  32. package/dist/sandbox.js +10 -0
  33. package/dist/scaffold.d.ts +8 -0
  34. package/dist/scaffold.js +4 -1
  35. package/dist/session.d.ts +26 -1
  36. package/dist/session.js +42 -3
  37. package/dist/term.d.ts +3 -0
  38. package/dist/term.js +17 -1
  39. package/dist/trace.d.ts +84 -0
  40. package/dist/trace.js +686 -0
  41. package/dist/validate.js +93 -4
  42. package/package.json +12 -6
  43. package/templates/editor/.github/copilot-instructions.md +11 -6
  44. package/templates/editor/.github/prompts/finetune.prompt.md +251 -0
  45. package/templates/editor/.github/prompts/spec-sync-project.prompt.md +25 -2
  46. package/templates/editor/.github/skills/zen-cli/SKILL.md +14 -3
  47. package/templates/editor/.github/skills/zen-cli/references/check.md +13 -5
  48. package/templates/editor/.github/skills/zen-cli/references/export.md +107 -0
  49. package/templates/editor/.github/skills/zen-cli/references/inspect.md +262 -21
  50. package/templates/editor/.github/skills/zen-cli/references/memory.md +123 -15
  51. package/templates/editor/.github/skills/zen-cli/references/models.md +1 -1
  52. package/templates/editor/.github/skills/zen-cli/references/projects.md +12 -5
  53. package/templates/editor/.github/skills/zen-cli/references/rag.md +6 -0
  54. package/templates/editor/.github/skills/zen-cli/references/run.md +201 -4
  55. package/templates/editor/.github/skills/zen-finetune/SKILL.md +1000 -0
  56. package/templates/editor/.github/skills/zen-finetune/scripts/collect.sh +432 -0
  57. package/templates/editor/.github/skills/zen-finetune/scripts/sample.sh +199 -0
  58. package/templates/editor/.github/skills/zen-inspect/SKILL.md +396 -0
  59. package/templates/editor/.github/skills/zen-memory/SKILL.md +74 -28
  60. package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +370 -408
  61. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +19 -0
  62. package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +334 -0
  63. package/templates/editor/.github/skills/zen-sandbox-capacity/references/loadtest.md +181 -0
  64. package/templates/editor/.github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh +457 -0
  65. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +6 -5
  66. package/templates/editor/.vscode/agents.schema.json +1 -1
  67. package/templates/editor/.vscode/settings.json +1 -0
  68. package/templates/project/SPECIFICATION.md.tmpl +2 -0
  69. package/templates/project/agents/files-instructions.md +13 -0
  70. package/templates/project/agents/fork-instructions.md +27 -0
  71. package/templates/project/agents/memory-instructions.md +80 -7
  72. package/templates/project/gitignore +3 -0
  73. package/templates/project/scripts/_setup.sh +49 -7
@@ -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 prompt = (named ? rest : positionals).join(' ').trim() || piped;
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 shot = Boolean(prompt);
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
- memoryDir: values.memory ? resolve(ctx.cwd, values.memory) : undefined,
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 = !prompt &&
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 (!prompt) {
201
+ if (!shot) {
131
202
  throw usageError('nothing to ask', 'give a prompt, or pipe one in');
132
203
  }
133
- await once(engine, prompt, values, ctx.json, ctx.cwd, where);
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, prompt, values, asJson, cwd, where) {
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, prompt, narrator.handle, stopping.signal);
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
- session: { id: engine.session.id, dir: engine.session.dir },
177
- run: {
178
- id: outcome.run.id,
179
- dir: outcome.run.dir,
180
- input: outcome.run.input,
181
- output: outcome.run.output,
182
- state: outcome.run.state,
183
- meta: outcome.run.meta,
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
- // --out is a destination, not a copy: the answer goes there instead of to
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 {
@@ -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, workspaceTools, } from '@zenera/neo';
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
- warn(describeIssue(issue));
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
- ...workspaceTools(workspaceOptions),
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
- writeJson(run.meta, {
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
- }, 0o644);
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
- if (status !== 429 && UNFUNDED.some((needle) => haystack.includes(needle))) {
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]);