@cachly-dev/mcp-server 0.10.49 → 0.10.51

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.
@@ -3,20 +3,32 @@ import { calculateConfidence, confidenceBadge, STRUCTURED_TEMPLATES, CONFIDENCE_
3
3
  import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '../ckg.js';
4
4
  import { safeJsonParse } from '../utils.js';
5
5
  import { keywordSearch, tokenize, splitMultiQuery, levenshtein, indexVocab as _indexVocab } from '../search.js';
6
+ import { rerankByQuality } from '../rerank.js';
6
7
  import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
7
8
  // ── Changelog (shown once per version in session_start) ──────────────────────
8
- const MCP_VERSION = '0.10.49';
9
+ const MCP_VERSION = '0.10.51';
9
10
  const WHATS_NEW = {
10
- '0.10.49': [
11
+ '0.10.51': [
11
12
  `🆕 **What's new in v${MCP_VERSION}:**`,
13
+ ` ✅ \`team_confirm\` — endorse a lesson in code/knowledge review; reviewed knowledge ranks higher (🛡️ senior / ✔️ peer)`,
14
+ ` ✅ Governance-aware recall — human-confirmed lessons outrank unreviewed auto-learned entries`,
15
+ ` ✅ \`team_recall\` — shows reviewer badges and sorts confirmed lessons first`,
16
+ ` 💡 Run \`team_confirm(topic, reviewer, level="senior")\` to bless the canonical solution`,
17
+ ],
18
+ '0.10.50': [
19
+ ` ✅ \`smart_recall\` — unified keyword + semantic hybrid list (one ranked result, not two sections)`,
20
+ ` ✅ \`smart_recall\` — "Brain saved you here" banner surfaces time saved inline per proven lesson`,
21
+ ` ✅ Quality reranking — proven successes outrank symptom-dense failures (+11% Precision@1)`,
22
+ ` ✅ Cachly-Bench — IR benchmark with CI regression guard (see BENCH.md for the proof)`,
23
+ ` 🔧 Contradiction resolution now persisted to Redis (90-day TTL)`,
24
+ ` 💡 Run \`smart_recall\` to see your Brain's recall quality in action`,
25
+ ],
26
+ '0.10.49': [
12
27
  ` ✅ \`brain_from_git\` — auto-seed your Brain from git history in seconds`,
13
28
  ` ✅ \`brain_predict_failures\` — predict CI/build failures before they happen`,
14
- ` ✅ \`smart_recall\` — semantic + keyword hybrid recall, now in every CLAUDE.md`,
15
29
  ` ✅ VS Code extension — Brain status bar, ambient learning, CodeLens hints`,
16
30
  ` ✅ Team Brain — share lessons across your whole engineering team`,
17
- ` ✅ 89 MCP tools — roadmap, A/B tests, cache, index, predict, syndicate`,
18
31
  ` 🔧 Stability — auto-expiring memory keys, longer provisioning wait, robust recall`,
19
- ` 💡 Run \`brain_from_git\` to seed your Brain from existing commits instantly`,
20
32
  ],
21
33
  };
22
34
  export const BRAIN_TOOL_NAMES = new Set([
@@ -72,6 +84,19 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
72
84
  await ckgUpdateEdge(redis, cId, 'contradicts', resId, false);
73
85
  }
74
86
  catch { /* non-critical */ }
87
+ // Persist a queryable resolution record — so contradiction history survives
88
+ // (CKG edges alone aren't easily auditable). Bounded + TTL'd like other history.
89
+ try {
90
+ const resolutionKey = `cachly:contradictions:${topic}`;
91
+ const direction = prev.outcome === 'success' && outcome === 'failure'
92
+ ? 'kept-success' : 'overwrote-failure';
93
+ await redis.rpush(resolutionKey, JSON.stringify({
94
+ ts, prev_outcome: prev.outcome, new_outcome: outcome, direction,
95
+ }));
96
+ await redis.ltrim(resolutionKey, -50, -1);
97
+ await redis.expire(resolutionKey, 180 * 86400);
98
+ }
99
+ catch { /* non-critical */ }
75
100
  contradictionWarning.push(`🗳️ Run \`madc_deliberate(topic="${topic}")\` to resolve via expert agent voting.`);
76
101
  }
77
102
  }
@@ -411,9 +436,11 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
411
436
  const { instance_id, query, threshold = 0.78, } = args;
412
437
  const redis = await getConnection(instance_id);
413
438
  // ── Layer 1: Keyword search across ALL brain data (always works, no embedding) ──
414
- const kwMatches = await keywordSearch(redis, ['cachly:ctx:*', 'cachly:lesson:best:*', 'cachly:idx:*'], query, 10);
415
- // Increment recall_count on matched lessons (fire-and-forget — same as recall_best_solution).
416
- // This ensures the dashboard metric, Proven Laws, and trust badges reflect real smart_recall usage.
439
+ const rawMatches = await keywordSearch(redis, ['cachly:ctx:*', 'cachly:lesson:best:*', 'cachly:idx:*'], query, 10);
440
+ // ── Layer 1.5: Quality-aware rerank — proven success lessons outrank
441
+ // text-similar failed attempts (the moat; see src/rerank.ts + Cachly-Bench). ──
442
+ const kwMatches = rerankByQuality(rawMatches);
443
+ const savedHere = [];
417
444
  const lessonMatches = kwMatches.filter(m => m.key.startsWith('cachly:lesson:best:'));
418
445
  for (const m of lessonMatches.slice(0, 5)) {
419
446
  const existing = await redis.get(m.key).catch(() => null);
@@ -422,41 +449,118 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
422
449
  if (lesson) {
423
450
  const updated = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1, verified_at: new Date().toISOString() };
424
451
  redis.set(m.key, JSON.stringify(updated)).catch(() => { });
425
- const savedMins = lesson.severity === 'critical' ? 240 : lesson.severity === 'major' ? 60 : 30;
452
+ const sev = lesson.severity;
453
+ const savedMins = sev === 'critical' ? 240 : sev === 'major' ? 60 : 30;
426
454
  redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => { });
455
+ // Surface banner for proven successes (recall_count >= 1 means it's been validated)
456
+ if (lesson.outcome === 'success' && (lesson.recall_count ?? 0) >= 1) {
457
+ savedHere.push({
458
+ topic: m.key.replace('cachly:lesson:best:', ''),
459
+ severity: sev ?? 'major',
460
+ recall_count: (lesson.recall_count ?? 0) + 1,
461
+ savedMins,
462
+ });
463
+ }
427
464
  }
