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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",
@@ -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;