memex-mvp 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HELP.md CHANGED
@@ -348,7 +348,7 @@ Magic-фича. Когда ты открываешь Claude Code в проект
348
348
 
349
349
  **Технически:** SessionStart hook в `~/.claude/settings.json`. При старте каждой Claude Code сессии хук вызывает `memex context` → memex выдаёт markdown summary → Claude получает его как system message _до_ твоего первого вопроса.
350
350
 
351
- **Установка:** во время `memex-sync install` будет промпт `[Y/n]` соглашайся (Y по default'у). Или установи позже:
351
+ **Установка:** проще всего — `curl -fsSL https://memex.parallelclaw.ai/install.sh | bash` поставит memex + daemon + auto-context хук одной командой. Если ставил memex вручную — во время `memex-sync install` будет промпт `[Y/n]` (соглашайся, Y по default'у). Или установи хук позже:
352
352
 
353
353
  ```bash
354
354
  memex hook install # добавить хук
@@ -378,7 +378,7 @@ memex context --freshness-days 30 # только последние 30 дне
378
378
  1. Вызови `memex_overview`. Внимание на статус-баннер сверху:
379
379
  - 🟢 daemon работает — всё ок, может ещё не успел проиндексировать
380
380
  - 🔴 daemon установлен но не работает — `launchctl load ~/Library/LaunchAgents/com.parallelclaw.memex.sync.plist`
381
- - ⚪ daemon не установлен — `npx memex-sync install` из директории memex-mvp
381
+ - ⚪ daemon не установлен — самый простой способ: `curl -fsSL https://memex.parallelclaw.ai/install.sh | bash` (поставит сам всё). Или вручную: `memex-sync install`.
382
382
 
383
383
  ### Хочу проиндексировать существующие сессии (бэкфилл)
384
384
 
@@ -390,9 +390,24 @@ npx memex-sync scan
390
390
 
391
391
  ### Telegram-чаты не появляются
392
392
 
393
- 1. В Telegram **Desktop** (не mobile!): чат → меню → **Export chat history** → **Format: JSON**
394
- 2. Кинь `result.json` в `~/.memex/inbox/`
395
- 3. Memex подхватит автоматически за ~1.5 сек
393
+ В Telegram **Desktop** (не mobile!): чат → меню (⋮ или ☰) → **Export chat history**.
394
+
395
+ **memex поддерживает оба формата экспорта (v0.9+):**
396
+
397
+ - **«Machine-readable JSON»** ← рекомендуется. Один файл `result.json`, чисто ингестится.
398
+ - **«HTML»** ← тоже работает (с v0.9+). Получишь директорию `ChatExport_<chat>_<date>/` — кинь её **целиком** в inbox, memex авто-парсит.
399
+
400
+ Шаги:
401
+ 1. Выбери формат (JSON — короче путь, HTML — если уже экспортировано).
402
+ 2. Сохрани в **`~/.memex/inbox/`** — для JSON это `result.json`, для HTML — вся директория `ChatExport_…`.
403
+ 3. Memex подхватит автоматически за ~1.5 секунды. Файл/директория уедет в `~/.memex/data/conversations/telegram/` (или `…/telegram-html/`) после ingest'a.
404
+
405
+ Если ингест не произошёл — проверь `~/.memex/data/memex.log`. Для HTML-export'а парсер пишет actionable error если что-то не так (формат изменился у Telegram, директория повреждена, и т.д.).
406
+
407
+ **Что НЕ работает:**
408
+ - ❌ Мобильный экспорт (Telegram mobile не экспортирует структурированно)
409
+ - ❌ Скриншоты чатов
410
+ - ❌ Только-медиа чаты (без текста парсятся как `[photo]`/`[voice]` placeholders, но без content)
396
411
 
397
412
  ### Хочу подключить новый Obsidian-vault
398
413
 
package/README.md CHANGED
@@ -26,6 +26,23 @@ MCP server → Cursor · Cline · Claude Code · Continue · Zed · Codex ·
26
26
 
27
27
  ## Install in 60 seconds
28
28
 
29
+ **One-line install (recommended):**
30
+
31
+ ```sh
32
+ curl -fsSL https://memex.parallelclaw.ai/install.sh | bash
33
+ ```
34
+
35
+ That single command:
36
+ 1. Verifies Node ≥ 20.
37
+ 2. Runs `npm install -g memex-mvp`, auto-fixing `EACCES` by moving npm's prefix to `~/.npm-global` (no `sudo` needed, ever).
38
+ 3. Installs the auto-capture daemon (`memex-sync install`) **with** the v0.8 Brian Chesky auto-context hook into `~/.claude/settings.json` (preserves existing hooks).
39
+ 4. Backfills history (`memex-sync scan`) so memex already knows about your past sessions.
40
+ 5. If `claude` (Claude Code CLI) is on PATH, runs `claude mcp add memex --scope user -- memex` to wire MCP automatically.
41
+
42
+ Idempotent — safe to re-run. To inspect the script before piping to bash: `curl -fsSL https://memex.parallelclaw.ai/install.sh | less`.
43
+
44
+ **Prefer manual install?**
45
+
29
46
  ```sh
30
47
  npm install -g memex-mvp
31
48
  memex-sync install # macOS LaunchAgent for auto-capture
@@ -172,7 +189,7 @@ Perplexity threads need to be made **Public** in the Share dialog first — meme
172
189
  | Cursor IDE chats | Auto: reads Cursor's local SQLite session store |
173
190
  | Continue / Zed | Auto: filesystem watchers per platform |
174
191
  | Obsidian notes | Auto: per-vault markdown watcher |
175
- | Telegram exports | Manual: drop `result.json` (Telegram Desktop) into `~/.memex/inbox/` |
192
+ | Telegram exports | Manual: drop `result.json` (Telegram Desktop → Export → JSON) into `~/.memex/inbox/`. v0.9+: also accepts the **HTML** export directory (`ChatExport_*/`) — drop the whole folder, memex parses it. |
176
193
  | Telegram (live) | Run [`memex-bot`](bot/README.md) — captures messages you send/forward to your private bot |
177
194
  | **Web pages, AI chat shares, pasted text** | From any MCP agent: *"save https://... to memex"*. Agent fetches; memex stores verbatim. Cloudflare-protected pages (Perplexity, npm.com, Twitter, Medium, …) handled via the agent's r.jina.ai fallback. See [HELP.md §8](HELP.md) |
178
195
 
package/README.ru.md CHANGED
@@ -83,6 +83,23 @@ MCP server (stdio JSON-RPC)
83
83
 
84
84
  ## Установка за 60 секунд
85
85
 
86
+ **Установка в одну строку (рекомендуется):**
87
+
88
+ ```bash
89
+ curl -fsSL https://memex.parallelclaw.ai/install.sh | bash
90
+ ```
91
+
92
+ Эта команда сама делает:
93
+ 1. Проверяет Node ≥ 20.
94
+ 2. Запускает `npm install -g memex-mvp`, и если ловит `EACCES` — сама переносит npm prefix в `~/.npm-global` (sudo больше не нужен — никогда, ни для одного `npm install -g`).
95
+ 3. Поднимает auto-capture daemon (`memex-sync install`) **вместе с** Brian Chesky auto-context хуком (v0.8+) в `~/.claude/settings.json` (другие хуки не трогает).
96
+ 4. Бэкфиллит историю (`memex-sync scan`) — memex сразу знает о твоих прошлых сессиях.
97
+ 5. Если на машине найден Claude Code CLI (`claude`), вызывает `claude mcp add memex --scope user -- memex` — MCP прописывается автоматом.
98
+
99
+ Идемпотентно — безопасно перезапускать. Хочешь сначала посмотреть скрипт: `curl -fsSL https://memex.parallelclaw.ai/install.sh | less`.
100
+
101
+ **Или вручную:**
102
+
86
103
  ```bash
87
104
  npm install -g memex-mvp
88
105
  memex-sync install # macOS LaunchAgent для auto-capture
@@ -224,7 +241,8 @@ which node # → путь до бинарника node (например /Users
224
241
  | **Claude Cowork** | `cowork-*.jsonl` (через filename prefix), включая subagents | ✅ работает |
225
242
  | **Cursor IDE** (Composer + Chat) | SQLite `state.vscdb` в `~/Library/Application Support/Cursor/` | ✅ работает (poll каждые 5 мин) |
226
243
  | **Obsidian** vault notes | `.md` файлы + YAML frontmatter | ✅ работает (FSEvents, hash-based dedupe) |
227
- | **Telegram** | `result.json` из Desktop export | ✅ работает |
244
+ | **Telegram (JSON export)** | `result.json` из Desktop export | ✅ работает |
245
+ | **Telegram (HTML export)** | директория `ChatExport_*` из Desktop (v0.9+) | ✅ работает — кидай всю папку в inbox |
228
246
  | **Telegram (live)** | бот `memex-bot` ловит твои сообщения / форварды | ✅ работает |
229
247
  | **Web-страницы, AI-share'ы, paste'ы** | `memex_store_document` — агент fetch'ит, memex хранит verbatim (v0.6+) | ✅ работает |
230
248
  | Claude.ai web export | будет в v0.7 | — |
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Telegram Desktop HTML export → Telegram-JSON-shape converter.
3
+ *
4
+ * Telegram Desktop offers two export formats:
5
+ * - "Machine-readable JSON" — what memex's importTelegram expects
6
+ * - "Human-readable HTML" — what many users pick by default
7
+ *
8
+ * Users frequently export as HTML by accident (often the default in the
9
+ * Telegram UI), then memex's inbox watcher silently ignores the dropped
10
+ * directory. This module makes HTML work: parse → emit the same shape
11
+ * importTelegram already understands.
12
+ *
13
+ * Telegram's HTML export is reasonably stable:
14
+ *
15
+ * ChatExport_<chat-title>_<date>/
16
+ * ├── messages.html (or messages.htm — chunked: messages2, messages3, …)
17
+ * ├── photos/
18
+ * ├── files/
19
+ * ├── stickers/
20
+ * └── voice_messages/
21
+ *
22
+ * Each messages*.html has structure:
23
+ *
24
+ * <div class="message default clearfix" id="message12345">
25
+ * <div class="body">
26
+ * <div class="from_name"> ↳ Sender Name </div> (may be absent on "joined" messages)
27
+ * <div class="text"> message text </div>
28
+ * <div class="pull_right date details" title="2024-01-01 14:23:45 UTC+03:00">14:23</div>
29
+ * </div>
30
+ * </div>
31
+ *
32
+ * Joined message = same sender as previous, has class "joined", no from_name.
33
+ * Service message = class "service" (joined chat, name change, …) — we skip these.
34
+ * Forwarded = "forwarded body" wrapping the message body.
35
+ * Reply = "reply_to details" sibling.
36
+ *
37
+ * We use regex-based parsing (no DOM dependency) because Telegram's class
38
+ * names are stable and we control which fields we care about. If Telegram
39
+ * radically changes the schema, parser breaks loudly (returns 0 messages
40
+ * + clear log) rather than silently corrupting.
41
+ */
42
+
43
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
44
+ import { join, basename, dirname } from 'node:path';
45
+
46
+ /**
47
+ * Detect if a given path is a Telegram HTML export.
48
+ * Accepts both a directory (most common — ChatExport_xxx/) and a bare
49
+ * messages.html file (rare — user dropped just the one file).
50
+ *
51
+ * Returns { type: 'dir' | 'file' | null, htmlFiles: string[] }
52
+ * null type means "not a Telegram HTML export"
53
+ */
54
+ export function detectTelegramHtml(path) {
55
+ if (!existsSync(path)) return { type: null, htmlFiles: [] };
56
+ const stats = statSync(path);
57
+
58
+ // Directory case: look for messages*.html inside
59
+ if (stats.isDirectory()) {
60
+ let entries = [];
61
+ try { entries = readdirSync(path); } catch (_) { return { type: null, htmlFiles: [] }; }
62
+ const htmlFiles = entries
63
+ .filter((f) => /^messages\d*\.html?$/i.test(f))
64
+ .map((f) => join(path, f));
65
+ if (htmlFiles.length === 0) return { type: null, htmlFiles: [] };
66
+ // Verify the first one contains Telegram-shaped markers
67
+ const head = safeReadHead(htmlFiles[0]);
68
+ if (!looksLikeTelegram(head)) return { type: null, htmlFiles: [] };
69
+ // Sort chunks: messages.html < messages2.html < messages3.html …
70
+ htmlFiles.sort(numericChunkSort);
71
+ return { type: 'dir', htmlFiles };
72
+ }
73
+
74
+ // Single file case: must be messages*.html
75
+ if (stats.isFile() && /\.html?$/i.test(path) && /messages\d*\.html?$/i.test(basename(path))) {
76
+ const head = safeReadHead(path);
77
+ if (!looksLikeTelegram(head)) return { type: null, htmlFiles: [] };
78
+ return { type: 'file', htmlFiles: [path] };
79
+ }
80
+
81
+ return { type: null, htmlFiles: [] };
82
+ }
83
+
84
+ function safeReadHead(file, bytes = 8192) {
85
+ try {
86
+ return readFileSync(file, 'utf-8').slice(0, bytes);
87
+ } catch (_) {
88
+ return '';
89
+ }
90
+ }
91
+
92
+ function looksLikeTelegram(head) {
93
+ // Reliable markers in Telegram Desktop HTML exports
94
+ return /class="page_wrap"/.test(head) ||
95
+ /class="page_body chat_page"/.test(head) ||
96
+ (/class="from_name"/.test(head) && /class="text"/.test(head));
97
+ }
98
+
99
+ function numericChunkSort(a, b) {
100
+ const numA = parseInt((basename(a).match(/messages(\d*)\.html?/i) || [, '0'])[1] || '0', 10);
101
+ const numB = parseInt((basename(b).match(/messages(\d*)\.html?/i) || [, '0'])[1] || '0', 10);
102
+ return numA - numB;
103
+ }
104
+
105
+ /**
106
+ * Strip HTML tags and decode common entities → plain text.
107
+ * Conservative: preserves newlines from <br>, paragraph breaks from </div>.
108
+ */
109
+ function htmlToText(html) {
110
+ if (!html) return '';
111
+ let out = String(html);
112
+ // Convert breaks to newlines BEFORE stripping tags
113
+ out = out.replace(/<br\s*\/?>/gi, '\n');
114
+ out = out.replace(/<\/p>/gi, '\n\n');
115
+ out = out.replace(/<\/div>/gi, '\n');
116
+ // Drop all remaining tags
117
+ out = out.replace(/<[^>]+>/g, '');
118
+ // Decode common entities
119
+ out = out
120
+ .replace(/&nbsp;/g, ' ')
121
+ .replace(/&amp;/g, '&')
122
+ .replace(/&lt;/g, '<')
123
+ .replace(/&gt;/g, '>')
124
+ .replace(/&quot;/g, '"')
125
+ .replace(/&apos;/g, "'")
126
+ .replace(/&#39;/g, "'")
127
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
128
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)));
129
+ // Collapse 3+ blank lines, trim
130
+ out = out.replace(/\n{3,}/g, '\n\n').trim();
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * Parse a Telegram date title into Unix timestamp.
136
+ * Telegram emits dates in the user's locale format, e.g.:
137
+ * • "2024-01-01 14:23:45 UTC+03:00" (ISO — English locale)
138
+ * • "01.01.2024 14:23:45 UTC+03:00" (European — Russian / German / etc.)
139
+ * • "01/01/2024 14:23:45 UTC+03:00" (US slash format — less common in exports)
140
+ * Returns { tsUnix, isoString } or null if unparseable.
141
+ */
142
+ function parseTelegramDate(title) {
143
+ if (!title) return null;
144
+ let y, mo, d, h, mi, s, sign, oh, om;
145
+ // ISO: YYYY-MM-DD HH:MM:SS [UTC±HH:MM]
146
+ let m = title.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})(?:\s+UTC([+-])(\d{2}):(\d{2}))?$/);
147
+ if (m) {
148
+ [, y, mo, d, h, mi, s, sign, oh, om] = m;
149
+ } else {
150
+ // European: DD.MM.YYYY HH:MM:SS [UTC±HH:MM] (also supports "/" or "-" as separator)
151
+ m = title.match(/^(\d{2})[.\/-](\d{2})[.\/-](\d{4})\s+(\d{2}):(\d{2}):(\d{2})(?:\s+UTC([+-])(\d{2}):(\d{2}))?$/);
152
+ if (!m) return null;
153
+ [, d, mo, y, h, mi, s, sign, oh, om] = m;
154
+ }
155
+ // Construct an ISO 8601 string with the explicit offset (or UTC if absent)
156
+ const offset = sign ? `${sign}${oh}:${om}` : 'Z';
157
+ const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}${offset}`;
158
+ const date = new Date(iso);
159
+ if (isNaN(date.getTime())) return null;
160
+ return {
161
+ tsUnix: Math.floor(date.getTime() / 1000),
162
+ isoString: iso.replace(/[+-]\d{2}:\d{2}$/, '').replace('Z', ''),
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Parse a single message div (raw HTML segment).
168
+ * Returns null for service messages (we skip those) or messages with no text.
169
+ */
170
+ function parseMessageDiv(messageHtml, lastSender) {
171
+ // Skip service messages outright
172
+ if (/class="message service\b/.test(messageHtml)) return null;
173
+
174
+ // Extract message id from outer div: id="message12345"
175
+ const idMatch = messageHtml.match(/id="message(\d+)"/);
176
+ const msgId = idMatch ? idMatch[1] : null;
177
+ if (!msgId) return null;
178
+
179
+ const isJoined = /class="message [^"]*joined/.test(messageHtml);
180
+
181
+ // Forwarded marker
182
+ const isForwarded = /class="forwarded body"/.test(messageHtml);
183
+ let forwardedFrom = null;
184
+ if (isForwarded) {
185
+ const fwdM = messageHtml.match(/class="forwarded[^"]*"[\s\S]*?<div class="from_name"[^>]*>\s*([\s\S]*?)\s*<\/div>/);
186
+ if (fwdM) {
187
+ forwardedFrom = htmlToText(fwdM[1]).replace(/^Forwarded from:?\s*/i, '').trim();
188
+ }
189
+ }
190
+
191
+ // Sender (from_name) — absent on joined messages
192
+ let fromName = null;
193
+ const fromM = messageHtml.match(/<div class="from_name"[^>]*>\s*([\s\S]*?)\s*<\/div>/);
194
+ if (fromM && !isForwarded) {
195
+ fromName = htmlToText(fromM[1]).trim();
196
+ }
197
+ // If joined, inherit lastSender; otherwise use parsed or fallback
198
+ if (!fromName && isJoined && lastSender) fromName = lastSender;
199
+ if (!fromName) fromName = 'Unknown';
200
+
201
+ // Date — title attribute on `.date.details`
202
+ let date = null;
203
+ const dateM = messageHtml.match(/class="[^"]*\bdate details[^"]*"\s+title="([^"]+)"/);
204
+ if (dateM) date = parseTelegramDate(dateM[1]);
205
+
206
+ // Main text — last `<div class="text">…</div>` inside body (forwards may have one earlier)
207
+ let text = '';
208
+ const textMatches = [...messageHtml.matchAll(/<div class="text"[^>]*>([\s\S]*?)<\/div>(?=\s*(?:<div class="(?!text)|<\/div>|<a class="|$))/g)];
209
+ if (textMatches.length > 0) {
210
+ // Use last one (the actual message body, after any quoted/forwarded preamble)
211
+ text = htmlToText(textMatches[textMatches.length - 1][1]);
212
+ }
213
+
214
+ // Reply marker — include as prefix so it's searchable but not lost
215
+ const replyM = messageHtml.match(/class="reply_to details"[^>]*>([\s\S]*?)<\/div>/);
216
+ if (replyM) {
217
+ const replyTxt = htmlToText(replyM[1]).replace(/^In reply to\s+/i, '').trim();
218
+ if (replyTxt) text = `↩ Reply: ${replyTxt}\n\n${text}`;
219
+ }
220
+
221
+ // Photo / media — if no text, note the media presence so the row isn't lost.
222
+ // Use word-boundary regexes since class attrs like "photo_wrap clearfix pull_left"
223
+ // wouldn't match a strict `class="photo_wrap"` pattern.
224
+ if (!text) {
225
+ if (/class="[^"]*\bphoto_wrap\b/.test(messageHtml)) text = '[photo]';
226
+ else if (/class="[^"]*\bmedia_voice_message\b/.test(messageHtml)) text = '[voice message]';
227
+ else if (/class="[^"]*\bmedia_video_file\b/.test(messageHtml)) text = '[video]';
228
+ else if (/class="[^"]*\bmedia_audio_file\b/.test(messageHtml)) text = '[audio]';
229
+ else if (/class="[^"]*\bmedia_file\b/.test(messageHtml)) text = '[file]';
230
+ else if (/class="[^"]*\bsticker\b/.test(messageHtml)) text = '[sticker]';
231
+ else return null; // Truly empty — skip
232
+ }
233
+
234
+ // Build the message object in the shape importTelegram expects
235
+ // (date and date_unixtime are required by the importer)
236
+ const isoDate = date ? date.isoString : null;
237
+ const tsUnix = date ? date.tsUnix : 0;
238
+
239
+ return {
240
+ id: parseInt(msgId, 10),
241
+ type: 'message',
242
+ date: isoDate || '1970-01-01T00:00:00',
243
+ date_unixtime: tsUnix > 0 ? String(tsUnix) : '0',
244
+ from: fromName,
245
+ from_id: fromName ? `user_html_${slugify(fromName)}` : 'unknown',
246
+ text: text,
247
+ ...(forwardedFrom ? { forwarded_from: forwardedFrom } : {}),
248
+ };
249
+ }
250
+
251
+ function slugify(s) {
252
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'anon';
253
+ }
254
+
255
+ /**
256
+ * Extract chat title from messages.html (or first chunk).
257
+ * Falls back to directory name basename, then "Telegram chat".
258
+ */
259
+ function extractChatTitle(htmlContent, fallbackPath) {
260
+ // PREFER the page_header — it's the actual chat name as shown in Telegram.
261
+ // The <title> tag is often the locale-specific "Exported Data" / "Telegram"
262
+ // boilerplate, which we want to avoid.
263
+ const headerM = htmlContent.match(/<div class="page_header"[\s\S]*?<div class="text bold"[^>]*>\s*([\s\S]*?)\s*<\/div>/);
264
+ if (headerM) {
265
+ const t = htmlToText(headerM[1]).trim();
266
+ if (t) return t;
267
+ }
268
+ // Fallback: <title>...</title> — strip "Chat Export" / "Telegram" / "Exported Data" suffixes
269
+ const titleM = htmlContent.match(/<title>\s*([^<]+?)\s*<\/title>/i);
270
+ if (titleM) {
271
+ let t = titleM[1].trim();
272
+ t = t.replace(/\s*[—-]\s*(Chat Export|Telegram).*$/i, '').trim();
273
+ // Skip locale boilerplate that Telegram itself uses as the page <title>
274
+ if (t && !/^(Telegram|Exported Data|Экспорт(ированные)? данные|Эспортированные данные)$/i.test(t)) {
275
+ return t;
276
+ }
277
+ }
278
+ // Fallback: dirname of the parent ChatExport_xxx folder
279
+ if (fallbackPath) {
280
+ const parent = basename(dirname(fallbackPath));
281
+ if (parent && parent.startsWith('ChatExport')) {
282
+ return parent.replace(/^ChatExport_?/, '').replace(/_/g, ' ').trim() || 'Telegram chat';
283
+ }
284
+ }
285
+ return 'Telegram chat';
286
+ }
287
+
288
+ /**
289
+ * Main entrypoint. Parse a Telegram HTML export path → return an object
290
+ * shaped like a Telegram JSON export, ready for importTelegram().
291
+ *
292
+ * Returns null if path isn't a valid Telegram HTML export.
293
+ *
294
+ * Object shape:
295
+ * {
296
+ * personal_information: { user_id: "" },
297
+ * chats: {
298
+ * list: [{
299
+ * id: <stable hash of chat title>,
300
+ * name: <chat title>,
301
+ * type: "personal_chat",
302
+ * messages: [{ id, type, date, date_unixtime, from, from_id, text, … }, …]
303
+ * }]
304
+ * }
305
+ * }
306
+ */
307
+ export function parseTelegramHtmlExport(path, opts = {}) {
308
+ const detection = detectTelegramHtml(path);
309
+ if (!detection.type) return null;
310
+ if (detection.htmlFiles.length === 0) return null;
311
+
312
+ let allMessages = [];
313
+ let chatTitle = null;
314
+ let lastSender = null;
315
+
316
+ for (const htmlPath of detection.htmlFiles) {
317
+ let content;
318
+ try { content = readFileSync(htmlPath, 'utf-8'); }
319
+ catch (_) { continue; }
320
+
321
+ if (!chatTitle) chatTitle = extractChatTitle(content, htmlPath);
322
+
323
+ // Split into per-message blocks. The reliable boundary is the
324
+ // opening `<div class="message ` of the next message.
325
+ // Use a tolerant regex that handles the message default / joined variants.
326
+ const messageBlocks = [...content.matchAll(/<div class="message [^"]*"[\s\S]*?(?=<div class="message [^"]*"|<div class="page_footer"|<\/body>)/g)];
327
+
328
+ for (const blockMatch of messageBlocks) {
329
+ const msg = parseMessageDiv(blockMatch[0], lastSender);
330
+ if (msg) {
331
+ allMessages.push(msg);
332
+ // Track sender for "joined" continuation messages
333
+ if (msg.from && msg.from !== 'Unknown') lastSender = msg.from;
334
+ }
335
+ }
336
+ }
337
+
338
+ if (allMessages.length === 0) return null;
339
+
340
+ // Stable chat id: hash of title + first message ts (good enough for dedup)
341
+ // We use a simple numeric hash so the synthetic chat_id is stable across re-imports.
342
+ const chatId = stableChatId(chatTitle || 'Telegram chat', allMessages[0]?.date_unixtime || '0');
343
+
344
+ // Detect chat type from sender diversity. A `personal_chat` has at most 2 distinct
345
+ // senders (you + the other person). 3+ distinct senders → group / supergroup.
346
+ // We can't distinguish private_group vs public_supergroup from HTML alone, so we
347
+ // call it `private_group` (matches the JSON export taxonomy).
348
+ const distinctSenders = new Set();
349
+ for (const m of allMessages) {
350
+ if (m.from && m.from !== 'Unknown') distinctSenders.add(m.from);
351
+ if (distinctSenders.size > 2) break;
352
+ }
353
+ const chatType = distinctSenders.size > 2 ? 'private_group' : 'personal_chat';
354
+
355
+ return {
356
+ personal_information: { user_id: '' },
357
+ chats: {
358
+ list: [
359
+ {
360
+ id: chatId,
361
+ name: chatTitle || 'Telegram chat',
362
+ type: chatType,
363
+ messages: allMessages,
364
+ },
365
+ ],
366
+ },
367
+ _source: {
368
+ format: 'telegram-html',
369
+ original_path: path,
370
+ chunks: detection.htmlFiles.length,
371
+ messages_total: allMessages.length,
372
+ },
373
+ };
374
+ }
375
+
376
+ function stableChatId(title, firstTs) {
377
+ let hash = 0;
378
+ const key = title + ':' + firstTs;
379
+ for (let i = 0; i < key.length; i++) {
380
+ hash = ((hash << 5) - hash) + key.charCodeAt(i);
381
+ hash |= 0;
382
+ }
383
+ return Math.abs(hash);
384
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memex-mvp",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "Local-first MCP server for cross-agent AI memory. One SQLite + FTS5 corpus across Claude Code, Cowork, Cursor, Continue, Zed, Obsidian, and Telegram — passively captured, verbatim, searchable from any MCP-compatible client.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -26,7 +26,7 @@
26
26
  "sync": "node ingest.js",
27
27
  "ingest": "node ingest.js",
28
28
  "bot": "node bot/index.js",
29
- "test": "node test/parser.test.js && node test/bot-inbox.test.js && node test/search-sort.test.js && node test/store-document.test.js && node test/cli.test.js && node test/hook.test.js",
29
+ "test": "node test/parser.test.js && node test/bot-inbox.test.js && node test/search-sort.test.js && node test/store-document.test.js && node test/cli.test.js && node test/hook.test.js && node test/telegram-html.test.js",
30
30
  "prepublishOnly": "npm test"
31
31
  },
