@zenera/cli 1.1.25 → 1.1.27

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 (96) hide show
  1. package/README.md +13 -1
  2. package/dist/catalog.d.ts +20 -0
  3. package/dist/catalog.js +60 -0
  4. package/dist/commands/index.js +2 -0
  5. package/dist/commands/inspect.d.ts +5 -1
  6. package/dist/commands/inspect.js +54 -14
  7. package/dist/commands/memory.js +28 -3
  8. package/dist/commands/meta.js +24 -5
  9. package/dist/commands/models.js +2 -1
  10. package/dist/commands/sandbox.js +73 -3
  11. package/dist/commands/view.d.ts +8 -0
  12. package/dist/commands/view.js +114 -0
  13. package/dist/engine.d.ts +3 -1
  14. package/dist/engine.js +6 -0
  15. package/dist/eventlog.d.ts +5 -0
  16. package/dist/eventlog.js +25 -1
  17. package/dist/liveness.js +20 -2
  18. package/dist/main.js +0 -0
  19. package/dist/meta/editor.d.ts +1 -1
  20. package/dist/meta/editor.js +4 -21
  21. package/dist/meta/finetune/analyze-brief.d.ts +26 -0
  22. package/dist/meta/finetune/analyze-brief.js +131 -0
  23. package/dist/meta/finetune/apply-brief.d.ts +23 -0
  24. package/dist/meta/finetune/apply-brief.js +247 -0
  25. package/dist/meta/finetune/brief.d.ts +45 -0
  26. package/dist/meta/finetune/brief.js +158 -0
  27. package/dist/meta/finetune/command.d.ts +1 -1
  28. package/dist/meta/finetune/command.js +224 -46
  29. package/dist/meta/finetune/cost.d.ts +46 -0
  30. package/dist/meta/finetune/cost.js +105 -0
  31. package/dist/meta/finetune/dataset/types.d.ts +3 -1
  32. package/dist/meta/finetune/feedback.d.ts +14 -2
  33. package/dist/meta/finetune/feedback.js +22 -3
  34. package/dist/meta/finetune/guard.d.ts +29 -0
  35. package/dist/meta/finetune/guard.js +104 -0
  36. package/dist/meta/finetune/improvements.d.ts +50 -3
  37. package/dist/meta/finetune/improvements.js +290 -33
  38. package/dist/meta/finetune/live.d.ts +17 -7
  39. package/dist/meta/finetune/live.js +56 -10
  40. package/dist/meta/finetune/loop.js +59 -10
  41. package/dist/meta/finetune/recheck.d.ts +58 -0
  42. package/dist/meta/finetune/recheck.js +311 -0
  43. package/dist/meta/finetune/report.d.ts +9 -2
  44. package/dist/meta/finetune/report.js +551 -438
  45. package/dist/meta/finetune/sampler.js +8 -2
  46. package/dist/meta/finetune/train.d.ts +6 -2
  47. package/dist/meta/finetune/train.js +55 -21
  48. package/dist/meta/finetune/tui.d.ts +3 -3
  49. package/dist/meta/finetune/tui.js +37 -12
  50. package/dist/meta/finetune/tuning.d.ts +27 -1
  51. package/dist/meta/finetune/tuning.js +21 -2
  52. package/dist/meta/finetune/usage.d.ts +4 -0
  53. package/dist/meta/finetune/usage.js +35 -0
  54. package/dist/meta/finetune/view.d.ts +9 -0
  55. package/dist/meta/finetune/view.js +27 -0
  56. package/dist/meta/host.d.ts +2 -1
  57. package/dist/meta/host.js +2 -1
  58. package/dist/meta.d.ts +291 -0
  59. package/dist/meta.js +981 -0
  60. package/dist/resources.d.ts +215 -0
  61. package/dist/resources.js +856 -0
  62. package/dist/scaffold.d.ts +3 -4
  63. package/dist/scaffold.js +21 -9
  64. package/dist/trace.d.ts +5 -0
  65. package/dist/trace.js +54 -5
  66. package/dist/tui/app.js +19 -5
  67. package/dist/tui/links.d.ts +27 -0
  68. package/dist/tui/links.js +142 -0
  69. package/dist/tui/models.d.ts +3 -1
  70. package/dist/tui/models.js +65 -7
  71. package/dist/tui/sandbox.d.ts +10 -0
  72. package/dist/tui/sandbox.js +131 -0
  73. package/package.json +3 -2
  74. package/templates/editor/.github/copilot-instructions.md +29 -43
  75. package/templates/editor/.github/prompts/analyze.prompt.md +3 -1
  76. package/templates/editor/.github/prompts/finetune-apply.prompt.md +221 -42
  77. package/templates/editor/.github/prompts/project-review.prompt.md +1 -1
  78. package/templates/editor/.github/skills/zen-analyze-run/SKILL.md +162 -57
  79. package/templates/editor/.github/skills/zen-cli/references/finetune.md +74 -14
  80. package/templates/editor/.github/skills/zen-cli/references/inspect.md +16 -2
  81. package/templates/editor/.github/skills/zen-cli/references/projects.md +4 -6
  82. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +25 -13
  83. package/templates/editor/.github/skills/zen-finetune/SKILL.md +74 -33
  84. package/templates/editor/.github/skills/zen-inspect/SKILL.md +14 -1
  85. package/templates/editor/.github/skills/zen-inspect-ask/SKILL.md +4 -3
  86. package/templates/editor/.github/skills/zen-instructions/SKILL.md +4 -12
  87. package/templates/editor/.github/skills/zen-memory/SKILL.md +64 -39
  88. package/templates/editor/.github/skills/zen-review/SKILL.md +6 -31
  89. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +1 -2
  90. package/templates/editor/.github/skills/zen-review/scripts/review.sh +3 -12
  91. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +2 -1
  92. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +2 -4
  93. package/templates/project/agents/fork-instructions.md +78 -28
  94. package/templates/project/agents/memory-instructions.md +222 -493
  95. package/templates/project/agents/tools-instructions.md +7 -0
  96. package/templates/editor/.github/skills/zen-memory/scripts/check-instructions.sh +0 -116
