klypix-mcp 1.0.4 → 1.1.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/README.md +0 -1
- package/bin/klypix-mcp.mjs +138 -18
- package/package.json +5 -2
- package/src/klypix-format.mjs +1 -0
package/README.md
CHANGED
|
@@ -47,7 +47,6 @@ notes into a board,"* or *"add a card with the decision we just made."*
|
|
|
47
47
|
| `search_canvases` | Search across canvases by name + content |
|
|
48
48
|
| `create_canvas` | Create a new `.klypix` from cards + connections |
|
|
49
49
|
| `add_to_canvas` | Append cards/connections to an existing canvas (positions preserved) |
|
|
50
|
-
| `search_all_brains` | Search every registered project brain (`~/.claude/project-brain/registry.json`) — cross-project recall |
|
|
51
50
|
|
|
52
51
|
## Use it as a library
|
|
53
52
|
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -18,14 +18,39 @@
|
|
|
18
18
|
import fs from 'fs';
|
|
19
19
|
import os from 'os';
|
|
20
20
|
import path from 'path';
|
|
21
|
+
import crypto from 'crypto';
|
|
21
22
|
import { z } from 'zod';
|
|
22
23
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
23
24
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
24
|
-
import { parseKlypix, buildKlypix, appendToKlypix, structToMarkdown, atomicWrite } from '../src/klypix-format.mjs';
|
|
25
|
+
import { parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown, atomicWrite } from '../src/klypix-format.mjs';
|
|
25
26
|
|
|
26
27
|
// IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
|
|
27
28
|
const log = (...a) => console.error('[klypix-mcp]', ...a);
|
|
28
29
|
|
|
30
|
+
// `npx klypix-mcp init` — 60-second onboarding: seed a starter project brain in
|
|
31
|
+
// the current folder so a new user's FIRST contact isn't an empty vault, then
|
|
32
|
+
// print a paste-ready MCP config. Runs before any server setup. (Dormant for
|
|
33
|
+
// the in-app bundled server, which always launches with --vault.)
|
|
34
|
+
if (process.argv[2] === 'init') {
|
|
35
|
+
const target = path.resolve(process.cwd(), 'brain.klypix');
|
|
36
|
+
if (fs.existsSync(target)) { console.error(`brain.klypix already exists in ${process.cwd()} — not overwriting.`); process.exit(0); }
|
|
37
|
+
const buf = await buildKlypixMap({
|
|
38
|
+
title: 'project brain',
|
|
39
|
+
areas: [
|
|
40
|
+
{ title: 'Goal', cards: [{ text: '❓ What is this project for, and for whom?\nAgent: survey the repo on your first session and replace this with the real goal.' }] },
|
|
41
|
+
{ title: 'Architecture', cards: [{ text: '❓ Key components and how they fit.\nAgent: record the actual shape from the repo — only what a new session must know.' }] },
|
|
42
|
+
{ title: 'Decisions', cards: [{ text: 'Decisions land here automatically: agents emit `🧠 BRAIN [Area]: …` markers; a new decision that replaces an old one archives it (superseded). Resolve finished items with `✓`, correct in place with `~`. Drag any card into 📌 Focus to make it lead every session brief.' }] },
|
|
43
|
+
{ title: 'Pending / next', cards: [{ text: 'What is in flight and what comes next. Close finished items with the ✓ marker.' }] },
|
|
44
|
+
{ title: 'Open questions', cards: [{ text: 'Unresolved questions (the ❓ marker) live here — the session brief surfaces them first.' }] },
|
|
45
|
+
{ title: '📌 Focus', cards: [{ text: 'Drag any card into this area to make it lead every session brief — steer your agent by moving cards.' }] },
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
fs.writeFileSync(target, buf);
|
|
49
|
+
const cfg = JSON.stringify({ mcpServers: { 'klypix-canvas': { command: 'npx', args: ['-y', 'klypix-mcp', '--vault', process.cwd().replace(/\\/g, '/')] } } }, null, 2);
|
|
50
|
+
console.error(`✓ Created ${target}\n\nAdd this to your MCP client config (.mcp.json / claude_desktop_config.json):\n\n${cfg}\n\nThen ask your agent to read the canvas "brain" — it now has a project memory.`);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
const vaultArgIdx = process.argv.indexOf('--vault');
|
|
30
55
|
const VAULT = path.resolve(
|
|
31
56
|
vaultArgIdx >= 0 ? process.argv[vaultArgIdx + 1]
|
|
@@ -179,46 +204,135 @@ server.registerTool('search_canvases', {
|
|
|
179
204
|
return { content: [{ type: 'text', text: hits.length ? `# Matches for "${query}"\n\n${hits.join('\n\n')}` : `No matches for "${query}" in ${VAULT}.` }] };
|
|
180
205
|
});
|
|
181
206
|
|
|
207
|
+
// ── On-device semantic memory ────────────────────────────────────────────────
|
|
208
|
+
// Embeddings run INSIDE this long-lived server (the hook stays instant), 100%
|
|
209
|
+
// local: transformers.js (WASM) + a 23MB MiniLM model cached under
|
|
210
|
+
// ~/.claude/project-brain/hf-cache on first use. Per-brain vectors are cached
|
|
211
|
+
// incrementally (content-hashed per card) in ~/.claude/project-brain/embeddings/
|
|
212
|
+
// — brains themselves are never mutated by search. Everything degrades to
|
|
213
|
+
// lexical scoring gracefully: no lib, no model, no network → search still works.
|
|
214
|
+
const PB_DIR = path.join(os.homedir(), '.claude', 'project-brain');
|
|
215
|
+
const EMB_DIR = path.join(PB_DIR, 'embeddings');
|
|
216
|
+
const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
217
|
+
let embedderPromise = null;
|
|
218
|
+
function getEmbedder() {
|
|
219
|
+
if (!embedderPromise) {
|
|
220
|
+
embedderPromise = (async () => {
|
|
221
|
+
// Dual-path: (1) bare specifier — npx/npm installs ship the lib;
|
|
222
|
+
// (2) ~/.claude/project-brain/semantic — where KLYPIX's one-click
|
|
223
|
+
// "semantic memory" install places it for the bundled server
|
|
224
|
+
// (the ONNX runtimes are ~350MB unpacked, far too heavy to bundle
|
|
225
|
+
// in the installer payload).
|
|
226
|
+
let t;
|
|
227
|
+
try { t = await import('@huggingface/transformers'); }
|
|
228
|
+
catch {
|
|
229
|
+
const local = path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers', 'dist', 'transformers.mjs');
|
|
230
|
+
t = await import(new URL('file:///' + local.replace(/\\/g, '/')).href);
|
|
231
|
+
}
|
|
232
|
+
t.env.cacheDir = path.join(PB_DIR, 'hf-cache');
|
|
233
|
+
return await t.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
|
|
234
|
+
})().catch(e => { log('semantic unavailable (lexical fallback):', e?.message || e); return null; });
|
|
235
|
+
}
|
|
236
|
+
return embedderPromise;
|
|
237
|
+
}
|
|
238
|
+
async function embedTexts(pipe, texts) {
|
|
239
|
+
const out = await pipe(texts, { pooling: 'mean', normalize: true });
|
|
240
|
+
const [n, d] = out.dims;
|
|
241
|
+
const vecs = [];
|
|
242
|
+
for (let i = 0; i < n; i++) vecs.push(Array.from(out.data.slice(i * d, (i + 1) * d)));
|
|
243
|
+
return vecs;
|
|
244
|
+
}
|
|
245
|
+
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; };
|
|
246
|
+
// Incremental per-brain vector cache: only new/changed cards get embedded.
|
|
247
|
+
async function vectorsForBrain(pipe, brainPath, cards) {
|
|
248
|
+
const file = path.join(EMB_DIR, sha1(brainPath.replace(/\\/g, '/')) + '.json');
|
|
249
|
+
let cache = { v: 1, cards: {} };
|
|
250
|
+
try { cache = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* fresh */ }
|
|
251
|
+
const want = cards.filter(c => c.type !== 'container' && (c.text || '').trim());
|
|
252
|
+
const missing = want.filter(c => cache.cards[c.id]?.h !== sha1(String(c.text)));
|
|
253
|
+
if (missing.length) {
|
|
254
|
+
const vecs = await embedTexts(pipe, missing.map(c => String(c.text).slice(0, 1500)));
|
|
255
|
+
missing.forEach((c, i) => { cache.cards[c.id] = { h: sha1(String(c.text)), v: vecs[i] }; });
|
|
256
|
+
const live = new Set(want.map(c => c.id));
|
|
257
|
+
for (const id of Object.keys(cache.cards)) if (!live.has(id)) delete cache.cards[id];
|
|
258
|
+
try { fs.mkdirSync(EMB_DIR, { recursive: true }); fs.writeFileSync(file, JSON.stringify(cache)); } catch { /* cache is best-effort */ }
|
|
259
|
+
}
|
|
260
|
+
const map = new Map();
|
|
261
|
+
for (const c of want) { const e = cache.cards[c.id]; if (e?.v) map.set(c.id, e.v); }
|
|
262
|
+
return map;
|
|
263
|
+
}
|
|
264
|
+
// Death date of an archived card (for as-of queries): the supersede/resolve stamp.
|
|
265
|
+
const deathDateOf = (text) => { const m = /(?:↩︎ superseded|✅) (\d{4}-\d{2}-\d{2})/.exec(String(text)); return m ? Date.parse(m[1]) : null; };
|
|
266
|
+
|
|
182
267
|
// Cross-project memory: search EVERY brain this machine has touched, not just
|
|
183
268
|
// this vault. The SessionStart/Stop hook registers each ./brain.klypix it runs
|
|
184
269
|
// against into ~/.claude/project-brain/registry.json — so simply having worked
|
|
185
270
|
// in a project makes its decisions findable from any other project ("what did
|
|
186
|
-
// I decide about auth — in ANY project?").
|
|
187
|
-
//
|
|
188
|
-
//
|
|
271
|
+
// I decide about auth — in ANY project?"). Hybrid ranking: on-device semantic
|
|
272
|
+
// similarity (when the local model is ready) blended with lexical term hits;
|
|
273
|
+
// as_of answers "what was true on <date>" via createdAt + supersession stamps.
|
|
189
274
|
server.registerTool('search_all_brains', {
|
|
190
275
|
title: 'Search every project brain on this machine',
|
|
191
|
-
description: 'Cross-project memory search: looks through every brain.klypix this machine has worked with (auto-registered by the brain hook), not just the current vault. Use when the answer may live in ANOTHER project\'s decisions.',
|
|
192
|
-
inputSchema: {
|
|
193
|
-
|
|
276
|
+
description: 'Cross-project memory search: looks through every brain.klypix this machine has worked with (auto-registered by the brain hook), not just the current vault. Semantic (on-device) + lexical hybrid ranking. Use when the answer may live in ANOTHER project\'s decisions. Optional as_of (YYYY-MM-DD) answers "what was true then" — superseded cards count as live if they were current at that date.',
|
|
277
|
+
inputSchema: {
|
|
278
|
+
query: z.string().describe('What to find across all project brains.'),
|
|
279
|
+
as_of: z.string().optional().describe('Optional YYYY-MM-DD: rank what was TRUE at that date (time-travel query).'),
|
|
280
|
+
},
|
|
281
|
+
}, async ({ query, as_of }) => {
|
|
194
282
|
const q = String(query || '').trim().toLowerCase();
|
|
195
283
|
if (!q) return { content: [{ type: 'text', text: 'Provide a non-empty query.' }], isError: true };
|
|
196
|
-
const reg = path.join(
|
|
284
|
+
const reg = path.join(PB_DIR, 'registry.json');
|
|
197
285
|
let brains = [];
|
|
198
286
|
try { brains = (JSON.parse(fs.readFileSync(reg, 'utf8')).brains || []).filter(b => b && b.path); } catch { /* no registry yet */ }
|
|
199
287
|
if (!brains.length) return { content: [{ type: 'text', text: 'No brains registered yet — the brain hook registers each project as you work in it.' }] };
|
|
200
288
|
const terms = q.split(/[^\p{L}\p{N}#]+/u).filter(t => t.length >= 3);
|
|
201
289
|
if (!terms.length) return { content: [{ type: 'text', text: 'Query too short — use words of 3+ characters.' }], isError: true };
|
|
290
|
+
const asOfTs = as_of ? Date.parse(as_of) : null;
|
|
291
|
+
if (as_of && Number.isNaN(asOfTs)) return { content: [{ type: 'text', text: `Bad as_of date: "${as_of}" — use YYYY-MM-DD.` }], isError: true };
|
|
292
|
+
|
|
293
|
+
// Semantic lane: wait briefly for the embedder; first-ever use downloads
|
|
294
|
+
// the model in the background — searches stay lexical until it's warm.
|
|
295
|
+
const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), 20_000))]);
|
|
296
|
+
let qv = null;
|
|
297
|
+
if (pipe) { try { [qv] = await embedTexts(pipe, [q]); } catch { /* lexical only */ } }
|
|
298
|
+
|
|
202
299
|
const fresh = Date.now() - 30 * 86_400_000;
|
|
203
300
|
const scored = [];
|
|
204
301
|
for (const b of brains) {
|
|
205
302
|
let struct;
|
|
206
303
|
try { ({ struct } = await parseKlypix(fs.readFileSync(b.path))); } catch { continue; }
|
|
304
|
+
let vecs = null;
|
|
305
|
+
if (qv) { try { vecs = await vectorsForBrain(pipe, b.path, struct.cards); } catch { /* lexical for this brain */ } }
|
|
207
306
|
for (const c of struct.cards) {
|
|
208
307
|
if (c.type === 'container') continue;
|
|
209
308
|
const text = String(c.text || '').toLowerCase();
|
|
309
|
+
const isArchived = /^archive$/i.test(c.area || '');
|
|
310
|
+
if (asOfTs != null) {
|
|
311
|
+
if ((c.createdAt || 0) > asOfTs) continue; // didn't exist yet
|
|
312
|
+
const died = isArchived ? deathDateOf(c.text) : null;
|
|
313
|
+
if (died != null && died <= asOfTs) continue; // already superseded then
|
|
314
|
+
}
|
|
315
|
+
let lex = 0;
|
|
210
316
|
const title = String(c.title || '').toLowerCase();
|
|
211
317
|
const tags = (c.tags || []).map(t => ('#' + t).toLowerCase());
|
|
212
|
-
let score = 0;
|
|
213
318
|
for (const t of terms) {
|
|
214
|
-
if (title.includes(t))
|
|
215
|
-
if (tags.some(g => g.includes(t)))
|
|
216
|
-
if (text.includes(t))
|
|
319
|
+
if (title.includes(t)) lex += 3;
|
|
320
|
+
if (tags.some(g => g.includes(t))) lex += 2;
|
|
321
|
+
if (text.includes(t)) lex += 1;
|
|
322
|
+
}
|
|
323
|
+
// Floor calibrated on real cards: related ≈ 0.25, unrelated ≈ 0.0
|
|
324
|
+
// (MiniLM, short decision texts) — 0.18 keeps recall with margin.
|
|
325
|
+
const sem = (qv && vecs?.get(c.id)) ? dot(qv, vecs.get(c.id)) : null;
|
|
326
|
+
if (!lex && (sem == null || sem < 0.18)) continue;
|
|
327
|
+
// Hybrid: semantic dominates when available; lexical is the tie-breaker
|
|
328
|
+
// and the only signal pre-warm-up. Recency/archive nudges skipped for
|
|
329
|
+
// time-travel queries (validity already handled above).
|
|
330
|
+
let score = sem != null ? sem * 10 + Math.min(lex, 6) * 0.5 : lex;
|
|
331
|
+
if (asOfTs == null) {
|
|
332
|
+
if ((c.createdAt || 0) >= fresh) score += 0.5;
|
|
333
|
+
if (isArchived) score -= 1;
|
|
217
334
|
}
|
|
218
|
-
|
|
219
|
-
if ((c.createdAt || 0) >= fresh) score += 1; // recency boost
|
|
220
|
-
if (/^archive$/i.test(c.area || '')) score -= 0.5; // superseded ranks lower
|
|
221
|
-
scored.push({ score, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
|
|
335
|
+
scored.push({ score, sem, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
|
|
222
336
|
}
|
|
223
337
|
}
|
|
224
338
|
if (!scored.length) return { content: [{ type: 'text', text: `No matches for "${query}" across ${brains.length} registered brain(s).` }] };
|
|
@@ -228,7 +342,9 @@ server.registerTool('search_all_brains', {
|
|
|
228
342
|
const when = h.c.createdAt ? new Date(h.c.createdAt).toISOString().slice(0, 10) : '';
|
|
229
343
|
return `- [${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
|
|
230
344
|
});
|
|
231
|
-
|
|
345
|
+
const mode = qv ? 'semantic+lexical (on-device)' : 'lexical (semantic model warming — retry for semantic ranking)';
|
|
346
|
+
const asOfNote = asOfTs != null ? ` · as of ${as_of}` : '';
|
|
347
|
+
return { content: [{ type: 'text', text: `# Cross-project matches for "${query}" (${scored.length} hits in ${brains.length} brains, top ${top.length} · ${mode}${asOfNote})\n\n${lines.join('\n')}` }] };
|
|
232
348
|
});
|
|
233
349
|
|
|
234
350
|
// Format the cards (optionally only a set of new ids) + connection graph so an
|
|
@@ -291,7 +407,11 @@ server.registerTool('add_to_canvas', {
|
|
|
291
407
|
// Snapshot existing ids so we can report ONLY the newly-added cards back.
|
|
292
408
|
let beforeIds = new Set();
|
|
293
409
|
try { const b = await parseKlypix(original); beforeIds = new Set(b.struct.cards.map(c => c.id)); } catch { /* new/legacy → treat all as new */ }
|
|
294
|
-
|
|
410
|
+
// Provenance: stamp WHICH agent wrote these cards (cursor / claude /
|
|
411
|
+
// cline — from the MCP client's initialize handshake).
|
|
412
|
+
let via; try { via = server.server.getClientVersion()?.name; } catch { /* optional */ }
|
|
413
|
+
const stamped = via ? cards.map(c => ({ ...c, createdVia: via })) : cards;
|
|
414
|
+
const buf = await appendToKlypix(original, { cards: stamped, connections });
|
|
295
415
|
await atomicWrite(file, buf);
|
|
296
416
|
let detail = '';
|
|
297
417
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "An open, local-first, agent-neutral canvas file your AI reads and writes over MCP — works with Claude, Cursor, Cline, any model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"homepage": "https://klypix.com",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
|
-
"url": "
|
|
22
|
+
"url": "https://github.com/dahshanlabs/klypix-mcp"
|
|
23
23
|
},
|
|
24
24
|
"bin": {
|
|
25
25
|
"klypix-mcp": "bin/klypix-mcp.mjs",
|
|
@@ -48,5 +48,8 @@
|
|
|
48
48
|
"jszip": "^3.10.1",
|
|
49
49
|
"zod": "^4.3.6",
|
|
50
50
|
"fractional-indexing": "^3.2.0"
|
|
51
|
+
},
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"@huggingface/transformers": "^4.2.0"
|
|
51
54
|
}
|
|
52
55
|
}
|
package/src/klypix-format.mjs
CHANGED
|
@@ -302,6 +302,7 @@ export async function appendToKlypix(buffer, addition) {
|
|
|
302
302
|
for (const a of added) {
|
|
303
303
|
zip.file(`items/${shard(a.id)}/${a.id}.json`, JSON.stringify({
|
|
304
304
|
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
305
|
+
...(a.card.createdVia ? { createdVia: String(a.card.createdVia) } : {}),
|
|
305
306
|
content: String(a.card.text), fontSize: FONT,
|
|
306
307
|
color: a.card.color || '#1a1a1f', border: !!a.card.border, borderColor: '#1e1e2e',
|
|
307
308
|
heading: !!a.card.heading, fontFamily: 'Thmanyah Sans',
|