@jbarket/loomai 0.10.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.
@@ -313,7 +313,7 @@ function canonical(v) {
313
313
  return JSON.stringify(v);
314
314
  }
315
315
  // ─── Store ───────────────────────────────────────────────────────────────────
316
- const PEOPLE_MIGRATION = 'people_cards';
316
+ const PEOPLE_MIGRATIONS = ['people_cards', 'person_revisions_actor'];
317
317
  function hasTable(db, table) {
318
318
  return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
319
319
  }
@@ -339,19 +339,23 @@ function parseRow(raw) {
339
339
  export class PeopleStore {
340
340
  db;
341
341
  now;
342
+ actor;
342
343
  /** False on a read-only store whose tables do not exist yet. */
343
344
  migrated;
344
345
  constructor(config) {
345
346
  this.now = config.now ?? Date.now;
347
+ this.actor = config.actor ?? null;
346
348
  this.db = new BetterSqlite3(config.dbPath, config.readonly ? { readonly: true } : {});
347
349
  this.db.pragma(`busy_timeout = ${config.busyTimeoutMs ?? 5000}`);
348
350
  if (!config.readonly) {
349
351
  this.db.pragma('journal_mode = WAL');
350
352
  // Only this feature's migration: the people tables do not depend on the
351
353
  // memories schema, and a raw store must not half-run the others.
352
- const m = MIGRATIONS.find((x) => x.id === PEOPLE_MIGRATION);
353
- if (m.pending(this.db))
354
- m.run(this.db);
354
+ for (const id of PEOPLE_MIGRATIONS) {
355
+ const m = MIGRATIONS.find((x) => x.id === id);
356
+ if (m.pending(this.db))
357
+ m.run(this.db);
358
+ }
355
359
  }
356
360
  this.migrated = hasTable(this.db, 'people') && hasTable(this.db, 'person_revisions');
357
361
  }
@@ -368,8 +372,8 @@ export class PeopleStore {
368
372
  const snap = { ...prev, card: JSON.parse(prev.card_json) };
369
373
  delete snap.card_json;
370
374
  const id = Number(this.db
371
- .prepare('INSERT INTO person_revisions (slug, snapshot_json, op, replaced_at) VALUES (?, ?, ?, ?)')
372
- .run(prev.slug, JSON.stringify(snap), op, at).lastInsertRowid);
375
+ .prepare('INSERT INTO person_revisions (slug, snapshot_json, op, replaced_at, actor) VALUES (?, ?, ?, ?, ?)')
376
+ .run(prev.slug, JSON.stringify(snap), op, at, this.actor).lastInsertRowid);
373
377
  this.db
374
378
  .prepare(`DELETE FROM person_revisions WHERE slug = ? AND id NOT IN
375
379
  (SELECT id FROM person_revisions WHERE slug = ? ORDER BY id DESC LIMIT ?)`)
@@ -681,10 +685,41 @@ export class PeopleStore {
681
685
  fail(`slug: ${slugErr}`);
682
686
  if (!this.migrated)
683
687
  return [];
688
+ const hasActor = this.db.pragma('table_info(person_revisions)').some((c) => c.name === 'actor');
684
689
  return this.db
685
- .prepare('SELECT id, op, replaced_at FROM person_revisions WHERE slug = ? ORDER BY id DESC')
690
+ .prepare(`SELECT id, op, replaced_at${hasActor ? ', actor' : ''} FROM person_revisions WHERE slug = ? ORDER BY id DESC`)
686
691
  .all(slug);
687
692
  }
693
+ /**
694
+ * Put a card in place as is, keeping the row's timestamps and archive state
695
+ * (a migration, not an edit). Recorded as a 'split' revision when a row is
696
+ * replaced.
697
+ */
698
+ replaceCard(slug, card, keep) {
699
+ validateCard(card, null);
700
+ this.mutate(() => {
701
+ const existing = this.readRow(slug);
702
+ if (existing)
703
+ this.snapshot(existing, 'split', this.nowIso());
704
+ this.store({ slug, name: card.name, card_json: JSON.stringify(card), ...keep });
705
+ });
706
+ }
707
+ /** The raw stored rows (timestamps and archive state included), for a migration. */
708
+ rows() {
709
+ if (!this.migrated)
710
+ return [];
711
+ return this.db.prepare('SELECT slug, name, card_json, archived_at, archived_by, archive_reason, updated, synthesized_at FROM people ORDER BY slug').all()
712
+ .map((r) => ({ slug: r.slug, card: JSON.parse(r.card_json), updated: r.updated, synthesized_at: r.synthesized_at, archived_at: r.archived_at, archived_by: r.archived_by, archive_reason: r.archive_reason }));
713
+ }
714
+ /** This database's `user` memories about a person, newer than `since` (all of them when null). */
715
+ memoriesSince(slug, since) {
716
+ if (!hasTable(this.db, 'memories'))
717
+ return 0;
718
+ const nowIso = new Date(this.now()).toISOString();
719
+ return this.db.prepare(`SELECT COUNT(*) AS n FROM memories
720
+ WHERE category = 'user' AND archived = 0 AND json_extract(metadata, '$.person') = ?
721
+ AND (expires_at IS NULL OR expires_at > ?) AND (? IS NULL OR created > ?)`).get(slug, nowIso, since, since).n;
722
+ }
688
723
  /** A revision's snapshot (the whole previous row), for tests and inspection. */
689
724
  revisionSnapshot(id) {
690
725
  const rev = this.db.prepare('SELECT snapshot_json FROM person_revisions WHERE id = ?').get(id);
@@ -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
+ }