@wipcomputer/wip-ldm-os 0.4.71 → 0.4.72

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';
@@ -411,6 +411,174 @@ async function installCatalogComponent(c) {
411
411
  }
412
412
 
413
413
  // ── Bridge deploy (#245) ──
414
+ // Deploy all scripts from scripts/ to ~/.ldm/bin/
415
+ // Called from both cmdInit() and cmdInstallCatalog() so script fixes land on every update.
416
+ function deployScripts() {
417
+ const scriptsSrc = join(__dirname, '..', 'scripts');
418
+ if (!existsSync(scriptsSrc)) return 0;
419
+ mkdirSync(join(LDM_ROOT, 'bin'), { recursive: true });
420
+ let count = 0;
421
+ for (const file of readdirSync(scriptsSrc)) {
422
+ if (!file.endsWith('.sh')) continue;
423
+ const src = join(scriptsSrc, file);
424
+ const dest = join(LDM_ROOT, 'bin', file);
425
+ cpSync(src, dest);
426
+ chmodSync(dest, 0o755);
427
+ count++;
428
+ }
429
+ if (count > 0) {
430
+ console.log(` + ${count} script(s) deployed to ~/.ldm/bin/`);
431
+ }
432
+ return count;
433
+ }
434
+
435
+ // Deploy personalized docs to both settings/docs/ and library/documentation/
436
+ // Called from both cmdInit() and cmdInstallCatalog() so doc fixes land on every update.
437
+ function deployDocs() {
438
+ const docsSrc = join(__dirname, '..', 'shared', 'docs');
439
+ if (!existsSync(docsSrc)) return 0;
440
+
441
+ let workspacePath = '';
442
+ try {
443
+ const ldmConfig = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
444
+ workspacePath = (ldmConfig.workspace || '').replace('~', HOME);
445
+ } catch { return 0; }
446
+ if (!workspacePath || !existsSync(workspacePath)) return 0;
447
+
448
+ // Read config for template vars
449
+ let ldmConfig;
450
+ try {
451
+ ldmConfig = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
452
+ } catch { return 0; }
453
+
454
+ const sc = ldmConfig;
455
+
456
+ // Agents from config (rich objects with harness/machine/prefix)
457
+ const agentsObj = sc.agents || {};
458
+ const agentsList = Object.entries(agentsObj).map(([id, a]) => `${id} (${a.harness} on ${a.machine})`).join(', ');
459
+ const agentsDetail = Object.entries(agentsObj).map(([id, a]) => `- **${id}**: ${a.harness} on ${a.machine}, branch prefix \`${a.prefix}/\``).join('\n');
460
+
461
+ // Harnesses from config
462
+ const harnessConfig = sc.harnesses || {};
463
+ const harnessesDetected = Object.entries(harnessConfig).filter(([,h]) => h.detected).map(([name]) => name);
464
+ const harnessesList = harnessesDetected.length > 0 ? harnessesDetected.join(', ') : 'run ldm install to detect';
465
+
466
+ const templateVars = {
467
+ 'name': sc.name || '',
468
+ 'org': sc.org || '',
469
+ 'timezone': sc.timezone || '',
470
+ 'paths.workspace': (sc.paths?.workspace || '').replace('~', HOME),
471
+ 'paths.ldm': (sc.paths?.ldm || '').replace('~', HOME),
472
+ 'paths.openclaw': (sc.paths?.openclaw || '').replace('~', HOME),
473
+ 'paths.icloud': (sc.paths?.icloud || '').replace('~', HOME),
474
+ 'memory.local': (sc.memory?.local || '').replace('~', HOME),
475
+ 'deploy.website': sc.deploy?.website || '',
476
+ 'backup.keep': String(sc.backup?.keep || 7),
477
+ 'agents_list': agentsList,
478
+ 'agents_detail': agentsDetail,
479
+ 'harnesses_list': harnessesList,
480
+ };
481
+
482
+ function renderTemplates(destDir) {
483
+ mkdirSync(destDir, { recursive: true });
484
+ let count = 0;
485
+ for (const file of readdirSync(docsSrc)) {
486
+ if (!file.endsWith('.tmpl')) continue;
487
+ let content = readFileSync(join(docsSrc, file), 'utf8');
488
+ content = content.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
489
+ return templateVars[key.trim()] || match;
490
+ });
491
+ const outName = file.replace('.tmpl', '');
492
+ writeFileSync(join(destDir, outName), content);
493
+ count++;
494
+ }
495
+ return count;
496
+ }
497
+
498
+ // Deploy to settings/docs/ (agent reference)
499
+ const docsDest = join(workspacePath, 'settings', 'docs');
500
+ const docsCount = renderTemplates(docsDest);
501
+ if (docsCount > 0) {
502
+ console.log(` + ${docsCount} personalized doc(s) deployed to ${docsDest.replace(HOME, '~')}/`);
503
+ }
504
+
505
+ // Deploy to library/documentation/ (human-readable library copy)
506
+ const libraryDest = join(workspacePath, 'library', 'documentation');
507
+ if (existsSync(join(workspacePath, 'library'))) {
508
+ const libCount = renderTemplates(libraryDest);
509
+ if (libCount > 0) {
510
+ console.log(` + ${libCount} doc(s) deployed to ${libraryDest.replace(HOME, '~')}/`);
511
+ }
512
+ }
513
+
514
+ return docsCount;
515
+ }
516
+
517
+ // Check backup health: is a trigger configured, did it run recently, is iCloud set up?
518
+ // Called from cmdInstallCatalog() on every install.
519
+ function checkBackupHealth() {
520
+ const config = readJSON(join(LDM_ROOT, 'config.json'));
521
+ if (!config) return;
522
+
523
+ const backup = config.backup || {};
524
+ const issues = [];
525
+
526
+ // Check iCloud offsite
527
+ const icloudPath = config.paths?.icloudBackup || backup.icloudPath;
528
+ if (!icloudPath) {
529
+ issues.push('iCloud offsite not configured. Add paths.icloudBackup to ~/.ldm/config.json');
530
+ } else {
531
+ const expandedPath = icloudPath.replace(/^~/, HOME);
532
+ if (!existsSync(expandedPath)) {
533
+ try { mkdirSync(expandedPath, { recursive: true }); } catch {}
534
+ if (!existsSync(expandedPath)) {
535
+ issues.push(`iCloud path does not exist: ${icloudPath}`);
536
+ }
537
+ }
538
+ }
539
+
540
+ // Check LaunchAgent
541
+ try {
542
+ const label = backup.triggerLabel || 'ai.openclaw.ldm-backup';
543
+ const result = execSync(`launchctl list ${label} 2>/dev/null`, { encoding: 'utf8', timeout: 3000 });
544
+ if (!result) issues.push(`LaunchAgent ${label} not loaded`);
545
+ } catch {
546
+ issues.push('Backup LaunchAgent not loaded. Backups may not run automatically.');
547
+ }
548
+
549
+ // Check last backup age
550
+ const backupRoot = join(LDM_ROOT, 'backups');
551
+ if (existsSync(backupRoot)) {
552
+ const dirs = readdirSync(backupRoot)
553
+ .filter(d => d.match(/^20\d{2}-\d{2}-\d{2}--/) && statSync(join(backupRoot, d)).isDirectory())
554
+ .sort()
555
+ .reverse();
556
+ if (dirs.length > 0) {
557
+ const latest = dirs[0];
558
+ const latestDate = latest.replace(/--.*/, '').replace(/-/g, '/');
559
+ const age = Date.now() - new Date(latestDate).getTime();
560
+ const hours = Math.round(age / (1000 * 60 * 60));
561
+ if (hours > 36) {
562
+ issues.push(`Last backup is ${hours} hours old (${latest}). Expected within 24 hours.`);
563
+ }
564
+ } else {
565
+ issues.push('No backups found. Run: ldm backup');
566
+ }
567
+ }
568
+
569
+ // Check backup script exists
570
+ const scriptPath = join(LDM_ROOT, 'bin', 'ldm-backup.sh');
571
+ if (!existsSync(scriptPath)) {
572
+ issues.push('Backup script missing at ~/.ldm/bin/ldm-backup.sh. Run: ldm init');
573
+ }
574
+
575
+ if (issues.length > 0) {
576
+ for (const issue of issues) {
577
+ console.log(` ! Backup: ${issue}`);
578
+ }
579
+ }
580
+ }
581
+
414
582
  // The bridge (src/bridge/) builds to dist/bridge/ and ships in the npm package.
