klypix-mcp 1.7.0 → 1.8.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.
@@ -17,6 +17,7 @@
17
17
 
18
18
  import fs from 'fs';
19
19
  import path from 'path';
20
+ import { createRequire } from 'module';
20
21
  import { z } from 'zod';
21
22
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
23
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -26,6 +27,10 @@ import {
26
27
  opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote,
27
28
  } from '../src/klypix-core.mjs';
28
29
 
30
+ // Real package version for the MCP handshake (was hardcoded '1.0.0', which
31
+ // misled every client/version diagnosis — it could never reflect the true release).
32
+ const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version; } catch { return '0.0.0'; } })();
33
+
29
34
  // IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
30
35
  const log = (...a) => console.error('[klypix-mcp]', ...a);
31
36
 
@@ -63,7 +68,7 @@ const toContent = (r) => {
63
68
  return r.isError ? { content, isError: true } : { content };
64
69
  };
65
70
 
66
- const server = new McpServer({ name: 'klypix-canvas', version: '1.0.0' });
71
+ const server = new McpServer({ name: 'klypix-canvas', version: PKG_VERSION });
67
72
 
68
73
  server.registerTool('list_canvases', {
69
74
  title: 'List KLYPIX canvases',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.8.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",
@@ -85,6 +85,52 @@ export function resolveCanvas(vault, ref) {
85
85
  return matches[0] || null;
86
86
  }
87
87
 
88
+ // Resolve the DEFAULT project brain for the brain-* ops, INDEPENDENT of the
89
+ // --vault library folder. The vault answers "where does my .klypix library live"
90
+ // (list/read/search); the BRAIN is "THIS project's ./brain.klypix". Conflating
91
+ // them is what made the brain ops read a stray canvas out of a global vault
92
+ // (the "SS2" bug — a foreign brain.klypix picked by a fuzzy basename walk).
93
+ // Precedence, project-first:
94
+ // 1. KLYPIX_BRAIN env (explicit override)
95
+ // 2. ./brain.klypix in the launch cwd — the project brain (coding agents launch
96
+ // this server with cwd = the project root)
97
+ // 3. <vault>/brain.klypix (exact) — when the vault itself is the brain's home
98
+ // 4. a SINGLE brain.klypix found by walking the vault; if MORE THAN ONE exists
99
+ // we REFUSE to guess (returns { ambiguous }) instead of silently taking one.
100
+ export function resolveDefaultBrain(vault) {
101
+ const ex = (p) => { try { return p && fs.existsSync(p) ? path.resolve(p) : null; } catch { return null; } };
102
+ let f;
103
+ if ((f = ex(process.env.KLYPIX_BRAIN))) return { file: f, how: 'env (KLYPIX_BRAIN)' };
104
+ if ((f = ex(path.join(process.cwd(), 'brain.klypix')))) return { file: f, how: 'project cwd' };
105
+ if ((f = ex(path.join(vault, 'brain.klypix')))) return { file: f, how: 'vault root' };
106
+ const matches = walkVault(vault).filter(p => /^brain\.(klypix|any)$/i.test(path.basename(p)));
107
+ if (matches.length === 1) return { file: matches[0], how: 'vault search' };
108
+ if (matches.length > 1) return { ambiguous: matches };
109
+ return { file: null };
110
+ }
111
+
112
+ // Resolve the brain a brain-* op should act on. An explicit `canvas` arg → exact
113
+ // resolve against the vault; otherwise the project-aware default above. Always
114
+ // returns one of: { file, how } · { ambiguous: [paths] } · { file: null }.
115
+ export function brainTarget(vault, canvas) {
116
+ if (canvas) { const file = resolveCanvas(vault, canvas); return file ? { file, how: `canvas:"${canvas}"` } : { file: null }; }
117
+ return resolveDefaultBrain(vault);
118
+ }
119
+
120
+ // One-line provenance shown atop every brain-op result so a wrong brain (the
121
+ // "SS2" class) is OBVIOUS at a glance instead of silent. Counts non-container cards.
122
+ export function brainStamp(file, struct, how) {
123
+ const title = (struct && struct.title) || path.basename(file);
124
+ const n = struct ? struct.cards.filter(c => c.type !== 'container').length : '?';
125
+ return `_brain: ${path.basename(file)} · “${title}” · ${n} cards${how ? ' · via ' + how : ''}_\n\n`;
126
+ }
127
+
128
+ // Shared error for the refuse-to-guess case — names the candidates so the caller
129
+ // can pick one (canvas:"<path>") or fix the vault / run from the project root.
130
+ function ambiguousBrainErr(matches) {
131
+ return err(`Found ${matches.length} brain.klypix files in the vault and no ./brain.klypix in the current project — refusing to guess which is "the brain". Pass canvas:"<path>", run from the project root, or set KLYPIX_BRAIN. Candidates:\n${matches.map(m => ' - ' + m).join('\n')}`);
132
+ }
133
+
88
134
  function safeName(vault, title) {
89
135
  const base = String(title || 'untitled').replace(/[^\w\- ]+/g, '').trim() || 'untitled';
90
136
  let name = base, n = 1;
@@ -273,7 +319,7 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
273
319
  if (pipe) { try { [qv] = await embedTexts(pipe, [q]); } catch { /* lexical only */ } }
274
320
 
275
321
  let curKey = null;
276
- try { const cb = resolveCanvas(vault, 'brain') || resolveCanvas(vault, 'brain.klypix'); if (cb) curKey = path.resolve(cb).replace(/\\/g, '/').toLowerCase(); } catch { /* no current brain */ }
322
+ try { const cb = resolveDefaultBrain(vault).file; if (cb) curKey = path.resolve(cb).replace(/\\/g, '/').toLowerCase(); } catch { /* no current brain */ }
277
323
  const fresh = Date.now() - 30 * 86_400_000;
278
324
  const scored = [];
279
325
  for (const b of brains) {
@@ -324,12 +370,13 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
324
370
  }
325
371
 
326
372
  export async function opBrainInsights({ vault, canvas, staleDays }) {
327
- const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
328
- if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>", or run \`npx klypix-mcp init\` to create one.`);
373
+ const t = brainTarget(vault, canvas);
374
+ if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
375
+ if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>", or run \`npx klypix-mcp init\` to create one.`);
329
376
  try {
330
- const { struct } = await parseKlypix(fs.readFileSync(file));
377
+ const { struct } = await parseKlypix(fs.readFileSync(t.file));
331
378
  const ins = brainInsights(struct, staleDays ? { staleDays } : {});
332
- return { blocks: [text(insightsToMarkdown(ins, struct.title))] };
379
+ return { blocks: [text(brainStamp(t.file, struct, t.how) + insightsToMarkdown(ins, struct.title))] };
333
380
  } catch (e) {
334
381
  return err(`Insights failed: ${e.message}`);
335
382
  }
@@ -359,20 +406,23 @@ export function collectMigrationFiles(root) {
359
406
  return out;
360
407
  }
361
408
  export async function opBrainReconcile({ vault, canvas, root }) {
362
- const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
363
- if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
409
+ const t = brainTarget(vault, canvas);
410
+ if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
411
+ if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
412
+ const file = t.file;
364
413
  let struct;
365
414
  try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
415
+ const stamp = brainStamp(file, struct, t.how);
366
416
  // Migrations live in the CODE repo (usually beside brain.klypix), not in a
367
417
  // separate canvas vault — so default the root to the brain file's folder.
368
418
  const repoRoot = root ? path.resolve(root) : path.dirname(file);
369
419
  const files = collectMigrationFiles(repoRoot);
370
- if (!files.length) return { blocks: [text(`No migration files under ${repoRoot} (looked in: ${MIGRATION_DIRS.join(', ')}). Nothing to reconcile.`)] };
420
+ if (!files.length) return { blocks: [text(stamp + `No migration files under ${repoRoot} (looked in: ${MIGRATION_DIRS.join(', ')}). Nothing to reconcile.`)] };
371
421
  const { gaps, total } = findUnrecordedMigrations(struct, files, { max: 20 });
372
- if (!gaps.length) return { blocks: [text(`✓ All ${files.length} migration(s) under ${path.basename(repoRoot)} are referenced by a brain card — no unrecorded rollouts.`)] };
422
+ if (!gaps.length) return { blocks: [text(stamp + `✓ All ${files.length} migration(s) under ${path.basename(repoRoot)} are referenced by a brain card — no unrecorded rollouts.`)] };
373
423
  const lines = gaps.map(g => `- \`${g.path}\` — committed, but no brain card mentions it. If applied to prod, record it:\n \`🧠 BRAIN [DB] !: migration ${g.file.replace(/\.sql$/i, '')} applied to prod ev: ${g.path}\``);
374
424
  const more = total > gaps.length ? `\n\n…and ${total - gaps.length} more.` : '';
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}`)] };
425
+ return { blocks: [text(stamp + `# ⚠️ ${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}`)] };
376
426
  }
377
427
 
378
428
  // ── Brain gardener (two-phase: select → agent synthesizes → apply) ───────────
@@ -381,17 +431,20 @@ export async function opBrainReconcile({ vault, canvas, root }) {
381
431
  // prose); apply consolidates each area into a 🌿 card and archives the originals
382
432
  // with audit arrows. Mirrors brain_connect's dry-run/apply discipline.
383
433
  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>".`);
434
+ const t = brainTarget(vault, canvas);
435
+ if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
436
+ if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
437
+ const file = t.file;
386
438
  let struct;
387
439
  try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
440
+ const stamp = brainStamp(file, struct, t.how);
388
441
  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.')] };
442
+ if (!areas.length) return { blocks: [text(stamp + '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
443
 
391
444
  if (!apply) {
392
445
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
393
446
  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}`)] };
447
+ return { blocks: [text(stamp + `# 🌿 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
448
  }
396
449
 
397
450
  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.');
@@ -410,8 +463,10 @@ export async function opBrainGarden({ vault, canvas, apply = false, syntheses })
410
463
  }
411
464
 
412
465
  export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, log = () => {} }) {
413
- const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
414
- if (!file) return err(`No brain canvas found in ${vault}.`);
466
+ const tgt = brainTarget(vault, canvas);
467
+ if (tgt.ambiguous) return ambiguousBrainErr(tgt.ambiguous);
468
+ if (!tgt.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}.`);
469
+ const file = tgt.file;
415
470
  let struct;
416
471
  try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
417
472
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 70);
@@ -516,8 +571,10 @@ export async function opAddToCanvas({ vault, canvas, cards, connections, via })
516
571
  // can now record a decision, ask an open question, mark a milestone, resolve a card,
517
572
  // or correct one — with the full lifecycle, not just a flat append.
518
573
  export async function opBrainNote({ vault, canvas, text: noteText, area, marker = '', closes, via }) {
519
- const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
520
- if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
574
+ const t = brainTarget(vault, canvas);
575
+ if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
576
+ if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
577
+ const file = t.file;
521
578
  if (!noteText || !String(noteText).trim()) return err('brain_note needs a non-empty text.');
522
579
  if (!['', '?', '!', '✓', '~'].includes(marker)) return err(`Invalid marker "${marker}" — use: (none)=decision · ?=open question · !=milestone · ✓=resolve a matching card · ~=update a matching card.`);
523
580
  const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', createdVia: via || 'mcp' });
@@ -528,7 +585,9 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
528
585
  const s = res.stats || {};
529
586
  const bits = [`${s.added || 0} added`];
530
587
  for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
531
- return { blocks: [text(`✓ brain_note ${path.relative(vault, file)} (${bits.join(' · ')}). Reopen the brain in KLYPIX to see it.`)] };
588
+ // Name the resolved brain explicitly (basename + how) so a write never lands
589
+ // in a surprise file silently — the write-side twin of the read-op stamp.
590
+ return { blocks: [text(`✓ brain_note → ${path.basename(file)} (via ${t.how}) · ${bits.join(' · ')}. Reopen the brain in KLYPIX to see it.`)] };
532
591
  } catch (e) {
533
592
  return err(`brain_note failed (brain unchanged): ${e.message}`);
534
593
  }