klypix-mcp 1.6.0 → 1.7.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 CHANGED
@@ -52,9 +52,29 @@ notes into a board,"* or *"add a card with the decision we just made."*
52
52
  | `list_canvases` | List every `.klypix` in the vault |
53
53
  | `read_canvas` | Read a canvas as markdown (cards, the connection graph, `[[links]]`, `#tags`) |
54
54
  | `search_canvases` | Search across canvases by name + content |
55
+ | `search_all_brains` | Cross-project memory search across every registered brain |
56
+ | `brain_insights` | Hubs, orphaned decisions, stale questions, area sizes |
57
+ | `brain_connect` | Find + draw related-but-unlinked cards (densify the graph) |
58
+ | `brain_reconcile` | Flag committed-but-unrecorded DB migrations (the brain can't see prod) |
55
59
  | `create_canvas` | Create a new `.klypix` from cards + connections |
56
60
  | `add_to_canvas` | Append cards/connections to an existing canvas (positions preserved) |
57
61
 
62
+ ### Tools vs. the *automatic* brain
63
+
64
+ This package is the **agent-neutral read/write surface** — any MCP client (Claude
65
+ Code, Claude Desktop, Cursor, Cline, Windsurf…) gets the **tools** above and can
66
+ read, search, and write canvases on demand (*pull*). That works in any agent, in
67
+ any project.
68
+
69
+ The **automatic** brain — auto-capturing decisions from your work, injecting the
70
+ relevant cards into each prompt, and coordinating across concurrent sessions
71
+ (*push*) — runs in a host **hook**, which is a Claude Code / KLYPIX-desktop
72
+ feature, not part of this npm package. So `npx klypix-mcp` gives you the tools
73
+ everywhere; the hands-free brain comes with the [KLYPIX desktop app](https://klypix.com)
74
+ or the Claude Code project-brain hook. (`search_all_brains` is also hook-fed — it
75
+ reads the cross-project registry the hook writes, so it stays empty until a hook
76
+ has registered at least one brain.)
77
+
58
78
  ## Also speaks A2A (Agent-to-Agent)
59
79
 
60
80
  The same engine is exposed as an **A2A agent** so other agents and orchestrators
@@ -23,7 +23,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
23
23
  import {
24
24
  resolveVault, getEmbedder, buildKlypixMap, cardSchema, connSchema,
25
25
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
26
- opBrainInsights, opBrainConnect, opBrainReconcile, opCreateCanvas, opAddToCanvas, opBrainNote,
26
+ opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote,
27
27
  } from '../src/klypix-core.mjs';
28
28
 
29
29
  // IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
@@ -121,6 +121,19 @@ server.registerTool('brain_reconcile', {
121
121
  },
122
122
  }, async ({ canvas, root }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root })));
123
123
 
