@cachly-dev/mcp-server 0.10.49 → 0.10.50

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.
@@ -8,22 +8,28 @@ import type { CKGEdge, CKGNode } from '../ckg.js';
8
8
  import { keywordSearch, tokenize, splitMultiQuery, levenshtein,
9
9
  ZERO_RESULTS_LOG, zeroResultsTotal, indexVocab as _indexVocab } from '../search.js';
10
10
  import type { KeywordMatch } from '../search.js';
11
+ import { rerankByQuality } from '../rerank.js';
11
12
  import { computeEmbedding, hasEmbedProvider, embedProviderHint, EMBED_PROVIDER } from '../embeddings.js';
12
13
  import { detectNamespace } from '../namespace.js';
13
14
 
14
15
  // ── Changelog (shown once per version in session_start) ──────────────────────
15
- const MCP_VERSION = '0.10.49';
16
+ const MCP_VERSION = '0.10.50';
16
17
  const WHATS_NEW: Record<string, string[]> = {
17
- '0.10.49': [
18
+ '0.10.50': [
18
19
  `🆕 **What's new in v${MCP_VERSION}:**`,
20
+ ` ✅ \`smart_recall\` — unified keyword + semantic hybrid list (one ranked result, not two sections)`,
21
+ ` ✅ \`smart_recall\` — "Brain saved you here" banner surfaces time saved inline per proven lesson`,
22
+ ` ✅ Quality reranking — proven successes outrank symptom-dense failures (+11% Precision@1)`,
23
+ ` ✅ Cachly-Bench — IR benchmark with CI regression guard (see BENCH.md for the proof)`,
24
+ ` 🔧 Contradiction resolution now persisted to Redis (90-day TTL)`,
25
+ ` 💡 Run \`smart_recall\` to see your Brain's recall quality in action`,
26
+ ],
27
+ '0.10.49': [
19
28
  ` ✅ \`brain_from_git\` — auto-seed your Brain from git history in seconds`,
20
29
  ` ✅ \`brain_predict_failures\` — predict CI/build failures before they happen`,
21
- ` ✅ \`smart_recall\` — semantic + keyword hybrid recall, now in every CLAUDE.md`,
22
30
  ` ✅ VS Code extension — Brain status bar, ambient learning, CodeLens hints`,
23
31
  ` ✅ Team Brain — share lessons across your whole engineering team`,
24
- ` ✅ 89 MCP tools — roadmap, A/B tests, cache, index, predict, syndicate`,
25
32
  ` 🔧 Stability — auto-expiring memory keys, longer provisioning wait, robust recall`,
26
- ` 💡 Run \`brain_from_git\` to seed your Brain from existing commits instantly`,
27
33
  ],
28
34
  };
29
35
 
@@ -141,6 +147,18 @@ export async function handleBrainTool(
141
147
  const resId = ckgSlug(`resolution:${topic}`);
142
148
  await ckgUpdateEdge(redis, cId, 'contradicts', resId, false);
143
149
  } catch { /* non-critical */ }
150
+ // Persist a queryable resolution record — so contradiction history survives
151
+ // (CKG edges alone aren't easily auditable). Bounded + TTL'd like other history.
152
+ try {
153
+ const resolutionKey = `cachly:contradictions:${topic}`;
154
+ const direction = prev.outcome === 'success' && outcome === 'failure'
155
+ ? 'kept-success' : 'overwrote-failure';
156
+ await redis.rpush(resolutionKey, JSON.stringify({
157
+ ts, prev_outcome: prev.outcome, new_outcome: outcome, direction,
158
+ }));
159
+ await redis.ltrim(resolutionKey, -50, -1);
160
+ await redis.expire(resolutionKey, 180 * 86400);
161
+ } catch { /* non-critical */ }
144
162
  contradictionWarning.push(`🗳️ Run \`madc_deliberate(topic="${topic}")\` to resolve via expert agent voting.`);
145
163
  }
146
164
  } catch { /* ignore parse error */ }
