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.
@@ -57,11 +57,15 @@ const {
57
57
  }));
58
58
 
59
59
  const AUDIT_TRAIL_TAG = 'audit-trail';
60
- const FEEDBACK_EVENT_DEDUPE_WINDOW_MS = 30 * 1000;
60
+ const FEEDBACK_EVENT_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
61
61
  const FEEDBACK_EVENT_CLAIM_STALE_MS = 60 * 1000;
62
62
  const FEEDBACK_EVENT_CLAIM_WAIT_MS = 15 * 1000;
63
63
  const FEEDBACK_EVENT_CLAIM_POLL_MS = 25;
64
64
 
65
+ function isSelfHarnessOptimizerEnabled(env = process.env) {
66
+ return /^(?:1|true)$/i.test(String(env.THUMBGATE_SELF_HARNESS_OPTIMIZER || '').trim());
67
+ }
68
+
65
69
  /**
66
70
  * Anonymous fire-and-forget CLI feedback telemetry.
67
71
  *
@@ -90,7 +94,9 @@ function emitAnonymousFeedbackPing(signal) {
90
94
  return;
91
95
  }
92
96
 
93
- const normalizedSignal = signal === 'positive' ? 'up' : signal === 'negative' ? 'down' : null;
97
+ let normalizedSignal = null;
98
+ if (signal === 'positive') normalizedSignal = 'up';
99
+ if (signal === 'negative') normalizedSignal = 'down';
94
100
  if (!normalizedSignal) return;
95
101
 
96
102
  // Reuse the canonical installId from cli-telemetry.js (persisted at
@@ -101,24 +107,24 @@ function emitAnonymousFeedbackPing(signal) {
101
107
  try {
102
108
  const { getInstallId } = require('./cli-telemetry');
103
109
  installId = getInstallId();
104
- } catch (_) { /* fall through */ }
110
+ } catch {
111
+ installId = null;
112
+ }
105
113
  if (!installId) {
106
- try {
107
- installId = require('crypto').randomUUID();
108
- } catch (_) {
109
- return; // no crypto, no install id → drop silently
110
- }
114
+ installId = crypto.randomUUID();
111
115
  }
112
116
 
113
117
  let tier = 'free';
114
118
  try {
115
119
  const { getStatuslineMeta } = require('./statusline-meta');
116
120
  const meta = getStatuslineMeta({ env });
117
- const rawTier = String(meta && meta.tier ? meta.tier : 'free').toLowerCase();
121
+ const rawTier = String(meta?.tier || 'free').toLowerCase();
118
122
  if (rawTier === 'pro' || rawTier === 'enterprise' || rawTier === 'free') {
119
123
  tier = rawTier;
120
124
  }
121
- } catch (_) { /* default to 'free' */ }
125
+ } catch {
126
+ tier = 'free';
127
+ }
122
128
 
123
129
  const base = env.THUMBGATE_PUBLIC_APP_ORIGIN
124
130
  || env.THUMBGATE_API_URL
@@ -137,13 +143,17 @@ function emitAnonymousFeedbackPing(signal) {
137
143
  if (typeof fetch !== 'function' || typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') {
138
144
  return;
139
145
  }
140
- fetch(`${base.replace(/\/+$/, '')}/v1/telemetry/ping`, {
146
+ let appOrigin = String(base);
147
+ while (appOrigin.endsWith('/')) appOrigin = appOrigin.slice(0, -1);
148
+ void fetch(`${appOrigin}/v1/telemetry/ping`, {
141
149
  method: 'POST',
142
150
  headers: { 'Content-Type': 'application/json' },
143
151
  body,
144
152
  signal: AbortSignal.timeout(2000),
145
- }).catch(() => { /* fire-and-forget */ });
146
- } catch (_) { /* telemetry must never disrupt CLI */ }
153
+ }).catch(() => undefined);
154
+ } catch {
155
+ return;
156
+ }
147
157
  }
148
158
 
