@agentshouse/kit 0.0.1 → 0.1.0-alpha.1

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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1696 -2
  3. package/bin/connect-linux.sh +127 -0
  4. package/bin/kit.js +1023 -0
  5. package/package.json +33 -3
  6. package/src/acp.js +99 -0
  7. package/src/adapter-mcp.js +44 -0
  8. package/src/adapter.js +252 -0
  9. package/src/agent-logins.js +65 -0
  10. package/src/agents.js +449 -0
  11. package/src/apply.js +29 -0
  12. package/src/attach.js +52 -0
  13. package/src/binding.js +90 -0
  14. package/src/blocks.js +156 -0
  15. package/src/bridge.js +449 -0
  16. package/src/bytes.js +89 -0
  17. package/src/channel.js +37 -0
  18. package/src/command.js +109 -0
  19. package/src/configuration.js +465 -0
  20. package/src/connector-login.js +91 -0
  21. package/src/connector.js +107 -0
  22. package/src/continuation.js +61 -0
  23. package/src/contract.js +363 -0
  24. package/src/conversation-originals.js +143 -0
  25. package/src/custom/authorization.js +63 -0
  26. package/src/declaration.js +35 -0
  27. package/src/delivery.js +191 -0
  28. package/src/directory.js +102 -0
  29. package/src/draft.js +89 -0
  30. package/src/google/calendar.js +177 -0
  31. package/src/google/declared.js +146 -0
  32. package/src/google/drive.js +435 -0
  33. package/src/google/gateway.js +166 -0
  34. package/src/google/gmail.js +249 -0
  35. package/src/google/id.js +1 -0
  36. package/src/google/oauth.js +273 -0
  37. package/src/google/source.js +25 -0
  38. package/src/hook.js +376 -0
  39. package/src/host.js +121 -0
  40. package/src/house-main.js +79 -0
  41. package/src/house.js +1085 -0
  42. package/src/inbound.js +175 -0
  43. package/src/instance/google.js +1 -0
  44. package/src/launcher-main.js +19 -0
  45. package/src/launcher.js +276 -0
  46. package/src/local-files.js +113 -0
  47. package/src/login.js +274 -0
  48. package/src/mcp.js +71 -0
  49. package/src/migrator.js +248 -0
  50. package/src/mount.js +582 -0
  51. package/src/operation.js +20 -0
  52. package/src/original.js +631 -0
  53. package/src/peer.js +124 -0
  54. package/src/plan.js +57 -0
  55. package/src/question.js +316 -0
  56. package/src/refusal.js +79 -0
  57. package/src/repository.js +521 -0
  58. package/src/session-bin/house +2 -0
  59. package/src/settlement.js +57 -0
  60. package/src/setup.js +185 -0
  61. package/src/source-originals.js +116 -0
  62. package/src/source.js +286 -0
  63. package/src/starter-set/grilling/LICENSE +21 -0
  64. package/src/starter-set/grilling/SKILL.md +30 -0
  65. package/src/starter-set/grilling/agents/openai.yaml +3 -0
  66. package/src/starter-set.js +17 -0
  67. package/src/state.js +157 -0
  68. package/src/sync-library.js +293 -0
  69. package/src/sync-state.js +116 -0
  70. package/src/sync.js +477 -0
  71. package/src/telegram.js +1045 -0
  72. package/src/terminal.js +57 -0
  73. package/src/tmux.js +119 -0
  74. package/src/worker.js +756 -0
  75. package/src/workspace.js +500 -0
