thumbgate 1.26.8 → 1.27.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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/agentic-verify.txt +1 -0
  3. package/.well-known/llms.txt +2 -0
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +44 -31
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  8. package/adapters/mcp/server-stdio.js +41 -1
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/bench/thumbgate-bench.json +2 -2
  11. package/bin/cli.js +184 -8
  12. package/bin/dashboard-cli.js +7 -0
  13. package/config/gate-classifier-routing.json +98 -0
  14. package/config/gate-templates.json +60 -0
  15. package/config/mcp-allowlists.json +8 -7
  16. package/config/model-candidates.json +71 -6
  17. package/package.json +28 -12
  18. package/public/about.html +162 -0
  19. package/public/chatgpt-app.html +330 -0
  20. package/public/codex-plugin.html +66 -14
  21. package/public/compare.html +2 -2
  22. package/public/dashboard.html +224 -36
  23. package/public/guide.html +2 -2
  24. package/public/index.html +122 -40
  25. package/public/learn.html +70 -0
  26. package/public/lessons.html +129 -6
  27. package/public/numbers.html +2 -2
  28. package/public/pricing.html +28 -23
  29. package/public/pro.html +3 -3
  30. package/scripts/agent-operations-planner.js +621 -0
  31. package/scripts/agent-reward-model.js +53 -1
  32. package/scripts/ai-component-inventory.js +367 -0
  33. package/scripts/classifier-routing.js +130 -0
  34. package/scripts/cli-schema.js +26 -0
  35. package/scripts/commercial-offer.js +10 -2
  36. package/scripts/dashboard-chat.js +199 -51
  37. package/scripts/feedback-sanitizer.js +105 -0
  38. package/scripts/gates-engine.js +301 -67
  39. package/scripts/hybrid-feedback-context.js +141 -7
  40. package/scripts/memory-scope-readiness.js +159 -0
  41. package/scripts/oss-pr-opportunity-scout.js +35 -5
  42. package/scripts/parallel-workflow-orchestrator.js +293 -0
  43. package/scripts/plausible-domain-config.js +86 -0
  44. package/scripts/plausible-server-events.js +4 -2
  45. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  46. package/scripts/qa-scenario-planner.js +136 -0
  47. package/scripts/rate-limiter.js +2 -2
  48. package/scripts/repeat-metric.js +28 -12
  49. package/scripts/secret-fixture-tokens.js +61 -0
  50. package/scripts/secret-scanner.js +44 -5
  51. package/scripts/security-scanner.js +80 -0
  52. package/scripts/seo-gsd.js +113 -0
  53. package/scripts/thumbgate-bench.js +16 -1
  54. package/scripts/tool-registry.js +37 -0
  55. package/scripts/workflow-sentinel.js +282 -54
  56. package/src/api/server.js +466 -60
  57. package/.claude-plugin/marketplace.json +0 -85
@@ -2,53 +2,138 @@
2
2
 
3
3
  // scripts/dashboard-chat.js
4
4
  // -----------------------------------------------------------------------------
5
- // "Chat with your data" — the dashboard chat backend. Answers a natural-language
6
- // question about THIS install's ThumbGate data (captured lessons + prevention
7
- // rules) by retrieving the most relevant lessons and asking Gemini to answer
8
- // grounded ONLY in that retrieved context (RAG). No data leaves the box except
9
- // the retrieved snippets + the question, sent to the configured Gemini endpoint.
5
+ // "Chat with your data" — the dashboard chat backend. Local-first RAG over
6
+ // this install's ThumbGate data (lessons, raw feedback memories via LanceDB
7
+ // vectors, receipts, gate stats). Retrieval is local (lesson search + optional
8
+ // vector-store.searchSimilar). Generation uses your configured LLM: a local
9
+ // OpenAI-compatible endpoint first, then Gemini or Perplexity when explicitly
10
+ // configured.
10
11
  //
11
- // Enterprise framing: this is the in-product "chat with your governed data"
12
- // experience. (The Dialogflow CX messenger widget is the separate path where a
13
- // customer connects their own DFCX agent + the ThumbGate webhook gate.)
12
+ // Dialogflow/Google is not the dashboard chatbot brain. It remains an optional
13
+ // guard-adapter path for buyers who already run their own Google agent tenancy.
14
14
  // -----------------------------------------------------------------------------
15
15
 
16
16
  const path = require('path');
17
17
 
18
18
  const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models';
19
+ const PERPLEXITY_ENDPOINT = 'https://api.perplexity.ai/chat/completions';
19
20
  const DEFAULT_MODEL = 'gemini-2.5-flash';
