klypix-mcp 1.18.1 → 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.
- package/bin/klypix-mcp.mjs +12 -1
- package/package.json +2 -2
- package/src/agent-rules.mjs +1 -1
- package/src/klypix-core.mjs +35 -0
- package/src/klypix-format.mjs +112 -0
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -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.
|
|
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",
|
package/src/agent-rules.mjs
CHANGED
|
@@ -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\`
|
|
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:
|
package/src/klypix-core.mjs
CHANGED
|
@@ -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);
|
package/src/klypix-format.mjs
CHANGED
|
@@ -1006,6 +1006,118 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
|
|
|
1006
1006
|
return scored.filter(s => s.score >= minScore).slice(0, topK);
|
|
1007
1007
|
}
|
|
1008
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
|
+
|
|
1009
1121
|
// ── External-state reconcile — migration omission tripwire ───────────────────
|
|
1010
1122
|
// The brain is a NARRATION-capture system: a fact exists only if someone wrote a
|
|
1011
1123
|
// 🧠 marker or a rationale-bearing commit body. Applying a DB migration to prod
|