415
583
  // After `npm install -g`, the updated files live at the npm package location but
416
584
  // never get copied to ~/.ldm/extensions/lesa-bridge/dist/. This function fixes that.
@@ -724,29 +892,8 @@ async function cmdInit() {
724
892
  }
725
893
  }
726
894
 
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
- }
895
+ // Deploy all scripts from scripts/ to ~/.ldm/bin/ (#119)
896
+ deployScripts();
750
897
 
751
898
  // Deploy shared rules to ~/.ldm/shared/rules/ and to harnesses
752
899
  const rulesSrc = join(__dirname, '..', 'shared', 'rules');
@@ -859,67 +1006,8 @@ async function cmdInit() {
859
1006
  }
860
1007
  } catch {}
861
1008
 
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
- }
1009
+ // Deploy personalized docs to settings/docs/ and library/documentation/
1010
+ deployDocs();
923
1011
 
924
1012
  // Deploy LaunchAgents to ~/Library/LaunchAgents/
925
1013
  // Templates use {{HOME}} and {{OPENCLAW_GATEWAY_TOKEN}} placeholders, replaced at deploy time.
@@ -1451,6 +1539,13 @@ async function cmdInstallCatalog() {
1451
1539
  // in the extension directories. This copies them to both LDM and OpenClaw targets.
1452
1540
  deployBridge();
1453
1541
 
1542
+ // Deploy scripts and docs on every install so fixes land without re-init
1543
+ deployScripts();
1544
+ deployDocs();
1545
+
1546
+ // Check backup configuration
1547
+ checkBackupHealth();
1548
+
1454
1549
  const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
1455
1550
  const state = detectSystemState();
1456
1551
  const reconciled = reconcileState(state);
@@ -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.72",
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`)