@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.
@@ -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
- ' --yes Do not ask before removing.',
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
- const opened = await open(ctx.cwd, values.project, values.dir);
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
@@ -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.22",
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
- "openai": "^6.0.0"
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 is a mismatch nothing reports.
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 `failed`.
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
- | `--yes` | Do not ask before removing |
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