parallelclaw 1.0.0

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/HELP.md +600 -0
  3. package/LICENSE +21 -0
  4. package/MULTI_MACHINE.md +152 -0
  5. package/README.md +417 -0
  6. package/README.ru.md +740 -0
  7. package/SYNC.md +844 -0
  8. package/bot/README.md +173 -0
  9. package/bot/config.js +66 -0
  10. package/bot/inbox.js +153 -0
  11. package/bot/index.js +294 -0
  12. package/bot/nexara.js +61 -0
  13. package/bot/poll.js +304 -0
  14. package/bot/search.js +155 -0
  15. package/bot/telegram.js +96 -0
  16. package/ingest.js +2712 -0
  17. package/lib/cli/index.js +1987 -0
  18. package/lib/config.js +220 -0
  19. package/lib/db-init.js +158 -0
  20. package/lib/hook/install.js +268 -0
  21. package/lib/import-telegram.js +158 -0
  22. package/lib/ingest-file.js +779 -0
  23. package/lib/notify-click-action.js +281 -0
  24. package/lib/openclaw-channel.js +643 -0
  25. package/lib/parse-cursor.js +172 -0
  26. package/lib/parse-obsidian.js +256 -0
  27. package/lib/parse-telegram-html.js +384 -0
  28. package/lib/parse.js +175 -0
  29. package/lib/render-markdown.js +0 -0
  30. package/lib/store-doc/canonicalize.js +116 -0
  31. package/lib/store-doc/detect.js +209 -0
  32. package/lib/store-doc/extract-title.js +162 -0
  33. package/lib/sync/auth.js +80 -0
  34. package/lib/sync/cert.js +144 -0
  35. package/lib/sync/cli.js +906 -0
  36. package/lib/sync/client.js +138 -0
  37. package/lib/sync/config.js +130 -0
  38. package/lib/sync/pair.js +145 -0
  39. package/lib/sync/pull.js +158 -0
  40. package/lib/sync/push.js +305 -0
  41. package/lib/sync/replicate.js +335 -0
  42. package/lib/sync/server.js +224 -0
  43. package/lib/sync/service.js +726 -0
  44. package/lib/tasks.js +215 -0
  45. package/lib/telegram-decisions.js +165 -0
  46. package/lib/telegram-discovery.js +373 -0
  47. package/lib/telegram-notify.js +272 -0
  48. package/lib/telegram-pending.js +200 -0
  49. package/lib/web/index.js +265 -0
  50. package/lib/web/routes/conversation.js +193 -0
  51. package/lib/web/routes/conversations.js +180 -0
  52. package/lib/web/routes/dashboard.js +175 -0
  53. package/lib/web/routes/pending.js +277 -0
  54. package/lib/web/routes/settings.js +226 -0
  55. package/lib/web/static/style.css +393 -0
  56. package/lib/web/templates.js +234 -0
  57. package/package.json +84 -0
  58. package/server.js +3816 -0
  59. package/skills/install-memex/README.md +109 -0
  60. package/skills/install-memex/SKILL.md +342 -0
  61. package/skills/install-memex/examples.md +294 -0
  62. package/skills/install-memex-claw/SKILL.md +423 -0
