klypix-mcp 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/klypix-mcp.mjs +16 -2
- package/package.json +1 -1
- package/src/klypix-format.mjs +93 -8
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -305,9 +305,17 @@ server.registerTool('search_all_brains', {
|
|
|
305
305
|
let qv = null;
|
|
306
306
|
if (pipe) { try { [qv] = await embedTexts(pipe, [q]); } catch { /* lexical only */ } }
|
|
307
307
|
|
|
308
|
+
// Current-project locality prior: a card from the project you're working in
|
|
309
|
+
// should outrank an equally-relevant card from an unrelated project (the
|
|
310
|
+
// "cross-project search drowned my own project's cards" complaint). The boost
|
|
311
|
+
// below is modest + mode-aware — never enough to bury a much stronger foreign hit.
|
|
312
|
+
let curKey = null;
|
|
313
|
+
try { const cb = resolveCanvas('brain') || resolveCanvas('brain.klypix'); if (cb) curKey = path.resolve(cb).replace(/\\/g, '/').toLowerCase(); } catch { /* no current brain */ }
|
|
308
314
|
const fresh = Date.now() - 30 * 86_400_000;
|
|
309
315
|
const scored = [];
|
|
310
316
|
for (const b of brains) {
|
|
317
|
+
let isCur = false;
|
|
318
|
+
try { isCur = !!curKey && path.resolve(b.path).replace(/\\/g, '/').toLowerCase() === curKey; } catch { /* */ }
|
|
311
319
|
let struct;
|
|
312
320
|
try { ({ struct } = await parseKlypix(fs.readFileSync(b.path))); } catch { continue; }
|
|
313
321
|
let vecs = null;
|
|
@@ -340,8 +348,9 @@ server.registerTool('search_all_brains', {
|
|
|
340
348
|
if (asOfTs == null) {
|
|
341
349
|
if ((c.createdAt || 0) >= fresh) score += 0.5;
|
|
342
350
|
if (isArchived) score -= 1;
|
|
351
|
+
if (isCur) score += sem != null ? 1.5 : 1; // current-project locality prior
|
|
343
352
|
}
|
|
344
|
-
scored.push({ score, sem, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
|
|
353
|
+
scored.push({ score, sem, cur: isCur, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
|
|
345
354
|
}
|
|
346
355
|
}
|
|
347
356
|
if (!scored.length) return { content: [{ type: 'text', text: `No matches for "${query}" across ${brains.length} registered brain(s).` }] };
|
|
@@ -349,7 +358,7 @@ server.registerTool('search_all_brains', {
|
|
|
349
358
|
const top = scored.slice(0, 20);
|
|
350
359
|
const lines = top.map(h => {
|
|
351
360
|
const when = h.c.createdAt ? new Date(h.c.createdAt).toISOString().slice(0, 10) : '';
|
|
352
|
-
return `- [${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
|
|
361
|
+
return `- ${h.cur ? '★ ' : ''}[${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
|
|
353
362
|
});
|
|
354
363
|
const mode = qv ? 'semantic+lexical (on-device)' : 'lexical (semantic model warming — retry for semantic ranking)';
|
|
355
364
|
const asOfNote = asOfTs != null ? ` · as of ${as_of}` : '';
|
|
@@ -520,3 +529,8 @@ server.registerTool('add_to_canvas', {
|
|
|
520
529
|
const transport = new StdioServerTransport();
|
|
521
530
|
await server.connect(transport);
|
|
522
531
|
log(`ready · vault=${VAULT}`);
|
|
532
|
+
// Pre-warm the on-device embedder in the BACKGROUND so the first cross-project
|
|
533
|
+
// search of a session is already semantic, not a lexical fallback while the
|
|
534
|
+
// MiniLM model loads. getEmbedder() memoizes + swallows its own errors, so this
|
|
535
|
+
// is a safe fire-and-forget (no await → zero added startup latency).
|
|
536
|
+
getEmbedder().then(p => log(p ? 'semantic ready (pre-warmed)' : 'semantic unavailable — lexical only')).catch(() => {});
|
package/package.json
CHANGED
package/src/klypix-format.mjs
CHANGED
|
@@ -113,6 +113,9 @@ export async function parseKlypix(buffer) {
|
|
|
113
113
|
parentId: it.parentId ?? null,
|
|
114
114
|
// Parent container's title — the card's "area" in brain terms.
|
|
115
115
|
area: it.parentId ? (cardTitle(items[it.parentId]) || null) : null,
|
|
116
|
+
// Evidence anchors (file:line / PR#) with the git blob OID stamped at
|
|
117
|
+
// capture-time — lets the hook flag a card whose cited code drifted.
|
|
118
|
+
evidence: Array.isArray(it.evidence) && it.evidence.length ? it.evidence : null,
|
|
116
119
|
})),
|
|
117
120
|
connections: connections.map(c => ({
|
|
118
121
|
from: titleOf(c.fromId), to: titleOf(c.toId),
|
|
@@ -464,6 +467,8 @@ export async function appendIntoContainers(buffer, addition) {
|
|
|
464
467
|
// Provenance: WHICH agent remembered this (claude-code / cursor /
|
|
465
468
|
// cline / …) — additive field, ignored by older readers.
|
|
466
469
|
...(card.createdVia ? { createdVia: String(card.createdVia) } : {}),
|
|
470
|
+
// Evidence anchors (file:line / PR#) — additive, ignored by older readers.
|
|
471
|
+
...(Array.isArray(card.evidence) && card.evidence.length ? { evidence: card.evidence } : {}),
|
|
467
472
|
content: wrapped, fontSize: G.FONT,
|
|
468
473
|
color: card.color || '#e8e8ed', border: true, borderColor: card.borderColor || card.color || 'rgba(16,185,129,0.45)',
|
|
469
474
|
fillColor: 'rgba(18,18,26,0.85)', heading: !!card.heading, fontFamily: 'Thmanyah Sans',
|
|
@@ -556,7 +561,7 @@ export async function tidyBrain(buffer) {
|
|
|
556
561
|
// decisions + milestones. Everything older stays in the file, reachable via the
|
|
557
562
|
// klypix-canvas MCP search or `--full`. Keeps the session-start cost flat as
|
|
558
563
|
// the brain grows (the full markdown scales with history; this doesn't).
|
|
559
|
-
export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMilestones = 8, maxConnections = 30 } = {}) {
|
|
564
|
+
export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMilestones = 8, maxConnections = 30, freshness = null } = {}) {
|
|
560
565
|
const cutoff = Date.now() - recentDays * 86_400_000;
|
|
561
566
|
const texts = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
|
|
562
567
|
const containers = struct.cards.filter(c => c.type === 'container');
|
|
@@ -575,6 +580,9 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
575
580
|
const archivedCount = texts.length - live.length;
|
|
576
581
|
|
|
577
582
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
583
|
+
// Freshness badge (✅ verified / ⚠️ drifted / 🌱 unverified) for code-anchored
|
|
584
|
+
// cards — supplied by the git-aware hook; absent → no badge. Trust at a glance.
|
|
585
|
+
const fr = (c) => (freshness && freshness[c.id]) ? freshness[c.id] + ' ' : '';
|
|
578
586
|
// HEADLINE = first sentence-ish, hard-capped — the brief is a scannable
|
|
579
587
|
// changelog; the agent pulls any card's full text via the MCP when needed.
|
|
580
588
|
const headline = (c, max = 160) => {
|
|
@@ -597,9 +605,9 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
597
605
|
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*`);
|
|
598
606
|
if (focus.length) {
|
|
599
607
|
push('', '## 📌 Human focus (cards the human placed here — act on these first)');
|
|
600
|
-
for (const c of focus) push(`- ${flat(c.text)}`);
|
|
608
|
+
for (const c of focus) push(`- ${fr(c)}${flat(c.text)}`);
|
|
601
609
|
}
|
|
602
|
-
if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${flat(c.text)}`); }
|
|
610
|
+
if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
|
|
603
611
|
// ⚠️ Conflicts — pairs flagged conflicts_with (e.g. by parallel sessions);
|
|
604
612
|
// surfaced HIGH so the next session reconciles them, not buries them.
|
|
605
613
|
const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
|
|
@@ -610,7 +618,7 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
610
618
|
if (areaCounts.length) { push('', '## Areas', areaCounts.join(' · ')); }
|
|
611
619
|
if (miles.length) {
|
|
612
620
|
push('', '## Milestones');
|
|
613
|
-
for (const c of miles.sort((a, b) => b.createdAt - a.createdAt).slice(0, maxMilestones)) push(`- ${headline(c)}`);
|
|
621
|
+
for (const c of miles.sort((a, b) => b.createdAt - a.createdAt).slice(0, maxMilestones)) push(`- ${fr(c)}${headline(c)}`);
|
|
614
622
|
}
|
|
615
623
|
let shownRecent = 0;
|
|
616
624
|
if (recent.length) {
|
|
@@ -621,7 +629,7 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
621
629
|
push(`### ${a}`);
|
|
622
630
|
for (const c of cs) {
|
|
623
631
|
if (used > BUDGET_CHARS) break outer;
|
|
624
|
-
push(`- ${day(c.createdAt)} ${headline(c)}`);
|
|
632
|
+
push(`- ${fr(c)}${day(c.createdAt)} ${headline(c)}`);
|
|
625
633
|
shownRecent++;
|
|
626
634
|
}
|
|
627
635
|
}
|
|
@@ -680,6 +688,47 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
|
|
|
680
688
|
return scored.filter(s => s.score >= minScore).slice(0, topK);
|
|
681
689
|
}
|
|
682
690
|
|
|
691
|
+
// ── Repeat / redundancy detection ("you already did this in another session") ─
|
|
692
|
+
// The PRECISION-first sibling of scoreCardsAgainstQuery. Instead of "related
|
|
693
|
+
// context" it answers a sharper question: is the user about to REDO work that's
|
|
694
|
+
// already DONE? It scans COMPLETED-work cards ONLY — 🏁 shipped, ✅ resolved,
|
|
695
|
+
// ↩︎ superseded — and INCLUDES the Archive (resolved/superseded cards live there,
|
|
696
|
+
// which the relevance ranker deliberately skips). Deliberately strict: a high
|
|
697
|
+
// score floor + ≥2 distinct query-token hits, because for a NUDGE a false "you
|
|
698
|
+
// already did this" is costly (erodes trust) while a miss is cheap (the loose
|
|
699
|
+
// recall list still shows below). Returns each card's `kind` so the caller can
|
|
700
|
+
// say "reuse it" (shipped/resolved) vs "see what replaced it" (superseded).
|
|
701
|
+
// Pure + node-runnable; reuses the one shared tokenizer — no divergent scorer.
|
|
702
|
+
export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTokens = 2 } = {}) {
|
|
703
|
+
const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
|
|
704
|
+
if (tokens.length < minTokens || !struct || !Array.isArray(struct.cards)) return [];
|
|
705
|
+
const kindOf = (t) => /🏁/.test(t) ? 'shipped' : /✅/.test(t) ? 'resolved' : /↩/.test(t) ? 'superseded' : null;
|
|
706
|
+
const rank = { shipped: 2, resolved: 2, superseded: 1 };
|
|
707
|
+
const out = [];
|
|
708
|
+
for (const c of struct.cards) {
|
|
709
|
+
if (c.type === 'container' || !(c.text || '').trim()) continue;
|
|
710
|
+
const kind = kindOf(c.text);
|
|
711
|
+
if (!kind) continue; // only COMPLETED-work cards qualify
|
|
712
|
+
// Score the first MEANINGFUL line, not a marker stamp: supersede/resolve
|
|
713
|
+
// prepend "↩︎ superseded <date>" / lead with "✅ …", which would otherwise
|
|
714
|
+
// become the title and hide the real content from title-weighted matching.
|
|
715
|
+
const firstMeaningful = String(c.text).split('\n').map(s => s.trim()).filter(Boolean).find(l => !/^[↩✅🏁]/u.test(l));
|
|
716
|
+
const titleW = wordsOf(firstMeaningful || c.title);
|
|
717
|
+
const bodyW = wordsOf(c.text);
|
|
718
|
+
const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
|
|
719
|
+
let score = 0, matched = 0;
|
|
720
|
+
for (const tok of tokens) {
|
|
721
|
+
if (titleW.has(tok)) { score += 3; matched++; }
|
|
722
|
+
else if (tagStems.has(tok)) { score += 3; matched++; }
|
|
723
|
+
else if (bodyW.has(tok)) { score += 1; matched++; }
|
|
724
|
+
}
|
|
725
|
+
if (matched < minTokens || score < minScore) continue; // precision-first floor
|
|
726
|
+
out.push({ card: c, score, kind });
|
|
727
|
+
}
|
|
728
|
+
out.sort((a, b) => b.score - a.score || (rank[b.kind] - rank[a.kind]) || (b.card.createdAt || 0) - (a.card.createdAt || 0));
|
|
729
|
+
return out.slice(0, topK);
|
|
730
|
+
}
|
|
731
|
+
|
|
683
732
|
// ── Conflict candidate detection ───────────────────────────────────────────
|
|
684
733
|
// REAL conflicts only — a conflict is two decisions that genuinely CONTRADICT
|
|
685
734
|
// (you can't honor both about the same thing). Topical similarity, duplication,
|
|
@@ -863,9 +912,9 @@ const overlapScore = (a, b) => {
|
|
|
863
912
|
return hit / Math.min(a.size, b.size);
|
|
864
913
|
};
|
|
865
914
|
export async function captureIntoBrain(buffer, { cards = [], resolutions = [], updates = [] } = {}) {
|
|
866
|
-
const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45;
|
|
915
|
+
const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45, CLOSE_AT = 0.25;
|
|
867
916
|
let work = buffer;
|
|
868
|
-
const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0 };
|
|
917
|
+
const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0 };
|
|
869
918
|
|
|
870
919
|
// Pass 1 — resolutions + supersede marking operate on EXISTING cards.
|
|
871
920
|
if (resolutions.length || cards.length || updates.length) {
|
|
@@ -974,11 +1023,14 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
974
1023
|
j.createdAt = now;
|
|
975
1024
|
j.borderColor = 'rgba(16,185,129,0.6)';
|
|
976
1025
|
if (u.createdVia) j.createdVia = String(u.createdVia);
|
|
1026
|
+
// Self-heal: a ~ update re-stamps the evidence (fresh OID +
|
|
1027
|
+
// verifiedAt), so confirming/correcting a drifted fact marks it ✅.
|
|
1028
|
+
if (Array.isArray(u.evidence) && u.evidence.length) j.evidence = u.evidence;
|
|
977
1029
|
});
|
|
978
1030
|
best.text = u.text;
|
|
979
1031
|
stats.updated++;
|
|
980
1032
|
} else {
|
|
981
|
-
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 });
|
|
1033
|
+
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, ...(Array.isArray(u.evidence) && u.evidence.length ? { evidence: u.evidence } : {}) });
|
|
982
1034
|
}
|
|
983
1035
|
}
|
|
984
1036
|
|
|
@@ -1007,6 +1059,38 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1007
1059
|
}
|
|
1008
1060
|
}
|
|
1009
1061
|
|
|
1062
|
+
// CLOSE-LINK — a card carrying `closes` resolves the (often cross-area)
|
|
1063
|
+
// strategy/question card that SPAWNED it: stamp ✅, archive it, and draw a
|
|
1064
|
+
// "closed by" arrow in pass 2. Unlike supersede (same-area, high lexical
|
|
1065
|
+
// overlap), a shipped milestone rarely echoes the strategy's prose — so
|
|
1066
|
+
// this matches across ALL areas, prefers an explicit [[wikilink]]/title
|
|
1067
|
+
// hit, and otherwise fires on only a low overlap. This is the fix for
|
|
1068
|
+
// "strategy cards never get closed out when their feature actually ships".
|
|
1069
|
+
for (const card of cards) {
|
|
1070
|
+
const target = (card.closes || '').toString().trim();
|
|
1071
|
+
if (!target) continue;
|
|
1072
|
+
const wantTitle = target.replace(/^\[\[/, '').replace(/\]\]$/, '').trim().toLowerCase();
|
|
1073
|
+
const tTok = tokenSet(target);
|
|
1074
|
+
let best = null, bestScore = 0;
|
|
1075
|
+
for (const c of liveTextCards()) {
|
|
1076
|
+
const ct = (c.title || '').trim().toLowerCase();
|
|
1077
|
+
if (ct && wantTitle && (ct === wantTitle || ct.startsWith(wantTitle) || wantTitle.startsWith(ct))) { best = c; bestScore = 1; break; }
|
|
1078
|
+
const s = overlapScore(tTok, tokenSet(c.text));
|
|
1079
|
+
if (s > bestScore) { bestScore = s; best = c; }
|
|
1080
|
+
}
|
|
1081
|
+
if (best && bestScore >= CLOSE_AT) {
|
|
1082
|
+
const ship = String(card.text).replace(/\s+/g, ' ').replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, '').trim().slice(0, 80);
|
|
1083
|
+
await rewriteCard(best.id, j => {
|
|
1084
|
+
j.content = `${j.content}\n✅ ${today}: closed by → ${ship}`;
|
|
1085
|
+
j.borderColor = 'rgba(16,185,129,0.35)';
|
|
1086
|
+
});
|
|
1087
|
+
await archiveCard(best.id);
|
|
1088
|
+
best.text = `✅ ${best.text}`;
|
|
1089
|
+
card.__closes = best.id;
|
|
1090
|
+
stats.closed++;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1010
1094
|
cards.push(...milestonesFallback);
|
|
1011
1095
|
work = await finalizeBrainZip(zip, canvas, manifest, now);
|
|
1012
1096
|
}
|
|
@@ -1042,6 +1126,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1042
1126
|
const created = findNew(card.text);
|
|
1043
1127
|
if (!created) continue;
|
|
1044
1128
|
if (card.__supersedes) addConn(card.__supersedes, created.id, 'superseded by', undefined);
|
|
1129
|
+
if (card.__closes) addConn(card.__closes, created.id, 'closed by', undefined);
|
|
1045
1130
|
for (const link of (created.links || [])) {
|
|
1046
1131
|
const want = String(link).trim().toLowerCase();
|
|
1047
1132
|
if (!want) continue;
|