@zenera/cli 1.1.24 → 1.1.26

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 (199) hide show
  1. package/README.md +65 -0
  2. package/dist/ask.js +28 -5
  3. package/dist/banner.js +2 -2
  4. package/dist/batch.d.ts +6 -0
  5. package/dist/batch.js +76 -1
  6. package/dist/catalog.d.ts +31 -1
  7. package/dist/catalog.js +140 -7
  8. package/dist/commands/index.js +2 -0
  9. package/dist/commands/inspect.d.ts +52 -2
  10. package/dist/commands/inspect.js +220 -31
  11. package/dist/commands/memory.js +156 -32
  12. package/dist/commands/meta.js +333 -24
  13. package/dist/commands/models.js +97 -12
  14. package/dist/commands/open.js +4 -4
  15. package/dist/commands/run.js +38 -4
  16. package/dist/commands/sandbox.js +148 -16
  17. package/dist/commands/view.d.ts +8 -0
  18. package/dist/commands/view.js +114 -0
  19. package/dist/engine.d.ts +8 -1
  20. package/dist/engine.js +7 -0
  21. package/dist/eventlog.d.ts +64 -0
  22. package/dist/eventlog.js +170 -0
  23. package/dist/liveness.d.ts +7 -0
  24. package/dist/liveness.js +35 -3
  25. package/dist/main.js +0 -0
  26. package/dist/meta/editor.d.ts +9 -0
  27. package/dist/meta/editor.js +43 -0
  28. package/dist/meta/finetune/analyze-brief.d.ts +26 -0
  29. package/dist/meta/finetune/analyze-brief.js +131 -0
  30. package/dist/meta/finetune/apply-brief.d.ts +23 -0
  31. package/dist/meta/finetune/apply-brief.js +247 -0
  32. package/dist/meta/finetune/brief.d.ts +45 -0
  33. package/dist/meta/finetune/brief.js +158 -0
  34. package/dist/meta/finetune/children.d.ts +8 -0
  35. package/dist/meta/finetune/children.js +76 -0
  36. package/dist/meta/finetune/command.d.ts +30 -0
  37. package/dist/meta/finetune/command.js +516 -0
  38. package/dist/meta/finetune/cost.d.ts +46 -0
  39. package/dist/meta/finetune/cost.js +105 -0
  40. package/dist/meta/finetune/dataset/anchors.d.ts +41 -0
  41. package/dist/meta/finetune/dataset/anchors.js +239 -0
  42. package/dist/meta/finetune/dataset/changes.d.ts +36 -0
  43. package/dist/meta/finetune/dataset/changes.js +319 -0
  44. package/dist/meta/finetune/dataset/command.d.ts +17 -0
  45. package/dist/meta/finetune/dataset/command.js +1011 -0
  46. package/dist/meta/finetune/dataset/drift.d.ts +33 -0
  47. package/dist/meta/finetune/dataset/drift.js +71 -0
  48. package/dist/meta/finetune/dataset/export.d.ts +26 -0
  49. package/dist/meta/finetune/dataset/export.js +51 -0
  50. package/dist/meta/finetune/dataset/index.d.ts +2 -0
  51. package/dist/meta/finetune/dataset/index.js +2 -0
  52. package/dist/meta/finetune/dataset/sample.d.ts +50 -0
  53. package/dist/meta/finetune/dataset/sample.js +197 -0
  54. package/dist/meta/finetune/dataset/schema.d.ts +33 -0
  55. package/dist/meta/finetune/dataset/schema.js +216 -0
  56. package/dist/meta/finetune/dataset/store.d.ts +35 -0
  57. package/dist/meta/finetune/dataset/store.js +177 -0
  58. package/dist/meta/finetune/dataset/types.d.ts +127 -0
  59. package/dist/meta/finetune/dataset/types.js +39 -0
  60. package/dist/meta/finetune/failure.d.ts +15 -0
  61. package/dist/meta/finetune/failure.js +26 -0
  62. package/dist/meta/finetune/feedback.d.ts +57 -0
  63. package/dist/meta/finetune/feedback.js +104 -0
  64. package/dist/meta/finetune/guard.d.ts +29 -0
  65. package/dist/meta/finetune/guard.js +104 -0
  66. package/dist/meta/finetune/improvements.d.ts +98 -0
  67. package/dist/meta/finetune/improvements.js +501 -0
  68. package/dist/meta/finetune/index.d.ts +2 -0
  69. package/dist/meta/finetune/index.js +2 -0
  70. package/dist/meta/finetune/journal.d.ts +35 -0
  71. package/dist/meta/finetune/journal.js +135 -0
  72. package/dist/meta/finetune/live.d.ts +84 -0
  73. package/dist/meta/finetune/live.js +272 -0
  74. package/dist/meta/finetune/loop.d.ts +3 -0
  75. package/dist/meta/finetune/loop.js +163 -0
  76. package/dist/meta/finetune/memories.d.ts +47 -0
  77. package/dist/meta/finetune/memories.js +154 -0
  78. package/dist/meta/finetune/recheck.d.ts +58 -0
  79. package/dist/meta/finetune/recheck.js +311 -0
  80. package/dist/meta/finetune/report.d.ts +40 -0
  81. package/dist/meta/finetune/report.js +1397 -0
  82. package/dist/meta/finetune/retry.d.ts +24 -0
  83. package/dist/meta/finetune/retry.js +96 -0
  84. package/dist/meta/finetune/sampler.d.ts +18 -0
  85. package/dist/meta/finetune/sampler.js +47 -0
  86. package/dist/meta/finetune/system.d.ts +27 -0
  87. package/dist/meta/finetune/system.js +132 -0
  88. package/dist/meta/finetune/train.d.ts +43 -0
  89. package/dist/meta/finetune/train.js +425 -0
  90. package/dist/meta/finetune/tui.d.ts +16 -0
  91. package/dist/meta/finetune/tui.js +316 -0
  92. package/dist/meta/finetune/tuning.d.ts +193 -0
  93. package/dist/meta/finetune/tuning.js +308 -0
  94. package/dist/meta/finetune/usage.d.ts +66 -0
  95. package/dist/meta/finetune/usage.js +260 -0
  96. package/dist/meta/finetune/view.d.ts +9 -0
  97. package/dist/meta/finetune/view.js +27 -0
  98. package/dist/meta/host.d.ts +15 -0
  99. package/dist/meta/host.js +21 -0
  100. package/dist/meta/index.d.ts +11 -0
  101. package/dist/meta/index.js +11 -0
  102. package/dist/meta/launch.d.ts +81 -0
  103. package/dist/meta/launch.js +286 -0
  104. package/dist/meta/model.d.ts +44 -0
  105. package/dist/meta/model.js +119 -0
  106. package/dist/meta/prompts.d.ts +18 -0
  107. package/dist/meta/prompts.js +61 -0
  108. package/dist/meta/relay.d.ts +20 -0
  109. package/dist/meta/relay.js +123 -0
  110. package/dist/meta/tokens.d.ts +52 -0
  111. package/dist/meta/tokens.js +226 -0
  112. package/dist/meta/window.d.ts +61 -0
  113. package/dist/meta/window.js +198 -0
  114. package/dist/meta/wire.d.ts +42 -0
  115. package/dist/meta/wire.js +129 -0
  116. package/dist/meta.d.ts +71 -1
  117. package/dist/meta.js +303 -6
  118. package/dist/modelref.d.ts +16 -0
  119. package/dist/modelref.js +41 -0
  120. package/dist/narrate.js +5 -2
  121. package/dist/podman.d.ts +26 -0
  122. package/dist/podman.js +83 -3
  123. package/dist/projects.d.ts +0 -5
  124. package/dist/projects.js +2 -15
  125. package/dist/resources.d.ts +215 -0
  126. package/dist/resources.js +856 -0
  127. package/dist/sandbox.d.ts +2 -0
  128. package/dist/sandbox.js +10 -3
  129. package/dist/scaffold.d.ts +15 -7
  130. package/dist/scaffold.js +24 -67
  131. package/dist/session.js +4 -29
  132. package/dist/term.d.ts +2 -0
  133. package/dist/term.js +11 -2
  134. package/dist/trace.d.ts +5 -0
  135. package/dist/trace.js +107 -7
  136. package/dist/tui/app.js +19 -5
  137. package/dist/tui/links.d.ts +27 -0
  138. package/dist/tui/links.js +142 -0
  139. package/dist/tui/models.d.ts +35 -0
  140. package/dist/tui/models.js +447 -0
  141. package/dist/tui/sandbox.d.ts +10 -0
  142. package/dist/tui/sandbox.js +131 -0
  143. package/dist/tui/wrap.d.ts +5 -0
  144. package/dist/tui/wrap.js +12 -0
  145. package/dist/usage.d.ts +78 -0
  146. package/dist/usage.js +51 -0
  147. package/package.json +4 -2
  148. package/templates/editor/.github/copilot-instructions.md +60 -145
  149. package/templates/editor/.github/prompts/analyze.prompt.md +33 -0
  150. package/templates/editor/.github/prompts/dataset.prompt.md +108 -0
  151. package/templates/editor/.github/prompts/finetune-apply.prompt.md +270 -0
  152. package/templates/editor/.github/prompts/finetune.prompt.md +691 -198
  153. package/templates/editor/.github/prompts/new-agent.prompt.md +3 -1
  154. package/templates/editor/.github/prompts/new-skill.prompt.md +2 -0
  155. package/templates/editor/.github/prompts/project-review.prompt.md +5 -4
  156. package/templates/editor/.github/skills/zen-analyze-run/SKILL.md +588 -0
  157. package/templates/editor/.github/skills/zen-cli/SKILL.md +9 -1
  158. package/templates/editor/.github/skills/zen-cli/references/dataset.md +34 -0
  159. package/templates/editor/.github/skills/zen-cli/references/finetune.md +137 -0
  160. package/templates/editor/.github/skills/zen-cli/references/inspect.md +63 -11
  161. package/templates/editor/.github/skills/zen-cli/references/memory.md +33 -10
  162. package/templates/editor/.github/skills/zen-cli/references/meta.md +61 -0
  163. package/templates/editor/.github/skills/zen-cli/references/models.md +5 -1
  164. package/templates/editor/.github/skills/zen-cli/references/projects.md +4 -6
  165. package/templates/editor/.github/skills/zen-cli/references/rag.md +24 -6
  166. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +35 -14
  167. package/templates/editor/.github/skills/zen-code-python/SKILL.md +123 -0
  168. package/templates/editor/.github/skills/zen-code-python/references/code_python.md +48 -0
  169. package/templates/editor/.github/skills/zen-dataset/SKILL.md +253 -0
  170. package/templates/editor/.github/skills/zen-finetune/SKILL.md +1492 -660
  171. package/templates/editor/.github/skills/zen-finetune/references/readme-template.md +268 -0
  172. package/templates/editor/.github/skills/zen-finetune/scripts/batch.mjs +390 -0
  173. package/templates/editor/.github/skills/zen-finetune/scripts/difficult.mjs +243 -0
  174. package/templates/editor/.github/skills/zen-finetune/scripts/memory.mjs +280 -0
  175. package/templates/editor/.github/skills/zen-finetune/scripts/next.mjs +398 -0
  176. package/templates/editor/.github/skills/zen-finetune/scripts/report.mjs +797 -0
  177. package/templates/editor/.github/skills/zen-finetune/scripts/select.mjs +354 -0
  178. package/templates/editor/.github/skills/zen-finetune/scripts/usage.mjs +722 -0
  179. package/templates/editor/.github/skills/zen-inspect/SKILL.md +74 -26
  180. package/templates/editor/.github/skills/zen-inspect-ask/SKILL.md +271 -0
  181. package/templates/editor/.github/skills/zen-instructions/SKILL.md +4 -12
  182. package/templates/editor/.github/skills/zen-memory/SKILL.md +82 -53
  183. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +20 -8
  184. package/templates/editor/.github/skills/zen-review/SKILL.md +8 -31
  185. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +5 -5
  186. package/templates/editor/.github/skills/zen-review/scripts/review.sh +3 -12
  187. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +9 -4
  188. package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +2 -2
  189. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +2 -4
  190. package/templates/editor/.github/skills/zen-topology/SKILL.md +283 -0
  191. package/templates/editor/.vscode/settings.json +1 -0
  192. package/templates/project/agents/files-instructions.md +29 -20
  193. package/templates/project/agents/fork-instructions.md +78 -28
  194. package/templates/project/agents/memory-instructions.md +222 -494
  195. package/templates/project/agents/tools-instructions.md +14 -4
  196. package/templates/project/gitignore +2 -0
  197. package/templates/editor/.github/skills/zen-finetune/scripts/collect.sh +0 -432
  198. package/templates/editor/.github/skills/zen-finetune/scripts/sample.sh +0 -199
  199. package/templates/editor/.github/skills/zen-memory/scripts/check-instructions.sh +0 -116
