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 +20 -0
- package/bin/klypix-mcp.mjs +29 -1
- package/package.json +1 -1
- package/src/klypix-core.mjs +72 -3
- package/src/klypix-format.mjs +232 -6
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
|
@@ -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
package/src/klypix-core.mjs
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
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 };
|
package/src/klypix-format.mjs
CHANGED
|
@@ -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
|
-
|
|
577
|
-
|
|
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) =>
|
|
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)) + (
|
|
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 (
|
|
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
|