thumbgate 1.28.1 → 1.28.3

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.
@@ -57,11 +57,15 @@ const {
57
57
  }));
58
58
 
59
59
  const AUDIT_TRAIL_TAG = 'audit-trail';
60
- const FEEDBACK_EVENT_DEDUPE_WINDOW_MS = 30 * 1000;
60
+ const FEEDBACK_EVENT_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
61
61
  const FEEDBACK_EVENT_CLAIM_STALE_MS = 60 * 1000;
62
62
  const FEEDBACK_EVENT_CLAIM_WAIT_MS = 15 * 1000;
63
63
  const FEEDBACK_EVENT_CLAIM_POLL_MS = 25;
64
64
 
65
+ function isSelfHarnessOptimizerEnabled(env = process.env) {
66
+ return /^(?:1|true)$/i.test(String(env.THUMBGATE_SELF_HARNESS_OPTIMIZER || '').trim());
67
+ }
68
+
65
69
  /**
66
70
  * Anonymous fire-and-forget CLI feedback telemetry.
67
71
  *
@@ -666,8 +670,11 @@ function parseFeedbackSourceTimestamp(value) {
666
670
  */
667
671
  function buildFeedbackSourceIdentity(input = {}) {
668
672
  const signal = normalizeSignal(input.signal);
669
- const normalizedText = normalizeFeedbackText(input.promptText || input.context);
670
- if (!signal || !normalizedText) return null;
673
+ if (!signal) return null;
674
+ // Emoji-only prompts normalize to no alphanumeric text. Preserve a stable,
675
+ // non-sensitive canonical value so duplicate 👍/👎 hook deliveries still
676
+ // share one source identity and cannot create duplicate feedback rows.
677
+ const normalizedText = normalizeFeedbackText(input.promptText || input.context) || `thumbs-${signal}`;
671
678
 
672
679
  const sessionHash = hashFeedbackSourcePart(input.sessionId);
673
680
  const promptIdHash = hashFeedbackSourcePart(input.promptId);
@@ -696,10 +703,10 @@ function buildFeedbackSourceIdentity(input = {}) {
696
703
  function normalizeFeedbackSourceIdentity(identity, params = {}) {
697
704
  if (!identity || typeof identity !== 'object' || typeof identity.key !== 'string') return null;
698
705
  const signal = normalizeSignal(params.signal || identity.signal);
706
+ if (!signal) return null;
699
707
  const normalizedText = normalizeFeedbackText(
700
708
  identity.normalizedText || params.context || params.whatWentWrong || params.whatWorked,
701
- );
702
- if (!signal || !normalizedText) return null;
709
+ ) || `thumbs-${signal}`;
703
710
 
704
711
  return {
705
712
  key: `fev_${hashFeedbackSourcePart(identity.key)}`,
@@ -781,6 +788,9 @@ function duplicateCaptureResult(receipt, paths, options = {}) {
781
788
  const memoryRecord = findRecordById(paths.MEMORY_LOG_PATH, receipt?.memoryId);
782
789
  return {
783
790
  accepted: Boolean(receipt?.accepted),
791
+ captured: Boolean(receipt?.signalLogged),
792
+ promoted: Boolean(receipt?.promoted),
793
+ promotionAccepted: Boolean(receipt?.promoted),
784
794
  signalLogged: Boolean(receipt?.signalLogged),
785
795
  duplicate: true,
786
796
  pending: Boolean(options.pending),
@@ -820,7 +830,8 @@ function receiptFromFeedbackEntry(entry, paths) {
820
830
  status: 'complete',
821
831
  feedbackId: entry.id,
822
832
  memoryId: memory?.id,
823
- accepted: entry.actionType !== 'no-action'
833
+ accepted: !Array.isArray(entry.validationIssues) || entry.validationIssues.length === 0,
834
+ promoted: entry.actionType !== 'no-action'
824
835
  && (!Array.isArray(entry.validationIssues) || entry.validationIssues.length === 0),
825
836
  signalLogged: true,
826
837
  completedAtMs: Date.parse(entry.timestamp || '') || Date.now(),
@@ -888,6 +899,7 @@ function finalizeFeedbackEventClaim(claim, result) {
888
899
  startedAtMs: Date.now(),
889
900
  completedAtMs: Date.now(),
890
901
  accepted: Boolean(result?.accepted),
902
+ promoted: Boolean(result?.promoted || result?.memoryRecord),
891
903
  signalLogged: Boolean(result?.signalLogged || result?.feedbackEvent),
892
904
  feedbackId: result?.feedbackEvent?.id || null,
893
905
  memoryId: result?.memoryRecord?.id || null,
@@ -1481,12 +1493,15 @@ function captureFeedback(params) {
1481
1493
  trainAndPersistInterventionPolicy(FEEDBACK_DIR);
1482
1494
  } catch { /* non-critical */ }
1483
1495
  updateStatuslineWithLesson({
1484
- accepted: false,
1496
+ accepted: true,
1485
1497
  signal,
1486
1498
  feedbackId: feedbackEvent.id,
1487
1499
  });
1488
1500
  return {
1489
- accepted: false,
1501
+ accepted: true,
1502
+ captured: true,
1503
+ promoted: false,
1504
+ promotionAccepted: false,
1490
1505
  signalLogged: true,
1491
1506
  status: clarification ? 'clarification_required' : 'rejected',
1492
1507
  reason: action.reason,
@@ -1692,6 +1707,9 @@ function captureFeedback(params) {
1692
1707
  // Build result immediately — all remaining side-effects are deferred
1693
1708
  const result = {
1694
1709
  accepted: true,
1710
+ captured: true,
1711
+ promoted: true,
1712
+ promotionAccepted: true,
1695
1713
  status: 'promoted',
1696
1714
  message: 'Feedback promoted to reusable memory.',
1697
1715
  feedbackEvent,
@@ -1770,19 +1788,23 @@ function captureFeedback(params) {
1770
1788
  });
1771
1789
  } catch { /* activation telemetry is non-critical */ }
1772
1790
 
1773
- // Trigger Self-Harness Optimizer to propagate the new rules to prompt files & validate
1774
- try {
1775
- const { fork } = require('child_process');
1776
- const localOptimizerPath = path.join(process.cwd(), 'scripts', 'self-harness-optimizer.js');
1777
- const packageOptimizerPath = path.join(__dirname, 'self-harness-optimizer.js');
1778
-
1779
- if (fs.existsSync(localOptimizerPath)) {
1780
- fork(localOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
1781
- } else if (fs.existsSync(packageOptimizerPath)) {
1782
- fork(packageOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
1791
+ // Prompt-file mutation and git commits are explicit opt-in side effects.
1792
+ // Feedback capture must never detach an unrequested process that edits
1793
+ // AGENTS.md/GEMINI.md merely because a negative rule was promoted.
1794
+ if (isSelfHarnessOptimizerEnabled()) {
1795
+ try {
1796
+ const { fork } = require('child_process');
1797
+ const localOptimizerPath = path.join(process.cwd(), 'scripts', 'self-harness-optimizer.js');
1798
+ const packageOptimizerPath = path.join(__dirname, 'self-harness-optimizer.js');
1799
+
1800
+ if (fs.existsSync(localOptimizerPath)) {
1801
+ fork(localOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
1802
+ } else if (fs.existsSync(packageOptimizerPath)) {
1803
+ fork(packageOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
1804
+ }
1805
+ } catch (err) {
1806
+ console.error('Failed to trigger self-harness optimizer:', err);
1783
1807
  }
1784
- } catch (err) {
1785
- console.error('Failed to trigger self-harness optimizer:', err);
1786
1808
  }
1787
1809
  }
1788
1810
  } catch { /* Gate promotion is non-critical */ }
@@ -2416,10 +2438,10 @@ function runTests() {
2416
2438
  budgetCompliant: true,
2417
2439
  }),
2418
2440
  });
2419
- assert(!blocked.accepted, 'captureFeedback blocks unsafe positive promotion via rubric gate');
2441
+ assert(blocked.accepted && blocked.promoted === false, 'captureFeedback stores but does not promote unsafe positive feedback');
2420
2442
 
2421
2443
  const bad = captureFeedback({ signal: 'down' });
2422
- assert(!bad.accepted, 'captureFeedback rejects vague negative feedback');
2444
+ assert(bad.accepted && bad.promoted === false, 'captureFeedback stores vague negative feedback without promotion');
2423
2445
  assert(bad.needsClarification === true, 'captureFeedback requests clarification for vague negative feedback');
2424
2446
 
2425
2447
  const summary = feedbackSummary(5);
@@ -2508,6 +2530,7 @@ module.exports = {
2508
2530
  updateStatuslineWithLesson,
2509
2531
  waitForBackgroundSideEffects,
2510
2532
  getPendingBackgroundSideEffectCount,
2533
+ isSelfHarnessOptimizerEnabled,
2511
2534
  getFeedbackPaths,
2512
2535
  get FEEDBACK_LOG_PATH() {
2513
2536
  return getFeedbackPaths().FEEDBACK_LOG_PATH;
@@ -2003,7 +2003,12 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
2003
2003
  } else {
2004
2004
  guard = hybrid.evaluatePretool(toolName, serializedInput);
2005
2005
  }
2006
- if (!guard || guard.mode === 'allow') {
2006
+ if (!guard || guard.mode !== 'block') {
2007
+ return null;
2008
+ }
2009
+ const guardReason = String(guard.reason || '');
2010
+ const contextualMatch = /^(?:Matched guard pattern|Recurring negative pattern)\b/i.test(guardReason);
2011
+ if (!contextualMatch) {
2007
2012
  return null;
2008
2013
  }
2009
2014
 
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Lesson Embedding Index — maintained dense index over the lesson corpus.
6
+ *
7
+ * Powers the dense half of hybrid retrieval in the per-action gating hot path.
8
+ * The gating path reads memory-log lessons and must surface past mistakes that
9
+ * are semantically related to the current action even when they share no
10
+ * keywords (paraphrase, synonym, different file path). Pure lexical scoring
11
+ * misses those; dense embeddings catch them.
12
+ *
13
+ * Design constraints:
14
+ * - Embedding the whole corpus on every tool call is too expensive. We cache
15
+ * document vectors keyed by `id + sha1(text)` in <feedbackDir>/lesson-embeddings.json.
16
+ * Only the query is embedded per call; only new/changed lessons re-embed.
17
+ * - The embedder is reused from vector-store.embed (Gemini -> local transformers
18
+ * -> stub). No new embedding dependency.
19
+ * - HONESTY: when no real embedder is available, callers must degrade to lexical.
20
+ * We never synthesize fake (e.g. hash-based) vectors — that would be overclaiming.
21
+ *
22
+ * Zero hard dependency on LanceDB: this module only needs the embed() function.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const crypto = require('crypto');
28
+
29
+ const CACHE_FILE = 'lesson-embeddings.json';
30
+
31
+ function resolveFeedbackDir(explicit) {
32
+ if (explicit) return explicit;
33
+ try {
34
+ const { resolveFeedbackDir: resolve } = require('./feedback-paths');
35
+ return resolve();
36
+ } catch {
37
+ return process.env.THUMBGATE_FEEDBACK_DIR || process.cwd();
38
+ }
39
+ }
40
+
41
+ function getCachePath(feedbackDir) {
42
+ return path.join(resolveFeedbackDir(feedbackDir), CACHE_FILE);
43
+ }
44
+
45
+ /** Canonical text for a lesson record (title + content + tags). */
46
+ function lessonText(lesson) {
47
+ if (!lesson) return '';
48
+ return [
49
+ lesson.title || '',
50
+ lesson.content || '',
51
+ Array.isArray(lesson.tags) ? lesson.tags.join(' ') : '',
52
+ ].filter(Boolean).join(' ').trim();
53
+ }
54
+
55
+ /**
56
+ * Truncate a vector to a specific dimension (Matryoshka Embedding Truncation).
57
+ * Assumes the model supports MRL (like text-embedding-3 or modern transformers).
58
+ */
59
+ function truncateVector(vector, dimension) {
60
+ if (!Array.isArray(vector) || !dimension || vector.length <= dimension) {
61
+ return vector;
62
+ }
63
+ return vector.slice(0, dimension);
64
+ }
65
+
66
+ function hashText(text) {
67
+ // Content-change key for the embedding cache (not a security context). sha256
68
+ // matches the repo's standard non-security hashing and avoids the weak-hash flag.
69
+ return crypto.createHash('sha256').update(String(text || '')).digest('hex');
70
+ }
71
+
72
+ function cosineSimilarity(a, b) {
73
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) {
74
+ return 0;
75
+ }
76
+ let dot = 0;
77
+ let normA = 0;
78
+ let normB = 0;
79
+ for (let i = 0; i < a.length; i++) {
80
+ const x = Number(a[i]) || 0;
81
+ const y = Number(b[i]) || 0;
82
+ dot += x * y;
83
+ normA += x * x;
84
+ normB += y * y;
85
+ }
86
+ if (normA === 0 || normB === 0) return 0;
87
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
88
+ }
89
+
90
+ /**
91
+ * Is a real embedder available? Used so callers can decide hybrid vs lexical-only
92
+ * WITHOUT loading a model. Returns true for the deterministic stub (test/CI safe).
93
+ */
94
+ function isEmbedderAvailable() {
95
+ if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') return true;
96
+ // Managed Gemini path
97
+ try {
98
+ const { resolveGeminiEmbeddingConfig } = require('./gemini-embedding-policy');
99
+ const cfg = resolveGeminiEmbeddingConfig();
100
+ if (cfg && cfg.enabled && cfg.apiKey) return true;
101
+ } catch { /* policy module unavailable */ }
102
+ // Local transformers path
103
+ try {
104
+ require.resolve('@huggingface/transformers');
105
+ return true;
106
+ } catch { /* not installed */ }
107
+ return false;
108
+ }
109
+
110
+ function defaultEmbedder() {
111
+ // Lazy: do not pull in LanceDB/transformers at module require time.
112
+ const { embed } = require('./vector-store');
113
+ return embed;
114
+ }
115
+
116
+ function readCache(cachePath) {
117
+ try {
118
+ const raw = fs.readFileSync(cachePath, 'utf8');
119
+ const parsed = JSON.parse(raw);
120
+ return parsed && typeof parsed === 'object' ? parsed : {};
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+
126
+ function writeCache(cachePath, cache) {
127
+ try {
128
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
129
+ fs.writeFileSync(cachePath, JSON.stringify(cache));
130
+ } catch {
131
+ /* cache is best-effort; never throw into the hot path */
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Rank lessons by dense (embedding) similarity to the query.
137
+ * Returns [{ id, score }] sorted descending. Embeds the query once; reuses cached
138
+ * document vectors and only embeds new/changed lessons.
139
+ *
140
+ * @returns {Promise<Array<{id:string, score:number}>>}
141
+ */
142
+ async function semanticRank(queryText, lessons = [], options = {}) {
143
+ const { feedbackDir, embedder = defaultEmbedder(), persist = true, truncateDimension = null } = options;
144
+ if (!queryText || !Array.isArray(lessons) || lessons.length === 0) return [];
145
+
146
+ const cachePath = getCachePath(feedbackDir);
147
+ const cache = readCache(cachePath);
148
+ let cacheDirty = false;
149
+
150
+ let queryVector = await embedder(queryText, { kind: 'query', task: 'code retrieval' });
151
+ if (!Array.isArray(queryVector) || queryVector.length === 0) return [];
152
+
153
+ if (truncateDimension) {
154
+ queryVector = truncateVector(queryVector, truncateDimension);
155
+ }
156
+
157
+ const scored = [];
158
+ for (const lesson of lessons) {
159
+ if (!lesson || !lesson.id) continue;
160
+ const text = lessonText(lesson);
161
+ if (!text) continue;
162
+ const hash = hashText(text);
163
+
164
+ let entry = cache[lesson.id];
165
+ if (!entry || entry.hash !== hash || !Array.isArray(entry.vector)) {
166
+ const vector = await embedder(text, {
167
+ kind: 'document',
168
+ task: 'code retrieval',
169
+ title: lesson.title || undefined,
170
+ });
171
+ if (!Array.isArray(vector) || vector.length === 0) continue;
172
+ entry = { hash, vector };
173
+ cache[lesson.id] = entry;
174
+ cacheDirty = true;
175
+ }
176
+
177
+ const docVector = truncateDimension ? truncateVector(entry.vector, truncateDimension) : entry.vector;
178
+ scored.push({ id: lesson.id, score: cosineSimilarity(queryVector, docVector) });
179
+ }
180
+
181
+ // Prune cache entries for lessons no longer present (bounded growth).
182
+ const liveIds = new Set(lessons.map((l) => l && l.id).filter(Boolean));
183
+ for (const id of Object.keys(cache)) {
184
+ if (!liveIds.has(id)) {
185
+ delete cache[id];
186
+ cacheDirty = true;
187
+ }
188
+ }
189
+
190
+ if (persist && cacheDirty) writeCache(cachePath, cache);
191
+
192
+ return scored.sort((a, b) => b.score - a.score);
193
+ }
194
+
195
+ module.exports = {
196
+ semanticRank,
197
+ isEmbedderAvailable,
198
+ cosineSimilarity,
199
+ truncateVector,
200
+ lessonText,
201
+ hashText,
202
+ getCachePath,
203
+ };
@@ -0,0 +1,263 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cross-encoder reranker for lesson retrieval.
5
+ *
6
+ * Unlike the bi-encoders already in use (Jaccard + bigram Jaccard), a
7
+ * cross-encoder processes the (query, lesson) pair jointly — so it can
8
+ * catch relevance signals that independent scoring misses:
9
+ *
10
+ * - Field-weighted BM25: a query term in `whatWentWrong` is worth more
11
+ * than the same term in `tags`
12
+ * - Synonym/alias expansion: "force-push" ↔ "push --force", "deploy" ↔
13
+ * "deployment", etc.
14
+ * - Signal coherence: failure-sounding queries boost negative-signal lessons
15
+ * - Tool name joint scoring: query toolName × lesson toolsUsed
16
+ * - Score blending: reranked score is blended with the original retrieval
17
+ * score so we never fully discard the bi-encoder's signal
18
+ *
19
+ * Usage:
20
+ * const { rerankLessons } = require('./lesson-reranker');
21
+ * const reranked = rerankLessons(query, candidates, { topK: 5, toolName });
22
+ */
23
+
24
+ // BM25 hyper-parameters
25
+ const BM25_K1 = 1.5; // term saturation
26
+ const BM25_B = 0.75; // length normalisation
27
+
28
+ // Weight given to each lesson field when scoring a (query, lesson) pair.
29
+ // Higher weight = query terms appearing in that field contribute more to score.
30
+ const FIELD_WEIGHTS = {
31
+ whatWentWrong: 3.0,
32
+ whatToChange: 2.5,
33
+ howToAvoid: 2.0,
34
+ whatWorked: 2.0,
35
+ summary: 1.5,
36
+ content: 1.5,
37
+ context: 1.2,
38
+ title: 1.0,
39
+ rootCause: 1.0,
40
+ reasoning: 0.8,
41
+ tags: 0.5,
42
+ category: 0.4,
43
+ };
44
+
45
+ // Synonym clusters: any term in a group matches all others.
46
+ const SYNONYM_GROUPS = [
47
+ ['force-push', 'force push', 'push --force', 'git push --force', 'force_push'],
48
+ ['main', 'main branch', 'master', 'trunk', 'protected branch'],
49
+ ['env', '.env', 'environment variable', 'env var', 'dotenv', 'secret'],
50
+ ['deploy', 'deployment', 'ship', 'release', 'publish', 'rollout'],
51
+ ['db', 'database', 'sqlite', 'postgres', 'postgresql', 'migration', 'migrate'],
52
+ ['test', 'tests', 'test suite', 'spec', 'failing test', 'test failure'],
53
+ ['ci', 'ci/cd', 'pipeline', 'github actions', 'workflow', 'build'],
54
+ ['lint', 'linter', 'eslint', 'prettier', 'format'],
55
+ ['auth', 'authentication', 'authorization', 'token', 'api key', 'credential'],
56
+ ['delete', 'remove', 'rm', 'drop', 'destroy', 'wipe'],
57
+ ['merge', 'pull request', 'pr', 'rebase', 'squash'],
58
+ ];
59
+
60
+ // Regex patterns that indicate the query is about a failure/mistake.
61
+ const FAILURE_PATTERN = /fail|error|wrong|broken|mistake|bad|incorrect|problem|issue|bug|crash|broke|exception/i;
62
+
63
+ /**
64
+ * Tokenise text into lowercase word-like tokens of length >= 2.
65
+ * Hyphens and underscores are treated as delimiters so "force-push"
66
+ * becomes ["force", "push"].
67
+ * Exported so tests can verify expansion behaviour.
68
+ */
69
+ function tokenize(text) {
70
+ if (!text) return [];
71
+ return text
72
+ .toLowerCase()
73
+ .replace(/[^\w\s]/g, ' ') // replace all non-word, non-space chars (incl. hyphens, dots) with space
74
+ .split(/[\s_]+/) // split on whitespace and underscores
75
+ .filter((t) => t.length >= 2);
76
+ }
77
+
78
+ /**
79
+ * Expand a set of query tokens with synonyms from SYNONYM_GROUPS.
80
+ * Returns a deduplicated array of all terms (originals + expansions).
81
+ */
82
+ function expandTerms(terms) {
83
+ const expanded = new Set(terms);
84
+ for (const term of terms) {
85
+ for (const group of SYNONYM_GROUPS) {
86
+ if (group.some((syn) => syn.split(/\s+/).some((w) => w === term || term.includes(w)))) {
87
+ group.forEach((syn) => tokenize(syn).forEach((t) => expanded.add(t)));
88
+ }
89
+ }
90
+ }
91
+ return [...expanded];
92
+ }
93
+
94
+ /**
95
+ * Extract the text value of a named field from a lesson candidate.
96
+ * Handles both the flat structure from lesson-retrieval.js and the nested
97
+ * { lesson: { whatWentWrong, ... } } structure from lesson-search.js.
98
+ */
99
+ function getField(candidate, field) {
100
+ const nested = candidate.lesson;
101
+ const val = (nested && nested[field]) || candidate[field] || '';
102
+ if (Array.isArray(val)) return val.join(' ');
103
+ return String(val);
104
+ }
105
+
106
+ /**
107
+ * Compute field-weighted BM25 scores for a list of candidates (BM25F variant).
108
+ *
109
+ * BM25F processes the (query, lesson) pair jointly: query terms are weighted
110
+ * differently depending on which lesson field they appear in (via FIELD_WEIGHTS).
111
+ * IDF is computed at document level (how many docs contain the term across any
112
+ * field) so it stays positive regardless of field weights.
113
+ *
114
+ * Returns an array of { candidate, bm25Score } objects in the same order
115
+ * as the input.
116
+ */
117
+ function fieldWeightedBM25(queryTerms, candidates) {
118
+ const N = candidates.length;
119
+ if (N === 0) return [];
120
+
121
+ const fieldEntries = Object.entries(FIELD_WEIGHTS);
122
+ const fieldKeys = Object.keys(FIELD_WEIGHTS);
123
+
124
+ // Precompute per-document, per-field token arrays (avoid re-tokenising)
125
+ const docFieldTokens = candidates.map((candidate) => {
126
+ const fieldMap = {};
127
+ for (const field of fieldKeys) {
128
+ fieldMap[field] = tokenize(getField(candidate, field));
129
+ }
130
+ return fieldMap;
131
+ });
132
+
133
+ // Per-field average token lengths across all documents
134
+ const avgFieldLen = {};
135
+ for (const field of fieldKeys) {
136
+ const total = docFieldTokens.reduce((sum, d) => sum + d[field].length, 0);
137
+ avgFieldLen[field] = total / N || 1; // fallback to 1 to avoid /0
138
+ }
139
+
140
+ // Document-level df: count of documents that contain each term (any field).
141
+ // Keeping df as a plain count (not field-weighted) ensures IDF is always positive.
142
+ const df = new Map();
143
+ for (let i = 0; i < N; i++) {
144
+ const seenInDoc = new Set();
145
+ for (const field of fieldKeys) {
146
+ for (const tok of docFieldTokens[i][field]) {
147
+ if (!seenInDoc.has(tok)) {
148
+ df.set(tok, (df.get(tok) || 0) + 1);
149
+ seenInDoc.add(tok);
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ return candidates.map((candidate, i) => {
156
+ let score = 0;
157
+
158
+ for (const qTerm of queryTerms) {
159
+ const termDf = df.get(qTerm) || 0;
160
+ if (termDf === 0) continue;
161
+
162
+ // IDF is always positive because df ≤ N
163
+ const idf = Math.log((N - termDf + 0.5) / (termDf + 0.5) + 1);
164
+ if (idf <= 0) continue;
165
+
166
+ // BM25F: compute weighted sum of per-field normalised TF, then scale by IDF
167
+ let weightedTF = 0;
168
+ for (const [field, fieldWeight] of fieldEntries) {
169
+ const tokens = docFieldTokens[i][field];
170
+ const fieldLen = tokens.length;
171
+ if (fieldLen === 0) continue;
172
+
173
+ let tf = 0;
174
+ for (const t of tokens) {
175
+ if (t === qTerm) tf++;
176
+ }
177
+ if (tf === 0) continue;
178
+
179
+ const avgLen = avgFieldLen[field];
180
+ const normTF = tf / (tf + BM25_K1 * (1 - BM25_B + BM25_B * fieldLen / avgLen));
181
+ weightedTF += fieldWeight * normTF;
182
+ }
183
+
184
+ score += idf * weightedTF;
185
+ }
186
+
187
+ return { candidate, bm25Score: score };
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Rerank a list of lesson candidates using a cross-encoder approach.
193
+ *
194
+ * @param {string} query - The original retrieval query / action context
195
+ * @param {Array} candidates - Lesson objects from the bi-encoder stage
196
+ * @param {object} options
197
+ * @param {number} [options.topK=5] - How many results to return
198
+ * @param {string} [options.toolName] - Tool name from the triggering hook call
199
+ * @param {number} [options.blendWeight=0.7] - Weight given to BM25 score vs original
200
+ * retrieval score (0 = all original, 1 = all BM25)
201
+ * @returns {Array} Reranked candidates with `rerankedScore` field added
202
+ */
203
+ function rerankLessons(query, candidates, options = {}) {
204
+ const {
205
+ topK = 5,
206
+ toolName = '',
207
+ blendWeight = 0.7,
208
+ } = options;
209
+
210
+ if (!candidates || candidates.length === 0) return [];
211
+ if (candidates.length === 1) return candidates.slice(0, topK);
212
+
213
+ // Build expanded query term set
214
+ const rawTerms = tokenize((query || '') + (toolName ? ' ' + toolName : ''));
215
+ const queryTerms = expandTerms(rawTerms);
216
+
217
+ const isFailureQuery = FAILURE_PATTERN.test(query || '');
218
+
219
+ // Compute BM25 scores for all candidates
220
+ const bm25Results = fieldWeightedBM25(queryTerms, candidates);
221
+
222
+ // Normalise BM25 scores to [0, 1]
223
+ const maxBm25 = Math.max(...bm25Results.map((r) => r.bm25Score), 1e-9);
224
+
225
+ const reranked = bm25Results.map(({ candidate, bm25Score }) => {
226
+ const normBm25 = bm25Score / maxBm25;
227
+
228
+ // Original bi-encoder score (field name differs between retrieval paths)
229
+ const origScore = candidate.relevanceScore ?? candidate.score ?? 0;
230
+
231
+ // Blend BM25 with original score
232
+ let finalScore = blendWeight * normBm25 + (1 - blendWeight) * origScore;
233
+
234
+ // Signal coherence bonus: failure queries → negative lessons rank higher
235
+ const candidateSignal =
236
+ candidate.signal ||
237
+ (candidate.tags && candidate.tags.includes('negative') ? 'negative' : null);
238
+ if (isFailureQuery && candidateSignal === 'negative') {
239
+ finalScore *= 1.2;
240
+ }
241
+
242
+ // Tool name joint bonus: exact tool match between query context and lesson
243
+ if (toolName) {
244
+ const lessonTools = [
245
+ ...(candidate.metadata?.toolsUsed || []),
246
+ getField(candidate, 'toolUsed'),
247
+ getField(candidate, 'toolName'),
248
+ ].map((t) => (t || '').toLowerCase());
249
+
250
+ if (lessonTools.some((t) => t && t.includes(toolName.toLowerCase()))) {
251
+ finalScore *= 1.3;
252
+ }
253
+ }
254
+
255
+ return { ...candidate, rerankedScore: Number(finalScore.toFixed(6)) };
256
+ });
257
+
258
+ return reranked
259
+ .sort((a, b) => b.rerankedScore - a.rerankedScore)
260
+ .slice(0, topK);
261
+ }
262
+
263
+ module.exports = { rerankLessons, fieldWeightedBM25, tokenize, expandTerms };