klypix-mcp 1.6.0 → 1.8.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/README.md +20 -0
- package/bin/klypix-mcp.mjs +20 -2
- package/package.json +1 -1
- package/src/klypix-core.mjs +109 -15
- package/src/klypix-format.mjs +169 -0
package/README.md
CHANGED
|
@@ -52,9 +52,29 @@ notes into a board,"* or *"add a card with the decision we just made."*
|
|
|
52
52
|
| `list_canvases` | List every `.klypix` in the vault |
|
|
53
53
|
| `read_canvas` | Read a canvas as markdown (cards, the connection graph, `[[links]]`, `#tags`) |
|
|
54
54
|
| `search_canvases` | Search across canvases by name + content |
|
|
55
|
+
| `search_all_brains` | Cross-project memory search across every registered brain |
|
|
56
|
+
| `brain_insights` | Hubs, orphaned decisions, stale questions, area sizes |
|
|
57
|
+
| `brain_connect` | Find + draw related-but-unlinked cards (densify the graph) |
|
|
58
|
+
| `brain_reconcile` | Flag committed-but-unrecorded DB migrations (the brain can't see prod) |
|
|
55
59
|
| `create_canvas` | Create a new `.klypix` from cards + connections |
|
|
56
60
|
| `add_to_canvas` | Append cards/connections to an existing canvas (positions preserved) |
|
|
57
61
|
|
|
62
|
+
### Tools vs. the *automatic* brain
|
|
63
|
+
|
|
64
|
+
This package is the **agent-neutral read/write surface** — any MCP client (Claude
|
|
65
|
+
Code, Claude Desktop, Cursor, Cline, Windsurf…) gets the **tools** above and can
|
|
66
|
+
read, search, and write canvases on demand (*pull*). That works in any agent, in
|
|
67
|
+
any project.
|
|
68
|
+
|
|
69
|
+
The **automatic** brain — auto-capturing decisions from your work, injecting the
|
|
70
|
+
relevant cards into each prompt, and coordinating across concurrent sessions
|
|
71
|
+
(*push*) — runs in a host **hook**, which is a Claude Code / KLYPIX-desktop
|
|
72
|
+
feature, not part of this npm package. So `npx klypix-mcp` gives you the tools
|
|
73
|
+
everywhere; the hands-free brain comes with the [KLYPIX desktop app](https://klypix.com)
|
|
74
|
+
or the Claude Code project-brain hook. (`search_all_brains` is also hook-fed — it
|
|
75
|
+
reads the cross-project registry the hook writes, so it stays empty until a hook
|
|
76
|
+
has registered at least one brain.)
|
|
77
|
+
|
|
58
78
|
## Also speaks A2A (Agent-to-Agent)
|
|
59
79
|
|
|
60
80
|
The same engine is exposed as an **A2A agent** so other agents and orchestrators
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -17,15 +17,20 @@
|
|
|
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';
|
|
23
24
|
import {
|
|
24
25
|
resolveVault, getEmbedder, buildKlypixMap, cardSchema, connSchema,
|
|
25
26
|
opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
|
|
26
|
-
opBrainInsights, opBrainConnect, opBrainReconcile, opCreateCanvas, opAddToCanvas, opBrainNote,
|
|
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
|
|
|
@@ -63,7 +68,7 @@ const toContent = (r) => {
|
|
|
63
68
|
return r.isError ? { content, isError: true } : { content };
|
|
64
69
|
};
|
|
65
70
|
|
|
66
|
-
const server = new McpServer({ name: 'klypix-canvas', version:
|
|
71
|
+
const server = new McpServer({ name: 'klypix-canvas', version: PKG_VERSION });
|
|
67
72
|
|
|
68
73
|
server.registerTool('list_canvases', {
|
|
69
74
|
title: 'List KLYPIX canvases',
|
|
@@ -121,6 +126,19 @@ server.registerTool('brain_reconcile', {
|
|
|
121
126
|
},
|
|
122
127
|
}, async ({ canvas, root }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root })));
|
|
123
128
|
|
|
129
|
+
server.registerTool('brain_garden', {
|
|
130
|
+
title: 'Garden the brain — consolidate over-grown areas (sleep-time compute)',
|
|
131
|
+
description: 'Tidy an over-grown brain WITHOUT losing anything — SMART and non-invasive: it only consolidates DORMANT cards (old + peripheral), never load-bearing ones. Two phases: call it with no apply to get the areas that have accumulated forgotten cards (deterministic: >3 cards that are older than 14 days, beyond the area\'s newest 8, AND have ≤1 connection — so hubs and still-referenced decisions are left untouched; Focus/Instructions/Archive/Open-questions areas protected) plus their card text; YOU write one tight synthesis per area; then call again with apply:true and syntheses:[{title, synthesis}]. Each area gets a 🌿 synthesis card, the originals are stamped "⤵ consolidated", moved to Archive, and arrowed to the synthesis — nothing is deleted, and one undo un-gardens. Run it when brain_insights or the brief shows an area has grown noisy.',
|
|
132
|
+
inputSchema: {
|
|
133
|
+
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
134
|
+
apply: z.boolean().optional().describe('false (default) = list over-grown areas + cards to synthesize; true = consolidate using the supplied syntheses.'),
|
|
135
|
+
syntheses: z.array(z.object({
|
|
136
|
+
title: z.string().describe('Area title EXACTLY as returned by the dry run.'),
|
|
137
|
+
synthesis: z.string().describe('3-6 sentence prose synthesis preserving every still-relevant fact/decision/number.'),
|
|
138
|
+
})).optional().describe('Required when apply:true — one entry per area you want consolidated.'),
|
|
139
|
+
},
|
|
140
|
+
}, async ({ canvas, apply, syntheses }) => toContent(await opBrainGarden({ vault: VAULT, canvas, apply, syntheses })));
|
|
141
|
+
|
|
124
142
|
server.registerTool('create_canvas', {
|
|
125
143
|
title: 'Create a KLYPIX canvas',
|
|
126
144
|
description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in KLYPIX (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows.',
|
package/package.json
CHANGED
package/src/klypix-core.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown,
|
|
26
26
|
brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
|
|
27
27
|
findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
|
|
28
|
+
selectGardenCandidates, applyGarden,
|
|
28
29
|
} from './klypix-format.mjs';
|
|
29
30
|
|
|
30
31
|
// ── Card / connection input shape (single source for every face) ─────────────
|
|
@@ -84,6 +85,52 @@ export function resolveCanvas(vault, ref) {
|
|
|
84
85
|
return matches[0] || null;
|
|
85
86
|
}
|
|
86
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
|
+
|
|
87
134
|
function safeName(vault, title) {
|
|
88
135
|
const base = String(title || 'untitled').replace(/[^\w\- ]+/g, '').trim() || 'untitled';
|
|
89
136
|
let name = base, n = 1;
|
|
@@ -272,7 +319,7 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
|
|
|
272
319
|
if (pipe) { try { [qv] = await embedTexts(pipe, [q]); } catch { /* lexical only */ } }
|
|
273
320
|
|
|
274
321
|
let curKey = null;
|
|
275
|
-
try { const cb =
|
|
322
|
+
try { const cb = resolveDefaultBrain(vault).file; if (cb) curKey = path.resolve(cb).replace(/\\/g, '/').toLowerCase(); } catch { /* no current brain */ }
|
|
276
323
|
const fresh = Date.now() - 30 * 86_400_000;
|
|
277
324
|
const scored = [];
|
|
278
325
|
for (const b of brains) {
|
|
@@ -323,12 +370,13 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
|
|
|
323
370
|
}
|
|
324
371
|
|
|
325
372
|
export async function opBrainInsights({ vault, canvas, staleDays }) {
|
|
326
|
-
const
|
|
327
|
-
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.`);
|
|
328
376
|
try {
|
|
329
|
-
const { struct } = await parseKlypix(fs.readFileSync(file));
|
|
377
|
+
const { struct } = await parseKlypix(fs.readFileSync(t.file));
|
|
330
378
|
const ins = brainInsights(struct, staleDays ? { staleDays } : {});
|
|
331
|
-
return { blocks: [text(insightsToMarkdown(ins, struct.title))] };
|
|
379
|
+
return { blocks: [text(brainStamp(t.file, struct, t.how) + insightsToMarkdown(ins, struct.title))] };
|
|
332
380
|
} catch (e) {
|
|
333
381
|
return err(`Insights failed: ${e.message}`);
|
|
334
382
|
}
|
|
@@ -358,25 +406,67 @@ export function collectMigrationFiles(root) {
|
|
|
358
406
|
return out;
|
|
359
407
|
}
|
|
360
408
|
export async function opBrainReconcile({ vault, canvas, root }) {
|
|
361
|
-
const
|
|
362
|
-
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;
|
|
363
413
|
let struct;
|
|
364
414
|
try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
|
|
415
|
+
const stamp = brainStamp(file, struct, t.how);
|
|
365
416
|
// Migrations live in the CODE repo (usually beside brain.klypix), not in a
|
|
366
417
|
// separate canvas vault — so default the root to the brain file's folder.
|
|
367
418
|
const repoRoot = root ? path.resolve(root) : path.dirname(file);
|
|
368
419
|
const files = collectMigrationFiles(repoRoot);
|
|
369
|
-
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.`)] };
|
|
370
421
|
const { gaps, total } = findUnrecordedMigrations(struct, files, { max: 20 });
|
|
371
|
-
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.`)] };
|
|
372
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}\``);
|
|
373
424
|
const more = total > gaps.length ? `\n\n…and ${total - gaps.length} more.` : '';
|
|
374
|
-
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}`)] };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Brain gardener (two-phase: select → agent synthesizes → apply) ───────────
|
|
429
|
+
// The portable /garden. Dry-run returns the over-grown areas + their old cards
|
|
430
|
+
// for the CALLING agent to synthesize (the engine is pure — the model writes the
|
|
431
|
+
// prose); apply consolidates each area into a 🌿 card and archives the originals
|
|
432
|
+
// with audit arrows. Mirrors brain_connect's dry-run/apply discipline.
|
|
433
|
+
export async function opBrainGarden({ vault, canvas, apply = false, syntheses }) {
|
|
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;
|
|
438
|
+
let struct;
|
|
439
|
+
try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
|
|
440
|
+
const stamp = brainStamp(file, struct, t.how);
|
|
441
|
+
const areas = selectGardenCandidates(struct);
|
|
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.')] };
|
|
443
|
+
|
|
444
|
+
if (!apply) {
|
|
445
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
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');
|
|
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}`)] };
|
|
448
|
+
}
|
|
449
|
+
|
|
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.');
|
|
451
|
+
try {
|
|
452
|
+
const { buffer, stats } = await applyGarden(fs.readFileSync(file), { syntheses });
|
|
453
|
+
const skippedNote = (stats.skipped && stats.skipped.length)
|
|
454
|
+
? `\n\n⚠️ Left untouched (faithfulness guard): ${stats.skipped.map(s => `"${s.title}" — ${s.reason}`).join('; ')}.`
|
|
455
|
+
: '';
|
|
456
|
+
if (!stats.synthCards) return { blocks: [text(`No areas consolidated — each synthesis \`title\` must match a dry-run area title exactly.${skippedNote}`)] };
|
|
457
|
+
let out = buffer; try { out = (await tidyBrain(buffer)).buffer; } catch { /* keep apply result if tidy fails */ }
|
|
458
|
+
await atomicWrite(file, out);
|
|
459
|
+
return { blocks: [text(`🌿 Gardened ${stats.areas} area(s): ${stats.archived} old card(s) → ${stats.synthCards} synthesis card(s); originals archived with "consolidated into" arrows (any prose-dropped figures appended verbatim). Reopen the brain in KLYPIX to see it.${skippedNote}`)] };
|
|
460
|
+
} catch (e) {
|
|
461
|
+
return err(`Garden apply failed (brain unchanged): ${e.message}`);
|
|
462
|
+
}
|
|
375
463
|
}
|
|
376
464
|
|
|
377
465
|
export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, log = () => {} }) {
|
|
378
|
-
const
|
|
379
|
-
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;
|
|
380
470
|
let struct;
|
|
381
471
|
try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
|
|
382
472
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 70);
|
|
@@ -481,8 +571,10 @@ export async function opAddToCanvas({ vault, canvas, cards, connections, via })
|
|
|
481
571
|
// can now record a decision, ask an open question, mark a milestone, resolve a card,
|
|
482
572
|
// or correct one — with the full lifecycle, not just a flat append.
|
|
483
573
|
export async function opBrainNote({ vault, canvas, text: noteText, area, marker = '', closes, via }) {
|
|
484
|
-
const
|
|
485
|
-
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;
|
|
486
578
|
if (!noteText || !String(noteText).trim()) return err('brain_note needs a non-empty text.');
|
|
487
579
|
if (!['', '?', '!', '✓', '~'].includes(marker)) return err(`Invalid marker "${marker}" — use: (none)=decision · ?=open question · !=milestone · ✓=resolve a matching card · ~=update a matching card.`);
|
|
488
580
|
const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', createdVia: via || 'mcp' });
|
|
@@ -493,7 +585,9 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
|
|
|
493
585
|
const s = res.stats || {};
|
|
494
586
|
const bits = [`${s.added || 0} added`];
|
|
495
587
|
for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
|
|
496
|
-
|
|
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.`)] };
|
|
497
591
|
} catch (e) {
|
|
498
592
|
return err(`brain_note failed (brain unchanged): ${e.message}`);
|
|
499
593
|
}
|
package/src/klypix-format.mjs
CHANGED
|
@@ -1200,6 +1200,175 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1200
1200
|
return { buffer: work, stats };
|
|
1201
1201
|
}
|
|
1202
1202
|
|
|
1203
|
+
// ── Brain gardener — sleep-time consolidation with a visible audit trail ─────
|
|
1204
|
+
// The portable engine twin of the in-app /garden (so ANY agent can run it over
|
|
1205
|
+
// MCP, not just the KLYPIX canvas). Two phases, like brain_connect: the engine
|
|
1206
|
+
// SELECTS deterministically (the model never decides WHAT merges, only writes the
|
|
1207
|
+
// prose), the agent writes one synthesis per area, then the engine APPLIES — each
|
|
1208
|
+
// area gets a 🌿 synthesis card; the originals are stamped "⤵ consolidated", moved
|
|
1209
|
+
// to Archive, and arrowed → the synthesis. Nothing is deleted (archived verbatim).
|
|
1210
|
+
const GARDEN_KEEP_NEWEST = 8; // per area, never consolidate the newest N
|
|
1211
|
+
const GARDEN_MIN_AGE_DAYS = 14; // only cards older than this are candidates
|
|
1212
|
+
const GARDEN_MIN_CANDIDATES = 3; // don't bother merging fewer than this
|
|
1213
|
+
const GARDEN_MAX_DEGREE = 1; // SMART guard: protect load-bearing cards —
|
|
1214
|
+
// only consolidate cards with ≤ this many connections (orphans + leaves). A
|
|
1215
|
+
// card the graph leans on (degree ≥ 2) is signal, not noise, and is left alone.
|
|
1216
|
+
// Areas the gardener must never touch: human steering + config + its own output.
|
|
1217
|
+
const GARDEN_PROTECTED = /^(archive|📌?\s*focus|(🤖\s*)?(agent\s+)?instructions|open questions|pending)/i;
|
|
1218
|
+
// Faithfulness guard: a synthesis shorter than this (after whitespace-collapse)
|
|
1219
|
+
// is treated as degenerate (model returned a stub) and its area is skipped.
|
|
1220
|
+
const MIN_SYNTHESIS_CHARS = 60;
|
|
1221
|
+
// Distinct "figures" worth never losing — tokens carrying ≥2 digits (versions
|
|
1222
|
+
// 1.3.7, dates 2026-06-24, sizes 50mb, counts 326, migration ids). Trivial single
|
|
1223
|
+
// digits (1, 3) are ignored. Used to append any prose-dropped figure verbatim.
|
|
1224
|
+
const figuresIn = (text) => {
|
|
1225
|
+
const out = new Set();
|
|
1226
|
+
for (const m of String(text || '').matchAll(/[0-9][0-9a-zA-Z._:-]*/g)) {
|
|
1227
|
+
const tok = m[0].replace(/[._:-]+$/, '').toLowerCase();
|
|
1228
|
+
if ((tok.match(/\d/g) || []).length >= 2) out.add(tok);
|
|
1229
|
+
}
|
|
1230
|
+
return out;
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
// Deterministic candidate selection — PURE, so the model never chooses WHAT to
|
|
1234
|
+
// merge. SMART + non-invasive: a card is a candidate only if it's DORMANT —
|
|
1235
|
+
// old (> minAgeDays), beyond the area's newest N, AND peripheral (connection
|
|
1236
|
+
// degree ≤ maxDegree). That protects hubs and still-referenced cards (the spine
|
|
1237
|
+
// of the brain), so consolidation hits forgotten noise — the same cards
|
|
1238
|
+
// brain_insights flags as orphaned — never load-bearing decisions. Returns each
|
|
1239
|
+
// over-grown area with its dormant cards (oldest first), each tagged with degree.
|
|
1240
|
+
export function selectGardenCandidates(struct, { keepNewest = GARDEN_KEEP_NEWEST, minAgeDays = GARDEN_MIN_AGE_DAYS, minCandidates = GARDEN_MIN_CANDIDATES, maxDegree = GARDEN_MAX_DEGREE, now = Date.now() } = {}) {
|
|
1241
|
+
if (!struct || !Array.isArray(struct.cards)) return [];
|
|
1242
|
+
const cutoff = now - minAgeDays * 86_400_000;
|
|
1243
|
+
// Connection degree per card — both ends of every edge. A card that is linked
|
|
1244
|
+
// to (or links out to) the rest of the graph is structurally load-bearing.
|
|
1245
|
+
const degree = new Map();
|
|
1246
|
+
for (const cn of (struct.connections || [])) {
|
|
1247
|
+
if (cn.fromId) degree.set(cn.fromId, (degree.get(cn.fromId) || 0) + 1);
|
|
1248
|
+
if (cn.toId) degree.set(cn.toId, (degree.get(cn.toId) || 0) + 1);
|
|
1249
|
+
}
|
|
1250
|
+
const out = [];
|
|
1251
|
+
for (const ctn of struct.cards) {
|
|
1252
|
+
if (ctn.type !== 'container') continue;
|
|
1253
|
+
const title = (ctn.title || '').trim();
|
|
1254
|
+
if (!title || GARDEN_PROTECTED.test(title)) continue;
|
|
1255
|
+
const children = struct.cards
|
|
1256
|
+
.filter(c => c.type === 'text' && c.parentId === ctn.id && (c.text || '').trim() && !/⤵|↩|✅/.test(c.text))
|
|
1257
|
+
.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
1258
|
+
const old = children
|
|
1259
|
+
.slice(0, Math.max(0, children.length - keepNewest))
|
|
1260
|
+
.filter(c => (c.createdAt || 0) < cutoff && (degree.get(c.id) || 0) <= maxDegree); // dormant: old AND peripheral
|
|
1261
|
+
if (old.length >= minCandidates) out.push({ containerId: ctn.id, title, candidates: old.map(c => ({ id: c.id, text: c.text, createdAt: c.createdAt || 0, degree: degree.get(c.id) || 0 })) });
|
|
1262
|
+
}
|
|
1263
|
+
return out;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Apply: re-selects deterministically (robust to drift since the dry-run) and,
|
|
1267
|
+
// for each area the agent supplied a synthesis for, adds the 🌿 card + archives
|
|
1268
|
+
// the originals with audit arrows. `syntheses`: [{ title, synthesis }].
|
|
1269
|
+
export async function applyGarden(buffer, { syntheses = [] } = {}) {
|
|
1270
|
+
const stats = { areas: 0, archived: 0, synthCards: 0, skipped: [] };
|
|
1271
|
+
const { zip, canvas, manifest, isV4, struct } = await parseKlypix(buffer);
|
|
1272
|
+
if (!isV4 || !canvas.positions) throw new Error('garden needs a v4 .klypix');
|
|
1273
|
+
const areas = selectGardenCandidates(struct);
|
|
1274
|
+
const synthByTitle = new Map();
|
|
1275
|
+
for (const s of syntheses || []) { const t = String(s?.title || '').trim().toLowerCase(); const txt = String(s?.synthesis || '').trim(); if (t && txt) synthByTitle.set(t, txt); }
|
|
1276
|
+
if (!areas.length || !synthByTitle.size) return { buffer, stats };
|
|
1277
|
+
|
|
1278
|
+
const now = Date.now();
|
|
1279
|
+
const today = new Date(now).toISOString().slice(0, 10);
|
|
1280
|
+
const rand = () => Math.random().toString(36).slice(2, 10);
|
|
1281
|
+
const top = Object.values(canvas.positions).map(p => p && p.zKey).filter(k => k && isValidZKey(k)).sort().pop() || null;
|
|
1282
|
+
const nextZKey = makeZKeyGen(top);
|
|
1283
|
+
canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
1284
|
+
const byTitle = new Map();
|
|
1285
|
+
for (const c of struct.cards) if (c.type === 'container') { const t = (c.title || '').trim().toLowerCase(); if (t && !byTitle.has(t)) byTitle.set(t, c.id); }
|
|
1286
|
+
// Archive primitives (mirror captureIntoBrain): find-or-create Archive, move a
|
|
1287
|
+
// card into it un-baking any group-shrink, and rewrite a card's text in place.
|
|
1288
|
+
const ensureArchive = () => {
|
|
1289
|
+
let id = byTitle.get('archive');
|
|
1290
|
+
if (id) return id;
|
|
1291
|
+
id = `ctn_${rand()}`;
|
|
1292
|
+
const G = BRAIN_GEOM;
|
|
1293
|
+
zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify({ type: 'container', locked: false, createdAt: now, createdBy: 'agent', title: 'Archive', collapsed: false, scopeLocked: false, borderColor: 'rgba(120,120,135,0.6)' }));
|
|
1294
|
+
canvas.positions[id] = { x: nextContainerX(canvas), y: G.START, w: G.AREA_W, h: G.TITLE_BAR + G.PAD * 2, zKey: nextZKey(), zIndex: canvas.order.length, parentId: null };
|
|
1295
|
+
canvas.order.push(id);
|
|
1296
|
+
byTitle.set('archive', id);
|
|
1297
|
+
return id;
|
|
1298
|
+
};
|
|
1299
|
+
const rewriteCard = async (id, mutate) => {
|
|
1300
|
+
const ip = `items/${shard(id)}/${id}.json`;
|
|
1301
|
+
const f = zip.file(ip); if (!f) return false;
|
|
1302
|
+
const j = JSON.parse(await f.async('string'));
|
|
1303
|
+
mutate(j);
|
|
1304
|
+
j.content = wrapText(String(j.content || ''));
|
|
1305
|
+
zip.file(ip, JSON.stringify(j));
|
|
1306
|
+
const pos = canvas.positions[id];
|
|
1307
|
+
if (pos) canvas.positions[id] = { ...pos, h: measureCardH(j.content) };
|
|
1308
|
+
return true;
|
|
1309
|
+
};
|
|
1310
|
+
const archiveCard = async (id) => {
|
|
1311
|
+
const arc = ensureArchive();
|
|
1312
|
+
let authoredW = null;
|
|
1313
|
+
const ip = `items/${shard(id)}/${id}.json`;
|
|
1314
|
+
const f = zip.file(ip);
|
|
1315
|
+
if (f) {
|
|
1316
|
+
const j = JSON.parse(await f.async('string'));
|
|
1317
|
+
const a = j.authoredInParent;
|
|
1318
|
+
if (a) {
|
|
1319
|
+
if (j.type === 'text' && a.fontSize) j.fontSize = a.fontSize;
|
|
1320
|
+
if (a.authoredWidth != null) j.authoredWidth = a.authoredWidth;
|
|
1321
|
+
authoredW = a.w || null;
|
|
1322
|
+
delete j.authoredInParent;
|
|
1323
|
+
zip.file(ip, JSON.stringify(j));
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
const pos = canvas.positions[id];
|
|
1327
|
+
if (pos) canvas.positions[id] = { ...pos, parentId: arc, ...(authoredW ? { w: authoredW } : {}) };
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
for (const area of areas) {
|
|
1331
|
+
const synthesis = synthByTitle.get(area.title.trim().toLowerCase());
|
|
1332
|
+
if (!synthesis) continue; // model skipped this area — leave it untouched
|
|
1333
|
+
const ctnPos = canvas.positions[area.containerId];
|
|
1334
|
+
if (!ctnPos) continue;
|
|
1335
|
+
// FAITHFULNESS GUARD (1) — degeneracy: a synthesis far too thin for the
|
|
1336
|
+
// cards it replaces is rejected; that area is left untouched + reported,
|
|
1337
|
+
// so a one-word "done" can't bury real history. (Originals stay put.)
|
|
1338
|
+
const collapsed = synthesis.replace(/\s+/g, ' ').trim();
|
|
1339
|
+
if (collapsed.length < MIN_SYNTHESIS_CHARS) {
|
|
1340
|
+
stats.skipped.push({ title: area.title, reason: `synthesis too thin (${collapsed.length} chars, need ${MIN_SYNTHESIS_CHARS}) — revise and re-apply` });
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
// FAITHFULNESS GUARD (2) — figures net: any distinct number (version /
|
|
1344
|
+
// size / date / count) in the originals that the prose dropped is appended
|
|
1345
|
+
// verbatim, so the crispest facts survive on the visible card even if the
|
|
1346
|
+
// synthesis missed them. The originals are archived verbatim regardless.
|
|
1347
|
+
const origFigs = new Set();
|
|
1348
|
+
for (const c of area.candidates) for (const f of figuresIn(c.text)) origFigs.add(f);
|
|
1349
|
+
const synLower = synthesis.toLowerCase();
|
|
1350
|
+
const missing = [...origFigs].filter(f => !synLower.includes(f));
|
|
1351
|
+
const finalSynth = missing.length ? `${synthesis}\n↳ figures: ${missing.slice(0, 10).join(', ')}${missing.length > 10 ? ' …' : ''}` : synthesis;
|
|
1352
|
+
const span = `${new Date(area.candidates[0].createdAt || now).toISOString().slice(0, 10)} → ${new Date(area.candidates[area.candidates.length - 1].createdAt || now).toISOString().slice(0, 10)}`;
|
|
1353
|
+
const content = wrapText(`${area.title}: 🌿 Consolidated history (${span}, ${area.candidates.length} cards)\n${finalSynth}`);
|
|
1354
|
+
const sid = `txt_${rand()}`;
|
|
1355
|
+
zip.file(`items/${shard(sid)}/${sid}.json`, JSON.stringify({ type: 'text', locked: false, createdAt: now, createdBy: 'agent', createdVia: 'gardener', content, fontSize: 12, color: '#e8e8ed', border: true, borderColor: 'rgba(59,130,246,0.6)', heading: false }));
|
|
1356
|
+
canvas.positions[sid] = { x: ctnPos.x + 20, y: ctnPos.y + (ctnPos.h || 0) + 10, w: 300, h: measureCardH(content), zKey: nextZKey(), zIndex: canvas.order.length, parentId: area.containerId };
|
|
1357
|
+
canvas.order.push(sid);
|
|
1358
|
+
stats.synthCards++;
|
|
1359
|
+
for (const cand of area.candidates) {
|
|
1360
|
+
await rewriteCard(cand.id, j => { j.content = `⤵ consolidated ${today}\n${j.content}`; j.borderColor = 'rgba(120,120,135,0.5)'; });
|
|
1361
|
+
await archiveCard(cand.id);
|
|
1362
|
+
canvas.connections.push({ id: `con_${rand()}`, fromId: cand.id, toId: sid, relationship: 'relates_to', label: 'consolidated into', arrowHead: true, width: 1.5, color: 'rgba(120,120,135,0.7)', style: 'solid' });
|
|
1363
|
+
stats.archived++;
|
|
1364
|
+
}
|
|
1365
|
+
stats.areas++;
|
|
1366
|
+
}
|
|
1367
|
+
if (!stats.synthCards) return { buffer, stats };
|
|
1368
|
+
const out = await finalizeBrainZip(zip, canvas, manifest, now);
|
|
1369
|
+
return { buffer: out, stats };
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1203
1372
|
// ── Stale-open reconcile ("marked open, but a milestone says it's done") ─────
|
|
1204
1373
|
// The READ-side twin of the closes: write path. An open ❓/🎯 card lingers as
|
|
1205
1374
|
// "still to do" forever unless someone emits a ✓/closes: for it — so a goal that
|