thumbgate 1.30.0 → 1.34.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.
Files changed (92) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +105 -10
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +168 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/claim-verifiers.example.json +42 -0
  15. package/config/gates/claim-verifiers.json +25 -0
  16. package/config/gates/default.json +217 -50
  17. package/config/mcp-allowlists.json +233 -206
  18. package/config/model-tiers.json +7 -2
  19. package/glama.json +6 -0
  20. package/hooks/hooks.json +1 -1
  21. package/package.json +69 -12
  22. package/public/assets/diagrams/before-after.svg +17 -16
  23. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  24. package/public/assets/diagrams/loop.svg +19 -13
  25. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  26. package/public/compare.html +1 -0
  27. package/public/dashboard.html +126 -28
  28. package/public/evaluations.html +1 -1
  29. package/public/index.html +142 -13
  30. package/public/numbers.html +3 -2
  31. package/public/pricing.html +143 -30
  32. package/scripts/a-plus-evidence-scorecard.js +303 -0
  33. package/scripts/agent-readiness.js +110 -0
  34. package/scripts/async-eval-observability.js +36 -11
  35. package/scripts/audit-trail.js +37 -1
  36. package/scripts/auto-promote-gates.js +149 -34
  37. package/scripts/auto-wire-hooks.js +20 -8
  38. package/scripts/cli-schema.js +14 -0
  39. package/scripts/colbert-style-maxsim.js +236 -0
  40. package/scripts/cross-encoder-reranker.js +356 -126
  41. package/scripts/dashboard-chat.js +350 -17
  42. package/scripts/document-intake.js +283 -7
  43. package/scripts/eval-quality-suite.js +204 -0
  44. package/scripts/feedback-loop.js +115 -7
  45. package/scripts/feedback-paths.js +32 -13
  46. package/scripts/feedback-quality.js +53 -0
  47. package/scripts/feedback-schema.js +3 -0
  48. package/scripts/file-ledger-lock.js +130 -0
  49. package/scripts/filesystem-search.js +17 -7
  50. package/scripts/financial-control-plane.js +1514 -0
  51. package/scripts/gates-engine.js +202 -7
  52. package/scripts/gemini-embedding-policy.js +1 -0
  53. package/scripts/harness-tool-names.js +70 -0
  54. package/scripts/hook-runtime.js +15 -3
  55. package/scripts/hook-stop-anti-claim.js +63 -3
  56. package/scripts/human-escalation.js +353 -41
  57. package/scripts/lesson-db.js +16 -5
  58. package/scripts/lesson-embedding-index.js +67 -20
  59. package/scripts/lesson-embedding-maintenance.js +177 -0
  60. package/scripts/lesson-reranker.js +55 -9
  61. package/scripts/lesson-retrieval.js +305 -29
  62. package/scripts/lesson-search.js +22 -8
  63. package/scripts/llm-client.js +304 -15
  64. package/scripts/model-tier-router.js +593 -0
  65. package/scripts/pragmatic-hybrid-search.js +379 -0
  66. package/scripts/provider-action-normalizer.js +11 -4
  67. package/scripts/rag-document-pipeline.js +461 -0
  68. package/scripts/rag-structured-output.js +441 -0
  69. package/scripts/ragas-style-metrics.js +351 -0
  70. package/scripts/request-envelope.js +178 -0
  71. package/scripts/rerank-pipeline.js +370 -0
  72. package/scripts/rerank-quality-eval.js +155 -0
  73. package/scripts/retrieval-hybrid-ablation.js +120 -0
  74. package/scripts/retrieval-quality-tier.js +118 -0
  75. package/scripts/secret-scanner.js +395 -4
  76. package/scripts/self-distill-agent.js +7 -1
  77. package/scripts/self-healing-check.js +25 -0
  78. package/scripts/skill-packs.js +183 -0
  79. package/scripts/slow-loop.js +72 -0
  80. package/scripts/statusline-links.js +1 -1
  81. package/scripts/statusline.sh +8 -1
  82. package/scripts/telemetry-analytics.js +13 -1
  83. package/scripts/thumbgate-search.js +98 -6
  84. package/scripts/tier-budget-guard.js +186 -0
  85. package/scripts/tool-registry.js +141 -5
  86. package/scripts/universal-claim-evaluator.js +767 -0
  87. package/scripts/vector-store.js +154 -17
  88. package/scripts/verify-marketing-pages-deployed.js +85 -3
  89. package/scripts/workflow-sentinel.js +77 -11
  90. package/server.json +44 -0
  91. package/smithery.yaml +17 -0
  92. package/src/api/server.js +196 -13
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { registerPreventionRules } = require('./contextfs');
6
+ const SKILL_PACKS_DIR = path.join(__dirname, '..', 'config', 'skill-packs');
7
+ const BUILTIN_PACKS = {
8
+ 'stripe-integration': { name: 'stripe-integration', description: 'Stripe API best practices', triggers: ['stripe', 'payment', 'checkout', 'subscription', 'webhook signature', 'credit card', 'card number'], rules: ['ALWAYS use idempotency keys on PaymentIntent creation to prevent duplicate charges.', 'NEVER log or store raw card numbers — use Stripe tokens or PaymentMethod IDs.', 'ALWAYS verify webhook signatures with stripe.webhooks.constructEvent() before processing.', 'Use Checkout Sessions instead of raw PaymentIntents for new integrations.', 'ALWAYS handle payment_intent.succeeded AND payment_intent.payment_failed webhooks.'], packTemplate: { namespaces: ['memoryError', 'memoryLearning', 'rules'], maxItems: 8, maxChars: 6000, queryPrefix: 'stripe payment checkout webhook idempotency' } },
9
+ 'railway-deploy': { name: 'railway-deploy', description: 'Railway deployment best practices', triggers: ['railway', 'deploy', 'dockerfile', 'health check'], rules: ['ALWAYS verify /health endpoint returns new version after deploy.', 'NEVER say "deployed" without curling the health endpoint and showing version match.', 'ALWAYS check Railway build logs for warnings even when deploy succeeds.', 'Use RAILWAY_VOLUME_MOUNT_PATH for persistent data.', 'ALWAYS wait 2-5 minutes after merge before verifying.'], packTemplate: { namespaces: ['memoryError', 'memoryLearning', 'rules'], maxItems: 8, maxChars: 6000, queryPrefix: 'railway deploy health version dockerfile' } },
10
+ 'database-migration': { name: 'database-migration', description: 'Database migration best practices', triggers: ['migration', 'prisma', 'sqlite', 'schema', 'alter table'], rules: ['ALWAYS back up the database before running destructive migrations.', 'NEVER drop columns in production without verifying no code references them.', 'ALWAYS run migrations against a test database first.', 'Use reversible migrations — every up() should have a corresponding down().', 'ALWAYS check for pending migrations before deploying new code.'], packTemplate: { namespaces: ['memoryError', 'rules'], maxItems: 6, maxChars: 5000, queryPrefix: 'migration database schema prisma sqlite' } },
11
+ 'database-agent-safety': {
12
+ name: 'database-agent-safety',
13
+ description: 'Pre-action checks for AI agents before they touch production databases.',
14
+ triggers: ['database', 'postgres', 'mysql', 'sql', 'migration', 'prisma', 'rails db:migrate', 'drop table', 'truncate', 'production database'],
15
+ rules: [
16
+ 'NEVER allow an autonomous agent to run DROP, TRUNCATE, DROP SCHEMA, or DROP DATABASE without explicit human approval and rollback evidence.',
17
+ 'ALWAYS require a backup, snapshot, or reversible rollback plan before production schema migrations.',
18
+ 'NEVER run UPDATE or DELETE without a restrictive WHERE clause; WHERE 1=1 and WHERE TRUE are not restrictive.',
19
+ 'ALWAYS require dry-run or EXPLAIN evidence before production writes, migrations, or high-cardinality queries.',
20
+ 'NEVER let agents create roles, alter roles, or grant broad privileges in a live database.',
21
+ 'ALWAYS treat database work as a pre-action approval boundary, not a post-hoc review item.',
22
+ ],
23
+ packTemplate: {
24
+ namespaces: ['memoryError', 'memoryLearning', 'rules'],
25
+ maxItems: 8,
26
+ maxChars: 6000,
27
+ queryPrefix: 'database postgres mysql sql migration production drop truncate rollback backup explain',
28
+ },
29
+ },
30
+ };
31
+ const registry = new Map(); for (const [id, p] of Object.entries(BUILTIN_PACKS)) registry.set(id, p);
32
+ function ensurePacksDir() { if (!fs.existsSync(SKILL_PACKS_DIR)) fs.mkdirSync(SKILL_PACKS_DIR, { recursive: true }); }
33
+ function registerSkillPack(pack) { if (!pack.name) throw new Error('Skill pack requires a name'); if (!Array.isArray(pack.rules) || pack.rules.length === 0) throw new Error('Skill pack requires at least one rule'); const n = { name: pack.name, description: pack.description || '', triggers: Array.isArray(pack.triggers) ? pack.triggers : [], rules: pack.rules, packTemplate: pack.packTemplate || null, registeredAt: new Date().toISOString() }; registry.set(n.name, n); ensurePacksDir(); fs.writeFileSync(path.join(SKILL_PACKS_DIR, `${n.name}.json`), JSON.stringify(n, null, 2) + '\n'); return n; }
34
+ function loadSkillPacksFromDisk() { ensurePacksDir(); for (const f of fs.readdirSync(SKILL_PACKS_DIR).filter((x) => x.endsWith('.json'))) { try { const p = JSON.parse(fs.readFileSync(path.join(SKILL_PACKS_DIR, f), 'utf-8')); if (p.name) registry.set(p.name, p); } catch { /* skip */ } } }
35
+ function listSkillPacks() { loadSkillPacksFromDisk(); return Array.from(registry.values()).map((p) => ({ name: p.name, description: p.description, triggers: p.triggers, ruleCount: p.rules.length, hasPackTemplate: !!p.packTemplate })); }
36
+ function getSkillPack(name) { loadSkillPacksFromDisk(); return registry.get(name) || null; }
37
+ function matchTokens(value) {
38
+ return String(value || '')
39
+ .toLowerCase()
40
+ .split(/[^a-z0-9]+/)
41
+ .filter((token) => token.length >= 3);
42
+ }
43
+ function matchSkillPacks(query) {
44
+ const tokens = matchTokens(query);
45
+ if (tokens.length === 0) return [];
46
+ loadSkillPacksFromDisk();
47
+ const scored = [];
48
+ for (const pack of registry.values()) {
49
+ let score = 0;
50
+ for (const trigger of pack.triggers) {
51
+ for (const triggerToken of matchTokens(trigger)) {
52
+ const matched = tokens.some((queryToken) => (
53
+ queryToken === triggerToken
54
+ || (
55
+ Math.min(queryToken.length, triggerToken.length) >= 4
56
+ && (queryToken.includes(triggerToken) || triggerToken.includes(queryToken))
57
+ )
58
+ ));
59
+ if (matched) score += 1;
60
+ }
61
+ }
62
+ if (score > 0) scored.push({ pack, score });
63
+ }
64
+ return scored.sort((a, b) => b.score - a.score).map((entry) => entry.pack);
65
+ }
66
+ function installSkillPackRules(name) { const pack = getSkillPack(name); if (!pack) throw new Error(`Skill pack not found: "${name}"`); return registerPreventionRules([`# Skill Pack: ${pack.name}`, '', pack.description || '', '', ...pack.rules.map((r, i) => `${i + 1}. ${r}`)].join('\n'), { skillPack: pack.name }); }
67
+ // ---------------------------------------------------------------------------
68
+ // L3 Resource Loading (ADK progressive disclosure)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const RESOURCES_DIR_NAME = 'references';
72
+
73
+ /**
74
+ * Load an L3 resource file for a skill pack.
75
+ * Resources live in config/skill-packs/{pack-name}/references/{filename}.
76
+ */
77
+ function loadSkillResource(packName, resourceName) {
78
+ const resDir = path.join(SKILL_PACKS_DIR, packName, RESOURCES_DIR_NAME);
79
+ const resPath = path.join(resDir, resourceName);
80
+ if (!fs.existsSync(resPath)) return null;
81
+ return { name: resourceName, path: resPath, content: fs.readFileSync(resPath, 'utf-8'), sizeBytes: fs.statSync(resPath).size };
82
+ }
83
+
84
+ /**
85
+ * List available L3 resources for a skill pack.
86
+ */
87
+ function listSkillResources(packName) {
88
+ const resDir = path.join(SKILL_PACKS_DIR, packName, RESOURCES_DIR_NAME);
89
+ if (!fs.existsSync(resDir)) return [];
90
+ return fs.readdirSync(resDir).filter((f) => !f.startsWith('.')).map((f) => {
91
+ const fp = path.join(resDir, f);
92
+ return { name: f, sizeBytes: fs.statSync(fp).size };
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Add an L3 resource file to a skill pack.
98
+ */
99
+ function addSkillResource(packName, resourceName, content) {
100
+ const resDir = path.join(SKILL_PACKS_DIR, packName, RESOURCES_DIR_NAME);
101
+ ensurePacksDir();
102
+ if (!fs.existsSync(resDir)) fs.mkdirSync(resDir, { recursive: true });
103
+ const resPath = path.join(resDir, resourceName);
104
+ fs.writeFileSync(resPath, content);
105
+ return { name: resourceName, path: resPath, sizeBytes: Buffer.byteLength(content) };
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Skill Factory — agent-driven skill generation
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Auto-generate a skill pack from recurring failure patterns.
114
+ * Uses distilled lessons to propose rules for a new domain.
115
+ *
116
+ * @param {Object} opts
117
+ * @param {string} opts.domain - Domain name (e.g., 'graphql-api')
118
+ * @param {Array} opts.lessons - Array of lesson strings from history distiller
119
+ * @param {string} [opts.description] - Pack description
120
+ * @param {Array} [opts.triggers] - Trigger keywords
121
+ * @returns {Object} The created skill pack
122
+ */
123
+ function generateSkillPack({ domain, lessons, description, triggers } = {}) {
124
+ if (!domain) throw new Error('Skill factory requires a domain name');
125
+ if (!Array.isArray(lessons) || lessons.length === 0) throw new Error('Skill factory requires at least one lesson');
126
+
127
+ // Convert lessons into NEVER/ALWAYS rules
128
+ const rules = lessons.map((lesson) => {
129
+ const l = String(lesson).trim();
130
+ if (/^(NEVER|ALWAYS|DO NOT|MUST)/i.test(l)) return l;
131
+ if (/fail|error|broke|wrong|bug|crash/i.test(l)) return `NEVER ${l.replace(/^(avoid|don'?t|stop)\s*/i, '').trim()}`;
132
+ return `ALWAYS ${l.replace(/^(repeat|keep|continue)\s*/i, '').trim()}`;
133
+ });
134
+
135
+ // Infer triggers from domain + lesson content
136
+ const inferredTriggers = triggers || [domain, ...domain.split('-').filter((t) => t.length > 2)];
137
+
138
+ return registerSkillPack({
139
+ name: domain,
140
+ description: description || `Auto-generated skill pack for ${domain} from ${lessons.length} lessons`,
141
+ triggers: inferredTriggers,
142
+ rules,
143
+ packTemplate: { namespaces: ['memoryError', 'memoryLearning', 'rules'], maxItems: 8, maxChars: 6000, queryPrefix: inferredTriggers.join(' ') },
144
+ });
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Token-Efficient Progressive Disclosure Metrics
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /**
152
+ * Measure token cost of each disclosure level for a skill pack.
153
+ * Helps agents decide which packs to load.
154
+ */
155
+ function measureSkillTokens(packName) {
156
+ const pack = getSkillPack(packName);
157
+ if (!pack) return null;
158
+
159
+ // L1: metadata only (~name + description + triggers)
160
+ const l1Text = `${pack.name}: ${pack.description} [${(pack.triggers || []).join(', ')}]`;
161
+ const l1Chars = l1Text.length;
162
+
163
+ // L2: full rules
164
+ const l2Text = pack.rules.join('\n');
165
+ const l2Chars = l2Text.length;
166
+
167
+ // L3: resources
168
+ const resources = listSkillResources(packName);
169
+ const l3Chars = resources.reduce((sum, r) => sum + r.sizeBytes, 0);
170
+
171
+ const totalChars = l1Chars + l2Chars + l3Chars;
172
+
173
+ return {
174
+ packName,
175
+ l1: { chars: l1Chars, estimatedTokens: Math.ceil(l1Chars / 4) },
176
+ l2: { chars: l2Chars, estimatedTokens: Math.ceil(l2Chars / 4), ruleCount: pack.rules.length },
177
+ l3: { chars: l3Chars, estimatedTokens: Math.ceil(l3Chars / 4), resourceCount: resources.length },
178
+ total: { chars: totalChars, estimatedTokens: Math.ceil(totalChars / 4) },
179
+ disclosureSavings: totalChars > 0 ? Math.round((1 - l1Chars / totalChars) * 100) : 0,
180
+ };
181
+ }
182
+
183
+ module.exports = { BUILTIN_PACKS, registerSkillPack, listSkillPacks, getSkillPack, matchSkillPacks, installSkillPackRules, SKILL_PACKS_DIR, loadSkillResource, listSkillResources, addSkillResource, generateSkillPack, measureSkillTokens };
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { createSchedule } = require('./schedule-manager');
7
+ const { resolveFeedbackDir } = require('./feedback-paths');
8
+
9
+ const IDLE_THRESHOLD_MINUTES = 30;
10
+ const SLOW_LOOP_STATE_FILE = 'slow-loop-state.json';
11
+
12
+ function getStatePath() {
13
+ return path.join(resolveFeedbackDir(), SLOW_LOOP_STATE_FILE);
14
+ }
15
+
16
+ function loadState() {
17
+ const p = getStatePath();
18
+ if (!fs.existsSync(p)) return { lastExportAt: null, exportCount: 0, lastIdleCheckAt: null, totalPairsExported: 0 };
19
+ try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return { lastExportAt: null, exportCount: 0, lastIdleCheckAt: null, totalPairsExported: 0 }; }
20
+ }
21
+
22
+ function saveState(state) {
23
+ const p = getStatePath();
24
+ const dir = path.dirname(p);
25
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
26
+ fs.writeFileSync(p, JSON.stringify(state, null, 2) + '\n');
27
+ }
28
+
29
+ function isIdle({ thresholdMinutes = IDLE_THRESHOLD_MINUTES } = {}) {
30
+ const feedbackDir = resolveFeedbackDir();
31
+ const logPath = path.join(feedbackDir, 'feedback-log.jsonl');
32
+ if (!fs.existsSync(logPath)) return true;
33
+ try {
34
+ const stats = fs.statSync(logPath);
35
+ const minutesSinceModified = (Date.now() - stats.mtimeMs) / (1000 * 60);
36
+ return minutesSinceModified >= thresholdMinutes;
37
+ } catch { return true; }
38
+ }
39
+
40
+ function runSlowLoop({ thresholdMinutes = IDLE_THRESHOLD_MINUTES, force = false } = {}) {
41
+ const state = loadState();
42
+ const idle = force || isIdle({ thresholdMinutes });
43
+ if (!idle) { state.lastIdleCheckAt = new Date().toISOString(); saveState(state); return { action: 'skipped', reason: 'system not idle', idle: false, state }; }
44
+
45
+ const feedbackDir = resolveFeedbackDir();
46
+ const logPath = path.join(feedbackDir, 'feedback-log.jsonl');
47
+ let newEntries = 0;
48
+ if (fs.existsSync(logPath)) {
49
+ const totalEntries = fs.readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean).length;
50
+ newEntries = totalEntries - (state.lastFeedbackCount || 0);
51
+ state.lastFeedbackCount = totalEntries;
52
+ }
53
+ if (newEntries <= 0 && !force) { state.lastIdleCheckAt = new Date().toISOString(); saveState(state); return { action: 'skipped', reason: 'no new feedback since last export', idle: true, newEntries: 0, state }; }
54
+
55
+ let dpoResult = null;
56
+ try { const { exportDpoPairs } = require('./feedback-loop'); dpoResult = exportDpoPairs(); } catch (err) { dpoResult = { error: err.message, pairsExported: 0 }; }
57
+
58
+ const pairsExported = dpoResult && dpoResult.pairs ? dpoResult.pairs.length : (dpoResult && dpoResult.pairsExported) || 0;
59
+ state.lastExportAt = new Date().toISOString();
60
+ state.exportCount = (state.exportCount || 0) + 1;
61
+ state.totalPairsExported = (state.totalPairsExported || 0) + pairsExported;
62
+ state.lastIdleCheckAt = new Date().toISOString();
63
+ saveState(state);
64
+ return { action: 'exported', idle: true, newEntries, pairsExported, totalExports: state.exportCount, totalPairsExported: state.totalPairsExported, exportedAt: state.lastExportAt, state };
65
+ }
66
+
67
+ function createSlowLoopSchedule({ schedule = 'hourly', thresholdMinutes = IDLE_THRESHOLD_MINUTES } = {}) {
68
+ const command = [`const sl = require(${JSON.stringify(__filename)});`, `const result = sl.runSlowLoop(${JSON.stringify({ thresholdMinutes })});`, 'process.stdout.write(JSON.stringify(result, null, 2) + "\\n");'].join(' ');
69
+ return createSchedule({ id: 'thumbgate-slow-loop', name: 'ThumbGate Slow Loop (DPO Export)', description: `Idle-time DPO export, runs ${schedule}`, schedule, command });
70
+ }
71
+
72
+ module.exports = { isIdle, runSlowLoop, createSlowLoopSchedule, loadState, getStatePath };
@@ -109,7 +109,7 @@ function launchLocalServer(options = {}) {
109
109
 
110
110
  const child = spawn(
111
111
  process.execPath,
112
- [path.join(PKG_ROOT, 'bin', 'cli.js'), 'start-api'],
112
+ [path.join(PKG_ROOT, 'src', 'api', 'server.js')],
113
113
  {
114
114
  cwd: projectDir,
115
115
  env: childEnv,
@@ -21,7 +21,14 @@ PROJECT_CWD="${PROJECT_CWD:-}"
21
21
  if [ -n "$PROJECT_CWD" ] && [ -d "$PROJECT_CWD" ]; then
22
22
  export THUMBGATE_PROJECT_DIR="$PROJECT_CWD"
23
23
  if [ -z "${THUMBGATE_FEEDBACK_DIR:-}" ]; then
24
- export THUMBGATE_FEEDBACK_DIR="${PROJECT_CWD}/.claude/memory/feedback"
24
+ THUMBGATE_FEEDBACK_DIR="$(node -e '
25
+ const { resolveFeedbackDir } = require(process.argv[1]);
26
+ process.stdout.write(resolveFeedbackDir({
27
+ projectDir: process.env.THUMBGATE_PROJECT_DIR,
28
+ env: process.env,
29
+ }));
30
+ ' "${SCRIPT_DIR}/feedback-paths.js" 2>/dev/null)"
31
+ export THUMBGATE_FEEDBACK_DIR="${THUMBGATE_FEEDBACK_DIR:-${PROJECT_CWD}/.thumbgate}"
25
32
  fi
26
33
  fi
27
34
 
@@ -419,7 +419,15 @@ function classifyTelemetryAudience(entry = {}, raw = {}) {
419
419
  }
420
420
 
421
421
  function sanitizeTelemetryPayload(payload = {}, headers = {}) {
422
- const raw = payload && typeof payload === 'object' ? payload : {};
422
+ const envelope = payload && typeof payload === 'object' ? payload : {};
423
+ const nestedProps = envelope.props
424
+ && typeof envelope.props === 'object'
425
+ && !Array.isArray(envelope.props)
426
+ ? envelope.props
427
+ : {};
428
+ // Browser analytics libraries conventionally wrap event properties in
429
+ // `props`. Flatten that envelope while letting explicit top-level fields win.
430
+ const raw = { ...nestedProps, ...envelope };
423
431
  const clientType = inferClientType(raw);
424
432
  const eventType = inferEventType(raw, clientType);
425
433
  const source = pickFirstText(raw.source, raw.utmSource, clientType === 'cli' ? 'cli' : 'direct');
@@ -471,6 +479,10 @@ function sanitizeTelemetryPayload(payload = {}, headers = {}) {
471
479
  ctaId: pickFirstText(raw.ctaId),
472
480
  ctaPlacement: pickFirstText(raw.ctaPlacement),
473
481
  planId: pickFirstText(raw.planId),
482
+ segment: pickFirstText(raw.segment, raw.buyerSegment, raw.campaignVariant, raw.variant),
483
+ experimentId: pickFirstText(raw.experimentId, raw.experiment, raw.utmCampaign),
484
+ value: normalizeInteger(raw.value),
485
+ currency: pickFirstText(raw.currency),
474
486
  linkSlug: pickFirstText(raw.linkSlug, raw.destinationSlug),
475
487
  destinationPath: pickFirstText(raw.destinationPath),
476
488
  pipelineStatus: pickFirstText(raw.pipelineStatus, raw.workflowSprintStatus, raw.status),
@@ -8,6 +8,7 @@ const {
8
8
  } = require('./filesystem-search');
9
9
  const {
10
10
  searchImportedDocuments,
11
+ searchImportedDocumentsAsync,
11
12
  } = require('./document-intake');
12
13
 
13
14
  const VALID_SOURCES = ['all', 'feedback', 'context', 'rules', 'documents'];
@@ -139,9 +140,25 @@ function mapDocumentResult(record) {
139
140
  proposalCount: safeArray(record.proposals).length,
140
141
  matchedTemplateIds: safeArray(record.matchedTemplateIds),
141
142
  sourceFormat: record.sourceFormat || null,
143
+ matchedChunks: safeArray(record._matchedChunks),
144
+ retrieval: record._retrieval || null,
142
145
  };
143
146
  }
144
147
 
148
+ async function getDocumentResultsAsync(query, limit, feedbackDir, options = {}) {
149
+ const documents = await searchImportedDocumentsAsync({
150
+ query,
151
+ limit,
152
+ feedbackDir,
153
+ metadataFilters: options.metadataFilters,
154
+ queryRewrite: options.queryRewrite,
155
+ embedder: options.embedder,
156
+ embedderId: options.embedderId,
157
+ accessContext: options.accessContext,
158
+ });
159
+ return documents.map(mapDocumentResult);
160
+ }
161
+
145
162
  function sortResults(results) {
146
163
  return [...results].sort((left, right) => {
147
164
  if ((right.score || 0) !== (left.score || 0)) {
@@ -154,7 +171,7 @@ function sortResults(results) {
154
171
  function extractFeedbackId(str) {
155
172
  if (!str) return null;
156
173
  const match = str.match(/fb[_-]\d+[_-][a-z0-9]+/i);
157
- return match ? match[0].replace(/-/g, '_').toLowerCase() : null;
174
+ return match ? match[0].replaceAll('-', '_').toLowerCase() : null;
158
175
  }
159
176
 
160
177
  function deduplicateResults(results) {
@@ -227,11 +244,23 @@ function getRuleResults(query, limit, feedbackDir) {
227
244
  return searchPreventionRulesSync(query, limit, { feedbackDir }).map(mapRuleResult);
228
245
  }
229
246
 
230
- function getDocumentResults(query, limit, feedbackDir) {
231
- return searchImportedDocuments({ query, limit, feedbackDir }).map(mapDocumentResult);
247
+ function getDocumentResults(query, limit, feedbackDir, accessContext) {
248
+ return searchImportedDocuments({
249
+ query,
250
+ limit,
251
+ feedbackDir,
252
+ accessContext,
253
+ }).map(mapDocumentResult);
232
254
  }
233
255
 
234
- function searchThumbgate({ query, source = 'all', limit = 10, signal = null, feedbackDir = null } = {}) {
256
+ function searchThumbgate({
257
+ query,
258
+ source = 'all',
259
+ limit = 10,
260
+ signal = null,
261
+ feedbackDir = null,
262
+ accessContext = null,
263
+ } = {}) {
235
264
  const trimmedQuery = String(query || '').trim();
236
265
  if (!trimmedQuery) {
237
266
  throw new Error('query is required');
@@ -254,14 +283,14 @@ function searchThumbgate({ query, source = 'all', limit = 10, signal = null, fee
254
283
  const raw = getRuleResults(trimmedQuery, fetchLimit, feedbackDir);
255
284
  results = deduplicateResults(raw).slice(0, normalizedLimit);
256
285
  } else if (normalizedSource === 'documents') {
257
- const raw = getDocumentResults(trimmedQuery, fetchLimit, feedbackDir);
286
+ const raw = getDocumentResults(trimmedQuery, fetchLimit, feedbackDir, accessContext);
258
287
  results = deduplicateResults(raw).slice(0, normalizedLimit);
259
288
  } else {
260
289
  const combined = [
261
290
  ...getFeedbackResults(trimmedQuery, fetchLimit, normalizedSignal, feedbackDir),
262
291
  ...getContextResults(trimmedQuery, fetchLimit, feedbackDir),
263
292
  ...getRuleResults(trimmedQuery, fetchLimit, feedbackDir),
264
- ...getDocumentResults(trimmedQuery, fetchLimit, feedbackDir),
293
+ ...getDocumentResults(trimmedQuery, fetchLimit, feedbackDir, accessContext),
265
294
  ];
266
295
  results = deduplicateResults(sortResults(combined)).slice(0, normalizedLimit);
267
296
  }
@@ -278,9 +307,72 @@ function searchThumbgate({ query, source = 'all', limit = 10, signal = null, fee
278
307
  };
279
308
  }
280
309
 
310
+ async function searchThumbgateAsync({
311
+ query,
312
+ source = 'all',
313
+ limit = 10,
314
+ signal = null,
315
+ feedbackDir = null,
316
+ metadataFilters = null,
317
+ queryRewrite = true,
318
+ embedder,
319
+ embedderId,
320
+ accessContext = null,
321
+ } = {}) {
322
+ const trimmedQuery = String(query || '').trim();
323
+ if (!trimmedQuery) throw new Error('query is required');
324
+ const normalizedSource = normalizeSource(source);
325
+ const normalizedSignal = normalizeSignal(signal);
326
+ const normalizedLimit = normalizeLimit(limit);
327
+ const fetchLimit = Math.max(100, normalizedLimit * 5);
328
+ const documentOptions = {
329
+ metadataFilters,
330
+ queryRewrite,
331
+ embedder,
332
+ embedderId,
333
+ accessContext,
334
+ };
335
+
336
+ let results;
337
+ if (normalizedSource === 'documents') {
338
+ results = deduplicateResults(
339
+ await getDocumentResultsAsync(trimmedQuery, fetchLimit, feedbackDir, documentOptions),
340
+ ).slice(0, normalizedLimit);
341
+ } else if (normalizedSource === 'all') {
342
+ const combined = [
343
+ ...getFeedbackResults(trimmedQuery, fetchLimit, normalizedSignal, feedbackDir),
344
+ ...getContextResults(trimmedQuery, fetchLimit, feedbackDir),
345
+ ...getRuleResults(trimmedQuery, fetchLimit, feedbackDir),
346
+ ...await getDocumentResultsAsync(trimmedQuery, fetchLimit, feedbackDir, documentOptions),
347
+ ];
348
+ results = deduplicateResults(sortResults(combined)).slice(0, normalizedLimit);
349
+ } else {
350
+ return searchThumbgate({
351
+ query: trimmedQuery,
352
+ source: normalizedSource,
353
+ limit: normalizedLimit,
354
+ signal: normalizedSignal,
355
+ feedbackDir,
356
+ accessContext,
357
+ });
358
+ }
359
+
360
+ return {
361
+ query: trimmedQuery,
362
+ source: normalizedSource,
363
+ signal: normalizedSignal,
364
+ limit: normalizedLimit,
365
+ engine: 'hybrid-parent-child',
366
+ returned: results.length,
367
+ total: results.length,
368
+ results,
369
+ };
370
+ }
371
+
281
372
  module.exports = {
282
373
  VALID_SOURCES,
283
374
  normalizeSearchSource: normalizeSource,
284
375
  normalizeSearchSignal: normalizeSignal,
285
376
  searchThumbgate,
377
+ searchThumbgateAsync,
286
378
  };
@@ -0,0 +1,186 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Hard cost + latency budgets for model tier routing.
5
+ *
6
+ * Complements FrontierBudget (session token cap) with:
7
+ * - per-request max cost (cents)
8
+ * - per-request max latency budget (ms) for planning/degrade
9
+ * - max frontier invocations per day (process-local counter)
10
+ *
11
+ * Env (optional):
12
+ * THUMBGATE_MAX_COST_CENTS_PER_REQUEST
13
+ * THUMBGATE_MAX_LATENCY_MS
14
+ * THUMBGATE_MAX_FRONTIER_PER_DAY
15
+ */
16
+
17
+ const { TIERS, classifyTask, FrontierBudget } = require('./model-tier-router');
18
+
19
+ const DEFAULTS = Object.freeze({
20
+ maxCostCentsPerRequest: 25, // $0.25
21
+ maxLatencyMs: 30_000,
22
+ maxFrontierPerDay: 50,
23
+ });
24
+
25
+ /** @type {Map<string, number>} dayKey → frontier invocation count */
26
+ const frontierDayCounts = new Map();
27
+
28
+ function dayKey(now = Date.now()) {
29
+ return new Date(now).toISOString().slice(0, 10);
30
+ }
31
+
32
+ function readEnvNumber(name, fallback) {
33
+ const raw = process.env[name];
34
+ if (raw == null || raw === '') return fallback;
35
+ const n = Number(raw);
36
+ return Number.isFinite(n) ? n : fallback;
37
+ }
38
+
39
+ function getBudgetConfig(overrides = {}) {
40
+ return {
41
+ maxCostCentsPerRequest: overrides.maxCostCentsPerRequest
42
+ ?? readEnvNumber('THUMBGATE_MAX_COST_CENTS_PER_REQUEST', DEFAULTS.maxCostCentsPerRequest),
43
+ maxLatencyMs: overrides.maxLatencyMs
44
+ ?? readEnvNumber('THUMBGATE_MAX_LATENCY_MS', DEFAULTS.maxLatencyMs),
45
+ maxFrontierPerDay: overrides.maxFrontierPerDay
46
+ ?? readEnvNumber('THUMBGATE_MAX_FRONTIER_PER_DAY', DEFAULTS.maxFrontierPerDay),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Estimate request cost cents from tier + token budget.
52
+ * @param {string} tier
53
+ * @param {number} estimatedTokens
54
+ */
55
+ function estimateTierCostCents(tier, estimatedTokens = 4000) {
56
+ const t = TIERS[tier] || TIERS.mini;
57
+ // Base: ~$3/M input + $15/M out blended as ~$6/M total for coding
58
+ const basePerM = 6;
59
+ const usd = (estimatedTokens / 1e6) * basePerM * (t.costMultiplier ?? 1);
60
+ return Number((usd * 100).toFixed(4));
61
+ }
62
+
63
+ /**
64
+ * Enforce hard budgets on a classification result.
65
+ *
66
+ * @param {object} task — same shape as classifyTask
67
+ * @param {object} [options]
68
+ * @param {object} [options.classification] — precomputed classifyTask result
69
+ * @param {FrontierBudget} [options.frontierBudget]
70
+ * @param {number} [options.estimatedTokens]
71
+ * @param {number} [options.nowMs]
72
+ * @returns {{
73
+ * allowed: boolean,
74
+ * tier: string,
75
+ * action: 'allow'|'degrade'|'deny',
76
+ * reasons: string[],
77
+ * classification: object,
78
+ * estimatedCostCents: number,
79
+ * budget: object,
80
+ * }}
81
+ */
82
+ function enforceTierBudgets(task = {}, options = {}) {
83
+ const config = getBudgetConfig(options);
84
+ const classification = options.classification || classifyTask(task);
85
+ let tier = classification.tier;
86
+ const reasons = [];
87
+ const estimatedTokens = Number(options.estimatedTokens) || 4000;
88
+ let estimatedCostCents = estimateTierCostCents(tier, estimatedTokens);
89
+ let action = 'allow';
90
+
91
+ // Daily frontier cap (process-local)
92
+ if (tier === 'frontier') {
93
+ const key = dayKey(options.nowMs);
94
+ const used = frontierDayCounts.get(key) || 0;
95
+ if (used >= config.maxFrontierPerDay) {
96
+ reasons.push(`frontier_daily_cap:${used}/${config.maxFrontierPerDay}`);
97
+ tier = 'mini';
98
+ action = 'degrade';
99
+ estimatedCostCents = estimateTierCostCents(tier, estimatedTokens);
100
+ }
101
+ }
102
+
103
+ // Per-request cost cap → degrade tier then deny if still over
104
+ if (estimatedCostCents > config.maxCostCentsPerRequest) {
105
+ if (tier === 'frontier') {
106
+ reasons.push(`cost_over_cap_degrade:${estimatedCostCents}>${config.maxCostCentsPerRequest}`);
107
+ tier = 'mini';
108
+ action = 'degrade';
109
+ estimatedCostCents = estimateTierCostCents(tier, estimatedTokens);
110
+ }
111
+ }
112
+ if (estimatedCostCents > config.maxCostCentsPerRequest && tier !== 'nano' && tier !== 'localFrontier') {
113
+ reasons.push(`cost_over_cap_degrade_nano:${estimatedCostCents}>${config.maxCostCentsPerRequest}`);
114
+ tier = 'nano';
115
+ action = 'degrade';
116
+ estimatedCostCents = estimateTierCostCents(tier, estimatedTokens);
117
+ }
118
+ if (estimatedCostCents > config.maxCostCentsPerRequest && (TIERS[tier]?.costMultiplier || 0) > 0) {
119
+ reasons.push(`cost_deny:${estimatedCostCents}>${config.maxCostCentsPerRequest}`);
120
+ action = 'deny';
121
+ }
122
+
123
+ // Session frontier token budget
124
+ const frontierBudget = options.frontierBudget || null;
125
+ if (frontierBudget && tier === 'frontier' && typeof frontierBudget.canSpend === 'function') {
126
+ const check = frontierBudget.canSpend(estimatedTokens, task.reason || classification.reason || 'routed_frontier');
127
+ if (!check.allowed) {
128
+ reasons.push(`frontier_session_budget:${check.reason}`);
129
+ tier = 'mini';
130
+ action = action === 'deny' ? 'deny' : 'degrade';
131
+ estimatedCostCents = estimateTierCostCents(tier, estimatedTokens);
132
+ }
133
+ }
134
+
135
+ // Latency budget is advisory for planning (caller may still enforce timeouts)
136
+ if (Number(task.expectedLatencyMs) > config.maxLatencyMs) {
137
+ reasons.push(`latency_budget_exceeded_plan:${task.expectedLatencyMs}>${config.maxLatencyMs}`);
138
+ if (tier === 'frontier') {
139
+ tier = 'mini';
140
+ action = action === 'deny' ? 'deny' : 'degrade';
141
+ }
142
+ }
143
+
144
+ const allowed = action !== 'deny';
145
+ return {
146
+ allowed,
147
+ tier,
148
+ action,
149
+ reasons,
150
+ classification,
151
+ estimatedCostCents,
152
+ budget: {
153
+ ...config,
154
+ estimatedTokens,
155
+ },
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Record a frontier invocation against the daily counter (call only when actually used).
161
+ */
162
+ function recordFrontierInvocation(nowMs = Date.now()) {
163
+ const key = dayKey(nowMs);
164
+ frontierDayCounts.set(key, (frontierDayCounts.get(key) || 0) + 1);
165
+ return frontierDayCounts.get(key);
166
+ }
167
+
168
+ /** Test helper */
169
+ function _resetFrontierDayCounts() {
170
+ frontierDayCounts.clear();
171
+ }
172
+
173
+ function getFrontierDayUsage(nowMs = Date.now()) {
174
+ const key = dayKey(nowMs);
175
+ return { day: key, count: frontierDayCounts.get(key) || 0 };
176
+ }
177
+
178
+ module.exports = {
179
+ DEFAULTS,
180
+ getBudgetConfig,
181
+ estimateTierCostCents,
182
+ enforceTierBudgets,
183
+ recordFrontierInvocation,
184
+ getFrontierDayUsage,
185
+ _resetFrontierDayCounts,
186
+ };