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.
@@ -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: 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.') },
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
- return { content: [{ type: 'text', text: structToMarkdown(struct) }] };
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.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'));
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
- return { content: [{ type: 'text', text: `Created ${out} — ${cards.length} cards, ${(connections || []).length} connections. Open it in KLYPIX (Canvas → Open).` }] };
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 buf = await appendToKlypix(fs.readFileSync(file), { cards, connections });
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
- return { content: [{ type: 'text', text: `Added ${cards.length} card(s) to ${path.relative(VAULT, file)}. Reopen the canvas in KLYPIX to see them.` }] };
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.0.0",
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
  }
@@ -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,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
- // '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 };
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: 'a' + String(z).padStart(4, '0'), zIndex: z, parentId: null };
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: 'a' + String(z).padStart(4, '0'), zIndex: z, parentId: ctnId };
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);