@alfe.ai/openclaw-sync 0.2.2 → 0.2.4

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.
@@ -90,6 +90,20 @@ async function updateManifestEntry(workspacePath, relativePath, entry) {
90
90
  await writeManifest(workspacePath, manifest);
91
91
  }
92
92
  /**
93
+ * Update many file entries in one read-modify-write pass.
94
+ *
95
+ * `updateManifestEntry` re-reads and rewrites the whole manifest JSON per
96
+ * call — fine for single-file watcher events, quadratic for bulk heals, and
97
+ * lossy when calls overlap. Use this for any batch (e.g. the recovery
98
+ * "identical → heal" pass). Optionally stamps the owning agentId.
99
+ */
100
+ async function updateManifestEntries(workspacePath, entries, options = {}) {
101
+ const manifest = await readManifest(workspacePath);
102
+ Object.assign(manifest.files, entries);
103
+ if (options.agentId) manifest.agentId = options.agentId;
104
+ await writeManifest(workspacePath, manifest);
105
+ }
106
+ /**
93
107
  * Remove a file entry from the local manifest.
94
108
  */
95
109
  async function removeManifestEntry(workspacePath, relativePath) {
@@ -264,6 +278,12 @@ async function withRetry(fn, options = {}) {
264
278
  * Each step retries with exponential backoff via `withRetry`.
265
279
  */
266
280
  const log$2 = (0, _auriclabs_logger.createLogger)("SyncUploader");
281
+ /**
282
+ * Prefixes stored as GLACIER_IR on the sync bucket. Shared with the recovery
283
+ * classifier in sync-engine.ts, which must never cut conflict sidecars for
284
+ * archive objects (their presigned GETs fail until restored, so hashing/
285
+ * preserving them is wasted work on a pull that can't succeed anyway).
286
+ */
267
287
  const ARCHIVE_PREFIXES = ["sessions/archive/", "context/archive/"];
268
288
  function getStorageClass(relativePath) {
269
289
  return ARCHIVE_PREFIXES.some((p) => relativePath.startsWith(p)) ? "GLACIER_IR" : "STANDARD";
@@ -303,17 +323,17 @@ async function uploadOne(workspacePath, relativePath, client) {
303
323
  size,
304
324
  storageClass
305
325
  });
306
- await updateManifestEntry(workspacePath, relativePath, {
307
- hash,
308
- size,
309
- lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
310
- storageClass
311
- });
312
326
  return {
313
327
  path: relativePath,
314
328
  success: true,
315
329
  hash,
316
- size
330
+ size,
331
+ entry: {
332
+ hash,
333
+ size,
334
+ lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
335
+ storageClass
336
+ }
317
337
  };
318
338
  });
319
339
  } catch (err) {
@@ -333,11 +353,20 @@ async function uploadFiles(workspacePath, relativePaths, client, options = {}) {
333
353
  for (let i = 0; i < relativePaths.length; i += concurrency) {
334
354
  const batch = relativePaths.slice(i, i + concurrency);
335
355
  const batchResults = await Promise.all(batch.map((path) => uploadOne(workspacePath, path, client)));
356
+ const entries = {};
336
357
  for (const result of batchResults) {
337
- results.push(result);
358
+ if (result.entry !== void 0) entries[result.path] = result.entry;
359
+ results.push({
360
+ path: result.path,
361
+ success: result.success,
362
+ hash: result.hash,
363
+ size: result.size,
364
+ error: result.error
365
+ });
338
366
  if (!quiet) if (result.success) log$2.info(`Uploaded ${result.path} (${formatBytes$1(result.size ?? 0)})`);
339
367
  else log$2.error(`Failed to upload ${result.path}: ${result.error ?? "Unknown error"}`);
340
368
  }
369
+ if (Object.keys(entries).length > 0) await updateManifestEntries(workspacePath, entries);
341
370
  }
342
371
  return results;
343
372
  }
@@ -372,16 +401,17 @@ async function downloadOne(workspacePath, relativePath, client, remoteEntry) {
372
401
  const absolutePath = (0, node_path.join)(workspacePath, relativePath);
373
402
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(absolutePath), { recursive: true });
374
403
  await (0, node_fs_promises.writeFile)(absolutePath, buffer);
375
- await updateManifestEntry(workspacePath, relativePath, {
404
+ const entry = {
376
405
  hash: remoteEntry?.hash ?? "",
377
406
  size: buffer.length,
378
407
  lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
379
408
  storageClass: remoteEntry?.storageClass ?? "STANDARD"
380
- });
409
+ };
381
410
  return {
382
411
  path: relativePath,
383
412
  success: true,
384
- size: buffer.length
413
+ size: buffer.length,
414
+ entry
385
415
  };
386
416
  });
