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
package/lib/cli/index.js
ADDED
|
@@ -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
|
+
}
|