klypix-mcp 1.0.0 → 1.0.4

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/README.md CHANGED
@@ -47,6 +47,7 @@ notes into a board,"* or *"add a card with the decision we just made."*
47
47
  | `search_canvases` | Search across canvases by name + content |
48
48
  | `create_canvas` | Create a new `.klypix` from cards + connections |
49
49
  | `add_to_canvas` | Append cards/connections to an existing canvas (positions preserved) |
50
+ | `search_all_brains` | Search every registered project brain (`~/.claude/project-brain/registry.json`) — cross-project recall |
50
51
 
51
52
  ## Use it as a library
52
53
 
@@ -110,16 +110,36 @@ server.registerTool('list_canvases', {
110
110
  return { content: [{ type: 'text', text: `# Canvases in ${VAULT}\n\n${rows.join('\n')}` }] };
111
111
  });
112
112
 
113
+ const IMG_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
114
+ const IMG_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp' };
115
+
113
116
  server.registerTool('read_canvas', {
114
117
  title: 'Read a KLYPIX canvas',
115
- description: 'Read a canvas as structured markdown: every card, the connection graph, [[wikilinks]], #tags, and an asset list. Accepts a filename, vault-relative path, or absolute path.',
116
- inputSchema: { canvas: z.string().describe('Canvas filename, vault-relative path, or absolute path.') },
118
+ 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.',
119
+ inputSchema: { canvas: z.string().describe('Canvas title or filename (e.g. "SS2"), vault-relative path, or absolute path.') },
117
120
  }, async ({ canvas }) => {
118
121
  const file = resolveCanvas(canvas);
119
122
  if (!file) return { content: [{ type: 'text', text: `Canvas not found: ${canvas} (vault: ${VAULT})` }], isError: true };
120
123
  try {
121
- const { struct } = await parseKlypix(fs.readFileSync(file));
122
- return { content: [{ type: 'text', text: structToMarkdown(struct) }] };
124
+ const { struct, zip, assetPaths } = await parseKlypix(fs.readFileSync(file));
125
+ const content = [{ type: 'text', text: structToMarkdown(struct) }];
126
+ // Return image assets as actual image content so a vision-capable model
127
+ // SEES them — the whole point of a multimodal canvas. Capped (count +
128
+ // per-image size) so the response stays sane.
129
+ let included = 0;
130
+ for (const p of assetPaths) {
131
+ if (included >= 8) break;
132
+ if (!IMG_RE.test(p)) continue;
133
+ try {
134
+ const b64 = await zip.file(p).async('base64');
135
+ if (!b64 || b64.length > 7_000_000) continue; // skip > ~5MB
136
+ const ext = p.split('.').pop().toLowerCase();
137
+ content.push({ type: 'image', data: b64, mimeType: IMG_MIME[ext] || 'image/png' });
138
+ included++;
139
+ } catch { /* skip unreadable asset */ }
140
+ }
141
+ if (included > 0) content.push({ type: 'text', text: `\n(${included} image${included > 1 ? 's' : ''} from this canvas are attached above — read them directly.)` });
142
+ return { content };
123
143
  } catch (e) {
124
144
  return { content: [{ type: 'text', text: `Failed to read ${file}: ${e.message}` }], isError: true };
125
145
  }
@@ -136,18 +156,101 @@ server.registerTool('search_canvases', {
136
156
  for (const f of walkVault()) {
137
157
  let struct;
138
158
  try { ({ struct } = await parseKlypix(fs.readFileSync(f))); } catch { continue; }
159
+ const rel = path.relative(VAULT, f);
160
+ // Match the canvas TITLE + FILENAME too — not just card text — so
161
+ // searching a canvas by its name (e.g. "SS2") actually finds it.
162
+ const nameMatch = (struct.title || '').toLowerCase().includes(q) || rel.toLowerCase().includes(q);
139
163
  const matched = struct.cards.filter(c =>
140
164
  (c.title || '').toLowerCase().includes(q) ||
141
165
  String(c.text || '').toLowerCase().includes(q) ||
142
166
  (c.tags || []).some(t => ('#' + t).toLowerCase().includes(q)));
143
- if (matched.length) {
144
- hits.push(`## ${path.relative(VAULT, f)} "${struct.title}"\n` +
145
- matched.slice(0, 6).map(c => `- ${c.title || '(card)'}: ${String(c.text || '').replace(/\s+/g, ' ').slice(0, 120)}`).join('\n'));
167
+ if (nameMatch || matched.length) {
168
+ // Rich hits: type + id + position + tags + a longer snippet, so the
169
+ // agent can FIND a card (and tell duplicates apart) before it WRITES.
170
+ const head = `## ${rel} — "${struct.title}" · ${struct.counts.cards} cards, ${struct.counts.connections} connections${nameMatch && !matched.length ? ' (name/title match)' : ''}`;
171
+ const body = matched.slice(0, 8).map(c => {
172
+ const pos = (c.pos && c.pos.x != null) ? ` @(${Math.round(c.pos.x)},${Math.round(c.pos.y)})` : '';
173
+ const tags = (c.tags && c.tags.length) ? ' ' + c.tags.map(t => '#' + t).join(' ') : '';
174
+ return `- [${c.type}] "${c.title || '(card)'}" (${c.id})${pos}${tags}\n ${String(c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
175
+ }).join('\n');
176
+ hits.push(matched.length ? `${head}\n${body}` : head);
146
177
  }
147
178
  }
148
179
  return { content: [{ type: 'text', text: hits.length ? `# Matches for "${query}"\n\n${hits.join('\n\n')}` : `No matches for "${query}" in ${VAULT}.` }] };
149
180
  });
150
181
 
182
+ // Cross-project memory: search EVERY brain this machine has touched, not just
183
+ // this vault. The SessionStart/Stop hook registers each ./brain.klypix it runs
184
+ // against into ~/.claude/project-brain/registry.json — so simply having worked
185
+ // in a project makes its decisions findable from any other project ("what did
186
+ // I decide about auth — in ANY project?"). Lexical scoring v1: term hits
187
+ // weighted title>tag>text, with a recency boost; the on-device embedding
188
+ // upgrade ranks the same index later without changing this tool's shape.
189
+ server.registerTool('search_all_brains', {
190
+ title: 'Search every project brain on this machine',
191
+ 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. Use when the answer may live in ANOTHER project\'s decisions.',
192
+ inputSchema: { query: z.string().describe('What to find across all project brains.') },
193
+ }, async ({ query }) => {
194
+ const q = String(query || '').trim().toLowerCase();
195
+ if (!q) return { content: [{ type: 'text', text: 'Provide a non-empty query.' }], isError: true };
196
+ const reg = path.join(os.homedir(), '.claude', 'project-brain', 'registry.json');
197
+ let brains = [];
198
+ try { brains = (JSON.parse(fs.readFileSync(reg, 'utf8')).brains || []).filter(b => b && b.path); } catch { /* no registry yet */ }
199
+ if (!brains.length) return { content: [{ type: 'text', text: 'No brains registered yet — the brain hook registers each project as you work in it.' }] };
200
+ const terms = q.split(/[^\p{L}\p{N}#]+/u).filter(t => t.length >= 3);
201
+ if (!terms.length) return { content: [{ type: 'text', text: 'Query too short — use words of 3+ characters.' }], isError: true };
202
+ const fresh = Date.now() - 30 * 86_400_000;
203
+ const scored = [];
204
+ for (const b of brains) {
205
+ let struct;
206
+ try { ({ struct } = await parseKlypix(fs.readFileSync(b.path))); } catch { continue; }
207
+ for (const c of struct.cards) {
208
+ if (c.type === 'container') continue;
209
+ const text = String(c.text || '').toLowerCase();
210
+ const title = String(c.title || '').toLowerCase();
211
+ const tags = (c.tags || []).map(t => ('#' + t).toLowerCase());
212
+ let score = 0;
213
+ for (const t of terms) {
214
+ if (title.includes(t)) score += 3;
215
+ if (tags.some(g => g.includes(t))) score += 2;
216
+ if (text.includes(t)) score += 1;
217
+ }
218
+ if (!score) continue;
219
+ if ((c.createdAt || 0) >= fresh) score += 1; // recency boost
220
+ if (/^archive$/i.test(c.area || '')) score -= 0.5; // superseded ranks lower
221
+ scored.push({ score, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
222
+ }
223
+ }
224
+ if (!scored.length) return { content: [{ type: 'text', text: `No matches for "${query}" across ${brains.length} registered brain(s).` }] };
225
+ scored.sort((a, b2) => b2.score - a.score);
226
+ const top = scored.slice(0, 20);
227
+ const lines = top.map(h => {
228
+ const when = h.c.createdAt ? new Date(h.c.createdAt).toISOString().slice(0, 10) : '';
229
+ return `- [${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
230
+ });
231
+ return { content: [{ type: 'text', text: `# Cross-project matches for "${query}" (${scored.length} hits in ${brains.length} brains, top ${top.length})\n\n${lines.join('\n')}` }] };
232
+ });
233
+
234
+ // Format the cards (optionally only a set of new ids) + connection graph so an
235
+ // agent that just wrote can chain follow-ups: reference card IDs, place near a
236
+ // position, or draw an arrow to something it created. Additive — appended after
237
+ // the human-readable line.
238
+ function cardDetailBlock(struct, onlyIds) {
239
+ const cards = onlyIds ? struct.cards.filter(c => onlyIds.has(c.id)) : struct.cards;
240
+ if (!cards.length) return '';
241
+ const lines = cards.map(c => {
242
+ const pos = (c.pos && c.pos.x != null) ? `(${Math.round(c.pos.x)},${Math.round(c.pos.y)})` : '(?)';
243
+ const tags = (c.tags && c.tags.length) ? ' ' + c.tags.map(t => '#' + t).join(' ') : '';
244
+ const title = c.title || (c.text ? String(c.text).replace(/\s+/g, ' ').slice(0, 40) : '(untitled)');
245
+ return `- ${c.id} · ${c.type} · ${pos} · "${title}"${tags}`;
246
+ });
247
+ let out = `\n\nCards you can reference (id · type · pos · title):\n${lines.join('\n')}`;
248
+ if (struct.connections && struct.connections.length) {
249
+ out += `\nConnections: ` + struct.connections.map(cn => `${cn.from} ${cn.relationship ? '—' + cn.relationship + '→' : '→'} ${cn.to}`).join('; ');
250
+ }
251
+ return out;
252
+ }
253
+
151
254
  server.registerTool('create_canvas', {
152
255
  title: 'Create a KLYPIX canvas',
153
256
  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 +267,9 @@ server.registerTool('create_canvas', {
164
267
  const name = filename ? safeName(filename.replace(IS_CANVAS, '')) : safeName(title);
165
268
  const out = path.join(VAULT, name);
166
269
  await atomicWrite(out, buf);
167
- return { content: [{ type: 'text', text: `Created ${out} — ${cards.length} cards, ${(connections || []).length} connections. Open it in KLYPIX (Canvas → Open).` }] };
270
+ let detail = '';
271
+ try { const { struct } = await parseKlypix(buf); detail = cardDetailBlock(struct); } catch { /* detail is optional */ }
272
+ return { content: [{ type: 'text', text: `Created ${out} — ${cards.length} cards, ${(connections || []).length} connections. Open it in KLYPIX (Canvas → Open).${detail}` }] };
168
273
  } catch (e) {
169
274
  return { content: [{ type: 'text', text: `Create failed: ${e.message}` }], isError: true };
170
275
  }
@@ -182,9 +287,18 @@ server.registerTool('add_to_canvas', {
182
287
  const file = resolveCanvas(canvas);
183
288
  if (!file) return { content: [{ type: 'text', text: `Canvas not found: ${canvas}` }], isError: true };
184
289
  try {
185
- const buf = await appendToKlypix(fs.readFileSync(file), { cards, connections });
290
+ const original = fs.readFileSync(file);
291
+ // Snapshot existing ids so we can report ONLY the newly-added cards back.
292
+ let beforeIds = new Set();
293
+ try { const b = await parseKlypix(original); beforeIds = new Set(b.struct.cards.map(c => c.id)); } catch { /* new/legacy → treat all as new */ }
294
+ const buf = await appendToKlypix(original, { cards, connections });
186
295
  await atomicWrite(file, buf);
187
- return { content: [{ type: 'text', text: `Added ${cards.length} card(s) to ${path.relative(VAULT, file)}. Reopen the canvas in KLYPIX to see them.` }] };
296
+ let detail = '';
297
+ try {
298
+ const { struct } = await parseKlypix(buf);
299
+ detail = cardDetailBlock(struct, new Set(struct.cards.map(c => c.id).filter(id => !beforeIds.has(id))));
300
+ } catch { /* detail is optional */ }
301
+ 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
302
  } catch (e) {
189
303
  return { content: [{ type: 'text', text: `Add failed: ${e.message}` }], isError: true };
190
304
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.4",
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",
@@ -19,7 +19,7 @@
19
19
  "homepage": "https://klypix.com",
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "https://github.com/dahshanlabs/klypix-mcp"
22
+ "url": "git+https://github.com/dahshanlabs/klypix-mcp.git"
23
23
  },
24
24
  "bin": {
25
25
  "klypix-mcp": "bin/klypix-mcp.mjs",
@@ -46,6 +46,7 @@
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"
50
51
  }
51
52
  }
@@ -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: 'a' + String(idx).padStart(4, '0'), zIndex: zi++, parentId: null,
208
+ w, h, zKey: nextZKey(), zIndex: zi++, parentId: null,
192
209
  };
193
210
  });
194
211
 
@@ -278,6 +295,10 @@ 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',
@@ -287,8 +308,7 @@ export async function appendToKlypix(buffer, addition) {
287
308
  fontWeight: a.card.heading ? 'bold' : 'normal', fontStyle: 'normal',
288
309
  textDecoration: 'none', textAlign: 'left', verticalAlign: 'top',
289
310
  }));
290
- // 'z' prefix sorts new cards above existing ones (which use 'a…').
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 };
311
+ canvas.positions[a.id] = { x: a.x, y: a.y, w: a.w, h: a.h, zKey: nextZKey(), zIndex: a.z, parentId: null };
292
312
  canvas.order.push(a.id);
293
313
  }
294
314
 
@@ -319,6 +339,490 @@ export async function appendToKlypix(buffer, addition) {
319
339
  return out;
320
340
  }
321
341
 
342
+ // ── Area-grouped layout (project brain) ──────────────────────────────────────
343
+ // Captured decisions carry an [Area]; these route cards INTO titled area
344
+ // containers (find-or-create) so the brain stays a clean areas-as-containers map
345
+ // instead of a rightward strip. Non-destructive, valid z-keys, atomic round-trip.
346
+ const BRAIN_GEOM = { TITLE_BAR: 40, PAD: 14, CARD_GAP: 10, CARD_W: 300, FONT: 12, LINE_H: 17, START: 80, COL_GAP: 44 };
347
+ BRAIN_GEOM.AREA_W = BRAIN_GEOM.CARD_W + BRAIN_GEOM.PAD * 2;
348
+
349
+ // Chars that fit on one rendered line. The bordered card has 10px L/R padding,
350
+ // so the text area is CARD_W-20; use a conservative char width (font*0.62) so a
351
+ // wrapped line never RE-wraps in-app (which would double a card's height).
352
+ function brainCPL() { return Math.max(8, Math.floor((BRAIN_GEOM.CARD_W - 24) / (BRAIN_GEOM.FONT * 0.62))); }
353
+
354
+ // Hard-wrap text to ~CARD_W by inserting newlines at word boundaries. KLYPIX text
355
+ // cards show a long SINGLE line as-typed (no auto-wrap until you resize), so a
356
+ // captured decision with no newlines runs off the box. Baking in line breaks
357
+ // makes brain cards render as tidy multi-line blocks regardless of that.
358
+ function wrapText(text, cpl = brainCPL()) {
359
+ const out = [];
360
+ for (const para of String(text ?? '').split('\n')) {
361
+ if (para.length <= cpl) { out.push(para); continue; }
362
+ let line = '';
363
+ for (const tok of para.split(/(\s+)/)) {
364
+ if (line && (line + tok).trimEnd().length > cpl) { out.push(line.trimEnd()); line = tok.replace(/^\s+/, ''); }
365
+ else line += tok;
366
+ while (line.length > cpl) { out.push(line.slice(0, cpl)); line = line.slice(cpl); } // break an over-long word
367
+ }
368
+ if (line.trim()) out.push(line.trimEnd());
369
+ }
370
+ return out.join('\n');
371
+ }
372
+
373
+ function measureCardH(text) {
374
+ // text is hard-wrapped to ≤CPL, so the \n-line count is the rendered line
375
+ // count. Match the bordered card (lineHeight 1.35*font + 8/8 vertical padding
376
+ // + border) and over-estimate slightly → small gaps, never overlap.
377
+ const lines = Math.max(1, String(text ?? '').split('\n').length);
378
+ return Math.max(40, Math.ceil(lines * BRAIN_GEOM.FONT * 1.45) + 26);
379
+ }
380
+ // Container the next NEW area goes to the right of the rightmost item.
381
+ function nextContainerX(canvas) {
382
+ const all = Object.values(canvas.positions);
383
+ return all.length ? Math.max(...all.map(p => (p.x || 0) + (p.w || BRAIN_GEOM.AREA_W))) + BRAIN_GEOM.COL_GAP : BRAIN_GEOM.START;
384
+ }
385
+ // Bottom y of a container's current children (where the next card stacks).
386
+ function containerChildBottom(canvas, ctnId) {
387
+ const ctn = canvas.positions[ctnId];
388
+ let cy = ctn.y + BRAIN_GEOM.TITLE_BAR + BRAIN_GEOM.PAD;
389
+ for (const id of canvas.order) {
390
+ const p = canvas.positions[id];
391
+ if (p && p.parentId === ctnId) cy = Math.max(cy, p.y + (p.h || 40) + BRAIN_GEOM.CARD_GAP);
392
+ }
393
+ return cy;
394
+ }
395
+ // Best-effort area name for a card: "Area: …" first-line prefix → first #tag → 'Notes'.
396
+ function areaOfCard(card) {
397
+ const line1 = String(card.text || '').split('\n')[0].trim();
398
+ const m = line1.match(/^([^:\n]{1,40}):\s+\S/);
399
+ if (m) return m[1].trim();
400
+ if (Array.isArray(card.tags) && card.tags[0]) return String(card.tags[0]);
401
+ return 'Notes';
402
+ }
403
+ async function finalizeBrainZip(zip, canvas, manifest, now) {
404
+ if (manifest) {
405
+ manifest.updatedAt = new Date(now).toISOString();
406
+ manifest.stats = manifest.stats || {};
407
+ manifest.stats.itemCount = canvas.order.length;
408
+ zip.file('manifest.json', JSON.stringify(manifest));
409
+ }
410
+ zip.file('canvas.json', JSON.stringify(canvas));
411
+ const out = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
412
+ try { await parseKlypix(out); }
413
+ catch (e) { throw new Error('brain write produced an unparseable .klypix — aborting to protect the brain: ' + (e?.message || e)); }
414
+ return out;
415
+ }
416
+
417
+ // Append cards routed INTO their [Area] container (find-or-create), so captures
418
+ // self-organize. addition.cards = [{ text, color?, area? }]. Non-destructive to
419
+ // existing items. Falls back to the flat appender for legacy/no-positions files.
420
+ export async function appendIntoContainers(buffer, addition) {
421
+ const { zip, canvas, manifest, isV4, struct } = await parseKlypix(buffer);
422
+ if (!isV4 || !canvas.positions) return appendToKlypix(buffer, addition);
423
+ const newCards = (addition?.cards || []).filter(c => c && typeof c.text === 'string' && c.text.trim());
424
+ if (newCards.length === 0) throw new Error('nothing to add — provide cards[] with text');
425
+
426
+ const now = Date.now();
427
+ const rand = () => Math.random().toString(36).slice(2, 10);
428
+ const G = BRAIN_GEOM;
429
+ canvas.order = Array.isArray(canvas.order) ? canvas.order : [];
430
+ const existingTop = Object.values(canvas.positions).map(p => p && p.zKey).filter(k => k && isValidZKey(k)).sort().pop() || null;
431
+ const nextZKey = makeZKeyGen(existingTop);
432
+
433
+ const byTitle = new Map();
434
+ 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); }
435
+ let ctnX = nextContainerX(canvas);
436
+ const ensureContainer = (area) => {
437
+ const key = area.toLowerCase();
438
+ let id = byTitle.get(key);
439
+ if (id) return id;
440
+ id = `ctn_${rand()}`;
441
+ zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify({
442
+ type: 'container', locked: false, createdAt: now, createdBy: 'agent',
443
+ title: area, collapsed: false, scopeLocked: false, borderColor: '#10b981',
444
+ }));
445
+ 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 };
446
+ canvas.order.push(id);
447
+ byTitle.set(key, id);
448
+ ctnX += G.AREA_W + G.COL_GAP;
449
+ return id;
450
+ };
451
+
452
+ for (const card of newCards) {
453
+ const area = (card.area || areaOfCard(card)).toString().trim() || 'Notes';
454
+ const ctnId = ensureContainer(area);
455
+ const ctn = canvas.positions[ctnId];
456
+ const wrapped = wrapText(String(card.text));
457
+ const h = measureCardH(wrapped);
458
+ const cy = containerChildBottom(canvas, ctnId);
459
+ const id = `txt_${rand()}`;
460
+ zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify({
461
+ type: 'text', locked: false, createdAt: now, createdBy: 'agent',
462
+ // Provenance: WHICH agent remembered this (claude-code / cursor /
463
+ // cline / …) — additive field, ignored by older readers.
464
+ ...(card.createdVia ? { createdVia: String(card.createdVia) } : {}),
465
+ content: wrapped, fontSize: G.FONT,
466
+ color: card.color || '#e8e8ed', border: true, borderColor: card.borderColor || card.color || 'rgba(16,185,129,0.45)',
467
+ fillColor: 'rgba(18,18,26,0.85)', heading: !!card.heading, fontFamily: 'Thmanyah Sans',
468
+ fontWeight: card.heading ? 'bold' : 'normal', fontStyle: 'normal',
469
+ textDecoration: 'none', textAlign: 'left', verticalAlign: 'top',
470
+ }));
471
+ canvas.positions[id] = { x: ctn.x + G.PAD, y: cy, w: G.CARD_W, h, zKey: nextZKey(), zIndex: canvas.order.length, parentId: ctnId };
472
+ canvas.order.push(id);
473
+ ctn.h = (cy + h + G.PAD) - ctn.y;
474
+ }
475
+ return finalizeBrainZip(zip, canvas, manifest, now);
476
+ }
477
+
478
+ // Tidy an EXISTING brain: re-parent every root-level text card into its [Area]
479
+ // container (find-or-create), grouping the messy strip into clean areas. Moves
480
+ // cards (keeps their ids → connections preserved); never drops a card. Caller
481
+ // should back up first; this round-trip-verifies before returning.
482
+ export async function tidyBrain(buffer) {
483
+ const { zip, canvas, manifest, isV4, struct } = await parseKlypix(buffer);
484
+ if (!isV4 || !canvas.positions) throw new Error('tidy supports v4 .klypix only');
485
+ const now = Date.now();
486
+ const rand = () => Math.random().toString(36).slice(2, 10);
487
+ const G = BRAIN_GEOM;
488
+ canvas.order = Array.isArray(canvas.order) ? canvas.order : [];
489
+ const top = Object.values(canvas.positions).map(p => p && p.zKey).filter(k => k && isValidZKey(k)).sort().pop() || null;
490
+ const nextZKey = makeZKeyGen(top);
491
+
492
+ // Normalize every text card to the compact brain font (so the render matches
493
+ // our height measure → no overlap) + cache each card's measured height.
494
+ const meta = new Map(); // id -> { h }
495
+ for (const c of struct.cards) {
496
+ if (c.type === 'container') continue;
497
+ const wrapped = wrapText(String(c.text ?? ''));
498
+ let createdAt = 0;
499
+ const ip = `items/${shard(c.id)}/${c.id}.json`;
500
+ 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 */ }
501
+ meta.set(c.id, { h: measureCardH(wrapped), createdAt });
502
+ }
503
+
504
+ const containerIds = new Set(struct.cards.filter(c => c.type === 'container').map(c => c.id));
505
+ const byTitle = new Map();
506
+ 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); }
507
+
508
+ // Group ROOT text cards by [Area].
509
+ const rootText = struct.cards.filter(c => c.type !== 'container' && canvas.positions[c.id] && canvas.positions[c.id].parentId == null);
510
+ const groups = new Map(); // key -> { title, ids: [] }
511
+ 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); }
512
+
513
+ let moved = 0;
514
+ 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++; } };
515
+ // Ensure a container exists for each area (create if missing); route root cards in.
516
+ for (const grp of groups.values()) {
517
+ const key = grp.title.toLowerCase();
518
+ let ctnId = byTitle.get(key);
519
+ if (!ctnId) {
520
+ ctnId = `ctn_${rand()}`;
521
+ 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' }));
522
+ canvas.order.push(ctnId);
523
+ 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 };
524
+ byTitle.set(key, ctnId); containerIds.add(ctnId);
525
+ }
526
+ assignTo(ctnId, grp.ids);
527
+ }
528
+
529
+ // UNIFIED LAYOUT — re-flow each container's children (chronological, compact
530
+ // heights) AND shelf-pack ALL containers into one grid by their TRUE height, so
531
+ // a container that grew (new captures) never overlaps its neighbor below.
532
+ const childrenOf = (cid) => canvas.order
533
+ .filter(id => canvas.positions[id] && canvas.positions[id].parentId === cid)
534
+ .sort((a, b) => (meta.get(a)?.createdAt || 0) - (meta.get(b)?.createdAt || 0));
535
+ 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); };
536
+ const orderedCtns = canvas.order.filter(id => containerIds.has(id) && canvas.positions[id]);
537
+ const cols = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(Math.max(1, orderedCtns.length)))));
538
+ let colIdx = 0, rowTopY = G.START, rowMaxH = 0, colX = G.START;
539
+ for (const cid of orderedCtns) {
540
+ const h = heightOf(cid);
541
+ if (colIdx >= cols) { rowTopY += rowMaxH + G.COL_GAP; rowMaxH = 0; colIdx = 0; colX = G.START; }
542
+ canvas.positions[cid] = { ...canvas.positions[cid], x: colX, y: rowTopY, w: G.AREA_W, h };
543
+ let cy = rowTopY + G.TITLE_BAR + G.PAD;
544
+ 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; }
545
+ colX += G.AREA_W + G.COL_GAP; colIdx++; rowMaxH = Math.max(rowMaxH, h);
546
+ }
547
+
548
+ const out = await finalizeBrainZip(zip, canvas, manifest, now);
549
+ return { buffer: out, moved, containers: byTitle.size };
550
+ }
551
+
552
+ // ── Tiered brain brief ───────────────────────────────────────────────────────
553
+ // A compact, token-bounded session brief: area map + open questions + recent
554
+ // decisions + milestones. Everything older stays in the file, reachable via the
555
+ // klypix-canvas MCP search or `--full`. Keeps the session-start cost flat as
556
+ // the brain grows (the full markdown scales with history; this doesn't).
557
+ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMilestones = 8, maxConnections = 30 } = {}) {
558
+ const cutoff = Date.now() - recentDays * 86_400_000;
559
+ const texts = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
560
+ const containers = struct.cards.filter(c => c.type === 'container');
561
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
562
+ // 📌 FOCUS — the human steers agent attention SPATIALLY: any card dragged
563
+ // into a container titled "Focus" (any decoration: "📌 Focus", "Focus
564
+ // (drag cards here)") leads every brief, full text, regardless of age.
565
+ const isFocus = (c) => /(^|\s)focus\b/i.test(c.area || '');
566
+ const live = texts.filter(c => !isArchived(c));
567
+ const focus = live.filter(isFocus);
568
+ const rest = live.filter(c => !isFocus(c));
569
+ const open = rest.filter(c => /❓/.test(c.text));
570
+ const miles = rest.filter(c => /🏁/.test(c.text) && !/❓/.test(c.text));
571
+ const plain = rest.filter(c => !open.includes(c) && !miles.includes(c));
572
+ const recent = plain.filter(c => c.createdAt >= cutoff).sort((a, b) => b.createdAt - a.createdAt).slice(0, maxRecent);
573
+ const archivedCount = texts.length - live.length;
574
+
575
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
576
+ // HEADLINE = first sentence-ish, hard-capped — the brief is a scannable
577
+ // changelog; the agent pulls any card's full text via the MCP when needed.
578
+ const headline = (c, max = 160) => {
579
+ const t = flat(c.text);
580
+ const stop = t.search(/(?<=[.!?])\s/);
581
+ const h = stop > 40 && stop < max ? t.slice(0, stop) : t;
582
+ return h.length > max ? h.slice(0, max - 1).trimEnd() + '…' : h;
583
+ };
584
+ const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
585
+
586
+ // TOKEN BUDGET (≈ chars/4): sections are added in priority order — Focus,
587
+ // Open, Areas, Milestones, Recent, Connections — and Recent stops when the
588
+ // budget is hit. The brief stays ~flat forever no matter how active a week.
589
+ const BUDGET_CHARS = 11_000; // ≈ 2.7k tokens
590
+ let used = 0;
591
+ const out = [];
592
+ const push = (...lines) => { for (const l of lines) { out.push(l); used += l.length + 1; } };
593
+
594
+ push(`# ${struct.title} — brain brief`);
595
+ 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*`);
596
+ if (focus.length) {
597
+ push('', '## 📌 Human focus (cards the human placed here — act on these first)');
598
+ for (const c of focus) push(`- ${flat(c.text)}`);
599
+ }
600
+ if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${flat(c.text)}`); }
601
+ const areaCounts = containers
602
+ .filter(c => !/^archive$/i.test(c.title || ''))
603
+ .map(c => `${flat(c.title)} (${texts.filter(t => t.parentId === c.id).length})`);
604
+ if (areaCounts.length) { push('', '## Areas', areaCounts.join(' · ')); }
605
+ if (miles.length) {
606
+ push('', '## Milestones');
607
+ for (const c of miles.sort((a, b) => b.createdAt - a.createdAt).slice(0, maxMilestones)) push(`- ${headline(c)}`);
608
+ }
609
+ let shownRecent = 0;
610
+ if (recent.length) {
611
+ push('', `## Recent decisions (last ${recentDays}d — headlines)`);
612
+ const byArea = new Map();
613
+ for (const c of recent) { const a = flat(c.area) || 'Notes'; if (!byArea.has(a)) byArea.set(a, []); byArea.get(a).push(c); }
614
+ outer: for (const [a, cs] of byArea) {
615
+ push(`### ${a}`);
616
+ for (const c of cs) {
617
+ if (used > BUDGET_CHARS) break outer;
618
+ push(`- ${day(c.createdAt)} ${headline(c)}`);
619
+ shownRecent++;
620
+ }
621
+ }
622
+ }
623
+ if (struct.connections.length && used <= BUDGET_CHARS) {
624
+ push('', '## Connections');
625
+ for (const cn of struct.connections.slice(0, maxConnections)) push(`- ${cn.from} → ${cn.to}${cn.label || cn.relationship ? ` (${cn.label || cn.relationship})` : ''}`);
626
+ }
627
+ const hidden = [];
628
+ const unshown = plain.length - shownRecent;
629
+ if (unshown > 0) hidden.push(`${unshown} older/over-budget decision${unshown === 1 ? '' : 's'}`);
630
+ if (archivedCount > 0) hidden.push(`${archivedCount} archived/superseded`);
631
+ 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\`.*`);
632
+ return out.join('\n') + '\n';
633
+ }
634
+
635
+ // ── Atomic brain capture: supersede + append + resolve + auto-link ──────────
636
+ // One verified write per capture batch. Beyond appendIntoContainers it adds:
637
+ // • SUPERSEDE — a new decision that heavily overlaps an existing live card in
638
+ // the same area archives the old one (↩︎ prefix, gray, → Archive) and draws
639
+ // an old→new "superseded by" arrow, instead of stacking a contradiction.
640
+ // • RESOLVE — resolutions[] (the ✓ marker) finds the best-matching live card
641
+ // in the area, stamps "✅ <date>: <note>" onto it and archives it; if no
642
+ // match, the note lands as a 🏁 milestone so nothing is lost.
643
+ // • AUTO-LINK — [[Title]] in a new card's text becomes a real connection to
644
+ // the card/container whose title matches (the graph stops being decorative).
645
+ const tokenSet = (s) => new Set(String(s || '').toLowerCase().replace(/\[\[|\]\]/g, ' ').split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 4));
646
+ const overlapScore = (a, b) => {
647
+ if (a.size < 4 || b.size < 4) return 0; // too short to judge
648
+ let hit = 0; for (const w of a) if (b.has(w)) hit++;
649
+ return hit / Math.min(a.size, b.size);
650
+ };
651
+ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], updates = [] } = {}) {
652
+ const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45;
653
+ let work = buffer;
654
+ const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0 };
655
+
656
+ // Pass 1 — resolutions + supersede marking operate on EXISTING cards.
657
+ if (resolutions.length || cards.length || updates.length) {
658
+ const { zip, canvas, manifest, isV4, struct } = await parseKlypix(work);
659
+ if (!isV4 || !canvas.positions) {
660
+ // Legacy file — no surgery possible; degrade to plain append.
661
+ return { buffer: await appendToKlypix(work, { cards }), stats };
662
+ }
663
+ const now = Date.now();
664
+ const today = new Date(now).toISOString().slice(0, 10);
665
+ const rand = () => Math.random().toString(36).slice(2, 10);
666
+ const top = Object.values(canvas.positions).map(p => p && p.zKey).filter(k => k && isValidZKey(k)).sort().pop() || null;
667
+ const nextZKey = makeZKeyGen(top);
668
+ const byTitle = new Map();
669
+ 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); }
670
+ const ensureArchive = () => {
671
+ let id = byTitle.get('archive');
672
+ if (id) return id;
673
+ id = `ctn_${rand()}`;
674
+ const G = BRAIN_GEOM;
675
+ 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)' }));
676
+ 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 };
677
+ canvas.order.push(id);
678
+ byTitle.set('archive', id);
679
+ return id;
680
+ };
681
+ const liveTextCards = () => struct.cards.filter(c =>
682
+ c.type !== 'container' && (c.text || '').trim()
683
+ && !/^archive$/i.test(c.area || '')
684
+ && !/↩|✅/.test(c.text));
685
+ const rewriteCard = async (id, mutate) => {
686
+ const ip = `items/${shard(id)}/${id}.json`;
687
+ const f = zip.file(ip); if (!f) return false;
688
+ const j = JSON.parse(await f.async('string'));
689
+ mutate(j);
690
+ j.content = wrapText(String(j.content || ''));
691
+ zip.file(ip, JSON.stringify(j));
692
+ const pos = canvas.positions[id];
693
+ if (pos) canvas.positions[id] = { ...pos, h: measureCardH(j.content) };
694
+ return true;
695
+ };
696
+ const archiveCard = (id) => {
697
+ const arc = ensureArchive();
698
+ const pos = canvas.positions[id];
699
+ if (pos) canvas.positions[id] = { ...pos, parentId: arc };
700
+ };
701
+ canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
702
+
703
+ // RESOLVE (✓ markers) — best live match in the area; ❓ cards preferred.
704
+ const milestonesFallback = [];
705
+ for (const r of resolutions) {
706
+ const rTok = tokenSet(r.text);
707
+ let best = null, bestScore = 0;
708
+ for (const c of liveTextCards()) {
709
+ if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
710
+ const s = overlapScore(rTok, tokenSet(c.text)) + (/❓/.test(c.text) ? 0.15 : 0);
711
+ if (s > bestScore) { bestScore = s; best = c; }
712
+ }
713
+ if (best && bestScore >= RESOLVE_AT) {
714
+ await rewriteCard(best.id, j => {
715
+ j.content = `${j.content}\n✅ ${today}: ${r.text}`;
716
+ j.borderColor = 'rgba(16,185,129,0.35)';
717
+ });
718
+ archiveCard(best.id);
719
+ best.text += ` ✅ ${r.text}`; // keep in-memory struct honest for later matching
720
+ stats.resolved++;
721
+ } else {
722
+ milestonesFallback.push({ text: (r.area ? `${r.area}: ` : '') + `🏁 ${r.text}`, area: r.area, borderColor: 'rgba(59,130,246,0.8)' });
723
+ }
724
+ }
725
+
726
+ // UPDATE (~ markers) — rewrite a matching card IN PLACE: for small
727
+ // corrections that don't deserve supersession history. Content is
728
+ // replaced (area prefix + tag preserved), createdAt bumped so the
729
+ // brief treats it as fresh. No match → falls through as a new card.
730
+ for (const u of updates) {
731
+ const uTok = tokenSet(u.text);
732
+ let best = null, bestScore = 0;
733
+ for (const c of liveTextCards()) {
734
+ if (u.area && (c.area || '').toLowerCase() !== u.area.toLowerCase()) continue;
735
+ const s = overlapScore(uTok, tokenSet(c.text));
736
+ if (s > bestScore) { bestScore = s; best = c; }
737
+ }
738
+ if (best && bestScore >= UPDATE_AT) {
739
+ const tag = u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : '';
740
+ await rewriteCard(best.id, j => {
741
+ j.content = (u.area ? `${u.area}: ` : '') + u.text + tag;
742
+ j.createdAt = now;
743
+ j.borderColor = 'rgba(16,185,129,0.6)';
744
+ if (u.createdVia) j.createdVia = String(u.createdVia);
745
+ });
746
+ best.text = u.text;
747
+ stats.updated++;
748
+ } else {
749
+ 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 });
750
+ }
751
+ }
752
+
753
+ // SUPERSEDE — pre-mark old cards that a NEW decision replaces. The arrow
754
+ // to the new card is drawn in pass 2 (after the new ids exist), matched
755
+ // back by remembering which old card each new card displaced.
756
+ for (const card of cards) {
757
+ if (/❓|🏁/.test(card.text)) continue; // only plain decisions supersede
758
+ const nTok = tokenSet(card.text);
759
+ const area = (card.area || '').toLowerCase();
760
+ let best = null, bestScore = 0;
761
+ for (const c of liveTextCards()) {
762
+ if (area && (c.area || '').toLowerCase() !== area) continue;
763
+ const s = overlapScore(nTok, tokenSet(c.text));
764
+ if (s > bestScore) { bestScore = s; best = c; }
765
+ }
766
+ if (best && bestScore >= SUPERSEDE_AT) {
767
+ await rewriteCard(best.id, j => {
768
+ j.content = `↩︎ superseded ${today}\n${j.content}`;
769
+ j.borderColor = 'rgba(120,120,135,0.5)';
770
+ });
771
+ archiveCard(best.id);
772
+ best.text = `↩︎ ${best.text}`;
773
+ card.__supersedes = best.id;
774
+ stats.superseded++;
775
+ }
776
+ }
777
+
778
+ cards.push(...milestonesFallback);
779
+ work = await finalizeBrainZip(zip, canvas, manifest, now);
780
+ }
781
+
782
+ // Pass 2 — append the new cards (existing self-organizing path), then wire
783
+ // connections: supersede arrows + [[wikilink]] auto-links.
784
+ if (cards.length) {
785
+ work = await appendIntoContainers(work, { cards });
786
+ stats.added = cards.length;
787
+ const { zip, canvas, manifest, struct } = await parseKlypix(work);
788
+ const now = Date.now();
789
+ const rand = () => Math.random().toString(36).slice(2, 10);
790
+ canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
791
+ const hasConn = (a, b) => canvas.connections.some(cn => (cn.fromId === a && cn.toId === b) || (cn.fromId === b && cn.toId === a));
792
+ const addConn = (fromId, toId, label, relationship) => {
793
+ if (!fromId || !toId || fromId === toId || hasConn(fromId, toId)) return;
794
+ canvas.connections.push({ id: `con_${rand()}`, fromId, toId, relationship, label, arrowHead: true, width: 2, color: '#10b981', style: 'solid' });
795
+ stats.linked++;
796
+ };
797
+ // Locate each appended card by exact text match (newest first wins).
798
+ const findNew = (text) => {
799
+ const flatT = wrapText(String(text));
800
+ for (let i = struct.cards.length - 1; i >= 0; i--) {
801
+ const c = struct.cards[i];
802
+ if (c.type !== 'container' && (c.text || '') === flatT) return c;
803
+ }
804
+ return null;
805
+ };
806
+ const titleIndex = struct.cards
807
+ .filter(c => (c.title || '').trim())
808
+ .map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
809
+ for (const card of cards) {
810
+ const created = findNew(card.text);
811
+ if (!created) continue;
812
+ if (card.__supersedes) addConn(card.__supersedes, created.id, 'superseded by', undefined);
813
+ for (const link of (created.links || [])) {
814
+ const want = String(link).trim().toLowerCase();
815
+ if (!want) continue;
816
+ const target = titleIndex.find(e => e.id !== created.id && (e.t === want || e.t.startsWith(want)));
817
+ if (target) addConn(created.id, target.id, undefined, 'relates_to');
818
+ }
819
+ }
820
+ work = await finalizeBrainZip(zip, canvas, manifest, now);
821
+ }
822
+
823
+ return { buffer: work, stats };
824
+ }
825
+
322
826
  /**
323
827
  * Build a RICH "map" .klypix: areas become titled containers, their cards
324
828
  * stack inside, connections draw across. Produces a real spatial board (used by
@@ -345,6 +849,7 @@ export async function buildKlypixMap(spec) {
345
849
  const titleToId = new Map(); // card title -> id (for connections)
346
850
  const firstLine = (t) => String(t ?? '').split('\n').map(s => s.trim()).find(Boolean) || '';
347
851
  let z = 0;
852
+ const nextZKey = makeZKeyGen();
348
853
 
349
854
  // Shelf-pack areas into rows of `cols`; each row's height = tallest area.
350
855
  let rowTopY = START, rowMaxH = 0, colX = START, colIdx = 0;
@@ -370,7 +875,7 @@ export async function buildKlypixMap(spec) {
370
875
  title: area.title || `Area ${ai + 1}`, collapsed: false, scopeLocked: false,
371
876
  borderColor: area.color || '#10b981',
372
877
  };
373
- positions[ctnId] = { x: ax, y: ay, w: AREA_W, h: areaH, zKey: 'a' + String(z).padStart(4, '0'), zIndex: z, parentId: null };
878
+ positions[ctnId] = { x: ax, y: ay, w: AREA_W, h: areaH, zKey: nextZKey(), zIndex: z, parentId: null };
374
879
  order.push(ctnId); z++;
375
880
 
376
881
  let cy = ay + TITLE_BAR + PAD;
@@ -388,7 +893,7 @@ export async function buildKlypixMap(spec) {
388
893
  textDecoration: 'none', textAlign: 'left', verticalAlign: 'top',
389
894
  fontFamily: 'Thmanyah Sans',
390
895
  };
391
- positions[id] = { x: ax + PAD, y: cy, w: CARD_W, h, zKey: 'a' + String(z).padStart(4, '0'), zIndex: z, parentId: ctnId };
896
+ positions[id] = { x: ax + PAD, y: cy, w: CARD_W, h, zKey: nextZKey(), zIndex: z, parentId: ctnId };
392
897
  order.push(id); z++;
393
898
  const t = firstLine(c.text).toLowerCase();
394
899
  if (t && !titleToId.has(t)) titleToId.set(t, id);