124
+ server.registerTool('brain_garden', {
125
+ title: 'Garden the brain — consolidate over-grown areas (sleep-time compute)',
126
+ description: 'Tidy an over-grown brain WITHOUT losing anything — SMART and non-invasive: it only consolidates DORMANT cards (old + peripheral), never load-bearing ones. Two phases: call it with no apply to get the areas that have accumulated forgotten cards (deterministic: >3 cards that are older than 14 days, beyond the area\'s newest 8, AND have ≤1 connection — so hubs and still-referenced decisions are left untouched; Focus/Instructions/Archive/Open-questions areas protected) plus their card text; YOU write one tight synthesis per area; then call again with apply:true and syntheses:[{title, synthesis}]. Each area gets a 🌿 synthesis card, the originals are stamped "⤵ consolidated", moved to Archive, and arrowed to the synthesis — nothing is deleted, and one undo un-gardens. Run it when brain_insights or the brief shows an area has grown noisy.',
127
+ inputSchema: {
128
+ canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
129
+ apply: z.boolean().optional().describe('false (default) = list over-grown areas + cards to synthesize; true = consolidate using the supplied syntheses.'),
130
+ syntheses: z.array(z.object({
131
+ title: z.string().describe('Area title EXACTLY as returned by the dry run.'),
132
+ synthesis: z.string().describe('3-6 sentence prose synthesis preserving every still-relevant fact/decision/number.'),
133
+ })).optional().describe('Required when apply:true — one entry per area you want consolidated.'),
134
+ },
135
+ }, async ({ canvas, apply, syntheses }) => toContent(await opBrainGarden({ vault: VAULT, canvas, apply, syntheses })));
136
+
124
137
  server.registerTool('create_canvas', {
125
138
  title: 'Create a KLYPIX canvas',
126
139
  description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in KLYPIX (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.7.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",
@@ -25,6 +25,7 @@ import {
25
25
  parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown,
26
26
  brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
27
27
  findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
28
+ selectGardenCandidates, applyGarden,
28
29
  } from './klypix-format.mjs';
29
30
 
30
31
  // ── Card / connection input shape (single source for every face) ─────────────
@@ -374,6 +375,40 @@ export async function opBrainReconcile({ vault, canvas, root }) {
374
375
  return { blocks: [text(`# ⚠️ ${total} migration(s) committed but unrecorded in the brain\n_The brain can't see prod — it flags migrations that are in git but unmentioned, so you can confirm the rollout. It never asserts a migration was applied. To dismiss one without applying, record any card that names it (e.g. "committed, not applied")._\n\n${lines.join('\n')}${more}`)] };
375
376
  }
376
377
 
378
+ // ── Brain gardener (two-phase: select → agent synthesizes → apply) ───────────
379
+ // The portable /garden. Dry-run returns the over-grown areas + their old cards
380
+ // for the CALLING agent to synthesize (the engine is pure — the model writes the
381
+ // prose); apply consolidates each area into a 🌿 card and archives the originals
382
+ // with audit arrows. Mirrors brain_connect's dry-run/apply discipline.
383
+ export async function opBrainGarden({ vault, canvas, apply = false, syntheses }) {
384
+ const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
385
+ if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
386
+ let struct;
387
+ try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
388
+ const areas = selectGardenCandidates(struct);
389
+ if (!areas.length) return { blocks: [text('Nothing to garden — no area has 3+ DORMANT cards (old, beyond its newest 8, AND peripheral/≤1 link). Anything still woven into the graph is protected. The brain is tidy.')] };
390
+
391
+ if (!apply) {
392
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
393
+ const body = areas.map(a => `## ${a.title} (${a.candidates.length} dormant cards)\n` + a.candidates.map(c => `- ${flat(c.text).slice(0, 240)}`).join('\n')).join('\n\n');
394
+ return { blocks: [text(`# 🌿 Gardener — ${areas.length} area(s) with DORMANT cards to consolidate\nThese are old, peripheral (≤1 link) cards only — hubs and still-referenced decisions were left untouched. For EACH area below, write ONE tight synthesis (3-6 sentences, plain prose, no headers) that preserves every still-relevant fact / decision / number and drops only repetition + play-by-play. Then call \`brain_garden\` again with \`apply:true\` and \`syntheses: [{ "title": "<area title EXACTLY as shown>", "synthesis": "<text>" }, …]\`. Originals are archived with audit arrows — nothing is deleted; one undo un-gardens.\n\n${body}`)] };
395
+ }
396
+
397
+ if (!Array.isArray(syntheses) || !syntheses.length) return err('apply:true needs syntheses:[{title, synthesis}, …] — run the dry run first (apply omitted) to get the areas + their cards.');
398
+ try {
399
+ const { buffer, stats } = await applyGarden(fs.readFileSync(file), { syntheses });
400
+ const skippedNote = (stats.skipped && stats.skipped.length)
401
+ ? `\n\n⚠️ Left untouched (faithfulness guard): ${stats.skipped.map(s => `"${s.title}" — ${s.reason}`).join('; ')}.`
402
+ : '';
403
+ if (!stats.synthCards) return { blocks: [text(`No areas consolidated — each synthesis \`title\` must match a dry-run area title exactly.${skippedNote}`)] };
404
+ let out = buffer; try { out = (await tidyBrain(buffer)).buffer; } catch { /* keep apply result if tidy fails */ }
405
+ await atomicWrite(file, out);
406
+ return { blocks: [text(`🌿 Gardened ${stats.areas} area(s): ${stats.archived} old card(s) → ${stats.synthCards} synthesis card(s); originals archived with "consolidated into" arrows (any prose-dropped figures appended verbatim). Reopen the brain in KLYPIX to see it.${skippedNote}`)] };
407
+ } catch (e) {
408
+ return err(`Garden apply failed (brain unchanged): ${e.message}`);
409
+ }
410
+ }
411
+
377
412
  export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, log = () => {} }) {
378
413
  const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
379
414
  if (!file) return err(`No brain canvas found in ${vault}.`);
@@ -1200,6 +1200,175 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1200
1200
  return { buffer: work, stats };
1201
1201
  }
1202
1202
 
1203
+ // ── Brain gardener — sleep-time consolidation with a visible audit trail ─────
1204
+ // The portable engine twin of the in-app /garden (so ANY agent can run it over
1205
+ // MCP, not just the KLYPIX canvas). Two phases, like brain_connect: the engine
1206
+ // SELECTS deterministically (the model never decides WHAT merges, only writes the
1207
+ // prose), the agent writes one synthesis per area, then the engine APPLIES — each
1208
+ // area gets a 🌿 synthesis card; the originals are stamped "⤵ consolidated", moved
1209
+ // to Archive, and arrowed → the synthesis. Nothing is deleted (archived verbatim).
1210
+ const GARDEN_KEEP_NEWEST = 8; // per area, never consolidate the newest N
1211
+ const GARDEN_MIN_AGE_DAYS = 14; // only cards older than this are candidates
1212
+ const GARDEN_MIN_CANDIDATES = 3; // don't bother merging fewer than this
1213
+ const GARDEN_MAX_DEGREE = 1; // SMART guard: protect load-bearing cards —
1214
+ // only consolidate cards with ≤ this many connections (orphans + leaves). A
1215
+ // card the graph leans on (degree ≥ 2) is signal, not noise, and is left alone.
1216
+ // Areas the gardener must never touch: human steering + config + its own output.
1217
+ const GARDEN_PROTECTED = /^(archive|📌?\s*focus|(🤖\s*)?(agent\s+)?instructions|open questions|pending)/i;
1218
+ // Faithfulness guard: a synthesis shorter than this (after whitespace-collapse)
1219
+ // is treated as degenerate (model returned a stub) and its area is skipped.
1220
+ const MIN_SYNTHESIS_CHARS = 60;
1221
+ // Distinct "figures" worth never losing — tokens carrying ≥2 digits (versions
1222
+ // 1.3.7, dates 2026-06-24, sizes 50mb, counts 326, migration ids). Trivial single
1223
+ // digits (1, 3) are ignored. Used to append any prose-dropped figure verbatim.
1224
+ const figuresIn = (text) => {
1225
+ const out = new Set();
1226
+ for (const m of String(text || '').matchAll(/[0-9][0-9a-zA-Z._:-]*/g)) {
1227
+ const tok = m[0].replace(/[._:-]+$/, '').toLowerCase();
1228
+ if ((tok.match(/\d/g) || []).length >= 2) out.add(tok);
1229
+ }
1230
+ return out;
1231
+ };
1232
+
1233
+ // Deterministic candidate selection — PURE, so the model never chooses WHAT to
1234
+ // merge. SMART + non-invasive: a card is a candidate only if it's DORMANT —
1235
+ // old (> minAgeDays), beyond the area's newest N, AND peripheral (connection
1236
+ // degree ≤ maxDegree). That protects hubs and still-referenced cards (the spine
1237
+ // of the brain), so consolidation hits forgotten noise — the same cards
1238
+ // brain_insights flags as orphaned — never load-bearing decisions. Returns each
1239
+ // over-grown area with its dormant cards (oldest first), each tagged with degree.
1240
+ export function selectGardenCandidates(struct, { keepNewest = GARDEN_KEEP_NEWEST, minAgeDays = GARDEN_MIN_AGE_DAYS, minCandidates = GARDEN_MIN_CANDIDATES, maxDegree = GARDEN_MAX_DEGREE, now = Date.now() } = {}) {
1241
+ if (!struct || !Array.isArray(struct.cards)) return [];
1242
+ const cutoff = now - minAgeDays * 86_400_000;
1243
+ // Connection degree per card — both ends of every edge. A card that is linked
1244
+ // to (or links out to) the rest of the graph is structurally load-bearing.
1245
+ const degree = new Map();
1246
+ for (const cn of (struct.connections || [])) {
1247
+ if (cn.fromId) degree.set(cn.fromId, (degree.get(cn.fromId) || 0) + 1);
1248
+ if (cn.toId) degree.set(cn.toId, (degree.get(cn.toId) || 0) + 1);
1249
+ }
1250
+ const out = [];
1251
+ for (const ctn of struct.cards) {
1252
+ if (ctn.type !== 'container') continue;
1253
+ const title = (ctn.title || '').trim();
1254
+ if (!title || GARDEN_PROTECTED.test(title)) continue;
1255
+ const children = struct.cards
1256
+ .filter(c => c.type === 'text' && c.parentId === ctn.id && (c.text || '').trim() && !/⤵|↩|✅/.test(c.text))
1257
+ .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1258
+ const old = children
1259
+ .slice(0, Math.max(0, children.length - keepNewest))
1260
+ .filter(c => (c.createdAt || 0) < cutoff && (degree.get(c.id) || 0) <= maxDegree); // dormant: old AND peripheral
1261
+ if (old.length >= minCandidates) out.push({ containerId: ctn.id, title, candidates: old.map(c => ({ id: c.id, text: c.text, createdAt: c.createdAt || 0, degree: degree.get(c.id) || 0 })) });
1262
+ }
1263
+ return out;
1264
+ }
1265
+
1266
+ // Apply: re-selects deterministically (robust to drift since the dry-run) and,
1267
+ // for each area the agent supplied a synthesis for, adds the 🌿 card + archives
1268
+ // the originals with audit arrows. `syntheses`: [{ title, synthesis }].
1269
+ export async function applyGarden(buffer, { syntheses = [] } = {}) {
1270
+ const stats = { areas: 0, archived: 0, synthCards: 0, skipped: [] };
1271
+ const { zip, canvas, manifest, isV4, struct } = await parseKlypix(buffer);
1272
+ if (!isV4 || !canvas.positions) throw new Error('garden needs a v4 .klypix');
1273
+ const areas = selectGardenCandidates(struct);
1274
+ const synthByTitle = new Map();
1275
+ for (const s of syntheses || []) { const t = String(s?.title || '').trim().toLowerCase(); const txt = String(s?.synthesis || '').trim(); if (t && txt) synthByTitle.set(t, txt); }
1276
+ if (!areas.length || !synthByTitle.size) return { buffer, stats };
1277
+
1278
+ const now = Date.now();
1279
+ const today = new Date(now).toISOString().slice(0, 10);
1280
+ const rand = () => Math.random().toString(36).slice(2, 10);
1281
+ const top = Object.values(canvas.positions).map(p => p && p.zKey).filter(k => k && isValidZKey(k)).sort().pop() || null;
1282
+ const nextZKey = makeZKeyGen(top);
1283
+ canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
1284
+ const byTitle = new Map();
1285
+ for (const c of struct.cards) if (c.type === 'container') { const t = (c.title || '').trim().toLowerCase(); if (t && !byTitle.has(t)) byTitle.set(t, c.id); }
1286
+ // Archive primitives (mirror captureIntoBrain): find-or-create Archive, move a
1287
+ // card into it un-baking any group-shrink, and rewrite a card's text in place.
1288
+ const ensureArchive = () => {
1289
+ let id = byTitle.get('archive');
1290
+ if (id) return id;
1291
+ id = `ctn_${rand()}`;
1292
+ const G = BRAIN_GEOM;
1293
+ zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify({ type: 'container', locked: false, createdAt: now, createdBy: 'agent', title: 'Archive', collapsed: false, scopeLocked: false, borderColor: 'rgba(120,120,135,0.6)' }));
1294
+ canvas.positions[id] = { x: nextContainerX(canvas), y: G.START, w: G.AREA_W, h: G.TITLE_BAR + G.PAD * 2, zKey: nextZKey(), zIndex: canvas.order.length, parentId: null };
1295
+ canvas.order.push(id);
1296
+ byTitle.set('archive', id);
1297
+ return id;
1298
+ };
1299
+ const rewriteCard = async (id, mutate) => {
1300
+ const ip = `items/${shard(id)}/${id}.json`;
1301
+ const f = zip.file(ip); if (!f) return false;
1302
+ const j = JSON.parse(await f.async('string'));
1303
+ mutate(j);
1304
+ j.content = wrapText(String(j.content || ''));
1305
+ zip.file(ip, JSON.stringify(j));
1306
+ const pos = canvas.positions[id];
1307
+ if (pos) canvas.positions[id] = { ...pos, h: measureCardH(j.content) };
1308
+ return true;
1309
+ };
1310
+ const archiveCard = async (id) => {
1311
+ const arc = ensureArchive();
1312
+ let authoredW = null;
1313
+ const ip = `items/${shard(id)}/${id}.json`;
1314
+ const f = zip.file(ip);
1315
+ if (f) {
1316
+ const j = JSON.parse(await f.async('string'));
1317
+ const a = j.authoredInParent;
1318
+ if (a) {
1319
+ if (j.type === 'text' && a.fontSize) j.fontSize = a.fontSize;
1320
+ if (a.authoredWidth != null) j.authoredWidth = a.authoredWidth;
1321
+ authoredW = a.w || null;
1322
+ delete j.authoredInParent;
1323
+ zip.file(ip, JSON.stringify(j));
1324
+ }
1325
+ }
1326
+ const pos = canvas.positions[id];
1327
+ if (pos) canvas.positions[id] = { ...pos, parentId: arc, ...(authoredW ? { w: authoredW } : {}) };
1328
+ };
1329
+
1330
+ for (const area of areas) {
1331
+ const synthesis = synthByTitle.get(area.title.trim().toLowerCase());
1332
+ if (!synthesis) continue; // model skipped this area — leave it untouched
1333
+ const ctnPos = canvas.positions[area.containerId];
1334
+ if (!ctnPos) continue;
1335
+ // FAITHFULNESS GUARD (1) — degeneracy: a synthesis far too thin for the
1336
+ // cards it replaces is rejected; that area is left untouched + reported,
1337
+ // so a one-word "done" can't bury real history. (Originals stay put.)
1338
+ const collapsed = synthesis.replace(/\s+/g, ' ').trim();
1339
+ if (collapsed.length < MIN_SYNTHESIS_CHARS) {
1340
+ stats.skipped.push({ title: area.title, reason: `synthesis too thin (${collapsed.length} chars, need ${MIN_SYNTHESIS_CHARS}) — revise and re-apply` });
1341
+ continue;
1342
+ }
1343
+ // FAITHFULNESS GUARD (2) — figures net: any distinct number (version /
1344
+ // size / date / count) in the originals that the prose dropped is appended
1345
+ // verbatim, so the crispest facts survive on the visible card even if the
1346
+ // synthesis missed them. The originals are archived verbatim regardless.
1347
+ const origFigs = new Set();
1348
+ for (const c of area.candidates) for (const f of figuresIn(c.text)) origFigs.add(f);
1349
+ const synLower = synthesis.toLowerCase();
1350
+ const missing = [...origFigs].filter(f => !synLower.includes(f));
1351
+ const finalSynth = missing.length ? `${synthesis}\n↳ figures: ${missing.slice(0, 10).join(', ')}${missing.length > 10 ? ' …' : ''}` : synthesis;
1352
+ const span = `${new Date(area.candidates[0].createdAt || now).toISOString().slice(0, 10)} → ${new Date(area.candidates[area.candidates.length - 1].createdAt || now).toISOString().slice(0, 10)}`;
1353
+ const content = wrapText(`${area.title}: 🌿 Consolidated history (${span}, ${area.candidates.length} cards)\n${finalSynth}`);
1354
+ const sid = `txt_${rand()}`;
1355
+ zip.file(`items/${shard(sid)}/${sid}.json`, JSON.stringify({ type: 'text', locked: false, createdAt: now, createdBy: 'agent', createdVia: 'gardener', content, fontSize: 12, color: '#e8e8ed', border: true, borderColor: 'rgba(59,130,246,0.6)', heading: false }));
1356
+ canvas.positions[sid] = { x: ctnPos.x + 20, y: ctnPos.y + (ctnPos.h || 0) + 10, w: 300, h: measureCardH(content), zKey: nextZKey(), zIndex: canvas.order.length, parentId: area.containerId };
1357
+ canvas.order.push(sid);
1358
+ stats.synthCards++;
1359
+ for (const cand of area.candidates) {
1360
+ await rewriteCard(cand.id, j => { j.content = `⤵ consolidated ${today}\n${j.content}`; j.borderColor = 'rgba(120,120,135,0.5)'; });
1361
+ await archiveCard(cand.id);
1362
+ canvas.connections.push({ id: `con_${rand()}`, fromId: cand.id, toId: sid, relationship: 'relates_to', label: 'consolidated into', arrowHead: true, width: 1.5, color: 'rgba(120,120,135,0.7)', style: 'solid' });
1363
+ stats.archived++;
1364
+ }
1365
+ stats.areas++;
1366
+ }
1367
+ if (!stats.synthCards) return { buffer, stats };
1368
+ const out = await finalizeBrainZip(zip, canvas, manifest, now);
1369
+ return { buffer: out, stats };
1370
+ }
1371
+
1203
1372
  // ── Stale-open reconcile ("marked open, but a milestone says it's done") ─────
1204
1373
  // The READ-side twin of the closes: write path. An open ❓/🎯 card lingers as
1205
1374
  // "still to do" forever unless someone emits a ✓/closes: for it — so a goal that