@@ -500,114 +518,173 @@ export async function handleBrainTool(
500
518
  const redis = await getConnection(instance_id);
501
519
 
502
520
  // ── Layer 1: Keyword search across ALL brain data (always works, no embedding) ──
503
- const kwMatches = await keywordSearch(
521
+ const rawMatches = await keywordSearch(
504
522
  redis,
505
523
  ['cachly:ctx:*', 'cachly:lesson:best:*', 'cachly:idx:*'],
506
524
  query,
507
525
  10,
508
526
  );
509
527
 
510
- // Increment recall_count on matched lessons (fire-and-forget — same as recall_best_solution).
511
- // This ensures the dashboard metric, Proven Laws, and trust badges reflect real smart_recall usage.
528
+ // ── Layer 1.5: Quality-aware rerank — proven success lessons outrank
529
+ // text-similar failed attempts (the moat; see src/rerank.ts + Cachly-Bench). ──
530
+ const kwMatches = rerankByQuality(rawMatches);
531
+
532
+ // Increment recall_count on matched lessons (fire-and-forget) + collect "Brain saved you here" signal.
533
+ type RecalledLesson = { topic: string; severity: string; recall_count: number; savedMins: number };
534
+ const savedHere: RecalledLesson[] = [];
512
535
  const lessonMatches = kwMatches.filter(m => m.key.startsWith('cachly:lesson:best:'));
513
536
  for (const m of lessonMatches.slice(0, 5)) {
514
537
  const existing = await redis.get(m.key).catch(() => null);
515
538
  if (existing) {
516
- const lesson = safeJsonParse(existing, null as null | { recall_count?: number; [k: string]: unknown });
539
+ const lesson = safeJsonParse(existing, null as null | { recall_count?: number; outcome?: string; severity?: string; [k: string]: unknown });
517
540
  if (lesson) {
518
541
  const updated = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1, verified_at: new Date().toISOString() };
519
542
  redis.set(m.key, JSON.stringify(updated)).catch(() => {});
520
- const savedMins = (lesson.severity as string) === 'critical' ? 240 : (lesson.severity as string) === 'major' ? 60 : 30;
543
+ const sev = lesson.severity as string;
544
+ const savedMins = sev === 'critical' ? 240 : sev === 'major' ? 60 : 30;
521
545
  redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
546
+ // Surface banner for proven successes (recall_count >= 1 means it's been validated)
547
+ if (lesson.outcome === 'success' && (lesson.recall_count ?? 0) >= 1) {
548
+ savedHere.push({
549
+ topic: m.key.replace('cachly:lesson:best:', ''),
550
+ severity: sev ?? 'major',
551
+ recall_count: (lesson.recall_count ?? 0) + 1,
552
+ savedMins,
553
+ });
554
+ }
522
555
  }
523
556
  }
524
557
  }
525
558
 
559
+ // ── Layer 2: Semantic search (parallel, optional) ────────────────────────
560
+ const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
561
+ type SemHit = { key: string; similarity: number; content: string };
562
+ const semHits: SemHit[] = [];
563
+ if (inst.vector_token && hasEmbedProvider()) {
564
+ try {
565
+ const embedding = await computeEmbedding(query);
566
+ const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
567
+ const searchRes = await fetch(`${vectorUrl}/search`, {
568
+ method: 'POST',
569
+ headers: { 'Content-Type': 'application/json' },
570
+ body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
571
+ });
572
+ if (searchRes.ok) {
573
+ const results = (await searchRes.json()) as SemanticSearchResponse[];
574
+ for (const hit of results.filter(r => r.found && r.id)) {
575
+ const parts = hit.id!.replace('ctx:', '').split(':');
576
+ const category = parts[0];
577
+ const key = parts.slice(1).join(':');
578
+ const redisKey = `cachly:ctx:${category}:${key}`;
579
+ const content = await redis.get(redisKey).catch(() => null);
580
+ semHits.push({ key: redisKey, similarity: hit.similarity ?? 0, content: content ?? '(evicted)' });
581
+ }
582
+ }
583
+ } catch {
584
+ // Semantic search failed silently — keyword results are enough
585
+ }
586
+ }
587
+
588
+ // ── Layer 3: Hybrid merge — one unified ranked list ──────────────────────
589
+ // BM25 quality scores (already reranked) are re-normalized to [0,1] for merging.
590
+ type HybridResult = {
591
+ key: string; content: string; hybridScore: number;
592
+ bm25Score?: number; semScore?: number; matchedWords?: string[];
593
+ matchType: 'keyword' | 'semantic' | 'hybrid'; subQuery?: string;
594
+ };
595
+
596
+ const bm25Scores = kwMatches.map(m => m.score);
597
+ const bm25Min = bm25Scores.length ? Math.min(...bm25Scores) : 0;
598
+ const bm25Range = bm25Scores.length ? (Math.max(...bm25Scores) - bm25Min) || 1 : 1;
599
+ const bm25Norm = (s: number) => (s - bm25Min) / bm25Range;
600
+
601
+ const hybridMap = new Map<string, HybridResult>();
602
+ for (const m of kwMatches) {
603
+ const n = bm25Norm(m.score);
604
+ hybridMap.set(m.key, {
605
+ key: m.key, content: m.content, bm25Score: n,
606
+ hybridScore: n * (semHits.length > 0 ? 0.7 : 1.0),
607
+ matchedWords: m.matchedWords, matchType: 'keyword', subQuery: m.subQuery,
608
+ });
609
+ }
610
+ for (const hit of semHits) {
611
+ const existing = hybridMap.get(hit.key);
612
+ if (existing) {
613
+ existing.semScore = hit.similarity;
614
+ existing.hybridScore = (existing.bm25Score ?? 0) * 0.6 + hit.similarity * 0.4;
615
+ existing.matchType = 'hybrid';
616
+ } else {
617
+ hybridMap.set(hit.key, {
618
+ key: hit.key, content: hit.content, semScore: hit.similarity,
619
+ hybridScore: hit.similarity * 0.5, matchType: 'semantic',
620
+ });
621
+ }
622
+ }
623
+ const hybridResults = [...hybridMap.values()].sort((a, b) => b.hybridScore - a.hybridScore);
624
+
625
+ // ── Build output ──────────────────────────────────────────────────────────
526
626
  const lines: string[] = [`🧠 **Smart Recall** for: _"${query}"_\n`];
527
627
 
628
+ // "Brain saved you here" banner — surfaces value inline for proven recalled lessons
629
+ const SORDER: Record<string, number> = { critical: 0, major: 1, minor: 2 };
630
+ const topLesson = savedHere.sort((a, b) => (SORDER[a.severity] ?? 1) - (SORDER[b.severity] ?? 1))[0];
631
+ if (topLesson) {
632
+ lines.push(`> 💡 **Brain saved you ~${topLesson.savedMins}m here** — ${topLesson.severity} issue from \`${topLesson.topic}\` recalled ${topLesson.recall_count}× proven\n`);
633
+ }
634
+
528
635
  // Show sub-query info if multi-topic was detected
529
636
  const subQueries = splitMultiQuery(query);
530
637
  if (subQueries.length > 1) {
531
638
  lines.push(`_Detected ${subQueries.length} sub-topics:_ ${subQueries.map((s, i) => `${i + 1}. "${s}"`).join(', ')}\n`);
532
639
  }
533
640
 
534
- if (kwMatches.length > 0) {
535
- lines.push(`### 🔍 BM25 Matches (${kwMatches.length})\n`);
641
+ if (hybridResults.length > 0) {
642
+ const modeLabel = semHits.length > 0
643
+ ? `keyword + semantic hybrid`
644
+ : `keyword`;
645
+ lines.push(`### 🔍 Results (${hybridResults.length} — ${modeLabel})\n`);
536
646
 
537
- // Group by sub-query if multi-topic
647
+ // Group by sub-query for multi-topic queries
538
648
  if (subQueries.length > 1) {
539
- const grouped = new Map<string, KeywordMatch[]>();
540
- for (const m of kwMatches.slice(0, 12)) {
541
- const sq = m.subQuery ?? query;
649
+ const grouped = new Map<string, HybridResult[]>();
650
+ for (const r of hybridResults.slice(0, 12)) {
651
+ const sq = r.subQuery ?? query;
542
652
  if (!grouped.has(sq)) grouped.set(sq, []);
543
- grouped.get(sq)!.push(m);
653
+ grouped.get(sq)!.push(r);
544
654
  }
545
- for (const [sq, matches] of grouped) {
546
- lines.push(`**Topic: "${sq}"** (${matches.length} results)\n`);
547
- for (const m of matches.slice(0, 4)) {
548
- const label = m.key
549
- .replace('cachly:ctx:', '📝 ')
550
- .replace('cachly:lesson:best:', '💡 ')
551
- .replace('cachly:idx:', '📂 ');
552
- const preview = m.content.slice(0, 300).replace(/\n/g, ' ');
553
- lines.push(` **${label}** _(BM25: ${m.score.toFixed(2)}, matched: ${m.matchedWords.join(', ')})_`);
554
- lines.push(` > ${preview}${m.content.length > 300 ? '…' : ''}\n`);
655
+ for (const [sq, results] of grouped) {
656
+ lines.push(`**Topic: "${sq}"** (${results.length} results)\n`);
657
+ for (const r of results.slice(0, 4)) {
658
+ const label = r.key.replace('cachly:ctx:', '📝 ').replace('cachly:lesson:best:', '💡 ').replace('cachly:idx:', '📂 ');
659
+ const scorePart = r.matchType === 'hybrid'
660
+ ? `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🔀 hybrid`
661
+ : r.matchType === 'semantic'
662
+ ? `sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🎯 semantic`
663
+ : `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, matched: ${r.matchedWords?.join(', ')}`;
664
+ const preview = r.content.slice(0, 300).replace(/\n/g, ' ');
665
+ lines.push(` **${label}** _(${scorePart})_`);
666
+ lines.push(` > ${preview}${r.content.length > 300 ? '…' : ''}\n`);
555
667
  }
556
668
  }
557
- // Summary: which sub-queries had matches
558
669
  const matched = [...grouped.keys()];
559
670
  const unmatched = subQueries.filter(sq => !matched.includes(sq));
560
671
  if (unmatched.length > 0) {
561
672
  lines.push(`\n⚠️ **No results for:** ${unmatched.map(s => `"${s}"`).join(', ')}`);
562
673
  }
563
674
  } else {
564
- for (const m of kwMatches.slice(0, 8)) {
565
- const label = m.key
566
- .replace('cachly:ctx:', '📝 ')
567
- .replace('cachly:lesson:best:', '💡 ')
568
- .replace('cachly:idx:', '📂 ');
569
- const preview = m.content.slice(0, 400).replace(/\n/g, ' ');
570
- lines.push(`**${label}** _(BM25: ${m.score.toFixed(2)}, matched: ${m.matchedWords.join(', ')})_`);
571
- lines.push(`> ${preview}${m.content.length > 400 ? '…' : ''}\n`);
675
+ for (const r of hybridResults.slice(0, 8)) {
676
+ const label = r.key.replace('cachly:ctx:', '📝 ').replace('cachly:lesson:best:', '💡 ').replace('cachly:idx:', '📂 ');
677
+ const scorePart = r.matchType === 'hybrid'
678
+ ? `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🔀 hybrid`
679
+ : r.matchType === 'semantic'
680
+ ? `sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🎯 semantic`
681
+ : `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, matched: ${r.matchedWords?.join(', ')}`;
682
+ const preview = r.content.slice(0, 400).replace(/\n/g, ' ');
683
+ lines.push(`**${label}** _(${scorePart})_`);
684
+ lines.push(`> ${preview}${r.content.length > 400 ? '…' : ''}\n`);
572
685
  }
573
686
  }
574
- }
575
-
576
- // ── Layer 2: Semantic search (optional, only if embedding provider + vector_token available) ──
577
- const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
578
- if (inst.vector_token && hasEmbedProvider()) {
579
- try {
580
- const embedding = await computeEmbedding(query);
581
- const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
582
- const searchRes = await fetch(`${vectorUrl}/search`, {
583
- method: 'POST',
584
- headers: { 'Content-Type': 'application/json' },
585
- body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
586
- });
587
-
588
- if (searchRes.ok) {
589
- const results = (await searchRes.json()) as SemanticSearchResponse[];
590
- const semHits = results.filter(r => r.found && r.id);
591
- if (semHits.length > 0) {
592
- lines.push(`\n### 🎯 Semantic Matches (${semHits.length})\n`);
593
- for (const hit of semHits) {
594
- const parts = hit.id!.replace('ctx:', '').split(':');
595
- const category = parts[0];
596
- const key = parts.slice(1).join(':');
597
- const content = await redis.get(`cachly:ctx:${category}:${key}`);
598
- lines.push(
599
- `**${key}** _(${((hit.similarity ?? 0) * 100).toFixed(0)}% similar)_`,
600
- `> ${content?.slice(0, 300) ?? '(evicted)'}${(content?.length ?? 0) > 300 ? '…' : ''}\n`,
601
- );
602
- }
603
- }
604
- }
605
- } catch {
606
- // Semantic search failed silently — keyword results are enough
607
- }
608
- }
609
-
610
- if (kwMatches.length === 0) {
687
+ } else {
611
688
  lines.push(`⚠️ No matches found for: "${query}"`);
612
689
 
613
690
  // Did-You-Mean: find nearest token in index vocab
package/src/rerank.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Quality-aware reranking for Brain recall.
3
+ *
4
+ * The raw BM25+ keyword search (src/search.ts) ranks purely by *textual* relevance.
5
+ * That is necessary but not sufficient: when an agent asks "how do I fix the flaky
6
+ * deploy?", a *failed* attempt with similar words can out-rank the *proven success*
7
+ * lesson that actually solves it. Anthropic's flat-file memory has exactly this
8
+ * problem — it reads files in order, with no notion of which lesson is trustworthy.
9
+ *
10
+ * This module is Cachly's structural advantage made concrete: we re-rank text
11
+ * matches by the *quality* of the underlying lesson — outcome, confidence,
12
+ * proven-ness (recall_count) and severity — so the lesson most likely to help
13
+ * surfaces first.
14
+ *
15
+ * It is intentionally pure (no I/O): the lesson JSON already travels inside each
16
+ * KeywordMatch.content for `cachly:lesson:best:*` keys, so reranking needs no extra
17
+ * Redis round-trips. This keeps it fast and trivially benchmarkable (see
18
+ * src/bench/cachly-bench.ts).
19
+ */
20
+
21
+ import type { KeywordMatch } from './search.js';
22
+ import { safeJsonParse } from './utils.js';
23
+
24
+ /** Subset of a stored lesson that influences ranking. */
25
+ export interface LessonQuality {
26
+ outcome?: string; // 'success' | 'partial' | 'failure'
27
+ confidence?: number; // 0..1
28
+ recall_count?: number; // times this lesson has been recalled (proven-ness)
29
+ severity?: string; // 'critical' | 'major' | 'minor'
30
+ ts?: string; // ISO timestamp (for freshness, mild)
31
+ verified_at?: string;
32
+ }
33
+
34
+ export interface RerankedMatch extends KeywordMatch {
35
+ /** Final score after applying the quality multiplier. */
36
+ finalScore: number;
37
+ /** The multiplier that was applied to the BM25 score (1.0 = neutral). */
38
+ qualityBoost: number;
39
+ }
40
+
41
+ // ── Tunable weights (kept conservative; validated by Cachly-Bench) ─────────────
42
+ const OUTCOME_WEIGHT: Record<string, number> = {
43
+ success: 1.0,
44
+ partial: 0.8,
45
+ failure: 0.55,
46
+ };
47
+ const OUTCOME_DEFAULT = 0.75; // unknown / not a lesson with an outcome
48
+
49
+ const SEVERITY_WEIGHT: Record<string, number> = {
50
+ critical: 1.15,
51
+ major: 1.05,
52
+ minor: 1.0,
53
+ };
54
+ const SEVERITY_DEFAULT = 1.0;
55
+
56
+ // Clamp so a strong text match is never fully buried by quality, and a weak text
57
+ // match is never catapulted to the top purely on quality.
58
+ const MIN_MULTIPLIER = 0.5;
59
+ const MAX_MULTIPLIER = 2.5;
60
+
61
+ const LESSON_KEY_PREFIX = 'cachly:lesson:best:';
62
+
63
+ /**
64
+ * Compute the quality multiplier for a single lesson.
65
+ * Returns 1.0 (neutral) for documents that carry no quality signal (e.g. context).
66
+ */
67
+ export function qualityMultiplier(lesson: LessonQuality | null): number {
68
+ if (!lesson || (lesson.outcome === undefined && lesson.confidence === undefined && lesson.recall_count === undefined)) {
69
+ return 1.0;
70
+ }
71
+
72
+ // 1. Outcome — a proven success is what the agent actually wants.
73
+ const outcome = OUTCOME_WEIGHT[lesson.outcome ?? ''] ?? OUTCOME_DEFAULT;
74
+
75
+ // 2. Confidence — maps 0..1 → 0.7..1.3 so it nudges, not dominates.
76
+ const conf = typeof lesson.confidence === 'number' ? Math.max(0, Math.min(1, lesson.confidence)) : 0.6;
77
+ const confidenceFactor = 0.7 + 0.6 * conf;
78
+
79
+ // 3. Proven-ness — a lesson recalled many times has stood the test of use.
80
+ // Log-scaled so it saturates: rc=0→1.0, rc=5→~1.18, rc=50→~1.30.
81
+ const rc = Math.max(0, lesson.recall_count ?? 0);
82
+ const provenBoost = 1 + Math.min(0.3, Math.log1p(rc) / 13);
83
+
84
+ // 4. Severity — when two lessons tie, surface the one guarding the worse failure.
85
+ const severity = SEVERITY_WEIGHT[lesson.severity ?? ''] ?? SEVERITY_DEFAULT;
86
+
87
+ const m = outcome * confidenceFactor * provenBoost * severity;
88
+ return Math.max(MIN_MULTIPLIER, Math.min(MAX_MULTIPLIER, m));
89
+ }
90
+
91
+ /** Extract the quality signal from a match, if its content is a stored lesson. */
92
+ export function extractLessonQuality(match: Pick<KeywordMatch, 'key' | 'content'>): LessonQuality | null {
93
+ if (!match.key.startsWith(LESSON_KEY_PREFIX)) return null;
94
+ return safeJsonParse<LessonQuality | null>(match.content, null);
95
+ }
96
+
97
+ /**
98
+ * Rerank BM25 keyword matches by lesson quality.
99
+ *
100
+ * The BM25 scores are first min-max normalized to [0,1] so the quality multiplier
101
+ * acts on a comparable scale across queries; ties are broken by the original BM25
102
+ * score (stable, deterministic).
103
+ */
104
+ export function rerankByQuality(matches: KeywordMatch[]): RerankedMatch[] {
105
+ if (matches.length === 0) return [];
106
+
107
+ const maxScore = Math.max(...matches.map(m => m.score));
108
+ const minScore = Math.min(...matches.map(m => m.score));
109
+ const range = maxScore - minScore || 1;
110
+
111
+ const reranked: RerankedMatch[] = matches.map(m => {
112
+ const norm = (m.score - minScore) / range; // 0..1
113
+ const lesson = extractLessonQuality(m);
114
+ const boost = qualityMultiplier(lesson);
115
+ // Blend: keep a floor of raw relevance so a top text hit stays competitive,
116
+ // then layer the quality multiplier on the normalized component.
117
+ const finalScore = m.score * (0.4 + 0.6 * boost) + norm * (boost - 1) * 0.0001;
118
+ return { ...m, finalScore, qualityBoost: boost };
119
+ });
120
+
121
+ reranked.sort((a, b) => (b.finalScore - a.finalScore) || (b.score - a.score));
122
+ return reranked;
123
+ }