428
465
  }
429
466
  }
467
+ // ── Layer 2: Semantic search (parallel, optional) ────────────────────────
468
+ const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
469
+ const semHits = [];
470
+ if (inst.vector_token && hasEmbedProvider()) {
471
+ try {
472
+ const embedding = await computeEmbedding(query);
473
+ const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
474
+ const searchRes = await fetch(`${vectorUrl}/search`, {
475
+ method: 'POST',
476
+ headers: { 'Content-Type': 'application/json' },
477
+ body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
478
+ });
479
+ if (searchRes.ok) {
480
+ const results = (await searchRes.json());
481
+ for (const hit of results.filter(r => r.found && r.id)) {
482
+ const parts = hit.id.replace('ctx:', '').split(':');
483
+ const category = parts[0];
484
+ const key = parts.slice(1).join(':');
485
+ const redisKey = `cachly:ctx:${category}:${key}`;
486
+ const content = await redis.get(redisKey).catch(() => null);
487
+ semHits.push({ key: redisKey, similarity: hit.similarity ?? 0, content: content ?? '(evicted)' });
488
+ }
489
+ }
490
+ }
491
+ catch {
492
+ // Semantic search failed silently — keyword results are enough
493
+ }
494
+ }
495
+ const bm25Scores = kwMatches.map(m => m.score);
496
+ const bm25Min = bm25Scores.length ? Math.min(...bm25Scores) : 0;
497
+ const bm25Range = bm25Scores.length ? (Math.max(...bm25Scores) - bm25Min) || 1 : 1;
498
+ const bm25Norm = (s) => (s - bm25Min) / bm25Range;
499
+ const hybridMap = new Map();
500
+ for (const m of kwMatches) {
501
+ const n = bm25Norm(m.score);
502
+ hybridMap.set(m.key, {
503
+ key: m.key, content: m.content, bm25Score: n,
504
+ hybridScore: n * (semHits.length > 0 ? 0.7 : 1.0),
505
+ matchedWords: m.matchedWords, matchType: 'keyword', subQuery: m.subQuery,
506
+ });
507
+ }
508
+ for (const hit of semHits) {
509
+ const existing = hybridMap.get(hit.key);
510
+ if (existing) {
511
+ existing.semScore = hit.similarity;
512
+ existing.hybridScore = (existing.bm25Score ?? 0) * 0.6 + hit.similarity * 0.4;
513
+ existing.matchType = 'hybrid';
514
+ }
515
+ else {
516
+ hybridMap.set(hit.key, {
517
+ key: hit.key, content: hit.content, semScore: hit.similarity,
518
+ hybridScore: hit.similarity * 0.5, matchType: 'semantic',
519
+ });
520
+ }
521
+ }
522
+ const hybridResults = [...hybridMap.values()].sort((a, b) => b.hybridScore - a.hybridScore);
523
+ // ── Build output ──────────────────────────────────────────────────────────
430
524
  const lines = [`🧠 **Smart Recall** for: _"${query}"_\n`];
