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.
- package/package.json +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.
|
|
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",
|
package/src/klypix-format.mjs
CHANGED
|
@@ -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
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
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
|
|
545
|
-
|
|
546
|
-
for (const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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);
|