@zenera/cli 1.1.23 → 1.1.25

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 (184) hide show
  1. package/README.md +88 -2
  2. package/dist/ask.d.ts +51 -0
  3. package/dist/ask.js +166 -0
  4. package/dist/banner.js +2 -2
  5. package/dist/batch.d.ts +83 -0
  6. package/dist/batch.js +413 -0
  7. package/dist/catalog.d.ts +11 -1
  8. package/dist/catalog.js +80 -7
  9. package/dist/command.d.ts +6 -0
  10. package/dist/commands/inspect.d.ts +57 -0
  11. package/dist/commands/inspect.js +539 -91
  12. package/dist/commands/list.js +128 -19
  13. package/dist/commands/memory.js +461 -32
  14. package/dist/commands/meta.js +311 -21
  15. package/dist/commands/models.js +110 -14
  16. package/dist/commands/open.js +4 -4
  17. package/dist/commands/run.js +175 -36
  18. package/dist/commands/sandbox.js +148 -15
  19. package/dist/engine.d.ts +24 -0
  20. package/dist/engine.js +61 -8
  21. package/dist/eventlog.d.ts +59 -0
  22. package/dist/eventlog.js +146 -0
  23. package/dist/liveness.d.ts +7 -0
  24. package/dist/liveness.js +20 -3
  25. package/dist/main.js +5 -2
  26. package/dist/meta/editor.d.ts +9 -0
  27. package/dist/meta/editor.js +60 -0
  28. package/dist/meta/finetune/children.d.ts +8 -0
  29. package/dist/meta/finetune/children.js +76 -0
  30. package/dist/meta/finetune/command.d.ts +30 -0
  31. package/dist/meta/finetune/command.js +338 -0
  32. package/dist/meta/finetune/dataset/anchors.d.ts +41 -0
  33. package/dist/meta/finetune/dataset/anchors.js +239 -0
  34. package/dist/meta/finetune/dataset/changes.d.ts +36 -0
  35. package/dist/meta/finetune/dataset/changes.js +319 -0
  36. package/dist/meta/finetune/dataset/command.d.ts +17 -0
  37. package/dist/meta/finetune/dataset/command.js +1011 -0
  38. package/dist/meta/finetune/dataset/drift.d.ts +33 -0
  39. package/dist/meta/finetune/dataset/drift.js +71 -0
  40. package/dist/meta/finetune/dataset/export.d.ts +26 -0
  41. package/dist/meta/finetune/dataset/export.js +51 -0
  42. package/dist/meta/finetune/dataset/index.d.ts +2 -0
  43. package/dist/meta/finetune/dataset/index.js +2 -0
  44. package/dist/meta/finetune/dataset/sample.d.ts +50 -0
  45. package/dist/meta/finetune/dataset/sample.js +197 -0
  46. package/dist/meta/finetune/dataset/schema.d.ts +33 -0
  47. package/dist/meta/finetune/dataset/schema.js +216 -0
  48. package/dist/meta/finetune/dataset/store.d.ts +35 -0
  49. package/dist/meta/finetune/dataset/store.js +177 -0
  50. package/dist/meta/finetune/dataset/types.d.ts +125 -0
  51. package/dist/meta/finetune/dataset/types.js +39 -0
  52. package/dist/meta/finetune/failure.d.ts +15 -0
  53. package/dist/meta/finetune/failure.js +26 -0
  54. package/dist/meta/finetune/feedback.d.ts +45 -0
  55. package/dist/meta/finetune/feedback.js +85 -0
  56. package/dist/meta/finetune/improvements.d.ts +51 -0
  57. package/dist/meta/finetune/improvements.js +244 -0
  58. package/dist/meta/finetune/index.d.ts +2 -0
  59. package/dist/meta/finetune/index.js +2 -0
  60. package/dist/meta/finetune/journal.d.ts +35 -0
  61. package/dist/meta/finetune/journal.js +135 -0
  62. package/dist/meta/finetune/live.d.ts +74 -0
  63. package/dist/meta/finetune/live.js +226 -0
  64. package/dist/meta/finetune/loop.d.ts +3 -0
  65. package/dist/meta/finetune/loop.js +114 -0
  66. package/dist/meta/finetune/memories.d.ts +47 -0
  67. package/dist/meta/finetune/memories.js +154 -0
  68. package/dist/meta/finetune/report.d.ts +33 -0
  69. package/dist/meta/finetune/report.js +1284 -0
  70. package/dist/meta/finetune/retry.d.ts +24 -0
  71. package/dist/meta/finetune/retry.js +96 -0
  72. package/dist/meta/finetune/sampler.d.ts +18 -0
  73. package/dist/meta/finetune/sampler.js +41 -0
  74. package/dist/meta/finetune/system.d.ts +27 -0
  75. package/dist/meta/finetune/system.js +132 -0
  76. package/dist/meta/finetune/train.d.ts +39 -0
  77. package/dist/meta/finetune/train.js +391 -0
  78. package/dist/meta/finetune/tui.d.ts +16 -0
  79. package/dist/meta/finetune/tui.js +291 -0
  80. package/dist/meta/finetune/tuning.d.ts +167 -0
  81. package/dist/meta/finetune/tuning.js +289 -0
  82. package/dist/meta/finetune/usage.d.ts +62 -0
  83. package/dist/meta/finetune/usage.js +225 -0
  84. package/dist/meta/host.d.ts +14 -0
  85. package/dist/meta/host.js +20 -0
  86. package/dist/meta/index.d.ts +11 -0
  87. package/dist/meta/index.js +11 -0
  88. package/dist/meta/launch.d.ts +81 -0
  89. package/dist/meta/launch.js +286 -0
  90. package/dist/meta/model.d.ts +44 -0
  91. package/dist/meta/model.js +119 -0
  92. package/dist/meta/prompts.d.ts +18 -0
  93. package/dist/meta/prompts.js +61 -0
  94. package/dist/meta/relay.d.ts +20 -0
  95. package/dist/meta/relay.js +123 -0
  96. package/dist/meta/tokens.d.ts +52 -0
  97. package/dist/meta/tokens.js +226 -0
  98. package/dist/meta/window.d.ts +61 -0
  99. package/dist/meta/window.js +198 -0
  100. package/dist/meta/wire.d.ts +42 -0
  101. package/dist/meta/wire.js +129 -0
  102. package/dist/modelref.d.ts +16 -0
  103. package/dist/modelref.js +41 -0
  104. package/dist/narrate.js +5 -2
  105. package/dist/podman.d.ts +42 -0
  106. package/dist/podman.js +198 -5
  107. package/dist/progress.d.ts +33 -0
  108. package/dist/progress.js +402 -0
  109. package/dist/projects.d.ts +0 -5
  110. package/dist/projects.js +2 -15
  111. package/dist/request.d.ts +45 -0
  112. package/dist/request.js +300 -0
  113. package/dist/sandbox.d.ts +2 -0
  114. package/dist/sandbox.js +20 -3
  115. package/dist/scaffold.d.ts +15 -6
  116. package/dist/scaffold.js +5 -60
  117. package/dist/session.d.ts +26 -1
  118. package/dist/session.js +46 -32
  119. package/dist/term.d.ts +5 -0
  120. package/dist/term.js +28 -3
  121. package/dist/trace.d.ts +84 -0
  122. package/dist/trace.js +737 -0
  123. package/dist/tui/models.d.ts +33 -0
  124. package/dist/tui/models.js +389 -0
  125. package/dist/tui/wrap.d.ts +5 -0
  126. package/dist/tui/wrap.js +12 -0
  127. package/dist/usage.d.ts +78 -0
  128. package/dist/usage.js +51 -0
  129. package/dist/validate.js +25 -3
  130. package/package.json +3 -2
  131. package/templates/editor/.github/copilot-instructions.md +41 -107
  132. package/templates/editor/.github/prompts/analyze.prompt.md +31 -0
  133. package/templates/editor/.github/prompts/dataset.prompt.md +108 -0
  134. package/templates/editor/.github/prompts/finetune-apply.prompt.md +91 -0
  135. package/templates/editor/.github/prompts/finetune.prompt.md +744 -0
  136. package/templates/editor/.github/prompts/new-agent.prompt.md +3 -1
  137. package/templates/editor/.github/prompts/new-skill.prompt.md +2 -0
  138. package/templates/editor/.github/prompts/project-review.prompt.md +4 -3
  139. package/templates/editor/.github/skills/zen-analyze-run/SKILL.md +483 -0
  140. package/templates/editor/.github/skills/zen-cli/SKILL.md +18 -4
  141. package/templates/editor/.github/skills/zen-cli/references/check.md +7 -5
  142. package/templates/editor/.github/skills/zen-cli/references/dataset.md +34 -0
  143. package/templates/editor/.github/skills/zen-cli/references/finetune.md +77 -0
  144. package/templates/editor/.github/skills/zen-cli/references/inspect.md +300 -21
  145. package/templates/editor/.github/skills/zen-cli/references/memory.md +121 -28
  146. package/templates/editor/.github/skills/zen-cli/references/meta.md +61 -0
  147. package/templates/editor/.github/skills/zen-cli/references/models.md +6 -2
  148. package/templates/editor/.github/skills/zen-cli/references/projects.md +12 -5
  149. package/templates/editor/.github/skills/zen-cli/references/rag.md +24 -6
  150. package/templates/editor/.github/skills/zen-cli/references/run.md +201 -4
  151. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +10 -1
  152. package/templates/editor/.github/skills/zen-code-python/SKILL.md +123 -0
  153. package/templates/editor/.github/skills/zen-code-python/references/code_python.md +48 -0
  154. package/templates/editor/.github/skills/zen-dataset/SKILL.md +253 -0
  155. package/templates/editor/.github/skills/zen-finetune/SKILL.md +1791 -0
  156. package/templates/editor/.github/skills/zen-finetune/references/readme-template.md +268 -0
  157. package/templates/editor/.github/skills/zen-finetune/scripts/batch.mjs +390 -0
  158. package/templates/editor/.github/skills/zen-finetune/scripts/difficult.mjs +243 -0
  159. package/templates/editor/.github/skills/zen-finetune/scripts/memory.mjs +280 -0
  160. package/templates/editor/.github/skills/zen-finetune/scripts/next.mjs +398 -0
  161. package/templates/editor/.github/skills/zen-finetune/scripts/report.mjs +797 -0
  162. package/templates/editor/.github/skills/zen-finetune/scripts/select.mjs +354 -0
  163. package/templates/editor/.github/skills/zen-finetune/scripts/usage.mjs +722 -0
  164. package/templates/editor/.github/skills/zen-inspect/SKILL.md +431 -0
  165. package/templates/editor/.github/skills/zen-inspect-ask/SKILL.md +270 -0
  166. package/templates/editor/.github/skills/zen-memory/SKILL.md +78 -29
  167. package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +363 -531
  168. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +20 -8
  169. package/templates/editor/.github/skills/zen-review/SKILL.md +5 -3
  170. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +5 -4
  171. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +25 -2
  172. package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +334 -0
  173. package/templates/editor/.github/skills/zen-sandbox-capacity/references/loadtest.md +181 -0
  174. package/templates/editor/.github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh +457 -0
  175. package/templates/editor/.github/skills/zen-topology/SKILL.md +283 -0
  176. package/templates/editor/.vscode/agents.schema.json +1 -1
  177. package/templates/editor/.vscode/settings.json +2 -0
  178. package/templates/project/agents/files-instructions.md +35 -13
  179. package/templates/project/agents/fork-instructions.md +27 -0
  180. package/templates/project/agents/memory-instructions.md +106 -34
  181. package/templates/project/agents/tools-instructions.md +7 -4
  182. package/templates/project/gitignore +5 -0
  183. package/dist/meta.d.ts +0 -221
  184. package/dist/meta.js +0 -684
