thumbgate 1.28.2 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thumbgate",
3
3
  "description": "One 👎 becomes a hard rule the agent cannot bypass. Captures thumbs-down feedback, distills it into PreToolUse Pre-Action Checks, enforced across every future Claude Code session.",
4
- "version": "1.28.2",
4
+ "version": "1.28.3",
5
5
  "author": {
6
6
  "name": "Igor Ganapolsky",
7
7
  "email": "ig5973700@gmail.com",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thumbgate",
3
- "version": "1.28.2",
3
+ "version": "1.28.3",
4
4
  "description": "ThumbGate — 👍👎 feedback that teaches your AI agent. Thumbs down a mistake, it never happens again.",
5
5
  "homepage": "https://thumbgate.ai",
6
6
  "transport": "stdio",
@@ -2,13 +2,13 @@
2
2
  "mcpServers": {
3
3
  "thumbgate": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "thumbgate@1.28.2", "thumbgate", "serve"]
5
+ "args": ["--yes", "--package", "thumbgate@1.28.3", "thumbgate", "serve"]
6
6
  }
7
7
  },
8
8
  "hooks": {
9
9
  "preToolUse": {
10
10
  "command": "npx",
11
- "args": ["--yes", "--package", "thumbgate@1.28.2", "thumbgate", "gate-check"]
11
+ "args": ["--yes", "--package", "thumbgate@1.28.3", "thumbgate", "gate-check"]
12
12
  }
13
13
  }
14
14
  }
