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,200 @@
1
+ /**
2
+ * Manage ~/.memex/pending/ — the staging area where the daemon parks
3
+ * Telegram exports it found in ~/Downloads/Telegram Desktop/ before the
4
+ * user explicitly approves them.
5
+ *
6
+ * Layout:
7
+ * ~/.memex/pending/
8
+ * ChatExport_2026-05-15/ ← exact original name
9
+ * messages.html
10
+ * photos/ ...
11
+ * result-2026-05-12.json
12
+ * .meta.json ← per-entry metadata cache (preview)
13
+ *
14
+ * The .meta.json is recomputed lazily — we cache preview output so listing
15
+ * 50 pending exports doesn't re-parse 50 HTML trees on every CLI invocation.
16
+ */
17
+
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readdirSync,
22
+ renameSync,
23
+ statSync,
24
+ rmSync,
25
+ cpSync,
26
+ writeFileSync,
27
+ readFileSync,
28
+ } from 'node:fs';
29
+ import { join, basename } from 'node:path';
30
+ import { homedir } from 'node:os';
31
+ import { previewExport } from './telegram-discovery.js';
32
+
33
+ // Compute lazily so process.env.HOME overrides (used by tests) actually work.
34
+ export function getPendingDir() {
35
+ return join(homedir(), '.memex', 'pending');
36
+ }
37
+ export function getMetaFile() {
38
+ return join(getPendingDir(), '.meta.json');
39
+ }
40
+ // Back-compat exports — still used by some callers
41
+ export const PENDING_DIR = getPendingDir();
42
+ export const META_FILE = getMetaFile();
43
+
44
+ export function ensurePendingDir() {
45
+ const dir = getPendingDir();
46
+ if (!existsSync(dir)) {
47
+ mkdirSync(dir, { recursive: true });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Move a freshly-detected export from its source location into pending/.
53
+ * If destination exists (re-export of same name), uses a numeric suffix.
54
+ *
55
+ * Returns the absolute destination path that now holds the export.
56
+ *
57
+ * `moveOrCopy` — 'move' uses fs.rename (atomic, same FS only); 'copy' uses
58
+ * cpSync. Default 'move'. If the rename fails (cross-device), automatically
59
+ * falls back to copy + rmSync.
60
+ */
61
+ export function stageExport(sourcePath, opts = {}) {
62
+ ensurePendingDir();
63
+ const moveOrCopy = opts.moveOrCopy || 'move';
64
+ const name = basename(sourcePath);
65
+ const baseDir = getPendingDir();
66
+ let dest = join(baseDir, name);
67
+ // Suffix preserves extension so file-type sniffing (e.g. `endsWith('.json')`)
68
+ // still works on the staged copy: result.json → result__1.json
69
+ let suffix = 0;
70
+ while (existsSync(dest)) {
71
+ suffix += 1;
72
+ const dot = name.lastIndexOf('.');
73
+ if (dot > 0 && dot < name.length - 1) {
74
+ const stem = name.slice(0, dot);
75
+ const ext = name.slice(dot);
76
+ dest = join(baseDir, `${stem}__${suffix}${ext}`);
77
+ } else {
78
+ dest = join(baseDir, `${name}__${suffix}`);
79
+ }
80
+ }
81
+
82
+ try {
83
+ if (moveOrCopy === 'move') {
84
+ renameSync(sourcePath, dest);
85
+ } else {
86
+ cpSync(sourcePath, dest, { recursive: true });
87
+ }
88
+ } catch (e) {
89
+ // EXDEV — cross-device link. Fall back to copy + delete.
90
+ if (e.code === 'EXDEV') {
91
+ cpSync(sourcePath, dest, { recursive: true });
92
+ try { rmSync(sourcePath, { recursive: true, force: true }); } catch (_) { /* ok */ }
93
+ } else {
94
+ throw e;
95
+ }
96
+ }
97
+
98
+ // Invalidate cache — next listPending() will recompute
99
+ invalidateMeta();
100
+ return dest;
101
+ }
102
+
103
+ /**
104
+ * List everything in pending/, with cached previews.
105
+ *
106
+ * Returns an array of objects shaped like:
107
+ * {
108
+ * index: 1,
109
+ * path: '/Users/.../pending/ChatExport_2026-05-15',
110
+ * basename: 'ChatExport_2026-05-15',
111
+ * kind: 'html-dir'|'json-file',
112
+ * chat_title: '...',
113
+ * chat_type: 'private_group'|'personal_chat',
114
+ * message_count: 492,
115
+ * date_first: '2026-03-20T14:08:43',
116
+ * date_last: '2026-05-12T00:40:08',
117
+ * senders_sample: ['Oleg', 'Andrey', ...],
118
+ * size_bytes: 468468,
119
+ * modified_ts: 1773820800
120
+ * }
121
+ *
122
+ * Indices are 1-based and stable within one process — used by CLI commands
123
+ * like `memex telegram import 1 3 5`. Order: newest modified first.
124
+ */
125
+ export function listPending(opts = {}) {
126
+ const baseDir = getPendingDir();
127
+ if (!existsSync(baseDir)) return [];
128
+
129
+ const cache = loadMeta();
130
+ const entries = [];
131
+
132
+ let names;
133
+ try { names = readdirSync(baseDir); } catch (_) { return []; }
134
+ for (const name of names) {
135
+ if (name.startsWith('.')) continue; // skip .meta.json and other hidden
136
+ const full = join(baseDir, name);
137
+ let st;
138
+ try { st = statSync(full); } catch (_) { continue; }
139
+ const mtime = Math.floor(st.mtimeMs / 1000);
140
+
141
+ let preview = cache[name];
142
+ if (!preview || preview.modified_ts !== mtime) {
143
+ preview = previewExport(full);
144
+ preview.modified_ts = mtime;
145
+ cache[name] = preview;
146
+ }
147
+ entries.push({
148
+ basename: name,
149
+ path: full,
150
+ modified_ts: mtime,
151
+ ...preview,
152
+ });
153
+ }
154
+
155
+ saveMeta(cache);
156
+
157
+ entries.sort((a, b) => b.modified_ts - a.modified_ts);
158
+ entries.forEach((e, i) => { e.index = i + 1; });
159
+ return entries;
160
+ }
161
+
162
+ /**
163
+ * Remove an entry from pending/ — used after successful import or after
164
+ * `memex telegram skip`. Deletes from disk and from the meta cache.
165
+ */
166
+ export function removePending(absPath) {
167
+ try { rmSync(absPath, { recursive: true, force: true }); } catch (_) { /* ok */ }
168
+ const cache = loadMeta();
169
+ const name = basename(absPath);
170
+ if (name in cache) {
171
+ delete cache[name];
172
+ saveMeta(cache);
173
+ }
174
+ }
175
+
176
+ // -------------------- meta cache helpers --------------------
177
+
178
+ function loadMeta() {
179
+ const meta = getMetaFile();
180
+ if (!existsSync(meta)) return {};
181
+ try { return JSON.parse(readFileSync(meta, 'utf-8')); }
182
+ catch (_) { return {}; }
183
+ }
184
+
185
+ function saveMeta(cache) {
186
+ ensurePendingDir();
187
+ const meta = getMetaFile();
188
+ try {
189
+ const tmp = meta + '.tmp';
190
+ writeFileSync(tmp, JSON.stringify(cache, null, 2));
191
+ renameSync(tmp, meta);
192
+ } catch (_) { /* non-fatal */ }
193
+ }
194
+
195
+ function invalidateMeta() {
196
+ const meta = getMetaFile();
197
+ if (existsSync(meta)) {
198
+ try { rmSync(meta, { force: true }); } catch (_) { /* ok */ }
199
+ }
200
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * memex web dashboard — HTTP server.
3
+ *
4
+ * Opt-in: invoked via `memex web` CLI command. Binds 127.0.0.1 by default.
5
+ * Read-only by design (only POST endpoints are pending review actions).
6
+ *
7
+ * Stack:
8
+ * • Node.js raw http module (no Express, no framework)
9
+ * • Tagged template literals for HTML
10
+ * • htmx for client-side reactivity (from CDN)
11
+ * • Better-sqlite3 read-only DB handle
12
+ *
13
+ * Routes:
14
+ * GET / → dashboard
15
+ * GET /conversations → list + search
16
+ * GET /conversations/search → htmx partial
17
+ * GET /c/:id → full transcript
18
+ * GET /pending → telegram exports awaiting decision
19
+ * POST /pending/import → import selected
20
+ * POST /pending/skip → skip selected
21
+ * GET /settings → daemon status, sources, hooks
22
+ * GET /static/<file> → static assets (CSS, etc)
23
+ * GET /api/health → JSON liveness probe
24
+ */
25
+
26
+ import { createServer } from 'node:http';
27
+ import { readFileSync, existsSync, statSync } from 'node:fs';
28
+ import { join, dirname } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { homedir } from 'node:os';
31
+ import { spawn, execSync } from 'node:child_process';
32
+ import Database from 'better-sqlite3';
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url));
35
+ const STATIC_DIR = join(__dirname, 'static');
36
+
37
+ const HOME = homedir();
38
+ const MEMEX_DIR = process.env.MEMEX_DIR || join(HOME, '.memex');
39
+ const DB_PATH = join(MEMEX_DIR, 'data', 'memex.db');
40
+
41
+ // ----- Helpers -----
42
+
43
+ function send(res, status, body, contentType = 'text/html; charset=utf-8') {
44
+ res.statusCode = status;
45
+ res.setHeader('Content-Type', contentType);
46
+ res.setHeader('X-Content-Type-Options', 'nosniff');
47
+ res.end(body);
48
+ }
49
+
50
+ function sendJson(res, status, obj) {
51
+ send(res, status, JSON.stringify(obj, null, 2), 'application/json; charset=utf-8');
52
+ }
53
+
54
+ function notFound(res) {
55
+ send(res, 404, '<h1>404 — not in memex</h1><p><a href="/">back to dashboard</a></p>');
56
+ }
57
+
58
+ function serverError(res, e) {
59
+ console.error('[memex web] error:', e);
60
+ send(res, 500, `<h1>500 — server error</h1><pre>${escapeHtml(e.message || String(e))}</pre>`);
61
+ }
62
+
63
+ function escapeHtml(s) {
64
+ return String(s || '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
65
+ }
66
+
67
+ // Parse the URL into { pathname, query }
68
+ function parseUrl(url) {
69
+ const i = url.indexOf('?');
70
+ if (i === -1) return { pathname: url, query: {} };
71
+ const pathname = url.slice(0, i);
72
+ const queryStr = url.slice(i + 1);
73
+ const query = {};
74
+ for (const pair of queryStr.split('&')) {
75
+ if (!pair) continue;
76
+ const [k, v] = pair.split('=');
77
+ query[decodeURIComponent(k)] = v != null ? decodeURIComponent(v.replace(/\+/g, ' ')) : '';
78
+ }
79
+ return { pathname, query };
80
+ }
81
+
82
+ // ----- Static file serving (CSS, etc) -----
83
+
84
+ const STATIC_TYPES = {
85
+ '.css': 'text/css; charset=utf-8',
86
+ '.js': 'application/javascript; charset=utf-8',
87
+ '.svg': 'image/svg+xml',
88
+ '.png': 'image/png',
89
+ '.ico': 'image/x-icon',
90
+ };
91
+
92
+ function serveStatic(res, pathname) {
93
+ const rel = pathname.replace(/^\/static\//, '').replace(/\.\./g, '');
94
+ const full = join(STATIC_DIR, rel);
95
+ if (!existsSync(full)) return notFound(res);
96
+ const ext = full.slice(full.lastIndexOf('.'));
97
+ const type = STATIC_TYPES[ext] || 'application/octet-stream';
98
+ const body = readFileSync(full);
99
+ res.statusCode = 200;
100
+ res.setHeader('Content-Type', type);
101
+ res.setHeader('Cache-Control', 'public, max-age=300');
102
+ res.end(body);
103
+ }
104
+
105
+ // ----- DB handle (read-only) -----
106
+
107
+ let _db = null;
108
+ function getDb() {
109
+ if (_db) return _db;
110
+ if (!existsSync(DB_PATH)) {
111
+ throw new Error(`memex.db not found at ${DB_PATH}. Run 'memex-sync install' first.`);
112
+ }
113
+ _db = new Database(DB_PATH, { readonly: true, fileMustExist: true });
114
+ return _db;
115
+ }
116
+
117
+ // ----- Sync status (daemon health) -----
118
+
119
+ function getDaemonStatus() {
120
+ try {
121
+ // Inline check — read the LaunchAgent plist + try to ps the daemon
122
+ const plistPath = join(HOME, 'Library/LaunchAgents/com.parallelclaw.memex.sync.plist');
123
+ const installed = existsSync(plistPath);
124
+ if (!installed) return { installed: false, running: false, lastCaptureMs: null };
125
+
126
+ // Recent activity from ingest.log
127
+ const logPath = join(MEMEX_DIR, 'data', 'ingest.log');
128
+ let lastCaptureMs = null;
129
+ if (existsSync(logPath)) {
130
+ const ageMs = Date.now() - statSync(logPath).mtimeMs;
131
+ lastCaptureMs = ageMs;
132
+ }
133
+
134
+ // Process check via launchctl (best effort)
135
+ let running = false;
136
+ try {
137
+ const out = execSync('launchctl list | grep com.parallelclaw.memex.sync', { encoding: 'utf-8', timeout: 1000 });
138
+ running = !out.match(/^\s*-\s/m); // "-" means not running
139
+ } catch (_) { /* not running */ }
140
+
141
+ return { installed: true, running, lastCaptureMs };
142
+ } catch (_) {
143
+ return { installed: false, running: false, lastCaptureMs: null };
144
+ }
145
+ }
146
+
147
+ // ----- Read-only auth (optional bearer token) -----
148
+
149
+ function checkAuth(req, expectedToken) {
150
+ if (!expectedToken) return true; // no auth required
151
+ const auth = req.headers.authorization || '';
152
+ return auth === `Bearer ${expectedToken}`;
153
+ }
154
+
155
+ // ----- Request handler -----
156
+
157
+ async function handleRequest(req, res, opts) {
158
+ const { pathname, query } = parseUrl(req.url);
159
+
160
+ // Static files (no auth required)
161
+ if (pathname.startsWith('/static/')) {
162
+ return serveStatic(res, pathname);
163
+ }
164
+
165
+ // Health check (no auth required, used by tests)
166
+ if (pathname === '/api/health') {
167
+ return sendJson(res, 200, { ok: true, db: existsSync(DB_PATH) });
168
+ }
169
+
170
+ // Auth check
171
+ if (!checkAuth(req, opts.token)) {
172
+ return send(res, 401, '<h1>401 — auth required</h1><p>Pass <code>--token</code> when starting <code>memex web</code> and include <code>Authorization: Bearer &lt;token&gt;</code></p>');
173
+ }
174
+
175
+ try {
176
+ // Route dispatch
177
+ if (pathname === '/') {
178
+ const { renderDashboard } = await import('./routes/dashboard.js');
179
+ return send(res, 200, await renderDashboard(getDb(), getDaemonStatus()));
180
+ }
181
+ if (pathname === '/conversations') {
182
+ const { renderConversations } = await import('./routes/conversations.js');
183
+ return send(res, 200, await renderConversations(getDb(), query, getDaemonStatus()));
184
+ }
185
+ if (pathname === '/conversations/search') {
186
+ const { renderConversationsPartial } = await import('./routes/conversations.js');
187
+ return send(res, 200, await renderConversationsPartial(getDb(), query));
188
+ }
189
+ if (pathname.startsWith('/c/')) {
190
+ const { renderConversation } = await import('./routes/conversation.js');
191
+ const id = decodeURIComponent(pathname.slice(3));
192
+ return send(res, 200, await renderConversation(getDb(), id, query, getDaemonStatus()));
193
+ }
194
+ if (pathname === '/pending') {
195
+ const { renderPending } = await import('./routes/pending.js');
196
+ return send(res, 200, await renderPending(getDaemonStatus()));
197
+ }
198
+ if (pathname === '/pending/import' && req.method === 'POST') {
199
+ const { handleImport } = await import('./routes/pending.js');
200
+ const body = await readBody(req);
201
+ return send(res, 200, await handleImport(body));
202
+ }
203
+ if (pathname === '/pending/skip' && req.method === 'POST') {
204
+ const { handleSkip } = await import('./routes/pending.js');
205
+ const body = await readBody(req);
206
+ return send(res, 200, await handleSkip(body));
207
+ }
208
+ if (pathname === '/settings') {
209
+ const { renderSettings } = await import('./routes/settings.js');
210
+ return send(res, 200, await renderSettings(getDb(), getDaemonStatus()));
211
+ }
212
+ return notFound(res);
213
+ } catch (e) {
214
+ return serverError(res, e);
215
+ }
216
+ }
217
+
218
+ function readBody(req) {
219
+ return new Promise((resolve, reject) => {
220
+ const chunks = [];
221
+ req.on('data', (c) => chunks.push(c));
222
+ req.on('end', () => {
223
+ const raw = Buffer.concat(chunks).toString('utf-8');
224
+ // Parse form-encoded body
225
+ const params = {};
226
+ for (const pair of raw.split('&')) {
227
+ if (!pair) continue;
228
+ const [k, v] = pair.split('=');
229
+ const key = decodeURIComponent(k);
230
+ const val = v != null ? decodeURIComponent(v.replace(/\+/g, ' ')) : '';
231
+ // Handle repeated keys (e.g., index=1&index=3 → array)
232
+ if (key in params) {
233
+ params[key] = Array.isArray(params[key]) ? [...params[key], val] : [params[key], val];
234
+ } else {
235
+ params[key] = val;
236
+ }
237
+ }
238
+ resolve(params);
239
+ });
240
+ req.on('error', reject);
241
+ });
242
+ }
243
+
244
+ // ----- Public entry point -----
245
+
246
+ export function startServer({ port = 8765, host = '127.0.0.1', token = null, open = false } = {}) {
247
+ const server = createServer((req, res) => {
248
+ handleRequest(req, res, { token }).catch((e) => serverError(res, e));
249
+ });
250
+
251
+ return new Promise((resolve, reject) => {
252
+ server.on('error', reject);
253
+ server.listen(port, host, () => {
254
+ const url = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`;
255
+ console.log(`memex web listening on ${url}`);
256
+ if (token) console.log(` auth: bearer token required (Authorization: Bearer ${token})`);
257
+ if (open) {
258
+ // Best-effort open browser on macOS / Linux
259
+ const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
260
+ try { spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref(); } catch (_) {}
261
+ }
262
+ resolve({ server, url });
263
+ });
264
+ });
265
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * GET /c/:id — verbatim transcript of one conversation.
3
+ *
4
+ * This is the page that demonstrates memex's verbatim moat: every message
5
+ * the user and the AI exchanged, in chat-bubble form, never paraphrased.
6
+ *
7
+ * Query params:
8
+ * q — optional search term to highlight inside the transcript
9
+ * offset — pagination offset (default 0)
10
+ * limit — page size (default 200, max 1000)
11
+ */
12
+
13
+ import { renderPage, html, raw, esc, fmtDate, fmtDateTime, fmtNum } from '../templates.js';
14
+
15
+ const MAX_LIMIT = 1000;
16
+ const DEFAULT_LIMIT = 200;
17
+
18
+ function clampLimit(raw) {
19
+ const n = parseInt(raw, 10);
20
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT;
21
+ return Math.min(n, MAX_LIMIT);
22
+ }
23
+
24
+ /**
25
+ * Determine which side of the transcript the message should appear on.
26
+ *
27
+ * Conventions:
28
+ * - role='user' or sender is the user (claude-code/cowork) → right side ("you")
29
+ * - role='assistant' or 'model' → left side ("ai")
30
+ * - Telegram: sender matches "self_indicator" → right; everyone else → left
31
+ */
32
+ function bubbleSide(msg) {
33
+ if (msg.role === 'user') return 'right';
34
+ if (msg.role === 'assistant' || msg.role === 'model') return 'left';
35
+ // Telegram heuristic: first sender alphabetically goes "left", rest on alternating sides.
36
+ // Without a notion of "me", we just put non-user roles on the left.
37
+ return 'left';
38
+ }
39
+
40
+ function dayKey(ts) {
41
+ if (!ts) return null;
42
+ return new Date(ts * 1000).toISOString().slice(0, 10);
43
+ }
44
+
45
+ /**
46
+ * Highlight occurrences of `q` (case-insensitive) inside escaped text.
47
+ * Receives ALREADY-ESCAPED HTML — we do regex over that string and wrap
48
+ * matches in <mark>. Safe because we never let user input near a tag opener.
49
+ */
50
+ function highlight(escapedText, q) {
51
+ if (!q || !q.trim()) return escapedText;
52
+ const needle = q.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53
+ // Avoid matching inside existing tags by requiring no '<' before next '>'
54
+ const re = new RegExp(`(${needle})`, 'gi');
55
+ return escapedText.replace(re, '<mark>$1</mark>');
56
+ }
57
+
58
+ function renderBubble(msg, q) {
59
+ const side = bubbleSide(msg);
60
+ const cls = side === 'right' ? 'chat-bubble user' : 'chat-bubble ai';
61
+ const who = msg.sender || msg.role || (side === 'right' ? 'you' : 'ai');
62
+ const when = msg.ts ? fmtDateTime(msg.ts) : '';
63
+ const text = msg.text || '';
64
+ const highlighted = highlight(esc(text), q);
65
+ return `
66
+ <div class="${cls}" id="msg-${msg.id}">
67
+ <span class="chat-who">${esc(who)}${when ? ' · ' + esc(when) : ''}</span>
68
+ <p>${highlighted}</p>
69
+ </div>
70
+ `;
71
+ }
72
+
73
+ export async function renderConversation(db, id, query, status) {
74
+ const q = query.q || '';
75
+ const offset = Math.max(0, parseInt(query.offset, 10) || 0);
76
+ const limit = clampLimit(query.limit);
77
+
78
+ const conv = db
79
+ .prepare('SELECT * FROM conversations WHERE conversation_id = ?')
80
+ .get(id);
81
+
82
+ if (!conv) {
83
+ const body = html`
84
+ <div class="empty">
85
+ <h3>Conversation not found</h3>
86
+ <p>No conversation with id <code>${esc(id)}</code> in this database.</p>
87
+ <p style="margin-top:12px;"><a class="btn" href="/conversations">← Back to conversations</a></p>
88
+ </div>
89
+ `;
90
+ return renderPage({ title: 'Not found', active: 'conversations', body, status });
91
+ }
92
+
93
+ const messages = db
94
+ .prepare(
95
+ `
96
+ SELECT id, role, sender, text, ts, msg_id
97
+ FROM messages
98
+ WHERE conversation_id = ?
99
+ AND role NOT IN ('boundary', 'summary')
100
+ ORDER BY COALESCE(ts, 0) ASC, id ASC
101
+ LIMIT ? OFFSET ?
102
+ `
103
+ )
104
+ .all(id, limit, offset);
105
+
106
+ const totalNonMeta = db
107
+ .prepare(
108
+ "SELECT COUNT(*) AS n FROM messages WHERE conversation_id = ? AND role NOT IN ('boundary', 'summary')"
109
+ )
110
+ .get(id).n;
111
+
112
+ // Build day-separated transcript
113
+ const transcriptParts = [];
114
+ let lastDay = null;
115
+ for (const m of messages) {
116
+ const day = dayKey(m.ts);
117
+ if (day && day !== lastDay) {
118
+ transcriptParts.push(`<div class="transcript-day">${esc(day)}</div>`);
119
+ lastDay = day;
120
+ }
121
+ transcriptParts.push(renderBubble(m, q));
122
+ }
123
+
124
+ const transcriptHtml = transcriptParts.join('\n');
125
+
126
+ // Pagination
127
+ const hasPrev = offset > 0;
128
+ const hasNext = offset + messages.length < totalNonMeta;
129
+ const baseQs = q ? `?q=${encodeURIComponent(q)}&` : '?';
130
+ const pagination = (hasPrev || hasNext)
131
+ ? html`
132
+ <div class="search-bar" style="justify-content:space-between;margin-top:24px;">
133
+ ${hasPrev
134
+ ? html`<a class="btn" href="/c/${encodeURIComponent(id)}${raw(baseQs)}offset=${Math.max(0, offset - limit)}&limit=${limit}">← Previous ${limit}</a>`
135
+ : html`<span></span>`}
136
+ <span class="search-meta">
137
+ Showing ${fmtNum(offset + 1)}–${fmtNum(offset + messages.length)} of ${fmtNum(totalNonMeta)}
138
+ </span>
139
+ ${hasNext
140
+ ? html`<a class="btn" href="/c/${encodeURIComponent(id)}${raw(baseQs)}offset=${offset + limit}&limit=${limit}">Next ${limit} →</a>`
141
+ : html`<span></span>`}
142
+ </div>
143
+ `
144
+ : null;
145
+
146
+ // Search box scoped to this conversation
147
+ const searchBar = html`
148
+ <form class="search-bar" method="get" action="/c/${encodeURIComponent(id)}">
149
+ <input
150
+ class="search-input"
151
+ type="search"
152
+ name="q"
153
+ placeholder="🔍 Find in this conversation…"
154
+ value="${esc(q)}"
155
+ autocomplete="off"
156
+ />
157
+ ${q
158
+ ? html`<a class="btn" href="/c/${encodeURIComponent(id)}">Clear</a>`
159
+ : null}
160
+ </form>
161
+ `;
162
+
163
+ const header = html`
164
+ <p style="margin-bottom:14px;">
165
+ <a class="btn" href="/conversations">← All conversations</a>
166
+ </p>
167
+ <section class="card">
168
+ <div class="card-label">
169
+ <span class="conv-source-tag">${conv.source}</span>
170
+ ${fmtNum(totalNonMeta)} messages
171
+ · ${fmtDate(conv.first_ts)} → ${fmtDate(conv.last_ts)}
172
+ ${conv.project_path ? raw(' · <code>' + esc(conv.project_path) + '</code>') : null}
173
+ </div>
174
+ <div class="card-body">
175
+ <h2 style="font-size:20px;font-weight:700;letter-spacing:-0.02em;">${conv.title || '(untitled)'}</h2>
176
+ </div>
177
+ </section>
178
+ `;
179
+
180
+ const body = html`
181
+ ${header}
182
+ ${searchBar}
183
+ <div class="transcript">${raw(transcriptHtml)}</div>
184
+ ${pagination}
185
+ `;
186
+
187
+ return renderPage({
188
+ title: conv.title || 'Conversation',
189
+ active: 'conversations',
190
+ body,
191
+ status,
192
+ });
193
+ }