thumbgate 1.29.2 → 1.31.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.
Files changed (111) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * Design constraints:
14
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.
15
+ * document vectors keyed by `id + sha256(text) + provider + dimension` in
16
+ * <feedbackDir>/lesson-embeddings.json.
16
17
  * Only the query is embedded per call; only new/changed lessons re-embed.
17
18
  * - The embedder is reused from vector-store.embed (Gemini -> local transformers
18
19
  * -> stub). No new embedding dependency.
@@ -92,15 +93,12 @@ function cosineSimilarity(a, b) {
92
93
  * WITHOUT loading a model. Returns true for the deterministic stub (test/CI safe).
93
94
  */
94
95
  function isEmbedderAvailable() {
95
- if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') return true;
96
- // Managed Gemini path
97
96
  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
- // The zero-dependency feature-hash provider is always available locally.
103
- return true;
97
+ const { hasSemanticEmbeddingProvider } = require('./vector-store');
98
+ return hasSemanticEmbeddingProvider();
99
+ } catch {
100
+ return false;
101
+ }
104
102
  }
105
103
 
106
104
  function defaultEmbedder() {
@@ -122,12 +120,34 @@ function readCache(cachePath) {
122
120
  function writeCache(cachePath, cache) {
123
121
  try {
124
122
  fs.mkdirSync(path.dirname(cachePath), { recursive: true });
125
- fs.writeFileSync(cachePath, JSON.stringify(cache));
123
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
124
+ fs.writeFileSync(tmpPath, JSON.stringify(cache), { mode: 0o600 });
125
+ fs.renameSync(tmpPath, cachePath);
126
+ fs.chmodSync(cachePath, 0o600);
126
127
  } catch {
127
128
  /* cache is best-effort; never throw into the hot path */
128
129
  }
129
130
  }
130
131
 
132
+ function resolveProviderFingerprint(options, vector) {
133
+ if (options.embedder) {
134
+ return String(options.embedderId || options.embedder.providerId || 'injected-test');
135
+ }
136
+ try {
137
+ const { getLastEmbeddingProfile } = require('./vector-store');
138
+ const profile = getLastEmbeddingProfile();
139
+ const active = profile && profile.activeProfile;
140
+ return [
141
+ profile && profile.source,
142
+ active && active.id,
143
+ active && active.model,
144
+ Array.isArray(vector) ? vector.length : 0,
145
+ ].filter(Boolean).join(':') || 'unknown';
146
+ } catch {
147
+ return 'unknown';
148
+ }
149
+ }
150
+
131
151
  /**
132
152
  * Rank lessons by dense (embedding) similarity to the query.
133
153
  * Returns [{ id, score }] sorted descending. Embeds the query once; reuses cached
@@ -136,10 +156,17 @@ function writeCache(cachePath, cache) {
136
156
  * @returns {Promise<Array<{id:string, score:number}>>}
137
157
  */
138
158
  async function semanticRank(queryText, lessons = [], options = {}) {
139
- const { feedbackDir, embedder = defaultEmbedder(), persist = true, truncateDimension = null } = options;
159
+ const {
160
+ feedbackDir,
161
+ embedder = defaultEmbedder(),
162
+ persist = true,
163
+ pruneCache = true,
164
+ truncateDimension = null,
165
+ cacheFile = CACHE_FILE,
166
+ } = options;
140
167
  if (!queryText || !Array.isArray(lessons) || lessons.length === 0) return [];
141
168
 
142
- const cachePath = getCachePath(feedbackDir);
169
+ const cachePath = path.join(resolveFeedbackDir(feedbackDir), path.basename(cacheFile));
143
170
  const cache = readCache(cachePath);
144
171
  let cacheDirty = false;
145
172
 
@@ -149,6 +176,11 @@ async function semanticRank(queryText, lessons = [], options = {}) {
149
176
  if (truncateDimension) {
150
177
  queryVector = truncateVector(queryVector, truncateDimension);
151
178
  }
179
+ const provider = resolveProviderFingerprint(options, queryVector);
180
+ const dimension = queryVector.length;
181
+ if (!options.embedder && /(?:built-in|feature-hash)/i.test(provider)) {
182
+ throw new Error('Semantic embedding provider degraded to feature hashing');
183
+ }
152
184
 
153
185
  const scored = [];
154
186
  for (const lesson of lessons) {
@@ -158,14 +190,22 @@ async function semanticRank(queryText, lessons = [], options = {}) {
158
190
  const hash = hashText(text);
159
191
 
160
192
  let entry = cache[lesson.id];
161
- if (!entry || entry.hash !== hash || !Array.isArray(entry.vector)) {
193
+ if (
194
+ !entry
195
+ || entry.hash !== hash
196
+ || entry.provider !== provider
197
+ || entry.dimension !== dimension
198
+ || !Array.isArray(entry.vector)
199
+ || entry.vector.length !== dimension
200
+ ) {
162
201
  const vector = await embedder(text, {
163
202
  kind: 'document',
164
203
  task: 'code retrieval',
165
204
  title: lesson.title || undefined,
166
205
  });
167
206
  if (!Array.isArray(vector) || vector.length === 0) continue;
168
- entry = { hash, vector };
207
+ if (vector.length !== dimension) continue;
208
+ entry = { hash, provider, dimension, vector };
169
209
  cache[lesson.id] = entry;
170
210
  cacheDirty = true;
171
211
  }
@@ -174,12 +214,16 @@ async function semanticRank(queryText, lessons = [], options = {}) {
174
214
  scored.push({ id: lesson.id, score: cosineSimilarity(queryVector, docVector) });
175
215
  }
176
216
 
177
- // Prune cache entries for lessons no longer present (bounded growth).
178
- const liveIds = new Set(lessons.map((l) => l && l.id).filter(Boolean));
179
- for (const id of Object.keys(cache)) {
180
- if (!liveIds.has(id)) {
181
- delete cache[id];
182
- cacheDirty = true;
217
+ // Prune only when the caller supplied the complete corpus. Metadata-filtered
218
+ // searches pass pruneCache=false so alternating filters cannot evict and
219
+ // regenerate one another's vectors.
220
+ if (pruneCache) {
221
+ const liveIds = new Set(lessons.map((l) => l && l.id).filter(Boolean));
222
+ for (const id of Object.keys(cache)) {
223
+ if (!liveIds.has(id)) {
224
+ delete cache[id];
225
+ cacheDirty = true;
226
+ }
183
227
  }
184
228
  }
185
229
 
@@ -196,4 +240,7 @@ module.exports = {
196
240
  lessonText,
197
241
  hashText,
198
242
  getCachePath,
243
+ readCache,
244
+ writeCache,
245
+ resolveProviderFingerprint,
199
246
  };
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const {
7
+ semanticRank,
8
+ isEmbedderAvailable,
9
+ lessonText,
10
+ hashText,
11
+ readCache,
12
+ } = require('./lesson-embedding-index');
13
+ const {
14
+ selectRetrievalMemories,
15
+ MAX_RETRIEVAL_MEMORY_LINES,
16
+ } = require('./lesson-retrieval');
17
+
18
+ function loadLessonCorpus(feedbackDir) {
19
+ const { readJSONL, getFeedbackPaths } = require('./feedback-loop');
20
+ const memoryPath = feedbackDir
21
+ ? path.join(feedbackDir, 'memory-log.jsonl')
22
+ : getFeedbackPaths().MEMORY_LOG_PATH;
23
+ const lessons = selectRetrievalMemories(
24
+ readJSONL(memoryPath, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
25
+ );
26
+ return { lessons, memoryPath };
27
+ }
28
+
29
+ function evaluateEmbeddingIndexDrift(options = {}) {
30
+ const feedbackDir = options.feedbackDir || process.env.THUMBGATE_FEEDBACK_DIR;
31
+ const { lessons, memoryPath } = loadLessonCorpus(feedbackDir);
32
+ const cachePath = path.join(
33
+ feedbackDir || path.dirname(memoryPath),
34
+ options.cacheFile || 'lesson-embeddings.json',
35
+ );
36
+ const cache = readCache(cachePath);
37
+ let indexedCount = 0;
38
+ const missingIds = [];
39
+ const staleIds = [];
40
+ const providers = {};
41
+ const dimensions = {};
42
+ const corpusIds = new Set(lessons.map((lesson) => lesson.id));
43
+
44
+ for (const lesson of lessons) {
45
+ const entry = cache[lesson.id];
46
+ const valid = entry
47
+ && entry.hash === hashText(lessonText(lesson))
48
+ && Array.isArray(entry.vector)
49
+ && entry.vector.length > 0
50
+ && entry.dimension === entry.vector.length
51
+ && typeof entry.provider === 'string';
52
+ if (valid) {
53
+ indexedCount += 1;
54
+ providers[entry.provider] = (providers[entry.provider] || 0) + 1;
55
+ dimensions[entry.dimension] = (dimensions[entry.dimension] || 0) + 1;
56
+ } else if (entry) {
57
+ staleIds.push(lesson.id);
58
+ } else {
59
+ missingIds.push(lesson.id);
60
+ }
61
+ }
62
+
63
+ const orphanedIds = Object.keys(cache)
64
+ .filter((id) => !corpusIds.has(id))
65
+ .sort((left, right) => left.localeCompare(right));
66
+ missingIds.sort((left, right) => left.localeCompare(right));
67
+ staleIds.sort((left, right) => left.localeCompare(right));
68
+
69
+ const corpusCount = lessons.length;
70
+ const coverage = corpusCount ? indexedCount / corpusCount : 1;
71
+ const semanticProviderAvailable = options.embedder
72
+ ? true
73
+ : isEmbedderAvailable();
74
+ const minCoverage = Math.max(0, Math.min(1, Number(options.minCoverage) || 0.95));
75
+ const requireExact = options.requireExact !== false;
76
+ const enabled = semanticProviderAvailable || Object.keys(cache).length > 0;
77
+ const exactIdSet = missingIds.length === 0
78
+ && staleIds.length === 0
79
+ && orphanedIds.length === 0;
80
+ const status = !enabled
81
+ ? 'not_configured'
82
+ : coverage >= minCoverage
83
+ && staleIds.length === 0
84
+ && orphanedIds.length === 0
85
+ && (!requireExact || exactIdSet)
86
+ ? 'healthy'
87
+ : 'unhealthy';
88
+ return {
89
+ ok: status !== 'unhealthy',
90
+ status,
91
+ semanticProviderAvailable,
92
+ corpusCount,
93
+ indexedCount,
94
+ missingCount: missingIds.length,
95
+ missingIds,
96
+ staleCount: staleIds.length,
97
+ staleIds,
98
+ orphanedCount: orphanedIds.length,
99
+ orphanedIds,
100
+ exactIdSet,
101
+ requireExact,
102
+ coverage: Number(coverage.toFixed(4)),
103
+ minCoverage,
104
+ providers,
105
+ dimensions,
106
+ memoryPath,
107
+ cachePath,
108
+ };
109
+ }
110
+
111
+ async function backfillLessonEmbeddings(options = {}) {
112
+ const feedbackDir = options.feedbackDir || process.env.THUMBGATE_FEEDBACK_DIR;
113
+ if (!options.embedder && !isEmbedderAvailable()) {
114
+ const error = new Error(
115
+ 'No semantic embedding provider configured. Set THUMBGATE_OLLAMA_EMBED_MODEL or configure another real provider.',
116
+ );
117
+ error.code = 'THUMBGATE_SEMANTIC_PROVIDER_UNAVAILABLE';
118
+ throw error;
119
+ }
120
+ const { lessons } = loadLessonCorpus(feedbackDir);
121
+ if (lessons.length > 0) {
122
+ await semanticRank('ThumbGate lesson embedding backfill health query', lessons, {
123
+ feedbackDir,
124
+ embedder: options.embedder,
125
+ embedderId: options.embedderId,
126
+ cacheFile: options.cacheFile,
127
+ });
128
+ }
129
+ return evaluateEmbeddingIndexDrift({
130
+ ...options,
131
+ feedbackDir,
132
+ embedder: options.embedder,
133
+ });
134
+ }
135
+
136
+ function parseArgs(argv) {
137
+ const args = { json: false, backfill: false, requireSemantic: false };
138
+ for (let index = 0; index < argv.length; index += 1) {
139
+ const arg = argv[index];
140
+ if (arg === '--json') args.json = true;
141
+ else if (arg === '--backfill') args.backfill = true;
142
+ else if (arg === '--require-semantic') args.requireSemantic = true;
143
+ else if (arg === '--feedback-dir') args.feedbackDir = argv[++index];
144
+ else if (arg === '--min-coverage') args.minCoverage = Number(argv[++index]);
145
+ }
146
+ return args;
147
+ }
148
+
149
+ async function runCli() {
150
+ const options = parseArgs(process.argv.slice(2));
151
+ let report;
152
+ try {
153
+ report = options.backfill
154
+ ? await backfillLessonEmbeddings(options)
155
+ : evaluateEmbeddingIndexDrift(options);
156
+ } catch (error) {
157
+ report = { ok: false, status: 'error', error: error.message, code: error.code || 'ERROR' };
158
+ }
159
+ const output = options.json
160
+ ? JSON.stringify(report, null, 2)
161
+ : `Lesson embeddings: ${report.status}; ${report.indexedCount || 0}/${report.corpusCount || 0} indexed; coverage=${report.coverage || 0}`;
162
+ process.stdout.write(`${output}\n`);
163
+ if (!report.ok || (options.requireSemantic && !report.semanticProviderAvailable)) {
164
+ process.exitCode = 1;
165
+ }
166
+ }
167
+
168
+ if (!module.parent) {
169
+ runCli();
170
+ }
171
+
172
+ module.exports = {
173
+ loadLessonCorpus,
174
+ evaluateEmbeddingIndexDrift,
175
+ backfillLessonEmbeddings,
176
+ parseArgs,
177
+ };
@@ -170,6 +170,20 @@ function isPositiveSignal(signal) {
170
170
  return signal === 'positive' || signal === 'up';
171
171
  }
172
172
 
173
+ function isHumanReviewedLesson(lesson = {}) {
174
+ return (lesson.metadata?.reviewOrigin || lesson.reviewOrigin) === 'human';
175
+ }
176
+
177
+ function dedupeHumanReviewedLessons(lessons = []) {
178
+ const byFeedback = new Map();
179
+ for (const lesson of lessons) {
180
+ if (isHumanReviewedLesson(lesson) && lesson.feedbackId) {
181
+ byFeedback.set(lesson.feedbackId, lesson);
182
+ }
183
+ }
184
+ return [...byFeedback.values()];
185
+ }
186
+
173
187
  function selectStatusbarLesson() {
174
188
  const lessons = readJsonl(getLessonsPath())
175
189
  .slice()
@@ -242,10 +256,14 @@ function searchLessons({ query = '', limit = 10, signal } = {}) {
242
256
  */
243
257
  function getLessonStats() {
244
258
  const lessons = readJsonl(getLessonsPath());
245
- const positive = lessons.filter((l) => l.signal === 'positive' || l.signal === 'up').length;
246
- const negative = lessons.filter((l) => l.signal === 'negative' || l.signal === 'down').length;
247
- const avgConfidence = lessons.length > 0 ? Math.round(lessons.reduce((s, l) => s + (l.confidence || 0), 0) / lessons.length) : 0;
248
- return { total: lessons.length, positive, negative, avgConfidence };
259
+ const humanReviewed = dedupeHumanReviewedLessons(lessons);
260
+ const positive = humanReviewed.filter((lesson) => isPositiveSignal(lesson.signal)).length;
261
+ const negative = humanReviewed.filter((lesson) => isNegativeSignal(lesson.signal)).length;
262
+ const avgConfidence = humanReviewed.length > 0
263
+ ? Math.round(humanReviewed.reduce((sum, lesson) => sum + (lesson.confidence || 0), 0) / humanReviewed.length)
264
+ : 0;
265
+ return { total: positive + negative, positive, negative, avgConfidence,
266
+ rawTotal: lessons.length, excludedTotal: lessons.length - humanReviewed.length };
249
267
  }
250
268
 
251
269
  // ---------------------------------------------------------------------------
@@ -650,6 +668,7 @@ async function inferStructuredLessonLLM(conversationWindow, signal, context) {
650
668
  module.exports = {
651
669
  inferFromSurroundingMessages, createLesson, getRecentLesson,
652
670
  searchLessons, getLessonStats, getStatusbarLessonData, getAllLessonsForContext,
671
+ isHumanReviewedLesson,
653
672
  getLessonsPath, getRecentLessonPath,
654
673
  selectStatusbarLesson, getLessonKind, stripLessonPrefix,
655
674
  formatLessonTimestamp, buildStatusbarLessonLabel,
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Cross-encoder reranker for lesson retrieval.
4
+ * Field-aware BM25F reranker for lesson retrieval.
5
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:
6
+ * This is a deterministic lexical second stage, not a neural cross-encoder.
7
+ * It scores query terms against lesson fields jointly and catches relevance
8
+ * signals that the first-stage overlap score can miss:
9
9
  *
10
10
  * - Field-weighted BM25: a query term in `whatWentWrong` is worth more
11
11
  * than the same term in `tags`
@@ -55,6 +55,11 @@ const SYNONYM_GROUPS = [
55
55
  ['auth', 'authentication', 'authorization', 'token', 'api key', 'credential'],
56
56
  ['delete', 'remove', 'rm', 'drop', 'destroy', 'wipe'],
57
57
  ['merge', 'pull request', 'pr', 'rebase', 'squash'],
58
+ ['tablet', 'ipad', 'ios device'],
59
+ ['overlay', 'tailscale', 'mesh network', 'vpn'],
60
+ ['laptop', 'macbook', 'mac'],
61
+ ['phone', 'mobile', 'android', 'device'],
62
+ ['conversation', 'session', 'thread', 'chat'],
58
63
  ];
59
64
 
60
65
  // Regex patterns that indicate the query is about a failure/mistake.
@@ -83,7 +88,13 @@ function expandTerms(terms) {
83
88
  const expanded = new Set(terms);
84
89
  for (const term of terms) {
85
90
  for (const group of SYNONYM_GROUPS) {
86
- if (group.some((syn) => syn.split(/\s+/).some((w) => w === term || term.includes(w)))) {
91
+ if (group.some((syn) => tokenize(syn).some((word) => (
92
+ word === term
93
+ || (word.length >= 4 && term.length >= 4 && (
94
+ word.startsWith(term)
95
+ || term.startsWith(word)
96
+ ))
97
+ )))) {
87
98
  group.forEach((syn) => tokenize(syn).forEach((t) => expanded.add(t)));
88
99
  }
89
100
  }
@@ -103,6 +114,28 @@ function getField(candidate, field) {
103
114
  return String(val);
104
115
  }
105
116
 
117
+ /**
118
+ * Score exact entity-channel overlap separately from prose relevance.
119
+ *
120
+ * Tags, categories, and tool identities are low-cardinality metadata. Keeping
121
+ * this score visible prevents a tag-only hit from being swallowed by BM25
122
+ * field-length normalization and makes reranking traces explainable.
123
+ */
124
+ function scoreEntityOverlap(queryTerms, candidate) {
125
+ const entityText = [
126
+ getField(candidate, 'tags'),
127
+ getField(candidate, 'category'),
128
+ getField(candidate, 'toolUsed'),
129
+ getField(candidate, 'toolName'),
130
+ ...(candidate.metadata?.toolsUsed || []),
131
+ ].filter(Boolean).join(' ');
132
+ const entityTokens = new Set(expandTerms(tokenize(entityText)));
133
+ const uniqueQueryTerms = [...new Set(queryTerms)];
134
+ if (uniqueQueryTerms.length === 0 || entityTokens.size === 0) return 0;
135
+ const hits = uniqueQueryTerms.filter((term) => entityTokens.has(term)).length;
136
+ return Number((hits / uniqueQueryTerms.length).toFixed(6));
137
+ }
138
+
106
139
  /**
107
140
  * Compute field-weighted BM25 scores for a list of candidates (BM25F variant).
108
141
  *
@@ -189,7 +222,7 @@ function fieldWeightedBM25(queryTerms, candidates) {
189
222
  }
190
223
 
191
224
  /**
192
- * Rerank a list of lesson candidates using a cross-encoder approach.
225
+ * Rerank a list of lesson candidates using field-aware BM25F.
193
226
  *
194
227
  * @param {string} query - The original retrieval query / action context
195
228
  * @param {Array} candidates - Lesson objects from the bi-encoder stage
@@ -224,12 +257,15 @@ function rerankLessons(query, candidates, options = {}) {
224
257
 
225
258
  const reranked = bm25Results.map(({ candidate, bm25Score }) => {
226
259
  const normBm25 = bm25Score / maxBm25;
260
+ const entityScore = scoreEntityOverlap(queryTerms, candidate);
227
261
 
228
262
  // Original bi-encoder score (field name differs between retrieval paths)
229
263
  const origScore = candidate.relevanceScore ?? candidate.score ?? 0;
230
264
 
231
265
  // Blend BM25 with original score
232
- let finalScore = blendWeight * normBm25 + (1 - blendWeight) * origScore;
266
+ let finalScore = 0.85 * (
267
+ blendWeight * normBm25 + (1 - blendWeight) * origScore
268
+ ) + 0.15 * entityScore;
233
269
 
234
270
  // Signal coherence bonus: failure queries → negative lessons rank higher
235
271
  const candidateSignal =
@@ -252,7 +288,11 @@ function rerankLessons(query, candidates, options = {}) {
252
288
  }
253
289
  }
254
290
 
255
- return { ...candidate, rerankedScore: Number(finalScore.toFixed(6)) };
291
+ return {
292
+ ...candidate,
293
+ entityScore,
294
+ rerankedScore: Number(finalScore.toFixed(6)),
295
+ };
256
296
  });
257
297
 
258
298
  return reranked
@@ -260,4 +300,10 @@ function rerankLessons(query, candidates, options = {}) {
260
300
  .slice(0, topK);
261
301
  }
262
302
 
263
- module.exports = { rerankLessons, fieldWeightedBM25, tokenize, expandTerms };
303
+ module.exports = {
304
+ rerankLessons,
305
+ fieldWeightedBM25,
306
+ scoreEntityOverlap,
307
+ tokenize,
308
+ expandTerms,
309
+ };