@wipcomputer/wip-ldm-os 0.4.71 → 0.4.73-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.
package/SKILL.md CHANGED
@@ -9,7 +9,7 @@ license: MIT
9
9
  compatibility: Requires git, npm, node. Node.js 18+.
10
10
  metadata:
11
11
  display-name: "LDM OS"
12
- version: "0.4.71"
12
+ version: "0.4.72"
13
13
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
14
14
  author: "Parker Todd Brooks"
15
15
  category: infrastructure
package/bin/ldm.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * ldm --version Show version
21
21
  */
22
22
 
23
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync } from 'node:fs';
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync, statSync } from 'node:fs';
24
24
  import { join, basename, resolve, dirname } from 'node:path';
25
25
  import { execSync } from 'node:child_process';
26
26
  import { fileURLToPath } from 'node:url';
@@ -148,6 +148,8 @@ const NONE_FLAG = args.includes('--none');
148
148
  const FIX_FLAG = args.includes('--fix');
149
149
  const CLEANUP_FLAG = args.includes('--cleanup');
150
150
  const CHECK_FLAG = args.includes('--check');
151
+ const ALPHA_FLAG = args.includes('--alpha');
152
+ const BETA_FLAG = args.includes('--beta');
151
153
 
152
154
  function readJSON(path) {
153
155
  try {
@@ -411,6 +413,174 @@ async function installCatalogComponent(c) {
411
413
  }
412
414
 
413
415
  // ── Bridge deploy (#245) ──
416
+ // Deploy all scripts from scripts/ to ~/.ldm/bin/
417
+ // Called from both cmdInit() and cmdInstallCatalog() so script fixes land on every update.
418
+ function deployScripts() {
419
+ const scriptsSrc = join(__dirname, '..', 'scripts');
420
+ if (!existsSync(scriptsSrc)) return 0;
421
+ mkdirSync(join(LDM_ROOT, 'bin'), { recursive: true });
422
+ let count = 0;
423
+ for (const file of readdirSync(scriptsSrc)) {
424
+ if (!file.endsWith('.sh')) continue;
425
+ const src = join(scriptsSrc, file);
426
+ const dest = join(LDM_ROOT, 'bin', file);
427
+ cpSync(src, dest);
428
+ chmodSync(dest, 0o755);
429
+ count++;
430
+ }
431
+ if (count > 0) {
432
+ console.log(` + ${count} script(s) deployed to ~/.ldm/bin/`);
433
+ }
434
+ return count;
435
+ }
436
+
437
+ // Deploy personalized docs to both settings/docs/ and library/documentation/
438
+ // Called from both cmdInit() and cmdInstallCatalog() so doc fixes land on every update.
439
+ function deployDocs() {
440
+ const docsSrc = join(__dirname, '..', 'shared', 'docs');
441
+ if (!existsSync(docsSrc)) return 0;
442
+
443
+ let workspacePath = '';
444
+ try {
445
+ const ldmConfig = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
446
+ workspacePath = (ldmConfig.workspace || '').replace('~', HOME);
447
+ } catch { return 0; }
448
+ if (!workspacePath || !existsSync(workspacePath)) return 0;
449
+
450
+ // Read config for template vars
451
+ let ldmConfig;
452
+ try {
453
+ ldmConfig = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
454
+ } catch { return 0; }
455
+
456
+ const sc = ldmConfig;
457
+
458
+ // Agents from config (rich objects with harness/machine/prefix)
459
+ const agentsObj = sc.agents || {};
460
+ const agentsList = Object.entries(agentsObj).map(([id, a]) => `${id} (${a.harness} on ${a.machine})`).join(', ');
461
+ const agentsDetail = Object.entries(agentsObj).map(([id, a]) => `- **${id}**: ${a.harness} on ${a.machine}, branch prefix \`${a.prefix}/\``).join('\n');
462
+
463
+ // Harnesses from config
464
+ const harnessConfig = sc.harnesses || {};
465
+ const harnessesDetected = Object.entries(harnessConfig).filter(([,h]) => h.detected).map(([name]) => name);
466
+ const harnessesList = harnessesDetected.length > 0 ? harnessesDetected.join(', ') : 'run ldm install to detect';
467
+
468
+ const templateVars = {
469
+ 'name': sc.name || '',
470
+ 'org': sc.org || '',
471
+ 'timezone': sc.timezone || '',
472
+ 'paths.workspace': (sc.paths?.workspace || '').replace('~', HOME),
473
+ 'paths.ldm': (sc.paths?.ldm || '').replace('~', HOME),
474
+ 'paths.openclaw': (sc.paths?.openclaw || '').replace('~', HOME),
475
+ 'paths.icloud': (sc.paths?.icloud || '').replace('~', HOME),
476
+ 'memory.local': (sc.memory?.local || '').replace('~', HOME),
477
+ 'deploy.website': sc.deploy?.website || '',
478
+ 'backup.keep': String(sc.backup?.keep || 7),
479
+ 'agents_list': agentsList,
480
+ 'agents_detail': agentsDetail,
481
+ 'harnesses_list': harnessesList,
482
+ };
483
+
484
+ function renderTemplates(destDir) {
485
+ mkdirSync(destDir, { recursive: true });
486
+ let count = 0;
487
+ for (const file of readdirSync(docsSrc)) {
488
+ if (!file.endsWith('.tmpl')) continue;
489
+ let content = readFileSync(join(docsSrc, file), 'utf8');
490
+ content = content.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
491
+ return templateVars[key.trim()] || match;
492
+ });
493
+ const outName = file.replace('.tmpl', '');
494
+ writeFileSync(join(destDir, outName), content);
495
+ count++;
496
+ }
497
+ return count;
498
+ }
499
+
500
+ // Deploy to settings/docs/ (agent reference)
501
+ const docsDest = join(workspacePath, 'settings', 'docs');
502
+ const docsCount = renderTemplates(docsDest);
503
+ if (docsCount > 0) {
504
+ console.log(` + ${docsCount} personalized doc(s) deployed to ${docsDest.replace(HOME, '~')}/`);
505
+ }
506
+
507
+ // Deploy to library/documentation/ (human-readable library copy)
508
+ const libraryDest = join(workspacePath, 'library', 'documentation');
509
+ if (existsSync(join(workspacePath, 'library'))) {
510
+ const libCount = renderTemplates(libraryDest);
511
+ if (libCount > 0) {
512
+ console.log(` + ${libCount} doc(s) deployed to ${libraryDest.replace(HOME, '~')}/`);
513
+ }
514
+ }
515
+
516
+ return docsCount;
517
+ }
518
+
519
+ // Check backup health: is a trigger configured, did it run recently, is iCloud set up?
520
+ // Called from cmdInstallCatalog() on every install.
521
+ function checkBackupHealth() {
522
+ const config = readJSON(join(LDM_ROOT, 'config.json'));
523
+ if (!config) return;
524
+
525
+ const backup = config.backup || {};
526
+ const issues = [];
527
+
528
+ // Check iCloud offsite
529
+ const icloudPath = config.paths?.icloudBackup || backup.icloudPath;
530
+ if (!icloudPath) {
531
+ issues.push('iCloud offsite not configured. Add paths.icloudBackup to ~/.ldm/config.json');
532
+ } else {
533
+ const expandedPath = icloudPath.replace(/^~/, HOME);
534
+ if (!existsSync(expandedPath)) {
535
+ try { mkdirSync(expandedPath, { recursive: true }); } catch {}
536
+ if (!existsSync(expandedPath)) {
537
+ issues.push(`iCloud path does not exist: ${icloudPath}`);
538
+ }
539
+ }
540
+ }
541
+
542
+ // Check LaunchAgent
543
+ try {
544
+ const label = backup.triggerLabel || 'ai.openclaw.ldm-backup';
545
+ const result = execSync(`launchctl list ${label} 2>/dev/null`, { encoding: 'utf8', timeout: 3000 });
546
+ if (!result) issues.push(`LaunchAgent ${label} not loaded`);
547
+ } catch {
548
+ issues.push('Backup LaunchAgent not loaded. Backups may not run automatically.');
549
+ }
550
+
551
+ // Check last backup age
552
+ const backupRoot = join(LDM_ROOT, 'backups');
553
+ if (existsSync(backupRoot)) {
554
+ const dirs = readdirSync(backupRoot)
555
+ .filter(d => d.match(/^20\d{2}-\d{2}-\d{2}--/) && statSync(join(backupRoot, d)).isDirectory())
556
+ .sort()
557
+ .reverse();
558
+ if (dirs.length > 0) {
559
+ const latest = dirs[0];
560
+ const latestDate = latest.replace(/--.*/, '').replace(/-/g, '/');
561
+ const age = Date.now() - new Date(latestDate).getTime();
562
+ const hours = Math.round(age / (1000 * 60 * 60));
563
+ if (hours > 36) {
564
+ issues.push(`Last backup is ${hours} hours old (${latest}). Expected within 24 hours.`);
565
+ }
566
+ } else {
567
+ issues.push('No backups found. Run: ldm backup');
568
+ }
569
+ }
570
+
571
+ // Check backup script exists
572
+ const scriptPath = join(LDM_ROOT, 'bin', 'ldm-backup.sh');
573
+ if (!existsSync(scriptPath)) {
574
+ issues.push('Backup script missing at ~/.ldm/bin/ldm-backup.sh. Run: ldm init');
575
+ }
576
+
577
+ if (issues.length > 0) {
578
+ for (const issue of issues) {
579
+ console.log(` ! Backup: ${issue}`);
580
+ }
581
+ }
582
+ }
583
+
414
584
  // The bridge (src/bridge/) builds to dist/bridge/ and ships in the npm package.
