@jbarket/loomai 0.11.0 → 0.12.0

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/CHANGELOG.md CHANGED
@@ -12,6 +12,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.12.0] - 2026-10-24
16
+
17
+ ### Added
18
+
19
+ - **The bundle: the household on a USB stick** (docs/specs/household.md,
20
+ phase 3).
21
+ - **`loom export --to <file> --recipient <age1…>`** writes the whole
22
+ household into one file, encrypted with age to the owner's key: its
23
+ files and tokens, the commons, every agent's directory (SQLite through
24
+ the backup API), and each piece's own part (`loom-<piece> bundle export`,
25
+ encrypted by the piece).
26
+ - **`loom restore <file> --identity <key>`** lays it down in an empty
27
+ LOOM_HOME and hands each piece its part (`bundle import`). A piece that isn't
28
+ installed keeps its part in `pieces-pending/`.
29
+ - **`--drill`** restores into a throwaway directory and compares it with
30
+ the live household, touching nothing live.
31
+ - **A plain context dir** exports as a household of one, and restores as
32
+ one with its cards split.
33
+
15
34
  ## [0.11.0] - 2026-10-24
16
35
 
17
36
  ### Changed
@@ -732,7 +751,8 @@ Initial public release.
732
751
  stack and all added external-service dependencies or operational
733
752
  overhead.
734
753
 
