parallelclaw 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/HELP.md +600 -0
  3. package/LICENSE +21 -0
  4. package/MULTI_MACHINE.md +152 -0
  5. package/README.md +417 -0
  6. package/README.ru.md +740 -0
  7. package/SYNC.md +844 -0
  8. package/bot/README.md +173 -0
  9. package/bot/config.js +66 -0
  10. package/bot/inbox.js +153 -0
  11. package/bot/index.js +294 -0
  12. package/bot/nexara.js +61 -0
  13. package/bot/poll.js +304 -0
  14. package/bot/search.js +155 -0
  15. package/bot/telegram.js +96 -0
  16. package/ingest.js +2712 -0
  17. package/lib/cli/index.js +1987 -0
  18. package/lib/config.js +220 -0
  19. package/lib/db-init.js +158 -0
  20. package/lib/hook/install.js +268 -0
  21. package/lib/import-telegram.js +158 -0
  22. package/lib/ingest-file.js +779 -0
  23. package/lib/notify-click-action.js +281 -0
  24. package/lib/openclaw-channel.js +643 -0
  25. package/lib/parse-cursor.js +172 -0
  26. package/lib/parse-obsidian.js +256 -0
  27. package/lib/parse-telegram-html.js +384 -0
  28. package/lib/parse.js +175 -0
  29. package/lib/render-markdown.js +0 -0
  30. package/lib/store-doc/canonicalize.js +116 -0
  31. package/lib/store-doc/detect.js +209 -0
  32. package/lib/store-doc/extract-title.js +162 -0
  33. package/lib/sync/auth.js +80 -0
  34. package/lib/sync/cert.js +144 -0
  35. package/lib/sync/cli.js +906 -0
  36. package/lib/sync/client.js +138 -0
  37. package/lib/sync/config.js +130 -0
  38. package/lib/sync/pair.js +145 -0
  39. package/lib/sync/pull.js +158 -0
  40. package/lib/sync/push.js +305 -0
  41. package/lib/sync/replicate.js +335 -0
  42. package/lib/sync/server.js +224 -0
  43. package/lib/sync/service.js +726 -0
  44. package/lib/tasks.js +215 -0
  45. package/lib/telegram-decisions.js +165 -0
  46. package/lib/telegram-discovery.js +373 -0
  47. package/lib/telegram-notify.js +272 -0
  48. package/lib/telegram-pending.js +200 -0
  49. package/lib/web/index.js +265 -0
  50. package/lib/web/routes/conversation.js +193 -0
  51. package/lib/web/routes/conversations.js +180 -0
  52. package/lib/web/routes/dashboard.js +175 -0
  53. package/lib/web/routes/pending.js +277 -0
  54. package/lib/web/routes/settings.js +226 -0
  55. package/lib/web/static/style.css +393 -0
  56. package/lib/web/templates.js +234 -0
  57. package/package.json +84 -0
  58. package/server.js +3816 -0
  59. package/skills/install-memex/README.md +109 -0
  60. package/skills/install-memex/SKILL.md +342 -0
  61. package/skills/install-memex/examples.md +294 -0
  62. package/skills/install-memex-claw/SKILL.md +423 -0
