@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.
- package/README.md +20 -0
- package/dist/banner.js +69 -47
- package/dist/commands/index.js +3 -0
- package/dist/commands/init.js +8 -4
- package/dist/commands/inspect.js +23 -5
- package/dist/commands/memory.d.ts +3 -0
- package/dist/commands/memory.js +375 -0
- package/dist/engine.js +15 -9
- package/dist/main.js +9 -0
- package/dist/podman.js +43 -9
- package/dist/projects.js +10 -2
- package/dist/scaffold.js +22 -1
- package/dist/tui/app.js +19 -11
- package/dist/tui/theme.d.ts +7 -0
- package/dist/tui/theme.js +18 -11
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +151 -43
- package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
- package/templates/editor/.github/prompts/sync-with-spec.prompt.md +7 -4
- package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
- package/templates/editor/.github/skills/zen-cli/references/memory.md +113 -0
- package/templates/editor/.github/skills/zen-cli/references/rag.md +2 -2
- package/templates/editor/.github/skills/zen-cli/references/run.md +3 -1
- package/templates/editor/.github/skills/zen-memory/SKILL.md +555 -0
- package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +16 -16
- package/templates/editor/.github/skills/zen-rag-schema/SKILL.md +2 -2
- package/templates/editor/.vscode/settings.json +4 -2
- package/templates/parts/exa.prompt.md.tmpl +4 -0
- package/templates/parts/exa.spec.md.tmpl +2 -0
- package/templates/project/INSTRUCTIONS.md +8 -0
- package/templates/project/SPECIFICATION.md.tmpl +47 -0
- package/templates/project/agents/prompts/{default.md → default.md.tmpl} +4 -0
- package/templates/project/agents.yaml.tmpl +4 -0
- package/templates/project/gitignore +7 -1
- package/templates/project/scripts/_setup.sh +101 -0
- 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,
|
|
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
|
-
|
|
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
|
|
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
|
});
|