memex-mvp 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HELP.md CHANGED
@@ -227,12 +227,60 @@ Memex по дефолту сортирует по **релевантности**
227
227
 
228
228
  ---
229
229
 
230
+ ### 8. 🔗 Сохранение URL'ов в memex (Perplexity, статьи, AI-share'ы)
231
+
232
+ Ты читаешь что-то — Perplexity research thread, длинную статью, GitHub-обсуждение, AI-chat share — и хочешь чтобы это жило в memex-памяти, искалось из любого AI-чата.
233
+
234
+ **В любом MCP-агенте (Claude Code, Cursor, Cline, Continue, Zed):**
235
+
236
+ ```
237
+ Сохрани https://perplexity.ai/share/<id> в memex
238
+ Добавь эту статью в memex: https://example.com/great-post
239
+ Захвати этот ChatGPT-разговор: https://chat.openai.com/share/<id>
240
+ ```
241
+
242
+ **Что происходит за кулисами:**
243
+
244
+ 1. Агент сам делает fetch URL'a (через свой WebFetch)
245
+ 2. Если страница защищена Cloudflare (Perplexity, npm.com, Twitter, Medium…) — агент авто-retry через `r.jina.ai` proxy (бесплатный JS-runtime, обходит Cloudflare)
246
+ 3. Агент вызывает `memex_store_document(content, url, title)`
247
+ 4. Memex сохраняет содержимое как conversation с `source: "web"` — ищется через `memex_search` рядом с AI-чатами
248
+
249
+ **Для Perplexity-thread'ов:** thread должен быть **PUBLIC**. В Perplexity: открой thread → Share → toggle "Public link" → скопируй новый URL → дай его агенту. URL из адресной строки браузера (`perplexity.ai/search/<id>`) — это **твой owner-URL, не shareable**.
250
+
251
+ Если забудешь — memex детектит «private» в ответе и agent тебе явно скажет что делать.
252
+
253
+ **Login-walled или paywalled контент не fetch'нется** (NYT subscription, твои приватные ChatGPT-чаты). Для них вставь контент руками:
254
+
255
+ ```
256
+ Сохрани этот текст в memex (название: "..."): <вставь содержимое>
257
+ ```
258
+
259
+ **Tag'и при сохранении** — для последующей фильтрации:
260
+
261
+ ```
262
+ Сохрани https://... в memex, поставь теги "research" и "perplexity"
263
+ ```
264
+
265
+ **Поиск:**
266
+
267
+ ```
268
+ Найди в memex что Perplexity говорил про X на прошлой неделе
269
+ ```
270
+
271
+ `memex_search` возвращает совпадения **и из AI-чатов, и из сохранённых URL'ов** в одном запросе — отсортировано по релевантности или дате.
272
+
273
+ **Memex принципиально НЕ делает outbound network calls.** Fetcher живёт в твоём AI-агенте. Если он использует Jina для обхода Cloudflare — Jina видит URL (но НЕ остальной memex-корпус). Это выбор агента, не memex'a.
274
+
275
+ ---
276
+
230
277
  ## Какие MCP-tools агент может вызвать
231
278
 
232
279
  | Tool | Что делает |
233
280
  |---|---|
234
281
  | `memex_overview` | Снэпшот корпуса: источники, сколько сообщений, последние чаты, статус auto-capture |
235
282
  | `memex_search(query)` | Полнотекстовый поиск (FTS5) с recency boost'ом. Параметры: `project`, `source`, `chat`, `half_life_days`, `expand_match`, `sort` |
283
+ | `memex_store_document(content, url?, title?)` | Сохранить внешний документ (web-страница, AI-chat share, paste) в memex. Агент сам делает fetch, memex хранит verbatim. Учит Jina-трюк для Cloudflare-страниц |
236
284
  | `memex_list_projects` | Список всех проектов с количеством разговоров |
237
285
  | `memex_list_conversations` | Список чатов отсортированных по recency |
238
286
  | `memex_get_conversation(id)` | Полный transcript одного чата |
package/README.md CHANGED
@@ -48,6 +48,24 @@ Or use one-shot `sudo npm install -g memex-mvp`.
48
48
  npx memex-mvp install
