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.
- package/CHANGELOG.md +204 -0
- package/HELP.md +600 -0
- package/LICENSE +21 -0
- package/MULTI_MACHINE.md +152 -0
- package/README.md +417 -0
- package/README.ru.md +740 -0
- package/SYNC.md +844 -0
- package/bot/README.md +173 -0
- package/bot/config.js +66 -0
- package/bot/inbox.js +153 -0
- package/bot/index.js +294 -0
- package/bot/nexara.js +61 -0
- package/bot/poll.js +304 -0
- package/bot/search.js +155 -0
- package/bot/telegram.js +96 -0
- package/ingest.js +2712 -0
- package/lib/cli/index.js +1987 -0
- package/lib/config.js +220 -0
- package/lib/db-init.js +158 -0
- package/lib/hook/install.js +268 -0
- package/lib/import-telegram.js +158 -0
- package/lib/ingest-file.js +779 -0
- package/lib/notify-click-action.js +281 -0
- package/lib/openclaw-channel.js +643 -0
- package/lib/parse-cursor.js +172 -0
- package/lib/parse-obsidian.js +256 -0
- package/lib/parse-telegram-html.js +384 -0
- package/lib/parse.js +175 -0
- package/lib/render-markdown.js +0 -0
- package/lib/store-doc/canonicalize.js +116 -0
- package/lib/store-doc/detect.js +209 -0
- package/lib/store-doc/extract-title.js +162 -0
- package/lib/sync/auth.js +80 -0
- package/lib/sync/cert.js +144 -0
- package/lib/sync/cli.js +906 -0
- package/lib/sync/client.js +138 -0
- package/lib/sync/config.js +130 -0
- package/lib/sync/pair.js +145 -0
- package/lib/sync/pull.js +158 -0
- package/lib/sync/push.js +305 -0
- package/lib/sync/replicate.js +335 -0
- package/lib/sync/server.js +224 -0
- package/lib/sync/service.js +726 -0
- package/lib/tasks.js +215 -0
- package/lib/telegram-decisions.js +165 -0
- package/lib/telegram-discovery.js +373 -0
- package/lib/telegram-notify.js +272 -0
- package/lib/telegram-pending.js +200 -0
- 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 +84 -0
- package/server.js +3816 -0
- package/skills/install-memex/README.md +109 -0
- package/skills/install-memex/SKILL.md +342 -0
- package/skills/install-memex/examples.md +294 -0
- 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
|
+
}
|
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
|
+
}
|