387
417
  } catch (err) {
@@ -398,11 +428,19 @@ async function downloadFiles(workspacePath, relativePaths, client, remoteManifes
398
428
  for (let i = 0; i < relativePaths.length; i += concurrency) {
399
429
  const batch = relativePaths.slice(i, i + concurrency);
400
430
  const batchResults = await Promise.all(batch.map((path) => downloadOne(workspacePath, path, client, remoteManifest?.files[path])));
431
+ const entries = {};
401
432
  for (const result of batchResults) {
402
- results.push(result);
433
+ if (result.entry !== void 0) entries[result.path] = result.entry;
434
+ results.push({
435
+ path: result.path,
436
+ success: result.success,
437
+ size: result.size,
438
+ error: result.error
439
+ });
403
440
  if (!quiet) if (result.success) log$1.info(`Downloaded ${result.path} (${formatBytes(result.size ?? 0)})`);
404
441
  else log$1.error(`Failed to download ${result.path}: ${result.error ?? "Unknown error"}`);
405
442
  }
443
+ if (Object.keys(entries).length > 0) await updateManifestEntries(workspacePath, entries);
406
444
  }
407
445
  return results;
408
446
  }
@@ -426,12 +464,45 @@ function formatBytes(bytes) {
426
464
  */
427
465
  const log = (0, _auriclabs_logger.createLogger)("SyncEngine");
428
466
  /**
467
+ * Above this size a diverged local file is NOT copied to a `.conflict-*`
468
+ * sidecar during recovery (the cloud copy still replaces it). Sidecars
469
+ * double disk usage per file; a multi-GB session archive could fill the VM.
470
+ * The un-preserved overwrite is never silent — it is logged and listed in
471
+ * the RECOVERY report.
472
+ */
473
+ const MAX_PRESERVE_BYTES = 100 * 1024 * 1024;
474
+ /** Marker that keeps the heartbeat recovery nudge idempotent across runs. */
475
+ const RECOVERY_NUDGE_MARKER = "<!-- alfe-sync:recovery-nudge -->";
476
+ /**
477
+ * Recovery artifacts: `.conflict-<ts>` sidecars and `RECOVERY-<ts>.md`
478
+ * reports. Anchored to the timestamp shape so legitimate user files like
479
+ * `my.conflict-notes.txt` or `RECOVERY-plan.md` are never misclassified.
480
+ *
481
+ * They seed UP to the cloud so a preserved local version survives even if
482
+ * the VM dies before the agent merges it (retrievable via the dashboard
483
+ * file manager), but they are never AUTO-restored back down and never
484
+ * preserved again — restoring would resurrect copies the agent already
485
+ * cleaned up, and re-preserving would cascade `.conflict`-of-`.conflict`
486
+ * files. Cloud copies are reclaimed when the agent's local cleanup
487
+ * propagates through the watcher; artifacts orphaned by a rebuild before
488
+ * cleanup linger until then (accepted: small, user-visible, deletable).
489
+ * The watcher's delete brake also exempts them so the agent's post-merge
490
+ * cleanup of many sidecars can't trip it.
491
+ *
492
+ * Deliberately NOT in the ignore set: ignored paths don't seed up and would
493
+ * be eligible for pruneIgnored deletion.
494
+ */
495
+ function isRecoveryArtifact(relativePath) {
496
+ const name = (0, node_path.basename)(relativePath);
497
+ return /\.conflict-\d{4}-\d{2}-\d{2}T/.test(name) || /^RECOVERY-\d{4}-\d{2}-\d{2}T.*\.md$/.test(name);
498
+ }
499
+ /**
429
500
  * Construct a sync engine bound to a workspace + agent API client.
430
501
  *
431
502
  * The client is constructed once in plugin.ts (or the CLI) and passed in,
432
503
  * so credentials never leak into multiple places.
433
504
  */
434
- function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
505
+ function createSyncEngine({ workspacePath, client, runtime = "openclaw", maxPreserveBytes = MAX_PRESERVE_BYTES }) {
435
506
  return {
436
507
  workspacePath,
437
508
  client,
@@ -524,15 +595,11 @@ function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
524
595
  const trueConflicts = diff.conflicts.filter((p) => localChanges.includes(p));
525
596
  const remoteOnlyChanges = diff.conflicts.filter((p) => !localChanges.includes(p));
526
597
  let conflictCount = 0;
598
+ const conflictTimestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
527
599
  for (const conflictPath of trueConflicts) {
528
- const absolutePath = (0, node_path.join)(workspacePath, conflictPath);
529
- if (!(0, node_fs.existsSync)(absolutePath)) continue;
530
- const ext = (0, node_path.extname)(conflictPath);
531
- const base = (0, node_path.basename)(conflictPath, ext);
532
- const dir = (0, node_path.dirname)(conflictPath);
533
- const conflictName = `${base}.conflict-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}${ext}`;
534
- await (0, node_fs_promises.writeFile)((0, node_path.join)(workspacePath, dir, conflictName), await (0, node_fs_promises.readFile)(absolutePath));
535
- if (!quiet) log.warn(`Conflict: ${conflictPath} — saved as ${conflictName}`);
600
+ const outcome = await preserveConflictCopy(workspacePath, conflictPath, conflictTimestamp);
601
+ if (outcome.status === "vanished") continue;
602
+ if (outcome.status !== "too-large" && !quiet) log.warn(`Conflict: ${conflictPath} — saved as ${outcome.conflictPath}`);
536
603
  conflictCount++;
537
604
  }
538
605
  const filesToPush = filterIgnored([...diff.toPush, ...localChanges.filter((p) => !diff.conflicts.includes(p))], ignorePatterns);
@@ -576,25 +643,104 @@ function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
576
643
  const remotePaths = Object.keys(remoteManifest.files);
577
644
  if (remotePaths.length === 0) {
578
645
  if (!quiet) log.info("First-run sync: no cloud state — seeding workspace to cloud");
579
- return this.push(void 0, options);
646
+ const seedResult = await this.push(void 0, options);
647
+ await updateManifestEntries(workspacePath, {}, { agentId: remoteManifest.agentId });
648
+ return seedResult;
580
649
  }
581
650
  if (!quiet) log.info(`First-run sync: ${String(remotePaths.length)} file(s) in cloud — restoring cloud state before seeding`);
582
651
  const ignorePatterns = await loadIgnorePatterns(workspacePath, runtime);
583
- const restorePaths = filterIgnored(remotePaths, ignorePatterns);
584
- const restore = await this.pullFiles(restorePaths, options);
652
+ const restorePaths = filterIgnored(remotePaths, ignorePatterns).filter((p) => !isRecoveryArtifact(p));
653
+ const localManifest = await readManifest(workspacePath);
654
+ const manifestTrusted = localManifest.agentId === void 0 || localManifest.agentId === remoteManifest.agentId;
655
+ if (!manifestTrusted) {
656
+ log.warn(`Local sync manifest belongs to agent ${localManifest.agentId ?? "?"} but cloud state is agent ${remoteManifest.agentId} — treating the manifest as lost`);
657
+ await writeManifest(workspacePath, {
658
+ files: {},
659
+ agentId: remoteManifest.agentId
660
+ });
661
+ }
662
+ const classes = await classifyRestorePaths(workspacePath, restorePaths, remoteManifest.files, localManifest, manifestTrusted);
663
+ for (const p of classes.offlineDeletes) if (!quiet) log.info(`Recovery: ${p} was deleted locally while sync was down — not restoring it`);
664
+ if (classes.offlineDeletes.length > 0) log.info(`Recovery: ${String(classes.offlineDeletes.length)} locally-deleted file(s) not restored from cloud`);
665
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
666
+ const preserved = [];
667
+ let newlyPreserved = 0;
668
+ for (const { path, diskHash, size } of classes.preserve) {
669
+ const outcome = await preserveConflictCopy(workspacePath, path, timestamp, {
670
+ maxBytes: maxPreserveBytes,
671
+ sourceHash: diskHash
672
+ });
673
+ if (outcome.status === "vanished") continue;
674
+ const record = {
675
+ path,
676
+ localHash: diskHash,
677
+ remoteHash: remoteManifest.files[path].hash,
678
+ size
679
+ };
680
+ if (outcome.status === "too-large") {
681
+ record.tooLarge = true;
682
+ newlyPreserved++;
683
+ log.warn(`Recovery: ${path} diverged locally but exceeds the sidecar size cap — cloud version will replace it WITHOUT a preserved copy`);
684
+ } else {
685
+ record.conflictPath = outcome.conflictPath;
686
+ if (outcome.status === "preserved") newlyPreserved++;
687
+ log.warn(`Recovery: preserved local ${path} as ${outcome.conflictPath} — cloud version restored in place`);
688
+ }
689
+ preserved.push(record);
690
+ }
691
+ let recoveryReportPath = null;
692
+ if (preserved.length > 0 && (newlyPreserved > 0 || !await recoveryReportExists(workspacePath))) {
693
+ recoveryReportPath = `${agentDirPrefix(workspacePath)}RECOVERY-${timestamp}.md`;
694
+ const reportAbsolute = (0, node_path.join)(workspacePath, recoveryReportPath);
695
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(reportAbsolute), { recursive: true });
696
+ await (0, node_fs_promises.writeFile)(reportAbsolute, buildRecoveryReport(preserved, workspacePath), "utf-8");
697
+ log.warn(`Recovery: ${String(preserved.length)} diverged local file(s) — see ${recoveryReportPath}`);
698
+ }
699
+ const pullList = [
700
+ ...classes.missing,
701
+ ...classes.pullClean,
702
+ ...classes.preserve.map((p) => p.path)
703
+ ];
704
+ const restore = {
705
+ pulled: 0,
706
+ errors: 0
707
+ };
708
+ if (pullList.length > 0) {
709
+ if (!quiet) log.info(`Pulling ${String(pullList.length)} file(s)`);
710
+ const results = await downloadFiles(workspacePath, pullList, client, remoteManifest, options);
711
+ restore.pulled = results.filter((r) => r.success).length;
712
+ restore.errors = results.filter((r) => !r.success).length;
713
+ }
714
+ if (Object.keys(classes.heal).length > 0 || localManifest.agentId !== remoteManifest.agentId) await updateManifestEntries(workspacePath, classes.heal, { agentId: remoteManifest.agentId });
585
715
  const remoteSet = new Set(remotePaths);
586
- const toSeed = (await detectLocalChanges(workspacePath, ignorePatterns)).filter((p) => !remoteSet.has(p));
587
- const seed = toSeed.length > 0 ? await this.push(toSeed, options) : {
716
+ const pushList = [...(await detectLocalChanges(workspacePath, ignorePatterns)).filter((p) => !remoteSet.has(p)), ...classes.localAhead];
717
+ const seed = pushList.length > 0 ? await this.push(pushList, options) : {
588
718
  pushed: 0,
589
719
  pulled: 0,
590
720
  conflicts: 0,
591
721
  errors: 0
592
722
  };
723
+ let nudge = {
724
+ pushed: 0,
725
+ errors: 0
726
+ };
727
+ if (preserved.length > 0 && recoveryReportPath !== null && runtime === "openclaw") try {
728
+ const nudgePath = await appendRecoveryNudge(workspacePath, recoveryReportPath, preserved.length);
729
+ if (nudgePath !== null) {
730
+ const pushResult = await this.push([nudgePath], options);
731
+ nudge = {
732
+ pushed: pushResult.pushed,
733
+ errors: pushResult.errors
734
+ };
735
+ }
736
+ } catch (err) {
737
+ log.warn(`Recovery: failed to write heartbeat nudge: ${err instanceof Error ? err.message : String(err)}`);
738
+ }
593
739
  return {
594
- pushed: seed.pushed,
740
+ pushed: seed.pushed + nudge.pushed,
595
741
  pulled: restore.pulled,
596
- conflicts: restore.conflicts + seed.conflicts,
597
- errors: restore.errors + seed.errors
742
+ conflicts: preserved.length + seed.conflicts,
743
+ errors: restore.errors + seed.errors + nudge.errors
598
744
  };
599
745
  },
600
746
  async pullFiles(paths, options = {}) {
@@ -697,6 +843,190 @@ async function detectLocalChanges(workspacePath, ignorePatterns) {
697
843
  await walk(workspacePath);
698
844
  return changed;
699
845
  }
846
+ /**
847
+ * Relative prefix of the agent's actual working directory. The sync root is
848
+ * the runtime HOME (`~/.openclaw`), but the agent lives and works in
849
+ * `~/.openclaw/workspace/` — recovery artifacts meant for the agent's eyes
850
+ * (RECOVERY report, heartbeat nudge) must land there or it never sees them.
851
+ * Falls back to the sync root when no `workspace/` dir exists (tests, CLI
852
+ * usage against a bare directory).
853
+ */
854
+ function agentDirPrefix(workspacePath) {
855
+ return (0, node_fs.existsSync)((0, node_path.join)(workspacePath, "workspace")) ? "workspace/" : "";
856
+ }
857
+ /** Whether an unresolved RECOVERY-*.md report already sits in the agent dir. */
858
+ async function recoveryReportExists(workspacePath) {
859
+ return (await (0, node_fs_promises.readdir)((0, node_path.join)(workspacePath, agentDirPrefix(workspacePath))).catch(() => [])).some((name) => name.startsWith("RECOVERY-") && name.endsWith(".md"));
860
+ }
861
+ /**
862
+ * Copy a file aside as `<base>.conflict-<ts><ext>` in the same directory
863
+ * before a remote copy overwrites it. Uses `copyFile` (no buffering — safe
864
+ * for large/binary files).
865
+ *
866
+ * Idempotent: if a sibling sidecar with identical content already exists
867
+ * (crash-loop retry, repeated recovery), no new copy is stacked. Pass
868
+ * `sourceHash` when the caller already hashed the file.
869
+ */
870
+ async function preserveConflictCopy(workspacePath, relativePath, timestamp, options = {}) {
871
+ const absolutePath = (0, node_path.join)(workspacePath, relativePath);
872
+ const ext = (0, node_path.extname)(relativePath);
873
+ const base = (0, node_path.basename)(relativePath, ext);
874
+ const dir = (0, node_path.dirname)(relativePath);
875
+ try {
876
+ const fileStat = await (0, node_fs_promises.stat)(absolutePath);
877
+ if (options.maxBytes !== void 0 && fileStat.size > options.maxBytes) return {
878
+ status: "too-large",
879
+ size: fileStat.size
880
+ };
881
+ const sourceHash = options.sourceHash ?? await computeFileHash(absolutePath);
882
+ const siblings = await (0, node_fs_promises.readdir)((0, node_path.join)(workspacePath, dir)).catch(() => []);
883
+ for (const name of siblings) {
884
+ if (!name.startsWith(`${base}.conflict-`)) continue;
885
+ if (ext !== "" && !name.endsWith(ext)) continue;
886
+ if (await computeFileHash((0, node_path.join)(workspacePath, dir, name)).catch(() => null) === sourceHash) return {
887
+ status: "already-preserved",
888
+ conflictPath: (0, node_path.join)(dir, name).replace(/\\/g, "/")
889
+ };
890
+ }
891
+ const conflictName = `${base}.conflict-${timestamp}${ext}`;
892
+ await (0, node_fs_promises.copyFile)(absolutePath, (0, node_path.join)(workspacePath, dir, conflictName));
893
+ return {
894
+ status: "preserved",
895
+ conflictPath: (0, node_path.join)(dir, conflictName).replace(/\\/g, "/")
896
+ };
897
+ } catch {
898
+ return { status: "vanished" };
899
+ }
900
+ }
901
+ const CLASSIFY_CONCURRENCY = 8;
902
+ /**
903
+ * Classify every cloud-held path for recovery. The destructive decision
904
+ * (overwrite local content) is only ever taken when the DISK hash diverges
905
+ * from the cloud AND the local manifest can't vouch for the disk content —
906
+ * and even then the local bytes are preserved first. The manifest and mtime
907
+ * are used solely to avoid work and to detect local-ahead edits, never to
908
+ * authorize an unpreserved overwrite.
909
+ */
910
+ async function classifyRestorePaths(workspacePath, restorePaths, remoteFiles, localManifest, manifestTrusted) {
911
+ const out = {
912
+ missing: [],
913
+ offlineDeletes: [],
914
+ heal: {},
915
+ pullClean: [],
916
+ localAhead: [],
917
+ preserve: []
918
+ };
919
+ async function classifyOne(path) {
920
+ const remote = remoteFiles[path];
921
+ const entry = manifestTrusted ? localManifest.files[path] : void 0;
922
+ const absolutePath = (0, node_path.join)(workspacePath, path);
923
+ let fileStat = null;
924
+ try {
925
+ fileStat = await (0, node_fs_promises.stat)(absolutePath);
926
+ } catch {}
927
+ if (!fileStat?.isFile()) {
928
+ if (entry?.hash === remote.hash) out.offlineDeletes.push(path);
929
+ else out.missing.push(path);
930
+ return;
931
+ }
932
+ const unchangedSinceSync = fileStat.size === entry?.size && fileStat.mtimeMs <= Date.parse(entry.lastSynced);
933
+ const isArchive = ARCHIVE_PREFIXES.some((p) => path.startsWith(p));
934
+ if (unchangedSinceSync || isArchive) {
935
+ if (entry?.hash === remote.hash) return;
936
+ out.pullClean.push(path);
937
+ return;
938
+ }
939
+ let diskHash;
940
+ try {
941
+ diskHash = await computeFileHash(absolutePath);
942
+ } catch {
943
+ out.missing.push(path);
944
+ return;
945
+ }
946
+ if (diskHash === remote.hash) {
947
+ if (entry?.hash !== remote.hash) out.heal[path] = {
948
+ hash: remote.hash,
949
+ size: fileStat.size,
950
+ lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
951
+ storageClass: remote.storageClass ?? "STANDARD"
952
+ };
953
+ return;
954
+ }
955
+ if (entry?.hash === diskHash) {
956
+ out.pullClean.push(path);
957
+ return;
958
+ }
959
+ if (entry?.hash === remote.hash) {
960
+ out.localAhead.push(path);
961
+ return;
962
+ }
963
+ out.preserve.push({
964
+ path,
965
+ diskHash,
966
+ size: fileStat.size
967
+ });
968
+ }
969
+ for (let i = 0; i < restorePaths.length; i += CLASSIFY_CONCURRENCY) await Promise.all(restorePaths.slice(i, i + CLASSIFY_CONCURRENCY).map(classifyOne));
970
+ return out;
971
+ }
972
+ function buildRecoveryReport(records, workspacePath) {
973
+ const lines = [
974
+ `# Workspace recovery report`,
975
+ "",
976
+ `A sync recovery restored this agent's cloud-saved workspace. The files`,
977
+ `below had local content that differed from the cloud copy. The cloud`,
978
+ `version now lives at the original path; the pre-recovery local version`,
979
+ `was preserved next to it as a \`.conflict-*\` sidecar.`,
980
+ "",
981
+ `**What to do:** open each sidecar, merge anything worth keeping into the`,
982
+ `live file, then delete the sidecar. Delete this report when done.`,
983
+ "",
984
+ `Paths are relative to the sync root: \`${workspacePath}\``,
985
+ ""
986
+ ];
987
+ const capped = records.filter((r) => r.tooLarge === true);
988
+ const kept = records.filter((r) => r.tooLarge !== true);
989
+ if (kept.length > 0) {
990
+ lines.push(`| File | Preserved copy | Local hash | Cloud hash | Size (bytes) |`);
991
+ lines.push(`| --- | --- | --- | --- | --- |`);
992
+ for (const r of kept) lines.push(`| \`${r.path}\` | \`${r.conflictPath ?? ""}\` | \`${r.localHash}\` | \`${r.remoteHash}\` | ${String(r.size)} |`);
993
+ lines.push("");
994
+ }
995
+ if (capped.length > 0) {
996
+ lines.push(`The following diverged files exceeded the sidecar size cap and were`, `replaced by the cloud version WITHOUT a preserved copy:`, "");
997
+ for (const r of capped) lines.push(`- \`${r.path}\` (${String(r.size)} bytes, local hash \`${r.localHash}\`)`);
998
+ lines.push("");
999
+ }
1000
+ return lines.join("\n");
1001
+ }
1002
+ /**
1003
+ * Append the recovery nudge to the agent's HEARTBEAT.md (OpenClaw reads it
1004
+ * from the agent workspace dir on its periodic heartbeat). Returns the
1005
+ * sync-relative path when a nudge was appended, or null when a prior nudge
1006
+ * is still pending (idempotency marker present).
1007
+ */
1008
+ async function appendRecoveryNudge(workspacePath, recoveryReportPath, preservedCount) {
1009
+ const prefix = agentDirPrefix(workspacePath);
1010
+ const heartbeatPath = `${prefix}HEARTBEAT.md`;
1011
+ const absolutePath = (0, node_path.join)(workspacePath, heartbeatPath);
1012
+ if (((0, node_fs.existsSync)(absolutePath) ? await (0, node_fs_promises.readFile)(absolutePath, "utf-8") : "").includes(RECOVERY_NUDGE_MARKER)) return null;
1013
+ const reportForAgent = recoveryReportPath.startsWith(prefix) ? recoveryReportPath.slice(prefix.length) : recoveryReportPath;
1014
+ const block = [
1015
+ "",
1016
+ RECOVERY_NUDGE_MARKER,
1017
+ "## Workspace recovery needs your attention",
1018
+ "",
1019
+ `A sync recovery preserved ${String(preservedCount)} pre-recovery local file version(s)`,
1020
+ `as \`.conflict-*\` sidecars. Read every \`RECOVERY-*.md\` report in this directory`,
1021
+ `(latest: \`${reportForAgent}\`), merge anything worth keeping into the live files,`,
1022
+ `then delete the sidecars, the reports, and this section (including its marker`,
1023
+ `comment).`,
1024
+ ""
1025
+ ].join("\n");
1026
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(absolutePath), { recursive: true });
1027
+ await (0, node_fs_promises.appendFile)(absolutePath, block, "utf-8");
1028
+ return heartbeatPath;
1029
+ }
700
1030
  //#endregion
701
1031
  Object.defineProperty(exports, "DEFAULT_IGNORES", {
702
1032
  enumerable: true,
@@ -740,6 +1070,12 @@ Object.defineProperty(exports, "filterIgnored", {
740
1070
  return filterIgnored;
741
1071
  }
742
1072
  });
1073
+ Object.defineProperty(exports, "isRecoveryArtifact", {
1074
+ enumerable: true,
1075
+ get: function() {
1076
+ return isRecoveryArtifact;
1077
+ }
1078
+ });
743
1079
  Object.defineProperty(exports, "loadIgnorePatterns", {
744
1080
  enumerable: true,
745
1081
  get: function() {