@@ -115,7 +115,11 @@ const {
115
115
  const {
116
116
  retrieveRelevantLessons,
117
117
  } = loadOptionalModule(path.join(__dirname, '../../scripts/lesson-retrieval'), () => ({
118
- retrieveRelevantLessons: () => [],
118
+ retrieveRelevantLessons: () => {
119
+ const error = new Error('retrieve_lessons is unavailable because the packaged retrieval modules are missing.');
120
+ error.code = 'THUMBGATE_CAPABILITY_UNAVAILABLE';
121
+ throw error;
122
+ },
119
123
  }));
120
124
  const {
121
125
  searchThumbgate,
@@ -186,8 +190,13 @@ const PRIVATE_MCP_MODULES = Object.freeze({
186
190
  lessonInference: path.resolve(__dirname, '../../scripts/lesson-inference.js'),
187
191
  lessonSearch: path.resolve(__dirname, '../../scripts/lesson-search.js'),
188
192
  });
189
-
190
- const PRIVATE_MCP_TOOL_REQUIREMENTS = Object.freeze({
193
+ const PUBLIC_MCP_MODULES = Object.freeze({
194
+ lessonRetrieval: path.resolve(__dirname, '../../scripts/lesson-retrieval.js'),
195
+ lessonReranker: path.resolve(__dirname, '../../scripts/lesson-reranker.js'),
196
+ crossEncoderReranker: path.resolve(__dirname, '../../scripts/cross-encoder-reranker.js'),
197
+ lessonEmbeddingIndex: path.resolve(__dirname, '../../scripts/lesson-embedding-index.js'),
198
+ });
199
+ const PRIVATE_TOOL_MODULE_KEYS = Object.freeze({
191
200
  search_lessons: ['lessonSearch'],
192
201
  reflect_on_feedback: ['reflectorAgent'],
193
202
  list_intents: ['intentRouter'],
@@ -204,6 +213,41 @@ const PRIVATE_MCP_TOOL_REQUIREMENTS = Object.freeze({
204
213
  managed_agent_status: ['managedLessonAgent'],
205
214
  context_stuff_lessons: ['lessonInference'],
206
215
  });
216
+ const PUBLIC_TOOL_MODULE_KEYS = Object.freeze({
217
+ retrieve_lessons: [
218
+ 'lessonRetrieval',
219
+ 'lessonReranker',
220
+ 'crossEncoderReranker',
221
+ 'lessonEmbeddingIndex',
222
+ ],
223
+ });
224
+
225
+ function getToolCapability(toolName, options = {}) {
226
+ const privateKeys = PRIVATE_TOOL_MODULE_KEYS[toolName] || [];
227
+ const publicKeys = PUBLIC_TOOL_MODULE_KEYS[toolName] || [];
228
+ const privateModuleAvailable = options.existsSync
229
+ ? (key) => options.existsSync(PRIVATE_MCP_MODULES[key])
230
+ : (key) => Boolean(loadPrivateMcpModule(key));
231
+ const publicModuleAvailable = options.existsSync
232
+ ? (key) => options.existsSync(PUBLIC_MCP_MODULES[key])
233
+ : (key) => fs.existsSync(PUBLIC_MCP_MODULES[key]);
234
+ const missingPrivateModules = privateKeys.filter((key) => !privateModuleAvailable(key));
235
+ const missingPublicModules = publicKeys.filter((key) => !publicModuleAvailable(key));
236
+ if (missingPrivateModules.length > 0) {
237
+ return { available: false, availability: 'private_core', missingModules: missingPrivateModules };
238
+ }
239
+ if (missingPublicModules.length > 0) {
240
+ return { available: false, availability: 'package_incomplete', missingModules: missingPublicModules };
241
+ }
242
+ return { available: true, availability: privateKeys.length > 0 ? 'private_core' : 'public', missingModules: [] };
243
+ }
244
+
245
+ function getExposedTools(profileName = getActiveMcpProfile(), options = {}) {
246
+ const allowed = new Set(getAllowedTools(profileName));
247
+ return TOOLS.filter((tool) => allowed.has(tool.name) && getToolCapability(tool.name, options).available);
248
+ }
249
+
250
+ const PRIVATE_MCP_TOOL_REQUIREMENTS = PRIVATE_TOOL_MODULE_KEYS;
207
251
 
208
252
  function loadPrivateMcpModule(key) {
209
253
  const modulePath = PRIVATE_MCP_MODULES[key];
@@ -223,17 +267,15 @@ function loadPrivateMcpModule(key) {
223
267
  }
224
268
 
225
269
  function isToolAvailable(toolName) {
226
- const requirements = PRIVATE_MCP_TOOL_REQUIREMENTS[toolName] || [];
227
270
  try {
228
- return requirements.every((key) => Boolean(loadPrivateMcpModule(key)));
271
+ return getToolCapability(toolName).available;
229
272
  } catch {
230
273
  return false;
231
274
  }
232
275
  }
233
276
 
234
277
  function listAvailableTools(profileName = getActiveMcpProfile()) {
235
- const allowedTools = new Set(getAllowedTools(profileName));
236
- return TOOLS.filter((tool) => allowedTools.has(tool.name) && isToolAvailable(tool.name));
278
+ return getExposedTools(profileName);
237
279
  }
238
280
 
239
281
  function unavailablePrivateMcpFeature(toolName) {
@@ -263,7 +305,7 @@ const {
263
305
  finalizeSession: finalizeFeedbackSession,
264
306
  } = require('../../scripts/feedback-session');
265
307
 
266
- const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.28.2' };
308
+ const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.28.3' };
267
309
  const COMMERCE_CATEGORIES = [
268
310
  'product_recommendation',
269
311
  'brand_compliance',
@@ -705,7 +747,21 @@ function buildEstimateUncertaintyResponse(args = {}) {
705
747
  }
706
748
 
707
749
  async function callTool(name, args = {}) {
708
- assertToolAllowed(name, getActiveMcpProfile());
750
+ const activeProfile = getActiveMcpProfile();
751
+ assertToolAllowed(name, activeProfile);
752
+ const capability = getToolCapability(name);
753
+ if (!capability.available) {
754
+ if (capability.availability === 'private_core') {
755
+ return unavailablePrivateMcpFeature(name);
756
+ }
757
+ const error = new Error(
758
+ `Tool '${name}' is unavailable (${capability.availability}): missing ${capability.missingModules.join(', ')}.`,
759
+ );
760
+ error.code = 'THUMBGATE_CAPABILITY_UNAVAILABLE';
761
+ error.errorCategory = 'capability';
762
+ error.isRetryable = false;
763
+ throw error;
764
+ }
709
765
 
710
766
  // Validate tool input contract against schema
711
767
  const { TOOLS } = require('../../scripts/tool-registry');
@@ -1389,7 +1445,7 @@ async function handleRequest(message) {
1389
1445
  };
1390
1446
  }
1391
1447
  if (message.method === 'ping') return {};
1392
- if (message.method === 'tools/list') return { tools: listAvailableTools() };
1448
+ if (message.method === 'tools/list') return { tools: getExposedTools() };
1393
1449
  if (message.method === 'tools/call') return callTool(message.params.name, message.params.arguments);
1394
1450
  throw new Error(`Unsupported method: ${message.method}`);
1395
1451
  }
@@ -1581,6 +1637,8 @@ if (require.main === module) startStdioServer();
1581
1637
 
1582
1638
  module.exports = {
1583
1639
  TOOLS,
1640
+ getExposedTools,
1641
+ getToolCapability,
1584
1642
  SAFE_DATA_DIR,
1585
1643
  handleRequest,
1586
1644
  callTool,
@@ -1591,6 +1649,9 @@ module.exports = {
1591
1649
  buildSuggestFixResponse,
1592
1650
  __test__: {
1593
1651
  PRIVATE_MCP_MODULES,
1652
+ PRIVATE_TOOL_MODULE_KEYS,
1653
+ PUBLIC_MCP_MODULES,
1654
+ PUBLIC_TOOL_MODULE_KEYS,
1594
1655
  PRIVATE_MCP_TOOL_REQUIREMENTS,
1595
1656
  loadPrivateMcpModule,
1596
1657
  isToolAvailable,
@@ -7,7 +7,7 @@
7
7
  "npx",
8
8
  "--yes",
9
9
  "--package",
10
- "thumbgate@1.28.2",
10
+ "thumbgate@1.28.3",
11
11
  "thumbgate",
12
12
  "serve"
13
13
  ],
@@ -210,7 +210,8 @@
210
210
  {
211
211
  "id": "deny-network-egress",
212
212
  "layer": "Cloud",
213
- "pattern": "fetch\\(|https?://(?!(?:github\\.com|registry\\.npmjs\\.org|api\\.anthropic\\.com|localhost|127\\.0\\.0\\.1|\\[::1\\]|0\\.0\\.0\\.0)(?:[:/?#]|$))|(?:curl|wget)\\s+(?:-\\S+\\s+)*(?!https?://|(?:github\\.com|registry\\.npmjs\\.org|api\\.anthropic\\.com|localhost|127\\.0\\.0\\.1|\\[::1\\]|0\\.0\\.0\\.0)(?:[:/?#]|$))[\\w.-]+\\.[a-z]{2,}",
213
+ "toolNames": ["Bash"],
214
+ "pattern": "(?:\\b(?:curl|wget)\\b[^\\n]{0,600}https?://(?!(?:github\\.com|registry\\.npmjs\\.org|api\\.anthropic\\.com|localhost|127\\.0\\.0\\.1|\\[::1\\]|0\\.0\\.0\\.0)(?:[:/?#]|$))[\\w.-]+|\\b(?:curl|wget)\\b\\s+(?:-\\S+\\s+)*(?!(?:https?://)?(?:github\\.com|registry\\.npmjs\\.org|api\\.anthropic\\.com|localhost|127\\.0\\.0\\.1|\\[::1\\]|0\\.0\\.0\\.0)(?:[:/?#]|$))(?:[\\w.-]+\\.)+[a-z]{2,}(?:[:/?#]|\\s|$)|\\bopen\\b[^\\n]{0,240}https?://(?!(?:github\\.com|registry\\.npmjs\\.org|api\\.anthropic\\.com|localhost|127\\.0\\.0\\.1|\\[::1\\]|0\\.0\\.0\\.0)(?:[:/?#]|$))[\\w.-]+|\\bnode\\b[^\\n]{0,240}\\bfetch\\s*\\(|\\bpython3?\\b[^\\n]{0,240}\\b(?:requests\\.(?:get|post)|urllib\\.request))",
214
215
  "action": "warn",
215
216
  "unless": "egress_approved",
216
217
  "message": "Potential unauthorized network egress detected.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thumbgate",
3
- "version": "1.28.2",
3
+ "version": "1.28.3",
4
4
  "description": "ThumbGate Pre-Action Checks derive rules from repeated failures, flag risky tool calls, hard-block detected secret leaks, and block matches in strict mode.",
5
5
  "homepage": "https://thumbgate.ai",
6
6
  "repository": {
@@ -50,6 +50,7 @@
50
50
  "scripts/bot-detection.js",
51
51
  "scripts/build-metadata.js",
52
52
  "scripts/classifier-routing.js",
53
+ "scripts/cross-encoder-reranker.js",
53
54
  "scripts/chatgpt-ads-readiness-pack.js",
54
55
  "scripts/claude-feedback-sync.js",
55
56
  "scripts/cli-demo.js",
@@ -125,7 +126,10 @@
125
126
  "scripts/judge-reward-function.js",
126
127
  "scripts/lesson-canonical.js",
127
128
  "scripts/lesson-db.js",
129
+ "scripts/lesson-embedding-index.js",
128
130
  "scripts/lesson-inference.js",
131
+ "scripts/lesson-reranker.js",
132
+ "scripts/lesson-retrieval.js",
129
133
  "scripts/lesson-rotation.js",
130
134
  "scripts/lesson-search.js",
131
135
  "scripts/lesson-synthesis.js",
@@ -449,7 +453,7 @@
449
453
  "test:memory-dedup": "node --test tests/memory-dedup.test.js",
450
454
  "test:lesson-db": "node --test tests/lesson-db.test.js",
451
455
  "test:lesson-rotation": "node --test tests/lesson-rotation.test.js",
452
- "test:feedback-quality": "node --test tests/feedback-quality.test.js tests/feedback-event-idempotency.test.js",
456
+ "test:feedback-quality": "node --test tests/feedback-quality.test.js tests/feedback-event-idempotency.test.js tests/feedback-capture-e2e.test.js",
453
457
  "test:sync-version": "node --test tests/sync-version.test.js",
454
458
  "test:release-window": "node --test tests/release-window.test.js",
455
459
  "test:team-sync": "node --test tests/team-sync.test.js",
@@ -474,7 +478,7 @@
474
478
  "test:okara-money-promo-automation": "node --test tests/okara-money-promo-automation.test.js",
475
479
  "test:operator-key-auth": "node --test tests/api-operator-key-auth.test.js",
476
480
  "test:cloudflare-sandbox": "node --test tests/cloudflare-dynamic-sandbox.test.js tests/cloudflare-sandbox-api.test.js",
477
- "test:mcp-config": "node --test tests/mcp-config.test.js",
481
+ "test:mcp-config": "node --test tests/mcp-config.test.js tests/mcp-capability-exposure.test.js",
478
482
  "test:mcp-tool-annotations": "node --test tests/mcp-tool-annotations.test.js",
479
483
  "test:mcp-oauth": "node --test tests/mcp-oauth.test.js",
480
484
  "test:mcp-oauth-flow": "node --test tests/mcp-oauth-flow.test.js",
@@ -515,7 +519,7 @@
515
519
  "test:quality": "node --test tests/validate-feedback.test.js tests/feedback-quality-eval-python.test.js tests/eval-gate-classifier.test.js",
516
520
  "test:intelligence": "node --test tests/intelligence.test.js",
517
521
  "test:training-export": "node --test tests/training-export.test.js tests/databricks-export.test.js",
518
- "test:deployment": "node --test tests/deployment.test.js tests/deploy-policy.test.js tests/publish-decision.test.js tests/changeset-check.test.js tests/release-notes.test.js tests/sonarcloud-workflow.test.js tests/package-boundary.test.js tests/public-package-boundary.test.js",
522
+ "test:deployment": "node --test tests/deployment.test.js tests/deploy-policy.test.js tests/publish-decision.test.js tests/changeset-check.test.js tests/release-notes.test.js tests/sonarcloud-workflow.test.js tests/package-boundary.test.js tests/public-package-boundary.test.js tests/packed-feedback-retrieval-e2e.test.js",
519
523
  "test:operational-integrity": "node --test tests/operational-integrity.test.js tests/sync-branch-protection.test.js",
520
524
  "test:workflow": "node --test tests/parallel-workflow.test.js tests/workflow-contract.test.js tests/positioning-contract.test.js tests/docs-claim-hygiene.test.js tests/thumbgate-scope.test.js tests/workflow-runs.test.js tests/workflow-sprint-intake.test.js tests/revenue-pack-utils.test.js tests/apollo-acquisition.test.js tests/sales-pipeline.test.js tests/github-outreach.test.js tests/enterprise-story.test.js tests/guide-conversion-path.test.js tests/buyer-intent-revenue-assist.test.js",
521
525
  "test:sales-pipeline": "node --test tests/sales-pipeline.test.js",
package/public/index.html CHANGED
@@ -20,7 +20,7 @@ __GOOGLE_SITE_VERIFICATION_META__
20
20
  <meta property="og:image" content="https://thumbgate.ai/og.png">
21
21
  <meta name="twitter:card" content="summary_large_image">
22
22
  <meta name="twitter:image" content="https://thumbgate.ai/og.png">
23
- <meta name="thumbgate-version" content="1.28.2">
23
+ <meta name="thumbgate-version" content="1.28.3">
24
24
  <meta name="keywords" content="ThumbGate, thumbgate, AI agent orchestration, AI experience orchestration, agentic development cycle, AC/DC framework, Guide Generate Verify Solve, agent enforcement layer, pre-action checks, agent governance, Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode, workflow hardening, context engineering, AI authenticity, brand authenticity AI">
25
25
  <link rel="canonical" href="__APP_ORIGIN__/">
26
26
  <link rel="alternate" type="text/markdown" title="ThumbGate LLM context" href="__APP_ORIGIN__/llm-context.md">
@@ -1776,7 +1776,7 @@ __GA_BOOTSTRAP__
1776
1776
  <a href="https://www.linkedin.com/in/igorganapolsky" target="_blank" rel="noopener">LinkedIn</a>
1777
1777
  <a href="/blog">Blog</a>
1778
1778
  </div>
1779
- <span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.28.2</span>
1779
+ <span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.28.3</span>
1780
1780
  </div>
1781
1781
  </footer>
1782
1782
 
@@ -25,7 +25,7 @@
25
25
  "alternateName": "thumbgate",
26
26
  "applicationCategory": "DeveloperApplication",
27
27
  "operatingSystem": "Cross-platform, Node.js >=18.18.0",
28
- "softwareVersion": "1.28.2",
28
+ "softwareVersion": "1.28.3",
29
29
  "url": "https://thumbgate.ai/numbers",
30
30
  "dateModified": "2026-05-07",
31
31
  "creator": {
@@ -202,7 +202,7 @@
202
202
  <main class="container">
203
203
  <h1>The Numbers</h1>
204
204
  <p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
205
- <div class="freshness">Updated: 2026-05-07 · Version 1.28.2</div>
205
+ <div class="freshness">Updated: 2026-05-07 · Version 1.28.3</div>
206
206
  <div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
207
207
 
208
208
  <h2>Gate enforcement</h2>
@@ -20,7 +20,7 @@ const { refreshStatuslineCache } = require('./hook-thumbgate-cache-updater');
20
20
  const SYNC_STATE_FILE = 'claude-feedback-sync-state.json';
21
21
  const DEFAULT_RECENT_FEEDBACK_LIMIT = 250;
22
22
  const DEFAULT_PROCESSED_ID_LIMIT = 512;
23
- const DUPLICATE_WINDOW_MS = 30 * 1000;
23
+ const DUPLICATE_WINDOW_MS = 5 * 60 * 1000;
24
24
 
25
25
  function getClaudeHistoryPath(options = {}) {
26
26
  if (options.historyPath) return options.historyPath;
@@ -116,6 +116,10 @@ function formatCliOutput(result) {
116
116
  const capturedIds = memoryId ? `${feedbackId}, ${memoryId}` : feedbackId;
117
117
  process.stderr.write(`✅ Feedback captured (${capturedIds})\n`);
118
118
  }
119
+ if (feedbackResult.promoted === false || !memoryId) {
120
+ const clarification = feedbackResult.prompt || feedbackResult.message || feedbackResult.reason;
121
+ lines.push(`${D} Reusable memory: not created${clarification ? ` — ${clarification}` : ''}${RST}`);
122
+ }
119
123
  } else {
120
124
  lines.push(`${R}Feedback not accepted: ${feedbackResult?.reason || 'unknown'}${RST}`);
121
125
  }
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Cross-Encoder Reranker for ThumbGate lesson retrieval.
6
+ *
7
+ * Two-stage retrieval:
8
+ * Stage 1: Fast candidate retrieval (existing bigram Jaccard + keyword matching)
9
+ * Stage 2: Cross-encoder reranking scores query-document pairs jointly
10
+ *
11
+ * The cross-encoder evaluates the query AND each lesson together (not independently),
12
+ * catching false positives that keyword/vector search misses.
13
+ *
14
+ * Architecture reference: "Advanced RAG Retrieval: Cross-Encoders & Reranking"
15
+ * (Towards Data Science, April 2026)
16
+ *
17
+ * When LLM is available (ANTHROPIC_API_KEY), uses Claude as the cross-encoder.
18
+ * Falls back to enhanced heuristic scoring when LLM is unavailable.
19
+ */
20
+
21
+ const { retrieveRelevantLessons, scoreRelevance, buildActionSignature } = require('./lesson-retrieval');
22
+
23
+ /**
24
+ * Heuristic cross-encoder: scores a (query, document) pair jointly.
25
+ * Unlike bi-encoder (independent embeddings), this examines the pair together
26
+ * to find semantic relationships that keyword matching misses.
27
+ */
28
+ function heuristicCrossEncode(query, document) {
29
+ const queryLower = (query || '').toLowerCase();
30
+ const docLower = (document || '').toLowerCase();
31
+
32
+ let score = 0;
33
+
34
+ // 1. Exact substring containment (strongest signal)
35
+ if (queryLower.length > 3 && docLower.length > 3 &&
36
+ (docLower.includes(queryLower) || queryLower.includes(docLower))) {
37
+ score += 0.9;
38
+ return Math.min(score, 1);
39
+ }
40
+
41
+ // 2. Shared noun phrases (not just tokens — consecutive word pairs)
42
+ const queryPhrases = extractPhrases(queryLower);
43
+ const docPhrases = extractPhrases(docLower);
44
+ const phraseOverlap = queryPhrases.filter((p) => docPhrases.includes(p));
45
+ score += Math.min(phraseOverlap.length * 0.15, 0.5);
46
+
47
+ // 3. Semantic category matching
48
+ const categories = {
49
+ destructive: ['delete', 'remove', 'drop', 'destroy', 'wipe', 'truncate', 'rm -rf', 'force-push', 'reset --hard'],
50
+ git: ['git', 'push', 'pull', 'merge', 'rebase', 'branch', 'commit', 'checkout', 'stash'],
51
+ database: ['sql', 'query', 'table', 'migration', 'schema', 'database', 'insert', 'update', 'select'],
52
+ deploy: ['deploy', 'release', 'publish', 'railway', 'vercel', 'heroku', 'npm publish'],
53
+ security: ['secret', 'token', 'api key', 'password', 'credential', 'env', '.env', 'pem'],
54
+ file: ['edit', 'write', 'create', 'modify', 'config', 'package.json', 'readme'],
55
+ };
56
+
57
+ for (const [, terms] of Object.entries(categories)) {
58
+ const queryHit = terms.some((t) => queryLower.includes(t));
59
+ const docHit = terms.some((t) => docLower.includes(t));
60
+ if (queryHit && docHit) {
61
+ score += 0.25;
62
+ break; // Only count strongest category match
63
+ }
64
+ }
65
+
66
+ // 4. Action-target alignment (e.g., "git push" in query matches "push to main" in doc)
67
+ const queryVerbs = extractVerbs(queryLower);
68
+ const docVerbs = extractVerbs(docLower);
69
+ const verbOverlap = queryVerbs.filter((v) => docVerbs.includes(v));
70
+ score += Math.min(verbOverlap.length * 0.1, 0.3);
71
+
72
+ // 5. Negation alignment (both about what NOT to do)
73
+ const queryNegated = /\b(don'?t|never|avoid|block|prevent|stop)\b/.test(queryLower);
74
+ const docNegated = /\b(don'?t|never|avoid|block|prevent|stop)\b/.test(docLower);
75
+ if (queryNegated && docNegated) score += 0.1;
76
+
77
+ return Math.min(score, 1);
78
+ }
79
+
80
+ /**
81
+ * LLM cross-encoder: uses Claude to score relevance of query-document pairs.
82
+ * More accurate but requires API key and costs tokens.
83
+ */
84
+ async function llmCrossEncode(query, documents) {
85
+ const { isAvailable, callClaudeJson, MODELS } = require('./llm-client');
86
+ if (!isAvailable()) return null;
87
+
88
+ const docList = documents
89
+ .map((d, i) => `[${i}] ${(d.title || '').slice(0, 100)} | ${(d.content || '').slice(0, 200)}`)
90
+ .join('\n');
91
+
92
+ const prompt = `You are a relevance scoring engine. Given a query and a list of documents, score each document's relevance to the query from 0.0 (irrelevant) to 1.0 (highly relevant).
93
+
94
+ Query: "${query.slice(0, 300)}"
95
+
96
+ Documents:
97
+ ${docList}
98
+
99
+ Return ONLY a JSON array of scores, one per document. Example: [0.9, 0.2, 0.7, 0.1, 0.5]
100
+ No other text.`;
101
+
102
+ try {
103
+ const scores = await callClaudeJson({
104
+ systemPrompt: 'You are a relevance scoring engine. Return only JSON arrays of numbers.',
105
+ userPrompt: prompt,
106
+ model: MODELS.FAST,
107
+ maxTokens: 256,
108
+ cache: true,
109
+ });
110
+ if (Array.isArray(scores) && scores.length === documents.length) {
111
+ return scores.map((s) => Math.max(0, Math.min(1, Number(s) || 0)));
112
+ }
113
+ } catch { /* fall back to heuristic */ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Two-stage retrieval with cross-encoder reranking.
119
+ *
120
+ * Stage 1: Retrieve top N candidates using existing keyword + bigram matching
121
+ * Stage 2: Rerank candidates using cross-encoder (LLM or heuristic)
122
+ * Return top K results by cross-encoder score
123
+ */
124
+ async function retrieveWithReranking(toolName, actionContext, options = {}) {
125
+ const {
126
+ candidateCount = 20,
127
+ maxResults = 5,
128
+ useLLM = false,
129
+ feedbackDir,
130
+ } = options;
131
+
132
+ // Stage 1: Fast candidate retrieval (existing system)
133
+ const candidates = retrieveRelevantLessons(toolName, actionContext, {
134
+ maxResults: candidateCount,
135
+ feedbackDir,
136
+ });
137
+
138
+ if (candidates.length === 0) return [];
139
+ if (candidates.length <= maxResults) return candidates;
140
+
141
+ const query = `${toolName || ''} ${actionContext || ''}`.trim();
142
+
143
+ // Stage 2: Cross-encoder reranking
144
+ let rerankedScores;
145
+
146
+ if (useLLM) {
147
+ rerankedScores = await llmCrossEncode(query, candidates);
148
+ }
149
+
150
+ // Fall back to heuristic cross-encoder if LLM unavailable or failed
151
+ if (!rerankedScores) {
152
+ rerankedScores = candidates.map((c) => {
153
+ const docText = `${c.title || ''} ${c.content || ''}`;
154
+ return heuristicCrossEncode(query, docText);
155
+ });
156
+ }
157
+
158
+ // Combine original relevance score with cross-encoder score
159
+ // Weight: 40% original, 60% cross-encoder (cross-encoder is more precise)
160
+ const reranked = candidates.map((c, i) => ({
161
+ ...c,
162
+ crossEncoderScore: rerankedScores[i],
163
+ combinedScore: c.relevanceScore * 0.4 + rerankedScores[i] * 0.6,
164
+ }));
165
+
166
+ return reranked
167
+ .sort((a, b) => b.combinedScore - a.combinedScore)
168
+ .slice(0, maxResults);
169
+ }
170
+
171
+ /**
172
+ * Synchronous version for use in PreToolUse hooks (cannot be async).
173
+ */
174
+ function retrieveWithRerankingSync(toolName, actionContext, options = {}) {
175
+ const {
176
+ candidateCount = 20,
177
+ maxResults = 5,
178
+ feedbackDir,
179
+ } = options;
180
+
181
+ const candidates = retrieveRelevantLessons(toolName, actionContext, {
182
+ maxResults: candidateCount,
183
+ feedbackDir,
184
+ });
185
+
186
+ if (candidates.length === 0) return [];
187
+ if (candidates.length <= maxResults) return candidates;
188
+
189
+ const query = `${toolName || ''} ${actionContext || ''}`.trim();
190
+
191
+ const rerankedScores = candidates.map((c) => {
192
+ const docText = `${c.title || ''} ${c.content || ''}`;
193
+ return heuristicCrossEncode(query, docText);
194
+ });
195
+
196
+ const reranked = candidates.map((c, i) => ({
197
+ ...c,
198
+ crossEncoderScore: rerankedScores[i],
199
+ combinedScore: c.relevanceScore * 0.4 + rerankedScores[i] * 0.6,
200
+ }));
201
+
202
+ return reranked
203
+ .sort((a, b) => b.combinedScore - a.combinedScore)
204
+ .slice(0, maxResults);
205
+ }
206
+
207
+ // --- Utility functions ---
208
+
209
+ function extractPhrases(text) {
210
+ const words = text.split(/\s+/).filter((w) => w.length > 2);
211
+ const phrases = [];
212
+ for (let i = 0; i < words.length - 1; i++) {
213
+ phrases.push(`${words[i]} ${words[i + 1]}`);
214
+ }
215
+ return phrases;
216
+ }
217
+
218
+ function extractVerbs(text) {
219
+ const verbPatterns = [
220
+ 'push', 'pull', 'merge', 'delete', 'create', 'edit', 'write', 'read',
221
+ 'deploy', 'install', 'remove', 'run', 'execute', 'build', 'test',
222
+ 'commit', 'rebase', 'reset', 'drop', 'truncate', 'migrate', 'publish',
223
+ 'block', 'allow', 'approve', 'deny', 'warn', 'log',
224
+ ];
225
+ return verbPatterns.filter((v) => text.includes(v));
226
+ }
227
+
228
+ module.exports = {
229
+ heuristicCrossEncode,
230
+ llmCrossEncode,
231
+ retrieveWithReranking,
232
+ retrieveWithRerankingSync,
233
+ extractPhrases,
234
+ extractVerbs,
235
+ };