klypix-mcp 1.18.0 → 1.19.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.
@@ -24,7 +24,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
24
24
  import {
25
25
  resolveVault, getEmbedder, buildKlypixMap, cardSchema, connSchema,
26
26
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
27
- opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage,
27
+ opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk,
28
28
  } from '../src/klypix-core.mjs';
29
29
 
30
30
  // Real package version for the MCP handshake (was hardcoded '1.0.0', which
@@ -116,6 +116,17 @@ server.registerTool('search_all_brains', {
116
116
  },
117
117
  }, async ({ query, as_of }) => toContent(await opSearchAllBrains({ vault: VAULT, query, as_of, log })));
118
118
 
119
+ server.registerTool('brain_ask', {
120
+ title: 'Ask the project brain a question (whole-brain, correction-aware answer)',
121
+ description: 'Answer a natural-language question from the WHOLE project brain — "what did we decide about X?", "where did the auth work land?", "why did we drop Y?". Ranks every card (semantic on-device + lexical hybrid), INCLUDES superseded/archived history (flagged, so you can see how a decision changed), and attaches each stale card\'s live CORRECTION so the answer reflects the current truth, not an outdated card. Returns a synthesis-ready context (full cards + provenance + lifecycle) for you to turn into a direct, cited answer — it does not itself write prose. Prefer this over search_canvases when the user asks a QUESTION (not a keyword lookup). Optional as_of (YYYY-MM-DD) answers "what was true then". Defaults to the project brain ("brain").',
122
+ inputSchema: {
123
+ question: z.string().describe('The natural-language question to answer from the brain.'),
124
+ canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
125
+ as_of: z.string().optional().describe('Optional YYYY-MM-DD: answer as of that date (superseded cards count as live if they were current then).'),
126
+ k: z.number().optional().describe('Max cards to surface for synthesis (default 10, capped 20).'),
127
+ },
128
+ }, async ({ question, canvas, as_of, k }) => toContent(await opBrainAsk({ vault: VAULT, canvas, question, as_of, k, log })));
129
+
119
130
  server.registerTool('brain_insights', {
120
131
  title: 'What matters in a brain — hubs, orphans, stale questions',
121
132
  description: 'Structural read of a brain.klypix: the most-connected "hub" cards (load-bearing decisions), orphaned decisions (no connections — maybe forgotten), stale open questions (aging & unresolved), and area sizes. Use to answer "what matters here / what am I forgetting / what should I review?" — read it at the start of a planning session, or before tidying.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.18.0",
3
+ "version": "1.19.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",
@@ -53,7 +53,7 @@
53
53
  "node": ">=18"
54
54
  },
55
55
  "scripts": {
56
- "test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs"
56
+ "test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs"
57
57
  },
58
58
  "dependencies": {
59
59
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -55,7 +55,7 @@ survives across sessions, agents, and context resets.
55
55
 
56
56
  **At the start of a task — read it** so you know the project's state and past decisions:
57
57
  - if \`.claude/brain-brief.md\` exists, read it — it is the full session brief the brain hook regenerates at every session start (Focus, open questions, skills, recent decisions).
58
- - with the \`klypix-canvas\` MCP server: call \`search_canvases\` / \`read_canvas\` (canvas: \`"brain"\`), or \`brain_insights\` for the load-bearing cards.
58
+ - with the \`klypix-canvas\` MCP server: to **answer a question** from the brain ("what did we decide about X?", "where did Y land?"), call \`brain_ask\` — it ranks the whole brain, includes superseded history, and surfaces the current truth for any corrected card. Use \`search_canvases\` for a raw keyword lookup, \`read_canvas\` (canvas: \`"brain"\`) for the whole thing, or \`brain_insights\` for the load-bearing cards.
59
59
  - or via CLI: \`npx klypix-read brain.klypix\`
60
60
 
61
61
  **When you make a real decision, finding, or milestone — capture it HERE** so it persists for the next session/agent:
@@ -26,6 +26,7 @@ import {
26
26
  brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
27
27
  findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
28
28
  selectGardenCandidates, applyGarden, detectContradictions,
29
+ rankForQuestion, questionContextToMarkdown,
29
30
  } from './klypix-format.mjs';
30
31
 
31
32
  // ── Card / connection input shape (single source for every face) ─────────────
@@ -369,6 +370,40 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
369
370
  return { blocks: [text(`# Cross-project matches for "${query}" (${scored.length} hits in ${brains.length} brains, top ${top.length} · ${mode}${asOfNote})\n\n${lines.join('\n')}`)] };
370
371
  }
371
372
 
373
+ // ── brain_ask — answer a natural-language question over the WHOLE brain ───────
374
+ // "What did we decide about X?" / "Where did the auth work land?" The daily-use
375
+ // surface: hybrid retrieval (semantic on-device + lexical) over every card
376
+ // (including archived history, flagged), correction-aware (a stale hit carries its
377
+ // live correction), assembled into a SYNTHESIS-READY context the calling agent
378
+ // turns into a direct, cited answer. The engine never calls an LLM (pure retrieval
379
+ // + assembly) — same "engine selects, model writes" contract as brain_connect.
380
+ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log = () => {} }) {
381
+ const q = String(question || '').trim();
382
+ if (!q) return err('brain_ask needs a question.');
383
+ const t = brainTarget(vault, canvas);
384
+ if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
385
+ if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
386
+ const asOfTs = as_of ? Date.parse(as_of) : null;
387
+ if (as_of && Number.isNaN(asOfTs)) return err(`Bad as_of date: "${as_of}" — use YYYY-MM-DD.`);
388
+ let struct;
389
+ try { ({ struct } = await parseKlypix(fs.readFileSync(t.file))); } catch (e) { return err(`Read failed: ${e.message}`); }
390
+ const stamp = brainStamp(t.file, struct, t.how);
391
+ // Semantic blend (best-effort, time-bounded): embed the question + the brain's
392
+ // cards on-device, hand rankForQuestion a Map<cardId, cosine>. A missing/warming
393
+ // model degrades cleanly to pure lexical.
394
+ let semantic = null, mode = 'lexical';
395
+ try {
396
+ const pipe = await Promise.race([getEmbedder(log), new Promise(r => setTimeout(() => r(null), 20_000))]);
397
+ if (pipe) {
398
+ const [qv] = await embedTexts(pipe, [q]);
399
+ const vecs = await vectorsForBrain(pipe, t.file, struct.cards);
400
+ if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); mode = 'semantic+lexical (on-device)'; }
401
+ }
402
+ } catch { semantic = null; mode = 'lexical (semantic warming — retry for semantic ranking)'; }
403
+ const result = rankForQuestion(struct, q, { semantic, k: Math.max(1, Math.min(20, k || 10)), as_of: asOfTs != null ? as_of : null });
404
+ return { blocks: [text(stamp + questionContextToMarkdown(q, result, { mode, as_of: asOfTs != null ? as_of : null }))] };
405
+ }
406
+
372
407
  export async function opBrainInsights({ vault, canvas, staleDays }) {
373
408
  const t = brainTarget(vault, canvas);
374
409
  if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
@@ -498,13 +498,20 @@ export async function tidyBrain(buffer) {
498
498
 
499
499
  // Normalize every text card to the compact brain font (so the render matches
500
500
  // our height measure → no overlap) + cache each card's measured height.
501
+ // ALSO drop each card's `authoredInParent` anchor: the KLYPIX app freezes a
502
+ // child's in-container position in that field and RE-DERIVES the card's x/y
503
+ // from it on every render (ContainerItem group-scale) — so it would ignore
504
+ // the masonry x/y we write and snap cards back to their old single-column
505
+ // spots (the container then auto-grows to wrap them → skyscraper). Clearing
506
+ // the anchor makes THIS layout the card's authored baseline; the app re-seeds
507
+ // from our x/y. Verified against the app's render math in test/layout-cluster.
501
508
  const meta = new Map(); // id -> { h }
502
509
  for (const c of struct.cards) {
503
510
  if (c.type === 'container') continue;
504
511
  const wrapped = wrapText(String(c.text ?? ''));
505
512
  let createdAt = 0;
506
513
  const ip = `items/${shard(c.id)}/${c.id}.json`;
507
- try { const f = zip.file(ip); if (f) { const j = JSON.parse(await f.async('string')); createdAt = Number(j.createdAt) || 0; j.fontSize = G.FONT; j.content = wrapped; zip.file(ip, JSON.stringify(j)); } } catch { /* leave as-is */ }
514
+ try { const f = zip.file(ip); if (f) { const j = JSON.parse(await f.async('string')); createdAt = Number(j.createdAt) || 0; j.fontSize = G.FONT; j.content = wrapped; delete j.authoredInParent; zip.file(ip, JSON.stringify(j)); } } catch { /* leave as-is */ }
508
515
  meta.set(c.id, { h: measureCardH(wrapped), createdAt });
509
516
  }
510
517
 
@@ -738,6 +745,12 @@ export async function tidyBrain(buffer) {
738
745
  for (const kid of plans.get(cid).kids) {
739
746
  canvas.positions[kid.id] = { ...canvas.positions[kid.id], x: x + kid.dx, y: y + kid.dy, w: G.CARD_W, h: kid.h };
740
747
  }
748
+ // Drop the container's frozen group-scale baseline so the app's
749
+ // child-scaling pass early-returns (ContainerItem: `if
750
+ // (!item.authoredW) return`) and honors our masonry child x/y
751
+ // instead of scaling children off a stale authored size.
752
+ const cp = `items/${shard(cid)}/${cid}.json`;
753
+ try { const f = zip.file(cp); if (f) { const j = JSON.parse(await f.async('string')); if (j.authoredW != null || j.authoredH != null) { delete j.authoredW; delete j.authoredH; zip.file(cp, JSON.stringify(j)); } } } catch { /* leave as-is */ }
741
754
  }
742
755
  canvas.settings = { ...(canvas.settings || {}), brainLayout: 'cluster-v1' };
743
756
  }
@@ -993,6 +1006,118 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
993
1006
  return scored.filter(s => s.score >= minScore).slice(0, topK);
994
1007
  }
