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 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
@@ -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: '1.0.0' });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.8.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",
@@ -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 = resolveCanvas(vault, 'brain') || resolveCanvas(vault, 'brain.klypix'); if (cb) curKey = path.resolve(cb).replace(/\\/g, '/').toLowerCase(); } catch { /* no current brain */ }
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 file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
327
- if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>", or run \`npx klypix-mcp init\` to create one.`);
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 file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
362
- if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
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 file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
379
- if (!file) return err(`No brain canvas found in ${vault}.`);
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 file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
485
- if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
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
- return { blocks: [text(`✓ brain_note → ${path.relative(vault, file)} (${bits.join(' · ')}). Reopen the brain in KLYPIX to see it.`)] };
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
  }
@@ -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