thumbgate 1.27.19 → 1.28.0
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/commands/dashboard.md +15 -0
- package/.claude/commands/thumbgate-blocked.md +27 -0
- package/.claude/commands/thumbgate-dashboard.md +15 -0
- package/.claude/commands/thumbgate-doctor.md +30 -0
- package/.claude/commands/thumbgate-guard.md +36 -0
- package/.claude/commands/thumbgate-protect.md +30 -0
- package/.claude/commands/thumbgate-rules.md +30 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +63 -45
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/gcp/dfcx-webhook-gate.js +295 -0
- package/adapters/letta/README.md +41 -0
- package/adapters/letta/thumbgate-letta-adapter.js +133 -0
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
- package/bench/observability-eval-suite.json +26 -0
- package/bin/cli.js +52 -9
- package/bin/dashboard-cli.js +7 -0
- package/bin/postinstall.js +14 -23
- package/commands/dashboard.md +15 -0
- package/commands/thumbgate-dashboard.md +15 -0
- package/config/entitlement-public-keys.json +6 -0
- package/config/gates/default.json +1 -1
- package/config/github-about.json +2 -2
- package/config/merge-quality-checks.json +3 -0
- package/package.json +233 -100
- package/public/about.html +159 -0
- package/public/agent-manager.html +179 -0
- package/public/agents-cost-savings.html +153 -0
- package/public/ai-malpractice-prevention.html +818 -0
- package/public/assets/brand/github-social-preview.png +0 -0
- package/public/assets/brand/thumbgate-icon-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
- package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
- package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
- package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
- package/public/assets/brand/thumbgate-mark-team.svg +26 -0
- package/public/assets/brand/thumbgate-mark.svg +21 -0
- package/public/assets/brand/thumbgate-wordmark.svg +20 -0
- package/public/assets/claude-thumbgate-statusbar.svg +8 -0
- package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
- package/public/assets/legal-intake-control-flow.svg +66 -0
- package/public/blog.html +12 -9
- package/public/brand/thumbgate-mark.svg +19 -0
- package/public/brand/thumbgate-og.svg +16 -0
- package/public/chatgpt-app.html +330 -0
- package/public/codex-enterprise.html +123 -0
- package/public/codex-plugin.html +5 -5
- package/public/compare.html +10 -10
- package/public/dashboard.html +1 -1
- package/public/diagnostic.html +345 -0
- package/public/federal.html +2 -2
- package/public/guide.html +14 -14
- package/public/index.html +81 -70
- package/public/install.html +193 -0
- package/public/js/buyer-intent.js +672 -0
- package/public/learn.html +4 -17
- package/public/numbers.html +2 -2
- package/public/pricing.html +392 -0
- package/public/pro.html +2 -2
- package/scripts/action-receipts.js +324 -0
- package/scripts/activation-quickstart.js +187 -0
- package/scripts/agent-operations-planner.js +621 -0
- package/scripts/agent-reward-model.js +13 -0
- package/scripts/ai-component-inventory.js +367 -0
- package/scripts/async-eval-observability.js +236 -0
- package/scripts/audit.js +65 -0
- package/scripts/aws-blocks-guardrails.js +272 -0
- package/scripts/bayes-optimal-gate.js +6 -1
- package/scripts/classifier-routing.js +130 -0
- package/scripts/cli-feedback.js +17 -1
- package/scripts/commercial-offer.js +3 -3
- package/scripts/dashboard-chat.js +332 -0
- package/scripts/entitlement.js +250 -0
- package/scripts/export-databricks-bundle.js +5 -0
- package/scripts/export-dpo-pairs.js +6 -0
- package/scripts/export-hf-dataset.js +5 -0
- package/scripts/feedback-aggregate.js +281 -0
- package/scripts/feedback-sanitizer.js +105 -0
- package/scripts/gates-engine.js +15 -3
- package/scripts/hook-stop-anti-claim.js +301 -0
- package/scripts/imperative-detector.js +85 -0
- package/scripts/install-shim.js +87 -0
- package/scripts/intervention-policy.js +13 -0
- package/scripts/mcp-oauth.js +293 -0
- package/scripts/noop-detect.js +285 -0
- package/scripts/parallel-workflow-orchestrator.js +293 -0
- package/scripts/plan-gate.js +243 -0
- package/scripts/plausible-domain-config.js +99 -0
- package/scripts/pr-manager.js +9 -22
- package/scripts/pro-local-dashboard.js +198 -0
- package/scripts/qa-scenario-planner.js +136 -0
- package/scripts/repeat-metric.js +137 -0
- package/scripts/risk-scorer.js +6 -0
- package/scripts/secret-fixture-tokens.js +61 -0
- package/scripts/secret-redaction.js +166 -0
- package/scripts/self-harness-optimizer.js +141 -0
- package/scripts/self-healing-check.js +193 -0
- package/scripts/self-protection.js +90 -0
- package/scripts/seo-gsd.js +2 -2
- package/scripts/silent-failure-cluster.js +531 -0
- package/scripts/statusline-cache-read.js +57 -0
- package/scripts/sync-telemetry-from-prod.js +374 -0
- package/scripts/thompson-sampling.js +11 -2
- package/scripts/tool-contract-validator.js +76 -0
- package/scripts/trajectory-scorer.js +63 -0
- package/scripts/verify-marketing-pages-deployed.js +212 -0
- package/scripts/visitor-journey.js +172 -0
- package/src/api/server.js +81 -10
- package/.claude-plugin/marketplace.json +0 -85
- package/adapters/chatgpt/openapi.yaml +0 -1707
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/dashboard-chat.js
|
|
4
|
+
// -----------------------------------------------------------------------------
|
|
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.
|
|
11
|
+
//
|
|
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
|
+
// -----------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
19
|
+
const PERPLEXITY_ENDPOINT = 'https://api.perplexity.ai/chat/completions';
|
|
20
|
+
const DEFAULT_MODEL = 'gemini-2.5-flash';
|
|
21
|
+
const MAX_QUESTION_CHARS = 2000;
|
|
22
|
+
const MAX_CONTEXT_LESSONS = 8;
|
|
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
|
+
|
|
41
|
+
function resolveApiKey(opts = {}) {
|
|
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}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadLessonSearcher() {
|
|
59
|
+
try {
|
|
60
|
+
return require(path.join(__dirname, 'lesson-search')).searchLessons;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
debugChatFallback('lesson search unavailable', err);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
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 [];
|
|
103
|
+
try {
|
|
104
|
+
const res = searchLessons(String(question || ''), {
|
|
105
|
+
limit: MAX_CONTEXT_LESSONS,
|
|
106
|
+
feedbackDir: opts.feedbackDir,
|
|
107
|
+
});
|
|
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);
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Live numeric snapshot from gate-stats + feedback analyzer. Always injected
|
|
133
|
+
// (~120 tokens) so the LLM can answer count/quantity questions like
|
|
134
|
+
// "how many were blocked today" without hallucinating.
|
|
135
|
+
function retrieveMetricsContext() {
|
|
136
|
+
const snapshot = {};
|
|
137
|
+
try {
|
|
138
|
+
const gs = require(path.join(__dirname, 'gate-stats'));
|
|
139
|
+
const s = gs.calculateStats();
|
|
140
|
+
snapshot.gates = {
|
|
141
|
+
total: s.totalGates,
|
|
142
|
+
blockRules: s.blockGates,
|
|
143
|
+
warnRules: s.warnGates,
|
|
144
|
+
totalBlockedEvents: s.totalBlocked,
|
|
145
|
+
totalWarnedEvents: s.totalWarned,
|
|
146
|
+
estimatedHoursSaved: s.estimatedHoursSaved,
|
|
147
|
+
topBlockedTrigger: s.topBlocked?.trigger || null,
|
|
148
|
+
topBlockedOccurrences: s.topBlocked?.occurrences || 0,
|
|
149
|
+
};
|
|
150
|
+
} catch (err) {
|
|
151
|
+
debugChatFallback('gate-stats unavailable', err);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const fl = require(path.join(__dirname, 'feedback-loop'));
|
|
155
|
+
const a = fl.analyzeFeedback();
|
|
156
|
+
snapshot.feedback = {
|
|
157
|
+
total: a.total,
|
|
158
|
+
positive: a.totalPositive,
|
|
159
|
+
negative: a.totalNegative,
|
|
160
|
+
approvalRate: a.approvalRate,
|
|
161
|
+
last7d: a.windows?.['7d'],
|
|
162
|
+
last30d: a.windows?.['30d'],
|
|
163
|
+
trend: a.trend,
|
|
164
|
+
};
|
|
165
|
+
} catch (err) {
|
|
166
|
+
debugChatFallback('feedback-loop analyze unavailable', err);
|
|
167
|
+
}
|
|
168
|
+
return snapshot;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Retrieve relevant stored lessons and optional raw feedback vector matches.
|
|
172
|
+
async function retrieveContext(question, opts = {}) {
|
|
173
|
+
const lessons = retrieveLessonContext(question, opts);
|
|
174
|
+
const vectors = await retrieveVectorContext(question, opts);
|
|
175
|
+
return dedupeContextItems([...lessons, ...vectors]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build a grounded RAG prompt. Pure function (testable).
|
|
179
|
+
function buildChatPrompt(question, lessons, metrics) {
|
|
180
|
+
const q = String(question || '').slice(0, MAX_QUESTION_CHARS).trim();
|
|
181
|
+
const context = (lessons || []).map((l, i) => {
|
|
182
|
+
const mark = /pos|up/i.test(l.signal) ? 'WORKED' : (/neg|down/i.test(l.signal) ? 'MISTAKE' : 'NOTE');
|
|
183
|
+
const tags = (l.tags || []).length ? ` [tags: ${l.tags.join(', ')}]` : '';
|
|
184
|
+
return `(${i + 1}) [${mark}] ${l.title || ''}${tags}\n ${l.content}`;
|
|
185
|
+
}).join('\n');
|
|
186
|
+
|
|
187
|
+
const metricsBlock = metrics && Object.keys(metrics).length
|
|
188
|
+
? `\n=== Live numeric snapshot (your data, current) ===\n${JSON.stringify(metrics, null, 2)}\n`
|
|
189
|
+
: '';
|
|
190
|
+
|
|
191
|
+
const system = [
|
|
192
|
+
'You are ThumbGate\'s "chat with your data" assistant. Answer the user\'s question',
|
|
193
|
+
'using ONLY the captured lessons and the live numeric snapshot below (this team\'s real data).',
|
|
194
|
+
'For count/quantity questions ("how many X", "what\'s our rate"), use the numeric snapshot.',
|
|
195
|
+
'For pattern/why questions, cite the lesson numbers like [1], [3].',
|
|
196
|
+
'If neither source contains the answer, say so plainly — do not invent facts.',
|
|
197
|
+
].join(' ');
|
|
198
|
+
|
|
199
|
+
return `${system}\n\n=== Captured lessons (your data) ===\n${context || '(no relevant lessons found)'}\n${metricsBlock}\n=== Question ===\n${q}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Parse the Gemini generateContent response into plain text. Pure (testable).
|
|
203
|
+
function parseGeminiAnswer(body) {
|
|
204
|
+
const parts = body?.candidates?.[0]?.content?.parts;
|
|
205
|
+
if (!Array.isArray(parts)) return '';
|
|
206
|
+
return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('').trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function buildOpenAiChatPayload(prompt, model) {
|
|
210
|
+
return JSON.stringify({
|
|
211
|
+
model,
|
|
212
|
+
messages: [{ role: 'user', content: prompt }],
|
|
213
|
+
temperature: 0.2,
|
|
214
|
+
max_tokens: 1024,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseOpenAiChatAnswer(json) {
|
|
219
|
+
return json?.choices?.[0]?.message?.content || '';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseModelError(json, status) {
|
|
223
|
+
return json?.error?.message ? String(json.error.message).split('\n')[0] : `HTTP ${status}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function trimTrailingSlashes(value) {
|
|
227
|
+
let text = String(value || '');
|
|
228
|
+
while (text.endsWith('/')) {
|
|
229
|
+
text = text.slice(0, -1);
|
|
230
|
+
}
|
|
231
|
+
return text;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function callLocalOpenAiEndpoint({ endpoint, apiKey, model, prompt, fetchImpl, sources }) {
|
|
235
|
+
const url = endpoint.includes('/chat/completions')
|
|
236
|
+
? endpoint
|
|
237
|
+
: `${trimTrailingSlashes(endpoint)}/chat/completions`;
|
|
238
|
+
const res = await fetchImpl(url, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: {
|
|
241
|
+
'content-type': 'application/json',
|
|
242
|
+
'Authorization': `Bearer ${apiKey || 'local'}`
|
|
243
|
+
},
|
|
244
|
+
body: buildOpenAiChatPayload(prompt, model),
|
|
245
|
+
});
|
|
246
|
+
const json = await res.json().catch(() => ({}));
|
|
247
|
+
if (!res.ok) {
|
|
248
|
+
return { ok: false, error: 'local_llm_error', status: res.status, message: parseModelError(json, res.status), sources };
|
|
249
|
+
}
|
|
250
|
+
const answer = parseOpenAiChatAnswer(json);
|
|
251
|
+
return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || model };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources }) {
|
|
255
|
+
const res = await fetchImpl(PERPLEXITY_ENDPOINT, {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
headers: { 'content-type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
258
|
+
body: buildOpenAiChatPayload(prompt, 'sonar'),
|
|
259
|
+
});
|
|
260
|
+
const json = await res.json().catch(() => ({}));
|
|
261
|
+
if (!res.ok) {
|
|
262
|
+
return { ok: false, error: 'perplexity_error', status: res.status, message: parseModelError(json, res.status), sources };
|
|
263
|
+
}
|
|
264
|
+
const answer = parseOpenAiChatAnswer(json);
|
|
265
|
+
return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || 'perplexity-hybrid' };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources }) {
|
|
269
|
+
const res = await fetchImpl(`${GEMINI_ENDPOINT}/${encodeURIComponent(model)}:generateContent`, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey },
|
|
272
|
+
body: JSON.stringify({
|
|
273
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
274
|
+
generationConfig: { temperature: 0.2, maxOutputTokens: 1024 },
|
|
275
|
+
}),
|
|
276
|
+
});
|
|
277
|
+
const json = await res.json().catch(() => ({}));
|
|
278
|
+
if (!res.ok) {
|
|
279
|
+
return { ok: false, error: 'gemini_error', status: res.status, message: parseModelError(json, res.status), sources };
|
|
280
|
+
}
|
|
281
|
+
const answer = parseGeminiAnswer(json);
|
|
282
|
+
return { ok: true, answer: answer || '(no answer returned)', sources, model: json.modelVersion || model };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Answer a question grounded in this install's lessons. Returns
|
|
286
|
+
// { ok, answer, sources, model } or { ok:false, error, ... }.
|
|
287
|
+
async function answerDataQuestion(question, opts = {}) {
|
|
288
|
+
const q = String(question || '').trim();
|
|
289
|
+
if (!q) return { ok: false, error: 'empty_question', message: 'Ask a question about your data.' };
|
|
290
|
+
if (q.length > MAX_QUESTION_CHARS) {
|
|
291
|
+
return { ok: false, error: 'question_too_long', message: `Question exceeds ${MAX_QUESTION_CHARS} characters.` };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const localEndpoint = opts.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '';
|
|
295
|
+
const localModel = opts.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || 'llama3';
|
|
296
|
+
const apiKey = resolveApiKey(opts);
|
|
297
|
+
const lessons = await retrieveContext(q, opts);
|
|
298
|
+
const sources = lessons.map((l) => ({ id: l.id, title: l.title, signal: l.signal }));
|
|
299
|
+
|
|
300
|
+
if (!apiKey && !localEndpoint) {
|
|
301
|
+
return {
|
|
302
|
+
ok: false,
|
|
303
|
+
error: 'no_api_key',
|
|
304
|
+
message: 'Chat is not configured. Set a valid GEMINI_API_KEY, PERPLEXITY_API_KEY, or THUMBGATE_LOCAL_LLM_ENDPOINT in the project .env.',
|
|
305
|
+
sources,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const model = resolveModel(opts.model);
|
|
310
|
+
const metrics = retrieveMetricsContext();
|
|
311
|
+
const prompt = buildChatPrompt(q, lessons, metrics);
|
|
312
|
+
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
313
|
+
const isPerplexity = apiKey && (apiKey.startsWith('pplx-') || apiKey.includes('perplexity'));
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
if (localEndpoint) return await callLocalOpenAiEndpoint({ endpoint: localEndpoint, apiKey, model: localModel, prompt, fetchImpl, sources });
|
|
317
|
+
if (isPerplexity) return await callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources });
|
|
318
|
+
return await callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
const safeMessage = (err && err.message) ? String(err.message).split('\n')[0].slice(0, 100) : 'An unexpected error occurred.';
|
|
321
|
+
return { ok: false, error: 'network', message: safeMessage, sources };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
module.exports = {
|
|
326
|
+
answerDataQuestion,
|
|
327
|
+
buildChatPrompt,
|
|
328
|
+
parseGeminiAnswer,
|
|
329
|
+
retrieveContext,
|
|
330
|
+
DEFAULT_MODEL,
|
|
331
|
+
MAX_QUESTION_CHARS,
|
|
332
|
+
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* entitlement.js — signed license entitlements for ThumbGate's paid tier.
|
|
5
|
+
*
|
|
6
|
+
* Replaces the old bypassable `tg_`/`tg_pro_` prefix check in license.js with a
|
|
7
|
+
* cryptographically-verifiable, offline-checkable license token.
|
|
8
|
+
*
|
|
9
|
+
* Token format (compact JWS-like, Ed25519 / "EdDSA"):
|
|
10
|
+
* base64url(header) "." base64url(payload) "." base64url(signature)
|
|
11
|
+
* header = { alg: "EdDSA", kid } // kid selects the public key
|
|
12
|
+
* payload = { tier, features[], exp, iat, customerId, keyId }
|
|
13
|
+
* sig = Ed25519 over `${b64url(header)}.${b64url(payload)}`
|
|
14
|
+
*
|
|
15
|
+
* The PRIVATE signing key lives only in the hosted billing service (or a local
|
|
16
|
+
* gitignored dev path). Only PUBLIC keys ship, in config/entitlement-public-keys.json,
|
|
17
|
+
* so any client can verify a license offline without contacting a server.
|
|
18
|
+
*
|
|
19
|
+
* Enforcement is opt-in via THUMBGATE_ENFORCE_ENTITLEMENTS so paid features can be
|
|
20
|
+
* gated without breaking existing users during rollout:
|
|
21
|
+
* - advisory (default): requireEntitlement returns {entitled:false, reason} and
|
|
22
|
+
* the caller may warn but proceed.
|
|
23
|
+
* - enforced (flag set): requireEntitlement throws EntitlementError when not entitled.
|
|
24
|
+
*
|
|
25
|
+
* LIMITATION — read before overselling this. This gate runs CLIENT-SIDE in an
|
|
26
|
+
* MIT, open-source, un-compiled package. It makes a license UN-FORGEABLE (you
|
|
27
|
+
* cannot mint a valid token without the private key), but it does NOT make the
|
|
28
|
+
* check UN-BYPASSABLE: anyone with the source can monkey-patch requireEntitlement
|
|
29
|
+
* or delete the gate. So this protects against fake keys and honest free-riding,
|
|
30
|
+
* and it is the correct authorization primitive for the HOSTED service — but the
|
|
31
|
+
* only real, un-bypassable protection for the crown-jewel intelligence is to run
|
|
32
|
+
* it SERVER-SIDE (client sends inputs, gets outputs, never sees the code/weights),
|
|
33
|
+
* with these tokens as the auth. Client-side gating is a speed bump, not a wall.
|
|
34
|
+
* See docs/COMMERCIALIZATION_STRATEGY.md.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const crypto = require('node:crypto');
|
|
38
|
+
const fs = require('node:fs');
|
|
39
|
+
const os = require('node:os');
|
|
40
|
+
const path = require('node:path');
|
|
41
|
+
|
|
42
|
+
const TIER_FEATURES = {
|
|
43
|
+
free: [],
|
|
44
|
+
pro: ['recall', 'lesson-search', 'unlimited-rules', 'data-export', 'hosted-dashboard', 'hosted-sync', 'learned-models'],
|
|
45
|
+
team: ['recall', 'lesson-search', 'unlimited-rules', 'data-export', 'hosted-dashboard', 'hosted-sync', 'learned-models', 'org-visibility'],
|
|
46
|
+
enterprise: ['recall', 'lesson-search', 'unlimited-rules', 'data-export', 'hosted-dashboard', 'hosted-sync', 'learned-models', 'org-visibility', 'sso', 'audit-log', 'compliance-export'],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
class EntitlementError extends Error {
|
|
50
|
+
constructor(message, code = 'entitlement_denied') {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'EntitlementError';
|
|
53
|
+
this.code = code;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const advisoryWarnings = new Set();
|
|
58
|
+
|
|
59
|
+
function b64urlEncode(buf) {
|
|
60
|
+
return Buffer.from(buf).toString('base64url');
|
|
61
|
+
}
|
|
62
|
+
function b64urlDecodeJson(str) {
|
|
63
|
+
return JSON.parse(Buffer.from(str, 'base64url').toString('utf8'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isTrueEnv(value) {
|
|
67
|
+
if (!value) return false;
|
|
68
|
+
const v = String(value).trim().toLowerCase();
|
|
69
|
+
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isEnforced(env = process.env) {
|
|
73
|
+
return isTrueEnv(env.THUMBGATE_ENFORCE_ENTITLEMENTS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Load the shipped public keyset ({ activeKid, keys: {kid: pem} }). */
|
|
77
|
+
function loadTrustedKeys(root = path.resolve(__dirname, '..')) {
|
|
78
|
+
try {
|
|
79
|
+
const p = path.join(root, 'config', 'entitlement-public-keys.json');
|
|
80
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
81
|
+
return raw.keys || {};
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Verify a license token offline. Returns a normalized result — never throws on
|
|
89
|
+
* bad input (returns { valid:false, reason }).
|
|
90
|
+
* @param {string} token
|
|
91
|
+
* @param {{ trustedKeys?: Record<string,string>, now?: number }} [opts]
|
|
92
|
+
*/
|
|
93
|
+
function verifyLicense(token, opts = {}) {
|
|
94
|
+
const trustedKeys = opts.trustedKeys || loadTrustedKeys();
|
|
95
|
+
const now = opts.now || Math.floor(nowMs() / 1000);
|
|
96
|
+
if (typeof token !== 'string' || !token.trim()) {
|
|
97
|
+
return { valid: false, reason: 'missing_token' };
|
|
98
|
+
}
|
|
99
|
+
// Reject the legacy bypassable prefix keys outright.
|
|
100
|
+
if (/^tg_/.test(token.trim())) {
|
|
101
|
+
return { valid: false, reason: 'legacy_prefix_key_not_a_signed_license' };
|
|
102
|
+
}
|
|
103
|
+
const parts = token.trim().split('.');
|
|
104
|
+
if (parts.length !== 3) return { valid: false, reason: 'malformed_token' };
|
|
105
|
+
const [h, p, s] = parts;
|
|
106
|
+
let header;
|
|
107
|
+
let payload;
|
|
108
|
+
try {
|
|
109
|
+
header = b64urlDecodeJson(h);
|
|
110
|
+
payload = b64urlDecodeJson(p);
|
|
111
|
+
} catch {
|
|
112
|
+
return { valid: false, reason: 'undecodable_token' };
|
|
113
|
+
}
|
|
114
|
+
if (header.alg !== 'EdDSA' || !header.kid) return { valid: false, reason: 'bad_header' };
|
|
115
|
+
const pubPem = trustedKeys[header.kid];
|
|
116
|
+
if (!pubPem) return { valid: false, reason: 'unknown_key_id' };
|
|
117
|
+
|
|
118
|
+
let signatureOk = false;
|
|
119
|
+
try {
|
|
120
|
+
signatureOk = crypto.verify(
|
|
121
|
+
null,
|
|
122
|
+
Buffer.from(`${h}.${p}`),
|
|
123
|
+
crypto.createPublicKey(pubPem),
|
|
124
|
+
Buffer.from(s, 'base64url')
|
|
125
|
+
);
|
|
126
|
+
} catch {
|
|
127
|
+
return { valid: false, reason: 'signature_verify_error' };
|
|
128
|
+
}
|
|
129
|
+
if (!signatureOk) return { valid: false, reason: 'bad_signature' };
|
|
130
|
+
|
|
131
|
+
if (typeof payload.exp === 'number' && payload.exp < now) {
|
|
132
|
+
return { valid: false, reason: 'expired', tier: payload.tier };
|
|
133
|
+
}
|
|
134
|
+
const tier = payload.tier || 'free';
|
|
135
|
+
const features = Array.isArray(payload.features) && payload.features.length
|
|
136
|
+
? payload.features
|
|
137
|
+
: (TIER_FEATURES[tier] || []);
|
|
138
|
+
return {
|
|
139
|
+
valid: true,
|
|
140
|
+
tier,
|
|
141
|
+
features,
|
|
142
|
+
customerId: payload.customerId || null,
|
|
143
|
+
keyId: header.kid,
|
|
144
|
+
exp: payload.exp || null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Sign a license token. PRIVATE-key operation — runs in the hosted billing
|
|
150
|
+
* service (or a local dev/setup script), never in the shipped client at runtime.
|
|
151
|
+
* @param {string} privateKeyPem
|
|
152
|
+
* @param {{ tier:string, features?:string[], customerId?:string, kid:string, expSeconds?:number, iat?:number, exp?:number }} claims
|
|
153
|
+
*/
|
|
154
|
+
function issueLicense(privateKeyPem, claims) {
|
|
155
|
+
const header = { alg: 'EdDSA', kid: claims.kid };
|
|
156
|
+
const iat = claims.iat || Math.floor(nowMs() / 1000);
|
|
157
|
+
const exp = claims.exp || (claims.expSeconds ? iat + claims.expSeconds : undefined);
|
|
158
|
+
const payload = {
|
|
159
|
+
tier: claims.tier,
|
|
160
|
+
features: claims.features || TIER_FEATURES[claims.tier] || [],
|
|
161
|
+
customerId: claims.customerId || null,
|
|
162
|
+
keyId: claims.kid,
|
|
163
|
+
iat,
|
|
164
|
+
...(exp ? { exp } : {}),
|
|
165
|
+
};
|
|
166
|
+
const h = b64urlEncode(JSON.stringify(header));
|
|
167
|
+
const p = b64urlEncode(JSON.stringify(payload));
|
|
168
|
+
const sig = crypto.sign(null, Buffer.from(`${h}.${p}`), crypto.createPrivateKey(privateKeyPem));
|
|
169
|
+
return `${h}.${p}.${b64urlEncode(sig)}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Resolve the active license token from env or the local config file. */
|
|
173
|
+
function resolveLicenseToken(env = process.env) {
|
|
174
|
+
if (env.THUMBGATE_LICENSE && env.THUMBGATE_LICENSE.trim()) return env.THUMBGATE_LICENSE.trim();
|
|
175
|
+
try {
|
|
176
|
+
const p = path.join(os.homedir(), '.config', 'thumbgate', 'license.json');
|
|
177
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
178
|
+
if (raw.license || raw.token) return String(raw.license || raw.token).trim();
|
|
179
|
+
} catch { /* no local license */ }
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Gate a paid feature. Advisory by default; throws EntitlementError only when
|
|
185
|
+
* THUMBGATE_ENFORCE_ENTITLEMENTS is set. Returns { entitled, tier, reason }.
|
|
186
|
+
* @param {string} feature
|
|
187
|
+
* @param {{ env?: NodeJS.ProcessEnv, trustedKeys?: object, token?: string }} [opts]
|
|
188
|
+
*/
|
|
189
|
+
function requireEntitlement(feature, opts = {}) {
|
|
190
|
+
const env = opts.env || process.env;
|
|
191
|
+
const token = opts.token !== undefined ? opts.token : resolveLicenseToken(env);
|
|
192
|
+
const result = verifyLicense(token, { trustedKeys: opts.trustedKeys });
|
|
193
|
+
const entitled = result.valid && result.features.includes(feature);
|
|
194
|
+
const decision = {
|
|
195
|
+
entitled,
|
|
196
|
+
tier: result.valid ? result.tier : 'free',
|
|
197
|
+
feature,
|
|
198
|
+
reason: entitled ? 'entitled' : (result.valid ? 'feature_not_in_tier' : result.reason),
|
|
199
|
+
enforced: isEnforced(env),
|
|
200
|
+
};
|
|
201
|
+
if (!entitled && decision.enforced) {
|
|
202
|
+
throw new EntitlementError(
|
|
203
|
+
`ThumbGate: "${feature}" requires a paid license (current tier: ${decision.tier}, reason: ${decision.reason}). `
|
|
204
|
+
+ `Get a license at https://thumbgate.ai/pricing, then set THUMBGATE_LICENSE or ~/.config/thumbgate/license.json.`,
|
|
205
|
+
decision.reason
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return decision;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Shared paid-feature wrapper for commercial entrypoints. In advisory mode it
|
|
213
|
+
* emits one warning per feature/label and lets the caller continue; in enforced
|
|
214
|
+
* mode `requireEntitlement()` throws before this function returns.
|
|
215
|
+
*/
|
|
216
|
+
function requirePaidFeature(feature, opts = {}) {
|
|
217
|
+
const decision = requireEntitlement(feature, opts);
|
|
218
|
+
const label = opts.label || feature;
|
|
219
|
+
const warningKey = `${feature}:${label}`;
|
|
220
|
+
if (!decision.entitled && !opts.silent && !advisoryWarnings.has(warningKey)) {
|
|
221
|
+
advisoryWarnings.add(warningKey);
|
|
222
|
+
console.error(`⚠️ ThumbGate: ${label} requires a paid license (tier: ${decision.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
|
|
223
|
+
}
|
|
224
|
+
return decision;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function requireLearnedModelsEntitlement(opts = {}) {
|
|
228
|
+
return requirePaidFeature('learned-models', {
|
|
229
|
+
...opts,
|
|
230
|
+
label: opts.label || 'learned-models intelligence',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Injectable clock (Date.now is fine at runtime; kept in one place for testability).
|
|
235
|
+
function nowMs() {
|
|
236
|
+
return Date.now();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = {
|
|
240
|
+
verifyLicense,
|
|
241
|
+
issueLicense,
|
|
242
|
+
requireEntitlement,
|
|
243
|
+
requirePaidFeature,
|
|
244
|
+
requireLearnedModelsEntitlement,
|
|
245
|
+
resolveLicenseToken,
|
|
246
|
+
loadTrustedKeys,
|
|
247
|
+
isEnforced,
|
|
248
|
+
TIER_FEATURES,
|
|
249
|
+
EntitlementError,
|
|
250
|
+
};
|
|
@@ -129,6 +129,11 @@ function timestampSlug() {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
function exportDatabricksBundle(feedbackDir = getDefaultFeedbackDir(), outputPath, options = {}) {
|
|
132
|
+
// Paid-feature gate (advisory by default; enforced via THUMBGATE_ENFORCE_ENTITLEMENTS=1).
|
|
133
|
+
const _ent = require('./entitlement').requireEntitlement('data-export');
|
|
134
|
+
if (!_ent.entitled) {
|
|
135
|
+
console.error(`⚠️ ThumbGate: Databricks bundle export is a paid feature (tier: ${_ent.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
|
|
136
|
+
}
|
|
132
137
|
const resolvedFeedbackDir = path.resolve(feedbackDir || getDefaultFeedbackDir());
|
|
133
138
|
const resolvedProofDir = path.resolve(options.proofDir || DEFAULT_PROOF_DIR);
|
|
134
139
|
const exportedAt = new Date().toISOString();
|
|
@@ -180,6 +180,12 @@ function toJSONL(pairs) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
function exportDpoFromMemories(memories) {
|
|
183
|
+
// Paid-feature gate: DPO export is a commercial (Pro+) capability. Advisory by
|
|
184
|
+
// default; enforced when THUMBGATE_ENFORCE_ENTITLEMENTS=1 (then this throws).
|
|
185
|
+
const _ent = require('./entitlement').requireEntitlement('data-export');
|
|
186
|
+
if (!_ent.entitled) {
|
|
187
|
+
console.error(`⚠️ ThumbGate: DPO export is a paid feature (tier: ${_ent.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
|
|
188
|
+
}
|
|
183
189
|
const errors = memories.filter((m) => m.category === 'error');
|
|
184
190
|
const learnings = memories.filter((m) => m.category === 'learning');
|
|
185
191
|
const result = buildDpoPairs(errors, learnings);
|
|
@@ -177,6 +177,11 @@ function buildDatasetInfo({ traceCount, preferenceCount, exportedAt }) {
|
|
|
177
177
|
* @returns {Object} Export summary
|
|
178
178
|
*/
|
|
179
179
|
function exportHfDataset(options = {}) {
|
|
180
|
+
// Paid-feature gate (advisory by default; enforced via THUMBGATE_ENFORCE_ENTITLEMENTS=1).
|
|
181
|
+
const _ent = require('./entitlement').requireEntitlement('data-export');
|
|
182
|
+
if (!_ent.entitled) {
|
|
183
|
+
console.error(`⚠️ ThumbGate: HuggingFace dataset export is a paid feature (tier: ${_ent.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
|
|
184
|
+
}
|
|
180
185
|
const feedbackDir = options.feedbackDir || resolveFeedbackDir();
|
|
181
186
|
const outputDir = options.outputDir || path.join(feedbackDir, 'hf-dataset');
|
|
182
187
|
const includeProvenance = options.includeProvenance !== false;
|