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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +105 -10
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +168 -31
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/claim-verifiers.example.json +42 -0
- package/config/gates/claim-verifiers.json +25 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +233 -206
- package/config/model-tiers.json +7 -2
- package/glama.json +6 -0
- package/hooks/hooks.json +1 -1
- package/package.json +69 -12
- package/public/assets/diagrams/before-after.svg +17 -16
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +19 -13
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/evaluations.html +1 -1
- package/public/index.html +142 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/agent-readiness.js +110 -0
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/auto-wire-hooks.js +20 -8
- package/scripts/cli-schema.js +14 -0
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +356 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-loop.js +115 -7
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/feedback-schema.js +3 -0
- package/scripts/file-ledger-lock.js +130 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/financial-control-plane.js +1514 -0
- package/scripts/gates-engine.js +202 -7
- package/scripts/gemini-embedding-policy.js +1 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +15 -3
- package/scripts/hook-stop-anti-claim.js +63 -3
- package/scripts/human-escalation.js +353 -41
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +305 -29
- package/scripts/lesson-search.js +22 -8
- package/scripts/llm-client.js +304 -15
- package/scripts/model-tier-router.js +593 -0
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/provider-action-normalizer.js +11 -4
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-registry.js +141 -5
- package/scripts/universal-claim-evaluator.js +767 -0
- package/scripts/vector-store.js +154 -17
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/scripts/workflow-sentinel.js +77 -11
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- 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 };
|
package/scripts/statusline.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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].
|
|
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({
|
|
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({
|
|
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
|
+
};
|