415
585
  // After `npm install -g`, the updated files live at the npm package location but
416
586
  // never get copied to ~/.ldm/extensions/lesa-bridge/dist/. This function fixes that.
@@ -724,29 +894,8 @@ async function cmdInit() {
724
894
  }
725
895
  }
726
896
 
727
- // Deploy backup + restore scripts (#119)
728
- const backupSrc = join(__dirname, '..', 'scripts', 'ldm-backup.sh');
729
- const backupDest = join(LDM_ROOT, 'bin', 'ldm-backup.sh');
730
- if (existsSync(backupSrc)) {
731
- mkdirSync(join(LDM_ROOT, 'bin'), { recursive: true });
732
- cpSync(backupSrc, backupDest);
733
- chmodSync(backupDest, 0o755);
734
- console.log(` + ldm-backup.sh deployed to ~/.ldm/bin/`);
735
- }
736
- const restoreSrc = join(__dirname, '..', 'scripts', 'ldm-restore.sh');
737
- const restoreDest = join(LDM_ROOT, 'bin', 'ldm-restore.sh');
738
- if (existsSync(restoreSrc)) {
739
- cpSync(restoreSrc, restoreDest);
740
- chmodSync(restoreDest, 0o755);
741
- console.log(` + ldm-restore.sh deployed to ~/.ldm/bin/`);
742
- }
743
- const summarySrc = join(__dirname, '..', 'scripts', 'ldm-summary.sh');
744
- const summaryDest = join(LDM_ROOT, 'bin', 'ldm-summary.sh');
745
- if (existsSync(summarySrc)) {
746
- cpSync(summarySrc, summaryDest);
747
- chmodSync(summaryDest, 0o755);
748
- console.log(` + ldm-summary.sh deployed to ~/.ldm/bin/`);
749
- }
897
+ // Deploy all scripts from scripts/ to ~/.ldm/bin/ (#119)
898
+ deployScripts();
750
899
 