20
21
  const MAX_QUESTION_CHARS = 2000;
21
22
  const MAX_CONTEXT_LESSONS = 8;
22
23
 
24
+ // Allowlist the model so a user-supplied `model` cannot route the call to an
25
+ // arbitrary / unexpected (or more expensive) endpoint. Anything not on the list
26
+ // falls back to the default.
27
+ const ALLOWED_MODELS = new Set([
28
+ 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.5-pro',
29
+ 'gemini-2.0-flash', 'gemini-2.0-flash-lite',
30
+ 'gemini-flash-latest', 'gemini-flash-lite-latest', 'gemini-pro-latest',
31
+ ]);
32
+
33
+ function resolveModel(requested) {
34
+ const r = String(requested || '').trim();
35
+ if (r && ALLOWED_MODELS.has(r)) return r;
36
+ const envModel = String(process.env.THUMBGATE_GEMINI_MODEL || '').trim();
37
+ if (envModel && ALLOWED_MODELS.has(envModel)) return envModel;
38
+ return DEFAULT_MODEL;
39
+ }
40
+
23
41
  function resolveApiKey(opts = {}) {
24
- return opts.apiKey || process.env.GEMINI_API_KEY || process.env.THUMBGATE_GEMINI_API_KEY || '';
42
+ let key = '';
43
+ if (Object.hasOwn(opts, 'apiKey')) {
44
+ key = opts.apiKey || '';
45
+ } else {
46
+ key = opts.apiKey || process.env.GEMINI_API_KEY || process.env.THUMBGATE_GEMINI_API_KEY || process.env.GOOGLE_API_KEY || process.env.PERPLEXITY_API_KEY || process.env.THUMBGATE_PERPLEXITY_API_KEY || '';
47
+ }
48
+ if (!key) return '';
49
+ return key.trim().replace(/^["']|["']$/g, '');
50
+ }
51
+
52
+ function debugChatFallback(label, err) {
53
+ if (process.env.THUMBGATE_DEBUG_CHAT !== '1') return;
54
+ const detail = err?.message ? err.message : String(err);
55
+ console.warn(`[dashboard-chat] ${label}: ${detail}`);
25
56
  }
26
57
 
27
- // Retrieve the most relevant stored lessons for the question.
28
- function retrieveContext(question, opts = {}) {
29
- let searchLessons;
58
+ function loadLessonSearcher() {
30
59
  try {
31
- ({ searchLessons } = require(path.join(__dirname, 'lesson-search')));
32
- } catch (_) {
33
- return [];
60
+ return require(path.join(__dirname, 'lesson-search')).searchLessons;
61
+ } catch (err) {
62
+ debugChatFallback('lesson search unavailable', err);
63
+ return null;
34
64
  }
35
- let res;
65
+ }
66
+
67
+ function lessonToContextItem(lesson) {
68
+ return {
69
+ id: lesson.id,
70
+ signal: lesson.signal || lesson.feedback || '',
71
+ title: (lesson.title || '').replace(/^(?:MISTAKE|SUCCESS):\s*/i, '').slice(0, 160),
72
+ content: String(lesson.content || lesson.context || '').replace(/\s+/g, ' ').trim().slice(0, 600),
73
+ tags: lesson.tags || [],
74
+ source: 'lessons',
75
+ };
76
+ }
77
+
78
+ function vectorMatchToContextItem(match, index) {
79
+ return {
80
+ id: match.id || `vec-${index}`,
81
+ signal: match.signal || '',
82
+ title: String(match.context || match.text || '').slice(0, 100),
83
+ content: match.text || match.context || '',
84
+ tags: match.tags ? String(match.tags).split(',').filter(Boolean) : [],
85
+ source: 'lancedb-vector',
86
+ };
87
+ }
88
+
89
+ function dedupeContextItems(items, limit = MAX_CONTEXT_LESSONS + 3) {
90
+ const seen = new Set();
91
+ return items.filter((item) => {
92
+ if (!(item.content || item.title)) return false;
93
+ const key = item.id || item.content.slice(0, 80);
94
+ if (seen.has(key)) return false;
95
+ seen.add(key);
96
+ return true;
97
+ }).slice(0, limit);
98
+ }
99
+
100
+ function retrieveLessonContext(question, opts = {}) {
101
+ const searchLessons = loadLessonSearcher();
102
+ if (!searchLessons) return [];
36
103
  try {
37
- res = searchLessons(String(question || ''), {
104
+ const res = searchLessons(String(question || ''), {
38
105
  limit: MAX_CONTEXT_LESSONS,
39
106
  feedbackDir: opts.feedbackDir,
40
107
  });
41
- } catch (_) {
108
+ const rows = res?.results || res?.lessons || [];
109
+ return rows.slice(0, MAX_CONTEXT_LESSONS).map(lessonToContextItem);
110
+ } catch (err) {
111
+ debugChatFallback('lesson retrieval failed', err);
112
+ return [];
113
+ }
114
+ }
115
+
116
+ async function retrieveVectorContext(question, opts = {}) {
117
+ if (opts.useVectorSearch === false) return [];
118
+ try {
119
+ const vectorStore = require(path.join(__dirname, 'vector-store'));
120
+ const vecResults = vectorStore.searchSimilar
121
+ ? await vectorStore.searchSimilar(String(question || ''), opts.vectorLimit || 4)
122
+ : [];
123
+ return vecResults
124
+ .filter((match) => match?.text)
125
+ .map(vectorMatchToContextItem);
126
+ } catch (err) {
127
+ debugChatFallback('vector retrieval failed', err);
42
128
  return [];
43
129
  }
44
- const rows = (res && (res.results || res.lessons)) || [];
45
- return rows.slice(0, MAX_CONTEXT_LESSONS).map((l) => ({
46
- id: l.id,
47
- signal: l.signal || l.feedback || '',
48
- title: (l.title || '').replace(/^(?:MISTAKE|SUCCESS):\s*/i, '').slice(0, 160),
49
- content: String(l.content || l.context || '').replace(/\s+/g, ' ').trim().slice(0, 600),
50
- tags: l.tags || [],
51
- })).filter((l) => l.content || l.title);
130
+ }
131
+
132
+ // Retrieve relevant stored lessons and optional raw feedback vector matches.
133
+ async function retrieveContext(question, opts = {}) {
134
+ const lessons = retrieveLessonContext(question, opts);
135
+ const vectors = await retrieveVectorContext(question, opts);
136
+ return dedupeContextItems([...lessons, ...vectors]);
52
137
  }
53
138
 
54
139
  // Build a grounded RAG prompt. Pure function (testable).
@@ -72,15 +157,87 @@ function buildChatPrompt(question, lessons) {
72
157
 
73
158
  // Parse the Gemini generateContent response into plain text. Pure (testable).
74
159
  function parseGeminiAnswer(body) {
75
- const parts = body
76
- && body.candidates
77
- && body.candidates[0]
78
- && body.candidates[0].content
79
- && body.candidates[0].content.parts;
160
+ const parts = body?.candidates?.[0]?.content?.parts;
80
161
  if (!Array.isArray(parts)) return '';
81
162
  return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('').trim();
82
163
  }
83
164
 
165
+ function buildOpenAiChatPayload(prompt, model) {
166
+ return JSON.stringify({
167
+ model,
168
+ messages: [{ role: 'user', content: prompt }],
169
+ temperature: 0.2,
170
+ max_tokens: 1024,
171
+ });
172
+ }
173
+
174
+ function parseOpenAiChatAnswer(json) {
175
+ return json?.choices?.[0]?.message?.content || '';
176
+ }
177
+
178
+ function parseModelError(json, status) {
179
+ return json?.error?.message ? String(json.error.message).split('\n')[0] : `HTTP ${status}`;
180
+ }
181
+
182
+ function trimTrailingSlashes(value) {
183
+ let text = String(value || '');
184
+ while (text.endsWith('/')) {
185
+ text = text.slice(0, -1);
186
+ }
187
+ return text;
188
+ }
189
+
190
+ async function callLocalOpenAiEndpoint({ endpoint, apiKey, model, prompt, fetchImpl, sources }) {
191
+ const url = endpoint.includes('/chat/completions')
192
+ ? endpoint
193
+ : `${trimTrailingSlashes(endpoint)}/chat/completions`;
194
+ const res = await fetchImpl(url, {
195
+ method: 'POST',
196
+ headers: {
197
+ 'content-type': 'application/json',
198
+ 'Authorization': `Bearer ${apiKey || 'local'}`
199
+ },
200
+ body: buildOpenAiChatPayload(prompt, model),
201
+ });
202
+ const json = await res.json().catch(() => ({}));
203
+ if (!res.ok) {
204
+ return { ok: false, error: 'local_llm_error', status: res.status, message: parseModelError(json, res.status), sources };
205
+ }
206
+ const answer = parseOpenAiChatAnswer(json);
207
+ return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || model };
208
+ }
209
+
210
+ async function callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources }) {
211
+ const res = await fetchImpl(PERPLEXITY_ENDPOINT, {
212
+ method: 'POST',
213
+ headers: { 'content-type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
214
+ body: buildOpenAiChatPayload(prompt, 'sonar'),
215
+ });
216
+ const json = await res.json().catch(() => ({}));
217
+ if (!res.ok) {
218
+ return { ok: false, error: 'perplexity_error', status: res.status, message: parseModelError(json, res.status), sources };
219
+ }
220
+ const answer = parseOpenAiChatAnswer(json);
221
+ return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || 'perplexity-hybrid' };
222
+ }
223
+
224
+ async function callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources }) {
225
+ const res = await fetchImpl(`${GEMINI_ENDPOINT}/${encodeURIComponent(model)}:generateContent`, {
226
+ method: 'POST',
227
+ headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey },
228
+ body: JSON.stringify({
229
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
230
+ generationConfig: { temperature: 0.2, maxOutputTokens: 1024 },
231
+ }),
232
+ });
233
+ const json = await res.json().catch(() => ({}));
234
+ if (!res.ok) {
235
+ return { ok: false, error: 'gemini_error', status: res.status, message: parseModelError(json, res.status), sources };
236
+ }
237
+ const answer = parseGeminiAnswer(json);
238
+ return { ok: true, answer: answer || '(no answer returned)', sources, model: json.modelVersion || model };
239
+ }
240
+
84
241
  // Answer a question grounded in this install's lessons. Returns
