klypix-mcp 1.5.0 → 1.7.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
@@ -23,7 +23,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
23
23
  import {
24
24
  resolveVault, getEmbedder, buildKlypixMap, cardSchema, connSchema,
25
25
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
26
- opBrainInsights, opBrainConnect, opBrainReconcile, opCreateCanvas, opAddToCanvas,
26
+ opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote,
27
27
  } from '../src/klypix-core.mjs';
28
28
 
29
29
  // IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
@@ -121,6 +121,19 @@ server.registerTool('brain_reconcile', {
121
121
  },
122
122
  }, async ({ canvas, root }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root })));
123
123
 
124
+ server.registerTool('brain_garden', {
125
+ title: 'Garden the brain — consolidate over-grown areas (sleep-time compute)',
126
+ 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.',
127
+ inputSchema: {
128
+ canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
129
+ apply: z.boolean().optional().describe('false (default) = list over-grown areas + cards to synthesize; true = consolidate using the supplied syntheses.'),
130
+ syntheses: z.array(z.object({
131
+ title: z.string().describe('Area title EXACTLY as returned by the dry run.'),
132
+ synthesis: z.string().describe('3-6 sentence prose synthesis preserving every still-relevant fact/decision/number.'),
133
+ })).optional().describe('Required when apply:true — one entry per area you want consolidated.'),
134
+ },
135
+ }, async ({ canvas, apply, syntheses }) => toContent(await opBrainGarden({ vault: VAULT, canvas, apply, syntheses })));
136
+
124
137
  server.registerTool('create_canvas', {
125
138
  title: 'Create a KLYPIX canvas',
126
139
  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.',
@@ -147,6 +160,21 @@ server.registerTool('add_to_canvas', {
147
160
  return toContent(await opAddToCanvas({ vault: VAULT, canvas, cards, connections, via }));
148
161
  });
149
162
 
163
+ 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").',
166
+ inputSchema: {
167
+ 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.'),
169
+ area: z.string().optional().describe('Area/topic — routes the card into that titled container and becomes a #tag (e.g. "Auth", "Release").'),
170
+ 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
+ canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
172
+ },
173
+ }, async ({ text, marker, area, closes, canvas }) => {
174
+ let via; try { via = server.server.getClientVersion()?.name; } catch { /* optional */ }
175
+ return toContent(await opBrainNote({ vault: VAULT, canvas, text, area, marker: marker || '', closes, via }));
176
+ });
177
+
150
178
  const transport = new StdioServerTransport();
151
179
  await server.connect(transport);
152
180
  log(`ready · vault=${VAULT}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "An open, local-first, agent-neutral canvas file your AI reads and writes over MCP — works with Claude, Cursor, Cline, any model.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,8 @@ import { z } from 'zod';
24
24
  import {
25
25
  parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown,
26
26
  brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
27
- findUnrecordedMigrations,
27
+ findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
28
+ selectGardenCandidates, applyGarden,
28
29
  } from './klypix-format.mjs';
29
30
 
30
31
  // ── Card / connection input shape (single source for every face) ─────────────
@@ -110,8 +111,16 @@ export function getEmbedder(log = () => {}) {
110
111
  let t;
111
112
  try { t = await import('@huggingface/transformers'); }
112
113
  catch {
113
- const local = path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers', 'dist', 'transformers.mjs');
114
- t = await import(new URL('file:///' + local.replace(/\\/g, '/')).href);
114
+ // The optional dep ships dist/transformers.node.mjs on v4 (Node build) and
115
+ // dist/transformers.mjs on older lines try both so a correct one-click
116
+ // install resolves regardless of version.
117
+ const base = path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers', 'dist');
118
+ let lastErr;
119
+ for (const f of ['transformers.node.mjs', 'transformers.mjs']) {
120
+ try { t = await import(new URL('file:///' + path.join(base, f).replace(/\\/g, '/')).href); lastErr = null; break; }
121
+ catch (e) { lastErr = e; }
122
+ }
123
+ if (!t) throw lastErr;
115
124
  }
116
125
  t.env.cacheDir = path.join(PB_DIR, 'hf-cache');
117
126
  return await t.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
@@ -366,6 +375,40 @@ export async function opBrainReconcile({ vault, canvas, root }) {
366
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}`)] };
367
376
  }
