@alfe.ai/openclaw-sync 0.2.2 → 0.2.3

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.
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { appendFile, copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
2
  import { createReadStream, existsSync, readFileSync } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { basename, dirname, extname, join } from "node:path";
@@ -67,6 +67,20 @@ async function updateManifestEntry(workspacePath, relativePath, entry) {
67
67
  await writeManifest(workspacePath, manifest);
68
68
  }
69
69
  /**
70
+ * Update many file entries in one read-modify-write pass.
71
+ *
72
+ * `updateManifestEntry` re-reads and rewrites the whole manifest JSON per
73
+ * call — fine for single-file watcher events, quadratic for bulk heals, and
74
+ * lossy when calls overlap. Use this for any batch (e.g. the recovery
75
+ * "identical → heal" pass). Optionally stamps the owning agentId.
76
+ */
77
+ async function updateManifestEntries(workspacePath, entries, options = {}) {
78
+ const manifest = await readManifest(workspacePath);
79
+ Object.assign(manifest.files, entries);
80
+ if (options.agentId) manifest.agentId = options.agentId;
81
+ await writeManifest(workspacePath, manifest);
82
+ }
83
+ /**
70
84
  * Remove a file entry from the local manifest.
71
85
  */
72
86
  async function removeManifestEntry(workspacePath, relativePath) {
@@ -241,6 +255,12 @@ async function withRetry(fn, options = {}) {
241
255
  * Each step retries with exponential backoff via `withRetry`.
242
256
  */
243
257
  const log$2 = createLogger("SyncUploader");
258
+ /**
259
+ * Prefixes stored as GLACIER_IR on the sync bucket. Shared with the recovery
260
+ * classifier in sync-engine.ts, which must never cut conflict sidecars for
261
+ * archive objects (their presigned GETs fail until restored, so hashing/
262
+ * preserving them is wasted work on a pull that can't succeed anyway).
263
+ */
244
264
  const ARCHIVE_PREFIXES = ["sessions/archive/", "context/archive/"];
245
265
  function getStorageClass(relativePath) {
246
266
  return ARCHIVE_PREFIXES.some((p) => relativePath.startsWith(p)) ? "GLACIER_IR" : "STANDARD";
@@ -280,17 +300,17 @@ async function uploadOne(workspacePath, relativePath, client) {
280
300
  size,
281
301
  storageClass
282
302
  });
283
- await updateManifestEntry(workspacePath, relativePath, {
284
- hash,
285
- size,
286
- lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
287
- storageClass
288
- });
289
303
  return {
290
304
  path: relativePath,
291
305
  success: true,
292
306
  hash,
293
- size
307
+ size,
308
+ entry: {
309
+ hash,
310
+ size,
311
+ lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
312
+ storageClass
313
+ }
294
314
  };
295
315
  });
296
316
  } catch (err) {
@@ -310,11 +330,20 @@ async function uploadFiles(workspacePath, relativePaths, client, options = {}) {
310
330
  for (let i = 0; i < relativePaths.length; i += concurrency) {
311
331
  const batch = relativePaths.slice(i, i + concurrency);
312
332
  const batchResults = await Promise.all(batch.map((path) => uploadOne(workspacePath, path, client)));
333
+ const entries = {};
313
334
  for (const result of batchResults) {
314
- results.push(result);
335
+ if (result.entry !== void 0) entries[result.path] = result.entry;
336
+ results.push({
337
+ path: result.path,
338
+ success: result.success,
339
+ hash: result.hash,
340
+ size: result.size,
341
+ error: result.error
342
+ });
315
343
  if (!quiet) if (result.success) log$2.info(`Uploaded ${result.path} (${formatBytes$1(result.size ?? 0)})`);
316
344
  else log$2.error(`Failed to upload ${result.path}: ${result.error ?? "Unknown error"}`);
317
345
  }
346
+ if (Object.keys(entries).length > 0) await updateManifestEntries(workspacePath, entries);
318
347
  }
319
348
  return results;
320
349
  }
@@ -349,16 +378,17 @@ async function downloadOne(workspacePath, relativePath, client, remoteEntry) {
349
378
  const absolutePath = join(workspacePath, relativePath);
350
379
  await mkdir(dirname(absolutePath), { recursive: true });
351
380
  await writeFile(absolutePath, buffer);
352
- await updateManifestEntry(workspacePath, relativePath, {
381
+ const entry = {
353
382
  hash: remoteEntry?.hash ?? "",
354
383
  size: buffer.length,
355
384
  lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
356
385
  storageClass: remoteEntry?.storageClass ?? "STANDARD"
357
- });
386
+ };
358
387
  return {
359
388
  path: relativePath,
360
389
  success: true,
361
- size: buffer.length
390
+ size: buffer.length,
391
+ entry
362
392
  };
363
393
  });
