thumbgate 1.27.16 → 1.27.18
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 +3 -4
- package/.well-known/llms.txt +12 -33
- package/.well-known/mcp/server-card.json +8 -8
- package/README.md +30 -215
- package/adapters/chatgpt/openapi.yaml +1695 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/gemini/function-declarations.json +0 -1
- package/adapters/mcp/server-stdio.js +11 -263
- package/adapters/opencode/opencode.json +1 -1
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +122 -1421
- package/bin/postinstall.js +27 -11
- package/config/gate-templates.json +0 -216
- package/config/gates/claim-verification.json +0 -12
- package/config/gates/default.json +2 -31
- package/config/github-about.json +2 -2
- package/config/mcp-allowlists.json +13 -23
- package/config/merge-quality-checks.json +1 -0
- package/config/model-candidates.json +6 -121
- package/openapi/openapi.yaml +0 -12
- package/package.json +98 -228
- package/public/blog.html +4 -4
- package/public/codex-plugin.html +20 -72
- package/public/compare.html +8 -31
- package/public/dashboard.html +166 -930
- package/public/federal.html +2 -2
- package/public/guide.html +9 -33
- package/public/index.html +116 -495
- package/public/learn.html +18 -191
- package/public/lessons.html +10 -168
- package/public/numbers.html +7 -7
- package/public/pro.html +11 -34
- package/scripts/agent-memory-lifecycle.js +0 -211
- package/scripts/agent-readiness.js +3 -167
- package/scripts/agent-reward-model.js +1 -53
- package/scripts/auto-promote-gates.js +10 -82
- package/scripts/auto-wire-hooks.js +0 -14
- package/scripts/billing.js +1 -93
- package/scripts/build-metadata.js +10 -50
- package/scripts/cli-feedback.js +6 -18
- package/scripts/cli-schema.js +0 -90
- package/scripts/cli-telemetry.js +1 -6
- package/scripts/commercial-offer.js +2 -82
- package/scripts/context-manager.js +6 -74
- package/scripts/dashboard.js +2 -68
- package/scripts/durability/step.js +3 -3
- package/scripts/export-databricks-bundle.js +1 -5
- package/scripts/export-dpo-pairs.js +2 -7
- package/scripts/feedback-loop.js +1 -123
- package/scripts/feedback-quality.js +0 -87
- package/scripts/filesystem-search.js +10 -35
- package/scripts/gate-stats.js +0 -89
- package/scripts/gates-engine.js +85 -1105
- package/scripts/gemini-embedding-policy.js +1 -2
- package/scripts/hook-runtime.js +14 -20
- package/scripts/hook-thumbgate-cache-updater.js +2 -18
- package/scripts/hybrid-feedback-context.js +7 -142
- package/scripts/lesson-inference.js +3 -8
- package/scripts/lesson-search.js +1 -17
- package/scripts/license.js +1 -0
- package/scripts/llm-client.js +9 -390
- package/scripts/local-model-profile.js +8 -15
- package/scripts/memory-scope-readiness.js +0 -159
- package/scripts/meta-agent-loop.js +0 -36
- package/scripts/operational-integrity.js +5 -39
- package/scripts/oss-pr-opportunity-scout.js +5 -35
- package/scripts/plausible-server-events.js +6 -9
- package/scripts/pro-local-dashboard.js +4 -4
- package/scripts/proxy-pointer-rag-guardrails.js +1 -42
- package/scripts/rate-limiter.js +13 -64
- package/scripts/secret-scanner.js +5 -44
- package/scripts/security-scanner.js +10 -260
- package/scripts/self-distill-agent.js +1 -3
- package/scripts/seo-gsd.js +2 -916
- package/scripts/statusline-cache-path.js +2 -17
- package/scripts/statusline-local-stats.js +1 -9
- package/scripts/statusline-meta.js +2 -28
- package/scripts/statusline.sh +4 -20
- package/scripts/telemetry-analytics.js +0 -357
- package/scripts/thompson-sampling.js +10 -31
- package/scripts/thumbgate-bench.js +1 -16
- package/scripts/thumbgate-search.js +19 -85
- package/scripts/tool-registry.js +1 -169
- package/scripts/vector-store.js +0 -45
- package/scripts/workflow-sentinel.js +53 -286
- package/scripts/workspace-evolver.js +2 -62
- package/src/api/server.js +290 -2578
- 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/.well-known/agentic-verify.txt +0 -1
- 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/config/gate-classifier-routing.json +0 -98
- package/config/post-deploy-marketing-pages.json +0 -61
- 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/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,228 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const fs = require('node:fs');
|
|
5
|
-
const path = require('node:path');
|
|
6
|
-
|
|
7
|
-
const AWS_BLOCKS_DEPENDENCY_PATTERN = /(^|\/)@aws-blocks\/(?:blocks|[^/\s"']+)/;
|
|
8
|
-
|
|
9
|
-
function toList(value) {
|
|
10
|
-
if (Array.isArray(value)) return value.map(String).map((entry) => entry.trim()).filter(Boolean);
|
|
11
|
-
if (typeof value === 'string') return value.split(',').map((entry) => entry.trim()).filter(Boolean);
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function normalizeText(value) {
|
|
16
|
-
if (value == null) return '';
|
|
17
|
-
if (typeof value === 'string') return value;
|
|
18
|
-
try {
|
|
19
|
-
return JSON.stringify(value);
|
|
20
|
-
} catch {
|
|
21
|
-
return String(value);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function readPackageJson(projectDir) {
|
|
26
|
-
try {
|
|
27
|
-
return JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'));
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function packageUsesAwsBlocks(pkg) {
|
|
34
|
-
if (!pkg || typeof pkg !== 'object') return false;
|
|
35
|
-
const dependencyText = JSON.stringify({
|
|
36
|
-
dependencies: pkg.dependencies || {},
|
|
37
|
-
devDependencies: pkg.devDependencies || {},
|
|
38
|
-
peerDependencies: pkg.peerDependencies || {},
|
|
39
|
-
optionalDependencies: pkg.optionalDependencies || {},
|
|
40
|
-
});
|
|
41
|
-
const scriptsText = JSON.stringify(pkg.scripts || {});
|
|
42
|
-
return AWS_BLOCKS_DEPENDENCY_PATTERN.test(dependencyText) || /aws-blocks|blocks-app|cdk|sandbox/i.test(scriptsText);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function detectAwsBlocksProject(projectDir = process.cwd()) {
|
|
46
|
-
const pkg = readPackageJson(projectDir);
|
|
47
|
-
if (packageUsesAwsBlocks(pkg)) return true;
|
|
48
|
-
return fs.existsSync(path.join(projectDir, 'aws-blocks', 'index.ts'))
|
|
49
|
-
|| fs.existsSync(path.join(projectDir, 'aws-blocks', 'index.js'))
|
|
50
|
-
|| fs.existsSync(path.join(projectDir, 'blocks.spec.json'));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function detectAction(input = {}) {
|
|
54
|
-
const command = normalizeText(input.command || input.args || input.shell || input.toolInput);
|
|
55
|
-
const toolName = normalizeText(input.toolName || input.tool || input.name);
|
|
56
|
-
const code = normalizeText(input.code || input.file || input.diff || input.body);
|
|
57
|
-
const combined = `${toolName}\n${command}\n${code}`;
|
|
58
|
-
const lower = combined.toLowerCase();
|
|
59
|
-
|
|
60
|
-
const signals = [];
|
|
61
|
-
const add = (id, label, severity = 'medium') => signals.push({ id, label, severity });
|
|
62
|
-
|
|
63
|
-
if (/\b(cdk\s+deploy|npm\s+run\s+deploy|pnpm\s+deploy|yarn\s+deploy|sst\s+deploy|blocks?\s+deploy)\b/i.test(combined)) {
|
|
64
|
-
add('aws-blocks-production-deploy', 'production deploy from an AWS Blocks workflow', 'high');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (/\b(cdk\s+destroy|npm\s+run\s+destroy|pnpm\s+destroy|yarn\s+destroy|sandbox\b.*--destroy|--destroy\b|aws\s+cloudformation\s+delete-stack)\b/i.test(combined)) {
|
|
68
|
-
add('aws-blocks-destroy', 'destroy command can remove AWS resources created from local Blocks code', 'critical');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (/\b(drop\s+(table|database|schema|index|column)|truncate\s+table|delete\s+from\s+[\w".-]+(?:\s*;|\s*$)|update\s+[\w".-]+\s+set\b(?![\s\S]{0,160}\bwhere\b))/i.test(combined)) {
|
|
72
|
-
add('destructive-sql-or-ddl', 'destructive or unscoped SQL mutation', 'critical');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (/\b(aws\s+dynamodb\s+delete-table|aws\s+rds\s+delete-db-instance|aws\s+s3\s+rm\b[\s\S]*--recursive|aws\s+lambda\s+delete-function|aws\s+bedrock-agent\s+delete-|aws\s+bedrock-agent-runtime\b)/i.test(combined)) {
|
|
76
|
-
add('destructive-aws-cli', 'destructive AWS CLI or Bedrock agent action', 'critical');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (/\b(gcloud|aws)\b[\s\S]{0,220}\b(add-iam-policy-binding|put-role-policy|attach-role-policy|create-policy-version)\b/i.test(combined)
|
|
80
|
-
|| /\b(AdministratorAccess|roles\/owner|iam:PassRole|serviceAccountTokenCreator)\b/i.test(combined)) {
|
|
81
|
-
add('iam-escalation', 'agent session attempts to grant broad IAM authority', 'critical');
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (/\b(new\s+Agent\s*\(|@aws-blocks\/(?:agent|blocks)|\bAgent\b[\s\S]{0,120}\btools?\b)/.test(combined)) {
|
|
85
|
-
add('blocks-agent-tool-call', 'AWS Blocks Agent or Bedrock-style tool action needs a tool boundary', 'medium');
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (/\b(npm\s+run\s+dev|npm\s+start|pnpm\s+dev|yarn\s+dev|create-blocks-app)\b/i.test(combined)) {
|
|
89
|
-
add('local-dev', 'local AWS Blocks development loop', 'low');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
command,
|
|
94
|
-
toolName,
|
|
95
|
-
code,
|
|
96
|
-
signals,
|
|
97
|
-
highRisk: signals.some((signal) => ['high', 'critical'].includes(signal.severity)),
|
|
98
|
-
localOnly: signals.some((signal) => signal.id === 'local-dev') && signals.every((signal) => signal.severity === 'low'),
|
|
99
|
-
lower,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function evaluateAwsBlocksAction(input = {}) {
|
|
104
|
-
const projectDir = input.projectDir || input.cwd || process.cwd();
|
|
105
|
-
const projectUsesAwsBlocks = Boolean(
|
|
106
|
-
input.projectUsesAwsBlocks
|
|
107
|
-
|| input.awsBlocks
|
|
108
|
-
|| input.blocksProject
|
|
109
|
-
|| detectAwsBlocksProject(projectDir)
|
|
110
|
-
);
|
|
111
|
-
const evidence = new Set(toList(input.evidence || input.proof || input.receipts));
|
|
112
|
-
const action = detectAction(input);
|
|
113
|
-
const requiredEvidence = [];
|
|
114
|
-
|
|
115
|
-
const requireEvidence = (id, label) => {
|
|
116
|
-
if (!evidence.has(id)) requiredEvidence.push({ id, label });
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
if (projectUsesAwsBlocks && action.signals.some((signal) => signal.id === 'aws-blocks-production-deploy')) {
|
|
120
|
-
requireEvidence('local-tests-pass', 'local AWS Blocks tests pass against local implementations');
|
|
121
|
-
requireEvidence('cdk-diff-reviewed', 'CDK diff or synthesized CloudFormation change set reviewed');
|
|
122
|
-
requireEvidence('cost-blast-radius-reviewed', 'AWS cost and resource blast radius reviewed');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (action.signals.some((signal) => signal.id === 'aws-blocks-destroy')) {
|
|
126
|
-
requireEvidence('resource-inventory-exported', 'resource inventory exported before destroy');
|
|
127
|
-
requireEvidence('human-destroy-approval', 'named human approval for destroy');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (action.signals.some((signal) => signal.id === 'destructive-sql-or-ddl')) {
|
|
131
|
-
requireEvidence('backup-or-rollback-ready', 'backup, rollback, or restore point exists');
|
|
132
|
-
requireEvidence('bounded-row-count-reviewed', 'row/table impact was previewed before mutation');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (action.signals.some((signal) => signal.id === 'destructive-aws-cli')) {
|
|
136
|
-
requireEvidence('aws-account-and-region-confirmed', 'target AWS account and region confirmed');
|
|
137
|
-
requireEvidence('rollback-plan-attached', 'rollback or recovery plan attached');
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (action.signals.some((signal) => signal.id === 'iam-escalation')) {
|
|
141
|
-
requireEvidence('least-privilege-review', 'least-privilege review completed');
|
|
142
|
-
requireEvidence('security-owner-approval', 'security owner approval captured');
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (action.signals.some((signal) => signal.id === 'blocks-agent-tool-call')) {
|
|
146
|
-
requireEvidence('agent-tool-allowlist', 'agent tool allowlist and data boundary declared');
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const shouldBlock = projectUsesAwsBlocks && requiredEvidence.length > 0;
|
|
150
|
-
const status = shouldBlock ? 'blocked' : (action.highRisk ? 'needs-review' : 'allowed');
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
name: 'thumbgate-aws-blocks-guardrails',
|
|
154
|
-
status,
|
|
155
|
-
projectUsesAwsBlocks,
|
|
156
|
-
signals: action.signals,
|
|
157
|
-
requiredEvidence,
|
|
158
|
-
enforcementBoundary: 'local AWS Blocks confidence must not automatically become production AWS authority',
|
|
159
|
-
gates: [
|
|
160
|
-
'allow local AWS Blocks dev loops without AWS credentials',
|
|
161
|
-
'require local test proof plus CDK diff before production deploy',
|
|
162
|
-
'block destroy and destructive data mutations until backup, blast-radius, and human approval evidence exists',
|
|
163
|
-
'block IAM escalation and Bedrock/Agent tool expansion until an owner approves the tool boundary',
|
|
164
|
-
'write a ThumbGate receipt for every allowed high-risk cloud action',
|
|
165
|
-
],
|
|
166
|
-
nextActions: shouldBlock
|
|
167
|
-
? requiredEvidence.map((item) => `Provide ${item.id}: ${item.label}`)
|
|
168
|
-
: ['Record an allow receipt when this action touches real AWS resources'],
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function buildAwsBlocksHardeningOffer(input = {}) {
|
|
173
|
-
const workflow = String(input.workflow || input.name || 'AWS Blocks backend').trim();
|
|
174
|
-
const buyer = String(input.buyer || input.owner || 'platform owner').trim();
|
|
175
|
-
return {
|
|
176
|
-
name: 'thumbgate-aws-blocks-hardening-offer',
|
|
177
|
-
status: 'ready-for-positioning',
|
|
178
|
-
buyer,
|
|
179
|
-
workflow,
|
|
180
|
-
headline: 'AWS Blocks helps agents build the backend. ThumbGate stops them before unsafe cloud actions run.',
|
|
181
|
-
offer: 'AWS Blocks Agent Safety Review',
|
|
182
|
-
diagnosticPrice: '$499',
|
|
183
|
-
proofPlan: [
|
|
184
|
-
'map one AWS Blocks local-to-cloud workflow',
|
|
185
|
-
'install ThumbGate against the agent running that workflow',
|
|
186
|
-
'add gates for deploy, destroy, data mutation, IAM, Bedrock Agent, and cost-blast-radius actions',
|
|
187
|
-
'produce a receipt showing the first blocked repeat and the evidence required to allow it',
|
|
188
|
-
],
|
|
189
|
-
cta: 'Send one AWS Blocks workflow that is about to deploy, mutate data, or call Bedrock tools.',
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function parseArgs(argv = process.argv.slice(2)) {
|
|
194
|
-
const args = {};
|
|
195
|
-
for (const arg of argv) {
|
|
196
|
-
if (arg === '--json') args.json = true;
|
|
197
|
-
else if (arg === '--aws-blocks' || arg === '--project-uses-aws-blocks') args.projectUsesAwsBlocks = true;
|
|
198
|
-
else if (arg.startsWith('--command=')) args.command = arg.slice('--command='.length);
|
|
199
|
-
else if (arg.startsWith('--tool=')) args.toolName = arg.slice('--tool='.length);
|
|
200
|
-
else if (arg.startsWith('--code=')) args.code = arg.slice('--code='.length);
|
|
201
|
-
else if (arg.startsWith('--cwd=')) args.projectDir = arg.slice('--cwd='.length);
|
|
202
|
-
else if (arg.startsWith('--evidence=')) args.evidence = arg.slice('--evidence='.length);
|
|
203
|
-
else if (arg.startsWith('--workflow=')) args.workflow = arg.slice('--workflow='.length);
|
|
204
|
-
else if (arg.startsWith('--buyer=')) args.buyer = arg.slice('--buyer='.length);
|
|
205
|
-
else if (arg === 'offer') args.commandName = 'offer';
|
|
206
|
-
}
|
|
207
|
-
return args;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function runCli(args = parseArgs()) {
|
|
211
|
-
const report = args.commandName === 'offer'
|
|
212
|
-
? buildAwsBlocksHardeningOffer(args)
|
|
213
|
-
: evaluateAwsBlocksAction(args);
|
|
214
|
-
if (args.json) console.log(JSON.stringify(report, null, 2));
|
|
215
|
-
else {
|
|
216
|
-
console.log(`${report.name}: ${report.status}`);
|
|
217
|
-
for (const action of report.nextActions || report.proofPlan || []) console.log(`- ${action}`);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (require.main === module) runCli();
|
|
222
|
-
|
|
223
|
-
module.exports = {
|
|
224
|
-
detectAwsBlocksProject,
|
|
225
|
-
detectAction,
|
|
226
|
-
evaluateAwsBlocksAction,
|
|
227
|
-
buildAwsBlocksHardeningOffer,
|
|
228
|
-
};
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
|
|
7
|
-
const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config', 'gate-classifier-routing.json');
|
|
8
|
-
|
|
9
|
-
function clamp01(value, fallback = 0) {
|
|
10
|
-
const number = Number(value);
|
|
11
|
-
if (!Number.isFinite(number)) return fallback;
|
|
12
|
-
return Math.min(1, Math.max(0, number));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function riskRank(risk) {
|
|
16
|
-
const normalized = String(risk || 'medium').toLowerCase();
|
|
17
|
-
if (['critical', 'block', 'regulated'].includes(normalized)) return 4;
|
|
18
|
-
if (['high', 'dangerous'].includes(normalized)) return 3;
|
|
19
|
-
if (['medium', 'warn'].includes(normalized)) return 2;
|
|
20
|
-
return 1;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function loadClassifierRoutingConfig(configPath = DEFAULT_CONFIG_PATH) {
|
|
24
|
-
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function lane(config, laneName, reason, input, overrides = {}) {
|
|
28
|
-
const laneConfig = (config.lanes && config.lanes[laneName]) || {};
|
|
29
|
-
return {
|
|
30
|
-
lane: laneName,
|
|
31
|
-
reason,
|
|
32
|
-
requiresEvidence: Boolean(laneConfig.requiresEvidence || overrides.requiresEvidence),
|
|
33
|
-
cloudAllowed: Boolean(laneConfig.cloudAllowed),
|
|
34
|
-
maxLatencyMs: laneConfig.maxLatencyMs,
|
|
35
|
-
risk: String(input.risk || 'medium').toLowerCase(),
|
|
36
|
-
ambiguity: clamp01(input.ambiguity),
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function routeClassifier(input = {}, config = loadClassifierRoutingConfig()) {
|
|
41
|
-
const thresholds = config.thresholds || {};
|
|
42
|
-
const labelCount = Number(input.labelCount || input.examples || 0);
|
|
43
|
-
const latencyBudgetMs = Number(input.latencyBudgetMs || 0);
|
|
44
|
-
const ambiguity = clamp01(input.ambiguity);
|
|
45
|
-
const risk = riskRank(input.risk);
|
|
46
|
-
const largeBatch = Number(input.batchRows || 0) >= Number(thresholds.largeBatchRows || 50);
|
|
47
|
-
const privacySensitive = Boolean(input.privacySensitive || input.containsSecrets || input.customerData);
|
|
48
|
-
const allowCloud = Boolean(input.allowCloud);
|
|
49
|
-
|
|
50
|
-
if (input.hasHardRule || input.exactPolicyMatch) {
|
|
51
|
-
return lane(config, 'deterministic', 'exact hard rule or policy match; do not spend model tokens', input);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (input.semanticCacheHit || input.equivalentRepeat) {
|
|
55
|
-
return lane(config, 'semantic_cache', 'semantically equivalent repeat; reuse the proven prior decision without a model call', input);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (input.rubricFailed || input.missingEvidence || input.completionClaimWithoutProof) {
|
|
59
|
-
return lane(config, 'rubric_gate', 'rubric or completion evidence failed; block done claims until proof exists', input);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (input.structuredDataset && (input.missingProvenance || input.missingSources)) {
|
|
63
|
-
return lane(config, 'rubric_gate', 'structured data claim is missing source provenance', input);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (privacySensitive && !allowCloud && risk >= 3 && ambiguity >= Number(thresholds.mediumAmbiguity || 0.35)) {
|
|
67
|
-
return lane(config, 'human_review', 'private high-risk ambiguous action; keep data local and require approval', input);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (labelCount >= Number(thresholds.classicalMinExamples || 40) && (largeBatch || latencyBudgetMs <= Number(thresholds.lowLatencyBudgetMs || 300)) && ambiguity < Number(thresholds.mediumAmbiguity || 0.35)) {
|
|
71
|
-
return lane(config, 'local_classical', 'enough examples and low ambiguity; use cheap local classification', input);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (risk >= 3 && ambiguity >= Number(thresholds.highRiskAmbiguity || 0.65)) {
|
|
75
|
-
if (allowCloud && latencyBudgetMs >= Number(thresholds.llmMinLatencyBudgetMs || 2000)) {
|
|
76
|
-
return lane(config, 'llm_judge', 'high-risk semantic ambiguity; use a budget-capped LLM judge with evidence', input);
|
|
77
|
-
}
|
|
78
|
-
return lane(config, 'human_review', 'high-risk ambiguity without approved cloud/budget route', input);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (labelCount < Number(thresholds.classicalMinExamples || 40) || ambiguity >= Number(thresholds.mediumAmbiguity || 0.35)) {
|
|
82
|
-
return lane(config, 'local_semantic', 'sparse labels or fuzzy intent; use local semantic recall before any LLM', input);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return lane(config, config.defaultLane || 'local_classical', 'default local route for routine gate classification', input);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function explainClassifierRoute(input = {}, config = loadClassifierRoutingConfig()) {
|
|
89
|
-
const decision = routeClassifier(input, config);
|
|
90
|
-
const laneConfig = (config.lanes && config.lanes[decision.lane]) || {};
|
|
91
|
-
return {
|
|
92
|
-
...decision,
|
|
93
|
-
description: laneConfig.description || '',
|
|
94
|
-
useFor: laneConfig.useFor || [],
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function parseArgs(argv) {
|
|
99
|
-
const input = {};
|
|
100
|
-
for (const arg of argv) {
|
|
101
|
-
if (arg === '--hard-rule') input.hasHardRule = true;
|
|
102
|
-
else if (arg === '--privacy-sensitive') input.privacySensitive = true;
|
|
103
|
-
else if (arg === '--allow-cloud') input.allowCloud = true;
|
|
104
|
-
else if (arg === '--customer-data') input.customerData = true;
|
|
105
|
-
else if (arg === '--semantic-cache-hit') input.semanticCacheHit = true;
|
|
106
|
-
else if (arg === '--equivalent-repeat') input.equivalentRepeat = true;
|
|
107
|
-
else if (arg === '--rubric-failed') input.rubricFailed = true;
|
|
108
|
-
else if (arg === '--missing-evidence') input.missingEvidence = true;
|
|
109
|
-
else if (arg === '--structured-dataset') input.structuredDataset = true;
|
|
110
|
-
else if (arg === '--missing-provenance') input.missingProvenance = true;
|
|
111
|
-
else if (arg.startsWith('--risk=')) input.risk = arg.slice('--risk='.length);
|
|
112
|
-
else if (arg.startsWith('--ambiguity=')) input.ambiguity = Number(arg.slice('--ambiguity='.length));
|
|
113
|
-
else if (arg.startsWith('--labels=')) input.labelCount = Number(arg.slice('--labels='.length));
|
|
114
|
-
else if (arg.startsWith('--latency-ms=')) input.latencyBudgetMs = Number(arg.slice('--latency-ms='.length));
|
|
115
|
-
else if (arg.startsWith('--batch-rows=')) input.batchRows = Number(arg.slice('--batch-rows='.length));
|
|
116
|
-
}
|
|
117
|
-
return input;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
module.exports = {
|
|
121
|
-
DEFAULT_CONFIG_PATH,
|
|
122
|
-
explainClassifierRoute,
|
|
123
|
-
loadClassifierRoutingConfig,
|
|
124
|
-
routeClassifier,
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
if (require.main === module) {
|
|
128
|
-
const decision = explainClassifierRoute(parseArgs(process.argv.slice(2)));
|
|
129
|
-
process.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
|
|
130
|
-
}
|
|
@@ -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
|
-
};
|