368
377
 
378
+ // ── Brain gardener (two-phase: select → agent synthesizes → apply) ───────────
379
+ // The portable /garden. Dry-run returns the over-grown areas + their old cards
380
+ // for the CALLING agent to synthesize (the engine is pure — the model writes the
381
+ // prose); apply consolidates each area into a 🌿 card and archives the originals
382
+ // with audit arrows. Mirrors brain_connect's dry-run/apply discipline.
383
+ export async function opBrainGarden({ vault, canvas, apply = false, syntheses }) {
384
+ const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
385
+ if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
386
+ let struct;
387
+ try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
388
+ 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.')] };
390
+
391
+ if (!apply) {
392
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
393
+ 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}`)] };
395
+ }
396
+
397
+ 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.');
398
+ try {
399
+ const { buffer, stats } = await applyGarden(fs.readFileSync(file), { syntheses });
400
+ const skippedNote = (stats.skipped && stats.skipped.length)
401
+ ? `\n\n⚠️ Left untouched (faithfulness guard): ${stats.skipped.map(s => `"${s.title}" — ${s.reason}`).join('; ')}.`
402
+ : '';
403
+ if (!stats.synthCards) return { blocks: [text(`No areas consolidated — each synthesis \`title\` must match a dry-run area title exactly.${skippedNote}`)] };
404
+ let out = buffer; try { out = (await tidyBrain(buffer)).buffer; } catch { /* keep apply result if tidy fails */ }
405
+ await atomicWrite(file, out);
406
+ 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}`)] };
407
+ } catch (e) {
408
+ return err(`Garden apply failed (brain unchanged): ${e.message}`);
409
+ }
410
+ }
411
+
369
412
  export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, log = () => {} }) {
370
413
  const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
371
414
  if (!file) return err(`No brain canvas found in ${vault}.`);
@@ -465,5 +508,31 @@ export async function opAddToCanvas({ vault, canvas, cards, connections, via })
465
508
  }
466
509
  }
467
510
 
511
+ // brain_note — the DELIBERATE, marker-aware write every agent (not just the
512
+ // Claude-Code Stop hook) can make on demand. Routes through the SAME captureInto-
513
+ // Brain engine the hook uses, so supersede / resolve / close-link / dedup behave
514
+ // identically to a harvested 🧠 marker. The agent-neutral half of "the brain is an
515
+ // open file any agent reads AND writes": a hookless client (Cursor/Cline/Desktop)
516
+ // can now record a decision, ask an open question, mark a milestone, resolve a card,
517
+ // or correct one — with the full lifecycle, not just a flat append.
518
+ export async function opBrainNote({ vault, canvas, text: noteText, area, marker = '', closes, via }) {
519
+ const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
520
+ if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
521
+ 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.`);
523
+ const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', createdVia: via || 'mcp' });
524
+ try {
525
+ const res = await captureIntoBrain(fs.readFileSync(file), input);
526
+ let out = res.buffer; try { out = (await tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
527
+ await atomicWrite(file, out);
528
+ const s = res.stats || {};
529
+ const bits = [`${s.added || 0} added`];
530
+ for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
531
+ return { blocks: [text(`✓ brain_note → ${path.relative(vault, file)} (${bits.join(' · ')}). Reopen the brain in KLYPIX to see it.`)] };
532
+ } catch (e) {
533
+ return err(`brain_note failed (brain unchanged): ${e.message}`);
534
+ }
535
+ }
536
+
468
537
  // Re-export the format helpers the bins need for non-op work (init onboarding).
469
538
  export { buildKlypixMap, parseKlypix };
@@ -573,8 +573,11 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
573
573
  const live = texts.filter(c => !isArchived(c));
574
574
  const focus = live.filter(isFocus);
575
575
  const rest = live.filter(c => !isFocus(c));
576
- const open = rest.filter(c => /❓/.test(c.text));
577
- const miles = rest.filter(c => /🏁/.test(c.text) && !/❓/.test(c.text));
576
+ // 🎯 (goal/target) reads as an OPEN item alongside ❓ — a goal card is
577
+ // still-to-do until a ✓/closes: or a covering milestone closes it (so it
578
+ // must NOT masquerade as a plain decision that quietly ages out of view).
579
+ const open = rest.filter(c => /❓|🎯/.test(c.text));
580
+ const miles = rest.filter(c => /🏁/.test(c.text) && !/❓|🎯/.test(c.text));
578
581
  const plain = rest.filter(c => !open.includes(c) && !miles.includes(c));
579
582
  const recent = plain.filter(c => c.createdAt >= cutoff).sort((a, b) => b.createdAt - a.createdAt).slice(0, maxRecent);
580
583
  const archivedCount = texts.length - live.length;
@@ -607,7 +610,7 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
607
610
  push('', '## 📌 Human focus (cards the human placed here — act on these first)');
608
611
  for (const c of focus) push(`- ${fr(c)}${flat(c.text)}`);
609
612
  }
