klypix-mcp 1.0.0 → 1.1.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-mcp.mjs +245 -11
- package/package.json +6 -2
- package/src/klypix-format.mjs +511 -5
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -18,14 +18,39 @@
|
|
|
18
18
|
import fs from 'fs';
|
|
19
19
|
import os from 'os';
|
|
20
20
|
import path from 'path';
|
|
21
|
+
import crypto from 'crypto';
|
|
21
22
|
import { z } from 'zod';
|
|
22
23
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
23
24
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
24
|
-
import { parseKlypix, buildKlypix, appendToKlypix, structToMarkdown, atomicWrite } from '../src/klypix-format.mjs';
|
|
25
|
+
import { parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown, atomicWrite } from '../src/klypix-format.mjs';
|
|
25
26
|
|
|
26
27
|
// IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
|
|
27
28
|
const log = (...a) => console.error('[klypix-mcp]', ...a);
|
|
28
29
|
|
|
30
|
+
// `npx klypix-mcp init` — 60-second onboarding: seed a starter project brain in
|
|
31
|
+
// the current folder so a new user's FIRST contact isn't an empty vault, then
|
|
32
|
+
// print a paste-ready MCP config. Runs before any server setup. (Dormant for
|
|
33
|
+
// the in-app bundled server, which always launches with --vault.)
|
|
34
|
+
if (process.argv[2] === 'init') {
|
|
35
|
+
const target = path.resolve(process.cwd(), 'brain.klypix');
|
|
36
|
+
if (fs.existsSync(target)) { console.error(`brain.klypix already exists in ${process.cwd()} — not overwriting.`); process.exit(0); }
|
|
37
|
+
const buf = await buildKlypixMap({
|
|
38
|
+
title: 'project brain',
|
|
39
|
+
areas: [
|
|
40
|
+
{ title: 'Goal', cards: [{ text: '❓ What is this project for, and for whom?\nAgent: survey the repo on your first session and replace this with the real goal.' }] },
|
|
41
|
+
{ title: 'Architecture', cards: [{ text: '❓ Key components and how they fit.\nAgent: record the actual shape from the repo — only what a new session must know.' }] },
|
|
42
|
+
{ title: 'Decisions', cards: [{ text: 'Decisions land here automatically: agents emit `🧠 BRAIN [Area]: …` markers; a new decision that replaces an old one archives it (superseded). Resolve finished items with `✓`, correct in place with `~`. Drag any card into 📌 Focus to make it lead every session brief.' }] },
|
|
43
|
+
{ title: 'Pending / next', cards: [{ text: 'What is in flight and what comes next. Close finished items with the ✓ marker.' }] },
|
|
44
|
+
{ title: 'Open questions', cards: [{ text: 'Unresolved questions (the ❓ marker) live here — the session brief surfaces them first.' }] },
|
|
45
|
+
{ title: '📌 Focus', cards: [{ text: 'Drag any card into this area to make it lead every session brief — steer your agent by moving cards.' }] },
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
fs.writeFileSync(target, buf);
|
|
49
|
+
const cfg = JSON.stringify({ mcpServers: { 'klypix-canvas': { command: 'npx', args: ['-y', 'klypix-mcp', '--vault', process.cwd().replace(/\\/g, '/')] } } }, null, 2);
|
|
50
|
+
console.error(`✓ Created ${target}\n\nAdd this to your MCP client config (.mcp.json / claude_desktop_config.json):\n\n${cfg}\n\nThen ask your agent to read the canvas "brain" — it now has a project memory.`);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
const vaultArgIdx = process.argv.indexOf('--vault');
|
|
30
55
|
const VAULT = path.resolve(
|
|
31
56
|
vaultArgIdx >= 0 ? process.argv[vaultArgIdx + 1]
|
|
@@ -110,16 +135,36 @@ server.registerTool('list_canvases', {
|
|
|
110
135
|
return { content: [{ type: 'text', text: `# Canvases in ${VAULT}\n\n${rows.join('\n')}` }] };
|
|
111
136
|
});
|
|
112
137
|
|
|
138
|
+
const IMG_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
139
|
+
const IMG_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp' };
|
|
140
|
+
|
|
113
141
|
server.registerTool('read_canvas', {
|
|
114
142
|
title: 'Read a KLYPIX canvas',
|
|
115
|
-
description: 'Read a canvas as structured markdown
|
|
116
|
-
inputSchema: { canvas: z.string().describe('Canvas filename, vault-relative path, or absolute path.') },
|
|
143
|
+
description: 'Read a canvas as structured markdown (every card, the connection graph, [[wikilinks]], #tags) AND return its images so you can SEE them, not just their filenames. Pass the canvas TITLE directly (e.g. "SS2") — a filename, vault-relative path, or absolute path also work; you do NOT need to list or search first.',
|
|
144
|
+
inputSchema: { canvas: z.string().describe('Canvas title or filename (e.g. "SS2"), vault-relative path, or absolute path.') },
|
|
117
145
|
}, async ({ canvas }) => {
|
|
118
146
|
const file = resolveCanvas(canvas);
|
|
119
147
|
if (!file) return { content: [{ type: 'text', text: `Canvas not found: ${canvas} (vault: ${VAULT})` }], isError: true };
|
|
120
148
|
try {
|
|
121
|
-
const { struct } = await parseKlypix(fs.readFileSync(file));
|
|
122
|
-
|
|
149
|
+
const { struct, zip, assetPaths } = await parseKlypix(fs.readFileSync(file));
|
|
150
|
+
const content = [{ type: 'text', text: structToMarkdown(struct) }];
|
|
151
|
+
// Return image assets as actual image content so a vision-capable model
|
|
152
|
+
// SEES them — the whole point of a multimodal canvas. Capped (count +
|
|
153
|
+
// per-image size) so the response stays sane.
|
|
154
|
+
let included = 0;
|
|
155
|
+
for (const p of assetPaths) {
|
|
156
|
+
if (included >= 8) break;
|
|
157
|
+
if (!IMG_RE.test(p)) continue;
|
|
158
|
+
try {
|
|
159
|
+
const b64 = await zip.file(p).async('base64');
|
|
160
|
+
if (!b64 || b64.length > 7_000_000) continue; // skip > ~5MB
|
|
161
|
+
const ext = p.split('.').pop().toLowerCase();
|
|
162
|
+
content.push({ type: 'image', data: b64, mimeType: IMG_MIME[ext] || 'image/png' });
|
|
163
|
+
included++;
|
|
164
|
+
} catch { /* skip unreadable asset */ }
|
|
165
|
+
}
|
|
166
|
+
if (included > 0) content.push({ type: 'text', text: `\n(${included} image${included > 1 ? 's' : ''} from this canvas are attached above — read them directly.)` });
|
|
167
|
+
return { content };
|
|
123
168
|
} catch (e) {
|
|
124
169
|
return { content: [{ type: 'text', text: `Failed to read ${file}: ${e.message}` }], isError: true };
|
|
125
170
|
}
|
|
@@ -136,18 +181,192 @@ server.registerTool('search_canvases', {
|
|
|
136
181
|
for (const f of walkVault()) {
|
|
137
182
|
let struct;
|
|
138
183
|
try { ({ struct } = await parseKlypix(fs.readFileSync(f))); } catch { continue; }
|
|
184
|
+
const rel = path.relative(VAULT, f);
|
|
185
|
+
// Match the canvas TITLE + FILENAME too — not just card text — so
|
|
186
|
+
// searching a canvas by its name (e.g. "SS2") actually finds it.
|
|
187
|
+
const nameMatch = (struct.title || '').toLowerCase().includes(q) || rel.toLowerCase().includes(q);
|
|
139
188
|
const matched = struct.cards.filter(c =>
|
|
140
189
|
(c.title || '').toLowerCase().includes(q) ||
|
|
141
190
|
String(c.text || '').toLowerCase().includes(q) ||
|
|
142
191
|
(c.tags || []).some(t => ('#' + t).toLowerCase().includes(q)));
|
|
143
|
-
if (matched.length) {
|
|
144
|
-
hits
|
|
145
|
-
|
|
192
|
+
if (nameMatch || matched.length) {
|
|
193
|
+
// Rich hits: type + id + position + tags + a longer snippet, so the
|
|
194
|
+
// agent can FIND a card (and tell duplicates apart) before it WRITES.
|
|
195
|
+
const head = `## ${rel} — "${struct.title}" · ${struct.counts.cards} cards, ${struct.counts.connections} connections${nameMatch && !matched.length ? ' (name/title match)' : ''}`;
|
|
196
|
+
const body = matched.slice(0, 8).map(c => {
|
|
197
|
+
const pos = (c.pos && c.pos.x != null) ? ` @(${Math.round(c.pos.x)},${Math.round(c.pos.y)})` : '';
|
|
198
|
+
const tags = (c.tags && c.tags.length) ? ' ' + c.tags.map(t => '#' + t).join(' ') : '';
|
|
199
|
+
return `- [${c.type}] "${c.title || '(card)'}" (${c.id})${pos}${tags}\n ${String(c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
|
|
200
|
+
}).join('\n');
|
|
201
|
+
hits.push(matched.length ? `${head}\n${body}` : head);
|
|
146
202
|
}
|
|
147
203
|
}
|
|
148
204
|
return { content: [{ type: 'text', text: hits.length ? `# Matches for "${query}"\n\n${hits.join('\n\n')}` : `No matches for "${query}" in ${VAULT}.` }] };
|
|
149
205
|
});
|
|
150
206
|
|
|
207
|
+
// ── On-device semantic memory ────────────────────────────────────────────────
|
|
208
|
+
// Embeddings run INSIDE this long-lived server (the hook stays instant), 100%
|
|
209
|
+
// local: transformers.js (WASM) + a 23MB MiniLM model cached under
|
|
210
|
+
// ~/.claude/project-brain/hf-cache on first use. Per-brain vectors are cached
|
|
211
|
+
// incrementally (content-hashed per card) in ~/.claude/project-brain/embeddings/
|
|
212
|
+
// — brains themselves are never mutated by search. Everything degrades to
|
|
213
|
+
// lexical scoring gracefully: no lib, no model, no network → search still works.
|
|
214
|
+
const PB_DIR = path.join(os.homedir(), '.claude', 'project-brain');
|
|
215
|
+
const EMB_DIR = path.join(PB_DIR, 'embeddings');
|
|
216
|
+
const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
217
|
+
let embedderPromise = null;
|
|
218
|
+
function getEmbedder() {
|
|
219
|
+
if (!embedderPromise) {
|
|
220
|
+
embedderPromise = (async () => {
|
|
221
|
+
// Dual-path: (1) bare specifier — npx/npm installs ship the lib;
|
|
222
|
+
// (2) ~/.claude/project-brain/semantic — where KLYPIX's one-click
|
|
223
|
+
// "semantic memory" install places it for the bundled server
|
|
224
|
+
// (the ONNX runtimes are ~350MB unpacked, far too heavy to bundle
|
|
225
|
+
// in the installer payload).
|
|
226
|
+
let t;
|
|
227
|
+
try { t = await import('@huggingface/transformers'); }
|
|
228
|
+
catch {
|
|
229
|
+
const local = path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers', 'dist', 'transformers.mjs');
|
|
230
|
+
t = await import(new URL('file:///' + local.replace(/\\/g, '/')).href);
|
|
231
|
+
}
|
|
232
|
+
t.env.cacheDir = path.join(PB_DIR, 'hf-cache');
|
|
233
|
+
return await t.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
|
|
234
|
+
})().catch(e => { log('semantic unavailable (lexical fallback):', e?.message || e); return null; });
|
|
235
|
+
}
|
|
236
|
+
return embedderPromise;
|
|
237
|
+
}
|
|
238
|
+
async function embedTexts(pipe, texts) {
|
|
239
|
+
const out = await pipe(texts, { pooling: 'mean', normalize: true });
|
|
240
|
+
const [n, d] = out.dims;
|
|
241
|
+
const vecs = [];
|
|
242
|
+
for (let i = 0; i < n; i++) vecs.push(Array.from(out.data.slice(i * d, (i + 1) * d)));
|
|
243
|
+
return vecs;
|
|
244
|
+
}
|
|
245
|
+
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; };
|
|
246
|
+
// Incremental per-brain vector cache: only new/changed cards get embedded.
|
|
247
|
+
async function vectorsForBrain(pipe, brainPath, cards) {
|
|
248
|
+
const file = path.join(EMB_DIR, sha1(brainPath.replace(/\\/g, '/')) + '.json');
|
|
249
|
+
let cache = { v: 1, cards: {} };
|
|
250
|
+
try { cache = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* fresh */ }
|
|
251
|
+
const want = cards.filter(c => c.type !== 'container' && (c.text || '').trim());
|
|
252
|
+
const missing = want.filter(c => cache.cards[c.id]?.h !== sha1(String(c.text)));
|
|
253
|
+
if (missing.length) {
|
|
254
|
+
const vecs = await embedTexts(pipe, missing.map(c => String(c.text).slice(0, 1500)));
|
|
255
|
+
missing.forEach((c, i) => { cache.cards[c.id] = { h: sha1(String(c.text)), v: vecs[i] }; });
|
|
256
|
+
const live = new Set(want.map(c => c.id));
|
|
257
|
+
for (const id of Object.keys(cache.cards)) if (!live.has(id)) delete cache.cards[id];
|
|
258
|
+
try { fs.mkdirSync(EMB_DIR, { recursive: true }); fs.writeFileSync(file, JSON.stringify(cache)); } catch { /* cache is best-effort */ }
|
|
259
|
+
}
|
|
260
|
+
const map = new Map();
|
|
261
|
+
for (const c of want) { const e = cache.cards[c.id]; if (e?.v) map.set(c.id, e.v); }
|
|
262
|
+
return map;
|
|
263
|
+
}
|
|
264
|
+
// Death date of an archived card (for as-of queries): the supersede/resolve stamp.
|
|
265
|
+
const deathDateOf = (text) => { const m = /(?:↩︎ superseded|✅) (\d{4}-\d{2}-\d{2})/.exec(String(text)); return m ? Date.parse(m[1]) : null; };
|
|
266
|
+
|
|
267
|
+
// Cross-project memory: search EVERY brain this machine has touched, not just
|
|
268
|
+
// this vault. The SessionStart/Stop hook registers each ./brain.klypix it runs
|
|
269
|
+
// against into ~/.claude/project-brain/registry.json — so simply having worked
|
|
270
|
+
// in a project makes its decisions findable from any other project ("what did
|
|
271
|
+
// I decide about auth — in ANY project?"). Hybrid ranking: on-device semantic
|
|
272
|
+
// similarity (when the local model is ready) blended with lexical term hits;
|
|
273
|
+
// as_of answers "what was true on <date>" via createdAt + supersession stamps.
|
|
274
|
+
server.registerTool('search_all_brains', {
|
|
275
|
+
title: 'Search every project brain on this machine',
|
|
276
|
+
description: 'Cross-project memory search: looks through every brain.klypix this machine has worked with (auto-registered by the brain hook), not just the current vault. Semantic (on-device) + lexical hybrid ranking. Use when the answer may live in ANOTHER project\'s decisions. Optional as_of (YYYY-MM-DD) answers "what was true then" — superseded cards count as live if they were current at that date.',
|
|
277
|
+
inputSchema: {
|
|
278
|
+
query: z.string().describe('What to find across all project brains.'),
|
|
279
|
+
as_of: z.string().optional().describe('Optional YYYY-MM-DD: rank what was TRUE at that date (time-travel query).'),
|
|
280
|
+
},
|
|
281
|
+
}, async ({ query, as_of }) => {
|
|
282
|
+
const q = String(query || '').trim().toLowerCase();
|
|
283
|
+
if (!q) return { content: [{ type: 'text', text: 'Provide a non-empty query.' }], isError: true };
|
|
284
|
+
const reg = path.join(PB_DIR, 'registry.json');
|
|
285
|
+
let brains = [];
|
|
286
|
+
try { brains = (JSON.parse(fs.readFileSync(reg, 'utf8')).brains || []).filter(b => b && b.path); } catch { /* no registry yet */ }
|
|
287
|
+
if (!brains.length) return { content: [{ type: 'text', text: 'No brains registered yet — the brain hook registers each project as you work in it.' }] };
|
|
288
|
+
const terms = q.split(/[^\p{L}\p{N}#]+/u).filter(t => t.length >= 3);
|
|
289
|
+
if (!terms.length) return { content: [{ type: 'text', text: 'Query too short — use words of 3+ characters.' }], isError: true };
|
|
290
|
+
const asOfTs = as_of ? Date.parse(as_of) : null;
|
|
291
|
+
if (as_of && Number.isNaN(asOfTs)) return { content: [{ type: 'text', text: `Bad as_of date: "${as_of}" — use YYYY-MM-DD.` }], isError: true };
|
|
292
|
+
|
|
293
|
+
// Semantic lane: wait briefly for the embedder; first-ever use downloads
|
|
294
|
+
// the model in the background — searches stay lexical until it's warm.
|
|
295
|
+
const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), 20_000))]);
|
|
296
|
+
let qv = null;
|
|
297
|
+
if (pipe) { try { [qv] = await embedTexts(pipe, [q]); } catch { /* lexical only */ } }
|
|
298
|
+
|
|
299
|
+
const fresh = Date.now() - 30 * 86_400_000;
|
|
300
|
+
const scored = [];
|
|
301
|
+
for (const b of brains) {
|
|
302
|
+
let struct;
|
|
303
|
+
try { ({ struct } = await parseKlypix(fs.readFileSync(b.path))); } catch { continue; }
|
|
304
|
+
let vecs = null;
|
|
305
|
+
if (qv) { try { vecs = await vectorsForBrain(pipe, b.path, struct.cards); } catch { /* lexical for this brain */ } }
|
|
306
|
+
for (const c of struct.cards) {
|
|
307
|
+
if (c.type === 'container') continue;
|
|
308
|
+
const text = String(c.text || '').toLowerCase();
|
|
309
|
+
const isArchived = /^archive$/i.test(c.area || '');
|
|
310
|
+
if (asOfTs != null) {
|
|
311
|
+
if ((c.createdAt || 0) > asOfTs) continue; // didn't exist yet
|
|
312
|
+
const died = isArchived ? deathDateOf(c.text) : null;
|
|
313
|
+
if (died != null && died <= asOfTs) continue; // already superseded then
|
|
314
|
+
}
|
|
315
|
+
let lex = 0;
|
|
316
|
+
const title = String(c.title || '').toLowerCase();
|
|
317
|
+
const tags = (c.tags || []).map(t => ('#' + t).toLowerCase());
|
|
318
|
+
for (const t of terms) {
|
|
319
|
+
if (title.includes(t)) lex += 3;
|
|
320
|
+
if (tags.some(g => g.includes(t))) lex += 2;
|
|
321
|
+
if (text.includes(t)) lex += 1;
|
|
322
|
+
}
|
|
323
|
+
// Floor calibrated on real cards: related ≈ 0.25, unrelated ≈ 0.0
|
|
324
|
+
// (MiniLM, short decision texts) — 0.18 keeps recall with margin.
|
|
325
|
+
const sem = (qv && vecs?.get(c.id)) ? dot(qv, vecs.get(c.id)) : null;
|
|
326
|
+
if (!lex && (sem == null || sem < 0.18)) continue;
|
|
327
|
+
// Hybrid: semantic dominates when available; lexical is the tie-breaker
|
|
328
|
+
// and the only signal pre-warm-up. Recency/archive nudges skipped for
|
|
329
|
+
// time-travel queries (validity already handled above).
|
|
330
|
+
let score = sem != null ? sem * 10 + Math.min(lex, 6) * 0.5 : lex;
|
|
331
|
+
if (asOfTs == null) {
|
|
332
|
+
if ((c.createdAt || 0) >= fresh) score += 0.5;
|
|
333
|
+
if (isArchived) score -= 1;
|
|
334
|
+
}
|
|
335
|
+
scored.push({ score, sem, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (!scored.length) return { content: [{ type: 'text', text: `No matches for "${query}" across ${brains.length} registered brain(s).` }] };
|
|
339
|
+
scored.sort((a, b2) => b2.score - a.score);
|
|
340
|
+
const top = scored.slice(0, 20);
|
|
341
|
+
const lines = top.map(h => {
|
|
342
|
+
const when = h.c.createdAt ? new Date(h.c.createdAt).toISOString().slice(0, 10) : '';
|
|
343
|
+
return `- [${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
|
|
344
|
+
});
|
|
345
|
+
const mode = qv ? 'semantic+lexical (on-device)' : 'lexical (semantic model warming — retry for semantic ranking)';
|
|
346
|
+
const asOfNote = asOfTs != null ? ` · as of ${as_of}` : '';
|
|
347
|
+
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
|
+
});
|
|
349
|
+
|
|
350
|
+
// Format the cards (optionally only a set of new ids) + connection graph so an
|
|
351
|
+
// agent that just wrote can chain follow-ups: reference card IDs, place near a
|
|
352
|
+
// position, or draw an arrow to something it created. Additive — appended after
|
|
353
|
+
// the human-readable line.
|
|
354
|
+
function cardDetailBlock(struct, onlyIds) {
|
|
355
|
+
const cards = onlyIds ? struct.cards.filter(c => onlyIds.has(c.id)) : struct.cards;
|
|
356
|
+
if (!cards.length) return '';
|
|
357
|
+
const lines = cards.map(c => {
|
|
358
|
+
const pos = (c.pos && c.pos.x != null) ? `(${Math.round(c.pos.x)},${Math.round(c.pos.y)})` : '(?)';
|
|
359
|
+
const tags = (c.tags && c.tags.length) ? ' ' + c.tags.map(t => '#' + t).join(' ') : '';
|
|
360
|
+
const title = c.title || (c.text ? String(c.text).replace(/\s+/g, ' ').slice(0, 40) : '(untitled)');
|
|
361
|
+
return `- ${c.id} · ${c.type} · ${pos} · "${title}"${tags}`;
|
|
362
|
+
});
|
|
363
|
+
let out = `\n\nCards you can reference (id · type · pos · title):\n${lines.join('\n')}`;
|
|
364
|
+
if (struct.connections && struct.connections.length) {
|
|
365
|
+
out += `\nConnections: ` + struct.connections.map(cn => `${cn.from} ${cn.relationship ? '—' + cn.relationship + '→' : '→'} ${cn.to}`).join('; ');
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
151
370
|
server.registerTool('create_canvas', {
|
|
152
371
|
title: 'Create a KLYPIX canvas',
|
|
153
372
|
description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in KLYPIX (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows.',
|
|
@@ -164,7 +383,9 @@ server.registerTool('create_canvas', {
|
|
|
164
383
|
const name = filename ? safeName(filename.replace(IS_CANVAS, '')) : safeName(title);
|
|
165
384
|
const out = path.join(VAULT, name);
|
|
166
385
|
await atomicWrite(out, buf);
|
|
167
|
-
|
|
386
|
+
let detail = '';
|
|
387
|
+
try { const { struct } = await parseKlypix(buf); detail = cardDetailBlock(struct); } catch { /* detail is optional */ }
|
|
388
|
+
return { content: [{ type: 'text', text: `Created ${out} — ${cards.length} cards, ${(connections || []).length} connections. Open it in KLYPIX (Canvas → Open).${detail}` }] };
|
|
168
389
|
} catch (e) {
|
|
169
390
|
return { content: [{ type: 'text', text: `Create failed: ${e.message}` }], isError: true };
|
|
170
391
|
}
|
|
@@ -182,9 +403,22 @@ server.registerTool('add_to_canvas', {
|
|
|
182
403
|
const file = resolveCanvas(canvas);
|
|
183
404
|
if (!file) return { content: [{ type: 'text', text: `Canvas not found: ${canvas}` }], isError: true };
|
|
184
405
|
try {
|
|
185
|
-
const
|
|
406
|
+
const original = fs.readFileSync(file);
|
|
407
|
+
// Snapshot existing ids so we can report ONLY the newly-added cards back.
|
|
408
|
+
let beforeIds = new Set();
|
|
409
|
+
try { const b = await parseKlypix(original); beforeIds = new Set(b.struct.cards.map(c => c.id)); } catch { /* new/legacy → treat all as new */ }
|
|
410
|
+
// Provenance: stamp WHICH agent wrote these cards (cursor / claude /
|
|
411
|
+
// cline — from the MCP client's initialize handshake).
|
|
412
|
+
let via; try { via = server.server.getClientVersion()?.name; } catch { /* optional */ }
|
|
413
|
+
const stamped = via ? cards.map(c => ({ ...c, createdVia: via })) : cards;
|
|
414
|
+
const buf = await appendToKlypix(original, { cards: stamped, connections });
|
|
186
415
|
await atomicWrite(file, buf);
|
|
187
|
-
|
|
416
|
+
let detail = '';
|
|
417
|
+
try {
|
|
418
|
+
const { struct } = await parseKlypix(buf);
|
|
419
|
+
detail = cardDetailBlock(struct, new Set(struct.cards.map(c => c.id).filter(id => !beforeIds.has(id))));
|
|
420
|
+
} catch { /* detail is optional */ }
|
|
421
|
+
return { content: [{ type: 'text', text: `Added ${cards.length} card(s) to ${path.relative(VAULT, file)}. Reopen the canvas in KLYPIX to see them.${detail}` }] };
|
|
188
422
|
} catch (e) {
|
|
189
423
|
return { content: [{ type: 'text', text: `Add failed: ${e.message}` }], isError: true };
|
|
190
424
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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",
|
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
48
|
"jszip": "^3.10.1",
|
|
49
|
-
"zod": "^4.3.6"
|
|
49
|
+
"zod": "^4.3.6",
|
|
50
|
+
"fractional-indexing": "^3.2.0"
|
|
51
|
+
},
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"@huggingface/transformers": "^4.2.0"
|
|
50
54
|
}
|
|
51
55
|
}
|
package/src/klypix-format.mjs
CHANGED
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
import JSZip from 'jszip';
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import fs from 'fs';
|
|
12
|
+
import { generateKeyBetween } from 'fractional-indexing';
|
|
13
|
+
|
|
14
|
+
// Valid fractional-indexing z-keys. Hand-rolled keys (e.g. 'a0000' / 'z00013')
|
|
15
|
+
// are REJECTED by the fractional-indexing lib the KLYPIX app uses and crash it
|
|
16
|
+
// the moment you edit such a canvas — so the writer MUST emit lib-valid keys.
|
|
17
|
+
// makeZKeyGen() returns an increasing-key generator starting just above `after`
|
|
18
|
+
// (or from the bottom if `after` is null/invalid).
|
|
19
|
+
const isValidZKey = (k) => { try { generateKeyBetween(k, null); return true; } catch { return false; } };
|
|
20
|
+
function makeZKeyGen(after = null) {
|
|
21
|
+
let last = (after && isValidZKey(after)) ? after : null;
|
|
22
|
+
return () => (last = generateKeyBetween(last, null));
|
|
23
|
+
}
|
|
12
24
|
|
|
13
25
|
export const WIKILINK = /\[\[([^[\]]+)\]\]/g;
|
|
14
26
|
export const TAG = /(^|\s)(#[a-zA-Z][\w-]*)/g;
|
|
@@ -97,6 +109,10 @@ export async function parseKlypix(buffer) {
|
|
|
97
109
|
links: it.type === 'text' ? extractLinks(it.content) : [],
|
|
98
110
|
tags: it.type === 'text' ? extractTags(it.content) : [],
|
|
99
111
|
pos: { x: it.x, y: it.y },
|
|
112
|
+
createdAt: Number(it.createdAt) || 0,
|
|
113
|
+
parentId: it.parentId ?? null,
|
|
114
|
+
// Parent container's title — the card's "area" in brain terms.
|
|
115
|
+
area: it.parentId ? (cardTitle(items[it.parentId]) || null) : null,
|
|
100
116
|
})),
|
|
101
117
|
connections: connections.map(c => ({
|
|
102
118
|
from: titleOf(c.fromId), to: titleOf(c.toId),
|
|
@@ -181,6 +197,7 @@ export async function buildKlypix(spec) {
|
|
|
181
197
|
const COL_W = 380, GAP_Y = 70, START = 80;
|
|
182
198
|
const positions = {};
|
|
183
199
|
let zi = 0;
|
|
200
|
+
const nextZKey = makeZKeyGen();
|
|
184
201
|
order.forEach((id, idx) => {
|
|
185
202
|
const card = cards[idByIndex.indexOf(id)];
|
|
186
203
|
const { w, h } = sizeFor(card);
|
|
@@ -188,7 +205,7 @@ export async function buildKlypix(spec) {
|
|
|
188
205
|
positions[id] = {
|
|
189
206
|
x: card.x ?? (START + col * COL_W),
|
|
190
207
|
y: card.y ?? (START + row * (180 + GAP_Y) + (col % 2) * 12),
|
|
191
|
-
w, h, zKey:
|
|
208
|
+
w, h, zKey: nextZKey(), zIndex: zi++, parentId: null,
|
|
192
209
|
};
|
|
193
210
|
});
|
|
194
211
|
|
|
@@ -278,17 +295,21 @@ export async function appendToKlypix(buffer, addition) {
|
|
|
278
295
|
};
|
|
279
296
|
|
|
280
297
|
canvas.order = Array.isArray(canvas.order) ? canvas.order : [];
|
|
298
|
+
// New cards go above the existing top — generate valid keys starting just
|
|
299
|
+
// above the highest existing VALID key (ignoring any legacy bad keys).
|
|
300
|
+
const existingTop = Object.values(canvas.positions || {}).map(p => p && p.zKey).filter(k => k && isValidZKey(k)).sort().pop() || null;
|
|
301
|
+
const nextZKey = makeZKeyGen(existingTop);
|
|
281
302
|
for (const a of added) {
|
|
282
303
|
zip.file(`items/${shard(a.id)}/${a.id}.json`, JSON.stringify({
|
|
283
304
|
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
305
|
+
...(a.card.createdVia ? { createdVia: String(a.card.createdVia) } : {}),
|
|
284
306
|
content: String(a.card.text), fontSize: FONT,
|
|
285
307
|
color: a.card.color || '#1a1a1f', border: !!a.card.border, borderColor: '#1e1e2e',
|
|
286
308
|
heading: !!a.card.heading, fontFamily: 'Thmanyah Sans',
|
|
287
309
|
fontWeight: a.card.heading ? 'bold' : 'normal', fontStyle: 'normal',
|
|
288
310
|
textDecoration: 'none', textAlign: 'left', verticalAlign: 'top',
|
|
289
311
|
}));
|
|
290
|
-
|
|
291
|
-
canvas.positions[a.id] = { x: a.x, y: a.y, w: a.w, h: a.h, zKey: 'z' + String(a.z).padStart(5, '0'), zIndex: a.z, parentId: null };
|
|
312
|
+
canvas.positions[a.id] = { x: a.x, y: a.y, w: a.w, h: a.h, zKey: nextZKey(), zIndex: a.z, parentId: null };
|
|
292
313
|
canvas.order.push(a.id);
|
|
293
314
|
}
|
|
294
315
|
|
|
@@ -319,6 +340,490 @@ export async function appendToKlypix(buffer, addition) {
|
|
|
319
340
|
return out;
|
|
320
341
|
}
|
|
321
342
|
|
|
343
|
+
// ── Area-grouped layout (project brain) ──────────────────────────────────────
|
|
344
|
+
// Captured decisions carry an [Area]; these route cards INTO titled area
|
|
345
|
+
// containers (find-or-create) so the brain stays a clean areas-as-containers map
|
|
346
|
+
// instead of a rightward strip. Non-destructive, valid z-keys, atomic round-trip.
|
|
347
|
+
const BRAIN_GEOM = { TITLE_BAR: 40, PAD: 14, CARD_GAP: 10, CARD_W: 300, FONT: 12, LINE_H: 17, START: 80, COL_GAP: 44 };
|
|
348
|
+
BRAIN_GEOM.AREA_W = BRAIN_GEOM.CARD_W + BRAIN_GEOM.PAD * 2;
|
|
349
|
+
|
|
350
|
+
// Chars that fit on one rendered line. The bordered card has 10px L/R padding,
|
|
351
|
+
// so the text area is CARD_W-20; use a conservative char width (font*0.62) so a
|
|
352
|
+
// wrapped line never RE-wraps in-app (which would double a card's height).
|
|
353
|
+
function brainCPL() { return Math.max(8, Math.floor((BRAIN_GEOM.CARD_W - 24) / (BRAIN_GEOM.FONT * 0.62))); }
|
|
354
|
+
|
|
355
|
+
// Hard-wrap text to ~CARD_W by inserting newlines at word boundaries. KLYPIX text
|
|
356
|
+
// cards show a long SINGLE line as-typed (no auto-wrap until you resize), so a
|
|
357
|
+
// captured decision with no newlines runs off the box. Baking in line breaks
|
|
358
|
+
// makes brain cards render as tidy multi-line blocks regardless of that.
|
|
359
|
+
function wrapText(text, cpl = brainCPL()) {
|
|
360
|
+
const out = [];
|
|
361
|
+
for (const para of String(text ?? '').split('\n')) {
|
|
362
|
+
if (para.length <= cpl) { out.push(para); continue; }
|
|
363
|
+
let line = '';
|
|
364
|
+
for (const tok of para.split(/(\s+)/)) {
|
|
365
|
+
if (line && (line + tok).trimEnd().length > cpl) { out.push(line.trimEnd()); line = tok.replace(/^\s+/, ''); }
|
|
366
|
+
else line += tok;
|
|
367
|
+
while (line.length > cpl) { out.push(line.slice(0, cpl)); line = line.slice(cpl); } // break an over-long word
|
|
368
|
+
}
|
|
369
|
+
if (line.trim()) out.push(line.trimEnd());
|
|
370
|
+
}
|
|
371
|
+
return out.join('\n');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function measureCardH(text) {
|
|
375
|
+
// text is hard-wrapped to ≤CPL, so the \n-line count is the rendered line
|
|
376
|
+
// count. Match the bordered card (lineHeight 1.35*font + 8/8 vertical padding
|
|
377
|
+
// + border) and over-estimate slightly → small gaps, never overlap.
|
|
378
|
+
const lines = Math.max(1, String(text ?? '').split('\n').length);
|
|
379
|
+
return Math.max(40, Math.ceil(lines * BRAIN_GEOM.FONT * 1.45) + 26);
|
|
380
|
+
}
|
|
381
|
+
// Container the next NEW area goes to the right of the rightmost item.
|
|
382
|
+
function nextContainerX(canvas) {
|
|
383
|
+
const all = Object.values(canvas.positions);
|
|
384
|
+
return all.length ? Math.max(...all.map(p => (p.x || 0) + (p.w || BRAIN_GEOM.AREA_W))) + BRAIN_GEOM.COL_GAP : BRAIN_GEOM.START;
|
|
385
|
+
}
|
|
386
|
+
// Bottom y of a container's current children (where the next card stacks).
|
|
387
|
+
function containerChildBottom(canvas, ctnId) {
|
|
388
|
+
const ctn = canvas.positions[ctnId];
|
|
389
|
+
let cy = ctn.y + BRAIN_GEOM.TITLE_BAR + BRAIN_GEOM.PAD;
|
|
390
|
+
for (const id of canvas.order) {
|
|
391
|
+
const p = canvas.positions[id];
|
|
392
|
+
if (p && p.parentId === ctnId) cy = Math.max(cy, p.y + (p.h || 40) + BRAIN_GEOM.CARD_GAP);
|
|
393
|
+
}
|
|
394
|
+
return cy;
|
|
395
|
+
}
|
|
396
|
+
// Best-effort area name for a card: "Area: …" first-line prefix → first #tag → 'Notes'.
|
|
397
|
+
function areaOfCard(card) {
|
|
398
|
+
const line1 = String(card.text || '').split('\n')[0].trim();
|
|
399
|
+
const m = line1.match(/^([^:\n]{1,40}):\s+\S/);
|
|
400
|
+
if (m) return m[1].trim();
|
|
401
|
+
if (Array.isArray(card.tags) && card.tags[0]) return String(card.tags[0]);
|
|
402
|
+
return 'Notes';
|
|
403
|
+
}
|
|
404
|
+
async function finalizeBrainZip(zip, canvas, manifest, now) {
|
|
405
|
+
if (manifest) {
|
|
406
|
+
manifest.updatedAt = new Date(now).toISOString();
|
|
407
|
+
manifest.stats = manifest.stats || {};
|
|
408
|
+
manifest.stats.itemCount = canvas.order.length;
|
|
409
|
+
zip.file('manifest.json', JSON.stringify(manifest));
|
|
410
|
+
}
|
|
411
|
+
zip.file('canvas.json', JSON.stringify(canvas));
|
|
412
|
+
const out = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
413
|
+
try { await parseKlypix(out); }
|
|
414
|
+
catch (e) { throw new Error('brain write produced an unparseable .klypix — aborting to protect the brain: ' + (e?.message || e)); }
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Append cards routed INTO their [Area] container (find-or-create), so captures
|
|
419
|
+
// self-organize. addition.cards = [{ text, color?, area? }]. Non-destructive to
|
|
420
|
+
// existing items. Falls back to the flat appender for legacy/no-positions files.
|
|
421
|
+
export async function appendIntoContainers(buffer, addition) {
|
|
422
|
+
const { zip, canvas, manifest, isV4, struct } = await parseKlypix(buffer);
|
|
423
|
+
if (!isV4 || !canvas.positions) return appendToKlypix(buffer, addition);
|
|
424
|
+
const newCards = (addition?.cards || []).filter(c => c && typeof c.text === 'string' && c.text.trim());
|
|
425
|
+
if (newCards.length === 0) throw new Error('nothing to add — provide cards[] with text');
|
|
426
|
+
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
const rand = () => Math.random().toString(36).slice(2, 10);
|
|
429
|
+
const G = BRAIN_GEOM;
|
|
430
|
+
canvas.order = Array.isArray(canvas.order) ? canvas.order : [];
|
|
431
|
+
const existingTop = Object.values(canvas.positions).map(p => p && p.zKey).filter(k => k && isValidZKey(k)).sort().pop() || null;
|
|
432
|
+
const nextZKey = makeZKeyGen(existingTop);
|
|
433
|
+
|
|
434
|
+
const byTitle = new Map();
|
|
435
|
+
for (const c of struct.cards) if (c.type === 'container') { const t = (c.title || '').trim().toLowerCase(); if (t && !byTitle.has(t)) byTitle.set(t, c.id); }
|
|
436
|
+
let ctnX = nextContainerX(canvas);
|
|
437
|
+
const ensureContainer = (area) => {
|
|
438
|
+
const key = area.toLowerCase();
|
|
439
|
+
let id = byTitle.get(key);
|
|
440
|
+
if (id) return id;
|
|
441
|
+
id = `ctn_${rand()}`;
|
|
442
|
+
zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify({
|
|
443
|
+
type: 'container', locked: false, createdAt: now, createdBy: 'agent',
|
|
444
|
+
title: area, collapsed: false, scopeLocked: false, borderColor: '#10b981',
|
|
445
|
+
}));
|
|
446
|
+
canvas.positions[id] = { x: ctnX, y: G.START, w: G.AREA_W, h: G.TITLE_BAR + G.PAD * 2, zKey: nextZKey(), zIndex: canvas.order.length, parentId: null };
|
|
447
|
+
canvas.order.push(id);
|
|
448
|
+
byTitle.set(key, id);
|
|
449
|
+
ctnX += G.AREA_W + G.COL_GAP;
|
|
450
|
+
return id;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
for (const card of newCards) {
|
|
454
|
+
const area = (card.area || areaOfCard(card)).toString().trim() || 'Notes';
|
|
455
|
+
const ctnId = ensureContainer(area);
|
|
456
|
+
const ctn = canvas.positions[ctnId];
|
|
457
|
+
const wrapped = wrapText(String(card.text));
|
|
458
|
+
const h = measureCardH(wrapped);
|
|
459
|
+
const cy = containerChildBottom(canvas, ctnId);
|
|
460
|
+
const id = `txt_${rand()}`;
|
|
461
|
+
zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify({
|
|
462
|
+
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
463
|
+
// Provenance: WHICH agent remembered this (claude-code / cursor /
|
|
464
|
+
// cline / …) — additive field, ignored by older readers.
|
|
465
|
+
...(card.createdVia ? { createdVia: String(card.createdVia) } : {}),
|
|
466
|
+
content: wrapped, fontSize: G.FONT,
|
|
467
|
+
color: card.color || '#e8e8ed', border: true, borderColor: card.borderColor || card.color || 'rgba(16,185,129,0.45)',
|
|
468
|
+
fillColor: 'rgba(18,18,26,0.85)', heading: !!card.heading, fontFamily: 'Thmanyah Sans',
|
|
469
|
+
fontWeight: card.heading ? 'bold' : 'normal', fontStyle: 'normal',
|
|
470
|
+
textDecoration: 'none', textAlign: 'left', verticalAlign: 'top',
|
|
471
|
+
}));
|
|
472
|
+
canvas.positions[id] = { x: ctn.x + G.PAD, y: cy, w: G.CARD_W, h, zKey: nextZKey(), zIndex: canvas.order.length, parentId: ctnId };
|
|
473
|
+
canvas.order.push(id);
|
|
474
|
+
ctn.h = (cy + h + G.PAD) - ctn.y;
|
|
475
|
+
}
|
|
476
|
+
return finalizeBrainZip(zip, canvas, manifest, now);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Tidy an EXISTING brain: re-parent every root-level text card into its [Area]
|
|
480
|
+
// container (find-or-create), grouping the messy strip into clean areas. Moves
|
|
481
|
+
// cards (keeps their ids → connections preserved); never drops a card. Caller
|
|
482
|
+
// should back up first; this round-trip-verifies before returning.
|
|
483
|
+
export async function tidyBrain(buffer) {
|
|
484
|
+
const { zip, canvas, manifest, isV4, struct } = await parseKlypix(buffer);
|
|
485
|
+
if (!isV4 || !canvas.positions) throw new Error('tidy supports v4 .klypix only');
|
|
486
|
+
const now = Date.now();
|
|
487
|
+
const rand = () => Math.random().toString(36).slice(2, 10);
|
|
488
|
+
const G = BRAIN_GEOM;
|
|
489
|
+
canvas.order = Array.isArray(canvas.order) ? canvas.order : [];
|
|
490
|
+
const top = Object.values(canvas.positions).map(p => p && p.zKey).filter(k => k && isValidZKey(k)).sort().pop() || null;
|
|
491
|
+
const nextZKey = makeZKeyGen(top);
|
|
492
|
+
|
|
493
|
+
// Normalize every text card to the compact brain font (so the render matches
|
|
494
|
+
// our height measure → no overlap) + cache each card's measured height.
|
|
495
|
+
const meta = new Map(); // id -> { h }
|
|
496
|
+
for (const c of struct.cards) {
|
|
497
|
+
if (c.type === 'container') continue;
|
|
498
|
+
const wrapped = wrapText(String(c.text ?? ''));
|
|
499
|
+
let createdAt = 0;
|
|
500
|
+
const ip = `items/${shard(c.id)}/${c.id}.json`;
|
|
501
|
+
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 */ }
|
|
502
|
+
meta.set(c.id, { h: measureCardH(wrapped), createdAt });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const containerIds = new Set(struct.cards.filter(c => c.type === 'container').map(c => c.id));
|
|
506
|
+
const byTitle = new Map();
|
|
507
|
+
for (const c of struct.cards) if (c.type === 'container') { const t = (c.title || '').trim().toLowerCase(); if (t && !byTitle.has(t)) byTitle.set(t, c.id); }
|
|
508
|
+
|
|
509
|
+
// Group ROOT text cards by [Area].
|
|
510
|
+
const rootText = struct.cards.filter(c => c.type !== 'container' && canvas.positions[c.id] && canvas.positions[c.id].parentId == null);
|
|
511
|
+
const groups = new Map(); // key -> { title, ids: [] }
|
|
512
|
+
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); }
|
|
513
|
+
|
|
514
|
+
let moved = 0;
|
|
515
|
+
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++; } };
|
|
516
|
+
// Ensure a container exists for each area (create if missing); route root cards in.
|
|
517
|
+
for (const grp of groups.values()) {
|
|
518
|
+
const key = grp.title.toLowerCase();
|
|
519
|
+
let ctnId = byTitle.get(key);
|
|
520
|
+
if (!ctnId) {
|
|
521
|
+
ctnId = `ctn_${rand()}`;
|
|
522
|
+
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' }));
|
|
523
|
+
canvas.order.push(ctnId);
|
|
524
|
+
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 };
|
|
525
|
+
byTitle.set(key, ctnId); containerIds.add(ctnId);
|
|
526
|
+
}
|
|
527
|
+
assignTo(ctnId, grp.ids);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// UNIFIED LAYOUT — re-flow each container's children (chronological, compact
|
|
531
|
+
// heights) AND shelf-pack ALL containers into one grid by their TRUE height, so
|
|
532
|
+
// a container that grew (new captures) never overlaps its neighbor below.
|
|
533
|
+
const childrenOf = (cid) => canvas.order
|
|
534
|
+
.filter(id => canvas.positions[id] && canvas.positions[id].parentId === cid)
|
|
535
|
+
.sort((a, b) => (meta.get(a)?.createdAt || 0) - (meta.get(b)?.createdAt || 0));
|
|
536
|
+
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); };
|
|
537
|
+
const orderedCtns = canvas.order.filter(id => containerIds.has(id) && canvas.positions[id]);
|
|
538
|
+
const cols = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(Math.max(1, orderedCtns.length)))));
|
|
539
|
+
let colIdx = 0, rowTopY = G.START, rowMaxH = 0, colX = G.START;
|
|
540
|
+
for (const cid of orderedCtns) {
|
|
541
|
+
const h = heightOf(cid);
|
|
542
|
+
if (colIdx >= cols) { rowTopY += rowMaxH + G.COL_GAP; rowMaxH = 0; colIdx = 0; colX = G.START; }
|
|
543
|
+
canvas.positions[cid] = { ...canvas.positions[cid], x: colX, y: rowTopY, w: G.AREA_W, h };
|
|
544
|
+
let cy = rowTopY + G.TITLE_BAR + G.PAD;
|
|
545
|
+
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; }
|
|
546
|
+
colX += G.AREA_W + G.COL_GAP; colIdx++; rowMaxH = Math.max(rowMaxH, h);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const out = await finalizeBrainZip(zip, canvas, manifest, now);
|
|
550
|
+
return { buffer: out, moved, containers: byTitle.size };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ── Tiered brain brief ───────────────────────────────────────────────────────
|
|
554
|
+
// A compact, token-bounded session brief: area map + open questions + recent
|
|
555
|
+
// decisions + milestones. Everything older stays in the file, reachable via the
|
|
556
|
+
// klypix-canvas MCP search or `--full`. Keeps the session-start cost flat as
|
|
557
|
+
// the brain grows (the full markdown scales with history; this doesn't).
|
|
558
|
+
export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMilestones = 8, maxConnections = 30 } = {}) {
|
|
559
|
+
const cutoff = Date.now() - recentDays * 86_400_000;
|
|
560
|
+
const texts = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
|
|
561
|
+
const containers = struct.cards.filter(c => c.type === 'container');
|
|
562
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
563
|
+
// 📌 FOCUS — the human steers agent attention SPATIALLY: any card dragged
|
|
564
|
+
// into a container titled "Focus" (any decoration: "📌 Focus", "Focus
|
|
565
|
+
// (drag cards here)") leads every brief, full text, regardless of age.
|
|
566
|
+
const isFocus = (c) => /(^|\s)focus\b/i.test(c.area || '');
|
|
567
|
+
const live = texts.filter(c => !isArchived(c));
|
|
568
|
+
const focus = live.filter(isFocus);
|
|
569
|
+
const rest = live.filter(c => !isFocus(c));
|
|
570
|
+
const open = rest.filter(c => /❓/.test(c.text));
|
|
571
|
+
const miles = rest.filter(c => /🏁/.test(c.text) && !/❓/.test(c.text));
|
|
572
|
+
const plain = rest.filter(c => !open.includes(c) && !miles.includes(c));
|
|
573
|
+
const recent = plain.filter(c => c.createdAt >= cutoff).sort((a, b) => b.createdAt - a.createdAt).slice(0, maxRecent);
|
|
574
|
+
const archivedCount = texts.length - live.length;
|
|
575
|
+
|
|
576
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
577
|
+
// HEADLINE = first sentence-ish, hard-capped — the brief is a scannable
|
|
578
|
+
// changelog; the agent pulls any card's full text via the MCP when needed.
|
|
579
|
+
const headline = (c, max = 160) => {
|
|
580
|
+
const t = flat(c.text);
|
|
581
|
+
const stop = t.search(/(?<=[.!?])\s/);
|
|
582
|
+
const h = stop > 40 && stop < max ? t.slice(0, stop) : t;
|
|
583
|
+
return h.length > max ? h.slice(0, max - 1).trimEnd() + '…' : h;
|
|
584
|
+
};
|
|
585
|
+
const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
|
|
586
|
+
|
|
587
|
+
// TOKEN BUDGET (≈ chars/4): sections are added in priority order — Focus,
|
|
588
|
+
// Open, Areas, Milestones, Recent, Connections — and Recent stops when the
|
|
589
|
+
// budget is hit. The brief stays ~flat forever no matter how active a week.
|
|
590
|
+
const BUDGET_CHARS = 11_000; // ≈ 2.7k tokens
|
|
591
|
+
let used = 0;
|
|
592
|
+
const out = [];
|
|
593
|
+
const push = (...lines) => { for (const l of lines) { out.push(l); used += l.length + 1; } };
|
|
594
|
+
|
|
595
|
+
push(`# ${struct.title} — brain brief`);
|
|
596
|
+
push(`*${struct.format} · ${struct.counts.cards} cards · ${struct.counts.connections} connections · tiered brief (focus + open + last ${recentDays}d headlines); full cards via klypix-canvas MCP search*`);
|
|
597
|
+
if (focus.length) {
|
|
598
|
+
push('', '## 📌 Human focus (cards the human placed here — act on these first)');
|
|
599
|
+
for (const c of focus) push(`- ${flat(c.text)}`);
|
|
600
|
+
}
|
|
601
|
+
if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${flat(c.text)}`); }
|
|
602
|
+
const areaCounts = containers
|
|
603
|
+
.filter(c => !/^archive$/i.test(c.title || ''))
|
|
604
|
+
.map(c => `${flat(c.title)} (${texts.filter(t => t.parentId === c.id).length})`);
|
|
605
|
+
if (areaCounts.length) { push('', '## Areas', areaCounts.join(' · ')); }
|
|
606
|
+
if (miles.length) {
|
|
607
|
+
push('', '## Milestones');
|
|
608
|
+
for (const c of miles.sort((a, b) => b.createdAt - a.createdAt).slice(0, maxMilestones)) push(`- ${headline(c)}`);
|
|
609
|
+
}
|
|
610
|
+
let shownRecent = 0;
|
|
611
|
+
if (recent.length) {
|
|
612
|
+
push('', `## Recent decisions (last ${recentDays}d — headlines)`);
|
|
613
|
+
const byArea = new Map();
|
|
614
|
+
for (const c of recent) { const a = flat(c.area) || 'Notes'; if (!byArea.has(a)) byArea.set(a, []); byArea.get(a).push(c); }
|
|
615
|
+
outer: for (const [a, cs] of byArea) {
|
|
616
|
+
push(`### ${a}`);
|
|
617
|
+
for (const c of cs) {
|
|
618
|
+
if (used > BUDGET_CHARS) break outer;
|
|
619
|
+
push(`- ${day(c.createdAt)} ${headline(c)}`);
|
|
620
|
+
shownRecent++;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (struct.connections.length && used <= BUDGET_CHARS) {
|
|
625
|
+
push('', '## Connections');
|
|
626
|
+
for (const cn of struct.connections.slice(0, maxConnections)) push(`- ${cn.from} → ${cn.to}${cn.label || cn.relationship ? ` (${cn.label || cn.relationship})` : ''}`);
|
|
627
|
+
}
|
|
628
|
+
const hidden = [];
|
|
629
|
+
const unshown = plain.length - shownRecent;
|
|
630
|
+
if (unshown > 0) hidden.push(`${unshown} older/over-budget decision${unshown === 1 ? '' : 's'}`);
|
|
631
|
+
if (archivedCount > 0) hidden.push(`${archivedCount} archived/superseded`);
|
|
632
|
+
if (hidden.length) push('', `*${hidden.join(' + ')} not shown — search the full brain via the klypix-canvas MCP (search/read tools) or \`node ~/.claude/project-brain/global-brain-hook.mjs --full\`.*`);
|
|
633
|
+
return out.join('\n') + '\n';
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── Atomic brain capture: supersede + append + resolve + auto-link ──────────
|
|
637
|
+
// One verified write per capture batch. Beyond appendIntoContainers it adds:
|
|
638
|
+
// • SUPERSEDE — a new decision that heavily overlaps an existing live card in
|
|
639
|
+
// the same area archives the old one (↩︎ prefix, gray, → Archive) and draws
|
|
640
|
+
// an old→new "superseded by" arrow, instead of stacking a contradiction.
|
|
641
|
+
// • RESOLVE — resolutions[] (the ✓ marker) finds the best-matching live card
|
|
642
|
+
// in the area, stamps "✅ <date>: <note>" onto it and archives it; if no
|
|
643
|
+
// match, the note lands as a 🏁 milestone so nothing is lost.
|
|
644
|
+
// • AUTO-LINK — [[Title]] in a new card's text becomes a real connection to
|
|
645
|
+
// the card/container whose title matches (the graph stops being decorative).
|
|
646
|
+
const tokenSet = (s) => new Set(String(s || '').toLowerCase().replace(/\[\[|\]\]/g, ' ').split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 4));
|
|
647
|
+
const overlapScore = (a, b) => {
|
|
648
|
+
if (a.size < 4 || b.size < 4) return 0; // too short to judge
|
|
649
|
+
let hit = 0; for (const w of a) if (b.has(w)) hit++;
|
|
650
|
+
return hit / Math.min(a.size, b.size);
|
|
651
|
+
};
|
|
652
|
+
export async function captureIntoBrain(buffer, { cards = [], resolutions = [], updates = [] } = {}) {
|
|
653
|
+
const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45;
|
|
654
|
+
let work = buffer;
|
|
655
|
+
const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0 };
|
|
656
|
+
|
|
657
|
+
// Pass 1 — resolutions + supersede marking operate on EXISTING cards.
|
|
658
|
+
if (resolutions.length || cards.length || updates.length) {
|
|
659
|
+
const { zip, canvas, manifest, isV4, struct } = await parseKlypix(work);
|
|
660
|
+
if (!isV4 || !canvas.positions) {
|
|
661
|
+
// Legacy file — no surgery possible; degrade to plain append.
|
|
662
|
+
return { buffer: await appendToKlypix(work, { cards }), stats };
|
|
663
|
+
}
|
|
664
|
+
const now = Date.now();
|
|
665
|
+
const today = new Date(now).toISOString().slice(0, 10);
|
|
666
|
+
const rand = () => Math.random().toString(36).slice(2, 10);
|
|
667
|
+
const top = Object.values(canvas.positions).map(p => p && p.zKey).filter(k => k && isValidZKey(k)).sort().pop() || null;
|
|
668
|
+
const nextZKey = makeZKeyGen(top);
|
|
669
|
+
const byTitle = new Map();
|
|
670
|
+
for (const c of struct.cards) if (c.type === 'container') { const t = (c.title || '').trim().toLowerCase(); if (t && !byTitle.has(t)) byTitle.set(t, c.id); }
|
|
671
|
+
const ensureArchive = () => {
|
|
672
|
+
let id = byTitle.get('archive');
|
|
673
|
+
if (id) return id;
|
|
674
|
+
id = `ctn_${rand()}`;
|
|
675
|
+
const G = BRAIN_GEOM;
|
|
676
|
+
zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify({ type: 'container', locked: false, createdAt: now, createdBy: 'agent', title: 'Archive', collapsed: false, scopeLocked: false, borderColor: 'rgba(120,120,135,0.6)' }));
|
|
677
|
+
canvas.positions[id] = { x: nextContainerX(canvas), y: G.START, w: G.AREA_W, h: G.TITLE_BAR + G.PAD * 2, zKey: nextZKey(), zIndex: canvas.order.length, parentId: null };
|
|
678
|
+
canvas.order.push(id);
|
|
679
|
+
byTitle.set('archive', id);
|
|
680
|
+
return id;
|
|
681
|
+
};
|
|
682
|
+
const liveTextCards = () => struct.cards.filter(c =>
|
|
683
|
+
c.type !== 'container' && (c.text || '').trim()
|
|
684
|
+
&& !/^archive$/i.test(c.area || '')
|
|
685
|
+
&& !/↩|✅/.test(c.text));
|
|
686
|
+
const rewriteCard = async (id, mutate) => {
|
|
687
|
+
const ip = `items/${shard(id)}/${id}.json`;
|
|
688
|
+
const f = zip.file(ip); if (!f) return false;
|
|
689
|
+
const j = JSON.parse(await f.async('string'));
|
|
690
|
+
mutate(j);
|
|
691
|
+
j.content = wrapText(String(j.content || ''));
|
|
692
|
+
zip.file(ip, JSON.stringify(j));
|
|
693
|
+
const pos = canvas.positions[id];
|
|
694
|
+
if (pos) canvas.positions[id] = { ...pos, h: measureCardH(j.content) };
|
|
695
|
+
return true;
|
|
696
|
+
};
|
|
697
|
+
const archiveCard = (id) => {
|
|
698
|
+
const arc = ensureArchive();
|
|
699
|
+
const pos = canvas.positions[id];
|
|
700
|
+
if (pos) canvas.positions[id] = { ...pos, parentId: arc };
|
|
701
|
+
};
|
|
702
|
+
canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
703
|
+
|
|
704
|
+
// RESOLVE (✓ markers) — best live match in the area; ❓ cards preferred.
|
|
705
|
+
const milestonesFallback = [];
|
|
706
|
+
for (const r of resolutions) {
|
|
707
|
+
const rTok = tokenSet(r.text);
|
|
708
|
+
let best = null, bestScore = 0;
|
|
709
|
+
for (const c of liveTextCards()) {
|
|
710
|
+
if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
|
|
711
|
+
const s = overlapScore(rTok, tokenSet(c.text)) + (/❓/.test(c.text) ? 0.15 : 0);
|
|
712
|
+
if (s > bestScore) { bestScore = s; best = c; }
|
|
713
|
+
}
|
|
714
|
+
if (best && bestScore >= RESOLVE_AT) {
|
|
715
|
+
await rewriteCard(best.id, j => {
|
|
716
|
+
j.content = `${j.content}\n✅ ${today}: ${r.text}`;
|
|
717
|
+
j.borderColor = 'rgba(16,185,129,0.35)';
|
|
718
|
+
});
|
|
719
|
+
archiveCard(best.id);
|
|
720
|
+
best.text += ` ✅ ${r.text}`; // keep in-memory struct honest for later matching
|
|
721
|
+
stats.resolved++;
|
|
722
|
+
} else {
|
|
723
|
+
milestonesFallback.push({ text: (r.area ? `${r.area}: ` : '') + `🏁 ${r.text}`, area: r.area, borderColor: 'rgba(59,130,246,0.8)' });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// UPDATE (~ markers) — rewrite a matching card IN PLACE: for small
|
|
728
|
+
// corrections that don't deserve supersession history. Content is
|
|
729
|
+
// replaced (area prefix + tag preserved), createdAt bumped so the
|
|
730
|
+
// brief treats it as fresh. No match → falls through as a new card.
|
|
731
|
+
for (const u of updates) {
|
|
732
|
+
const uTok = tokenSet(u.text);
|
|
733
|
+
let best = null, bestScore = 0;
|
|
734
|
+
for (const c of liveTextCards()) {
|
|
735
|
+
if (u.area && (c.area || '').toLowerCase() !== u.area.toLowerCase()) continue;
|
|
736
|
+
const s = overlapScore(uTok, tokenSet(c.text));
|
|
737
|
+
if (s > bestScore) { bestScore = s; best = c; }
|
|
738
|
+
}
|
|
739
|
+
if (best && bestScore >= UPDATE_AT) {
|
|
740
|
+
const tag = u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : '';
|
|
741
|
+
await rewriteCard(best.id, j => {
|
|
742
|
+
j.content = (u.area ? `${u.area}: ` : '') + u.text + tag;
|
|
743
|
+
j.createdAt = now;
|
|
744
|
+
j.borderColor = 'rgba(16,185,129,0.6)';
|
|
745
|
+
if (u.createdVia) j.createdVia = String(u.createdVia);
|
|
746
|
+
});
|
|
747
|
+
best.text = u.text;
|
|
748
|
+
stats.updated++;
|
|
749
|
+
} else {
|
|
750
|
+
cards.push({ text: (u.area ? `${u.area}: ` : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// SUPERSEDE — pre-mark old cards that a NEW decision replaces. The arrow
|
|
755
|
+
// to the new card is drawn in pass 2 (after the new ids exist), matched
|
|
756
|
+
// back by remembering which old card each new card displaced.
|
|
757
|
+
for (const card of cards) {
|
|
758
|
+
if (/❓|🏁/.test(card.text)) continue; // only plain decisions supersede
|
|
759
|
+
const nTok = tokenSet(card.text);
|
|
760
|
+
const area = (card.area || '').toLowerCase();
|
|
761
|
+
let best = null, bestScore = 0;
|
|
762
|
+
for (const c of liveTextCards()) {
|
|
763
|
+
if (area && (c.area || '').toLowerCase() !== area) continue;
|
|
764
|
+
const s = overlapScore(nTok, tokenSet(c.text));
|
|
765
|
+
if (s > bestScore) { bestScore = s; best = c; }
|
|
766
|
+
}
|
|
767
|
+
if (best && bestScore >= SUPERSEDE_AT) {
|
|
768
|
+
await rewriteCard(best.id, j => {
|
|
769
|
+
j.content = `↩︎ superseded ${today}\n${j.content}`;
|
|
770
|
+
j.borderColor = 'rgba(120,120,135,0.5)';
|
|
771
|
+
});
|
|
772
|
+
archiveCard(best.id);
|
|
773
|
+
best.text = `↩︎ ${best.text}`;
|
|
774
|
+
card.__supersedes = best.id;
|
|
775
|
+
stats.superseded++;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
cards.push(...milestonesFallback);
|
|
780
|
+
work = await finalizeBrainZip(zip, canvas, manifest, now);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Pass 2 — append the new cards (existing self-organizing path), then wire
|
|
784
|
+
// connections: supersede arrows + [[wikilink]] auto-links.
|
|
785
|
+
if (cards.length) {
|
|
786
|
+
work = await appendIntoContainers(work, { cards });
|
|
787
|
+
stats.added = cards.length;
|
|
788
|
+
const { zip, canvas, manifest, struct } = await parseKlypix(work);
|
|
789
|
+
const now = Date.now();
|
|
790
|
+
const rand = () => Math.random().toString(36).slice(2, 10);
|
|
791
|
+
canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
792
|
+
const hasConn = (a, b) => canvas.connections.some(cn => (cn.fromId === a && cn.toId === b) || (cn.fromId === b && cn.toId === a));
|
|
793
|
+
const addConn = (fromId, toId, label, relationship) => {
|
|
794
|
+
if (!fromId || !toId || fromId === toId || hasConn(fromId, toId)) return;
|
|
795
|
+
canvas.connections.push({ id: `con_${rand()}`, fromId, toId, relationship, label, arrowHead: true, width: 2, color: '#10b981', style: 'solid' });
|
|
796
|
+
stats.linked++;
|
|
797
|
+
};
|
|
798
|
+
// Locate each appended card by exact text match (newest first wins).
|
|
799
|
+
const findNew = (text) => {
|
|
800
|
+
const flatT = wrapText(String(text));
|
|
801
|
+
for (let i = struct.cards.length - 1; i >= 0; i--) {
|
|
802
|
+
const c = struct.cards[i];
|
|
803
|
+
if (c.type !== 'container' && (c.text || '') === flatT) return c;
|
|
804
|
+
}
|
|
805
|
+
return null;
|
|
806
|
+
};
|
|
807
|
+
const titleIndex = struct.cards
|
|
808
|
+
.filter(c => (c.title || '').trim())
|
|
809
|
+
.map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
|
|
810
|
+
for (const card of cards) {
|
|
811
|
+
const created = findNew(card.text);
|
|
812
|
+
if (!created) continue;
|
|
813
|
+
if (card.__supersedes) addConn(card.__supersedes, created.id, 'superseded by', undefined);
|
|
814
|
+
for (const link of (created.links || [])) {
|
|
815
|
+
const want = String(link).trim().toLowerCase();
|
|
816
|
+
if (!want) continue;
|
|
817
|
+
const target = titleIndex.find(e => e.id !== created.id && (e.t === want || e.t.startsWith(want)));
|
|
818
|
+
if (target) addConn(created.id, target.id, undefined, 'relates_to');
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
work = await finalizeBrainZip(zip, canvas, manifest, now);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return { buffer: work, stats };
|
|
825
|
+
}
|
|
826
|
+
|
|
322
827
|
/**
|
|
323
828
|
* Build a RICH "map" .klypix: areas become titled containers, their cards
|
|
324
829
|
* stack inside, connections draw across. Produces a real spatial board (used by
|
|
@@ -345,6 +850,7 @@ export async function buildKlypixMap(spec) {
|
|
|
345
850
|
const titleToId = new Map(); // card title -> id (for connections)
|
|
346
851
|
const firstLine = (t) => String(t ?? '').split('\n').map(s => s.trim()).find(Boolean) || '';
|
|
347
852
|
let z = 0;
|
|
853
|
+
const nextZKey = makeZKeyGen();
|
|
348
854
|
|
|
349
855
|
// Shelf-pack areas into rows of `cols`; each row's height = tallest area.
|
|
350
856
|
let rowTopY = START, rowMaxH = 0, colX = START, colIdx = 0;
|
|
@@ -370,7 +876,7 @@ export async function buildKlypixMap(spec) {
|
|
|
370
876
|
title: area.title || `Area ${ai + 1}`, collapsed: false, scopeLocked: false,
|
|
371
877
|
borderColor: area.color || '#10b981',
|
|
372
878
|
};
|
|
373
|
-
positions[ctnId] = { x: ax, y: ay, w: AREA_W, h: areaH, zKey:
|
|
879
|
+
positions[ctnId] = { x: ax, y: ay, w: AREA_W, h: areaH, zKey: nextZKey(), zIndex: z, parentId: null };
|
|
374
880
|
order.push(ctnId); z++;
|
|
375
881
|
|
|
376
882
|
let cy = ay + TITLE_BAR + PAD;
|
|
@@ -388,7 +894,7 @@ export async function buildKlypixMap(spec) {
|
|
|
388
894
|
textDecoration: 'none', textAlign: 'left', verticalAlign: 'top',
|
|
389
895
|
fontFamily: 'Thmanyah Sans',
|
|
390
896
|
};
|
|
391
|
-
positions[id] = { x: ax + PAD, y: cy, w: CARD_W, h, zKey:
|
|
897
|
+
positions[id] = { x: ax + PAD, y: cy, w: CARD_W, h, zKey: nextZKey(), zIndex: z, parentId: ctnId };
|
|
392
898
|
order.push(id); z++;
|
|
393
899
|
const t = firstLine(c.text).toLowerCase();
|
|
394
900
|
if (t && !titleToId.has(t)) titleToId.set(t, id);
|