149
159
  function isAuditTrailEntry(entry = {}) {
@@ -666,8 +676,11 @@ function parseFeedbackSourceTimestamp(value) {
666
676
  */
667
677
  function buildFeedbackSourceIdentity(input = {}) {
668
678
  const signal = normalizeSignal(input.signal);
669
- const normalizedText = normalizeFeedbackText(input.promptText || input.context);
670
- if (!signal || !normalizedText) return null;
679
+ if (!signal) return null;
680
+ // Emoji-only prompts normalize to no alphanumeric text. Preserve a stable,
681
+ // non-sensitive canonical value so duplicate 👍/👎 hook deliveries still
682
+ // share one source identity and cannot create duplicate feedback rows.
683
+ const normalizedText = normalizeFeedbackText(input.promptText || input.context) || `thumbs-${signal}`;
671
684
 
672
685
  const sessionHash = hashFeedbackSourcePart(input.sessionId);
673
686
  const promptIdHash = hashFeedbackSourcePart(input.promptId);
@@ -696,10 +709,10 @@ function buildFeedbackSourceIdentity(input = {}) {
696
709
  function normalizeFeedbackSourceIdentity(identity, params = {}) {
697
710
  if (!identity || typeof identity !== 'object' || typeof identity.key !== 'string') return null;
698
711
  const signal = normalizeSignal(params.signal || identity.signal);
712
+ if (!signal) return null;
699
713
  const normalizedText = normalizeFeedbackText(
700
714
  identity.normalizedText || params.context || params.whatWentWrong || params.whatWorked,
701
- );
702
- if (!signal || !normalizedText) return null;
715
+ ) || `thumbs-${signal}`;
703
716
 
704
717
  return {
705
718
  key: `fev_${hashFeedbackSourcePart(identity.key)}`,
@@ -781,6 +794,9 @@ function duplicateCaptureResult(receipt, paths, options = {}) {
781
794
  const memoryRecord = findRecordById(paths.MEMORY_LOG_PATH, receipt?.memoryId);
782
795
  return {
783
796
  accepted: Boolean(receipt?.accepted),
797
+ captured: Boolean(receipt?.signalLogged),
798
+ promoted: Boolean(receipt?.promoted),
799
+ promotionAccepted: Boolean(receipt?.promoted),
784
800
  signalLogged: Boolean(receipt?.signalLogged),
785
801
  duplicate: true,
786
802
  pending: Boolean(options.pending),
@@ -820,7 +836,8 @@ function receiptFromFeedbackEntry(entry, paths) {
820
836
  status: 'complete',
821
837
  feedbackId: entry.id,
822
838
  memoryId: memory?.id,
823
- accepted: entry.actionType !== 'no-action'
839
+ accepted: !Array.isArray(entry.validationIssues) || entry.validationIssues.length === 0,
840
+ promoted: entry.actionType !== 'no-action'
824
841
  && (!Array.isArray(entry.validationIssues) || entry.validationIssues.length === 0),
825
842
  signalLogged: true,
826
843
  completedAtMs: Date.parse(entry.timestamp || '') || Date.now(),
@@ -888,6 +905,7 @@ function finalizeFeedbackEventClaim(claim, result) {
888
905
  startedAtMs: Date.now(),
889
906
  completedAtMs: Date.now(),
890
907
  accepted: Boolean(result?.accepted),
908
+ promoted: Boolean(result?.promoted || result?.memoryRecord),
891
909
  signalLogged: Boolean(result?.signalLogged || result?.feedbackEvent),
892
910
  feedbackId: result?.feedbackEvent?.id || null,
893
911
  memoryId: result?.memoryRecord?.id || null,
@@ -1481,12 +1499,15 @@ function captureFeedback(params) {
1481
1499
  trainAndPersistInterventionPolicy(FEEDBACK_DIR);
1482
1500
  } catch { /* non-critical */ }
1483
1501
  updateStatuslineWithLesson({
1484
- accepted: false,
1502
+ accepted: true,
1485
1503
  signal,
1486
1504
  feedbackId: feedbackEvent.id,
1487
1505
  });
1488
1506
  return {
1489
- accepted: false,
1507
+ accepted: true,
1508
+ captured: true,
1509
+ promoted: false,
1510
+ promotionAccepted: false,
1490
1511
  signalLogged: true,
1491
1512
  status: clarification ? 'clarification_required' : 'rejected',
1492
1513
  reason: action.reason,
@@ -1692,6 +1713,9 @@ function captureFeedback(params) {
1692
1713
  // Build result immediately — all remaining side-effects are deferred
1693
1714
  const result = {
1694
1715
  accepted: true,
1716
+ captured: true,
1717
+ promoted: true,
1718
+ promotionAccepted: true,
1695
1719
  status: 'promoted',
1696
1720
  message: 'Feedback promoted to reusable memory.',
1697
1721
  feedbackEvent,
@@ -1770,19 +1794,23 @@ function captureFeedback(params) {
1770
1794
  });
1771
1795
  } catch { /* activation telemetry is non-critical */ }
1772
1796
 
1773
- // Trigger Self-Harness Optimizer to propagate the new rules to prompt files & validate
1774
- try {
1775
- const { fork } = require('child_process');
1776
- const localOptimizerPath = path.join(process.cwd(), 'scripts', 'self-harness-optimizer.js');
1777
- const packageOptimizerPath = path.join(__dirname, 'self-harness-optimizer.js');
1778
-
1779
- if (fs.existsSync(localOptimizerPath)) {
1780
- fork(localOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
1781
- } else if (fs.existsSync(packageOptimizerPath)) {
1782
- fork(packageOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
1797
+ // Prompt-file mutation and git commits are explicit opt-in side effects.
1798
+ // Feedback capture must never detach an unrequested process that edits
1799
+ // AGENTS.md/GEMINI.md merely because a negative rule was promoted.
1800
+ if (isSelfHarnessOptimizerEnabled()) {
1801
+ try {
1802
+ const { fork } = require('node:child_process');
1803
+ const localOptimizerPath = path.join(process.cwd(), 'scripts', 'self-harness-optimizer.js');
1804
+ const packageOptimizerPath = path.join(__dirname, 'self-harness-optimizer.js');
1805
+
1806
+ if (fs.existsSync(localOptimizerPath)) {
1807
+ fork(localOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
1808
+ } else if (fs.existsSync(packageOptimizerPath)) {
1809
+ fork(packageOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
1810
+ }
1811
+ } catch (err) {
1812
+ console.error('Failed to trigger self-harness optimizer:', err);
1783
1813
  }
1784
- } catch (err) {
1785
- console.error('Failed to trigger self-harness optimizer:', err);
1786
1814
  }
1787
1815
  }
1788
1816
  } catch { /* Gate promotion is non-critical */ }
@@ -1798,8 +1826,9 @@ function captureFeedback(params) {
1798
1826
  feedbackDir: FEEDBACK_DIR,
1799
1827
  outputDir: process.env.THUMBGATE_OBSIDIAN_VAULT_PATH,
1800
1828
  });
1801
- } catch (_err) {
1802
- // Non-critical, do not crash feedback loop
1829
+ } catch {
1830
+ resolve();
1831
+ return;
1803
1832
  }
1804
1833
  resolve();
1805
1834
  });
@@ -2416,10 +2445,10 @@ function runTests() {
2416
2445
  budgetCompliant: true,
2417
2446
  }),
2418
2447
  });
2419
- assert(!blocked.accepted, 'captureFeedback blocks unsafe positive promotion via rubric gate');
2448
+ assert(blocked.accepted && blocked.promoted === false, 'captureFeedback stores but does not promote unsafe positive feedback');
2420
2449
 
2421
2450
  const bad = captureFeedback({ signal: 'down' });
2422
- assert(!bad.accepted, 'captureFeedback rejects vague negative feedback');
2451
+ assert(bad.accepted && bad.promoted === false, 'captureFeedback stores vague negative feedback without promotion');
2423
2452
  assert(bad.needsClarification === true, 'captureFeedback requests clarification for vague negative feedback');
2424
2453
 
2425
2454
  const summary = feedbackSummary(5);
@@ -2489,6 +2518,7 @@ function buildCorrectiveActionsReminder(correctiveActions = []) {
2489
2518
 
2490
2519
  module.exports = {
2491
2520
  captureFeedback: captureFeedbackIdempotent,
2521
+ emitAnonymousFeedbackPing,
2492
2522
  buildFeedbackSourceIdentity,
2493
2523
  compactMemories,
2494
2524
  buildCorrectiveActionsReminder,
@@ -2508,6 +2538,7 @@ module.exports = {
2508
2538
  updateStatuslineWithLesson,
2509
2539
  waitForBackgroundSideEffects,
2510
2540
  getPendingBackgroundSideEffectCount,
2541
+ isSelfHarnessOptimizerEnabled,
2511
2542
  getFeedbackPaths,
2512
2543
  get FEEDBACK_LOG_PATH() {
2513
2544
  return getFeedbackPaths().FEEDBACK_LOG_PATH;
@@ -2003,7 +2003,12 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
2003
2003
  } else {
2004
2004
  guard = hybrid.evaluatePretool(toolName, serializedInput);
2005
2005
  }
2006
- if (!guard || guard.mode === 'allow') {
2006
+ if (!guard || guard.mode !== 'block') {
2007
+ return null;
2008
+ }
2009
+ const guardReason = String(guard.reason || '');
2010
+ const contextualMatch = /^(?:Matched guard pattern|Recurring negative pattern)\b/i.test(guardReason);
2011
+ if (!contextualMatch) {
2007
2012
  return null;
2008
2013
  }
2009
2014
 
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Lesson Embedding Index — maintained dense index over the lesson corpus.
6
+ *
7
+ * Powers the dense half of hybrid retrieval in the per-action gating hot path.
8
+ * The gating path reads memory-log lessons and must surface past mistakes that
9
+ * are semantically related to the current action even when they share no
10
+ * keywords (paraphrase, synonym, different file path). Pure lexical scoring
11
+ * misses those; dense embeddings catch them.
12
+ *
13
+ * Design constraints:
14
+ * - Embedding the whole corpus on every tool call is too expensive. We cache
15
+ * document vectors keyed by `id + sha1(text)` in <feedbackDir>/lesson-embeddings.json.
16
+ * Only the query is embedded per call; only new/changed lessons re-embed.
17
+ * - The embedder is reused from vector-store.embed (Gemini -> local transformers
18
+ * -> stub). No new embedding dependency.
19
+ * - HONESTY: when no real embedder is available, callers must degrade to lexical.
20
+ * We never synthesize fake (e.g. hash-based) vectors — that would be overclaiming.
21
+ *
22
+ * Zero hard dependency on LanceDB: this module only needs the embed() function.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const crypto = require('crypto');
28
+
29
+ const CACHE_FILE = 'lesson-embeddings.json';
30
+
31
+ function resolveFeedbackDir(explicit) {
32
+ if (explicit) return explicit;
33
+ try {
34
+ const { resolveFeedbackDir: resolve } = require('./feedback-paths');
35
+ return resolve();
36
+ } catch {
37
+ return process.env.THUMBGATE_FEEDBACK_DIR || process.cwd();
38
+ }
39
+ }
40
+
41
+ function getCachePath(feedbackDir) {
42
+ return path.join(resolveFeedbackDir(feedbackDir), CACHE_FILE);
43
+ }
44
+
45
+ /** Canonical text for a lesson record (title + content + tags). */
46
+ function lessonText(lesson) {
47
+ if (!lesson) return '';
48
+ return [
49
+ lesson.title || '',
50
+ lesson.content || '',
51
+ Array.isArray(lesson.tags) ? lesson.tags.join(' ') : '',
52
+ ].filter(Boolean).join(' ').trim();
53
+ }
54
+
55
+ /**
56
+ * Truncate a vector to a specific dimension (Matryoshka Embedding Truncation).
57
+ * Assumes the model supports MRL (like text-embedding-3 or modern transformers).
58
+ */
59
+ function truncateVector(vector, dimension) {
60
+ if (!Array.isArray(vector) || !dimension || vector.length <= dimension) {
61
+ return vector;
62
+ }
63
+ return vector.slice(0, dimension);
64
+ }
65
+
66
+ function hashText(text) {
67
+ // Content-change key for the embedding cache (not a security context). sha256
68
+ // matches the repo's standard non-security hashing and avoids the weak-hash flag.
69
+ return crypto.createHash('sha256').update(String(text || '')).digest('hex');
70
+ }
71
+
72
+ function cosineSimilarity(a, b) {
73
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) {
74
+ return 0;
75
+ }
76
+ let dot = 0;
77
+ let normA = 0;
78
+ let normB = 0;
79
+ for (let i = 0; i < a.length; i++) {
80
+ const x = Number(a[i]) || 0;
81
+ const y = Number(b[i]) || 0;
82
+ dot += x * y;
83
+ normA += x * x;
84
+ normB += y * y;
85
+ }
86
+ if (normA === 0 || normB === 0) return 0;
87
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
88
+ }
89
+
90
+ /**
91
+ * Is a real embedder available? Used so callers can decide hybrid vs lexical-only
92
+ * WITHOUT loading a model. Returns true for the deterministic stub (test/CI safe).
93
+ */
94
+ function isEmbedderAvailable() {
95
+ if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') return true;
96
+ // Managed Gemini path
97
+ try {
98
+ const { resolveGeminiEmbeddingConfig } = require('./gemini-embedding-policy');
99
+ const cfg = resolveGeminiEmbeddingConfig();
100
+ if (cfg && cfg.enabled && cfg.apiKey) return true;
101
+ } catch { /* policy module unavailable */ }
102
+ // Local transformers path
103
+ try {
104
+ require.resolve('@huggingface/transformers');
105
+ return true;
106
+ } catch { /* not installed */ }
107
+ return false;
108
+ }
109
+
110
+ function defaultEmbedder() {
111
+ // Lazy: do not pull in LanceDB/transformers at module require time.
112
+ const { embed } = require('./vector-store');
113
+ return embed;
114
+ }
115
+
116
+ function readCache(cachePath) {
117
+ try {
118
+ const raw = fs.readFileSync(cachePath, 'utf8');
119
+ const parsed = JSON.parse(raw);
120
+ return parsed && typeof parsed === 'object' ? parsed : {};
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+
126
+ function writeCache(cachePath, cache) {
127
+ try {
128
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
129
+ fs.writeFileSync(cachePath, JSON.stringify(cache));
130
+ } catch {
131
+ /* cache is best-effort; never throw into the hot path */
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Rank lessons by dense (embedding) similarity to the query.
137
+ * Returns [{ id, score }] sorted descending. Embeds the query once; reuses cached
138
+ * document vectors and only embeds new/changed lessons.
139
+ *
140
+ * @returns {Promise<Array<{id:string, score:number}>>}
141
+ */
142
+ async function semanticRank(queryText, lessons = [], options = {}) {
143
+ const { feedbackDir, embedder = defaultEmbedder(), persist = true, truncateDimension = null } = options;
144
+ if (!queryText || !Array.isArray(lessons) || lessons.length === 0) return [];
145
+
146
+ const cachePath = getCachePath(feedbackDir);
147
+ const cache = readCache(cachePath);
148
+ let cacheDirty = false;
149
+
150
+ let queryVector = await embedder(queryText, { kind: 'query', task: 'code retrieval' });
151
+ if (!Array.isArray(queryVector) || queryVector.length === 0) return [];
152
+
153
+ if (truncateDimension) {
154
+ queryVector = truncateVector(queryVector, truncateDimension);
155
+ }
156
+
157
+ const scored = [];
158
+ for (const lesson of lessons) {
159
+ if (!lesson || !lesson.id) continue;
160
+ const text = lessonText(lesson);
161
+ if (!text) continue;
162
+ const hash = hashText(text);
163
+
164
+ let entry = cache[lesson.id];
165
+ if (!entry || entry.hash !== hash || !Array.isArray(entry.vector)) {
166
+ const vector = await embedder(text, {
167
+ kind: 'document',
168
+ task: 'code retrieval',
169
+ title: lesson.title || undefined,
170
+ });
171
+ if (!Array.isArray(vector) || vector.length === 0) continue;
172
+ entry = { hash, vector };
173
+ cache[lesson.id] = entry;
174
+ cacheDirty = true;
175
+ }
176
+
177
+ const docVector = truncateDimension ? truncateVector(entry.vector, truncateDimension) : entry.vector;
178
+ scored.push({ id: lesson.id, score: cosineSimilarity(queryVector, docVector) });
179
+ }
180
+
181
+ // Prune cache entries for lessons no longer present (bounded growth).
182
+ const liveIds = new Set(lessons.map((l) => l && l.id).filter(Boolean));
183
+ for (const id of Object.keys(cache)) {
184
+ if (!liveIds.has(id)) {
185
+ delete cache[id];
186
+ cacheDirty = true;
187
+ }
188
+ }
189
+
190
+ if (persist && cacheDirty) writeCache(cachePath, cache);
191
+
192
+ return scored.sort((a, b) => b.score - a.score);
193
+ }
194
+
195
+ module.exports = {
196
+ semanticRank,
197
+ isEmbedderAvailable,
198
+ cosineSimilarity,
199
+ truncateVector,
200
+ lessonText,
201
+ hashText,
202
+ getCachePath,
203
+ };