@@ -0,0 +1,726 @@
1
+ /**
2
+ * Durable service registration for the sync-server (Phase 2).
3
+ *
4
+ * Turns `memex-sync sync-server start` (a foreground process) into a managed
5
+ * service that survives reboot and auto-restarts on crash:
6
+ * • macOS → LaunchAgent com.parallelclaw.memex.syncserver
7
+ * • Linux → systemd-user memex-sync-server.service
8
+ *
9
+ * Deliberately SEPARATE from the capture daemon (com.parallelclaw.memex.sync
10
+ * / memex-sync.service). A host can run both: the capture daemon ingests local
11
+ * sources, the sync-server answers remote pull/push. Different jobs, different
12
+ * lifecycles.
13
+ *
14
+ * The bearer token and TLS cert persist on disk (~/.memex/config.json +
15
+ * sync-cert.pem), so a restart reuses the SAME credentials — paired peers
16
+ * keep working without re-pairing. That's the whole point of Phase 2.
17
+ *
18
+ * The unit/plist MUST inject MEMEX_SYNC_EXPERIMENTAL=1, otherwise the
19
+ * sync-server start command refuses to run (experimental gate).
20
+ */
21
+
22
+ import { platform, homedir } from 'node:os';
23
+ import { join, resolve } from 'node:path';
24
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
25
+ import { execSync } from 'node:child_process';
26
+
27
+ const HOME = homedir();
28
+ const MEMEX_DIR = process.env.MEMEX_DIR || join(HOME, '.memex');
29
+ const DATA = join(MEMEX_DIR, 'data');
30
+
31
+ // Service identity — distinct from the capture daemon.
32
+ const MAC_LABEL = 'com.parallelclaw.memex.syncserver';
33
+ const MAC_PLIST = join(HOME, 'Library', 'LaunchAgents', `${MAC_LABEL}.plist`);
34
+ const LINUX_UNIT = 'memex-sync-server.service';
35
+ const LINUX_DIR = join(HOME, '.config', 'systemd', 'user');
36
+ const LINUX_PATH = join(LINUX_DIR, LINUX_UNIT);
37
+
38
+ const OUT_LOG = join(DATA, 'sync-server.out.log');
39
+ const ERR_LOG = join(DATA, 'sync-server.err.log');
40
+
41
+ // Scheduler identity (Phase 3) — distinct again from both the capture daemon
42
+ // AND the sync-server. This is the client-side timer that runs `sync-run --all`
43
+ // every N minutes. On a hub (VPS) you typically run sync-server; on a spoke
44
+ // (laptop) you run the schedule. A machine can run both.
45
+ const SCHED_MAC_LABEL = 'com.parallelclaw.memex.syncschedule';
46
+ const SCHED_MAC_PLIST = join(HOME, 'Library', 'LaunchAgents', `${SCHED_MAC_LABEL}.plist`);
47
+ const SCHED_LINUX_SERVICE = 'memex-sync-schedule.service';
48
+ const SCHED_LINUX_TIMER = 'memex-sync-schedule.timer';
49
+ const SCHED_SERVICE_PATH = join(LINUX_DIR, SCHED_LINUX_SERVICE);
50
+ const SCHED_TIMER_PATH = join(LINUX_DIR, SCHED_LINUX_TIMER);
51
+ const SCHED_OUT_LOG = join(DATA, 'sync-schedule.out.log');
52
+ const SCHED_ERR_LOG = join(DATA, 'sync-schedule.err.log');
53
+
54
+ // Tunnel keeper (sync-join, v0.13) — a durable forward SSH tunnel from a
55
+ // spoke (laptop) to the hub's loopback sync-server. The OS supervisor
56
+ // (KeepAlive / Restart=always) respawns ssh whenever it exits — sleep/wake,
57
+ // network change, drop — which is what makes the tunnel self-healing.
58
+ const TUNNEL_MAC_LABEL = 'com.parallelclaw.memex.synctunnel';
59
+ const TUNNEL_MAC_PLIST = join(HOME, 'Library', 'LaunchAgents', `${TUNNEL_MAC_LABEL}.plist`);
60
+ const TUNNEL_LINUX_UNIT = 'memex-sync-tunnel.service';
61
+ const TUNNEL_LINUX_PATH = join(LINUX_DIR, TUNNEL_LINUX_UNIT);
62
+ const TUNNEL_SCRIPT = join(MEMEX_DIR, 'sync-tunnel.sh');
63
+ const TUNNEL_OUT_LOG = join(DATA, 'sync-tunnel.out.log');
64
+ const TUNNEL_ERR_LOG = join(DATA, 'sync-tunnel.err.log');
65
+
66
+ // Watchdog (sync-join, v0.13) — hourly `sync-watchdog` pass that checks every
67
+ // remote's last_sync_at + the tunnel unit and surfaces silent failures
68
+ // (notification + alert file). The 2026-06 incident — a dead tunnel silently
69
+ // stranding 6 days of data — is exactly what this catches on day one.
70
+ const WD_MAC_LABEL = 'com.parallelclaw.memex.syncwatchdog';
71
+ const WD_MAC_PLIST = join(HOME, 'Library', 'LaunchAgents', `${WD_MAC_LABEL}.plist`);
72
+ const WD_LINUX_SERVICE = 'memex-sync-watchdog.service';
73
+ const WD_LINUX_TIMER = 'memex-sync-watchdog.timer';
74
+ const WD_SERVICE_PATH = join(LINUX_DIR, WD_LINUX_SERVICE);
75
+ const WD_TIMER_PATH = join(LINUX_DIR, WD_LINUX_TIMER);
76
+ const WD_OUT_LOG = join(DATA, 'sync-watchdog.out.log');
77
+ const WD_ERR_LOG = join(DATA, 'sync-watchdog.err.log');
78
+
79
+ export const SERVICE_PATHS = {
80
+ MAC_LABEL, MAC_PLIST, LINUX_UNIT, LINUX_DIR, LINUX_PATH, OUT_LOG, ERR_LOG,
81
+ SCHED_MAC_LABEL, SCHED_MAC_PLIST, SCHED_LINUX_SERVICE, SCHED_LINUX_TIMER,
82
+ SCHED_SERVICE_PATH, SCHED_TIMER_PATH,
83
+ TUNNEL_MAC_LABEL, TUNNEL_MAC_PLIST, TUNNEL_LINUX_UNIT, TUNNEL_LINUX_PATH, TUNNEL_SCRIPT,
84
+ WD_MAC_LABEL, WD_MAC_PLIST, WD_LINUX_SERVICE, WD_LINUX_TIMER, WD_SERVICE_PATH, WD_TIMER_PATH,
85
+ };
86
+
87
+ /**
88
+ * Install + start the sync-server as a managed service.
89
+ *
90
+ * opts:
91
+ * scriptPath — absolute path to ingest.js (defaults to process.argv[1])
92
+ * port, bind — listen config baked into the unit's ExecStart
93
+ * nodePath — node binary (defaults to process.execPath)
94
+ *
95
+ * Returns { platform, unitPath } on success; throws on failure.
96
+ */
97
+ export function installSyncServerService({ scriptPath, port, bind, nodePath = process.execPath } = {}) {
98
+ const script = resolve(scriptPath || process.argv[1]);
99
+ if (!existsSync(script)) {
100
+ throw new Error(`installSyncServerService: script not found at ${script}`);
101
+ }
102
+ mkdirSync(DATA, { recursive: true });
103
+
104
+ if (platform() === 'darwin') return installLaunchAgent({ script, port, bind, nodePath });
105
+ if (platform() === 'linux') return installSystemd({ script, port, bind, nodePath });
106
+ throw new Error(`installSyncServerService: unsupported platform ${platform()}`);
107
+ }
108
+
109
+ export function uninstallSyncServerService() {
110
+ if (platform() === 'darwin') return uninstallLaunchAgent();
111
+ if (platform() === 'linux') return uninstallSystemd();
112
+ throw new Error(`uninstallSyncServerService: unsupported platform ${platform()}`);
113
+ }
114
+
115
+ /**
116
+ * Report service state: { installed, running, manager, unitPath, detail }.
117
+ * Best-effort — never throws.
118
+ */
119
+ export function syncServerServiceStatus() {
120
+ if (platform() === 'darwin') {
121
+ const installed = existsSync(MAC_PLIST);
122
+ let running = false, detail = '';
123
+ if (installed) {
124
+ try {
125
+ const out = execSync(`launchctl list 2>/dev/null | grep ${MAC_LABEL} || true`, { encoding: 'utf-8' });
126
+ running = out.trim().length > 0 && !out.trim().startsWith('-');
127
+ detail = out.trim();
128
+ } catch (_) {}
129
+ }
130
+ return { installed, running, manager: 'launchd', unitPath: MAC_PLIST, detail };
131
+ }
132
+ if (platform() === 'linux') {
133
+ const installed = existsSync(LINUX_PATH);
134
+ let running = false, detail = '';
135
+ if (installed) {
136
+ try {
137
+ detail = execSync(`systemctl --user is-active ${LINUX_UNIT} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
138
+ running = detail === 'active';
139
+ } catch (_) {}
140
+ }
141
+ return { installed, running, manager: 'systemd-user', unitPath: LINUX_PATH, detail };
142
+ }
143
+ return { installed: false, running: false, manager: 'none', unitPath: null, detail: 'unsupported platform' };
144
+ }
145
+
146
+ // ── macOS LaunchAgent ────────────────────────────────────────────────────────
147
+
148
+ /**
149
+ * Pure builder — returns the LaunchAgent plist XML. Exported for testing so
150
+ * we can assert the env var / args / paths without touching launchctl.
151
+ */
152
+ export function buildLaunchAgentPlist({ script, port, bind, nodePath }) {
153
+ const args = ['sync-server', 'start'];
154
+ if (port) args.push('--port', String(port));
155
+ if (bind) args.push('--bind', String(bind));
156
+ const argXml = [nodePath, script, ...args].map((a) => ` <string>${a}</string>`).join('\n');
157
+
158
+ return `<?xml version="1.0" encoding="UTF-8"?>
159
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
160
+ <plist version="1.0">
161
+ <dict>
162
+ <key>Label</key>
163
+ <string>${MAC_LABEL}</string>
164
+ <key>ProgramArguments</key>
165
+ <array>
166
+ ${argXml}
167
+ </array>
168
+ <key>EnvironmentVariables</key>
169
+ <dict>
170
+ <key>MEMEX_SYNC_EXPERIMENTAL</key><string>1</string>
171
+ <key>HOME</key><string>${HOME}</string>
172
+ <key>MEMEX_DIR</key><string>${MEMEX_DIR}</string>
173
+ </dict>
174
+ <key>RunAtLoad</key><true/>
175
+ <key>KeepAlive</key><true/>
176
+ <key>ProcessType</key><string>Background</string>
177
+ <key>StandardOutPath</key><string>${OUT_LOG}</string>
178
+ <key>StandardErrorPath</key><string>${ERR_LOG}</string>
179
+ <key>WorkingDirectory</key><string>${resolve(script, '..')}</string>
180
+ </dict>
181
+ </plist>
182
+ `;
183
+ }
184
+
185
+ function installLaunchAgent({ script, port, bind, nodePath }) {
186
+ const plist = buildLaunchAgentPlist({ script, port, bind, nodePath });
187
+ mkdirSync(join(HOME, 'Library', 'LaunchAgents'), { recursive: true });
188
+ try { execSync(`launchctl unload ${JSON.stringify(MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
189
+ writeFileSync(MAC_PLIST, plist);
190
+ execSync(`launchctl load ${JSON.stringify(MAC_PLIST)}`, { stdio: 'inherit' });
191
+ return { platform: 'darwin', unitPath: MAC_PLIST };
192
+ }
193
+
194
+ function uninstallLaunchAgent() {
195
+ try { execSync(`launchctl unload ${JSON.stringify(MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
196
+ if (existsSync(MAC_PLIST)) unlinkSync(MAC_PLIST);
197
+ return { platform: 'darwin', unitPath: MAC_PLIST };
198
+ }
199
+
200
+ // ── Linux systemd-user ───────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Pure builder — returns the systemd-user unit file content. Exported for
204
+ * testing so we can assert env var / ExecStart / restart policy without
205
+ * touching systemctl.
206
+ */
207
+ export function buildSystemdUnit({ script, port, bind, nodePath }) {
208
+ const args = ['sync-server', 'start'];
209
+ if (port) args.push('--port', String(port));
210
+ if (bind) args.push('--bind', String(bind));
211
+ const execStart = [nodePath, script, ...args].join(' ');
212
+
213
+ return `[Unit]
214
+ Description=memex sync server (experimental multi-device replication)
215
+ Documentation=https://github.com/parallelclaw/memex-mvp/blob/main/SYNC.md
216
+ After=network.target
217
+
218
+ [Service]
219
+ Type=simple
220
+ ExecStart=${execStart}
221
+ WorkingDirectory=${resolve(script, '..')}
222
+ Restart=on-failure
223
+ RestartSec=10s
224
+ StartLimitIntervalSec=60
225
+ StartLimitBurst=5
226
+ Environment=MEMEX_SYNC_EXPERIMENTAL=1
227
+ Environment=HOME=${HOME}
228
+ Environment=MEMEX_DIR=${MEMEX_DIR}
229
+ StandardOutput=append:${OUT_LOG}
230
+ StandardError=append:${ERR_LOG}
231
+
232
+ [Install]
233
+ WantedBy=default.target
234
+ `;
235
+ }
236
+
237
+ function installSystemd({ script, port, bind, nodePath }) {
238
+ try { execSync('systemctl --user --version', { stdio: 'ignore' }); }
239
+ catch (_) {
240
+ throw new Error(
241
+ 'systemctl --user not available. Run the server under nohup instead, ' +
242
+ 'or enable lingering: `loginctl enable-linger $USER`.'
243
+ );
244
+ }
245
+
246
+ const unit = buildSystemdUnit({ script, port, bind, nodePath });
247
+ mkdirSync(LINUX_DIR, { recursive: true });
248
+ try { execSync(`systemctl --user stop ${LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
249
+ writeFileSync(LINUX_PATH, unit);
250
+ execSync('systemctl --user daemon-reload', { stdio: 'inherit' });
251
+ execSync(`systemctl --user enable ${LINUX_UNIT}`, { stdio: 'inherit' });
252
+ execSync(`systemctl --user start ${LINUX_UNIT}`, { stdio: 'inherit' });
253
+ return { platform: 'linux', unitPath: LINUX_PATH };
254
+ }
255
+
256
+ function uninstallSystemd() {
257
+ try { execSync(`systemctl --user stop ${LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
258
+ try { execSync(`systemctl --user disable ${LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
259
+ if (existsSync(LINUX_PATH)) unlinkSync(LINUX_PATH);
260
+ try { execSync('systemctl --user daemon-reload', { stdio: 'ignore' }); } catch (_) {}
261
+ return { platform: 'linux', unitPath: LINUX_PATH };
262
+ }
263
+
264
+ // ════════════════════════════════════════════════════════════════════════════
265
+ // Phase 3 · scheduled auto-sync (client side)
266
+ // Runs `sync-run --all` every N minutes via the platform scheduler.
267
+ // ════════════════════════════════════════════════════════════════════════════
268
+
269
+ /**
270
+ * Install the recurring auto-sync schedule.
271
+ * opts.everyMinutes — interval (default 15)
272
+ * opts.scriptPath — ingest.js (defaults to process.argv[1])
273
+ * opts.nodePath — node binary (defaults to process.execPath)
274
+ */
275
+ export function installSyncSchedule({ scriptPath, everyMinutes = 15, nodePath = process.execPath } = {}) {
276
+ const script = resolve(scriptPath || process.argv[1]);
277
+ if (!existsSync(script)) throw new Error(`installSyncSchedule: script not found at ${script}`);
278
+ const mins = Math.max(1, Math.floor(Number(everyMinutes) || 15));
279
+ mkdirSync(DATA, { recursive: true });
280
+
281
+ if (platform() === 'darwin') return installScheduleLaunchAgent({ script, mins, nodePath });
282
+ if (platform() === 'linux') return installScheduleSystemd({ script, mins, nodePath });
283
+ throw new Error(`installSyncSchedule: unsupported platform ${platform()}`);
284
+ }
285
+
286
+ export function uninstallSyncSchedule() {
287
+ if (platform() === 'darwin') {
288
+ try { execSync(`launchctl unload ${JSON.stringify(SCHED_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
289
+ if (existsSync(SCHED_MAC_PLIST)) unlinkSync(SCHED_MAC_PLIST);
290
+ return { platform: 'darwin', unitPath: SCHED_MAC_PLIST };
291
+ }
292
+ if (platform() === 'linux') {
293
+ try { execSync(`systemctl --user stop ${SCHED_LINUX_TIMER}`, { stdio: 'ignore' }); } catch (_) {}
294
+ try { execSync(`systemctl --user disable ${SCHED_LINUX_TIMER}`, { stdio: 'ignore' }); } catch (_) {}
295
+ if (existsSync(SCHED_TIMER_PATH)) unlinkSync(SCHED_TIMER_PATH);
296
+ if (existsSync(SCHED_SERVICE_PATH)) unlinkSync(SCHED_SERVICE_PATH);
297
+ try { execSync('systemctl --user daemon-reload', { stdio: 'ignore' }); } catch (_) {}
298
+ return { platform: 'linux', unitPath: SCHED_TIMER_PATH };
299
+ }
300
+ throw new Error(`uninstallSyncSchedule: unsupported platform ${platform()}`);
301
+ }
302
+
303
+ /** { installed, running, manager, everyMinutes?, unitPath, detail } — best-effort. */
304
+ export function syncScheduleStatus() {
305
+ if (platform() === 'darwin') {
306
+ const installed = existsSync(SCHED_MAC_PLIST);
307
+ let running = false, detail = '';
308
+ if (installed) {
309
+ try {
310
+ const out = execSync(`launchctl list 2>/dev/null | grep ${SCHED_MAC_LABEL} || true`, { encoding: 'utf-8' });
311
+ running = out.trim().length > 0;
312
+ detail = out.trim();
313
+ } catch (_) {}
314
+ }
315
+ return { installed, running, manager: 'launchd', unitPath: SCHED_MAC_PLIST, detail };
316
+ }
317
+ if (platform() === 'linux') {
318
+ const installed = existsSync(SCHED_TIMER_PATH);
319
+ let running = false, detail = '';
320
+ if (installed) {
321
+ try {
322
+ detail = execSync(`systemctl --user is-active ${SCHED_LINUX_TIMER} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
323
+ running = detail === 'active';
324
+ } catch (_) {}
325
+ }
326
+ return { installed, running, manager: 'systemd-user', unitPath: SCHED_TIMER_PATH, detail };
327
+ }
328
+ return { installed: false, running: false, manager: 'none', unitPath: null, detail: 'unsupported' };
329
+ }
330
+
331
+ // ── macOS: LaunchAgent with StartInterval (re-runs the one-shot every N sec) ──
332
+
333
+ export function buildScheduleLaunchAgentPlist({ script, mins, nodePath }) {
334
+ const interval = mins * 60;
335
+ const argXml = [nodePath, script, 'sync-run', '--all']
336
+ .map((a) => ` <string>${a}</string>`).join('\n');
337
+ return `<?xml version="1.0" encoding="UTF-8"?>
338
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
339
+ <plist version="1.0">
340
+ <dict>
341
+ <key>Label</key>
342
+ <string>${SCHED_MAC_LABEL}</string>
343
+ <key>ProgramArguments</key>
344
+ <array>
345
+ ${argXml}
346
+ </array>
347
+ <key>EnvironmentVariables</key>
348
+ <dict>
349
+ <key>MEMEX_SYNC_EXPERIMENTAL</key><string>1</string>
350
+ <key>HOME</key><string>${HOME}</string>
351
+ <key>MEMEX_DIR</key><string>${MEMEX_DIR}</string>
352
+ </dict>
353
+ <key>RunAtLoad</key><true/>
354
+ <key>StartInterval</key><integer>${interval}</integer>
355
+ <key>ProcessType</key><string>Background</string>
356
+ <key>StandardOutPath</key><string>${SCHED_OUT_LOG}</string>
357
+ <key>StandardErrorPath</key><string>${SCHED_ERR_LOG}</string>
358
+ <key>WorkingDirectory</key><string>${resolve(script, '..')}</string>
359
+ </dict>
360
+ </plist>
361
+ `;
362
+ }
363
+
364
+ function installScheduleLaunchAgent({ script, mins, nodePath }) {
365
+ const plist = buildScheduleLaunchAgentPlist({ script, mins, nodePath });
366
+ mkdirSync(join(HOME, 'Library', 'LaunchAgents'), { recursive: true });
367
+ try { execSync(`launchctl unload ${JSON.stringify(SCHED_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
368
+ writeFileSync(SCHED_MAC_PLIST, plist);
369
+ execSync(`launchctl load ${JSON.stringify(SCHED_MAC_PLIST)}`, { stdio: 'inherit' });
370
+ return { platform: 'darwin', unitPath: SCHED_MAC_PLIST, everyMinutes: mins };
371
+ }
372
+
373
+ // ── Linux: systemd .timer + oneshot .service ─────────────────────────────────
374
+
375
+ export function buildScheduleSystemdService({ script, nodePath }) {
376
+ return `[Unit]
377
+ Description=memex sync — one auto-sync pass (all remotes)
378
+ Documentation=https://github.com/parallelclaw/memex-mvp/blob/main/SYNC.md
379
+
380
+ [Service]
381
+ Type=oneshot
382
+ ExecStart=${nodePath} ${script} sync-run --all
383
+ WorkingDirectory=${resolve(script, '..')}
384
+ Environment=MEMEX_SYNC_EXPERIMENTAL=1
385
+ Environment=HOME=${HOME}
386
+ Environment=MEMEX_DIR=${MEMEX_DIR}
387
+ StandardOutput=append:${SCHED_OUT_LOG}
388
+ StandardError=append:${SCHED_ERR_LOG}
389
+ `;
390
+ }
391
+
392
+ export function buildScheduleSystemdTimer({ mins }) {
393
+ return `[Unit]
394
+ Description=memex sync — run auto-sync every ${mins}m
395
+ Documentation=https://github.com/parallelclaw/memex-mvp/blob/main/SYNC.md
396
+
397
+ [Timer]
398
+ OnBootSec=2min
399
+ OnUnitActiveSec=${mins}min
400
+ AccuracySec=30s
401
+ Persistent=true
402
+
403
+ [Install]
404
+ WantedBy=timers.target
405
+ `;
406
+ }
407
+
408
+ function installScheduleSystemd({ script, mins, nodePath }) {
409
+ try { execSync('systemctl --user --version', { stdio: 'ignore' }); }
410
+ catch (_) {
411
+ throw new Error('systemctl --user not available. Enable lingering (loginctl enable-linger $USER) or run sync manually.');
412
+ }
413
+ mkdirSync(LINUX_DIR, { recursive: true });
414
+ writeFileSync(SCHED_SERVICE_PATH, buildScheduleSystemdService({ script, nodePath }));
415
+ writeFileSync(SCHED_TIMER_PATH, buildScheduleSystemdTimer({ mins }));
416
+ execSync('systemctl --user daemon-reload', { stdio: 'inherit' });
417
+ execSync(`systemctl --user enable ${SCHED_LINUX_TIMER}`, { stdio: 'inherit' });
418
+ execSync(`systemctl --user start ${SCHED_LINUX_TIMER}`, { stdio: 'inherit' });
419
+ return { platform: 'linux', unitPath: SCHED_TIMER_PATH, everyMinutes: mins };
420
+ }
421
+
422
+ // ════════════════════════════════════════════════════════════════════════════
423
+ // sync-join (v0.13) · durable forward tunnel — spoke → hub loopback
424
+ // ════════════════════════════════════════════════════════════════════════════
425
+
426
+ /**
427
+ * Pure builder — the tunnel keeper script. Runs ssh in the FOREGROUND
428
+ * (`-N`, no `-f`): the OS supervisor owns the lifecycle and respawns on any
429
+ * exit. ServerAlive* makes ssh notice a dead peer within ~90s and exit so
430
+ * the supervisor can heal it. ExitOnForwardFailure turns a failed -L bind
431
+ * into an exit (→ respawn with backoff) instead of a silent no-op tunnel.
432
+ * Explicit IPv4 loopback on both sides — a bare port once bound ::1-only
433
+ * and produced a tunnel nobody dialed.
434
+ */
435
+ export function buildTunnelScript({ sshTarget, localPort, remotePort, identity = null }) {
436
+ if (!sshTarget) throw new Error('buildTunnelScript: sshTarget required');
437
+ const lp = Number(localPort) || 8766;
438
+ const rp = Number(remotePort) || 8766;
439
+ // Either '' or a full continuation line — the template line before it always
440
+ // ends in `\\`, so this must NEVER inject a bare ` \\` (a backslash-escaped
441
+ // space becomes a literal space argument to ssh and breaks the tunnel).
442
+ const idFlags = identity ? `\n -i "${identity}" -o IdentitiesOnly=yes \\` : '';
443
+ return `#!/bin/bash
444
+ # memex sync tunnel keeper — generated by \`memex-sync sync-join\`. Do not edit;
445
+ # re-run sync-join to regenerate. Managed by the OS service alongside it.
446
+ exec ssh -N \\
447
+ -o BatchMode=yes \\
448
+ -o StrictHostKeyChecking=accept-new \\
449
+ -o ExitOnForwardFailure=yes \\
450
+ -o ServerAliveInterval=30 \\
451
+ -o ServerAliveCountMax=3 \\${idFlags}
452
+ -L 127.0.0.1:${lp}:127.0.0.1:${rp} \\
453
+ "${sshTarget}"
454
+ `;
455
+ }
456
+
457
+ /** Pure builder — launchd plist for the tunnel keeper (KeepAlive = self-heal). */
458
+ export function buildTunnelLaunchAgentPlist() {
459
+ return `<?xml version="1.0" encoding="UTF-8"?>
460
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
461
+ <plist version="1.0">
462
+ <dict>
463
+ <key>Label</key>
464
+ <string>${TUNNEL_MAC_LABEL}</string>
465
+ <key>ProgramArguments</key>
466
+ <array>
467
+ <string>${TUNNEL_SCRIPT}</string>
468
+ </array>
469
+ <key>RunAtLoad</key><true/>
470
+ <key>KeepAlive</key><true/>
471
+ <key>ThrottleInterval</key><integer>15</integer>
472
+ <key>ProcessType</key><string>Background</string>
473
+ <key>StandardOutPath</key><string>${TUNNEL_OUT_LOG}</string>
474
+ <key>StandardErrorPath</key><string>${TUNNEL_ERR_LOG}</string>
475
+ </dict>
476
+ </plist>
477
+ `;
478
+ }
479
+
480
+ /** Pure builder — systemd-user unit for the tunnel keeper. */
481
+ export function buildTunnelSystemdUnit() {
482
+ return `[Unit]
483
+ Description=memex sync tunnel keeper (self-healing forward SSH tunnel to hub)
484
+ Documentation=https://github.com/parallelclaw/memex-mvp/blob/main/SYNC.md
485
+ After=network-online.target
486
+ Wants=network-online.target
487
+
488
+ [Service]
489
+ Type=simple
490
+ ExecStart=${TUNNEL_SCRIPT}
491
+ Restart=always
492
+ RestartSec=15
493
+ StandardOutput=append:${TUNNEL_OUT_LOG}
494
+ StandardError=append:${TUNNEL_ERR_LOG}
495
+
496
+ [Install]
497
+ WantedBy=default.target
498
+ `;
499
+ }
500
+
501
+ /**
502
+ * Install (or replace) the durable tunnel. Writes the keeper script, then
503
+ * registers the supervisor unit. Idempotent: an existing tunnel is stopped
504
+ * and replaced — which also frees its local port before the new bind.
505
+ */
506
+ export function installSyncTunnel({ sshTarget, localPort, remotePort, identity } = {}) {
507
+ if (!sshTarget) throw new Error('installSyncTunnel: sshTarget required');
508
+ mkdirSync(DATA, { recursive: true });
509
+ writeFileSync(TUNNEL_SCRIPT, buildTunnelScript({ sshTarget, localPort, remotePort, identity }), { mode: 0o755 });
510
+
511
+ if (platform() === 'darwin') {
512
+ try { execSync(`launchctl unload ${JSON.stringify(TUNNEL_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
513
+ mkdirSync(join(HOME, 'Library', 'LaunchAgents'), { recursive: true });
514
+ writeFileSync(TUNNEL_MAC_PLIST, buildTunnelLaunchAgentPlist());
515
+ execSync(`launchctl load ${JSON.stringify(TUNNEL_MAC_PLIST)}`, { stdio: 'inherit' });
516
+ return { platform: 'darwin', unitPath: TUNNEL_MAC_PLIST, scriptPath: TUNNEL_SCRIPT };
517
+ }
518
+ if (platform() === 'linux') {
519
+ try { execSync('systemctl --user --version', { stdio: 'ignore' }); }
520
+ catch (_) { throw new Error('systemctl --user not available — enable lingering: loginctl enable-linger $USER'); }
521
+ mkdirSync(LINUX_DIR, { recursive: true });
522
+ try { execSync(`systemctl --user stop ${TUNNEL_LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
523
+ writeFileSync(TUNNEL_LINUX_PATH, buildTunnelSystemdUnit());
524
+ execSync('systemctl --user daemon-reload', { stdio: 'inherit' });
525
+ execSync(`systemctl --user enable ${TUNNEL_LINUX_UNIT}`, { stdio: 'inherit' });
526
+ execSync(`systemctl --user start ${TUNNEL_LINUX_UNIT}`, { stdio: 'inherit' });
527
+ return { platform: 'linux', unitPath: TUNNEL_LINUX_PATH, scriptPath: TUNNEL_SCRIPT };
528
+ }
529
+ throw new Error(`installSyncTunnel: unsupported platform ${platform()}`);
530
+ }
531
+
532
+ export function uninstallSyncTunnel() {
533
+ if (platform() === 'darwin') {
534
+ try { execSync(`launchctl unload ${JSON.stringify(TUNNEL_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
535
+ if (existsSync(TUNNEL_MAC_PLIST)) unlinkSync(TUNNEL_MAC_PLIST);
536
+ if (existsSync(TUNNEL_SCRIPT)) unlinkSync(TUNNEL_SCRIPT);
537
+ return { platform: 'darwin', unitPath: TUNNEL_MAC_PLIST };
538
+ }
539
+ if (platform() === 'linux') {
540
+ try { execSync(`systemctl --user stop ${TUNNEL_LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
541
+ try { execSync(`systemctl --user disable ${TUNNEL_LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
542
+ if (existsSync(TUNNEL_LINUX_PATH)) unlinkSync(TUNNEL_LINUX_PATH);
543
+ if (existsSync(TUNNEL_SCRIPT)) unlinkSync(TUNNEL_SCRIPT);
544
+ try { execSync('systemctl --user daemon-reload', { stdio: 'ignore' }); } catch (_) {}
545
+ return { platform: 'linux', unitPath: TUNNEL_LINUX_PATH };
546
+ }
547
+ throw new Error(`uninstallSyncTunnel: unsupported platform ${platform()}`);
548
+ }
549
+
550
+ /**
551
+ * { installed, running, manager, unitPath, scriptPath, spec } — best-effort.
552
+ * `spec` is parsed back out of the generated script ({sshTarget, localPort,
553
+ * remotePort}) so status can describe the tunnel without extra state files.
554
+ */
555
+ export function syncTunnelStatus() {
556
+ let spec = null;
557
+ if (existsSync(TUNNEL_SCRIPT)) {
558
+ try {
559
+ const body = readFileSync(TUNNEL_SCRIPT, 'utf-8');
560
+ const m = body.match(/-L 127\.0\.0\.1:(\d+):127\.0\.0\.1:(\d+)[\s\S]*?"([^"]+)"/);
561
+ if (m) spec = { localPort: Number(m[1]), remotePort: Number(m[2]), sshTarget: m[3] };
562
+ } catch (_) {}
563
+ }
564
+ if (platform() === 'darwin') {
565
+ const installed = existsSync(TUNNEL_MAC_PLIST);
566
+ let running = false, detail = '';
567
+ if (installed) {
568
+ try {
569
+ const out = execSync(`launchctl list 2>/dev/null | grep ${TUNNEL_MAC_LABEL} || true`, { encoding: 'utf-8' });
570
+ running = out.trim().length > 0 && !out.trim().startsWith('-');
571
+ detail = out.trim();
572
+ } catch (_) {}
573
+ }
574
+ return { installed, running, manager: 'launchd', unitPath: TUNNEL_MAC_PLIST, scriptPath: TUNNEL_SCRIPT, spec, detail };
575
+ }
576
+ if (platform() === 'linux') {
577
+ const installed = existsSync(TUNNEL_LINUX_PATH);
578
+ let running = false, detail = '';
579
+ if (installed) {
580
+ try {
581
+ detail = execSync(`systemctl --user is-active ${TUNNEL_LINUX_UNIT} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
582
+ running = detail === 'active';
583
+ } catch (_) {}
584
+ }
585
+ return { installed, running, manager: 'systemd-user', unitPath: TUNNEL_LINUX_PATH, scriptPath: TUNNEL_SCRIPT, spec, detail };
586
+ }
587
+ return { installed: false, running: false, manager: 'none', unitPath: null, scriptPath: TUNNEL_SCRIPT, spec, detail: 'unsupported' };
588
+ }
589
+
590
+ // ════════════════════════════════════════════════════════════════════════════
591
+ // sync-join (v0.13) · watchdog timer — hourly silent-failure detector
592
+ // ════════════════════════════════════════════════════════════════════════════
593
+
594
+ /** Pure builder — launchd plist running `sync-watchdog` every N minutes. */
595
+ export function buildWatchdogLaunchAgentPlist({ script, mins = 60, nodePath }) {
596
+ const argXml = [nodePath, script, 'sync-watchdog']
597
+ .map((a) => ` <string>${a}</string>`).join('\n');
598
+ return `<?xml version="1.0" encoding="UTF-8"?>
599
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
600
+ <plist version="1.0">
601
+ <dict>
602
+ <key>Label</key>
603
+ <string>${WD_MAC_LABEL}</string>
604
+ <key>ProgramArguments</key>
605
+ <array>
606
+ ${argXml}
607
+ </array>
608
+ <key>EnvironmentVariables</key>
609
+ <dict>
610
+ <key>MEMEX_SYNC_EXPERIMENTAL</key><string>1</string>
611
+ <key>HOME</key><string>${HOME}</string>
612
+ <key>MEMEX_DIR</key><string>${MEMEX_DIR}</string>
613
+ </dict>
614
+ <key>RunAtLoad</key><true/>
615
+ <key>StartInterval</key><integer>${Math.max(60, Math.floor(mins * 60))}</integer>
616
+ <key>ProcessType</key><string>Background</string>
617
+ <key>StandardOutPath</key><string>${WD_OUT_LOG}</string>
618
+ <key>StandardErrorPath</key><string>${WD_ERR_LOG}</string>
619
+ <key>WorkingDirectory</key><string>${resolve(script, '..')}</string>
620
+ </dict>
621
+ </plist>
622
+ `;
623
+ }
624
+
625
+ /** Pure builders — systemd oneshot + timer for the watchdog. */
626
+ export function buildWatchdogSystemdService({ script, nodePath }) {
627
+ return `[Unit]
628
+ Description=memex sync — one watchdog pass (detect silent sync failure)
629
+
630
+ [Service]
631
+ Type=oneshot
632
+ ExecStart=${nodePath} ${script} sync-watchdog
633
+ WorkingDirectory=${resolve(script, '..')}
634
+ Environment=MEMEX_SYNC_EXPERIMENTAL=1
635
+ Environment=HOME=${HOME}
636
+ Environment=MEMEX_DIR=${MEMEX_DIR}
637
+ StandardOutput=append:${WD_OUT_LOG}
638
+ StandardError=append:${WD_ERR_LOG}
639
+ `;
640
+ }
641
+
642
+ export function buildWatchdogSystemdTimer({ mins = 60 }) {
643
+ return `[Unit]
644
+ Description=memex sync — watchdog every ${mins}m
645
+
646
+ [Timer]
647
+ OnBootSec=5min
648
+ OnUnitActiveSec=${mins}min
649
+ AccuracySec=1min
650
+ Persistent=true
651
+
652
+ [Install]
653
+ WantedBy=timers.target
654
+ `;
655
+ }
656
+
657
+ export function installSyncWatchdog({ scriptPath, everyMinutes = 60, nodePath = process.execPath } = {}) {
658
+ const script = resolve(scriptPath || process.argv[1]);
659
+ if (!existsSync(script)) throw new Error(`installSyncWatchdog: script not found at ${script}`);
660
+ const mins = Math.max(5, Math.floor(Number(everyMinutes) || 60));
661
+ mkdirSync(DATA, { recursive: true });
662
+
663
+ if (platform() === 'darwin') {
664
+ try { execSync(`launchctl unload ${JSON.stringify(WD_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
665
+ mkdirSync(join(HOME, 'Library', 'LaunchAgents'), { recursive: true });
666
+ writeFileSync(WD_MAC_PLIST, buildWatchdogLaunchAgentPlist({ script, mins, nodePath }));
667
+ execSync(`launchctl load ${JSON.stringify(WD_MAC_PLIST)}`, { stdio: 'inherit' });
668
+ return { platform: 'darwin', unitPath: WD_MAC_PLIST, everyMinutes: mins };
669
+ }
670
+ if (platform() === 'linux') {
671
+ try { execSync('systemctl --user --version', { stdio: 'ignore' }); }
672
+ catch (_) { throw new Error('systemctl --user not available — enable lingering: loginctl enable-linger $USER'); }
673
+ mkdirSync(LINUX_DIR, { recursive: true });
674
+ writeFileSync(WD_SERVICE_PATH, buildWatchdogSystemdService({ script, nodePath }));
675
+ writeFileSync(WD_TIMER_PATH, buildWatchdogSystemdTimer({ mins }));
676
+ execSync('systemctl --user daemon-reload', { stdio: 'inherit' });
677
+ execSync(`systemctl --user enable ${WD_LINUX_TIMER}`, { stdio: 'inherit' });
678
+ execSync(`systemctl --user start ${WD_LINUX_TIMER}`, { stdio: 'inherit' });
679
+ return { platform: 'linux', unitPath: WD_TIMER_PATH, everyMinutes: mins };
680
+ }
681
+ throw new Error(`installSyncWatchdog: unsupported platform ${platform()}`);
682
+ }
683
+
684
+ export function uninstallSyncWatchdog() {
685
+ if (platform() === 'darwin') {
686
+ try { execSync(`launchctl unload ${JSON.stringify(WD_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
687
+ if (existsSync(WD_MAC_PLIST)) unlinkSync(WD_MAC_PLIST);
688
+ return { platform: 'darwin', unitPath: WD_MAC_PLIST };
689
+ }
690
+ if (platform() === 'linux') {
691
+ try { execSync(`systemctl --user stop ${WD_LINUX_TIMER}`, { stdio: 'ignore' }); } catch (_) {}
692
+ try { execSync(`systemctl --user disable ${WD_LINUX_TIMER}`, { stdio: 'ignore' }); } catch (_) {}
693
+ if (existsSync(WD_TIMER_PATH)) unlinkSync(WD_TIMER_PATH);
694
+ if (existsSync(WD_SERVICE_PATH)) unlinkSync(WD_SERVICE_PATH);
695
+ try { execSync('systemctl --user daemon-reload', { stdio: 'ignore' }); } catch (_) {}
696
+ return { platform: 'linux', unitPath: WD_TIMER_PATH };
697
+ }
698
+ throw new Error(`uninstallSyncWatchdog: unsupported platform ${platform()}`);
699
+ }
700
+
701
+ export function syncWatchdogStatus() {
702
+ if (platform() === 'darwin') {
703
+ const installed = existsSync(WD_MAC_PLIST);
704
+ let running = false, detail = '';
705
+ if (installed) {
706
+ try {
707
+ const out = execSync(`launchctl list 2>/dev/null | grep ${WD_MAC_LABEL} || true`, { encoding: 'utf-8' });
708
+ running = out.trim().length > 0;
709
+ detail = out.trim();
710
+ } catch (_) {}
711
+ }
712
+ return { installed, running, manager: 'launchd', unitPath: WD_MAC_PLIST, detail };
713
+ }
714
+ if (platform() === 'linux') {
715
+ const installed = existsSync(WD_TIMER_PATH);
716
+ let running = false, detail = '';
717
+ if (installed) {
718
+ try {
719
+ detail = execSync(`systemctl --user is-active ${WD_LINUX_TIMER} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
720
+ running = detail === 'active';
721
+ } catch (_) {}
722
+ }
723
+ return { installed, running, manager: 'systemd-user', unitPath: WD_TIMER_PATH, detail };
724
+ }
725
+ return { installed: false, running: false, manager: 'none', unitPath: null, detail: 'unsupported' };
726
+ }