610
- if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
613
+ if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
611
614
  // ⚠️ Conflicts — pairs flagged conflicts_with (e.g. by parallel sessions);
612
615
  // surfaced HIGH so the next session reconciles them, not buries them.
613
616
  const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
@@ -837,7 +840,7 @@ export function brainInsights(struct, { staleDays = 21, topHubs = 6 } = {}) {
837
840
  if (cn.toId) deg.set(cn.toId, (deg.get(cn.toId) || 0) + 1);
838
841
  }
839
842
  const headline = (c) => String(c.text || '').replace(/\s+/g, ' ').trim().replace(/^(.*?)([.!?](\s|$)|$)/, '$1').slice(0, 120);
840
- const isQuestion = (c) => /❓/.test(c.text);
843
+ const isQuestion = (c) => /❓|🎯/.test(c.text); // ❓ open question + 🎯 goal both read as "open"
841
844
  const hubs = live
842
845
  .map(c => ({ id: c.id, area: c.area, degree: deg.get(c.id) || 0, headline: headline(c) }))
843
846
  .filter(x => x.degree > 0)
@@ -1042,7 +1045,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1042
1045
  let best = null, bestScore = 0;
1043
1046
  for (const c of liveTextCards()) {
1044
1047
  if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
1045
- const s = overlapScore(rTok, tokenSet(c.text)) + (/❓/.test(c.text) ? 0.15 : 0);
1048
+ const s = overlapScore(rTok, tokenSet(c.text)) + (/❓|🎯/.test(c.text) ? 0.15 : 0);
1046
1049
  if (s > bestScore) { bestScore = s; best = c; }
1047
1050
  }
