klypix-mcp 1.4.0 → 1.5.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.
@@ -36,7 +36,16 @@ import {
36
36
  } from '../src/klypix-core.mjs';
37
37
 
38
38
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
- const PKG = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
39
+ // Resolve the package version across layouts: the published package (bin/ →
40
+ // ../package.json) and the desktop-bundled FLAT layout (~/.claude/project-brain/,
41
+ // ./package.json, which may carry no version) — degrade gracefully either way.
42
+ function readVersion() {
43
+ for (const p of [path.join(__dirname, '..', 'package.json'), path.join(__dirname, 'package.json')]) {
44
+ try { const v = JSON.parse(fs.readFileSync(p, 'utf8')).version; if (v) return v; } catch { /* try next */ }
45
+ }
46
+ return '1.x';
47
+ }
48
+ const PKG = { version: readVersion() };
40
49
  const log = (...a) => console.error('[klypix-a2a]', ...a);
41
50
 
42
51
  const arg = (flag) => { const i = process.argv.indexOf(flag); return i >= 0 ? process.argv[i + 1] : undefined; };
@@ -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, opCreateCanvas, opAddToCanvas,
26
+ opBrainInsights, opBrainConnect, opBrainReconcile, opCreateCanvas, opAddToCanvas,
27
27
  } from '../src/klypix-core.mjs';
28
28
 
29
29
  // IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
@@ -112,6 +112,15 @@ server.registerTool('brain_connect', {
112
112
  },
113
113
  }, async ({ canvas, apply, max, threshold }) => toContent(await opBrainConnect({ vault: VAULT, canvas, apply, max, threshold, log })));
114
114
 