@@ -11,14 +11,14 @@
11
11
  // instead", and prints a ref on stdout that can be pasted straight back into
12
12
  // agents.yaml — or read by an agent doing the pasting.
13
13
  // ---------------------------------------------------------------------------
14
- import { createEmbedder, createModel, ModelRegistry } from '@zenera/neo';
14
+ import { createEmbedder, ModelRegistry } from '@zenera/neo';
15
15
  import { parse } from "../args.js";
16
- import { loadCatalog, loadCatalogs, matches, PREFERRED, } from "../catalog.js";
16
+ import { byGroup, groupOf, isPartner, loadCatalog, loadCatalogs, matches, PREFERRED, } from "../catalog.js";
17
17
  import { ensureHome } from "../home.js";
18
18
  import { envNames, form, isProvider, KeyStore, PROVIDERS } from "../keys.js";
19
- import { probeModel } from "../liveness.js";
20
- import { ago, bold, credentialError, cyan, dim, green, json, note, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
21
- const USAGE = 'zen models <providers|ls|search|show|test|pick> [ref] [options]';
19
+ import { probeChat, probeModel } from "../liveness.js";
20
+ import { ago, bold, CliError, credentialError, cyan, dim, EXIT, green, isInteractive, json, note, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
21
+ const USAGE = 'zen models <providers|ls|search|show|test|pick|browse> [ref] [options]';
22
22
  /**
23
23
  * The keyring is materialised here, and only here.
24
24
  *
@@ -112,15 +112,32 @@ function freshness(cat) {
112
112
  return cat.origin === 'live' ? green('fetched now') : dim(`cached ${ago(cat.fetchedAt)}`);
113
113
  }
114
114
  const roleMark = (row) => row.roles.join('+');
115
+ const maker = (row) => !row.publisher ? '' : isPartner(row) ? yellow(row.publisher) : green(row.publisher);
115
116
  function row(entry) {
116
117
  return [
117
118
  cyan(entry.ref),
119
+ maker(entry),
118
120
  dim(roleMark(entry)),
119
121
  dim(tokens(entry.contextLength)),
120
122
  dim(entry.pricing?.free ? 'free' : ''),
121
123
  dim(entry.name ?? ''),
122
124
  ];
123
125
  }
126
+ /** The table, with a heading wherever a reseller's rows change maker. */
127
+ function grouped(entries) {
128
+ const lines = table(entries.map(row));
129
+ const out = [];
130
+ let last;
131
+ entries.forEach((e, i) => {
132
+ const group = groupOf(e);
133
+ if (group && group !== last) {
134
+ out.push(...(out.length > 0 ? [''] : []), bold(group));
135
+ }
136
+ last = group;
137
+ out.push(lines[i]);
138
+ });
139
+ return out;
140
+ }
124
141
  /**
125
142
  * Says how a listing was obtained whenever it was not the vendor's own word —
126
143
  * on stderr, because it is narration about the answer rather than the answer.
@@ -175,6 +192,7 @@ const providers = async (ctx, args) => {
175
192
  })));
176
193
  note('');
177
194
  note(dim('zen models ls <provider> what one of them serves'));
195
+ note(dim('zen models browse walk them with the arrow keys'));
178
196
  note(dim('zen models pick --chat the first one that answers'));
179
197
  };
180
198
  // ---------------------------------------------------------------------------
@@ -198,7 +216,7 @@ const ls = async (ctx, args) => {
198
216
  const all = cats
199
217
  .flatMap((c) => c.entries)
200
218
  .filter((e) => matches(e, '', { roles }))
201
- .sort((a, b) => a.ref.localeCompare(b.ref));
219
+ .sort(byGroup);
202
220
  if (ctx.json) {
203
221
  json({
204
222
  models: all,
@@ -215,10 +233,13 @@ const ls = async (ctx, args) => {
215
233
  note(dim('nothing matched — try: zen models ls --refresh'));
216
234
  return;
217
235
  }
218
- writeAll(table(all.slice(0, limit).map(row)));
236
+ writeAll(grouped(all.slice(0, limit)));
219
237
  if (all.length > limit) {
220
238
  note(dim(`${all.length - limit} more — narrow with \`zen models search\`, or pass --all`));
221
239
  }
240
+ if (all.some(isPartner)) {
241
+ note(dim('partner models are billed through Vertex and must be enabled in Model Garden first: https://console.cloud.google.com/vertex-ai/model-garden'));
242
+ }
222
243
  };
223
244
  // ---------------------------------------------------------------------------
224
245
  // search
@@ -255,7 +276,7 @@ const search = async (ctx, args) => {
255
276
  // Cheapest first when price is known: search is usually the step before
256
277
  // picking one, and the free ones are what most people are looking for.
257
278
  .sort((a, b) => Number(b.pricing?.free ?? false) - Number(a.pricing?.free ?? false) ||
258
- a.ref.localeCompare(b.ref));
279
+ byGroup(a, b));
259
280
  if (ctx.json) {
260
281
  json({ query, matched: hits.length, models: hits.slice(0, limit) });
261
282
  return;
@@ -377,9 +398,7 @@ function target(ref, provider, id, role) {
377
398
  if (role !== 'chat') {
378
399
  throw usageError(`${ref} is ${role}-only, and there is no way to ask it a question from here`, 'zen models test only exercises chat and embedding models');
379
400
  }
380
- // 16 tokens is enough for "ok" and not enough to matter. Anthropic requires
381
- // a cap at all, so this is not merely thrift.
382
- return { ref, kind: 'model', model: createModel({ provider, model: id, maxTokens: 16 }) };
401
+ return { ref, kind: 'model', model: probeChat(provider, id) };
383
402
  }
384
403
  function verdict(probe) {
385
404
  switch (probe.check.state) {
@@ -393,6 +412,18 @@ function verdict(probe) {
393
412
  return dim('no answer');
394
413
  }
395
414
  }
415
+ /**
416
+ * What the provider said, in the verdict's own colour. Grey is for the half of
417
+ * the output nobody needs to read, and the one line explaining a refusal is not
418
+ * in that half.
419
+ */
420
+ function reason(probe) {
421
+ const detail = probe.check.detail ?? '';
422
+ if (detail === '' || probe.check.state === 'live') {
423
+ return dim(detail);
424
+ }
425
+ return probe.check.state === 'dead' ? red(detail) : yellow(detail);
426
+ }
396
427
  /**
397
428
  * Which project and region a Vertex call was actually made in. A publisher
398
429
  * model is only missing *somewhere*, and the somewhere is the part nobody
@@ -429,11 +460,11 @@ const test = async (ctx, args) => {
429
460
  cyan(p.ref),
430
461
  verdict(p),
431
462
  dim(p.dimensions ? `${p.dimensions} dims` : ''),
432
- dim(p.check.detail ?? ''),
463
+ reason(p),
433
464
  ])));
434
465
  for (const p of probes) {
435
466
  if (p.check.fix) {
436
- note(dim(`${p.ref}: ${p.check.fix}`));
467
+ note(`${yellow('fix')} ${bold(p.ref)} ${p.check.fix}`);
437
468
  }
438
469
  }
439
470
  // A Vertex refusal is about a model *in a project*, and the project is
@@ -524,6 +555,67 @@ const pick = async (ctx, args) => {
524
555
  throw credentialError(`no ${role} model answered on this machine`, 'add a credential: zen key add openai');
525
556
  };
526
557
  // ---------------------------------------------------------------------------
558
+ // browse
559
+ // ---------------------------------------------------------------------------
560
+ /**
561
+ * The same catalog, walked with the arrow keys: providers, what one serves,
562
+ * one model in full, with a key to ask it a question and a key to take it.
563
+ *
564
+ * The interface is drawn on stderr, so a pick reaches stdout alone and
565
+ * `$(zen models browse)` is the ref, the same contract `pick` keeps.
566
+ */
567
+ const browse = async (ctx, args) => {
568
+ const { values, positionals } = parse(args, { refresh: { type: 'boolean' }, theme: { type: 'string' } }, 'zen models browse [provider] [--refresh] [--theme dark|light|auto]');
569
+ const initial = positionals[0];
570
+ if (initial !== undefined && !isProvider(initial)) {
571
+ throw usageError(`unknown provider "${initial}"`, `known: ${PROVIDERS.join(', ')}`);
572
+ }
573
+ if (ctx.json || !isInteractive()) {
574
+ throw usageError('browsing needs a person at a terminal', 'from a script: zen models ls --json, or zen models pick --chat');
575
+ }
576
+ const where = await credentials();
577
+ // Offline, like `providers`: the overview should not cost five listings.
578
+ // A provider is fetched when it is opened.
579
+ const cats = await loadCatalogs(PROVIDERS, { offline: true });
580
+ const { browse: draw } = await import("../tui/models.js");
581
+ const chosen = await draw({
582
+ providers: cats.map((c) => ({
583
+ provider: c.provider,
584
+ credential: source(where, c.provider),
585
+ models: c.entries.length,
586
+ origin: c.origin,
587
+ fetchedAt: c.fetchedAt,
588
+ })),
589
+ initial,
590
+ theme: values.theme,
591
+ load: (provider, refresh) => loadCatalog(provider, { refresh: refresh || values.refresh }),
592
+ test: async (entry) => {
593
+ const role = entry.roles.includes('chat')
594
+ ? 'chat'
595
+ : entry.roles.includes('embedding')
596
+ ? 'embedding'
597
+ : (entry.roles[0] ?? 'chat');
598
+ const probe = await probeModel(target(entry.ref, entry.provider, entry.id, role));
599
+ return {
600
+ state: probe.check.state,
601
+ ms: probe.ms,
602
+ detail: probe.check.detail,
603
+ fix: probe.check.fix,
604
+ dimensions: probe.dimensions,
605
+ };
606
+ },
607
+ });
608
+ if (!chosen) {
609
+ // Leaving is a fine way to end a browse, but not an answer to a
610
+ // substitution that was waiting for one.
611
+ if (!process.stdout.isTTY) {
612
+ throw new CliError('nothing picked', EXIT.failed);
613
+ }
614
+ return;
615
+ }
616
+ write(chosen.ref);
617
+ };
618
+ // ---------------------------------------------------------------------------
527
619
  // Dispatch
528
620
  // ---------------------------------------------------------------------------
529
621
  const SUBS = {
@@ -536,6 +628,8 @@ const SUBS = {
536
628
  test,
537
629
  check: test,
538
630
  pick,
631
+ browse,
632
+ ui: browse,
539
633
  };
540
634
  export const models = {
541
635
  summary: 'What this machine can use: list, search, test and pick models.',
@@ -558,6 +652,8 @@ export const models = {
558
652
  ' zen models show <ref> One model, in full.',
559
653
  ' zen models test <ref> … Ask it one real question.',
560
654
  ' zen models pick --embedding The first ref that answers, on stdout.',
655
+ ' zen models browse [provider] Walk providers and models with the',
656
+ ' arrow keys; t asks one, p prints its ref.',
561
657
  ],
562
658
  run: async (ctx) => {
563
659
  const [name, ...rest] = ctx.args;
@@ -576,7 +672,7 @@ export const models = {
576
672
  await ls(ctx, ctx.args);
577
673
  return;
578
674
  }
579
- throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, pick')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
675
+ throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, pick, browse')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
580
676
  },
581
677
  };
582
678
  //# sourceMappingURL=models.js.map
@@ -3,9 +3,9 @@ import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { delimiter, join, resolve, sep } from 'node:path';
5
5
  import { one, parse } from "../args.js";
6
+ import { editorFiles } from "../meta/editor.js";
6
7
  import { project as resolveProject } from "../resolve.js";
7
- import { editorFiles } from "../scaffold.js";
8
- import { CliError, cyan, dim, EXIT, json, note, usageError } from "../term.js";
8
+ import { CliError, cyan, dim, EXIT, isInteractive, json, note, usageError } from "../term.js";
9
9
  const USAGE = 'zen open [project] [--editor <cmd>] [--wait]';
10
10
  /**
11
11
  * A project is resolved by name or from the cwd, and the path is handed to an
@@ -247,8 +247,8 @@ function verify(editor) {
247
247
  ? 'name one with --editor, or set $ZENERA_EDITOR'
248
248
  : `${editor.from} names a command that is not on PATH`);
249
249
  }
250
- if ((editor.from === '$VISUAL' || editor.from === '$EDITOR') && !process.stdin.isTTY) {
251
- throw usageError(`${editor.from} names a terminal editor and there is no terminal here`, 'name a windowed one with --editor, or set $ZENERA_EDITOR');
250
+ if ((editor.from === '$VISUAL' || editor.from === '$EDITOR') && !isInteractive()) {
251
+ throw usageError(`${editor.from} names a terminal editor and there is no one at a terminal here`, 'name a windowed one with --editor, or set $ZENERA_EDITOR');
252
252
  }
253
253
  }
254
254
  async function launch(editor, dir) {
@@ -1,29 +1,40 @@
1
+ import { usageByModel } from '@zenera/neo';
1
2
  import { writeFile } from 'node:fs/promises';
2
3
  import { resolve } from 'node:path';
3
4
  import { parse } from "../args.js";
5
+ import { concurrency, runBatch } from "../batch.js";
4
6
  import * as Engine from "../engine.js";
7
+ import { agentEventLog } from "../eventlog.js";
5
8
  import { duration, Narrator, stopMark, summary } from "../narrate.js";
6
9
  import * as Projects from "../projects.js";
10
+ import { readBatch, readRequest } from "../request.js";
7
11
  import { target } from "../resolve.js";
8
12
  import { display } from "../session.js";
9
- import { bold, cyan, dim, json, note, readStdin, usageError, write } from "../term.js";
13
+ import { bold, cyan, dim, drivenByAgent, json, jsonText, note, readStdin, usageError, write, } from "../term.js";
10
14
  import { parseChoice } from "../tui/theme.js";
15
+ import { appendUsage } from "../usage.js";
11
16
  const USAGE = 'zen run [project] [prompt] [options]';
12
17
  export const run = {
13
18
  summary: 'Run the project — the TUI on a terminal, one shot otherwise.',
14
19
  usage: USAGE,
15
20
  banner: { head: 'Zenera', accent: 'Neo', subtitle: 'Agentic Runtime', hue: 'orange' },
21
+ // A batch prints the path of its directory, and a banner above a path is a
22
+ // banner inside `$(zen run batch ...)`. True whether or not --json is on.
23
+ quiet: (args) => args[0] === 'batch',
16
24
  details: [
17
25
  'Arguments:',
18
26
  ' [project] Name of a project. Default: the one you are in.',
19
27
  ' [prompt] Your question, in quotes. Without one, the TUI opens.',
28
+ ' batch Run a file full of questions at once. See below.',
20
29
  '',
21
30
  'Options:',
22
31
  ' --project <name|dir> Which project to run. Default: the one you are in.',
23
32
  ' --session <id> Continue this session.',
24
33
  ' --new Start a new session without asking which one.',
34
+ ' --input <file> Read the whole request from JSON. `-` is stdin.',
25
35
  ' --workspace <dir> Directory the agent can read and write.',
26
36
  ' --memory <dir> Directory the agents remember into.',
37
+ ' --memory-read-only Recall from it, write nothing back.',
27
38
  ' --model <ref> Use this model instead of the default.',
28
39
  ' --image <ref> Use this container image to run commands in.',
29
40
  ' --read-only Take away every tool that can write.',
@@ -31,9 +42,18 @@ export const run = {
31
42
  ' --plain Never open the TUI. Needs a prompt.',
32
43
  ' --theme <dark|light> Colours for the TUI. Default: auto.',
33
44
  ' --out <file> Put the answer in this file instead of on screen.',
45
+ ' With --json, the file gets the whole JSON envelope.',
46
+ ' --events <file> Append what the agent does to this file as it does it:',
47
+ ' one JSON line per model call, tool call and message.',
48
+ ' --disposable Remove its containers when it ends, whatever `persist:`',
49
+ ' says: for a session that will never be continued.',
34
50
  ' --yes Answer yes to every question.',
35
51
  ' --json Print machine-readable JSON (session, run, mounts, output, etc...).',
36
52
  '',
53
+ 'Batch options:',
54
+ ' --batch-dir <dir> Where the runs live. Default: <project>/batches/<stamp>.',
55
+ ' --concurrency <n> How many at a time. Default: 16, most: 32.',
56
+ '',
37
57
  'The first word is the project when it names one, otherwise the prompt.',
38
58
  'The answer goes to stdout and the progress to stderr, so `> out.md` keeps',
39
59
  'the answer alone and `2>/dev/null` hides the progress.',
@@ -41,6 +61,39 @@ export const run = {
41
61
  'A prompt always starts a new session, so --new is for the TUI, where the',
42
62
  'alternative is being asked which session to continue.',
43
63
  '',
64
+ '--input names a file holding the whole request - project, input, workspace,',
65
+ 'memory - and what it says wins over the same flag on the line. Its paths are',
66
+ 'relative to the file, so a case travels with the images beside it. It is the',
67
+ 'way to ask about a picture, and it never opens the TUI:',
68
+ '',
69
+ ' { "input": [{ "text": "what is this?" }, { "image": "./shot.png" }] }',
70
+ '',
71
+ '`zen run batch --input cases.json` asks a whole file of them at once. The',
72
+ 'file is the same shape, pluralised, and nothing else is allowed in it:',
73
+ '',
74
+ ' { "batch": [{ "id": "vat", "input": "what is VAT?" }, { "input": "..." }] }',
75
+ '',
76
+ 'Every item is its own session, in its own directory under the batch dir:',
77
+ '',
78
+ ' <batch-dir>/<id>/workspace/ what that item could read and write',
79
+ ' <batch-dir>/<id>/memory/ its own copy, in the copying mode',
80
+ ' <batch-dir>/<id>/output.json exactly what `zen run --json` prints',
81
+ ' <batch-dir>/batch.json the index: every item, ok, and its file',
82
+ ' <batch-dir>/README.md a dashboard, rewritten every second',
83
+ '',
84
+ 'Memory comes in two modes, because a memory is a locked directory and',
85
+ 'sixteen runs cannot hold one lock. With --memory-read-only they all recall',
86
+ 'from the one graph and write nothing. Without it, each gets a copy, and the',
87
+ "project's own memory is never touched - fold them back in afterwards with",
88
+ '`zen memory merge <batch-dir>/*/memory`. An item that committed nothing',
89
+ 'leaves no memory behind, and a --memory that does not exist yet starts every',
90
+ 'item from an empty graph - which is how a cold project is warmed.',
91
+ '',
92
+ 'A batch prints the path of its directory and nothing else; --json prints',
93
+ 'the index there instead. An item that fails is written down like any other',
94
+ 'and the exit code says how many did, so one bad question costs one answer.',
95
+ 'For a project or a prompt actually called "batch", say --project batch.',
96
+ '',
44
97
  'Examples:',
45
98
  ' zen run open the TUI in this project',
46
99
  ' zen run acme open the TUI in the acme project',
@@ -49,17 +102,24 @@ export const run = {
49
102
  ' zen run "what changed?" > out.md redirect the answer into a file',
50
103
  ' zen run --out out.md "what changed?" write the answer to out.md only',
51
104
  ' zen run --json "what changed?" output JSON with workspace, run paths, state, etc...',
105
+ ' zen run --input case.json --json run a request from a file, answer as JSON',
106
+ ' zen run --input case.json --json --out result.json ... into a file',
52
107
  ' zen run --read-only "what changed?" let it read but not write',
53
108
  ' zen run --memory ./mem remember into ./mem, not the project',
54
109
  ' zen run --new open the TUI in a new session',
110
+ ' zen run batch --input cases.json ask them all, 16 at a time',
111
+ ' zen run batch --input cases.json --memory-read-only ... sharing one memory',
112
+ ' zen run batch --input cases.json --json | jq .batch_results',
55
113
  ],
56
114
  run: async (ctx) => {
57
115
  const { values, positionals } = parse(ctx.args, {
58
116
  project: { type: 'string' },
59
117
  session: { type: 'string' },
60
118
  new: { type: 'boolean' },
119
+ input: { type: 'string' },
61
120
  workspace: { type: 'string' },
62
121
  memory: { type: 'string' },
122
+ 'memory-read-only': { type: 'boolean' },
63
123
  model: { type: 'string' },
64
124
  image: { type: 'string' },
65
125
  'no-keys': { type: 'boolean' },
@@ -68,8 +128,18 @@ export const run = {
68
128
  plain: { type: 'boolean' },
69
129
  theme: { type: 'string' },
70
130
  out: { type: 'string' },
131
+ events: { type: 'string' },
132
+ disposable: { type: 'boolean' },
133
+ 'batch-dir': { type: 'string' },
134
+ concurrency: { type: 'string' },
71
135
  }, USAGE);
72
136
  const piped = await readStdin();
137
+ // Before the first positional is read as a project name, because in a
138
+ // batch it is not one and `batch` is not a prompt either.
139
+ if (positionals[0] === 'batch') {
140
+ await batch(ctx, values, positionals.slice(1), piped);
141
+ return;
142
+ }
73
143
  // `zen run acme` is what everyone types before finding --project, and a
74
144
  // project name is a bare word where a prompt is a sentence. So the
75
145
  // first positional is read as a project when it names one and as the
@@ -78,23 +148,34 @@ export const run = {
78
148
  // there for the day a project is called "why".
79
149
  const [head, ...rest] = positionals;
80
150
  const named = !values.project && head ? await Projects.find(head) : undefined;
81
- const prompt = (named ? rest : positionals).join(' ').trim() || piped;
151
+ const typed = (named ? rest : positionals).join(' ').trim();
152
+ if (values.input && typed) {
153
+ throw usageError('a prompt and --input both say what to ask', 'put the prompt in the file, or drop --input');
154
+ }
82
155
  if (values.theme !== undefined && !parseChoice(values.theme)) {
83
156
  throw usageError(`unknown theme: ${values.theme}`, 'dark, light or auto');
84
157
  }
158
+ // With `--input -` the pipe carries the request, not the prompt, so it
159
+ // is read here and never looked at again below.
160
+ const request = values.input ? await readRequest(values.input, ctx.cwd, piped) : undefined;
161
+ const prompt = typed || (request ? '' : (piped ?? ''));
85
162
  // A prompt on the command line is a request for an answer, not a
86
163
  // conversation to pick up. So it answers the three questions itself:
87
164
  // a fresh session, the directory it was typed in as the workspace,
88
165
  // and no confirmation for it — `zen run acme "what changed?"` should
89
166
  // read the code that is right there. Every flag still wins, and the
90
167
  // TUI, where there is someone to ask, still asks.
91
- const shot = Boolean(prompt);
168
+ const input = request ? request.input : prompt;
169
+ const shot = Boolean(prompt) || Boolean(request);
170
+ if (values.events && !shot) {
171
+ throw usageError('--events records a one-shot run', 'give a prompt or --input');
172
+ }
92
173
  const where = await target({
93
174
  cwd: ctx.cwd,
94
- project: values.project ?? (named ? head : undefined),
175
+ project: request?.project ?? values.project ?? (named ? head : undefined),
95
176
  session: values.session,
96
177
  fresh: values.new || (shot && !values.session),
97
- workspace: values.workspace ?? (shot ? ctx.cwd : undefined),
178
+ workspace: request?.workspace ?? values.workspace ?? (shot ? ctx.cwd : undefined),
98
179
  yes: values.yes || shot,
99
180
  });
100
181
  const engine = await Engine.open({
@@ -104,20 +185,24 @@ export const run = {
104
185
  model: values.model,
105
186
  image: values.image,
106
187
  // 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,
188
+ // — the project root is not the cwd. A request's paths are already
189
+ // absolute, resolved against the file rather than against this.
190
+ memoryDir: request?.memory ?? (values.memory ? resolve(ctx.cwd, values.memory) : undefined),
191
+ memoryReadOnly: values['memory-read-only'],
109
192
  keys: values['no-keys'] ? false : undefined,
110
193
  yes: values.yes || ctx.json,
194
+ disposable: values.disposable,
111
195
  });
112
196
  try {
113
197
  // The TUI is for the case it is actually good at: a person at a
114
198
  // terminal, with nothing to say yet. A prompt on the command line
115
199
  // is a request for an answer, and drawing a full-screen interface
116
200
  // over it would be worse than not drawing one.
117
- const drawing = !prompt &&
201
+ const drawing = !shot &&
118
202
  !values.plain &&
119
203
  !ctx.json &&
120
- Boolean(process.stdout.isTTY && process.stdin.isTTY);
204
+ Boolean(process.stdout.isTTY && process.stdin.isTTY) &&
205
+ !drivenByAgent();
121
206
  if (drawing) {
122
207
  const { start } = await import("../tui/app.js");
123
208
  await start(engine, {
@@ -127,10 +212,10 @@ export const run = {
127
212
  });
128
213
  return;
129
214
  }
130
- if (!prompt) {
215
+ if (!shot) {
131
216
  throw usageError('nothing to ask', 'give a prompt, or pipe one in');
132
217
  }
133
- await once(engine, prompt, values, ctx.json, ctx.cwd, where);
218
+ await once(engine, input, values, ctx.json, ctx.cwd, where);
134
219
  }
135
220
  finally {
136
221
  await engine.close();
@@ -138,18 +223,72 @@ export const run = {
138
223
  },
139
224
  };
140
225
  // ---------------------------------------------------------------------------
226
+ // Many shots
227
+ // ---------------------------------------------------------------------------
228
+ /**
229
+ * Flags that mean something to one run and nothing to a hundred. Refused by
230
+ * name rather than ignored: a `--workspace` that was quietly dropped is a batch
231
+ * whose answers came from somewhere else.
232
+ */
233
+ const NOT_IN_A_BATCH = [
234
+ ['session', 'every item is a new session of its own'],
235
+ ['new', 'every item is new already'],
236
+ ['workspace', 'they cannot share one; each item gets its own, or names one in the file'],
237
+ ['plain', 'a batch never draws the TUI'],
238
+ ['theme', 'a batch never draws the TUI'],
239
+ ['events', 'each item already has its own output.json'],
240
+ ['disposable', 'a batch removes its containers already'],
241
+ ];
242
+ async function batch(ctx, values, rest, piped) {
243
+ if (rest.length > 0) {
244
+ throw usageError(`zen run batch: nothing goes after "batch" (got "${rest[0]}")`, 'the questions live in the file named by --input');
245
+ }
246
+ for (const [flag, why] of NOT_IN_A_BATCH) {
247
+ if (values[flag] !== undefined && values[flag] !== false) {
248
+ throw usageError(`--${flag} means nothing in a batch`, why);
249
+ }
250
+ }
251
+ if (!values.input) {
252
+ throw usageError('zen run batch needs --input <file>', 'a JSON file holding the batch');
253
+ }
254
+ await runBatch({
255
+ request: await readBatch(values.input, ctx.cwd, piped),
256
+ input: values.input === '-' ? '<stdin>' : resolve(ctx.cwd, values.input),
257
+ dir: values['batch-dir'],
258
+ cwd: ctx.cwd,
259
+ project: values.project,
260
+ memory: values.memory ? resolve(ctx.cwd, values.memory) : undefined,
261
+ memoryReadOnly: values['memory-read-only'],
262
+ concurrency: concurrency(values.concurrency),
263
+ model: values.model,
264
+ image: values.image,
265
+ readOnly: values['read-only'],
266
+ keys: values['no-keys'] ? false : undefined,
267
+ yes: values.yes,
268
+ out: values.out,
269
+ json: ctx.json,
270
+ });
271
+ }
272
+ // ---------------------------------------------------------------------------
141
273
  // One shot
142
274
  // ---------------------------------------------------------------------------
143
- async function once(engine, prompt, values, asJson, cwd, where) {
275
+ async function once(engine, input, values, asJson, cwd, where) {
144
276
  const narrator = new Narrator({
145
277
  quiet: asJson,
146
278
  live: Boolean(process.stderr.isTTY),
147
279
  });
280
+ const record = values.events ? agentEventLog(resolve(cwd, values.events)) : undefined;
281
+ const onEvent = (event) => {
282
+ narrator.handle(event);
283
+ record?.(event);
284
+ };
148
285
  // Ctrl-C asks the run to stop rather than killing the process, so the turn
149
- // still lands on disk and the session stays resumable.
286
+ // still lands on disk and the session stays resumable. SIGTERM too: it is
287
+ // how a tuning stops a run it left behind, and its containers go with it.
150
288
  const stopping = new AbortController();
151
289
  const onInterrupt = () => stopping.abort();
152
290
  process.once('SIGINT', onInterrupt);
291
+ process.once('SIGTERM', onInterrupt);
153
292
  if (!asJson) {
154
293
  // Two questions were just answered, possibly without being asked. Say
155
294
  // which way they went: a run that quietly resumed the wrong session, or
@@ -162,39 +301,39 @@ async function once(engine, prompt, values, asJson, cwd, where) {
162
301
  }
163
302
  let outcome;
164
303
  try {
165
- outcome = await Engine.run(engine, prompt, narrator.handle, stopping.signal);
304
+ outcome = await Engine.run(engine, input, onEvent, stopping.signal);
166
305
  }
167
306
  finally {
168
307
  narrator.done();
169
308
  process.off('SIGINT', onInterrupt);
309
+ process.off('SIGTERM', onInterrupt);
170
310
  }
171
- if (values.out) {
172
- await writeFile(values.out, `${outcome.text}\n`, 'utf8');
173
- }
311
+ appendUsage(where.project.dir, {
312
+ kind: 'run',
313
+ ts: new Date().toISOString(),
314
+ runDir: outcome.run.dir,
315
+ session: engine.session.id,
316
+ ok: true,
317
+ stopReason: outcome.result.stopReason,
318
+ durationMs: outcome.durationMs,
319
+ models: usageByModel(outcome.result.state.trajectory),
320
+ });
174
321
  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
- });
322
+ // --out is a destination, not a copy, and with --json the answer *is*
323
+ // the envelope — so that is what lands in the file, not the prose.
324
+ const body = Engine.envelope(engine, outcome);
325
+ if (values.out) {
326
+ await writeFile(values.out, jsonText(body), 'utf8');
327
+ }
328
+ else {
329
+ json(body);
330
+ }
193
331
  return;
194
332
  }
195
- // --out is a destination, not a copy: the answer goes there instead of to
333
+ // The same rule for prose: the answer goes to the file instead of to
196
334
  // stdout, so a redirect and a file cannot both end up holding it.
197
335
  if (values.out) {
336
+ await writeFile(values.out, `${outcome.text}\n`, 'utf8');
198
337
  note(dim(`answer: ${cyan(values.out)}`));
199
338
  }
200
339
  else {