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
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-action lesson retrieval.
|
|
6
|
+
* v3: bi-encoder retrieval → cross-encoder reranking
|
|
7
|
+
*
|
|
8
|
+
* Stage 1 (bi-encoder): score all memories independently using token overlap,
|
|
9
|
+
* bigram Jaccard, tool-name matching, and recency decay. Retrieve top-50.
|
|
10
|
+
* Stage 2 (cross-encoder): rerank the top-50 candidates by computing a
|
|
11
|
+
* field-weighted BM25 score that processes (query, lesson) jointly, then
|
|
12
|
+
* blend with the original bi-encoder score. Return top-maxResults.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const RECENCY_DECAY_DAYS = 30;
|
|
16
|
+
const RERANK_CANDIDATE_POOL = 50; // bi-encoder retrieves this many; reranker picks topK
|
|
17
|
+
|
|
18
|
+
function retrieveRelevantLessons(toolName, actionContext, options = {}) {
|
|
19
|
+
const { maxResults = 5, feedbackDir } = options;
|
|
20
|
+
const { getFeedbackPaths, readJSONL } = require('./feedback-loop');
|
|
21
|
+
const { rerankLessons } = require('./lesson-reranker');
|
|
22
|
+
const pathMod = require('path');
|
|
23
|
+
const paths = feedbackDir
|
|
24
|
+
? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
|
|
25
|
+
: getFeedbackPaths();
|
|
26
|
+
|
|
27
|
+
const memories = readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 200 });
|
|
28
|
+
if (memories.length === 0) return [];
|
|
29
|
+
|
|
30
|
+
const actionSig = buildActionSignature(toolName, actionContext);
|
|
31
|
+
|
|
32
|
+
// Stage 1 — bi-encoder: score all memories independently, take top-50 candidates
|
|
33
|
+
const candidates = memories
|
|
34
|
+
.map((mem) => ({
|
|
35
|
+
...mem,
|
|
36
|
+
relevanceScore: scoreRelevance(mem, toolName, actionContext, actionSig),
|
|
37
|
+
}))
|
|
38
|
+
.filter((m) => m.relevanceScore > 0.1)
|
|
39
|
+
.sort((a, b) => b.relevanceScore - a.relevanceScore)
|
|
40
|
+
.slice(0, RERANK_CANDIDATE_POOL);
|
|
41
|
+
|
|
42
|
+
if (candidates.length === 0) return [];
|
|
43
|
+
|
|
44
|
+
// Stage 2 — cross-encoder reranker: rerank candidates by joint (query, lesson) score
|
|
45
|
+
const reranked = rerankLessons(actionContext, candidates, {
|
|
46
|
+
topK: maxResults,
|
|
47
|
+
toolName,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Stage 3 (opt-in) — Memora-style nucleus stop: trim the low-mass tail so a
|
|
51
|
+
// dominant lesson isn't padded out to maxResults. No-op unless topP < 1.
|
|
52
|
+
const deduped = dedupeSupersededLessons(reranked);
|
|
53
|
+
const selected = filterTopP(deduped, resolveTopP(options), { minKeep: options.minKeep });
|
|
54
|
+
|
|
55
|
+
return selected.map((m) => ({
|
|
56
|
+
id: m.id,
|
|
57
|
+
title: m.title,
|
|
58
|
+
content: m.content,
|
|
59
|
+
signal: m.tags?.includes('negative') ? 'negative' : 'positive',
|
|
60
|
+
rule: m.structuredRule || null,
|
|
61
|
+
relevanceScore: m.rerankedScore ?? m.relevanceScore,
|
|
62
|
+
timestamp: m.timestamp,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reciprocal Rank Fusion — merge several ranked id-lists into one ranking.
|
|
68
|
+
*
|
|
69
|
+
* RRF is scale-free: it fuses on rank position, not raw scores, so the lexical
|
|
70
|
+
* (BM25-ish, 0..~1.5) and dense (cosine, -1..1) rankers combine without any
|
|
71
|
+
* normalization. score(id) = Σ 1/(k + rank), rank starting at 1. k=60 is the
|
|
72
|
+
* value from the original Cormack et al. paper and the de-facto standard.
|
|
73
|
+
*
|
|
74
|
+
* @param {string[][]} rankedLists - each inner array is ids in descending relevance
|
|
75
|
+
* @param {object} [options]
|
|
76
|
+
* @param {number} [options.k=60]
|
|
77
|
+
* @returns {Array<{id:string, score:number}>} fused ids, descending
|
|
78
|
+
*/
|
|
79
|
+
function reciprocalRankFusion(rankedLists = [], options = {}) {
|
|
80
|
+
const k = Number.isFinite(options.k) ? options.k : 60;
|
|
81
|
+
const scores = new Map();
|
|
82
|
+
for (const list of rankedLists) {
|
|
83
|
+
if (!Array.isArray(list)) continue;
|
|
84
|
+
list.forEach((id, index) => {
|
|
85
|
+
if (id === undefined || id === null) return;
|
|
86
|
+
const rank = index + 1;
|
|
87
|
+
scores.set(id, (scores.get(id) || 0) + 1 / (k + rank));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return [...scores.entries()]
|
|
91
|
+
.map(([id, score]) => ({ id, score }))
|
|
92
|
+
.sort((a, b) => b.score - a.score);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function loadMemories(feedbackDir) {
|
|
96
|
+
const { getFeedbackPaths, readJSONL } = require('./feedback-loop');
|
|
97
|
+
const pathMod = require('path');
|
|
98
|
+
const paths = feedbackDir
|
|
99
|
+
? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
|
|
100
|
+
: getFeedbackPaths();
|
|
101
|
+
return readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 200 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function shapeLesson(m) {
|
|
105
|
+
return {
|
|
106
|
+
id: m.id,
|
|
107
|
+
title: m.title,
|
|
108
|
+
content: m.content,
|
|
109
|
+
signal: m.tags?.includes('negative') ? 'negative' : 'positive',
|
|
110
|
+
rule: m.structuredRule || null,
|
|
111
|
+
relevanceScore: m.rerankedScore ?? m.relevanceScore,
|
|
112
|
+
timestamp: m.timestamp,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Hybrid (dense + sparse) per-action lesson retrieval — the async counterpart of
|
|
118
|
+
* retrieveRelevantLessons. Used by the async gate path (gates-engine runAsync).
|
|
119
|
+
*
|
|
120
|
+
* Pipeline: lexical ranking ⊕ dense (embedding) ranking → Reciprocal Rank Fusion
|
|
121
|
+
* → cross-encoder rerank → top-K. Dense recall surfaces past mistakes that share
|
|
122
|
+
* no keywords with the action (paraphrase/synonym) — the value lexical alone misses.
|
|
123
|
+
*
|
|
124
|
+
* HONEST DEGRADATION: if no real embedder is available, or embedding errors, this
|
|
125
|
+
* returns the pure-lexical result (identical to retrieveRelevantLessons). Never
|
|
126
|
+
* fabricates semantics.
|
|
127
|
+
*/
|
|
128
|
+
async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {}) {
|
|
129
|
+
const { maxResults = 5, feedbackDir } = options;
|
|
130
|
+
|
|
131
|
+
let embeddingIndex;
|
|
132
|
+
try {
|
|
133
|
+
embeddingIndex = require('./lesson-embedding-index');
|
|
134
|
+
} catch {
|
|
135
|
+
return retrieveRelevantLessons(toolName, actionContext, options);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// No real embedder → degrade to lexical (no fake vectors, no regression).
|
|
139
|
+
if (!options.embedder && !embeddingIndex.isEmbedderAvailable()) {
|
|
140
|
+
return retrieveRelevantLessons(toolName, actionContext, options);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const memories = loadMemories(feedbackDir);
|
|
144
|
+
if (memories.length === 0) return [];
|
|
145
|
+
|
|
146
|
+
const actionSig = buildActionSignature(toolName, actionContext);
|
|
147
|
+
|
|
148
|
+
// Sparse: score every memory, keep those with any lexical signal.
|
|
149
|
+
const lexicalScored = memories
|
|
150
|
+
.map((mem) => ({ ...mem, relevanceScore: scoreRelevance(mem, toolName, actionContext, actionSig) }))
|
|
151
|
+
.filter((m) => m.relevanceScore > 0.1)
|
|
152
|
+
.sort((a, b) => b.relevanceScore - a.relevanceScore);
|
|
153
|
+
const lexicalRanked = lexicalScored.slice(0, RERANK_CANDIDATE_POOL).map((m) => m.id);
|
|
154
|
+
|
|
155
|
+
// Check if any lexical match is conclusive (exact/regex match on structured rule or high relevance)
|
|
156
|
+
let conclusive = false;
|
|
157
|
+
for (const candidate of lexicalScored) {
|
|
158
|
+
if (candidate.relevanceScore >= 0.85) {
|
|
159
|
+
conclusive = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
const rule = candidate.structuredRule;
|
|
163
|
+
if (rule) {
|
|
164
|
+
const cond = String(rule.trigger?.condition || rule.if || '').trim().toLowerCase();
|
|
165
|
+
if (cond.length >= 3) {
|
|
166
|
+
if (actionContext.toLowerCase().includes(cond)) {
|
|
167
|
+
conclusive = true;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const regex = new RegExp(cond, 'i');
|
|
172
|
+
if (regex.test(actionContext)) {
|
|
173
|
+
conclusive = true;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (conclusive) {
|
|
182
|
+
// Short-circuit: skip embedding/dense search completely
|
|
183
|
+
const { rerankLessons } = require('./lesson-reranker');
|
|
184
|
+
const reranked = rerankLessons(actionContext, lexicalScored.slice(0, RERANK_CANDIDATE_POOL), { topK: maxResults, toolName });
|
|
185
|
+
return filterTopP(dedupeSupersededLessons(reranked), resolveTopP(options), { minKeep: options.minKeep }).map(shapeLesson);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// WHERE-clause pruning: filter memories before vector search to only include
|
|
189
|
+
// memories relevant to the current toolName or context.
|
|
190
|
+
const prunedMemories = memories.filter((mem) => {
|
|
191
|
+
// 1. If lexical score is non-trivial, keep it.
|
|
192
|
+
const score = scoreRelevance(mem, toolName, actionContext, actionSig);
|
|
193
|
+
if (score > 0.1) return true;
|
|
194
|
+
|
|
195
|
+
// 2. Otherwise, check tool compatibility: if toolsUsed is specified, it must contain our tool
|
|
196
|
+
const memTools = mem.metadata?.toolsUsed || [];
|
|
197
|
+
if (memTools.length > 0 && !memTools.some(t => t.toLowerCase() === toolName.toLowerCase())) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
const ruleTools = mem.structuredRule?.metadata?.toolsUsed || [];
|
|
201
|
+
if (ruleTools.length > 0 && !ruleTools.some(t => t.toLowerCase() === toolName.toLowerCase())) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Dense: rank the pruned corpus by embedding similarity (cached vectors).
|
|
208
|
+
let semanticRanked = [];
|
|
209
|
+
if (prunedMemories.length > 0) {
|
|
210
|
+
try {
|
|
211
|
+
const dense = await embeddingIndex.semanticRank(actionContext, prunedMemories, {
|
|
212
|
+
feedbackDir,
|
|
213
|
+
embedder: options.embedder,
|
|
214
|
+
});
|
|
215
|
+
semanticRanked = dense.slice(0, RERANK_CANDIDATE_POOL).map((d) => d.id);
|
|
216
|
+
} catch {
|
|
217
|
+
// Embedding failed at runtime → fall back to pure lexical.
|
|
218
|
+
return retrieveRelevantLessons(toolName, actionContext, options);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Fuse. Candidate pool is the union — dense can introduce lessons lexical missed.
|
|
223
|
+
const fused = reciprocalRankFusion([lexicalRanked, semanticRanked]);
|
|
224
|
+
if (fused.length === 0) return [];
|
|
225
|
+
|
|
226
|
+
const byId = new Map(memories.map((m) => [m.id, m]));
|
|
227
|
+
const lexById = new Map(lexicalScored.map((m) => [m.id, m.relevanceScore]));
|
|
228
|
+
const topFusedScore = fused[0].score || 1;
|
|
229
|
+
|
|
230
|
+
const candidates = fused
|
|
231
|
+
.slice(0, RERANK_CANDIDATE_POOL)
|
|
232
|
+
.map((entry) => {
|
|
233
|
+
const mem = byId.get(entry.id);
|
|
234
|
+
if (!mem) return null;
|
|
235
|
+
// Carry a relevanceScore the cross-encoder can blend against. Prefer the
|
|
236
|
+
// lexical score when present; otherwise use the normalized fusion score so
|
|
237
|
+
// dense-only candidates still rank sensibly.
|
|
238
|
+
const relevanceScore = lexById.has(entry.id)
|
|
239
|
+
? lexById.get(entry.id)
|
|
240
|
+
: entry.score / topFusedScore;
|
|
241
|
+
return { ...mem, relevanceScore };
|
|
242
|
+
})
|
|
243
|
+
.filter(Boolean);
|
|
244
|
+
|
|
245
|
+
if (candidates.length === 0) return [];
|
|
246
|
+
|
|
247
|
+
const { rerankLessons } = require('./lesson-reranker');
|
|
248
|
+
const reranked = rerankLessons(actionContext, candidates, { topK: maxResults, toolName });
|
|
249
|
+
return filterTopP(dedupeSupersededLessons(reranked), resolveTopP(options), { minKeep: options.minKeep }).map(shapeLesson);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function buildActionSignature(toolName, actionContext) {
|
|
253
|
+
const toolLower = (toolName || '').toLowerCase();
|
|
254
|
+
const contextLower = (actionContext || '').toLowerCase();
|
|
255
|
+
const sigPaths = extractPaths(actionContext);
|
|
256
|
+
const tokens = tokenize(contextLower);
|
|
257
|
+
const ngramSet = textBigrams(contextLower);
|
|
258
|
+
return { toolLower, contextLower, paths: sigPaths, tokens, ngramSet };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function textBigrams(text) {
|
|
262
|
+
const normalized = (text || '')
|
|
263
|
+
.toLowerCase()
|
|
264
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
265
|
+
.replace(/\s+/g, ' ')
|
|
266
|
+
.trim();
|
|
267
|
+
const set = new Set();
|
|
268
|
+
for (let i = 0; i < normalized.length - 1; i++) {
|
|
269
|
+
set.add(normalized.slice(i, i + 2));
|
|
270
|
+
}
|
|
271
|
+
return set;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function bigramJaccard(setA, setB) {
|
|
275
|
+
if (setA.size === 0 && setB.size === 0) return 1;
|
|
276
|
+
if (setA.size === 0 || setB.size === 0) return 0;
|
|
277
|
+
let intersection = 0;
|
|
278
|
+
for (const item of setA) {
|
|
279
|
+
if (setB.has(item)) intersection++;
|
|
280
|
+
}
|
|
281
|
+
const union = setA.size + setB.size - intersection;
|
|
282
|
+
return union === 0 ? 0 : intersection / union;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function scoreRelevance(memory, toolName, actionContext, actionSig) {
|
|
286
|
+
const sig = actionSig || buildActionSignature(toolName, actionContext);
|
|
287
|
+
let score = 0;
|
|
288
|
+
|
|
289
|
+
const memText = ((memory.title || '') + ' ' + (memory.content || '') + ' ' + (memory.tags || []).join(' ')).toLowerCase();
|
|
290
|
+
|
|
291
|
+
if (memory.metadata?.toolsUsed?.some((t) => t.toLowerCase() === sig.toolLower)) score += 0.4;
|
|
292
|
+
if (memText.includes(sig.toolLower)) score += 0.2;
|
|
293
|
+
|
|
294
|
+
const memPaths = memory.metadata?.filesInvolved || extractPaths(memText);
|
|
295
|
+
const pathOverlap = sig.paths.filter((p) =>
|
|
296
|
+
memPaths.some((mp) => mp.includes(p) || p.includes(mp)),
|
|
297
|
+
);
|
|
298
|
+
if (pathOverlap.length > 0) score += 0.3;
|
|
299
|
+
|
|
300
|
+
const memTokens = tokenize(memText);
|
|
301
|
+
const overlap = sig.tokens.filter((t) => memTokens.includes(t));
|
|
302
|
+
score += Math.min(overlap.length * 0.05, 0.3);
|
|
303
|
+
|
|
304
|
+
// Fuzzy n-gram matching (only when there is already signal)
|
|
305
|
+
if (score > 0) {
|
|
306
|
+
const memBigrams = textBigrams(memText);
|
|
307
|
+
const fuzzyScore = bigramJaccard(sig.ngramSet, memBigrams);
|
|
308
|
+
score += fuzzyScore * 0.2;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (memory.tags?.includes('negative')) score += 0.1;
|
|
312
|
+
|
|
313
|
+
if (memory.timestamp) {
|
|
314
|
+
const ageMs = Date.now() - new Date(memory.timestamp).getTime();
|
|
315
|
+
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
316
|
+
const decay = Math.max(0, 1 - ageDays / RECENCY_DECAY_DAYS);
|
|
317
|
+
score *= 0.5 + 0.5 * decay;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (memory.structuredRule) score += 0.15;
|
|
321
|
+
|
|
322
|
+
return score;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function extractPaths(text) {
|
|
326
|
+
return [...new Set((text || '').match(/(?:src\/|scripts\/|tests\/)[^\s,)'"<>]+/g) || [])];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function tokenize(text) {
|
|
330
|
+
return (text || '').split(/[\s.,;:!?()\[\]{}"'`]+/).filter((t) => t.length > 3);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Resolve the nucleus (top-P) cutoff for a retrieval call. Precedence:
|
|
335
|
+
* options.topP (finite, 0 < p <= 1) → env THUMBGATE_RETRIEVAL_TOP_P → 1.0 (off).
|
|
336
|
+
* 1.0 is a no-op (returns the full reranked top-K), so retrieval behaviour is
|
|
337
|
+
* unchanged unless an operator explicitly opts in.
|
|
338
|
+
*/
|
|
339
|
+
function resolveTopP(options = {}) {
|
|
340
|
+
const fromOption = Number(options && options.topP);
|
|
341
|
+
if (Number.isFinite(fromOption) && fromOption > 0 && fromOption <= 1) return fromOption;
|
|
342
|
+
const fromEnv = Number(process.env.THUMBGATE_RETRIEVAL_TOP_P);
|
|
343
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0 && fromEnv <= 1) return fromEnv;
|
|
344
|
+
return 1.0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Nucleus (top-P) filter over a ranked lesson list — the lightweight realization of
|
|
349
|
+
* Memora's "policy-guided retriever decides when to stop" (ICML 2026). Instead of a
|
|
350
|
+
* fixed top-K, keep the smallest prefix whose NORMALIZED relevance mass covers `topP`.
|
|
351
|
+
*
|
|
352
|
+
* Scores are normalized into a probability distribution first, so the cutoff is
|
|
353
|
+
* scale-free: it behaves the same on raw bi-encoder scores (0..~1.5) and on blended
|
|
354
|
+
* rerank scores. When one or two lessons dominate the distribution, fewer lessons are
|
|
355
|
+
* returned (less context stuffed into the prompt, fewer tokens); when relevance is
|
|
356
|
+
* spread out, more are kept.
|
|
357
|
+
*
|
|
358
|
+
* Guarantees:
|
|
359
|
+
* - never returns more lessons than the input (purely subtractive);
|
|
360
|
+
* - always returns at least `minKeep` (default 1) when the input is non-empty;
|
|
361
|
+
* - topP >= 1 (or non-positive/NaN) is a no-op: returns the input, order preserved.
|
|
362
|
+
*
|
|
363
|
+
* @param {Array<object>} lessons - ranked lessons (each with rerankedScore or relevanceScore)
|
|
364
|
+
* @param {number} [topP=1.0] - cumulative normalized mass to cover, in (0, 1]
|
|
365
|
+
* @param {object} [options]
|
|
366
|
+
* @param {number} [options.minKeep=1] - hard floor on lessons returned
|
|
367
|
+
* @returns {Array<object>} the retained prefix, highest score first
|
|
368
|
+
*/
|
|
369
|
+
function filterTopP(lessons, topP = 1.0, options = {}) {
|
|
370
|
+
if (!Array.isArray(lessons) || lessons.length === 0) return [];
|
|
371
|
+
const minKeep = Math.max(1, Math.floor(options && options.minKeep) || 1);
|
|
372
|
+
if (!(topP > 0) || topP >= 1) return lessons.slice();
|
|
373
|
+
|
|
374
|
+
const scoreOf = (l) => {
|
|
375
|
+
const s = l && (l.rerankedScore ?? l.relevanceScore);
|
|
376
|
+
return Number.isFinite(s) && s > 0 ? s : 0;
|
|
377
|
+
};
|
|
378
|
+
const sorted = [...lessons].sort((a, b) => scoreOf(b) - scoreOf(a));
|
|
379
|
+
const total = sorted.reduce((sum, l) => sum + scoreOf(l), 0);
|
|
380
|
+
if (total <= 0) return sorted.slice(0, Math.min(minKeep, sorted.length));
|
|
381
|
+
|
|
382
|
+
let cumulative = 0;
|
|
383
|
+
const kept = [];
|
|
384
|
+
for (const lesson of sorted) {
|
|
385
|
+
kept.push(lesson);
|
|
386
|
+
cumulative += scoreOf(lesson) / total;
|
|
387
|
+
if (kept.length >= minKeep && cumulative >= topP) break;
|
|
388
|
+
}
|
|
389
|
+
return kept;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Drop superseded / contradictory lessons before final selection.
|
|
394
|
+
*
|
|
395
|
+
* Retrieval can surface two lessons on the SAME topic at near-equal relevance —
|
|
396
|
+
* a stale one and a newer correction. Handing an agent contradictory guidance
|
|
397
|
+
* ("never force-push" AND "force-push is fine") poisons its decision and wastes
|
|
398
|
+
* tokens — the "context poisoning" failure mode. This collapses same-topic
|
|
399
|
+
* lessons on the reranked list:
|
|
400
|
+
* - same topic + SAME signal → duplicate: keep the higher-ranked, drop rest
|
|
401
|
+
* - same topic + OPPOSITE signal → contradiction: keep the most RECENT (it
|
|
402
|
+
* supersedes), drop the older
|
|
403
|
+
*
|
|
404
|
+
* "Same topic" = identical/near-identical structured-rule trigger (the strong
|
|
405
|
+
* signal), else high character-bigram similarity of title+content. Conservative
|
|
406
|
+
* by design: distinct lessons are never merged, and survivor order is preserved.
|
|
407
|
+
* Affects only lesson RETRIEVAL — never the hard gate rules.
|
|
408
|
+
*
|
|
409
|
+
* @param {Array<object>} lessons - reranked lessons/memories, best-first
|
|
410
|
+
* @param {object} [options]
|
|
411
|
+
* @param {number} [options.similarityThreshold=0.82] - bigram-Jaccard cutoff for "same topic"
|
|
412
|
+
* @returns {Array<object>} deduped list, order preserved
|
|
413
|
+
*/
|
|
414
|
+
function dedupeSupersededLessons(lessons, options = {}) {
|
|
415
|
+
if (!Array.isArray(lessons) || lessons.length <= 1) {
|
|
416
|
+
return Array.isArray(lessons) ? lessons.slice() : [];
|
|
417
|
+
}
|
|
418
|
+
const threshold = Number.isFinite(options.similarityThreshold) ? options.similarityThreshold : 0.82;
|
|
419
|
+
const signalOf = (x) => x.signal || (x.tags?.includes('negative') ? 'negative' : 'positive');
|
|
420
|
+
const topicSignature = (x) => {
|
|
421
|
+
const rule = x.rule || x.structuredRule;
|
|
422
|
+
const cond = rule && (rule.trigger?.condition || rule.if);
|
|
423
|
+
if (cond && String(cond).trim().length >= 3) return String(cond).toLowerCase().trim();
|
|
424
|
+
return `${x.title || ''} ${x.content || ''}`.toLowerCase().trim();
|
|
425
|
+
};
|
|
426
|
+
const timeOf = (x) => {
|
|
427
|
+
const t = x.timestamp ? new Date(x.timestamp).getTime() : NaN;
|
|
428
|
+
return Number.isFinite(t) ? t : -Infinity;
|
|
429
|
+
};
|
|
430
|
+
const sigs = lessons.map(topicSignature);
|
|
431
|
+
const grams = sigs.map((s) => textBigrams(s));
|
|
432
|
+
const sameTopic = (i, j) => {
|
|
433
|
+
if (!sigs[i] || !sigs[j]) return false;
|
|
434
|
+
if (sigs[i] === sigs[j]) return true;
|
|
435
|
+
return bigramJaccard(grams[i], grams[j]) >= threshold;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const kept = []; // indices into `lessons`, best-first
|
|
439
|
+
for (let i = 0; i < lessons.length; i++) {
|
|
440
|
+
let collapsed = false;
|
|
441
|
+
for (let k = 0; k < kept.length; k++) {
|
|
442
|
+
const j = kept[k];
|
|
443
|
+
if (!sameTopic(i, j)) continue;
|
|
444
|
+
if (signalOf(lessons[i]) === signalOf(lessons[j])) {
|
|
445
|
+
collapsed = true; // duplicate → keep the already-kept (higher-ranked)
|
|
446
|
+
} else if (timeOf(lessons[i]) > timeOf(lessons[j])) {
|
|
447
|
+
kept[k] = i; // contradiction → newer supersedes, take the slot
|
|
448
|
+
collapsed = true;
|
|
449
|
+
} else {
|
|
450
|
+
collapsed = true; // contradiction, incoming is older → drop it
|
|
451
|
+
}
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
if (!collapsed) kept.push(i);
|
|
455
|
+
}
|
|
456
|
+
return kept.map((idx) => lessons[idx]);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function calculateRetrievalEntropy(lessons) {
|
|
460
|
+
if (!Array.isArray(lessons) || lessons.length === 0) return 0;
|
|
461
|
+
let pW = 0, nW = 0, tW = 0;
|
|
462
|
+
for (const l of lessons) {
|
|
463
|
+
const w = l.relevanceScore || 0.1;
|
|
464
|
+
if (l.signal === "positive") pW += w; else nW += w;
|
|
465
|
+
tW += w;
|
|
466
|
+
}
|
|
467
|
+
if (tW === 0) return 0;
|
|
468
|
+
const pPos = pW / tW, pNeg = nW / tW;
|
|
469
|
+
const entropy = (pPos > 0 ? -pPos * Math.log2(pPos) : 0) + (pNeg > 0 ? -pNeg * Math.log2(pNeg) : 0);
|
|
470
|
+
return Number(entropy.toFixed(4));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
module.exports = {
|
|
474
|
+
retrieveRelevantLessons,
|
|
475
|
+
retrieveRelevantLessonsAsync,
|
|
476
|
+
reciprocalRankFusion,
|
|
477
|
+
scoreRelevance,
|
|
478
|
+
buildActionSignature,
|
|
479
|
+
textBigrams,
|
|
480
|
+
bigramJaccard,
|
|
481
|
+
calculateRetrievalEntropy,
|
|
482
|
+
filterTopP,
|
|
483
|
+
resolveTopP,
|
|
484
|
+
dedupeSupersededLessons,
|
|
485
|
+
};
|
package/scripts/llm-client.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const { runStep } = require('./durability/step');
|
|
5
|
+
const { redactSecrets } = require('./secret-redaction');
|
|
5
6
|
|
|
6
7
|
const MODELS = {
|
|
7
8
|
FAST: 'claude-haiku-4-5-20251001',
|
|
@@ -141,6 +142,19 @@ function parseClaudeJson(text) {
|
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
function buildSafeProviderError(error) {
|
|
146
|
+
const rawMessage = error && error.message ? String(error.message) : 'provider request failed';
|
|
147
|
+
const summary = {
|
|
148
|
+
name: String(error && error.name || 'Error').slice(0, 80),
|
|
149
|
+
message: redactSecrets(rawMessage).split('\n')[0].slice(0, 500),
|
|
150
|
+
};
|
|
151
|
+
const status = Number(error && (error.status || (error.response && error.response.status)));
|
|
152
|
+
if (Number.isFinite(status)) summary.status = status;
|
|
153
|
+
const code = error && error.code;
|
|
154
|
+
if (typeof code === 'string' || typeof code === 'number') summary.code = code;
|
|
155
|
+
return summary;
|
|
156
|
+
}
|
|
157
|
+
|
|
144
158
|
function getZaiApiKey(env = process.env) {
|
|
145
159
|
return env.ZAI_API_KEY || env.THUMBGATE_ZAI_API_KEY || '';
|
|
146
160
|
}
|
|
@@ -198,6 +212,8 @@ async function callGeminiInternal(options = {}) {
|
|
|
198
212
|
const { detectInferenceBackend } = require('./local-model-profile');
|
|
199
213
|
const providerMode = detectInferenceBackend(env).providerMode;
|
|
200
214
|
|
|
215
|
+
if (providerMode !== 'vertex' && !env.GEMINI_API_KEY) return null;
|
|
216
|
+
|
|
201
217
|
try {
|
|
202
218
|
const { GoogleGenAI } = require('@google/genai');
|
|
203
219
|
if (!_geminiClient) {
|
|
@@ -246,7 +262,7 @@ async function callGeminiInternal(options = {}) {
|
|
|
246
262
|
model: options.model,
|
|
247
263
|
};
|
|
248
264
|
} catch (err) {
|
|
249
|
-
console.error('Gemini/Vertex AI execution error:', err);
|
|
265
|
+
console.error('Gemini/Vertex AI execution error:', JSON.stringify(buildSafeProviderError(err)));
|
|
250
266
|
return null;
|
|
251
267
|
}
|
|
252
268
|
}
|
|
@@ -366,5 +382,6 @@ module.exports = {
|
|
|
366
382
|
parseClaudeJson,
|
|
367
383
|
normalizeCacheOptions,
|
|
368
384
|
buildClaudeRequest,
|
|
385
|
+
buildSafeProviderError,
|
|
369
386
|
MODELS,
|
|
370
387
|
};
|
package/scripts/mcp-policy.js
CHANGED
|
@@ -30,6 +30,11 @@ function loadSubagentProfiles() {
|
|
|
30
30
|
return parsed;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function getConfiguredDefaultProfile() {
|
|
34
|
+
const { getSetting } = require('./settings-hierarchy');
|
|
35
|
+
return String(getSetting('mcp.defaultProfile') || 'essential');
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
function getActiveMcpProfile(toolName) {
|
|
34
39
|
const explicitProfile = process.env.THUMBGATE_MCP_PROFILE || null;
|
|
35
40
|
const runtimeSubagentProfile = process.env.THUMBGATE_SUBAGENT_PROFILE || null;
|
|
@@ -42,7 +47,7 @@ function getActiveMcpProfile(toolName) {
|
|
|
42
47
|
const routing = routeProfile({ toolName });
|
|
43
48
|
return routing.profile;
|
|
44
49
|
}
|
|
45
|
-
return explicitProfile ||
|
|
50
|
+
return explicitProfile || getConfiguredDefaultProfile();
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
const config = loadSubagentProfiles();
|
|
@@ -86,6 +91,7 @@ module.exports = {
|
|
|
86
91
|
loadMcpPolicy,
|
|
87
92
|
loadSubagentProfiles,
|
|
88
93
|
getActiveMcpProfile,
|
|
94
|
+
getConfiguredDefaultProfile,
|
|
89
95
|
getAllowedTools,
|
|
90
96
|
isToolAllowed,
|
|
91
97
|
assertToolAllowed,
|
|
@@ -282,17 +282,27 @@ function isProtectedApprovalRelevant(toolName, toolInput = {}) {
|
|
|
282
282
|
function normalizeMemoryGuardForSentinel(memoryGuard, isHighRisk) {
|
|
283
283
|
if (!memoryGuard || memoryGuard.mode === 'allow') return memoryGuard;
|
|
284
284
|
const reason = String(memoryGuard.reason || '');
|
|
285
|
-
const broadToolOnlySignal = /^Tool "[^"]+" has \d+ attributed negative\(s\), \d+ total negative\(s\)$/i.test(reason);
|
|
286
|
-
if (
|
|
285
|
+
const broadToolOnlySignal = /^Tool "[^"]+" (?:has \d+ attributed negative\(s\), \d+ total negative\(s\)|has recurring negative patterns \(count: \d+\))$/i.test(reason);
|
|
286
|
+
if (broadToolOnlySignal) {
|
|
287
287
|
return {
|
|
288
288
|
...memoryGuard,
|
|
289
|
-
mode: '
|
|
290
|
-
reason: `${reason}.
|
|
289
|
+
mode: 'allow',
|
|
290
|
+
reason: `${reason}. Ignored for this action because no contextual pattern matched the current input.`,
|
|
291
291
|
};
|
|
292
292
|
}
|
|
293
293
|
return memoryGuard;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
function normalizeLearnedPolicyForSentinel(learnedPolicy, actionable) {
|
|
297
|
+
if (!learnedPolicy?.enabled || actionable) return learnedPolicy;
|
|
298
|
+
return {
|
|
299
|
+
...learnedPolicy,
|
|
300
|
+
enabled: false,
|
|
301
|
+
advisoryOnly: true,
|
|
302
|
+
reason: 'non_execution_action',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
296
306
|
function buildTaskScopeViolation(taskScope, affectedFiles) {
|
|
297
307
|
if (!Array.isArray(affectedFiles) || affectedFiles.length === 0) return null;
|
|
298
308
|
if (!taskScope || !Array.isArray(taskScope.allowedPaths) || taskScope.allowedPaths.length === 0) {
|
|
@@ -1465,7 +1475,12 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
|
|
|
1465
1475
|
options,
|
|
1466
1476
|
}
|
|
1467
1477
|
);
|
|
1468
|
-
|
|
1478
|
+
// Scope is an execution boundary, not a read boundary. Treating Read/Glob/Grep
|
|
1479
|
+
// paths as writes generated a warning on almost every inspection and trained
|
|
1480
|
+
// callers to ignore the sentinel.
|
|
1481
|
+
const taskScopeViolation = highRiskAction
|
|
1482
|
+
? buildTaskScopeViolation(governanceState.taskScope, affectedFiles)
|
|
1483
|
+
: null;
|
|
1469
1484
|
const protectedSurface = buildProtectedSurface(governanceState, affectedFiles);
|
|
1470
1485
|
const protectedSurfaceForRisk = isProtectedApprovalRelevant(normalizedToolName, normalizedToolInput)
|
|
1471
1486
|
? protectedSurface
|
|
@@ -1481,7 +1496,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
|
|
|
1481
1496
|
affectedFiles,
|
|
1482
1497
|
}), options.feedbackOptions || {});
|
|
1483
1498
|
const memoryGuard = normalizeMemoryGuardForSentinel(rawMemoryGuard, highRiskAction);
|
|
1484
|
-
const
|
|
1499
|
+
const rawLearnedPolicy = getInterventionRecommendation({
|
|
1485
1500
|
toolName: normalizedToolName,
|
|
1486
1501
|
command: normalizedToolInput.command || '',
|
|
1487
1502
|
affectedFiles,
|
|
@@ -1495,6 +1510,18 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
|
|
|
1495
1510
|
|| process.env.THUMBGATE_FEEDBACK_DIR
|
|
1496
1511
|
|| (repoRoot ? path.join(repoRoot, '.thumbgate') : null),
|
|
1497
1512
|
});
|
|
1513
|
+
// Learned deny/warn models exist to constrain execution, not observation.
|
|
1514
|
+
// Keeping their prediction in the report preserves diagnostics while making
|
|
1515
|
+
// read-only inspection immune to stale or unrelated training state.
|
|
1516
|
+
const learnedPolicy = normalizeLearnedPolicyForSentinel(
|
|
1517
|
+
rawLearnedPolicy,
|
|
1518
|
+
normalizedToolName === 'Bash'
|
|
1519
|
+
|| EDIT_LIKE_TOOLS.has(normalizedToolName)
|
|
1520
|
+
|| highRiskAction
|
|
1521
|
+
|| actionProfile.backgroundAgent
|
|
1522
|
+
|| actionProfile.economicAction
|
|
1523
|
+
|| actionProfile.customerSystemAction
|
|
1524
|
+
);
|
|
1498
1525
|
const blastRadius = buildBlastRadius({
|
|
1499
1526
|
affectedFiles,
|
|
1500
1527
|
integrity,
|
|
@@ -1638,6 +1665,7 @@ module.exports = {
|
|
|
1638
1665
|
evaluateWorkflowSentinel,
|
|
1639
1666
|
isHighRiskAction,
|
|
1640
1667
|
loadGovernanceState,
|
|
1668
|
+
normalizeLearnedPolicyForSentinel,
|
|
1641
1669
|
scoreRisk,
|
|
1642
1670
|
};
|
|
1643
1671
|
|