@@ -0,0 +1,1987 @@
1
+ /**
2
+ * memex CLI — terminal-mode subcommands for the `memex` binary.
3
+ *
4
+ * When the user invokes the `memex` bin with a recognized subcommand
5
+ * (search / recent / list / get / overview / projects / help / --help
6
+ * / --version), we run a one-shot query and exit. When called WITHOUT
7
+ * any argument, server.js falls through to MCP-stdio mode (the
8
+ * primary mode used by Claude Code, Cursor, OpenClaw).
9
+ *
10
+ * The CLI opens memex.db in read-only mode and uses WAL-friendly
11
+ * queries — safe to run while memex-sync daemon is writing.
12
+ *
13
+ * Why duplicate SQL from server.js? The MCP handlers in server.js
14
+ * are tightly coupled with the JSON-RPC response shape (jsonResult /
15
+ * textResult, half-life-boost params, group_by_conversation, …).
16
+ * Replicating the simple queries here keeps the CLI self-contained
17
+ * and avoids a risky refactor of the production MCP path. The CLI
18
+ * intentionally exposes the MOST USEFUL subset — not every MCP tool
19
+ * has a CLI peer.
20
+ *
21
+ * Output format:
22
+ * default → human-friendly markdown with light ANSI colors (TTY only)
23
+ * --json → structured JSON for shell pipelines / agents
24
+ */
25
+
26
+ import Database from 'better-sqlite3';
27
+ import { join, basename } from 'node:path';
28
+ import { homedir } from 'node:os';
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { fileURLToPath } from 'node:url';
31
+ import {
32
+ installHook,
33
+ uninstallHook,
34
+ getHookStatus,
35
+ resolveMemexBinPath,
36
+ } from '../hook/install.js';
37
+
38
+ // ---------- Subcommand registry ----------
39
+ export const CLI_SUBCOMMAND_NAMES = [
40
+ 'search', 'recent', 'list', 'get', 'overview',
41
+ 'projects', 'context', 'hook', 'when', 'telegram', 'web', 'import',
42
+ 'help', '-h', '--help', '-v', '--version',
43
+ ];
44
+
45
+ // ---------- Path helpers ----------
46
+ const HOME = homedir();
47
+ const MEMEX_DIR = process.env.MEMEX_DIR || join(HOME, '.memex');
48
+ const DB_PATH = join(MEMEX_DIR, 'data', 'memex.db');
49
+ // HELP.md lives at the package root, two levels up from lib/cli/
50
+ const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
51
+ const HELP_MD_PATH = join(PACKAGE_ROOT, 'HELP.md');
52
+
53
+ // ---------- ANSI helpers ----------
54
+ const TTY = process.stdout.isTTY;
55
+ const c = TTY
56
+ ? {
57
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
58
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
59
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
60
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
61
+ yellow:(s) => `\x1b[33m${s}\x1b[0m`,
62
+ }
63
+ : {
64
+ dim: (s) => s, bold: (s) => s, cyan: (s) => s,
65
+ green: (s) => s, yellow: (s) => s,
66
+ };
67
+
68
+ // ---------- argv parser (minimal, no deps) ----------
69
+ function parseArgs(argv) {
70
+ const opts = {};
71
+ const positionals = [];
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const a = argv[i];
74
+ if (a === '--json') opts.json = true;
75
+ else if (a === '--limit') opts.limit = parseInt(argv[++i], 10);
76
+ else if (a === '--source') opts.source = argv[++i];
77
+ else if (a === '--channel') opts.channel = argv[++i];
78
+ else if (a === '--chat') opts.chat = argv[++i];
79
+ else if (a === '--project') opts.project = argv[++i];
80
+ else if (a === '--sort') opts.sort = argv[++i];
81
+ else if (a === '--include-archived') opts.includeArchived = true;
82
+ else if (a === '--pwd') opts.pwd = argv[++i];
83
+ else if (a === '--budget' || a === '--budget-tokens') opts.budget = parseInt(argv[++i], 10);
84
+ else if (a === '--freshness-days') opts.freshnessDays = parseInt(argv[++i], 10);
85
+ else if (a === '--no-source') {
86
+ // Allow repeated --no-source telegram --no-source obsidian
87
+ if (!Array.isArray(opts.noSource)) opts.noSource = [];
88
+ opts.noSource.push(argv[++i]);
89
+ }
90
+ else if (a === '--as-of') opts.asOf = argv[++i];
91
+ else if (a === '--help' || a === '-h') opts.help = true;
92
+ else if (a.startsWith('--')) { /* ignore unknown flag for forward-compat */ }
93
+ else positionals.push(a);
94
+ }
95
+ return { opts, positionals };
96
+ }
97
+
98
+ function openDb() {
99
+ if (!existsSync(DB_PATH)) {
100
+ console.error(`memex.db not found at ${DB_PATH}`);
101
+ console.error(`Run 'memex-sync install' to set up the daemon and create the DB.`);
102
+ process.exit(1);
103
+ }
104
+ // Read-only handle: WAL allows this to coexist with the writing daemon.
105
+ return new Database(DB_PATH, { readonly: true, fileMustExist: true });
106
+ }
107
+
108
+ function fmtDate(ts) {
109
+ if (!ts || ts === 0) return '?';
110
+ return new Date(ts * 1000).toISOString().slice(0, 10);
111
+ }
112
+
113
+ /**
114
+ * Channel B (Telegram pending tip): print a short non-blocking tip at the
115
+ * end of ANY CLI command if there are exports awaiting review AND the tip
116
+ * hasn't been shown in the last 6 hours. Skipped automatically when:
117
+ * • --json is set (don't pollute machine-readable output)
118
+ * • the command is `memex telegram <anything>` (user is already in TG flow)
119
+ * • PENDING_TIP_SUPPRESS=1 in env (e.g. tests)
120
+ */
121
+ function maybePrintTelegramTip(opts = {}, currentSubcommand = '') {
122
+ if (opts.json) return;
123
+ if (currentSubcommand === 'telegram') return;
124
+ if (process.env.MEMEX_TIP_SUPPRESS === '1') return;
125
+ // Lazy-load to avoid I/O penalty for cold paths
126
+ let listPending, loadNotifyState, saveNotifyState, cliTipDue, markCliTipShown, formatTelegramTip;
127
+ try {
128
+ ({ listPending } = require('../telegram-pending.js'));
129
+ ({ loadNotifyState, saveNotifyState, cliTipDue, markCliTipShown, formatTelegramTip } = require('../telegram-notify.js'));
130
+ } catch (_) {
131
+ // ESM: fall through to async dynamic import (handled by caller)
132
+ return;
133
+ }
134
+ const list = listPending();
135
+ if (!list || list.length === 0) return;
136
+ const state = loadNotifyState();
137
+ if (!cliTipDue(state)) return;
138
+ const showTitles = state.notifications.show_titles !== false;
139
+ const tip = formatTelegramTip(list, { showTitles });
140
+ if (tip) {
141
+ console.log(tip);
142
+ markCliTipShown(state);
143
+ saveNotifyState(state);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * ESM-friendly async version — used because the CLI loads libs via dynamic
149
+ * import. Each subcommand calls `await afterCommand(opts, sub)` at the end.
150
+ */
151
+ async function afterCommand(opts = {}, currentSubcommand = '') {
152
+ if (opts.json) return;
153
+ if (currentSubcommand === 'telegram') return;
154
+ if (currentSubcommand === 'web') return; // web command holds the loop; tip would never print anyway
155
+ // `context` output IS the SessionStart hook payload — it gets injected
156
+ // verbatim into Claude Code's session header. Tips would corrupt that
157
+ // (and bust the --budget-tokens cap that the hook contract relies on).
158
+ if (currentSubcommand === 'context') return;
159
+ if (process.env.MEMEX_TIP_SUPPRESS === '1') return;
160
+ try {
161
+ const notify = await import('../telegram-notify.js');
162
+ const state = notify.loadNotifyState();
163
+
164
+ // Channel B priority: TG-pending tip first (it's actionable — there's
165
+ // a real export waiting). Dashboard tip is "nice to know", so only
166
+ // show it when the TG channel has nothing to surface.
167
+ const { listPending } = await import('../telegram-pending.js');
168
+ const list = listPending();
169
+ const hasPending = list && list.length > 0;
170
+
171
+ if (hasPending && notify.cliTipDue(state)) {
172
+ const showTitles = state.notifications.show_titles !== false;
173
+ const tip = notify.formatTelegramTip(list, { showTitles });
174
+ if (tip) {
175
+ console.log(tip);
176
+ notify.markCliTipShown(state);
177
+ notify.saveNotifyState(state);
178
+ return;
179
+ }
180
+ }
181
+
182
+ if (!hasPending && notify.dashboardTipDue(state)) {
183
+ const tip = notify.formatDashboardTip();
184
+ if (tip) {
185
+ console.log(c.dim(tip));
186
+ notify.markDashboardTipShown(state);
187
+ notify.saveNotifyState(state);
188
+ }
189
+ }
190
+ } catch (_) {
191
+ /* never break a command because of a tip */
192
+ }
193
+ }
194
+
195
+ function fmtDateTime(ts) {
196
+ if (!ts || ts === 0) return '?';
197
+ return new Date(ts * 1000).toISOString().slice(0, 16).replace('T', ' ');
198
+ }
199
+
200
+ /**
201
+ * Parse YYYY-MM-DD into unix timestamp at start-of-day (00:00 UTC).
202
+ * Returns null on invalid input.
203
+ */
204
+ function parseAsOf(s) {
205
+ if (typeof s !== 'string') return null;
206
+ const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
207
+ if (!m) return null;
208
+ const [, y, mo, d] = m;
209
+ const date = new Date(`${y}-${mo}-${d}T00:00:00Z`);
210
+ if (isNaN(date.getTime())) return null;
211
+ return Math.floor(date.getTime() / 1000);
212
+ }
213
+
214
+ // FTS5 expects sanitized tokens — strip what would be operators
215
+ function sanitizeFtsQuery(q) {
216
+ return String(q || '')
217
+ .trim()
218
+ .replace(/[^\p{L}\p{N}_\-\s"]/gu, ' ')
219
+ .replace(/\s+/g, ' ')
220
+ .trim();
221
+ }
222
+
223
+ // =============================================================
224
+ // SEARCH
225
+ // =============================================================
226
+ async function cmdSearch(args) {
227
+ const { opts, positionals } = parseArgs(args);
228
+ const query = positionals.join(' ').trim();
229
+ if (!query || opts.help) {
230
+ console.error('Usage: memex search "<query>" [--source X] [--chat X] [--project X] [--sort SORT] [--limit N] [--json]');
231
+ console.error(' --sort: relevance (default) | date_asc | date_desc');
232
+ process.exit(query ? 0 : 2);
233
+ }
234
+ const limit = Math.min(50, Math.max(1, opts.limit || 10));
235
+ const sanitized = sanitizeFtsQuery(query);
236
+ if (!sanitized) {
237
+ console.error('Query became empty after sanitization — try simpler keywords.');
238
+ process.exit(2);
239
+ }
240
+
241
+ const filters = ['messages_fts MATCH ?'];
242
+ const params = [sanitized];
243
+ if (opts.source) {
244
+ filters.push('m.source = ?');
245
+ params.push(opts.source);
246
+ }
247
+ if (opts.channel) {
248
+ filters.push('m.channel = ?');
249
+ params.push(opts.channel);
250
+ }
251
+ if (!opts.includeArchived) {
252
+ filters.push('(c.archived_at IS NULL OR c.archived_at = 0)');
253
+ }
254
+ if (opts.project) {
255
+ filters.push('c.project_path LIKE ?');
256
+ params.push(`%${opts.project}%`);
257
+ }
258
+ if (opts.chat) {
259
+ filters.push('LOWER(c.title) LIKE LOWER(?)');
260
+ params.push(`%${opts.chat}%`);
261
+ }
262
+ // Time-travel: --as-of YYYY-MM-DD returns only messages with ts strictly
263
+ // before that calendar date (start-of-day). Useful for retrospectives:
264
+ // "what did I know about X two weeks ago?"
265
+ if (opts.asOf) {
266
+ const cutoff = parseAsOf(opts.asOf);
267
+ if (cutoff === null) {
268
+ console.error(`Invalid --as-of date: "${opts.asOf}". Expected YYYY-MM-DD.`);
269
+ process.exit(2);
270
+ }
271
+ filters.push('m.ts > 0 AND m.ts < ?');
272
+ params.push(cutoff);
273
+ }
274
+
275
+ let orderBy;
276
+ if (opts.sort === 'date_asc') {
277
+ orderBy = 'CASE WHEN m.ts IS NULL OR m.ts = 0 THEN 1 ELSE 0 END, m.ts ASC';
278
+ } else if (opts.sort === 'date_desc') {
279
+ orderBy = 'CASE WHEN m.ts IS NULL OR m.ts = 0 THEN 1 ELSE 0 END, m.ts DESC';
280
+ } else {
281
+ // Same BM25 × recency formula as memex_search, with half_life = 30 days
282
+ orderBy = `bm25(messages_fts) * exp(-(CAST(strftime('%s','now') AS REAL) - COALESCE(NULLIF(m.ts, 0), CAST(strftime('%s','now') AS REAL))) / 86400.0 / 30.0)`;
283
+ }
284
+
285
+ const sql = `
286
+ SELECT m.source, m.conversation_id, m.role, m.sender, m.ts,
287
+ snippet(messages_fts, 0, '<<', '>>', ' … ', 18) AS snippet,
288
+ c.title AS conversation_title
289
+ FROM messages_fts
290
+ JOIN messages m ON m.id = messages_fts.rowid
291
+ LEFT JOIN conversations c ON c.conversation_id = m.conversation_id
292
+ WHERE ${filters.join(' AND ')}
293
+ ORDER BY ${orderBy}
294
+ LIMIT ?
295
+ `;
296
+ const db = openDb();
297
+ const rows = db.prepare(sql).all(...params, limit);
298
+ db.close();
299
+
300
+ if (opts.json) {
301
+ console.log(JSON.stringify({ query, count: rows.length, results: rows }, null, 2));
302
+ return;
303
+ }
304
+
305
+ if (rows.length === 0) {
306
+ console.log(`No results for ${c.bold('"' + query + '"')}`);
307
+ return;
308
+ }
309
+ console.log(`${c.bold(rows.length)} result(s) for ${c.bold('"' + query + '"')}\n`);
310
+ for (const r of rows) {
311
+ console.log(`${c.cyan(r.conversation_title || r.conversation_id)} ${c.dim('· ' + r.source + ' · ' + fmtDate(r.ts))}`);
312
+ console.log(` ${r.snippet.replace(/<<(.+?)>>/g, (_, m) => c.yellow(m))}`);
313
+ console.log(` ${c.dim('conversation_id: ' + r.conversation_id)}`);
314
+ console.log('');
315
+ }
316
+ }
317
+
318
+ // =============================================================
319
+ // RECENT
320
+ // =============================================================
321
+ async function cmdRecent(args) {
322
+ const { opts } = parseArgs(args);
323
+ if (opts.help) {
324
+ console.error('Usage: memex recent [--limit N] [--source X] [--json]');
325
+ process.exit(0);
326
+ }
327
+ const limit = Math.min(100, Math.max(1, opts.limit || 20));
328
+ const filters = [];
329
+ const params = [];
330
+ if (opts.source) {
331
+ filters.push('m.source = ?');
332
+ params.push(opts.source);
333
+ }
334
+ if (opts.channel) {
335
+ filters.push('m.channel = ?');
336
+ params.push(opts.channel);
337
+ }
338
+ if (!opts.includeArchived) {
339
+ filters.push('(c.archived_at IS NULL OR c.archived_at = 0)');
340
+ }
341
+ const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
342
+ const sql = `
343
+ SELECT m.source, m.conversation_id, m.role, m.sender, m.ts,
344
+ substr(m.text, 1, 240) AS preview,
345
+ c.title AS conversation_title
346
+ FROM messages m
347
+ LEFT JOIN conversations c ON c.conversation_id = m.conversation_id
348
+ ${where}
349
+ ORDER BY m.ts DESC
350
+ LIMIT ?
351
+ `;
352
+ const db = openDb();
353
+ const rows = db.prepare(sql).all(...params, limit);
354
+ db.close();
355
+
356
+ if (opts.json) {
357
+ console.log(JSON.stringify({ count: rows.length, results: rows }, null, 2));
358
+ return;
359
+ }
360
+ console.log(`${c.bold(rows.length)} recent message(s)\n`);
361
+ for (const r of rows) {
362
+ console.log(`${c.cyan(r.conversation_title || r.conversation_id)} ${c.dim('· ' + r.source + ' · ' + fmtDateTime(r.ts))}`);
363
+ console.log(` ${c.dim(r.role + ':')} ${r.preview.replace(/\s+/g, ' ').trim()}`);
364
+ console.log('');
365
+ }
366
+ }
367
+
368
+ // =============================================================
369
+ // LIST conversations
370
+ // =============================================================
371
+ async function cmdList(args) {
372
+ const { opts } = parseArgs(args);
373
+ if (opts.help) {
374
+ console.error('Usage: memex list [--source X] [--limit N] [--json]');
375
+ process.exit(0);
376
+ }
377
+ const limit = Math.min(200, Math.max(1, opts.limit || 20));
378
+ const filters = [];
379
+ const params = [];
380
+ if (opts.source) {
381
+ filters.push('source = ?');
382
+ params.push(opts.source);
383
+ }
384
+ if (!opts.includeArchived) {
385
+ filters.push('(archived_at IS NULL OR archived_at = 0)');
386
+ }
387
+ filters.push("(parent_conversation_id IS NULL)"); // skip subagents by default
388
+ const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
389
+ const sql = `
390
+ SELECT conversation_id, source, title, first_ts, last_ts, message_count
391
+ FROM conversations
392
+ ${where}
393
+ ORDER BY last_ts DESC
394
+ LIMIT ?
395
+ `;
396
+ const db = openDb();
397
+ const rows = db.prepare(sql).all(...params, limit);
398
+ db.close();
399
+
400
+ if (opts.json) {
401
+ console.log(JSON.stringify({ count: rows.length, conversations: rows }, null, 2));
402
+ return;
403
+ }
404
+ console.log(`${c.bold(rows.length)} conversation(s)\n`);
405
+ for (const r of rows) {
406
+ console.log(`${c.cyan(r.title || r.conversation_id)}`);
407
+ console.log(` ${c.dim(r.source + ' · ' + r.message_count + ' msgs · ' + fmtDate(r.first_ts) + ' → ' + fmtDate(r.last_ts))}`);
408
+ console.log(` ${c.dim(r.conversation_id)}`);
409
+ console.log('');
410
+ }
411
+ }
412
+
413
+ // =============================================================
414
+ // GET full conversation
415
+ // =============================================================
416
+ async function cmdGet(args) {
417
+ const { opts, positionals } = parseArgs(args);
418
+ const convId = positionals[0];
419
+ if (!convId || opts.help) {
420
+ console.error('Usage: memex get <conversation_id> [--limit N] [--json]');
421
+ console.error('Find conversation_ids via `memex list` or `memex search`.');
422
+ process.exit(convId ? 0 : 2);
423
+ }
424
+ const limit = Math.min(2000, Math.max(1, opts.limit || 200));
425
+ const db = openDb();
426
+ const conv = db
427
+ .prepare(`SELECT * FROM conversations WHERE conversation_id = ?`)
428
+ .get(convId);
429
+ if (!conv) {
430
+ db.close();
431
+ console.error(`No conversation found for id: ${convId}`);
432
+ process.exit(1);
433
+ }
434
+ const msgs = db
435
+ .prepare(`
436
+ SELECT role, sender, text, ts
437
+ FROM messages
438
+ WHERE conversation_id = ?
439
+ ORDER BY ts ASC, id ASC
440
+ LIMIT ?
441
+ `)
442
+ .all(convId, limit);
443
+ db.close();
444
+
445
+ if (opts.json) {
446
+ console.log(JSON.stringify({ conversation: conv, messages: msgs }, null, 2));
447
+ return;
448
+ }
449
+ console.log(`# ${conv.title || conv.conversation_id}`);
450
+ console.log(`${c.dim(conv.source + ' · ' + msgs.length + ' message(s) · ' + fmtDate(conv.first_ts) + ' → ' + fmtDate(conv.last_ts))}`);
451
+ console.log('');
452
+ for (const m of msgs) {
453
+ console.log(`${c.cyan(m.role + ' (' + m.sender + ')')} ${c.dim(fmtDateTime(m.ts))}`);
454
+ console.log(m.text);
455
+ console.log('');
456
+ }
457
+ }
458
+
459
+ // =============================================================
460
+ // OVERVIEW
461
+ // =============================================================
462
+ async function cmdOverview(args) {
463
+ const { opts } = parseArgs(args);
464
+ const db = openDb();
465
+ const sources = db.prepare(`
466
+ SELECT source, COUNT(*) AS msgs, COUNT(DISTINCT conversation_id) AS chats,
467
+ MIN(ts) AS first_ts, MAX(ts) AS last_ts
468
+ FROM messages
469
+ GROUP BY source
470
+ ORDER BY msgs DESC
471
+ `).all();
472
+ const totalMsgs = db.prepare(`SELECT COUNT(*) AS c FROM messages`).get().c;
473
+ const totalConvs = db.prepare(`SELECT COUNT(*) AS c FROM conversations`).get().c;
474
+ const recentConvs = db.prepare(`
475
+ SELECT conversation_id, source, title, last_ts
476
+ FROM conversations
477
+ WHERE archived_at IS NULL OR archived_at = 0
478
+ ORDER BY last_ts DESC
479
+ LIMIT 10
480
+ `).all();
481
+
482
+ // Streak + today's capture count (D6 — GitHub-style daily habit signal)
483
+ const streak = computeStreak(db);
484
+
485
+ db.close();
486
+
487
+ if (opts.json) {
488
+ console.log(JSON.stringify({
489
+ total_messages: totalMsgs,
490
+ total_conversations: totalConvs,
491
+ sources,
492
+ recent_conversations: recentConvs,
493
+ streak: streak,
494
+ }, null, 2));
495
+ return;
496
+ }
497
+ console.log(c.bold('memex corpus snapshot') + '\n');
498
+ console.log(`Total: ${c.green(totalMsgs + ' messages')} in ${c.green(totalConvs + ' conversations')}\n`);
499
+
500
+ // Streak block — only show if there's at least one captured day
501
+ if (streak.streakDays > 0) {
502
+ const today = streak.todayMessages;
503
+ const todayLine = today > 0
504
+ ? `Today: ${c.green(today + ' messages')} across ${streak.todayConversations} conversation(s).`
505
+ : `${c.dim('No captures yet today.')}`;
506
+ const streakLine = streak.streakDays >= 2
507
+ ? `${c.green('✓ ' + streak.streakDays + '-day capture streak')} (since ${fmtDate(streak.streakStartTs)}). ${todayLine}`
508
+ : `${c.dim('Starting fresh — capture something today to begin a streak.')} ${todayLine}`;
509
+ console.log(streakLine + '\n');
510
+ }
511
+
512
+ console.log(c.bold('By source:'));
513
+ for (const s of sources) {
514
+ console.log(` ${s.source.padEnd(18)} ${String(s.msgs).padStart(7)} msgs · ${String(s.chats).padStart(5)} chats · ${fmtDate(s.first_ts)} → ${fmtDate(s.last_ts)}`);
515
+ }
516
+ console.log('');
517
+ console.log(c.bold('10 most recent conversations:'));
518
+ for (const r of recentConvs) {
519
+ console.log(` ${c.dim(fmtDate(r.last_ts))} ${c.cyan((r.title || r.conversation_id).slice(0, 60))} ${c.dim('(' + r.source + ')')}`);
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Compute current capture streak: consecutive days (working backward from
525
+ * today) with at least one message captured.
526
+ *
527
+ * Returns:
528
+ * {
529
+ * streakDays: number, // 0 if today has 0 captures
530
+ * streakStartTs: number, // ts of the earliest day in the streak
531
+ * todayMessages: number, // count of messages captured today
532
+ * todayConversations: number,
533
+ * }
534
+ *
535
+ * "Day" boundaries are UTC (matches how we store ts). A more user-friendly
536
+ * version would use local-day, but UTC is consistent and predictable —
537
+ * good enough for v0.8.1.
538
+ */
539
+ function computeStreak(db) {
540
+ const now = Math.floor(Date.now() / 1000);
541
+ const todayStart = Math.floor(now / 86400) * 86400; // UTC midnight today
542
+
543
+ // Distinct days with captures, sorted desc — pull up to ~365 days to bound work
544
+ const days = db.prepare(`
545
+ SELECT DISTINCT (ts / 86400) AS day
546
+ FROM messages
547
+ WHERE ts >= ?
548
+ ORDER BY day DESC
549
+ `).all(todayStart - 365 * 86400);
550
+
551
+ let streakDays = 0;
552
+ let streakStartTs = 0;
553
+ if (days.length > 0) {
554
+ const todayDay = Math.floor(todayStart / 86400);
555
+ let cursor = todayDay;
556
+ for (const row of days) {
557
+ if (row.day === cursor) {
558
+ streakDays += 1;
559
+ streakStartTs = row.day * 86400;
560
+ cursor -= 1;
561
+ } else if (row.day < cursor) {
562
+ // Streak broken — stop
563
+ break;
564
+ }
565
+ // row.day > cursor shouldn't happen with DESC order; if it does, skip
566
+ }
567
+ }
568
+
569
+ const todayRow = db.prepare(`
570
+ SELECT COUNT(*) AS msgs, COUNT(DISTINCT conversation_id) AS convs
571
+ FROM messages
572
+ WHERE ts >= ?
573
+ `).get(todayStart);
574
+
575
+ return {
576
+ streakDays,
577
+ streakStartTs,
578
+ todayMessages: todayRow.msgs,
579
+ todayConversations: todayRow.convs,
580
+ };
581
+ }
582
+
583
+ // =============================================================
584
+ // PROJECTS
585
+ // =============================================================
586
+ async function cmdProjects(args) {
587
+ const { opts } = parseArgs(args);
588
+ const limit = Math.min(500, Math.max(1, opts.limit || 50));
589
+ const db = openDb();
590
+ const rows = db.prepare(`
591
+ SELECT project_path AS path, COUNT(*) AS chats
592
+ FROM conversations
593
+ WHERE project_path IS NOT NULL AND project_path != ''
594
+ GROUP BY project_path
595
+ ORDER BY chats DESC, project_path ASC
596
+ LIMIT ?
597
+ `).all(limit);
598
+ db.close();
599
+
600
+ if (opts.json) {
601
+ console.log(JSON.stringify({ count: rows.length, projects: rows }, null, 2));
602
+ return;
603
+ }
604
+ if (rows.length === 0) {
605
+ console.log('No projects captured yet. Run `memex-sync backfill-projects` to populate project paths on older conversations.');
606
+ return;
607
+ }
608
+ console.log(`${c.bold(rows.length)} project(s):\n`);
609
+ for (const r of rows) {
610
+ console.log(` ${String(r.chats).padStart(4)} chats ${c.cyan(r.path)}`);
611
+ }
612
+ }
613
+
614
+ // =============================================================
615
+ // WHEN — chronological "when did we talk about X" CLI shortcut
616
+ // =============================================================
617
+ //
618
+ // `memex when "JWT decision"` answers the single most common memex query
619
+ // in 1 second: dates, sources, conversation titles. No snippets, no
620
+ // re-ranking — just chronological recall.
621
+ //
622
+ // Returns one row per matching conversation, sorted by latest message in
623
+ // that conversation (date_desc). Useful when the user remembers a topic
624
+ // but can't recall WHICH session it came from or WHEN.
625
+ async function cmdWhen(args) {
626
+ const { opts, positionals } = parseArgs(args);
627
+ const query = positionals.join(' ').trim();
628
+
629
+ if (!query || opts.help) {
630
+ console.error('Usage: memex when "<query>" [--source X] [--limit N] [--json]');
631
+ console.error('');
632
+ console.error('Returns a chronological "when did we talk about X" list — date + source +');
633
+ console.error('conversation title, no snippets. Sorted newest first.');
634
+ process.exit(query ? 0 : 2);
635
+ }
636
+
637
+ const limit = Math.min(50, Math.max(1, opts.limit || 15));
638
+ const sanitized = sanitizeFtsQuery(query);
639
+ if (!sanitized) {
640
+ console.error('Query became empty after sanitization — try simpler keywords.');
641
+ process.exit(2);
642
+ }
643
+
644
+ const filters = ['messages_fts MATCH ?'];
645
+ const params = [sanitized];
646
+ if (opts.source) {
647
+ filters.push('m.source = ?');
648
+ params.push(opts.source);
649
+ }
650
+ if (opts.channel) {
651
+ filters.push('m.channel = ?');
652
+ params.push(opts.channel);
653
+ }
654
+ if (!opts.includeArchived) {
655
+ filters.push('(c.archived_at IS NULL OR c.archived_at = 0)');
656
+ }
657
+
658
+ // Aggregate by conversation: one row per chat, latest hit's date, match count
659
+ const sql = `
660
+ SELECT m.conversation_id,
661
+ m.source,
662
+ MAX(m.ts) AS latest_ts,
663
+ MIN(m.ts) AS earliest_ts,
664
+ COUNT(*) AS match_count,
665
+ c.title AS conversation_title
666
+ FROM messages_fts
667
+ JOIN messages m ON m.id = messages_fts.rowid
668
+ LEFT JOIN conversations c ON c.conversation_id = m.conversation_id
669
+ WHERE ${filters.join(' AND ')}
670
+ GROUP BY m.conversation_id
671
+ ORDER BY latest_ts DESC
672
+ LIMIT ?
673
+ `;
674
+ const db = openDb();
675
+ const rows = db.prepare(sql).all(...params, limit);
676
+ db.close();
677
+
678
+ if (opts.json) {
679
+ console.log(JSON.stringify({ query, count: rows.length, results: rows }, null, 2));
680
+ return;
681
+ }
682
+ if (rows.length === 0) {
683
+ console.log(`No mentions of ${c.bold('"' + query + '"')} found.`);
684
+ return;
685
+ }
686
+ console.log(`${c.bold('"' + query + '"')} mentioned in ${c.bold(rows.length)} conversation(s):\n`);
687
+ for (const r of rows) {
688
+ const date = fmtDate(r.latest_ts);
689
+ const range = r.earliest_ts && r.earliest_ts !== r.latest_ts
690
+ ? ` (also ${fmtDate(r.earliest_ts)})`
691
+ : '';
692
+ const count = r.match_count > 1 ? ` · ${r.match_count} matches` : '';
693
+ console.log(` ${c.green(date)}${c.dim(range)} ${c.dim(r.source.padEnd(14))} ${c.cyan((r.conversation_title || r.conversation_id).slice(0, 60))}${c.dim(count)}`);
694
+ }
695
+ console.log('');
696
+ console.log(c.dim(`To read one: memex get <conversation_id> | to search content: memex search "${query}"`));
697
+ }
698
+
699
+ // =============================================================
700
+ // CONTEXT — output relevant memex context for current pwd
701
+ // =============================================================
702
+ //
703
+ // Designed to be called by Claude Code SessionStart hook (or equivalent).
704
+ // stdout markdown becomes a system message injected into Claude's context
705
+ // BEFORE the user sends their first prompt. So Claude "knows" what the
706
+ // user has been doing in this project without being asked.
707
+ //
708
+ // Smart selection:
709
+ // 1. Direct project_path match — conversations where this exact path was
710
+ // captured (Claude Code/Cowork cwd, Obsidian vault, etc.)
711
+ // 2. Project-name fuzzy match — conversations whose title mentions the
712
+ // basename of pwd (catches discussions of the project across sources
713
+ // like Telegram where there's no project_path).
714
+ //
715
+ // Default budget: 1500 tokens (≈6000 chars markdown). Truncated cleanly
716
+ // if needed — never spill into Claude's context window unboundedly.
717
+ //
718
+ // Privacy: telegram source is included by default (users discussed feature
719
+ // idea here) but can be excluded via --no-source telegram. Future:
720
+ // per-source sensitivity flags in ~/.memex/config.json.
721
+ //
722
+ // Output is markdown. --json gives the structured underlying data.
723
+ async function cmdContext(args) {
724
+ const { opts } = parseArgs(args);
725
+
726
+ if (opts.help) {
727
+ console.error('Usage: memex context [--pwd PATH] [--limit N] [--budget-tokens N] [--freshness-days N] [--no-source NAME] [--json]');
728
+ console.error('');
729
+ console.error('Outputs markdown summarizing recent memex activity relevant to the current pwd.');
730
+ console.error('Designed for use as a Claude Code SessionStart hook.');
731
+ process.exit(0);
732
+ }
733
+
734
+ const pwd = opts.pwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
735
+ const limit = Math.min(20, Math.max(1, opts.limit || 5));
736
+ const tokenBudget = Math.min(8000, Math.max(50, opts.budget || 1500));
737
+ const freshnessDays = Math.min(365, Math.max(1, opts.freshnessDays || 90));
738
+ const excludeSources = Array.isArray(opts.noSource) ? opts.noSource : (opts.noSource ? [opts.noSource] : []);
739
+
740
+ const project = basename(pwd);
741
+ const sinceTs = Math.floor(Date.now() / 1000) - freshnessDays * 86400;
742
+
743
+ const db = openDb();
744
+
745
+ // 1. Direct project_path matches — highest signal.
746
+ const directFilters = [
747
+ 'project_path LIKE ?',
748
+ 'last_ts >= ?',
749
+ '(archived_at IS NULL OR archived_at = 0)',
750
+ ];
751
+ const directParams = [`%${pwd}%`, sinceTs];
752
+ if (excludeSources.length) {
753
+ const placeholders = excludeSources.map(() => '?').join(',');
754
+ directFilters.push(`source NOT IN (${placeholders})`);
755
+ directParams.push(...excludeSources);
756
+ }
757
+ const directConvs = db.prepare(`
758
+ SELECT conversation_id, source, title, first_ts, last_ts, message_count
759
+ FROM conversations
760
+ WHERE ${directFilters.join(' AND ')}
761
+ ORDER BY last_ts DESC
762
+ LIMIT ?
763
+ `).all(...directParams, limit);
764
+
765
+ // 2. Fuzzy project-name matches in title (catches Telegram / web discussion of project).
766
+ // Skip duplicates we already got from direct match.
767
+ const seenIds = new Set(directConvs.map((c) => c.conversation_id));
768
+ const fuzzyFilters = [
769
+ 'LOWER(title) LIKE LOWER(?)',
770
+ 'last_ts >= ?',
771
+ '(archived_at IS NULL OR archived_at = 0)',
772
+ ];
773
+ const fuzzyParams = [`%${project}%`, sinceTs];
774
+ if (excludeSources.length) {
775
+ const placeholders = excludeSources.map(() => '?').join(',');
776
+ fuzzyFilters.push(`source NOT IN (${placeholders})`);
777
+ fuzzyParams.push(...excludeSources);
778
+ }
779
+ const fuzzyConvs = db.prepare(`
780
+ SELECT conversation_id, source, title, first_ts, last_ts, message_count
781
+ FROM conversations
782
+ WHERE ${fuzzyFilters.join(' AND ')}
783
+ ORDER BY last_ts DESC
784
+ LIMIT ?
785
+ `).all(...fuzzyParams, limit * 2);
786
+ const filteredFuzzy = fuzzyConvs.filter((r) => !seenIds.has(r.conversation_id)).slice(0, Math.max(1, limit - directConvs.length));
787
+
788
+ db.close();
789
+
790
+ const all = [...directConvs, ...filteredFuzzy];
791
+
792
+ // Channel D: Telegram pending exports — Brian Chesky moment. If the
793
+ // user has staged exports awaiting review, surface them in the auto-
794
+ // context injection so Claude leads with "I see you exported N chats —
795
+ // import which?" before the user even types.
796
+ let pendingTg = [];
797
+ try {
798
+ const { listPending } = await import('../telegram-pending.js');
799
+ pendingTg = listPending();
800
+ } catch (_) { /* ignore */ }
801
+
802
+ if (opts.json) {
803
+ console.log(JSON.stringify({
804
+ pwd, project, freshness_days: freshnessDays,
805
+ direct_matches: directConvs.length,
806
+ fuzzy_matches: filteredFuzzy.length,
807
+ conversations: all,
808
+ token_budget: tokenBudget,
809
+ telegram_pending: pendingTg.map((e) => ({
810
+ chat_title: e.chat_title,
811
+ chat_type: e.chat_type,
812
+ message_count: e.message_count,
813
+ date_first: e.date_first,
814
+ date_last: e.date_last,
815
+ })),
816
+ }, null, 2));
817
+ return;
818
+ }
819
+
820
+ // Markdown output (TTY-color stripped — this is consumed by Claude, not the user)
821
+ const lines = [];
822
+ lines.push(`## memex auto-context for ${project}`);
823
+ lines.push('');
824
+
825
+ // Pending Telegram banner — top of the hook so Claude reads it first
826
+ if (pendingTg.length > 0) {
827
+ lines.push(`### 🆕 ${pendingTg.length} Telegram export${pendingTg.length === 1 ? '' : 's'} awaiting review`);
828
+ lines.push('');
829
+ lines.push(`The user recently exported these chats from Telegram Desktop. They are NOT yet in memex — waiting for explicit per-chat consent.`);
830
+ lines.push('');
831
+ for (const e of pendingTg.slice(0, 10)) {
832
+ const t = e.chat_title || '(untitled)';
833
+ const range = (e.date_first || '').slice(0, 10) + ' → ' + (e.date_last || '').slice(0, 10);
834
+ const type = e.chat_type === 'private_group' ? ' · _group_' : '';
835
+ lines.push(`- **${t}** — ${(e.message_count || 0).toLocaleString()} msgs · ${range}${type}`);
836
+ }
837
+ if (pendingTg.length > 10) lines.push(`- _… and ${pendingTg.length - 10} more_`);
838
+ lines.push('');
839
+ lines.push(`**INSTRUCTION FOR THE AGENT:** When the user types their first message, BEFORE answering, mention this and ask which to import. Phrase it naturally — name the chats. Example:`);
840
+ lines.push('');
841
+ lines.push(`> "I see you exported ${pendingTg.length} Telegram chat${pendingTg.length === 1 ? '' : 's'} — ${pendingTg.slice(0, 3).map((e) => `"${e.chat_title || '?'}"`).join(', ')}${pendingTg.length > 3 ? ', and a few more' : ''}. Want me to import them? Tell me which (or 'all', or 'skip therapist and bank')."`);
842
+ lines.push('');
843
+ lines.push(`Then call \`memex_telegram_import\` with the user's selection. If user names sensitive-looking chats but doesn't explicitly include them, confirm: don't skip silently.`);
844
+ lines.push('');
845
+ lines.push(`If user says skip / not now / later → just continue with their actual request. The exports stay in pending; the next session will mention them again.`);
846
+ lines.push('');
847
+ }
848
+
849
+ if (all.length === 0 && pendingTg.length === 0) {
850
+ lines.push(`_No recent activity in memex for this project (last ${freshnessDays} days)._`);
851
+ lines.push('');
852
+ lines.push(`Path searched: \`${pwd}\``);
853
+ lines.push('');
854
+ lines.push('---');
855
+ lines.push(`_memex auto-context · empty · v0.8+_`);
856
+ process.stdout.write(lines.join('\n') + '\n');
857
+ return;
858
+ }
859
+
860
+ if (directConvs.length > 0) {
861
+ lines.push(`### Recent conversations in this project (last ${freshnessDays} days)`);
862
+ lines.push('');
863
+ for (const conv of directConvs) {
864
+ const date = fmtDate(conv.last_ts);
865
+ const title = (conv.title || conv.conversation_id).slice(0, 100);
866
+ lines.push(`- **${date}** · _${conv.source}_ · ${title} (${conv.message_count} msgs)`);
867
+ }
868
+ lines.push('');
869
+ }
870
+
871
+ if (filteredFuzzy.length > 0) {
872
+ lines.push(`### Related discussions mentioning "${project}"`);
873
+ lines.push('');
874
+ for (const conv of filteredFuzzy) {
875
+ const date = fmtDate(conv.last_ts);
876
+ const title = (conv.title || conv.conversation_id).slice(0, 100);
877
+ lines.push(`- **${date}** · _${conv.source}_ · ${title}`);
878
+ }
879
+ lines.push('');
880
+ }
881
+
882
+ lines.push('---');
883
+ lines.push(`_memex auto-context · ${all.length} sources · v0.8+_`);
884
+ lines.push(`_To search deeper, ask memex (via MCP tool or terminal: \`memex search "..."\`)._`);
885
+
886
+ let out = lines.join('\n') + '\n';
887
+
888
+ // Token budget enforcement — rough estimate ≈ 4 chars/token. Truncate
889
+ // cleanly at a line boundary to keep markdown valid.
890
+ const maxChars = tokenBudget * 4;
891
+ if (out.length > maxChars) {
892
+ out = out.slice(0, maxChars);
893
+ const lastNewline = out.lastIndexOf('\n');
894
+ if (lastNewline > 0) out = out.slice(0, lastNewline);
895
+ out += '\n\n_[context truncated — exceeds token budget]_\n';
896
+ }
897
+
898
+ process.stdout.write(out);
899
+ }
900
+
901
+ // =============================================================
902
+ // HOOK — install/uninstall/status for Claude Code SessionStart
903
+ // =============================================================
904
+ async function cmdHook(args) {
905
+ const sub = args[0];
906
+ const rest = args.slice(1);
907
+ const { opts } = parseArgs(rest);
908
+
909
+ if (!sub || sub === '--help' || sub === '-h') {
910
+ console.error('Usage: memex hook <install|uninstall|status>');
911
+ console.error('');
912
+ console.error(' install Add memex SessionStart hook to ~/.claude/settings.json');
913
+ console.error(' Idempotent — re-runs are no-ops.');
914
+ console.error(' Claude Code will inject memex context on every new session.');
915
+ console.error('');
916
+ console.error(' uninstall Remove the memex hook entry. Preserves all other hooks.');
917
+ console.error('');
918
+ console.error(' status Show whether the hook is currently installed.');
919
+ process.exit(sub ? 0 : 2);
920
+ }
921
+
922
+ if (sub === 'install') {
923
+ const r = installHook();
924
+ if (opts.json) {
925
+ console.log(JSON.stringify(r, null, 2));
926
+ return;
927
+ }
928
+ if (r.error) {
929
+ console.error(`✗ ${r.error}`);
930
+ process.exit(1);
931
+ }
932
+ if (r.alreadyPresent) {
933
+ console.log(`✓ memex hook already installed in ${r.settingsPath}`);
934
+ console.log(` command: ${r.command}`);
935
+ return;
936
+ }
937
+ console.log(`✓ memex SessionStart hook installed`);
938
+ console.log(` settings: ${r.settingsPath}`);
939
+ console.log(` command: ${r.command}`);
940
+ console.log('');
941
+ console.log('Restart Claude Code (Cmd+Q + reopen) for the hook to activate.');
942
+ console.log('After restart, Claude will see memex context on every new session.');
943
+ console.log('');
944
+ console.log('Disable later: memex hook uninstall');
945
+ return;
946
+ }
947
+
948
+ if (sub === 'uninstall') {
949
+ const r = uninstallHook();
950
+ if (opts.json) {
951
+ console.log(JSON.stringify(r, null, 2));
952
+ return;
953
+ }
954
+ if (r.error) {
955
+ console.error(`✗ ${r.error}`);
956
+ process.exit(1);
957
+ }
958
+ if (!r.wasPresent) {
959
+ console.log('memex hook was not installed (nothing to remove).');
960
+ return;
961
+ }
962
+ console.log('✓ memex SessionStart hook removed');
963
+ console.log(' Other hooks in ~/.claude/settings.json preserved.');
964
+ console.log('');
965
+ console.log('Restart Claude Code (Cmd+Q + reopen) for the change to take effect.');
966
+ return;
967
+ }
968
+
969
+ if (sub === 'status') {
970
+ const r = getHookStatus();
971
+ if (opts.json) {
972
+ console.log(JSON.stringify(r, null, 2));
973
+ return;
974
+ }
975
+ console.log(`settings file: ${r.settingsPath}`);
976
+ if (!r.settingsExists) {
977
+ console.log(' status: file does not exist');
978
+ console.log(' hook: NOT installed');
979
+ return;
980
+ }
981
+ if (!r.settingsValid) {
982
+ console.log(' status: file exists but is not valid JSON');
983
+ console.log(' hook: could not determine (fix settings file first)');
984
+ return;
985
+ }
986
+ console.log(` hook: ${r.installed ? 'INSTALLED' : 'NOT installed'}`);
987
+ if (r.installed) console.log(` command: ${r.command}`);
988
+ console.log(` other SessionStart hooks: ${r.otherSessionStartHooks}`);
989
+ if (!r.installed) {
990
+ console.log('');
991
+ console.log('Install with: memex hook install');
992
+ }
993
+ return;
994
+ }
995
+
996
+ console.error(`Unknown hook subcommand: ${sub}`);
997
+ console.error('Run "memex hook --help" for usage.');
998
+ process.exit(2);
999
+ }
1000
+
1001
+ // =============================================================
1002
+ // HELP — print HELP.md content
1003
+ // =============================================================
1004
+ async function cmdHelp() {
1005
+ if (!existsSync(HELP_MD_PATH)) {
1006
+ console.error(`HELP.md not found at ${HELP_MD_PATH}`);
1007
+ console.error(`See https://github.com/parallelclaw/memex-mvp/blob/main/HELP.md`);
1008
+ process.exit(1);
1009
+ }
1010
+ process.stdout.write(readFileSync(HELP_MD_PATH, 'utf-8'));
1011
+ }
1012
+
1013
+ // =============================================================
1014
+ // USAGE — `memex --help`
1015
+ // =============================================================
1016
+ async function cmdUsage() {
1017
+ console.log(`memex — local-first MCP memory server for AI agents
1018
+
1019
+ USAGE
1020
+ memex run as MCP stdio server (called by Claude Code,
1021
+ Cursor, OpenClaw via MCP config)
1022
+
1023
+ memex <command> [args] run a one-shot terminal query and exit
1024
+
1025
+ COMMANDS
1026
+ search "<query>" full-text search across all sources
1027
+ --source <name> filter by source (telegram, claude-code, …)
1028
+ --chat "<title>" filter by conversation title (substring)
1029
+ --project <path> filter by project_path (substring)
1030
+ --sort <mode> relevance | date_asc | date_desc
1031
+ --as-of YYYY-MM-DD time-travel: only messages before this date
1032
+ --limit N max results (default 10, max 50)
1033
+ --json output JSON instead of markdown
1034
+
1035
+ when "<query>" chronological "when did we talk about X" —
1036
+ one row per conversation, date + title, no snippets
1037
+ --source <name> filter by source
1038
+ --limit N default 15, max 50
1039
+ --json
1040
+
1041
+ recent most recent messages across all sources
1042
+ --limit N default 20, max 100
1043
+ --source <name> filter by source
1044
+ --json
1045
+
1046
+ list list conversations by recency
1047
+ --source <name> filter by source
1048
+ --limit N default 20, max 200
1049
+ --json
1050
+
1051
+ get <conversation_id> full transcript of one conversation
1052
+ --limit N max messages (default 200, max 2000)
1053
+ --json
1054
+
1055
+ overview corpus snapshot — sources, counts, recent chats
1056
+ --json
1057
+
1058
+ projects list distinct project_paths captured
1059
+ --limit N default 50, max 500
1060
+ --json
1061
+
1062
+ context output markdown summary of recent activity
1063
+ in current pwd (for Claude Code SessionStart
1064
+ hook — auto-injects context into new sessions)
1065
+ --pwd PATH override (default: $CLAUDE_PROJECT_DIR or cwd)
1066
+ --limit N max conversations to include (default 5)
1067
+ --budget-tokens N cap output size (default 1500)
1068
+ --freshness-days N only conversations newer than (default 90)
1069
+ --no-source NAME exclude a source (repeatable; e.g. telegram)
1070
+ --json
1071
+
1072
+ hook install install SessionStart hook in
1073
+ ~/.claude/settings.json (idempotent)
1074
+ hook uninstall remove only the memex hook entry
1075
+ hook status show whether the hook is installed
1076
+
1077
+ help print the user guide (HELP.md)
1078
+ --help, -h this command reference
1079
+ --version, -v print package version
1080
+
1081
+ EXAMPLES
1082
+ memex search "Postgres migration"
1083
+ memex search "Q2 deck" --chat "Memex Bot"
1084
+ memex search "auth" --source claude-code --sort date_desc --limit 5
1085
+ memex list --source web --json | jq '.conversations[].title'
1086
+ memex get web-1582ab51a7b7
1087
+
1088
+ DAEMON COMMANDS (separate binary)
1089
+ memex-sync install register the macOS LaunchAgent for auto-capture
1090
+ memex-sync status daemon health + watched files
1091
+ memex-sync scan one-time backfill of existing AI sessions
1092
+ memex-sync --help full daemon CLI reference
1093
+
1094
+ For the full user guide: memex help
1095
+ On the web: https://memex.parallelclaw.ai
1096
+ `);
1097
+ }
1098
+
1099
+ // =============================================================
1100
+ // VERSION
1101
+ // =============================================================
1102
+ async function cmdVersion() {
1103
+ try {
1104
+ const pkgPath = join(PACKAGE_ROOT, 'package.json');
1105
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
1106
+ console.log(`${pkg.name} ${pkg.version}`);
1107
+ } catch (_) {
1108
+ console.log('parallelclaw (version unknown)');
1109
+ }
1110
+ }
1111
+
1112
+ // =============================================================
1113
+ // `memex telegram <sub>` — Telegram import flow (v0.10+)
1114
+ // =============================================================
1115
+ async function cmdTelegram(args) {
1116
+ const sub = args[0];
1117
+ const rest = args.slice(1);
1118
+ const { opts, positionals } = parseArgs(rest);
1119
+
1120
+ if (!sub || sub === '--help' || sub === '-h') {
1121
+ console.error('Usage: memex telegram <subcommand>');
1122
+ console.error('');
1123
+ console.error(' check Show Telegram-Desktop detection + watcher status');
1124
+ console.error(' pending List exports staged in ~/.memex/pending/ awaiting decision');
1125
+ console.error(' import <ids|all> Import specified pending entries into memex.db');
1126
+ console.error(' ids = comma-separated indices from `pending`, or chat titles');
1127
+ console.error(' skip <ids> Mark entries as "skip permanently" (deletes from pending)');
1128
+ console.error(' remove <title> Forget a chat: delete from memex.db + skip future re-imports');
1129
+ console.error(' allow <title> Add chat to allow-list (auto-import on future re-exports)');
1130
+ console.error(' block <pattern> Add a title pattern to block-list (never import — supports *)');
1131
+ console.error(' unblock <pattern> Remove a block pattern');
1132
+ console.error(' unskip <title> Remove a chat from skip-list');
1133
+ console.error(' mode [pick|auto|manual] Get or set capture mode (default: pick)');
1134
+ console.error(' status Show counts: allowed/skipped/blocked + recent imports');
1135
+ console.error(' scan One-shot rescan of ~/Downloads/Telegram Desktop/');
1136
+ console.error(' notifications <on|off|status|target> [--show-titles]');
1137
+ console.error(' macOS native notification on new export detect (default OFF)');
1138
+ console.error(' target <auto|claude-cli|claude-desktop|terminal|none>');
1139
+ console.error(' — what clicking the banner does (default auto)');
1140
+ console.error(' open-pending [--in <claude|claude-desktop|terminal>]');
1141
+ console.error(' Open pending list in best available client (Claude CLI > Desktop > Terminal)');
1142
+ console.error('');
1143
+ console.error(' --json Machine-readable output (works on most subcommands)');
1144
+ process.exit(sub ? 0 : 2);
1145
+ }
1146
+
1147
+ // Lazy imports — keeps cold-start fast for unrelated subcommands
1148
+ const discovery = await import('../telegram-discovery.js');
1149
+ const decisions = await import('../telegram-decisions.js');
1150
+ const pending = await import('../telegram-pending.js');
1151
+
1152
+ switch (sub) {
1153
+ case 'check': return tgCmdCheck(opts, discovery, decisions, pending);
1154
+ case 'pending': return tgCmdPending(opts, pending);
1155
+ case 'import': return tgCmdImport(positionals, opts, pending, decisions);
1156
+ case 'skip': return tgCmdSkip(positionals, opts, pending, decisions);
1157
+ case 'remove': return tgCmdRemove(positionals, opts, decisions);
1158
+ case 'allow': return tgCmdAllow(positionals, opts, decisions);
1159
+ case 'unskip': return tgCmdUnskip(positionals, opts, decisions);
1160
+ case 'block': return tgCmdBlock(positionals, opts, decisions);
1161
+ case 'unblock': return tgCmdUnblock(positionals, opts, decisions);
1162
+ case 'mode': return tgCmdMode(positionals, opts, decisions);
1163
+ case 'status': return tgCmdStatus(opts, decisions, pending);
1164
+ case 'scan': return tgCmdScan(opts, discovery, pending);
1165
+ case 'notifications': return tgCmdNotifications(positionals, opts, args);
1166
+ case 'open-pending': return tgCmdOpenPending(positionals, opts, args);
1167
+ default:
1168
+ console.error(`Unknown 'memex telegram' subcommand: ${sub}`);
1169
+ console.error(`Run 'memex telegram --help' for usage.`);
1170
+ process.exit(2);
1171
+ }
1172
+ }
1173
+
1174
+ function fmtBytes(b) {
1175
+ if (b < 1024) return `${b} B`;
1176
+ if (b < 1024*1024) return `${(b/1024).toFixed(1)} KB`;
1177
+ return `${(b/(1024*1024)).toFixed(1)} MB`;
1178
+ }
1179
+
1180
+ function fmtRange(iso1, iso2) {
1181
+ const a = (iso1 || '').slice(0, 10);
1182
+ const b = (iso2 || '').slice(0, 10);
1183
+ if (!a && !b) return '?';
1184
+ if (a === b) return a;
1185
+ return `${a} → ${b}`;
1186
+ }
1187
+
1188
+ function tgCmdCheck(opts, discovery, decisions, pending) {
1189
+ const desktop = discovery.detectTelegramDesktop();
1190
+ const login = discovery.detectFirstLogin();
1191
+ const dlPaths = discovery.defaultDownloadsPaths();
1192
+ const found = discovery.discoverExports(dlPaths);
1193
+ const state = decisions.loadDecisions();
1194
+ const pendingCount = pending.listPending().length;
1195
+
1196
+ const report = {
1197
+ desktop,
1198
+ login,
1199
+ downloads_paths: dlPaths,
1200
+ exports_found_in_downloads: found.length,
1201
+ pending_count: pendingCount,
1202
+ mode: state.mode,
1203
+ allowed_count: state.allowed_chats.length,
1204
+ skipped_count: state.skipped_chats.length,
1205
+ blocked_count: state.blocked_patterns.length,
1206
+ };
1207
+
1208
+ if (opts.json) {
1209
+ console.log(JSON.stringify(report, null, 2));
1210
+ return;
1211
+ }
1212
+
1213
+ console.log(c.bold('Telegram capture · status'));
1214
+ console.log('');
1215
+ if (desktop.installed) {
1216
+ console.log(` ${c.green('✓')} Telegram Desktop found ${c.dim(`(${desktop.variant}) ${desktop.path}`)}`);
1217
+ for (const n of desktop.notes) console.log(` ${c.yellow('⚠')} ${n}`);
1218
+ } else {
1219
+ console.log(` ${c.yellow('✗')} Telegram Desktop NOT installed`);
1220
+ console.log(` Get it at: ${c.cyan('https://telegram.org/dl/' + (desktop.platform === 'darwin' ? 'macos' : desktop.platform === 'linux' ? 'desktop' : 'windows'))}`);
1221
+ }
1222
+
1223
+ if (login.logged_in) {
1224
+ if (login.export_allowed) {
1225
+ console.log(` ${c.green('✓')} Logged in ${login.hours_since_login}h ago — chat export allowed`);
1226
+ } else {
1227
+ const wait = 24 - login.hours_since_login;
1228
+ console.log(` ${c.yellow('⌛')} Logged in ${login.hours_since_login}h ago — Telegram blocks export for ${wait} more hour(s)`);
1229
+ }
1230
+ } else {
1231
+ console.log(` ${c.dim('?')} Login state unknown (no tdata yet — log in to Telegram Desktop first)`);
1232
+ }
1233
+
1234
+ console.log('');
1235
+ console.log(` Mode: ${c.cyan(state.mode)}`);
1236
+ console.log(` Watching: ${dlPaths.length ? dlPaths.join(', ') : c.dim('(no Downloads/Telegram Desktop/ path on disk)')}`);
1237
+ console.log(` Pending: ${pendingCount} ${pendingCount ? c.dim('— run `memex telegram pending`') : ''}`);
1238
+ console.log(` Allowed chats: ${state.allowed_chats.length}`);
1239
+ console.log(` Skipped chats: ${state.skipped_chats.length}`);
1240
+ console.log(` Block patterns: ${state.blocked_patterns.length}`);
1241
+ }
1242
+
1243
+ async function tgCmdPending(opts, pending) {
1244
+ const list = pending.listPending();
1245
+ if (opts.json) {
1246
+ console.log(JSON.stringify({ count: list.length, entries: list }, null, 2));
1247
+ return;
1248
+ }
1249
+ if (list.length === 0) {
1250
+ console.log('No Telegram exports awaiting decision.');
1251
+
1252
+ // Smart hint (v0.10.13+): if there ARE exports sitting in ~/Downloads/Telegram Desktop/
1253
+ // that the daemon hasn't staged (pre-install backfill case, or watcher race),
1254
+ // tell the user to run `memex telegram scan` to pick them up. This was the
1255
+ // tester-8 pain point: HTML exports on disk, daemon installed AFTER them,
1256
+ // chokidar's initial scan apparently missed them, agent didn't know about
1257
+ // the scan command.
1258
+ try {
1259
+ const discovery = await import('../telegram-discovery.js');
1260
+ const candidatePaths = discovery.defaultDownloadsPaths();
1261
+ const found = discovery.discoverExports(candidatePaths);
1262
+ if (found.length > 0) {
1263
+ console.log('');
1264
+ console.log(c.yellow(`⚠ However, found ${found.length} unprocessed export(s) in ~/Downloads/Telegram Desktop/`));
1265
+ console.log(' that the daemon hasn\'t staged yet (likely there before memex was installed).');
1266
+ console.log('');
1267
+ console.log(` Run: ${c.cyan('memex telegram scan')} to back-fill them into pending.`);
1268
+ console.log('');
1269
+ return;
1270
+ }
1271
+ } catch (_) {
1272
+ /* discovery unavailable — fall through to the normal hint */
1273
+ }
1274
+
1275
+ console.log('');
1276
+ console.log('To add some:');
1277
+ console.log(' 1. Open Telegram Desktop');
1278
+ console.log(' 2. Choose a chat → ⋮ menu → "Export chat history"');
1279
+ console.log(' 3. Pick HTML or JSON, click Export');
1280
+ console.log(' 4. memex will detect the export in ~/Downloads/Telegram Desktop/ and stage it here.');
1281
+ console.log('');
1282
+ console.log(c.dim('Already have exports there? Run `memex telegram scan` to back-fill them.'));
1283
+ return;
1284
+ }
1285
+ console.log(c.bold(`${list.length} Telegram export(s) awaiting decision:`));
1286
+ console.log('');
1287
+ for (const e of list) {
1288
+ const title = e.chat_title || c.dim('(unparseable)');
1289
+ const range = fmtRange(e.date_first, e.date_last);
1290
+ const type = e.chat_type === 'private_group' ? c.dim('group') : c.dim('1-on-1');
1291
+ console.log(` ${c.cyan(String(e.index).padStart(2, ' '))} ${c.bold(title)} ${type}`);
1292
+ console.log(` ${e.message_count} msgs · ${range} · ${fmtBytes(e.size_bytes)}`);
1293
+ if (e.senders_sample.length) {
1294
+ console.log(` senders: ${e.senders_sample.slice(0, 4).join(', ')}${e.senders_sample.length > 4 ? '…' : ''}`);
1295
+ }
1296
+ }
1297
+ console.log('');
1298
+ console.log(c.dim('Commands:'));
1299
+ console.log(c.dim(' memex telegram import 1 2 3 — import these'));
1300
+ console.log(c.dim(' memex telegram import all — import everything (with confirmation)'));
1301
+ console.log(c.dim(' memex telegram skip 4 5 — never index these (forget the export)'));
1302
+ }
1303
+
1304
+ function getWritableDb() {
1305
+ if (!existsSync(DB_PATH)) {
1306
+ console.error(`memex.db not found at ${DB_PATH}`);
1307
+ console.error(`Run 'memex-sync install' first.`);
1308
+ process.exit(1);
1309
+ }
1310
+ return new Database(DB_PATH);
1311
+ }
1312
+
1313
+ function resolveTargets(positionals, pending) {
1314
+ const list = pending.listPending();
1315
+ if (positionals.length === 0) return [];
1316
+ if (positionals.length === 1 && positionals[0].toLowerCase() === 'all') return list.slice();
1317
+ const result = [];
1318
+ const seen = new Set();
1319
+ for (const arg of positionals) {
1320
+ // Comma-separated indices ("1,2,3") or single index ("1") or title text
1321
+ const parts = arg.split(',').map((p) => p.trim()).filter(Boolean);
1322
+ for (const p of parts) {
1323
+ const num = parseInt(p, 10);
1324
+ if (!isNaN(num) && String(num) === p) {
1325
+ const match = list.find((e) => e.index === num);
1326
+ if (match && !seen.has(match.path)) { result.push(match); seen.add(match.path); }
1327
+ } else {
1328
+ // Match by chat title (case-insensitive, substring)
1329
+ const needle = p.toLowerCase();
1330
+ for (const e of list) {
1331
+ if (e.chat_title && e.chat_title.toLowerCase().includes(needle) && !seen.has(e.path)) {
1332
+ result.push(e); seen.add(e.path);
1333
+ }
1334
+ }
1335
+ }
1336
+ }
1337
+ }
1338
+ return result;
1339
+ }
1340
+
1341
+ async function tgCmdImport(positionals, opts, pending, decisions) {
1342
+ const targets = resolveTargets(positionals, pending);
1343
+ if (targets.length === 0) {
1344
+ if (positionals.length === 0) {
1345
+ console.error('Specify what to import: indices (1 2 3), titles, or "all".');
1346
+ console.error('See `memex telegram pending` for the list.');
1347
+ } else {
1348
+ console.error('No pending entries matched your selection.');
1349
+ }
1350
+ process.exit(1);
1351
+ }
1352
+
1353
+ // Soft sanity check for `all`
1354
+ if (positionals.length === 1 && positionals[0].toLowerCase() === 'all' && targets.length > 3 && !opts.json) {
1355
+ console.log(`About to import ${targets.length} chats with ${targets.reduce((s,t)=>s+t.message_count,0).toLocaleString()} messages total:`);
1356
+ for (const t of targets.slice(0, 8)) console.log(` • ${t.chat_title} (${t.message_count} msgs)`);
1357
+ if (targets.length > 8) console.log(` • … and ${targets.length - 8} more`);
1358
+ console.log('');
1359
+ console.log('Type `memex telegram import 1 2 3 …` for a smaller subset, or re-run with MEMEX_YES=1 to confirm.');
1360
+ if (!process.env.MEMEX_YES) process.exit(0);
1361
+ }
1362
+
1363
+ const db = getWritableDb();
1364
+ const { importTelegramRaw } = await import('../import-telegram.js');
1365
+ const { parseTelegramHtmlExport } = await import('../parse-telegram-html.js');
1366
+ const state = decisions.loadDecisions();
1367
+ const results = [];
1368
+
1369
+ try {
1370
+ for (const t of targets) {
1371
+ let raw;
1372
+ if (t.kind === 'html-dir') {
1373
+ raw = parseTelegramHtmlExport(t.path);
1374
+ } else if (t.kind === 'json-file') {
1375
+ raw = JSON.parse(readFileSync(t.path, 'utf-8'));
1376
+ } else if (t.kind === 'json-in-dir' && t.inner_json_path) {
1377
+ raw = JSON.parse(readFileSync(t.inner_json_path, 'utf-8'));
1378
+ } else {
1379
+ results.push({ path: t.path, error: `unknown kind: ${t.kind}` });
1380
+ continue;
1381
+ }
1382
+ if (!raw) {
1383
+ results.push({ path: t.path, error: 'parse-failed' });
1384
+ continue;
1385
+ }
1386
+ const r = importTelegramRaw(db, raw);
1387
+ // Mark allowed for future auto-import
1388
+ const title = raw.chats?.list?.[0]?.name || t.chat_title || 'Telegram chat';
1389
+ decisions.allowChat(state, title);
1390
+ pending.removePending(t.path);
1391
+ results.push({ path: t.path, title, ...r });
1392
+ }
1393
+ decisions.saveDecisions(state);
1394
+ } finally {
1395
+ db.close();
1396
+ }
1397
+
1398
+ if (opts.json) {
1399
+ console.log(JSON.stringify({ imported: results }, null, 2));
1400
+ return;
1401
+ }
1402
+ for (const r of results) {
1403
+ if (r.error) {
1404
+ console.log(`${c.yellow('✗')} ${basename(r.path)}: ${r.error}`);
1405
+ } else {
1406
+ console.log(`${c.green('✓')} Imported "${r.title}" — ${r.totalImported.toLocaleString()} msg(s)`);
1407
+ }
1408
+ }
1409
+ const total = results.reduce((s, r) => s + (r.totalImported || 0), 0);
1410
+ console.log('');
1411
+ console.log(`Done. ${total.toLocaleString()} messages in memex.db. Searchable from any MCP agent.`);
1412
+ }
1413
+
1414
+ function tgCmdSkip(positionals, opts, pending, decisions) {
1415
+ const targets = resolveTargets(positionals, pending);
1416
+ if (targets.length === 0) {
1417
+ console.error('Specify entries to skip (indices or titles).');
1418
+ process.exit(1);
1419
+ }
1420
+ const state = decisions.loadDecisions();
1421
+ for (const t of targets) {
1422
+ if (t.chat_title) decisions.skipChat(state, t.chat_title);
1423
+ pending.removePending(t.path);
1424
+ }
1425
+ decisions.saveDecisions(state);
1426
+ if (opts.json) {
1427
+ console.log(JSON.stringify({ skipped: targets.map((t) => t.chat_title) }, null, 2));
1428
+ return;
1429
+ }
1430
+ console.log(`${c.green('✓')} Skipped ${targets.length} chat(s):`);
1431
+ for (const t of targets) console.log(` • ${t.chat_title || basename(t.path)}`);
1432
+ console.log('');
1433
+ console.log(c.dim('These chats will be auto-skipped on future re-exports. To undo: memex telegram unskip <title>.'));
1434
+ }
1435
+
1436
+ function tgCmdRemove(positionals, opts, decisions) {
1437
+ if (positionals.length === 0) {
1438
+ console.error('Usage: memex telegram remove <chat title>');
1439
+ process.exit(2);
1440
+ }
1441
+ const title = positionals.join(' ');
1442
+ const db = getWritableDb();
1443
+ let removed = 0;
1444
+ try {
1445
+ const row = db.prepare('SELECT conversation_id, message_count FROM conversations WHERE source = ? AND title = ?').get('telegram', title);
1446
+ if (!row) {
1447
+ console.error(`No Telegram conversation with title "${title}" in memex.db.`);
1448
+ process.exit(1);
1449
+ }
1450
+ removed = row.message_count;
1451
+ db.prepare('DELETE FROM messages WHERE conversation_id = ?').run(row.conversation_id);
1452
+ db.prepare('DELETE FROM conversations WHERE conversation_id = ?').run(row.conversation_id);
1453
+ } finally {
1454
+ db.close();
1455
+ }
1456
+ // Also add to skipped list so future re-exports don't bring it back
1457
+ const state = decisions.loadDecisions();
1458
+ decisions.skipChat(state, title);
1459
+ decisions.saveDecisions(state);
1460
+ if (opts.json) {
1461
+ console.log(JSON.stringify({ title, removed_messages: removed, skipped: true }, null, 2));
1462
+ return;
1463
+ }
1464
+ console.log(`${c.green('✓')} Removed "${title}" from memex (${removed.toLocaleString()} msgs) and added to skip-list.`);
1465
+ }
1466
+
1467
+ function tgCmdAllow(positionals, opts, decisions) {
1468
+ if (positionals.length === 0) {
1469
+ console.error('Usage: memex telegram allow <chat title>');
1470
+ process.exit(2);
1471
+ }
1472
+ const title = positionals.join(' ');
1473
+ const state = decisions.loadDecisions();
1474
+ decisions.allowChat(state, title);
1475
+ decisions.saveDecisions(state);
1476
+ if (opts.json) { console.log(JSON.stringify({ allowed: title }, null, 2)); return; }
1477
+ console.log(`${c.green('✓')} "${title}" added to allow-list. Future exports of this chat will auto-import in 'auto' mode.`);
1478
+ }
1479
+
1480
+ function tgCmdUnskip(positionals, opts, decisions) {
1481
+ if (positionals.length === 0) {
1482
+ console.error('Usage: memex telegram unskip <chat title>');
1483
+ process.exit(2);
1484
+ }
1485
+ const title = positionals.join(' ');
1486
+ const state = decisions.loadDecisions();
1487
+ decisions.unskipChat(state, title);
1488
+ decisions.saveDecisions(state);
1489
+ if (opts.json) { console.log(JSON.stringify({ unskipped: title }, null, 2)); return; }
1490
+ console.log(`${c.green('✓')} "${title}" removed from skip-list.`);
1491
+ }
1492
+
1493
+ function tgCmdBlock(positionals, opts, decisions) {
1494
+ if (positionals.length === 0) {
1495
+ console.error('Usage: memex telegram block <pattern>');
1496
+ console.error(' Pattern can be exact substring or use * wildcard, e.g. "*bank*"');
1497
+ process.exit(2);
1498
+ }
1499
+ const pattern = positionals.join(' ');
1500
+ const state = decisions.loadDecisions();
1501
+ decisions.blockPattern(state, pattern);
1502
+ decisions.saveDecisions(state);
1503
+ if (opts.json) { console.log(JSON.stringify({ blocked: pattern }, null, 2)); return; }
1504
+ console.log(`${c.green('✓')} Block pattern "${pattern}" added. Any chat title matching will never be imported.`);
1505
+ }
1506
+
1507
+ function tgCmdUnblock(positionals, opts, decisions) {
1508
+ if (positionals.length === 0) {
1509
+ console.error('Usage: memex telegram unblock <pattern>');
1510
+ process.exit(2);
1511
+ }
1512
+ const pattern = positionals.join(' ');
1513
+ const state = decisions.loadDecisions();
1514
+ decisions.unblockPattern(state, pattern);
1515
+ decisions.saveDecisions(state);
1516
+ if (opts.json) { console.log(JSON.stringify({ unblocked: pattern }, null, 2)); return; }
1517
+ console.log(`${c.green('✓')} Block pattern "${pattern}" removed.`);
1518
+ }
1519
+
1520
+ function tgCmdMode(positionals, opts, decisions) {
1521
+ const state = decisions.loadDecisions();
1522
+ if (positionals.length === 0) {
1523
+ if (opts.json) { console.log(JSON.stringify({ mode: state.mode })); return; }
1524
+ console.log(`Current mode: ${c.cyan(state.mode)}`);
1525
+ console.log('');
1526
+ console.log(` pick — daemon stages exports to pending/; you decide what to import (default)`);
1527
+ console.log(` auto — exports of allow-listed chats auto-import; new chats still go to pending/`);
1528
+ console.log(` manual — watcher off; you manually drop files into ~/.memex/inbox/`);
1529
+ console.log('');
1530
+ console.log('Set: memex telegram mode <pick|auto|manual>');
1531
+ return;
1532
+ }
1533
+ try {
1534
+ decisions.setMode(state, positionals[0]);
1535
+ decisions.saveDecisions(state);
1536
+ if (opts.json) { console.log(JSON.stringify({ mode: state.mode })); return; }
1537
+ console.log(`${c.green('✓')} Mode set to "${state.mode}"`);
1538
+ } catch (e) {
1539
+ console.error(`✗ ${e.message}`);
1540
+ process.exit(2);
1541
+ }
1542
+ }
1543
+
1544
+ function tgCmdStatus(opts, decisions, pending) {
1545
+ const state = decisions.loadDecisions();
1546
+ const pendingCount = pending.listPending().length;
1547
+ const report = {
1548
+ mode: state.mode,
1549
+ pending_count: pendingCount,
1550
+ allowed_chats: state.allowed_chats,
1551
+ skipped_chats: state.skipped_chats,
1552
+ blocked_patterns: state.blocked_patterns,
1553
+ };
1554
+ if (opts.json) { console.log(JSON.stringify(report, null, 2)); return; }
1555
+ console.log(`${c.bold('Telegram decisions')} (${DB_PATH.replace(homedir(), '~').replace('/data/memex.db', '/telegram-decisions.json')})`);
1556
+ console.log('');
1557
+ console.log(` mode: ${c.cyan(state.mode)}`);
1558
+ console.log(` pending: ${pendingCount}`);
1559
+ console.log(` allowed: ${state.allowed_chats.length}`);
1560
+ for (const a of state.allowed_chats.slice(0, 10)) console.log(` • ${a.title} ${c.dim('(' + a.first_imported.slice(0, 10) + ')')}`);
1561
+ if (state.allowed_chats.length > 10) console.log(c.dim(` … ${state.allowed_chats.length - 10} more`));
1562
+ console.log(` skipped: ${state.skipped_chats.length}`);
1563
+ for (const s of state.skipped_chats.slice(0, 10)) console.log(` • ${s.title}`);
1564
+ if (state.skipped_chats.length > 10) console.log(c.dim(` … ${state.skipped_chats.length - 10} more`));
1565
+ console.log(` block patterns: ${state.blocked_patterns.length}`);
1566
+ for (const b of state.blocked_patterns) console.log(` • ${b.pattern} ${b.note ? c.dim('— ' + b.note) : ''}`);
1567
+ }
1568
+
1569
+ async function tgCmdNotifications(positionals, opts, args) {
1570
+ const action = positionals[0];
1571
+ const showTitlesFlag = args.includes('--show-titles');
1572
+ const noTitlesFlag = args.includes('--no-titles') || args.includes('--hide-titles');
1573
+ const notify = await import('../telegram-notify.js');
1574
+ const click = await import('../notify-click-action.js');
1575
+ const state = notify.loadNotifyState();
1576
+
1577
+ if (!action || action === 'status') {
1578
+ const env = click.detectEnvironment(true);
1579
+ const effectiveTarget = click.pickTarget(state.notifications.click_target, env);
1580
+ if (opts.json) {
1581
+ console.log(JSON.stringify({
1582
+ ...state.notifications,
1583
+ backend: env.terminal_notifier ? 'terminal-notifier' : 'osascript',
1584
+ effective_target: effectiveTarget,
1585
+ environment: {
1586
+ terminal_notifier_installed: !!env.terminal_notifier,
1587
+ claude_cli_installed: !!env.claude_cli,
1588
+ claude_desktop_installed: !!env.claude_desktop,
1589
+ },
1590
+ }, null, 2));
1591
+ return;
1592
+ }
1593
+ console.log(`Telegram notifications: ${state.notifications.enabled ? c.green('ON') : c.dim('OFF')}`);
1594
+ console.log(` show_titles: ${state.notifications.show_titles ? c.green('yes') : c.dim('no (privacy: just count)')}`);
1595
+ console.log(` click target: ${c.cyan(state.notifications.click_target)} → ${click.targetLabel(effectiveTarget)}`);
1596
+ console.log(` backend: ${env.terminal_notifier ? c.green('terminal-notifier') + c.dim(' (clickable)') : c.yellow('osascript') + c.dim(' (no click — install brew terminal-notifier for click)')}`);
1597
+ console.log('');
1598
+ console.log(` Environment:`);
1599
+ console.log(` terminal-notifier: ${env.terminal_notifier ? c.green('✓ ' + env.terminal_notifier) : c.dim('✗ not installed — brew install terminal-notifier')}`);
1600
+ console.log(` Claude Code CLI: ${env.claude_cli ? c.green('✓ ' + env.claude_cli) : c.dim('✗ not installed')}`);
1601
+ console.log(` Claude Desktop: ${env.claude_desktop ? c.green('✓ ' + env.claude_desktop) : c.dim('✗ not installed')}`);
1602
+ console.log('');
1603
+ if (!state.notifications.enabled) {
1604
+ console.log('Enable: memex telegram notifications on');
1605
+ console.log(' --show-titles include chat names in banner (default off — privacy on lock screen)');
1606
+ }
1607
+ console.log('Override click target: memex telegram notifications target <auto|claude-cli|claude-desktop|terminal|none>');
1608
+ return;
1609
+ }
1610
+
1611
+ if (action === 'on' || action === 'enable') {
1612
+ notify.setNotificationsEnabled(state, true, showTitlesFlag ? true : (noTitlesFlag ? false : null));
1613
+ notify.saveNotifyState(state);
1614
+ const env = click.detectEnvironment(true);
1615
+ const effectiveTarget = click.pickTarget(state.notifications.click_target, env);
1616
+ if (opts.json) {
1617
+ console.log(JSON.stringify({
1618
+ ...state.notifications,
1619
+ backend: env.terminal_notifier ? 'terminal-notifier' : 'osascript',
1620
+ effective_target: effectiveTarget,
1621
+ }));
1622
+ return;
1623
+ }
1624
+ console.log(`${c.green('✓')} Telegram notifications: ON`);
1625
+ console.log(` show_titles: ${state.notifications.show_titles ? 'yes' : 'no (privacy)'}`);
1626
+ console.log(` click target: ${effectiveTarget} (${click.targetLabel(effectiveTarget)})`);
1627
+ console.log('');
1628
+ if (!env.terminal_notifier) {
1629
+ console.log(c.yellow(' ℹ Banner will not be clickable — install brew terminal-notifier for click-through.'));
1630
+ console.log(c.dim(' Without it: banner text is self-contained ("Run: memex telegram pending").'));
1631
+ } else if (effectiveTarget === 'claude-cli') {
1632
+ console.log(c.dim(' ℹ First click → macOS will ask permission to control Terminal.'));
1633
+ console.log(c.dim(' Allow it — that\'s how memex opens a new Terminal tab with Claude Code.'));
1634
+ }
1635
+ console.log(c.dim(' On first export, macOS may also ask to grant notification permission.'));
1636
+ return;
1637
+ }
1638
+ if (action === 'off' || action === 'disable') {
1639
+ notify.setNotificationsEnabled(state, false);
1640
+ notify.saveNotifyState(state);
1641
+ if (opts.json) { console.log(JSON.stringify(state.notifications)); return; }
1642
+ console.log(`${c.green('✓')} Telegram notifications: OFF`);
1643
+ return;
1644
+ }
1645
+ if (action === 'target') {
1646
+ const newTarget = positionals[1];
1647
+ if (!newTarget) {
1648
+ console.error('Usage: memex telegram notifications target <auto|claude-cli|claude-desktop|terminal|none>');
1649
+ process.exit(2);
1650
+ }
1651
+ try {
1652
+ notify.setClickTarget(state, newTarget);
1653
+ notify.saveNotifyState(state);
1654
+ } catch (e) {
1655
+ console.error(`✗ ${e.message}`);
1656
+ process.exit(2);
1657
+ }
1658
+ const env = click.detectEnvironment(true);
1659
+ const effectiveTarget = click.pickTarget(newTarget, env);
1660
+ if (opts.json) { console.log(JSON.stringify({ click_target: newTarget, effective_target: effectiveTarget })); return; }
1661
+ console.log(`${c.green('✓')} click target: ${newTarget} → ${click.targetLabel(effectiveTarget)}`);
1662
+ if (newTarget !== effectiveTarget) {
1663
+ console.log(c.dim(` (your preference "${newTarget}" is not installed; falling back to "${effectiveTarget}")`));
1664
+ }
1665
+ return;
1666
+ }
1667
+ console.error(`Unknown action: ${action}. Use: on | off | status | target`);
1668
+ process.exit(2);
1669
+ }
1670
+
1671
+ async function tgCmdOpenPending(positionals, opts, args) {
1672
+ const click = await import('../notify-click-action.js');
1673
+ // --in <X> flag override, else 'auto'
1674
+ let preference = 'auto';
1675
+ const inIdx = args.indexOf('--in');
1676
+ if (inIdx >= 0 && args[inIdx + 1]) {
1677
+ preference = args[inIdx + 1];
1678
+ // Accept short aliases for ergonomics
1679
+ if (preference === 'claude') preference = 'claude-cli';
1680
+ if (preference === 'term') preference = 'terminal';
1681
+ if (preference === 'desktop') preference = 'claude-desktop';
1682
+ }
1683
+ const env = click.detectEnvironment(true);
1684
+ const target = click.pickTarget(preference, env);
1685
+ if (target === 'none') {
1686
+ console.error(`No click target available. Use --in <claude|claude-desktop|terminal>.`);
1687
+ process.exit(1);
1688
+ }
1689
+ if (opts.json) {
1690
+ console.log(JSON.stringify({ preference, effective_target: target, label: click.targetLabel(target) }));
1691
+ } else {
1692
+ console.log(`${c.cyan('▶')} Opening pending in: ${click.targetLabel(target)}`);
1693
+ }
1694
+ const r = click.executeClickAction(preference, env);
1695
+ if (!r.ran && !opts.json) {
1696
+ console.error(`✗ Failed: ${r.reason || 'unknown'}`);
1697
+ process.exit(1);
1698
+ }
1699
+ }
1700
+
1701
+ function tgCmdScan(opts, discovery, pending) {
1702
+ const paths = discovery.defaultDownloadsPaths();
1703
+ const found = discovery.discoverExports(paths);
1704
+ if (found.length === 0) {
1705
+ if (opts.json) { console.log(JSON.stringify({ scanned_paths: paths, found: 0 })); return; }
1706
+ console.log(`Scanned ${paths.length} path(s) — no Telegram exports found.`);
1707
+ console.log(' Paths: ' + (paths.length ? paths.join(', ') : '(no default Downloads/Telegram Desktop/ on disk)'));
1708
+ return;
1709
+ }
1710
+ let staged = 0;
1711
+ const errors = [];
1712
+ for (const f of found) {
1713
+ try {
1714
+ pending.stageExport(f.path, { moveOrCopy: 'copy' }); // copy in one-shot scan (don't move user's Downloads)
1715
+ staged++;
1716
+ } catch (e) {
1717
+ errors.push({ path: f.path, error: e.message });
1718
+ }
1719
+ }
1720
+ if (opts.json) {
1721
+ console.log(JSON.stringify({ scanned_paths: paths, found: found.length, staged, errors }, null, 2));
1722
+ return;
1723
+ }
1724
+ console.log(`${c.green('✓')} Found ${found.length} export(s) · staged ${staged} into ~/.memex/pending/`);
1725
+ if (errors.length) {
1726
+ console.log(c.yellow(` ${errors.length} error(s):`));
1727
+ for (const e of errors) console.log(` • ${basename(e.path)}: ${e.error}`);
1728
+ }
1729
+ console.log('');
1730
+ console.log(c.dim('Review: memex telegram pending'));
1731
+ }
1732
+
1733
+ // =============================================================
1734
+ // `memex import <path>` — single-command file ingest from any path
1735
+ // =============================================================
1736
+ async function cmdImport(args) {
1737
+ let path = null;
1738
+ let format = 'auto';
1739
+ let force = false;
1740
+ let json = false;
1741
+
1742
+ for (let i = 0; i < args.length; i++) {
1743
+ const a = args[i];
1744
+ if (a === '--format') format = args[++i] || 'auto';
1745
+ else if (a === '--force' || a === '-f') force = true;
1746
+ else if (a === '--json') json = true;
1747
+ else if (a === '--help' || a === '-h') {
1748
+ console.log(`memex import — ingest a chat file from any path on disk
1749
+
1750
+ Usage:
1751
+ memex import <path> [options]
1752
+
1753
+ Arguments:
1754
+ <path> Absolute or ~-relative path to the file or directory.
1755
+ Supported: Telegram JSON / HTML / dir, Claude Code JSONL,
1756
+ Cowork JSONL.
1757
+
1758
+ Options:
1759
+ --format <fmt> Override auto-detection. One of: telegram-json,
1760
+ telegram-html, claude-jsonl, cowork-jsonl, auto (default).
1761
+ --force, -f Bypass Telegram privacy gate (import new chat without
1762
+ asking, or override a previously-skipped chat). Only set
1763
+ this when the user has explicitly approved.
1764
+ --json Output JSON instead of human text.
1765
+ -h, --help Show this help.
1766
+
1767
+ Examples:
1768
+ memex import ~/projects/memex/result.json
1769
+ memex import ~/Downloads/ChatExport_2026-05-18/
1770
+ memex import ~/path/to/session.jsonl --format claude-jsonl
1771
+ memex import ~/Downloads/result.json --force # skip privacy gate
1772
+ `);
1773
+ return;
1774
+ }
1775
+ else if (a.startsWith('-')) {
1776
+ console.error(`Unknown flag: ${a}`);
1777
+ process.exit(2);
1778
+ }
1779
+ else if (!path) path = a;
1780
+ else { console.error(`Unexpected positional: ${a}`); process.exit(2); }
1781
+ }
1782
+
1783
+ if (!path) {
1784
+ console.error('Usage: memex import <path> [--format X] [--force]');
1785
+ console.error('Run `memex import --help` for details.');
1786
+ process.exit(2);
1787
+ }
1788
+
1789
+ // Open writable DB so the helper can do upserts directly.
1790
+ if (!existsSync(DB_PATH)) {
1791
+ console.error(`memex.db not found at ${DB_PATH}`);
1792
+ console.error(`Run 'memex-sync install' first.`);
1793
+ process.exit(1);
1794
+ }
1795
+ const db = new Database(DB_PATH);
1796
+ db.pragma('journal_mode = WAL');
1797
+ db.pragma('synchronous = NORMAL');
1798
+
1799
+ const { ingestFile } = await import('../ingest-file.js');
1800
+ let result;
1801
+ try {
1802
+ result = await ingestFile(db, path, { format, force });
1803
+ } finally {
1804
+ db.close();
1805
+ }
1806
+
1807
+ if (json) {
1808
+ console.log(JSON.stringify(result, null, 2));
1809
+ return;
1810
+ }
1811
+
1812
+ // Human-readable summary
1813
+ switch (result.status) {
1814
+ case 'imported': {
1815
+ if (result.chats) {
1816
+ // Telegram path
1817
+ console.log(c.green('✓') + ` Imported ${result.total_imported.toLocaleString()} messages from ${result.chats.length} chat(s):`);
1818
+ for (const ch of result.chats) {
1819
+ console.log(` • ${ch.title} — ${ch.msg_count.toLocaleString()} msgs · ${ch.date_first} → ${ch.date_last}`);
1820
+ }
1821
+ } else {
1822
+ // Claude/Cowork JSONL path
1823
+ console.log(c.green('✓') + ` Imported ${result.total_imported.toLocaleString()} records into "${result.title}"`);
1824
+ console.log(c.dim(` conversation_id: ${result.conversation_id}`));
1825
+ }
1826
+ console.log('');
1827
+ console.log(c.dim('Searchable from any MCP client now.'));
1828
+ break;
1829
+ }
1830
+ case 'needs_consent': {
1831
+ console.log(c.yellow('⚠ ') + `New Telegram chat requires your consent:`);
1832
+ console.log(` Title: ${c.bold(result.chat_title)}`);
1833
+ console.log(` Type: ${result.chat_type}`);
1834
+ console.log(` Messages: ${result.message_count.toLocaleString()}`);
1835
+ console.log(` Dates: ${result.date_first} → ${result.date_last}`);
1836
+ if (result.senders_sample?.length) {
1837
+ console.log(` Senders: ${result.senders_sample.join(', ')}`);
1838
+ }
1839
+ console.log('');
1840
+ console.log(c.dim('To import this chat:'));
1841
+ console.log(` ${c.cyan('memex import')} ${path} ${c.cyan('--force')}`);
1842
+ console.log('');
1843
+ console.log(c.dim('To never import this chat:'));
1844
+ console.log(` ${c.cyan('memex telegram skip')} "${result.chat_title}"`);
1845
+ process.exit(0);
1846
+ }
1847
+ case 'skipped': {
1848
+ console.log(c.yellow('⏭ Skipped: ') + result.chat_title);
1849
+ console.log(c.dim(` Reason: ${result.reason}`));
1850
+ if (result.message) console.log(c.dim(' ' + result.message));
1851
+ process.exit(0);
1852
+ }
1853
+ case 'error':
1854
+ default: {
1855
+ console.error(c.yellow('✗ ') + 'Import failed: ' + (result.error || 'unknown'));
1856
+ process.exit(1);
1857
+ }
1858
+ }
1859
+ }
1860
+
1861
+ // =============================================================
1862
+ // `memex web` — opt-in local dashboard
1863
+ // =============================================================
1864
+ async function cmdWeb(args) {
1865
+ // Parse web-specific flags out of argv. We DO NOT route through parseArgs()
1866
+ // because it would silently ignore --port/--host/--token (forward-compat
1867
+ // unknown-flag policy), and we want strict parsing for the server.
1868
+ const opts = { port: 8765, host: '127.0.0.1', token: null, open: false };
1869
+ for (let i = 0; i < args.length; i++) {
1870
+ const a = args[i];
1871
+ if (a === '--port' || a === '-p') opts.port = parseInt(args[++i], 10);
1872
+ else if (a === '--host') opts.host = args[++i];
1873
+ else if (a === '--token') opts.token = args[++i];
1874
+ else if (a === '--open' || a === '-o') opts.open = true;
1875
+ else if (a === '--public') opts.host = '0.0.0.0';
1876
+ else if (a === '--help' || a === '-h') {
1877
+ console.log(`memex web — local read-only dashboard
1878
+
1879
+ Usage:
1880
+ memex web [options]
1881
+
1882
+ Options:
1883
+ --port, -p <num> Port to bind (default: 8765)
1884
+ --host <addr> Bind address (default: 127.0.0.1, localhost-only)
1885
+ --public Shorthand for --host 0.0.0.0 (bind on all interfaces)
1886
+ --token <str> Require Authorization: Bearer <str> for all routes
1887
+ except /api/health and /static/*. Recommended when
1888
+ using --public or behind a tunnel.
1889
+ --open, -o Open the dashboard in your browser after start
1890
+ -h, --help Show this help
1891
+
1892
+ Examples:
1893
+ memex web localhost:8765, no auth
1894
+ memex web --open and open in browser
1895
+ memex web --port 9000 custom port
1896
+ memex web --public --token s3cret bind on 0.0.0.0 with bearer auth
1897
+ `);
1898
+ // Help path: exit cleanly so the caller (server.js) doesn't fall through
1899
+ // to MCP-mode init, and Node doesn't warn about an unsettled top-level
1900
+ // await waiting on an HTTP server that was never started.
1901
+ process.exit(0);
1902
+ }
1903
+ else if (a.startsWith('--')) {
1904
+ console.error(`Unknown flag: ${a}`);
1905
+ console.error(`Run 'memex web --help' for usage.`);
1906
+ process.exit(2);
1907
+ }
1908
+ }
1909
+
1910
+ if (!Number.isFinite(opts.port) || opts.port <= 0 || opts.port > 65535) {
1911
+ console.error(`Invalid port: ${opts.port}`);
1912
+ process.exit(2);
1913
+ }
1914
+ if (opts.host !== '127.0.0.1' && opts.host !== 'localhost' && !opts.token) {
1915
+ console.error(c.yellow('⚠ Binding on ' + opts.host + ' without --token leaves the dashboard open to the network.'));
1916
+ console.error(c.yellow(' Press Ctrl+C now if that\'s not what you want. Continuing in 3 seconds…'));
1917
+ await new Promise((r) => setTimeout(r, 3000));
1918
+ }
1919
+
1920
+ if (!existsSync(DB_PATH)) {
1921
+ console.error(`memex.db not found at ${DB_PATH}`);
1922
+ console.error(`Run 'memex-sync install' first to set up the daemon and create the DB.`);
1923
+ process.exit(1);
1924
+ }
1925
+
1926
+ const { startServer } = await import('../web/index.js');
1927
+ await startServer(opts);
1928
+
1929
+ // First successful start ever → silence the discovery tip forever.
1930
+ // User has clearly found the feature; no need to keep nagging.
1931
+ try {
1932
+ const notify = await import('../telegram-notify.js');
1933
+ const state = notify.loadNotifyState();
1934
+ if (!state.dashboard_ever_opened) {
1935
+ notify.markDashboardEverOpened(state);
1936
+ notify.saveNotifyState(state);
1937
+ }
1938
+ } catch (_) { /* never break server start on notify-state write */ }
1939
+
1940
+ // The HTTP server now holds the event loop open. Park on an unsettled
1941
+ // promise so cmdWeb never returns to the dispatcher — that way server.js
1942
+ // doesn't reach process.exit(0) below or fall through to MCP-mode init.
1943
+ // (Required because `await runCli(...)` would otherwise resolve as soon
1944
+ // as startServer's listen-callback fires.)
1945
+ await new Promise(() => {});
1946
+ }
1947
+
1948
+ // =============================================================
1949
+ // DISPATCH
1950
+ // =============================================================
1951
+ export async function runCli(sub, args) {
1952
+ // Sniff --json early so afterCommand() knows to suppress the tip
1953
+ const opts = { json: args.includes('--json') };
1954
+ let exitCode = 0;
1955
+ try {
1956
+ switch (sub) {
1957
+ case 'search': await cmdSearch(args); break;
1958
+ case 'recent': await cmdRecent(args); break;
1959
+ case 'list': await cmdList(args); break;
1960
+ case 'get': await cmdGet(args); break;
1961
+ case 'overview': await cmdOverview(args); break;
1962
+ case 'projects': await cmdProjects(args); break;
1963
+ case 'when': await cmdWhen(args); break;
1964
+ case 'context': await cmdContext(args); break;
1965
+ case 'hook': await cmdHook(args); break;
1966
+ case 'telegram': await cmdTelegram(args); break;
1967
+ case 'web': await cmdWeb(args); break;
1968
+ case 'import': await cmdImport(args); break;
1969
+ case 'help': await cmdHelp(); break;
1970
+ case '--help':
1971
+ case '-h': await cmdUsage(); break;
1972
+ case '--version':
1973
+ case '-v': await cmdVersion(); break;
1974
+ default:
1975
+ console.error(`Unknown subcommand: ${sub}`);
1976
+ console.error(`Run 'memex --help' for usage.`);
1977
+ process.exit(2);
1978
+ }
1979
+ } catch (e) {
1980
+ console.error(e.stack || e.message);
1981
+ exitCode = 1;
1982
+ }
1983
+ // Channel B: render the Telegram-pending tip at the end of any non-TG,
1984
+ // non-JSON command. Throttled to once per 6h. Failures swallowed.
1985
+ await afterCommand(opts, sub);
1986
+ if (exitCode) process.exit(exitCode);
1987
+ }