115
+ server.registerTool('brain_reconcile', {
116
+ title: 'Reconcile the brain against committed migrations (find unrecorded rollouts)',
117
+ description: 'External-state check the brain otherwise CANNOT do: a brain only knows facts someone narrated (a marker, a commit body), so a DB migration APPLIED to prod — which narrates nothing — silently never lands. This lists committed migration files (Supabase / Rails / Prisma / Knex / generic) under the project and flags any that NO brain card references, so you can confirm the rollout with one marker. It reads ONLY the filesystem — never the database, never the network — and never claims a migration was applied, only that it is unrecorded. Defaults to the migrations dir beside the project brain; pass root to point elsewhere. Run it when you want to be sure the brain reflects what actually shipped.',
118
+ inputSchema: {
119
+ canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
120
+ root: z.string().optional().describe("Project root holding the migrations dir (default: the brain file's folder)."),
121
+ },
122
+ }, async ({ canvas, root }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root })));
123
+
115
124
  server.registerTool('create_canvas', {
116
125
  title: 'Create a KLYPIX canvas',
117
126
  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.4.0",
3
+ "version": "1.5.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",
@@ -24,6 +24,7 @@ import { z } from 'zod';
24
24
  import {
25
25
  parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown,
26
26
  brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
27
+ findUnrecordedMigrations,
27
28
  } from './klypix-format.mjs';
28
29
 
29
30
  // ── Card / connection input shape (single source for every face) ─────────────
@@ -325,6 +326,46 @@ export async function opBrainInsights({ vault, canvas, staleDays }) {
325
326
  }
326
327
  }
327
328
 
329
+ // ── Migration reconcile (external-state omission tripwire) ────────────────────
330
+ // Lists committed migration files under a project root (Supabase / Rails / Prisma
331
+ // / Knex / generic layouts) and feeds them to the pure findUnrecordedMigrations(),
332
+ // returning the ones no live brain card records. Portable: pure fs, no DB, no
333
+ // network, no credentials — it flags "committed but unmentioned", NEVER claims
334
+ // "applied to prod". Degrades to a clean message for a project with no migrations.
335
+ const MIGRATION_DIRS = ['supabase/migrations', 'db/migrate', 'db/migrations', 'prisma/migrations', 'migrations'];
336
+ export function collectMigrationFiles(root) {
337
+ const out = [];
338
+ for (const rel of MIGRATION_DIRS) {
339
+ const abs = path.join(root, ...rel.split('/'));
340
+ let entries;
341
+ try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { continue; }
342
+ for (const e of entries) {
343
+ if (e.isFile() && /\.sql$/i.test(e.name)) out.push(rel + '/' + e.name);
344
+ // Prisma nests each migration in its own folder holding a migration.sql.
345
+ else if (e.isDirectory()) {
346
+ try { if (fs.statSync(path.join(abs, e.name, 'migration.sql')).isFile()) out.push(rel + '/' + e.name + '/migration.sql'); } catch { /* not a prisma migration dir */ }
347
+ }
348
+ }
349
+ }
350
+ return out;
351
+ }
352
+ export async function opBrainReconcile({ vault, canvas, root }) {
353
+ const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
354
+ if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
355
+ let struct;
356
+ try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
357
+ // Migrations live in the CODE repo (usually beside brain.klypix), not in a
358
+ // separate canvas vault — so default the root to the brain file's folder.
359
+ const repoRoot = root ? path.resolve(root) : path.dirname(file);
360
+ const files = collectMigrationFiles(repoRoot);
361
+ if (!files.length) return { blocks: [text(`No migration files under ${repoRoot} (looked in: ${MIGRATION_DIRS.join(', ')}). Nothing to reconcile.`)] };
362
+ const { gaps, total } = findUnrecordedMigrations(struct, files, { max: 20 });
363
+ if (!gaps.length) return { blocks: [text(`✓ All ${files.length} migration(s) under ${path.basename(repoRoot)} are referenced by a brain card — no unrecorded rollouts.`)] };
364
+ 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}\``);
365
+ const more = total > gaps.length ? `\n\n…and ${total - gaps.length} more.` : '';
366
+ 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}`)] };
367
+ }
368
+
328
369
  export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, log = () => {} }) {
329
370
  const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
330
371
  if (!file) return err(`No brain canvas found in ${vault}.`);
@@ -688,6 +688,53 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
688
688
  return scored.filter(s => s.score >= minScore).slice(0, topK);
689
689
  }
690
690
 
691
+ // ── External-state reconcile — migration omission tripwire ───────────────────
692
+ // The brain is a NARRATION-capture system: a fact exists only if someone wrote a
693
+ // 🧠 marker or a rationale-bearing commit body. Applying a DB migration to prod
694
+ // is an OBSERVED side-effect that narrates nothing, so it silently never lands —
695
+ // and the brain can't tell a *committed* migration from an *applied* one. These
696
+ // two pure functions are the portable seam that closes that blind spot WITHOUT
697
+ // making the brain omniscient: no I/O, no DB probe, no network, no credentials. A
698
+ // collector (the Claude-Code hook, or the brain_reconcile MCP tool) hands them
699
+ // the migration FILES found on disk; they return the ones NO LIVE card references,
700
+ // so the surface can PROMPT the human to confirm the rollout — never assert it.
701
+ // Recall-first by design: ANY plausible hit counts as "recorded", erring toward
702
+ // silence over a false nag.
703
+ const MIG_STOP = new Set(['migration', 'migrations', 'sql', 'create', 'alter', 'table', 'drop', 'add', 'update', 'init', 'schema', 'public', 'new', 'fix', 'set', 'col', 'column', 'index']);
704
+ // Split on EVERY non-alphanumeric (unlike the shared wordsOf, which keeps _- glued)
705
+ // so `20260620000000_canvas_blob_size_limit` and the `#file-…` tag both tokenize
706
+ // into the same words a card's prose carries — the precise match signal.
707
+ const migWords = (s) => new Set(String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter(Boolean));
708
+ export function migrationSignature(file) {
709
+ const orig = String(file || '').replace(/\\/g, '/');
710
+ const base = orig.split('/').pop() || '';
711
+ const stem = base.replace(/\.[a-z0-9]+$/i, '');
712
+ const ts = (stem.match(/^\d{8,}/) || [''])[0]; // leading timestamp id (Supabase/Prisma/Knex)
713
+ const distinctive = stem.replace(/^\d{8,}[_-]?/, '') // drop the timestamp prefix → the descriptive name
714
+ .split(/[^a-z0-9]+/i).map(t => t.toLowerCase())
715
+ .filter(t => t.length >= 3 && !MIG_STOP.has(t));
716
+ return { path: orig, file: base, ts, distinctive };
717
+ }
718
+ // A migration is "recorded" if some LIVE (non-archived) card mentions its timestamp
719
+ // id OR every distinctive word of its name (so "canvas" alone never claims to record
720
+ // canvas_blob_size_limit, but a card carrying the #file-<stem> tag or the applied-
721
+ // marker prose does). Returns the UNrecorded ones (capped); [] when there are no
722
+ // migrations (plain projects stay silent).
723
+ export function findUnrecordedMigrations(struct, files, { max = 6 } = {}) {
724
+ const empty = { gaps: [], total: 0, scanned: 0 };
725
+ const sigs = (files || []).map(migrationSignature).filter(s => s.ts || s.distinctive.length);
726
+ if (!sigs.length || !struct || !Array.isArray(struct.cards)) return empty;
727
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
728
+ const cardWords = struct.cards
729
+ .filter(c => c && c.type !== 'container' && !isArchived(c))
730
+ .map(c => migWords(String(c.text || '') + ' ' + (c.tags || []).join(' ')));
731
+ const recorded = (sig) => cardWords.some(ws =>
732
+ (sig.ts && ws.has(sig.ts))
733
+ || (sig.distinctive.length > 0 && sig.distinctive.every(t => ws.has(t))));
734
+ const unrecorded = sigs.filter(s => !recorded(s));
735
+ return { gaps: unrecorded.slice(0, max).map(s => ({ file: s.file, path: s.path, ts: s.ts })), total: unrecorded.length, scanned: sigs.length };
736
+ }
737
+
691
738
  // ── Repeat / redundancy detection ("you already did this in another session") ─
692
739
  // The PRECISION-first sibling of scoreCardsAgainstQuery. Instead of "related
693
740
  // context" it answers a sharper question: is the user about to REDO work that's