@silo-ai/silo 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -47,6 +47,12 @@ The first schema mutation creates the database. Inspect the resulting logical sc
47
47
 
48
48
  Run `silo --help` and `silo <group> <command> --help` for the authoritative command syntax and examples. The self-contained [`skills/silo/`](skills/silo/) package includes both agent operating practices and the exact JSON request contracts it references.
49
49
 
50
+ To make the packaged guidance discoverable without installing a separate agent skill, add this rule to your global `AGENTS.md`:
51
+
52
+ > - When told to “use Silo” or do something with Silo, run `silo skill` and follow its instructions. Read any referenced task guide or JSON Schema with `silo skill <relative-path>`.
53
+
54
+ `silo skill` prints the main skill. Its relative links can be read from any directory, for example with `silo skill tasks/create-table.md` or `silo skill schemas/row-write.schema.json`.
55
+
50
56
  ## Import a schema template
51
57
 
52
58
  Import the bundled agent-first task schema into the current repository:
package/dist/bin.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { binary, command, number, option, optional, positional, runSafely, string, subcommands } from "cmd-ts";
2
+ import { binary, command, extendType, flag, number, oneOf, option, optional, positional, runSafely, string, subcommands } from "cmd-ts";
3
3
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { File } from "cmd-ts/dist/cjs/batteries/fs.js";
5
6
  import { DatabaseSync, backup, constants } from "node:sqlite";
6
7
  import { dirname, join } from "node:path";
7
- import { fileURLToPath } from "node:url";
8
8
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
9
9
  import { isIP } from "node:net";
10
10
  import { execFile, execFileSync, spawn } from "node:child_process";
@@ -15,7 +15,7 @@ import { renderToStaticMarkup } from "react-dom/server";
15
15
  import ReactMarkdown from "react-markdown";
16
16
  import remarkGfm from "remark-gfm";
17
17
  import { jsx, jsxs } from "react/jsx-runtime";
