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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +71 -10
- package/adapters/opencode/opencode.json +1 -1
- package/config/gates/default.json +2 -1
- package/package.json +8 -4
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/claude-feedback-sync.js +1 -1
- package/scripts/cli-feedback.js +4 -0
- package/scripts/cross-encoder-reranker.js +235 -0
- package/scripts/feedback-loop.js +45 -22
- package/scripts/gates-engine.js +6 -1
- package/scripts/lesson-embedding-index.js +203 -0
- package/scripts/lesson-reranker.js +263 -0
- package/scripts/lesson-retrieval.js +485 -0
- package/scripts/llm-client.js +18 -1
- package/scripts/mcp-policy.js +7 -1
- package/scripts/workflow-sentinel.js +34 -6
|
@@ -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.
|
|
4
|
+
"version": "1.28.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Igor Ganapolsky",
|
|
7
7
|
"email": "ig5973700@gmail.com",
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
"mcpServers": {
|
|
3
3
|
"thumbgate": {
|
|
4
4
|
"command": "npx",
|
|
5
|
-
"args": ["--yes", "--package", "thumbgate@1.28.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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,
|
|
@@ -210,7 +210,8 @@
|
|
|
210
210
|
{
|
|
211
211
|
"id": "deny-network-egress",
|
|
212
212
|
"layer": "Cloud",
|
|
213
|
-
"
|
|
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.
|
|
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.
|
|
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.
|
|
1779
|
+
<span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.28.3</span>
|
|
1780
1780
|
</div>
|
|
1781
1781
|
</footer>
|
|
1782
1782
|
|
package/public/numbers.html
CHANGED
|
@@ -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.
|
|
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.
|
|
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 =
|
|
23
|
+
const DUPLICATE_WINDOW_MS = 5 * 60 * 1000;
|
|
24
24
|
|
|
25
25
|
function getClaudeHistoryPath(options = {}) {
|
|
26
26
|
if (options.historyPath) return options.historyPath;
|
package/scripts/cli-feedback.js
CHANGED
|
@@ -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
|
+
};
|