@zenera/cli 1.1.22 → 1.1.23
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 +37 -0
- package/dist/archive.d.ts +139 -0
- package/dist/archive.js +376 -0
- package/dist/commands/export.d.ts +3 -0
- package/dist/commands/export.js +255 -0
- package/dist/commands/import.d.ts +3 -0
- package/dist/commands/import.js +173 -0
- package/dist/commands/index.d.ts +5 -1
- package/dist/commands/index.js +13 -1
- package/dist/commands/memory.js +117 -10
- package/dist/scaffold.d.ts +8 -0
- package/dist/scaffold.js +4 -1
- package/dist/validate.js +68 -1
- package/package.json +12 -6
- package/templates/editor/.github/prompts/spec-sync-project.prompt.md +25 -2
- package/templates/editor/.github/skills/zen-cli/SKILL.md +6 -1
- package/templates/editor/.github/skills/zen-cli/references/check.md +6 -0
- package/templates/editor/.github/skills/zen-cli/references/export.md +107 -0
- package/templates/editor/.github/skills/zen-cli/references/memory.md +51 -13
- package/templates/editor/.github/skills/zen-cli/references/rag.md +6 -0
- package/templates/editor/.github/skills/zen-memory/SKILL.md +2 -1
- package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +188 -58
- package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +6 -5
- package/templates/project/SPECIFICATION.md.tmpl +2 -0
- package/templates/project/scripts/_setup.sh +49 -7
package/dist/commands/memory.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { ALL_AGENTS, buildMemoryReport, hostPath, memoryDir, MemoryIndex, MemoryStore, readProjectConfig, renderMemoryHtml, } from '@zenera/neo';
|
|
1
|
+
import { ALL_AGENTS, buildMemoryReport, hostPath, memoryDir, MemoryIndex, MemoryStore, MergeConflicts, mergeMemories, readProjectConfig, renderMemoryHtml, } from '@zenera/neo';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { writeFile } from 'node:fs/promises';
|
|
5
|
-
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
6
|
import { parse } from "../args.js";
|
|
7
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'];
|
|
8
|
+
import { ago, bold, bytes, CliError, confirm, count, cyan, dim, EXIT, green, isInteractive, json, note, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
9
|
+
const USAGE = 'zen memory [stats|ls|show|export|merge|forget] [args] [options]';
|
|
10
|
+
const SUBCOMMANDS = ['stats', 'ls', 'show', 'export', 'merge', 'forget'];
|
|
11
11
|
/** Enough to see the shape of it; the rest is a number. */
|
|
12
12
|
const LISTED = 30;
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
@@ -37,6 +37,7 @@ export const memory = {
|
|
|
37
37
|
' ls Nodes, newest first. Changes nothing.',
|
|
38
38
|
' show <id> One node in full, with what it links to.',
|
|
39
39
|
' export [file] The whole graph as one HTML page.',
|
|
40
|
+
' merge <dir...> Fold other memories into this one.',
|
|
40
41
|
' forget <id...> Remove nodes, their vectors and their files.',
|
|
41
42
|
'',
|
|
42
43
|
' --project <name|dir> Which project. Defaults to the one you are in.',
|
|
@@ -48,7 +49,10 @@ export const memory = {
|
|
|
48
49
|
' --limit <n> Rows to list. Default 30.',
|
|
49
50
|
' --out <file> Where `export` writes. Default memory.html.',
|
|
50
51
|
' --open Open the exported page.',
|
|
51
|
-
' --
|
|
52
|
+
' --dry-run Say what `merge` would do, and stop.',
|
|
53
|
+
' --no-dedupe Keep memories `merge` would otherwise fold together.',
|
|
54
|
+
' --force Let `merge` pick a winner where two copies disagree.',
|
|
55
|
+
' --yes Do not ask before removing or merging.',
|
|
52
56
|
'',
|
|
53
57
|
'Everything here reads the graph *unmasked* — every audience, including',
|
|
54
58
|
'what no agent can see. That is the point: when a mask is the thing',
|
|
@@ -57,6 +61,11 @@ export const memory = {
|
|
|
57
61
|
'`export` is the one to reach for. It writes a single self-contained',
|
|
58
62
|
'page: the node list on the left, the graph in the middle, and whatever',
|
|
59
63
|
'you click on the right, file contents and all.',
|
|
64
|
+
'',
|
|
65
|
+
'`merge` is for warming a memory in parallel. The lock is per directory,',
|
|
66
|
+
'so N runs write N memories; this folds them back into one:',
|
|
67
|
+
'',
|
|
68
|
+
' zen memory merge .tmp/warmup-*/memory',
|
|
60
69
|
],
|
|
61
70
|
run: async (ctx) => {
|
|
62
71
|
const { values, positionals } = parse(ctx.args, {
|
|
@@ -70,13 +79,18 @@ export const memory = {
|
|
|
70
79
|
out: { type: 'string' },
|
|
71
80
|
open: { type: 'boolean' },
|
|
72
81
|
yes: { type: 'boolean' },
|
|
82
|
+
force: { type: 'boolean' },
|
|
83
|
+
'dry-run': { type: 'boolean' },
|
|
84
|
+
'no-dedupe': { type: 'boolean' },
|
|
73
85
|
}, USAGE);
|
|
74
86
|
const what = positionals[0] ?? 'stats';
|
|
75
87
|
if (!SUBCOMMANDS.includes(what)) {
|
|
76
88
|
throw usageError(`unknown subcommand: ${what}`, USAGE);
|
|
77
89
|
}
|
|
78
90
|
const rest = positionals.slice(1);
|
|
79
|
-
|
|
91
|
+
// `merge` is the one subcommand that may write a memory into existence;
|
|
92
|
+
// every other one is an inspector and a missing manifest is a mistake.
|
|
93
|
+
const opened = await open(ctx.cwd, values.project, values.dir, what === 'merge');
|
|
80
94
|
try {
|
|
81
95
|
switch (what) {
|
|
82
96
|
case 'stats':
|
|
@@ -87,6 +101,8 @@ export const memory = {
|
|
|
87
101
|
return show(opened, rest, ctx.json);
|
|
88
102
|
case 'export':
|
|
89
103
|
return await write_(opened, rest, values, ctx.cwd, ctx.json);
|
|
104
|
+
case 'merge':
|
|
105
|
+
return await merge(opened, rest, values, ctx.cwd, ctx.json);
|
|
90
106
|
default:
|
|
91
107
|
return await forget(opened, rest, values, ctx.json);
|
|
92
108
|
}
|
|
@@ -105,11 +121,15 @@ export const memory = {
|
|
|
105
121
|
* A named directory is opened as it stands, project or no project: a graph
|
|
106
122
|
* copied out of a running session is the one most worth looking at, and it
|
|
107
123
|
* should not have to be given an `agents.yaml` first.
|
|
124
|
+
*
|
|
125
|
+
* `create` drops the requirement that anything be there yet, for `merge`,
|
|
126
|
+
* which has somewhere to put what it reads even when the destination is a name
|
|
127
|
+
* nothing has written to.
|
|
108
128
|
*/
|
|
109
|
-
async function open(cwd, want, at) {
|
|
129
|
+
async function open(cwd, want, at, create = false) {
|
|
110
130
|
if (at) {
|
|
111
131
|
const dir = resolve(cwd, at);
|
|
112
|
-
if (!existsSync(join(dir, 'manifest.json'))) {
|
|
132
|
+
if (!create && !existsSync(join(dir, 'manifest.json'))) {
|
|
113
133
|
throw usageError(`${dir} is not a memory`, 'no manifest.json in it');
|
|
114
134
|
}
|
|
115
135
|
return { store: await MemoryStore.open(dir), dir, project: basename(dir) };
|
|
@@ -120,7 +140,7 @@ async function open(cwd, want, at) {
|
|
|
120
140
|
if (!dir) {
|
|
121
141
|
throw usageError(`${project.name} has no memory`, 'give an agent `memory: true` in agents.yaml, or add a `memory:` block');
|
|
122
142
|
}
|
|
123
|
-
if (!existsSync(join(dir, 'manifest.json'))) {
|
|
143
|
+
if (!create && !existsSync(join(dir, 'manifest.json'))) {
|
|
124
144
|
throw usageError(`${project.name} has a memory configured but nothing in it yet`, 'it is written the first time an agent commits something');
|
|
125
145
|
}
|
|
126
146
|
return { store: await MemoryStore.open(dir), dir, project: project.name };
|
|
@@ -336,6 +356,88 @@ function reveal(target) {
|
|
|
336
356
|
spawn(command, [target], { stdio: 'ignore', detached: true }).unref();
|
|
337
357
|
}
|
|
338
358
|
// ---------------------------------------------------------------------------
|
|
359
|
+
// merge
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
/**
|
|
362
|
+
* Warming a memory is parallel work, and the lock is per directory, so N runs
|
|
363
|
+
* produce N memories rather than one. This is the other half of that: it folds
|
|
364
|
+
* them back together, offline and without a model.
|
|
365
|
+
*
|
|
366
|
+
* Divergence refuses rather than guesses. Two copies of the same memory that
|
|
367
|
+
* disagree are a question about which piece of work was right, and answering
|
|
368
|
+
* it silently is how a merge loses the answer.
|
|
369
|
+
*/
|
|
370
|
+
async function merge(o, dirs, flags, cwd, asJson) {
|
|
371
|
+
if (!dirs.length) {
|
|
372
|
+
throw usageError('merge takes at least one memory directory', 'zen memory merge <dir...> [--dir <into>]');
|
|
373
|
+
}
|
|
374
|
+
const dryRun = flags['dry-run'] === true;
|
|
375
|
+
const held = o.store.graph.order;
|
|
376
|
+
if (held && !dryRun && !flags.yes) {
|
|
377
|
+
if (!isInteractive()) {
|
|
378
|
+
throw usageError(`${o.project} already remembers ${count(held, 'node')}`, 'pass --yes, or --dry-run to see what would change first');
|
|
379
|
+
}
|
|
380
|
+
note(`${count(dirs.length, 'memory', 'memories')} into ${bold(o.dir)} ` +
|
|
381
|
+
dim(`· ${count(held, 'node')} already there`));
|
|
382
|
+
if (!(await confirm('Merge?'))) {
|
|
383
|
+
note('nothing merged');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
let report;
|
|
388
|
+
try {
|
|
389
|
+
report = await mergeMemories(o.store, dirs.map((d) => resolve(cwd, d)), { dedupe: !flags['no-dedupe'], force: flags.force, dryRun });
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
if (err instanceof MergeConflicts) {
|
|
393
|
+
throw diverged(err, cwd, asJson);
|
|
394
|
+
}
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
if (asJson) {
|
|
398
|
+
json(report);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
writeAll(table([
|
|
402
|
+
[dim('memory'), dim('nodes'), dim('new'), dim('shared'), dim('folded'), dim('files')],
|
|
403
|
+
...report.sources.map((s) => [
|
|
404
|
+
near(s.dir, cwd),
|
|
405
|
+
String(s.nodes),
|
|
406
|
+
s.added ? green(String(s.added)) : '0',
|
|
407
|
+
String(s.shared),
|
|
408
|
+
s.twins ? yellow(String(s.twins)) : '0',
|
|
409
|
+
String(s.files),
|
|
410
|
+
]),
|
|
411
|
+
]));
|
|
412
|
+
write();
|
|
413
|
+
const summary = [
|
|
414
|
+
count(report.added, 'node'),
|
|
415
|
+
`${count(report.edges, 'edge')}`,
|
|
416
|
+
`${report.twins} folded`,
|
|
417
|
+
].join(', ');
|
|
418
|
+
note(dryRun ? `${yellow('would add')} ${summary}` : `${green('merged')} ${summary}`);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* A refusal with the ids to look at. It is deliberately not a summary: the
|
|
422
|
+
* point of stopping is that a person decides, and they cannot decide from a
|
|
423
|
+
* count.
|
|
424
|
+
*/
|
|
425
|
+
function diverged(err, cwd, asJson) {
|
|
426
|
+
if (asJson) {
|
|
427
|
+
json({ error: err.message, conflicts: err.conflicts });
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
writeAll(table(err.conflicts.map((c) => [
|
|
431
|
+
cyan(c.id),
|
|
432
|
+
dim(`r${c.mine} ↔ r${c.theirs}`),
|
|
433
|
+
clip(c.text, 48),
|
|
434
|
+
dim(near(c.dir, cwd)),
|
|
435
|
+
])));
|
|
436
|
+
write();
|
|
437
|
+
}
|
|
438
|
+
return new CliError(err.message, EXIT.failed, err.hint);
|
|
439
|
+
}
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
339
441
|
// forget
|
|
340
442
|
// ---------------------------------------------------------------------------
|
|
341
443
|
/**
|
|
@@ -386,4 +488,9 @@ function clip(text, n) {
|
|
|
386
488
|
const one = text.replace(/\s+/g, ' ').trim();
|
|
387
489
|
return one.length > n ? one.slice(0, n - 1) + '…' : one;
|
|
388
490
|
}
|
|
491
|
+
/** A path as it was probably typed: relative when it is below here, absolute when it is not. */
|
|
492
|
+
function near(dir, cwd) {
|
|
493
|
+
const rel = relative(cwd, dir);
|
|
494
|
+
return rel && !rel.startsWith('..') ? rel : dir;
|
|
495
|
+
}
|
|
389
496
|
//# sourceMappingURL=memory.js.map
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
* own beside them.
|
|
12
12
|
*/
|
|
13
13
|
export declare const SHARED_RULES: readonly string[];
|
|
14
|
+
/**
|
|
15
|
+
* This `zen`'s own version, which the scaffold pins the sandbox's tools to.
|
|
16
|
+
* The publishable packages move in lockstep, so one number covers them all.
|
|
17
|
+
*
|
|
18
|
+
* Exported so the check that reports a drifted pin reads the same number the
|
|
19
|
+
* scaffold wrote, rather than a second definition of "this version".
|
|
20
|
+
*/
|
|
21
|
+
export declare function ownVersion(): string;
|
|
14
22
|
/**
|
|
15
23
|
* Writes the editor's files under `dir`, replacing what is there. They are
|
|
16
24
|
* ours: they say how the editor is to treat a directory the agents write into,
|
package/dist/scaffold.js
CHANGED
|
@@ -59,8 +59,11 @@ const MEMORY_REFERENCE = join('.github', 'skills', 'zen-memory', 'references', '
|
|
|
59
59
|
/**
|
|
60
60
|
* This `zen`'s own version, which the scaffold pins the sandbox's tools to.
|
|
61
61
|
* The publishable packages move in lockstep, so one number covers them all.
|
|
62
|
+
*
|
|
63
|
+
* Exported so the check that reports a drifted pin reads the same number the
|
|
64
|
+
* scaffold wrote, rather than a second definition of "this version".
|
|
62
65
|
*/
|
|
63
|
-
function ownVersion() {
|
|
66
|
+
export function ownVersion() {
|
|
64
67
|
const manifest = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
65
68
|
return JSON.parse(readFileSync(manifest, 'utf8')).version;
|
|
66
69
|
}
|
package/dist/validate.js
CHANGED
|
@@ -7,7 +7,7 @@ import { resolveBuild } from "./image.js";
|
|
|
7
7
|
import { SHAPES, envNames, form } from "./keys.js";
|
|
8
8
|
import { probeModels } from "./liveness.js";
|
|
9
9
|
import { BuildError, ensurePodmanReady } from "./podman.js";
|
|
10
|
-
import { staleShared } from "./scaffold.js";
|
|
10
|
+
import { ownVersion, staleShared } from "./scaffold.js";
|
|
11
11
|
// Mirrors the loader's own constants (`packages/neo/src/project/load.ts`).
|
|
12
12
|
// Duplicated rather than exported, because a check that agreed with the loader
|
|
13
13
|
// by construction could not report that the two had diverged.
|
|
@@ -1252,6 +1252,7 @@ function checkSandbox(root, config, record, add) {
|
|
|
1252
1252
|
if (!ok) {
|
|
1253
1253
|
return undefined;
|
|
1254
1254
|
}
|
|
1255
|
+
checkPin(join(root, file), file, add);
|
|
1255
1256
|
try {
|
|
1256
1257
|
return resolveBuild(root, config.sandbox);
|
|
1257
1258
|
}
|
|
@@ -1265,6 +1266,72 @@ function checkSandbox(root, config, record, add) {
|
|
|
1265
1266
|
return undefined;
|
|
1266
1267
|
}
|
|
1267
1268
|
}
|
|
1269
|
+
/** `@zenera/cli`, and the version after it when the line pins one. */
|
|
1270
|
+
const ZENERA_INSTALL = /@zenera\/([a-z0-9-]+)(?:@([^\s\\'"]+))?/g;
|
|
1271
|
+
/**
|
|
1272
|
+
* Whether the Dockerfile's `@zenera/*` installs name this `zen`.
|
|
1273
|
+
*
|
|
1274
|
+
* `zen init` writes the version it was run by and never rewrites the file
|
|
1275
|
+
* again — the scaffold keeps what the project already has — so the pin is fixed
|
|
1276
|
+
* at creation and falls behind the host on the next upgrade. Nothing else
|
|
1277
|
+
* notices: the file parses, the image builds, and the container simply holds a
|
|
1278
|
+
* different `zen` than the session outside it, which is a mismatch that shows
|
|
1279
|
+
* up as a tool behaving unlike its documentation.
|
|
1280
|
+
*
|
|
1281
|
+
* Read rather than run, so it is true on the laptop with no container engine —
|
|
1282
|
+
* the one `--no-sandbox` leaves with no other word on the subject.
|
|
1283
|
+
*/
|
|
1284
|
+
function checkPin(path, rel, add) {
|
|
1285
|
+
let text;
|
|
1286
|
+
try {
|
|
1287
|
+
// Existence is already a finding; an unreadable file is not worth a
|
|
1288
|
+
// second one, and a throw here would cost every check after it.
|
|
1289
|
+
text = readFileSync(path, 'utf8');
|
|
1290
|
+
}
|
|
1291
|
+
catch {
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const mine = ownVersion();
|
|
1295
|
+
const stale = new Set();
|
|
1296
|
+
const loose = new Set();
|
|
1297
|
+
for (const line of text.split('\n')) {
|
|
1298
|
+
// A comment naming the package is prose about the pin, not the pin.
|
|
1299
|
+
if (/^\s*#/.test(line)) {
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
for (const match of line.matchAll(ZENERA_INSTALL)) {
|
|
1303
|
+
const name = `@zenera/${match[1]}`;
|
|
1304
|
+
const pinned = match[2];
|
|
1305
|
+
if (pinned === undefined) {
|
|
1306
|
+
loose.add(name);
|
|
1307
|
+
}
|
|
1308
|
+
else if (pinned !== mine) {
|
|
1309
|
+
stale.add(`${name}@${pinned}`);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
const fix = `pin them to ${mine}, the version \`zen --version\` reports`;
|
|
1314
|
+
if (stale.size > 0) {
|
|
1315
|
+
add({
|
|
1316
|
+
severity: 'warning',
|
|
1317
|
+
code: 'sandbox.pin.stale',
|
|
1318
|
+
where: rel,
|
|
1319
|
+
message: `installs ${[...stale].join(', ')}, but this \`zen\` is ${mine} — the CLI an ` +
|
|
1320
|
+
'agent runs inside the container is not the one that built the session',
|
|
1321
|
+
fix,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
if (loose.size > 0) {
|
|
1325
|
+
add({
|
|
1326
|
+
severity: 'warning',
|
|
1327
|
+
code: 'sandbox.pin.absent',
|
|
1328
|
+
where: rel,
|
|
1329
|
+
message: `installs ${[...loose].join(', ')} unpinned, so the next image build takes ` +
|
|
1330
|
+
'whatever is current and drifts away from the host without saying so',
|
|
1331
|
+
fix,
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1268
1335
|
/** Written by the smoke test and read back, to prove the mount is real. */
|
|
1269
1336
|
const SMOKE = 'zen-check';
|
|
1270
1337
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.23",
|
|
4
4
|
"description": "Command-line front end for @zenera/neo: agentic projects you can run, share and commit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -48,14 +48,20 @@
|
|
|
48
48
|
"prepack": "tsc -b"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@inkjs/ui": "^2.0.0",
|
|
52
|
-
"ink": "^7.1.1",
|
|
53
|
-
"react": "^19.2.8",
|
|
54
|
-
"@zenera/neo": "^1.1.22",
|
|
55
51
|
"@anthropic-ai/sdk": "^0.120.0",
|
|
56
52
|
"@google/genai": "^2.18.0",
|
|
53
|
+
"@inkjs/ui": "^2.0.0",
|
|
57
54
|
"@openrouter/sdk": "^1.2.80",
|
|
55
|
+
"@zenera/neo": "^1.1.23",
|
|
58
56
|
"google-auth-library": "^10.0.0",
|
|
59
|
-
"
|
|
57
|
+
"ink": "^7.1.1",
|
|
58
|
+
"openai": "^6.0.0",
|
|
59
|
+
"react": "^19.2.8",
|
|
60
|
+
"yauzl": "^3.4.0",
|
|
61
|
+
"yazl": "^3.3.1"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/yauzl": "^3.4.0",
|
|
65
|
+
"@types/yazl": "^3.3.1"
|
|
60
66
|
}
|
|
61
67
|
}
|
|
@@ -115,7 +115,10 @@ the model to compute or remember. Specifically:
|
|
|
115
115
|
in it to the version of `zen` in hand. Read that version with `zen --version`
|
|
116
116
|
and write it into the `npm install -g` line, so the CLI an agent runs inside
|
|
117
117
|
the container is the same one that built it. An unpinned name drifts away from
|
|
118
|
-
the host on the next image build; a stale pin
|
|
118
|
+
the host on the next image build; a stale pin leaves the container holding an
|
|
119
|
+
older `zen` than the session outside it. `zen check` reports both, as
|
|
120
|
+
`sandbox.pin.absent` and `sandbox.pin.stale`, but will not repair either - the
|
|
121
|
+
Dockerfile is the project's file, so this edit is yours to make.
|
|
119
122
|
|
|
120
123
|
Never delete an **extra** without saying so. Report it, say why the
|
|
121
124
|
specification does not cover it, and leave it unless I tell you to remove it.
|
|
@@ -148,6 +151,18 @@ not a paragraph of instructions for a human.
|
|
|
148
151
|
to a temporary path and moving it into place so an interrupted run never leaves
|
|
149
152
|
a half-built artefact behind. `scripts/_setup.sh --force` redoes everything,
|
|
150
153
|
and reaches each step as `$FORCE=1`.
|
|
154
|
+
- **Every step answers a dry run.** `scripts/_setup.sh --check` reaches each step
|
|
155
|
+
as `$CHECK=1` and means: make the same "already done" test, then do none of the
|
|
156
|
+
work. Exit **3** when the answer is yes, otherwise say what is missing and exit
|
|
157
|
+
**4**, which the runner reports as `needs setup`. This is the one command that
|
|
158
|
+
says whether the project can run without spending the download and the
|
|
159
|
+
embedding to find out, so the line a step prints under `$CHECK` is the whole
|
|
160
|
+
value of it: name the artefact and the command that produces it. Pass a tool's
|
|
161
|
+
own words through rather than writing a second opinion: `zen rag docs ready`
|
|
162
|
+
without `--quiet` already prints what is wrong and the `restore` or `index`
|
|
163
|
+
that fixes it. A check must not assume an earlier step has run: a missing
|
|
164
|
+
prerequisite is a 4 of its own, not a failure, or the first unbuilt thing
|
|
165
|
+
hides everything after it.
|
|
151
166
|
- **A step's "already done" test must name something that survives a clone.**
|
|
152
167
|
Git-ignored output does not. A rag index commits its `manifest.json` and
|
|
153
168
|
ignores `lance/`, so `[ -f "$OUT/manifest.json" ]` is true on a fresh checkout
|
|
@@ -164,6 +179,10 @@ not a paragraph of instructions for a human.
|
|
|
164
179
|
echo "$OUT is already built"
|
|
165
180
|
exit 3
|
|
166
181
|
fi
|
|
182
|
+
if [ "${CHECK:-0}" = 1 ]; then
|
|
183
|
+
zen rag docs ready --dir "$OUT" || true # its reason and its fix
|
|
184
|
+
exit 4
|
|
185
|
+
fi
|
|
167
186
|
if [ "${FORCE:-0}" = 0 ] && [ -f "$OUT/manifest.json" ]; then
|
|
168
187
|
zen rag docs restore --dir "$OUT" # committed index, ignored vectors
|
|
169
188
|
exit 0
|
|
@@ -175,7 +194,8 @@ not a paragraph of instructions for a human.
|
|
|
175
194
|
|
|
176
195
|
- **It must be watchable.** `scripts/_setup.sh` tees each step's output to
|
|
177
196
|
`.tmp/logs/setup-<step>.log`, prints a heartbeat line while a long step runs,
|
|
178
|
-
and finishes with one line per step: `ok`, `skipped`, or
|
|
197
|
+
and finishes with one line per step: `ok`, `skipped`, `needs setup`, or
|
|
198
|
+
`failed`.
|
|
179
199
|
- **Everything transient goes under `.tmp/`** - logs, scratch files, downloads,
|
|
180
200
|
test output, and the temporary path a step writes to before moving its
|
|
181
201
|
artefact into place. Nothing else in the tree is a scratch directory, and
|
|
@@ -379,6 +399,9 @@ background and follow the logs instead of waiting blind:
|
|
|
379
399
|
for the same reason, stop and raise it in `SPECIFICATION-FEEDBACK.md`.
|
|
380
400
|
- Prove re-entrancy: once it has succeeded, run `scripts/_setup.sh` once more and
|
|
381
401
|
check that every step reports `skipped` rather than rebuilding.
|
|
402
|
+
- Prove the dry run: `scripts/_setup.sh --check` must then report every step
|
|
403
|
+
`skipped` and exit 0. `needs setup` for something setup has just built means
|
|
404
|
+
the step's two tests disagree, and one of them is wrong.
|
|
382
405
|
- **`skipped` on a machine that has never built it is a bug, not a pass.** A
|
|
383
406
|
step that tests for a committed file skips on a fresh clone and leaves the
|
|
384
407
|
project unusable. For every index the project builds, check it can actually
|
|
@@ -24,7 +24,9 @@ zen key <ls|add|use|check|rm|show|env> [ref] [options]
|
|
|
24
24
|
zen models <providers|ls|search|show|test|pick> [ref] [options]
|
|
25
25
|
zen check [name|dir] [--project <name|dir>] [--no-sandbox] [--no-models] [--strict] [--quiet]
|
|
26
26
|
zen inspect [run] [--session <id>] [--open] [--rebuild] [--serve [port]]
|
|
27
|
-
zen memory [stats|ls|show|export|forget] [args] [options]
|
|
27
|
+
zen memory [stats|ls|show|export|merge|forget] [args] [options]
|
|
28
|
+
zen export [project] [--out <file>] [--no-vectors] [--no-memory] [--force]
|
|
29
|
+
zen import <file.zip> [dir] [--name <name>] [--force] [--no-register]
|
|
28
30
|
zen sandbox [status|up|pull|clean|disk] [options]
|
|
29
31
|
zen version
|
|
30
32
|
|
|
@@ -50,6 +52,7 @@ guess a flag, and do not read them all.
|
|
|
50
52
|
| The container shell commands run in, images, `persist` | [sandbox.md](./references/sandbox.md) (and `zen-sandbox` skill) |
|
|
51
53
|
| Run reports, trajectories, what a session directory holds | [inspect.md](./references/inspect.md) |
|
|
52
54
|
| What the agents remember, why something was recalled, forgetting | [memory.md](./references/memory.md) |
|
|
55
|
+
| Sharing a project: archives, what travels, importing one safely | [export.md](./references/export.md) |
|
|
53
56
|
| `zen faker` - a mock API from an OpenAPI/Swagger document | [faker.md](./references/faker.md) |
|
|
54
57
|
| `zen rag` - searching an API description, or a pile of markdown | [rag.md](./references/rag.md) |
|
|
55
58
|
|
|
@@ -62,6 +65,8 @@ zen run the TUI
|
|
|
62
65
|
zen run "what changed?" one answer, this directory as the workspace
|
|
63
66
|
zen inspect --open what the model was actually given
|
|
64
67
|
zen memory export --open the whole memory graph, as a page
|
|
68
|
+
zen export the project as a zip you can send
|
|
69
|
+
zen import a.zip unpack one somebody sent, run nothing in it
|
|
65
70
|
zen key ls --check which credentials still work
|
|
66
71
|
zen models test <ref> whether one model actually answers
|
|
67
72
|
zen models pick --embedding the first embedder that does, on stdout
|
|
@@ -51,6 +51,11 @@ checked too. A word that is neither is a usage error (exit 2), not a report.
|
|
|
51
51
|
exist, and - unless `--no-sandbox` - the image **builds** and one command runs
|
|
52
52
|
in it, against a temporary directory rather than your workspace. No container
|
|
53
53
|
engine at all is a warning, not an error.
|
|
54
|
+
- The `@zenera/*` the Dockerfile installs are pinned to **this** `zen`. The pin
|
|
55
|
+
is written once, by `zen init`, and nothing rewrites it afterwards, so it falls
|
|
56
|
+
behind the host on the next upgrade and the container ends up holding a
|
|
57
|
+
different `zen` than the session outside it. Read rather than run, so this one
|
|
58
|
+
is reported with `--no-sandbox` too.
|
|
54
59
|
|
|
55
60
|
### Findings
|
|
56
61
|
|
|
@@ -66,6 +71,7 @@ fork.unknown skills.missing skill.unloadable / .no-skill-md
|
|
|
66
71
|
skills.no-catalog skills.unreachable skill.unused
|
|
67
72
|
assets.missing assets.overbroad sandbox.dockerfile.missing
|
|
68
73
|
sandbox.build sandbox.smoke sandbox.start / .unchecked
|
|
74
|
+
sandbox.pin.stale sandbox.pin.absent
|
|
69
75
|
provider.invalid model.none model.unresolvable
|
|
70
76
|
model.refused model.unreachable model.unusable
|
|
71
77
|
embedding.refused embedding.unreachable embedding.unusable
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Sharing a project - `zen export` and `zen import`
|
|
2
|
+
|
|
3
|
+
A project is a directory, so moving one is a zip - except that half of the
|
|
4
|
+
directory belongs to that machine rather than to the project. These two write
|
|
5
|
+
and read the other half.
|
|
6
|
+
|
|
7
|
+
## `zen export`
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
zen export [project] [--out <file>] [--no-vectors] [--no-memory] [--force]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Aliases: `pack`, `archive`, `share`.
|
|
14
|
+
|
|
15
|
+
Writes `./<project>-<stamp>.zip` in the working directory unless `--out` names
|
|
16
|
+
somewhere else. The stamp means two exports in one afternoon are two files, not
|
|
17
|
+
one silently replaced; an `--out` that already exists is a usage error until
|
|
18
|
+
`--force`.
|
|
19
|
+
|
|
20
|
+
| Flag | Meaning |
|
|
21
|
+
| -------------- | ------------------------------------------------------------ |
|
|
22
|
+
| `--out <file>` | Where the archive goes |
|
|
23
|
+
| `--no-vectors` | Leave out `assets/**/lance/` - much smaller, needs a restore |
|
|
24
|
+
| `--no-memory` | Leave out `memory/` entirely |
|
|
25
|
+
| `--force` | Overwrite the target, and export past a held memory lock |
|
|
26
|
+
|
|
27
|
+
### What travels
|
|
28
|
+
|
|
29
|
+
Everything that is the project: `agents.yaml`, the `agents/` tree,
|
|
30
|
+
`SPECIFICATION.md` and its feedback, `assets/`, `memory/`, `sandbox/`,
|
|
31
|
+
`scripts/`, and the editor files (`.github/`, `.vscode/`).
|
|
32
|
+
|
|
33
|
+
What does not: `sessions/`, `.tmp/`, `.git/`, `node_modules/`, lock files,
|
|
34
|
+
`.DS_Store`, any `*.zip` at the top of the project (the default destination
|
|
35
|
+
puts one there, and a second export must not pack the first), and `.env`.
|
|
36
|
+
Symbolic links are counted and never followed. A zip that is genuine project
|
|
37
|
+
material lives under `assets/`, where this rule does not look.
|
|
38
|
+
|
|
39
|
+
**The values in `.env` never travel, behind no flag.** The names do, as a
|
|
40
|
+
`.env.example` with every value blank and every comment kept - so the other end
|
|
41
|
+
knows which credentials to supply without being handed any.
|
|
42
|
+
|
|
43
|
+
### The vectors travel by default
|
|
44
|
+
|
|
45
|
+
This is the decision the command is built around. A rag index without its
|
|
46
|
+
`lance/` tree looks built and cannot search, and memory has no fallback at all:
|
|
47
|
+
a graph without `vectors.f32` recalls by term overlap until every node is
|
|
48
|
+
written again. So the default archive works when it is opened.
|
|
49
|
+
|
|
50
|
+
`--no-vectors` makes a much smaller one for someone who will run
|
|
51
|
+
`zen rag <subject> restore` on arrival. The manifest records the choice and
|
|
52
|
+
`zen import` prints the restore step when it was taken.
|
|
53
|
+
|
|
54
|
+
### What it refuses
|
|
55
|
+
|
|
56
|
+
Only one thing: memory held by a live run, because a graph copied mid-write is
|
|
57
|
+
an archive that opens and is wrong. `--force` overrides it, and
|
|
58
|
+
`--no-memory` sidesteps it.
|
|
59
|
+
|
|
60
|
+
A project that fails `zen check` is exported anyway, with a warning. Sending
|
|
61
|
+
someone a broken project to ask for help is a legitimate reason to export one.
|
|
62
|
+
|
|
63
|
+
`--json` prints `{file, bytes, files, project, root, vectors, memory, skipped, ok}`.
|
|
64
|
+
|
|
65
|
+
## `zen import`
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
zen import <file.zip> [dir] [--name <name>] [--force] [--no-register]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Aliases: `unpack`.
|
|
72
|
+
|
|
73
|
+
Unpacks into `./<project>` unless a directory is named, and registers it so
|
|
74
|
+
`zen list` and `zen open` find it by name. A target directory with anything in
|
|
75
|
+
it is refused until `--force`.
|
|
76
|
+
|
|
77
|
+
| Flag | Meaning |
|
|
78
|
+
| --------------- | --------------------------------------------------- |
|
|
79
|
+
| `--name <name>` | Register under this name - use it when one is taken |
|
|
80
|
+
| `--force` | Write into a directory that is not empty |
|
|
81
|
+
| `--no-register` | Unpack and leave the registry alone |
|
|
82
|
+
|
|
83
|
+
A zip without `zenera-export.json` at its root is a usage error: only an archive
|
|
84
|
+
`zen export` wrote can be imported.
|
|
85
|
+
|
|
86
|
+
### It treats the archive as hostile
|
|
87
|
+
|
|
88
|
+
Every path, size and mode in a zip is a claim made by whoever sent it. So:
|
|
89
|
+
entry paths that are absolute, contain `..`, or fall outside the single
|
|
90
|
+
directory the manifest names are refused; an entry claiming to be a symbolic
|
|
91
|
+
link is refused; the entry count and unpacked size are bounded; and file modes
|
|
92
|
+
are replaced rather than honoured (`0644`, and `0755` for `*.sh` only).
|
|
93
|
+
|
|
94
|
+
**Nothing in the archive is executed** - not `scripts/_setup.sh`, not the
|
|
95
|
+
Dockerfile, not a line of the `.github/` tree. The commands to run next are
|
|
96
|
+
printed instead, so they can be read before they are typed.
|
|
97
|
+
|
|
98
|
+
### After importing
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
cp .env.example .env # and fill in the credentials; they were not in the archive
|
|
102
|
+
zen check <name> # what this machine is still missing
|
|
103
|
+
zen rag <subject> restore # only when the archive was made with --no-vectors
|
|
104
|
+
zen open <name>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`--json` prints `{dir, name, files, bytes, registered, from, vectors, memory}`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Memory - `zen memory`
|
|
2
2
|
|
|
3
3
|
```
|
|
4
|
-
zen memory [stats|ls|show|export|forget] [args] [options]
|
|
4
|
+
zen memory [stats|ls|show|export|merge|forget] [args] [options]
|
|
5
5
|
```
|
|
6
6
|
|
|
7
7
|
Alias: `mem`.
|
|
@@ -24,20 +24,24 @@ skill.
|
|
|
24
24
|
| `zen memory ls` | Nodes, newest first. Changes nothing |
|
|
25
25
|
| `zen memory show <id>` | One node in full, with what it links to |
|
|
26
26
|
| `zen memory export [f]` | The whole graph as one HTML page |
|
|
27
|
+
| `zen memory merge <dir…>` | Fold other memories into this one |
|
|
27
28
|
| `zen memory forget <id…>` | Remove nodes, their vectors and their files |
|
|
28
29
|
|
|
29
|
-
| Flag | Meaning
|
|
30
|
-
| ----------------------- |
|
|
31
|
-
| `--project <name\|dir>` | Which project. Inferred from the directory
|
|
32
|
-
| `--dir <dir>` | Read this memory directory instead
|
|
33
|
-
| `--kind <name>` | Only this kind of node
|
|
34
|
-
| `--audience <name>` | Only nodes committed under this label
|
|
35
|
-
| `--files` | Only nodes that remember a file
|
|
36
|
-
| `--stale` | Only nodes something has superseded
|
|
37
|
-
| `--limit <n>` | Rows to list. Default 30
|
|
38
|
-
| `--out <file>` | Where `export` writes. Default `memory.html`
|
|
39
|
-
| `--open` | Open the exported page
|
|
40
|
-
| `--
|
|
30
|
+
| Flag | Meaning |
|
|
31
|
+
| ----------------------- | --------------------------------------------- |
|
|
32
|
+
| `--project <name\|dir>` | Which project. Inferred from the directory |
|
|
33
|
+
| `--dir <dir>` | Read this memory directory instead |
|
|
34
|
+
| `--kind <name>` | Only this kind of node |
|
|
35
|
+
| `--audience <name>` | Only nodes committed under this label |
|
|
36
|
+
| `--files` | Only nodes that remember a file |
|
|
37
|
+
| `--stale` | Only nodes something has superseded |
|
|
38
|
+
| `--limit <n>` | Rows to list. Default 30 |
|
|
39
|
+
| `--out <file>` | Where `export` writes. Default `memory.html` |
|
|
40
|
+
| `--open` | Open the exported page |
|
|
41
|
+
| `--dry-run` | Say what `merge` would do, and stop |
|
|
42
|
+
| `--no-dedupe` | Keep memories `merge` would otherwise fold |
|
|
43
|
+
| `--force` | Let `merge` pick a winner where copies differ |
|
|
44
|
+
| `--yes` | Do not ask before removing or merging |
|
|
41
45
|
|
|
42
46
|
## A graph that is not the project's
|
|
43
47
|
|
|
@@ -90,6 +94,40 @@ It is a single file with no server behind it, so it attaches to a bug report.
|
|
|
90
94
|
The only network it does is fetching Mermaid; without that it degrades to a
|
|
91
95
|
working list and detail view.
|
|
92
96
|
|
|
97
|
+
## Merging
|
|
98
|
+
|
|
99
|
+
The lock is per directory, so two runs cannot warm the same memory at once.
|
|
100
|
+
They each warm their own, and `merge` puts the results back together:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
zen memory merge .tmp/warmup-*/memory into the project’s
|
|
104
|
+
zen memory merge a/memory b/memory --dir merged/memory into a named one
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Sources are positional and the target is `--dir`, or the project you are in —
|
|
108
|
+
the same shape as everywhere else here. The shell expands the glob, so a
|
|
109
|
+
twelve-way fan-out is still one word. `merge` is the one subcommand that will
|
|
110
|
+
write a memory into existence: a target that is not there yet is created.
|
|
111
|
+
|
|
112
|
+
It is offline and contacts no model, which is why every side has to have been
|
|
113
|
+
embedded with the same model already. Node ids are kept, so the interesting
|
|
114
|
+
case is the same id on both sides:
|
|
115
|
+
|
|
116
|
+
- **Same revision, same content** — a shared ancestor, because the runs started
|
|
117
|
+
from a copy of the same memory. Use counts and timestamps reconcile to the
|
|
118
|
+
larger of the two, never the sum, so merging a source twice changes nothing.
|
|
119
|
+
- **Anything else** — a divergence. The whole merge is refused, nothing is
|
|
120
|
+
written, and every conflicting id is listed with both revisions. `--force`
|
|
121
|
+
takes the highest revision instead.
|
|
122
|
+
|
|
123
|
+
Memories that are not the same node but say the same thing fold together on the
|
|
124
|
+
same rule a commit uses for duplicates — near-identical by cosine, same kind,
|
|
125
|
+
same audience, never one that remembers a file. `--no-dedupe` turns that off.
|
|
126
|
+
Edges follow whatever their ends folded onto, and an edge whose ends both
|
|
127
|
+
landed on the same memory is dropped.
|
|
128
|
+
|
|
129
|
+
`--dry-run` reports all of it and writes nothing.
|
|
130
|
+
|
|
93
131
|
## Forgetting
|
|
94
132
|
|
|
95
133
|
```sh
|