525
+ // "Brain saved you here" banner — surfaces value inline for proven recalled lessons
526
+ const SORDER = { critical: 0, major: 1, minor: 2 };
527
+ const topLesson = savedHere.sort((a, b) => (SORDER[a.severity] ?? 1) - (SORDER[b.severity] ?? 1))[0];
528
+ if (topLesson) {
529
+ lines.push(`> 💡 **Brain saved you ~${topLesson.savedMins}m here** — ${topLesson.severity} issue from \`${topLesson.topic}\` recalled ${topLesson.recall_count}× proven\n`);
530
+ }
431
531
  // Show sub-query info if multi-topic was detected
432
532
  const subQueries = splitMultiQuery(query);
433
533
  if (subQueries.length > 1) {
434
534
  lines.push(`_Detected ${subQueries.length} sub-topics:_ ${subQueries.map((s, i) => `${i + 1}. "${s}"`).join(', ')}\n`);
435
535
  }
436
- if (kwMatches.length > 0) {
437
- lines.push(`### 🔍 BM25 Matches (${kwMatches.length})\n`);
438
- // Group by sub-query if multi-topic
536
+ if (hybridResults.length > 0) {
537
+ const modeLabel = semHits.length > 0
538
+ ? `keyword + semantic hybrid`
539
+ : `keyword`;
540
+ lines.push(`### 🔍 Results (${hybridResults.length} — ${modeLabel})\n`);
541
+ // Group by sub-query for multi-topic queries
439
542
  if (subQueries.length > 1) {
440
543
  const grouped = new Map();
441
- for (const m of kwMatches.slice(0, 12)) {
442
- const sq = m.subQuery ?? query;
544
+ for (const r of hybridResults.slice(0, 12)) {
545
+ const sq = r.subQuery ?? query;
443
546
  if (!grouped.has(sq))
444
547
  grouped.set(sq, []);
445
- grouped.get(sq).push(m);
548
+ grouped.get(sq).push(r);
446
549
  }
447
- for (const [sq, matches] of grouped) {
448
- lines.push(`**Topic: "${sq}"** (${matches.length} results)\n`);
449
- for (const m of matches.slice(0, 4)) {
450
- const label = m.key
451
- .replace('cachly:ctx:', '📝 ')
452
- .replace('cachly:lesson:best:', '💡 ')
453
- .replace('cachly:idx:', '📂 ');
454
- const preview = m.content.slice(0, 300).replace(/\n/g, ' ');
455
- lines.push(` **${label}** _(BM25: ${m.score.toFixed(2)}, matched: ${m.matchedWords.join(', ')})_`);
456
- lines.push(` > ${preview}${m.content.length > 300 ? '…' : ''}\n`);
550
+ for (const [sq, results] of grouped) {
551
+ lines.push(`**Topic: "${sq}"** (${results.length} results)\n`);
552
+ for (const r of results.slice(0, 4)) {
553
+ const label = r.key.replace('cachly:ctx:', '📝 ').replace('cachly:lesson:best:', '💡 ').replace('cachly:idx:', '📂 ');
554
+ const scorePart = r.matchType === 'hybrid'
555
+ ? `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🔀 hybrid`
556
+ : r.matchType === 'semantic'
557
+ ? `sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🎯 semantic`
558
+ : `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, matched: ${r.matchedWords?.join(', ')}`;
559
+ const preview = r.content.slice(0, 300).replace(/\n/g, ' ');
560
+ lines.push(` **${label}** _(${scorePart})_`);
561
+ lines.push(` > ${preview}${r.content.length > 300 ? '…' : ''}\n`);
457
562
  }
458
563
  }
459
- // Summary: which sub-queries had matches
460
564
  const matched = [...grouped.keys()];
461
565
  const unmatched = subQueries.filter(sq => !matched.includes(sq));
462
566
  if (unmatched.length > 0) {
@@ -464,48 +568,20 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
464
568
  }
465
569
  }
466
570
  else {
467
- for (const m of kwMatches.slice(0, 8)) {
468
- const label = m.key
469
- .replace('cachly:ctx:', '📝 ')
470
- .replace('cachly:lesson:best:', '💡 ')
471
- .replace('cachly:idx:', '📂 ');
472
- const preview = m.content.slice(0, 400).replace(/\n/g, ' ');
473
- lines.push(`**${label}** _(BM25: ${m.score.toFixed(2)}, matched: ${m.matchedWords.join(', ')})_`);
474
- lines.push(`> ${preview}${m.content.length > 400 ? '…' : ''}\n`);
475
- }
476
- }
477
- }
478
- // ── Layer 2: Semantic search (optional, only if embedding provider + vector_token available) ──
479
- const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
480
- if (inst.vector_token && hasEmbedProvider()) {
481
- try {
482
- const embedding = await computeEmbedding(query);
483
- const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
484
- const searchRes = await fetch(`${vectorUrl}/search`, {
485
- method: 'POST',
486
- headers: { 'Content-Type': 'application/json' },
487
- body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
488
- });
489
- if (searchRes.ok) {
490
- const results = (await searchRes.json());
491
- const semHits = results.filter(r => r.found && r.id);
492
- if (semHits.length > 0) {
493
- lines.push(`\n### 🎯 Semantic Matches (${semHits.length})\n`);
494
- for (const hit of semHits) {
495
- const parts = hit.id.replace('ctx:', '').split(':');
496
- const category = parts[0];
497
- const key = parts.slice(1).join(':');
498
- const content = await redis.get(`cachly:ctx:${category}:${key}`);
499
- lines.push(`**${key}** _(${((hit.similarity ?? 0) * 100).toFixed(0)}% similar)_`, `> ${content?.slice(0, 300) ?? '(evicted)'}${(content?.length ?? 0) > 300 ? '…' : ''}\n`);
500
- }
501
- }
571
+ for (const r of hybridResults.slice(0, 8)) {
572
+ const label = r.key.replace('cachly:ctx:', '📝 ').replace('cachly:lesson:best:', '💡 ').replace('cachly:idx:', '📂 ');
573
+ const scorePart = r.matchType === 'hybrid'
574
+ ? `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🔀 hybrid`
575
+ : r.matchType === 'semantic'
576
+ ? `sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🎯 semantic`
577
+ : `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, matched: ${r.matchedWords?.join(', ')}`;
578
+ const preview = r.content.slice(0, 400).replace(/\n/g, ' ');
579
+ lines.push(`**${label}** _(${scorePart})_`);
580
+ lines.push(`> ${preview}${r.content.length > 400 ? '…' : ''}\n`);
502
581
  }
503
582
  }
504
- catch {
505
- // Semantic search failed silently — keyword results are enough
506
- }
507
583
  }
508
- if (kwMatches.length === 0) {
584
+ else {
509
585
  lines.push(`⚠️ No matches found for: "${query}"`);
510
586
  // Did-You-Mean: find nearest token in index vocab
511
587
  const queryTokens = tokenize(query);