@jbarket/loomai 0.11.0 → 0.13.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,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.13.0] - 2026-10-24
16
+
17
+ ### Added
18
+
19
+ - **The household page** (docs/specs/household.md, phase 4). In a
20
+ household, the page is the owner's view of all of it:
21
+ - **Home:** every agent (what it's doing, from its tape), the tape across
22
+ all of them, and the commons.
23
+ - **Knowledge:** the commons' pages, by domain.
24
+ - **People:** the directory, with every agent's relationship to each
25
+ person, and who changed the shared entry.
26
+ - **`/a/<agent>`:** that agent's view, plus **Setup**: its identity
27
+ (read-only), its tokens (make one, shown once; revoke) and health.
28
+ - **Add an agent:** creates its stack and its first token.
29
+
30
+ A daemon now re-reads household.json as it changes, so a new agent is
31
+ served at once. The old single-agent addresses (`/mind`, `/memory/…`)
32
+ land on the default agent's view. A plain context dir keeps the
33
+ single-agent page.
34
+
35
+ ## [0.12.0] - 2026-10-24
36
+
37
+ ### Added
38
+
39
+ - **The bundle: the household on a USB stick** (docs/specs/household.md,
40
+ phase 3).
41
+ - **`loom export --to <file> --recipient <age1…>`** writes the whole
42
+ household into one file, encrypted with age to the owner's key: its
43
+ files and tokens, the commons, every agent's directory (SQLite through
44
+ the backup API), and each piece's own part (`loom-<piece> bundle export`,
45
+ encrypted by the piece).
46
+ - **`loom restore <file> --identity <key>`** lays it down in an empty
47
+ LOOM_HOME and hands each piece its part (`bundle import`). A piece that isn't
48
+ installed keeps its part in `pieces-pending/`.
49
+ - **`--drill`** restores into a throwaway directory and compares it with
50
+ the live household, touching nothing live.
51
+ - **A plain context dir** exports as a household of one, and restores as
52
+ one with its cards split.
53
+
15
54
  ## [0.11.0] - 2026-10-24
16
55
 
17
56
  ### Changed
@@ -732,7 +771,9 @@ Initial public release.
732
771
  stack and all added external-service dependencies or operational
733
772
  overhead.
734
773
 
735
- [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.11.0...HEAD
774
+ [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.13.0...HEAD
775
+ [0.13.0]: https://github.com/sleepunit-agents/loom/compare/v0.12.0...v0.13.0
776
+ [0.12.0]: https://github.com/sleepunit-agents/loom/compare/v0.11.0...v0.12.0
736
777
  [0.11.0]: https://github.com/sleepunit-agents/loom/compare/v0.10.0...v0.11.0
737
778
  [0.10.0]: https://github.com/sleepunit-agents/loom/compare/v0.9.2...v0.10.0
738
779
  [0.9.2]: https://github.com/sleepunit-agents/loom/compare/v0.9.1...v0.9.2
package/README.md CHANGED
@@ -402,6 +402,19 @@ replace, never read back), the chat bridges. Nothing to configure: install a
402
402
  piece and it shows up, uninstall it and it's gone. loom never names a piece
403
403
  ([`docs/extensions.md`](docs/extensions.md), "The page").
404
404
 
405
+ **In a household** ([`docs/specs/household.md`](docs/specs/household.md))
406
+ the page is the owner's view of all of it:
407
+ - **Home** shows every agent (what it's doing, from its tape), the tape
408
+ across all of them, and the commons at a glance.
409
+ - **Knowledge** is the commons' pages.
410
+ - **People** is the directory, and each person's page shows every agent's
411
+ relationship with them.
412
+ - **`/a/<agent>`** is one agent's own view (its tape, Mind and people) plus
413
+ **Setup**: its identity (read-only), its tokens (make one, shown once;
414
+ revoke), and health.
415
+ - **Add an agent** creates its stack and hands over its first token. It's
416
+ served at once, without a restart.
417
+
405
418
  **The page needs `LOOM_BEARER_TOKEN`.** A daemon with no token serves none
406
419
  (403), the same rule as `/cli`, because any site that DNS-rebinds a name to
407
420
  the loopback address could read an open page. Log in once with the token. The
@@ -677,6 +690,41 @@ dir is `<home>/agents/<name>` next to a `household.json` serves the whole
677
690
  household. A process with `LOOM_HOME` set and no context dir uses
678
691
  `LOOM_AGENT`, or the household's default.
679
692
 
693
+ ### The bundle — the household on a USB stick
694
+
695
+ ```bash
696
+ loom export --to household.bundle --recipient age1… # your key, never the box's
697
+ loom restore household.bundle --identity ~/owner.key --drill # does it really come back?
698
+ loom restore household.bundle --identity ~/owner.key --to ~/.config/loom # on a new machine
699
+ ```
700
+
701
+ **`loom export`** writes the whole household into one file, encrypted with
702
+ [age](https://age-encryption.org) to a recipient the owner holds (a YubiKey,
703
+ or a key kept in a password manager). The file contains:
704
+ - the household's files and tokens;
705
+ - the commons;
706
+ - every agent's directory. Databases go through SQLite's backup API, never
707
+ as a raw copy of a live file. Old `*.bak` files and `backup-*` folders
708
+ are left out.
709
+ - each installed piece's own part (`loom-<piece> bundle export`), which the piece
710
+ encrypts itself to the same recipient. Core never sees inside it.
711
+
712
+ A plain context dir exports as a household of one.
713
+
714
+ **`loom restore`** lays a bundle down in an empty `LOOM_HOME` (never over a
715
+ live one). It then hands each installed piece its part (`loom-<piece>
716
+ import`, with `LOOM_IMPORT_IDENTITY` naming the key so the piece can
717
+ re-encrypt for the new machine). A piece that isn't installed yet keeps its
718
+ part in `pieces-pending/`.
719
+
720
+ **`--drill`** restores into a throwaway directory instead and compares the
721
+ result with the live household: identities, memory counts, every card,
722
+ knowledge, tokens. Nothing live is touched.
723
+
724
+ A restore trusts what's in the bundle. age encrypts, but it doesn't prove who
725
+ made the file, so only restore bundles you made. Needs the `age` and `tar`
726
+ programs.
727
+
680
728
  ### Harness manifests and self-describe
681
729
 
682
730
  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>;