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 +1 -0
- package/bin/klypix-mcp.mjs +124 -10
- package/package.json +4 -3
- package/src/klypix-format.mjs +510 -5
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
|
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/src/klypix-format.mjs
CHANGED
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
import JSZip from 'jszip';
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import fs from 'fs';
|
|
12
|
+
import { generateKeyBetween } from 'fractional-indexing';
|
|
13
|
+
|
|
14
|
+
// Valid fractional-indexing z-keys. Hand-rolled keys (e.g. 'a0000' / 'z00013')
|
|
15
|
+
// are REJECTED by the fractional-indexing lib the KLYPIX app uses and crash it
|
|
16
|
+
// the moment you edit such a canvas — so the writer MUST emit lib-valid keys.
|
|
17
|
+
// makeZKeyGen() returns an increasing-key generator starting just above `after`
|
|
18
|
+
// (or from the bottom if `after` is null/invalid).
|
|
19
|
+
const isValidZKey = (k) => { try { generateKeyBetween(k, null); return true; } catch { return false; } };
|
|
20
|
+
function makeZKeyGen(after = null) {
|
|
21
|
+
let last = (after && isValidZKey(after)) ? after : null;
|
|
22
|
+
return () => (last = generateKeyBetween(last, null));
|
|
23
|
+
}
|
|
12
24
|
|
|
13
25
|
export const WIKILINK = /\[\[([^[\]]+)\]\]/g;
|
|
14
26
|
export const TAG = /(^|\s)(#[a-zA-Z][\w-]*)/g;
|
|
@@ -97,6 +109,10 @@ export async function parseKlypix(buffer) {
|
|
|
97
109
|
links: it.type === 'text' ? extractLinks(it.content) : [],
|
|
98
110
|
tags: it.type === 'text' ? extractTags(it.content) : [],
|
|
99
111
|
pos: { x: it.x, y: it.y },
|
|
112
|
+
createdAt: Number(it.createdAt) || 0,
|
|
113
|
+
parentId: it.parentId ?? null,
|
|
114
|
+
// Parent container's title — the card's "area" in brain terms.
|
|
115
|
+
area: it.parentId ? (cardTitle(items[it.parentId]) || null) : null,
|
|
100
116
|
})),
|
|
101
117
|
connections: connections.map(c => ({
|
|
102
118
|
from: titleOf(c.fromId), to: titleOf(c.toId),
|
|
@@ -181,6 +197,7 @@ export async function buildKlypix(spec) {
|
|
|
181
197
|
const COL_W = 380, GAP_Y = 70, START = 80;
|
|
182
198
|
const positions = {};
|
|
183
199
|
let zi = 0;
|
|
200
|
+
const nextZKey = makeZKeyGen();
|
|
184
201
|
order.forEach((id, idx) => {
|
|
185
202
|
const card = cards[idByIndex.indexOf(id)];
|
|
186
203
|
const { w, h } = sizeFor(card);
|
|
@@ -188,7 +205,7 @@ export async function buildKlypix(spec) {
|
|
|
188
205
|
positions[id] = {
|
|
189
206
|
x: card.x ?? (START + col * COL_W),
|
|
190
207
|
y: card.y ?? (START + row * (180 + GAP_Y) + (col % 2) * 12),
|
|
191
|
-
w, h, zKey:
|
|
208
|
+
w, h, zKey: nextZKey(), zIndex: zi++, parentId: null,
|
|
192
209
|
};
|
|
193
210
|
});
|
|
194
211
|
|
|
@@ -278,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
|
-
|
|
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:
|
|
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:
|
|
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);
|