klypix-mcp 1.1.0 → 1.2.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.
@@ -347,6 +356,90 @@ server.registerTool('search_all_brains', {
347
356
  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
357
  });
349
358
 
359
+ server.registerTool('brain_insights', {
360
+ title: 'What matters in a brain — hubs, orphans, stale questions',
361
+ 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.',
362
+ inputSchema: {
363
+ canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
364
+ staleDays: z.number().optional().describe('Open questions older than this many days count as stale (default 21).'),
365
+ },
366
+ }, async ({ canvas, staleDays }) => {
367
+ const file = resolveCanvas(canvas || 'brain') || resolveCanvas('brain.klypix');
368
+ 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 };
369
+ try {
370
+ const { struct } = await parseKlypix(fs.readFileSync(file));
371
+ const ins = brainInsights(struct, staleDays ? { staleDays } : {});
372
+ return { content: [{ type: 'text', text: insightsToMarkdown(ins, struct.title) }] };
373
+ } catch (e) {
374
+ return { content: [{ type: 'text', text: `Insights failed: ${e.message}` }], isError: true };
375
+ }
376
+ });
377
+
378
+ server.registerTool('brain_connect', {
379
+ title: 'Connect related-but-unlinked brain cards (densify the graph)',
380
+ 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.',
381
+ inputSchema: {
382
+ canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
383
+ apply: z.boolean().optional().describe('false (default) = suggest only; true = draw the connections.'),
384
+ max: z.number().optional().describe('Max connections to propose/draw (default 24).'),
385
+ threshold: z.number().optional().describe('Min semantic similarity 0–1 to link (default 0.45). Higher = fewer, tighter links.'),
386
+ },
387
+ }, async ({ canvas, apply = false, max = 24, threshold = 0.45 }) => {
388
+ const file = resolveCanvas(canvas || 'brain') || resolveCanvas('brain.klypix');
389
+ if (!file) return { content: [{ type: 'text', text: `No brain canvas found in ${VAULT}.` }], isError: true };
390
+ let struct;
391
+ try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return { content: [{ type: 'text', text: `Read failed: ${e.message}` }], isError: true }; }
392
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 70);
393
+ const byId = new Map(struct.cards.map(c => [c.id, c]));
394
+ const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
395
+ const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
396
+
397
+ let edges = [];
398
+ let mode = 'structural (shared tags + [[mentions]])';
399
+ const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), 20_000))]);
400
+ if (pipe) {
401
+ try {
402
+ const vecs = await vectorsForBrain(pipe, file, struct.cards);
403
+ const items = live.filter(c => vecs.get(c.id));
404
+ for (const a of items) {
405
+ const av = vecs.get(a.id);
406
+ const sims = items
407
+ .filter(b => b.id !== a.id)
408
+ .map(b => ({ b, s: dot(av, vecs.get(b.id)), cross: (b.area || '') !== (a.area || '') }))
409
+ .sort((x, y) => (y.s + (y.cross ? 0.03 : 0)) - (x.s + (x.cross ? 0.03 : 0))); // nudge toward cross-area links
410
+ let taken = 0;
411
+ for (const { b, s } of sims) {
412
+ if (s < threshold || taken >= 2) break; // each card keeps its ≤2 strongest fresh links
413
+ const key = [a.id, b.id].sort().join('|');
414
+ if (linked.has(key)) continue;
415
+ linked.add(key);
416
+ edges.push({ fromId: a.id, toId: b.id, sim: s });
417
+ taken++;
418
+ }
419
+ }
420
+ edges.sort((x, y) => y.sim - x.sim);
421
+ mode = 'semantic (on-device)';
422
+ } catch (e) { mode = `structural (semantic failed: ${e.message})`; }
423
+ }
424
+ if (!edges.length && mode.startsWith('structural')) {
425
+ edges = proposeStructuralConnections(struct);
426
+ }
427
+ const chosen = edges.slice(0, max);
428
+ if (!chosen.length) return { content: [{ type: 'text', text: `Nothing to connect — no related-but-unlinked cards found (mode: ${mode}).` }] };
429
+
430
+ 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})` : ''}`;
431
+ if (!apply) {
432
+ 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')}` }] };
433
+ }
434
+ try {
435
+ const { buffer, added } = await addBrainConnections(fs.readFileSync(file), chosen);
436
+ await atomicWrite(file, buffer);
437
+ 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')}` }] };
438
+ } catch (e) {
439
+ return { content: [{ type: 'text', text: `Apply failed (brain unchanged): ${e.message}` }], isError: true };
440
+ }
441
+ });
442
+
350
443
  // Format the cards (optionally only a set of new ids) + connection graph so an