995
1008
 
1009
+ // ── Ask-the-brain — whole-brain, correction-aware retrieval for a question ───
1010
+ // The surface a human actually uses ("what did we decide about X?", "where did
1011
+ // the auth work land?"). Distinct from the per-prompt recall hook (which injects
1012
+ // a few RELATED cards into every prompt) and search_canvases (raw substring): this
1013
+ // RANKS the whole brain against a natural-language question and assembles a
1014
+ // SYNTHESIS-READY context for the calling agent to answer from — the engine stays
1015
+ // model-free (mirrors brain_connect/garden: engine selects, model writes prose).
1016
+ // Three things make it an ANSWER path, not a card dump:
1017
+ // 1. Hybrid-ready — lexical always (the shared scorer's weights + length-norm),
1018
+ // semantic blended when the caller (core, with the on-device embedder) passes
1019
+ // a sim map; on a lexical miss the semantic floor still surfaces paraphrases.
1020
+ // 2. History-aware — INCLUDES archived/superseded cards (penalized + flagged), so
1021
+ // "what did we decide" can show the arc (decided A → changed to B), and an
1022
+ // optional as_of answers "what was true then".
1023
+ // 3. Truth-aware — every stale hit carries its live CORRECTION (the P1 machinery),
1024
+ // so the agent answers from the correction, never the outdated card alone.
1025
+ // Pure + node-runnable. `semantic` is Map<cardId, 0..1> or null.
1026
+ const deathDateOfCard = (text) => { const m = /(?:↩︎ superseded|↩ superseded|✅) (\d{4}-\d{2}-\d{2})/.exec(String(text)); return m ? Date.parse(m[1]) : null; };
1027
+ export function rankForQuestion(struct, question, { semantic = null, k = 10, as_of = null, now = Date.now(), semFloor = 0.30, recentDays = 30 } = {}) {
1028
+ const tokens = queryTokens(question);
1029
+ if (!struct || !Array.isArray(struct.cards) || (!tokens.length && !semantic)) return { hits: [], total: 0, tokens };
1030
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
1031
+ const asOfTs = as_of ? Date.parse(as_of) : null;
1032
+ const timeTravel = asOfTs != null && Number.isFinite(asOfTs);
1033
+ const cutoff = now - recentDays * 86_400_000;
1034
+ const scored = [];
1035
+ for (const c of struct.cards) {
1036
+ if (c.type === 'container' || !(c.text || '').trim()) continue;
1037
+ const arch = isArchived(c);
1038
+ if (timeTravel) {
1039
+ if ((c.createdAt || 0) > asOfTs) continue; // didn't exist yet
1040
+ if (arch) {
1041
+ // A card archived NOW: keep it ONLY if it demonstrably outlived
1042
+ // as_of (its retirement is stamped LATER) — then it was the live
1043
+ // truth then. If it died by as_of, or carries NO dated stamp (we
1044
+ // can't prove it was still live), exclude it — precision-first, so
1045
+ // a "what was true then" answer never asserts a since-dead fact.
1046
+ const died = deathDateOfCard(c.text);
1047
+ if (died == null || died <= asOfTs) continue;
1048
+ }
1049
+ }
1050
+ const titleW = wordsOf(c.title);
1051
+ const bodyW = wordsOf(c.text);
1052
+ const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
1053
+ const lenNorm = Math.min(1, 6 / Math.max(6, Math.log2(bodyW.size || 1)));
1054
+ let lex = 0;
1055
+ for (const tok of tokens) {
1056
+ if (titleW.has(tok)) lex += 3;
1057
+ else if (tagStems.has(tok)) lex += 3;
1058
+ else if (bodyW.has(tok)) lex += lenNorm;
1059
+ }
1060
+ const sem = semantic ? (semantic.get(c.id) ?? null) : null;
1061
+ if (lex <= 0 && (sem == null || sem < semFloor)) continue; // no lexical AND no strong semantic → skip
1062
+ // In time-travel a surviving card WAS live at as_of, so it is NOT stale
1063
+ // history — don't demote or flag it as archived (that status is a present
1064
+ // fact). Outside time-travel, archived cards are demoted but never excluded
1065
+ // (history matters for "what did we…").
1066
+ const effArch = timeTravel ? false : arch;
1067
+ let score = sem != null ? sem * 10 + Math.min(lex, 6) * 0.5 : lex;
1068
+ if (!timeTravel && (c.createdAt || 0) >= cutoff) score += 0.5;
1069
+ if (/🛠/.test(c.text)) score += 1; // standing skills
1070
+ if (effArch) score -= 1.5;
1071
+ scored.push({ card: c, score, sem, archived: effArch });
1072
+ }
1073
+ scored.sort((a, b) => b.score - a.score || (b.card.createdAt || 0) - (a.card.createdAt || 0));
1074
+ const top = scored.slice(0, k);
1075
+ // Correction overlays on the surfaced hits — a stale card gets its live
1076
+ // corrector so the agent answers from the truth (edge or cue; P1 machinery).
1077
+ // NOT in time-travel: a correction is a PRESENT fact; importing a future
1078
+ // corrector into a "what was true then" answer would contaminate it (a
1079
+ // 2026-05 correction leaking into a 2026-02 query). Then-live cards stand
1080
+ // as they were.
1081
+ let overlays = new Map();
1082
+ if (!timeTravel) { try { overlays = correctionOverlaysFor(struct, top.map(h => h.card)); } catch { /* best-effort */ } }
1083
+ const hits = top.map(h => ({ ...h, correction: overlays.get(h.card.id) || null }));
1084
+ return { hits, total: scored.length, tokens };
1085
+ }
1086
+
1087
+ // Assemble the ranked hits into a SYNTHESIS-READY markdown context: a header that
1088
+ // instructs the agent to answer the question directly (cite cards, honor
1089
+ // corrections, admit gaps), then each hit full-text with provenance + lifecycle +
1090
+ // its correction. Char-budgeted so a huge brain can't blow the tool result.
1091
+ export function questionContextToMarkdown(question, result, { mode = 'lexical', as_of = null, budgetChars = 9000 } = {}) {
1092
+ const { hits, total } = result;
1093
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
1094
+ const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
1095
+ if (!hits.length) {
1096
+ return `# No brain cards answer: “${flat(question)}”\n`
1097
+ + `Searched the whole brain (${mode}) and found nothing relevant. Tell the user the brain doesn't cover this yet — don't guess. If you learn the answer this session, capture it: \`🧠 BRAIN [Area]: <decision>\`.\n`;
1098
+ }
1099
+ const out = [];
1100
+ out.push(`# Answer “${flat(question)}” from these ${hits.length} brain card(s)${as_of ? ` (as of ${as_of})` : ''} — ${total} matched, ${mode} ranking`);
1101
+ out.push('_Synthesize a DIRECT answer from the cards below, then cite the ones you used by [Area]+date. Where a card is marked ⚠️ CORRECTED, answer from the correction, NOT the stale card. Include superseded/archived cards only to show how a decision CHANGED. If the cards don\'t actually answer the question, say so — don\'t pad._');
1102
+ out.push('');
1103
+ let used = out.join('\n').length;
1104
+ let shown = 0;
1105
+ for (const h of hits) {
1106
+ const c = h.card;
1107
+ const status = h.archived ? ' · ⛔ archived/superseded' : '';
1108
+ const rel = h.sem != null ? ` · sim ${h.sem.toFixed(2)}` : '';
1109
+ let block = `## [${flat(c.area) || 'Notes'}] ${day(c.createdAt)}${status}${rel}\n${flat(c.text)}`;
1110
+ if (h.correction) {
1111
+ block += `\n\n ⚠️ CORRECTED — this card is STALE; the current truth is:\n ${flat(h.correction.by.text).slice(0, 600)}`;
1112
+ }
1113
+ if (used + block.length + 2 > budgetChars && shown > 0) { out.push(`\n_…and ${hits.length - shown} more matched card(s) omitted for length — narrow the question or use search for the rest._`); break; }
1114
+ out.push(block, '');
1115
+ used += block.length + 2;
1116
+ shown++;
1117
+ }
1118
+ return out.join('\n') + '\n';
1119
+ }
1120
+
996
1121
  // ── External-state reconcile — migration omission tripwire ───────────────────
997
1122
  // The brain is a NARRATION-capture system: a fact exists only if someone wrote a
998
1123
  // 🧠 marker or a rationale-bearing commit body. Applying a DB migration to prod