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,906 @@
1
+ /**
2
+ * CLI subcommands for memex sync (v0.11.11 experimental tracer-bullet).
3
+ *
4
+ * Wired into ingest.js's subcommand dispatcher under:
5
+ * memex-sync sync-server — start the server side (replaces config),
6
+ * prints bearer + cert fingerprint to copy
7
+ * memex-sync sync-add — register a remote on the client side
8
+ * memex-sync sync-list — list configured remotes + their cursors
9
+ * memex-sync sync-run — execute one bidirectional sync round
10
+ * memex-sync sync-status — show cursor state across remotes
11
+ *
12
+ * All commands refuse to operate unless MEMEX_SYNC_EXPERIMENTAL=1 (or 'true').
13
+ *
14
+ * v0.12+ will replace these with a polished `memex sync ...` namespace
15
+ * (under server.js entry, not ingest.js), with adaptive setup wizard.
16
+ */
17
+
18
+ import {
19
+ syncExperimentEnabled,
20
+ loadSyncConfig,
21
+ upsertSyncRemote,
22
+ getSyncRemote,
23
+ removeSyncRemote,
24
+ listSyncRemotes,
25
+ } from './config.js';
26
+ import { startSyncServer, DEFAULT_SYNC_PORT } from './server.js';
27
+ import { replicateOnce } from './replicate.js';
28
+
29
+ /** Print the "you forgot to enable the env var" banner and exit. */
30
+ function refuseUnlessEnabled() {
31
+ if (syncExperimentEnabled()) return;
32
+ console.error('memex sync is experimental in v0.11.11.');
33
+ console.error('Enable with:');
34
+ console.error(' export MEMEX_SYNC_EXPERIMENTAL=1');
35
+ console.error('');
36
+ console.error('Wire protocol may change before v0.12 — pin your memex');
37
+ console.error('version on both peers if you use this in production.');
38
+ process.exit(2);
39
+ }
40
+
41
+ // ── sync-server ─────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * `memex-sync sync-server start [--port N] [--bind ADDR]`
45
+ *
46
+ * Foreground HTTPS server. Prints bearer + cert fingerprint to stdout — the
47
+ * operator copies these to the peer's `sync-add` command.
48
+ *
49
+ * For tracer-bullet: foreground only. Backgrounding via LaunchAgent/systemd
50
+ * lands when we wire memex-sync install to know about sync.
51
+ */
52
+ export async function cmdSyncServer() {
53
+ refuseUnlessEnabled();
54
+ const sub = process.argv[3];
55
+ const args = parseFlags(process.argv.slice(4));
56
+
57
+ switch (sub) {
58
+ case 'start': {
59
+ const port = parseInt(args['--port'] || '', 10) || undefined;
60
+ const bind = args['--bind'] || undefined;
61
+
62
+ console.log('Starting memex sync server (foreground)…');
63
+ const result = await startSyncServer({ port, bind });
64
+ console.log(`✓ Listening on ${bind || result.server.address().address}:${result.port}`);
65
+ console.log('');
66
+ console.log('Pair credentials — paste into the other host:');
67
+ console.log(` memex-sync sync-add <alias> https://<host>:${result.port} ${result.bearer} --insecure`);
68
+ console.log('');
69
+ console.log(`Cert fingerprint: ${result.fingerprint}`);
70
+ console.log(`Bearer (256-bit): ${result.bearer}`);
71
+ if (result.bearerMinted) {
72
+ console.log('');
73
+ console.log('(bearer minted on first start — stored in ~/.memex/config.json)');
74
+ }
75
+ console.log('');
76
+ console.log('Server running. Press Ctrl+C to stop.');
77
+
78
+ // Keep alive on Ctrl+C / kill.
79
+ process.on('SIGINT', () => { console.log('\nshutting down…'); result.server.close(() => process.exit(0)); });
80
+ process.on('SIGTERM', () => { result.server.close(() => process.exit(0)); });
81
+
82
+ // CRITICAL: return a promise that never resolves. The ingest.js
83
+ // dispatcher does `await handler(); process.exit(0)` for non-fallthrough
84
+ // commands — if we returned normally here, that process.exit(0) would
85
+ // kill the freshly-started server. A never-resolving promise keeps the
86
+ // dispatcher awaiting forever; the process stays alive on the HTTPS
87
+ // server's event loop until SIGINT/SIGTERM fires the handlers above.
88
+ return new Promise(() => { /* never resolves — foreground server */ });
89
+ }
90
+ case 'install': {
91
+ // Register the server as a managed service (systemd-user / LaunchAgent)
92
+ // so it survives reboot and auto-restarts on crash. Port/bind are baked
93
+ // in from flags or existing config.
94
+ const { installSyncServerService, syncServerServiceStatus } =
95
+ await import('./service.js');
96
+ const cfg = loadSyncConfig();
97
+ const port = parseInt(args['--port'] || '', 10) || cfg.server.port || undefined;
98
+ const bind = args['--bind'] || cfg.server.bind || undefined;
99
+
100
+ console.log(`Installing sync-server as a managed service (port ${port}, bind ${bind})…`);
101
+ try {
102
+ const r = installSyncServerService({ port, bind });
103
+ const st = syncServerServiceStatus();
104
+ console.log(`✓ installed via ${r.platform === 'darwin' ? 'LaunchAgent' : 'systemd-user'}`);
105
+ console.log(` unit: ${r.unitPath}`);
106
+ console.log(` running: ${st.running ? 'yes' : '(check status)'}`);
107
+ console.log('');
108
+ console.log('Server now survives reboot + auto-restarts on crash.');
109
+ console.log('Bearer + cert persist on disk, so paired peers keep working.');
110
+ console.log('Logs: ~/.memex/data/sync-server.{out,err}.log');
111
+ } catch (e) {
112
+ console.error(`✗ install failed: ${e.message}`);
113
+ process.exit(1);
114
+ }
115
+ process.exit(0);
116
+ }
117
+ case 'uninstall': {
118
+ const { uninstallSyncServerService } = await import('./service.js');
119
+ console.log('Removing sync-server service…');
120
+ try {
121
+ const r = uninstallSyncServerService();
122
+ console.log(`✓ uninstalled (${r.unitPath})`);
123
+ console.log('Data, bearer, and cert are preserved — only the service is gone.');
124
+ } catch (e) {
125
+ console.error(`✗ uninstall failed: ${e.message}`);
126
+ process.exit(1);
127
+ }
128
+ process.exit(0);
129
+ }
130
+ case 'invite': {
131
+ // Ensure cert + bearer exist (without starting the server), then print
132
+ // a single pair blob that bundles host/port/cert_fp/token. The operator
133
+ // pastes it into `sync pair` on the other machine.
134
+ const { ensureCert } = await import('./cert.js');
135
+ const { generateBearerToken } = await import('./auth.js');
136
+ const { updateSyncServer } = await import('./config.js');
137
+ const { encodePairBlob, encodeJoinBlob, DEFAULT_PAIR_TTL_SEC, DEFAULT_JOIN_TTL_SEC } = await import('./pair.js');
138
+ const { homedir, userInfo } = await import('node:os');
139
+ const { join } = await import('node:path');
140
+
141
+ const cfg = loadSyncConfig();
142
+ const MEMEX_DIR = process.env.MEMEX_DIR || join(homedir(), '.memex');
143
+ const certPath = cfg.server.cert_path || join(MEMEX_DIR, 'sync-cert.pem');
144
+ const keyPath = cfg.server.key_path || join(MEMEX_DIR, 'sync-key.pem');
145
+
146
+ const certInfo = await ensureCert({ certPath, keyPath });
147
+ let bearer = cfg.server.bearer;
148
+ if (!bearer) { bearer = generateBearerToken(); }
149
+
150
+ const port = parseInt(args['--port'] || '', 10) || cfg.server.port || DEFAULT_SYNC_PORT;
151
+ // Persist so a later `sync-server start/install` reuses the same creds.
152
+ updateSyncServer({ bearer, cert_path: certPath, key_path: keyPath, cert_fp: certInfo.fingerprint, port });
153
+
154
+ let host = args['--host'];
155
+ let hostNote = '';
156
+ if (!host) {
157
+ host = await detectPublicIp();
158
+ if (host) hostNote = `(auto-detected public IP — override with --host if you use an SSH tunnel [localhost] or Tailscale [magicdns name])`;
159
+ }
160
+ if (!host) {
161
+ console.error('Could not auto-detect a public host. Pass --host explicitly:');
162
+ console.error(' • public IP: memex-sync sync-server invite --host 203.0.113.5');
163
+ console.error(' • SSH tunnel: memex-sync sync-server invite --host localhost');
164
+ console.error(' • Tailscale: memex-sync sync-server invite --host my-vps.tail-xxxx.ts.net');
165
+ process.exit(1);
166
+ }
167
+
168
+ // --join: emit a JOIN token (v0.13 lazy flow). Carries ssh_target so the
169
+ // other machine builds a forward tunnel to THIS host's loopback server —
170
+ // no public port ever needed. Default target: <this-user>@<public-ip>.
171
+ if ('--join' in args) {
172
+ const ttlSec = parseInt(args['--ttl'] || '', 10) || DEFAULT_JOIN_TTL_SEC;
173
+ let sshTarget = args['--ssh-target'];
174
+ if (!sshTarget) {
175
+ const user = process.env.USER || userInfo().username;
176
+ sshTarget = `${user}@${host}`;
177
+ }
178
+ const blob = encodeJoinBlob({ ssh_target: sshTarget, port, cert_fp: certInfo.fingerprint, token: bearer, ttlSec });
179
+ console.log('Join token (valid ' + Math.round(ttlSec / 60) + ' min) — paste on the other machine:');
180
+ console.log('');
181
+ console.log(' memex-sync sync-join ' + blob);
182
+ console.log('');
183
+ console.log(`ssh target: ${sshTarget} server: 127.0.0.1:${port} (loopback — reached via tunnel)`);
184
+ console.log(`fingerprint: ${certInfo.fingerprint}`);
185
+ if ((cfg.server.bind || '0.0.0.0') !== '127.0.0.1') {
186
+ console.log('');
187
+ console.log('Note: for the canonical loopback-hub setup, install the server with');
188
+ console.log(' memex-sync sync-server install --bind 127.0.0.1');
189
+ }
190
+ process.exit(0);
191
+ }
192
+
193
+ const ttlSec = parseInt(args['--ttl'] || '', 10) || DEFAULT_PAIR_TTL_SEC;
194
+ const blob = encodePairBlob({ host, port, cert_fp: certInfo.fingerprint, token: bearer, ttlSec });
195
+
196
+ console.log('Pair blob (valid ' + Math.round(ttlSec / 60) + ' min) — paste on the other machine:');
197
+ console.log('');
198
+ console.log(' memex-sync sync-pair ' + blob);
199
+ console.log('');
200
+ console.log(`host: ${host}:${port} ${hostNote}`);
201
+ console.log(`fingerprint: ${certInfo.fingerprint}`);
202
+ console.log('');
203
+ console.log('Make sure the server is actually running/reachable on that host:port');
204
+ console.log(' (memex-sync sync-server start or sync-server install)');
205
+ process.exit(0);
206
+ }
207
+ case 'status': {
208
+ const cfg = loadSyncConfig();
209
+ const { syncServerServiceStatus } = await import('./service.js');
210
+ const svc = syncServerServiceStatus();
211
+ console.log('config:');
212
+ console.log(` enabled: ${cfg.server.enabled ? 'yes' : 'no'}`);
213
+ console.log(` port: ${cfg.server.port}`);
214
+ console.log(` bind: ${cfg.server.bind}`);
215
+ console.log(` cert fingerprint: ${cfg.server.cert_fp || '(not generated yet)'}`);
216
+ console.log(` bearer: ${cfg.server.bearer ? '(configured)' : '(none)'}`);
217
+ console.log('');
218
+ console.log('service:');
219
+ console.log(` manager: ${svc.manager}`);
220
+ console.log(` installed: ${svc.installed ? 'yes' : 'no'}`);
221
+ console.log(` running: ${svc.running ? 'yes' : 'no'}${svc.detail ? ' (' + svc.detail + ')' : ''}`);
222
+ console.log(` unit: ${svc.unitPath || '(n/a)'}`);
223
+ if (!svc.installed) {
224
+ console.log('');
225
+ console.log('Not installed as a service. Either:');
226
+ console.log(' • foreground: memex-sync sync-server start');
227
+ console.log(' • durable: memex-sync sync-server install');
228
+ }
229
+ process.exit(0);
230
+ }
231
+ default:
232
+ console.error('usage:');
233
+ console.error(' memex-sync sync-server start [--port 8766] [--bind 0.0.0.0] foreground');
234
+ console.error(' memex-sync sync-server install [--port 8766] [--bind 0.0.0.0] durable service');
235
+ console.error(' memex-sync sync-server invite [--host H] [--port N] [--ttl 600] print pair blob');
236
+ console.error(' memex-sync sync-server invite --join [--ssh-target u@h] print JOIN token (lazy flow)');
237
+ console.error(' memex-sync sync-server uninstall remove service');
238
+ console.error(' memex-sync sync-server status config + service state');
239
+ process.exit(2);
240
+ }
241
+ }
242
+
243
+ // ── sync-add / sync-list / sync-remove ──────────────────────────────────────
244
+
245
+ /**
246
+ * `memex-sync sync-add <alias> <url> <bearer> [--insecure] [--cert-fp <fp>]`
247
+ *
248
+ * Registers a remote in ~/.memex/config.json sync.remotes.<alias>.
249
+ *
250
+ * `--insecure` is the tracer-bullet escape hatch — skips TLS cert validation
251
+ * entirely. Use only over SSH tunnel or Tailscale, where transport is
252
+ * already encrypted. v0.12 will require either --cert-fp or pair blob.
253
+ */
254
+ export function cmdSyncAdd() {
255
+ refuseUnlessEnabled();
256
+ const alias = process.argv[3];
257
+ const url = process.argv[4];
258
+ const bearer = process.argv[5];
259
+ const args = parseFlags(process.argv.slice(6));
260
+
261
+ if (!alias || !url || !bearer) {
262
+ console.error('usage: memex-sync sync-add <alias> <url> <bearer> [--insecure] [--cert-fp <fp>]');
263
+ process.exit(2);
264
+ }
265
+ if (!/^[a-z0-9_-]+$/i.test(alias)) {
266
+ console.error('alias must match [a-zA-Z0-9_-]+');
267
+ process.exit(2);
268
+ }
269
+ if (!/^https?:\/\//.test(url)) {
270
+ console.error('url must start with http:// or https://');
271
+ process.exit(2);
272
+ }
273
+ if (!/^[0-9a-fA-F]{32,}$/.test(bearer)) {
274
+ console.error('bearer must be a hex string (32+ chars)');
275
+ process.exit(2);
276
+ }
277
+
278
+ const insecure = '--insecure' in args;
279
+ const cert_fp = args['--cert-fp'] || null;
280
+ if (!insecure && !cert_fp) {
281
+ console.error('refusing to add a remote without --insecure or --cert-fp.');
282
+ console.error('use --insecure if you trust the transport (SSH tunnel / Tailscale)');
283
+ console.error('or pass --cert-fp <sha256:AA:BB:...> to pin the server cert.');
284
+ process.exit(2);
285
+ }
286
+
287
+ upsertSyncRemote(alias, {
288
+ url, bearer: bearer.toLowerCase(),
289
+ insecure, cert_fp,
290
+ pulled_to: 0, pushed_to: 0,
291
+ last_sync_at: 0, last_error: null,
292
+ });
293
+ console.log(`✓ remote "${alias}" added (${url}, ${insecure ? 'insecure' : 'pinned'})`);
294
+ process.exit(0);
295
+ }
296
+
297
+ /**
298
+ * `memex-sync sync-pair <memex-pair:...> [--alias vps]`
299
+ *
300
+ * The one-paste counterpart to sync-add: decodes a pair blob (host, port,
301
+ * cert_fp, token) and registers the remote. Validates version + expiry.
302
+ */
303
+ export async function cmdSyncPair() {
304
+ refuseUnlessEnabled();
305
+ const blob = process.argv[3];
306
+ const args = parseFlags(process.argv.slice(4));
307
+ if (!blob) {
308
+ console.error('usage: memex-sync sync-pair <memex-pair:...> [--alias <name>]');
309
+ process.exit(2);
310
+ }
311
+ const { parsePairBlob } = await import('./pair.js');
312
+ let parsed;
313
+ try {
314
+ parsed = parsePairBlob(blob);
315
+ } catch (e) {
316
+ console.error(`✗ ${e.message}`);
317
+ process.exit(2);
318
+ }
319
+ const alias = args['--alias'] || 'vps';
320
+ upsertSyncRemote(alias, {
321
+ url: parsed.url,
322
+ bearer: parsed.token.toLowerCase(),
323
+ cert_fp: parsed.cert_fp,
324
+ insecure: !parsed.cert_fp, // no fingerprint in blob → transport-trusted
325
+ pulled_to: 0, pushed_to: 0,
326
+ last_sync_at: 0, last_error: null,
327
+ });
328
+ console.log(`✓ paired remote "${alias}" → ${parsed.url}`);
329
+ console.log(` transport: ${parsed.cert_fp ? 'TLS pinned (' + parsed.cert_fp.slice(0, 23) + '…)' : 'insecure (no fingerprint in blob)'}`);
330
+ console.log('');
331
+ console.log('Test it now: memex-sync sync-run ' + alias);
332
+ console.log('Automate it: memex-sync sync-schedule install --every 15m');
333
+ process.exit(0);
334
+ }
335
+
336
+ export function cmdSyncList() {
337
+ refuseUnlessEnabled();
338
+ const remotes = listSyncRemotes();
339
+ const keys = Object.keys(remotes);
340
+ if (keys.length === 0) {
341
+ console.log('No remotes configured.');
342
+ console.log('Add one with: memex-sync sync-add <alias> <url> <bearer> --insecure');
343
+ process.exit(0);
344
+ }
345
+ for (const alias of keys) {
346
+ const r = remotes[alias];
347
+ console.log(`${alias}`);
348
+ console.log(` url: ${r.url}`);
349
+ console.log(` pulled_to: ${r.pulled_to ?? 0}`);
350
+ console.log(` pushed_to: ${r.pushed_to ?? 0}`);
351
+ console.log(` last_sync_at: ${r.last_sync_at ? new Date(r.last_sync_at).toISOString() : 'never'}`);
352
+ if (r.last_error) console.log(` last_error: ${r.last_error}`);
353
+ console.log(` transport: ${r.insecure ? 'insecure (skip TLS check)' : 'pinned ' + (r.cert_fp || '?')}`);
354
+ console.log('');
355
+ }
356
+ process.exit(0);
357
+ }
358
+
359
+ export function cmdSyncRemove() {
360
+ refuseUnlessEnabled();
361
+ const alias = process.argv[3];
362
+ if (!alias) {
363
+ console.error('usage: memex-sync sync-remove <alias>');
364
+ process.exit(2);
365
+ }
366
+ const ok = removeSyncRemote(alias);
367
+ console.log(ok ? `✓ removed "${alias}"` : `no remote "${alias}"`);
368
+ process.exit(0);
369
+ }
370
+
371
+ // ── sync-run / sync-status ──────────────────────────────────────────────────
372
+
373
+ export async function cmdSyncRun() {
374
+ refuseUnlessEnabled();
375
+ const arg = process.argv[3];
376
+
377
+ // `--all` syncs every configured remote in sequence. This is what the
378
+ // scheduled timer (sync-schedule) invokes — one tick covers all peers.
379
+ if (arg === '--all') {
380
+ const remotes = Object.keys(listSyncRemotes());
381
+ if (remotes.length === 0) {
382
+ console.log('No remotes configured — nothing to sync.');
383
+ process.exit(0);
384
+ }
385
+ let anyFailed = false;
386
+ for (const alias of remotes) {
387
+ const ok = await runOneRemote(alias);
388
+ if (!ok) anyFailed = true;
389
+ }
390
+ process.exit(anyFailed ? 1 : 0);
391
+ }
392
+
393
+ const alias = arg;
394
+ if (!alias) {
395
+ console.error('usage: memex-sync sync-run <alias> (or --all for every remote)');
396
+ process.exit(2);
397
+ }
398
+ if (!getSyncRemote(alias)) {
399
+ console.error(`no remote "${alias}". configure with sync-add first.`);
400
+ process.exit(2);
401
+ }
402
+ const ok = await runOneRemote(alias);
403
+ process.exit(ok ? 0 : 1);
404
+ }
405
+
406
+ /**
407
+ * Replicate one remote, print a compact report. Returns true on success,
408
+ * false on failure (caller decides exit code — used by both single and --all).
409
+ * Never throws — failures are logged so --all can continue to the next peer.
410
+ */
411
+ async function runOneRemote(alias) {
412
+ console.log(`replicating "${alias}"…`);
413
+ try {
414
+ const stats = await replicateOnce({ alias });
415
+ console.log(`✓ peer ${alias} is alive (memex v${stats.peer_version})`);
416
+ console.log(` pulled ${stats.pulled.rows} rows · accepted=${stats.pulled.accepted} dedup=${stats.pulled.deduplicated}${stats.pulled.skipped ? ' skipped=' + stats.pulled.skipped : ''}`);
417
+ console.log(` pushed ${stats.pushed.rows} rows · accepted=${stats.pushed.accepted} dedup=${stats.pushed.deduplicated}`);
418
+ console.log(` cursor pull ${stats.cursors_before.pulled_to}→${stats.cursors_after.pulled_to} push ${stats.cursors_before.pushed_to}→${stats.cursors_after.pushed_to} (${stats.elapsed_ms}ms)`);
419
+ return true;
420
+ } catch (err) {
421
+ console.error(`✗ ${alias}: ${err.message}`);
422
+ return false;
423
+ }
424
+ }
425
+
426
+ /**
427
+ * `memex-sync sync-schedule install [--every 15m] | uninstall | status`
428
+ *
429
+ * Registers a recurring timer (LaunchAgent StartInterval on macOS, systemd
430
+ * .timer on Linux) that runs `sync-run --all` every N minutes. This is the
431
+ * Phase 3 deliverable — turns manual sync into hands-off auto-sync.
432
+ */
433
+ export async function cmdSyncSchedule() {
434
+ refuseUnlessEnabled();
435
+ const sub = process.argv[3];
436
+ const args = parseFlags(process.argv.slice(4));
437
+ const {
438
+ installSyncSchedule, uninstallSyncSchedule, syncScheduleStatus,
439
+ } = await import('./service.js');
440
+
441
+ switch (sub) {
442
+ case 'install': {
443
+ if (Object.keys(listSyncRemotes()).length === 0) {
444
+ console.error('No remotes configured yet — add one before scheduling:');
445
+ console.error(' memex-sync sync-add <alias> <url> <bearer> --cert-fp <fp>');
446
+ process.exit(2);
447
+ }
448
+ const everyMinutes = parseInterval(args['--every']) || 15;
449
+ console.log(`Installing auto-sync schedule (every ${everyMinutes}m)…`);
450
+ try {
451
+ const r = installSyncSchedule({ everyMinutes });
452
+ console.log(`✓ scheduled via ${r.platform === 'darwin' ? 'LaunchAgent' : 'systemd timer'} (every ${r.everyMinutes}m)`);
453
+ console.log(` unit: ${r.unitPath}`);
454
+ console.log(` runs: sync-run --all`);
455
+ console.log(` logs: ~/.memex/data/sync-schedule.{out,err}.log`);
456
+ console.log('');
457
+ console.log('Auto-sync is now hands-off. New conversations propagate within the interval.');
458
+ } catch (e) {
459
+ console.error(`✗ schedule install failed: ${e.message}`);
460
+ process.exit(1);
461
+ }
462
+ process.exit(0);
463
+ }
464
+ case 'uninstall': {
465
+ try {
466
+ const r = uninstallSyncSchedule();
467
+ console.log(`✓ auto-sync schedule removed (${r.unitPath})`);
468
+ } catch (e) {
469
+ console.error(`✗ ${e.message}`);
470
+ process.exit(1);
471
+ }
472
+ process.exit(0);
473
+ }
474
+ case 'status': {
475
+ const st = syncScheduleStatus();
476
+ console.log(`manager: ${st.manager}`);
477
+ console.log(`installed: ${st.installed ? 'yes' : 'no'}`);
478
+ console.log(`running: ${st.running ? 'yes' : 'no'}${st.detail ? ' (' + st.detail + ')' : ''}`);
479
+ console.log(`unit: ${st.unitPath || '(n/a)'}`);
480
+ process.exit(0);
481
+ }
482
+ default:
483
+ console.error('usage:');
484
+ console.error(' memex-sync sync-schedule install [--every 15m] start hands-off auto-sync');
485
+ console.error(' memex-sync sync-schedule uninstall stop auto-sync');
486
+ console.error(' memex-sync sync-schedule status timer state');
487
+ process.exit(2);
488
+ }
489
+ }
490
+
491
+ export async function cmdSyncStatus() {
492
+ refuseUnlessEnabled();
493
+ const cfg = loadSyncConfig();
494
+ const { syncScheduleStatus } = await import('./service.js');
495
+ console.log('server side:');
496
+ console.log(` enabled: ${cfg.server.enabled ? 'yes' : 'no'}`);
497
+ console.log(` port: ${cfg.server.port}`);
498
+ console.log(` bind: ${cfg.server.bind}`);
499
+ console.log(` fingerprint: ${cfg.server.cert_fp || '(not generated)'}`);
500
+ console.log('');
501
+ console.log('auto-sync schedule:');
502
+ const sched = syncScheduleStatus();
503
+ console.log(` installed: ${sched.installed ? 'yes' : 'no'}`);
504
+ console.log(` running: ${sched.running ? 'yes' : 'no'}`);
505
+ console.log('');
506
+ const { syncTunnelStatus, syncWatchdogStatus } = await import('./service.js');
507
+ const tun = syncTunnelStatus();
508
+ console.log('tunnel keeper:');
509
+ if (!tun.installed) {
510
+ console.log(' installed: no (direct connection or hub side)');
511
+ } else {
512
+ console.log(` installed: yes`);
513
+ console.log(` running: ${tun.running ? 'yes (self-healing)' : 'NO — check ~/.memex/data/sync-tunnel.err.log'}`);
514
+ if (tun.spec) console.log(` route: 127.0.0.1:${tun.spec.localPort} → ${tun.spec.sshTarget} → 127.0.0.1:${tun.spec.remotePort}`);
515
+ }
516
+ console.log('');
517
+ const wd = syncWatchdogStatus();
518
+ console.log('watchdog:');
519
+ console.log(` installed: ${wd.installed ? 'yes (hourly)' : 'no'}`);
520
+ console.log('');
521
+ console.log('remotes:');
522
+ const remotes = listSyncRemotes();
523
+ const keys = Object.keys(remotes);
524
+ if (keys.length === 0) {
525
+ console.log(' (none configured)');
526
+ process.exit(0);
527
+ }
528
+ const now = Date.now();
529
+ for (const alias of keys) {
530
+ const r = remotes[alias];
531
+ const last = r.last_sync_at
532
+ ? `${new Date(r.last_sync_at).toISOString()} (${Math.round((now - r.last_sync_at) / 60000)}m ago)`
533
+ : 'never';
534
+ console.log(` ${alias.padEnd(16)} pull→${(r.pulled_to ?? 0).toString().padStart(8)} push→${(r.pushed_to ?? 0).toString().padStart(8)}`);
535
+ console.log(` ${' '.repeat(16)} last: ${last}`);
536
+ if (r.last_error) console.log(` ${' '.repeat(16)} ⚠ ERROR: ${r.last_error}`);
537
+ }
538
+ process.exit(0);
539
+ }
540
+
541
+ // ── sync-join (v0.13) — the lazy-user orchestrator ──────────────────────────
542
+
543
+ /**
544
+ * `memex-sync sync-join <memex-join:...|memex-pair:...> [--alias vps]
545
+ * [--local-port N] [--every 15m] [--no-watchdog] [--no-selftest]`
546
+ *
547
+ * One command on the spoke that glues the whole canonical setup together:
548
+ * parse token → SSH probe → durable -L tunnel → verify pinned cert → register
549
+ * remote → first sync → schedule → watchdog → marker self-test → done.
550
+ * Every step is idempotent; re-running replaces the tunnel and re-verifies.
551
+ *
552
+ * Join is the STABLE lazy path: it self-enables the experimental gate for
553
+ * this process and persists sync.enabled=true on success, so the user never
554
+ * has to know MEMEX_SYNC_EXPERIMENTAL exists.
555
+ */
556
+ export async function cmdSyncJoin() {
557
+ process.env.MEMEX_SYNC_EXPERIMENTAL = '1';
558
+ const blob = process.argv[3];
559
+ const args = parseFlags(process.argv.slice(4));
560
+ if (!blob) {
561
+ console.error('usage: memex-sync sync-join <memex-join:...> [--alias vps] [--local-port N] [--every 15m] [--no-watchdog] [--no-selftest]');
562
+ console.error('');
563
+ console.error('Get a token from the hub machine (or its agent):');
564
+ console.error(' memex-sync sync-server invite --join');
565
+ process.exit(2);
566
+ }
567
+
568
+ const { parsePairBlob } = await import('./pair.js');
569
+ let t;
570
+ try { t = parsePairBlob(blob); }
571
+ catch (e) { console.error(`✗ ${e.message}`); process.exit(2); }
572
+
573
+ const alias = args['--alias'] || 'vps';
574
+ const everyMinutes = parseInterval(args['--every']) || 15;
575
+ const expMin = t.exp ? Math.max(0, Math.round((t.exp * 1000 - Date.now()) / 60000)) : null;
576
+ console.log(`✓ token valid (${t.kind === 'join' ? t.ssh_target : t.host + ':' + t.port}${expMin != null ? `, expires in ${expMin}m` : ''})`);
577
+
578
+ let url;
579
+ if (t.kind === 'join') {
580
+ // 1. SSH reachability — the only credential the user might lack.
581
+ const { spawnSync } = await import('node:child_process');
582
+ const probe = spawnSync('ssh', [
583
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
584
+ '-o', 'StrictHostKeyChecking=accept-new', t.ssh_target, 'true',
585
+ ], { encoding: 'utf-8' });
586
+ if (probe.status !== 0) {
587
+ console.error(`✗ SSH to ${t.ssh_target} failed (${(probe.stderr || '').trim().split('\n')[0] || 'no access'})`);
588
+ console.error('');
589
+ const pub = await ensureSshPubkey();
590
+ if (pub) {
591
+ console.error('Give this public key to the hub machine (its agent can add it');
592
+ console.error('to ~/.ssh/authorized_keys), then re-run the same sync-join command:');
593
+ console.error('');
594
+ console.error(` ${pub.trim()}`);
595
+ } else {
596
+ console.error('Could not find or generate an SSH key (~/.ssh/id_ed25519).');
597
+ }
598
+ process.exit(2);
599
+ }
600
+ console.log(`✓ SSH access to ${t.ssh_target} — OK`);
601
+
602
+ // 2. Durable tunnel. Replace any previous keeper first (frees its port),
603
+ // then pick a free local port starting from the requested one.
604
+ const { installSyncTunnel, uninstallSyncTunnel, syncTunnelStatus } = await import('./service.js');
605
+ if (syncTunnelStatus().installed) {
606
+ try { uninstallSyncTunnel(); } catch (_) {}
607
+ }
608
+ const wantPort = parseInt(args['--local-port'] || '', 10) || t.port;
609
+ const localPort = await findFreeLocalPort(wantPort);
610
+ if (localPort !== wantPort) console.log(` (local port ${wantPort} busy — using ${localPort})`);
611
+ installSyncTunnel({ sshTarget: t.ssh_target, localPort, remotePort: t.port });
612
+
613
+ // 3. Wait for the keeper's listener to come up.
614
+ const up = await waitForPort(localPort, 15000);
615
+ if (!up) {
616
+ console.error(`✗ tunnel did not come up on 127.0.0.1:${localPort} within 15s.`);
617
+ console.error(` Check logs: ~/.memex/data/sync-tunnel.err.log`);
618
+ process.exit(1);
619
+ }
620
+ console.log(`✓ self-healing tunnel up (127.0.0.1:${localPort} → ${t.ssh_target} → 127.0.0.1:${t.port})`);
621
+ url = `https://127.0.0.1:${localPort}`;
622
+ } else {
623
+ url = t.url; // plain pair token — direct dial, no tunnel
624
+ }
625
+
626
+ // 4. Health + cert-pin verification (the client enforces the fingerprint).
627
+ const { createSyncClient } = await import('./client.js');
628
+ const client = createSyncClient({ url, bearer: t.token, cert_fp: t.cert_fp, insecure: !t.cert_fp });
629
+ let health;
630
+ try { health = await client.health(); }
631
+ catch (e) {
632
+ if (/fingerprint mismatch/i.test(e.message)) {
633
+ console.error(`✗ ${e.message}`);
634
+ console.error(' The token is stale or points at a different server — re-emit it on the hub.');
635
+ } else {
636
+ console.error(`✗ tunnel is up but the sync server didn't answer: ${e.message}`);
637
+ console.error(' On the hub, check: memex-sync sync-server status');
638
+ }
639
+ process.exit(1);
640
+ }
641
+ console.log(`✓ hub sync-server alive (memex v${health.version}${t.cert_fp ? ', cert pinned' : ''})`);
642
+
643
+ // 5. Register remote + first sync. If we've synced with this SAME hub
644
+ // before (identified by its pinned cert), keep the existing cursors —
645
+ // re-joining (new token, new tunnel, new URL) must not force a full
646
+ // re-replication of an already-converged pair. Cursors live in the peer's
647
+ // id space, which is tied to the peer's DB, not to how we dial it.
648
+ const prior = getSyncRemote(alias);
649
+ const sameHub = !!(prior && prior.cert_fp && t.cert_fp && prior.cert_fp === t.cert_fp);
650
+ if (sameHub && (prior.pulled_to || prior.pushed_to)) {
651
+ console.log(' (known hub — resuming existing sync cursors, no full re-replication)');
652
+ }
653
+ upsertSyncRemote(alias, {
654
+ url, bearer: t.token.toLowerCase(), cert_fp: t.cert_fp, insecure: !t.cert_fp,
655
+ pulled_to: sameHub ? (prior.pulled_to || 0) : 0,
656
+ pushed_to: sameHub ? (prior.pushed_to || 0) : 0,
657
+ last_sync_at: sameHub ? (prior.last_sync_at || 0) : 0,
658
+ last_error: null,
659
+ });
660
+ const { replicateOnce } = await import('./replicate.js');
661
+ let first;
662
+ try { first = await replicateOnce({ alias, log: () => {} }); }
663
+ catch (e) { console.error(`✗ first sync failed: ${e.message}`); process.exit(1); }
664
+ console.log(`✓ first sync: pulled ${first.pulled.rows} · pushed ${first.pushed.rows} (${(first.elapsed_ms / 1000).toFixed(1)}s)`);
665
+
666
+ // 6. Automation + observability.
667
+ const { installSyncSchedule, installSyncWatchdog } = await import('./service.js');
668
+ installSyncSchedule({ everyMinutes });
669
+ console.log(`✓ auto-sync every ${everyMinutes}m installed (survives reboot)`);
670
+ if (!('--no-watchdog' in args)) {
671
+ installSyncWatchdog({});
672
+ console.log('✓ health watchdog installed (hourly; alerts if sync goes silent)');
673
+ }
674
+
675
+ // 7. Marker self-test — prove the loop, don't promise it.
676
+ if (!('--no-selftest' in args)) {
677
+ try {
678
+ const st = await joinSelfTest({ alias, client });
679
+ if (st.ok) console.log(`✓ end-to-end verified: a test note round-tripped in ${(st.ms / 1000).toFixed(1)}s`);
680
+ else console.log('⚠ setup complete, but the round-trip was not confirmed within this pass — data may sync on the next cycle. Check: memex-sync sync-status');
681
+ } catch (e) {
682
+ console.log(`⚠ self-test skipped (${e.message}) — sync itself succeeded.`);
683
+ }
684
+ }
685
+
686
+ const { markSyncEnabled } = await import('./config.js');
687
+ markSyncEnabled();
688
+ console.log('');
689
+ console.log(`Done. Memory now syncs with "${alias}" automatically.`);
690
+ console.log('Check anytime: memex-sync sync-status');
691
+ process.exit(0);
692
+ }
693
+
694
+ /**
695
+ * Self-test: write a marker locally, sync, then pull from the hub PAST the
696
+ * pre-push cursor and confirm the hub serves the marker back. Wire-level
697
+ * proof that data round-trips — the same check we ran by hand on the live
698
+ * mesh, now built in. The marker conversation is archived locally so it
699
+ * never pollutes listings.
700
+ */
701
+ async function joinSelfTest({ alias, client }) {
702
+ const Database = (await import('better-sqlite3')).default;
703
+ const { homedir } = await import('node:os');
704
+ const { join } = await import('node:path');
705
+ const dbPath = join(process.env.MEMEX_DIR || join(homedir(), '.memex'), 'data', 'memex.db');
706
+
707
+ const ts = Math.floor(Date.now() / 1000);
708
+ const text = `MEMEX-SYNC-SELFTEST-${Date.now()}-${process.pid}`;
709
+ const CONV = 'memex-sync-selftest';
710
+
711
+ const db = new Database(dbPath);
712
+ db.pragma('journal_mode = WAL');
713
+ db.pragma('busy_timeout = 10000');
714
+ db.prepare(`INSERT OR IGNORE INTO conversations (conversation_id, source, title, first_ts, last_ts, message_count, archived_at)
715
+ VALUES (?,?,?,?,?,0,?)`)
716
+ .run(CONV, 'sync-selftest', 'memex sync self-test', ts, ts, ts);
717
+ const { getOrigin } = await import('../config.js');
718
+ db.prepare(`INSERT INTO messages (source, conversation_id, msg_id, role, sender, text, ts, origin)
719
+ VALUES (?,?,?,?,?,?,?,?)`)
720
+ .run('sync-selftest', CONV, `st-${Date.now()}-${process.pid}`, 'user', 'sync-selftest', text, ts, getOrigin());
721
+ db.close();
722
+
723
+ const t0 = Date.now();
724
+ const { replicateOnce } = await import('./replicate.js');
725
+ const stats = await replicateOnce({ alias, log: () => {} });
726
+ // Pull happens before push inside replicateOnce, so pulled_to is the hub's
727
+ // max id BEFORE our marker landed — pulling past it must return the marker.
728
+ const page = await client.pull({ since: stats.cursors_after.pulled_to, limit: 500 });
729
+ const found = Array.isArray(page.rows) && page.rows.some((r) => r.text === text);
730
+ return { ok: found && stats.pushed.accepted >= 1, ms: Date.now() - t0 };
731
+ }
732
+
733
+ /** Ensure ~/.ssh/id_ed25519 exists (generate passphrase-less if absent); return the pubkey line. */
734
+ async function ensureSshPubkey() {
735
+ const { homedir } = await import('node:os');
736
+ const { join } = await import('node:path');
737
+ const { existsSync, readFileSync } = await import('node:fs');
738
+ const { spawnSync } = await import('node:child_process');
739
+ const priv = join(homedir(), '.ssh', 'id_ed25519');
740
+ const pub = priv + '.pub';
741
+ if (!existsSync(pub)) {
742
+ const r = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', priv, '-q'], { encoding: 'utf-8' });
743
+ if (r.status !== 0) return null;
744
+ }
745
+ try { return readFileSync(pub, 'utf-8'); } catch (_) { return null; }
746
+ }
747
+
748
+ /** First free local port at or after `start` (bind-test on 127.0.0.1). */
749
+ async function findFreeLocalPort(start) {
750
+ const net = await import('node:net');
751
+ const tryPort = (p) => new Promise((res) => {
752
+ const s = net.createServer();
753
+ s.once('error', () => res(false));
754
+ s.listen(p, '127.0.0.1', () => s.close(() => res(true)));
755
+ });
756
+ for (let p = start; p < start + 34; p++) {
757
+ if (await tryPort(p)) return p;
758
+ }
759
+ throw new Error(`no free local port in ${start}–${start + 33}`);
760
+ }
761
+
762
+ /** Poll until something accepts TCP on 127.0.0.1:port, or timeout. */
763
+ async function waitForPort(port, timeoutMs) {
764
+ const net = await import('node:net');
765
+ const t0 = Date.now();
766
+ while (Date.now() - t0 < timeoutMs) {
767
+ const ok = await new Promise((res) => {
768
+ const c = net.connect({ host: '127.0.0.1', port, timeout: 1000 });
769
+ c.once('connect', () => { c.destroy(); res(true); });
770
+ c.once('error', () => res(false));
771
+ c.once('timeout', () => { c.destroy(); res(false); });
772
+ });
773
+ if (ok) return true;
774
+ await new Promise((r) => setTimeout(r, 500));
775
+ }
776
+ return false;
777
+ }
778
+
779
+ // ── sync-watchdog (v0.13) — detect silent sync failure ──────────────────────
780
+
781
+ /**
782
+ * `memex-sync sync-watchdog [--threshold-min 60]`
783
+ *
784
+ * One read-only health pass: every remote's last_sync_at must be fresher than
785
+ * the threshold, and the tunnel keeper (if installed) must be running.
786
+ * Problems → ~/.memex/sync-alert.txt + a desktop notification + exit 1.
787
+ * Healthy → alert file removed, line appended to the log, exit 0.
788
+ *
789
+ * Deliberately NOT gated behind the experimental flag: it's read-only, and it
790
+ * must keep working from the hourly timer no matter what shell env the user has.
791
+ */
792
+ export async function cmdSyncWatchdog() {
793
+ const args = parseFlags(process.argv.slice(3));
794
+ const thresholdMin = parseInt(args['--threshold-min'] || '', 10) || 60;
795
+ const { homedir } = await import('node:os');
796
+ const { join } = await import('node:path');
797
+ const { writeFileSync, appendFileSync, unlinkSync, existsSync, mkdirSync } = await import('node:fs');
798
+ const { spawnSync } = await import('node:child_process');
799
+ const MEMEX_DIR = process.env.MEMEX_DIR || join(homedir(), '.memex');
800
+ const alertPath = join(MEMEX_DIR, 'sync-alert.txt');
801
+ const logPath = join(MEMEX_DIR, 'data', 'sync-watchdog.log');
802
+ mkdirSync(join(MEMEX_DIR, 'data'), { recursive: true });
803
+ const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
804
+
805
+ const problems = [];
806
+ const remotes = listSyncRemotes();
807
+ const aliases = Object.keys(remotes);
808
+ if (aliases.length === 0) {
809
+ appendFileSync(logPath, `${stamp} OK — no remotes configured, nothing to watch\n`);
810
+ console.log('no remotes configured — nothing to watch.');
811
+ process.exit(0);
812
+ }
813
+ const now = Date.now();
814
+ for (const alias of aliases) {
815
+ const r = remotes[alias];
816
+ if (!r.last_sync_at) { problems.push(`${alias}: never synced`); continue; }
817
+ const ageMin = Math.round((now - r.last_sync_at) / 60000);
818
+ if (ageMin > thresholdMin) {
819
+ problems.push(`${alias}: sync silent for ${ageMin}m (threshold ${thresholdMin}m)` +
820
+ (r.last_error ? ` — last error: ${r.last_error}` : ''));
821
+ }
822
+ }
823
+ const { syncTunnelStatus } = await import('./service.js');
824
+ const tun = syncTunnelStatus();
825
+ if (tun.installed && !tun.running) problems.push('tunnel keeper installed but not running');
826
+
827
+ if (problems.length === 0) {
828
+ if (existsSync(alertPath)) { try { unlinkSync(alertPath); } catch (_) {} }
829
+ appendFileSync(logPath, `${stamp} OK — all sync legs fresh\n`);
830
+ console.log('✓ all sync legs fresh');
831
+ process.exit(0);
832
+ }
833
+
834
+ const summary = problems.join('; ');
835
+ appendFileSync(logPath, `${stamp} ALARM — ${summary}\n`);
836
+ writeFileSync(alertPath, `memex sync watchdog — PROBLEM (${stamp}):\n` +
837
+ problems.map((p) => ` - ${p}`).join('\n') +
838
+ `\n\nDiagnose: memex-sync sync-status\nLog: ${logPath}\n`);
839
+ // Best-effort desktop notification — never fail the pass over it.
840
+ try {
841
+ if (process.platform === 'darwin') {
842
+ spawnSync('osascript', ['-e',
843
+ `display notification ${JSON.stringify(summary.slice(0, 120))} with title "memex sync: problem" sound name "Basso"`]);
844
+ } else if (process.platform === 'linux') {
845
+ spawnSync('notify-send', ['memex sync: problem', summary.slice(0, 200)]);
846
+ }
847
+ } catch (_) {}
848
+ console.error(`✗ ${summary}`);
849
+ console.error(` details: ${alertPath}`);
850
+ process.exit(1);
851
+ }
852
+
853
+ // ── helpers ─────────────────────────────────────────────────────────────────
854
+
855
+ /** Crude flag parser — collects --flag and --flag value pairs. */
856
+ function parseFlags(argv) {
857
+ const out = {};
858
+ for (let i = 0; i < argv.length; i++) {
859
+ const a = argv[i];
860
+ if (!a.startsWith('--')) continue;
861
+ const next = argv[i + 1];
862
+ if (next != null && !next.startsWith('--')) { out[a] = next; i++; }
863
+ else { out[a] = true; }
864
+ }
865
+ return out;
866
+ }
867
+
868
+ /**
869
+ * Best-effort public-IP detection for `sync-server invite`. Returns the IP
870
+ * string or null. Uses a 4s-timeout fetch to a couple of echo services.
871
+ * Node 20+ has global fetch.
872
+ */
873
+ async function detectPublicIp() {
874
+ const endpoints = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com'];
875
+ for (const url of endpoints) {
876
+ try {
877
+ const ctrl = new AbortController();
878
+ const timer = setTimeout(() => ctrl.abort(), 4000);
879
+ const res = await fetch(url, { signal: ctrl.signal });
880
+ clearTimeout(timer);
881
+ if (!res.ok) continue;
882
+ const ip = (await res.text()).trim();
883
+ if (/^[0-9.]+$/.test(ip) || /^[0-9a-f:]+$/i.test(ip)) return ip;
884
+ } catch (_) { /* try next */ }
885
+ }
886
+ return null;
887
+ }
888
+
889
+ /**
890
+ * Parse an interval like "15m", "30m", "1h", "900s", or bare "15" (minutes)
891
+ * into a whole number of MINUTES. Returns null if unparseable.
892
+ */
893
+ function parseInterval(v) {
894
+ if (v == null || v === true) return null;
895
+ const s = String(v).trim().toLowerCase();
896
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|hour)?$/);
897
+ if (!m) return null;
898
+ const n = parseFloat(m[1]);
899
+ const unit = m[2] || 'm';
900
+ let minutes;
901
+ if (unit.startsWith('s')) minutes = n / 60;
902
+ else if (unit.startsWith('h')) minutes = n * 60;
903
+ else minutes = n; // m / min / bare
904
+ const rounded = Math.max(1, Math.round(minutes));
905
+ return rounded;
906
+ }