package/README.md CHANGED
@@ -494,6 +494,8 @@ you want to validate a project's execution environment before a run.
494
494
 
495
495
  ```sh
496
496
  zen sandbox status # engine, project image and active containers
497
+ zen sandbox top # memory and cpu, live: this computer, the machine, each container
498
+ zen sandbox top --once # the same, printed once (also with --json or when piped)
497
499
  zen sandbox up # prepare the execution environment for this project
498
500
  zen sandbox pull # pull or build this project's image only
499
501
  zen sandbox status --project my-app # inspect a named project from anywhere
@@ -507,6 +509,13 @@ zen sandbox clean --idle # only what no live run is using
507
509
  `disk` are machine-wide operations, so use them when you mean to inspect or
508
510
  remove resources beyond the current project.
509
511
 
512
+ `top` is machine-wide too. Next to every figure it says in a few words what the
513
+ figure means; `--brief` leaves those notes out, `--interval <seconds>` sets how
514
+ often the live screen refreshes. On macOS it answers the usual question - why
515
+ Activity Monitor shows "Virtual Machine Service for vfkit" holding many
516
+ gigabytes - by setting what macOS counts for the machine against what is in use
517
+ inside it, and naming the command that gives the difference back.
518
+
510
519
  ## Sharing a project
511
520
 
512
521
  A project is a directory, so sending one somewhere else is a zip - except that
@@ -554,6 +563,7 @@ The commands to run next are printed for you to read and type.
554
563
  | `run` | Runs the project - the TUI on a terminal, a single answer otherwise. |
555
564
  | `meta` | Runs the meta agent over the project - the one that edits it. |
556
565
  | `inspect` | Opens or rebuilds a run's `report.html`. |
566
+ | `view` | Shows a folder in the browser, redrawn live as its files change. |
557
567
  | `memory` | What the agents remember - size, listing, one node, or a whole page. |
558
568
  | `check` | Validates the project and every file it names, and asks the models. |
559
569
  | `export` | Writes the project to a shareable zip archive. |
@@ -734,6 +744,7 @@ See the `zen-dataset` skill a project carries in `.github/skills/`.
734
744
  zen meta finetune start -N 4 # 4 cases at a time; resumes if stopped
735
745
  zen meta finetune # where it stands
736
746
  zen meta finetune stop # finish the steps in flight, then stop
747
+ zen meta finetune serve # the pages live in a browser, while it is stopped
737
748
  zen meta finetune session plan-day # resume that case's analyze session
738
749
  ```
