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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.6.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,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
- const local = path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers', 'dist', 'transformers.mjs');
114
- t = await import(new URL('file:///' + local.replace(/\\/g, '/')).href);
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 };
@@ -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
- const open = rest.filter(c => /❓/.test(c.text));
577
- const miles = rest.filter(c => /🏁/.test(c.text) && !/❓/.test(c.text));
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) => /❓/.test(c.text);
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)) + (/❓/.test(c.text) ? 0.15 : 0);
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 (/❓|🏁/.test(card.text)) continue; // only plain decisions supersede
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