klypix-mcp 1.8.0 → 1.9.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.
@@ -32,8 +32,9 @@ import { z } from 'zod';
32
32
  import {
33
33
  resolveVault, getEmbedder, cardSchema, connSchema,
34
34
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
35
- opBrainInsights, opBrainConnect, opCreateCanvas, opAddToCanvas,
35
+ opBrainInsights, opBrainConnect, opCreateCanvas, opAddToCanvas, opBrainNote,
36
36
  } from '../src/klypix-core.mjs';
37
+ import { looksLikeSkill } from '../src/klypix-format.mjs';
37
38
 
38
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
40
  // Resolve the package version across layouts: the published package (bin/ →
@@ -87,12 +88,21 @@ function agentCard(publicUrl) {
87
88
  {
88
89
  id: 'remember',
89
90
  name: 'Remember into the canvas / brain',
90
- description: 'Append a decision or cards (with optional connections) to an existing .klypix, preserving every existing item and position. The durable, cross-session memory a multi-agent run keeps writing to.',
91
- tags: ['memory', 'append', 'write', 'brain'],
91
+ description: 'Append a decision or cards (with optional connections) to an existing .klypix, preserving every existing item and position. The durable, cross-session memory a multi-agent run keeps writing to. Pass a "marker" in args for the full lifecycle: ""=decision · "?"=open question · "!"=milestone · "+"=🛠️ skill · "✓"=resolve a match · "~"=update a match.',
92
+ tags: ['memory', 'append', 'write', 'brain', 'lifecycle'],
92
93
  examples: ['Remember that we chose Postgres over Mongo', 'Add a card with this finding to the roadmap canvas'],
93
94
  inputModes: ['text/plain', 'application/json'],
94
95
  outputModes: [KLYPIX_MIME, 'text/plain'],
95
96
  },
97
+ {
98
+ id: 'learn_skill',
99
+ name: 'Learn a reusable skill (how-to / gotcha)',
100
+ description: 'Record a 🛠️ SKILL — a reusable how-to, gotcha, or convention ("always dedup zKeys before REORDER") that should resurface every session and never age out, distinct from a one-time decision. Routes through the capture engine with the "+" marker. A delegating agent uses this to teach the shared brain how work is done here, so every future agent inherits it. Plain "remember" auto-promotes to a skill when the text reads as a general rule.',
101
+ tags: ['memory', 'skill', 'how-to', 'procedure', 'brain', 'learn'],
102
+ examples: ['Learn this gotcha: never set backgroundThrottling false — it breaks visibility detection', 'Remember the convention: Electron main imports must stay at top'],
103
+ inputModes: ['text/plain', 'application/json'],
104
+ outputModes: [KLYPIX_MIME, 'text/plain'],
105
+ },
96
106
  {
97
107
  id: 'recall',
98
108
  name: 'Recall context from the canvases',
@@ -195,7 +205,21 @@ async function runSkill(skill, args, text, via) {
195
205
  return await opCreateCanvas({ vault: VAULT, title: args.title ?? 'Untitled board', cards: parsed.data, connections: conns.data, filename: args.filename });
196
206
  }
197
207
  case 'remember':
198
- case 'add_to_canvas': {
208
+ case 'add_to_canvas':
209
+ case 'learn_skill': {
210
+ // Route through the brain's CAPTURE ENGINE (full lifecycle + markers, incl.
211
+ // 🛠️ skills) when: the caller passed a marker, asked for learn_skill, or the
212
+ // text reads as a reusable rule (auto-skill from the flow — the same
213
+ // classifier the Claude-Code hook uses). Otherwise: flat multi-card append.
214
+ let marker = String(args.marker || '').trim();
215
+ if (skill === 'learn_skill') marker = '+';
216
+ const single = (!args.cards && text.trim()) ? stripVerb(text) : null;
217
+ if (!marker && single && looksLikeSkill(single)) marker = '+'; // NL "remember this gotcha: always…" → skill
218
+ if (marker) {
219
+ const noteText = single ?? (Array.isArray(args.cards) && args.cards[0]?.text) ?? '';
220
+ if (!String(noteText).trim()) return needInput('Nothing to capture — send text or a card to remember.');
221
+ return await opBrainNote({ vault: VAULT, canvas: args.canvas ?? 'brain', text: noteText, area: args.area, marker, closes: args.closes, via });
222
+ }
199
223
  // NL convenience: a bare "remember: X" becomes a single card on the brain.
200
224
  const cards = args.cards ?? (text.trim() ? [{ text: stripVerb(text) }] : null);
201
225
  const parsed = cardsArg.safeParse(cards);
@@ -250,6 +274,7 @@ function routeIntent(text, dataArgs) {
250
274
  const t = String(text || '').toLowerCase();
251
275
  const named = extractCanvas(text);
252
276
  if (/\b(make|build|create|draw|turn .* into).{0,30}(board|canvas|mind ?map|map|diagram)\b/.test(t)) return { skill: 'make_board', args: {} };
277
+ if (/\b(learn (this|a) skill|teach the brain|remember the (convention|rule|gotcha)|this is a (gotcha|pitfall|footgun))\b/.test(t) || /\bskill:/i.test(text)) return { skill: 'learn_skill', args: {} };
253
278
  if (/\b(remember|note this|capture this|log that|record that|add a card)\b/.test(t)) return { skill: 'remember', args: {} };
254
279
  // An explicit read verb OR a specific named canvas → read it. Checked BEFORE
255
280
  // list so "what's on the roadmap canvas" reads that canvas, not the vault index.
@@ -46,6 +46,7 @@ if (process.argv[2] === 'init') {
46
46
  { title: 'Goal', cards: [{ text: '❓ What is this project for, and for whom?\nAgent: survey the repo on your first session and replace this with the real goal.' }] },
47
47
  { title: 'Architecture', cards: [{ text: '❓ Key components and how they fit.\nAgent: record the actual shape from the repo — only what a new session must know.' }] },
48
48
  { title: 'Decisions', cards: [{ text: 'Decisions land here automatically: agents emit `🧠 BRAIN [Area]: …` markers; a new decision that replaces an old one archives it (superseded). Resolve finished items with `✓`, correct in place with `~`. Drag any card into 📌 Focus to make it lead every session brief.' }] },
49
+ { title: '🛠️ Skills', cards: [{ text: '🛠️ Reusable how-tos, gotchas & conventions land here — emit `🧠 BRAIN [Area] +: <skill>` (or just state a rule like "always X / never Y" and it auto-promotes). Skills resurface every session and never age out, unlike one-time decisions. This is "how we work here", inherited by every future agent.' }] },
49
50
  { title: 'Pending / next', cards: [{ text: 'What is in flight and what comes next. Close finished items with the ✓ marker.' }] },
50
51
  { title: 'Open questions', cards: [{ text: 'Unresolved questions (the ❓ marker) live here — the session brief surfaces them first.' }] },
51
52
  { title: '📌 Focus', cards: [{ text: 'Drag any card into this area to make it lead every session brief — steer your agent by moving cards.' }] },
@@ -166,11 +167,11 @@ server.registerTool('add_to_canvas', {
166
167
  });
167
168
 
168
169
  server.registerTool('brain_note', {
169
- title: 'Write a deliberate note to the project brain (decision / question / milestone / resolve / update)',
170
- 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").',
170
+ title: 'Write a deliberate note to the project brain (decision / question / milestone / skill / resolve / update)',
171
+ 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 marker "+" to record a 🛠️ SKILL — a reusable how-to/gotcha/convention ("always dedup zKeys before REORDER") that should resurface every session and never age out, distinct from a one-time decision. Use it to remember a decision, ask an open question, mark a milestone, log a skill, resolve a finished item, or correct a card. Defaults to the project brain ("brain").',
171
172
  inputSchema: {
172
173
  text: z.string().describe('The note — one concise idea; the first line becomes the card title.'),
173
- marker: z.enum(['', '?', '!', '✓', '~']).optional().describe('(none)=decision · ?=open question · !=milestone · ✓=resolve+archive the best-matching card · ~=update the matching card in place. Default: decision.'),
174
+ marker: z.enum(['', '?', '!', '+', '✓', '~']).optional().describe('(none)=decision · ?=open question · !=milestone · +=🛠️ skill (reusable how-to/gotcha; always resurfaces, never ages out) · ✓=resolve+archive the best-matching card · ~=update the matching card in place. Default: decision.'),
174
175
  area: z.string().optional().describe('Area/topic — routes the card into that titled container and becomes a #tag (e.g. "Auth", "Release").'),
175
176
  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.'),
176
177
  canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.8.0",
3
+ "version": "1.9.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",
@@ -576,7 +576,7 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
576
576
  if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
577
577
  const file = t.file;
578
578
  if (!noteText || !String(noteText).trim()) return err('brain_note needs a non-empty text.');
579
- if (!['', '?', '!', '✓', '~'].includes(marker)) return err(`Invalid marker "${marker}" — use: (none)=decision · ?=open question · !=milestone · ✓=resolve a matching card · ~=update a matching card.`);
579
+ if (!['', '?', '!', '✓', '~', '+'].includes(marker)) return err(`Invalid marker "${marker}" — use: (none)=decision · ?=open question · !=milestone · +=skill (reusable how-to) · ✓=resolve a matching card · ~=update a matching card.`);
580
580
  const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', createdVia: via || 'mcp' });
581
581
  try {
582
582
  const res = await captureIntoBrain(fs.readFileSync(file), input);
@@ -561,7 +561,7 @@ export async function tidyBrain(buffer) {
561
561
  // decisions + milestones. Everything older stays in the file, reachable via the
562
562
  // klypix-canvas MCP search or `--full`. Keeps the session-start cost flat as
563
563
  // the brain grows (the full markdown scales with history; this doesn't).
564
- export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMilestones = 8, maxConnections = 30, freshness = null } = {}) {
564
+ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMilestones = 8, maxConnections = 30, maxSkills = 24, freshness = null } = {}) {
565
565
  const cutoff = Date.now() - recentDays * 86_400_000;
566
566
  const texts = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
567
567
  const containers = struct.cards.filter(c => c.type === 'container');
@@ -576,9 +576,14 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
576
576
  // 🎯 (goal/target) reads as an OPEN item alongside ❓ — a goal card is
577
577
  // still-to-do until a ✓/closes: or a covering milestone closes it (so it
578
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));
581
- const plain = rest.filter(c => !open.includes(c) && !miles.includes(c));
579
+ // 🛠️ Skills — reusable how-tos / gotchas / procedures (the '+' marker).
580
+ // Standing reference, NOT a point-in-time event: always shown, never
581
+ // recency-decayed, and excluded from open/milestones/recent so a skill never
582
+ // masquerades as (or ages out like) a decision.
583
+ const skills = rest.filter(c => /🛠/.test(c.text));
584
+ const open = rest.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text));
585
+ const miles = rest.filter(c => /🏁/.test(c.text) && !/❓|🎯|🛠/.test(c.text));
586
+ const plain = rest.filter(c => !open.includes(c) && !miles.includes(c) && !skills.includes(c));
582
587
  const recent = plain.filter(c => c.createdAt >= cutoff).sort((a, b) => b.createdAt - a.createdAt).slice(0, maxRecent);
583
588
  const archivedCount = texts.length - live.length;
584
589
 
@@ -611,6 +616,11 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
611
616
  for (const c of focus) push(`- ${fr(c)}${flat(c.text)}`);
612
617
  }
613
618
  if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
619
+ if (skills.length) {
620
+ push('', '## 🛠️ Skills — how we do things here (reusable; applies every session)');
621
+ for (const c of skills.slice(0, maxSkills)) push(`- ${fr(c)}${flat(c.text)}`);
622
+ if (skills.length > maxSkills) push(`- …and ${skills.length - maxSkills} more skill(s) — search the brain.`);
623
+ }
614
624
  // ⚠️ Conflicts — pairs flagged conflicts_with (e.g. by parallel sessions);
615
625
  // surfaced HIGH so the next session reconciles them, not buries them.
616
626
  const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
@@ -685,6 +695,7 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
685
695
  }
686
696
  if (score <= 0) continue;
687
697
  if ((c.createdAt || 0) >= cutoff) score += 0.5; // gentle recency tiebreak, never dominant
698
+ if (/🛠/.test(c.text)) score += 1; // 🛠️ skills are standing how-tos — surface them when relevant, regardless of age
688
699
  scored.push({ card: c, score });
689
700
  }
690
701
  scored.sort((a, b) => b.score - a.score || (b.card.createdAt || 0) - (a.card.createdAt || 0));
@@ -968,6 +979,25 @@ const overlapScore = (a, b) => {
968
979
  // a real `closes: v1.2.0 staged as a github draft` — only 3 long tokens — silently
969
980
  // failed to fire.)
970
981
  const coverageOf = (target, hay) => { if (!target.size) return 0; let h = 0; for (const w of target) if (hay.has(w)) h++; return h / target.size; };
982
+
983
+ // ── Auto-skill classifier (skills emerge from the flow, not just the '+' marker) ─
984
+ // A REUSABLE skill (how-to / gotcha / convention) reads as a GENERAL RULE that
985
+ // applies next time — distinct from a one-time decision ("we shipped X"). This is
986
+ // the high-precision signal the capture path uses to AUTO-promote a plain decision
987
+ // or a rationale-bearing commit into a 🛠️ skill, so the brain learns "how we work
988
+ // here" without anyone remembering to type '+'. Deliberately conservative: STRONG
989
+ // rule cues only, and NOT pinned to a one-time event (a version/PR#/date/ship verb
990
+ // makes it "what happened", not "how to do it"). The explicit '+' marker always
991
+ // wins and covers everything this misses; a false miss is cheap, a false skill is
992
+ // noisy — so this errs toward silence.
993
+ const STRONG_SKILL_CUES = /(\balways\b|\bnever\b|\bmust\s+(?:not|always)\b|\bdon'?t\s+(?:ever|forget)\b|\bgotcha\b|\bwatch\s+out\b|\bthe\s+trick\s+is\b|\brule\s+of\s+thumb\b|\bby\s+convention\b|\bpitfall\b|\bfootgun\b|\bremember\s+to\b|\bbe\s+sure\s+to\b)/i;
994
+ const SKILL_EVENT_PINS = /(\bv?\d+\.\d+\.\d+\b|\bPR\s*#?\d+\b|#\d{2,}\b|\b20\d{2}-\d{2}-\d{2}\b|\bshipped\b|\breleased\b|\bpublished\b|\bmerged\b|\bdeployed\b)/i;
995
+ export function looksLikeSkill(text) {
996
+ const t = String(text || '');
997
+ if (/🛠|❓|🎯|🏁/.test(t)) return false; // already glyphed (skill/question/goal/milestone) — don't reclassify
998
+ return STRONG_SKILL_CUES.test(t) && !SKILL_EVENT_PINS.test(t);
999
+ }
1000
+
971
1001
  export async function captureIntoBrain(buffer, { cards = [], resolutions = [], updates = [] } = {}) {
972
1002
  const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45, CLOSE_COVER_AT = 0.6;
973
1003
  let work = buffer;
@@ -1095,12 +1125,13 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1095
1125
  // to the new card is drawn in pass 2 (after the new ids exist), matched
1096
1126
  // back by remembering which old card each new card displaced.
1097
1127
  for (const card of cards) {
1098
- if (/❓|🎯|🏁/.test(card.text)) continue; // only plain decisions supersede (not questions/goals/milestones)
1128
+ if (/❓|🎯|🏁|🛠/.test(card.text)) continue; // only plain decisions supersede (not questions/goals/milestones/skills)
1099
1129
  const nTok = tokenSet(card.text);
1100
1130
  const area = (card.area || '').toLowerCase();
1101
1131
  let best = null, bestScore = 0;
1102
1132
  for (const c of liveTextCards()) {
1103
1133
  if (area && (c.area || '').toLowerCase() !== area) continue;
1134
+ if (/🛠/.test(c.text)) continue; // never auto-archive a 🛠️ skill via a decision's supersede — skills are standing reference (correct with ~)
1104
1135
  const s = overlapScore(nTok, tokenSet(c.text));
1105
1136
  if (s > bestScore) { bestScore = s; best = c; }
1106
1137
  }
@@ -1253,7 +1284,7 @@ export function selectGardenCandidates(struct, { keepNewest = GARDEN_KEEP_NEWEST
1253
1284
  const title = (ctn.title || '').trim();
1254
1285
  if (!title || GARDEN_PROTECTED.test(title)) continue;
1255
1286
  const children = struct.cards
1256
- .filter(c => c.type === 'text' && c.parentId === ctn.id && (c.text || '').trim() && !/⤵|↩|✅/.test(c.text))
1287
+ .filter(c => c.type === 'text' && c.parentId === ctn.id && (c.text || '').trim() && !/⤵|↩|✅|🛠/.test(c.text)) // 🛠️ skills are standing reference — never consolidate them away
1257
1288
  .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1258
1289
  const old = children
1259
1290
  .slice(0, Math.max(0, children.length - keepNewest))
@@ -1409,15 +1440,17 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
1409
1440
  // write (the brain_note MCP tool, the brain-note CLI — any agent, not just the
1410
1441
  // Claude-Code hook) get IDENTICAL supersede / resolve / close / dedup semantics as
1411
1442
  // a harvested 🧠 marker. marker ∈ '' (decision) | '?' (open question) | '!'
1412
- // (milestone) | '✓' (resolve+archive a match) | '~' (update a match in place).
1443
+ // (milestone) | '✓' (resolve+archive a match) | '~' (update a match in place) |
1444
+ // '+' (skill — a REUSABLE how-to/gotcha/procedure, standing reference that always
1445
+ // surfaces and never ages out, distinct from a point-in-time decision).
1413
1446
  export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, createdVia = 'mcp' } = {}) {
1414
1447
  const body = String(text).trim();
1415
1448
  if (!body) return { cards: [], resolutions: [], updates: [] };
1416
1449
  const a = String(area || '').trim();
1417
1450
  if (marker === '✓') return { cards: [], resolutions: [{ area: a, text: body }], updates: [] };
1418
1451
  if (marker === '~') return { cards: [], resolutions: [], updates: [{ area: a, text: body, createdVia, ...(evidence ? { evidence } : {}) }] };
1419
- const prefix = marker === '?' ? '❓ ' : marker === '!' ? '🏁 ' : '';
1420
- const borderColor = marker === '?' ? 'rgba(245,166,35,0.8)' : marker === '!' ? 'rgba(59,130,246,0.8)' : 'rgba(16,185,129,0.6)';
1452
+ const prefix = marker === '?' ? '❓ ' : marker === '!' ? '🏁 ' : marker === '+' ? '🛠️ ' : '';
1453
+ const borderColor = marker === '?' ? 'rgba(245,166,35,0.8)' : marker === '!' ? 'rgba(59,130,246,0.8)' : marker === '+' ? 'rgba(139,92,246,0.85)' : 'rgba(16,185,129,0.6)';
1421
1454
  const tag = a ? `\n#${a.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : '';
1422
1455
  const cardText = (a ? `${a}: ${prefix}${body}` : `${prefix}${body}`) + tag;
1423
1456
  return { cards: [{ text: cardText, area: a, color: '#e8e8ed', borderColor, createdVia, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}) }], resolutions: [], updates: [] };