@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 +21 -1
- package/README.md +35 -0
- package/dist/bundle.d.ts +80 -0
- package/dist/bundle.js +377 -0
- package/dist/cli/bundle.d.ts +3 -0
- package/dist/cli/bundle.js +107 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- package/package.json +1 -1
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.
|
|
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,
|
package/dist/bundle.d.ts
ADDED
|
@@ -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,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];
|
package/dist/cli/subcommands.js
CHANGED
|
@@ -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.
|
|
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",
|