klypix-mcp 1.7.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.
- package/bin/klypix-a2a.mjs +29 -4
- package/bin/klypix-mcp.mjs +10 -4
- package/package.json +1 -1
- package/src/klypix-core.mjs +79 -20
- package/src/klypix-format.mjs +42 -9
package/bin/klypix-a2a.mjs
CHANGED
|
@@ -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.
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import fs from 'fs';
|
|
19
19
|
import path from 'path';
|
|
20
|
+
import { createRequire } from 'module';
|
|
20
21
|
import { z } from 'zod';
|
|
21
22
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
22
23
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
@@ -26,6 +27,10 @@ import {
|
|
|
26
27
|
opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote,
|
|
27
28
|
} from '../src/klypix-core.mjs';
|
|
28
29
|
|
|
30
|
+
// Real package version for the MCP handshake (was hardcoded '1.0.0', which
|
|
31
|
+
// misled every client/version diagnosis — it could never reflect the true release).
|
|
32
|
+
const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version; } catch { return '0.0.0'; } })();
|
|
33
|
+
|
|
29
34
|
// IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
|
|
30
35
|
const log = (...a) => console.error('[klypix-mcp]', ...a);
|
|
31
36
|
|
|
@@ -41,6 +46,7 @@ if (process.argv[2] === 'init') {
|
|
|
41
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.' }] },
|
|
42
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.' }] },
|
|
43
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.' }] },
|
|
44
50
|
{ title: 'Pending / next', cards: [{ text: 'What is in flight and what comes next. Close finished items with the ✓ marker.' }] },
|
|
45
51
|
{ title: 'Open questions', cards: [{ text: 'Unresolved questions (the ❓ marker) live here — the session brief surfaces them first.' }] },
|
|
46
52
|
{ title: '📌 Focus', cards: [{ text: 'Drag any card into this area to make it lead every session brief — steer your agent by moving cards.' }] },
|
|
@@ -63,7 +69,7 @@ const toContent = (r) => {
|
|
|
63
69
|
return r.isError ? { content, isError: true } : { content };
|
|
64
70
|
};
|
|
65
71
|
|
|
66
|
-
const server = new McpServer({ name: 'klypix-canvas', version:
|
|
72
|
+
const server = new McpServer({ name: 'klypix-canvas', version: PKG_VERSION });
|
|
67
73
|
|
|
68
74
|
server.registerTool('list_canvases', {
|
|
69
75
|
title: 'List KLYPIX canvases',
|
|
@@ -161,11 +167,11 @@ server.registerTool('add_to_canvas', {
|
|
|
161
167
|
});
|
|
162
168
|
|
|
163
169
|
server.registerTool('brain_note', {
|
|
164
|
-
title: 'Write a deliberate note to the project brain (decision / question / milestone / resolve / update)',
|
|
165
|
-
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").',
|
|
166
172
|
inputSchema: {
|
|
167
173
|
text: z.string().describe('The note — one concise idea; the first line becomes the card title.'),
|
|
168
|
-
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.'),
|
|
169
175
|
area: z.string().optional().describe('Area/topic — routes the card into that titled container and becomes a #tag (e.g. "Auth", "Release").'),
|
|
170
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.'),
|
|
171
177
|
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
package/package.json
CHANGED
package/src/klypix-core.mjs
CHANGED
|
@@ -85,6 +85,52 @@ export function resolveCanvas(vault, ref) {
|
|
|
85
85
|
return matches[0] || null;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// Resolve the DEFAULT project brain for the brain-* ops, INDEPENDENT of the
|
|
89
|
+
// --vault library folder. The vault answers "where does my .klypix library live"
|
|
90
|
+
// (list/read/search); the BRAIN is "THIS project's ./brain.klypix". Conflating
|
|
91
|
+
// them is what made the brain ops read a stray canvas out of a global vault
|
|
92
|
+
// (the "SS2" bug — a foreign brain.klypix picked by a fuzzy basename walk).
|
|
93
|
+
// Precedence, project-first:
|
|
94
|
+
// 1. KLYPIX_BRAIN env (explicit override)
|
|
95
|
+
// 2. ./brain.klypix in the launch cwd — the project brain (coding agents launch
|
|
96
|
+
// this server with cwd = the project root)
|
|
97
|
+
// 3. <vault>/brain.klypix (exact) — when the vault itself is the brain's home
|
|
98
|
+
// 4. a SINGLE brain.klypix found by walking the vault; if MORE THAN ONE exists
|
|
99
|
+
// we REFUSE to guess (returns { ambiguous }) instead of silently taking one.
|
|
100
|
+
export function resolveDefaultBrain(vault) {
|
|
101
|
+
const ex = (p) => { try { return p && fs.existsSync(p) ? path.resolve(p) : null; } catch { return null; } };
|
|
102
|
+
let f;
|
|
103
|
+
if ((f = ex(process.env.KLYPIX_BRAIN))) return { file: f, how: 'env (KLYPIX_BRAIN)' };
|
|
104
|
+
if ((f = ex(path.join(process.cwd(), 'brain.klypix')))) return { file: f, how: 'project cwd' };
|
|
105
|
+
if ((f = ex(path.join(vault, 'brain.klypix')))) return { file: f, how: 'vault root' };
|
|
106
|
+
const matches = walkVault(vault).filter(p => /^brain\.(klypix|any)$/i.test(path.basename(p)));
|
|
107
|
+
if (matches.length === 1) return { file: matches[0], how: 'vault search' };
|
|
108
|
+
if (matches.length > 1) return { ambiguous: matches };
|
|
109
|
+
return { file: null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Resolve the brain a brain-* op should act on. An explicit `canvas` arg → exact
|
|
113
|
+
// resolve against the vault; otherwise the project-aware default above. Always
|
|
114
|
+
// returns one of: { file, how } · { ambiguous: [paths] } · { file: null }.
|
|
115
|
+
export function brainTarget(vault, canvas) {
|
|
116
|
+
if (canvas) { const file = resolveCanvas(vault, canvas); return file ? { file, how: `canvas:"${canvas}"` } : { file: null }; }
|
|
117
|
+
return resolveDefaultBrain(vault);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One-line provenance shown atop every brain-op result so a wrong brain (the
|
|
121
|
+
// "SS2" class) is OBVIOUS at a glance instead of silent. Counts non-container cards.
|
|
122
|
+
export function brainStamp(file, struct, how) {
|
|
123
|
+
const title = (struct && struct.title) || path.basename(file);
|
|
124
|
+
const n = struct ? struct.cards.filter(c => c.type !== 'container').length : '?';
|
|
125
|
+
return `_brain: ${path.basename(file)} · “${title}” · ${n} cards${how ? ' · via ' + how : ''}_\n\n`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Shared error for the refuse-to-guess case — names the candidates so the caller
|
|
129
|
+
// can pick one (canvas:"<path>") or fix the vault / run from the project root.
|
|
130
|
+
function ambiguousBrainErr(matches) {
|
|
131
|
+
return err(`Found ${matches.length} brain.klypix files in the vault and no ./brain.klypix in the current project — refusing to guess which is "the brain". Pass canvas:"<path>", run from the project root, or set KLYPIX_BRAIN. Candidates:\n${matches.map(m => ' - ' + m).join('\n')}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
88
134
|
function safeName(vault, title) {
|
|
89
135
|
const base = String(title || 'untitled').replace(/[^\w\- ]+/g, '').trim() || 'untitled';
|
|
90
136
|
let name = base, n = 1;
|
|
@@ -273,7 +319,7 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
|
|
|
273
319
|
if (pipe) { try { [qv] = await embedTexts(pipe, [q]); } catch { /* lexical only */ } }
|
|
274
320
|
|
|
275
321
|
let curKey = null;
|
|
276
|
-
try { const cb =
|
|
322
|
+
try { const cb = resolveDefaultBrain(vault).file; if (cb) curKey = path.resolve(cb).replace(/\\/g, '/').toLowerCase(); } catch { /* no current brain */ }
|
|
277
323
|
const fresh = Date.now() - 30 * 86_400_000;
|
|
278
324
|
const scored = [];
|
|
279
325
|
for (const b of brains) {
|
|
@@ -324,12 +370,13 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
|
|
|
324
370
|
}
|
|
325
371
|
|
|
326
372
|
export async function opBrainInsights({ vault, canvas, staleDays }) {
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
373
|
+
const t = brainTarget(vault, canvas);
|
|
374
|
+
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
375
|
+
if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>", or run \`npx klypix-mcp init\` to create one.`);
|
|
329
376
|
try {
|
|
330
|
-
const { struct } = await parseKlypix(fs.readFileSync(file));
|
|
377
|
+
const { struct } = await parseKlypix(fs.readFileSync(t.file));
|
|
331
378
|
const ins = brainInsights(struct, staleDays ? { staleDays } : {});
|
|
332
|
-
return { blocks: [text(insightsToMarkdown(ins, struct.title))] };
|
|
379
|
+
return { blocks: [text(brainStamp(t.file, struct, t.how) + insightsToMarkdown(ins, struct.title))] };
|
|
333
380
|
} catch (e) {
|
|
334
381
|
return err(`Insights failed: ${e.message}`);
|
|
335
382
|
}
|
|
@@ -359,20 +406,23 @@ export function collectMigrationFiles(root) {
|
|
|
359
406
|
return out;
|
|
360
407
|
}
|
|
361
408
|
export async function opBrainReconcile({ vault, canvas, root }) {
|
|
362
|
-
const
|
|
363
|
-
if (
|
|
409
|
+
const t = brainTarget(vault, canvas);
|
|
410
|
+
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
411
|
+
if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
|
|
412
|
+
const file = t.file;
|
|
364
413
|
let struct;
|
|
365
414
|
try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
|
|
415
|
+
const stamp = brainStamp(file, struct, t.how);
|
|
366
416
|
// Migrations live in the CODE repo (usually beside brain.klypix), not in a
|
|
367
417
|
// separate canvas vault — so default the root to the brain file's folder.
|
|
368
418
|
const repoRoot = root ? path.resolve(root) : path.dirname(file);
|
|
369
419
|
const files = collectMigrationFiles(repoRoot);
|
|
370
|
-
if (!files.length) return { blocks: [text(`No migration files under ${repoRoot} (looked in: ${MIGRATION_DIRS.join(', ')}). Nothing to reconcile.`)] };
|
|
420
|
+
if (!files.length) return { blocks: [text(stamp + `No migration files under ${repoRoot} (looked in: ${MIGRATION_DIRS.join(', ')}). Nothing to reconcile.`)] };
|
|
371
421
|
const { gaps, total } = findUnrecordedMigrations(struct, files, { max: 20 });
|
|
372
|
-
if (!gaps.length) return { blocks: [text(`✓ All ${files.length} migration(s) under ${path.basename(repoRoot)} are referenced by a brain card — no unrecorded rollouts.`)] };
|
|
422
|
+
if (!gaps.length) return { blocks: [text(stamp + `✓ All ${files.length} migration(s) under ${path.basename(repoRoot)} are referenced by a brain card — no unrecorded rollouts.`)] };
|
|
373
423
|
const lines = gaps.map(g => `- \`${g.path}\` — committed, but no brain card mentions it. If applied to prod, record it:\n \`🧠 BRAIN [DB] !: migration ${g.file.replace(/\.sql$/i, '')} applied to prod ev: ${g.path}\``);
|
|
374
424
|
const more = total > gaps.length ? `\n\n…and ${total - gaps.length} more.` : '';
|
|
375
|
-
return { blocks: [text(`# ⚠️ ${total} migration(s) committed but unrecorded in the brain\n_The brain can't see prod — it flags migrations that are in git but unmentioned, so you can confirm the rollout. It never asserts a migration was applied. To dismiss one without applying, record any card that names it (e.g. "committed, not applied")._\n\n${lines.join('\n')}${more}`)] };
|
|
425
|
+
return { blocks: [text(stamp + `# ⚠️ ${total} migration(s) committed but unrecorded in the brain\n_The brain can't see prod — it flags migrations that are in git but unmentioned, so you can confirm the rollout. It never asserts a migration was applied. To dismiss one without applying, record any card that names it (e.g. "committed, not applied")._\n\n${lines.join('\n')}${more}`)] };
|
|
376
426
|
}
|
|
377
427
|
|
|
378
428
|
// ── Brain gardener (two-phase: select → agent synthesizes → apply) ───────────
|
|
@@ -381,17 +431,20 @@ export async function opBrainReconcile({ vault, canvas, root }) {
|
|
|
381
431
|
// prose); apply consolidates each area into a 🌿 card and archives the originals
|
|
382
432
|
// with audit arrows. Mirrors brain_connect's dry-run/apply discipline.
|
|
383
433
|
export async function opBrainGarden({ vault, canvas, apply = false, syntheses }) {
|
|
384
|
-
const
|
|
385
|
-
if (
|
|
434
|
+
const t = brainTarget(vault, canvas);
|
|
435
|
+
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
436
|
+
if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
|
|
437
|
+
const file = t.file;
|
|
386
438
|
let struct;
|
|
387
439
|
try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
|
|
440
|
+
const stamp = brainStamp(file, struct, t.how);
|
|
388
441
|
const areas = selectGardenCandidates(struct);
|
|
389
|
-
if (!areas.length) return { blocks: [text('Nothing to garden — no area has 3+ DORMANT cards (old, beyond its newest 8, AND peripheral/≤1 link). Anything still woven into the graph is protected. The brain is tidy.')] };
|
|
442
|
+
if (!areas.length) return { blocks: [text(stamp + 'Nothing to garden — no area has 3+ DORMANT cards (old, beyond its newest 8, AND peripheral/≤1 link). Anything still woven into the graph is protected. The brain is tidy.')] };
|
|
390
443
|
|
|
391
444
|
if (!apply) {
|
|
392
445
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
393
446
|
const body = areas.map(a => `## ${a.title} (${a.candidates.length} dormant cards)\n` + a.candidates.map(c => `- ${flat(c.text).slice(0, 240)}`).join('\n')).join('\n\n');
|
|
394
|
-
return { blocks: [text(`# 🌿 Gardener — ${areas.length} area(s) with DORMANT cards to consolidate\nThese are old, peripheral (≤1 link) cards only — hubs and still-referenced decisions were left untouched. For EACH area below, write ONE tight synthesis (3-6 sentences, plain prose, no headers) that preserves every still-relevant fact / decision / number and drops only repetition + play-by-play. Then call \`brain_garden\` again with \`apply:true\` and \`syntheses: [{ "title": "<area title EXACTLY as shown>", "synthesis": "<text>" }, …]\`. Originals are archived with audit arrows — nothing is deleted; one undo un-gardens.\n\n${body}`)] };
|
|
447
|
+
return { blocks: [text(stamp + `# 🌿 Gardener — ${areas.length} area(s) with DORMANT cards to consolidate\nThese are old, peripheral (≤1 link) cards only — hubs and still-referenced decisions were left untouched. For EACH area below, write ONE tight synthesis (3-6 sentences, plain prose, no headers) that preserves every still-relevant fact / decision / number and drops only repetition + play-by-play. Then call \`brain_garden\` again with \`apply:true\` and \`syntheses: [{ "title": "<area title EXACTLY as shown>", "synthesis": "<text>" }, …]\`. Originals are archived with audit arrows — nothing is deleted; one undo un-gardens.\n\n${body}`)] };
|
|
395
448
|
}
|
|
396
449
|
|
|
397
450
|
if (!Array.isArray(syntheses) || !syntheses.length) return err('apply:true needs syntheses:[{title, synthesis}, …] — run the dry run first (apply omitted) to get the areas + their cards.');
|
|
@@ -410,8 +463,10 @@ export async function opBrainGarden({ vault, canvas, apply = false, syntheses })
|
|
|
410
463
|
}
|
|
411
464
|
|
|
412
465
|
export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, log = () => {} }) {
|
|
413
|
-
const
|
|
414
|
-
if (
|
|
466
|
+
const tgt = brainTarget(vault, canvas);
|
|
467
|
+
if (tgt.ambiguous) return ambiguousBrainErr(tgt.ambiguous);
|
|
468
|
+
if (!tgt.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}.`);
|
|
469
|
+
const file = tgt.file;
|
|
415
470
|
let struct;
|
|
416
471
|
try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
|
|
417
472
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 70);
|
|
@@ -516,10 +571,12 @@ export async function opAddToCanvas({ vault, canvas, cards, connections, via })
|
|
|
516
571
|
// can now record a decision, ask an open question, mark a milestone, resolve a card,
|
|
517
572
|
// or correct one — with the full lifecycle, not just a flat append.
|
|
518
573
|
export async function opBrainNote({ vault, canvas, text: noteText, area, marker = '', closes, via }) {
|
|
519
|
-
const
|
|
520
|
-
if (
|
|
574
|
+
const t = brainTarget(vault, canvas);
|
|
575
|
+
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
576
|
+
if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
|
|
577
|
+
const file = t.file;
|
|
521
578
|
if (!noteText || !String(noteText).trim()) return err('brain_note needs a non-empty text.');
|
|
522
|
-
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.`);
|
|
523
580
|
const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', createdVia: via || 'mcp' });
|
|
524
581
|
try {
|
|
525
582
|
const res = await captureIntoBrain(fs.readFileSync(file), input);
|
|
@@ -528,7 +585,9 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
|
|
|
528
585
|
const s = res.stats || {};
|
|
529
586
|
const bits = [`${s.added || 0} added`];
|
|
530
587
|
for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
|
|
531
|
-
|
|
588
|
+
// Name the resolved brain explicitly (basename + how) so a write never lands
|
|
589
|
+
// in a surprise file silently — the write-side twin of the read-op stamp.
|
|
590
|
+
return { blocks: [text(`✓ brain_note → ${path.basename(file)} (via ${t.how}) · ${bits.join(' · ')}. Reopen the brain in KLYPIX to see it.`)] };
|
|
532
591
|
} catch (e) {
|
|
533
592
|
return err(`brain_note failed (brain unchanged): ${e.message}`);
|
|
534
593
|
}
|
package/src/klypix-format.mjs
CHANGED
|
@@ -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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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 (
|
|
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() &&
|
|
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: [] };
|