735
- [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.11.0...HEAD
754
+ [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.12.0...HEAD
755
+ [0.12.0]: https://github.com/sleepunit-agents/loom/compare/v0.11.0...v0.12.0
736
756
  [0.11.0]: https://github.com/sleepunit-agents/loom/compare/v0.10.0...v0.11.0
737
757
  [0.10.0]: https://github.com/sleepunit-agents/loom/compare/v0.9.2...v0.10.0
738
758
  [0.9.2]: https://github.com/sleepunit-agents/loom/compare/v0.9.1...v0.9.2
package/README.md CHANGED
@@ -677,6 +677,41 @@ dir is `<home>/agents/<name>` next to a `household.json` serves the whole
677
677
  household. A process with `LOOM_HOME` set and no context dir uses
678
678
  `LOOM_AGENT`, or the household's default.
679
679
 
680
+ ### The bundle — the household on a USB stick
681
+
682
+ ```bash
683
+ loom export --to household.bundle --recipient age1… # your key, never the box's
684
+ loom restore household.bundle --identity ~/owner.key --drill # does it really come back?
685
+ loom restore household.bundle --identity ~/owner.key --to ~/.config/loom # on a new machine
686
+ ```
687
+
688
+ **`loom export`** writes the whole household into one file, encrypted with
689
+ [age](https://age-encryption.org) to a recipient the owner holds (a YubiKey,
690
+ or a key kept in a password manager). The file contains:
691
+ - the household's files and tokens;
692
+ - the commons;
693
+ - every agent's directory. Databases go through SQLite's backup API, never
694
+ as a raw copy of a live file. Old `*.bak` files and `backup-*` folders
695
+ are left out.
696
+ - each installed piece's own part (`loom-<piece> bundle export`), which the piece
697
+ encrypts itself to the same recipient. Core never sees inside it.
698
+
699
+ A plain context dir exports as a household of one.
700
+
701
+ **`loom restore`** lays a bundle down in an empty `LOOM_HOME` (never over a
702
+ live one). It then hands each installed piece its part (`loom-<piece>
703
+ import`, with `LOOM_IMPORT_IDENTITY` naming the key so the piece can
704
+ re-encrypt for the new machine). A piece that isn't installed yet keeps its
705
+ part in `pieces-pending/`.
706
+
707
+ **`--drill`** restores into a throwaway directory instead and compares the
708
+ result with the live household: identities, memory counts, every card,
709
+ knowledge, tokens. Nothing live is touched.
710
+
711
+ A restore trusts what's in the bundle. age encrypts, but it doesn't prove who
712
+ made the file, so only restore bundles you made. Needs the `age` and `tar`
713
+ programs.
714
+
680
715
  ### Harness manifests and self-describe
681
716
 
682
717
  A **harness** is the MCP-capable runtime the agent runs in (Claude Code,
@@ -0,0 +1,80 @@
1
+ import { type Household } from './household.js';
2
+ export declare class BundleError extends Error {
3
+ }
4
+ export interface PiecePart {
5
+ name: string;
6
+ included: boolean;
7
+ reason?: string;
8
+ bytes?: number;
9
+ sha256?: string;
10
+ }
11
+ export interface Manifest {
12
+ v: 1;
13
+ loom: string;
14
+ created: string;
15
+ household: {
16
+ agents: string[];
17
+ default: string;
18
+ owner?: string;
19
+ };
20
+ pieces: PiecePart[];
21
+ }
22
+ /** Where to export from: the household a context dir belongs to, or a plain context dir as a household of one. */
23
+ export declare function exportSource(contextDir: string): {
24
+ household: Household;
25
+ plain: boolean;
26
+ contextDir: string;
27
+ };
28
+ /** Write the bundle. Returns the manifest. */
29
+ export declare function exportBundle(opts: {
30
+ contextDir: string;
31
+ to: string;
32
+ recipients: string[];
33
+ env?: NodeJS.ProcessEnv;
34
+ now?: Date;
35
+ }): Promise<Manifest>;
36
+ export interface RestoreReport {
37
+ home: string;
38
+ manifest: Manifest;
39
+ pieces: Array<{
40
+ name: string;
41
+ restored: boolean;
42
+ reason?: string;
43
+ }>;
44
+ }
45
+ /**
46
+ * Lay a bundle down in an empty $LOOM_HOME and hand each piece its part
47
+ * (`loom-<piece> bundle import`, with LOOM_IMPORT_IDENTITY naming the key so the
48
+ * piece can re-encrypt for this machine). A piece that isn't installed here
49
+ * has its part kept in pieces-pending/ for later.
50
+ */
51
+ export declare function restoreBundle(opts: {
52
+ file: string;
53
+ identity: string;
54
+ home: string;
55
+ env?: NodeJS.ProcessEnv;
56
+ }): Promise<RestoreReport>;
57
+ export interface DrillReport {
58
+ ok: boolean;
59
+ manifest: Manifest;
60
+ differences: Array<{
61
+ what: string;
62
+ live: unknown;
63
+ restored: unknown;
64
+ }>;
65
+ }
66
+ /**
67
+ * Restore a bundle into a throwaway directory and compare it with the live
68
+ * household: agents, identities, memory counts, every relationship and
69
+ * directory card, knowledge, tokens. Pieces aren't imported (that would
70
+ * write their live stores); their parts are only checked for presence. The
71
+ * throwaway directory is always removed.
72
+ */
73
+ export declare function drillBundle(opts: {
74
+ file: string;
75
+ identity: string;
76
+ contextDir: string;
77
+ env?: NodeJS.ProcessEnv;
78
+ }): Promise<DrillReport>;
79
+ /** How big a file is, for messages. */
80
+ export declare function sizeOf(path: string): number;
package/dist/bundle.js ADDED
@@ -0,0 +1,377 @@
1
+ /**
2
+ * The bundle (docs/specs/household.md, "Export and restore"): the whole
3
+ * household in one file, encrypted to a key the owner holds. A USB stick
4
+ * with the latest bundle and that key brings the household back anywhere.
5
+ *
6
+ * loom export --to <file> --recipient <age1…> write one
7
+ * loom restore <file> --identity <key> lay it down in an empty $LOOM_HOME
8
+ * loom restore <file> --identity <key> --drill restore into a throwaway dir and
9
+ * compare with the live household
10
+ *
11
+ * Inside (a tar, then age-encrypted): manifest.json, household.json, the token
12
+ * files, the commons, every agent's directory (SQLite through the backup API,
13
+ * never a raw copy of a live file), and each installed piece's own part, which
14
+ * the piece encrypts itself (`loom-<piece> bundle export`): core never sees inside
15
+ * it. A plain context dir exports as a household of one.
16
+ *
17
+ * Encryption and archiving use the `age` and `tar` programs. Plaintext only
18
+ * ever sits in a fresh 0700 temp directory that's removed on the way out.
19
+ */
20
+ import { spawn, spawnSync } from 'node:child_process';
21
+ import { createHash } from 'node:crypto';
22
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { basename, delimiter, join, resolve } from 'node:path';
25
+ import BetterSqlite3 from 'better-sqlite3';
26
+ import { resolveRepoRoot, resolveSqliteDbPath } from './config.js';
27
+ import { AGENT_NAME, agentDir, commonsKnowledgePath, householdOf, loadHousehold, } from './household.js';
28
+ import { commonsPeoplePath, splitPeopleIntoCommons } from './backends/people-household.js';
29
+ import { listPieces } from './cli/pieces.js';
30
+ export class BundleError extends Error {
31
+ }
32
+ const PIECE_TIMEOUT_MS = 120_000;
33
+ const PIECE_MAX_BYTES = 256 * 1024 * 1024;
34
+ /** Left out of an agent's directory: SQLite sidecars (the backup API covers them) and old hand-made backups. */
35
+ const SKIP_FILE = /(\.db-(wal|shm)$)|(\.bak($|[.-]))|(\.loom\.tmp$)/;
36
+ const SKIP_DIR = /^backup-/;
37
+ function loomVersion() {
38
+ try {
39
+ return JSON.parse(readFileSync(join(resolveRepoRoot(), 'package.json'), 'utf-8')).version;
40
+ }
41
+ catch {
42
+ return 'unknown';
43
+ }
44
+ }
45
+ /**
46
+ * The `age` or `tar` program: LOOM_AGE / LOOM_TAR when set (an absolute path
47
+ * to a file), else the first on PATH. Always run without a shell, with
48
+ * absolute paths as arguments (so nothing can be read as an option).
49
+ */
50
+ function findProgram(name, env) {
51
+ const override = env[`LOOM_${name.toUpperCase()}`];
52
+ if (override) {
53
+ if (!override.startsWith('/') || !existsSync(override) || !statSync(override).isFile()) {
54
+ throw new BundleError(`LOOM_${name.toUpperCase()} must be the absolute path of the ${name} program`);
55
+ }
56
+ return override;
57
+ }
58
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
59
+ if (dir && existsSync(join(dir, name)))
60
+ return join(dir, name);
61
+ }
62
+ throw new BundleError(`${name} isn't installed (or on PATH); the bundle needs it. Set LOOM_${name.toUpperCase()} to its path if it lives elsewhere.`);
63
+ }
64
+ function run(cmd, args, what) {
65
+ const r = spawnSync(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf-8' });
66
+ if (r.status !== 0)
67
+ throw new BundleError(`${what} failed: ${(r.stderr || r.error?.message || `exit ${r.status}`).trim()}`);
68
+ }
69
+ async function backupDb(from, to) {
70
+ const db = new BetterSqlite3(from, { readonly: true, fileMustExist: true });
71
+ try {
72
+ await db.backup(to);
73
+ }
74
+ finally {
75
+ db.close();
76
+ }
77
+ }
78
+ /** Copy an agent's directory: databases through the backup API, everything else as is. */
79
+ async function copyAgent(from, to) {
80
+ mkdirSync(to, { recursive: true, mode: 0o700 });
81
+ for (const name of readdirSync(from)) {
82
+ const src = join(from, name);
83
+ const dst = join(to, name);
84
+ const st = lstatSync(src);
85
+ if (st.isSymbolicLink()) {
86
+ symlinkSync(readlinkSync(src), dst);
87
+ continue;
88
+ }
89
+ if (st.isDirectory()) {
90
+ if (!SKIP_DIR.test(name))
91
+ await copyAgent(src, dst);
92
+ continue;
93
+ }
94
+ if (!st.isFile() || SKIP_FILE.test(name))
95
+ continue;
96
+ if (name.endsWith('.db'))
97
+ await backupDb(src, dst);
98
+ else
99
+ copyFileSync(src, dst);
100
+ }
101
+ }
102
+ /**
103
+ * `loom-<piece> bundle export|import`: namespaced under `bundle`, so they can't
104
+ * collide with a piece's own export/import commands (taskstore has both).
105
+ */
106
+ function pieceRun(path, verb, env, stdin) {
107
+ return new Promise((resolveP) => {
108
+ const child = spawn(path, ['bundle', verb], { env, stdio: [stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
109
+ const chunks = [];
110
+ let bytes = 0;
111
+ let err = '';
112
+ let done = false;
113
+ const finish = (r) => { if (!done) {
114
+ done = true;
115
+ clearTimeout(timer);
116
+ resolveP(r);
117
+ } };
118
+ const timer = setTimeout(() => { child.kill('SIGKILL'); finish({ ok: false, reason: 'timed out' }); }, PIECE_TIMEOUT_MS);
119
+ child.stdout.on('data', (d) => {
120
+ bytes += d.length;
121
+ if (bytes > PIECE_MAX_BYTES) {
122
+ child.kill('SIGKILL');
123
+ finish({ ok: false, reason: 'part too large' });
124
+ return;
125
+ }
126
+ chunks.push(d);
127
+ });
128
+ child.stderr.on('data', (d) => { if (err.length < 2000)
129
+ err += d.toString('utf-8'); });
130
+ child.on('error', (e) => finish({ ok: false, reason: e.message }));
131
+ child.on('close', (code) => finish(code === 0 ? { ok: true, out: Buffer.concat(chunks) } : { ok: false, reason: (err.trim().split('\n').pop() || `exit ${code}`).slice(0, 300) }));
132
+ if (stdin && child.stdin) {
133
+ child.stdin.on('error', () => { });
134
+ child.stdin.end(stdin);
135
+ }
136
+ });
137
+ }
138
+ /** Where to export from: the household a context dir belongs to, or a plain context dir as a household of one. */
139
+ export function exportSource(contextDir) {
140
+ const hh = householdOf(contextDir);
141
+ if (hh)
142
+ return { household: hh.household, plain: false, contextDir };
143
+ const name = basename(resolve(contextDir)).toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^[^a-z]+/, '') || 'agent';
144
+ const agent = AGENT_NAME.test(name) ? name : 'agent';
145
+ return { household: { home: resolve(contextDir, '..'), agents: [agent], default: agent }, plain: true, contextDir };
146
+ }
147
+ /** Write the bundle. Returns the manifest. */
148
+ export async function exportBundle(opts) {
149
+ const env = opts.env ?? process.env;
150
+ if (!opts.recipients.length)
151
+ throw new BundleError('a bundle needs at least one --recipient (an age public key the owner holds)');
152
+ for (const r of opts.recipients)
153
+ if (!/^age1[0-9a-z]{20,}$/.test(r) && !r.startsWith('ssh-'))
154
+ throw new BundleError(`${r} isn't an age recipient (age1…) or an ssh public key`);
155
+ const age = findProgram('age', env);
156
+ const tar = findProgram('tar', env);
157
+ const src = exportSource(opts.contextDir);
158
+ const h = src.household;
159
+ const work = mkdtempSync(join(tmpdir(), 'loom-export-'));
160
+ chmodSync(work, 0o700);
161
+ try {
162
+ const stage = join(work, 'household');
163
+ mkdirSync(stage, { mode: 0o700 });
164
+ // The household's own files.
165
+ writeFileSync(join(stage, 'household.json'), JSON.stringify({ v: 1, agents: h.agents, default: h.default, ...(h.owner ? { owner: h.owner } : {}) }, null, 2) + '\n', { mode: 0o600 });
166
+ if (!src.plain)
167
+ for (const f of ['tokens.json', 'tokens.used.json'])
168
+ if (existsSync(join(h.home, f)))
169
+ copyFileSync(join(h.home, f), join(stage, f));
170
+ // The commons.
171
+ mkdirSync(join(stage, 'commons'), { mode: 0o700 });
172
+ const knowledge = src.plain ? join(src.contextDir, 'knowledge.db') : commonsKnowledgePath(h);
173
+ if (existsSync(knowledge))
174
+ await backupDb(knowledge, join(stage, 'commons', 'knowledge.db'));
175
+ if (!src.plain && existsSync(commonsPeoplePath(h)))
176
+ await backupDb(commonsPeoplePath(h), join(stage, 'commons', 'people.db'));
177
+ // Each agent.
178
+ for (const a of h.agents) {
179
+ const from = src.plain ? src.contextDir : agentDir(h, a);
180
+ await copyAgent(from, join(stage, 'agents', a));
181
+ if (src.plain)
182
+ rmSync(join(stage, 'agents', a, 'knowledge.db'), { force: true });
183
+ }
184
+ // Each piece's own part, already encrypted by the piece to the same recipients.
185
+ const pieces = [];
186
+ mkdirSync(join(stage, 'pieces'), { mode: 0o700 });
187
+ for (const p of listPieces(env).filter((x) => x.name !== 'loom')) {
188
+ const r = await pieceRun(p.path, 'export', { ...env, LOOM_EXPORT_RECIPIENTS: opts.recipients.join(',') });
189
+ if (!r.ok) {
190
+ pieces.push({ name: p.name, included: false, reason: r.reason });
191
+ continue;
192
+ }
193
+ writeFileSync(join(stage, 'pieces', `${p.name}.part`), r.out, { mode: 0o600 });
194
+ pieces.push({ name: p.name, included: true, bytes: r.out.length, sha256: createHash('sha256').update(r.out).digest('hex') });
195
+ }
196
+ const manifest = {
197
+ v: 1, loom: loomVersion(), created: (opts.now ?? new Date()).toISOString(),
198
+ household: { agents: h.agents, default: h.default, ...(h.owner ? { owner: h.owner } : {}) }, pieces,
199
+ };
200
+ writeFileSync(join(stage, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
201
+ // tar, then encrypt; only the encrypted file leaves the temp dir.
202
+ const plain = join(work, 'household.tar');
203
+ run(tar, ['-C', stage, '-cf', plain, '.'], 'tar');
204
+ const out = resolve(opts.to);
205
+ const tmpOut = `${out}.${process.pid}.tmp`;
206
+ run(age, ['-e', ...opts.recipients.flatMap((r) => ['-r', r]), '-o', tmpOut, plain], 'age');
207
+ chmodSync(tmpOut, 0o600);
208
+ renameSync(tmpOut, out);
209
+ return manifest;
210
+ }
211
+ finally {
212
+ rmSync(work, { recursive: true, force: true });
213
+ }
214
+ }
215
+ /** Decrypt and unpack a bundle into `dir` (must be empty). Returns its manifest. */
216
+ function unpack(fileIn, identityIn, dir, env) {
217
+ // Absolute paths: a file named like an option can't be taken for one.
218
+ const file = resolve(fileIn);
219
+ const identity = resolve(identityIn);
220
+ const age = findProgram('age', env);
221
+ const tar = findProgram('tar', env);
222
+ if (!existsSync(identity))
223
+ throw new BundleError(`${identity}: no such identity file`);
224
+ const work = mkdtempSync(join(tmpdir(), 'loom-restore-'));
225
+ chmodSync(work, 0o700);
226
+ try {
227
+ const plain = join(work, 'household.tar');
228
+ run(age, ['-d', '-i', identity, '-o', plain, file], 'age (decrypt: is this the right key?)');
229
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
230
+ // Members are ours (we wrote them), but never let one land outside dir.
231
+ run(tar, ['-C', dir, '--no-same-owner', '-xf', plain], 'tar');
232
+ const m = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf-8'));
233
+ if (m.v !== 1)
234
+ throw new BundleError(`this bundle is format v${String(m.v)}; this loom reads v1`);
235
+ rmSync(join(dir, 'manifest.json'));
236
+ chmodSync(dir, 0o700);
237
+ for (const f of ['household.json', 'tokens.json', 'tokens.used.json'])
238
+ if (existsSync(join(dir, f)))
239
+ chmodSync(join(dir, f), 0o600);
240
+ return m;
241
+ }
242
+ finally {
243
+ rmSync(work, { recursive: true, force: true });
244
+ }
245
+ }
246
+ function emptyOrMissing(dir) {
247
+ return !existsSync(dir) || readdirSync(dir).length === 0;
248
+ }
249
+ /**
250
+ * Lay a bundle down in an empty $LOOM_HOME and hand each piece its part
251
+ * (`loom-<piece> bundle import`, with LOOM_IMPORT_IDENTITY naming the key so the
252
+ * piece can re-encrypt for this machine). A piece that isn't installed here
253
+ * has its part kept in pieces-pending/ for later.
254
+ */
255
+ export async function restoreBundle(opts) {
256
+ const env = opts.env ?? process.env;
257
+ const home = resolve(opts.home);
258
+ if (!emptyOrMissing(home))
259
+ throw new BundleError(`${home} isn't empty; restore into a new LOOM_HOME (--to), never over a live one`);
260
+ const manifest = unpack(opts.file, opts.identity, home, env);
261
+ const h = loadHousehold(home);
262
+ if (!h)
263
+ throw new BundleError('the bundle has no household.json');
264
+ // A bundle made from a plain context dir still holds whole cards: split them now.
265
+ for (const a of h.agents)
266
+ if (existsSync(resolveSqliteDbPath(agentDir(h, a))))
267
+ splitPeopleIntoCommons(h, a, agentDir(h, a));
268
+ const installed = new Map(listPieces(env).map((p) => [p.name, p.path]));
269
+ const pieces = [];
270
+ for (const part of manifest.pieces.filter((p) => p.included)) {
271
+ const blob = join(home, 'pieces', `${part.name}.part`);
272
+ const path = installed.get(part.name);
273
+ const pending = () => {
274
+ mkdirSync(join(home, 'pieces-pending'), { recursive: true, mode: 0o700 });
275
+ renameSync(blob, join(home, 'pieces-pending', `${part.name}.part`));
276
+ };
277
+ if (!path) {
278
+ pending();
279
+ pieces.push({ name: part.name, restored: false, reason: `loom-${part.name} isn't installed here; its part is in pieces-pending/` });
280
+ continue;
281
+ }
282
+ const r = await pieceRun(path, 'import', { ...env, LOOM_IMPORT_IDENTITY: resolve(opts.identity) }, readFileSync(blob));
283
+ if (r.ok) {
284
+ rmSync(blob);
285
+ pieces.push({ name: part.name, restored: true });
286
+ }
287
+ else {
288
+ pending();
289
+ pieces.push({ name: part.name, restored: false, reason: r.reason });
290
+ }
291
+ }
292
+ rmSync(join(home, 'pieces'), { recursive: true, force: true });
293
+ return { home, manifest, pieces };
294
+ }
295
+ // ─── The drill ───────────────────────────────────────────────────────────────
296
+ function sha256File(path) {
297
+ try {
298
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
299
+ }
300
+ catch {
301
+ return null;
302
+ }
303
+ }
304
+ function count(path, sql) {
305
+ if (!existsSync(path))
306
+ return null;
307
+ const db = new BetterSqlite3(path, { readonly: true, fileMustExist: true });
308
+ try {
309
+ return db.prepare(sql).get().n;
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ finally {
315
+ db.close();
316
+ }
317
+ }
318
+ /** What a household is, in terms a restore must reproduce. */
319
+ function fingerprint(h) {
320
+ const fp = {
321
+ agents: h.agents, default: h.default,
322
+ knowledge_pages: count(commonsKnowledgePath(h), 'SELECT COUNT(*) AS n FROM pages'),
323
+ people: count(commonsPeoplePath(h), 'SELECT COUNT(*) AS n FROM people'),
324
+ people_cards: count(commonsPeoplePath(h), "SELECT group_concat(slug || ':' || card_json, '|') AS n FROM (SELECT slug, card_json FROM people ORDER BY slug)"),
325
+ tokens: sha256File(join(h.home, 'tokens.json')),
326
+ };
327
+ for (const a of h.agents) {
328
+ const d = agentDir(h, a);
329
+ fp[`${a}.identity`] = sha256File(join(d, 'IDENTITY.md'));
330
+ fp[`${a}.memories`] = count(join(d, 'memories.db'), 'SELECT COUNT(*) AS n FROM memories WHERE archived = 0');
331
+ fp[`${a}.relationships`] = count(join(d, 'memories.db'), "SELECT group_concat(slug || ':' || card_json, '|') AS n FROM (SELECT slug, card_json FROM people ORDER BY slug)");
332
+ }
333
+ return fp;
334
+ }
335
+ /**
336
+ * Restore a bundle into a throwaway directory and compare it with the live
337
+ * household: agents, identities, memory counts, every relationship and
338
+ * directory card, knowledge, tokens. Pieces aren't imported (that would
339
+ * write their live stores); their parts are only checked for presence. The
340
+ * throwaway directory is always removed.
341
+ */
342
+ export async function drillBundle(opts) {
343
+ const env = opts.env ?? process.env;
344
+ const live = householdOf(opts.contextDir);
345
+ if (!live)
346
+ throw new BundleError('the drill compares against a live household; this context dir isn\'t in one');
347
+ const scratch = mkdtempSync(join(tmpdir(), 'loom-drill-'));
348
+ try {
349
+ const home = join(scratch, 'home');
350
+ const manifest = unpack(opts.file, opts.identity, home, env);
351
+ const restored = loadHousehold(home);
352
+ if (!restored)
353
+ throw new BundleError('the bundle has no household.json');
354
+ const a = fingerprint(live.household);
355
+ const b = fingerprint(restored);
356
+ const differences = Object.keys({ ...a, ...b })
357
+ .filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]))
358
+ .map((k) => ({ what: k, live: typeof a[k] === 'string' && a[k].length > 80 ? '(cards)' : a[k], restored: typeof b[k] === 'string' && b[k].length > 80 ? '(cards)' : b[k] }));
359
+ for (const p of manifest.pieces.filter((x) => x.included)) {
360
+ if (sha256File(join(home, 'pieces', `${p.name}.part`)) !== p.sha256)
361
+ differences.push({ what: `piece ${p.name}`, live: p.sha256, restored: 'missing or altered' });
362
+ }
363
+ return { ok: differences.length === 0, manifest, differences };
364
+ }
365
+ finally {
366
+ rmSync(scratch, { recursive: true, force: true });
367
+ }
368
+ }
369
+ /** How big a file is, for messages. */
370
+ export function sizeOf(path) {
371
+ try {
372
+ return statSync(path).size;
373
+ }
374
+ catch {
375
+ return 0;
376
+ }
377
+ }
@@ -0,0 +1,3 @@
1
+ import type { IOStreams } from './io.js';
2
+ export declare function runExport(rawArgv: string[], io: IOStreams): Promise<number>;
3
+ export declare function runRestore(rawArgv: string[], io: IOStreams): Promise<number>;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `loom export` and `loom restore` (src/bundle.ts; docs/specs/household.md,
3
+ * "Export and restore"). Local only: neither is a remote verb.
4
+ */
5
+ import { householdHome } from '../household.js';
6
+ import { BundleError, drillBundle, exportBundle, restoreBundle, sizeOf } from '../bundle.js';
7
+ import { extractGlobalFlags, resolveEnv } from './args.js';
8
+ const EXPORT_USAGE = `Usage: loom export --to <file> --recipient <age1…> [--recipient …]
9
+
10
+ Write the whole household to one file, encrypted to the owner's key (an age
11
+ public key, or an ssh public key). Inside: the household's files, the commons,
12
+ every agent's directory, and each installed piece's own part, which the piece
13
+ encrypts itself (loom-<piece> bundle export). A plain context dir exports as a
14
+ household of one.
15
+ `;
16
+ const RESTORE_USAGE = `Usage: loom restore <file> --identity <key file> [--to <dir>]
17
+ loom restore <file> --identity <key file> --drill
18
+
19
+ Lay a bundle down in an empty LOOM_HOME (--to, else $LOOM_HOME, else
20
+ ~/.config/loom), then hand each installed piece its part (loom-<piece> bundle import).
21
+ A piece that isn't installed keeps its part in pieces-pending/.
22
+
23
+ --drill restores into a throwaway directory instead and compares it with the
24
+ live household (agents, identities, memories, every card, knowledge, tokens),
25
+ then removes it. Nothing live is touched.
26
+
27
+ A restore trusts what's in the bundle: age encrypts but doesn't prove who made
28
+ the file, so only restore bundles you made.
29
+ `;
30
+ function flags(args, name) {
31
+ const out = [];
32
+ for (let i = 0; i < args.length; i++)
33
+ if (args[i] === name && args[i + 1])
34
+ out.push(args[++i]);
35
+ return out;
36
+ }
37
+ export async function runExport(rawArgv, io) {
38
+ const { flags: global, rest: argv } = extractGlobalFlags(rawArgv);
39
+ if (argv.includes('--help') || argv.includes('-h')) {
40
+ io.stdout(EXPORT_USAGE);
41
+ return 0;
42
+ }
43
+ const to = flags(argv, '--to')[0];
44
+ const recipients = flags(argv, '--recipient');
45
+ if (!to || recipients.length === 0) {
46
+ io.stderr(EXPORT_USAGE);
47
+ return 2;
48
+ }
49
+ const { contextDir } = resolveEnv(global, io.env);
50
+ try {
51
+ const m = await exportBundle({ contextDir, to, recipients, env: io.env });
52
+ const left = m.pieces.filter((p) => !p.included);
53
+ io.stdout(`Bundle written: ${to} (${(sizeOf(to) / 1048576).toFixed(1)} MB), agents ${m.household.agents.join(', ')}.\n`);
54
+ for (const p of m.pieces.filter((x) => x.included))
55
+ io.stdout(` piece ${p.name}: included (${p.bytes} bytes, encrypted by the piece)\n`);
56
+ for (const p of left)
57
+ io.stdout(` piece ${p.name}: not included (${p.reason})\n`);
58
+ return 0;
59
+ }
60
+ catch (err) {
61
+ if (err instanceof BundleError) {
62
+ io.stderr(`${err.message}\n`);
63
+ return 1;
64
+ }
65
+ throw err;
66
+ }
67
+ }
68
+ export async function runRestore(rawArgv, io) {
69
+ const { flags: global, rest: argv } = extractGlobalFlags(rawArgv);
70
+ if (argv.includes('--help') || argv.includes('-h')) {
71
+ io.stdout(RESTORE_USAGE);
72
+ return 0;
73
+ }
74
+ const file = argv.find((a, i) => !a.startsWith('--') && (i === 0 || !argv[i - 1].startsWith('--')));
75
+ const identity = flags(argv, '--identity')[0];
76
+ if (!file || !identity) {
77
+ io.stderr(RESTORE_USAGE);
78
+ return 2;
79
+ }
80
+ try {
81
+ if (argv.includes('--drill')) {
82
+ const { contextDir } = resolveEnv(global, io.env);
83
+ const r = await drillBundle({ file, identity, contextDir, env: io.env });
84
+ if (r.ok) {
85
+ io.stdout(`Drill passed: the bundle from ${r.manifest.created} restores to a household that matches the live one.\n`);
86
+ return 0;
87
+ }
88
+ io.stdout(`Drill found ${r.differences.length} difference${r.differences.length === 1 ? '' : 's'} (the live household may simply have moved on since ${r.manifest.created}):\n`);
89
+ for (const d of r.differences)
90
+ io.stdout(` ${d.what}: live ${JSON.stringify(d.live)}, restored ${JSON.stringify(d.restored)}\n`);
91
+ return 1;
92
+ }
93
+ const home = flags(argv, '--to')[0] ?? householdHome(io.env);
94
+ const r = await restoreBundle({ file, identity, home, env: io.env });
95
+ io.stdout(`Restored the household (${r.manifest.household.agents.join(', ')}, bundle from ${r.manifest.created}) to ${r.home}.\n`);
96
+ for (const p of r.pieces)
97
+ io.stdout(` piece ${p.name}: ${p.restored ? 'restored' : `not restored (${p.reason})`}\n`);
98
+ return 0;
99
+ }
100
+ catch (err) {
101
+ if (err instanceof BundleError) {
102
+ io.stderr(`${err.message}\n`);
103
+ return 1;
104
+ }
105
+ throw err;
106
+ }
107
+ }
package/dist/cli/index.js CHANGED
@@ -34,6 +34,8 @@ Commands:
34
34
  pieces List installed pieces (loom-<name> on PATH)
35
35
  household The household: agents and the default (docs/specs/household.md)
36
36
  token Mint, list and revoke the household's tokens
37
+ export Write the whole household to one encrypted bundle
38
+ restore Lay a bundle down in an empty LOOM_HOME, or --drill it
37
39
 
38
40
  Pieces: \`loom <name> ...\` runs an installed extension, loom-<name> on PATH
39
41
  (e.g. loom tasks list). Core commands always win. See docs/extensions.md.
@@ -208,6 +210,14 @@ async function dispatch(sub, rest, io) {
208
210
  const { runToken } = await import('./household.js');
209
211
  return runToken(rest, io);
210
212
  }
213
+ case 'export': {
214
+ const { runExport } = await import('./bundle.js');
215
+ return runExport(rest, io);
216
+ }
217
+ case 'restore': {
218
+ const { runRestore } = await import('./bundle.js');
219
+ return runRestore(rest, io);
220
+ }
211
221
  default:
212
222
  io.stderr(`Subcommand not implemented yet: ${sub}\n`);
213
223
  return 2;
@@ -3,5 +3,5 @@
3
3
  * (src/cli/index.ts) and the CLI-vs-MCP routing gate (src/index.ts) so
4
4
  * the set never drifts between entry points.
5
5
  */
6
- export declare const SUBCOMMANDS: readonly ['wake', 'recall', 'remember', 'forget', 'update', 'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve', 'inject', 'harness', 'install', 'doctor', 'migrate', 'pieces', 'household', 'token'];
6
+ export declare const SUBCOMMANDS: readonly ['wake', 'recall', 'remember', 'forget', 'update', 'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve', 'inject', 'harness', 'install', 'doctor', 'migrate', 'pieces', 'household', 'token', 'export', 'restore'];
7
7
  export type Subcommand = typeof SUBCOMMANDS[number];
@@ -7,5 +7,5 @@ export const SUBCOMMANDS = [
7
7
  'wake', 'recall', 'remember', 'forget', 'update',
8
8
  'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve',
9
9
  'inject', 'harness',
10
- 'install', 'doctor', 'migrate', 'pieces', 'household', 'token',
10
+ 'install', 'doctor', 'migrate', 'pieces', 'household', 'token', 'export', 'restore',
11
11
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbarket/loomai",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "MCP server for persistent AI agent identity and memory. Single-file sqlite-vec + local ONNX embeddings; zero external services.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",