klypix-mcp 1.5.0 → 1.6.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 +16 -1
- package/package.json +1 -1
- package/src/klypix-core.mjs +37 -3
- package/src/klypix-format.mjs +63 -6
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -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,
|
|
26
|
+
opBrainInsights, opBrainConnect, opBrainReconcile, 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.
|
|
@@ -147,6 +147,21 @@ server.registerTool('add_to_canvas', {
|
|
|
147
147
|
return toContent(await opAddToCanvas({ vault: VAULT, canvas, cards, connections, via }));
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
server.registerTool('brain_note', {
|
|
151
|
+
title: 'Write a deliberate note to the project brain (decision / question / milestone / resolve / update)',
|
|
152
|
+
description: 'Record something in the project brain ON DEMAND — the agent-neutral twin of the Claude-Code capture hook, so any client (Cursor / Cline / Desktop) can write the brain, not just read it. Unlike add_to_canvas (a flat append), this routes through the brain\'s capture engine, so a new decision SUPERSEDES a heavily-overlapping older one, ✓ RESOLVES/archives a matching card, closes: resolves the strategy/question a milestone fulfils, and ~ UPDATES a card in place — the full decision lifecycle, with dedup. Use it to remember a decision, ask an open question, mark a milestone, resolve a finished item, or correct a card. Defaults to the project brain ("brain").',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
text: z.string().describe('The note — one concise idea; the first line becomes the card title.'),
|
|
155
|
+
marker: z.enum(['', '?', '!', '✓', '~']).optional().describe('(none)=decision · ?=open question · !=milestone · ✓=resolve+archive the best-matching card · ~=update the matching card in place. Default: decision.'),
|
|
156
|
+
area: z.string().optional().describe('Area/topic — routes the card into that titled container and becomes a #tag (e.g. "Auth", "Release").'),
|
|
157
|
+
closes: z.string().optional().describe('Title or [[wikilink]] of a strategy/question card this note fulfils — resolves+archives it and draws a "closed by" arrow.'),
|
|
158
|
+
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
159
|
+
},
|
|
160
|
+
}, async ({ text, marker, area, closes, canvas }) => {
|
|
161
|
+
let via; try { via = server.server.getClientVersion()?.name; } catch { /* optional */ }
|
|
162
|
+
return toContent(await opBrainNote({ vault: VAULT, canvas, text, area, marker: marker || '', closes, via }));
|
|
163
|
+
});
|
|
164
|
+
|
|
150
165
|
const transport = new StdioServerTransport();
|
|
151
166
|
await server.connect(transport);
|
|
152
167
|
log(`ready · vault=${VAULT}`);
|
package/package.json
CHANGED
package/src/klypix-core.mjs
CHANGED
|
@@ -24,7 +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
|
+
findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
|
|
28
28
|
} from './klypix-format.mjs';
|
|
29
29
|
|
|
30
30
|
// ── Card / connection input shape (single source for every face) ─────────────
|
|
@@ -110,8 +110,16 @@ export function getEmbedder(log = () => {}) {
|
|
|
110
110
|
let t;
|
|
111
111
|
try { t = await import('@huggingface/transformers'); }
|
|
112
112
|
catch {
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
// The optional dep ships dist/transformers.node.mjs on v4 (Node build) and
|
|
114
|
+
// dist/transformers.mjs on older lines — try both so a correct one-click
|
|
115
|
+
// install resolves regardless of version.
|
|
116
|
+
const base = path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers', 'dist');
|
|
117
|
+
let lastErr;
|
|
118
|
+
for (const f of ['transformers.node.mjs', 'transformers.mjs']) {
|
|
119
|
+
try { t = await import(new URL('file:///' + path.join(base, f).replace(/\\/g, '/')).href); lastErr = null; break; }
|
|
120
|
+
catch (e) { lastErr = e; }
|
|
121
|
+
}
|
|
122
|
+
if (!t) throw lastErr;
|
|
115
123
|
}
|
|
116
124
|
t.env.cacheDir = path.join(PB_DIR, 'hf-cache');
|
|
117
125
|
return await t.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
|
|
@@ -465,5 +473,31 @@ export async function opAddToCanvas({ vault, canvas, cards, connections, via })
|
|
|
465
473
|
}
|
|
466
474
|
}
|
|
467
475
|
|
|
476
|
+
// brain_note — the DELIBERATE, marker-aware write every agent (not just the
|
|
477
|
+
// Claude-Code Stop hook) can make on demand. Routes through the SAME captureInto-
|
|
478
|
+
// Brain engine the hook uses, so supersede / resolve / close-link / dedup behave
|
|
479
|
+
// identically to a harvested 🧠 marker. The agent-neutral half of "the brain is an
|
|
480
|
+
// open file any agent reads AND writes": a hookless client (Cursor/Cline/Desktop)
|
|
481
|
+
// can now record a decision, ask an open question, mark a milestone, resolve a card,
|
|
482
|
+
// or correct one — with the full lifecycle, not just a flat append.
|
|
483
|
+
export async function opBrainNote({ vault, canvas, text: noteText, area, marker = '', closes, via }) {
|
|
484
|
+
const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
|
|
485
|
+
if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
|
|
486
|
+
if (!noteText || !String(noteText).trim()) return err('brain_note needs a non-empty text.');
|
|
487
|
+
if (!['', '?', '!', '✓', '~'].includes(marker)) return err(`Invalid marker "${marker}" — use: (none)=decision · ?=open question · !=milestone · ✓=resolve a matching card · ~=update a matching card.`);
|
|
488
|
+
const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', createdVia: via || 'mcp' });
|
|
489
|
+
try {
|
|
490
|
+
const res = await captureIntoBrain(fs.readFileSync(file), input);
|
|
491
|
+
let out = res.buffer; try { out = (await tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
|
|
492
|
+
await atomicWrite(file, out);
|
|
493
|
+
const s = res.stats || {};
|
|
494
|
+
const bits = [`${s.added || 0} added`];
|
|
495
|
+
for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
|
|
496
|
+
return { blocks: [text(`✓ brain_note → ${path.relative(vault, file)} (${bits.join(' · ')}). Reopen the brain in KLYPIX to see it.`)] };
|
|
497
|
+
} catch (e) {
|
|
498
|
+
return err(`brain_note failed (brain unchanged): ${e.message}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
468
502
|
// Re-export the format helpers the bins need for non-op work (init onboarding).
|
|
469
503
|
export { buildKlypixMap, parseKlypix };
|
package/src/klypix-format.mjs
CHANGED
|
@@ -573,8 +573,11 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
573
573
|
const live = texts.filter(c => !isArchived(c));
|
|
574
574
|
const focus = live.filter(isFocus);
|
|
575
575
|
const rest = live.filter(c => !isFocus(c));
|
|
576
|
-
|
|
577
|
-
|
|
576
|
+
// 🎯 (goal/target) reads as an OPEN item alongside ❓ — a goal card is
|
|
577
|
+
// still-to-do until a ✓/closes: or a covering milestone closes it (so it
|
|
578
|
+
// must NOT masquerade as a plain decision that quietly ages out of view).
|
|
579
|
+
const open = rest.filter(c => /❓|🎯/.test(c.text));
|
|
580
|
+
const miles = rest.filter(c => /🏁/.test(c.text) && !/❓|🎯/.test(c.text));
|
|
578
581
|
const plain = rest.filter(c => !open.includes(c) && !miles.includes(c));
|
|
579
582
|
const recent = plain.filter(c => c.createdAt >= cutoff).sort((a, b) => b.createdAt - a.createdAt).slice(0, maxRecent);
|
|
580
583
|
const archivedCount = texts.length - live.length;
|
|
@@ -607,7 +610,7 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
607
610
|
push('', '## 📌 Human focus (cards the human placed here — act on these first)');
|
|
608
611
|
for (const c of focus) push(`- ${fr(c)}${flat(c.text)}`);
|
|
609
612
|
}
|
|
610
|
-
if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
|
|
613
|
+
if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
|
|
611
614
|
// ⚠️ Conflicts — pairs flagged conflicts_with (e.g. by parallel sessions);
|
|
612
615
|
// surfaced HIGH so the next session reconciles them, not buries them.
|
|
613
616
|
const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
|
|
@@ -837,7 +840,7 @@ export function brainInsights(struct, { staleDays = 21, topHubs = 6 } = {}) {
|
|
|
837
840
|
if (cn.toId) deg.set(cn.toId, (deg.get(cn.toId) || 0) + 1);
|
|
838
841
|
}
|
|
839
842
|
const headline = (c) => String(c.text || '').replace(/\s+/g, ' ').trim().replace(/^(.*?)([.!?](\s|$)|$)/, '$1').slice(0, 120);
|
|
840
|
-
const isQuestion = (c) =>
|
|
843
|
+
const isQuestion = (c) => /❓|🎯/.test(c.text); // ❓ open question + 🎯 goal both read as "open"
|
|
841
844
|
const hubs = live
|
|
842
845
|
.map(c => ({ id: c.id, area: c.area, degree: deg.get(c.id) || 0, headline: headline(c) }))
|
|
843
846
|
.filter(x => x.degree > 0)
|
|
@@ -1042,7 +1045,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1042
1045
|
let best = null, bestScore = 0;
|
|
1043
1046
|
for (const c of liveTextCards()) {
|
|
1044
1047
|
if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
|
|
1045
|
-
const s = overlapScore(rTok, tokenSet(c.text)) + (
|
|
1048
|
+
const s = overlapScore(rTok, tokenSet(c.text)) + (/❓|🎯/.test(c.text) ? 0.15 : 0);
|
|
1046
1049
|
if (s > bestScore) { bestScore = s; best = c; }
|
|
1047
1050
|
}
|
|
1048
1051
|
if (best && bestScore >= RESOLVE_AT) {
|
|
@@ -1092,7 +1095,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1092
1095
|
// to the new card is drawn in pass 2 (after the new ids exist), matched
|
|
1093
1096
|
// back by remembering which old card each new card displaced.
|
|
1094
1097
|
for (const card of cards) {
|
|
1095
|
-
if (
|
|
1098
|
+
if (/❓|🎯|🏁/.test(card.text)) continue; // only plain decisions supersede (not questions/goals/milestones)
|
|
1096
1099
|
const nTok = tokenSet(card.text);
|
|
1097
1100
|
const area = (card.area || '').toLowerCase();
|
|
1098
1101
|
let best = null, bestScore = 0;
|
|
@@ -1197,6 +1200,60 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1197
1200
|
return { buffer: work, stats };
|
|
1198
1201
|
}
|
|
1199
1202
|
|
|
1203
|
+
// ── Stale-open reconcile ("marked open, but a milestone says it's done") ─────
|
|
1204
|
+
// The READ-side twin of the closes: write path. An open ❓/🎯 card lingers as
|
|
1205
|
+
// "still to do" forever unless someone emits a ✓/closes: for it — so a goal that
|
|
1206
|
+
// quietly SHIPPED keeps surfacing in recall as a "next move". This pure pass
|
|
1207
|
+
// finds open cards a LATER live 🏁 milestone appears to fulfil (its text COVERS
|
|
1208
|
+
// the open card's distinctive tokens) and returns them so the surface can PROMPT
|
|
1209
|
+
// the human to close them — never auto-archives (precision-first, suggestion-only,
|
|
1210
|
+
// like the migration tripwire). Requires the milestone to post-date the goal so a
|
|
1211
|
+
// pre-existing milestone can't "fulfil" a newer goal. No I/O, node-runnable.
|
|
1212
|
+
export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
|
|
1213
|
+
const empty = { gaps: [], total: 0 };
|
|
1214
|
+
if (!struct || !Array.isArray(struct.cards)) return empty;
|
|
1215
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1216
|
+
const isOpen = (c) => /❓|🎯/.test(c.text);
|
|
1217
|
+
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c) && !/↩|✅/.test(c.text));
|
|
1218
|
+
const opens = live.filter(isOpen);
|
|
1219
|
+
const miles = live.filter(c => /🏁/.test(c.text) && !isOpen(c));
|
|
1220
|
+
if (!opens.length || !miles.length) return empty;
|
|
1221
|
+
const out = [];
|
|
1222
|
+
for (const o of opens) {
|
|
1223
|
+
const oTok = tokenSet(o.text);
|
|
1224
|
+
if (oTok.size < 3) continue; // too vague to match safely → leave it
|
|
1225
|
+
let best = null, bestCov = 0;
|
|
1226
|
+
for (const m of miles) {
|
|
1227
|
+
if ((m.createdAt || 0) <= (o.createdAt || 0)) continue; // only a milestone shipped AFTER the goal
|
|
1228
|
+
const cov = coverageOf(oTok, tokenSet(m.text)); // how much of the goal the milestone covers
|
|
1229
|
+
if (cov > bestCov) { bestCov = cov; best = m; }
|
|
1230
|
+
}
|
|
1231
|
+
if (best && bestCov >= coverAt) out.push({ open: o, by: best, cov: Math.round(bestCov * 100) / 100 });
|
|
1232
|
+
}
|
|
1233
|
+
out.sort((a, b) => b.cov - a.cov);
|
|
1234
|
+
return { gaps: out.slice(0, max), total: out.length };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// ── Deliberate note → capture input ──────────────────────────────────────────
|
|
1238
|
+
// Turn ONE structured note into captureIntoBrain's input shape — the deliberate
|
|
1239
|
+
// twin of the Stop hook's transcript marker parser. This is what lets an ON-DEMAND
|
|
1240
|
+
// write (the brain_note MCP tool, the brain-note CLI — any agent, not just the
|
|
1241
|
+
// Claude-Code hook) get IDENTICAL supersede / resolve / close / dedup semantics as
|
|
1242
|
+
// a harvested 🧠 marker. marker ∈ '' (decision) | '?' (open question) | '!'
|
|
1243
|
+
// (milestone) | '✓' (resolve+archive a match) | '~' (update a match in place).
|
|
1244
|
+
export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, createdVia = 'mcp' } = {}) {
|
|
1245
|
+
const body = String(text).trim();
|
|
1246
|
+
if (!body) return { cards: [], resolutions: [], updates: [] };
|
|
1247
|
+
const a = String(area || '').trim();
|
|
1248
|
+
if (marker === '✓') return { cards: [], resolutions: [{ area: a, text: body }], updates: [] };
|
|
1249
|
+
if (marker === '~') return { cards: [], resolutions: [], updates: [{ area: a, text: body, createdVia, ...(evidence ? { evidence } : {}) }] };
|
|
1250
|
+
const prefix = marker === '?' ? '❓ ' : marker === '!' ? '🏁 ' : '';
|
|
1251
|
+
const borderColor = marker === '?' ? 'rgba(245,166,35,0.8)' : marker === '!' ? 'rgba(59,130,246,0.8)' : 'rgba(16,185,129,0.6)';
|
|
1252
|
+
const tag = a ? `\n#${a.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : '';
|
|
1253
|
+
const cardText = (a ? `${a}: ${prefix}${body}` : `${prefix}${body}`) + tag;
|
|
1254
|
+
return { cards: [{ text: cardText, area: a, color: '#e8e8ed', borderColor, createdVia, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}) }], resolutions: [], updates: [] };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1200
1257
|
/**
|
|
1201
1258
|
* Build a RICH "map" .klypix: areas become titled containers, their cards
|
|
1202
1259
|
* stack inside, connections draw across. Produces a real spatial board (used by
|