751
900
  // Deploy shared rules to ~/.ldm/shared/rules/ and to harnesses
752
901
  const rulesSrc = join(__dirname, '..', 'shared', 'rules');
@@ -859,67 +1008,8 @@ async function cmdInit() {
859
1008
  }
860
1009
  } catch {}
861
1010
 
862
- // Deploy personalized docs to settings/docs/ (from templates + config.json)
863
- const docsSrc = join(__dirname, '..', 'shared', 'docs');
864
- if (existsSync(docsSrc)) {
865
- let workspacePath = '';
866
- try {
867
- const ldmConfig = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
868
- workspacePath = (ldmConfig.workspace || '').replace('~', HOME);
869
-
870
- if (workspacePath && existsSync(workspacePath)) {
871
- const docsDest = join(workspacePath, 'settings', 'docs');
872
- mkdirSync(docsDest, { recursive: true });
873
- let docsCount = 0;
874
-
875
- // Build template values from ~/.ldm/config.json (unified config)
876
- // Legacy: settings/config.json was a separate file, now merged into config.json
877
- const sc = ldmConfig;
878
- const lc = ldmConfig;
879
-
880
- // Agents from settings config (rich objects with harness/machine/prefix)
881
- const agentsObj = sc.agents || {};
882
- const agentsList = Object.entries(agentsObj).map(([id, a]) => `${id} (${a.harness} on ${a.machine})`).join(', ');
883
- const agentsDetail = Object.entries(agentsObj).map(([id, a]) => `- **${id}**: ${a.harness} on ${a.machine}, branch prefix \`${a.prefix}/\``).join('\n');
884
-
885
- // Harnesses from ldm config
886
- const harnessConfig = lc.harnesses || {};
887
- const harnessesDetected = Object.entries(harnessConfig).filter(([,h]) => h.detected).map(([name]) => name);
888
- const harnessesList = harnessesDetected.length > 0 ? harnessesDetected.join(', ') : 'run ldm install to detect';
889
-
890
- const templateVars = {
891
- 'name': sc.name || '',
892
- 'org': sc.org || '',
893
- 'timezone': sc.timezone || '',
894
- 'paths.workspace': (sc.paths?.workspace || '').replace('~', HOME),
895
- 'paths.ldm': (sc.paths?.ldm || '').replace('~', HOME),
896
- 'paths.openclaw': (sc.paths?.openclaw || '').replace('~', HOME),
897
- 'paths.icloud': (sc.paths?.icloud || '').replace('~', HOME),
898
- 'memory.local': (sc.memory?.local || '').replace('~', HOME),
899
- 'deploy.website': sc.deploy?.website || '',
900
- 'backup.keep': String(sc.backup?.keep || 7),
901
- 'agents_list': agentsList,
902
- 'agents_detail': agentsDetail,
903
- 'harnesses_list': harnessesList,
904
- };
905
-
906
- for (const file of readdirSync(docsSrc)) {
907
- if (!file.endsWith('.tmpl')) continue;
908
- let content = readFileSync(join(docsSrc, file), 'utf8');
909
- // Replace template vars
910
- content = content.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
911
- return templateVars[key.trim()] || match;
912
- });
913
- const outName = file.replace('.tmpl', '');
914
- writeFileSync(join(docsDest, outName), content);
915
- docsCount++;
916
- }
917
- if (docsCount > 0) {
918
- console.log(` + ${docsCount} personalized doc(s) deployed to ${docsDest.replace(HOME, '~')}/`);
919
- }
920
- }
921
- } catch {}
922
- }
1011
+ // Deploy personalized docs to settings/docs/ and library/documentation/
1012
+ deployDocs();
923
1013
 