@@ -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, routerBalance, } 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) {
@@ -536,6 +555,68 @@ const pick = async (ctx, args) => {
536
555
  throw credentialError(`no ${role} model answered on this machine`, 'add a credential: zen key add openai');
537
556
  };
538
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
+ balance: async (provider) => provider === 'openrouter' && source(where, provider) ? routerBalance() : undefined,
593
+ test: async (entry) => {
594
+ const role = entry.roles.includes('chat')
595
+ ? 'chat'
596
+ : entry.roles.includes('embedding')
597
+ ? 'embedding'
598
+ : (entry.roles[0] ?? 'chat');
599
+ const probe = await probeModel(target(entry.ref, entry.provider, entry.id, role));
600
+ return {
601
+ state: probe.check.state,
602
+ ms: probe.ms,
603
+ detail: probe.check.detail,
604
+ fix: probe.check.fix,
605
+ dimensions: probe.dimensions,
606
+ };
607
+ },
608
+ });
609
+ if (!chosen) {
610
+ // Leaving is a fine way to end a browse, but not an answer to a
611
+ // substitution that was waiting for one.
612
+ if (!process.stdout.isTTY) {
613
+ throw new CliError('nothing picked', EXIT.failed);
614
+ }
615
+ return;
616
+ }
617
+ write(chosen.ref);
618
+ };
619
+ // ---------------------------------------------------------------------------
539
620
  // Dispatch
