klypix-mcp 1.17.0 → 1.18.1

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/klypix-format.mjs +218 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.17.0",
3
+ "version": "1.18.1",
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",
@@ -53,7 +53,7 @@
53
53
  "node": ">=18"
54
54
  },
55
55
  "scripts": {
56
- "test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs"
56
+ "test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs"
57
57
  },
58
58
  "dependencies": {
59
59
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -498,13 +498,20 @@ export async function tidyBrain(buffer) {
498
498
 
499
499
  // Normalize every text card to the compact brain font (so the render matches
500
500
  // our height measure → no overlap) + cache each card's measured height.
501
+ // ALSO drop each card's `authoredInParent` anchor: the KLYPIX app freezes a
502
+ // child's in-container position in that field and RE-DERIVES the card's x/y
503
+ // from it on every render (ContainerItem group-scale) — so it would ignore
504
+ // the masonry x/y we write and snap cards back to their old single-column
505
+ // spots (the container then auto-grows to wrap them → skyscraper). Clearing
506
+ // the anchor makes THIS layout the card's authored baseline; the app re-seeds
507
+ // from our x/y. Verified against the app's render math in test/layout-cluster.
501
508
  const meta = new Map(); // id -> { h }
502
509
  for (const c of struct.cards) {
503
510
  if (c.type === 'container') continue;
504
511
  const wrapped = wrapText(String(c.text ?? ''));
505
512
  let createdAt = 0;
506
513
  const ip = `items/${shard(c.id)}/${c.id}.json`;
507
- try { const f = zip.file(ip); if (f) { const j = JSON.parse(await f.async('string')); createdAt = Number(j.createdAt) || 0; j.fontSize = G.FONT; j.content = wrapped; zip.file(ip, JSON.stringify(j)); } } catch { /* leave as-is */ }
514
+ try { const f = zip.file(ip); if (f) { const j = JSON.parse(await f.async('string')); createdAt = Number(j.createdAt) || 0; j.fontSize = G.FONT; j.content = wrapped; delete j.authoredInParent; zip.file(ip, JSON.stringify(j)); } } catch { /* leave as-is */ }
508
515
  meta.set(c.id, { h: measureCardH(wrapped), createdAt });
509
516
  }
510
517
 
@@ -518,6 +525,7 @@ export async function tidyBrain(buffer) {
518
525
  for (const c of rootText) { const a = areaOfCard(c); const k = a.toLowerCase(); if (!groups.has(k)) groups.set(k, { title: a, ids: [] }); groups.get(k).ids.push(c.id); }
519
526
 
520
527
  let moved = 0;
528
+ const createdNow = new Set(); // containers born in THIS pass have no meaningful previous position
521
529
  const assignTo = (ctnId, ids) => { for (const id of ids) { const p = canvas.positions[id]; canvas.positions[id] = { ...p, parentId: ctnId, zKey: (p && p.zKey && isValidZKey(p.zKey)) ? p.zKey : nextZKey() }; moved++; } };
522
530
  // Ensure a container exists for each area (create if missing); route root cards in.
523
531
  for (const grp of groups.values()) {
@@ -525,6 +533,7 @@ export async function tidyBrain(buffer) {
525
533
  let ctnId = byTitle.get(key);
526
534
  if (!ctnId) {
527
535
  ctnId = `ctn_${rand()}`;
536
+ createdNow.add(ctnId);
528
537
  zip.file(`items/${shard(ctnId)}/${ctnId}.json`, JSON.stringify({ type: 'container', locked: false, createdAt: now, createdBy: 'agent', title: grp.title, collapsed: false, scopeLocked: false, borderColor: '#10b981' }));
529
538
  canvas.order.push(ctnId);
530
539
  canvas.positions[ctnId] = { x: G.START, y: G.START, w: G.AREA_W, h: G.TITLE_BAR + G.PAD * 2, zKey: nextZKey(), zIndex: canvas.order.length, parentId: null };
@@ -533,23 +542,217 @@ export async function tidyBrain(buffer) {
533
542
  assignTo(ctnId, grp.ids);
534
543
  }
535
544
 
536
- // UNIFIED LAYOUT — re-flow each container's children (chronological, compact
537
- // heights) AND shelf-pack ALL containers into one grid by their TRUE height, so
538
- // a container that grew (new captures) never overlaps its neighbor below.
545
+ // ── CLUSTER LAYOUT ────────────────────────────────────────────────────────
546
+ // The old shelf-grid stacked every area's cards in ONE column and packed
547
+ // containers in creation order — at 400+ cards the brain rendered as an
548
+ // unreadable strip and position encoded nothing. Now:
549
+ // • MASONRY inside each container: cards flow into 1-5 balanced columns
550
+ // targeting a ~1.15 height/width ratio → areas are squarish tiles.
551
+ // • CLUSTERS across containers: each area is placed greedily beside the
552
+ // already-placed area it shares the most connections with — related
553
+ // knowledge is literally near, cross-area arrows stay short.
554
+ // • 📌 Focus (the human steering surface) anchors the map center; Archive
555
+ // is pinned to the cold rim (its supersede/close arrows touch every
556
+ // area, so edge weights would otherwise drag it central).
557
+ // Deterministic (no RNG, stable ordering): the same brain always maps the
558
+ // same way, and one new card nudges its own cluster instead of reshuffling
559
+ // the world. At map zoom the app's capsule/dot tiers render this as the
560
+ // cluster-galaxy view; membership steering (dragging a card into an area)
561
+ // is preserved — tidy re-flows coordinates, never parentage.
562
+ // Containers are never masonry "kids" — a nested container would otherwise
563
+ // be committed twice (as its parent's 300×40 pseudo-card AND as its own
564
+ // box), stranding its children at abandoned coordinates.
539
565
  const childrenOf = (cid) => canvas.order
540
- .filter(id => canvas.positions[id] && canvas.positions[id].parentId === cid)
566
+ .filter(id => !containerIds.has(id) && canvas.positions[id] && canvas.positions[id].parentId === cid)
541
567
  .sort((a, b) => (meta.get(a)?.createdAt || 0) - (meta.get(b)?.createdAt || 0));
542
- const heightOf = (cid) => { const inner = childrenOf(cid).reduce((s, id) => s + (meta.get(id)?.h || 40) + G.CARD_GAP, 0); return Math.max(G.TITLE_BAR + G.PAD * 2, G.TITLE_BAR + G.PAD + inner + G.PAD); };
543
568
  const orderedCtns = canvas.order.filter(id => containerIds.has(id) && canvas.positions[id]);
544
- const cols = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(Math.max(1, orderedCtns.length)))));
545
- let colIdx = 0, rowTopY = G.START, rowMaxH = 0, colX = G.START;
546
- for (const cid of orderedCtns) {
547
- const h = heightOf(cid);
548
- if (colIdx >= cols) { rowTopY += rowMaxH + G.COL_GAP; rowMaxH = 0; colIdx = 0; colX = G.START; }
549
- canvas.positions[cid] = { ...canvas.positions[cid], x: colX, y: rowTopY, w: G.AREA_W, h };
550
- let cy = rowTopY + G.TITLE_BAR + G.PAD;
551
- for (const kid of childrenOf(cid)) { const kh = meta.get(kid)?.h || 40; canvas.positions[kid] = { ...canvas.positions[kid], x: colX + G.PAD, y: cy, w: G.CARD_W, h: kh }; cy += kh + G.CARD_GAP; }
552
- colX += G.AREA_W + G.COL_GAP; colIdx++; rowMaxH = Math.max(rowMaxH, h);
569
+ const ctnTitle = new Map();
570
+ for (const c of struct.cards) if (c.type === 'container') ctnTitle.set(c.id, c.title || '');
571
+ for (const [t, id] of byTitle) if (!ctnTitle.has(id)) ctnTitle.set(id, t);
572
+ const isArchiveCtn = (cid) => /^archive$/i.test(String(ctnTitle.get(cid) || '').trim());
573
+ const isFocusCtn = (cid) => /(^|\s)focus\b/i.test(String(ctnTitle.get(cid) || ''));
574
+
575
+ if (orderedCtns.length) {
576
+ // 0. Flatten human-nested containers to root. The capture toolchain
577
+ // never nests; a hand-nested area would be committed twice (as its
578
+ // parent's 300×40 pseudo-card AND as its own box), stranding its
579
+ // children. Promoted areas keep their absolute spot via the
580
+ // incremental anchor below, so visually nothing jumps.
581
+ for (const cid of orderedCtns) {
582
+ const p = canvas.positions[cid];
583
+ if (p && p.parentId != null && containerIds.has(p.parentId)) canvas.positions[cid] = { ...p, parentId: null };
584
+ }
585
+
586
+ // 1. Masonry plan per container: pick the column count whose resulting
587
+ // box is closest to the target aspect, then flow cards (chronological)
588
+ // into the currently-shortest column.
589
+ const plans = new Map(); // cid -> { w, h, kids: [{id, dx, dy, h}] }
590
+ for (const cid of orderedCtns) {
591
+ const kids = childrenOf(cid);
592
+ const totalH = kids.reduce((s, id) => s + (meta.get(id)?.h || 40) + G.CARD_GAP, 0);
593
+ let k = 1, best = Infinity;
594
+ for (let n = 1; n <= 5 && n <= Math.max(1, kids.length); n++) {
595
+ const w = G.PAD * 2 + n * G.CARD_W + (n - 1) * G.CARD_GAP;
596
+ const h = G.TITLE_BAR + G.PAD * 2 + Math.max(40, totalH / n);
597
+ const score = Math.abs((h / w) - 1.15);
598
+ if (score < best) { best = score; k = n; }
599
+ }
600
+ const colY = new Array(k).fill(G.TITLE_BAR + G.PAD);
601
+ const placedKids = [];
602
+ for (const id of kids) {
603
+ const h = meta.get(id)?.h || 40;
604
+ let col = 0; for (let c = 1; c < k; c++) if (colY[c] < colY[col]) col = c;
605
+ placedKids.push({ id, dx: G.PAD + col * (G.CARD_W + G.CARD_GAP), dy: colY[col], h });
606
+ colY[col] += h + G.CARD_GAP;
607
+ }
608
+ plans.set(cid, {
609
+ w: G.PAD * 2 + k * G.CARD_W + (k - 1) * G.CARD_GAP,
610
+ h: Math.max(G.TITLE_BAR + G.PAD * 2, Math.max(...colY) + G.PAD),
611
+ kids: placedKids,
612
+ });
613
+ }
614
+
615
+ // 2. Inter-area connection weights (Archive excluded — rim-pinned).
616
+ const parentOf = (id) => canvas.positions[id]?.parentId ?? null;
617
+ const weights = new Map();
618
+ for (const cn of (canvas.connections || [])) {
619
+ const pa = parentOf(cn.fromId), pb = parentOf(cn.toId);
620
+ if (!pa || !pb || pa === pb || !plans.has(pa) || !plans.has(pb)) continue;
621
+ if (isArchiveCtn(pa) || isArchiveCtn(pb)) continue;
622
+ const key = pa < pb ? pa + '|' + pb : pb + '|' + pa;
623
+ weights.set(key, (weights.get(key) || 0) + 1);
624
+ }
625
+ const wOf = (a, b) => weights.get(a < b ? a + '|' + b : b + '|' + a) || 0;
626
+ const degree = new Map(orderedCtns.map(cid => [cid, 0]));
627
+ for (const [key, n] of weights) { const [a, b] = key.split('|'); degree.set(a, (degree.get(a) || 0) + n); degree.set(b, (degree.get(b) || 0) + n); }
628
+
629
+ // 3. INCREMENTAL by default, full cluster pass only on migration.
630
+ // The full pass orders by (mutable) connectivity — running it per
631
+ // capture reshuffled the entire map the moment one cross-area arrow
632
+ // landed (field-measured: 45/45 containers teleported ~4.4k px on one
633
+ // wikilink). A memory's map must be as stable as the memory: normally
634
+ // every container ANCHORS to its previous spot (taken verbatim when
635
+ // nothing grew into it), so a capture moves at most the areas whose
636
+ // size actually changed. The full pass runs only when the previous
637
+ // layout isn't this engine's (legacy strip, foreign grid, degenerate
638
+ // aspect) — detected via the settings stamp + geometry heuristics.
639
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0); // code-unit compare — locale-independent, unlike bare localeCompare
640
+ const prev = new Map();
641
+ for (const cid of orderedCtns) {
642
+ if (createdNow.has(cid)) continue;
643
+ const p = canvas.positions[cid];
644
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) prev.set(cid, { x: p.x, y: p.y, w: p.w || 0, h: p.h || 0 });
645
+ }
646
+ let fullPass = !(canvas.settings && canvas.settings.brainLayout === 'cluster-v1');
647
+ if (fullPass && prev.size >= 2 && [...prev.values()].some(b => b.w > 400)) fullPass = false; // stamp lost (e.g. app re-save) but geometry is clearly cluster-made
648
+ if (!fullPass && prev.size >= 2) {
649
+ const xs = [...prev.values()];
650
+ const w = Math.max(...xs.map(b => b.x + b.w)) - Math.min(...xs.map(b => b.x));
651
+ const h = Math.max(...xs.map(b => b.y + b.h)) - Math.min(...xs.map(b => b.y));
652
+ const aspect = h / Math.max(1, w);
653
+ if (aspect > 4 || aspect < 0.1) fullPass = true; // degenerate strip → re-map
654
+ }
655
+ if (prev.size < 2) fullPass = true;
656
+
657
+ const focusCtns = orderedCtns.filter(isFocusCtn);
658
+ const isRim = (c) => !isFocusCtn(c) && isArchiveCtn(c);
659
+ let placeOrder, rimCtns;
660
+ if (fullPass) {
661
+ // Hubs early so satellites attach to them; Focus anchors the map.
662
+ rimCtns = orderedCtns.filter(isRim);
663
+ const middle = orderedCtns.filter(c => !isFocusCtn(c) && !isRim(c))
664
+ .sort((a, b) => ((degree.get(b) || 0) - (degree.get(a) || 0))
665
+ || (plans.get(b).kids.length - plans.get(a).kids.length)
666
+ || cmp(String(ctnTitle.get(a) || ''), String(ctnTitle.get(b) || ''))
667
+ || cmp(a, b));
668
+ placeOrder = [...focusCtns, ...middle];
669
+ } else {
670
+ // Previous spatial order (top-left claims its spot first) — an
671
+ // IMMUTABLE ordering, so new arrows can't reshuffle the queue.
672
+ const anchored = orderedCtns.filter(c => prev.has(c))
673
+ .sort((a, b) => (prev.get(a).y - prev.get(b).y) || (prev.get(a).x - prev.get(b).x) || cmp(a, b));
674
+ const fresh = orderedCtns.filter(c => !prev.has(c) && !isRim(c))
675
+ .sort((a, b) => ((degree.get(b) || 0) - (degree.get(a) || 0)) || cmp(a, b));
676
+ rimCtns = orderedCtns.filter(c => !prev.has(c) && isRim(c));
677
+ placeOrder = [...anchored, ...fresh];
678
+ }
679
+
680
+ // 4. Greedy placement. Anchored containers try their previous spot
681
+ // verbatim first (zero movement when nothing grew into it), else the
682
+ // nearest free side/corner slot. Unanchored ones score by
683
+ // connection-weighted distance + a gentle compactness pull.
684
+ const GAP = G.COL_GAP;
685
+ const boxes = new Map();
686
+ const overlapsAny = (b) => { for (const o of boxes.values()) if (b.x < o.x + o.w + GAP && o.x < b.x + b.w + GAP && b.y < o.y + o.h + GAP && o.y < b.y + b.h + GAP) return true; return false; };
687
+ const cxOf = (b) => b.x + b.w / 2, cyOf = (b) => b.y + b.h / 2;
688
+ const distC = (a, b) => Math.hypot(cxOf(a) - cxOf(b), cyOf(a) - cyOf(b));
689
+ for (const cid of placeOrder) {
690
+ const plan = plans.get(cid);
691
+ const anchor = !fullPass && prev.has(cid) ? prev.get(cid) : null;
692
+ if (anchor) {
693
+ const b0 = { x: anchor.x, y: anchor.y, w: plan.w, h: plan.h };
694
+ if (!overlapsAny(b0)) { boxes.set(cid, b0); continue; }
695
+ }
696
+ if (!boxes.size) { boxes.set(cid, { x: anchor ? anchor.x : 0, y: anchor ? anchor.y : 0, w: plan.w, h: plan.h }); continue; }
697
+ let mx = 0, my = 0;
698
+ for (const b of boxes.values()) { mx += cxOf(b); my += cyOf(b); }
699
+ mx /= boxes.size; my /= boxes.size;
700
+ const anchorBox = anchor ? { x: anchor.x, y: anchor.y, w: plan.w, h: plan.h } : null;
701
+ let bestPos = null, bestScore = Infinity;
702
+ for (const o of boxes.values()) {
703
+ const cands = [
704
+ { x: o.x + o.w + GAP, y: o.y }, { x: o.x, y: o.y + o.h + GAP },
705
+ { x: o.x - GAP - plan.w, y: o.y }, { x: o.x, y: o.y - GAP - plan.h },
706
+ { x: o.x + o.w + GAP, y: o.y + o.h + GAP }, { x: o.x - GAP - plan.w, y: o.y + o.h + GAP },
707
+ { x: o.x + o.w + GAP, y: o.y - GAP - plan.h }, { x: o.x - GAP - plan.w, y: o.y - GAP - plan.h },
708
+ ];
709
+ for (const c of cands) {
710
+ const b = { x: c.x, y: c.y, w: plan.w, h: plan.h };
711
+ if (overlapsAny(b)) continue;
712
+ let score;
713
+ if (anchorBox) {
714
+ score = distC(b, anchorBox); // reclaim the old neighborhood
715
+ } else {
716
+ let pull = 0, wsum = 0;
717
+ for (const [pid, pb] of boxes) { const w = wOf(cid, pid); if (w) { pull += w * distC(b, pb); wsum += w; } }
718
+ const toCentroid = Math.hypot(cxOf(b) - mx, cyOf(b) - my);
719
+ score = (wsum ? pull / wsum : toCentroid) + 0.05 * toCentroid;
720
+ }
721
+ if (score < bestScore) { bestScore = score; bestPos = b; }
722
+ }
723
+ }
724
+ boxes.set(cid, bestPos || { x: 0, y: Math.max(...[...boxes.values()].map(b => b.y + b.h)) + GAP, w: plan.w, h: plan.h });
725
+ }
726
+ // Rim containers (Archive without a previous spot / full pass): the
727
+ // cold right edge, each right of the last so they never stack.
728
+ for (const cid of rimCtns) {
729
+ const plan = plans.get(cid);
730
+ const maxX = boxes.size ? Math.max(...[...boxes.values()].map(b => b.x + b.w)) : 0;
731
+ const topY = boxes.size ? Math.min(...[...boxes.values()].map(b => b.y)) : 0;
732
+ boxes.set(cid, { x: maxX + GAP * 3, y: topY, w: plan.w, h: plan.h });
733
+ }
734
+
735
+ // 5. Commit: normalize to START only when the map would drift off-origin
736
+ // (incremental runs keep absolute coordinates → anchored spots stay
737
+ // byte-identical), write containers + kids, stamp the layout engine.
738
+ const minX = Math.min(...[...boxes.values()].map(b => b.x));
739
+ const minY = Math.min(...[...boxes.values()].map(b => b.y));
740
+ const dx = (fullPass || minX < 0) ? G.START - minX : 0;
741
+ const dy = (fullPass || minY < 0) ? G.START - minY : 0;
742
+ for (const [cid, b] of boxes) {
743
+ const x = b.x + dx, y = b.y + dy;
744
+ canvas.positions[cid] = { ...canvas.positions[cid], x, y, w: b.w, h: b.h };
745
+ for (const kid of plans.get(cid).kids) {
746
+ canvas.positions[kid.id] = { ...canvas.positions[kid.id], x: x + kid.dx, y: y + kid.dy, w: G.CARD_W, h: kid.h };
747
+ }
748
+ // Drop the container's frozen group-scale baseline so the app's
749
+ // child-scaling pass early-returns (ContainerItem: `if
750
+ // (!item.authoredW) return`) and honors our masonry child x/y
751
+ // instead of scaling children off a stale authored size.
752
+ const cp = `items/${shard(cid)}/${cid}.json`;
753
+ try { const f = zip.file(cp); if (f) { const j = JSON.parse(await f.async('string')); if (j.authoredW != null || j.authoredH != null) { delete j.authoredW; delete j.authoredH; zip.file(cp, JSON.stringify(j)); } } } catch { /* leave as-is */ }
754
+ }
755
+ canvas.settings = { ...(canvas.settings || {}), brainLayout: 'cluster-v1' };
553
756
  }
554
757
 
555
758
  const out = await finalizeBrainZip(zip, canvas, manifest, now);