@zenera/cli 1.1.11 → 1.1.14

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 (36) hide show
  1. package/README.md +20 -0
  2. package/dist/banner.js +69 -47
  3. package/dist/commands/index.js +3 -0
  4. package/dist/commands/init.js +8 -4
  5. package/dist/commands/inspect.js +23 -5
  6. package/dist/commands/memory.d.ts +3 -0
  7. package/dist/commands/memory.js +375 -0
  8. package/dist/engine.js +15 -9
  9. package/dist/main.js +9 -0
  10. package/dist/podman.js +43 -9
  11. package/dist/projects.js +10 -2
  12. package/dist/scaffold.js +22 -1
  13. package/dist/tui/app.js +19 -11
  14. package/dist/tui/theme.d.ts +7 -0
  15. package/dist/tui/theme.js +18 -11
  16. package/package.json +2 -2
  17. package/templates/editor/.github/copilot-instructions.md +151 -43
  18. package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
  19. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +7 -4
  20. package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
  21. package/templates/editor/.github/skills/zen-cli/references/memory.md +113 -0
  22. package/templates/editor/.github/skills/zen-cli/references/rag.md +2 -2
  23. package/templates/editor/.github/skills/zen-cli/references/run.md +3 -1
  24. package/templates/editor/.github/skills/zen-memory/SKILL.md +555 -0
  25. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +16 -16
  26. package/templates/editor/.github/skills/zen-rag-schema/SKILL.md +2 -2
  27. package/templates/editor/.vscode/settings.json +4 -2
  28. package/templates/parts/exa.prompt.md.tmpl +4 -0
  29. package/templates/parts/exa.spec.md.tmpl +2 -0
  30. package/templates/project/INSTRUCTIONS.md +8 -0
  31. package/templates/project/SPECIFICATION.md.tmpl +47 -0
  32. package/templates/project/agents/prompts/{default.md → default.md.tmpl} +4 -0
  33. package/templates/project/agents.yaml.tmpl +4 -0
  34. package/templates/project/gitignore +7 -1
  35. package/templates/project/scripts/_setup.sh +101 -0
  36. package/templates/project/SPECIFICATION.md +0 -6