739
750
 
@@ -743,7 +754,8 @@ applied in batches between runs, and every apply is a new, diffable version of
743
754
  the prose. Follow it in `finetune/STATUS.md`, whose summary shows whether the
744
755
  fixes and the memory are making runs cheaper; each case has its own
745
756
  `finetune/cases/<id>/FEEDBACK.md` with its latest analysis and the metrics of
746
- every try.
757
+ every try. `start` prints a local address that shows those pages in a browser,
758
+ with their diagrams drawn, updated in place as the tuning writes them.
747
759
 
748
760
  ## Credentials
749
761
 
package/dist/catalog.d.ts CHANGED
@@ -103,6 +103,26 @@ export interface CatalogOptions {
103
103
  * model list to four rows.
104
104
  */
105
105
  export declare function loadCatalog(provider: Provider, opts?: CatalogOptions): Promise<Catalog>;
106
+ /** A per-token USD price as dollars per million tokens, the unit price lists use. */
107
+ export declare function perMillion(price: string | undefined): string;
108
+ /** What an OpenRouter account has left to spend, as far as its key may see. */
109
+ export interface Balance {
110
+ /** credits bought minus credits used; absent when the key may not read the account */
111
+ credits?: number;
112
+ /** the key's own spending cap, when one is set */
113
+ cap?: {
114
+ limit: number;
115
+ remaining: number;
116
+ reset?: string;
117
+ };
118
+ /** requests to `:free` models left today */
119
+ free?: {
120
+ used: number;
121
+ limit: number;
122
+ remaining: number;
123
+ };
124
+ }
125
+ export declare function routerBalance(): Promise<Balance>;
106
126
  /** Every provider asked at once — they are independent and mostly latency. */
107
127
  export declare function loadCatalogs(providers: readonly Provider[], opts?: CatalogOptions): Promise<Catalog[]>;
108
128
  export interface Filters {
package/dist/catalog.js CHANGED
@@ -477,6 +477,66 @@ export async function loadCatalog(provider, opts = {}) {
477
477
  fetchedAt: new Date(0).toISOString(),
478
478
  };
479
479
  }
480
+ // ---------------------------------------------------------------------------
481
+ // Prices and balance
482
+ // ---------------------------------------------------------------------------
483
+ /** A per-token USD price as dollars per million tokens, the unit price lists use. */
484
+ export function perMillion(price) {
485
+ if (price === undefined || price === '') {
486
+ return '';
487
+ }
488
+ const n = Number(price) * 1_000_000;
489
+ if (!Number.isFinite(n)) {
490
+ return '';
491
+ }
492
+ // OpenRouter prices its routers (`openrouter/auto`) as "-1": it depends on the pick.
493
+ if (n < 0) {
494
+ return 'varies';
495
+ }
496
+ if (n === 0) {
497
+ return 'free';
498
+ }
499
+ if (n >= 1) {
500
+ return `$${n.toFixed(2)}`;
501
+ }
502
+ // Two significant digits for fractions of a dollar, but never fewer than cents.
503
+ const short = String(Number(n.toPrecision(2)));
504
+ return `$${(short.split('.')[1]?.length ?? 0) < 2 ? n.toFixed(2) : short}`;
505
+ }
506
+ export async function routerBalance() {
507
+ const base = (process.env.OPENROUTER_BASE_URL ?? 'https://openrouter.ai/api/v1').replace(/\/+$/, '');
508
+ const get = async (path) => {
509
+ const res = await fetch(`${base}${path}`, {
510
+ headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY ?? ''}` },
511
+ signal: AbortSignal.timeout(15_000),
512
+ });
513
+ if (!res.ok) {
514
+ throw Object.assign(new Error(`${path} answered ${res.status}`), {
515
+ status: res.status,
516
+ });
517
+ }
518
+ return (await res.json()).data;
519
+ };
520
+ // Raw rather than the SDK: its schema drops `free_model_daily_requests`.
521
+ const [key, credits] = await Promise.all([
522
+ get('/key'),
523
+ // Documented as management-key only, though ordinary keys are answered too.
524
+ get('/credits').catch(() => undefined),
525
+ ]);
526
+ return {
527
+ ...(credits ? { credits: credits.total_credits - credits.total_usage } : {}),
528
+ ...(key.limit !== null && key.limit_remaining !== null
529
+ ? {
530
+ cap: {
531
+ limit: key.limit,
532
+ remaining: key.limit_remaining,
533
+ ...(key.limit_reset ? { reset: key.limit_reset } : {}),
534
+ },
535
+ }
536
+ : {}),
537
+ ...(key.free_model_daily_requests ? { free: key.free_model_daily_requests } : {}),
538
+ };
539
+ }
480
540
  /** Every provider asked at once — they are independent and mostly latency. */
481
541
  export async function loadCatalogs(providers, opts = {}) {
482
542
  return Promise.all(providers.map((p) => loadCatalog(p, opts)));
@@ -13,6 +13,7 @@ import { open } from "./open.js";
13
13
  import { run } from "./run.js";
14
14
  import { sandbox } from "./sandbox.js";
15
15
  import { version } from "./version.js";
16
+ import { view } from "./view.js";
16
17
  /**
17
18
  * Insertion order is the order help prints in, and it is deliberate: the four
18
19
  * a new user needs first, then the credentials-and-models pair, then the two
@@ -32,6 +33,7 @@ export const COMMANDS = {
32
33
  models,
33
34
  check,
34
35
  inspect,
36
+ view,
35
37
  export: pack,
36
38
  import: unpack,
37
39
  memory,
@@ -11,15 +11,19 @@ interface Flags {
11
11
  part?: string[];
12
12
  'question-file'?: string;
13
13
  full?: boolean;
14
+ raw?: boolean;
14
15
  open?: boolean;
15
16
  rebuild?: boolean;
16
17
  'no-timing'?: boolean;
17
18
  'no-style'?: boolean;
18
19
  }
19
20
  export declare const inspect: Command;
21
+ /** The `--part` names that match no part of any of these nodes. */
22
+ export declare function missingParts(found: readonly NodeDetail[], want: readonly string[]): string[];
20
23
  /**
21
24
  * Which parts print in full: everything `--part` names, or everything but
22
- * `request`. A prefix matches, so `--part call` catches `call run_command (…)`.
25
+ * the bulky ones. A prefix matches, so `--part call` catches `call run_command (…)`.
26
+ * A name that matches nothing is an error only when no name matches at all.
23
27
  */
24
28
  export declare function wholeParts(found: readonly NodeDetail[], values: {
25
29
  part?: string[];
@@ -1,8 +1,9 @@
1
- import { assertState, buildRunReport, FilePayloadStore, memoryDir, MemoryStore, PayloadResolver, projectRegistry, readProjectConfig, renderReportHtml, text, } from '@zenera/neo';
1
+ import { assertState, buildRunReport, FilePayloadStore, memoryDir, MemoryStore, PayloadResolver, projectRegistry, readProjectConfig, renderReportHtml, SANDBOX_MOUNT, text, } from '@zenera/neo';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { readFile, writeFile } from 'node:fs/promises';
5
5
  import { join, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
6
7
  import { parse } from "../args.js";
7
8
  import { buildDiagnostic, parseRecordedRequest, pickModel } from "../ask.js";
8
9
  import { loadProjectEnv } from "../env.js";
@@ -59,6 +60,7 @@ export const inspect = {
59
60
  ' --question-file <path> Read the `ask` question from a file; `-` is stdin.',
60
61
  ' --part <name> Print this part of a node in full. Repeatable.',
61
62
  ' --full Print every part, the recorded `request` included.',
63
+ ' --raw With one node and one --part: that part alone, unframed.',
62
64
  ' --rebuild Build report.html again from the run state.',
63
65
  ' --open Open the report in a browser.',
64
66
  ' --no-timing Leave the clock off the graph.',
@@ -84,6 +86,14 @@ export const inspect = {
84
86
  '',
85
87
  ' zen inspect node n11 --part thinking --part text --dir <run dir>',
86
88
  '',
89
+ 'An `llm_call` also carries two parts read out of its request, printed',
90
+ 'only when named: `tools`, the tool names that call was offered, and',
91
+ '`messages`, the conversation it saw, one clipped line per message.',
92
+ '`--raw` prints one part with nothing around it, for a pipe:',
93
+ '',
94
+ ' zen inspect node n11 --part tools --dir <run dir>',
95
+ " zen inspect node n11 --part request --raw --dir <run dir> | jq '.messages'",
96
+ '',
87
97
  '`ask` replays one `llm_call` — its system prompt, its messages, its tool',
88
98
  'schemas — to a model, with your question on the end and tool calling off.',
89
99
  'It is told the run is over and that it may quote its own instructions, so',
@@ -122,6 +132,7 @@ export const inspect = {
122
132
  part: { type: 'string', multiple: true },
123
133
  'question-file': { type: 'string' },
124
134
  full: { type: 'boolean' },
135
+ raw: { type: 'boolean' },
125
136
  open: { type: 'boolean' },
126
137
  rebuild: { type: 'boolean' },
127
138
  'no-timing': { type: 'boolean' },
@@ -238,18 +249,42 @@ async function nodes(at, values, ids, asJson) {
238
249
  return;
239
250
  }
240
251
  const whole = wholeParts(found, values);
241
- writeAll(preamble(run.id, found, whole, trace.entries.length));
252
+ if (values.raw) {
253
+ const [node] = found;
254
+ const hits = node?.parts.filter((p) => whole(p.name)) ?? [];
255
+ if (found.length !== 1 || values.part?.length !== 1 || hits.length !== 1) {
256
+ throw usageError('--raw prints one part of one node', hits.length > 1
257
+ ? `name one: ${hits.map((p) => `--part ${flag(p.name)}`).join(', ')}`
258
+ : 'zen inspect node n11 --part request --raw --dir <run dir>');
259
+ }
260
+ const { text } = hits[0];
261
+ process.stdout.write(text.endsWith('\n') ? text : `${text}\n`);
262
+ return;
263
+ }
264
+ writeAll([
265
+ ...preamble(run.id, found, whole, trace.entries.length),
266
+ ...missingParts(found, values.part ?? []).map((name) => `# no part starts with "${name}" in these nodes`),
267
+ ]);
242
268
  for (const node of found) {
243
269
  write(renderNode(node, whole));
244
270
  }
245
- note(dim(`the rest of the run: ${cyan(`zen inspect graph --dir ${run.dir}`)}`));
246
- note(dim(`why it did this: ${cyan(`zen inspect ask ${found[0]?.id ?? 'n1'} "…" --dir ${run.dir}`)}`));
271
+ // A model reading this already has the commands; only a person needs reminding.
272
+ if (process.stderr.isTTY) {
273
+ note(dim(`the rest of the run: ${cyan(`zen inspect graph --dir ${run.dir}`)}`));
274
+ note(dim(`why it did this: ${cyan(`zen inspect ask ${found[0]?.id ?? 'n1'} "…" --dir ${run.dir}`)}`));
275
+ }
276
+ }
277
+ /** Parts `node` names with their size instead of printing, unless asked for. */
278
+ const BULKY = new Set(['request', 'messages', 'tools']);
279
+ /** The `--part` names that match no part of any of these nodes. */
280
+ export function missingParts(found, want) {
281
+ const names = found.flatMap((n) => n.parts.map((p) => p.name));
282
+ return want.filter((asked) => !names.some((name) => name.startsWith(asked)));
247
283
  }
248
- /** The part `--part` leaves out unless it is asked for by name. */
249
- const BULKY = 'request';
250
284
  /**
251
285
  * Which parts print in full: everything `--part` names, or everything but
252
- * `request`. A prefix matches, so `--part call` catches `call run_command (…)`.
286
+ * the bulky ones. A prefix matches, so `--part call` catches `call run_command (…)`.
287
+ * A name that matches nothing is an error only when no name matches at all.
253
288
  */
254
289
  export function wholeParts(found, values) {
255
290
  if (values.full) {
@@ -257,13 +292,12 @@ export function wholeParts(found, values) {
257
292
  }
258
293
  const want = values.part ?? [];
259
294
  if (!want.length) {
260
- return (name) => name !== BULKY;
295
+ return (name) => !BULKY.has(name);
261
296
  }
262
- const names = [...new Set(found.flatMap((n) => n.parts.map((p) => p.name)))];
263
- for (const asked of want) {
264
- if (!names.some((name) => name.startsWith(asked))) {
265
- throw usageError(`no part starts with "${asked}" in ${found.length === 1 ? found[0]?.id : 'these nodes'}`, names.length ? `they carry: ${names.join(', ')}` : 'they carry no payloads');
266
- }
297
+ const missing = missingParts(found, want);
298
+ if (missing.length === want.length) {
299
+ const names = [...new Set(found.flatMap((n) => n.parts.map((p) => p.name)))];
300
+ throw usageError(`no part starts with "${want[0]}" in ${found.length === 1 ? found[0]?.id : 'these nodes'}`, names.length ? `they carry: ${names.join(', ')}` : 'they carry no payloads');
267
301
  }
268
302
  return (name) => want.some((asked) => name.startsWith(asked));
269
303
  }
@@ -618,7 +652,13 @@ async function pickRun(session, asked, asking) {
618
652
  async function rebuild(session, run, store) {
619
653
  const state = await readState(run);
620
654
  const payloads = new PayloadResolver(new FilePayloadStore({ dir: session.blobs, id: 'file' }));
621
- const report = await buildRunReport(state, payloads, { title: run.id, memory: store });
655
+ const { workspace } = await readRunMeta(run);
656
+ const report = await buildRunReport(state, payloads, {
657
+ title: run.id,
658
+ memory: store,
659
+ // The run never recorded a `workdir:`, so the default name is the guess.
660
+ mounts: workspace ? [{ at: SANDBOX_MOUNT, href: pathToFileURL(workspace).href }] : [],
661
+ });
622
662
  await writeFile(run.report, renderReportHtml(report), 'utf8');
623
663
  }
624
664
  /**
@@ -1,6 +1,6 @@
1
1
  import { ALL_AGENTS, buildMemoryReport, createEmbedder, diffMemories, GREP_FIELDS, grepMemory, hostPath, memoryDir, MemoryError, MemoryIndex, MemoryStore, MergeConflicts, mergeMemories, readProjectConfig, renderMemoryHtml, renderRecollection, } from '@zenera/neo';
2
2
  import { spawn } from 'node:child_process';
3
- import { existsSync } from 'node:fs';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
4
  import { writeFile } from 'node:fs/promises';
5
5
  import { basename, isAbsolute, join, resolve } from 'node:path';
6
6
  import { parse } from "../args.js";
@@ -49,6 +49,7 @@ export const memory = {
49
49
  '',
50
50
  ' --project <name|dir> Which project. Defaults to the one you are in.',
51
51
  ' --dir <dir> Read this memory directory instead of the project’s.',
52
+ ' Run from inside a memory directory, that one is the default.',
52
53
  ' --kind <name> Only this kind of node.',
53
54
  ' --audience <name> Only nodes committed under this label.',
54
55
  ' --files Only nodes that remember a file.',
@@ -198,6 +199,11 @@ export const memory = {
198
199
  */
199
200
  async function open(cwd, want, at, opts = {}) {
200
201
  const { create = false, lock = true } = opts;
202
+ // Standing inside a memory (a merged or batch copy) means that memory, not
203
+ // the project's own one a few directories up.
204
+ if (!at && !want && isMemoryDir(cwd)) {
205
+ at = cwd;
206
+ }
201
207
  if (at) {
202
208
  const dir = resolve(cwd, at);
203
209
  if (!create && !existsSync(join(dir, 'manifest.json'))) {
@@ -221,6 +227,19 @@ async function open(cwd, want, at, opts = {}) {
221
227
  at: { root: project.dir, config },
222
228
  };
223
229
  }
230
+ /** A rag index also has a manifest and a graph.json; only a memory's manifest counts nodes and edges. */
231
+ function isMemoryDir(dir) {
232
+ if (!existsSync(join(dir, 'graph.json'))) {
233
+ return false;
234
+ }
235
+ try {
236
+ const m = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'));
237
+ return typeof m?.nodes === 'number' && typeof m?.edges === 'number';
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ }
224
243
  // ---------------------------------------------------------------------------
225
244
  // stats
226
245
  // ---------------------------------------------------------------------------
@@ -753,10 +772,16 @@ async function write_(o, rest, flags, cwd, asJson) {
753
772
  const report = { ...built, nodes: built.nodes.map((n) => onHost(o, n)) };
754
773
  await writeFile(target, renderMemoryHtml(report), 'utf8');
755
774
  if (asJson) {
756
- json({ path: target, nodes: report.nodes.length, edges: report.edges.length });
775
+ json({
776
+ path: target,
777
+ from: o.dir,
778
+ nodes: report.nodes.length,
779
+ edges: report.edges.length,
780
+ });
757
781
  }
758
782
  else {
759
- note(`${green('wrote')} ${target} ${dim(`· ${count(report.nodes.length, 'node')}, ${count(report.edges.length, 'edge')}`)}`);
783
+ note(`${green('wrote')} ${target} ${dim(`· ${count(report.nodes.length, 'node')}, ${count(report.edges.length, 'edge')}` +
784
+ ` · from ${o.dir}`)}`);
760
785
  }
761
786
  if (flags.open) {
762
787
  reveal(target);
@@ -1,7 +1,7 @@
1
1
  import { readProjectConfig } from '@zenera/neo';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { existsSync } from 'node:fs';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { setTimeout as sleep } from 'node:timers/promises';
7
7
  import { parse } from "../args.js";
@@ -83,6 +83,7 @@ export const meta = {
83
83
  ' --project <name|dir> Which project to work in. The agent is rooted there.',
84
84
  ' --model <ref> Model for this run, e.g. vertex/gemini-3.8-flash.',
85
85
  ' --provider <name> Force the key to use. Default: the model ref says.',
86
+ ' --prompt-file <file> Append this file to the prompt, after any words.',
86
87
  ' --agent <name> A .github/agents/<name>.agent.md.',
87
88
  ' --effort <level> none, minimal, low, medium, high, xhigh or max.',
88
89
  ' --allow-tool <list> Only these: read, shell(git:*), write(README.md).',
@@ -164,6 +165,7 @@ export const meta = {
164
165
  provider: { type: 'string' },
165
166
  model: { type: 'string' },
166
167
  prompt: { type: 'string', short: 'p' },
168
+ 'prompt-file': { type: 'string' },
167
169
  agent: { type: 'string' },
168
170
  effort: { type: 'string' },
169
171
  share: { type: 'string' },
@@ -243,7 +245,9 @@ async function where(ctx, flag, positionals) {
243
245
  function refresh(project) {
244
246
  try {
245
247
  const written = editorFiles(project.dir);
246
- note(dim(`refreshed ${written.length} editor files in ${project.name}`));
248
+ if (written.length) {
249
+ note(dim(`refreshed ${written.length} editor files in ${project.name}`));
250
+ }
247
251
  }
248
252
  catch (err) {
249
253
  warn(`could not refresh the editor files — ${err.message}`);
@@ -282,7 +286,7 @@ async function stored(ctx, values, args) {
282
286
  if (name) {
283
287
  return await runPrompt(ctx, values, project, name, rest);
284
288
  }
285
- const question = (values.prompt ?? rest.join(' ')).trim() || (await readStdin());
289
+ const question = withFile(values, (values.prompt ?? rest.join(' ')).trim()) || (await readStdin());
286
290
  if (question) {
287
291
  return await go(ctx, values, project, question);
288
292
  }
@@ -294,9 +298,24 @@ async function runPrompt(ctx, values, project, name, extra) {
294
298
  if (found.description) {
295
299
  note(dim(found.description));
296
300
  }
297
- const prompt = extra.length > 0 ? `${found.body}\n\n${extra.join(' ')}` : found.body;
301
+ const prompt = withFile(values, extra.length > 0 ? `${found.body}\n\n${extra.join(' ')}` : found.body);
298
302
  await go(ctx, values, project, prompt, name.replace(/^\//, ''));
299
303
  }
304
+ /** The prompt with `--prompt-file` after it: text too long, or too awkward to quote, for a word. */
305
+ function withFile(values, prompt) {
306
+ const file = values['prompt-file'];
307
+ if (!file) {
308
+ return prompt;
309
+ }
310
+ let text;
311
+ try {
312
+ text = readFileSync(resolve(file), 'utf8').trim();
313
+ }
314
+ catch {
315
+ throw usageError(`no prompt file at ${file}`, 'name a file that exists');
316
+ }
317
+ return [prompt, text].filter(Boolean).join('\n\n');
318
+ }
300
319
  async function pickPrompt(ctx, project) {
301
320
  const names = await listPrompts(project.dir);
302
321
  if (names.length === 0) {
@@ -348,7 +367,7 @@ async function resumeRun(ctx, values, args) {
348
367
  if (!values['no-refresh']) {
349
368
  refresh(project);
350
369
  }
351
- const message = (values.prompt ?? words.join(' ')).trim() || RESUME_PROMPT;
370
+ const message = withFile(values, (values.prompt ?? words.join(' ')).trim()) || RESUME_PROMPT;
352
371
  await go(ctx, { ...values, resume: session, continue: false }, project, message);
353
372
  }
354
373
  // ---------------------------------------------------------------------------
@@ -13,7 +13,7 @@
13
13
  // ---------------------------------------------------------------------------
14
14
  import { createEmbedder, ModelRegistry } from '@zenera/neo';
15
15
  import { parse } from "../args.js";
16
- import { byGroup, groupOf, isPartner, 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
19
  import { probeChat, probeModel } from "../liveness.js";
@@ -589,6 +589,7 @@ const browse = async (ctx, args) => {
589
589
  initial,
590
590
  theme: values.theme,
591
591
  load: (provider, refresh) => loadCatalog(provider, { refresh: refresh || values.refresh }),
592
+ balance: async (provider) => provider === 'openrouter' && source(where, provider) ? routerBalance() : undefined,
592
593
  test: async (entry) => {
593
594
  const role = entry.roles.includes('chat')
594
595
  ? 'chat'
@@ -3,8 +3,14 @@ import { parse } from "../args.js";
3
3
  import { resolveBuild } from "../image.js";
4
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, count, 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. */
@@ -28,6 +34,8 @@ export const sandbox = {
28
34
  banner: { head: 'Zenera', accent: 'Sandbox', subtitle: 'Container Isolation', hue: 'azure' },
29
35
  details: [
30
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.',
31
39
  ' up Install if asked, start the machine, pull or build the image.',
32
40
  ' pull Just the image: pulled, or built from the project\u2019s Dockerfile.',
33
41
  ' clean Remove every container this CLI created.',
@@ -37,6 +45,10 @@ export const sandbox = {
37
45
  ' --image <ref> Use this image instead of the project\u2019s.',
38
46
  ' --idle With clean: only what no live run is using — stopped ones,',
39
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.',
40
52
  '',
41
53
  'Without --project this is the project you are standing in, and standing',
42
54
  'nowhere is an answer: `status` then reports the engine and every container',
@@ -51,9 +63,13 @@ export const sandbox = {
51
63
  image: { type: 'string' },
52
64
  stopped: { type: 'boolean' },
53
65
  idle: { type: 'boolean' },
66
+ once: { type: 'boolean' },
67
+ interval: { type: 'string' },
68
+ brief: { type: 'boolean' },
69
+ theme: { type: 'string' },
54
70
  }, USAGE);
55
71
  const what = positionals[0] ?? 'status';
56
- if (!['status', 'up', 'pull', 'clean', 'disk'].includes(what)) {
72
+ if (!SUBCOMMANDS.includes(what)) {
57
73
  throw usageError(`unknown subcommand: ${what}`, USAGE);
58
74
  }
59
75
  if (positionals.length > 1) {
@@ -75,6 +91,8 @@ export const sandbox = {
75
91
  throw usageError('no project here, so there is no image to pull', 'name one with --project, or an image with --image');
76
92
  }
77
93
  return up(image, build, true, ctx.json, true);
94
+ case 'top':
95
+ return top(values, ctx.json);
78
96
  case 'clean':
79
97
  return clean(ctx.json, Boolean(values.idle || values.stopped));
80
98
  case 'disk':
@@ -167,6 +185,58 @@ async function status(project, image, asJson) {
167
185
  note('');
168
186
  note(dim('run `zen sandbox up` to fix what can be fixed.'));
169
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('');
170
240
  }
171
241
  /**
172
242
  * The hungriest container this project can start. An agent may override the
@@ -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