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,138 @@
1
+ /**
2
+ * HTTPS client for talking to a peer's /sync/{health,push,pull} endpoints.
3
+ *
4
+ * Tracer-bullet shape: supports plain bearer + --insecure (skip TLS validation).
5
+ * Cert-fingerprint pinning lands in Day 5; for now we still send the request
6
+ * over TLS, just don't verify the cert chain.
7
+ *
8
+ * The client is intentionally dumb — it doesn't decide WHAT to push or pull,
9
+ * just shuttles bytes. The replication loop lives in lib/sync/replicate.js.
10
+ */
11
+
12
+ import { request as httpsRequest } from 'node:https';
13
+ import { fingerprintsMatch } from './cert.js';
14
+
15
+ /**
16
+ * Create a client bound to one remote.
17
+ *
18
+ * opts:
19
+ * url — required, e.g. "https://localhost:8765"
20
+ * bearer — required, hex token
21
+ * insecure — bool. If true, skip TLS cert validation entirely (tracer-bullet).
22
+ * cert_fp — string. If set, pin the server cert to this SHA-256 fingerprint
23
+ * (overrides insecure — pinning is enabled even when insecure=true).
24
+ * timeoutMs — request timeout, default 30s
25
+ */
26
+ export function createSyncClient(opts) {
27
+ if (!opts || !opts.url) throw new Error('createSyncClient: url required');
28
+ if (!opts.bearer) throw new Error('createSyncClient: bearer required');
29
+
30
+ const url = new URL(opts.url);
31
+ const insecure = !!opts.insecure;
32
+ const certFp = opts.cert_fp || null;
33
+ const timeoutMs = opts.timeoutMs ?? 30_000;
34
+
35
+ function makeRequest({ method, path, body }) {
36
+ return new Promise((resolve, reject) => {
37
+ const payload = body == null ? null : Buffer.from(JSON.stringify(body));
38
+ const headers = {
39
+ Authorization: `Bearer ${opts.bearer}`,
40
+ };
41
+ if (payload) {
42
+ headers['Content-Type'] = 'application/json';
43
+ headers['Content-Length'] = payload.length;
44
+ }
45
+
46
+ const req = httpsRequest({
47
+ host: url.hostname,
48
+ port: url.port || 443,
49
+ path,
50
+ method,
51
+ headers,
52
+ // When cert pinning is on (cert_fp set), we still set rejectUnauthorized
53
+ // to false because self-signed certs would otherwise fail the chain check —
54
+ // we validate the fingerprint manually on 'secureConnect' instead.
55
+ rejectUnauthorized: (!insecure && !certFp),
56
+ timeout: timeoutMs,
57
+ // Disable keep-alive pooling. With pooling, a reused TLS socket
58
+ // doesn't re-fire 'secureConnect', so our per-request pinning
59
+ // listener would pile up (MaxListenersExceededWarning seen during
60
+ // the 326-batch live push). A fresh socket per request keeps the
61
+ // pinning check correct and leak-free; sync is low-frequency enough
62
+ // that the extra handshakes are negligible.
63
+ agent: false,
64
+ }, (res) => {
65
+ let chunks = '';
66
+ res.on('data', (c) => { chunks += c; });
67
+ res.on('end', () => {
68
+ let parsed = null;
69
+ try { parsed = chunks ? JSON.parse(chunks) : null; } catch (_) { parsed = { _raw: chunks }; }
70
+ resolve({ status: res.statusCode, body: parsed });
71
+ });
72
+ });
73
+
74
+ // Cert-pinning validation hook. With agent:false each request gets a
75
+ // fresh socket, so 'socket' and 'secureConnect' each fire exactly once
76
+ // per request. We use .once() defensively so a listener can never
77
+ // outlive the handshake it was registered for.
78
+ if (certFp) {
79
+ req.once('socket', (socket) => {
80
+ socket.once('secureConnect', () => {
81
+ const peerCert = socket.getPeerCertificate(true);
82
+ // node returns SHA-256 fingerprint as fingerprint256 in "AA:BB:..." form
83
+ const peerFp = peerCert?.fingerprint256;
84
+ if (!peerFp || !fingerprintsMatch(certFp, peerFp)) {
85
+ req.destroy(new Error(
86
+ `TLS fingerprint mismatch — expected ${certFp}, got ${peerFp || 'none'}`
87
+ ));
88
+ }
89
+ });
90
+ });
91
+ }
92
+
93
+ req.on('error', reject);
94
+ req.on('timeout', () => req.destroy(new Error(`request timeout (${timeoutMs}ms)`)));
95
+
96
+ if (payload) req.write(payload);
97
+ req.end();
98
+ });
99
+ }
100
+
101
+ return {
102
+ async health() {
103
+ const r = await makeRequest({ method: 'GET', path: '/sync/health' });
104
+ if (r.status !== 200) throw new Error(`health failed: ${r.status} ${JSON.stringify(r.body)}`);
105
+ return r.body;
106
+ },
107
+
108
+ async pull({ since = 0, limit = 500 } = {}) {
109
+ const r = await makeRequest({
110
+ method: 'GET',
111
+ path: `/sync/pull?since=${since}&limit=${limit}`,
112
+ });
113
+ if (r.status !== 200) throw new Error(`pull failed: ${r.status} ${JSON.stringify(r.body)}`);
114
+ return r.body;
115
+ },
116
+
117
+ async push({ rows }) {
118
+ if (!Array.isArray(rows)) throw new Error('push: rows[] required');
119
+ const r = await makeRequest({
120
+ method: 'POST',
121
+ path: '/sync/push',
122
+ body: { rows },
123
+ });
124
+ if (r.status !== 200) {
125
+ // Carry the HTTP status on the error so the replication loop can
126
+ // react — e.g. halve the batch on 413 payload_too_large.
127
+ const err = new Error(`push failed: ${r.status} ${JSON.stringify(r.body)}`);
128
+ err.status = r.status;
129
+ err.body = r.body;
130
+ throw err;
131
+ }
132
+ return r.body;
133
+ },
134
+
135
+ // Direct access for advanced/test callers
136
+ raw: makeRequest,
137
+ };
138
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Sync section of ~/.memex/config.json — owns its own defaults and helpers,
3
+ * but persists through the same file as the rest of the config (so a single
4
+ * config.json round-trips cleanly).
5
+ *
6
+ * Schema:
7
+ * sync: {
8
+ * server: {
9
+ * enabled: false, // are we running `memex sync server`?
10
+ * port: 8765, // listen port
11
+ * bind: "0.0.0.0", // 0.0.0.0 | 127.0.0.1 | tailscale-ip
12
+ * bearer: "<64-hex>", // 256-bit token (one per server)
13
+ * cert_path: "~/.memex/sync-cert.pem",
14
+ * key_path: "~/.memex/sync-key.pem",
15
+ * cert_fp: "sha256:..." // computed at server start
16
+ * },
17
+ * remotes: {
18
+ * "<alias>": {
19
+ * url: "https://host:8765",
20
+ * bearer: "<64-hex>",
21
+ * cert_fp: "sha256:...", // pinned fingerprint (from pair blob)
22
+ * pulled_to: 0, // last id we pulled FROM this remote
23
+ * pushed_to: 0, // last OUR id we pushed TO this remote
24
+ * last_sync_at: 0, // unix ms; 0 = never
25
+ * last_error: null // string | null
26
+ * }
27
+ * }
28
+ * }
29
+ *
30
+ * The MEMEX_SYNC_EXPERIMENTAL=1 env var gates all sync activity at the CLI
31
+ * layer; this module only knows shape, not whether the feature is on.
32
+ */
33
+
34
+ import { loadConfig, saveConfig } from '../config.js';
35
+
36
+ export const DEFAULT_SYNC = Object.freeze({
37
+ server: {
38
+ enabled: false,
39
+ port: 8765,
40
+ bind: '0.0.0.0',
41
+ bearer: null,
42
+ cert_path: null,
43
+ key_path: null,
44
+ cert_fp: null,
45
+ },
46
+ remotes: {},
47
+ });
48
+
49
+ export function loadSyncConfig() {
50
+ const cfg = loadConfig();
51
+ const sync = cfg.sync || {};
52
+ return {
53
+ ...sync, // preserve extra keys (e.g. `enabled` set by sync-join) across saves
54
+ server: { ...DEFAULT_SYNC.server, ...(sync.server || {}) },
55
+ remotes: { ...DEFAULT_SYNC.remotes, ...(sync.remotes || {}) },
56
+ };
57
+ }
58
+
59
+ export function saveSyncConfig(syncCfg) {
60
+ const cfg = loadConfig();
61
+ cfg.sync = syncCfg;
62
+ saveConfig(cfg);
63
+ }
64
+
65
+ /**
66
+ * Merge `patch` into the sync.server section and persist. Other config sections
67
+ * untouched. Returns the new server config.
68
+ */
69
+ export function updateSyncServer(patch) {
70
+ const sync = loadSyncConfig();
71
+ sync.server = { ...sync.server, ...patch };
72
+ saveSyncConfig(sync);
73
+ return sync.server;
74
+ }
75
+
76
+ /**
77
+ * Upsert a remote by alias. `patch` shallowly merges into the existing
78
+ * remote (or seeds a new one with cursor=0).
79
+ */
80
+ export function upsertSyncRemote(alias, patch) {
81
+ if (!alias) throw new Error('upsertSyncRemote: alias required');
82
+ const sync = loadSyncConfig();
83
+ const existing = sync.remotes[alias] || {
84
+ url: null, bearer: null, cert_fp: null,
85
+ pulled_to: 0, pushed_to: 0,
86
+ last_sync_at: 0, last_error: null,
87
+ };
88
+ sync.remotes[alias] = { ...existing, ...patch };
89
+ saveSyncConfig(sync);
90
+ return sync.remotes[alias];
91
+ }
92
+
93
+ export function getSyncRemote(alias) {
94
+ const sync = loadSyncConfig();
95
+ return sync.remotes[alias] || null;
96
+ }
97
+
98
+ export function listSyncRemotes() {
99
+ return loadSyncConfig().remotes;
100
+ }
101
+
102
+ export function removeSyncRemote(alias) {
103
+ const sync = loadSyncConfig();
104
+ if (!(alias in sync.remotes)) return false;
105
+ delete sync.remotes[alias];
106
+ saveSyncConfig(sync);
107
+ return true;
108
+ }
109
+
110
+ /**
111
+ * Returns true if sync is enabled — either via the MEMEX_SYNC_EXPERIMENTAL
112
+ * env var (the original opt-in) or via `sync.enabled: true` in config.json,
113
+ * which `sync-join` sets on success. The config path is what makes the
114
+ * lazy-user flow work: after one successful join, every later sync command
115
+ * (sync-run, sync-status, the schedule timer) just works in any shell with
116
+ * no env var to remember.
117
+ */
118
+ export function syncExperimentEnabled() {
119
+ const v = process.env.MEMEX_SYNC_EXPERIMENTAL;
120
+ if (v && (v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes')) return true;
121
+ try { return (loadConfig().sync || {}).enabled === true; }
122
+ catch (_) { return false; }
123
+ }
124
+
125
+ /** Persist sync.enabled=true — called by sync-join on success. */
126
+ export function markSyncEnabled() {
127
+ const sync = loadSyncConfig();
128
+ sync.enabled = true;
129
+ saveSyncConfig(sync);
130
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Pair blob — collapse {host, port, cert_fp, bearer} into one copy-pasteable
3
+ * string so a peer can be added with a single paste instead of three CLI args.
4
+ *
5
+ * memex-pair:<base64url(JSON)>
6
+ *
7
+ * The JSON payload:
8
+ * { v, host, port, cert_fp, token, exp }
9
+ *
10
+ * Design notes:
11
+ * • base64url (no +/=) so it survives chat clients, URLs, and shell args
12
+ * without escaping.
13
+ * • `exp` (unix seconds) — a short TTL (default 10 min). A leaked blob is
14
+ * only useful until it expires; after that the client refuses it and the
15
+ * operator mints a fresh one. The bearer itself doesn't rotate on expiry
16
+ * (it persists server-side); expiry just bounds the pairing window.
17
+ * • `v` version gate — a client that doesn't understand the version refuses
18
+ * rather than mis-parsing.
19
+ * • This is transport-agnostic: `host` is whatever the CLIENT will dial —
20
+ * a public IP, a localhost SSH-tunnel port, or a Tailscale MagicDNS name.
21
+ * The server can't know that, so the invite step chooses/declares it.
22
+ *
23
+ * Security model unchanged from the 3-arg path: cert_fp gives TLS pinning,
24
+ * token is the 256-bit bearer. The blob just bundles them.
25
+ */
26
+
27
+ const PREFIX = 'memex-pair:';
28
+ // Join token (v0.13 lazy-user flow) — same payload as a pair blob PLUS
29
+ // `ssh_target` ("user@host"). The presence of ssh_target tells the client to
30
+ // reach the server through a forward SSH tunnel (-L) instead of dialing
31
+ // host:port directly — the canonical loopback-hub topology where the server
32
+ // never exposes a public port. A distinct prefix so old clients fail with
33
+ // "not a memex-pair token" instead of silently dialing 127.0.0.1.
34
+ const JOIN_PREFIX = 'memex-join:';
35
+ const PAIR_VERSION = 1;
36
+ export const DEFAULT_PAIR_TTL_SEC = 600; // 10 minutes
37
+ export const DEFAULT_JOIN_TTL_SEC = 1800; // 30 minutes — join is a longer dance
38
+
39
+ /**
40
+ * Encode a pair blob. Returns the "memex-pair:..." string.
41
+ * host — required; what the client will connect to
42
+ * port — default 8766
43
+ * cert_fp — TLS fingerprint to pin (sha256:AA:BB:...); may be null for
44
+ * transport-trusted setups (SSH tunnel / Tailscale)
45
+ * token — required; 256-bit hex bearer
46
+ * ttlSec — seconds until the blob expires (default 10 min)
47
+ * now — injectable clock (ms) for tests
48
+ */
49
+ export function encodePairBlob({ host, port = 8766, cert_fp = null, token, ttlSec = DEFAULT_PAIR_TTL_SEC, now = Date.now() }) {
50
+ if (!host) throw new Error('encodePairBlob: host required');
51
+ if (!token) throw new Error('encodePairBlob: token required');
52
+ const payload = {
53
+ v: PAIR_VERSION,
54
+ host,
55
+ port: Number(port) || 8766,
56
+ cert_fp: cert_fp || null,
57
+ token,
58
+ exp: Math.floor(now / 1000) + Math.max(1, Math.floor(ttlSec)),
59
+ };
60
+ const b64 = Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
61
+ return PREFIX + b64;
62
+ }
63
+
64
+ /**
65
+ * Encode a JOIN token — the lazy-user variant. Same fields as a pair blob,
66
+ * plus `ssh_target`. `host` is pinned to 127.0.0.1: the server binds loopback
67
+ * and the client reaches it through its own `-L` tunnel, so loopback is the
68
+ * only address that's ever dialed.
69
+ */
70
+ export function encodeJoinBlob({ ssh_target, port = 8766, cert_fp = null, token, ttlSec = DEFAULT_JOIN_TTL_SEC, now = Date.now() }) {
71
+ if (!ssh_target || !/^[^@\s]+@[^@\s]+$/.test(ssh_target)) {
72
+ throw new Error('encodeJoinBlob: ssh_target required (user@host)');
73
+ }
74
+ if (!token) throw new Error('encodeJoinBlob: token required');
75
+ const payload = {
76
+ v: PAIR_VERSION,
77
+ host: '127.0.0.1',
78
+ ssh_target,
79
+ port: Number(port) || 8766,
80
+ cert_fp: cert_fp || null,
81
+ token,
82
+ exp: Math.floor(now / 1000) + Math.max(1, Math.floor(ttlSec)),
83
+ };
84
+ return JOIN_PREFIX + Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
85
+ }
86
+
87
+ /**
88
+ * Parse + validate a pair OR join blob. Throws a friendly Error on any problem.
89
+ * Returns { kind, host, port, url, cert_fp, token, exp, ssh_target }.
90
+ * kind — 'pair' | 'join' (by prefix)
91
+ * ssh_target — "user@host" for join tokens, null for plain pair blobs
92
+ *
93
+ * now — injectable clock (ms) for tests.
94
+ */
95
+ export function parsePairBlob(blob, { now = Date.now() } = {}) {
96
+ if (typeof blob !== 'string' || !blob.trim()) {
97
+ throw new Error('pair blob must be a non-empty string');
98
+ }
99
+ let s = blob.trim();
100
+ let kind;
101
+ if (s.startsWith(JOIN_PREFIX)) {
102
+ kind = 'join';
103
+ s = s.slice(JOIN_PREFIX.length).trim();
104
+ } else if (s.startsWith(PREFIX)) {
105
+ kind = 'pair';
106
+ s = s.slice(PREFIX.length).trim();
107
+ } else {
108
+ throw new Error(`not a memex pair/join token (must start with "${PREFIX}" or "${JOIN_PREFIX}")`);
109
+ }
110
+
111
+ let payload;
112
+ try {
113
+ payload = JSON.parse(Buffer.from(s, 'base64url').toString('utf-8'));
114
+ } catch (_) {
115
+ throw new Error('pair blob is corrupt (base64/JSON decode failed) — re-copy it whole');
116
+ }
117
+
118
+ if (payload.v !== PAIR_VERSION) {
119
+ throw new Error(`unsupported pair blob version ${payload.v} — this memex speaks v${PAIR_VERSION}; upgrade the older side`);
120
+ }
121
+ if (!payload.host || !payload.token) {
122
+ throw new Error('pair blob missing host or token');
123
+ }
124
+ if (kind === 'join' && !/^[^@\s]+@[^@\s]+$/.test(payload.ssh_target || '')) {
125
+ throw new Error('join token missing ssh_target (user@host) — re-emit it with `sync-server invite --join`');
126
+ }
127
+ if (payload.exp && Math.floor(now / 1000) > payload.exp) {
128
+ const agoMin = Math.round((Math.floor(now / 1000) - payload.exp) / 60);
129
+ throw new Error(`pair blob expired ${agoMin}m ago — mint a fresh one with \`memex-sync sync-server invite\``);
130
+ }
131
+
132
+ const port = Number(payload.port) || 8766;
133
+ return {
134
+ kind,
135
+ host: payload.host,
136
+ port,
137
+ url: `https://${payload.host}:${port}`,
138
+ cert_fp: payload.cert_fp || null,
139
+ token: payload.token,
140
+ exp: payload.exp || null,
141
+ ssh_target: kind === 'join' ? payload.ssh_target : null,
142
+ };
143
+ }
144
+
145
+ export { PREFIX as PAIR_PREFIX, JOIN_PREFIX, PAIR_VERSION };
@@ -0,0 +1,158 @@
1
+ /**
2
+ * GET /sync/pull?since=<int>&limit=<int> — return rows the caller hasn't seen.
3
+ *
4
+ * Wire contract (see SYNC.md §Wire protocol):
5
+ *
6
+ * Query:
7
+ * since — caller's last-seen local id from this server (0 = first pull)
8
+ * limit — max rows; default 500, hard cap 1000
9
+ *
10
+ * Response: {
11
+ * rows: [Row, ...], // ordered by id ASC, id > since
12
+ * next_cursor: <int>, // id of the last row in this batch
13
+ * // (caller passes this as since= next time)
14
+ * has_more: bool, // true → more rows wait; caller should
15
+ * // re-pull immediately with the new cursor
16
+ * server_now: <ms_epoch> // informational
17
+ * }
18
+ *
19
+ * Each Row matches the shape POST /sync/push expects, including the embedded
20
+ * conversation metadata (so the receiver can upsert without a separate
21
+ * join-and-fetch round trip).
22
+ *
23
+ * Conversation embedding is denormalised on read: we LEFT JOIN conversations
24
+ * and inline the columns. For a typical 500-row pull this is one query.
25
+ */
26
+
27
+ const DEFAULT_LIMIT = 500;
28
+ const MAX_LIMIT = 1000;
29
+
30
+ export function makePullHandler({ db }) {
31
+ if (!db) throw new Error('makePullHandler: db is required');
32
+
33
+ // One query, LEFT JOIN — every message also surfaces its conversation row
34
+ // for the receiver's upsert. Order is critical: ASC by id ensures the
35
+ // cursor (max id returned) is monotonic across pulls.
36
+ const fetchSince = db.prepare(`
37
+ SELECT
38
+ m.id AS id,
39
+ m.source AS source,
40
+ m.conversation_id AS conversation_id,
41
+ m.msg_id AS msg_id,
42
+ m.role AS role,
43
+ m.sender AS sender,
44
+ m.text AS text,
45
+ m.ts AS ts,
46
+ m.edited_at AS edited_at,
47
+ m.uuid AS uuid,
48
+ m.channel AS channel,
49
+ m.origin AS origin,
50
+ m.metadata AS metadata,
51
+ c.title AS conv_title,
52
+ c.first_ts AS conv_first_ts,
53
+ c.last_ts AS conv_last_ts,
54
+ c.parent_conversation_id AS conv_parent,
55
+ c.project_path AS conv_project_path
56
+ FROM messages m
57
+ LEFT JOIN conversations c ON c.conversation_id = m.conversation_id
58
+ WHERE m.id > ?
59
+ ORDER BY m.id ASC
60
+ LIMIT ?
61
+ `);
62
+
63
+ return function pullHandler(req, res) {
64
+ let url;
65
+ try {
66
+ url = new URL(req.url, 'https://placeholder.local');
67
+ } catch (_) {
68
+ return respondJson(res, 400, { error: 'bad_request', detail: 'malformed URL' });
69
+ }
70
+
71
+ const sinceRaw = url.searchParams.get('since') ?? '0';
72
+ const limitRaw = url.searchParams.get('limit') ?? String(DEFAULT_LIMIT);
73
+
74
+ const since = parseNonNegInt(sinceRaw);
75
+ if (since == null) {
76
+ return respondJson(res, 400, { error: 'bad_request', detail: 'since must be a non-negative integer' });
77
+ }
78
+ const limit = clampLimit(limitRaw);
79
+
80
+ let rows;
81
+ try {
82
+ rows = fetchSince.all(since, limit + 1);
83
+ } catch (err) {
84
+ return respondJson(res, 500, { error: 'internal', detail: err.message });
85
+ }
86
+
87
+ // If we fetched limit+1, the extra row indicates there's more after this batch.
88
+ const has_more = rows.length > limit;
89
+ if (has_more) rows = rows.slice(0, limit);
90
+
91
+ const wireRows = rows.map(rowToWire);
92
+ const next_cursor = wireRows.length
93
+ ? wireRows[wireRows.length - 1].id_serverside
94
+ : since;
95
+
96
+ // Strip the bookkeeping field — id_serverside was only for next_cursor.
97
+ for (const r of wireRows) delete r.id_serverside;
98
+
99
+ respondJson(res, 200, {
100
+ rows: wireRows,
101
+ next_cursor,
102
+ has_more,
103
+ server_now: Date.now(),
104
+ });
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Map a SQLite-row into the wire Row shape per SYNC.md. We tuck the
110
+ * server-side id into a temporary `id_serverside` field so the caller of
111
+ * makePullHandler can compute next_cursor without re-iterating. Field is
112
+ * removed before serialization.
113
+ */
114
+ function rowToWire(r) {
115
+ return {
116
+ source: r.source,
117
+ conversation_id: r.conversation_id,
118
+ msg_id: r.msg_id,
119
+ uuid: r.uuid,
120
+ role: r.role,
121
+ sender: r.sender,
122
+ text: r.text,
123
+ ts: r.ts,
124
+ edited_at: r.edited_at,
125
+ channel: r.channel,
126
+ origin: r.origin,
127
+ metadata: r.metadata,
128
+ conversation: {
129
+ title: r.conv_title,
130
+ first_ts: r.conv_first_ts,
131
+ last_ts: r.conv_last_ts,
132
+ parent_conversation_id: r.conv_parent,
133
+ project_path: r.conv_project_path,
134
+ },
135
+ id_serverside: r.id, // stripped before response
136
+ };
137
+ }
138
+
139
+ function parseNonNegInt(s) {
140
+ if (s == null) return null;
141
+ const n = Number(s);
142
+ if (!Number.isFinite(n)) return null;
143
+ if (n < 0) return null;
144
+ return Math.trunc(n);
145
+ }
146
+
147
+ function clampLimit(s) {
148
+ const n = parseNonNegInt(s);
149
+ if (n == null || n === 0) return DEFAULT_LIMIT;
150
+ return Math.min(n, MAX_LIMIT);
151
+ }
152
+
153
+ function respondJson(res, status, obj) {
154
+ if (res.headersSent || res.writableEnded) return;
155
+ res.statusCode = status;
156
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
157
+ res.end(JSON.stringify(obj));
158
+ }