18
- import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
18
+ import { DeleteObjectsCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
19
19
  import { promisify } from "node:util";
20
20
  //#region src/model.ts
21
21
  var SiloError = class extends Error {
@@ -1306,6 +1306,10 @@ var SiloDatabase = class SiloDatabase {
1306
1306
  canonical.close();
1307
1307
  }
1308
1308
  }
1309
+ async backupRecovery(path) {
1310
+ this.database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1311
+ await backup(this.database, path);
1312
+ }
1309
1313
  rebasePending(pending, generation, etag, discardTransactionId) {
1310
1314
  if (!this.getSyncState()) throw new SiloError(exits.integrity, "sync_metadata_missing", "Restored sync metadata is missing.");
1311
1315
  try {
@@ -2231,6 +2235,63 @@ var S3SyncRemote = class {
2231
2235
  generationUrl(generation) {
2232
2236
  return `s3://${this.bucket}/${this.prefix}/generations/${generation}`;
2233
2237
  }
2238
+ async listGenerations() {
2239
+ const prefix = `${this.prefix}/generations/`;
2240
+ const generations = /* @__PURE__ */ new Map();
2241
+ let continuationToken;
2242
+ try {
2243
+ do {
2244
+ const response = await this.client.send(new ListObjectsV2Command({
2245
+ Bucket: this.bucket,
2246
+ Prefix: prefix,
2247
+ ContinuationToken: continuationToken
2248
+ }));
2249
+ for (const object of response.Contents ?? []) {
2250
+ const generation = (object.Key?.slice(prefix.length))?.split("/", 1)[0];
2251
+ if (!generation || !object.LastModified) continue;
2252
+ const previous = generations.get(generation);
2253
+ if (!previous || object.LastModified > previous) generations.set(generation, object.LastModified);
2254
+ }
2255
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
2256
+ } while (continuationToken);
2257
+ return [...generations].map(([generation, last_modified]) => ({
2258
+ generation,
2259
+ last_modified
2260
+ }));
2261
+ } catch (error) {
2262
+ throw new SiloError(exits.io, "sync_generations_list_failed", error instanceof Error ? error.message : String(error));
2263
+ }
2264
+ }
2265
+ async deleteGeneration(generation) {
2266
+ const prefix = `${this.prefix}/generations/${generation}/`;
2267
+ const objects = [];
2268
+ let continuationToken;
2269
+ try {
2270
+ do {
2271
+ const response = await this.client.send(new ListObjectsV2Command({
2272
+ Bucket: this.bucket,
2273
+ Prefix: prefix,
2274
+ ContinuationToken: continuationToken
2275
+ }));
2276
+ objects.push(...(response.Contents ?? []).flatMap((object) => object.Key ? [{ Key: object.Key }] : []));
2277
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
2278
+ } while (continuationToken);
2279
+ for (let offset = 0; offset < objects.length; offset += 1e3) {
2280
+ const batch = objects.slice(offset, offset + 1e3);
2281
+ const result = await this.client.send(new DeleteObjectsCommand({
2282
+ Bucket: this.bucket,
2283
+ Delete: {
2284
+ Objects: batch,
2285
+ Quiet: true
2286
+ }
2287
+ }));
2288
+ if (result.Errors?.length) throw new Error(result.Errors.map((item) => `${item.Key ?? "unknown key"}: ${item.Message ?? item.Code ?? "delete failed"}`).join("; "));
2289
+ }
2290
+ return objects.length;
2291
+ } catch (error) {
2292
+ throw new SiloError(exits.io, "sync_generation_delete_failed", `Generation ${generation}: ${error instanceof Error ? error.message : String(error)}`);
2293
+ }
2294
+ }
2234
2295
  };
2235
2296
  var LitestreamCheckpoint = class {
2236
2297
  executable;
@@ -2384,7 +2445,7 @@ var SiloSync = class {
2384
2445
  const database = SiloDatabase.open(this.workspace, true, true);
2385
2446
  try {
2386
2447
  const state = database.getSyncState();
2387
- if (head && !state) throw new SiloError(exits.revision, "sync_local_diverged", "A remote database already exists; initialize from a workspace without local state.");
2448
+ if (head && !state) throw new SiloError(exits.revision, "sync_local_diverged", `A local database and remote generation ${head.manifest.generation} both exist. Choose an explicit sync recovery workflow.`);
2388
2449
  const configured = database.configureSync(remoteUrl, head?.manifest.database_id);
2389
2450
  if (head) validateHead(this.workspace, head, configured.database_id);
2390
2451
  } finally {
@@ -2394,6 +2455,88 @@ var SiloSync = class {
2394
2455
  return this.status();
2395
2456
  });
2396
2457
  }
2458
+ async adoptRemote(remoteUrl, confirmedGeneration) {
2459
+ return withSyncLock(this.workspace, async () => {
2460
+ await this.services.checkpoint.check();
2461
+ if (!existsSync(this.workspace.databasePath)) throw new SiloError(exits.absent, "sync_local_absent", "A local database is required to use the adopt-remote recovery workflow.");
2462
+ const local = SiloDatabase.open(this.workspace, true, true);
2463
+ try {
2464
+ if (local.getSyncState()) throw new SiloError(exits.workspace, "sync_already_configured", "The local database is already configured for synchronization.");
2465
+ } finally {
2466
+ local.close();
2467
+ }
2468
+ const remote = this.services.remote(remoteUrl);
2469
+ const head = await remote.readHead();
2470
+ if (!head) throw new SiloError(exits.absent, "sync_remote_absent", "Remote HEAD does not exist.");
2471
+ validateHead(this.workspace, head);
2472
+ this.confirmRecovery(head, confirmedGeneration);
2473
+ const restored = temporaryPath(this.workspace, "adopt");
2474
+ const preserved = `${this.workspace.databasePath}.recovery-local-${randomUUID()}.sqlite`;
2475
+ try {
2476
+ await this.restoreAndVerify(remote, head, restored);
2477
+ const original = SiloDatabase.open(this.workspace, true, true);
2478
+ try {
2479
+ await original.backupRecovery(preserved);
2480
+ } finally {
2481
+ original.close();
2482
+ }
2483
+ installDatabase(restored, this.workspace.databasePath);
2484
+ const adopted = SiloDatabase.open(this.workspace, true, true);
2485
+ try {
2486
+ adopted.markSynchronized(head.manifest.generation, head.etag);
2487
+ } finally {
2488
+ adopted.close();
2489
+ }
2490
+ } finally {
2491
+ removeDatabase(restored);
2492
+ }
2493
+ return {
2494
+ status: await this.status(),
2495
+ preserved
2496
+ };
2497
+ });
2498
+ }
2499
+ async replaceRemote(remoteUrl, confirmedGeneration) {
2500
+ return withSyncLock(this.workspace, async () => {
2501
+ await this.services.checkpoint.check();
2502
+ if (!existsSync(this.workspace.databasePath)) throw new SiloError(exits.absent, "sync_local_absent", "A local database is required to use the replace-remote recovery workflow.");
2503
+ const remote = this.services.remote(remoteUrl);
2504
+ const head = await remote.readHead();
2505
+ if (!head) throw new SiloError(exits.absent, "sync_remote_absent", "Remote HEAD does not exist.");
2506
+ validateHead(this.workspace, head);
2507
+ this.confirmRecovery(head, confirmedGeneration);
2508
+ const candidate = temporaryPath(this.workspace, "replace");
2509
+ const local = SiloDatabase.open(this.workspace, true, true);
2510
+ try {
2511
+ if (local.getSyncState()) throw new SiloError(exits.workspace, "sync_already_configured", "The local database is already configured for synchronization.");
2512
+ await local.backupRecovery(candidate);
2513
+ } finally {
2514
+ local.close();
2515
+ }
2516
+ try {
2517
+ const configured = SiloDatabase.open({
2518
+ ...this.workspace,
2519
+ databasePath: candidate
2520
+ }, true, true);
2521
+ try {
2522
+ configured.configureSync(remoteUrl, head.manifest.database_id);
2523
+ } finally {
2524
+ configured.close();
2525
+ }
2526
+ await this.publishDatabase(candidate, remote, head);
2527
+ installDatabase(candidate, this.workspace.databasePath);
2528
+ } finally {
2529
+ removeDatabase(candidate);
2530
+ }
2531
+ return {
2532
+ status: await this.status(),
2533
+ preserved: remote.generationUrl(head.manifest.generation)
2534
+ };
2535
+ });
2536
+ }
2537
+ confirmRecovery(head, confirmedGeneration) {
2538
+ if (confirmedGeneration !== head.manifest.generation) throw new SiloError(exits.revision, "sync_recovery_confirmation_mismatch", `Confirmation must equal current remote generation ${head.manifest.generation}.`);
2539
+ }
2397
2540
  async status() {
2398
2541
  if (!existsSync(this.workspace.databasePath)) return {
2399
2542
  state: "unconfigured",
@@ -2501,7 +2644,7 @@ var SiloSync = class {
2501
2644
  async push() {
2502
2645
  return withSyncLock(this.workspace, async () => {
2503
2646
  await this.services.checkpoint.check();
2504
- let database = SiloDatabase.open(this.workspace, true, true);
2647
+ const database = SiloDatabase.open(this.workspace, true, true);
2505
2648
  let state;
2506
2649
  let pendingCount;
2507
2650
  try {
@@ -2527,64 +2670,111 @@ var SiloSync = class {
2527
2670
  head = await remote.readHead();
2528
2671
  if (head) validateHead(this.workspace, head, state.database_id);
2529
2672
  }
2530
- database = SiloDatabase.open(this.workspace, true, true);
2531
- const publicationId = randomUUID();
2532
- const generation = randomUUID();
2533
- const candidate = temporaryPath(this.workspace, "publish");
2534
- const verified = temporaryPath(this.workspace, "verify");
2673
+ await this.publishDatabase(this.workspace.databasePath, remote, head);
2674
+ return this.status();
2675
+ });
2676
+ }
2677
+ async prune(olderThanDays = 7, apply = false, now = /* @__PURE__ */ new Date()) {
2678
+ return withSyncLock(this.workspace, async () => {
2679
+ const database = SiloDatabase.open(this.workspace);
2680
+ let state;
2535
2681
  try {
2536
- await database.backupCanonical(candidate, generation);
2537
- const manifest = {
2538
- format_version: 1,
2539
- database_id: state.database_id,
2540
- identity: this.workspace.identity,
2541
- generation,
2542
- publication_id: publicationId,
2543
- parent_generation: head?.manifest.generation ?? null,
2544
- schema_revision: database.getSchema().revision,
2545
- database_sha256: hashDatabase(candidate),
2546
- created_at: (/* @__PURE__ */ new Date()).toISOString()
2547
- };
2548
- database.close();
2549
- await this.services.checkpoint.publish(candidate, remote.generationUrl(generation));
2550
- await this.services.checkpoint.restore(remote.generationUrl(generation), verified);
2551
- if (hashDatabase(verified) !== manifest.database_sha256) throw new SiloError(exits.integrity, "sync_checkpoint_hash_mismatch", "The restored checkpoint does not match the published database.");
2552
- const verification = SiloDatabase.open({
2553
- ...this.workspace,
2554
- databasePath: verified
2555
- }, false, true);
2556
- try {
2557
- if (verification.getSchema().revision !== manifest.schema_revision) throw new SiloError(exits.integrity, "sync_checkpoint_schema_mismatch", "The published checkpoint schema does not match its manifest.");
2558
- const integrity = verification.query("PRAGMA integrity_check");
2559
- if (integrity.rows.length !== 1 || integrity.rows[0]?.[0] !== "ok") throw new SiloError(exits.integrity, "integrity_check_failed", "The published checkpoint failed SQLite integrity checking.");
2560
- } finally {
2561
- verification.close();
2562
- }
2563
- let etag;
2564
- try {
2565
- etag = await remote.publishHead(manifest, head?.etag ?? null);
2566
- } catch (error) {
2567
- if (!(error instanceof SiloError) || error.code !== "sync_head_changed") throw error;
2568
- const current = await remote.readHead();
2569
- if (!current || current.manifest.publication_id !== publicationId) throw error;
2570
- etag = current.etag;
2571
- }
2572
- const updated = SiloDatabase.open(this.workspace, true, true);
2573
- try {
2574
- updated.markSynchronized(generation, etag);
2575
- } finally {
2576
- updated.close();
2577
- }
2682
+ state = database.getSyncState();
2578
2683
  } finally {
2579
- try {
2580
- database.close();
2581
- } catch {}
2582
- removeDatabase(candidate);
2583
- removeDatabase(verified);
2684
+ database.close();
2584
2685
  }
2585
- return this.status();
2686
+ if (!state) throw new SiloError(exits.workspace, "sync_not_configured", "Synchronization is not configured.");
2687
+ const remote = this.services.remote(state.remote_url);
2688
+ const head = await remote.readHead();
2689
+ if (!head) throw new SiloError(exits.absent, "sync_remote_absent", "Remote HEAD does not exist.");
2690
+ validateHead(this.workspace, head, state.database_id);
2691
+ const cutoff = /* @__PURE__ */ new Date(now.getTime() - olderThanDays * 864e5);
2692
+ const generations = await remote.listGenerations();
2693
+ const eligible = generations.filter((item) => item.generation !== head.manifest.generation && item.last_modified.getTime() <= cutoff.getTime()).map((item) => item.generation).sort();
2694
+ const deleted = [];
2695
+ if (apply && eligible.length) for (const generation of eligible) {
2696
+ const current = await remote.readHead();
2697
+ if (!current || current.etag !== head.etag) throw new SiloError(exits.revision, "sync_head_changed", deleted.length ? `Remote HEAD changed during cleanup after ${deleted.length} generation(s) were deleted; remaining generations were preserved.` : "Remote HEAD changed during cleanup; no generations were deleted.");
2698
+ validateHead(this.workspace, current, state.database_id);
2699
+ if (generation === current.manifest.generation) continue;
2700
+ await remote.deleteGeneration(generation);
2701
+ deleted.push(generation);
2702
+ }
2703
+ return {
2704
+ remote_url: state.remote_url,
2705
+ current_generation: head.manifest.generation,
2706
+ cutoff: cutoff.toISOString(),
2707
+ scanned_generations: generations.length,
2708
+ eligible_generations: eligible,
2709
+ deleted_generations: deleted,
2710
+ dry_run: !apply
2711
+ };
2586
2712
  });
2587
2713
  }
2714
+ async publishDatabase(databasePath, remote, head) {
2715
+ let database = SiloDatabase.open({
2716
+ ...this.workspace,
2717
+ databasePath
2718
+ }, true, true);
2719
+ const state = database.getSyncState();
2720
+ const publicationId = randomUUID();
2721
+ const generation = randomUUID();
2722
+ const candidate = temporaryPath(this.workspace, "publish");
2723
+ const verified = temporaryPath(this.workspace, "verify");
2724
+ try {
2725
+ await database.backupCanonical(candidate, generation);
2726
+ const manifest = {
2727
+ format_version: 1,
2728
+ database_id: state.database_id,
2729
+ identity: this.workspace.identity,
2730
+ generation,
2731
+ publication_id: publicationId,
2732
+ parent_generation: head?.manifest.generation ?? null,
2733
+ schema_revision: database.getSchema().revision,
2734
+ database_sha256: hashDatabase(candidate),
2735
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
2736
+ };
2737
+ database.close();
2738
+ await this.services.checkpoint.publish(candidate, remote.generationUrl(generation));
2739
+ await this.services.checkpoint.restore(remote.generationUrl(generation), verified);
2740
+ if (hashDatabase(verified) !== manifest.database_sha256) throw new SiloError(exits.integrity, "sync_checkpoint_hash_mismatch", "The restored checkpoint does not match the published database.");
2741
+ const verification = SiloDatabase.open({
2742
+ ...this.workspace,
2743
+ databasePath: verified
2744
+ }, false, true);
2745
+ try {
2746
+ if (verification.getSchema().revision !== manifest.schema_revision) throw new SiloError(exits.integrity, "sync_checkpoint_schema_mismatch", "The published checkpoint schema does not match its manifest.");
2747
+ const integrity = verification.query("PRAGMA integrity_check");
2748
+ if (integrity.rows.length !== 1 || integrity.rows[0]?.[0] !== "ok") throw new SiloError(exits.integrity, "integrity_check_failed", "The published checkpoint failed SQLite integrity checking.");
2749
+ } finally {
2750
+ verification.close();
2751
+ }
2752
+ let etag;
2753
+ try {
2754
+ etag = await remote.publishHead(manifest, head?.etag ?? null);
2755
+ } catch (error) {
2756
+ if (!(error instanceof SiloError) || error.code !== "sync_head_changed") throw error;
2757
+ const current = await remote.readHead();
2758
+ if (!current || current.manifest.publication_id !== publicationId) throw error;
2759
+ etag = current.etag;
2760
+ }
2761
+ const updated = SiloDatabase.open({
2762
+ ...this.workspace,
2763
+ databasePath
2764
+ }, true, true);
2765
+ try {
2766
+ updated.markSynchronized(generation, etag);
2767
+ } finally {
2768
+ updated.close();
2769
+ }
2770
+ } finally {
2771
+ try {
2772
+ database.close();
2773
+ } catch {}
2774
+ removeDatabase(candidate);
2775
+ removeDatabase(verified);
2776
+ }
2777
+ }
2588
2778
  async restoreAndVerify(remote, head, outputPath) {
2589
2779
  await this.services.checkpoint.restore(remote.generationUrl(head.manifest.generation), outputPath);
2590
2780
  if (hashDatabase(outputPath) !== head.manifest.database_sha256) throw new SiloError(exits.integrity, "sync_checkpoint_hash_mismatch", "The restored checkpoint does not match remote HEAD.");
@@ -2605,6 +2795,23 @@ var SiloSync = class {
2605
2795
  };
2606
2796
  //#endregion
2607
2797
  //#region src/cli.ts
2798
+ const skillResources = [
2799
+ "SKILL.md",
2800
+ "tasks/alter-table.md",
2801
+ "tasks/create-report.md",
2802
+ "tasks/create-table.md",
2803
+ "tasks/query-with-sql.md",
2804
+ "tasks/synchronize.md",
2805
+ "tasks/update-with-revision.md",
2806
+ "tasks/upsert-rows.md",
2807
+ "schemas/report-put.schema.json",
2808
+ "schemas/row-write.schema.json",
2809
+ "schemas/table-alter.schema.json",
2810
+ "schemas/table-create.schema.json"
2811
+ ];
2812
+ function readSkillResource(resource = "SKILL.md") {
2813
+ return readFileSync(fileURLToPath(new URL(`../skills/silo/${resource}`, import.meta.url)), "utf8");
2814
+ }
2608
2815
  const inputFile = option({
2609
2816
  type: optional(File),
2610
2817
  long: "file",
@@ -2753,6 +2960,16 @@ const status = command({
2753
2960
  ])));
2754
2961
  })
2755
2962
  });
2963
+ const skill = command({
2964
+ name: "skill",
2965
+ description: "Print the packaged Silo agent skill or one of its referenced resources.",
2966
+ args: { resource: positional({
2967
+ type: optional(oneOf(skillResources)),
2968
+ displayName: "relative-path",
2969
+ description: "A task or schema path referenced by SKILL.md."
2970
+ }) },
2971
+ handler: ({ resource }) => output(readSkillResource(resource))
2972
+ });
2756
2973
  const databaseList = command({
2757
2974
  name: "list",
2758
2975
  description: "Discover Silo databases in the application-data catalog.",
@@ -3223,6 +3440,36 @@ const syncInit = command({
3223
3440
  output(renderSyncStatus(await new SiloSync(resolveWorkspace()).initialize(remote)));
3224
3441
  })
3225
3442
  });
3443
+ function renderSyncRecovery(result) {
3444
+ return `${renderSyncStatus(result.status)}\n\n${heading("Preserved losing copy", `\`${result.preserved}\``)}`;
3445
+ }
3446
+ const recoveryArgs = {
3447
+ remote: positional({
3448
+ type: string,
3449
+ displayName: "s3-url"
3450
+ }),
3451
+ confirm: option({
3452
+ type: string,
3453
+ long: "confirm",
3454
+ description: "Confirm the exact current remote generation that this recovery will resolve."
3455
+ })
3456
+ };
3457
+ const syncAdoptRemote = command({
3458
+ name: "adopt-remote",
3459
+ description: "Replace an unconfigured local database after preserving it as a recovery snapshot.",
3460
+ args: recoveryArgs,
3461
+ handler: withErrors(async ({ remote, confirm }) => {
3462
+ output(renderSyncRecovery(await new SiloSync(resolveWorkspace()).adoptRemote(remote, confirm)));
3463
+ })
3464
+ });
3465
+ const syncReplaceRemote = command({
3466
+ name: "replace-remote",
3467
+ description: "Publish an unconfigured local database over a confirmed remote generation.",
3468
+ args: recoveryArgs,
3469
+ handler: withErrors(async ({ remote, confirm }) => {
3470
+ output(renderSyncRecovery(await new SiloSync(resolveWorkspace()).replaceRemote(remote, confirm)));
3471
+ })
3472
+ });
3226
3473
  const syncStatus = command({
3227
3474
  name: "status",
3228
3475
  description: "Compare local synchronization state with remote HEAD.",
@@ -3250,6 +3497,24 @@ const pull = command({
3250
3497
  output(renderSyncStatus(await new SiloSync(resolveWorkspace()).pull()));
3251
3498
  })
3252
3499
  });
3500
+ const push = command({
3501
+ name: "push",
3502
+ description: "Publish a merged immutable checkpoint and conditionally advance remote HEAD.",
3503
+ args: {},
3504
+ handler: withErrors(async () => {
3505
+ output(renderSyncStatus(await new SiloSync(resolveWorkspace()).push()));
3506
+ })
3507
+ });
3508
+ function renderPruneResult(result) {
3509
+ return heading(result.dry_run ? "Synchronization Cleanup Preview" : "Synchronization Cleanup", table(["Property", "Value"], [
3510
+ ["Remote", result.remote_url],
3511
+ ["Current generation", result.current_generation],
3512
+ ["Cutoff", result.cutoff],
3513
+ ["Scanned generations", result.scanned_generations],
3514
+ ["Eligible generations", result.eligible_generations.length],
3515
+ ["Deleted generations", result.deleted_generations.length]
3516
+ ]) + (result.eligible_generations.length ? `\n\n${heading("Eligible Generation IDs", result.eligible_generations.map((id) => `- \`${id}\``).join("\n"))}` : ""));
3517
+ }
3253
3518
  //#endregion
3254
3519
  //#region src/bin.ts
3255
3520
  const result = await runSafely(binary(subcommands({
@@ -3257,21 +3522,43 @@ const result = await runSafely(binary(subcommands({
3257
3522
  version: "0.1.0",
3258
3523
  cmds: {
3259
3524
  status,
3260
- push: command({
3261
- name: "push",
3262
- description: "Publish a merged immutable checkpoint and conditionally advance remote HEAD.",
3263
- args: {},
3264
- handler: withErrors(async () => {
3265
- output(renderSyncStatus(await new SiloSync(resolveWorkspace()).push()));
3266
- })
3267
- }),
3525
+ skill,
3526
+ push,
3268
3527
  pull,
3269
3528
  sync: subcommands({
3270
3529
  name: "sync",
3271
3530
  cmds: {
3272
3531
  init: syncInit,
3532
+ "adopt-remote": syncAdoptRemote,
3533
+ "replace-remote": syncReplaceRemote,
3273
3534
  status: syncStatus,
3274
- discard: syncDiscard
3535
+ discard: syncDiscard,
3536
+ prune: command({
3537
+ name: "prune",
3538
+ description: "Preview or delete remote generations older than a grace period.",
3539
+ args: {
3540
+ olderThan: option({
3541
+ type: extendType(number, {
3542
+ displayName: "days",
3543
+ async from(value) {
3544
+ if (!Number.isFinite(value) || value <= 0) throw new Error("Expected a positive number of days.");
3545
+ return value;
3546
+ }
3547
+ }),
3548
+ long: "older-than",
3549
+ description: "Only consider generations at least this many days old.",
3550
+ defaultValue: () => 7
3551
+ }),
3552
+ apply: flag({
3553
+ long: "apply",
3554
+ description: "Delete eligible generations after revalidating remote HEAD.",
3555
+ defaultValue: () => false
3556
+ })
3557
+ },
3558
+ handler: withErrors(async ({ olderThan, apply }) => {
3559
+ output(renderPruneResult(await new SiloSync(resolveWorkspace()).prune(olderThan, apply)));
3560
+ })
3561
+ })
3275
3562
  }
3276
3563
  }),
3277
3564
  database: subcommands({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silo-ai/silo",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "license": "MIT OR Apache-2.0",
5
5
  "author": "Alec Larson",
6
6
  "repository": {
@@ -13,6 +13,18 @@ silo sync status
13
13
 
14
14
  An existing local database with an empty remote becomes `ahead`; push it to establish remote `HEAD`. An absent local database restores an existing remote. Do not attempt to combine an existing unconfigured local database with an existing remote.
15
15
 
16
+ When both sides exist, initialization reports the current remote generation and stops. Inspect the local database, deliberately choose one authority, and confirm that exact remote generation:
17
+
18
+ ```sh
19
+ # Preserve local as a recovery snapshot, then install remote.
20
+ silo sync adopt-remote s3://my-bucket/silo/project --confirm <remote-generation>
21
+
22
+ # Preserve the old immutable remote generation, then publish local.
23
+ silo sync replace-remote s3://my-bucket/silo/project --confirm <remote-generation>
24
+ ```
25
+
26
+ Both commands report the losing copy's location. A changed confirmation is a new decision point, not permission to retry blindly.
27
+
16
28
  For normal shared work:
17
29
 
18
30
  ```sh
@@ -31,4 +43,11 @@ Discard rebuilds from remote and replays the remaining pending transactions; it
31
43
 
32
44
  Schema commands require `clean` status and no pending data transaction. Pull first, make one schema mutation, and push it before further work. Concurrent schema changes do not merge; discard the losing schema transaction, adopt the remote winner, and reapply a compatible change deliberately.
33
45
 
34
- Synchronization is explicit. There is no background replication, Git-style history, automatic conflict winner, or automatic remote generation cleanup.
46
+ Synchronization is explicit. There is no background replication, Git-style history, automatic conflict winner, or background remote generation cleanup. Operators can preview generations older than the default seven-day grace period, then deliberately apply the reviewed cleanup:
47
+
48
+ ```sh
49
+ silo sync prune
50
+ silo sync prune --apply
51
+ ```
52
+
53
+ Use `--older-than <days>` for a longer retention boundary. Never bypass the preview or remove the generation named by remote `HEAD` directly.