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 +20 -5
- package/README.md +18 -1
- package/README.ru.md +19 -1
- package/lib/parse-telegram-html.js +384 -0
- package/package.json +2 -2
- package/server.js +109 -4
- package/skills/install-memex/README.md +5 -6
- package/skills/install-memex/SKILL.md +52 -10
- package/skills/install-memex/examples.md +59 -13
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]`
|
|
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 не установлен — `
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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(/ /g, ' ')
|
|
121
|
+
.replace(/&/g, '&')
|
|
122
|
+
.replace(/</g, '<')
|
|
123
|
+
.replace(/>/g, '>')
|
|
124
|
+
.replace(/"/g, '"')
|
|
125
|
+
.replace(/'/g, "'")
|
|
126
|
+
.replace(/'/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.
|
|
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
|
-
/**
|
|
329
|
-
|
|
330
|
-
|
|
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.
|
|
11
|
-
3. **
|
|
12
|
-
4.
|
|
13
|
-
5.
|
|
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
|
|
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.
|
|
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
|
-
##
|
|
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),
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
> **
|
|
90
|
-
>
|
|
91
|
-
>
|
|
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
|
-
>
|
|
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
|
-
- **
|
|
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
|
|