1048
1051
  if (best && bestScore >= RESOLVE_AT) {
@@ -1092,7 +1095,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1092
1095
  // to the new card is drawn in pass 2 (after the new ids exist), matched
1093
1096
  // back by remembering which old card each new card displaced.
1094
1097
  for (const card of cards) {
1095
- if (/❓|🏁/.test(card.text)) continue; // only plain decisions supersede
1098
+ if (/❓|🎯|🏁/.test(card.text)) continue; // only plain decisions supersede (not questions/goals/milestones)
1096
1099
  const nTok = tokenSet(card.text);
1097
1100
  const area = (card.area || '').toLowerCase();
1098
1101
  let best = null, bestScore = 0;
@@ -1197,6 +1200,229 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1197
1200
  return { buffer: work, stats };
1198
1201
  }
1199
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
+
1372
+ // ── Stale-open reconcile ("marked open, but a milestone says it's done") ─────
1373
+ // The READ-side twin of the closes: write path. An open ❓/🎯 card lingers as
1374
+ // "still to do" forever unless someone emits a ✓/closes: for it — so a goal that
1375
+ // quietly SHIPPED keeps surfacing in recall as a "next move". This pure pass
1376
+ // finds open cards a LATER live 🏁 milestone appears to fulfil (its text COVERS
1377
+ // the open card's distinctive tokens) and returns them so the surface can PROMPT
1378
+ // the human to close them — never auto-archives (precision-first, suggestion-only,
1379
+ // like the migration tripwire). Requires the milestone to post-date the goal so a
1380
+ // pre-existing milestone can't "fulfil" a newer goal. No I/O, node-runnable.
1381
+ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
1382
+ const empty = { gaps: [], total: 0 };
1383
+ if (!struct || !Array.isArray(struct.cards)) return empty;
1384
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
1385
+ const isOpen = (c) => /❓|🎯/.test(c.text);
1386
+ const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c) && !/↩|✅/.test(c.text));
1387
+ const opens = live.filter(isOpen);
1388
+ const miles = live.filter(c => /🏁/.test(c.text) && !isOpen(c));
1389
+ if (!opens.length || !miles.length) return empty;
1390
+ const out = [];
1391
+ for (const o of opens) {
1392
+ const oTok = tokenSet(o.text);
1393
+ if (oTok.size < 3) continue; // too vague to match safely → leave it
1394
+ let best = null, bestCov = 0;
1395
+ for (const m of miles) {
1396
+ if ((m.createdAt || 0) <= (o.createdAt || 0)) continue; // only a milestone shipped AFTER the goal
1397
+ const cov = coverageOf(oTok, tokenSet(m.text)); // how much of the goal the milestone covers
1398
+ if (cov > bestCov) { bestCov = cov; best = m; }
1399
+ }
1400
+ if (best && bestCov >= coverAt) out.push({ open: o, by: best, cov: Math.round(bestCov * 100) / 100 });
1401
+ }
1402
+ out.sort((a, b) => b.cov - a.cov);
1403
+ return { gaps: out.slice(0, max), total: out.length };
1404
+ }
1405
+
1406
+ // ── Deliberate note → capture input ──────────────────────────────────────────
1407
+ // Turn ONE structured note into captureIntoBrain's input shape — the deliberate
1408
+ // twin of the Stop hook's transcript marker parser. This is what lets an ON-DEMAND
1409
+ // write (the brain_note MCP tool, the brain-note CLI — any agent, not just the
1410
+ // Claude-Code hook) get IDENTICAL supersede / resolve / close / dedup semantics as
1411
+ // a harvested 🧠 marker. marker ∈ '' (decision) | '?' (open question) | '!'
1412
+ // (milestone) | '✓' (resolve+archive a match) | '~' (update a match in place).
1413
+ export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, createdVia = 'mcp' } = {}) {
1414
+ const body = String(text).trim();
1415
+ if (!body) return { cards: [], resolutions: [], updates: [] };
1416
+ const a = String(area || '').trim();
1417
+ if (marker === '✓') return { cards: [], resolutions: [{ area: a, text: body }], updates: [] };
1418
+ 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)';
1421
+ const tag = a ? `\n#${a.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : '';
1422
+ const cardText = (a ? `${a}: ${prefix}${body}` : `${prefix}${body}`) + tag;
1423
+ return { cards: [{ text: cardText, area: a, color: '#e8e8ed', borderColor, createdVia, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}) }], resolutions: [], updates: [] };
1424
+ }
1425
+
1200
1426
  /**
1201
1427
  * Build a RICH "map" .klypix: areas become titled containers, their cards
1202
1428
  * stack inside, connections draw across. Produces a real spatial board (used by