klypix-mcp 1.17.0 → 1.18.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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/klypix-format.mjs +204 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.17.0",
3
+ "version": "1.18.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",
@@ -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",
@@ -518,6 +518,7 @@ export async function tidyBrain(buffer) {
518
518
  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
519
 
520
520
  let moved = 0;
521
+ const createdNow = new Set(); // containers born in THIS pass have no meaningful previous position
521
522
  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
523
  // Ensure a container exists for each area (create if missing); route root cards in.
523
524
  for (const grp of groups.values()) {
@@ -525,6 +526,7 @@ export async function tidyBrain(buffer) {
525
526
  let ctnId = byTitle.get(key);
526
527
  if (!ctnId) {
527
528
  ctnId = `ctn_${rand()}`;
529
+ createdNow.add(ctnId);
528
530
  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
531
  canvas.order.push(ctnId);
530
532
  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 +535,211 @@ export async function tidyBrain(buffer) {
533
535
  assignTo(ctnId, grp.ids);
534
536
  }
535
537
 
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.
538
+ // ── CLUSTER LAYOUT ────────────────────────────────────────────────────────
539
+ // The old shelf-grid stacked every area's cards in ONE column and packed
540
+ // containers in creation order — at 400+ cards the brain rendered as an
541
+ // unreadable strip and position encoded nothing. Now:
542
+ // • MASONRY inside each container: cards flow into 1-5 balanced columns
543
+ // targeting a ~1.15 height/width ratio → areas are squarish tiles.
544
+ // • CLUSTERS across containers: each area is placed greedily beside the
545
+ // already-placed area it shares the most connections with — related
546
+ // knowledge is literally near, cross-area arrows stay short.
547
+ // • 📌 Focus (the human steering surface) anchors the map center; Archive
548
+ // is pinned to the cold rim (its supersede/close arrows touch every
549
+ // area, so edge weights would otherwise drag it central).
550
+ // Deterministic (no RNG, stable ordering): the same brain always maps the
551
+ // same way, and one new card nudges its own cluster instead of reshuffling
552
+ // the world. At map zoom the app's capsule/dot tiers render this as the
553
+ // cluster-galaxy view; membership steering (dragging a card into an area)
554
+ // is preserved — tidy re-flows coordinates, never parentage.
555
+ // Containers are never masonry "kids" — a nested container would otherwise
556
+ // be committed twice (as its parent's 300×40 pseudo-card AND as its own
557
+ // box), stranding its children at abandoned coordinates.
539
558
  const childrenOf = (cid) => canvas.order
540
- .filter(id => canvas.positions[id] && canvas.positions[id].parentId === cid)
559
+ .filter(id => !containerIds.has(id) && canvas.positions[id] && canvas.positions[id].parentId === cid)
541
560
  .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
561
  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);
562
+ const ctnTitle = new Map();
563
+ for (const c of struct.cards) if (c.type === 'container') ctnTitle.set(c.id, c.title || '');
564
+ for (const [t, id] of byTitle) if (!ctnTitle.has(id)) ctnTitle.set(id, t);
565
+ const isArchiveCtn = (cid) => /^archive$/i.test(String(ctnTitle.get(cid) || '').trim());
566
+ const isFocusCtn = (cid) => /(^|\s)focus\b/i.test(String(ctnTitle.get(cid) || ''));
567
+
568
+ if (orderedCtns.length) {
569
+ // 0. Flatten human-nested containers to root. The capture toolchain
570
+ // never nests; a hand-nested area would be committed twice (as its
571
+ // parent's 300×40 pseudo-card AND as its own box), stranding its
572
+ // children. Promoted areas keep their absolute spot via the
573
+ // incremental anchor below, so visually nothing jumps.
574
+ for (const cid of orderedCtns) {
575
+ const p = canvas.positions[cid];
576
+ if (p && p.parentId != null && containerIds.has(p.parentId)) canvas.positions[cid] = { ...p, parentId: null };
577
+ }
578
+
579
+ // 1. Masonry plan per container: pick the column count whose resulting
580
+ // box is closest to the target aspect, then flow cards (chronological)
581
+ // into the currently-shortest column.
582
+ const plans = new Map(); // cid -> { w, h, kids: [{id, dx, dy, h}] }
583
+ for (const cid of orderedCtns) {
584
+ const kids = childrenOf(cid);
585
+ const totalH = kids.reduce((s, id) => s + (meta.get(id)?.h || 40) + G.CARD_GAP, 0);
586
+ let k = 1, best = Infinity;
587
+ for (let n = 1; n <= 5 && n <= Math.max(1, kids.length); n++) {
588
+ const w = G.PAD * 2 + n * G.CARD_W + (n - 1) * G.CARD_GAP;
589
+ const h = G.TITLE_BAR + G.PAD * 2 + Math.max(40, totalH / n);
590
+ const score = Math.abs((h / w) - 1.15);
591
+ if (score < best) { best = score; k = n; }
592
+ }
593
+ const colY = new Array(k).fill(G.TITLE_BAR + G.PAD);
594
+ const placedKids = [];
595
+ for (const id of kids) {
596
+ const h = meta.get(id)?.h || 40;
597
+ let col = 0; for (let c = 1; c < k; c++) if (colY[c] < colY[col]) col = c;
598
+ placedKids.push({ id, dx: G.PAD + col * (G.CARD_W + G.CARD_GAP), dy: colY[col], h });
599
+ colY[col] += h + G.CARD_GAP;
600
+ }
601
+ plans.set(cid, {
602
+ w: G.PAD * 2 + k * G.CARD_W + (k - 1) * G.CARD_GAP,
603
+ h: Math.max(G.TITLE_BAR + G.PAD * 2, Math.max(...colY) + G.PAD),
604
+ kids: placedKids,
605
+ });
606
+ }
607
+
608
+ // 2. Inter-area connection weights (Archive excluded — rim-pinned).
609
+ const parentOf = (id) => canvas.positions[id]?.parentId ?? null;
610
+ const weights = new Map();
611
+ for (const cn of (canvas.connections || [])) {
612
+ const pa = parentOf(cn.fromId), pb = parentOf(cn.toId);
613
+ if (!pa || !pb || pa === pb || !plans.has(pa) || !plans.has(pb)) continue;
614
+ if (isArchiveCtn(pa) || isArchiveCtn(pb)) continue;
615
+ const key = pa < pb ? pa + '|' + pb : pb + '|' + pa;
616
+ weights.set(key, (weights.get(key) || 0) + 1);
617
+ }
618
+ const wOf = (a, b) => weights.get(a < b ? a + '|' + b : b + '|' + a) || 0;
619
+ const degree = new Map(orderedCtns.map(cid => [cid, 0]));
620
+ 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); }
621
+
622
+ // 3. INCREMENTAL by default, full cluster pass only on migration.
623
+ // The full pass orders by (mutable) connectivity — running it per
624
+ // capture reshuffled the entire map the moment one cross-area arrow
625
+ // landed (field-measured: 45/45 containers teleported ~4.4k px on one
626
+ // wikilink). A memory's map must be as stable as the memory: normally
627
+ // every container ANCHORS to its previous spot (taken verbatim when
628
+ // nothing grew into it), so a capture moves at most the areas whose
629
+ // size actually changed. The full pass runs only when the previous
630
+ // layout isn't this engine's (legacy strip, foreign grid, degenerate
631
+ // aspect) — detected via the settings stamp + geometry heuristics.
632
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0); // code-unit compare — locale-independent, unlike bare localeCompare
633
+ const prev = new Map();
634
+ for (const cid of orderedCtns) {
635
+ if (createdNow.has(cid)) continue;
636
+ const p = canvas.positions[cid];
637
+ 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 });
638
+ }
639
+ let fullPass = !(canvas.settings && canvas.settings.brainLayout === 'cluster-v1');
640
+ 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
641
+ if (!fullPass && prev.size >= 2) {
642
+ const xs = [...prev.values()];
643
+ const w = Math.max(...xs.map(b => b.x + b.w)) - Math.min(...xs.map(b => b.x));
644
+ const h = Math.max(...xs.map(b => b.y + b.h)) - Math.min(...xs.map(b => b.y));
645
+ const aspect = h / Math.max(1, w);
646
+ if (aspect > 4 || aspect < 0.1) fullPass = true; // degenerate strip → re-map
647
+ }
648
+ if (prev.size < 2) fullPass = true;
649
+
650
+ const focusCtns = orderedCtns.filter(isFocusCtn);
651
+ const isRim = (c) => !isFocusCtn(c) && isArchiveCtn(c);
652
+ let placeOrder, rimCtns;
653
+ if (fullPass) {
654
+ // Hubs early so satellites attach to them; Focus anchors the map.
655
+ rimCtns = orderedCtns.filter(isRim);
656
+ const middle = orderedCtns.filter(c => !isFocusCtn(c) && !isRim(c))
657
+ .sort((a, b) => ((degree.get(b) || 0) - (degree.get(a) || 0))
658
+ || (plans.get(b).kids.length - plans.get(a).kids.length)
659
+ || cmp(String(ctnTitle.get(a) || ''), String(ctnTitle.get(b) || ''))
660
+ || cmp(a, b));
661
+ placeOrder = [...focusCtns, ...middle];
662
+ } else {
663
+ // Previous spatial order (top-left claims its spot first) — an
664
+ // IMMUTABLE ordering, so new arrows can't reshuffle the queue.
665
+ const anchored = orderedCtns.filter(c => prev.has(c))
666
+ .sort((a, b) => (prev.get(a).y - prev.get(b).y) || (prev.get(a).x - prev.get(b).x) || cmp(a, b));
667
+ const fresh = orderedCtns.filter(c => !prev.has(c) && !isRim(c))
668
+ .sort((a, b) => ((degree.get(b) || 0) - (degree.get(a) || 0)) || cmp(a, b));
669
+ rimCtns = orderedCtns.filter(c => !prev.has(c) && isRim(c));
670
+ placeOrder = [...anchored, ...fresh];
671
+ }
672
+
673
+ // 4. Greedy placement. Anchored containers try their previous spot
674
+ // verbatim first (zero movement when nothing grew into it), else the
675
+ // nearest free side/corner slot. Unanchored ones score by
676
+ // connection-weighted distance + a gentle compactness pull.
677
+ const GAP = G.COL_GAP;
678
+ const boxes = new Map();
679
+ 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; };
680
+ const cxOf = (b) => b.x + b.w / 2, cyOf = (b) => b.y + b.h / 2;
681
+ const distC = (a, b) => Math.hypot(cxOf(a) - cxOf(b), cyOf(a) - cyOf(b));
682
+ for (const cid of placeOrder) {
683
+ const plan = plans.get(cid);
684
+ const anchor = !fullPass && prev.has(cid) ? prev.get(cid) : null;
685
+ if (anchor) {
686
+ const b0 = { x: anchor.x, y: anchor.y, w: plan.w, h: plan.h };
687
+ if (!overlapsAny(b0)) { boxes.set(cid, b0); continue; }
688
+ }
689
+ if (!boxes.size) { boxes.set(cid, { x: anchor ? anchor.x : 0, y: anchor ? anchor.y : 0, w: plan.w, h: plan.h }); continue; }
690
+ let mx = 0, my = 0;
691
+ for (const b of boxes.values()) { mx += cxOf(b); my += cyOf(b); }
692
+ mx /= boxes.size; my /= boxes.size;
693
+ const anchorBox = anchor ? { x: anchor.x, y: anchor.y, w: plan.w, h: plan.h } : null;
694
+ let bestPos = null, bestScore = Infinity;
695
+ for (const o of boxes.values()) {
696
+ const cands = [
697
+ { x: o.x + o.w + GAP, y: o.y }, { x: o.x, y: o.y + o.h + GAP },
698
+ { x: o.x - GAP - plan.w, y: o.y }, { x: o.x, y: o.y - GAP - plan.h },
699
+ { x: o.x + o.w + GAP, y: o.y + o.h + GAP }, { x: o.x - GAP - plan.w, y: o.y + o.h + GAP },
700
+ { x: o.x + o.w + GAP, y: o.y - GAP - plan.h }, { x: o.x - GAP - plan.w, y: o.y - GAP - plan.h },
701
+ ];
702
+ for (const c of cands) {
703
+ const b = { x: c.x, y: c.y, w: plan.w, h: plan.h };
704
+ if (overlapsAny(b)) continue;
705
+ let score;
706
+ if (anchorBox) {
707
+ score = distC(b, anchorBox); // reclaim the old neighborhood
708
+ } else {
709
+ let pull = 0, wsum = 0;
710
+ for (const [pid, pb] of boxes) { const w = wOf(cid, pid); if (w) { pull += w * distC(b, pb); wsum += w; } }
711
+ const toCentroid = Math.hypot(cxOf(b) - mx, cyOf(b) - my);
712
+ score = (wsum ? pull / wsum : toCentroid) + 0.05 * toCentroid;
713
+ }
714
+ if (score < bestScore) { bestScore = score; bestPos = b; }
715
+ }
716
+ }
717
+ boxes.set(cid, bestPos || { x: 0, y: Math.max(...[...boxes.values()].map(b => b.y + b.h)) + GAP, w: plan.w, h: plan.h });
718
+ }
719
+ // Rim containers (Archive without a previous spot / full pass): the
720
+ // cold right edge, each right of the last so they never stack.
721
+ for (const cid of rimCtns) {
722
+ const plan = plans.get(cid);
723
+ const maxX = boxes.size ? Math.max(...[...boxes.values()].map(b => b.x + b.w)) : 0;
724
+ const topY = boxes.size ? Math.min(...[...boxes.values()].map(b => b.y)) : 0;
725
+ boxes.set(cid, { x: maxX + GAP * 3, y: topY, w: plan.w, h: plan.h });
726
+ }
727
+
728
+ // 5. Commit: normalize to START only when the map would drift off-origin
729
+ // (incremental runs keep absolute coordinates → anchored spots stay
730
+ // byte-identical), write containers + kids, stamp the layout engine.
731
+ const minX = Math.min(...[...boxes.values()].map(b => b.x));
732
+ const minY = Math.min(...[...boxes.values()].map(b => b.y));
733
+ const dx = (fullPass || minX < 0) ? G.START - minX : 0;
734
+ const dy = (fullPass || minY < 0) ? G.START - minY : 0;
735
+ for (const [cid, b] of boxes) {
736
+ const x = b.x + dx, y = b.y + dy;
737
+ canvas.positions[cid] = { ...canvas.positions[cid], x, y, w: b.w, h: b.h };
738
+ for (const kid of plans.get(cid).kids) {
739
+ canvas.positions[kid.id] = { ...canvas.positions[kid.id], x: x + kid.dx, y: y + kid.dy, w: G.CARD_W, h: kid.h };
740
+ }
741
+ }
742
+ canvas.settings = { ...(canvas.settings || {}), brainLayout: 'cluster-v1' };
553
743
  }
554
744
 
555
745
  const out = await finalizeBrainZip(zip, canvas, manifest, now);