klypix-mcp 1.82.2 → 1.83.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/bin/klypix-a2a.mjs +5 -2
- package/bin/klypix-worker.mjs +5 -4
- package/bin/klypix-write.mjs +11 -6
- package/package.json +2 -2
- package/src/klypix-core.mjs +16 -3
- package/src/klypix-format.mjs +111 -13
package/bin/klypix-a2a.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import crypto from 'crypto';
|
|
|
32
32
|
import { fileURLToPath } from 'url';
|
|
33
33
|
import { z } from 'zod';
|
|
34
34
|
import {
|
|
35
|
-
resolveVault, resolveCanvas, getEmbedder, shouldPrewarmSemantic, cardSchema, connSchema,
|
|
35
|
+
resolveVault, resolveCanvas, getEmbedder, shouldPrewarmSemantic, cardSchema, connSchema, groupSchema,
|
|
36
36
|
opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
|
|
37
37
|
opBrainInsights, opBrainConnect, opCreateCanvas, opAddToCanvas, opBrainNote,
|
|
38
38
|
} from '../src/klypix-core.mjs';
|
|
@@ -221,6 +221,7 @@ const a2aCardSchema = cardSchema.extend({
|
|
|
221
221
|
});
|
|
222
222
|
const cardsArg = z.array(a2aCardSchema).min(1).max(500);
|
|
223
223
|
const connsArg = z.array(connSchema).max(1_000).optional();
|
|
224
|
+
const groupsArg = z.array(groupSchema).max(100).optional();
|
|
224
225
|
|
|
225
226
|
// Compatibility spellings accepted by the dispatcher but deliberately omitted
|
|
226
227
|
// from the Agent Card. The smoke test asserts every switch case is either
|
|
@@ -312,7 +313,9 @@ async function runSkill(skill, args, text, via) {
|
|
|
312
313
|
}
|
|
313
314
|
const conns = connsArg.safeParse(args.connections);
|
|
314
315
|
if (!conns.success) return needInput('connections must be `[{ "from": <index|title>, "to": <index|title> }]`.');
|
|
315
|
-
|
|
316
|
+
const groups = groupsArg.safeParse(args.groups);
|
|
317
|
+
if (!groups.success) return needInput('groups must be `[{ "title": "…", "cards": [<index|title|id>, …] }]` — cards listed in reading order.');
|
|
318
|
+
return await opCreateCanvas({ vault: VAULT, title: args.title ?? 'Untitled board', cards: parsed.data, connections: conns.data, groups: groups.data, filename: args.filename });
|
|
316
319
|
}
|
|
317
320
|
case 'remember':
|
|
318
321
|
case 'learn_skill': {
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { z } from 'zod';
|
|
|
25
25
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
26
26
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
27
27
|
import {
|
|
28
|
-
resolveVault, getEmbedder, shouldPrewarmSemantic, buildKlypixMap, cardSchema, connSchema,
|
|
28
|
+
resolveVault, getEmbedder, shouldPrewarmSemantic, buildKlypixMap, cardSchema, connSchema, groupSchema,
|
|
29
29
|
opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
|
|
30
30
|
opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk, opBrainChallenge, opCanvasView, opBrainLens,
|
|
31
31
|
opBrainTaskContext,
|
|
@@ -652,14 +652,15 @@ server.registerTool('brain_garden', {
|
|
|
652
652
|
|
|
653
653
|
server.registerTool('create_canvas', {
|
|
654
654
|
title: 'Create a KLYPIX canvas',
|
|
655
|
-
description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in the KLYPIX app (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows.',
|
|
655
|
+
description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in the KLYPIX app (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows. For anything a person reads IN ORDER — steps, phases, checklists, sections — put the cards in `groups`: each group becomes a titled box with its cards stacked in the order given, boxes left-to-right; the loose grid follows arrows, not reading order, and scatters a sequence.',
|
|
656
656
|
inputSchema: {
|
|
657
657
|
title: z.string().describe('Canvas title (also the filename).'),
|
|
658
|
-
cards: z.array(cardSchema).min(1).describe('The cards. 5-12 atomic cards is ideal.'),
|
|
658
|
+
cards: z.array(cardSchema).min(1).describe('The cards. 5-12 atomic cards is ideal for a mind-map; a checklist can be longer when grouped.'),
|
|
659
659
|
connections: z.array(connSchema).optional().describe('Arrows between cards.'),
|
|
660
|
+
groups: z.array(groupSchema).optional().describe('Titled boxes, each listing its member cards in reading order (index, title, or id). Ungrouped cards form a band above the boxes — good for the title card, a link, a legend.'),
|
|
660
661
|
filename: z.string().optional().describe('Override the output filename (without extension).'),
|
|
661
662
|
},
|
|
662
|
-
}, async ({ title, cards, connections, filename }) => toContent(await opCreateCanvas({ vault: mcpPresence.vault, title, cards, connections, filename })));
|
|
663
|
+
}, async ({ title, cards, connections, groups, filename }) => toContent(await opCreateCanvas({ vault: mcpPresence.vault, title, cards, connections, groups, filename })));
|
|
663
664
|
|
|
664
665
|
server.registerTool('add_to_canvas', {
|
|
665
666
|
title: 'Add cards to an existing canvas',
|
package/bin/klypix-write.mjs
CHANGED
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
// cat spec.json | node scripts/write-klypix.mjs --out board.klypix
|
|
10
10
|
//
|
|
11
11
|
// Spec:
|
|
12
|
-
// { "title": "...", "cards": [{ "text": "...", "heading"?, "color"? }],
|
|
13
|
-
// "connections": [{ "from": 0, "to": 1, "relationship"? }]
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
12
|
+
// { "title": "...", "cards": [{ "text": "...", "heading"?, "color"?, "group"? }],
|
|
13
|
+
// "connections": [{ "from": 0, "to": 1, "relationship"? }],
|
|
14
|
+
// "groups": [{ "title": "Part 1", "cards": [0, 1, 2], "color"?, "columns"?, "width"? }] }
|
|
15
|
+
// from/to (and group members) reference a card by INDEX, id, or its title
|
|
16
|
+
// (first line). relationship ∈ leads_to | depends_on | relates_to |
|
|
17
|
+
// conflicts_with | supports | questions | costs | blocks.
|
|
18
|
+
// groups: anything read IN ORDER (steps, phases, sections) — each becomes a
|
|
19
|
+
// titled box with its cards stacked in the order listed, boxes left-to-right.
|
|
20
|
+
// Loose cards keep the connection-driven grid, as a band above the boxes.
|
|
17
21
|
|
|
18
22
|
import fs from 'fs';
|
|
19
23
|
import { buildKlypix, atomicWrite } from '../src/klypix-format.mjs';
|
|
@@ -41,5 +45,6 @@ const outPath = outArg || `${(spec.title || 'untitled').replace(/[^\w\- ]+/g, ''
|
|
|
41
45
|
await atomicWrite(outPath, buf);
|
|
42
46
|
const cardCount = spec.cards.length;
|
|
43
47
|
const connCount = Array.isArray(spec.connections) ? spec.connections.length : 0;
|
|
44
|
-
|
|
48
|
+
const groupCount = Array.isArray(spec.groups) ? spec.groups.length : 0;
|
|
49
|
+
console.log(`Wrote ${outPath} — ${cardCount} cards, ${connCount} connections${groupCount ? `, ${groupCount} group box${groupCount === 1 ? '' : 'es'}` : ''}.`);
|
|
45
50
|
console.log(`Open it in the KLYPIX app (Canvas → Open), or verify: node scripts/read-klypix.mjs "${outPath}"`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.83.0",
|
|
4
4
|
"mcpName": "io.github.dahshanlabs/klypix-mcp",
|
|
5
5
|
"description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"bench": "node bin/klypix-mcp.mjs bench",
|
|
85
85
|
"test:bench": "node test/bench.mjs",
|
|
86
86
|
"pretest": "node test/publish-workflow.mjs",
|
|
87
|
-
"test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
|
|
87
|
+
"test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
|
|
88
88
|
"test:memory": "node test/memory-runtime.mjs",
|
|
89
89
|
"test:memory:soak": "node --expose-gc test/memory-soak.mjs",
|
|
90
90
|
"runtime": "node bin/klypix-runtime.mjs"
|
package/src/klypix-core.mjs
CHANGED
|
@@ -55,6 +55,7 @@ export const cardSchema = z.object({
|
|
|
55
55
|
text: z.string().describe('Card text. First line is the card title.'),
|
|
56
56
|
heading: z.boolean().optional().describe('Bold title card for the main goal/topic.'),
|
|
57
57
|
color: z.string().optional().describe('Hex color, e.g. #ef4444 for a risk/blocker.'),
|
|
58
|
+
group: z.string().optional().describe('Put this card inside the titled box of that name (created if missing), in card order. Alternative to listing it in `groups`.'),
|
|
58
59
|
});
|
|
59
60
|
export const connSchema = z.object({
|
|
60
61
|
from: z.union([z.number(), z.string()]).describe('Source card: index (0-based), title, or id.'),
|
|
@@ -62,6 +63,17 @@ export const connSchema = z.object({
|
|
|
62
63
|
relationship: z.string().optional().describe('leads_to|depends_on|relates_to|conflicts_with|supports|questions|costs|blocks'),
|
|
63
64
|
label: z.string().optional(),
|
|
64
65
|
});
|
|
66
|
+
// Groups (2026-09-05): a titled box whose cards stack top-to-bottom IN THE ORDER
|
|
67
|
+
// GIVEN, boxes left-to-right. The plain grid follows arrows, not reading order,
|
|
68
|
+
// which scatters step 1 far from step 2 — for anything a reader follows in
|
|
69
|
+
// sequence, put the cards in groups.
|
|
70
|
+
export const groupSchema = z.object({
|
|
71
|
+
title: z.string().describe('Box header, e.g. "Part 1 · Get ready". Also the card\'s area.'),
|
|
72
|
+
cards: z.array(z.union([z.number(), z.string()])).min(1).describe('Member cards IN READING ORDER — index (0-based), title, or id. They stack top-to-bottom inside the box.'),
|
|
73
|
+
color: z.string().optional().describe('Hex border color for the box (default emerald).'),
|
|
74
|
+
columns: z.number().int().min(1).max(4).optional().describe('Split a long list into N columns, filled top-to-bottom then next column. Default 1 (2 when more than 12 cards).'),
|
|
75
|
+
width: z.number().int().min(200).max(640).optional().describe('Card width inside the box in px (default 340). Use ~520 for long prose cards so they do not become skyscrapers.'),
|
|
76
|
+
});
|
|
65
77
|
|
|
66
78
|
// ── Vault discovery / resolution ─────────────────────────────────────────────
|
|
67
79
|
export const IS_CANVAS = /\.(klypix|any)$/i;
|
|
@@ -1154,21 +1166,22 @@ export async function opBrainConnect({ vault, canvas, apply = false, max = 24, t
|
|
|
1154
1166
|
}, { brain: true });
|
|
1155
1167
|
}
|
|
1156
1168
|
|
|
1157
|
-
export async function opCreateCanvas({ vault, title, cards, connections, filename }) {
|
|
1169
|
+
export async function opCreateCanvas({ vault, title, cards, connections, groups, filename }) {
|
|
1158
1170
|
if (!fs.existsSync(vault)) { try { fs.mkdirSync(vault, { recursive: true }); } catch { /* ignore */ } }
|
|
1159
1171
|
// Locked on the VAULT: safeName picks a free name by probing the directory, so
|
|
1160
1172
|
// two concurrent creates of the same title would both see it free and the second
|
|
1161
1173
|
// atomicWrite would silently replace the first canvas.
|
|
1162
1174
|
return withVaultCreateLock(vault, async () => {
|
|
1163
1175
|
try {
|
|
1164
|
-
const buf = await buildKlypix({ title, cards, connections });
|
|
1176
|
+
const buf = await buildKlypix({ title, cards, connections, groups });
|
|
1165
1177
|
const name = filename ? safeName(vault, filename.replace(IS_CANVAS, '')) : safeName(vault, title);
|
|
1166
1178
|
const out = path.join(vault, name);
|
|
1167
1179
|
await atomicWrite(out, buf);
|
|
1168
1180
|
let detail = '', struct;
|
|
1169
1181
|
try { ({ struct } = await parseKlypix(buf)); detail = cardDetailBlock(struct); } catch { /* detail is optional */ }
|
|
1182
|
+
const groupNote = Array.isArray(groups) && groups.length ? `, ${groups.length} group box${groups.length === 1 ? '' : 'es'}` : '';
|
|
1170
1183
|
return {
|
|
1171
|
-
blocks: [text(`Created ${out} — ${cards.length} cards, ${(connections || []).length} connections. Open it in the KLYPIX app (Canvas → Open).${detail}`)],
|
|
1184
|
+
blocks: [text(`Created ${out} — ${cards.length} cards, ${(connections || []).length} connections${groupNote}. Open it in the KLYPIX app (Canvas → Open).${detail}`)],
|
|
1172
1185
|
file: { name, buffer: buf }, struct,
|
|
1173
1186
|
};
|
|
1174
1187
|
} catch (e) {
|
package/src/klypix-format.mjs
CHANGED
|
@@ -420,10 +420,14 @@ const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', '
|
|
|
420
420
|
|
|
421
421
|
/**
|
|
422
422
|
* Build a real .klypix v4 file (nodebuffer) from a simple spec:
|
|
423
|
-
* { title, cards: [{id?, type?, text, heading?, color?, x?, y?, w?}],
|
|
424
|
-
* from
|
|
425
|
-
*
|
|
426
|
-
*
|
|
423
|
+
* { title, cards: [{id?, type?, text, heading?, color?, group?, x?, y?, w?}],
|
|
424
|
+
* connections: [{from, to, relationship?, label?}],
|
|
425
|
+
* groups?: [{title, cards: [ref…], color?, columns?, width?}], layout?: {groupsPerRow?} }
|
|
426
|
+
* from/to (and group member refs) reference a card by INDEX, generated id, or
|
|
427
|
+
* its title (first line). Loose cards are content-sized and laid out on a
|
|
428
|
+
* BFS-ordered grid so linked cards land near each other. Grouped cards render
|
|
429
|
+
* inside a titled container in the ORDER LISTED, boxes left-to-right — use
|
|
430
|
+
* groups for anything read in sequence (steps, phases, sections).
|
|
427
431
|
*/
|
|
428
432
|
export async function buildKlypix(spec) {
|
|
429
433
|
if (!spec || !Array.isArray(spec.cards) || spec.cards.length === 0) {
|
|
@@ -458,14 +462,62 @@ export async function buildKlypix(spec) {
|
|
|
458
462
|
};
|
|
459
463
|
}).filter(Boolean);
|
|
460
464
|
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
for
|
|
464
|
-
|
|
465
|
-
|
|
465
|
+
// ── Groups (2026-09-05): titled boxes whose cards stack IN THE ORDER GIVEN.
|
|
466
|
+
// The BFS grid below is the right shape for a mind-map and the wrong shape
|
|
467
|
+
// for anything a person reads in sequence — the founder's 27-step App
|
|
468
|
+
// Review checklist came out with step 1 at the bottom-left, Part 2's steps
|
|
469
|
+
// scattered, and arrows crossing the whole board, because BFS follows
|
|
470
|
+
// arrows, not reading order. A group renders as a KLYPIX container (the
|
|
471
|
+
// same item shape the brain's areas use) with its member cards laid out
|
|
472
|
+
// top-to-bottom in the order the spec lists them, boxes left-to-right in
|
|
473
|
+
// spec order. Cards may also name their group inline (`group: "Part 1"`),
|
|
474
|
+
// which appends them to that box in card order. Ungrouped cards keep the
|
|
475
|
+
// BFS grid, placed as a band ABOVE the boxes (title card, links, legend).
|
|
476
|
+
const groupDefs = [];
|
|
477
|
+
const groupByKey = new Map();
|
|
478
|
+
const groupOf = new Map(); // cardId → index into groupDefs
|
|
479
|
+
const groupKey = (t) => String(t ?? '').trim().toLowerCase();
|
|
480
|
+
const ensureGroup = (g) => {
|
|
481
|
+
const key = groupKey(g.title);
|
|
482
|
+
if (!key) throw new Error('every group needs a non-empty "title"');
|
|
483
|
+
if (groupByKey.has(key)) return groupByKey.get(key);
|
|
484
|
+
const def = { title: String(g.title).trim(), color: g.color, columns: g.columns, width: g.width, kids: [], _id: `ctn_${rand()}_${groupDefs.length}` };
|
|
485
|
+
groupByKey.set(key, groupDefs.length);
|
|
486
|
+
groupDefs.push(def);
|
|
487
|
+
return groupDefs.length - 1;
|
|
488
|
+
};
|
|
489
|
+
// First membership wins — a card listed in two groups stays in the first.
|
|
490
|
+
const joinGroup = (gi, id) => { if (groupOf.has(id)) return; groupOf.set(id, gi); groupDefs[gi].kids.push(id); };
|
|
491
|
+
if (spec.groups != null && !Array.isArray(spec.groups)) throw new Error('"groups" must be an array of { title, cards: [...] }');
|
|
492
|
+
(spec.groups || []).forEach((g, gi0) => {
|
|
493
|
+
if (!g || typeof g !== 'object') throw new Error(`groups[${gi0}] must be an object with a title and cards`);
|
|
494
|
+
const gi = ensureGroup(g);
|
|
495
|
+
(Array.isArray(g.cards) ? g.cards : []).forEach((ref, ci) => {
|
|
496
|
+
const id = resolveRef(ref);
|
|
497
|
+
// Loud, not silent: a typo'd ref would otherwise drop the step out of
|
|
498
|
+
// its box onto the loose grid and the reader would never know.
|
|
499
|
+
if (!id) throw new Error(`groups[${gi0}] ("${g.title}").cards[${ci}] = ${JSON.stringify(ref)} does not match any card — use its index, id, or exact title`);
|
|
500
|
+
joinGroup(gi, id);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
cards.forEach(c => { if (c.group != null && String(c.group).trim()) joinGroup(ensureGroup({ title: c.group }), c._id); });
|
|
504
|
+
const hasGroups = groupDefs.length > 0;
|
|
505
|
+
|
|
506
|
+
// BFS order so connected LOOSE cards land near each other. Grouped cards are
|
|
507
|
+
// excluded from both the walk and the degree count — with no groups this is
|
|
508
|
+
// byte-for-byte the previous behaviour.
|
|
509
|
+
const loose = idByIndex.filter(id => !groupOf.has(id));
|
|
510
|
+
const looseSet = new Set(loose);
|
|
511
|
+
const adj = new Map(loose.map(id => [id, []]));
|
|
512
|
+
const indeg = new Map(loose.map(id => [id, 0]));
|
|
513
|
+
for (const c of connections) {
|
|
514
|
+
if (!looseSet.has(c.fromId) || !looseSet.has(c.toId)) continue;
|
|
515
|
+
adj.get(c.fromId).push(c.toId); adj.get(c.toId).push(c.fromId);
|
|
516
|
+
indeg.set(c.toId, indeg.get(c.toId) + 1);
|
|
517
|
+
}
|
|
466
518
|
const visited = new Set();
|
|
467
519
|
const order = [];
|
|
468
|
-
const starts = [...
|
|
520
|
+
const starts = [...loose].sort((a, b) => (indeg.get(a) - indeg.get(b)) || (idByIndex.indexOf(a) - idByIndex.indexOf(b)));
|
|
469
521
|
for (const s of starts) {
|
|
470
522
|
if (visited.has(s)) continue;
|
|
471
523
|
const q = [s];
|
|
@@ -478,11 +530,13 @@ export async function buildKlypix(spec) {
|
|
|
478
530
|
}
|
|
479
531
|
|
|
480
532
|
const FONT = 20, PAD = 28, LINE_H = FONT * 1.35;
|
|
481
|
-
|
|
482
|
-
|
|
533
|
+
// forcedW: a grouped card takes its box's column width so the column reads
|
|
534
|
+
// as one aligned list (and so a one-line step doesn't shrink to a stub).
|
|
535
|
+
const sizeFor = (card, forcedW = null) => {
|
|
536
|
+
if (forcedW == null && card.x != null && card.w != null) return { w: card.w, h: card.h ?? 40 };
|
|
483
537
|
const lines = String(card.text ?? '').split('\n');
|
|
484
538
|
const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
485
|
-
const w = Math.max(160, Math.min(360, Math.round(longest * (FONT * 0.55)) + PAD));
|
|
539
|
+
const w = forcedW ?? Math.max(160, Math.min(360, Math.round(longest * (FONT * 0.55)) + PAD));
|
|
486
540
|
// Wrap-aware height (76eea3f contract): the app renders at width w and
|
|
487
541
|
// wraps at ~0.5em/char; its observer only GROWS an under-estimate, so a
|
|
488
542
|
// long single-line card used to measure 40px and render 150+ → overlap.
|
|
@@ -515,6 +569,49 @@ export async function buildKlypix(spec) {
|
|
|
515
569
|
};
|
|
516
570
|
});
|
|
517
571
|
|
|
572
|
+
// Group boxes: below the loose band, left-to-right in spec order, wrapping
|
|
573
|
+
// after `layout.groupsPerRow` (default 4). Inside a box the kids fill one
|
|
574
|
+
// column top-to-bottom; a long list (>12) or an explicit `columns` splits
|
|
575
|
+
// into N columns filled column-major, so reading order survives (finish
|
|
576
|
+
// column 1, then column 2) — never the row-major/masonry orders that put
|
|
577
|
+
// step 2 beside step 1 instead of under it.
|
|
578
|
+
const containerItems = new Map(); // ctnId → item json
|
|
579
|
+
if (hasGroups) {
|
|
580
|
+
const G = { TITLE_BAR: 44, PAD: 16, GAP: 12, KID_W: 340, COL_GAP: 60, ROW_GAP: 80 };
|
|
581
|
+
const perRow = Math.max(1, Math.min(8, Math.round(Number(spec.layout?.groupsPerRow)) || 4));
|
|
582
|
+
const looseBottom = order.length ? Math.max(...order.map(id => positions[id].y + positions[id].h)) : START;
|
|
583
|
+
let gx = START, gy = order.length ? looseBottom + G.ROW_GAP : START, rowH = 0, inRow = 0;
|
|
584
|
+
for (const g of groupDefs) {
|
|
585
|
+
const n = g.kids.length;
|
|
586
|
+
const kidW = Math.max(200, Math.min(640, Math.round(Number(g.width)) || G.KID_W));
|
|
587
|
+
const gcols = Math.max(1, Math.min(4, Math.round(Number(g.columns)) || (n > 12 ? 2 : 1)));
|
|
588
|
+
const perCol = Math.max(1, Math.ceil(n / gcols));
|
|
589
|
+
const kidSizes = g.kids.map(id => sizeFor(cards[idByIndex.indexOf(id)], kidW));
|
|
590
|
+
const colHs = new Array(gcols).fill(0);
|
|
591
|
+
kidSizes.forEach((sz, i) => { colHs[Math.floor(i / perCol)] += sz.h + G.GAP; });
|
|
592
|
+
const bodyH = Math.max(0, Math.max(0, ...colHs) - G.GAP);
|
|
593
|
+
const w = G.PAD * 2 + gcols * kidW + (gcols - 1) * G.GAP;
|
|
594
|
+
const h = G.TITLE_BAR + G.PAD + bodyH + G.PAD;
|
|
595
|
+
if (inRow >= perRow) { gx = START; gy += rowH + G.ROW_GAP; rowH = 0; inRow = 0; }
|
|
596
|
+
positions[g._id] = { x: gx, y: gy, w, h, zKey: nextZKey(), zIndex: zi++, parentId: null };
|
|
597
|
+
order.push(g._id);
|
|
598
|
+
containerItems.set(g._id, {
|
|
599
|
+
type: 'container', locked: false, createdAt: now, createdBy: 'agent', ...authorField(),
|
|
600
|
+
title: g.title, collapsed: false, scopeLocked: false,
|
|
601
|
+
borderColor: (typeof g.color === 'string' && g.color.trim()) ? g.color.trim() : '#10b981',
|
|
602
|
+
});
|
|
603
|
+
const colY = new Array(gcols).fill(gy + G.TITLE_BAR + G.PAD);
|
|
604
|
+
g.kids.forEach((id, i) => {
|
|
605
|
+
const c = Math.floor(i / perCol);
|
|
606
|
+
const { w: kw, h: kh } = kidSizes[i];
|
|
607
|
+
positions[id] = { x: gx + G.PAD + c * (kidW + G.GAP), y: colY[c], w: kw, h: kh, zKey: nextZKey(), zIndex: zi++, parentId: g._id };
|
|
608
|
+
colY[c] += kh + G.GAP;
|
|
609
|
+
order.push(id);
|
|
610
|
+
});
|
|
611
|
+
gx += w + G.COL_GAP; rowH = Math.max(rowH, h); inRow++;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
518
615
|
const itemJson = (card, w) => {
|
|
519
616
|
if (card.type === 'text') {
|
|
520
617
|
return {
|
|
@@ -562,6 +659,7 @@ export async function buildKlypix(spec) {
|
|
|
562
659
|
zip.file('manifest.json', JSON.stringify(manifest));
|
|
563
660
|
zip.file('canvas.json', JSON.stringify(canvasJson));
|
|
564
661
|
for (const id of order) {
|
|
662
|
+
if (containerItems.has(id)) { zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify(containerItems.get(id))); continue; }
|
|
565
663
|
const card = cards[idByIndex.indexOf(id)];
|
|
566
664
|
zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify(itemJson(card, positions[id]?.w)));
|
|
567
665
|
}
|