32
32
  "engines": {
package/server.js CHANGED
@@ -49,6 +49,10 @@ import {
49
49
  } from './lib/store-doc/canonicalize.js';
50
50
  import { detectIssues, isBlocked } from './lib/store-doc/detect.js';
51
51
  import { extractTitle } from './lib/store-doc/extract-title.js';
52
+ import {
53
+ detectTelegramHtml,
54
+ parseTelegramHtmlExport,
55
+ } from './lib/parse-telegram-html.js';
52
56
  import { createHash } from 'node:crypto';
53
57
  import { runCli, CLI_SUBCOMMAND_NAMES } from './lib/cli/index.js';
54
58
 
@@ -325,9 +329,17 @@ const insertImport = db.prepare(`
325
329
 
326
330
  // -------------------- Importers --------------------
327
331
 
328
- /** Telegram Desktop JSON export (single chat or all_chats). */
329
- function importTelegram(filePath) {
330
- const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
332
+ /**
333
+ * Telegram Desktop export importer. Accepts:
334
+ * - filePath (string) — path to result.json
335
+ * - rawObject (object) — already-parsed export, e.g. from parseTelegramHtmlExport
336
+ *
337
+ * Returns total imported message count.
338
+ */
339
+ function importTelegram(filePathOrRaw) {
340
+ const raw = typeof filePathOrRaw === 'string'
341
+ ? JSON.parse(readFileSync(filePathOrRaw, 'utf-8'))
342
+ : filePathOrRaw;
331
343
 
332
344
  // Telegram Desktop produces either a single chat object or { chats: { list: [...] } }
333
345
  const chats = Array.isArray(raw.chats?.list)
@@ -670,9 +682,83 @@ function resolvePendingParents() {
670
682
  }
671
683
 
672
684
  /** Auto-detect format and import */
685
+ /**
686
+ * Try to import a path as a Telegram HTML export (directory or single file).
687
+ * Returns imported message count, or 0 if not an HTML export.
688
+ *
689
+ * Side effects on success:
690
+ * - Inserts an `imports` row tagged "telegram-html"
691
+ * - Moves the source directory/file to ~/.memex/data/conversations/telegram-html/
692
+ *
693
+ * If it LOOKS like a Telegram HTML export but parsing failed, prints an
694
+ * actionable error pointing the user at the Desktop export menu — instead
695
+ * of silently ignoring. This was Tester 5's friction point.
696
+ */
697
+ function importTelegramHtmlIfMatches(path) {
698
+ const detection = detectTelegramHtml(path);
699
+ if (!detection.type) return 0;
700
+
701
+ let parsed;
702
+ try {
703
+ parsed = parseTelegramHtmlExport(path);
704
+ } catch (err) {
705
+ log('telegram-html parse error:', basename(path), err.message);
706
+ parsed = null;
707
+ }
708
+
709
+ if (!parsed || parsed.chats.list[0].messages.length === 0) {
710
+ // Looked like Telegram HTML (had markers) but extraction yielded nothing.
711
+ // Print actionable error rather than silent ignore.
712
+ log('');
713
+ log('⚠ Detected Telegram HTML export at ' + basename(path) + ' but extracted 0 messages.');
714
+ log(' This usually means Telegram changed the HTML format, or the export is partial.');
715
+ log(' EASIEST FIX — re-export as JSON:');
716
+ log(' 1. Open Telegram Desktop');
717
+ log(' 2. Click the chat → ⋮ menu → "Export chat history"');
718
+ log(' 3. Format: change "HTML" to "Machine-readable JSON"');
719
+ log(' 4. Drop the new result.json into ~/.memex/inbox/');
720
+ log('');
721
+ log(' HTML export will be left in place — feel free to delete it once JSON works.');
722
+ return 0;
723
+ }
724
+
725
+ let imported = 0;
726
+ try {
727
+ imported = importTelegram(parsed);
728
+ } catch (err) {
729
+ log('telegram-html import error:', err.message);
730
+ return 0;
731
+ }
732
+
733
+ if (imported > 0) {
734
+ insertImport.run(
735
+ basename(path),
736
+ 'telegram-html',
737
+ Math.floor(Date.now() / 1000),
738
+ imported
739
+ );
740
+ // Archive: move the whole directory (or file) so the watcher doesn't re-process
741
+ const targetDir = join(ARCHIVE, 'telegram-html');
742
+ mkdirSync(targetDir, { recursive: true });
743
+ const target = join(targetDir, basename(path));
744
+ try {
745
+ renameSync(path, target);
746
+ } catch (_) {}
747
+ log(`imported ${imported} messages from ${basename(path)} (telegram-html, ${detection.htmlFiles.length} chunk(s))`);
748
+ }
749
+ return imported;
750
+ }
751
+
673
752
  function importFile(filePath) {
674
753
  if (!existsSync(filePath)) return 0;
675
754
  const stats = statSync(filePath);
755
+
756
+ // Telegram HTML export — can be either a directory (ChatExport_xxx/)
757
+ // or a bare messages.html file. We accept both. Detected via marker
758
+ // patterns inside the HTML, not file extension alone.
759
+ if (stats.isDirectory()) {
760
+ return importTelegramHtmlIfMatches(filePath);
761
+ }
676
762
  if (!stats.isFile()) return 0;
677
763
 
678
764
  const lower = filePath.toLowerCase();
@@ -692,6 +778,10 @@ function importFile(filePath) {
692
778
  imported = importTelegram(filePath);
693
779
  source = 'telegram';
694
780
  }
781
+ } else if (/\.html?$/i.test(lower)) {
782
+ // Single-file HTML drop (rare — usually a directory)
783
+ imported = importTelegramHtmlIfMatches(filePath);
784
+ if (imported > 0) source = 'telegram';
695
785
  } else if (lower.endsWith('.jsonl')) {
696
786
  // Filename prefix tells us which product the session came from.
697
787
  // cowork- → Claude Cowork (incl. its subagents)
@@ -739,15 +829,30 @@ function importFile(filePath) {
739
829
  // dropping a partial file by hand — the watcher must not race the writer and
740
830
  // move the unfinished tmp into archive, which used to spam ENOENT into the
741
831
  // daemon's rename and corrupt the import accounting.
832
+ // Watch INBOX top-level. Files: chokidar 'add' event. Directories:
833
+ // chokidar 'addDir' event (v0.9+ inbox can also receive Telegram HTML
834
+ // export DIRECTORIES like ChatExport_xxx/, not just JSON/JSONL files).
835
+ //
836
+ // `depth: 0` means we only get top-level entries — we DON'T want every
837
+ // .html chunk inside ChatExport_xxx to fire 'add' separately. The
838
+ // directory drop itself is what we react to; the HTML parser walks
839
+ // inside.
742
840
  chokidar
743
841
  .watch(INBOX, {
744
842
  ignoreInitial: false,
745
843
  ignored: /\.tmp$/,
746
844
  awaitWriteFinish: { stabilityThreshold: 800 },
845
+ depth: 0,
747
846
  })
748
847
  .on('add', (filePath) => {
749
- log('inbox detected:', basename(filePath));
848
+ log('inbox detected (file):', basename(filePath));
750
849
  importFile(filePath);
850
+ })
851
+ .on('addDir', (dirPath) => {
852
+ // Skip the inbox itself
853
+ if (dirPath === INBOX) return;
854
+ log('inbox detected (dir):', basename(dirPath));
855
+ importFile(dirPath);
751
856
  });
752
857
 
753
858
  // -------------------- MCP Server --------------------
@@ -7,13 +7,12 @@
7
7
  After you drop the skill into your agent (`~/.claude/skills/` for Claude Code, or your client's equivalent), saying **"install memex"** triggers a guided installation:
8
8
 
9
9
  1. **Discovery** — read-only checks for which MCP client you're using and what AI data already exists on your machine
10
- 2. **`npm install -g memex-mvp`** with EACCES fallbacks (one-shot sudo OR permanent prefix-fix; the agent asks you which)
11
- 3. **MCP config merge** — adds a single absolute-path `command` entry into your client's `mcpServers` config. Never overwrites your other servers
12
- 4. **`memex-sync install`**registers the macOS LaunchAgent for live auto-capture
13
- 5. **`memex-sync scan`**one-time backfill of every session that already exists on disk
14
- 6. **Restart hint + verification commands** — including the v0.7+ CLI fallback (`memex overview`, `memex search "foo"`) so you can verify memex works even if MCP didn't wire up cleanly
10
+ 2. **Fast path (v1.1+)** — `curl -fsSL https://memex.parallelclaw.ai/install.sh | bash`: one hosted bash script does npm install (with EACCES auto-fix to `~/.npm-global`), daemon setup, v0.8 auto-context hook, history backfill, and `claude mcp add memex` if Claude Code CLI is on PATH. Idempotent.
11
+ 3. **Fallback: manual five-step** — if curl fails, the user declines, or the agent is inside a GUI client (Cursor/Cline/Continue/Zed) where the MCP config still needs editing: `npm install -g memex-mvp` → MCP config merge `memex-sync install` → `memex-sync scan`.
12
+ 4. **MCP config merge** (only needed for GUI clients) adds a single absolute-path `command` entry into your client's `mcpServers` config. Never overwrites your other servers.
13
+ 5. **Restart hint + verification commands** including the v0.7+ CLI fallback (`memex overview`, `memex search "foo"`) so you can verify memex works even if MCP didn't wire up cleanly.
15
14
 
16
- End-to-end: **~2 minutes**, fully observable (agent shows each command before running).
15
+ End-to-end: **~60 seconds** via fast path, **~2 minutes** via manual flow, fully observable (agent shows each command before running).
17
16
 
18
17
  ## What is memex?
19
18
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: install-memex
3
3
  description: Make Claude, Cursor, Cline, Continue, and Zed remember every AI conversation forever — one local SQLite corpus shared across all of them. Installs memex (local-first MCP server) in ~2 minutes — npm install, MCP config wiring, auto-capture daemon, history backfill. No cloud, no account, verbatim storage. Also indexes Obsidian notes, Telegram chats, and any URL the user wants to save (web pages, Perplexity threads, AI chat shares — memex_store_document tool, v0.6+). Use when the user says "install memex", "set up memex", "add memory to my AI", "make my agent remember across sessions", or similar.
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  metadata:
6
6
  openclaw:
7
7
  emoji: "📚"
@@ -48,7 +48,32 @@ Scan the user's setup so you can tailor advice and tell them exactly what memex
48
48
  - "After install, memex will auto-index conversations from: [detected sources]"
49
49
  4. Wait for the user's "ok" before starting step 1.
50
50
 
51
- ## Five-step install
51
+ ## Fast path — one-line installer (try this first)
52
+
53
+ memex ships a hosted bash installer that does steps 1, 3, and 4 in a single run — and also wires up Claude Code's MCP entry if `claude` is on PATH. It's idempotent (safe to re-run), auto-fixes the `EACCES` case by moving npm's prefix to `~/.npm-global`, and prompts before enabling the auto-context hook.
54
+
55
+ Show this command to the user, explain what it does, get their **explicit ok**, then run:
56
+
57
+ ```sh
58
+ curl -fsSL https://memex.parallelclaw.ai/install.sh | bash
59
+ ```
60
+
61
+ What the script does, in order:
62
+ 1. Checks Node ≥ 20.
63
+ 2. `npm install -g memex-mvp` — on EACCES, sets `npm config set prefix ~/.npm-global`, appends PATH to `~/.zshrc`, retries.
64
+ 3. `memex-sync install` with `--auto-context yes` (Brian Chesky hook into `~/.claude/settings.json` — preserves other hooks).
65
+ 4. `memex-sync scan` — backfills existing history.
66
+ 5. `claude mcp add memex --scope user -- memex` if Claude Code CLI is detected.
67
+
68
+ After the script finishes:
69
+ - If the user is in **Claude Code (CLI)** → install is complete. Skip straight to step 5 (verification + restart).
70
+ - If the user is in **Cursor / Cline / Continue / Zed** → the npm install + daemon + auto-context + scan are done, but the GUI client's MCP config still needs the memex entry. **Skip step 1 (already installed)**, **skip step 3** (daemon already installed) and **skip step 4** (scan already ran). **Do step 2** (wire MCP into the GUI client's config) and **step 5** (verify + restart).
71
+
72
+ If the script fails for any reason — non-zero exit, weird output, user uncomfortable piping curl to bash — fall back to the **Manual five-step install** below.
73
+
74
+ To inspect what the script does first: `curl -fsSL https://memex.parallelclaw.ai/install.sh | less` (don't pipe to bash).
75
+
76
+ ## Manual five-step install (if the fast path didn't fit)
52
77
 
53
78
  Do these in order. Show each command before running it. Stop and ask if anything fails or looks wrong.
54
79
 
@@ -58,14 +83,19 @@ Do these in order. Show each command before running it. Stop and ask if anything
58
83
  npm install -g memex-mvp
59
84
  ```
60
85
 
61
- If you get `EACCES` (macOS system Node), tell the user to choose:
86
+ If you get `EACCES` (macOS system Node), **recommend Option B (permanent prefix fix) by default** and proceed unless the user explicitly chooses sudo.
62
87
 
63
- **Option A** one-shot sudo:
64
- ```sh
65
- sudo npm install -g memex-mvp
66
- ```
88
+ Say to the user:
89
+
90
+ > "Hit EACCES — your Node is installed in a system directory that npm can't write to without admin rights. Two fixes:
91
+ >
92
+ > **A)** Quick: `sudo npm install -g memex-mvp` — one-time, requires your password
93
+ > **B)** Permanent fix: I'll move npm's install location to your home directory. After this, no `npm install -g` will ever ask for sudo again, for any package. Five commands, one-time.
94
+ >
95
+ > I recommend **B** — it's a permanent improvement that benefits all your future Node tools, not just memex. Proceed with B?"
96
+
97
+ If the user agrees (or says anything like "ok", "yes", "default", "go", "B") — proceed with B without further questions:
67
98
 
68
- **Option B** — fix prefix permanently (better long-term):
69
99
  ```sh
70
100
  mkdir -p ~/.npm-global
71
101
  npm config set prefix ~/.npm-global
@@ -74,7 +104,19 @@ source ~/.zshrc
74
104
  npm install -g memex-mvp
75
105
  ```
76
106
 
77
- **Ask which the user prefers don't guess.**
107
+ If the user **explicitly** picks A (or says "sudo", "fast", "quick"), use sudo:
108
+
109
+ ```sh
110
+ sudo npm install -g memex-mvp
111
+ ```
112
+
113
+ After the install completes, verify with `which memex` (should print an absolute path) and `memex --version` (should print the version).
114
+
115
+ **Important if Option B was used + user already had memex from a prior sudo install:** the old sudo-installed copy in `/usr/local/lib/node_modules/memex-mvp/` is now orphaned (PATH prefers the new home install). Mention this once:
116
+
117
+ > "By the way, you have an older sudo-installed memex copy in `/usr/local/lib/`. It's harmless but takes ~60 MB. Clean it up when convenient: `sudo npm uninstall -g memex-mvp` — not urgent."
118
+
119
+ Don't run that uninstall yourself — let the user decide when.
78
120
 
79
121
  If `node` or `npm` are missing, STOP and tell the user to install Node.js (recommend nvm.sh or `brew install node`). Don't try to install Node yourself.
80
122
 
@@ -207,4 +249,4 @@ This is also useful for agents without native MCP support (OpenCode + Kimi, plai
207
249
 
208
250
  ## Begin
209
251
 
210
- Greet the user, confirm which MCP client you're running inside, and run the Discovery checks before any install actions.
252
+ Greet the user, confirm which MCP client you're running inside, and run the Discovery checks before any install actions. After Discovery, **propose the fast path (curl one-liner) first** — it covers ~90% of cases in one shot. Only fall back to the manual five-step install if the user objects, the script fails, or you're inside a GUI client where you'll still need to do step 2 manually after the script runs.
@@ -2,6 +2,52 @@
2
2
 
3
3
  Sample interactions when the user invokes this skill. These show typical happy paths and how the skill handles common edge cases.
4
4
 
5
+ > **Note (v1.1+):** The skill now leads with the **fast-path one-liner** (`curl -fsSL https://memex.parallelclaw.ai/install.sh | bash`) after Discovery — see Example 0 below. The legacy five-step examples (1–5 below) stay as the fallback when the agent is inside a GUI client (Cursor / Cline / Continue / Zed — still need step 2 manually after the script), the user declines curl, or the script fails.
6
+
7
+ ---
8
+
9
+ ## Example 0 · Fast path on Claude Code CLI (v1.1+)
10
+
11
+ > **User:** install memex
12
+ >
13
+ > **Agent:** 👋 You're running inside Claude Code CLI. Let me run a quick discovery first.
14
+ >
15
+ > ```
16
+ > ✓ Found Claude Code data at ~/.claude/projects/ (14 sessions)
17
+ > ✓ Found Cowork sessions (5 subagent transcripts)
18
+ > ✗ Cursor data not found
19
+ > ✗ Continue / Zed not found
20
+ > ```
21
+ >
22
+ > After install, memex will auto-index conversations from: **Claude Code, Cowork**. You can drop Telegram exports into `~/.memex/inbox/` later if needed.
23
+ >
24
+ > The fast path is a single hosted bash script that does **npm install (with EACCES auto-fix), daemon setup, Brian Chesky auto-context hook, history backfill, and `claude mcp add memex`** all in one go. Idempotent. Want me to run:
25
+ >
26
+ > ```sh
27
+ > curl -fsSL https://memex.parallelclaw.ai/install.sh | bash
28
+ > ```
29
+ >
30
+ > **User:** ok
31
+ >
32
+ > **Agent:** [runs the curl command, streams output]
33
+ >
34
+ > ```
35
+ > ▸ memex one-line installer
36
+ > ▸ Node v20.11.1 — ok
37
+ > ▸ Installing memex-mvp from npm...
38
+ > ✓ memex 0.9.0 ready at /Users/you/.npm-global/bin/memex
39
+ > ▸ Installing memex-sync (LaunchAgent + auto-context hook)...
40
+ > ✓ daemon running (PID 41872)
41
+ > ✓ Brian Chesky auto-context hook added to ~/.claude/settings.json
42
+ > ▸ Backfilling history (memex-sync scan)...
43
+ > ✓ 87 conversations indexed
44
+ > ▸ Wiring MCP into Claude Code...
45
+ > ✓ claude mcp add memex — done
46
+ > ▸ All set. Quit Claude Code (Cmd+Q) and reopen any project. Memex will inject prior context before your first message.
47
+ > ```
48
+ >
49
+ > All five steps done in ~25 seconds. Quit Claude Code (Cmd+Q) and reopen — when you open any project you've worked in, memex will inject relevant context before you even type. Try: `memex overview` from terminal to confirm.
50
+
5
51
  ---
6
52
 
7
53
  ## Example 1 · Happy path on a fresh Cursor install
@@ -73,7 +119,7 @@ Sample interactions when the user invokes this skill. These show typical happy p
73
119
 
74
120
  ---
75
121
 
76
- ## Example 2 · EACCES recovery (macOS system Node)
122
+ ## Example 2 · EACCES recovery (macOS system Node) — recommend B by default
77
123
 
78
124
  > **User:** install memex
79
125
  >
@@ -84,14 +130,18 @@ Sample interactions when the user invokes this skill. These show typical happy p
84
130
  > npm error path: '/usr/local/lib/node_modules/memex-mvp'
85
131
  > ```
86
132
  >
87
- > Hit a permissions error this is common on macOS when Node is installed system-wide. Two ways to fix:
133
+ > Hit EACCES your Node is installed in a system directory that npm can't write to without admin rights. Two fixes:
88
134
  >
89
- > **Option A** (quick, requires sudo password):
90
- > ```sh
91
- > sudo npm install -g memex-mvp
92
- > ```
135
+ > **A)** Quick: `sudo npm install -g memex-mvp` — one-time, requires your password
136
+ >
137
+ > **B)** Permanent fix: I'll move npm's install location to your home directory. After this, no `npm install -g` will ever ask for sudo again, for any package. Five commands, one-time.
138
+ >
139
+ > I recommend **B** — it's a permanent improvement that benefits all your future Node tools, not just memex. **Proceed with B?**
140
+ >
141
+ > **User:** ok
142
+ >
143
+ > **Agent:** Running the 5 prefix-fix commands:
93
144
  >
94
- > **Option B** (one-time setup, then future `npm install -g` works without sudo):
95
145
  > ```sh
96
146
  > mkdir -p ~/.npm-global
97
147
  > npm config set prefix ~/.npm-global
@@ -100,11 +150,7 @@ Sample interactions when the user invokes this skill. These show typical happy p
100
150
  > npm install -g memex-mvp
101
151
  > ```
102
152
  >
103
- > Option B is the standard npm recommendation — solves this for every future global install. Option A is faster if you don't want to touch your shell config. Which do you prefer?
104
- >
105
- > **User:** B
106
- >
107
- > **Agent:** [runs the 5 commands in sequence, shows output for each] ✓ Done. Verifying:
153
+ > Done. Verifying:
108
154
  >
109
155
  > ```
110
156
  > which memex → /Users/macbook/.npm-global/bin/memex
@@ -242,7 +288,7 @@ Sample interactions when the user invokes this skill. These show typical happy p
242
288
 
243
289
  - **Always discover first**, then ask for confirmation before any write action
244
290
  - **Show every command** before running it; show diffs for config edits
245
- - **Offer choices** at decision points (EACCES, daemon yes/no) rather than guessing
291
+ - **Recommend the better default** with a clear reason (e.g. Option B for EACCES) instead of forcing the user to weigh both options blind. Fall back to alternatives only if explicitly chosen.
246
292
  - **Detect and adapt** to existing installs, missing prerequisites, partial setups
247
293
  - **Refuse gracefully** when running in a context where the install can't actually work (web-only agents)
248
294