klypix-mcp 1.1.0 → 1.3.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.
@@ -22,7 +22,7 @@ import crypto from 'crypto';
22
22
  import { z } from 'zod';
23
23
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
24
24
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
25
- import { parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown, atomicWrite } from '../src/klypix-format.mjs';
25
+ import { parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown, brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite } from '../src/klypix-format.mjs';
26
26
 
27
27
  // IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
28
28
  const log = (...a) => console.error('[klypix-mcp]', ...a);
@@ -177,6 +177,15 @@ server.registerTool('search_canvases', {
177
177
  }, async ({ query }) => {
178
178
  const q = String(query || '').trim().toLowerCase();
179
179
  if (!q) return { content: [{ type: 'text', text: 'Provide a non-empty query.' }], isError: true };
180
+ // Tokenize so a multi-word query ("window snap") matches a card holding ANY
181
+ // term — the old verbatim `.includes(fullQuery)` only fired on an exact
182
+ // contiguous substring, so most multi-word searches silently found nothing.
183
+ // NOTE: deliberately NOT reusing the brief's scoreCardsAgainstQuery (its
184
+ // precision sibling, now shared in src/klypix-format.mjs) — that one skips
185
+ // containers + archived cards and applies a minScore floor, all of which
186
+ // would hide results a recall-first finder is expected to surface.
187
+ const terms = q.split(/\s+/).filter(Boolean);
188
+ const hit = (s) => { const v = String(s || '').toLowerCase(); return terms.some(t => v.includes(t)); };
180
189
  const hits = [];
181
190
  for (const f of walkVault()) {
182
191
  let struct;
@@ -184,11 +193,11 @@ server.registerTool('search_canvases', {
184
193
  const rel = path.relative(VAULT, f);
185
194
  // Match the canvas TITLE + FILENAME too — not just card text — so
186
195
  // searching a canvas by its name (e.g. "SS2") actually finds it.
187
- const nameMatch = (struct.title || '').toLowerCase().includes(q) || rel.toLowerCase().includes(q);
196
+ const nameMatch = hit(struct.title) || hit(rel);
188
197
  const matched = struct.cards.filter(c =>
189
- (c.title || '').toLowerCase().includes(q) ||
190
- String(c.text || '').toLowerCase().includes(q) ||
191
- (c.tags || []).some(t => ('#' + t).toLowerCase().includes(q)));
198
+ hit(c.title) ||
199
+ hit(c.text) ||
200
+ (c.tags || []).some(t => hit('#' + t)));
192
201
  if (nameMatch || matched.length) {
193
202
  // Rich hits: type + id + position + tags + a longer snippet, so the
194
203
  // agent can FIND a card (and tell duplicates apart) before it WRITES.
@@ -296,9 +305,17 @@ server.registerTool('search_all_brains', {
296
305
  let qv = null;
297
306
  if (pipe) { try { [qv] = await embedTexts(pipe, [q]); } catch { /* lexical only */ } }
298
307
 
308
+ // Current-project locality prior: a card from the project you're working in
309
+ // should outrank an equally-relevant card from an unrelated project (the
310
+ // "cross-project search drowned my own project's cards" complaint). The boost
311
+ // below is modest + mode-aware — never enough to bury a much stronger foreign hit.
312
+ let curKey = null;
313
+ try { const cb = resolveCanvas('brain') || resolveCanvas('brain.klypix'); if (cb) curKey = path.resolve(cb).replace(/\\/g, '/').toLowerCase(); } catch { /* no current brain */ }
299
314
  const fresh = Date.now() - 30 * 86_400_000;
300
315
  const scored = [];
301
316
  for (const b of brains) {
317
+ let isCur = false;
318
+ try { isCur = !!curKey && path.resolve(b.path).replace(/\\/g, '/').toLowerCase() === curKey; } catch { /* */ }
302
319
  let struct;
303
320
  try { ({ struct } = await parseKlypix(fs.readFileSync(b.path))); } catch { continue; }
304
321
  let vecs = null;
@@ -331,8 +348,9 @@ server.registerTool('search_all_brains', {
331
348
  if (asOfTs == null) {
332
349
  if ((c.createdAt || 0) >= fresh) score += 0.5;
333
350
  if (isArchived) score -= 1;
351
+ if (isCur) score += sem != null ? 1.5 : 1; // current-project locality prior
334
352
  }
335
- scored.push({ score, sem, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
353
+ scored.push({ score, sem, cur: isCur, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
336
354
  }
337
355
  }
338
356
  if (!scored.length) return { content: [{ type: 'text', text: `No matches for "${query}" across ${brains.length} registered brain(s).` }] };
@@ -340,13 +358,97 @@ server.registerTool('search_all_brains', {
340
358
  const top = scored.slice(0, 20);
341
359
  const lines = top.map(h => {
342
360
  const when = h.c.createdAt ? new Date(h.c.createdAt).toISOString().slice(0, 10) : '';
343
- return `- [${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
361
+ return `- ${h.cur ? '★ ' : ''}[${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
344
362
  });
345
363
  const mode = qv ? 'semantic+lexical (on-device)' : 'lexical (semantic model warming — retry for semantic ranking)';
346
364
  const asOfNote = asOfTs != null ? ` · as of ${as_of}` : '';
347
365
  return { content: [{ type: 'text', text: `# Cross-project matches for "${query}" (${scored.length} hits in ${brains.length} brains, top ${top.length} · ${mode}${asOfNote})\n\n${lines.join('\n')}` }] };
348
366
  });
349
367
 
368
+ server.registerTool('brain_insights', {
369
+ title: 'What matters in a brain — hubs, orphans, stale questions',
370
+ description: 'Structural read of a brain.klypix: the most-connected "hub" cards (load-bearing decisions), orphaned decisions (no connections — maybe forgotten), stale open questions (aging & unresolved), and area sizes. Use to answer "what matters here / what am I forgetting / what should I review?" — read it at the start of a planning session, or before tidying.',
371
+ inputSchema: {
372
+ canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
373
+ staleDays: z.number().optional().describe('Open questions older than this many days count as stale (default 21).'),
374
+ },
375
+ }, async ({ canvas, staleDays }) => {
376
+ const file = resolveCanvas(canvas || 'brain') || resolveCanvas('brain.klypix');
377
+ if (!file) return { content: [{ type: 'text', text: `No brain canvas found in ${VAULT}. Pass canvas: "<name>", or run \`npx klypix-mcp init\` to create one.` }], isError: true };
378
+ try {
379
+ const { struct } = await parseKlypix(fs.readFileSync(file));
380
+ const ins = brainInsights(struct, staleDays ? { staleDays } : {});
381
+ return { content: [{ type: 'text', text: insightsToMarkdown(ins, struct.title) }] };
382
+ } catch (e) {
383
+ return { content: [{ type: 'text', text: `Insights failed: ${e.message}` }], isError: true };
384
+ }
385
+ });
386
+
387
+ server.registerTool('brain_connect', {
388
+ title: 'Connect related-but-unlinked brain cards (densify the graph)',
389
+ description: 'Finds genuinely related cards that AREN\'T linked yet and proposes connections — semantic similarity when the on-device model is installed, else shared tags + [[mentions]]. Dry-run by default (review the suggestions); pass apply:true to draw them (ADDITIVE — never deletes; the human can remove any arrow). Use after brain_insights flags many orphans, to turn a flat list into a real knowledge graph.',
390
+ inputSchema: {
391
+ canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
392
+ apply: z.boolean().optional().describe('false (default) = suggest only; true = draw the connections.'),
393
+ max: z.number().optional().describe('Max connections to propose/draw (default 24).'),
394
+ threshold: z.number().optional().describe('Min semantic similarity 0–1 to link (default 0.45). Higher = fewer, tighter links.'),
395
+ },
396
+ }, async ({ canvas, apply = false, max = 24, threshold = 0.45 }) => {
397
+ const file = resolveCanvas(canvas || 'brain') || resolveCanvas('brain.klypix');
398
+ if (!file) return { content: [{ type: 'text', text: `No brain canvas found in ${VAULT}.` }], isError: true };
399
+ let struct;
400
+ try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return { content: [{ type: 'text', text: `Read failed: ${e.message}` }], isError: true }; }
401
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 70);
402
+ const byId = new Map(struct.cards.map(c => [c.id, c]));
403
+ const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
404
+ const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
405
+
406
+ let edges = [];
407
+ let mode = 'structural (shared tags + [[mentions]])';
408
+ const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), 20_000))]);
409
+ if (pipe) {
410
+ try {
411
+ const vecs = await vectorsForBrain(pipe, file, struct.cards);
412
+ const items = live.filter(c => vecs.get(c.id));
413
+ for (const a of items) {
414
+ const av = vecs.get(a.id);
415
+ const sims = items
416
+ .filter(b => b.id !== a.id)
417
+ .map(b => ({ b, s: dot(av, vecs.get(b.id)), cross: (b.area || '') !== (a.area || '') }))
418
+ .sort((x, y) => (y.s + (y.cross ? 0.03 : 0)) - (x.s + (x.cross ? 0.03 : 0))); // nudge toward cross-area links
419
+ let taken = 0;
420
+ for (const { b, s } of sims) {
421
+ if (s < threshold || taken >= 2) break; // each card keeps its ≤2 strongest fresh links
422
+ const key = [a.id, b.id].sort().join('|');
423
+ if (linked.has(key)) continue;
424
+ linked.add(key);
425
+ edges.push({ fromId: a.id, toId: b.id, sim: s });
426
+ taken++;
427
+ }
428
+ }
429
+ edges.sort((x, y) => y.sim - x.sim);
430
+ mode = 'semantic (on-device)';
431
+ } catch (e) { mode = `structural (semantic failed: ${e.message})`; }
432
+ }
433
+ if (!edges.length && mode.startsWith('structural')) {
434
+ edges = proposeStructuralConnections(struct);
435
+ }
436
+ const chosen = edges.slice(0, max);
437
+ if (!chosen.length) return { content: [{ type: 'text', text: `Nothing to connect — no related-but-unlinked cards found (mode: ${mode}).` }] };
438
+
439
+ const render = (e) => `- ${flat(byId.get(e.fromId)?.text)} ↔ ${flat(byId.get(e.toId)?.text)}${e.sim != null ? ` (${e.sim.toFixed(2)})` : e.why ? ` (${e.why})` : ''}`;
440
+ if (!apply) {
441
+ return { content: [{ type: 'text', text: `# ${chosen.length} suggested connection(s) · ${mode}\n_Review, then re-run with apply:true to draw them._\n\n${chosen.map(render).join('\n')}` }] };
442
+ }
443
+ try {
444
+ const { buffer, added } = await addBrainConnections(fs.readFileSync(file), chosen);
445
+ await atomicWrite(file, buffer);
446
+ return { content: [{ type: 'text', text: `✓ Drew ${added} connection(s) into ${path.relative(VAULT, file)} (${mode}). Reopen the brain to see the new arrows.\n\n${chosen.slice(0, added).map(render).join('\n')}` }] };
447
+ } catch (e) {
448
+ return { content: [{ type: 'text', text: `Apply failed (brain unchanged): ${e.message}` }], isError: true };
449
+ }
450
+ });
451
+
350
452
  // Format the cards (optionally only a set of new ids) + connection graph so an
351
453
  // agent that just wrote can chain follow-ups: reference card IDs, place near a
352
454
  // position, or draw an arrow to something it created. Additive — appended after
@@ -427,3 +529,8 @@ server.registerTool('add_to_canvas', {
427
529
  const transport = new StdioServerTransport();
428
530
  await server.connect(transport);
429
531
  log(`ready · vault=${VAULT}`);
532
+ // Pre-warm the on-device embedder in the BACKGROUND so the first cross-project
533
+ // search of a session is already semantic, not a lexical fallback while the
534
+ // MiniLM model loads. getEmbedder() memoizes + swallows its own errors, so this
535
+ // is a safe fire-and-forget (no await → zero added startup latency).
536
+ getEmbedder().then(p => log(p ? 'semantic ready (pre-warmed)' : 'semantic unavailable — lexical only')).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.3.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",
@@ -113,9 +113,13 @@ export async function parseKlypix(buffer) {
113
113
  parentId: it.parentId ?? null,
114
114
  // Parent container's title — the card's "area" in brain terms.
115
115
  area: it.parentId ? (cardTitle(items[it.parentId]) || null) : null,
116
+ // Evidence anchors (file:line / PR#) with the git blob OID stamped at
117
+ // capture-time — lets the hook flag a card whose cited code drifted.
118
+ evidence: Array.isArray(it.evidence) && it.evidence.length ? it.evidence : null,
116
119
  })),
117
120
  connections: connections.map(c => ({
118
121
  from: titleOf(c.fromId), to: titleOf(c.toId),
122
+ fromId: c.fromId, toId: c.toId, // raw ids — for graph analysis (brainInsights)
119
123
  relationship: c.relationship || null, label: c.label || null,
120
124
  })),
121
125
  assets: assetPaths.map(p => path.basename(p)),
@@ -463,6 +467,8 @@ export async function appendIntoContainers(buffer, addition) {
463
467
  // Provenance: WHICH agent remembered this (claude-code / cursor /
464
468
  // cline / …) — additive field, ignored by older readers.
465
469
  ...(card.createdVia ? { createdVia: String(card.createdVia) } : {}),
470
+ // Evidence anchors (file:line / PR#) — additive, ignored by older readers.
471
+ ...(Array.isArray(card.evidence) && card.evidence.length ? { evidence: card.evidence } : {}),
466
472
  content: wrapped, fontSize: G.FONT,
467
473
  color: card.color || '#e8e8ed', border: true, borderColor: card.borderColor || card.color || 'rgba(16,185,129,0.45)',
468
474
  fillColor: 'rgba(18,18,26,0.85)', heading: !!card.heading, fontFamily: 'Thmanyah Sans',
@@ -555,7 +561,7 @@ export async function tidyBrain(buffer) {
555
561
  // decisions + milestones. Everything older stays in the file, reachable via the
556
562
  // klypix-canvas MCP search or `--full`. Keeps the session-start cost flat as
557
563
  // the brain grows (the full markdown scales with history; this doesn't).
558
- export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMilestones = 8, maxConnections = 30 } = {}) {
564
+ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMilestones = 8, maxConnections = 30, freshness = null } = {}) {
559
565
  const cutoff = Date.now() - recentDays * 86_400_000;
560
566
  const texts = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
561
567
  const containers = struct.cards.filter(c => c.type === 'container');
@@ -574,6 +580,9 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
574
580
  const archivedCount = texts.length - live.length;
575
581
 
576
582
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
583
+ // Freshness badge (✅ verified / ⚠️ drifted / 🌱 unverified) for code-anchored
584
+ // cards — supplied by the git-aware hook; absent → no badge. Trust at a glance.
585
+ const fr = (c) => (freshness && freshness[c.id]) ? freshness[c.id] + ' ' : '';
577
586
  // HEADLINE = first sentence-ish, hard-capped — the brief is a scannable
578
587
  // changelog; the agent pulls any card's full text via the MCP when needed.
579
588
  const headline = (c, max = 160) => {
@@ -596,16 +605,20 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
596
605
  push(`*${struct.format} · ${struct.counts.cards} cards · ${struct.counts.connections} connections · tiered brief (focus + open + last ${recentDays}d headlines); full cards via klypix-canvas MCP search*`);
597
606
  if (focus.length) {
598
607
  push('', '## 📌 Human focus (cards the human placed here — act on these first)');
599
- for (const c of focus) push(`- ${flat(c.text)}`);
608
+ for (const c of focus) push(`- ${fr(c)}${flat(c.text)}`);
600
609
  }
601
- if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${flat(c.text)}`); }
610
+ if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
611
+ // ⚠️ Conflicts — pairs flagged conflicts_with (e.g. by parallel sessions);
612
+ // surfaced HIGH so the next session reconciles them, not buries them.
613
+ const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
614
+ if (conflicts.length) { push('', '## ⚠️ Conflicts to reconcile (parallel decisions that may disagree)'); for (const c of conflicts.slice(0, 10)) push(`- ${flat(c.from)} ⚔️ ${flat(c.to)}`); }
602
615
  const areaCounts = containers
603
616
  .filter(c => !/^archive$/i.test(c.title || ''))
604
617
  .map(c => `${flat(c.title)} (${texts.filter(t => t.parentId === c.id).length})`);
605
618
  if (areaCounts.length) { push('', '## Areas', areaCounts.join(' · ')); }
606
619
  if (miles.length) {
607
620
  push('', '## Milestones');
608
- for (const c of miles.sort((a, b) => b.createdAt - a.createdAt).slice(0, maxMilestones)) push(`- ${headline(c)}`);
621
+ for (const c of miles.sort((a, b) => b.createdAt - a.createdAt).slice(0, maxMilestones)) push(`- ${fr(c)}${headline(c)}`);
609
622
  }
610
623
  let shownRecent = 0;
611
624
  if (recent.length) {
@@ -616,7 +629,7 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
616
629
  push(`### ${a}`);
617
630
  for (const c of cs) {
618
631
  if (used > BUDGET_CHARS) break outer;
619
- push(`- ${day(c.createdAt)} ${headline(c)}`);
632
+ push(`- ${fr(c)}${day(c.createdAt)} ${headline(c)}`);
620
633
  shownRecent++;
621
634
  }
622
635
  }
@@ -633,6 +646,255 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
633
646
  return out.join('\n') + '\n';
634
647
  }
635
648
 
649
+ // ── Relevance ranking ─────────────────────────────────────────────────────
650
+ // ONE shared lexical ranker so the per-prompt retrieval hook (and, later, the
651
+ // MCP search) rank cards the SAME way — no third divergent scorer. Weights
652
+ // mirror the MCP convention: title 3, tag 2, body 1, plus a gentle recency
653
+ // tiebreak. Pure + node-runnable (no embeddings / network) so the Stop/prompt
654
+ // hooks can call it with zero extra deps. `#file-…`/`#dir-…` tags (added at
655
+ // capture) are what make a git-diff token match a card precisely.
656
+ const STOPWORDS = new Set(['the', 'and', 'for', 'that', 'this', 'with', 'from', 'have', 'has', 'was', 'were', 'are', 'you', 'your', 'not', 'but', 'its', 'into', 'out', 'can', 'will', 'use', 'using', 'about', 'what', 'when', 'why', 'how', 'add', 'fix', 'make', 'need', 'want', 'let', 'see', 'get', 'got', 'now', 'all', 'any', 'via', 'per', 'etc', 'should', 'could', 'would', 'does', 'did', 'still', 'just', 'like', 'also', 'then', 'than', 'them', 'they']);
657
+ export function queryTokens(s) {
658
+ return [...new Set(String(s || '').toLowerCase().match(/[a-z0-9][a-z0-9_-]{2,}/g) || [])].filter(t => !STOPWORDS.has(t));
659
+ }
660
+ const wordsOf = (s) => new Set(String(s || '').toLowerCase().match(/[a-z0-9][a-z0-9_-]{1,}/g) || []);
661
+ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2, recentDays = 30 } = {}) {
662
+ const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
663
+ if (!tokens.length || !struct || !Array.isArray(struct.cards)) return [];
664
+ const cutoff = Date.now() - recentDays * 86_400_000;
665
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
666
+ const scored = [];
667
+ for (const c of struct.cards) {
668
+ if (c.type === 'container' || isArchived(c) || !(c.text || '').trim()) continue;
669
+ // WORD-level matching (not substring) so "app" can't hit "append-klypix"
670
+ // and "main" can't hit "domain". Tag match is on the tag's STEM (the
671
+ // slug after #file-/#dir-/#) so a git-diff token (slugify(basename))
672
+ // lands EXACTLY on its #file- anchor — the precise signal, weighted = a
673
+ // title hit so ONE anchored file match (3 + 0.5 recency) clears minScore.
674
+ const titleW = wordsOf(c.title);
675
+ const bodyW = wordsOf(c.text);
676
+ const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
677
+ let score = 0;
678
+ for (const tok of tokens) {
679
+ if (titleW.has(tok)) score += 3;
680
+ else if (tagStems.has(tok)) score += 3;
681
+ else if (bodyW.has(tok)) score += 1;
682
+ }
683
+ if (score <= 0) continue;
684
+ if ((c.createdAt || 0) >= cutoff) score += 0.5; // gentle recency tiebreak, never dominant
685
+ scored.push({ card: c, score });
686
+ }
687
+ scored.sort((a, b) => b.score - a.score || (b.card.createdAt || 0) - (a.card.createdAt || 0));
688
+ return scored.filter(s => s.score >= minScore).slice(0, topK);
689
+ }
690
+
691
+ // ── Repeat / redundancy detection ("you already did this in another session") ─
692
+ // The PRECISION-first sibling of scoreCardsAgainstQuery. Instead of "related
693
+ // context" it answers a sharper question: is the user about to REDO work that's
694
+ // already DONE? It scans COMPLETED-work cards ONLY — 🏁 shipped, ✅ resolved,
695
+ // ↩︎ superseded — and INCLUDES the Archive (resolved/superseded cards live there,
696
+ // which the relevance ranker deliberately skips). Deliberately strict: a high
697
+ // score floor + ≥2 distinct query-token hits, because for a NUDGE a false "you
698
+ // already did this" is costly (erodes trust) while a miss is cheap (the loose
699
+ // recall list still shows below). Returns each card's `kind` so the caller can
700
+ // say "reuse it" (shipped/resolved) vs "see what replaced it" (superseded).
701
+ // Pure + node-runnable; reuses the one shared tokenizer — no divergent scorer.
702
+ export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTokens = 2 } = {}) {
703
+ const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
704
+ if (tokens.length < minTokens || !struct || !Array.isArray(struct.cards)) return [];
705
+ const kindOf = (t) => /🏁/.test(t) ? 'shipped' : /✅/.test(t) ? 'resolved' : /↩/.test(t) ? 'superseded' : null;
706
+ const rank = { shipped: 2, resolved: 2, superseded: 1 };
707
+ const out = [];
708
+ for (const c of struct.cards) {
709
+ if (c.type === 'container' || !(c.text || '').trim()) continue;
710
+ const kind = kindOf(c.text);
711
+ if (!kind) continue; // only COMPLETED-work cards qualify
712
+ // Score the first MEANINGFUL line, not a marker stamp: supersede/resolve
713
+ // prepend "↩︎ superseded <date>" / lead with "✅ …", which would otherwise
714
+ // become the title and hide the real content from title-weighted matching.
715
+ const firstMeaningful = String(c.text).split('\n').map(s => s.trim()).filter(Boolean).find(l => !/^[↩✅🏁]/u.test(l));
716
+ const titleW = wordsOf(firstMeaningful || c.title);
717
+ const bodyW = wordsOf(c.text);
718
+ const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
719
+ let score = 0, matched = 0;
720
+ for (const tok of tokens) {
721
+ if (titleW.has(tok)) { score += 3; matched++; }
722
+ else if (tagStems.has(tok)) { score += 3; matched++; }
723
+ else if (bodyW.has(tok)) { score += 1; matched++; }
724
+ }
725
+ if (matched < minTokens || score < minScore) continue; // precision-first floor
726
+ out.push({ card: c, score, kind });
727
+ }
728
+ out.sort((a, b) => b.score - a.score || (rank[b.kind] - rank[a.kind]) || (b.card.createdAt || 0) - (a.card.createdAt || 0));
729
+ return out.slice(0, topK);
730
+ }
731
+
732
+ // ── Conflict candidate detection ───────────────────────────────────────────
733
+ // REAL conflicts only — a conflict is two decisions that genuinely CONTRADICT
734
+ // (you can't honor both about the same thing). Topical similarity, duplication,
735
+ // and sequential supersession are explicitly NOT conflicts and must never be
736
+ // flagged (no false-conflict dump). This is a cheap LEXICAL pre-filter that
737
+ // returns CANDIDATES only — same-subject, not-already-connected pairs where at
738
+ // least one card uses explicit REVERSAL language. Candidates mean nothing on
739
+ // their own: the caller (brain-conflicts.mjs) confirms each with an LLM
740
+ // contradiction-check before anything is ever drawn or surfaced. No verifier →
741
+ // nothing surfaces. Pure + node-runnable.
742
+ // Strong reversal/contradiction terms ONLY — deliberately NOT bare "not"/"don't"
743
+ // (too common → false positives). A candidate still has to clear the LLM gate.
744
+ const OPPOSITION_RE = /\b(instead of|reverted?|no longer|dropp(?:ed|ing)?|deprecat\w*|abandon\w*|replaced?\b|supersed\w*|changed from|switch(?:ed)? (?:from|to)|rolled? back|overrod|overrides?|contradic\w*|disagree\w*|conflicts? with|reversed?\b)/i;
745
+ export function detectConflicts(struct, { minOverlap = 0.45, topK = 12 } = {}) {
746
+ if (!struct || !Array.isArray(struct.cards)) return [];
747
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
748
+ const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
749
+ const wset = new Map(live.map(c => [c.id, new Set(queryTokens(c.text))]));
750
+ const connected = new Set();
751
+ for (const e of struct.connections || []) { connected.add(e.fromId + '|' + e.toId); connected.add(e.toId + '|' + e.fromId); }
752
+ const out = [];
753
+ for (let i = 0; i < live.length; i++) {
754
+ for (let j = i + 1; j < live.length; j++) {
755
+ const a = live[i], b = live[j];
756
+ if (connected.has(a.id + '|' + b.id)) continue;
757
+ const A = wset.get(a.id), B = wset.get(b.id);
758
+ if (A.size < 4 || B.size < 4) continue;
759
+ let inter = 0; for (const t of A) if (B.has(t)) inter++;
760
+ const overlap = inter / Math.min(A.size, B.size); // overlap coefficient — same subject?
761
+ // Must be same-subject AND carry an explicit reversal signal. Pure
762
+ // similarity / duplication is NOT a candidate (that was the dump).
763
+ if (overlap < minOverlap) continue;
764
+ if (!(OPPOSITION_RE.test(a.text) || OPPOSITION_RE.test(b.text))) continue;
765
+ out.push({ aId: a.id, bId: b.id, a: a.text, b: b.text, area: a.area, overlap: Math.round(overlap * 100) / 100 });
766
+ }
767
+ }
768
+ out.sort((x, y) => y.overlap - x.overlap);
769
+ return out.slice(0, topK);
770
+ }
771
+
772
+ // ── Brain insights ───────────────────────────────────────────────────────────
773
+ // "What matters here, and what am I forgetting?" — a deterministic structural
774
+ // read of the brain (borrowed from graphify's GRAPH_REPORT, applied to the
775
+ // AUTHORED brain, not derived code). Surfaces:
776
+ // • hubs — the most-connected cards (load-bearing decisions)
777
+ // • orphans — decision/milestone cards with NO connections (isolated;
778
+ // maybe forgotten — link them or archive them)
779
+ // • stale ❓ — open questions older than `staleDays` (aging unknowns)
780
+ // • areas — live-card count per area (what's growing / dormant)
781
+ // Pure + node-runnable: no LLM, no network. The agent renders/acts on it.
782
+ export function brainInsights(struct, { staleDays = 21, topHubs = 6 } = {}) {
783
+ const cutoff = Date.now() - staleDays * 86_400_000;
784
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
785
+ const cards = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
786
+ const live = cards.filter(c => !isArchived(c));
787
+ const deg = new Map();
788
+ for (const cn of struct.connections) {
789
+ if (cn.fromId) deg.set(cn.fromId, (deg.get(cn.fromId) || 0) + 1);
790
+ if (cn.toId) deg.set(cn.toId, (deg.get(cn.toId) || 0) + 1);
791
+ }
792
+ const headline = (c) => String(c.text || '').replace(/\s+/g, ' ').trim().replace(/^(.*?)([.!?](\s|$)|$)/, '$1').slice(0, 120);
793
+ const isQuestion = (c) => /❓/.test(c.text);
794
+ const hubs = live
795
+ .map(c => ({ id: c.id, area: c.area, degree: deg.get(c.id) || 0, headline: headline(c) }))
796
+ .filter(x => x.degree > 0)
797
+ .sort((a, b) => b.degree - a.degree)
798
+ .slice(0, topHubs);
799
+ const orphans = live
800
+ .filter(c => !isQuestion(c) && !/🌿|⤵/.test(c.text) && !(deg.get(c.id) > 0))
801
+ .map(c => ({ id: c.id, area: c.area, headline: headline(c), age: c.createdAt }));
802
+ const staleQuestions = live
803
+ .filter(c => isQuestion(c) && (c.createdAt || 0) < cutoff)
804
+ .map(c => ({ id: c.id, area: c.area, headline: headline(c), age: c.createdAt }))
805
+ .sort((a, b) => (a.age || 0) - (b.age || 0));
806
+ const areas = struct.cards
807
+ .filter(c => c.type === 'container' && !/^archive$/i.test(c.title || ''))
808
+ .map(c => ({ title: c.title, count: cards.filter(t => t.parentId === c.id).length }))
809
+ .sort((a, b) => b.count - a.count);
810
+ return {
811
+ hubs, orphans, staleQuestions, areas,
812
+ totals: { live: live.length, archived: cards.length - live.length, connections: struct.connections.length },
813
+ };
814
+ }
815
+
816
+ // Render brainInsights as a compact markdown report (used by the MCP tool).
817
+ export function insightsToMarkdown(ins, title = 'brain') {
818
+ const out = [`# ${title} — insights`, `*${ins.totals.live} live cards · ${ins.totals.archived} archived · ${ins.totals.connections} connections*`];
819
+ if (ins.hubs.length) { out.push('', '## 🪢 Hubs (most-connected — the load-bearing cards)'); for (const h of ins.hubs) out.push(`- (${h.degree}) [${h.area || '?'}] ${h.headline}`); }
820
+ if (ins.orphans.length) { out.push('', `## 🔌 Orphaned decisions (${ins.orphans.length} — no connections; link them or archive)`); for (const o of ins.orphans.slice(0, 12)) out.push(`- [${o.area || '?'}] ${o.headline}`); if (ins.orphans.length > 12) out.push(`- …and ${ins.orphans.length - 12} more`); }
821
+ if (ins.staleQuestions.length) { out.push('', `## ⏳ Stale open questions (${ins.staleQuestions.length} — unresolved & aging)`); for (const q of ins.staleQuestions.slice(0, 12)) out.push(`- [${q.area || '?'}] ${q.headline}`); }
822
+ out.push('', '## 📍 Areas (by live cards)', ins.areas.map(a => `${a.title} (${a.count})`).join(' · ') || '(none)');
823
+ if (!ins.hubs.length && !ins.orphans.length && !ins.staleQuestions.length) out.push('', '_Brain is small/tidy — no hubs, orphans, or stale questions to flag yet._');
824
+ return out.join('\n') + '\n';
825
+ }
826
+
827
+ // Append raw connections (by id) to a brain — additive, never deletes, skips
828
+ // self-links / duplicates / dangling ids, verifies a round-trip parse. Used by
829
+ // the brain_connect tool to densify a sparse brain into a real graph.
830
+ export async function addBrainConnections(buffer, edges) {
831
+ const { zip, canvas, manifest, isV4 } = await parseKlypix(buffer);
832
+ if (!isV4 || !canvas.positions) throw new Error('connections need a v4 .klypix');
833
+ canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
834
+ const linked = (a, b) => canvas.connections.some(c => (c.fromId === a && c.toId === b) || (c.fromId === b && c.toId === a));
835
+ const rand = () => Math.random().toString(36).slice(2, 10);
836
+ let added = 0;
837
+ for (const e of edges || []) {
838
+ if (!e.fromId || !e.toId || e.fromId === e.toId) continue;
839
+ if (!canvas.positions[e.fromId] || !canvas.positions[e.toId]) continue; // both must exist
840
+ if (linked(e.fromId, e.toId)) continue;
841
+ canvas.connections.push({
842
+ id: `con_${rand()}`, fromId: e.fromId, toId: e.toId,
843
+ relationship: REL.has(e.relationship) ? e.relationship : 'relates_to',
844
+ label: typeof e.label === 'string' ? e.label : undefined,
845
+ arrowHead: true, width: 2, color: typeof e.color === 'string' ? e.color : '#10b981', style: 'solid',
846
+ });
847
+ added++;
848
+ }
849
+ if (!added) return { buffer, added: 0 };
850
+ const out = await finalizeBrainZip(zip, canvas, manifest, Date.now());
851
+ return { buffer: out, added };
852
+ }
853
+
854
+ // Structural connection suggestions — pure, no embeddings (the fallback when
855
+ // the on-device model isn't installed, and the twin of the in-app Connect
856
+ // button). Signals, strongest first: an unlinked [[title]] mention (3), a
857
+ // shared topical tag ACROSS areas (2), a shared tag within an area (1). The
858
+ // area-name tag is dropped (redundant with containment), and — crucially —
859
+ // each card keeps at most `maxPerCard` links, so a tag shared by ten cards
860
+ // can't explode into a 45-edge clique. Mirrors the semantic pass's discipline.
861
+ export function proposeStructuralConnections(struct, { maxPerCard = 2 } = {}) {
862
+ const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
863
+ const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
864
+ const titleIx = live.filter(c => (c.title || '').trim()).map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
865
+ const tagsOf = (c) => (c.tags || [])
866
+ .map(t => String(t).toLowerCase().replace(/^#/, ''))
867
+ .filter(t => t && t !== 'area' && t !== String(c.area || '').toLowerCase());
868
+ const cand = [];
869
+ for (const c of live) {
870
+ for (const link of (c.links || [])) {
871
+ const want = String(link).trim().toLowerCase();
872
+ const tgt = titleIx.find(e => e.id !== c.id && (e.t === want || e.t.startsWith(want)));
873
+ if (tgt) cand.push({ a: c.id, b: tgt.id, score: 3, why: 'mention' });
874
+ }
875
+ const ct = tagsOf(c);
876
+ if (!ct.length) continue;
877
+ for (const d of live) {
878
+ if (d.id <= c.id) continue;
879
+ if (tagsOf(d).some(t => ct.includes(t))) cand.push({ a: c.id, b: d.id, score: c.area !== d.area ? 2 : 1, why: 'shared tag' });
880
+ }
881
+ }
882
+ cand.sort((x, y) => y.score - x.score);
883
+ const per = new Map();
884
+ const edges = [];
885
+ for (const e of cand) {
886
+ if (e.a === e.b) continue;
887
+ const key = [e.a, e.b].sort().join('|');
888
+ if (linked.has(key)) continue;
889
+ if ((per.get(e.a) || 0) >= maxPerCard || (per.get(e.b) || 0) >= maxPerCard) continue;
890
+ linked.add(key);
891
+ per.set(e.a, (per.get(e.a) || 0) + 1);
892
+ per.set(e.b, (per.get(e.b) || 0) + 1);
893
+ edges.push({ fromId: e.a, toId: e.b, why: e.why });
894
+ }
895
+ return edges;
896
+ }
897
+
636
898
  // ── Atomic brain capture: supersede + append + resolve + auto-link ──────────
637
899
  // One verified write per capture batch. Beyond appendIntoContainers it adds:
638
900
  // • SUPERSEDE — a new decision that heavily overlaps an existing live card in
@@ -650,9 +912,9 @@ const overlapScore = (a, b) => {
650
912
  return hit / Math.min(a.size, b.size);
651
913
  };
652
914
  export async function captureIntoBrain(buffer, { cards = [], resolutions = [], updates = [] } = {}) {
653
- const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45;
915
+ const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45, CLOSE_AT = 0.25;
654
916
  let work = buffer;
655
- const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0 };
917
+ const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0 };
656
918
 
657
919
  // Pass 1 — resolutions + supersede marking operate on EXISTING cards.
658
920
  if (resolutions.length || cards.length || updates.length) {
@@ -694,10 +956,28 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
694
956
  if (pos) canvas.positions[id] = { ...pos, h: measureCardH(j.content) };
695
957
  return true;
696
958
  };
697
- const archiveCard = (id) => {
959
+ const archiveCard = async (id) => {
698
960
  const arc = ensureArchive();
961
+ // The Archive is readable STORAGE, not a vector-scaled layout: un-bake
962
+ // any group-shrink (font/width baked tiny) by restoring the frozen
963
+ // authored baseline and dropping it, so the card sits in the Archive
964
+ // at full readable size and re-seeds cleanly if the Archive is resized.
965
+ let authoredW = null;
966
+ const ip = `items/${shard(id)}/${id}.json`;
967
+ const f = zip.file(ip);
968
+ if (f) {
969
+ const j = JSON.parse(await f.async('string'));
970
+ const a = j.authoredInParent;
971
+ if (a) {
972
+ if (j.type === 'text' && a.fontSize) j.fontSize = a.fontSize;
973
+ if (a.authoredWidth != null) j.authoredWidth = a.authoredWidth;
974
+ authoredW = a.w || null;
975
+ delete j.authoredInParent;
976
+ zip.file(ip, JSON.stringify(j));
977
+ }
978
+ }
699
979
  const pos = canvas.positions[id];
700
- if (pos) canvas.positions[id] = { ...pos, parentId: arc };
980
+ if (pos) canvas.positions[id] = { ...pos, parentId: arc, ...(authoredW ? { w: authoredW } : {}) };
701
981
  };
702
982
  canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
703
983
 
@@ -716,7 +996,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
716
996
  j.content = `${j.content}\n✅ ${today}: ${r.text}`;
717
997
  j.borderColor = 'rgba(16,185,129,0.35)';
718
998
  });
719
- archiveCard(best.id);
999
+ await archiveCard(best.id);
720
1000
  best.text += ` ✅ ${r.text}`; // keep in-memory struct honest for later matching
721
1001
  stats.resolved++;
722
1002
  } else {
@@ -743,11 +1023,14 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
743
1023
  j.createdAt = now;
744
1024
  j.borderColor = 'rgba(16,185,129,0.6)';
745
1025
  if (u.createdVia) j.createdVia = String(u.createdVia);
1026
+ // Self-heal: a ~ update re-stamps the evidence (fresh OID +
1027
+ // verifiedAt), so confirming/correcting a drifted fact marks it ✅.
1028
+ if (Array.isArray(u.evidence) && u.evidence.length) j.evidence = u.evidence;
746
1029
  });
