klypix-mcp 1.0.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/bin/klypix-mcp.mjs +235 -22
- package/package.json +5 -2
- package/src/klypix-format.mjs +236 -4
package/README.md
CHANGED
|
@@ -47,7 +47,6 @@ 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 |
|
|
51
50
|
|
|
52
51
|
## Use it as a library
|
|
53
52
|
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -18,14 +18,39 @@
|
|
|
18
18
|
import fs from 'fs';
|
|
19
19
|
import os from 'os';
|
|
20
20
|
import path from 'path';
|
|
21
|
+
import crypto from 'crypto';
|
|
21
22
|
import { z } from 'zod';
|
|
22
23
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
23
24
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
24
|
-
import { parseKlypix, buildKlypix, appendToKlypix, structToMarkdown, atomicWrite } from '../src/klypix-format.mjs';
|
|
25
|
+
import { parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown, brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, 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]
|
|
@@ -152,6 +177,15 @@ server.registerTool('search_canvases', {
|
|
|
152
177
|
}, async ({ query }) => {
|
|
153
178
|
const q = String(query || '').trim().toLowerCase();
|
|
154
179
|
if (!q) return { content: [{ type: 'text', text: 'Provide a non-empty query.' }], isError: true };
|
|
180
|
+
// Tokenize so a multi-word query ("window snap") matches a card holding ANY
|
|
181
|
+
// term — the old verbatim `.includes(fullQuery)` only fired on an exact
|
|
182
|
+
// contiguous substring, so most multi-word searches silently found nothing.
|
|
183
|
+
// NOTE: deliberately NOT reusing the brief's scoreCardsAgainstQuery (its
|
|
184
|
+
// precision sibling, now shared in src/klypix-format.mjs) — that one skips
|
|
185
|
+
// containers + archived cards and applies a minScore floor, all of which
|
|
186
|
+
// would hide results a recall-first finder is expected to surface.
|
|
187
|
+
const terms = q.split(/\s+/).filter(Boolean);
|
|
188
|
+
const hit = (s) => { const v = String(s || '').toLowerCase(); return terms.some(t => v.includes(t)); };
|
|
155
189
|
const hits = [];
|
|
156
190
|
for (const f of walkVault()) {
|
|
157
191
|
let struct;
|
|
@@ -159,11 +193,11 @@ server.registerTool('search_canvases', {
|
|
|
159
193
|
const rel = path.relative(VAULT, f);
|
|
160
194
|
// Match the canvas TITLE + FILENAME too — not just card text — so
|
|
161
195
|
// searching a canvas by its name (e.g. "SS2") actually finds it.
|
|
162
|
-
const nameMatch = (struct.title ||
|
|
196
|
+
const nameMatch = hit(struct.title) || hit(rel);
|
|
163
197
|
const matched = struct.cards.filter(c =>
|
|
164
|
-
(c.title
|
|
165
|
-
|
|
166
|
-
(c.tags || []).some(t => ('#' + t)
|
|
198
|
+
hit(c.title) ||
|
|
199
|
+
hit(c.text) ||
|
|
200
|
+
(c.tags || []).some(t => hit('#' + t)));
|
|
167
201
|
if (nameMatch || matched.length) {
|
|
168
202
|
// Rich hits: type + id + position + tags + a longer snippet, so the
|
|
169
203
|
// agent can FIND a card (and tell duplicates apart) before it WRITES.
|
|
@@ -179,46 +213,135 @@ server.registerTool('search_canvases', {
|
|
|
179
213
|
return { content: [{ type: 'text', text: hits.length ? `# Matches for "${query}"\n\n${hits.join('\n\n')}` : `No matches for "${query}" in ${VAULT}.` }] };
|
|
180
214
|
});
|
|
181
215
|
|
|
216
|
+
// ── On-device semantic memory ────────────────────────────────────────────────
|
|
217
|
+
// Embeddings run INSIDE this long-lived server (the hook stays instant), 100%
|
|
218
|
+
// local: transformers.js (WASM) + a 23MB MiniLM model cached under
|
|
219
|
+
// ~/.claude/project-brain/hf-cache on first use. Per-brain vectors are cached
|
|
220
|
+
// incrementally (content-hashed per card) in ~/.claude/project-brain/embeddings/
|
|
221
|
+
// — brains themselves are never mutated by search. Everything degrades to
|
|
222
|
+
// lexical scoring gracefully: no lib, no model, no network → search still works.
|
|
223
|
+
const PB_DIR = path.join(os.homedir(), '.claude', 'project-brain');
|
|
224
|
+
const EMB_DIR = path.join(PB_DIR, 'embeddings');
|
|
225
|
+
const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
226
|
+
let embedderPromise = null;
|
|
227
|
+
function getEmbedder() {
|
|
228
|
+
if (!embedderPromise) {
|
|
229
|
+
embedderPromise = (async () => {
|
|
230
|
+
// Dual-path: (1) bare specifier — npx/npm installs ship the lib;
|
|
231
|
+
// (2) ~/.claude/project-brain/semantic — where KLYPIX's one-click
|
|
232
|
+
// "semantic memory" install places it for the bundled server
|
|
233
|
+
// (the ONNX runtimes are ~350MB unpacked, far too heavy to bundle
|
|
234
|
+
// in the installer payload).
|
|
235
|
+
let t;
|
|
236
|
+
try { t = await import('@huggingface/transformers'); }
|
|
237
|
+
catch {
|
|
238
|
+
const local = path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers', 'dist', 'transformers.mjs');
|
|
239
|
+
t = await import(new URL('file:///' + local.replace(/\\/g, '/')).href);
|
|
240
|
+
}
|
|
241
|
+
t.env.cacheDir = path.join(PB_DIR, 'hf-cache');
|
|
242
|
+
return await t.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
|
|
243
|
+
})().catch(e => { log('semantic unavailable (lexical fallback):', e?.message || e); return null; });
|
|
244
|
+
}
|
|
245
|
+
return embedderPromise;
|
|
246
|
+
}
|
|
247
|
+
async function embedTexts(pipe, texts) {
|
|
248
|
+
const out = await pipe(texts, { pooling: 'mean', normalize: true });
|
|
249
|
+
const [n, d] = out.dims;
|
|
250
|
+
const vecs = [];
|
|
251
|
+
for (let i = 0; i < n; i++) vecs.push(Array.from(out.data.slice(i * d, (i + 1) * d)));
|
|
252
|
+
return vecs;
|
|
253
|
+
}
|
|
254
|
+
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; };
|
|
255
|
+
// Incremental per-brain vector cache: only new/changed cards get embedded.
|
|
256
|
+
async function vectorsForBrain(pipe, brainPath, cards) {
|
|
257
|
+
const file = path.join(EMB_DIR, sha1(brainPath.replace(/\\/g, '/')) + '.json');
|
|
258
|
+
let cache = { v: 1, cards: {} };
|
|
259
|
+
try { cache = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* fresh */ }
|
|
260
|
+
const want = cards.filter(c => c.type !== 'container' && (c.text || '').trim());
|
|
261
|
+
const missing = want.filter(c => cache.cards[c.id]?.h !== sha1(String(c.text)));
|
|
262
|
+
if (missing.length) {
|
|
263
|
+
const vecs = await embedTexts(pipe, missing.map(c => String(c.text).slice(0, 1500)));
|
|
264
|
+
missing.forEach((c, i) => { cache.cards[c.id] = { h: sha1(String(c.text)), v: vecs[i] }; });
|
|
265
|
+
const live = new Set(want.map(c => c.id));
|
|
266
|
+
for (const id of Object.keys(cache.cards)) if (!live.has(id)) delete cache.cards[id];
|
|
267
|
+
try { fs.mkdirSync(EMB_DIR, { recursive: true }); fs.writeFileSync(file, JSON.stringify(cache)); } catch { /* cache is best-effort */ }
|
|
268
|
+
}
|
|
269
|
+
const map = new Map();
|
|
270
|
+
for (const c of want) { const e = cache.cards[c.id]; if (e?.v) map.set(c.id, e.v); }
|
|
271
|
+
return map;
|
|
272
|
+
}
|
|
273
|
+
// Death date of an archived card (for as-of queries): the supersede/resolve stamp.
|
|
274
|
+
const deathDateOf = (text) => { const m = /(?:↩︎ superseded|✅) (\d{4}-\d{2}-\d{2})/.exec(String(text)); return m ? Date.parse(m[1]) : null; };
|
|
275
|
+
|
|
182
276
|
// Cross-project memory: search EVERY brain this machine has touched, not just
|
|
183
277
|
// this vault. The SessionStart/Stop hook registers each ./brain.klypix it runs
|
|
184
278
|
// against into ~/.claude/project-brain/registry.json — so simply having worked
|
|
185
279
|
// in a project makes its decisions findable from any other project ("what did
|
|
186
|
-
// I decide about auth — in ANY project?").
|
|
187
|
-
//
|
|
188
|
-
//
|
|
280
|
+
// I decide about auth — in ANY project?"). Hybrid ranking: on-device semantic
|
|
281
|
+
// similarity (when the local model is ready) blended with lexical term hits;
|
|
282
|
+
// as_of answers "what was true on <date>" via createdAt + supersession stamps.
|
|
189
283
|
server.registerTool('search_all_brains', {
|
|
190
284
|
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: {
|
|
193
|
-
|
|
285
|
+
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.',
|
|
286
|
+
inputSchema: {
|
|
287
|
+
query: z.string().describe('What to find across all project brains.'),
|
|
288
|
+
as_of: z.string().optional().describe('Optional YYYY-MM-DD: rank what was TRUE at that date (time-travel query).'),
|
|
289
|
+
},
|
|
290
|
+
}, async ({ query, as_of }) => {
|
|
194
291
|
const q = String(query || '').trim().toLowerCase();
|
|
195
292
|
if (!q) return { content: [{ type: 'text', text: 'Provide a non-empty query.' }], isError: true };
|
|
196
|
-
const reg = path.join(
|
|
293
|
+
const reg = path.join(PB_DIR, 'registry.json');
|
|
197
294
|
let brains = [];
|
|
198
295
|
try { brains = (JSON.parse(fs.readFileSync(reg, 'utf8')).brains || []).filter(b => b && b.path); } catch { /* no registry yet */ }
|
|
199
296
|
if (!brains.length) return { content: [{ type: 'text', text: 'No brains registered yet — the brain hook registers each project as you work in it.' }] };
|
|
200
297
|
const terms = q.split(/[^\p{L}\p{N}#]+/u).filter(t => t.length >= 3);
|
|
201
298
|
if (!terms.length) return { content: [{ type: 'text', text: 'Query too short — use words of 3+ characters.' }], isError: true };
|
|
299
|
+
const asOfTs = as_of ? Date.parse(as_of) : null;
|
|
300
|
+
if (as_of && Number.isNaN(asOfTs)) return { content: [{ type: 'text', text: `Bad as_of date: "${as_of}" — use YYYY-MM-DD.` }], isError: true };
|
|
301
|
+
|
|
302
|
+
// Semantic lane: wait briefly for the embedder; first-ever use downloads
|
|
303
|
+
// the model in the background — searches stay lexical until it's warm.
|
|
304
|
+
const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), 20_000))]);
|
|
305
|
+
let qv = null;
|
|
306
|
+
if (pipe) { try { [qv] = await embedTexts(pipe, [q]); } catch { /* lexical only */ } }
|
|
307
|
+
|
|
202
308
|
const fresh = Date.now() - 30 * 86_400_000;
|
|
203
309
|
const scored = [];
|
|
204
310
|
for (const b of brains) {
|
|
205
311
|
let struct;
|
|
206
312
|
try { ({ struct } = await parseKlypix(fs.readFileSync(b.path))); } catch { continue; }
|
|
313
|
+
let vecs = null;
|
|
314
|
+
if (qv) { try { vecs = await vectorsForBrain(pipe, b.path, struct.cards); } catch { /* lexical for this brain */ } }
|
|
207
315
|
for (const c of struct.cards) {
|
|
208
316
|
if (c.type === 'container') continue;
|
|
209
317
|
const text = String(c.text || '').toLowerCase();
|
|
318
|
+
const isArchived = /^archive$/i.test(c.area || '');
|
|
319
|
+
if (asOfTs != null) {
|
|
320
|
+
if ((c.createdAt || 0) > asOfTs) continue; // didn't exist yet
|
|
321
|
+
const died = isArchived ? deathDateOf(c.text) : null;
|
|
322
|
+
if (died != null && died <= asOfTs) continue; // already superseded then
|
|
323
|
+
}
|
|
324
|
+
let lex = 0;
|
|
210
325
|
const title = String(c.title || '').toLowerCase();
|
|
211
326
|
const tags = (c.tags || []).map(t => ('#' + t).toLowerCase());
|
|
212
|
-
let score = 0;
|
|
213
327
|
for (const t of terms) {
|
|
214
|
-
if (title.includes(t))
|
|
215
|
-
if (tags.some(g => g.includes(t)))
|
|
216
|
-
if (text.includes(t))
|
|
328
|
+
if (title.includes(t)) lex += 3;
|
|
329
|
+
if (tags.some(g => g.includes(t))) lex += 2;
|
|
330
|
+
if (text.includes(t)) lex += 1;
|
|
331
|
+
}
|
|
332
|
+
// Floor calibrated on real cards: related ≈ 0.25, unrelated ≈ 0.0
|
|
333
|
+
// (MiniLM, short decision texts) — 0.18 keeps recall with margin.
|
|
334
|
+
const sem = (qv && vecs?.get(c.id)) ? dot(qv, vecs.get(c.id)) : null;
|
|
335
|
+
if (!lex && (sem == null || sem < 0.18)) continue;
|
|
336
|
+
// Hybrid: semantic dominates when available; lexical is the tie-breaker
|
|
337
|
+
// and the only signal pre-warm-up. Recency/archive nudges skipped for
|
|
338
|
+
// time-travel queries (validity already handled above).
|
|
339
|
+
let score = sem != null ? sem * 10 + Math.min(lex, 6) * 0.5 : lex;
|
|
340
|
+
if (asOfTs == null) {
|
|
341
|
+
if ((c.createdAt || 0) >= fresh) score += 0.5;
|
|
342
|
+
if (isArchived) score -= 1;
|
|
217
343
|
}
|
|
218
|
-
|
|
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 });
|
|
344
|
+
scored.push({ score, sem, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
|
|
222
345
|
}
|
|
223
346
|
}
|
|
224
347
|
if (!scored.length) return { content: [{ type: 'text', text: `No matches for "${query}" across ${brains.length} registered brain(s).` }] };
|
|
@@ -228,7 +351,93 @@ server.registerTool('search_all_brains', {
|
|
|
228
351
|
const when = h.c.createdAt ? new Date(h.c.createdAt).toISOString().slice(0, 10) : '';
|
|
229
352
|
return `- [${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
|
|
230
353
|
});
|
|
231
|
-
|
|
354
|
+
const mode = qv ? 'semantic+lexical (on-device)' : 'lexical (semantic model warming — retry for semantic ranking)';
|
|
355
|
+
const asOfNote = asOfTs != null ? ` · as of ${as_of}` : '';
|
|
356
|
+
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')}` }] };
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
server.registerTool('brain_insights', {
|
|
360
|
+
title: 'What matters in a brain — hubs, orphans, stale questions',
|
|
361
|
+
description: 'Structural read of a brain.klypix: the most-connected "hub" cards (load-bearing decisions), orphaned decisions (no connections — maybe forgotten), stale open questions (aging & unresolved), and area sizes. Use to answer "what matters here / what am I forgetting / what should I review?" — read it at the start of a planning session, or before tidying.',
|
|
362
|
+
inputSchema: {
|
|
363
|
+
canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
|
|
364
|
+
staleDays: z.number().optional().describe('Open questions older than this many days count as stale (default 21).'),
|
|
365
|
+
},
|
|
366
|
+
}, async ({ canvas, staleDays }) => {
|
|
367
|
+
const file = resolveCanvas(canvas || 'brain') || resolveCanvas('brain.klypix');
|
|
368
|
+
if (!file) return { content: [{ type: 'text', text: `No brain canvas found in ${VAULT}. Pass canvas: "<name>", or run \`npx klypix-mcp init\` to create one.` }], isError: true };
|
|
369
|
+
try {
|
|
370
|
+
const { struct } = await parseKlypix(fs.readFileSync(file));
|
|
371
|
+
const ins = brainInsights(struct, staleDays ? { staleDays } : {});
|
|
372
|
+
return { content: [{ type: 'text', text: insightsToMarkdown(ins, struct.title) }] };
|
|
373
|
+
} catch (e) {
|
|
374
|
+
return { content: [{ type: 'text', text: `Insights failed: ${e.message}` }], isError: true };
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
server.registerTool('brain_connect', {
|
|
379
|
+
title: 'Connect related-but-unlinked brain cards (densify the graph)',
|
|
380
|
+
description: 'Finds genuinely related cards that AREN\'T linked yet and proposes connections — semantic similarity when the on-device model is installed, else shared tags + [[mentions]]. Dry-run by default (review the suggestions); pass apply:true to draw them (ADDITIVE — never deletes; the human can remove any arrow). Use after brain_insights flags many orphans, to turn a flat list into a real knowledge graph.',
|
|
381
|
+
inputSchema: {
|
|
382
|
+
canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
|
|
383
|
+
apply: z.boolean().optional().describe('false (default) = suggest only; true = draw the connections.'),
|
|
384
|
+
max: z.number().optional().describe('Max connections to propose/draw (default 24).'),
|
|
385
|
+
threshold: z.number().optional().describe('Min semantic similarity 0–1 to link (default 0.45). Higher = fewer, tighter links.'),
|
|
386
|
+
},
|
|
387
|
+
}, async ({ canvas, apply = false, max = 24, threshold = 0.45 }) => {
|
|
388
|
+
const file = resolveCanvas(canvas || 'brain') || resolveCanvas('brain.klypix');
|
|
389
|
+
if (!file) return { content: [{ type: 'text', text: `No brain canvas found in ${VAULT}.` }], isError: true };
|
|
390
|
+
let struct;
|
|
391
|
+
try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return { content: [{ type: 'text', text: `Read failed: ${e.message}` }], isError: true }; }
|
|
392
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 70);
|
|
393
|
+
const byId = new Map(struct.cards.map(c => [c.id, c]));
|
|
394
|
+
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
|
|
395
|
+
const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
|
|
396
|
+
|
|
397
|
+
let edges = [];
|
|
398
|
+
let mode = 'structural (shared tags + [[mentions]])';
|
|
399
|
+
const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), 20_000))]);
|
|
400
|
+
if (pipe) {
|
|
401
|
+
try {
|
|
402
|
+
const vecs = await vectorsForBrain(pipe, file, struct.cards);
|
|
403
|
+
const items = live.filter(c => vecs.get(c.id));
|
|
404
|
+
for (const a of items) {
|
|
405
|
+
const av = vecs.get(a.id);
|
|
406
|
+
const sims = items
|
|
407
|
+
.filter(b => b.id !== a.id)
|
|
408
|
+
.map(b => ({ b, s: dot(av, vecs.get(b.id)), cross: (b.area || '') !== (a.area || '') }))
|
|
409
|
+
.sort((x, y) => (y.s + (y.cross ? 0.03 : 0)) - (x.s + (x.cross ? 0.03 : 0))); // nudge toward cross-area links
|
|
410
|
+
let taken = 0;
|
|
411
|
+
for (const { b, s } of sims) {
|
|
412
|
+
if (s < threshold || taken >= 2) break; // each card keeps its ≤2 strongest fresh links
|
|
413
|
+
const key = [a.id, b.id].sort().join('|');
|
|
414
|
+
if (linked.has(key)) continue;
|
|
415
|
+
linked.add(key);
|
|
416
|
+
edges.push({ fromId: a.id, toId: b.id, sim: s });
|
|
417
|
+
taken++;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
edges.sort((x, y) => y.sim - x.sim);
|
|
421
|
+
mode = 'semantic (on-device)';
|
|
422
|
+
} catch (e) { mode = `structural (semantic failed: ${e.message})`; }
|
|
423
|
+
}
|
|
424
|
+
if (!edges.length && mode.startsWith('structural')) {
|
|
425
|
+
edges = proposeStructuralConnections(struct);
|
|
426
|
+
}
|
|
427
|
+
const chosen = edges.slice(0, max);
|
|
428
|
+
if (!chosen.length) return { content: [{ type: 'text', text: `Nothing to connect — no related-but-unlinked cards found (mode: ${mode}).` }] };
|
|
429
|
+
|
|
430
|
+
const render = (e) => `- ${flat(byId.get(e.fromId)?.text)} ↔ ${flat(byId.get(e.toId)?.text)}${e.sim != null ? ` (${e.sim.toFixed(2)})` : e.why ? ` (${e.why})` : ''}`;
|
|
431
|
+
if (!apply) {
|
|
432
|
+
return { content: [{ type: 'text', text: `# ${chosen.length} suggested connection(s) · ${mode}\n_Review, then re-run with apply:true to draw them._\n\n${chosen.map(render).join('\n')}` }] };
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const { buffer, added } = await addBrainConnections(fs.readFileSync(file), chosen);
|
|
436
|
+
await atomicWrite(file, buffer);
|
|
437
|
+
return { content: [{ type: 'text', text: `✓ Drew ${added} connection(s) into ${path.relative(VAULT, file)} (${mode}). Reopen the brain to see the new arrows.\n\n${chosen.slice(0, added).map(render).join('\n')}` }] };
|
|
438
|
+
} catch (e) {
|
|
439
|
+
return { content: [{ type: 'text', text: `Apply failed (brain unchanged): ${e.message}` }], isError: true };
|
|
440
|
+
}
|
|
232
441
|
});
|
|
233
442
|
|
|
234
443
|
// Format the cards (optionally only a set of new ids) + connection graph so an
|
|
@@ -291,7 +500,11 @@ server.registerTool('add_to_canvas', {
|
|
|
291
500
|
// Snapshot existing ids so we can report ONLY the newly-added cards back.
|
|
292
501
|
let beforeIds = new Set();
|
|
293
502
|
try { const b = await parseKlypix(original); beforeIds = new Set(b.struct.cards.map(c => c.id)); } catch { /* new/legacy → treat all as new */ }
|
|
294
|
-
|
|
503
|
+
// Provenance: stamp WHICH agent wrote these cards (cursor / claude /
|
|
504
|
+
// cline — from the MCP client's initialize handshake).
|
|
505
|
+
let via; try { via = server.server.getClientVersion()?.name; } catch { /* optional */ }
|
|
506
|
+
const stamped = via ? cards.map(c => ({ ...c, createdVia: via })) : cards;
|
|
507
|
+
const buf = await appendToKlypix(original, { cards: stamped, connections });
|
|
295
508
|
await atomicWrite(file, buf);
|
|
296
509
|
let detail = '';
|
|
297
510
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"homepage": "https://klypix.com",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
|
-
"url": "
|
|
22
|
+
"url": "https://github.com/dahshanlabs/klypix-mcp"
|
|
23
23
|
},
|
|
24
24
|
"bin": {
|
|
25
25
|
"klypix-mcp": "bin/klypix-mcp.mjs",
|
|
@@ -48,5 +48,8 @@
|
|
|
48
48
|
"jszip": "^3.10.1",
|
|
49
49
|
"zod": "^4.3.6",
|
|
50
50
|
"fractional-indexing": "^3.2.0"
|
|
51
|
+
},
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"@huggingface/transformers": "^4.2.0"
|
|
51
54
|
}
|
|
52
55
|
}
|
package/src/klypix-format.mjs
CHANGED
|
@@ -116,6 +116,7 @@ export async function parseKlypix(buffer) {
|
|
|
116
116
|
})),
|
|
117
117
|
connections: connections.map(c => ({
|
|
118
118
|
from: titleOf(c.fromId), to: titleOf(c.toId),
|
|
119
|
+
fromId: c.fromId, toId: c.toId, // raw ids — for graph analysis (brainInsights)
|
|
119
120
|
relationship: c.relationship || null, label: c.label || null,
|
|
120
121
|
})),
|
|
121
122
|
assets: assetPaths.map(p => path.basename(p)),
|
|
@@ -302,6 +303,7 @@ export async function appendToKlypix(buffer, addition) {
|
|
|
302
303
|
for (const a of added) {
|
|
303
304
|
zip.file(`items/${shard(a.id)}/${a.id}.json`, JSON.stringify({
|
|
304
305
|
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
306
|
+
...(a.card.createdVia ? { createdVia: String(a.card.createdVia) } : {}),
|
|
305
307
|
content: String(a.card.text), fontSize: FONT,
|
|
306
308
|
color: a.card.color || '#1a1a1f', border: !!a.card.border, borderColor: '#1e1e2e',
|
|
307
309
|
heading: !!a.card.heading, fontFamily: 'Thmanyah Sans',
|
|
@@ -598,6 +600,10 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
598
600
|
for (const c of focus) push(`- ${flat(c.text)}`);
|
|
599
601
|
}
|
|
600
602
|
if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${flat(c.text)}`); }
|
|
603
|
+
// ⚠️ Conflicts — pairs flagged conflicts_with (e.g. by parallel sessions);
|
|
604
|
+
// surfaced HIGH so the next session reconciles them, not buries them.
|
|
605
|
+
const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
|
|
606
|
+
if (conflicts.length) { push('', '## ⚠️ Conflicts to reconcile (parallel decisions that may disagree)'); for (const c of conflicts.slice(0, 10)) push(`- ${flat(c.from)} ⚔️ ${flat(c.to)}`); }
|
|
601
607
|
const areaCounts = containers
|
|
602
608
|
.filter(c => !/^archive$/i.test(c.title || ''))
|
|
603
609
|
.map(c => `${flat(c.title)} (${texts.filter(t => t.parentId === c.id).length})`);
|
|
@@ -632,6 +638,214 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
632
638
|
return out.join('\n') + '\n';
|
|
633
639
|
}
|
|
634
640
|
|
|
641
|
+
// ── Relevance ranking ─────────────────────────────────────────────────────
|
|
642
|
+
// ONE shared lexical ranker so the per-prompt retrieval hook (and, later, the
|
|
643
|
+
// MCP search) rank cards the SAME way — no third divergent scorer. Weights
|
|
644
|
+
// mirror the MCP convention: title 3, tag 2, body 1, plus a gentle recency
|
|
645
|
+
// tiebreak. Pure + node-runnable (no embeddings / network) so the Stop/prompt
|
|
646
|
+
// hooks can call it with zero extra deps. `#file-…`/`#dir-…` tags (added at
|
|
647
|
+
// capture) are what make a git-diff token match a card precisely.
|
|
648
|
+
const STOPWORDS = new Set(['the', 'and', 'for', 'that', 'this', 'with', 'from', 'have', 'has', 'was', 'were', 'are', 'you', 'your', 'not', 'but', 'its', 'into', 'out', 'can', 'will', 'use', 'using', 'about', 'what', 'when', 'why', 'how', 'add', 'fix', 'make', 'need', 'want', 'let', 'see', 'get', 'got', 'now', 'all', 'any', 'via', 'per', 'etc', 'should', 'could', 'would', 'does', 'did', 'still', 'just', 'like', 'also', 'then', 'than', 'them', 'they']);
|
|
649
|
+
export function queryTokens(s) {
|
|
650
|
+
return [...new Set(String(s || '').toLowerCase().match(/[a-z0-9][a-z0-9_-]{2,}/g) || [])].filter(t => !STOPWORDS.has(t));
|
|
651
|
+
}
|
|
652
|
+
const wordsOf = (s) => new Set(String(s || '').toLowerCase().match(/[a-z0-9][a-z0-9_-]{1,}/g) || []);
|
|
653
|
+
export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2, recentDays = 30 } = {}) {
|
|
654
|
+
const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
|
|
655
|
+
if (!tokens.length || !struct || !Array.isArray(struct.cards)) return [];
|
|
656
|
+
const cutoff = Date.now() - recentDays * 86_400_000;
|
|
657
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
658
|
+
const scored = [];
|
|
659
|
+
for (const c of struct.cards) {
|
|
660
|
+
if (c.type === 'container' || isArchived(c) || !(c.text || '').trim()) continue;
|
|
661
|
+
// WORD-level matching (not substring) so "app" can't hit "append-klypix"
|
|
662
|
+
// and "main" can't hit "domain". Tag match is on the tag's STEM (the
|
|
663
|
+
// slug after #file-/#dir-/#) so a git-diff token (slugify(basename))
|
|
664
|
+
// lands EXACTLY on its #file- anchor — the precise signal, weighted = a
|
|
665
|
+
// title hit so ONE anchored file match (3 + 0.5 recency) clears minScore.
|
|
666
|
+
const titleW = wordsOf(c.title);
|
|
667
|
+
const bodyW = wordsOf(c.text);
|
|
668
|
+
const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
|
|
669
|
+
let score = 0;
|
|
670
|
+
for (const tok of tokens) {
|
|
671
|
+
if (titleW.has(tok)) score += 3;
|
|
672
|
+
else if (tagStems.has(tok)) score += 3;
|
|
673
|
+
else if (bodyW.has(tok)) score += 1;
|
|
674
|
+
}
|
|
675
|
+
if (score <= 0) continue;
|
|
676
|
+
if ((c.createdAt || 0) >= cutoff) score += 0.5; // gentle recency tiebreak, never dominant
|
|
677
|
+
scored.push({ card: c, score });
|
|
678
|
+
}
|
|
679
|
+
scored.sort((a, b) => b.score - a.score || (b.card.createdAt || 0) - (a.card.createdAt || 0));
|
|
680
|
+
return scored.filter(s => s.score >= minScore).slice(0, topK);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ── Conflict candidate detection ───────────────────────────────────────────
|
|
684
|
+
// REAL conflicts only — a conflict is two decisions that genuinely CONTRADICT
|
|
685
|
+
// (you can't honor both about the same thing). Topical similarity, duplication,
|
|
686
|
+
// and sequential supersession are explicitly NOT conflicts and must never be
|
|
687
|
+
// flagged (no false-conflict dump). This is a cheap LEXICAL pre-filter that
|
|
688
|
+
// returns CANDIDATES only — same-subject, not-already-connected pairs where at
|
|
689
|
+
// least one card uses explicit REVERSAL language. Candidates mean nothing on
|
|
690
|
+
// their own: the caller (brain-conflicts.mjs) confirms each with an LLM
|
|
691
|
+
// contradiction-check before anything is ever drawn or surfaced. No verifier →
|
|
692
|
+
// nothing surfaces. Pure + node-runnable.
|
|
693
|
+
// Strong reversal/contradiction terms ONLY — deliberately NOT bare "not"/"don't"
|
|
694
|
+
// (too common → false positives). A candidate still has to clear the LLM gate.
|
|
695
|
+
const OPPOSITION_RE = /\b(instead of|reverted?|no longer|dropp(?:ed|ing)?|deprecat\w*|abandon\w*|replaced?\b|supersed\w*|changed from|switch(?:ed)? (?:from|to)|rolled? back|overrod|overrides?|contradic\w*|disagree\w*|conflicts? with|reversed?\b)/i;
|
|
696
|
+
export function detectConflicts(struct, { minOverlap = 0.45, topK = 12 } = {}) {
|
|
697
|
+
if (!struct || !Array.isArray(struct.cards)) return [];
|
|
698
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
699
|
+
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
|
|
700
|
+
const wset = new Map(live.map(c => [c.id, new Set(queryTokens(c.text))]));
|
|
701
|
+
const connected = new Set();
|
|
702
|
+
for (const e of struct.connections || []) { connected.add(e.fromId + '|' + e.toId); connected.add(e.toId + '|' + e.fromId); }
|
|
703
|
+
const out = [];
|
|
704
|
+
for (let i = 0; i < live.length; i++) {
|
|
705
|
+
for (let j = i + 1; j < live.length; j++) {
|
|
706
|
+
const a = live[i], b = live[j];
|
|
707
|
+
if (connected.has(a.id + '|' + b.id)) continue;
|
|
708
|
+
const A = wset.get(a.id), B = wset.get(b.id);
|
|
709
|
+
if (A.size < 4 || B.size < 4) continue;
|
|
710
|
+
let inter = 0; for (const t of A) if (B.has(t)) inter++;
|
|
711
|
+
const overlap = inter / Math.min(A.size, B.size); // overlap coefficient — same subject?
|
|
712
|
+
// Must be same-subject AND carry an explicit reversal signal. Pure
|
|
713
|
+
// similarity / duplication is NOT a candidate (that was the dump).
|
|
714
|
+
if (overlap < minOverlap) continue;
|
|
715
|
+
if (!(OPPOSITION_RE.test(a.text) || OPPOSITION_RE.test(b.text))) continue;
|
|
716
|
+
out.push({ aId: a.id, bId: b.id, a: a.text, b: b.text, area: a.area, overlap: Math.round(overlap * 100) / 100 });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
out.sort((x, y) => y.overlap - x.overlap);
|
|
720
|
+
return out.slice(0, topK);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ── Brain insights ───────────────────────────────────────────────────────────
|
|
724
|
+
// "What matters here, and what am I forgetting?" — a deterministic structural
|
|
725
|
+
// read of the brain (borrowed from graphify's GRAPH_REPORT, applied to the
|
|
726
|
+
// AUTHORED brain, not derived code). Surfaces:
|
|
727
|
+
// • hubs — the most-connected cards (load-bearing decisions)
|
|
728
|
+
// • orphans — decision/milestone cards with NO connections (isolated;
|
|
729
|
+
// maybe forgotten — link them or archive them)
|
|
730
|
+
// • stale ❓ — open questions older than `staleDays` (aging unknowns)
|
|
731
|
+
// • areas — live-card count per area (what's growing / dormant)
|
|
732
|
+
// Pure + node-runnable: no LLM, no network. The agent renders/acts on it.
|
|
733
|
+
export function brainInsights(struct, { staleDays = 21, topHubs = 6 } = {}) {
|
|
734
|
+
const cutoff = Date.now() - staleDays * 86_400_000;
|
|
735
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
736
|
+
const cards = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
|
|
737
|
+
const live = cards.filter(c => !isArchived(c));
|
|
738
|
+
const deg = new Map();
|
|
739
|
+
for (const cn of struct.connections) {
|
|
740
|
+
if (cn.fromId) deg.set(cn.fromId, (deg.get(cn.fromId) || 0) + 1);
|
|
741
|
+
if (cn.toId) deg.set(cn.toId, (deg.get(cn.toId) || 0) + 1);
|
|
742
|
+
}
|
|
743
|
+
const headline = (c) => String(c.text || '').replace(/\s+/g, ' ').trim().replace(/^(.*?)([.!?](\s|$)|$)/, '$1').slice(0, 120);
|
|
744
|
+
const isQuestion = (c) => /❓/.test(c.text);
|
|
745
|
+
const hubs = live
|
|
746
|
+
.map(c => ({ id: c.id, area: c.area, degree: deg.get(c.id) || 0, headline: headline(c) }))
|
|
747
|
+
.filter(x => x.degree > 0)
|
|
748
|
+
.sort((a, b) => b.degree - a.degree)
|
|
749
|
+
.slice(0, topHubs);
|
|
750
|
+
const orphans = live
|
|
751
|
+
.filter(c => !isQuestion(c) && !/🌿|⤵/.test(c.text) && !(deg.get(c.id) > 0))
|
|
752
|
+
.map(c => ({ id: c.id, area: c.area, headline: headline(c), age: c.createdAt }));
|
|
753
|
+
const staleQuestions = live
|
|
754
|
+
.filter(c => isQuestion(c) && (c.createdAt || 0) < cutoff)
|
|
755
|
+
.map(c => ({ id: c.id, area: c.area, headline: headline(c), age: c.createdAt }))
|
|
756
|
+
.sort((a, b) => (a.age || 0) - (b.age || 0));
|
|
757
|
+
const areas = struct.cards
|
|
758
|
+
.filter(c => c.type === 'container' && !/^archive$/i.test(c.title || ''))
|
|
759
|
+
.map(c => ({ title: c.title, count: cards.filter(t => t.parentId === c.id).length }))
|
|
760
|
+
.sort((a, b) => b.count - a.count);
|
|
761
|
+
return {
|
|
762
|
+
hubs, orphans, staleQuestions, areas,
|
|
763
|
+
totals: { live: live.length, archived: cards.length - live.length, connections: struct.connections.length },
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Render brainInsights as a compact markdown report (used by the MCP tool).
|
|
768
|
+
export function insightsToMarkdown(ins, title = 'brain') {
|
|
769
|
+
const out = [`# ${title} — insights`, `*${ins.totals.live} live cards · ${ins.totals.archived} archived · ${ins.totals.connections} connections*`];
|
|
770
|
+
if (ins.hubs.length) { out.push('', '## 🪢 Hubs (most-connected — the load-bearing cards)'); for (const h of ins.hubs) out.push(`- (${h.degree}) [${h.area || '?'}] ${h.headline}`); }
|
|
771
|
+
if (ins.orphans.length) { out.push('', `## 🔌 Orphaned decisions (${ins.orphans.length} — no connections; link them or archive)`); for (const o of ins.orphans.slice(0, 12)) out.push(`- [${o.area || '?'}] ${o.headline}`); if (ins.orphans.length > 12) out.push(`- …and ${ins.orphans.length - 12} more`); }
|
|
772
|
+
if (ins.staleQuestions.length) { out.push('', `## ⏳ Stale open questions (${ins.staleQuestions.length} — unresolved & aging)`); for (const q of ins.staleQuestions.slice(0, 12)) out.push(`- [${q.area || '?'}] ${q.headline}`); }
|
|
773
|
+
out.push('', '## 📍 Areas (by live cards)', ins.areas.map(a => `${a.title} (${a.count})`).join(' · ') || '(none)');
|
|
774
|
+
if (!ins.hubs.length && !ins.orphans.length && !ins.staleQuestions.length) out.push('', '_Brain is small/tidy — no hubs, orphans, or stale questions to flag yet._');
|
|
775
|
+
return out.join('\n') + '\n';
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Append raw connections (by id) to a brain — additive, never deletes, skips
|
|
779
|
+
// self-links / duplicates / dangling ids, verifies a round-trip parse. Used by
|
|
780
|
+
// the brain_connect tool to densify a sparse brain into a real graph.
|
|
781
|
+
export async function addBrainConnections(buffer, edges) {
|
|
782
|
+
const { zip, canvas, manifest, isV4 } = await parseKlypix(buffer);
|
|
783
|
+
if (!isV4 || !canvas.positions) throw new Error('connections need a v4 .klypix');
|
|
784
|
+
canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
785
|
+
const linked = (a, b) => canvas.connections.some(c => (c.fromId === a && c.toId === b) || (c.fromId === b && c.toId === a));
|
|
786
|
+
const rand = () => Math.random().toString(36).slice(2, 10);
|
|
787
|
+
let added = 0;
|
|
788
|
+
for (const e of edges || []) {
|
|
789
|
+
if (!e.fromId || !e.toId || e.fromId === e.toId) continue;
|
|
790
|
+
if (!canvas.positions[e.fromId] || !canvas.positions[e.toId]) continue; // both must exist
|
|
791
|
+
if (linked(e.fromId, e.toId)) continue;
|
|
792
|
+
canvas.connections.push({
|
|
793
|
+
id: `con_${rand()}`, fromId: e.fromId, toId: e.toId,
|
|
794
|
+
relationship: REL.has(e.relationship) ? e.relationship : 'relates_to',
|
|
795
|
+
label: typeof e.label === 'string' ? e.label : undefined,
|
|
796
|
+
arrowHead: true, width: 2, color: typeof e.color === 'string' ? e.color : '#10b981', style: 'solid',
|
|
797
|
+
});
|
|
798
|
+
added++;
|
|
799
|
+
}
|
|
800
|
+
if (!added) return { buffer, added: 0 };
|
|
801
|
+
const out = await finalizeBrainZip(zip, canvas, manifest, Date.now());
|
|
802
|
+
return { buffer: out, added };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Structural connection suggestions — pure, no embeddings (the fallback when
|
|
806
|
+
// the on-device model isn't installed, and the twin of the in-app Connect
|
|
807
|
+
// button). Signals, strongest first: an unlinked [[title]] mention (3), a
|
|
808
|
+
// shared topical tag ACROSS areas (2), a shared tag within an area (1). The
|
|
809
|
+
// area-name tag is dropped (redundant with containment), and — crucially —
|
|
810
|
+
// each card keeps at most `maxPerCard` links, so a tag shared by ten cards
|
|
811
|
+
// can't explode into a 45-edge clique. Mirrors the semantic pass's discipline.
|
|
812
|
+
export function proposeStructuralConnections(struct, { maxPerCard = 2 } = {}) {
|
|
813
|
+
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
|
|
814
|
+
const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
|
|
815
|
+
const titleIx = live.filter(c => (c.title || '').trim()).map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
|
|
816
|
+
const tagsOf = (c) => (c.tags || [])
|
|
817
|
+
.map(t => String(t).toLowerCase().replace(/^#/, ''))
|
|
818
|
+
.filter(t => t && t !== 'area' && t !== String(c.area || '').toLowerCase());
|
|
819
|
+
const cand = [];
|
|
820
|
+
for (const c of live) {
|
|
821
|
+
for (const link of (c.links || [])) {
|
|
822
|
+
const want = String(link).trim().toLowerCase();
|
|
823
|
+
const tgt = titleIx.find(e => e.id !== c.id && (e.t === want || e.t.startsWith(want)));
|
|
824
|
+
if (tgt) cand.push({ a: c.id, b: tgt.id, score: 3, why: 'mention' });
|
|
825
|
+
}
|
|
826
|
+
const ct = tagsOf(c);
|
|
827
|
+
if (!ct.length) continue;
|
|
828
|
+
for (const d of live) {
|
|
829
|
+
if (d.id <= c.id) continue;
|
|
830
|
+
if (tagsOf(d).some(t => ct.includes(t))) cand.push({ a: c.id, b: d.id, score: c.area !== d.area ? 2 : 1, why: 'shared tag' });
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
cand.sort((x, y) => y.score - x.score);
|
|
834
|
+
const per = new Map();
|
|
835
|
+
const edges = [];
|
|
836
|
+
for (const e of cand) {
|
|
837
|
+
if (e.a === e.b) continue;
|
|
838
|
+
const key = [e.a, e.b].sort().join('|');
|
|
839
|
+
if (linked.has(key)) continue;
|
|
840
|
+
if ((per.get(e.a) || 0) >= maxPerCard || (per.get(e.b) || 0) >= maxPerCard) continue;
|
|
841
|
+
linked.add(key);
|
|
842
|
+
per.set(e.a, (per.get(e.a) || 0) + 1);
|
|
843
|
+
per.set(e.b, (per.get(e.b) || 0) + 1);
|
|
844
|
+
edges.push({ fromId: e.a, toId: e.b, why: e.why });
|
|
845
|
+
}
|
|
846
|
+
return edges;
|
|
847
|
+
}
|
|
848
|
+
|
|
635
849
|
// ── Atomic brain capture: supersede + append + resolve + auto-link ──────────
|
|
636
850
|
// One verified write per capture batch. Beyond appendIntoContainers it adds:
|
|
637
851
|
// • SUPERSEDE — a new decision that heavily overlaps an existing live card in
|
|
@@ -693,10 +907,28 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
693
907
|
if (pos) canvas.positions[id] = { ...pos, h: measureCardH(j.content) };
|
|
694
908
|
return true;
|
|
695
909
|
};
|
|
696
|
-
const archiveCard = (id) => {
|
|
910
|
+
const archiveCard = async (id) => {
|
|
697
911
|
const arc = ensureArchive();
|
|
912
|
+
// The Archive is readable STORAGE, not a vector-scaled layout: un-bake
|
|
913
|
+
// any group-shrink (font/width baked tiny) by restoring the frozen
|
|
914
|
+
// authored baseline and dropping it, so the card sits in the Archive
|
|
915
|
+
// at full readable size and re-seeds cleanly if the Archive is resized.
|
|
916
|
+
let authoredW = null;
|
|
917
|
+
const ip = `items/${shard(id)}/${id}.json`;
|
|
918
|
+
const f = zip.file(ip);
|
|
919
|
+
if (f) {
|
|
920
|
+
const j = JSON.parse(await f.async('string'));
|
|
921
|
+
const a = j.authoredInParent;
|
|
922
|
+
if (a) {
|
|
923
|
+
if (j.type === 'text' && a.fontSize) j.fontSize = a.fontSize;
|
|
924
|
+
if (a.authoredWidth != null) j.authoredWidth = a.authoredWidth;
|
|
925
|
+
authoredW = a.w || null;
|
|
926
|
+
delete j.authoredInParent;
|
|
927
|
+
zip.file(ip, JSON.stringify(j));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
698
930
|
const pos = canvas.positions[id];
|
|
699
|
-
if (pos) canvas.positions[id] = { ...pos, parentId: arc };
|
|
931
|
+
if (pos) canvas.positions[id] = { ...pos, parentId: arc, ...(authoredW ? { w: authoredW } : {}) };
|
|
700
932
|
};
|
|
701
933
|
canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
702
934
|
|
|
@@ -715,7 +947,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
715
947
|
j.content = `${j.content}\n✅ ${today}: ${r.text}`;
|
|
716
948
|
j.borderColor = 'rgba(16,185,129,0.35)';
|
|
717
949
|
});
|
|
718
|
-
archiveCard(best.id);
|
|
950
|
+
await archiveCard(best.id);
|
|
719
951
|
best.text += ` ✅ ${r.text}`; // keep in-memory struct honest for later matching
|
|
720
952
|
stats.resolved++;
|
|
721
953
|
} else {
|
|
@@ -768,7 +1000,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
768
1000
|
j.content = `↩︎ superseded ${today}\n${j.content}`;
|
|
769
1001
|
j.borderColor = 'rgba(120,120,135,0.5)';
|
|
770
1002
|
});
|
|
771
|
-
archiveCard(best.id);
|
|
1003
|
+
await archiveCard(best.id);
|
|
772
1004
|
best.text = `↩︎ ${best.text}`;
|
|
773
1005
|
card.__supersedes = best.id;
|
|
774
1006
|
stats.superseded++;
|