85
242
  // { ok, answer, sources, model } or { ok:false, error, ... }.
86
243
  async function answerDataQuestion(question, opts = {}) {
@@ -90,41 +247,32 @@ async function answerDataQuestion(question, opts = {}) {
90
247
  return { ok: false, error: 'question_too_long', message: `Question exceeds ${MAX_QUESTION_CHARS} characters.` };
91
248
  }
92
249
 
250
+ const localEndpoint = opts.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '';
251
+ const localModel = opts.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || 'llama3';
93
252
  const apiKey = resolveApiKey(opts);
94
- const lessons = retrieveContext(q, opts);
253
+ const lessons = await retrieveContext(q, opts);
95
254
  const sources = lessons.map((l) => ({ id: l.id, title: l.title, signal: l.signal }));
96
255
 
97
- if (!apiKey) {
256
+ if (!apiKey && !localEndpoint) {
98
257
  return {
99
258
  ok: false,
100
259
  error: 'no_api_key',
101
- message: 'Chat is not configured. Set GEMINI_API_KEY (e.g. `npx thumbgate setup-vertex --write`) to enable "chat with your data".',
260
+ message: 'Chat is not configured. Set a valid GEMINI_API_KEY, PERPLEXITY_API_KEY, or THUMBGATE_LOCAL_LLM_ENDPOINT in the project .env.',
102
261
  sources,
103
262
  };
104
263
  }
105
264
 
106
- const model = opts.model || process.env.THUMBGATE_GEMINI_MODEL || DEFAULT_MODEL;
265
+ const model = resolveModel(opts.model);
107
266
  const prompt = buildChatPrompt(q, lessons);
108
267
  const fetchImpl = opts.fetch || globalThis.fetch;
268
+ const isPerplexity = apiKey && (apiKey.startsWith('pplx-') || apiKey.includes('perplexity'));
109
269
 
110
270
  try {
111
- const res = await fetchImpl(`${GEMINI_ENDPOINT}/${encodeURIComponent(model)}:generateContent`, {
112
- method: 'POST',
113
- headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey },
114
- body: JSON.stringify({
115
- contents: [{ role: 'user', parts: [{ text: prompt }] }],
116
- generationConfig: { temperature: 0.2, maxOutputTokens: 1024 },
117
- }),
118
- });
119
- const json = await res.json().catch(() => ({}));
120
- if (!res.ok) {
121
- const msg = (json && json.error && json.error.message) ? String(json.error.message).split('\n')[0] : `HTTP ${res.status}`;
122
- return { ok: false, error: 'gemini_error', status: res.status, message: msg, sources };
123
- }
124
- const answer = parseGeminiAnswer(json);
125
- return { ok: true, answer: answer || '(no answer returned)', sources, model: json.modelVersion || model };
271
+ if (localEndpoint) return await callLocalOpenAiEndpoint({ endpoint: localEndpoint, apiKey, model: localModel, prompt, fetchImpl, sources });
272
+ if (isPerplexity) return await callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources });
273
+ return await callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources });
126
274
  } catch (err) {
127
- return { ok: false, error: 'network', message: err && err.message ? err.message : String(err), sources };
275
+ return { ok: false, error: 'network', message: err?.message || String(err), sources };
128
276
  }
