thumbgate 1.27.17 → 1.27.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +85 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +33 -5
- package/adapters/chatgpt/openapi.yaml +1707 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +2 -2
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +63 -54
- package/bin/postinstall.js +23 -14
- package/config/post-deploy-marketing-pages.json +19 -0
- package/config/tessl-tiles.json +1 -3
- package/package.json +99 -229
- package/public/guide.html +0 -4
- package/public/index.html +28 -49
- package/public/learn.html +0 -8
- package/public/numbers.html +2 -2
- package/scripts/agent-readiness.js +0 -147
- package/scripts/bot-detection.js +61 -3
- package/scripts/cli-feedback.js +4 -14
- package/scripts/cli-schema.js +7 -0
- package/scripts/durability/step.js +3 -3
- package/scripts/feedback-loop.js +10 -32
- package/scripts/gates-engine.js +72 -1
- package/scripts/hook-runtime.js +2 -2
- package/scripts/license.js +10 -9
- package/scripts/llm-client.js +44 -260
- package/scripts/mcp-config.js +7 -1
- package/scripts/published-cli.js +0 -8
- package/scripts/seo-gsd.js +0 -5
- package/src/api/server.js +111 -35
- package/.claude/commands/dashboard.md +0 -15
- package/.claude/commands/thumbgate-blocked.md +0 -27
- package/.claude/commands/thumbgate-dashboard.md +0 -15
- package/.claude/commands/thumbgate-doctor.md +0 -30
- package/.claude/commands/thumbgate-guard.md +0 -36
- package/.claude/commands/thumbgate-protect.md +0 -30
- package/.claude/commands/thumbgate-rules.md +0 -30
- package/adapters/gcp/dfcx-webhook-gate.js +0 -295
- package/adapters/letta/README.md +0 -41
- package/adapters/letta/thumbgate-letta-adapter.js +0 -133
- package/adapters/policy-engine/ethicore-guardian-client.js +0 -68
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +0 -260
- package/bench/observability-eval-suite.json +0 -26
- package/bin/dashboard-cli.js +0 -7
- package/commands/dashboard.md +0 -15
- package/commands/thumbgate-dashboard.md +0 -15
- package/config/builtin-lessons.json +0 -23
- package/public/about.html +0 -162
- package/public/agent-manager.html +0 -179
- package/public/agents-cost-savings.html +0 -153
- package/public/ai-malpractice-prevention.html +0 -818
- 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 +0 -22
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +0 -19
- package/public/assets/brand/thumbgate-mark-pro.svg +0 -23
- package/public/assets/brand/thumbgate-mark-team.svg +0 -26
- package/public/assets/brand/thumbgate-mark.svg +0 -21
- package/public/assets/brand/thumbgate-wordmark.svg +0 -20
- package/public/assets/claude-thumbgate-statusbar.svg +0 -8
- package/public/assets/codex-thumbgate-statusbar-test.svg +0 -9
- package/public/assets/legal-intake-control-flow.svg +0 -66
- package/public/brand/thumbgate-mark.svg +0 -19
- package/public/brand/thumbgate-og.svg +0 -16
- package/public/chatgpt-app.html +0 -330
- package/public/codex-enterprise.html +0 -123
- package/public/js/buyer-intent.js +0 -672
- package/public/pricing.html +0 -399
- package/scripts/action-receipts.js +0 -324
- package/scripts/activation-quickstart.js +0 -187
- package/scripts/agent-operations-planner.js +0 -621
- package/scripts/ai-component-inventory.js +0 -367
- package/scripts/async-eval-observability.js +0 -236
- package/scripts/audit.js +0 -65
- package/scripts/aws-blocks-guardrails.js +0 -228
- package/scripts/bot-detector.js +0 -50
- package/scripts/classifier-routing.js +0 -130
- package/scripts/dashboard-chat.js +0 -332
- package/scripts/feedback-aggregate.js +0 -281
- package/scripts/feedback-sanitizer.js +0 -105
- package/scripts/hob-pack.js +0 -591
- package/scripts/hook-stop-anti-claim.js +0 -301
- package/scripts/install-shim.js +0 -87
- package/scripts/mcp-oauth.js +0 -293
- package/scripts/noop-detect.js +0 -285
- package/scripts/omlx-smoke.js +0 -192
- package/scripts/parallel-workflow-orchestrator.js +0 -293
- package/scripts/plan-gate.js +0 -243
- package/scripts/plausible-domain-config.js +0 -99
- package/scripts/qa-scenario-planner.js +0 -136
- package/scripts/repeat-metric.js +0 -137
- package/scripts/secret-fixture-tokens.js +0 -61
- package/scripts/secret-redaction.js +0 -166
- package/scripts/self-harness-optimizer.js +0 -141
- package/scripts/self-healing-check.js +0 -193
- package/scripts/silent-failure-cluster.js +0 -531
- package/scripts/statusline-cache-read.js +0 -57
- package/scripts/sync-telemetry-from-prod.js +0 -374
- package/scripts/tool-contract-validator.js +0 -76
- package/scripts/trajectory-scorer.js +0 -63
- package/scripts/verify-marketing-pages-deployed.js +0 -195
- package/scripts/visitor-journey.js +0 -172
|
@@ -1,332 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const fs = require('node:fs');
|
|
5
|
-
const os = require('node:os');
|
|
6
|
-
const path = require('node:path');
|
|
7
|
-
const crypto = require('node:crypto');
|
|
8
|
-
const {
|
|
9
|
-
getFallbackFeedbackDir,
|
|
10
|
-
getGlobalFeedbackDir,
|
|
11
|
-
getHomeDir,
|
|
12
|
-
getLegacyFeedbackDir,
|
|
13
|
-
getThumbgateFeedbackDir,
|
|
14
|
-
resolveFeedbackDir,
|
|
15
|
-
resolveProjectDir,
|
|
16
|
-
} = require('./feedback-paths');
|
|
17
|
-
const { readJsonl } = require('./fs-utils');
|
|
18
|
-
|
|
19
|
-
const FEEDBACK_LOG = 'feedback-log.jsonl';
|
|
20
|
-
const MEMORY_LOG = 'memory-log.jsonl';
|
|
21
|
-
const STATUSLINE_CACHE = 'statusline_cache.json';
|
|
22
|
-
|
|
23
|
-
function truthyDisabled(value) {
|
|
24
|
-
return value === '0' || value === 'false' || value === 'local';
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function shouldAggregateFeedback(options = {}) {
|
|
28
|
-
const env = options.env || process.env;
|
|
29
|
-
return !truthyDisabled(String(env.THUMBGATE_STATUSLINE_AGGREGATE || env.THUMBGATE_AGGREGATE_FEEDBACK || '1').toLowerCase());
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function normalizePath(candidate) {
|
|
33
|
-
if (!candidate) return null;
|
|
34
|
-
try {
|
|
35
|
-
return path.resolve(String(candidate));
|
|
36
|
-
} catch {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function uniquePaths(values = []) {
|
|
42
|
-
const seen = new Set();
|
|
43
|
-
const out = [];
|
|
44
|
-
for (const value of values) {
|
|
45
|
-
const resolved = normalizePath(value);
|
|
46
|
-
if (!resolved || seen.has(resolved)) continue;
|
|
47
|
-
seen.add(resolved);
|
|
48
|
-
out.push(resolved);
|
|
49
|
-
}
|
|
50
|
-
return out;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function safeExists(candidate) {
|
|
54
|
-
try {
|
|
55
|
-
return Boolean(candidate && fs.existsSync(candidate));
|
|
56
|
-
} catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function immediateChildDirs(parentDir) {
|
|
62
|
-
try {
|
|
63
|
-
return fs.readdirSync(parentDir, { withFileTypes: true })
|
|
64
|
-
.filter((entry) => entry.isDirectory())
|
|
65
|
-
.map((entry) => path.join(parentDir, entry.name));
|
|
66
|
-
} catch {
|
|
67
|
-
return [];
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function ancestorProjectFeedbackDirs(projectDir, options = {}) {
|
|
72
|
-
const home = normalizePath(getHomeDir(options));
|
|
73
|
-
const start = normalizePath(projectDir);
|
|
74
|
-
if (!start) return [];
|
|
75
|
-
|
|
76
|
-
const dirs = [];
|
|
77
|
-
let cursor = start;
|
|
78
|
-
while (cursor && cursor !== path.dirname(cursor)) {
|
|
79
|
-
dirs.push(
|
|
80
|
-
path.join(cursor, '.thumbgate'),
|
|
81
|
-
path.join(cursor, '.thumbgate-compat'),
|
|
82
|
-
path.join(cursor, '.claude', 'memory', 'feedback')
|
|
83
|
-
);
|
|
84
|
-
if (home && cursor === home) break;
|
|
85
|
-
const next = path.dirname(cursor);
|
|
86
|
-
if (next === cursor) break;
|
|
87
|
-
cursor = next;
|
|
88
|
-
}
|
|
89
|
-
return dirs;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function listFeedbackStoreDirs(options = {}) {
|
|
93
|
-
const env = options.env || process.env;
|
|
94
|
-
const projectDir = resolveProjectDir({ cwd: options.cwd, env, projectDir: options.projectDir });
|
|
95
|
-
const home = getHomeDir({ env });
|
|
96
|
-
const homeThumbgate = path.join(home, '.thumbgate');
|
|
97
|
-
const projectsDir = path.join(homeThumbgate, 'projects');
|
|
98
|
-
const explicitRoots = String(env.THUMBGATE_AGGREGATE_ROOTS || '')
|
|
99
|
-
.split(path.delimiter)
|
|
100
|
-
.map((value) => value.trim())
|
|
101
|
-
.filter(Boolean);
|
|
102
|
-
const explicitFeedbackDir = options.feedbackDir || env.THUMBGATE_FEEDBACK_DIR;
|
|
103
|
-
const normalizedExplicitFeedbackDir = normalizePath(explicitFeedbackDir);
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
normalizedExplicitFeedbackDir &&
|
|
107
|
-
normalizedExplicitFeedbackDir.startsWith(normalizePath(os.tmpdir()) + path.sep) &&
|
|
108
|
-
explicitRoots.length === 0
|
|
109
|
-
) {
|
|
110
|
-
return uniquePaths([explicitFeedbackDir])
|
|
111
|
-
.filter((dir) => safeExists(path.join(dir, FEEDBACK_LOG)) || safeExists(path.join(dir, MEMORY_LOG)) || safeExists(path.join(dir, STATUSLINE_CACHE)));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return uniquePaths([
|
|
115
|
-
explicitFeedbackDir,
|
|
116
|
-
resolveFeedbackDir({ projectDir, env }),
|
|
117
|
-
getThumbgateFeedbackDir({ projectDir, env }),
|
|
118
|
-
getFallbackFeedbackDir({ projectDir, env }),
|
|
119
|
-
getLegacyFeedbackDir({ projectDir, env }),
|
|
120
|
-
getGlobalFeedbackDir({ projectDir, env }),
|
|
121
|
-
homeThumbgate,
|
|
122
|
-
...immediateChildDirs(projectsDir),
|
|
123
|
-
...ancestorProjectFeedbackDirs(projectDir, { env }),
|
|
124
|
-
...explicitRoots,
|
|
125
|
-
]).filter((dir) => safeExists(path.join(dir, FEEDBACK_LOG)) || safeExists(path.join(dir, MEMORY_LOG)) || safeExists(path.join(dir, STATUSLINE_CACHE)));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function normalizeSignal(signal) {
|
|
129
|
-
const raw = String(signal || '').toLowerCase();
|
|
130
|
-
if (raw === 'positive' || raw === 'up' || raw === 'thumbs_up') return 'positive';
|
|
131
|
-
if (raw === 'negative' || raw === 'down' || raw === 'thumbs_down') return 'negative';
|
|
132
|
-
return raw || null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function stableEntryKey(entry = {}, source = {}) {
|
|
136
|
-
const id = entry.id || entry.feedbackId || entry.sourceFeedbackId;
|
|
137
|
-
if (id) return `id:${id}`;
|
|
138
|
-
const material = JSON.stringify({
|
|
139
|
-
sourcePath: source.logPath || '',
|
|
140
|
-
sourceIndex: Number.isFinite(source.index) ? source.index : -1,
|
|
141
|
-
entry,
|
|
142
|
-
});
|
|
143
|
-
return `sha:${crypto.createHash('sha256').update(material).digest('hex')}`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function sortByTimestamp(entries) {
|
|
147
|
-
return entries.sort((a, b) => {
|
|
148
|
-
const at = a.timestamp ? Date.parse(a.timestamp) : 0;
|
|
149
|
-
const bt = b.timestamp ? Date.parse(b.timestamp) : 0;
|
|
150
|
-
return (Number.isFinite(at) ? at : 0) - (Number.isFinite(bt) ? bt : 0);
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function collectAggregateLogEntries(fileName, options = {}) {
|
|
155
|
-
const stores = listFeedbackStoreDirs(options);
|
|
156
|
-
const seen = new Set();
|
|
157
|
-
const entries = [];
|
|
158
|
-
|
|
159
|
-
for (const dir of stores) {
|
|
160
|
-
const logPath = path.join(dir, fileName);
|
|
161
|
-
if (!safeExists(logPath)) continue;
|
|
162
|
-
const rows = readJsonl(logPath, { maxLines: 0 }) || [];
|
|
163
|
-
for (let index = 0; index < rows.length; index += 1) {
|
|
164
|
-
const rawEntry = rows[index];
|
|
165
|
-
const entry = { ...rawEntry };
|
|
166
|
-
if (entry.signal || entry.feedback) entry.signal = normalizeSignal(entry.signal || entry.feedback);
|
|
167
|
-
const key = stableEntryKey(entry, { logPath, index });
|
|
168
|
-
if (seen.has(key)) continue;
|
|
169
|
-
seen.add(key);
|
|
170
|
-
entries.push({ ...entry, sourceFeedbackDir: dir });
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
entries: sortByTimestamp(entries),
|
|
176
|
-
stores,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function summarizeFeedbackEntries(entries) {
|
|
181
|
-
let totalPositive = 0;
|
|
182
|
-
let totalNegative = 0;
|
|
183
|
-
let rubricSamples = 0;
|
|
184
|
-
|
|
185
|
-
for (const entry of entries) {
|
|
186
|
-
if (entry.signal === 'positive') totalPositive += 1;
|
|
187
|
-
if (entry.signal === 'negative') totalNegative += 1;
|
|
188
|
-
if (entry.rubric && entry.rubric.weightedScore != null) rubricSamples += 1;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return { totalPositive, totalNegative, rubricSamples };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function createRateWindows(total, totalPositive, approvalRate) {
|
|
195
|
-
return {
|
|
196
|
-
'7d': { total: 0, positive: 0, rate: 0 },
|
|
197
|
-
'30d': { total: 0, positive: 0, rate: 0 },
|
|
198
|
-
lifetime: { total, positive: totalPositive, rate: approvalRate },
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function applyEntryToRateWindow(window, entry) {
|
|
203
|
-
window.total += 1;
|
|
204
|
-
if (entry.signal === 'positive') window.positive += 1;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function updateRateWindows(windows, entries, now = Date.now()) {
|
|
208
|
-
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
|
209
|
-
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
|
|
210
|
-
|
|
211
|
-
for (const entry of entries) {
|
|
212
|
-
const ts = entry.timestamp ? Date.parse(entry.timestamp) : Number.NaN;
|
|
213
|
-
if (!Number.isFinite(ts)) continue;
|
|
214
|
-
const age = now - ts;
|
|
215
|
-
if (age <= sevenDays) applyEntryToRateWindow(windows['7d'], entry);
|
|
216
|
-
if (age <= thirtyDays) applyEntryToRateWindow(windows['30d'], entry);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function finalizeRateWindows(windows) {
|
|
221
|
-
for (const key of ['7d', '30d']) {
|
|
222
|
-
const window = windows[key];
|
|
223
|
-
window.rate = 0;
|
|
224
|
-
if (window.total > 0) {
|
|
225
|
-
window.rate = Math.round((window.positive / window.total) * 1000) / 1000;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function trendFromRateWindows(windows) {
|
|
231
|
-
const hasTrendData = windows['7d'].total > 0 && windows['30d'].total > 0;
|
|
232
|
-
if (hasTrendData) {
|
|
233
|
-
if (windows['7d'].rate > windows['30d'].rate + 0.05) return 'improving';
|
|
234
|
-
if (windows['7d'].rate < windows['30d'].rate - 0.05) return 'degrading';
|
|
235
|
-
}
|
|
236
|
-
return 'stable';
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function computeAggregateFeedbackStats(options = {}) {
|
|
240
|
-
const { entries, stores } = collectAggregateLogEntries(FEEDBACK_LOG, options);
|
|
241
|
-
const memory = collectAggregateLogEntries(MEMORY_LOG, options);
|
|
242
|
-
const { totalPositive, totalNegative, rubricSamples } = summarizeFeedbackEntries(entries);
|
|
243
|
-
const total = totalPositive + totalNegative;
|
|
244
|
-
const approvalRate = total > 0 ? Math.round((totalPositive / total) * 1000) / 1000 : 0;
|
|
245
|
-
const windows = createRateWindows(total, totalPositive, approvalRate);
|
|
246
|
-
updateRateWindows(windows, entries);
|
|
247
|
-
finalizeRateWindows(windows);
|
|
248
|
-
const trend = trendFromRateWindows(windows);
|
|
249
|
-
|
|
250
|
-
return {
|
|
251
|
-
total,
|
|
252
|
-
totalPositive,
|
|
253
|
-
totalNegative,
|
|
254
|
-
approvalRate,
|
|
255
|
-
recentRate: windows['7d'].rate || approvalRate,
|
|
256
|
-
trend,
|
|
257
|
-
windows,
|
|
258
|
-
rubric: { samples: rubricSamples || memory.entries.length, blockedPromotions: 0, failingCriteria: {} },
|
|
259
|
-
aggregate: {
|
|
260
|
-
enabled: true,
|
|
261
|
-
stores: stores.length,
|
|
262
|
-
feedbackLogPaths: stores.map((dir) => path.join(dir, FEEDBACK_LOG)),
|
|
263
|
-
memoryStores: memory.stores.length,
|
|
264
|
-
},
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function getAggregateStatuslineCachePath(options = {}) {
|
|
269
|
-
const env = options.env || process.env;
|
|
270
|
-
if (env.THUMBGATE_STATUSLINE_CACHE) return path.resolve(env.THUMBGATE_STATUSLINE_CACHE);
|
|
271
|
-
return path.join(getHomeDir({ env }), '.thumbgate', STATUSLINE_CACHE);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
module.exports = {
|
|
275
|
-
collectAggregateLogEntries,
|
|
276
|
-
computeAggregateFeedbackStats,
|
|
277
|
-
getAggregateStatuslineCachePath,
|
|
278
|
-
listFeedbackStoreDirs,
|
|
279
|
-
normalizeSignal,
|
|
280
|
-
shouldAggregateFeedback,
|
|
281
|
-
};
|