351
444
  // agent that just wrote can chain follow-ups: reference card IDs, place near a
352
445
  // position, or draw an arrow to something it created. Additive — appended after
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",
@@ -116,6 +116,7 @@ export async function parseKlypix(buffer) {
116
116
  })),
117
117
  connections: connections.map(c => ({
118
118
  from: titleOf(c.fromId), to: titleOf(c.toId),
119
+ fromId: c.fromId, toId: c.toId, // raw ids — for graph analysis (brainInsights)
119
120
  relationship: c.relationship || null, label: c.label || null,
120
121
  })),
121
122
  assets: assetPaths.map(p => path.basename(p)),
@@ -599,6 +600,10 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
599
600
  for (const c of focus) push(`- ${flat(c.text)}`);
600
601
  }
601
602
  if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${flat(c.text)}`); }
603
+ // ⚠️ Conflicts — pairs flagged conflicts_with (e.g. by parallel sessions);
604
+ // surfaced HIGH so the next session reconciles them, not buries them.
605
+ const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
606
+ 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
607
  const areaCounts = containers
603
608
  .filter(c => !/^archive$/i.test(c.title || ''))
604
609
  .map(c => `${flat(c.title)} (${texts.filter(t => t.parentId === c.id).length})`);
@@ -633,6 +638,214 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
633
638
  return out.join('\n') + '\n';
634
639
  }
635
640
 
641
+ // ── Relevance ranking ─────────────────────────────────────────────────────
642
+ // ONE shared lexical ranker so the per-prompt retrieval hook (and, later, the
643
+ // MCP search) rank cards the SAME way — no third divergent scorer. Weights
644
+ // mirror the MCP convention: title 3, tag 2, body 1, plus a gentle recency
645
+ // tiebreak. Pure + node-runnable (no embeddings / network) so the Stop/prompt
646
+ // hooks can call it with zero extra deps. `#file-…`/`#dir-…` tags (added at
647
+ // capture) are what make a git-diff token match a card precisely.
648
+ 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']);
649
+ export function queryTokens(s) {
650
+ return [...new Set(String(s || '').toLowerCase().match(/[a-z0-9][a-z0-9_-]{2,}/g) || [])].filter(t => !STOPWORDS.has(t));
651
+ }
652
+ const wordsOf = (s) => new Set(String(s || '').toLowerCase().match(/[a-z0-9][a-z0-9_-]{1,}/g) || []);
653
+ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2, recentDays = 30 } = {}) {
654
+ const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
655
+ if (!tokens.length || !struct || !Array.isArray(struct.cards)) return [];
656
+ const cutoff = Date.now() - recentDays * 86_400_000;
657
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
658
+ const scored = [];
659
+ for (const c of struct.cards) {
660
+ if (c.type === 'container' || isArchived(c) || !(c.text || '').trim()) continue;
661
+ // WORD-level matching (not substring) so "app" can't hit "append-klypix"
662
+ // and "main" can't hit "domain". Tag match is on the tag's STEM (the
663
+ // slug after #file-/#dir-/#) so a git-diff token (slugify(basename))
664
+ // lands EXACTLY on its #file- anchor — the precise signal, weighted = a
665
+ // title hit so ONE anchored file match (3 + 0.5 recency) clears minScore.
666
+ const titleW = wordsOf(c.title);
667
+ const bodyW = wordsOf(c.text);
668
+ const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
669
+ let score = 0;
670
+ for (const tok of tokens) {
671
+ if (titleW.has(tok)) score += 3;
672
+ else if (tagStems.has(tok)) score += 3;
673
+ else if (bodyW.has(tok)) score += 1;
674
+ }
675
+ if (score <= 0) continue;
676
+ if ((c.createdAt || 0) >= cutoff) score += 0.5; // gentle recency tiebreak, never dominant
677
+ scored.push({ card: c, score });
678
+ }
679
+ scored.sort((a, b) => b.score - a.score || (b.card.createdAt || 0) - (a.card.createdAt || 0));
680
+ return scored.filter(s => s.score >= minScore).slice(0, topK);
681
+ }
682
+
683
+ // ── Conflict candidate detection ───────────────────────────────────────────
684
+ // REAL conflicts only — a conflict is two decisions that genuinely CONTRADICT
685
+ // (you can't honor both about the same thing). Topical similarity, duplication,
686
+ // and sequential supersession are explicitly NOT conflicts and must never be
687
+ // flagged (no false-conflict dump). This is a cheap LEXICAL pre-filter that
688
+ // returns CANDIDATES only — same-subject, not-already-connected pairs where at
689
+ // least one card uses explicit REVERSAL language. Candidates mean nothing on
690
+ // their own: the caller (brain-conflicts.mjs) confirms each with an LLM
691
+ // contradiction-check before anything is ever drawn or surfaced. No verifier →
692
+ // nothing surfaces. Pure + node-runnable.
693
+ // Strong reversal/contradiction terms ONLY — deliberately NOT bare "not"/"don't"
694
+ // (too common → false positives). A candidate still has to clear the LLM gate.
695
+ 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;
696
+ export function detectConflicts(struct, { minOverlap = 0.45, topK = 12 } = {}) {
697
+ if (!struct || !Array.isArray(struct.cards)) return [];
698
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
699
+ const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
700
+ const wset = new Map(live.map(c => [c.id, new Set(queryTokens(c.text))]));
701
+ const connected = new Set();
702
+ for (const e of struct.connections || []) { connected.add(e.fromId + '|' + e.toId); connected.add(e.toId + '|' + e.fromId); }
703
+ const out = [];
704
+ for (let i = 0; i < live.length; i++) {
705
+ for (let j = i + 1; j < live.length; j++) {
706
+ const a = live[i], b = live[j];
707
+ if (connected.has(a.id + '|' + b.id)) continue;
708
+ const A = wset.get(a.id), B = wset.get(b.id);
709
+ if (A.size < 4 || B.size < 4) continue;
710
+ let inter = 0; for (const t of A) if (B.has(t)) inter++;
711
+ const overlap = inter / Math.min(A.size, B.size); // overlap coefficient — same subject?
712
+ // Must be same-subject AND carry an explicit reversal signal. Pure
713
+ // similarity / duplication is NOT a candidate (that was the dump).
714
+ if (overlap < minOverlap) continue;
715
+ if (!(OPPOSITION_RE.test(a.text) || OPPOSITION_RE.test(b.text))) continue;
716
+ out.push({ aId: a.id, bId: b.id, a: a.text, b: b.text, area: a.area, overlap: Math.round(overlap * 100) / 100 });
717
+ }
718
+ }
719
+ out.sort((x, y) => y.overlap - x.overlap);
720
+ return out.slice(0, topK);
721
+ }
722
+
723
+ // ── Brain insights ───────────────────────────────────────────────────────────
724
+ // "What matters here, and what am I forgetting?" — a deterministic structural
725
+ // read of the brain (borrowed from graphify's GRAPH_REPORT, applied to the
726
+ // AUTHORED brain, not derived code). Surfaces:
727
+ // • hubs — the most-connected cards (load-bearing decisions)
728
+ // • orphans — decision/milestone cards with NO connections (isolated;
729
+ // maybe forgotten — link them or archive them)
730
+ // • stale ❓ — open questions older than `staleDays` (aging unknowns)
731
+ // • areas — live-card count per area (what's growing / dormant)
732
+ // Pure + node-runnable: no LLM, no network. The agent renders/acts on it.
733
+ export function brainInsights(struct, { staleDays = 21, topHubs = 6 } = {}) {
734
+ const cutoff = Date.now() - staleDays * 86_400_000;
735
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
736
+ const cards = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
737
+ const live = cards.filter(c => !isArchived(c));
738
+ const deg = new Map();
739
+ for (const cn of struct.connections) {
740
+ if (cn.fromId) deg.set(cn.fromId, (deg.get(cn.fromId) || 0) + 1);
741
+ if (cn.toId) deg.set(cn.toId, (deg.get(cn.toId) || 0) + 1);
742
+ }
743
+ const headline = (c) => String(c.text || '').replace(/\s+/g, ' ').trim().replace(/^(.*?)([.!?](\s|$)|$)/, '$1').slice(0, 120);
744
+ const isQuestion = (c) => /❓/.test(c.text);
745
+ const hubs = live
746
+ .map(c => ({ id: c.id, area: c.area, degree: deg.get(c.id) || 0, headline: headline(c) }))
747
+ .filter(x => x.degree > 0)
748
+ .sort((a, b) => b.degree - a.degree)
749
+ .slice(0, topHubs);
750
+ const orphans = live
751
+ .filter(c => !isQuestion(c) && !/🌿|⤵/.test(c.text) && !(deg.get(c.id) > 0))
752
+ .map(c => ({ id: c.id, area: c.area, headline: headline(c), age: c.createdAt }));
753
+ const staleQuestions = live
754
+ .filter(c => isQuestion(c) && (c.createdAt || 0) < cutoff)
755
+ .map(c => ({ id: c.id, area: c.area, headline: headline(c), age: c.createdAt }))
756
+ .sort((a, b) => (a.age || 0) - (b.age || 0));
757
+ const areas = struct.cards
758
+ .filter(c => c.type === 'container' && !/^archive$/i.test(c.title || ''))
759
+ .map(c => ({ title: c.title, count: cards.filter(t => t.parentId === c.id).length }))
760
+ .sort((a, b) => b.count - a.count);
761
+ return {
762
+ hubs, orphans, staleQuestions, areas,
763
+ totals: { live: live.length, archived: cards.length - live.length, connections: struct.connections.length },
764
+ };
765
+ }
766
+
767
+ // Render brainInsights as a compact markdown report (used by the MCP tool).
768
+ export function insightsToMarkdown(ins, title = 'brain') {
769
+ const out = [`# ${title} — insights`, `*${ins.totals.live} live cards · ${ins.totals.archived} archived · ${ins.totals.connections} connections*`];
770
+ 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}`); }
771
+ 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`); }
772
+ 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}`); }
773
+ out.push('', '## 📍 Areas (by live cards)', ins.areas.map(a => `${a.title} (${a.count})`).join(' · ') || '(none)');
774
+ 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._');
775
+ return out.join('\n') + '\n';
776
+ }
777
+
778
+ // Append raw connections (by id) to a brain — additive, never deletes, skips
779
+ // self-links / duplicates / dangling ids, verifies a round-trip parse. Used by
780
+ // the brain_connect tool to densify a sparse brain into a real graph.
781
+ export async function addBrainConnections(buffer, edges) {
782
+ const { zip, canvas, manifest, isV4 } = await parseKlypix(buffer);
783
+ if (!isV4 || !canvas.positions) throw new Error('connections need a v4 .klypix');
784
+ canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
785
+ const linked = (a, b) => canvas.connections.some(c => (c.fromId === a && c.toId === b) || (c.fromId === b && c.toId === a));
786
+ const rand = () => Math.random().toString(36).slice(2, 10);
787
+ let added = 0;
788
+ for (const e of edges || []) {
789
+ if (!e.fromId || !e.toId || e.fromId === e.toId) continue;
790
+ if (!canvas.positions[e.fromId] || !canvas.positions[e.toId]) continue; // both must exist
791
+ if (linked(e.fromId, e.toId)) continue;
792
+ canvas.connections.push({
793
+ id: `con_${rand()}`, fromId: e.fromId, toId: e.toId,
794
+ relationship: REL.has(e.relationship) ? e.relationship : 'relates_to',
795
+ label: typeof e.label === 'string' ? e.label : undefined,
796
+ arrowHead: true, width: 2, color: typeof e.color === 'string' ? e.color : '#10b981', style: 'solid',
797
+ });
798
+ added++;
799
+ }
800
+ if (!added) return { buffer, added: 0 };
801
+ const out = await finalizeBrainZip(zip, canvas, manifest, Date.now());
802
+ return { buffer: out, added };
803
+ }
804
+
805
+ // Structural connection suggestions — pure, no embeddings (the fallback when
806
+ // the on-device model isn't installed, and the twin of the in-app Connect
807
+ // button). Signals, strongest first: an unlinked [[title]] mention (3), a
808
+ // shared topical tag ACROSS areas (2), a shared tag within an area (1). The
809
+ // area-name tag is dropped (redundant with containment), and — crucially —
810
+ // each card keeps at most `maxPerCard` links, so a tag shared by ten cards
811
+ // can't explode into a 45-edge clique. Mirrors the semantic pass's discipline.
812
+ export function proposeStructuralConnections(struct, { maxPerCard = 2 } = {}) {
813
+ const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
814
+ const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
815
+ const titleIx = live.filter(c => (c.title || '').trim()).map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
816
+ const tagsOf = (c) => (c.tags || [])
817
+ .map(t => String(t).toLowerCase().replace(/^#/, ''))
818
+ .filter(t => t && t !== 'area' && t !== String(c.area || '').toLowerCase());
819
+ const cand = [];
820
+ for (const c of live) {
821
+ for (const link of (c.links || [])) {
822
+ const want = String(link).trim().toLowerCase();
823
+ const tgt = titleIx.find(e => e.id !== c.id && (e.t === want || e.t.startsWith(want)));
824
+ if (tgt) cand.push({ a: c.id, b: tgt.id, score: 3, why: 'mention' });
825
+ }
826
+ const ct = tagsOf(c);
827
+ if (!ct.length) continue;
828
+ for (const d of live) {
829
+ if (d.id <= c.id) continue;
830
+ 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' });
831
+ }
832
+ }
833
+ cand.sort((x, y) => y.score - x.score);
834
+ const per = new Map();
835
+ const edges = [];
836
+ for (const e of cand) {
837
+ if (e.a === e.b) continue;
838
+ const key = [e.a, e.b].sort().join('|');
839
+ if (linked.has(key)) continue;
840
+ if ((per.get(e.a) || 0) >= maxPerCard || (per.get(e.b) || 0) >= maxPerCard) continue;
841
+ linked.add(key);
842
+ per.set(e.a, (per.get(e.a) || 0) + 1);
843
+ per.set(e.b, (per.get(e.b) || 0) + 1);
844
+ edges.push({ fromId: e.a, toId: e.b, why: e.why });
845
+ }
846
+ return edges;
847
+ }
848
+
636
849
  // ── Atomic brain capture: supersede + append + resolve + auto-link ──────────
637
850
  // One verified write per capture batch. Beyond appendIntoContainers it adds:
638
851
  // • SUPERSEDE — a new decision that heavily overlaps an existing live card in
@@ -694,10 +907,28 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
694
907
  if (pos) canvas.positions[id] = { ...pos, h: measureCardH(j.content) };
695
908
  return true;
696
909
  };
697
- const archiveCard = (id) => {
910
+ const archiveCard = async (id) => {
698
911
  const arc = ensureArchive();
912
+ // The Archive is readable STORAGE, not a vector-scaled layout: un-bake
913
+ // any group-shrink (font/width baked tiny) by restoring the frozen
914
+ // authored baseline and dropping it, so the card sits in the Archive
915
+ // at full readable size and re-seeds cleanly if the Archive is resized.
916
+ let authoredW = null;
917
+ const ip = `items/${shard(id)}/${id}.json`;
918
+ const f = zip.file(ip);
919
+ if (f) {
920
+ const j = JSON.parse(await f.async('string'));
921
+ const a = j.authoredInParent;
922
+ if (a) {
923
+ if (j.type === 'text' && a.fontSize) j.fontSize = a.fontSize;
924
+ if (a.authoredWidth != null) j.authoredWidth = a.authoredWidth;
925
+ authoredW = a.w || null;
926
+ delete j.authoredInParent;
927
+ zip.file(ip, JSON.stringify(j));
928
+ }
929
+ }
699
930
  const pos = canvas.positions[id];
700
- if (pos) canvas.positions[id] = { ...pos, parentId: arc };
931
+ if (pos) canvas.positions[id] = { ...pos, parentId: arc, ...(authoredW ? { w: authoredW } : {}) };
701
932
  };
702
933
  canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
703
934
 
@@ -716,7 +947,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
716
947
  j.content = `${j.content}\n✅ ${today}: ${r.text}`;
717
948
  j.borderColor = 'rgba(16,185,129,0.35)';
718
949
  });
719
- archiveCard(best.id);
950
+ await archiveCard(best.id);
720
951
  best.text += ` ✅ ${r.text}`; // keep in-memory struct honest for later matching
721
952
  stats.resolved++;
722
953
  } else {
@@ -769,7 +1000,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
769
1000
  j.content = `↩︎ superseded ${today}\n${j.content}`;
770
1001
  j.borderColor = 'rgba(120,120,135,0.5)';
771
1002
  });
772
- archiveCard(best.id);
1003
+ await archiveCard(best.id);
773
1004
  best.text = `↩︎ ${best.text}`;
774
1005
  card.__supersedes = best.id;
775
1006
  stats.superseded++;