129
277
  }
130
278
 
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const TRANSPORT_KEYS = new Set([
6
+ 'hookeventname',
7
+ 'hook_event_name',
8
+ 'sessionid',
9
+ 'session_id',
10
+ 'transcriptpath',
11
+ 'transcript_path',
12
+ 'timestamp',
13
+ 'createdat',
14
+ 'created_at',
15
+ 'updatedat',
16
+ 'updated_at',
17
+ 'cwd',
18
+ 'pid',
19
+ 'processid',
20
+ 'process_id',
21
+ 'promptid',
22
+ 'prompt_id',
23
+ 'traceid',
24
+ 'trace_id',
25
+ 'requestid',
26
+ 'request_id',
27
+ 'installid',
28
+ 'install_id',
29
+ 'visitorsessionid',
30
+ 'visitor_session_id',
31
+ 'toolinput',
32
+ 'tool_input',
33
+ ]);
34
+
35
+ const TRANSPORT_WORDS = new Set([
36
+ ...TRANSPORT_KEYS,
37
+ 'hook',
38
+ 'event',
39
+ 'userpromptsubmit',
40
+ 'user_prompt_submit',
41
+ 'pretooluse',
42
+ 'pre_tool_use',
43
+ 'posttooluse',
44
+ 'post_tool_use',
45
+ 'claude',
46
+ 'codex',
47
+ 'projects',
48
+ 'redacted',
49
+ 'tmp',
50
+ 'private',
51
+ 'folders',
52
+ 'json',
53
+ ]);
54
+
55
+ function stripEphemeralText(text) {
56
+ if (!text || typeof text !== 'string') return '';
57
+ return String(text)
58
+ .replace(/["']?(?:hook_?event_?name|session_?id|transcript_?path|timestamp|created_?at|updated_?at|cwd|pid|process_?id|prompt_?id|trace_?id|request_?id|install_?id|visitor_?session_?id)["']?\s*[:=]\s*["']?[^"',}\]\s]+["']?/gi, ' ')
59
+ .replace(/\/(?:private\/)?tmp\/[^\s"',}\]]+/gi, ' ')
60
+ .replace(/\/var\/folders\/[^\s"',}\]]+/gi, ' ')
61
+ .replace(/\/Users\/[^/\s]+\/\.(?:claude|codex|thumbgate)\/[^\s"',}\]]+/gi, ' ')
62
+ .replace(/\/Users\/[^/\s]+\/\.config\/thumbgate\/[^\s"',}\]]+/gi, ' ')
63
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' ')
64
+ .replace(/\b[0-9a-f]{24,}\b/gi, ' ')
65
+ .replace(/\b\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}(?:\.\d+)?z?\b/gi, ' ')
66
+ .replace(/\b\d{10,13}\b/g, ' ')
67
+ .replace(/:\d{4,5}\b/g, ':PORT');
68
+ }
69
+
70
+ function transportWordsOnly(text) {
71
+ const tokens = String(text || '')
72
+ .toLowerCase()
73
+ .replace(/[^a-z0-9_ -]/g, ' ')
74
+ .split(/\s+/)
75
+ .filter((token) => token.length >= 3);
76
+ if (tokens.length === 0) return true;
77
+ return tokens.every((token) => TRANSPORT_WORDS.has(token));
78
+ }
79
+
80
+ function sanitizeFeedbackText(text) {
81
+ const stripped = stripEphemeralText(text)
82
+ .replace(/\/Users\/[^\s/]+/g, '/Users/redacted')
83
+ .replace(/\s+/g, ' ')
84
+ .trim();
85
+ if (transportWordsOnly(stripped)) return '';
86
+ return stripped;
87
+ }
88
+
89
+ function actionFingerprint(parts) {
90
+ const raw = Array.isArray(parts) ? parts.join(' ') : String(parts || '');
91
+ const stable = sanitizeFeedbackText(raw)
92
+ .toLowerCase()
93
+ .replace(/[^a-z0-9._:/ -]/g, ' ')
94
+ .replace(/\s+/g, ' ')
95
+ .trim();
96
+ if (!stable || transportWordsOnly(stable)) return null;
97
+ return crypto.createHash('sha256').update(stable).digest('hex').slice(0, 16);
98
+ }
99
+
100
+ module.exports = {
101
+ TRANSPORT_WORDS,
102
+ sanitizeFeedbackText,
103
+ actionFingerprint,
104
+ transportWordsOnly,
105
+ };