747
1030
  best.text = u.text;
748
1031
  stats.updated++;
749
1032
  } else {
750
- cards.push({ text: (u.area ? `${u.area}: ` : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia });
1033
+ cards.push({ text: (u.area ? `${u.area}: ` : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia, ...(Array.isArray(u.evidence) && u.evidence.length ? { evidence: u.evidence } : {}) });
751
1034
  }
752
1035
  }
753
1036
 
@@ -769,13 +1052,45 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
769
1052
  j.content = `↩︎ superseded ${today}\n${j.content}`;
770
1053
  j.borderColor = 'rgba(120,120,135,0.5)';
771
1054
  });
772
- archiveCard(best.id);
1055
+ await archiveCard(best.id);
773
1056
  best.text = `↩︎ ${best.text}`;
774
1057
  card.__supersedes = best.id;
775
1058
  stats.superseded++;
776
1059
  }
777
1060
  }
778
1061
 
1062
+ // CLOSE-LINK — a card carrying `closes` resolves the (often cross-area)
1063
+ // strategy/question card that SPAWNED it: stamp ✅, archive it, and draw a
1064
+ // "closed by" arrow in pass 2. Unlike supersede (same-area, high lexical
1065
+ // overlap), a shipped milestone rarely echoes the strategy's prose — so
1066
+ // this matches across ALL areas, prefers an explicit [[wikilink]]/title
1067
+ // hit, and otherwise fires on only a low overlap. This is the fix for
1068
+ // "strategy cards never get closed out when their feature actually ships".
1069
+ for (const card of cards) {
1070
+ const target = (card.closes || '').toString().trim();
1071
+ if (!target) continue;
1072
+ const wantTitle = target.replace(/^\[\[/, '').replace(/\]\]$/, '').trim().toLowerCase();
1073
+ const tTok = tokenSet(target);
1074
+ let best = null, bestScore = 0;
1075
+ for (const c of liveTextCards()) {
1076
+ const ct = (c.title || '').trim().toLowerCase();
1077
+ if (ct && wantTitle && (ct === wantTitle || ct.startsWith(wantTitle) || wantTitle.startsWith(ct))) { best = c; bestScore = 1; break; }
1078
+ const s = overlapScore(tTok, tokenSet(c.text));
1079
+ if (s > bestScore) { bestScore = s; best = c; }
1080
+ }
1081
+ if (best && bestScore >= CLOSE_AT) {
1082
+ const ship = String(card.text).replace(/\s+/g, ' ').replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, '').trim().slice(0, 80);
1083
+ await rewriteCard(best.id, j => {
1084
+ j.content = `${j.content}\n✅ ${today}: closed by → ${ship}`;
1085
+ j.borderColor = 'rgba(16,185,129,0.35)';
1086
+ });
1087
+ await archiveCard(best.id);
1088
+ best.text = `✅ ${best.text}`;
1089
+ card.__closes = best.id;
1090
+ stats.closed++;
1091
+ }
1092
+ }
1093
+
779
1094
  cards.push(...milestonesFallback);
780
1095
  work = await finalizeBrainZip(zip, canvas, manifest, now);
781
1096
  }
@@ -811,6 +1126,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
811
1126
  const created = findNew(card.text);
812
1127
  if (!created) continue;
813
1128
  if (card.__supersedes) addConn(card.__supersedes, created.id, 'superseded by', undefined);
1129
+ if (card.__closes) addConn(card.__closes, created.id, 'closed by', undefined);
814
1130
  for (const link of (created.links || [])) {
815
1131
  const want = String(link).trim().toLowerCase();
816
1132
  if (!want) continue;