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 +34 -0
- package/README.md +30 -0
- package/README.ru.md +30 -0
- package/lib/cli/index.js +77 -1
- package/lib/web/index.js +265 -0
- package/lib/web/routes/conversation.js +193 -0
- package/lib/web/routes/conversations.js +180 -0
- package/lib/web/routes/dashboard.js +175 -0
- package/lib/web/routes/pending.js +277 -0
- package/lib/web/routes/settings.js +226 -0
- package/lib/web/static/style.css +393 -0
- package/lib/web/templates.js +234 -0
- package/package.json +6 -6
- package/server.js +4 -0
- package/skills/install-memex/SKILL.md +33 -1
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;
|
package/lib/web/index.js
ADDED
|
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 <token></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
|
+
}
|