thumbgate 1.28.2 → 1.28.4

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.
@@ -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
+ };
@@ -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
  };
@@ -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 || 'default';
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,
@@ -3,7 +3,7 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { execFileSync } = require('child_process');
6
+ const { execFileSync } = require('node:child_process');
7
7
 
8
8
  const {
9
9
  DEFAULT_BASE_BRANCH,
@@ -90,7 +90,9 @@ function safeExecFileLines(binary, args, cwd) {
90
90
  }
91
91
 
92
92
  function normalizeGlob(glob) {
93
- return normalizePosix(glob).replace(/\/+$/, '');
93
+ let normalized = normalizePosix(glob);
94
+ while (normalized.endsWith('/')) normalized = normalized.slice(0, -1);
95
+ return normalized;
94
96
  }
95
97
 
96
98
  function sanitizeGlobList(globs) {
@@ -282,17 +284,27 @@ function isProtectedApprovalRelevant(toolName, toolInput = {}) {
282
284
  function normalizeMemoryGuardForSentinel(memoryGuard, isHighRisk) {
283
285
  if (!memoryGuard || memoryGuard.mode === 'allow') return memoryGuard;
284
286
  const reason = String(memoryGuard.reason || '');
285
- const broadToolOnlySignal = /^Tool "[^"]+" has \d+ attributed negative\(s\), \d+ total negative\(s\)$/i.test(reason);
286
- if (!isHighRisk && broadToolOnlySignal) {
287
+ const broadToolOnlySignal = /^Tool "[^"]+" (?:has \d+ attributed negative\(s\), \d+ total negative\(s\)|has recurring negative patterns \(count: \d+\))$/i.test(reason);
288
+ if (broadToolOnlySignal) {
287
289
  return {
288
290
  ...memoryGuard,
289
- mode: 'warn',
290
- reason: `${reason}. Treating this as advisory because the current action is not in the high-risk command set.`,
291
+ mode: 'allow',
292
+ reason: `${reason}. Ignored for this action because no contextual pattern matched the current input.`,
291
293
  };
292
294
  }
293
295
  return memoryGuard;
294
296
  }
295
297
 
298
+ function normalizeLearnedPolicyForSentinel(learnedPolicy, actionable) {
299
+ if (!learnedPolicy?.enabled || actionable) return learnedPolicy;
300
+ return {
301
+ ...learnedPolicy,
302
+ enabled: false,
303
+ advisoryOnly: true,
304
+ reason: 'non_execution_action',
305
+ };
306
+ }
307
+
296
308
  function buildTaskScopeViolation(taskScope, affectedFiles) {
297
309
  if (!Array.isArray(affectedFiles) || affectedFiles.length === 0) return null;
298
310
  if (!taskScope || !Array.isArray(taskScope.allowedPaths) || taskScope.allowedPaths.length === 0) {
@@ -1465,7 +1477,12 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1465
1477
  options,
1466
1478
  }
1467
1479
  );
1468
- const taskScopeViolation = buildTaskScopeViolation(governanceState.taskScope, affectedFiles);
1480
+ // Scope is an execution boundary, not a read boundary. Treating Read/Glob/Grep
1481
+ // paths as writes generated a warning on almost every inspection and trained
1482
+ // callers to ignore the sentinel.
1483
+ const taskScopeViolation = highRiskAction
1484
+ ? buildTaskScopeViolation(governanceState.taskScope, affectedFiles)
1485
+ : null;
1469
1486
  const protectedSurface = buildProtectedSurface(governanceState, affectedFiles);
1470
1487
  const protectedSurfaceForRisk = isProtectedApprovalRelevant(normalizedToolName, normalizedToolInput)
1471
1488
  ? protectedSurface
@@ -1481,7 +1498,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1481
1498
  affectedFiles,
1482
1499
  }), options.feedbackOptions || {});
1483
1500
  const memoryGuard = normalizeMemoryGuardForSentinel(rawMemoryGuard, highRiskAction);
1484
- const learnedPolicy = getInterventionRecommendation({
1501
+ const rawLearnedPolicy = getInterventionRecommendation({
1485
1502
  toolName: normalizedToolName,
1486
1503
  command: normalizedToolInput.command || '',
1487
1504
  affectedFiles,
@@ -1495,6 +1512,18 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1495
1512
  || process.env.THUMBGATE_FEEDBACK_DIR
1496
1513
  || (repoRoot ? path.join(repoRoot, '.thumbgate') : null),
1497
1514
  });
1515
+ // Learned deny/warn models exist to constrain execution, not observation.
1516
+ // Keeping their prediction in the report preserves diagnostics while making
1517
+ // read-only inspection immune to stale or unrelated training state.
1518
+ const learnedPolicy = normalizeLearnedPolicyForSentinel(
1519
+ rawLearnedPolicy,
1520
+ normalizedToolName === 'Bash'
1521
+ || EDIT_LIKE_TOOLS.has(normalizedToolName)
1522
+ || highRiskAction
1523
+ || actionProfile.backgroundAgent
1524
+ || actionProfile.economicAction
1525
+ || actionProfile.customerSystemAction
1526
+ );
1498
1527
  const blastRadius = buildBlastRadius({
1499
1528
  affectedFiles,
1500
1529
  integrity,
@@ -1638,6 +1667,7 @@ module.exports = {
1638
1667
  evaluateWorkflowSentinel,
1639
1668
  isHighRiskAction,
1640
1669
  loadGovernanceState,
1670
+ normalizeLearnedPolicyForSentinel,
1641
1671
  scoreRisk,
1642
1672
  };
1643
1673