540
621
  // ---------------------------------------------------------------------------
541
622
  const SUBS = {
@@ -548,6 +629,8 @@ const SUBS = {
548
629
  test,
549
630
  check: test,
550
631
  pick,
632
+ browse,
633
+ ui: browse,
551
634
  };
552
635
  export const models = {
553
636
  summary: 'What this machine can use: list, search, test and pick models.',
@@ -570,6 +653,8 @@ export const models = {
570
653
  ' zen models show <ref> One model, in full.',
571
654
  ' zen models test <ref> … Ask it one real question.',
572
655
  ' zen models pick --embedding The first ref that answers, on stdout.',
656
+ ' zen models browse [provider] Walk providers and models with the',
657
+ ' arrow keys; t asks one, p prints its ref.',
573
658
  ],
574
659
  run: async (ctx) => {
575
660
  const [name, ...rest] = ctx.args;
@@ -588,7 +673,7 @@ export const models = {
588
673
  await ls(ctx, ctx.args);
589
674
  return;
590
675
  }
591
- throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, pick')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
676
+ throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, pick, browse')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
592
677
  },
593
678
  };
594
679
  //# 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,15 +1,18 @@
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";
4
5
  import { concurrency, runBatch } from "../batch.js";
5
6
  import * as Engine from "../engine.js";
7
+ import { agentEventLog } from "../eventlog.js";
6
8
  import { duration, Narrator, stopMark, summary } from "../narrate.js";
7
9
  import * as Projects from "../projects.js";
8
10
  import { readBatch, readRequest } from "../request.js";
9
11
  import { target } from "../resolve.js";
10
12
  import { display } from "../session.js";
11
- import { bold, cyan, dim, json, jsonText, note, readStdin, usageError, write } from "../term.js";
13
+ import { bold, cyan, dim, drivenByAgent, json, jsonText, note, readStdin, usageError, write, } from "../term.js";
12
14
  import { parseChoice } from "../tui/theme.js";
15
+ import { appendUsage } from "../usage.js";
13
16
  const USAGE = 'zen run [project] [prompt] [options]';
14
17
  export const run = {
15
18
  summary: 'Run the project — the TUI on a terminal, one shot otherwise.',
@@ -40,6 +43,10 @@ export const run = {
40
43
  ' --theme <dark|light> Colours for the TUI. Default: auto.',
41
44
  ' --out <file> Put the answer in this file instead of on screen.',
42
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.',
43
50
  ' --yes Answer yes to every question.',
44
51
  ' --json Print machine-readable JSON (session, run, mounts, output, etc...).',
45
52
  '',
@@ -121,6 +128,8 @@ export const run = {
121
128
  plain: { type: 'boolean' },
122
129
  theme: { type: 'string' },
123
130
  out: { type: 'string' },
131
+ events: { type: 'string' },
132
+ disposable: { type: 'boolean' },
124
133
  'batch-dir': { type: 'string' },
125
134
  concurrency: { type: 'string' },
126
135
  }, USAGE);
@@ -158,6 +167,9 @@ export const run = {
158
167
  // TUI, where there is someone to ask, still asks.
159
168
  const input = request ? request.input : prompt;
160
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
+ }
161
173
  const where = await target({
162
174
  cwd: ctx.cwd,
163
175
  project: request?.project ?? values.project ?? (named ? head : undefined),
@@ -179,6 +191,7 @@ export const run = {
179
191
  memoryReadOnly: values['memory-read-only'],
180
192
  keys: values['no-keys'] ? false : undefined,
181
193
  yes: values.yes || ctx.json,
194
+ disposable: values.disposable,
182
195
  });
183
196
  try {
184
197
  // The TUI is for the case it is actually good at: a person at a
@@ -188,7 +201,8 @@ export const run = {
188
201
  const drawing = !shot &&
189
202
  !values.plain &&
190
203
  !ctx.json &&
191
- Boolean(process.stdout.isTTY && process.stdin.isTTY);
204
+ Boolean(process.stdout.isTTY && process.stdin.isTTY) &&
205
+ !drivenByAgent();
192
206
  if (drawing) {
193
207
  const { start } = await import("../tui/app.js");
194
208
  await start(engine, {
@@ -222,6 +236,8 @@ const NOT_IN_A_BATCH = [
222
236
  ['workspace', 'they cannot share one; each item gets its own, or names one in the file'],
223
237
  ['plain', 'a batch never draws the TUI'],
224
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'],
225
241
  ];
226
242
  async function batch(ctx, values, rest, piped) {
227
243
  if (rest.length > 0) {
@@ -261,11 +277,18 @@ async function once(engine, input, values, asJson, cwd, where) {
261
277
  quiet: asJson,
262
278
  live: Boolean(process.stderr.isTTY),
263
279
  });
280
+ const record = values.events ? agentEventLog(resolve(cwd, values.events)) : undefined;
281
+ const onEvent = (event) => {
282
+ narrator.handle(event);
283
+ record?.(event);
284
+ };
264
285
  // Ctrl-C asks the run to stop rather than killing the process, so the turn
265
- // 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.
266
288
  const stopping = new AbortController();
267
289
  const onInterrupt = () => stopping.abort();
268
290
  process.once('SIGINT', onInterrupt);
291
+ process.once('SIGTERM', onInterrupt);
269
292
  if (!asJson) {
270
293
  // Two questions were just answered, possibly without being asked. Say
271
294
  // which way they went: a run that quietly resumed the wrong session, or
@@ -278,12 +301,23 @@ async function once(engine, input, values, asJson, cwd, where) {
278
301
  }
279
302
  let outcome;
280
303
  try {
281
- outcome = await Engine.run(engine, input, narrator.handle, stopping.signal);
304
+ outcome = await Engine.run(engine, input, onEvent, stopping.signal);
282
305
  }
283
306
  finally {
284
307
  narrator.done();
285
308
  process.off('SIGINT', onInterrupt);
309
+ process.off('SIGTERM', onInterrupt);
286
310
  }
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
+ });
287
321
  if (asJson) {
288
322
  // --out is a destination, not a copy, and with --json the answer *is*
289
323
  // the envelope — so that is what lands in the file, not the prose.
@@ -1,16 +1,24 @@
1
1
  import { readProjectConfig, sandboxLimits, } from '@zenera/neo';
2
2
  import { parse } from "../args.js";
3
3
  import { resolveBuild } from "../image.js";
4
- import { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "../podman.js";
4
+ import { engineDisk, ensurePodmanReady, idleContainers, ownedContainers, podmanStatus, removeContainers, sessionOwners, } from "../podman.js";
5
5
  import { current as currentProject, dirSize, isProjectDir, open as openProject, Registry, sessionIds, } from "../projects.js";
6
- import { ago, bold, bytes, dim, green, json, note, red, table, usageError, write, writeAll, yellow, } from "../term.js";
7
- const USAGE = 'zen sandbox [status|up|pull|clean|disk] [options]';
6
+ import { layout, ResourceSampler } from "../resources.js";
7
+ import { ago, bold, bytes, count, cyan, dim, green, json, note, red, table, usageError, write, writeAll, yellow, } from "../term.js";
8
+ const USAGE = 'zen sandbox [status|top|up|pull|clean|disk] [options]';
9
+ const SUBCOMMANDS = ['status', 'top', 'up', 'pull', 'clean', 'disk'];
10
+ /** `top` refreshes this often unless told otherwise. */
11
+ const DEFAULT_INTERVAL_S = 2;
12
+ /** A one-shot report still needs two readings for a cpu figure. */
13
+ const ONCE_INTERVAL_MS = 1000;
8
14
  /** Enough to see the pattern; the rest are a number. */
9
15
  const LISTED = 6;
10
16
  /** Under the labels, which is where the eye already is. */
11
17
  const INDENT = ' '.repeat(11);
12
18
  /** `agents.yaml` states memory in MiB; `bytes` prints bytes. */
13
19
  const MIB = 1024 * 1024;
20
+ /** Below this many free locks, `status` says so: a batch can spend that in minutes. */
21
+ const FEW_LOCKS = 256;
14
22
  // ---------------------------------------------------------------------------
15
23
  // The container engine, on its own
16
24
  //
@@ -26,6 +34,8 @@ export const sandbox = {
26
34
  banner: { head: 'Zenera', accent: 'Sandbox', subtitle: 'Container Isolation', hue: 'azure' },
27
35
  details: [
28
36
  ' status What is installed, running and pulled. Changes nothing.',
37
+ ' top Memory and cpu, live: this computer, the machine, each',
38
+ ' container — with a note on what every figure means.',
29
39
  ' up Install if asked, start the machine, pull or build the image.',
30
40
  ' pull Just the image: pulled, or built from the project\u2019s Dockerfile.',
31
41
  ' clean Remove every container this CLI created.',
@@ -33,6 +43,12 @@ export const sandbox = {
33
43
  '',
34
44
  ' --project <name|dir> Which project the image and containers belong to.',
35
45
  ' --image <ref> Use this image instead of the project\u2019s.',
46
+ ' --idle With clean: only what no live run is using — stopped ones,',
47
+ ' and running ones whose run was killed before it cleaned up.',
48
+ ' --once With top: measure once and print, no live screen. Also',
49
+ ' what happens with --json or when output is not a terminal.',
50
+ ' --interval <seconds> With top: how often the live screen refreshes (default 2).',
51
+ ' --brief With top: the figures without the notes explaining them.',
36
52
  '',
37
53
  'Without --project this is the project you are standing in, and standing',
38
54
  'nowhere is an answer: `status` then reports the engine and every container',
@@ -45,9 +61,15 @@ export const sandbox = {
45
61
  const { values, positionals } = parse(ctx.args, {
46
62
  project: { type: 'string' },
47
63
  image: { type: 'string' },
64
+ stopped: { type: 'boolean' },
65
+ idle: { type: 'boolean' },
66
+ once: { type: 'boolean' },
67
+ interval: { type: 'string' },
68
+ brief: { type: 'boolean' },
69
+ theme: { type: 'string' },
48
70
  }, USAGE);
49
71
  const what = positionals[0] ?? 'status';
50
- if (!['status', 'up', 'pull', 'clean', 'disk'].includes(what)) {
72
+ if (!SUBCOMMANDS.includes(what)) {
51
73
  throw usageError(`unknown subcommand: ${what}`, USAGE);
52
74
  }
53
75
  if (positionals.length > 1) {
@@ -69,8 +91,10 @@ export const sandbox = {
69
91
  throw usageError('no project here, so there is no image to pull', 'name one with --project, or an image with --image');
70
92
  }
71
93
  return up(image, build, true, ctx.json, true);
94
+ case 'top':
95
+ return top(values, ctx.json);
72
96
  case 'clean':
73
- return clean(ctx.json);
97
+ return clean(ctx.json, Boolean(values.idle || values.stopped));
74
98
  case 'disk':
75
99
  return disk(ctx.json);
76
100
  }
@@ -116,6 +140,7 @@ async function status(project, image, asJson) {
116
140
  // a report about one project must not list another's — or the faker's,
117
141
  // which wears the same label and belongs to no project at all.
118
142
  const containers = project ? ofProject(project.dir, all) : all;
143
+ const idle = all.length > 0 ? idleContainers(all, await sessionOwners()) : [];
119
144
  if (asJson) {
120
145
  json({
121
146
  ...found,
@@ -124,6 +149,7 @@ async function status(project, image, asJson) {
124
149
  limits: project?.limits ?? null,
125
150
  fits: fits(found.capacity?.memory, project?.limits?.memory) ?? null,
126
151
  containers,
152
+ idle: idle.map((c) => c.name),
127
153
  });
128
154
  return;
129
155
  }
@@ -153,11 +179,64 @@ async function status(project, image, asJson) {
153
179
  if (build) {
154
180
  write(`${bold('dockerfile')} ${dim(build.dockerfile)}`);
155
181
  }
156
- writeAll(containerLines(containers, project?.name));
182
+ writeAll(lockLines(found.freeLocks, idle.length));
183
+ writeAll(containerLines(containers, idle, project?.name, all.length));
157
184
  if (!found.installed || !found.ready) {
158
185
  note('');
159
186
  note(dim('run `zen sandbox up` to fix what can be fixed.'));
160
187
  }
188
+ else {
189
+ note('');
190
+ note(dim('memory and cpu, live: zen sandbox top'));
191
+ }
192
+ }
193
+ // ---------------------------------------------------------------------------
194
+ // Memory and cpu
195
+ //
196
+ // A live screen when a person is watching, one report when not: the same
197
+ // layout either way, so what a script reads is what the screen showed.
198
+ // ---------------------------------------------------------------------------
199
+ async function top(values, asJson) {
200
+ const interval = values.interval === undefined ? DEFAULT_INTERVAL_S : Number(values.interval);
201
+ if (!Number.isFinite(interval) || interval < 0.5 || interval > 3600) {
202
+ throw usageError(`--interval must be a number of seconds from 0.5 to 3600, not ${values.interval}`, USAGE);
203
+ }
204
+ const sampler = new ResourceSampler();
205
+ const live = !asJson && !values.once && Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
206
+ if (live) {
207
+ const { watch } = await import("../tui/sandbox.js");
208
+ await watch({
209
+ sampler,
210
+ intervalMs: interval * 1000,
211
+ explain: !values.brief,
212
+ theme: values.theme,
213
+ });
214
+ return;
215
+ }
216
+ // The first reading has nothing to compare against, so its cpu figures are
217
+ // lifetime averages; the second one is over a second of real time.
218
+ await sampler.next();
219
+ await new Promise((done) => setTimeout(done, ONCE_INTERVAL_MS));
220
+ const view = await sampler.next();
221
+ if (asJson) {
222
+ json(view);
223
+ return;
224
+ }
225
+ const width = process.stdout.columns || Number(process.env.COLUMNS) || 100;
226
+ writeAll(layout(view, { width, explain: !values.brief, sort: 'memory', wrapNotes: true }).map(ansi));
227
+ }
228
+ const PAINT = {
229
+ plain: (s) => s,
230
+ strong: bold,
231
+ heading: (s) => bold(cyan(s)),
232
+ dim,
233
+ good: green,
234
+ warn: yellow,
235
+ bad: red,
236
+ accent: cyan,
237
+ };
238
+ function ansi(line) {
239
+ return line.map((s) => PAINT[s.tone ?? 'plain'](s.text)).join('');
161
240
  }
162
241
  /**
163
242
  * The hungriest container this project can start. An agent may override the
@@ -225,6 +304,22 @@ function ofProject(dir, containers) {
225
304
  const sessions = new Set(sessionIds(dir));
226
305
  return containers.filter((c) => c.key !== undefined && sessions.has(c.key));
227
306
  }
307
+ /**
308
+ * Every container the engine holds takes one of its locks, stopped or not, and
309
+ * at zero nothing can be created — for any project. Said only when it is close.
310
+ */
311
+ function lockLines(free, idle) {
312
+ if (free === undefined || free >= FEW_LOCKS) {
313
+ return [];
314
+ }
315
+ const left = free === 0 ? red('none free') : yellow(`${free} free`);
316
+ return [
317
+ `${bold('locks')} ${left} ${dim('· one per container, stopped ones included')}`,
318
+ ...(idle > 0
319
+ ? [`${INDENT}${dim(`${idle} no live run is using — zen sandbox clean --idle`)}`]
320
+ : []),
321
+ ];
322
+ }
228
323
  /**
229
324
  * One per line rather than one long line, because there is normally more than
230
325
  * one and the interesting part — how old, and whether anything is still up —
@@ -235,21 +330,31 @@ function ofProject(dir, containers) {
235
330
  * stopped ones behind. `scope` names the project these belong to, and saying
236
331
  * so is half the answer — the other half is that there are more elsewhere.
237
332
  */
238
- function containerLines(containers, scope) {
333
+ function containerLines(containers, idle, scope, total = containers.length) {
239
334
  const tail = scope
240
335
  ? `one per session in ${scope}, kept by \`persist: true\` — all of them: zen sandbox disk`
241
336
  : 'one per session, kept by `persist: true` — see: zen sandbox disk';
337
+ const elsewhere = total > containers.length ? ` · ${total} on the machine` : '';
242
338
  if (containers.length === 0) {
243
- return [`${bold('containers')} ${dim(scope ? `none in ${scope}` : 'none')}`];
339
+ return [`${bold('containers')} ${dim(scope ? `none in ${scope}${elsewhere}` : 'none')}`];
244
340
  }
341
+ // Running, but the run that started it is gone: nothing will ever stop it.
342
+ const orphans = new Set(idle.filter((c) => c.state === 'running').map((c) => c.name));
245
343
  const running = containers.filter((c) => c.state === 'running').length;
246
- const head = `${bold('containers')} ${containers.length} ${dim(running ? `· ${running} running` : '· none running')}`;
344
+ const lost = containers.filter((c) => orphans.has(c.name)).length;
345
+ const head = `${bold('containers')} ${containers.length} ${dim((running ? `· ${running} running` : '· none running') +
346
+ (lost ? `, ${lost} of them orphaned` : '') +
347
+ elsewhere)}`;
247
348
  const rows = containers
248
349
  .slice(0, LISTED)
249
350
  .map((c) => [
250
351
  INDENT.slice(2),
251
352
  c.name,
252
- c.state === 'running' ? green('running') : dim(c.state),
353
+ orphans.has(c.name)
354
+ ? yellow('orphaned')
355
+ : c.state === 'running'
356
+ ? green('running')
357
+ : dim(c.state),
253
358
  dim(ago(c.createdAt)),
254
359
  ]);
255
360
  const rest = containers.length - LISTED;
@@ -258,6 +363,9 @@ function containerLines(containers, scope) {
258
363
  ...table(rows),
259
364
  ...(rest > 0 ? [`${INDENT}${dim(`+${rest} more`)}`] : []),
260
365
  `${INDENT}${dim(tail)}`,
366
+ ...(lost
367
+ ? [`${INDENT}${dim('orphaned: its run was killed — zen sandbox clean --idle')}`]
368
+ : []),
261
369
  ];
262
370
  }
263
371
  async function up(image, build, yes, asJson, rebuild = false) {
@@ -268,8 +376,9 @@ async function up(image, build, yes, asJson, rebuild = false) {
268
376
  }
269
377
  write(`${green('ready')}${image ? ` ${dim(image)}` : ''}`);
270
378
  }
271
- async function clean(asJson) {
272
- const containers = await ownedContainers(undefined, undefined, { sizes: true });
379
+ async function clean(asJson, idle) {
380
+ const owned = await ownedContainers(undefined, undefined, { sizes: true });
381
+ const containers = idle ? idleContainers(owned, await sessionOwners()) : owned;
273
382
  const names = containers.map((c) => c.name);
274
383
  const freed = containers.reduce((n, c) => n + (c.size ?? 0), 0);
275
384
  await removeContainers(names);
@@ -307,10 +416,30 @@ async function disk(asJson) {
307
416
  write('');
308
417
  }
309
418
  writeAll(projectRows(projects, loose));
310
- if (usage && usage.images.reclaimable > 0) {
419
+ const idle = idleContainers(containers, await sessionOwners());
420
+ const live = containers.length - idle.length;
421
+ const sessions = projects.reduce((n, p) => n + p.files, 0);
422
+ const hints = [
423
+ ...(idle.length > 0
424
+ ? [`zen sandbox clean --idle ${count(idle.length, 'container')} no live run is using`]
425
+ : []),
426
+ ...(live > 0
427
+ ? [`zen sandbox clean also the ${live} in use, cutting off whatever runs there`]
428
+ : []),
429
+ ...(usage && usage.images.reclaimable > 0
430
+ ? [`podman image prune -a ${bytes(usage.images.reclaimable)} of unused images`]
431
+ : []),
432
+ // The ON DISK column is the bulk of the report and no command above touches it.
433
+ ...(sessions > 0
434
+ ? [
435
+ `${bytes(sessions)} ON DISK is project directories on this host, not the engine: ` +
436
+ 'removing containers frees none of it',
437
+ ]
438
+ : []),
439
+ ];
440
+ if (hints.length > 0) {
311
441
  write('');
312
- write(dim(`zen sandbox clean every container above`));
313
- write(dim(`podman image prune -a ${bytes(usage.images.reclaimable)} of unused images`));
442
+ writeAll(hints.map((h) => dim(h)));
314
443
  }
315
444
  }
316
445
  function engineRows(usage) {
@@ -382,13 +511,16 @@ function projectRows(projects, loose) {
382
511
  // Containers whose session directory is gone, and faker's, which are
383
512
  // labelled the same way and belong to no project at all.
384
513
  const size = loose.reduce((n, c) => n + (c.size ?? 0), 0);
514
+ const fakers = loose.filter((c) => c.key?.startsWith('faker-')).length;
385
515
  rows.push([
386
516
  dim('(unclaimed)'),
387
517
  dim('—'),
388
518
  dim('—'),
389
519
  dim(String(loose.length)),
390
520
  dim(bytes(size)),
391
- dim('no session owns these'),
521
+ dim(fakers === loose.length
522
+ ? `zen faker ${fakers === 1 ? 'server' : 'servers'}, no project`
523
+ : 'no session owns these'),
392
524
  ]);
393
525
  }
394
526
  const total = (pick) => projects.reduce((n, p) => n + pick(p), 0);
@@ -0,0 +1,8 @@
1
+ import type { Command } from '../command.ts';
2
+ /** A file inside the cwd is served from the cwd, so its links up the tree still resolve. */
3
+ export declare function placeOf(cwd: string, target: string): {
4
+ root: string;
5
+ start: string;
6
+ };
7
+ export declare const view: Command;
8
+ //# sourceMappingURL=view.d.ts.map