package/src/mount.js ADDED
@@ -0,0 +1,582 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { apply, links, parse, serialize } from "@agentshouse/mdmodel";
5
+ import {
6
+ KIT_DOOR_BATCH_PATH,
7
+ KIT_DOOR_BOOTSTRAP_PATH,
8
+ KIT_DOOR_DISCOVER_PATH,
9
+ KIT_DOOR_UNMOUNT_PATH,
10
+ MIGRATOR_ATTACHMENTS_HOME,
11
+ MIGRATOR_CONTENT_HOME,
12
+ MOUNT_REPORT_NAME,
13
+ } from "./contract.js";
14
+ import { heldControl, houseGetJson, houseRequest, maintainControl, openSession } from "./house.js";
15
+ import { operationId } from "./operation.js";
16
+ import { placeAttachment } from "./original.js";
17
+ import { KitRefusal } from "./refusal.js";
18
+ import {
19
+ OPERATIONS,
20
+ assertAdmitted,
21
+ admitsOperation,
22
+ isProtected,
23
+ protectedPathRefusal,
24
+ operationDeniedRefusal,
25
+ housePath,
26
+ VERSION,
27
+ } from "./sync-library.js";
28
+
29
+ const COLLECTION_HOME = "library";
30
+ const MODEL_HOME = "settings/model";
31
+
32
+ function admitModel(authority, relative) {
33
+ if (isProtected(relative, authority)) throw protectedPathRefusal(authority, relative);
34
+ if (!admitsOperation(relative, authority, "write")) {
35
+ throw operationDeniedRefusal(authority, relative, "write");
36
+ }
37
+ }
38
+
39
+ const PENDING_LINK_SCHEME = "house-attachment";
40
+ const ATTACHMENT_PREFIX = `${MIGRATOR_ATTACHMENTS_HOME}/`;
41
+
42
+ function refuse(code, detail = null) {
43
+ throw new KitRefusal(code, detail);
44
+ }
45
+
46
+ function sessionDependencies(options, dependencies) {
47
+ return {
48
+ fetch: dependencies.fetch ?? options.fetch,
49
+ now: dependencies.now ?? options.now,
50
+ };
51
+ }
52
+
53
+ async function withSession(options, dependencies, operation) {
54
+ const session = await openSession(options, sessionDependencies(options, dependencies));
55
+ return maintainControl(session, (signal) => operation(session, signal), options.signal);
56
+ }
57
+
58
+ async function door(session, path, body, signal) {
59
+ return houseRequest(
60
+ session.house,
61
+ await heldControl(session, signal),
62
+ path,
63
+ body,
64
+ session.dependencies,
65
+ signal,
66
+ );
67
+ }
68
+
69
+ export function mountTarget(subtree) {
70
+ if (typeof subtree !== "string" || !subtree.startsWith("/") || subtree.includes("//")) {
71
+ refuse("kit_mount_subtree_invalid", subtree);
72
+ }
73
+ const segments = subtree.slice(1).split("/");
74
+ const own = segments[0] === "private";
75
+ if (!own && (segments[0] !== "rooms" || segments.length < 2)) {
76
+ refuse("kit_mount_subtree_invalid", subtree);
77
+ }
78
+ const relative = segments.slice(own ? 1 : 2);
79
+ if (
80
+ relative.length < 2 ||
81
+ relative[0] !== COLLECTION_HOME ||
82
+ segments.some((segment) => !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment))
83
+ ) {
84
+ refuse("kit_mount_subtree_invalid", subtree);
85
+ }
86
+ return {
87
+ kind: own ? "private" : "room",
88
+ handle: own ? null : segments[1],
89
+ root: own ? "/private" : `/rooms/${segments[1]}`,
90
+ subtree,
91
+ relative: relative.join("/"),
92
+ };
93
+ }
94
+
95
+ function discoveryOf(value) {
96
+ if (typeof value?.md_model_version !== "string" || !Array.isArray(value.authorities)) {
97
+ refuse("kit_mount_rejected", "discover");
98
+ }
99
+ if (value.md_model_version !== VERSION) {
100
+ refuse("kit_md_model_version_mismatch", value.md_model_version);
101
+ }
102
+ if (!Number.isSafeInteger(value.bounds?.max_batch_operations) || value.bounds.max_batch_operations <= 0) {
103
+ refuse("kit_mount_rejected", "discover.bounds.max_batch_operations");
104
+ }
105
+ return value;
106
+ }
107
+
108
+ function authorityFor(discovery, target) {
109
+ const found = discovery.authorities.find((entry) =>
110
+ target.kind === "private" ? entry.kind === "private" : entry.room_handle === target.handle,
111
+ );
112
+ if (found === undefined) refuse("kit_mount_authority_unknown", target.root);
113
+ if (
114
+ !Array.isArray(found.protected_paths) ||
115
+ found.operations === null ||
116
+ typeof found.operations !== "object" ||
117
+ OPERATIONS.some((operation) => !Array.isArray(found.operations[operation]))
118
+ ) {
119
+ refuse("kit_mount_rejected", "discover");
120
+ }
121
+ return {
122
+ kind: found.kind,
123
+ authority: found.kind === "private" ? "private" : found.room_ref,
124
+ path: target.root,
125
+ protected_paths: found.protected_paths,
126
+ operations: found.operations,
127
+ files: [],
128
+ };
129
+ }
130
+
131
+ function hrefSuffix(href) {
132
+ const match = /[?#]/.exec(href);
133
+ return match === null ? "" : href.slice(match.index);
134
+ }
135
+
136
+ function replaceLink(document, href, destination) {
137
+ const marker = `house-kit-link:${operationId()}`;
138
+ const located = apply(document, { kind: "link", href, to: marker });
139
+ if (located.outcome !== "applied") refuse("kit_mount_link_unwritable", href);
140
+ const cleared = parse(serialize(located.document).replaceAll(`${marker}${hrefSuffix(href)}`, marker));
141
+ if (!cleared.ok) refuse("kit_mount_link_unwritable", href);
142
+ const changed = apply(cleared.document, { kind: "link", href: marker, to: destination });
143
+ if (changed.outcome !== "applied") refuse("kit_mount_link_unwritable", href);
144
+ return changed.document;
145
+ }
146
+
147
+ function sourceHref(source, suffix) {
148
+ const sourceFragment = source.indexOf("#");
149
+ const base = sourceFragment === -1 ? source : source.slice(0, sourceFragment);
150
+ const priorFragment = sourceFragment === -1 ? "" : source.slice(sourceFragment);
151
+ const suffixFragment = suffix.indexOf("#");
152
+ const query = suffixFragment === -1 ? suffix : suffix.slice(0, suffixFragment);
153
+ const fragment = suffixFragment === -1 ? priorFragment : suffix.slice(suffixFragment);
154
+ if (query === "") return base + fragment;
155
+ const separator = base.includes("?") ? (/[?&]$/.test(base) ? "" : "&") : "?";
156
+ return base + separator + query.slice(1) + fragment;
157
+ }
158
+
159
+ export function rewriteAttachmentLinks(held, mintVersion) {
160
+ const content = {};
161
+ const saves = [];
162
+ for (const [relative, text] of Object.entries(held.content).sort()) {
163
+ const file = `${MIGRATOR_CONTENT_HOME}/${relative}`;
164
+ const parsed = parse(text, relative);
165
+ if (!parsed.ok) {
166
+ content[relative] = text;
167
+ continue;
168
+ }
169
+ let document = parsed.document;
170
+ for (const link of links(document, file)) {
171
+ if (!link.target.startsWith(ATTACHMENT_PREFIX)) continue;
172
+ const path = link.target.slice(ATTACHMENT_PREFIX.length);
173
+ const declared = held.manifest.attachments.find(
174
+ (entry) => entry.path === path && entry.download === "downloaded",
175
+ );
176
+ if (declared === undefined) refuse("kit_migrator_attachment_undeclared", link.target);
177
+ if (!declared.referrers.includes(relative)) {
178
+ refuse("kit_migrator_referrer_undeclared", `${relative} -> ${path}`);
179
+ }
180
+ const version = mintVersion(path);
181
+ const applied = apply(document, {
182
+ kind: "link",
183
+ href: link.href,
184
+ to: `${PENDING_LINK_SCHEME}:${version}`,
185
+ });
186
+ if (applied.outcome !== "applied") refuse("kit_mount_link_unwritable", link.href);
187
+ document = applied.document;
188
+ let save = saves.find((entry) => entry.path === path);
189
+ if (save === undefined) {
190
+ save = { path, version, documents: [], hrefs: [] };
191
+ saves.push(save);
192
+ }
193
+ if (!save.documents.includes(relative)) save.documents.push(relative);
194
+ const suffix = hrefSuffix(link.href);
195
+ const href = `${PENDING_LINK_SCHEME}:${version}${suffix}`;
196
+ if (!save.hrefs.some((entry) => entry.document === relative && entry.href === href)) {
197
+ save.hrefs.push({ document: relative, href, suffix });
198
+ }
199
+ }
200
+ content[relative] = serialize(document);
201
+ }
202
+ for (const entry of held.manifest.attachments) {
203
+ if (entry.download !== "downloaded" || saves.some((save) => save.path === entry.path)) continue;
204
+ refuse("kit_migrator_attachment_unlinked", entry.path);
205
+ }
206
+ return { content, saves };
207
+ }
208
+
209
+ export function withinBounds(bounds, target, content) {
210
+ if (bounds === null || typeof bounds !== "object") return;
211
+ const files = Object.keys(content);
212
+ if (typeof bounds.max_mount_files === "number" && files.length > bounds.max_mount_files) {
213
+ refuse("mount_files_exceeded", `${files.length}`);
214
+ }
215
+ let bytes = 0;
216
+ for (const relative of files) {
217
+ const size = Buffer.byteLength(content[relative], "utf8");
218
+ if (typeof bounds.max_authored_file_bytes === "number" && size > bounds.max_authored_file_bytes) {
219
+ refuse("content_too_large", `${target.subtree}/${relative}`);
220
+ }
221
+ bytes += size;
222
+ const path = `${target.subtree}/${relative}`;
223
+ if (typeof bounds.max_path_length === "number" && path.length > bounds.max_path_length) {
224
+ refuse("invalid_authored_path", path);
225
+ }
226
+ if (
227
+ typeof bounds.max_path_depth === "number" &&
228
+ `${target.relative}/${relative}`.split("/").length > bounds.max_path_depth
229
+ ) {
230
+ refuse("invalid_authored_path", path);
231
+ }
232
+ }
233
+ if (typeof bounds.max_mount_bytes === "number" && bytes > bounds.max_mount_bytes) {
234
+ refuse("batch_too_large", `${bytes}`);
235
+ }
236
+ }
237
+
238
+ async function currentFiles(session, prefixes, signal) {
239
+ const answer = await door(
240
+ session,
241
+ KIT_DOOR_BOOTSTRAP_PATH,
242
+ { prefixes },
243
+ signal,
244
+ );
245
+ const bundle =
246
+ typeof answer?.transfer?.url === "string"
247
+ ? await houseGetJson(answer.transfer.url, session.dependencies, signal)
248
+ : answer;
249
+ if (!Array.isArray(bundle?.scopes)) refuse("kit_mount_rejected", "bootstrap");
250
+ const held = new Map();
251
+ for (const scope of bundle.scopes) {
252
+ for (const entry of scope.contents ?? []) {
253
+ if (typeof entry?.path !== "string" || typeof entry.content !== "string") continue;
254
+ held.set(entry.path, entry.content);
255
+ }
256
+ }
257
+ return held;
258
+ }
259
+
260
+ async function prepareModel(session, target, authority, model, held, signal) {
261
+ const prepared = [];
262
+ const changes = [];
263
+ for (const [name, definition] of Object.entries(model).sort()) {
264
+ const relative = `${MODEL_HOME}/${name}`;
265
+ admitModel(authority, relative);
266
+ prepared.push(relative);
267
+ changes.push({ op: "create", path: housePath(target.root, relative), content: definition });
268
+ }
269
+ if (changes.length === 0) return { prepared, adopted: [], written: [] };
270
+ const adopted = [];
271
+ const pending = [];
272
+ for (const change of changes) {
273
+ const relative = change.path.slice(target.root.length + 1);
274
+ const current = held.get(relative);
275
+ if (current === undefined) {
276
+ pending.push(change);
277
+ continue;
278
+ }
279
+ if (current !== change.content) refuse("kit_mount_model_incompatible", change.path);
280
+ adopted.push(relative);
281
+ }
282
+ if (pending.length > 0) {
283
+ await door(
284
+ session,
285
+ KIT_DOOR_BATCH_PATH,
286
+ { operation_id: operationId(), changes: pending },
287
+ signal,
288
+ );
289
+ }
290
+ return { prepared, adopted, written: pending.map((change) => change.path) };
291
+ }
292
+
293
+ function mountedRevisions(outcome, target) {
294
+ if (
295
+ !Array.isArray(outcome?.contents) ||
296
+ typeof outcome.position !== "string" ||
297
+ typeof outcome.log_epoch !== "string"
298
+ ) {
299
+ refuse("kit_mount_rejected", "batch");
300
+ }
301
+ const revisions = new Map();
302
+ const prefix = `${target.subtree}/`;
303
+ for (const entry of outcome.contents) {
304
+ if (typeof entry?.path !== "string" || typeof entry.revision !== "string") continue;
305
+ if (!entry.path.startsWith(prefix)) continue;
306
+ revisions.set(entry.path.slice(prefix.length), entry.revision);
307
+ }
308
+ return revisions;
309
+ }
310
+
311
+ function transferFailure(error) {
312
+ return error instanceof KitRefusal ? error.code : "kit_attachment_transfer_failed";
313
+ }
314
+
315
+ function pendingTransfer(held, save) {
316
+ const declared = held.manifest.attachments.find((entry) => entry.path === save.path);
317
+ return {
318
+ path: save.path,
319
+ documents: save.documents,
320
+ source: declared.source,
321
+ name: declared.name,
322
+ version: save.version,
323
+ downloaded: true,
324
+ attachment: null,
325
+ save: { status: "pending", failure: null },
326
+ };
327
+ }
328
+
329
+ async function submitAttachments(session, held, target, authority, plan, signal) {
330
+ const transfers = [];
331
+ for (const save of plan.saves) {
332
+ const local = held.attachments.get(save.path);
333
+ const declared = held.manifest.attachments.find((entry) => entry.path === save.path);
334
+ const record = pendingTransfer(held, save);
335
+ try {
336
+ const body = await readFile(local.file);
337
+ if (body.length !== local.bytes || createHash("sha256").update(body).digest("hex") !== local.sha256) {
338
+ refuse("kit_migrator_attachment_changed", save.path);
339
+ }
340
+ const placed = await placeAttachment(session, {
341
+ version: save.version,
342
+ name: declared.name,
343
+ mediaType: declared.mediaType ?? undefined,
344
+ room: target.kind === "room" ? authority.authority : null,
345
+ body,
346
+ signal,
347
+ });
348
+ record.attachment = placed.attachment;
349
+ record.save = placed.save;
350
+ } catch (error) {
351
+ record.save = { status: "failed", failure: transferFailure(error) };
352
+ }
353
+ transfers.push(record);
354
+ }
355
+ return transfers;
356
+ }
357
+
358
+ function reportRow(values) {
359
+ return `| ${values.map((value) => String(value ?? "").replace(/\|/g, "&#124;").replace(/[\r\n]/g, " ")).join(" | ")} |`;
360
+ }
361
+
362
+ export function reportDocument(result) {
363
+ const lines = [
364
+ "# Migration report",
365
+ "",
366
+ `Migrator: ${result.migrator}`,
367
+ `Subtree: ${result.subtree}`,
368
+ `Documents mounted: ${result.documents}`,
369
+ `Model definitions prepared: ${result.model.prepared.length}`,
370
+ "",
371
+ "## Attachment transfers",
372
+ "",
373
+ reportRow(["Source", "Referring document", "Local download", "Save", "Attachment", "Version", "Detail"]),
374
+ reportRow(["---", "---", "---", "---", "---", "---", "---"]),
375
+ ];
376
+ for (const transfer of result.transfers) {
377
+ for (const document of transfer.documents) {
378
+ lines.push(
379
+ reportRow([
380
+ transfer.source,
381
+ document,
382
+ "downloaded",
383
+ transfer.save.status,
384
+ transfer.save.status === "saved" ? transfer.attachment : "",
385
+ transfer.version,
386
+ transfer.save.failure ?? "",
387
+ ]),
388
+ );
389
+ }
390
+ }
391
+ for (const missing of result.unavailable) {
392
+ for (const referrer of missing.referrers) {
393
+ lines.push(
394
+ reportRow([
395
+ missing.source,
396
+ referrer,
397
+ missing.download,
398
+ "skipped",
399
+ "",
400
+ "",
401
+ missing.failure ?? "",
402
+ ]),
403
+ );
404
+ }
405
+ }
406
+ lines.push("");
407
+ return `${lines.join("\n")}\n`;
408
+ }
409
+
410
+ export async function mountPackage(held, options, dependencies = {}) {
411
+ const target = mountTarget(options.subtree);
412
+ const minted = new Map();
413
+ const plan = rewriteAttachmentLinks(held, (path) => {
414
+ if (!minted.has(path)) minted.set(path, operationId());
415
+ return minted.get(path);
416
+ });
417
+ return withSession(options, dependencies, async (session, signal) => {
418
+ const discovery = discoveryOf(
419
+ await door(session, KIT_DOOR_DISCOVER_PATH, { prefixes: [target.root] }, signal),
420
+ );
421
+ const authority = authorityFor(discovery, target);
422
+ for (const relative of Object.keys(plan.content)) {
423
+ assertAdmitted(authority, `${target.relative}/${relative}`, "write");
424
+ }
425
+ const reportRelative = `${target.relative}/${MOUNT_REPORT_NAME}`;
426
+ assertAdmitted(authority, reportRelative, "write");
427
+ for (const name of Object.keys(held.model)) {
428
+ admitModel(authority, `${MODEL_HOME}/${name}`);
429
+ }
430
+ const existing = Object.keys(held.model).length > 0
431
+ ? await currentFiles(session, [`${target.root}/${MODEL_HOME}`], signal)
432
+ : new Map();
433
+ const unavailable = held.manifest.attachments
434
+ .filter((entry) => entry.download !== "downloaded")
435
+ .map((entry) => ({ source: entry.source, referrers: entry.referrers,
436
+ download: entry.download, failure: entry.failure }));
437
+ const pending = plan.saves.map((save) => pendingTransfer(held, save));
438
+ const initialReport = reportDocument({ migrator: held.manifest.migrator,
439
+ subtree: target.subtree, documents: Object.keys(plan.content).length,
440
+ model: { prepared: Object.keys(held.model) }, transfers: pending, unavailable });
441
+ const mounting = { ...plan.content, [MOUNT_REPORT_NAME]: initialReport };
442
+ withinBounds(discovery.bounds, target, mounting);
443
+ const model = await prepareModel(session, target, authority, held.model, existing, signal);
444
+ const outcome = await door(
445
+ session,
446
+ KIT_DOOR_BATCH_PATH,
447
+ {
448
+ operation_id: operationId(),
449
+ mount: target.subtree,
450
+ changes: Object.entries(mounting)
451
+ .sort()
452
+ .map(([relative, content]) => ({
453
+ op: "create",
454
+ path: `${target.subtree}/${relative}`,
455
+ content,
456
+ })),
457
+ },
458
+ signal,
459
+ );
460
+ const revisions = mountedRevisions(outcome, target);
461
+ const result = {
462
+ migrator: held.manifest.migrator,
463
+ subtree: target.subtree,
464
+ authority: outcome.authority,
465
+ position: outcome.position,
466
+ log_epoch: outcome.log_epoch,
467
+ documents: Object.keys(plan.content).length,
468
+ model,
469
+ transfers: pending,
470
+ unavailable,
471
+ };
472
+ const localReport = join(held.root, "MIGRATION.json");
473
+ await writeFile(localReport, `${JSON.stringify(result, null, 2)}\n`);
474
+ result.transfers = await submitAttachments(session, held, target, authority, plan, signal);
475
+
476
+ const report = `${target.subtree}/${MOUNT_REPORT_NAME}`;
477
+ await writeFile(localReport, `${JSON.stringify(result, null, 2)}\n`);
478
+ const changes = [];
479
+ for (const [relative, text] of Object.entries(plan.content)) {
480
+ let content = text;
481
+ for (const transfer of result.transfers.filter((entry) => entry.documents.includes(relative))) {
482
+ const save = plan.saves.find((entry) => entry.path === transfer.path);
483
+ for (const { href, suffix } of save.hrefs.filter((entry) => entry.document === relative)) {
484
+ const parsed = parse(content, relative);
485
+ const destination = transfer.save.status === "saved"
486
+ ? `${transfer.attachment}${suffix}`
487
+ : sourceHref(transfer.source, suffix);
488
+ content = serialize(replaceLink(parsed.document, href, destination));
489
+ }
490
+ }
491
+ if (content !== text) changes.push({ op: "replace", path: `${target.subtree}/${relative}`, base: revisions.get(relative), content });
492
+ }
493
+ changes.push({ op: "replace", path: report, base: revisions.get(MOUNT_REPORT_NAME), content: reportDocument(result) });
494
+ try {
495
+ let reported;
496
+ const limit = discovery.bounds.max_batch_operations;
497
+ for (let offset = 0; offset < changes.length; offset += limit) {
498
+ reported = await door(session, KIT_DOOR_BATCH_PATH, { operation_id: operationId(), changes: changes.slice(offset, offset + limit) }, signal);
499
+ }
500
+ return { ...result, report, reportPosition: reported.position, reportLogEpoch: reported.log_epoch, reportSaved: true };
501
+ } catch (error) {
502
+ return { ...result, report, reportSaved: false, reportFailure: transferFailure(error) };
503
+ }
504
+ });
505
+ }
506
+
507
+ export async function unmountSubtree(options, dependencies = {}) {
508
+ const target = mountTarget(options.subtree);
509
+ const confirm = options.confirm;
510
+ if (confirm !== undefined && confirm !== target.subtree) {
511
+ refuse("kit_unmount_confirmation_mismatch", confirm);
512
+ }
513
+ return withSession(options, dependencies, async (session, signal) => {
514
+ const discovery = discoveryOf(
515
+ await door(session, KIT_DOOR_DISCOVER_PATH, { prefixes: [target.root] }, signal),
516
+ );
517
+ const authority = authorityFor(discovery, target);
518
+ assertAdmitted(authority, target.relative, "delete");
519
+ assertAdmitted(authority, target.relative, "write");
520
+ const files = await currentFiles(session, [target.subtree], signal);
521
+ for (const path of files.keys()) {
522
+ if (!path.startsWith(`${target.relative}/`) || path.split("/").includes("_provenance")) continue;
523
+ assertAdmitted(authority, path, "delete");
524
+ assertAdmitted(authority, path, "write");
525
+ }
526
+ if (confirm === undefined) {
527
+ return {
528
+ status: "confirmation_required",
529
+ subtree: target.subtree,
530
+ summary: `Permanently remove every file and revision under ${target.subtree}.`,
531
+ };
532
+ }
533
+ const outcome = await door(
534
+ session,
535
+ KIT_DOOR_UNMOUNT_PATH,
536
+ { operation_id: operationId(), subtree: target.subtree },
537
+ signal,
538
+ );
539
+ if (outcome?.status !== "unmounted") refuse("kit_unmount_rejected", "unmount");
540
+ return {
541
+ status: "unmounted",
542
+ subtree: target.subtree,
543
+ authority: outcome.authority,
544
+ position: outcome.position,
545
+ log_epoch: outcome.log_epoch,
546
+ removed: Array.isArray(outcome.contents) ? outcome.contents.length : 0,
547
+ };
548
+ });
549
+ }
550
+
551
+ export function formatMount(result) {
552
+ const saved = result.transfers.filter((transfer) => transfer.save.status === "saved").length;
553
+ const lines = [
554
+ `Mounted ${result.documents} documents into ${result.subtree} at position ${result.position}.`,
555
+ `Prepared ${result.model.prepared.length} model definitions: ${result.model.written.length} written, ${result.model.adopted.length} already present.`,
556
+ `Downloaded ${result.transfers.length} attachments locally; ${saved} saved as House attachments.`,
557
+ ];
558
+ for (const transfer of result.transfers) {
559
+ if (transfer.save.status === "saved") continue;
560
+ lines.push(
561
+ `Attachment ${transfer.source} referenced by ${transfer.documents.join(", ")}: ${transfer.save.status}` +
562
+ `${transfer.save.failure === null ? "" : ` (${transfer.save.failure})`}.`,
563
+ );
564
+ }
565
+ for (const missing of result.unavailable) {
566
+ lines.push(
567
+ `Unavailable source ${missing.source} referenced by ${missing.referrers.join(", ")}: ${missing.download}` +
568
+ `${missing.failure === null ? "" : ` (${missing.failure})`}.`,
569
+ );
570
+ }
571
+ lines.push(result.reportSaved ? `Report written to ${result.report}.` : `Final report pending at ${result.report}: ${result.reportFailure}. Local outcome: MIGRATION.json in the package.`);
572
+ return lines;
573
+ }
574
+
575
+ export function formatUnmount(result) {
576
+ if (result.status === "confirmation_required") {
577
+ return [result.summary, `Confirm with --confirm ${result.subtree} to remove it.`];
578
+ }
579
+ return [
580
+ `Unmounted ${result.subtree}: removed ${result.removed} files at position ${result.position}.`,
581
+ ];
582
+ }
@@ -0,0 +1,20 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ function versioned(bytes) {
4
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
5
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
6
+ const hex = bytes.toString("hex");
7
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
8
+ }
9
+
10
+ export function operationId() {
11
+ const bytes = randomBytes(16);
12
+ bytes.writeUIntBE(Date.now(), 0, 6);
13
+ return versioned(bytes);
14
+ }
15
+
16
+ export function derivedOperationId(operation, label) {
17
+ const bytes = createHash("sha256").update(`${operation}/${label}`).digest().subarray(0, 16);
18
+ bytes.write(operation.replaceAll("-", "").slice(0, 12), 0, "hex");
19
+ return versioned(bytes);
20
+ }