@@ -0,0 +1,375 @@
1
+ import { ALL_AGENTS, buildMemoryReport, hostPath, memoryDir, MemoryIndex, MemoryStore, readProjectConfig, renderMemoryHtml, } from '@zenera/neo';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { writeFile } from 'node:fs/promises';
5
+ import { isAbsolute, join, resolve } from 'node:path';
6
+ import { parse } from "../args.js";
7
+ import { project as resolveProject } from "../resolve.js";
8
+ import { ago, bold, bytes, confirm, count, cyan, dim, green, isInteractive, json, note, table, usageError, write, writeAll, yellow, } from "../term.js";
9
+ const USAGE = 'zen memory [stats|ls|show|export|forget] [args] [options]';
10
+ const SUBCOMMANDS = ['stats', 'ls', 'show', 'export', 'forget'];
11
+ /** Enough to see the shape of it; the rest is a number. */
12
+ const LISTED = 30;
13
+ // ---------------------------------------------------------------------------
14
+ // zen memory
15
+ //
16
+ // The graph from outside the agents, and the reason it needs a command is that
17
+ // there is otherwise no way to answer the question memory always eventually
18
+ // raises: *why did it recall that?* Recall is masked, ranked and truncated by
19
+ // design, so what an agent sees is never the whole picture — and when the
20
+ // picture is what is wrong, you need the part that was hidden.
21
+ //
22
+ // So everything here reads the graph unmasked. That is safe because it is
23
+ // local: this command is a person at a terminal in the project directory, not
24
+ // an agent inside a run, and the mask exists to keep agents apart rather than
25
+ // to keep secrets from the person who owns the files.
26
+ //
27
+ // Nothing here contacts a model. The store is opened without an embedder, so
28
+ // inspection is free and works offline; the price is that `ls` filters by text
29
+ // rather than by meaning, which is the right trade for a debugging tool.
30
+ // ---------------------------------------------------------------------------
31
+ export const memory = {
32
+ summary: 'What the agents remember, and getting rid of it.',
33
+ usage: USAGE,
34
+ details: [
35
+ ' stats Size, vocabulary, and whether it is embedded.',
36
+ ' ls Nodes, newest first. Changes nothing.',
37
+ ' show <id> One node in full, with what it links to.',
38
+ ' export [file] The whole graph as one HTML page.',
39
+ ' forget <id...> Remove nodes, their vectors and their files.',
40
+ '',
41
+ ' --project <name|dir> Which project. Defaults to the one you are in.',
42
+ ' --kind <name> Only this kind of node.',
43
+ ' --audience <name> Only nodes committed under this label.',
44
+ ' --files Only nodes that remember a file.',
45
+ ' --stale Only nodes something has superseded.',
46
+ ' --limit <n> Rows to list. Default 30.',
47
+ ' --out <file> Where `export` writes. Default memory.html.',
48
+ ' --open Open the exported page.',
49
+ ' --yes Do not ask before removing.',
50
+ '',
51
+ 'Everything here reads the graph *unmasked* — every audience, including',
52
+ 'what no agent can see. That is the point: when a mask is the thing',
53
+ 'that is wrong, the hidden part is what you need to look at.',
54
+ '',
55
+ '`export` is the one to reach for. It writes a single self-contained',
56
+ 'page: the node list on the left, the graph in the middle, and whatever',
57
+ 'you click on the right, file contents and all.',
58
+ ],
59
+ run: async (ctx) => {
60
+ const { values, positionals } = parse(ctx.args, {
61
+ project: { type: 'string' },
62
+ kind: { type: 'string' },
63
+ audience: { type: 'string' },
64
+ files: { type: 'boolean' },
65
+ stale: { type: 'boolean' },
66
+ limit: { type: 'string' },
67
+ out: { type: 'string' },
68
+ open: { type: 'boolean' },
69
+ yes: { type: 'boolean' },
70
+ }, USAGE);
71
+ const what = positionals[0] ?? 'stats';
72
+ if (!SUBCOMMANDS.includes(what)) {
73
+ throw usageError(`unknown subcommand: ${what}`, USAGE);
74
+ }
75
+ const rest = positionals.slice(1);
76
+ const opened = await open(ctx.cwd, values.project);
77
+ try {
78
+ switch (what) {
79
+ case 'stats':
80
+ return stats(opened, ctx.json);
81
+ case 'ls':
82
+ return list(opened, values, ctx.json);
83
+ case 'show':
84
+ return show(opened, rest, ctx.json);
85
+ case 'export':
86
+ return await write_(opened, rest, values, ctx.cwd, ctx.json);
87
+ default:
88
+ return await forget(opened, rest, values, ctx.json);
89
+ }
90
+ }
91
+ finally {
92
+ opened.store.release();
93
+ }
94
+ },
95
+ };
96
+ /**
97
+ * The store, opened directly rather than through `loadProject`. Loading the
98
+ * project would resolve models and read every prompt file to inspect a graph
99
+ * that needs none of it — and would fail on a project whose credentials are
100
+ * missing, which is exactly when someone is debugging.
101
+ */
102
+ async function open(cwd, want) {
103
+ const project = await resolveProject({ cwd, project: want });
104
+ const { config } = readProjectConfig(project.dir);
105
+ const dir = memoryDir(project.dir, config);
106
+ if (!dir) {
107
+ throw usageError(`${project.name} has no memory`, 'give an agent `memory: true` in agents.yaml, or add a `memory:` block');
108
+ }
109
+ if (!existsSync(join(dir, 'manifest.json'))) {
110
+ throw usageError(`${project.name} has a memory configured but nothing in it yet`, 'it is written the first time an agent commits something');
111
+ }
112
+ return { store: await MemoryStore.open(dir), dir, project: project.name };
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // stats
116
+ // ---------------------------------------------------------------------------
117
+ function stats(o, asJson) {
118
+ const nodes = o.store.graph.nodes();
119
+ const edges = o.store.graph.edges();
120
+ const stale = o.store.graph.superseded();
121
+ const vectors = o.store.vectors;
122
+ const files = nodes.filter((n) => n.file);
123
+ const byKind = tally(nodes.map((n) => n.kind));
124
+ const byRelation = tally(edges.map((e) => e.relation));
125
+ const byAudience = tally(nodes.flatMap((n) => n.audience));
126
+ if (asJson) {
127
+ json({
128
+ dir: o.dir,
129
+ nodes: nodes.length,
130
+ edges: edges.length,
131
+ superseded: stale.size,
132
+ files: {
133
+ count: files.length,
134
+ bytes: files.reduce((t, n) => t + (n.file?.bytes ?? 0), 0),
135
+ },
136
+ embedding: o.store.embedding,
137
+ vectors: vectors ? { rows: vectors.rows, dims: vectors.dims } : undefined,
138
+ kinds: byKind,
139
+ relations: byRelation,
140
+ audiences: byAudience,
141
+ });
142
+ return;
143
+ }
144
+ if (!nodes.length) {
145
+ note(`nothing remembered yet ${dim(o.dir)}`);
146
+ return;
147
+ }
148
+ write(bold(o.project) + dim(' · ' + o.dir));
149
+ write();
150
+ writeAll(table([
151
+ [dim('nodes'), String(nodes.length)],
152
+ [dim('edges'), String(edges.length)],
153
+ [dim('superseded'), String(stale.size)],
154
+ [
155
+ dim('files'),
156
+ files.length
157
+ ? `${files.length} · ${bytes(files.reduce((t, n) => t + (n.file?.bytes ?? 0), 0))}`
158
+ : '0',
159
+ ],
160
+ [
161
+ dim('embedding'),
162
+ o.store.embedding
163
+ ? `${o.store.embedding.model} ${dim(o.store.embedding.dimensions + 'd')}`
164
+ : yellow('none — recall falls back to term overlap'),
165
+ ],
166
+ [dim('vectors'), vectors ? `${vectors.rows} rows` : dim('—')],
167
+ ]));
168
+ // A node without a vector cannot be found by meaning, only by luck. It is
169
+ // the one silent failure this store has, so it is called out by name.
170
+ if (vectors && vectors.rows < nodes.length) {
171
+ write();
172
+ note(yellow(`${count(nodes.length - vectors.rows, 'node')} with no vector`));
173
+ }
174
+ section('kinds', byKind);
175
+ section('relations', byRelation);
176
+ section('audiences', byAudience);
177
+ }
178
+ function section(title, counts) {
179
+ const rows = Object.entries(counts).sort((a, b) => b[1] - a[1]);
180
+ if (!rows.length) {
181
+ return;
182
+ }
183
+ write();
184
+ write(dim(title));
185
+ writeAll(table(rows.map(([k, n]) => [` ${cyan(k)}`, String(n)])));
186
+ }
187
+ function tally(values) {
188
+ const out = {};
189
+ for (const v of values) {
190
+ out[v] = (out[v] ?? 0) + 1;
191
+ }
192
+ return out;
193
+ }
194
+ // ---------------------------------------------------------------------------
195
+ // ls
196
+ // ---------------------------------------------------------------------------
197
+ function list(o, flags, asJson) {
198
+ const stale = o.store.graph.superseded();
199
+ const limit = Number(flags.limit ?? LISTED);
200
+ if (!Number.isInteger(limit) || limit < 1) {
201
+ throw usageError(`--limit wants a positive whole number, got ${flags.limit}`, USAGE);
202
+ }
203
+ const all = o.store.graph
204
+ .nodes()
205
+ .filter((n) => !flags.kind || n.kind === flags.kind)
206
+ .filter((n) => !flags.audience || n.audience.includes(flags.audience))
207
+ .filter((n) => !flags.files || n.file)
208
+ .filter((n) => !flags.stale || stale.has(n.id))
209
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
210
+ if (asJson) {
211
+ json({ total: all.length, nodes: all.slice(0, limit) });
212
+ return;
213
+ }
214
+ if (!all.length) {
215
+ note('nothing matches');
216
+ return;
217
+ }
218
+ writeAll(table(all
219
+ .slice(0, limit)
220
+ .map((n) => [
221
+ cyan(n.id),
222
+ n.kind,
223
+ n.file ? dim('⎘ ' + n.file.format) : '',
224
+ clip(n.text, 56),
225
+ dim(ago(n.createdAt)),
226
+ stale.has(n.id) ? yellow('superseded') : '',
227
+ ])));
228
+ if (all.length > limit) {
229
+ write();
230
+ note(dim(`${all.length - limit} more — raise --limit, or use \`zen memory export\``));
231
+ }
232
+ }
233
+ // ---------------------------------------------------------------------------
234
+ // show
235
+ // ---------------------------------------------------------------------------
236
+ function show(o, ids, asJson) {
237
+ const id = ids[0];
238
+ if (!id || ids.length > 1) {
239
+ throw usageError('show takes exactly one id', 'zen memory show <id>');
240
+ }
241
+ const node = o.store.graph.get(id);
242
+ if (!node) {
243
+ throw usageError(`no node ${id}`, 'list them with `zen memory ls`');
244
+ }
245
+ const links = o.store.graph.neighbors(id);
246
+ if (asJson) {
247
+ json({ node, links, file: node.file ? hostPath(o.dir, node.file) : undefined });
248
+ return;
249
+ }
250
+ write(bold(node.id) + ' ' + cyan(node.kind));
251
+ write();
252
+ writeAll(table([
253
+ [dim('revision'), String(node.revision)],
254
+ [dim('used'), `${node.useCount}× · last ${ago(node.lastUsedAt)}`],
255
+ [dim('created'), `${node.createdAt} ${dim(ago(node.createdAt))}`],
256
+ [dim('updated'), node.updatedAt],
257
+ [dim('audience'), node.audience.join(', ')],
258
+ ]));
259
+ write();
260
+ write(node.text);
261
+ if (node.metadata) {
262
+ write();
263
+ write(dim('metadata'));
264
+ write(JSON.stringify(node.metadata, null, 2));
265
+ }
266
+ if (node.file) {
267
+ write();
268
+ write(dim('file'));
269
+ writeAll(table([
270
+ [dim(' path'), hostPath(o.dir, node.file)],
271
+ [dim(' size'), bytes(node.file.bytes)],
272
+ [dim(' sha256'), node.file.sha256],
273
+ ]));
274
+ }
275
+ write();
276
+ write(dim(`links (${links.length})`));
277
+ if (!links.length) {
278
+ note(dim(' nothing points here and it points nowhere'));
279
+ return;
280
+ }
281
+ writeAll(table(links.map((e) => {
282
+ const out = e.source === id;
283
+ const other = out ? e.target : e.source;
284
+ return [
285
+ ` ${out ? '→' : '←'} ${yellow(e.relation)}`,
286
+ cyan(other),
287
+ clip(o.store.graph.get(other)?.text ?? '', 48),
288
+ ];
289
+ })));
290
+ }
291
+ // ---------------------------------------------------------------------------
292
+ // export
293
+ // ---------------------------------------------------------------------------
294
+ async function write_(o, rest, flags, cwd, asJson) {
295
+ if (rest.length > 1) {
296
+ throw usageError('export takes at most one file name', 'zen memory export [file]');
297
+ }
298
+ const named = rest[0] ?? flags.out ?? 'memory.html';
299
+ const target = isAbsolute(named) ? named : resolve(cwd, named);
300
+ const report = await buildMemoryReport(o.store, { title: `${o.project} · memory` });
301
+ await writeFile(target, renderMemoryHtml(report), 'utf8');
302
+ if (asJson) {
303
+ json({ path: target, nodes: report.nodes.length, edges: report.edges.length });
304
+ }
305
+ else {
306
+ note(`${green('wrote')} ${target} ${dim(`· ${count(report.nodes.length, 'node')}, ${count(report.edges.length, 'edge')}`)}`);
307
+ }
308
+ if (flags.open) {
309
+ reveal(target);
310
+ }
311
+ }
312
+ /**
313
+ * Handing a path to the platform opener. `spawn` without a shell, so the path
314
+ * is an argument rather than something a shell gets to interpret.
315
+ */
316
+ function reveal(target) {
317
+ const command = process.platform === 'darwin'
318
+ ? 'open'
319
+ : process.platform === 'win32'
320
+ ? 'explorer'
321
+ : 'xdg-open';
322
+ spawn(command, [target], { stdio: 'ignore', detached: true }).unref();
323
+ }
324
+ // ---------------------------------------------------------------------------
325
+ // forget
326
+ // ---------------------------------------------------------------------------
327
+ /**
328
+ * The only destructive thing here, and it is deliberately not the way to fix a
329
+ * memory that turned out wrong — that is a new node and a `SUPERSEDES` edge,
330
+ * which keeps the reason it changed. This is for what should never have been
331
+ * written down: it takes the node, its vector and its file bytes together.
332
+ */
333
+ async function forget(o, ids, flags, asJson) {
334
+ if (!ids.length) {
335
+ throw usageError('forget takes at least one id', 'zen memory forget <id...>');
336
+ }
337
+ const found = [];
338
+ for (const id of ids) {
339
+ const node = o.store.graph.get(id);
340
+ if (!node) {
341
+ throw usageError(`no node ${id}`, 'list them with `zen memory ls`');
342
+ }
343
+ found.push(node);
344
+ }
345
+ if (!flags.yes) {
346
+ if (!isInteractive()) {
347
+ throw usageError('refusing to remove without confirmation', 'pass --yes');
348
+ }
349
+ writeAll(table(found.map((n) => [cyan(n.id), n.kind, clip(n.text, 56)])));
350
+ write();
351
+ if (!(await confirm(`Forget ${count(found.length, 'node')}?`))) {
352
+ note('nothing removed');
353
+ return;
354
+ }
355
+ }
356
+ // The vector and the bytes go with the node, which `MemoryIndex` already
357
+ // guarantees; `sees` is every audience in the graph, because this command
358
+ // is the person who owns the files rather than an agent inside a run.
359
+ const index = new MemoryIndex({ store: o.store });
360
+ await index.forget(found.map((n) => n.id), [ALL_AGENTS, ...new Set(o.store.graph.nodes().flatMap((n) => n.audience))]);
361
+ if (asJson) {
362
+ json({ forgotten: found.map((n) => n.id) });
363
+ }
364
+ else {
365
+ note(`${green('forgot')} ${count(found.length, 'node')}`);
366
+ }
367
+ }
368
+ // ---------------------------------------------------------------------------
369
+ // Shared
370
+ // ---------------------------------------------------------------------------
371
+ function clip(text, n) {
372
+ const one = text.replace(/\s+/g, ' ').trim();
373
+ return one.length > n ? one.slice(0, n - 1) + '…' : one;
374
+ }
375
+ //# sourceMappingURL=memory.js.map
package/dist/engine.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AgentRunner, FileMemoryStore, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
1
+ import { AgentRunner, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { writeFile } from 'node:fs/promises';
4
4
  import { resolve } from 'node:path';
@@ -27,7 +27,6 @@ export async function open(opts) {
27
27
  const meta = await readSessionMeta(opts.session);
28
28
  const workspace = resolve(meta.workspace);
29
29
  const payloads = new FilePayloadStore({ dir: opts.session.blobs, id: 'file' });
30
- const memory = [new FileMemoryStore({ dir: opts.session.memory, id: 'file' })];
31
30
  // The config is read before the project is loaded because the sandbox is
32
31
  // configured by it and the tools have to exist before selectors can be
33
32
  // resolved against them. They are always registered, whether or not anyone
@@ -35,6 +34,7 @@ export async function open(opts) {
35
34
  // fails to load with "unknown tool", which would be a lie.
36
35
  let sandbox;
37
36
  let project;
37
+ let files;
38
38
  try {
39
39
  const { root, config } = readProjectConfig(opts.project.dir);
40
40
  // Assets and the skill catalog are mounted for both, under one name.
@@ -49,17 +49,21 @@ export async function open(opts) {
49
49
  keys: opts.keys,
50
50
  mounts,
51
51
  });
52
+ const workspaceOptions = {
53
+ root: workspace,
54
+ readOnly: opts.readOnly,
55
+ mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
56
+ mounts,
57
+ };
58
+ // The same view the file tools have, so a path the agent just wrote
59
+ // resolves the same way when it asks memory to keep a copy.
60
+ files = new Workspace(workspaceOptions);
52
61
  project = await loadProject(opts.project.dir, {
53
62
  tools: [
54
63
  // Both groups are pointed at one directory, so both are told the
55
64
  // one name for it: whatever `run_command` prints a path as,
56
65
  // `read_file` accepts.
57
- ...workspaceTools({
58
- root: workspace,
59
- readOnly: opts.readOnly,
60
- mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
61
- mounts,
62
- }),
66
+ ...workspaceTools(workspaceOptions),
63
67
  ...sandboxTools(sandbox.pool),
64
68
  // Registered whether or not a key exists: the credential is
65
69
  // read when a tool is called, so a project that names `exa:*`
@@ -69,7 +73,7 @@ export async function open(opts) {
69
73
  ],
70
74
  skillsAt: SKILLS_MOUNT,
71
75
  payloads,
72
- memory,
76
+ resolveFile: (path) => files.within(path),
73
77
  });
74
78
  }
75
79
  catch (err) {
@@ -105,6 +109,7 @@ export async function open(opts) {
105
109
  lock,
106
110
  close: async () => {
107
111
  await teardown(sandbox.pool);
112
+ project.close();
108
113
  lock.release();
109
114
  },
110
115
  };
@@ -167,6 +172,7 @@ async function record(engine, input, result, startedAt, durationMs) {
167
172
  const built = await buildRunReport(result.state, engine.runner.services.payloads, {
168
173
  title: `${engine.name} · ${run.id}`,
169
174
  architecture: await engine.runner.describe(),
175
+ memory: engine.runner.services.memory?.store,
170
176
  });
171
177
  await writeFile(run.report, renderReportHtml(built), 'utf8');
172
178
  report = run.report;
package/dist/main.js CHANGED
@@ -151,5 +151,14 @@ async function usage(name) {
151
151
  write(` -C, --directory <d> ${dim('Act as if run in <d>.')}`);
152
152
  write(`\n${dim(`Start with ${cyan(`${NAME} init`)}, then ${cyan(`${NAME} run`)}.`)}`);
153
153
  }
154
+ // `zen memory ls | head` closes the pipe while we are still writing to it, and
155
+ // an unhandled EPIPE turns that ordinary shell idiom into a stack trace. The
156
+ // reader having left is not our error, so we stop quietly.
157
+ process.stdout.on('error', (err) => {
158
+ if (err.code !== 'EPIPE') {
159
+ throw err;
160
+ }
161
+ process.exit(0);
162
+ });
154
163
  process.exitCode = await main(process.argv.slice(2));
155
164
  //# sourceMappingURL=main.js.map
package/dist/podman.js CHANGED
@@ -2,7 +2,7 @@ import { runProcess, SandboxError } from '@zenera/neo';
2
2
  import { readdirSync, statSync } from 'node:fs';
3
3
  import { homedir, platform } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { CliError, confirm, dim, EXIT, isInteractive, note } from "./term.js";
5
+ import { CliError, confirm, dim, EXIT, isInteractive, note, progress } from "./term.js";
6
6
  const DEFAULT_MACHINE_CPUS = 2;
7
7
  const DEFAULT_MACHINE_MEMORY = 2048;
8
8
  /** Starting a virtual machine and pulling an image are both slow on purpose. */
@@ -73,7 +73,7 @@ async function preflight(opts) {
73
73
  const present = await call(['image', 'exists', opts.image], 30_000);
74
74
  if (present.code !== 0) {
75
75
  note(dim(`pulling ${opts.image} — this happens once`));
76
- const pulled = await stream(engine, ['pull', opts.image], run);
76
+ const pulled = await stream(engine, ['pull', opts.image], run, 'pulling');
77
77
  if (pulled.code !== 0) {
78
78
  throw sandboxError(`could not pull ${opts.image}`, first(pulled) || 'check the image name and your network');
79
79
  }
@@ -99,7 +99,7 @@ async function install(engine, opts, run) {
99
99
  throw sandboxError('podman is not installed', how);
100
100
  }
101
101
  note(dim('installing podman — this takes a few minutes'));
102
- const done = await stream('brew', ['install', 'podman'], run);
102
+ const done = await stream('brew', ['install', 'podman'], run, 'installing podman');
103
103
  if (done.code !== 0) {
104
104
  throw sandboxError('could not install podman', first(done) || how);
105
105
  }
@@ -127,7 +127,7 @@ async function machine(engine, call, opts, run) {
127
127
  const cpus = String(opts.cpus ?? DEFAULT_MACHINE_CPUS);
128
128
  const memory = String(opts.memory ?? DEFAULT_MACHINE_MEMORY);
129
129
  note(dim(`initialising the podman machine (${cpus} cpus, ${memory} MiB) — once per host`));
130
- const created = await stream(engine, ['machine', 'init', '--cpus', cpus, '--memory', memory], run);
130
+ const created = await stream(engine, ['machine', 'init', '--cpus', cpus, '--memory', memory], run, 'initialising the machine');
131
131
  if (created.code !== 0) {
132
132
  throw sandboxError('could not create the podman machine', first(created));
133
133
  }
@@ -143,7 +143,7 @@ async function machine(engine, call, opts, run) {
143
143
  args.push(chosen.Name);
144
144
  }
145
145
  note(dim('starting the podman machine'));
146
- const started = await stream(engine, args, run, SLOW_MS);
146
+ const started = await stream(engine, args, run, 'starting the machine', SLOW_MS);
147
147
  if (started.code !== 0 && !/already running/i.test(started.stderr)) {
148
148
  throw sandboxError('could not start the podman machine', first(started));
149
149
  }
@@ -197,8 +197,8 @@ async function build(engine, spec, run, force = false) {
197
197
  }
198
198
  note(dim(`${force ? 'rebuilding' : 'building'} the sandbox image from ${spec.dockerfile}`));
199
199
  note(dim(' the agent runs its commands inside a container, so the image is needed first'));
200
- note(dim(' this takes a few minutes; later runs reuse it until the Dockerfile changes'));
201
- const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, BUILD_MS);
200
+ note(dim(' this takes a few minutes; later runs reuse it'));
201
+ const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, 'building the sandbox image', BUILD_MS);
202
202
  if (built.code !== 0) {
203
203
  throw new BuildError(`could not build ${spec.dockerfile}`, EXIT.sandbox, last(built) || 'run the build by hand to see what the engine says');
204
204
  }
@@ -384,13 +384,20 @@ function children(dir) {
384
384
  }
385
385
  }
386
386
  // ---------------------------------------------------------------------------
387
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
388
+ const FRAME_MS = 100;
387
389
  /**
388
390
  * Long steps print as they go. A five-minute pull with no output is
389
391
  * indistinguishable from a hang, and the one thing worse than waiting is not
390
392
  * knowing whether you are waiting.
393
+ *
394
+ * The engine's own output is buffered until it exits, so while the step runs
395
+ * the only honest thing to show is that it is still running: a spinner and a
396
+ * clock, both of which have to move without any event to move them. Off
397
+ * without a terminal — a CI log wants one line per fact, not ten a second.
391
398
  */
392
- async function stream(bin, args, run, timeoutMs = SLOW_MS) {
393
- const res = await run(bin, args, { timeoutMs });
399
+ async function stream(bin, args, run, label, timeoutMs = SLOW_MS) {
400
+ const res = await ticking(label, run(bin, args, { timeoutMs }));
394
401
  for (const line of res.stderr.split('\n').slice(-3)) {
395
402
  if (line.trim()) {
396
403
  note(dim(` ${line.trim()}`));
@@ -398,6 +405,33 @@ async function stream(bin, args, run, timeoutMs = SLOW_MS) {
398
405
  }
399
406
  return res;
400
407
  }
408
+ async function ticking(label, work) {
409
+ if (!process.stderr.isTTY) {
410
+ return work;
411
+ }
412
+ const bar = progress();
413
+ const startedAt = Date.now();
414
+ let frame = 0;
415
+ const timer = setInterval(() => {
416
+ const spin = SPINNER[frame++ % SPINNER.length];
417
+ bar.update(dim(` ${spin} ${label} ${elapsed(Date.now() - startedAt)}`));
418
+ }, FRAME_MS);
419
+ timer.unref();
420
+ try {
421
+ return await work;
422
+ }
423
+ finally {
424
+ clearInterval(timer);
425
+ bar.done();
426
+ }
427
+ }
428
+ function elapsed(ms) {
429
+ const total = Math.floor(ms / 1000);
430
+ const seconds = total % 60;
431
+ return total < 60
432
+ ? `${seconds}s`
433
+ : `${Math.floor(total / 60)}m ${String(seconds).padStart(2, '0')}s`;
434
+ }
401
435
  function first(res) {
402
436
  return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
403
437
  }
package/dist/projects.js CHANGED
@@ -1,5 +1,5 @@
1
- import { ASSETS_MOUNT, assetsDir, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
2
- import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
1
+ import { ASSETS_MOUNT, assetsDir, FILES_DIR, MEMORY_MOUNT, memoryDir, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { paths, readJson, writeJson } from "./home.js";
5
5
  import { isStamp } from "./ids.js";
@@ -47,6 +47,14 @@ export function projectMounts(root, config) {
47
47
  for (const dir of skillMounts(skillDirs(root, config), SKILLS_MOUNT)) {
48
48
  mounts.push({ host: realpathSync(dir.path), at: dir.at, readOnly: true });
49
49
  }
50
+ const memory = memoryDir(root, config);
51
+ if (memory) {
52
+ // Created here because the mount is decided before the project is
53
+ // loaded, and podman would refuse a source that does not exist yet.
54
+ const files = join(memory, FILES_DIR);
55
+ mkdirSync(files, { recursive: true });
56
+ mounts.push({ host: realpathSync(files), at: MEMORY_MOUNT, readOnly: true });
57
+ }
50
58
  return mounts;
51
59
  }
52
60
  // ---------------------------------------------------------------------------
package/dist/scaffold.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  // ---------------------------------------------------------------------------
@@ -22,6 +22,12 @@ import { fileURLToPath } from 'node:url';
22
22
  // What is there is deliberately close to empty: a template full of
23
23
  // commented-out options is a template nobody reads and everybody deletes. The
24
24
  // one agent works as written, and every other knob is in `docs/`.
25
+ //
26
+ // `SPECIFICATION.md` is the exception, and is written out in full. It states
27
+ // what the project that was just scaffolded is for and what it may do, and it
28
+ // is *true as written* — so the first thing anyone sees is a specification
29
+ // standing next to the files that implement it, which is the shape every
30
+ // change to the project is then made in.
25
31
  // ---------------------------------------------------------------------------
26
32
  const TEMPLATES = fileURLToPath(new URL('../templates', import.meta.url));
27
33
  /** The suffix on a file with `{{...}}` in it, dropped when the file lands. */
@@ -42,6 +48,11 @@ function ownVersion() {
42
48
  * it — which is how an optional block leaves nothing behind. Anywhere else it
43
49
  * takes a word. A name nothing supplies throws, so a typo in a template is a
44
50
  * failing test rather than a `{{provider}}` sitting in somebody's agents.yaml.
51
+ *
52
+ * Only the value is trimmed at the end, never the start, so a fragment that has
53
+ * to be a paragraph of its own carries its own blank line as its first line.
54
+ * That way the template needs no blank line around the placeholder, and dropping
55
+ * the fragment leaves the surrounding paragraphs exactly as they were.
45
56
  */
46
57
  function render(text, vars) {
47
58
  const value = (name) => {
@@ -123,6 +134,10 @@ function copyTree(from, dir, rel, opts) {
123
134
  ? render(readFileSync(source, 'utf8'), opts.vars ?? {})
124
135
  : readFileSync(source);
125
136
  writeFileSync(join(dir, child), body);
137
+ // The bytes are copied, not the file, so a script arrives unrunnable.
138
+ if (child.endsWith('.sh')) {
139
+ chmodSync(join(dir, child), 0o755);
140
+ }
126
141
  written.push(child);
127
142
  }
128
143
  return written;
@@ -176,7 +191,13 @@ export function scaffold(opts) {
176
191
  keep: true,
177
192
  vars: {
178
193
  model: modelSection(opts.model, opts.modelOptions),
194
+ modelRef: opts.model,
179
195
  exa: opts.web ? part('exa.yaml') : '',
196
+ // The same capability stated three times, because the grant, the
197
+ // specification of it and the instruction to use it are read by
198
+ // different readers — and a tool nothing says to use is not one.
199
+ web: opts.web ? part('exa.spec.md') : '',
200
+ webPrompt: opts.web ? part('exa.prompt.md') : '',
180
201
  version: ownVersion(),
181
202
  },
182
203
  });