924
1014
  // Deploy LaunchAgents to ~/Library/LaunchAgents/
925
1015
  // Templates use {{HOME}} and {{OPENCLAW_GATEWAY_TOKEN}} placeholders, replaced at deploy time.
@@ -1103,6 +1193,8 @@ async function cmdInstall() {
1103
1193
  --json JSON output
1104
1194
  --yes Auto-accept catalog prompts
1105
1195
  --none Skip catalog prompts
1196
+ --alpha Check @alpha npm tag for updates (prerelease track)
1197
+ --beta Check @beta npm tag for updates (prerelease track)
1106
1198
  `);
1107
1199
  process.exit(0);
1108
1200
  }
@@ -1410,13 +1502,19 @@ async function cmdInstallCatalog() {
1410
1502
  // Self-update: check if CLI itself is outdated. Update first, then re-exec.
1411
1503
  // This breaks the chicken-and-egg: new features in ldm install are always
1412
1504
  // available because the installer upgrades itself before doing anything else.
1505
+ // --alpha and --beta flags check the corresponding npm dist-tag instead of @latest.
1413
1506
  if (!DRY_RUN && !process.env.LDM_SELF_UPDATED) {
1414
1507
  try {
1415
- const latest = execSync('npm view @wipcomputer/wip-ldm-os version 2>/dev/null', {
1508
+ const npmTag = ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
1509
+ const trackLabel = npmTag === 'latest' ? '' : ` (${npmTag} track)`;
1510
+ const npmViewCmd = npmTag === 'latest'
1511
+ ? 'npm view @wipcomputer/wip-ldm-os version 2>/dev/null'
1512
+ : `npm view @wipcomputer/wip-ldm-os dist-tags.${npmTag} 2>/dev/null`;
1513
+ const latest = execSync(npmViewCmd, {
1416
1514
  encoding: 'utf8', timeout: 15000,
1417
1515
  }).trim();
1418
1516
  if (latest && latest !== PKG_VERSION) {
1419
- console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}. Updating first...`);
1517
+ console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel}. Updating first...`);
1420
1518
  try {
1421
1519
  execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`, { stdio: 'inherit', timeout: 60000 });