49
49
  ```
50
50
 
51
+ ### Install via AI skill (Claude Code / OpenClaw)
52
+
53
+ If you'd rather have an AI agent walk you through everything, drop the
54
+ [install-memex skill](skills/install-memex/) into `~/.claude/skills/`:
55
+
56
+ ```sh
57
+ mkdir -p ~/.claude/skills
58
+ curl -fsSL https://raw.githubusercontent.com/parallelclaw/memex-mvp/main/skills/install-memex/SKILL.md \
59
+ -o ~/.claude/skills/install-memex/SKILL.md
60
+ ```
61
+
62
+ Then in Claude Code (or any Skills-aware agent) just say:
63
+
64
+ > install memex
65
+
66
+ …or `/install-memex`. The agent handles `npm install`, MCP-config wiring,
67
+ auto-capture daemon, and verification — ~2 minutes.
68
+
51
69
  ---
52
70
 
53
71
  ## Connect to your MCP client
package/README.ru.md CHANGED
@@ -105,6 +105,22 @@ sudo npm install -g memex-mvp
105
105
 
106
106
  После установки `memex-sync install` поднимет фоновый daemon (`~/.memex/{inbox,data}/` создадутся автоматически при первом запуске).
107
107
 
108
+ ### Установка через AI-скилл (Claude Code / OpenClaw)
109
+
110
+ Если хочешь чтобы агент сам всё сделал — закинь [install-memex skill](skills/install-memex/) в `~/.claude/skills/`:
111
+
112
+ ```bash
113
+ mkdir -p ~/.claude/skills
114
+ curl -fsSL https://raw.githubusercontent.com/parallelclaw/memex-mvp/main/skills/install-memex/SKILL.md \
115
+ -o ~/.claude/skills/install-memex/SKILL.md
116
+ ```
117
+
118
+ Затем в Claude Code (или любом Skills-aware агенте) скажи:
119
+
120
+ > установи memex
121
+
122
+ …или `/install-memex`. Агент сам сделает `npm install`, пропишет MCP-config, поднимет daemon и проверит что всё работает — ~2 минуты.
123
+
108
124
  ### Подключение к Claude Code
109
125
 
110
126
  Сначала возьми **два абсолютных пути** в терминале:
@@ -0,0 +1,116 @@
1
+ /**
2
+ * URL canonicalization for stable deduplication of stored web documents.
3
+ *
4
+ * Goal: two URLs that point to "the same document" should map to the same
5
+ * canonical form, so memex_store_document gives them the same conversation_id
6
+ * via sha256(canonical).
7
+ *
8
+ * What we normalize:
9
+ * - Lowercase scheme + host
10
+ * - Strip known tracking params (utm_*, fbclid, gclid, ref, mc_*, _ga, …)
11
+ * - Drop the fragment (#anchor) — same document
12
+ * - Normalize trailing slash on pathname
13
+ *
14
+ * What we DON'T normalize:
15
+ * - Path case (some servers are case-sensitive)
16
+ * - Non-tracking query params (?q= search, ?id= permalinks — meaningful)
17
+ * - Port (rare in public URLs)
18
+ *
19
+ * If the input isn't a valid URL, we return the input unchanged. Callers
20
+ * should still hash the result for deduplication.
21
+ */
22
+
23
+ // Well-known tracking-param families. Case-insensitive prefix match.
24
+ const TRACKING_PREFIXES = [
25
+ 'utm_', // Google Analytics
26
+ 'mc_', // Mailchimp
27
+ ];
28
+ const TRACKING_EXACT = new Set([
29
+ 'fbclid', // Facebook
30
+ 'gclid', // Google ads
31
+ 'dclid', // Google DoubleClick
32
+ 'gbraid', // Google
33
+ 'wbraid', // Google
34
+ 'yclid', // Yandex
35
+ 'msclkid', // Microsoft ads
36
+ 'twclid', // Twitter
37
+ 'igshid', // Instagram
38
+ 'ref', // generic referrer
39
+ 'ref_source',
40
+ 'ref_url',
41
+ 'referrer',
42
+ 'source', // common referrer flag (NOT always tracking but very often)
43
+ '_ga', // Google Analytics
44
+ '_gl', // Google Analytics linker
45
+ 'hsCtaTracking',
46
+ 'hsenc',
47
+ 'hsmi',
48
+ 'mkt_tok',
49
+ 'pk_campaign',
50
+ 'pk_source',
51
+ 'pk_medium',
52
+ 'pk_keyword',
53
+ 'pk_content',
54
+ 'vero_id',
55
+ 'vero_conv',
56
+ ]);
57
+
58
+ function isTrackingParam(name) {
59
+ const lower = name.toLowerCase();
60
+ if (TRACKING_EXACT.has(lower)) return true;
61
+ for (const prefix of TRACKING_PREFIXES) {
62
+ if (lower.startsWith(prefix)) return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * @param {string} rawUrl
69
+ * @returns {string} canonicalized URL (or the input unchanged if unparseable)
70
+ */
71
+ export function canonicalize(rawUrl) {
72
+ if (typeof rawUrl !== 'string' || !rawUrl.trim()) return rawUrl;
73
+
74
+ let u;
75
+ try {
76
+ u = new URL(rawUrl.trim());
77
+ } catch (_) {
78
+ return rawUrl.trim();
79
+ }
80
+
81
+ // Lowercase scheme + host (URL parser already does that, but be explicit)
82
+ u.protocol = u.protocol.toLowerCase();
83
+ u.hostname = u.hostname.toLowerCase();
84
+
85
+ // Drop the fragment
86
+ u.hash = '';
87
+
88
+ // Strip tracking params
89
+ const cleanParams = new URLSearchParams();
90
+ for (const [k, v] of u.searchParams) {
91
+ if (!isTrackingParam(k)) cleanParams.append(k, v);
92
+ }
93
+ u.search = cleanParams.toString();
94
+
95
+ // Normalize trailing slash: drop trailing slash on non-root paths,
96
+ // so /foo and /foo/ are treated as the same document
97
+ if (u.pathname.length > 1 && u.pathname.endsWith('/')) {
98
+ u.pathname = u.pathname.replace(/\/+$/, '');
99
+ }
100
+
101
+ return u.toString();
102
+ }
103
+
104
+ /**
105
+ * Best-effort domain extraction for metadata (e.g. "perplexity.ai").
106
+ * Returns null for unparseable URLs.
107
+ */
108
+ export function extractDomain(rawUrl) {
109
+ if (typeof rawUrl !== 'string') return null;
110
+ try {
111
+ const u = new URL(rawUrl);
112
+ return u.hostname.toLowerCase().replace(/^www\./, '');
113
+ } catch (_) {
114
+ return null;
115
+ }
116
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Pattern detection for memex_store_document.
3
+ *
4
+ * When the agent passes content to memex_store_document, memex sniffs it
5
+ * for known failure signatures (Cloudflare challenge, Perplexity-private,
6
+ * paywalls, …) and returns actionable warnings.
7
+ *
8
+ * Each detector returns either null or an object:
9
+ * { type, blocking, message }
10
+ *
11
+ * `blocking: true` → memex returns stored:false to the agent. Use only for
12
+ * clear-cut failures where storing the content would pollute the corpus.
13
+ * `blocking: false` → memex stores the content but appends the warning so
14
+ * the agent can decide whether to surface it to the user.
15
+ *
16
+ * Patterns may grow over time as new failure modes appear in real use.
17
+ * Single-purpose regexes — order matters (more specific first).
18
+ */
19
+
20
+ const CLOUDFLARE_PATTERNS = [
21
+ /Just a moment\.\.\./i,
22
+ /Verifying you are human/i,
23
+ /cf-turnstile/i,
24
+ /cf_chl_opt/i,
25
+ /__cf_bm/i, // Cloudflare's bot-management cookie name in challenge pages
26
+ /Checking your browser before accessing/i,
27
+ ];
28
+
29
+ const PERPLEXITY_PRIVATE_PATTERNS = [
30
+ /This thread is private/i,
31
+ /Sign in if you are the owner of this thread/i,
32
+ ];
33
+
34
+ const LOGIN_REQUIRED_PATTERNS = [
35
+ /Sign in to continue/i,
36
+ /Please log in to continue/i,
37
+ /Login required/i,
38
+ /<form[^>]+action=["']\/?(login|signin|sign-in|auth)/i,
39
+ /You must be logged in/i,
40
+ ];
41
+
42
+ const PAYWALL_PATTERNS = [
43
+ /Subscribe to (read|continue)/i,
44
+ /Continue reading with subscription/i,
45
+ /\bpaywall\b/i,
46
+ /You've reached your free article limit/i,
47
+ /metered access/i,
48
+ /Become a (member|subscriber)/i,
49
+ ];
50
+
51
+ /**
52
+ * Detect a Cloudflare bot-challenge page. Blocking.
53
+ */
54
+ function detectCloudflareChallenge(content, _url) {
55
+ for (const re of CLOUDFLARE_PATTERNS) {
56
+ if (re.test(content)) {
57
+ return {
58
+ type: 'cloudflare-challenge',
59
+ blocking: true,
60
+ message:
61
+ 'The content you passed looks like a Cloudflare bot-challenge page, not the actual content. The page is protected by Cloudflare and your direct fetch was blocked.\n\n' +
62
+ 'Retry by prepending https://r.jina.ai/ to the URL:\n\n' +
63
+ ' WebFetch("https://r.jina.ai/" + originalUrl)\n\n' +
64
+ 'Jina AI Reader runs a real browser server-side and bypasses Cloudflare. Returns clean markdown. Free for personal use, no API key needed.\n\n' +
65
+ 'If Jina ALSO fails or returns "private/login required", the page needs authentication — Jina can\'t bypass that.',
66
+ };
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Detect a private Perplexity thread. Blocking — Jina can't help here,
74
+ * the user needs to make the thread public first.
75
+ */
76
+ function detectPerplexityPrivate(content, url) {
77
+ // Only flag if we have a URL hint that it's Perplexity, OR if the message
78
+ // text is unambiguously Perplexity's phrasing.
79
+ const isPerplexityUrl =
80
+ typeof url === 'string' && /perplexity\.ai/i.test(url);
81
+
82
+ let matched = false;
83
+ for (const re of PERPLEXITY_PRIVATE_PATTERNS) {
84
+ if (re.test(content)) {
85
+ matched = true;
86
+ break;
87
+ }
88
+ }
89
+ if (!matched) return null;
90
+ if (!isPerplexityUrl && !/perplexity/i.test(content)) {
91
+ // Same phrasing might appear on other sites — only act if we're confident
92
+ return null;
93
+ }
94
+
95
+ return {
96
+ type: 'perplexity-private',
97
+ blocking: true,
98
+ message:
99
+ 'This Perplexity thread is marked private — even Jina Reader can\'t access it (this is an authentication wall, not Cloudflare bot protection).\n\n' +
100
+ 'Tell the user: "To save this Perplexity thread to memex, you need to make it public first:\n' +
101
+ ' 1. Open the thread in Perplexity\n' +
102
+ ' 2. Click Share (top right)\n' +
103
+ ' 3. Toggle \'Public link\' on\n' +
104
+ ' 4. Copy the new shareable URL Perplexity shows\n' +
105
+ ' 5. Send me THAT URL — it\'ll work"\n\n' +
106
+ 'The URL in the user\'s address bar (perplexity.ai/search/<id>) is the owner\'s private URL, not the shareable one.',
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Suspiciously short content from a URL that should be substantive.
112
+ * Non-blocking — we store it, but warn.
113
+ */
114
+ function detectSuspiciouslySmall(content, url) {
115
+ const trimmed = (content || '').trim();
116
+ // Threshold: documents shorter than 200 chars are almost certainly noise
117
+ // (error pages, redirects, JS-only stubs). Pasted snippets can legitimately
118
+ // be that short, so only flag when we have a URL (suggesting a fetch was
119
+ // attempted) — pastes get a free pass.
120
+ if (!url) return null;
121
+ if (trimmed.length >= 200) return null;
122
+ return {
123
+ type: 'suspiciously-small',
124
+ blocking: false,
125
+ message:
126
+ `The content you passed is very short (${trimmed.length} chars). ` +
127
+ 'The page might have been blocked, redirect-failed, or be JS-rendered with no SSR. ' +
128
+ 'Stored as-is — consider verifying with the user that this is what they expected.',
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Login required (form / prompt). Non-blocking but worth flagging.
134
+ */
135
+ function detectLoginRequired(content, _url) {
136
+ for (const re of LOGIN_REQUIRED_PATTERNS) {
137
+ if (re.test(content)) {
138
+ return {
139
+ type: 'login-required',
140
+ blocking: false,
141
+ message:
142
+ 'The page appears to require login (sign-in prompt / login form detected). ' +
143
+ 'The content you stored may be a login page, not the actual content the user wanted. ' +
144
+ 'Ask the user to paste the content manually if this isn\'t what they expected.',
145
+ };
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Paywall / subscription-gated content. Non-blocking.
153
+ */
154
+ function detectPaywalled(content, _url) {
155
+ for (const re of PAYWALL_PATTERNS) {
156
+ if (re.test(content)) {
157
+ return {
158
+ type: 'paywalled',
159
+ blocking: false,
160
+ message:
161
+ 'The page appears to be paywalled (subscription/payment prompt detected). ' +
162
+ 'The content stored may just be the teaser. ' +
163
+ 'If the user has full access, they can paste the complete article manually.',
164
+ };
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * Returns array of warnings sorted with blocking warnings first.
172
+ * If the first warning is blocking, memex should refuse the store
173
+ * and return that warning to the agent.
174
+ *
175
+ * Detectors run in this order (more-specific first):
176
+ * 1. cloudflare-challenge (blocking)
177
+ * 2. perplexity-private (blocking)
178
+ * 3. suspiciously-small (non-blocking)
179
+ * 4. login-required (non-blocking)
180
+ * 5. paywalled (non-blocking)
181
+ */
182
+ export function detectIssues(content, url) {
183
+ const safeContent = typeof content === 'string' ? content : '';
184
+ const warnings = [];
185
+
186
+ // Blocking first — stop on first hit so we surface the most actionable.
187
+ const blocking =
188
+ detectCloudflareChallenge(safeContent, url) ||
189
+ detectPerplexityPrivate(safeContent, url);
190
+ if (blocking) {
191
+ warnings.push(blocking);
192
+ return warnings;
193
+ }
194
+
195
+ // Non-blocking — collect all that match.
196
+ for (const fn of [detectSuspiciouslySmall, detectLoginRequired, detectPaywalled]) {
197
+ const w = fn(safeContent, url);
198
+ if (w) warnings.push(w);
199
+ }
200
+
201
+ return warnings;
202
+ }
203
+
204
+ /**
205
+ * Convenience: is any warning blocking?
206
+ */
207
+ export function isBlocked(warnings) {
208
+ return Array.isArray(warnings) && warnings.some((w) => w.blocking);
209
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Extract a title from fetched page content.
3
+ *
4
+ * Strategy (first hit wins):
5
+ * 1. Markdown H1 — `# Title text` (Jina Reader's output starts with this)
6
+ * 2. HTML <title> — `<title>Page Title</title>`
7
+ * 3. HTML <h1> — `<h1>Page Title</h1>`
8
+ * 4. First non-empty line if short enough to look like a title
9
+ * 5. URL slug fallback — last meaningful path segment, decoded
10
+ * 6. Domain fallback — just the domain name
11
+ * 7. "Untitled document"
12
+ *
13
+ * Returns a trimmed string up to MAX_LEN characters. Always returns a
14
+ * non-empty string (worst case "Untitled document").
15
+ */
16
+
17
+ const MAX_LEN = 200;
18
+
19
+ function trimTitle(s) {
20
+ if (!s) return '';
21
+ let t = String(s).replace(/\s+/g, ' ').trim();
22
+ if (t.length > MAX_LEN) t = t.slice(0, MAX_LEN).trim() + '…';
23
+ return t;
24
+ }
25
+
26
+ function fromMarkdownH1(content) {
27
+ // Markdown H1: line starts with single # then space, then text.
28
+ // Use \r? for cross-platform line endings. Stop at end-of-line.
29
+ const m = content.match(/^[ \t]*#[ \t]+([^\r\n]+?)[ \t]*$/m);
30
+ return m ? trimTitle(m[1]) : '';
31
+ }
32
+
33
+ function fromHtmlTitle(content) {
34
+ const m = content.match(/<title[^>]*>([^<]+)<\/title>/i);
35
+ return m ? trimTitle(decodeEntities(m[1])) : '';
36
+ }
37
+
38
+ function fromHtmlH1(content) {
39
+ // Inner text only — strip nested tags like <span>...</span>
40
+ const m = content.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
41
+ if (!m) return '';
42
+ const inner = m[1].replace(/<[^>]+>/g, '');
43
+ return trimTitle(decodeEntities(inner));
44
+ }
45
+
46
+ function fromFirstLine(content) {
47
+ // First non-empty line, but only if it looks like a heading
48
+ // (short-ish, no markdown junk).
49
+ const lines = content.split(/\r?\n/);
50
+ for (const raw of lines) {
51
+ const line = raw.trim();
52
+ if (!line) continue;
53
+ // Skip leading markdown decorators / metadata
54
+ if (/^[#\-=*>|`]/.test(line)) continue;
55
+ if (line.length > 0 && line.length <= 120) {
56
+ return trimTitle(line);
57
+ }
58
+ // First substantive line is too long — give up on this strategy
59
+ break;
60
+ }
61
+ return '';
62
+ }
63
+
64
+ function fromUrlSlug(rawUrl) {
65
+ if (!rawUrl) return '';
66
+ try {
67
+ const u = new URL(rawUrl);
68
+ // Last meaningful path segment
69
+ const segs = u.pathname.split('/').filter(Boolean);
70
+ if (segs.length) {
71
+ const slug = decodeURIComponent(segs[segs.length - 1])
72
+ .replace(/[-_]+/g, ' ')
73
+ .replace(/\.(html?|md|pdf|txt)$/i, '')
74
+ .trim();
75
+ if (slug) return trimTitle(slug);
76
+ }
77
+ // No useful path — fall through to domain
78
+ return trimTitle(u.hostname.replace(/^www\./, ''));
79
+ } catch (_) {
80
+ return '';
81
+ }
82
+ }
83
+
84
+ // Minimal HTML-entity decode for &amp; &lt; &gt; &quot; &apos; &#39; &#nnn;
85
+ function decodeEntities(s) {
86
+ if (!s) return s;
87
+ return String(s)
88
+ .replace(/&amp;/g, '&')
89
+ .replace(/&lt;/g, '<')
90
+ .replace(/&gt;/g, '>')
91
+ .replace(/&quot;/g, '"')
92
+ .replace(/&apos;/g, "'")
93
+ .replace(/&#39;/g, "'")
94
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) =>
95
+ String.fromCodePoint(parseInt(hex, 16))
96
+ )
97
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)));
98
+ }
99
+
100
+ /**
101
+ * @param {string} content - fetched page content
102
+ * @param {string|null} url - source URL (used for slug fallback)
103
+ * @returns {string} a non-empty trimmed title
104
+ */
105
+ export function extractTitle(content, url) {
106
+ const safe = typeof content === 'string' ? content : '';
107
+
108
+ return (
109
+ fromMarkdownH1(safe) ||
110
+ fromHtmlTitle(safe) ||
111
+ fromHtmlH1(safe) ||
112
+ fromFirstLine(safe) ||
113
+ fromUrlSlug(url) ||
114
+ 'Untitled document'
115
+ );
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memex-mvp",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
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",
@@ -16,6 +16,7 @@
16
16
  "ingest.js",
17
17
  "lib/",
18
18
  "bot/",
19
+ "skills/",
19
20
  "HELP.md",
20
21
  "README.md",
21
22
  "LICENSE"
@@ -25,7 +26,7 @@
25
26
  "sync": "node ingest.js",
26
27
  "ingest": "node ingest.js",
27
28
  "bot": "node bot/index.js",
28
- "test": "node test/parser.test.js && node test/bot-inbox.test.js && node test/search-sort.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",
29
30
  "prepublishOnly": "npm test"
30
31
  },
31
32
  "engines": {