memex-mvp 0.10.7 → 0.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HELP.md CHANGED
@@ -363,6 +363,40 @@ memex get web-1582ab51a7b7 --json > backup.json
363
363
 
364
364
  ---
365
365
 
366
+ ## 🖥 Web-дашборд (v0.10.8+) — посмотреть память глазами
367
+
368
+ Когда хочется не звать AI, а **самому полистать** что у тебя в memex'е — есть локальный read-only веб-UI. Та же SQLite, другая поверхность.
369
+
370
+ ```bash
371
+ memex web --open # localhost:8765, откроется в браузере
372
+ memex web --port 9000 # свой порт
373
+ memex web --public --token s3cret # 0.0.0.0 с bearer-авторизацией (для туннеля)
374
+ memex web --help
375
+ ```
376
+
377
+ **5 страниц:**
378
+
379
+ - `/` — статы (messages / conversations / sources / imports), sources breakdown, callout про pending Telegram, последние 10 чатов
380
+ - `/conversations` — список с **live FTS5-поиском** через htmx (печатаешь — обновляется за 200мс), фильтры-чипы по source
381
+ - `/c/:id` — **verbatim** транскрипт chat-bubble'ами (то самое, что не делает claude-mem — он только AI-резюмирует); поиск внутри чата с подсветкой; пагинация для 1000+ сообщений
382
+ - `/pending` — Telegram-экспорты с чекбоксами; нажимаешь Import / Skip — тот же гейт privacy, что и `memex telegram import`
383
+ - `/settings` — daemon status, путь и размер БД, установленные хуки, decisions count (read-only)
384
+
385
+ **Что важно:**
386
+ - Не always-on. `memex web` поднял — Ctrl+C погасил. Никакого фонового сервера.
387
+ - Read-only. Единственные записи — это TG import / skip на `/pending`.
388
+ - Localhost-only by default. Для remote — `--public --token …`.
389
+ - Без build-step'a. Raw Node `http` + tagged template literals + htmx 14KB. Клиентский bundle ≈ 30KB (для сравнения у claude-mem ~10MB React-бандл).
390
+ - Брендирование как на лендинге (mint #6ee7b7 на тёмном).
391
+
392
+ **Когда полезно:**
393
+ - Хочешь сам прокрутить большой Telegram-чат и не вспоминать какой именно поиск ввести
394
+ - Демо коллеге — открыл вкладку, показал что у тебя реально лежит дословно (это и есть verbatim-moat — на любой чат с любого источника можно перейти и увидеть сырое, не AI-пересказ)
395
+ - Аудит pending'a с мышкой и галочками вместо `memex telegram import 1 3 5`
396
+ - Cron-friendly remote endpoint (тот же сервер потом получит multi-host sync API в v0.13+)
397
+
398
+ ---
399
+
366
400
  ## 🪄 Auto-context (v0.8+) — Brian Chesky moment
367
401
 
368
402
  Magic-фича. Когда ты открываешь Claude Code в проекте, Claude **сам** инжектит 500-1500 токенов контекста про этот проект — что ты делал недавно, какие conversations касались темы. Ты ещё ничего не спросил, а AI **уже знает**.
package/README.md CHANGED
@@ -211,6 +211,36 @@ Terminal equivalents: `memex telegram check / pending / import 1 3 5 / skip 2 /
211
211
 
212
212
  ---
213
213
 
214
+ ## Web dashboard (v0.10.8+) — see your own memory
215
+
216
+ Opt-in, read-only local UI for browsing the corpus without any AI in the loop. Same SQLite, different surface.
217
+
218
+ ```sh
219
+ memex web --open # localhost:8765, opens in browser
220
+ memex web --port 9000 # custom port
221
+ memex web --public --token s3cret # bind on 0.0.0.0 with bearer auth (for remote / tunnel)
222
+ memex web --help
223
+ ```
224
+
225
+ Five pages:
226
+
227
+ | Page | What it shows |
228
+ |-------------------|------------------------------------------------------------------------------------------|
229
+ | `/` | Stats grid · sources breakdown · pending Telegram callout · recent 10 conversations |
230
+ | `/conversations` | Live FTS5 search via htmx (200ms debounce) · source-chip filters · hit counts per chat |
231
+ | `/c/:id` | **Verbatim** transcript in chat-bubbles · in-chat search with `<mark>` highlight · paged |
232
+ | `/pending` | Telegram exports awaiting decision · bulk Import / Skip checkboxes · decision history |
233
+ | `/settings` | Daemon status · DB path & size · hooks installed · TG decisions counts (read-only) |
234
+
235
+ **Design constraints:**
236
+ - **Opt-in, not always-on.** `memex web` starts the server; Ctrl+C stops it. No daemon.
237
+ - **Read-only by default.** The only writes are TG import / skip on the `/pending` page — same privacy gate as `memex telegram import`.
238
+ - **Localhost-only by default.** Binds `127.0.0.1`. Use `--public --token <…>` for remote access (cron-friendly: same endpoint reserved for the future multi-host sync API).
239
+ - **No build step.** Node raw `http` + tagged template literals + htmx 14KB CDN. Total client bundle: ~30KB.
240
+ - **Brand-aligned.** Same Inter + mint palette as [memex.parallelclaw.ai](https://memex.parallelclaw.ai).
241
+
242
+ ---
243
+
214
244
  ## What it captures
215
245
 
216
246
  | Source | How it gets in |
package/README.ru.md CHANGED
@@ -528,6 +528,36 @@ One file with all your AI conversations — sounds scarier than it is.
528
528
 
529
529
  ---
530
530
 
531
+ ## Web-дашборд (v0.10.8+) — увидеть свою память без AI
532
+
533
+ Опциональный read-only локальный UI для просмотра корпуса. Та же SQLite, другая поверхность — пригождается когда хочется просто посмотреть на свои разговоры глазами, без MCP-агента в цикле.
534
+
535
+ ```sh
536
+ memex web --open # localhost:8765, откроет браузер
537
+ memex web --port 9000 # свой порт
538
+ memex web --public --token s3cret # 0.0.0.0 с bearer-авторизацией (для remote / туннеля)
539
+ memex web --help
540
+ ```
541
+
542
+ Пять страниц:
543
+
544
+ | Страница | Что внутри |
545
+ |-------------------|-------------------------------------------------------------------------------------------|
546
+ | `/` | Сетка статов · sources breakdown · callout про pending Telegram · последние 10 conversations |
547
+ | `/conversations` | Live FTS5-поиск через htmx (200мс debounce) · фильтры-чипы по source · кол-во hit'ов на чат |
548
+ | `/c/:id` | **Verbatim** транскрипт chat-bubble'ами · поиск внутри чата с `<mark>` подсветкой · пагинация |
549
+ | `/pending` | Telegram-экспорты ждущие решения · чекбоксы bulk Import / Skip · история твоих решений |
550
+ | `/settings` | Статус daemon'а · путь и размер БД · установленные хуки · TG decisions counts (read-only) |
551
+
552
+ **Принципы:**
553
+ - **Opt-in, не always-on.** `memex web` поднимает сервер; Ctrl+C гасит. Никакого фонового демона.
554
+ - **Read-only по умолчанию.** Единственные записи — TG import / skip на `/pending` (тот же privacy-gate, что и `memex telegram import`).
555
+ - **Localhost-only по умолчанию.** Слушает на `127.0.0.1`. Для удалённого доступа — `--public --token <…>` (на этом же endpoint в будущем поедет multi-host sync API).
556
+ - **Без build-step'а.** Raw Node `http` + tagged template literals + htmx 14KB с CDN. Клиентский bundle ≈ 30KB.
557
+ - **Брендирование совпадает с лендингом.** Inter + mint-палитра как на [memex.parallelclaw.ai](https://memex.parallelclaw.ai).
558
+
559
+ ---
560
+
531
561
  ## Как использовать на практике / How to actually use it
532
562
 
533
563
  Полный guide с **6 типовыми use case'ами** (Telegram → action plan, cross-AI bridge, recall, project resume, patterns, deck-анализ), описанием всех MCP-tools и troubleshooting — в [HELP.md](HELP.md). Скопируй любой промпт из этого файла → вставь в свой AI-агент → попробуй сразу после установки.
package/lib/cli/index.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  // ---------- Subcommand registry ----------
39
39
  export const CLI_SUBCOMMAND_NAMES = [
40
40
  'search', 'recent', 'list', 'get', 'overview',
41
- 'projects', 'context', 'hook', 'when', 'telegram',
41
+ 'projects', 'context', 'hook', 'when', 'telegram', 'web',
42
42
  'help', '-h', '--help', '-v', '--version',
43
43
  ];
44
44
 
@@ -1670,6 +1670,81 @@ function tgCmdScan(opts, discovery, pending) {
1670
1670
  console.log(c.dim('Review: memex telegram pending'));
1671
1671
  }
1672
1672
 
1673
+ // =============================================================
1674
+ // `memex web` — opt-in local dashboard
1675
+ // =============================================================
1676
+ async function cmdWeb(args) {
1677
+ // Parse web-specific flags out of argv. We DO NOT route through parseArgs()
1678
+ // because it would silently ignore --port/--host/--token (forward-compat
1679
+ // unknown-flag policy), and we want strict parsing for the server.
1680
+ const opts = { port: 8765, host: '127.0.0.1', token: null, open: false };
1681
+ for (let i = 0; i < args.length; i++) {
1682
+ const a = args[i];
1683
+ if (a === '--port' || a === '-p') opts.port = parseInt(args[++i], 10);
1684
+ else if (a === '--host') opts.host = args[++i];
1685
+ else if (a === '--token') opts.token = args[++i];
1686
+ else if (a === '--open' || a === '-o') opts.open = true;
1687
+ else if (a === '--public') opts.host = '0.0.0.0';
1688
+ else if (a === '--help' || a === '-h') {
1689
+ console.log(`memex web — local read-only dashboard
1690
+
1691
+ Usage:
1692
+ memex web [options]
1693
+
1694
+ Options:
1695
+ --port, -p <num> Port to bind (default: 8765)
1696
+ --host <addr> Bind address (default: 127.0.0.1, localhost-only)
1697
+ --public Shorthand for --host 0.0.0.0 (bind on all interfaces)
1698
+ --token <str> Require Authorization: Bearer <str> for all routes
1699
+ except /api/health and /static/*. Recommended when
1700
+ using --public or behind a tunnel.
1701
+ --open, -o Open the dashboard in your browser after start
1702
+ -h, --help Show this help
1703
+
1704
+ Examples:
1705
+ memex web localhost:8765, no auth
1706
+ memex web --open and open in browser
1707
+ memex web --port 9000 custom port
1708
+ memex web --public --token s3cret bind on 0.0.0.0 with bearer auth
1709
+ `);
1710
+ // Help path: exit cleanly so the caller (server.js) doesn't fall through
1711
+ // to MCP-mode init, and Node doesn't warn about an unsettled top-level
1712
+ // await waiting on an HTTP server that was never started.
1713
+ process.exit(0);
1714
+ }
1715
+ else if (a.startsWith('--')) {
1716
+ console.error(`Unknown flag: ${a}`);
1717
+ console.error(`Run 'memex web --help' for usage.`);
1718
+ process.exit(2);
1719
+ }
1720
+ }
1721
+
1722
+ if (!Number.isFinite(opts.port) || opts.port <= 0 || opts.port > 65535) {
1723
+ console.error(`Invalid port: ${opts.port}`);
1724
+ process.exit(2);
1725
+ }
1726
+ if (opts.host !== '127.0.0.1' && opts.host !== 'localhost' && !opts.token) {
1727
+ console.error(c.yellow('⚠ Binding on ' + opts.host + ' without --token leaves the dashboard open to the network.'));
1728
+ console.error(c.yellow(' Press Ctrl+C now if that\'s not what you want. Continuing in 3 seconds…'));
1729
+ await new Promise((r) => setTimeout(r, 3000));
1730
+ }
1731
+
1732
+ if (!existsSync(DB_PATH)) {
1733
+ console.error(`memex.db not found at ${DB_PATH}`);
1734
+ console.error(`Run 'memex-sync install' first to set up the daemon and create the DB.`);
1735
+ process.exit(1);
1736
+ }
1737
+
1738
+ const { startServer } = await import('../web/index.js');
1739
+ await startServer(opts);
1740
+ // The HTTP server now holds the event loop open. Park on an unsettled
1741
+ // promise so cmdWeb never returns to the dispatcher — that way server.js
1742
+ // doesn't reach process.exit(0) below or fall through to MCP-mode init.
1743
+ // (Required because `await runCli(...)` would otherwise resolve as soon
1744
+ // as startServer's listen-callback fires.)
1745
+ await new Promise(() => {});
1746
+ }
1747
+
1673
1748
  // =============================================================
1674
1749
  // DISPATCH
1675
1750
  // =============================================================
@@ -1689,6 +1764,7 @@ export async function runCli(sub, args) {
1689
1764
  case 'context': await cmdContext(args); break;
1690
1765
  case 'hook': await cmdHook(args); break;
1691
1766
  case 'telegram': await cmdTelegram(args); break;
1767
+ case 'web': await cmdWeb(args); break;
1692
1768
  case 'help': await cmdHelp(); break;
1693
1769
  case '--help':
1694
1770
  case '-h': await cmdUsage(); break;
@@ -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
+ }