1422
1520
  console.log(` CLI updated to v${latest}. Re-running with new code...`);
@@ -1451,6 +1549,13 @@ async function cmdInstallCatalog() {
1451
1549
  // in the extension directories. This copies them to both LDM and OpenClaw targets.
1452
1550
  deployBridge();
1453
1551
 
1552
+ // Deploy scripts and docs on every install so fixes land without re-init
1553
+ deployScripts();
1554
+ deployDocs();
1555
+
1556
+ // Check backup configuration
1557
+ checkBackupHealth();
1558
+
1454
1559
  const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
1455
1560
  const state = detectSystemState();
1456
1561
  const reconciled = reconcileState(state);
@@ -1724,9 +1829,14 @@ async function cmdInstallCatalog() {
1724
1829
  }
1725
1830
 
1726
1831
  // Check npm for updates (fast, one HTTP call)
1832
+ // --alpha and --beta flags check the corresponding npm dist-tag
1727
1833
  if (npmPkg) {
1728
1834
  try {
1729
- const latestVersion = execSync(`npm view ${npmPkg} version 2>/dev/null`, {
1835
+ const npmTag = ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
1836
+ const npmViewCmd = npmTag === 'latest'
1837
+ ? `npm view ${npmPkg} version 2>/dev/null`
1838
+ : `npm view ${npmPkg} dist-tags.${npmTag} 2>/dev/null`;
1839
+ const latestVersion = execSync(npmViewCmd, {
1730
1840
  encoding: 'utf8', timeout: 10000,
1731
1841
  }).trim();
1732
1842
 
@@ -1790,7 +1900,11 @@ async function cmdInstallCatalog() {
1790
1900
  if (!currentVersion) continue;
1791
1901
 
1792
1902
  try {
1793
- const latestVersion = execSync(`npm view ${catalogComp.npm} version 2>/dev/null`, {
1903
+ const npmTag = ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
1904
+ const npmViewCmd = npmTag === 'latest'
1905
+ ? `npm view ${catalogComp.npm} version 2>/dev/null`
1906
+ : `npm view ${catalogComp.npm} dist-tags.${npmTag} 2>/dev/null`;
1907
+ const latestVersion = execSync(npmViewCmd, {
1794
1908
  encoding: 'utf8', timeout: 10000,
1795
1909
  }).trim();
1796
1910
  if (latestVersion && latestVersion !== currentVersion) {
@@ -0,0 +1,108 @@
1
+ # Backup
2
+
3
+ ## One Script, One Place
4
+
5
+ `~/.ldm/bin/ldm-backup.sh` runs daily at 3:00 AM via LaunchAgent `ai.openclaw.ldm-backup`. It backs up everything to `~/.ldm/backups/`, then tars it to iCloud for offsite.
6
+
7
+ ## What Gets Backed Up
8
+
9
+ | Source | Method | What's in it |
10
+ |--------|--------|-------------|
11
+ | `~/.ldm/memory/crystal.db` | sqlite3 .backup | Irreplaceable memory (all agents) |
12
+ | `~/.ldm/agents/` | cp -a | Identity files, journals, daily logs |
13
+ | `~/.ldm/state/` | cp -a | Config, version, registry |
14
+ | `~/.ldm/config.json` | cp | Workspace pointer, org |
15
+ | `~/.openclaw/memory/main.sqlite` | sqlite3 .backup | OC conversations |
16
+ | `~/.openclaw/memory/context-embeddings.sqlite` | sqlite3 .backup | Embeddings |
17
+ | `~/.openclaw/workspace/` | tar | Shared context, daily logs |
18
+ | `~/.openclaw/agents/main/sessions/` | tar | OC session JSONL |
19
+ | `~/.openclaw/openclaw.json` | cp | OC config |
20
+ | `~/.claude/CLAUDE.md` | cp | CC instructions |
21
+ | `~/.claude/settings.json` | cp | CC settings |
22
+ | `~/.claude/projects/` | tar | CC auto-memory + transcripts |
23
+ | Workspace directory | tar (excludes node_modules, .git/objects, old backups, _trash) | Entire workspace |
24
+
25
+ **NOT backed up:** node_modules/, .git/objects/ (reconstructable), extensions (reinstallable), ~/.claude/cache.
26
+
27
+ ## Backup Structure
28
+
29
+ ```
30
+ ~/.ldm/backups/2026-03-24--09-50-22/
31
+ ldm/
32
+ memory/crystal.db
33
+ agents/
34
+ state/
35
+ config.json
36
+ openclaw/
37
+ memory/main.sqlite
38
+ memory/context-embeddings.sqlite
39
+ workspace.tar
40
+ sessions.tar
41
+ openclaw.json
42
+ claude/
43
+ CLAUDE.md
44
+ settings.json
45
+ projects.tar
46
+ <workspace>.tar
47
+ ```
48
+
49
+ ## iCloud Offsite
50
+
51
+ After local backup, the entire dated folder is compressed and copied to iCloud. The destination path is read from `~/.ldm/config.json` at `paths.icloudBackup`.
52
+
53
+ One file per backup. iCloud syncs it across devices. Rotation matches the local retention setting.
54
+
55
+ ## How to Run
56
+
57
+ ```bash
58
+ ~/.ldm/bin/ldm-backup.sh # run backup now
59
+ ~/.ldm/bin/ldm-backup.sh --dry-run # preview what would be backed up
60
+ ~/.ldm/bin/ldm-backup.sh --keep 14 # keep 14 days instead of 7
61
+ ~/.ldm/bin/ldm-backup.sh --include-secrets # include ~/.ldm/secrets/
62
+ ```
63
+
64
+ You can also run via the CLI:
65
+
66
+ ```bash
67
+ ldm backup # run backup now
68
+ ldm backup --dry-run # preview with sizes
69
+ ldm backup --pin "before upgrade" # pin latest backup so rotation skips it
70
+ ```
71
+
72
+ ## How to Restore
73
+
74
+ ```bash
75
+ ~/.ldm/bin/ldm-restore.sh # list available backups
76
+ ~/.ldm/bin/ldm-restore.sh 2026-03-24--09-50-22 # restore everything
77
+ ~/.ldm/bin/ldm-restore.sh --only ldm <backup> # restore only crystal.db + agents
78
+ ~/.ldm/bin/ldm-restore.sh --only openclaw <backup> # restore only OC data
79
+ ~/.ldm/bin/ldm-restore.sh --from-icloud <file> # restore from iCloud tar
80
+ ~/.ldm/bin/ldm-restore.sh --dry-run <backup> # preview
81
+ ```
82
+
83
+ After restore: `openclaw gateway restart` then `crystal status` to verify.
84
+
85
+ ## Schedule
86
+
87
+ | What | When | How |
88
+ |------|------|-----|
89
+ | Backup | 3:00 AM | LaunchAgent `ai.openclaw.ldm-backup` |
90
+
91
+ One LaunchAgent. One script. No Full Disk Access currently (target: midnight via LDMDevTools.app once PID error is fixed). Verify is built into the script (exit code + log).
92
+
93
+ ## Config
94
+
95
+ All backup settings live in `~/.ldm/config.json`:
96
+ - `paths.workspace` ... workspace path
97
+ - `paths.icloudBackup` ... iCloud offsite destination
98
+ - `backup.keep` ... retention days (default: 7)
99
+ - `backup.includeSecrets` ... whether to include `~/.ldm/secrets/`
100
+ - `org` ... used for tar filename prefix
101
+
102
+ ## Logs
103
+
104
+ `~/.ldm/logs/backup.log` (LaunchAgent stdout/stderr)
105
+
106
+ ## Technical Details
107
+
108
+ See [TECHNICAL.md](./TECHNICAL.md) for config schema, LaunchAgent plist, rotation logic, and script internals.
@@ -0,0 +1,112 @@
1
+ # Backup: Technical Details
2
+
3
+ ## Config Schema
4
+
5
+ All backup settings are in `~/.ldm/config.json`. The backup script reads these at runtime.
6
+
7
+ ```json
8
+ {
9
+ "org": "wipcomputerinc",
10
+ "paths": {
11
+ "workspace": "~/wipcomputerinc",
12
+ "ldm": "~/.ldm",
13
+ "claude": "~/.claude",
14
+ "openclaw": "~/.openclaw",
15
+ "icloudBackup": "~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups"
16
+ },
17
+ "backup": {
18
+ "keep": 7,
19
+ "includeSecrets": false
20
+ }
21
+ }
22
+ ```
23
+
24
+ | Key | Type | Default | Description |
25
+ |-----|------|---------|-------------|
26
+ | `paths.workspace` | string | required | Root workspace directory to back up |
27
+ | `paths.icloudBackup` | string | optional | iCloud destination for offsite copies |
28
+ | `backup.keep` | number | 7 | Days of backups to keep before rotation |
29
+ | `backup.includeSecrets` | boolean | false | Whether to include `~/.ldm/secrets/` |
30
+ | `org` | string | required | Used as prefix in iCloud tar filenames |
31
+
32
+ ## Script Location
33
+
34
+ - **Source:** `scripts/ldm-backup.sh` in the wip-ldm-os-private repo
35
+ - **Deployed to:** `~/.ldm/bin/ldm-backup.sh`
36
+ - **Deployed by:** `deployScripts()` in `bin/ldm.js`, called during both `ldm init` and `ldm install`
37
+ - **Restore script:** `scripts/ldm-restore.sh` deployed to `~/.ldm/bin/ldm-restore.sh`
38
+
39
+ All `.sh` files in the repo's `scripts/` directory are deployed to `~/.ldm/bin/` on every `ldm install`. This means script fixes land automatically on the next update without requiring a full `ldm init`.
40
+
41
+ ## LaunchAgent
42
+
43
+ **Label:** `ai.openclaw.ldm-backup`
44
+ **Plist source:** `shared/launchagents/ai.openclaw.ldm-backup.plist`
45
+ **Deployed to:** `~/Library/LaunchAgents/ai.openclaw.ldm-backup.plist`
46
+
47
+ ```xml
48
+ <key>StartCalendarInterval</key>
49
+ <dict>
50
+ <key>Hour</key>
51
+ <integer>3</integer>
52
+ <key>Minute</key>
53
+ <integer>0</integer>
54
+ </dict>
55
+ ```
56
+
57
+ The plist uses `{{HOME}}` placeholders that are replaced at deploy time by `ldm init`.
58
+
59
+ **Logs:** stdout and stderr both go to `~/.ldm/logs/backup.log`.
60
+
61
+ **No Full Disk Access (FDA):** The LaunchAgent runs at 3:00 AM without FDA. Some paths (like `~/Library/Messages/`) are inaccessible without FDA. The target is to move the trigger to midnight via LDMDevTools.app (which has FDA) once the PID error is resolved.
62
+
63
+ ### Dead Triggers (Cleaned Automatically)
64
+
65
+ The `cleanDeadBackupTriggers()` function in `ldm.js` removes old competing triggers on every `ldm init`:
66
+ - Old cron entries referencing `LDMDevTools.app`
67
+ - `com.wipcomputer.daily-backup` LaunchAgent
68
+ - OpenClaw `backup-verify` cron entries
69
+
70
+ Only `ai.openclaw.ldm-backup` should exist.
71
+
72
+ ## Rotation Logic
73
+
74
+ The backup script handles rotation after a successful backup:
75
+
76
+ 1. List all dated directories in `~/.ldm/backups/` (format: `YYYY-MM-DD--HH-MM-SS`)
77
+ 2. Sort by name (which sorts chronologically)
78
+ 3. Skip any directory containing a `.pinned` marker file
79
+ 4. Delete directories beyond the `keep` count (oldest first)
80
+ 5. Same rotation logic applies to iCloud tars at `paths.icloudBackup`
81
+
82
+ **Pinning:** `ldm backup --pin "reason"` creates a `.pinned` file in the latest backup directory. Pinned backups are never rotated.
83
+
84
+ ## iCloud Offsite Details
85
+
86
+ After the local backup completes:
87
+
88
+ 1. Tar + gzip the entire dated backup directory
89
+ 2. Filename format: `<org>-<machine>-<timestamp>.tar.gz`
90
+ 3. Copy to `paths.icloudBackup` (from config.json)
91
+ 4. Apply the same rotation (keep N, skip pinned)
92
+ 5. iCloud syncs the file to all devices automatically
93
+
94
+ The iCloud path must exist. The script does not create it. `ldm init` does not create it either. Create it manually if it does not exist.
95
+
96
+ ## SQLite Safety
97
+
98
+ SQLite files are backed up using `sqlite3 .backup`, not `cp`. This ensures a consistent snapshot even if the database is being written to. The script checks for the `sqlite3` binary and skips database backup with a warning if it is not found.
99
+
100
+ Files backed up this way:
101
+ - `~/.ldm/memory/crystal.db`
102
+ - `~/.openclaw/memory/main.sqlite`
103
+ - `~/.openclaw/memory/context-embeddings.sqlite`
104
+
105
+ ## Excludes
106
+
107
+ The workspace tar excludes:
108
+ - `node_modules/` ... reconstructable via npm install
109
+ - `.git/objects/` ... reconstructable via git fetch
110
+ - `backups/` ... avoids recursive backup
111
+ - `_trash/` ... already deleted content
112
+ - `*.tar.gz` ... avoids backing up old backup archives
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.71",
3
+ "version": "0.4.73-alpha.1",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## One Script, One Place
4
4
 
5
- `~/.ldm/bin/ldm-backup.sh` runs daily at midnight via LDM Dev Tools.app. It backs up everything to `~/.ldm/backups/`, then tars it to iCloud for offsite.
5
+ `~/.ldm/bin/ldm-backup.sh` runs daily at 3:00 AM via LaunchAgent `ai.openclaw.ldm-backup`. It backs up everything to `~/.ldm/backups/`, then tars it to iCloud for offsite.
6
6
 
7
7
  ## What Gets Backed Up
8
8
 
@@ -48,14 +48,14 @@
48
48
 
49
49
  ## iCloud Offsite
50
50
 
51
- After local backup, the entire dated folder is compressed and copied to iCloud:
51
+ After local backup, the entire dated folder is compressed and copied to iCloud. The iCloud path is read from `~/.ldm/config.json` at `paths.icloudBackup`.
52
52
 
53
53
  ```
54
54
  ~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups/
55
55
  wipcomputerinc-lesa-2026-03-24--09-50-22.tar.gz
56
56
  ```
57
57
 
58
- One file per backup. iCloud syncs it across devices. Rotates to 7 days.
58
+ One file per backup. iCloud syncs it across devices. Rotates to {{backup.keep}} days.
59
59
 
60
60
  ## How to Run
61
61
 
@@ -83,19 +83,22 @@ After restore: `openclaw gateway restart` then `crystal status` to verify.
83
83
 
84
84
  | What | When | How |
85
85
  |------|------|-----|
86
- | Backup | Midnight | cron -> LDM Dev Tools.app -> ~/.ldm/bin/ldm-backup.sh |
86
+ | Backup | 3:00 AM | LaunchAgent `ai.openclaw.ldm-backup` runs `~/.ldm/bin/ldm-backup.sh` |
87
87
 
88
- One cron entry. One script. One app. Verify is built into the script (exit code + log).
88
+ One LaunchAgent. One script. No Full Disk Access currently (target: midnight via LDMDevTools.app once PID error is fixed). Verify is built into the script (exit code + log).
89
89
 
90
90
  ## Config
91
91
 
92
- Backup reads from two config files:
93
- - `~/.ldm/config.json` ... workspace path, org name
94
- - `~/wipcomputerinc/settings/config.json` ... backup.keep (retention days), paths.icloudBackup
92
+ All backup settings live in `~/.ldm/config.json`:
93
+ - `paths.workspace` ... workspace path
94
+ - `paths.icloudBackup` ... iCloud offsite destination
95
+ - `backup.keep` ... retention days (default: 7)
96
+ - `backup.includeSecrets` ... whether to include `~/.ldm/secrets/`
97
+ - `org` ... used for tar filename prefix
95
98
 
96
99
  ## Logs
97
100
 
98
- `~/.ldm/logs/cron.log` (via LDM Dev Tools.app stdout)
101
+ `~/.ldm/logs/backup.log` (LaunchAgent stdout/stderr)
99
102
 
100
103
  ---
101
104
 
@@ -103,6 +106,6 @@ Backup reads from two config files:
103
106
 
104
107
  **Local backups:** `~/.ldm/backups/`
105
108
  **iCloud offsite:** `~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups/`
106
- **Schedule:** Midnight via LDM Dev Tools.app
107
- **Retention:** 7 days local, 7 days iCloud
109
+ **Schedule:** 3:00 AM via LaunchAgent `ai.openclaw.ldm-backup`
110
+ **Retention:** {{backup.keep}} days local, {{backup.keep}} days iCloud
108
111
  **Script:** `~/.ldm/bin/ldm-backup.sh` (deployed by `ldm install`)