364
394
  } catch (err) {
@@ -375,11 +405,19 @@ async function downloadFiles(workspacePath, relativePaths, client, remoteManifes
375
405
  for (let i = 0; i < relativePaths.length; i += concurrency) {
376
406
  const batch = relativePaths.slice(i, i + concurrency);
377
407
  const batchResults = await Promise.all(batch.map((path) => downloadOne(workspacePath, path, client, remoteManifest?.files[path])));
408
+ const entries = {};
378
409
  for (const result of batchResults) {
379
- results.push(result);
410
+ if (result.entry !== void 0) entries[result.path] = result.entry;
411
+ results.push({
412
+ path: result.path,
413
+ success: result.success,
414
+ size: result.size,
415
+ error: result.error
416
+ });
380
417
  if (!quiet) if (result.success) log$1.info(`Downloaded ${result.path} (${formatBytes(result.size ?? 0)})`);
381
418
  else log$1.error(`Failed to download ${result.path}: ${result.error ?? "Unknown error"}`);
382
419
  }
420
+ if (Object.keys(entries).length > 0) await updateManifestEntries(workspacePath, entries);
383
421
  }
384
422
  return results;
385
423
  }
@@ -403,12 +441,45 @@ function formatBytes(bytes) {
403
441
  */
404
442
  const log = createLogger("SyncEngine");
405
443
  /**
444
+ * Above this size a diverged local file is NOT copied to a `.conflict-*`
445
+ * sidecar during recovery (the cloud copy still replaces it). Sidecars
446
+ * double disk usage per file; a multi-GB session archive could fill the VM.
447
+ * The un-preserved overwrite is never silent — it is logged and listed in
448
+ * the RECOVERY report.
449
+ */
450
+ const MAX_PRESERVE_BYTES = 100 * 1024 * 1024;
451
+ /** Marker that keeps the heartbeat recovery nudge idempotent across runs. */
452
+ const RECOVERY_NUDGE_MARKER = "<!-- alfe-sync:recovery-nudge -->";
453
+ /**
454
+ * Recovery artifacts: `.conflict-<ts>` sidecars and `RECOVERY-<ts>.md`
455
+ * reports. Anchored to the timestamp shape so legitimate user files like
456
+ * `my.conflict-notes.txt` or `RECOVERY-plan.md` are never misclassified.
457
+ *
458
+ * They seed UP to the cloud so a preserved local version survives even if
459
+ * the VM dies before the agent merges it (retrievable via the dashboard
460
+ * file manager), but they are never AUTO-restored back down and never
461
+ * preserved again — restoring would resurrect copies the agent already
462
+ * cleaned up, and re-preserving would cascade `.conflict`-of-`.conflict`
463
+ * files. Cloud copies are reclaimed when the agent's local cleanup
464
+ * propagates through the watcher; artifacts orphaned by a rebuild before
465
+ * cleanup linger until then (accepted: small, user-visible, deletable).
466
+ * The watcher's delete brake also exempts them so the agent's post-merge
467
+ * cleanup of many sidecars can't trip it.
468
+ *
469
+ * Deliberately NOT in the ignore set: ignored paths don't seed up and would
470
+ * be eligible for pruneIgnored deletion.
471
+ */
472
+ function isRecoveryArtifact(relativePath) {
473
+ const name = basename(relativePath);
474
+ return /\.conflict-\d{4}-\d{2}-\d{2}T/.test(name) || /^RECOVERY-\d{4}-\d{2}-\d{2}T.*\.md$/.test(name);
475
+ }
476
+ /**
406
477
  * Construct a sync engine bound to a workspace + agent API client.
407
478
  *
408
479
  * The client is constructed once in plugin.ts (or the CLI) and passed in,
409
480
  * so credentials never leak into multiple places.
410
481
  */
411
- function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
482
+ function createSyncEngine({ workspacePath, client, runtime = "openclaw", maxPreserveBytes = MAX_PRESERVE_BYTES }) {
412
483
  return {
413
484
  workspacePath,
414
485
  client,
@@ -501,15 +572,11 @@ function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
501
572
  const trueConflicts = diff.conflicts.filter((p) => localChanges.includes(p));
502
573
  const remoteOnlyChanges = diff.conflicts.filter((p) => !localChanges.includes(p));
503
574
  let conflictCount = 0;
575
+ const conflictTimestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
504
576
  for (const conflictPath of trueConflicts) {
505
- const absolutePath = join(workspacePath, conflictPath);
506
- if (!existsSync(absolutePath)) continue;
507
- const ext = extname(conflictPath);
508
- const base = basename(conflictPath, ext);
509
- const dir = dirname(conflictPath);
510
- const conflictName = `${base}.conflict-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}${ext}`;
511
- await writeFile(join(workspacePath, dir, conflictName), await readFile(absolutePath));
512
- if (!quiet) log.warn(`Conflict: ${conflictPath} — saved as ${conflictName}`);
577
+ const outcome = await preserveConflictCopy(workspacePath, conflictPath, conflictTimestamp);
578
+ if (outcome.status === "vanished") continue;
579
+ if (outcome.status !== "too-large" && !quiet) log.warn(`Conflict: ${conflictPath} — saved as ${outcome.conflictPath}`);
513
580
  conflictCount++;
514
581
  }
515
582
  const filesToPush = filterIgnored([...diff.toPush, ...localChanges.filter((p) => !diff.conflicts.includes(p))], ignorePatterns);
@@ -553,25 +620,104 @@ function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
553
620
  const remotePaths = Object.keys(remoteManifest.files);
554
621
  if (remotePaths.length === 0) {
555
622
  if (!quiet) log.info("First-run sync: no cloud state — seeding workspace to cloud");
556
- return this.push(void 0, options);
623
+ const seedResult = await this.push(void 0, options);
624
+ await updateManifestEntries(workspacePath, {}, { agentId: remoteManifest.agentId });
625
+ return seedResult;
557
626
  }
558
627
  if (!quiet) log.info(`First-run sync: ${String(remotePaths.length)} file(s) in cloud — restoring cloud state before seeding`);
559
628
  const ignorePatterns = await loadIgnorePatterns(workspacePath, runtime);
560
- const restorePaths = filterIgnored(remotePaths, ignorePatterns);
561
- const restore = await this.pullFiles(restorePaths, options);
629
+ const restorePaths = filterIgnored(remotePaths, ignorePatterns).filter((p) => !isRecoveryArtifact(p));
630
+ const localManifest = await readManifest(workspacePath);
631
+ const manifestTrusted = localManifest.agentId === void 0 || localManifest.agentId === remoteManifest.agentId;
632
+ if (!manifestTrusted) {
633
+ log.warn(`Local sync manifest belongs to agent ${localManifest.agentId ?? "?"} but cloud state is agent ${remoteManifest.agentId} — treating the manifest as lost`);
634
+ await writeManifest(workspacePath, {
635
+ files: {},
636
+ agentId: remoteManifest.agentId
637
+ });
638
+ }
639
+ const classes = await classifyRestorePaths(workspacePath, restorePaths, remoteManifest.files, localManifest, manifestTrusted);
640
+ for (const p of classes.offlineDeletes) if (!quiet) log.info(`Recovery: ${p} was deleted locally while sync was down — not restoring it`);
641
+ if (classes.offlineDeletes.length > 0) log.info(`Recovery: ${String(classes.offlineDeletes.length)} locally-deleted file(s) not restored from cloud`);
642
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
643
+ const preserved = [];
644
+ let newlyPreserved = 0;
645
+ for (const { path, diskHash, size } of classes.preserve) {
646
+ const outcome = await preserveConflictCopy(workspacePath, path, timestamp, {
647
+ maxBytes: maxPreserveBytes,
648
+ sourceHash: diskHash
649
+ });
650
+ if (outcome.status === "vanished") continue;
651
+ const record = {
652
+ path,
653
+ localHash: diskHash,
654
+ remoteHash: remoteManifest.files[path].hash,
655
+ size
656
+ };
657
+ if (outcome.status === "too-large") {
658
+ record.tooLarge = true;
659
+ newlyPreserved++;
660
+ log.warn(`Recovery: ${path} diverged locally but exceeds the sidecar size cap — cloud version will replace it WITHOUT a preserved copy`);
661
+ } else {
662
+ record.conflictPath = outcome.conflictPath;
663
+ if (outcome.status === "preserved") newlyPreserved++;
664
+ log.warn(`Recovery: preserved local ${path} as ${outcome.conflictPath} — cloud version restored in place`);
665
+ }
666
+ preserved.push(record);
667
+ }
668
+ let recoveryReportPath = null;
669
+ if (preserved.length > 0 && (newlyPreserved > 0 || !await recoveryReportExists(workspacePath))) {
670
+ recoveryReportPath = `${agentDirPrefix(workspacePath)}RECOVERY-${timestamp}.md`;
671
+ const reportAbsolute = join(workspacePath, recoveryReportPath);
672
+ await mkdir(dirname(reportAbsolute), { recursive: true });
673
+ await writeFile(reportAbsolute, buildRecoveryReport(preserved, workspacePath), "utf-8");
674
+ log.warn(`Recovery: ${String(preserved.length)} diverged local file(s) — see ${recoveryReportPath}`);
675
+ }
676
+ const pullList = [
677
+ ...classes.missing,
678
+ ...classes.pullClean,
679
+ ...classes.preserve.map((p) => p.path)
680
+ ];
681
+ const restore = {
682
+ pulled: 0,
683
+ errors: 0
684
+ };
685
+ if (pullList.length > 0) {
686
+ if (!quiet) log.info(`Pulling ${String(pullList.length)} file(s)`);
687
+ const results = await downloadFiles(workspacePath, pullList, client, remoteManifest, options);
688
+ restore.pulled = results.filter((r) => r.success).length;
689
+ restore.errors = results.filter((r) => !r.success).length;
690
+ }
691
+ if (Object.keys(classes.heal).length > 0 || localManifest.agentId !== remoteManifest.agentId) await updateManifestEntries(workspacePath, classes.heal, { agentId: remoteManifest.agentId });
562
692
  const remoteSet = new Set(remotePaths);
563
- const toSeed = (await detectLocalChanges(workspacePath, ignorePatterns)).filter((p) => !remoteSet.has(p));
564
- const seed = toSeed.length > 0 ? await this.push(toSeed, options) : {
693
+ const pushList = [...(await detectLocalChanges(workspacePath, ignorePatterns)).filter((p) => !remoteSet.has(p)), ...classes.localAhead];
694
+ const seed = pushList.length > 0 ? await this.push(pushList, options) : {
565
695
  pushed: 0,
566
696
  pulled: 0,
567
697
  conflicts: 0,
568
698
  errors: 0
569
699
  };
700
+ let nudge = {
701
+ pushed: 0,
702
+ errors: 0
703
+ };
704
+ if (preserved.length > 0 && recoveryReportPath !== null && runtime === "openclaw") try {
705
+ const nudgePath = await appendRecoveryNudge(workspacePath, recoveryReportPath, preserved.length);
706
+ if (nudgePath !== null) {
707
+ const pushResult = await this.push([nudgePath], options);
708
+ nudge = {
709
+ pushed: pushResult.pushed,
710
+ errors: pushResult.errors
711
+ };
712
+ }
713
+ } catch (err) {
714
+ log.warn(`Recovery: failed to write heartbeat nudge: ${err instanceof Error ? err.message : String(err)}`);
715
+ }
570
716
  return {
571
- pushed: seed.pushed,
717
+ pushed: seed.pushed + nudge.pushed,
572
718
  pulled: restore.pulled,
573
- conflicts: restore.conflicts + seed.conflicts,
574
- errors: restore.errors + seed.errors
719
+ conflicts: preserved.length + seed.conflicts,
720
+ errors: restore.errors + seed.errors + nudge.errors
575
721
  };
576
722
  },
577
723
  async pullFiles(paths, options = {}) {
@@ -674,7 +820,191 @@ async function detectLocalChanges(workspacePath, ignorePatterns) {
674
820
  await walk(workspacePath);
675
821
  return changed;
676
822
  }
823
+ /**
824
+ * Relative prefix of the agent's actual working directory. The sync root is
825
+ * the runtime HOME (`~/.openclaw`), but the agent lives and works in
826
+ * `~/.openclaw/workspace/` — recovery artifacts meant for the agent's eyes
827
+ * (RECOVERY report, heartbeat nudge) must land there or it never sees them.
828
+ * Falls back to the sync root when no `workspace/` dir exists (tests, CLI
829
+ * usage against a bare directory).
830
+ */
831
+ function agentDirPrefix(workspacePath) {
832
+ return existsSync(join(workspacePath, "workspace")) ? "workspace/" : "";
833
+ }
834
+ /** Whether an unresolved RECOVERY-*.md report already sits in the agent dir. */
835
+ async function recoveryReportExists(workspacePath) {
836
+ return (await readdir(join(workspacePath, agentDirPrefix(workspacePath))).catch(() => [])).some((name) => name.startsWith("RECOVERY-") && name.endsWith(".md"));
837
+ }
838
+ /**
839
+ * Copy a file aside as `<base>.conflict-<ts><ext>` in the same directory
840
+ * before a remote copy overwrites it. Uses `copyFile` (no buffering — safe
841
+ * for large/binary files).
842
+ *
843
+ * Idempotent: if a sibling sidecar with identical content already exists
844
+ * (crash-loop retry, repeated recovery), no new copy is stacked. Pass
845
+ * `sourceHash` when the caller already hashed the file.
846
+ */
847
+ async function preserveConflictCopy(workspacePath, relativePath, timestamp, options = {}) {
848
+ const absolutePath = join(workspacePath, relativePath);
849
+ const ext = extname(relativePath);
850
+ const base = basename(relativePath, ext);
851
+ const dir = dirname(relativePath);
852
+ try {
853
+ const fileStat = await stat(absolutePath);
854
+ if (options.maxBytes !== void 0 && fileStat.size > options.maxBytes) return {
855
+ status: "too-large",
856
+ size: fileStat.size
857
+ };
858
+ const sourceHash = options.sourceHash ?? await computeFileHash(absolutePath);
859
+ const siblings = await readdir(join(workspacePath, dir)).catch(() => []);
860
+ for (const name of siblings) {
861
+ if (!name.startsWith(`${base}.conflict-`)) continue;
862
+ if (ext !== "" && !name.endsWith(ext)) continue;
863
+ if (await computeFileHash(join(workspacePath, dir, name)).catch(() => null) === sourceHash) return {
864
+ status: "already-preserved",
865
+ conflictPath: join(dir, name).replace(/\\/g, "/")
866
+ };
867
+ }
868
+ const conflictName = `${base}.conflict-${timestamp}${ext}`;
869
+ await copyFile(absolutePath, join(workspacePath, dir, conflictName));
870
+ return {
871
+ status: "preserved",
872
+ conflictPath: join(dir, conflictName).replace(/\\/g, "/")
873
+ };
874
+ } catch {
875
+ return { status: "vanished" };
876
+ }
877
+ }
878
+ const CLASSIFY_CONCURRENCY = 8;
879
+ /**
880
+ * Classify every cloud-held path for recovery. The destructive decision
881
+ * (overwrite local content) is only ever taken when the DISK hash diverges
882
+ * from the cloud AND the local manifest can't vouch for the disk content —
883
+ * and even then the local bytes are preserved first. The manifest and mtime
884
+ * are used solely to avoid work and to detect local-ahead edits, never to
885
+ * authorize an unpreserved overwrite.
886
+ */
887
+ async function classifyRestorePaths(workspacePath, restorePaths, remoteFiles, localManifest, manifestTrusted) {
888
+ const out = {
889
+ missing: [],
890
+ offlineDeletes: [],
891
+ heal: {},
892
+ pullClean: [],
893
+ localAhead: [],
894
+ preserve: []
895
+ };
896
+ async function classifyOne(path) {
897
+ const remote = remoteFiles[path];
898
+ const entry = manifestTrusted ? localManifest.files[path] : void 0;
899
+ const absolutePath = join(workspacePath, path);
900
+ let fileStat = null;
901
+ try {
902
+ fileStat = await stat(absolutePath);
903
+ } catch {}
904
+ if (!fileStat?.isFile()) {
905
+ if (entry?.hash === remote.hash) out.offlineDeletes.push(path);
906
+ else out.missing.push(path);
907
+ return;
908
+ }
909
+ const unchangedSinceSync = fileStat.size === entry?.size && fileStat.mtimeMs <= Date.parse(entry.lastSynced);
910
+ const isArchive = ARCHIVE_PREFIXES.some((p) => path.startsWith(p));
911
+ if (unchangedSinceSync || isArchive) {
912
+ if (entry?.hash === remote.hash) return;
913
+ out.pullClean.push(path);
914
+ return;
915
+ }
916
+ let diskHash;
917
+ try {
918
+ diskHash = await computeFileHash(absolutePath);
919
+ } catch {
920
+ out.missing.push(path);
921
+ return;
922
+ }
923
+ if (diskHash === remote.hash) {
924
+ if (entry?.hash !== remote.hash) out.heal[path] = {
925
+ hash: remote.hash,
926
+ size: fileStat.size,
927
+ lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
928
+ storageClass: remote.storageClass ?? "STANDARD"
929
+ };
930
+ return;
931
+ }
932
+ if (entry?.hash === diskHash) {
933
+ out.pullClean.push(path);
934
+ return;
935
+ }
936
+ if (entry?.hash === remote.hash) {
937
+ out.localAhead.push(path);
938
+ return;
939
+ }
940
+ out.preserve.push({
941
+ path,
942
+ diskHash,
943
+ size: fileStat.size
944
+ });
945
+ }
946
+ for (let i = 0; i < restorePaths.length; i += CLASSIFY_CONCURRENCY) await Promise.all(restorePaths.slice(i, i + CLASSIFY_CONCURRENCY).map(classifyOne));
947
+ return out;
948
+ }
949
+ function buildRecoveryReport(records, workspacePath) {
950
+ const lines = [
951
+ `# Workspace recovery report`,
952
+ "",
953
+ `A sync recovery restored this agent's cloud-saved workspace. The files`,
954
+ `below had local content that differed from the cloud copy. The cloud`,
955
+ `version now lives at the original path; the pre-recovery local version`,
956
+ `was preserved next to it as a \`.conflict-*\` sidecar.`,
957
+ "",
958
+ `**What to do:** open each sidecar, merge anything worth keeping into the`,
959
+ `live file, then delete the sidecar. Delete this report when done.`,
960
+ "",
961
+ `Paths are relative to the sync root: \`${workspacePath}\``,
962
+ ""
963
+ ];
964
+ const capped = records.filter((r) => r.tooLarge === true);
965
+ const kept = records.filter((r) => r.tooLarge !== true);
966
+ if (kept.length > 0) {
967
+ lines.push(`| File | Preserved copy | Local hash | Cloud hash | Size (bytes) |`);
968
+ lines.push(`| --- | --- | --- | --- | --- |`);
969
+ for (const r of kept) lines.push(`| \`${r.path}\` | \`${r.conflictPath ?? ""}\` | \`${r.localHash}\` | \`${r.remoteHash}\` | ${String(r.size)} |`);
970
+ lines.push("");
971
+ }
972
+ if (capped.length > 0) {
973
+ lines.push(`The following diverged files exceeded the sidecar size cap and were`, `replaced by the cloud version WITHOUT a preserved copy:`, "");
974
+ for (const r of capped) lines.push(`- \`${r.path}\` (${String(r.size)} bytes, local hash \`${r.localHash}\`)`);
975
+ lines.push("");
976
+ }
977
+ return lines.join("\n");
978
+ }
979
+ /**
980
+ * Append the recovery nudge to the agent's HEARTBEAT.md (OpenClaw reads it
981
+ * from the agent workspace dir on its periodic heartbeat). Returns the
982
+ * sync-relative path when a nudge was appended, or null when a prior nudge
983
+ * is still pending (idempotency marker present).
984
+ */
985
+ async function appendRecoveryNudge(workspacePath, recoveryReportPath, preservedCount) {
986
+ const prefix = agentDirPrefix(workspacePath);
987
+ const heartbeatPath = `${prefix}HEARTBEAT.md`;
988
+ const absolutePath = join(workspacePath, heartbeatPath);
989
+ if ((existsSync(absolutePath) ? await readFile(absolutePath, "utf-8") : "").includes(RECOVERY_NUDGE_MARKER)) return null;
990
+ const reportForAgent = recoveryReportPath.startsWith(prefix) ? recoveryReportPath.slice(prefix.length) : recoveryReportPath;
991
+ const block = [
992
+ "",
993
+ RECOVERY_NUDGE_MARKER,
994
+ "## Workspace recovery needs your attention",
995
+ "",
996
+ `A sync recovery preserved ${String(preservedCount)} pre-recovery local file version(s)`,
997
+ `as \`.conflict-*\` sidecars. Read every \`RECOVERY-*.md\` report in this directory`,
998
+ `(latest: \`${reportForAgent}\`), merge anything worth keeping into the live files,`,
999
+ `then delete the sidecars, the reports, and this section (including its marker`,
1000
+ `comment).`,
1001
+ ""
1002
+ ].join("\n");
1003
+ await mkdir(dirname(absolutePath), { recursive: true });
1004
+ await appendFile(absolutePath, block, "utf-8");
1005
+ return heartbeatPath;
1006
+ }
677
1007
  //#endregion
678
- export { DEFAULT_IGNORES as a, shouldIgnore as c, diffManifests as d, readManifest as f, writeManifest as h, withRetry as i, shouldIgnoreDir as l, updateManifestEntry as m, downloadFiles as n, filterIgnored as o, removeManifestEntry as p, uploadFiles as r, loadIgnorePatterns as s, createSyncEngine as t, computeFileHash as u };
1008
+ export { withRetry as a, loadIgnorePatterns as c, computeFileHash as d, diffManifests as f, writeManifest as g, updateManifestEntry as h, uploadFiles as i, shouldIgnore as l, removeManifestEntry as m, isRecoveryArtifact as n, DEFAULT_IGNORES as o, readManifest as p, downloadFiles as r, filterIgnored as s, createSyncEngine as t, shouldIgnoreDir as u };
679
1009
 
680
1010
  //# sourceMappingURL=sync-engine.js.map