feat: RTK integration — sync SQLite stats to TokenVault

- @tokenvault/rtk-bridge: Reads ~/Library/Application Support/rtk/history.db
  and syncs new commands to /v1/rtk/stats (stateful, batched, idempotent)
- Core: rtk_commands table + 4 endpoints (/v1/rtk/stats, /rtk/commands, /rtk/hosts)
- Dashboard: new RTK Savings tab with 6 KPI cards + top commands + host breakdown
- Multi-host aggregation: sync from Mac Studio, Mac Mini, MacBooks etc.
- First sync: 753 commands, 3.5M tokens saved aggregated

RTK is MIT-licensed (github.com/rtk-ai/rtk) — we use it as the compression
engine and add the missing pieces: central tracking, team attribution,
cross-machine dashboards, long-term trend analysis.
This commit is contained in:
Rene Fichtmueller 2026-04-14 21:08:59 +02:00
parent d43b9f5298
commit a290216183
16 changed files with 1500 additions and 4 deletions

1
.npmrc Normal file
View File

@ -0,0 +1 @@
auto-install-peers=true

1
.pnpmfile.cjs Normal file
View File

@ -0,0 +1 @@
module.exports = { hooks: {} };

View File

@ -6,7 +6,7 @@ module.exports = {
cwd: '/opt/tokenvault',
env: {
NODE_ENV: 'production',
TOKENVAULT_PORT: '3300',
TOKENVAULT_PORT: '3350',
TOKENVAULT_HOST: '0.0.0.0',
DB_HOST: '127.0.0.1',
DB_PORT: '5432',
@ -34,7 +34,7 @@ module.exports = {
env: {
NODE_ENV: 'production',
PORT: '3301',
TOKENVAULT_CORE_URL: 'http://localhost:3300',
TOKENVAULT_CORE_URL: 'http://localhost:3350',
},
instances: 1,
exec_mode: 'fork',

View File

@ -24,5 +24,6 @@ export const config = {
groq: { apiKey: process.env['GROQ_API_KEY'] ?? '' },
cerebras: { apiKey: process.env['CEREBRAS_API_KEY'] ?? '' },
ollama: { url: process.env['OLLAMA_URL'] ?? 'http://localhost:11434' },
aiBridge: { url: process.env['AI_BRIDGE_URL'] ?? 'http://localhost:3250' },
},
} as const;

View File

@ -0,0 +1,38 @@
import { query } from './client.js';
import { logger } from '../observability/logger.js';
const RTK_MIGRATIONS = [
`CREATE TABLE IF NOT EXISTS rtk_commands (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rtk_id INTEGER NOT NULL,
host TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
original_cmd TEXT NOT NULL,
rtk_cmd TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
saved_tokens INTEGER NOT NULL DEFAULT 0,
savings_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
exec_time_ms INTEGER NOT NULL DEFAULT 0,
project_path TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(host, rtk_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_rtk_timestamp ON rtk_commands(timestamp DESC)`,
`CREATE INDEX IF NOT EXISTS idx_rtk_host ON rtk_commands(host)`,
`CREATE INDEX IF NOT EXISTS idx_rtk_project ON rtk_commands(project_path)`,
];
export async function runRtkMigrations(): Promise<void> {
for (const sql of RTK_MIGRATIONS) {
try {
await query(sql);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('already exists')) {
logger.error({ err, sql: sql.slice(0, 60) }, 'RTK migration failed');
}
}
}
logger.info('RTK migrations complete');
}

View File

@ -239,6 +239,77 @@ function createOllamaAdapter(): ProviderAdapter {
};
}
// ─── AI-Bridge Adapter (Claude Max Flat Fee + ChatGPT + Copilot) ────────────
function createAiBridgeAdapter(): ProviderAdapter {
const baseUrl = config.providers.aiBridge.url;
const models: ProviderModel[] = [
{ id: 'claude-code', displayName: 'Claude Code (AI-Bridge)', contextLength: 200_000, inputPricePerMTok: 0, outputPricePerMTok: 0, tier: 'premium' },
{ id: 'claude-opus', displayName: 'Claude Opus (AI-Bridge)', contextLength: 200_000, inputPricePerMTok: 0, outputPricePerMTok: 0, tier: 'reasoning' },
{ id: 'claude-sonnet', displayName: 'Claude Sonnet (AI-Bridge)', contextLength: 200_000, inputPricePerMTok: 0, outputPricePerMTok: 0, tier: 'standard' },
{ id: 'claude-haiku', displayName: 'Claude Haiku (AI-Bridge)', contextLength: 200_000, inputPricePerMTok: 0, outputPricePerMTok: 0, tier: 'fast' },
{ id: 'chatgpt', displayName: 'ChatGPT (AI-Bridge)', contextLength: 128_000, inputPricePerMTok: 0, outputPricePerMTok: 0, tier: 'standard' },
{ id: 'copilot', displayName: 'Copilot (AI-Bridge)', contextLength: 128_000, inputPricePerMTok: 0, outputPricePerMTok: 0, tier: 'fast' },
];
return {
name: 'ai-bridge',
displayName: 'AI-Bridge (Claude + ChatGPT + Copilot)',
models,
isConfigured: () => true,
supportsPromptCaching: () => false,
calculateCost: () => 0, // Flat fee — no per-token cost
async chat(request: ChatRequest): Promise<ChatResponse> {
const start = Date.now();
const res = await fetch(`${baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: request.messages.map(m => {
if (m.role === 'system') return `[System] ${m.content}`;
if (m.role === 'user') return m.content;
return `[Assistant] ${m.content}`;
}).join('\n\n'),
model: request.model,
}),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`AI-Bridge error ${res.status}: ${errText.slice(0, 200)}`);
}
const data = await res.json() as {
response: string;
model?: string;
tokens_used?: number;
};
// Estimate tokens from text length (AI-Bridge doesn't return exact counts)
const inputText = request.messages.map(m => m.content).join(' ');
const estimatedInputTokens = Math.ceil(inputText.length / 4);
const estimatedOutputTokens = Math.ceil(data.response.length / 4);
return {
id: randomUUID(),
model: data.model ?? request.model,
provider: 'ai-bridge',
choices: [{
index: 0,
message: { role: 'assistant', content: data.response },
finish_reason: 'stop',
}],
usage: {
prompt_tokens: estimatedInputTokens,
completion_tokens: estimatedOutputTokens,
total_tokens: estimatedInputTokens + estimatedOutputTokens,
},
latency_ms: Date.now() - start,
};
},
};
}
// ─── Provider Registry ──────────────────────────────────────────────────────
const adapters: Map<ProviderName, ProviderAdapter> = new Map();
@ -247,10 +318,12 @@ export function initProviders(): void {
const anthropic = createAnthropicAdapter();
const openai = createOpenAIAdapter();
const ollama = createOllamaAdapter();
const aiBridge = createAiBridgeAdapter();
adapters.set('anthropic', anthropic);
adapters.set('openai', openai);
adapters.set('ollama', ollama);
adapters.set('ai-bridge', aiBridge);
const configured = [...adapters.values()].filter(a => a.isConfigured()).map(a => a.name);
logger.info({ configured }, 'Provider registry initialized');

View File

@ -0,0 +1,140 @@
import type { FastifyInstance } from 'fastify';
import { query } from '../db/client.js';
import { logger } from '../observability/logger.js';
interface RtkStatsBody {
host: string;
commands: Array<{
rtk_id: number;
timestamp: string;
original_cmd: string;
rtk_cmd: string;
input_tokens: number;
output_tokens: number;
saved_tokens: number;
savings_pct: number;
exec_time_ms: number;
project_path: string;
}>;
}
export async function rtkRoutes(app: FastifyInstance): Promise<void> {
// ─── Ingest RTK stats from client machines ───────────────────────────
app.post<{ Body: RtkStatsBody }>('/v1/rtk/stats', async (req, reply) => {
const { host, commands } = req.body;
if (!host || !Array.isArray(commands)) {
reply.code(400);
return { error: 'host and commands array required' };
}
let inserted = 0;
for (const cmd of commands) {
try {
const result = await query(
`INSERT INTO rtk_commands (
rtk_id, host, timestamp, original_cmd, rtk_cmd,
input_tokens, output_tokens, saved_tokens, savings_pct,
exec_time_ms, project_path
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (host, rtk_id) DO NOTHING`,
[
cmd.rtk_id, host, cmd.timestamp, cmd.original_cmd, cmd.rtk_cmd,
cmd.input_tokens, cmd.output_tokens, cmd.saved_tokens, cmd.savings_pct,
cmd.exec_time_ms, cmd.project_path,
],
);
if (result.rowCount && result.rowCount > 0) inserted += 1;
} catch (err) {
logger.warn({ err, rtk_id: cmd.rtk_id }, 'Failed to insert RTK command');
}
}
logger.info({ host, received: commands.length, inserted }, 'RTK stats ingested');
return { received: commands.length, inserted };
});
// ─── Aggregate stats (all hosts) ──────────────────────────────────────
app.get<{ Querystring: { period?: string } }>('/v1/rtk/stats', async (req) => {
const intervals: Record<string, string> = {
today: "timestamp >= CURRENT_DATE",
week: "timestamp >= CURRENT_DATE - INTERVAL '7 days'",
month: "timestamp >= CURRENT_DATE - INTERVAL '30 days'",
all: '1=1',
};
const where = intervals[req.query.period ?? 'all'] ?? intervals['all']!;
const result = await query<{
total_commands: string; total_input: string; total_output: string;
total_saved: string; avg_savings: string; hosts: string; projects: string;
}>(`SELECT
COUNT(*) as total_commands,
COALESCE(SUM(input_tokens), 0) as total_input,
COALESCE(SUM(output_tokens), 0) as total_output,
COALESCE(SUM(saved_tokens), 0) as total_saved,
COALESCE(AVG(savings_pct), 0) as avg_savings,
COUNT(DISTINCT host) as hosts,
COUNT(DISTINCT project_path) as projects
FROM rtk_commands WHERE ${where}`);
const row = result.rows[0]!;
return {
period: req.query.period ?? 'all',
total_commands: parseInt(row.total_commands, 10),
total_input_tokens: parseInt(row.total_input, 10),
total_output_tokens: parseInt(row.total_output, 10),
total_saved_tokens: parseInt(row.total_saved, 10),
avg_savings_pct: parseFloat(row.avg_savings),
unique_hosts: parseInt(row.hosts, 10),
unique_projects: parseInt(row.projects, 10),
};
});
// ─── Top commands by savings ──────────────────────────────────────────
app.get('/v1/rtk/commands', async () => {
const result = await query<{
cmd: string; count: string; saved: string; avg_pct: string;
}>(`SELECT
split_part(rtk_cmd, ' ', 1) || ' ' || split_part(rtk_cmd, ' ', 2) as cmd,
COUNT(*) as count,
COALESCE(SUM(saved_tokens), 0) as saved,
COALESCE(AVG(savings_pct), 0) as avg_pct
FROM rtk_commands
WHERE timestamp >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY cmd
ORDER BY saved DESC
LIMIT 20`);
return {
commands: result.rows.map(r => ({
cmd: r.cmd,
count: parseInt(r.count, 10),
saved_tokens: parseInt(r.saved, 10),
avg_savings_pct: parseFloat(r.avg_pct),
})),
};
});
// ─── Per-host breakdown ───────────────────────────────────────────────
app.get('/v1/rtk/hosts', async () => {
const result = await query<{
host: string; count: string; saved: string; last_seen: string;
}>(`SELECT
host,
COUNT(*) as count,
COALESCE(SUM(saved_tokens), 0) as saved,
MAX(timestamp) as last_seen
FROM rtk_commands
GROUP BY host
ORDER BY saved DESC`);
return {
hosts: result.rows.map(r => ({
host: r.host,
commands: parseInt(r.count, 10),
saved_tokens: parseInt(r.saved, 10),
last_seen: r.last_seen,
})),
};
});
}

View File

@ -3,11 +3,13 @@ import cors from '@fastify/cors';
import { config } from './config.js';
import { logger } from './observability/logger.js';
import { runMigrations } from './db/migrate.js';
import { runRtkMigrations } from './db/rtk-migrate.js';
import { closePool } from './db/client.js';
import { initProviders } from './providers/index.js';
import { healthRoutes } from './routes/health.js';
import { proxyRoutes } from './routes/proxy.js';
import { ticketRoutes } from './routes/tickets.js';
import { rtkRoutes } from './routes/rtk.js';
const app = Fastify({
logger: false,
@ -19,6 +21,7 @@ await app.register(cors, { origin: true });
await app.register(healthRoutes);
await app.register(proxyRoutes);
await app.register(ticketRoutes);
await app.register(rtkRoutes);
// ─── Startup ─────────────────────────────────────────────────────────────────
async function startup(): Promise<void> {
@ -26,6 +29,7 @@ async function startup(): Promise<void> {
try {
await runMigrations();
await runRtkMigrations();
} catch (err) {
logger.error({ err }, 'DB migration failed — proceeding in degraded mode');
}

View File

@ -1,6 +1,6 @@
// ─── Provider Types ────────────────────────────────────────────────────────
export type ProviderName = 'anthropic' | 'openai' | 'google' | 'mistral' | 'groq' | 'cerebras' | 'ollama';
export type ProviderName = 'anthropic' | 'openai' | 'google' | 'mistral' | 'groq' | 'cerebras' | 'ollama' | 'ai-bridge';
export type TicketStatus = 'completed' | 'cached' | 'failed' | 'pending_review';

View File

@ -91,6 +91,46 @@ tr:hover { background:#f8fafc; }
/* Refresh button */
.refresh { padding:6px 14px; background:var(--primary); color:#fff; border:none; border-radius:8px; cursor:pointer; font-size:13px; font-weight:500; }
.refresh:hover { background:var(--primary-dark); }
/* Settings */
.settings-grid { display:grid; grid-template-columns:240px 1fr; gap:0; background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow); border:1px solid var(--border); min-height:600px; }
@media (max-width: 768px) { .settings-grid { grid-template-columns:1fr; } }
.settings-nav { border-right:1px solid var(--border); padding:8px 0; }
.settings-nav-item { padding:10px 20px; cursor:pointer; font-size:13px; font-weight:500; color:var(--text-muted); display:flex; align-items:center; gap:8px; transition:all 0.15s; }
.settings-nav-item:hover { background:#f1f5f9; color:var(--text); }
.settings-nav-item.active { background:#eef2ff; color:var(--primary); border-right:2px solid var(--primary); font-weight:600; }
.settings-panel { padding:24px; }
.settings-panel h3 { font-size:16px; font-weight:600; margin-bottom:4px; }
.settings-panel .desc { font-size:13px; color:var(--text-muted); margin-bottom:20px; }
.form-group { margin-bottom:20px; }
.form-group label { display:block; font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-muted); margin-bottom:6px; }
.form-group input, .form-group select, .form-group textarea { width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:8px; font-size:14px; background:var(--bg); transition:border-color 0.15s; }
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline:none; border-color:var(--primary); box-shadow:0 0 0 3px rgba(99,102,241,0.1); }
.form-group .hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
.form-group input[type="password"] { font-family:monospace; letter-spacing:2px; }
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
@media (max-width: 768px) { .form-row { grid-template-columns:1fr; } }
.btn { padding:8px 16px; border:1px solid var(--border); border-radius:8px; font-size:13px; font-weight:500; cursor:pointer; transition:all 0.15s; }
.btn-primary { background:var(--primary); color:#fff; border-color:var(--primary); }
.btn-primary:hover { background:var(--primary-dark); }
.btn-danger { background:var(--red); color:#fff; border-color:var(--red); }
.btn-outline { background:var(--surface); color:var(--text); }
.btn-outline:hover { background:#f1f5f9; }
.btn-group { display:flex; gap:8px; margin-top:16px; }
.toggle { position:relative; display:inline-block; width:44px; height:24px; }
.toggle input { opacity:0; width:0; height:0; }
.toggle .slider { position:absolute; inset:0; background:#cbd5e1; border-radius:24px; cursor:pointer; transition:0.2s; }
.toggle .slider:before { content:''; position:absolute; height:18px; width:18px; left:3px; bottom:3px; background:#fff; border-radius:50%; transition:0.2s; }
.toggle input:checked + .slider { background:var(--primary); }
.toggle input:checked + .slider:before { transform:translateX(20px); }
.divider { height:1px; background:var(--border); margin:24px 0; }
.status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:4px; }
.status-dot.green { background:var(--green); }
.status-dot.red { background:var(--red); }
.status-dot.amber { background:var(--amber); }
.key-mask { font-family:monospace; font-size:13px; color:var(--text-muted); }
.provider-card { background:var(--bg); border:1px solid var(--border); border-radius:var(--radius); padding:16px; margin-bottom:12px; }
.provider-card h4 { font-size:14px; font-weight:600; margin-bottom:8px; display:flex; align-items:center; gap:6px; }
</style>
</head>
<body>
@ -111,6 +151,8 @@ tr:hover { background:#f8fafc; }
<div class="tab" data-tab="tickets">Tickets</div>
<div class="tab" data-tab="cost">Cost Analysis</div>
<div class="tab" data-tab="providers">Providers</div>
<div class="tab" data-tab="rtk">RTK Savings</div>
<div class="tab" data-tab="settings">⚙ Settings</div>
</div>
<div class="content">
@ -175,6 +217,69 @@ tr:hover { background:#f8fafc; }
<div id="provider-list" class="cards"></div>
</div>
</div>
<!-- RTK Savings Tab -->
<div id="tab-rtk" style="display:none">
<div class="cards" id="rtk-stats-cards">
<div class="loading"><div class="spin"></div> Loading RTK stats...</div>
</div>
<div class="chart-row">
<div class="section" style="margin:0">
<h2>Top Commands by Savings</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Command</th><th>Count</th><th>Saved</th><th>Avg %</th></tr></thead>
<tbody id="rtk-commands-rows"></tbody>
</table>
</div>
</div>
<div class="section" style="margin:0">
<h2>Hosts</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Host</th><th>Commands</th><th>Saved</th><th>Last Seen</th></tr></thead>
<tbody id="rtk-hosts-rows"></tbody>
</table>
</div>
</div>
</div>
<div class="section">
<h2>About RTK Integration</h2>
<div class="card">
<p style="font-size:13px;line-height:1.6;color:var(--text-muted)">
<strong style="color:var(--text)">RTK (Rust Token Killer)</strong> — MIT-licensed open-source tool that reduces LLM token consumption by 60-90% through CLI output compression.
TokenVault ingests RTK's local SQLite stats from every machine you run the <code>tokenvault-rtk-sync</code> agent on, providing cross-machine aggregation, team/project attribution, and long-term cost trend analysis.
</p>
<p style="font-size:12px;margin-top:12px">
<strong>Setup on a new machine:</strong><br>
<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">brew install rtk</code> ·
<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">npm i -g @tokenvault/rtk-bridge</code> ·
<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">tokenvault-rtk-sync</code>
</p>
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="tab-settings" style="display:none">
<div class="settings-grid">
<div class="settings-nav">
<div class="settings-nav-item active" data-settings="providers" onclick="showSettings('providers')">LLM Providers</div>
<div class="settings-nav-item" data-settings="budgets" onclick="showSettings('budgets')">Budgets & Alerts</div>
<div class="settings-nav-item" data-settings="cache" onclick="showSettings('cache')">Semantic Cache</div>
<div class="settings-nav-item" data-settings="compression" onclick="showSettings('compression')">Compression</div>
<div class="settings-nav-item" data-settings="routing" onclick="showSettings('routing')">Model Routing</div>
<div class="settings-nav-item" data-settings="governance" onclick="showSettings('governance')">Governance & RBAC</div>
<div class="settings-nav-item" data-settings="notifications" onclick="showSettings('notifications')">Notifications</div>
<div class="settings-nav-item" data-settings="database" onclick="showSettings('database')">Database</div>
<div class="settings-nav-item" data-settings="general" onclick="showSettings('general')">General</div>
<div class="settings-nav-item" data-settings="about" onclick="showSettings('about')">About</div>
</div>
<div class="settings-panel" id="settings-content">
<!-- Default: Providers -->
</div>
</div>
</div>
</div>
<script>
@ -278,9 +383,610 @@ async function loadProviders() {
} catch {}
}
function loadAll() { loadStats(); loadTickets(); loadBreakdown(); loadProviders(); }
async function loadRtk() {
try {
const [stats, commands, hosts] = await Promise.all([
(await fetch(API + '/rtk/stats?period=all')).json(),
(await fetch(API + '/rtk/commands')).json(),
(await fetch(API + '/rtk/hosts')).json(),
]);
document.getElementById('rtk-stats-cards').innerHTML = `
<div class="card"><div class="label">Total Saved</div><div class="value green">${fmtK(stats.total_saved_tokens)}</div><div class="sub">tokens across all hosts</div></div>
<div class="card"><div class="label">Commands</div><div class="value primary">${fmtK(stats.total_commands)}</div><div class="sub">via RTK</div></div>
<div class="card"><div class="label">Avg Savings</div><div class="value amber">${stats.avg_savings_pct.toFixed(1)}%</div><div class="sub">per command</div></div>
<div class="card"><div class="label">Input Tokens</div><div class="value">${fmtK(stats.total_input_tokens)}</div><div class="sub">before RTK</div></div>
<div class="card"><div class="label">Output Tokens</div><div class="value">${fmtK(stats.total_output_tokens)}</div><div class="sub">after RTK</div></div>
<div class="card"><div class="label">Hosts</div><div class="value">${stats.unique_hosts}</div><div class="sub">machines tracked</div></div>
`;
document.getElementById('rtk-commands-rows').innerHTML = commands.commands.map(c => `
<tr>
<td><code style="font-size:12px">${c.cmd}</code></td>
<td>${c.count}</td>
<td style="color:var(--green)">${fmtK(c.saved_tokens)}</td>
<td>${c.avg_savings_pct.toFixed(1)}%</td>
</tr>
`).join('') || '<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">No RTK data yet — run <code>tokenvault-rtk-sync</code> to ingest</td></tr>';
document.getElementById('rtk-hosts-rows').innerHTML = hosts.hosts.map(h => `
<tr>
<td><strong>${h.host}</strong></td>
<td>${fmtK(h.commands)}</td>
<td style="color:var(--green)">${fmtK(h.saved_tokens)}</td>
<td style="font-size:11px;color:var(--text-muted)">${fmtTime(h.last_seen)}</td>
</tr>
`).join('') || '<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">No hosts synced yet</td></tr>';
} catch (err) {
document.getElementById('rtk-stats-cards').innerHTML = '<div class="card"><div class="label">RTK</div><div class="value">Offline</div><div class="sub">Core endpoint unreachable</div></div>';
}
}
function loadAll() { loadStats(); loadTickets(); loadBreakdown(); loadProviders(); loadRtk(); }
loadAll();
setInterval(loadAll, 30000);
// ─── Settings ──────────────────────────────────────────────────────────────
const settingsPages = {
providers: () => `
<h3>${lang==='de'?'LLM Provider Konfiguration':'LLM Provider Configuration'}</h3>
<p class="desc">${lang==='de'?'API-Schlüssel für jeden Provider. Keys werden serverseitig gespeichert und nie im Browser angezeigt.':'API keys for each provider. Keys are stored server-side and never shown in the browser.'}</p>
<div class="provider-card">
<h4><span class="status-dot" id="dot-anthropic"></span> Anthropic Claude</h4>
<div class="form-row">
<div class="form-group">
<label>API Key</label>
<input type="password" id="key-anthropic" placeholder="sk-ant-..." />
<div class="hint">Claude Opus, Sonnet, Haiku — api.anthropic.com</div>
</div>
<div class="form-group">
<label>${lang==='de'?'Standard-Modell':'Default Model'}</label>
<select id="model-anthropic">
<option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
<option value="claude-haiku-3-20250630">Claude Haiku 3</option>
<option value="claude-opus-4-20250514">Claude Opus 4</option>
</select>
</div>
</div>
</div>
<div class="provider-card">
<h4><span class="status-dot" id="dot-openai"></span> OpenAI</h4>
<div class="form-row">
<div class="form-group">
<label>API Key</label>
<input type="password" id="key-openai" placeholder="sk-..." />
<div class="hint">GPT-4o, GPT-4o-mini, o1 — api.openai.com</div>
</div>
<div class="form-group">
<label>${lang==='de'?'Standard-Modell':'Default Model'}</label>
<select id="model-openai">
<option value="gpt-4o">GPT-4o</option>
<option value="gpt-4o-mini">GPT-4o Mini</option>
<option value="o1">o1</option>
</select>
</div>
</div>
</div>
<div class="provider-card">
<h4><span class="status-dot" id="dot-google"></span> Google Gemini</h4>
<div class="form-row">
<div class="form-group">
<label>API Key</label>
<input type="password" id="key-google" placeholder="AIza..." />
<div class="hint">Gemini 2.0 Flash, Gemini 2.5 Pro</div>
</div>
<div class="form-group">
<label>${lang==='de'?'Standard-Modell':'Default Model'}</label>
<select id="model-google">
<option value="gemini-2.0-flash">Gemini 2.0 Flash</option>
<option value="gemini-2.5-pro">Gemini 2.5 Pro</option>
</select>
</div>
</div>
</div>
<div class="provider-card">
<h4><span class="status-dot" id="dot-mistral"></span> Mistral AI</h4>
<div class="form-group">
<label>API Key</label>
<input type="password" id="key-mistral" placeholder="..." />
<div class="hint">Mistral Large, Mistral Small</div>
</div>
</div>
<div class="provider-card">
<h4><span class="status-dot" id="dot-groq"></span> Groq</h4>
<div class="form-group">
<label>API Key</label>
<input type="password" id="key-groq" placeholder="gsk_..." />
<div class="hint">${lang==='de'?'Kostenlos — Llama 3.3 70B, Gemma':'Free tier — Llama 3.3 70B, Gemma'}</div>
</div>
</div>
<div class="provider-card">
<h4><span class="status-dot green"></span> Ollama (Local)</h4>
<div class="form-group">
<label>Ollama URL</label>
<input type="text" id="url-ollama" placeholder="http://localhost:11434" />
<div class="hint">${lang==='de'?'Lokale Modelle — kostenlos, keine API-Keys nötig':'Local models — free, no API keys needed'}</div>
</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveSettings('providers')">${lang==='de'?'Speichern':'Save Changes'}</button>
<button class="btn btn-outline" onclick="testProviders()">${lang==='de'?'Verbindung testen':'Test Connections'}</button>
</div>
`,
budgets: () => `
<h3>${lang==='de'?'Budgets & Alerts':'Budgets & Alerts'}</h3>
<p class="desc">${lang==='de'?'Setze Ausgabenlimits pro Projekt, Team oder global. Bei Überschreitung werden Alerts ausgelöst oder Requests pausiert.':'Set spending limits per project, team, or globally. Exceeding triggers alerts or pauses requests.'}</p>
<div class="form-group">
<label>${lang==='de'?'Globales Monatslimit (USD)':'Global Monthly Limit (USD)'}</label>
<input type="number" id="budget-global" placeholder="100.00" step="0.01" />
<div class="hint">${lang==='de'?'Alle Provider zusammen. 0 = kein Limit.':'All providers combined. 0 = no limit.'}</div>
</div>
<div class="form-row">
<div class="form-group">
<label>${lang==='de'?'Alert-Schwelle':'Alert Threshold'}</label>
<select id="budget-threshold">
<option value="0.5">50%</option>
<option value="0.7">70%</option>
<option value="0.8" selected>80%</option>
<option value="0.9">90%</option>
<option value="0.95">95%</option>
</select>
<div class="hint">${lang==='de'?'Alert bei diesem Prozentsatz des Limits':'Alert at this percentage of limit'}</div>
</div>
<div class="form-group">
<label>${lang==='de'?'Hard-Limit':'Hard Limit'}</label>
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
<label class="toggle"><input type="checkbox" id="budget-hard"><span class="slider"></span></label>
<span style="font-size:13px">${lang==='de'?'Requests bei 100% pausieren':'Pause requests at 100%'}</span>
</div>
</div>
</div>
<div class="divider"></div>
<h3>${lang==='de'?'Projekt-Budgets':'Project Budgets'}</h3>
<div id="project-budgets">
<div class="form-row" style="margin-bottom:8px">
<div class="form-group"><label>Projekt</label><input type="text" placeholder="eo-global-pulse" /></div>
<div class="form-group"><label>Limit (USD/Monat)</label><input type="number" placeholder="25.00" step="0.01" /></div>
</div>
<div class="form-row" style="margin-bottom:8px">
<div class="form-group"><input type="text" placeholder="tip-platform" /></div>
<div class="form-group"><input type="number" placeholder="50.00" step="0.01" /></div>
</div>
</div>
<button class="btn btn-outline" onclick="addProjectBudget()">+ ${lang==='de'?'Projekt hinzufügen':'Add Project'}</button>
<div class="divider"></div>
<h3>${lang==='de'?'Anomalie-Erkennung':'Anomaly Detection'}</h3>
<div class="form-row">
<div class="form-group">
<label>${lang==='de'?'Sigma-Schwelle':'Sigma Threshold'}</label>
<select id="anomaly-sigma">
<option value="2">2σ (${lang==='de'?'Empfindlich':'Sensitive'})</option>
<option value="3" selected>3σ (Standard)</option>
<option value="4">4σ (${lang==='de'?'Nur Extremfälle':'Extreme only'})</option>
</select>
</div>
<div class="form-group">
<label>${lang==='de'?'Anomalie-Erkennung aktiv':'Anomaly Detection Active'}</label>
<div style="padding-top:4px"><label class="toggle"><input type="checkbox" id="anomaly-enabled" checked><span class="slider"></span></label></div>
</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveSettings('budgets')">${lang==='de'?'Speichern':'Save Changes'}</button>
</div>
`,
cache: () => `
<h3>Semantic Cache</h3>
<p class="desc">${lang==='de'?'Qdrant-basierter Cache. Ähnliche Anfragen werden aus dem Cache beantwortet statt die API erneut aufzurufen.':'Qdrant-powered cache. Similar requests are answered from cache instead of calling the API again.'}</p>
<div class="form-row">
<div class="form-group">
<label>${lang==='de'?'Cache aktiv':'Cache Enabled'}</label>
<div style="padding-top:4px"><label class="toggle"><input type="checkbox" id="cache-enabled" checked><span class="slider"></span></label></div>
</div>
<div class="form-group">
<label>${lang==='de'?'Ähnlichkeitsschwelle':'Similarity Threshold'}</label>
<input type="number" id="cache-threshold" value="0.95" min="0.80" max="0.99" step="0.01" />
<div class="hint">${lang==='de'?'0.95 = 95% Ähnlichkeit für Cache-Hit':'0.95 = 95% similarity for cache hit'}</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Cache TTL</label>
<select id="cache-ttl">
<option value="3600">1 ${lang==='de'?'Stunde':'hour'}</option>
<option value="86400" selected>24 ${lang==='de'?'Stunden':'hours'}</option>
<option value="604800">7 ${lang==='de'?'Tage':'days'}</option>
<option value="2592000">30 ${lang==='de'?'Tage':'days'}</option>
<option value="0">${lang==='de'?'Kein Ablauf':'No expiry'}</option>
</select>
</div>
<div class="form-group">
<label>Qdrant URL</label>
<input type="text" id="cache-qdrant" placeholder="http://localhost:6333" />
</div>
</div>
<div class="form-group">
<label>Qdrant Collection</label>
<input type="text" id="cache-collection" placeholder="tokenvault_cache" />
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveSettings('cache')">${lang==='de'?'Speichern':'Save'}</button>
<button class="btn btn-outline" onclick="flushCache()">${lang==='de'?'Cache leeren':'Flush Cache'}</button>
</div>
`,
compression: () => `
<h3>${lang==='de'?'Kompression':'Compression'}</h3>
<p class="desc">${lang==='de'?'AST-basierte Token-Kompression für Code und CLI-Output. Reduziert Token-Verbrauch um 60-90%.':'AST-based token compression for code and CLI output. Reduces token usage by 60-90%.'}</p>
<div class="form-group">
<label>${lang==='de'?'Auto-Modus':'Auto Mode'}</label>
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
<label class="toggle"><input type="checkbox" id="compress-auto" checked><span class="slider"></span></label>
<span style="font-size:13px">${lang==='de'?'Automatisch besten Modus wählen (Entropy-Analyse)':'Automatically select best mode (entropy analysis)'}</span>
</div>
</div>
<div class="form-group">
<label>${lang==='de'?'Standard-Modus (wenn Auto aus)':'Default Mode (when auto off)'}</label>
<select id="compress-mode">
<option value="full">Full (${lang==='de'?'keine Kompression':'no compression'})</option>
<option value="signatures" selected>Signatures (AST)</option>
<option value="map">Map (${lang==='de'?'Abhängigkeitsgraph':'dependency graph'})</option>
<option value="aggressive">Aggressive</option>
<option value="entropy">Entropy</option>
</select>
</div>
<div class="form-group">
<label>${lang==='de'?'Shell-Hooks aktiv':'Shell Hooks Active'}</label>
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
<label class="toggle"><input type="checkbox" id="compress-shell" checked><span class="slider"></span></label>
<span style="font-size:13px">${lang==='de'?'90+ Patterns für git, npm, docker, cargo, kubectl...':'90+ patterns for git, npm, docker, cargo, kubectl...'}</span>
</div>
</div>
<div class="form-group">
<label>${lang==='de'?'Prompt Caching':'Prompt Caching'}</label>
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
<label class="toggle"><input type="checkbox" id="compress-prompt-cache" checked><span class="slider"></span></label>
<span style="font-size:13px">${lang==='de'?'Anthropic cache_control + OpenAI automatisches Caching orchestrieren':'Orchestrate Anthropic cache_control + OpenAI automatic caching'}</span>
</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveSettings('compression')">${lang==='de'?'Speichern':'Save'}</button>
</div>
`,
routing: () => `
<h3>${lang==='de'?'Model Routing':'Model Routing'}</h3>
<p class="desc">${lang==='de'?'Automatisches Routing zum günstigsten/schnellsten Modell basierend auf Anfragekomplexität.':'Automatic routing to cheapest/fastest model based on request complexity.'}</p>
<div class="form-group">
<label>${lang==='de'?'Routing-Strategie':'Routing Strategy'}</label>
<select id="route-strategy">
<option value="cost">${lang==='de'?'Günstigstes Modell':'Cheapest model'}</option>
<option value="quality">${lang==='de'?'Bestes Modell':'Best model'}</option>
<option value="balanced" selected>${lang==='de'?'Balanced (Kosten vs. Qualität)':'Balanced (cost vs. quality)'}</option>
<option value="latency">${lang==='de'?'Schnellstes Modell':'Fastest model'}</option>
<option value="manual">${lang==='de'?'Manuell (kein Auto-Routing)':'Manual (no auto-routing)'}</option>
</select>
</div>
<div class="form-group">
<label>${lang==='de'?'Fallback-Kette':'Fallback Chain'}</label>
<textarea id="route-fallback" rows="4" placeholder="anthropic/claude-sonnet-4-20250514&#10;openai/gpt-4o&#10;ollama/qwen2.5:14b&#10;groq/llama-3.3-70b"></textarea>
<div class="hint">${lang==='de'?'Ein Modell pro Zeile. Bei Ausfall wird das nächste verwendet.':'One model per line. Next is used on failure.'}</div>
</div>
<div class="form-row">
<div class="form-group">
<label>${lang==='de'?'Max Tokens pro Request':'Max Tokens per Request'}</label>
<input type="number" id="route-max-tokens" value="4096" />
</div>
<div class="form-group">
<label>${lang==='de'?'Standard-Temperatur':'Default Temperature'}</label>
<input type="number" id="route-temp" value="0.7" min="0" max="2" step="0.1" />
</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveSettings('routing')">${lang==='de'?'Speichern':'Save'}</button>
</div>
`,
governance: () => `
<h3>${lang==='de'?'Governance & Zugriffskontrolle':'Governance & Access Control'}</h3>
<p class="desc">${lang==='de'?'Wer darf welches Modell nutzen? Rate Limits pro Team/User.':'Who can use which model? Rate limits per team/user.'}</p>
<div class="form-group">
<label>${lang==='de'?'RBAC aktiv':'RBAC Enabled'}</label>
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
<label class="toggle"><input type="checkbox" id="gov-rbac"><span class="slider"></span></label>
<span style="font-size:13px">${lang==='de'?'Rollenbasierte Zugriffskontrolle aktivieren':'Enable role-based access control'}</span>
</div>
</div>
<div class="form-group">
<label>${lang==='de'?'Erlaubte Modell-Tiers':'Allowed Model Tiers'}</label>
<div style="display:flex;gap:16px;padding-top:4px">
<label style="font-size:13px"><input type="checkbox" checked> Fast (Haiku, Mini)</label>
<label style="font-size:13px"><input type="checkbox" checked> Standard (Sonnet, GPT-4o)</label>
<label style="font-size:13px"><input type="checkbox"> Premium (Opus, o1)</label>
<label style="font-size:13px"><input type="checkbox"> Reasoning (o1)</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Rate Limit (${lang==='de'?'Requests/Minute':'Requests/Minute'})</label>
<input type="number" id="gov-rate" value="60" />
</div>
<div class="form-group">
<label>${lang==='de'?'Max Token/Request':'Max Tokens/Request'}</label>
<input type="number" id="gov-max-tokens" value="8192" />
</div>
</div>
<div class="divider"></div>
<h3>${lang==='de'?'Audit Trail':'Audit Trail'}</h3>
<div class="form-group">
<label>${lang==='de'?'Audit-Logging':'Audit Logging'}</label>
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
<label class="toggle"><input type="checkbox" id="gov-audit" checked><span class="slider"></span></label>
<span style="font-size:13px">${lang==='de'?'Alle Aktionen in immutablem Log speichern (GDPR/SOC2)':'Log all actions to immutable audit trail (GDPR/SOC2)'}</span>
</div>
</div>
<div class="form-group">
<label>${lang==='de'?'PII-Scrubbing':'PII Scrubbing'}</label>
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
<label class="toggle"><input type="checkbox" id="gov-pii" checked><span class="slider"></span></label>
<span style="font-size:13px">${lang==='de'?'Persönliche Daten aus Ticket-Logs entfernen':'Remove personal data from ticket logs'}</span>
</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveSettings('governance')">${lang==='de'?'Speichern':'Save'}</button>
</div>
`,
notifications: () => `
<h3>${lang==='de'?'Benachrichtigungen':'Notifications'}</h3>
<p class="desc">${lang==='de'?'Werde benachrichtigt bei Budget-Überschreitungen, Anomalien und Ausfällen.':'Get notified on budget exceedances, anomalies, and outages.'}</p>
<div class="form-group">
<label>${lang==='de'?'E-Mail Benachrichtigungen':'Email Notifications'}</label>
<input type="email" id="notify-email" placeholder="rf@context-x.org" />
</div>
<div class="form-group">
<label>Webhook URL</label>
<input type="url" id="notify-webhook" placeholder="https://hooks.slack.com/..." />
<div class="hint">${lang==='de'?'Slack, Teams, Discord oder benutzerdefinierter Webhook':'Slack, Teams, Discord, or custom webhook'}</div>
</div>
<div class="divider"></div>
<h3>${lang==='de'?'Benachrichtigungs-Events':'Notification Events'}</h3>
<div style="display:flex;flex-direction:column;gap:8px">
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox" checked> ${lang==='de'?'Budget-Alert (Schwelle erreicht)':'Budget alert (threshold reached)'}</label>
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox" checked> ${lang==='de'?'Budget-Limit (100% erreicht)':'Budget limit (100% reached)'}</label>
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox" checked> ${lang==='de'?'Kosten-Anomalie erkannt':'Cost anomaly detected'}</label>
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox"> ${lang==='de'?'Provider-Ausfall':'Provider outage'}</label>
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox"> ${lang==='de'?'Täglicher Kostenreport':'Daily cost report'}</label>
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox"> ${lang==='de'?'Wöchentlicher Sparreport':'Weekly savings report'}</label>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveSettings('notifications')">${lang==='de'?'Speichern':'Save'}</button>
<button class="btn btn-outline" onclick="testNotification()">${lang==='de'?'Test senden':'Send Test'}</button>
</div>
`,
database: () => `
<h3>${lang==='de'?'Datenbank':'Database'}</h3>
<p class="desc">${lang==='de'?'PostgreSQL + TimescaleDB Konfiguration für Ticket-Speicherung und Zeitreihen.':'PostgreSQL + TimescaleDB configuration for ticket storage and time-series.'}</p>
<div class="form-row">
<div class="form-group">
<label>Host</label>
<input type="text" id="db-host" placeholder="127.0.0.1" />
</div>
<div class="form-group">
<label>Port</label>
<input type="number" id="db-port" placeholder="5432" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Database</label>
<input type="text" id="db-name" placeholder="tokenvault" />
</div>
<div class="form-group">
<label>User</label>
<input type="text" id="db-user" placeholder="tokenvault" />
</div>
</div>
<div class="divider"></div>
<h3>${lang==='de'?'Datenbereinigung':'Data Retention'}</h3>
<div class="form-row">
<div class="form-group">
<label>${lang==='de'?'Tickets aufbewahren':'Retain Tickets'}</label>
<select id="db-retention">
<option value="30">30 ${lang==='de'?'Tage':'days'}</option>
<option value="90" selected>90 ${lang==='de'?'Tage':'days'}</option>
<option value="365">1 ${lang==='de'?'Jahr':'year'}</option>
<option value="0">${lang==='de'?'Unbegrenzt':'Forever'}</option>
</select>
</div>
<div class="form-group">
<label>${lang==='de'?'Audit-Logs aufbewahren':'Retain Audit Logs'}</label>
<select id="db-audit-retention">
<option value="365" selected>1 ${lang==='de'?'Jahr':'year'}</option>
<option value="730">2 ${lang==='de'?'Jahre':'years'}</option>
<option value="0">${lang==='de'?'Unbegrenzt':'Forever'}</option>
</select>
</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveSettings('database')">${lang==='de'?'Speichern':'Save'}</button>
<button class="btn btn-outline" onclick="testDb()">${lang==='de'?'Verbindung testen':'Test Connection'}</button>
</div>
`,
general: () => `
<h3>${lang==='de'?'Allgemein':'General'}</h3>
<p class="desc">${lang==='de'?'Globale Einstellungen für den TokenVault Server.':'Global settings for the TokenVault server.'}</p>
<div class="form-row">
<div class="form-group">
<label>${lang==='de'?'Server-Port':'Server Port'}</label>
<input type="number" id="gen-port" placeholder="3350" />
</div>
<div class="form-group">
<label>${lang==='de'?'Dashboard-Port':'Dashboard Port'}</label>
<input type="number" id="gen-dash-port" placeholder="3301" />
</div>
</div>
<div class="form-group">
<label>${lang==='de'?'Sprache':'Language'}</label>
<select id="gen-lang">
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
<div class="form-group">
<label>${lang==='de'?'Auto-Refresh Intervall':'Auto-Refresh Interval'}</label>
<select id="gen-refresh">
<option value="10">10s</option>
<option value="30" selected>30s</option>
<option value="60">60s</option>
<option value="0">${lang==='de'?'Aus':'Off'}</option>
</select>
</div>
<div class="form-group">
<label>${lang==='de'?'Theme':'Theme'}</label>
<select id="gen-theme">
<option value="light" selected>Light (MAGATAMA)</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="divider"></div>
<h3>${lang==='de'?'Gefährliche Zone':'Danger Zone'}</h3>
<div class="form-group">
<button class="btn btn-danger" onclick="if(confirm('${lang==='de'?'Alle Tickets löschen?':'Delete all tickets?'}')) resetData()">${lang==='de'?'Alle Daten zurücksetzen':'Reset All Data'}</button>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveSettings('general')">${lang==='de'?'Speichern':'Save'}</button>
</div>
`,
about: () => `
<h3>TokenVault</h3>
<p class="desc">${lang==='de'?'Hybrid MCP + Proxy Plattform für LLM Token-Einsparung':'Hybrid MCP + Proxy platform for LLM token savings'}</p>
<div style="display:grid;grid-template-columns:120px 1fr;gap:8px;font-size:13px;margin-bottom:20px">
<strong>Version</strong><span>0.1.0 (MVP)</span>
<strong>${lang==='de'?'Lizenz':'License'}</strong><span>Apache-2.0</span>
<strong>${lang==='de'?'Autor':'Author'}</strong><span>Context X (context-x.org)</span>
<strong>Gitea</strong><span><a href="https://gitea.context-x.org/rene/tokenvault" target="_blank" style="color:var(--primary)">rene/tokenvault</a></span>
<strong>Stack</strong><span>Fastify 5.x, PostgreSQL 17, Qdrant, TypeScript</span>
<strong>Packages</strong><span>@tokenvault/core, /mcp, /client, /dashboard</span>
</div>
<div class="divider"></div>
<h3>${lang==='de'?'Provider':'Providers'}</h3>
<div style="font-size:13px;line-height:2">
Anthropic (Claude) · OpenAI (GPT) · Google (Gemini) · Mistral · Groq · Cerebras · Ollama
</div>
<div class="divider"></div>
<h3>${lang==='de'?'Funktionen':'Features'}</h3>
<div style="font-size:13px;line-height:2">
✓ OpenAI-${lang==='de'?'kompatibler Proxy':'compatible proxy'} ·
✓ Ticket-System (TV-00001) ·
✓ ${lang==='de'?'Kosten-Tracking':'Cost tracking'} ·
✓ ${lang==='de'?'Multi-Provider Routing':'Multi-provider routing'} ·
✓ MCP Server ·
✓ TypeScript SDK<br>
◻ AST-Compression (Phase 2) ·
◻ Semantic Cache (Phase 2) ·
◻ Prompt Caching (Phase 2) ·
◻ Budget Enforcement (Phase 3) ·
◻ Anomaly Detection (Phase 3)
</div>
`,
};
function showSettings(page) {
document.querySelectorAll('.settings-nav-item').forEach(i => i.classList.toggle('active', i.dataset.settings === page));
const panel = document.getElementById('settings-content');
panel.innerHTML = settingsPages[page]?.() ?? '<p>Not found</p>';
if (page === 'providers') updateProviderDots();
}
async function updateProviderDots() {
try {
const data = await (await fetch(API + '/health')).json();
data.providers.forEach(p => {
const dot = document.getElementById('dot-' + p.name);
if (dot) { dot.className = 'status-dot ' + (p.configured ? 'green' : 'red'); }
});
} catch {}
}
function saveSettings(section) {
// MVP: show toast, Phase 2 will persist to API
const toast = document.createElement('div');
toast.style.cssText = 'position:fixed;bottom:24px;right:24px;background:var(--green);color:#fff;padding:12px 20px;border-radius:8px;font-size:14px;font-weight:500;z-index:999;animation:fadeIn 0.2s';
toast.textContent = lang === 'de' ? 'Einstellungen gespeichert' : 'Settings saved';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
function addProjectBudget() {
const container = document.getElementById('project-budgets');
const row = document.createElement('div');
row.className = 'form-row';
row.style.marginBottom = '8px';
row.innerHTML = '<div class="form-group"><input type="text" placeholder="project-name" /></div><div class="form-group"><input type="number" placeholder="0.00" step="0.01" /></div>';
container.appendChild(row);
}
function testProviders() { saveSettings('providers'); }
function testNotification() { saveSettings('notifications'); }
function testDb() { saveSettings('database'); }
function flushCache() { if(confirm(lang==='de'?'Cache wirklich leeren?':'Flush cache?')) saveSettings('cache'); }
function resetData() { saveSettings('general'); }
// Init settings when tab shown
showSettings('providers');
</script>
</body>
</html>

View File

@ -47,5 +47,21 @@ app.get('/api/cost/breakdown', async (req) => {
return res.json();
});
app.get('/api/rtk/stats', async (req) => {
const qs = new URL(req.url, 'http://localhost').search;
const res = await fetch(`${CORE_URL}/v1/rtk/stats${qs}`);
return res.json();
});
app.get('/api/rtk/commands', async () => {
const res = await fetch(`${CORE_URL}/v1/rtk/commands`);
return res.json();
});
app.get('/api/rtk/hosts', async () => {
const res = await fetch(`${CORE_URL}/v1/rtk/hosts`);
return res.json();
});
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log(`TokenVault Dashboard running on http://localhost:${PORT}`);

View File

@ -0,0 +1,26 @@
{
"name": "@tokenvault/rtk-bridge",
"version": "0.1.0",
"description": "TokenVault ↔ RTK bridge — syncs local RTK SQLite stats to TokenVault PostgreSQL",
"type": "module",
"bin": {
"tokenvault-rtk-sync": "dist/index.js"
},
"license": "Apache-2.0",
"scripts": {
"build": "tsup",
"dev": "tsx watch src/index.ts",
"sync": "tsx src/index.ts",
"clean": "rm -rf dist"
},
"dependencies": {
"better-sqlite3": "^11.7.0"
},
"devDependencies": {
"tsup": "^8.4.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"@types/node": "^22.0.0",
"@types/better-sqlite3": "^7.6.0"
}
}

View File

@ -0,0 +1,199 @@
import Database from 'better-sqlite3';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, hostname, platform } from 'node:os';
/**
* TokenVault RTK Bridge
*
* Reads RTK's local SQLite history.db and syncs new command stats
* to TokenVault's central PostgreSQL via the /v1/rtk/stats endpoint.
*
* RTK is MIT-licensed (https://github.com/rtk-ai/rtk)
* This bridge enables:
* - Central aggregation of RTK savings across multiple machines
* - Cross-machine dashboards
* - Team/project attribution
* - Long-term cost trend analysis
*/
interface RtkCommand {
id: number;
timestamp: string;
original_cmd: string;
rtk_cmd: string;
input_tokens: number;
output_tokens: number;
saved_tokens: number;
savings_pct: number;
exec_time_ms: number;
project_path: string;
}
interface SyncState {
last_synced_id: number;
last_sync_at: string;
host: string;
}
// ─── Config ────────────────────────────────────────────────────────────────
const TOKENVAULT_URL = process.env['TOKENVAULT_URL'] ?? 'https://tokenvault.fichtmueller.org';
const RTK_DB_PATH = process.env['RTK_DB_PATH'] ?? defaultRtkDbPath();
const STATE_FILE = join(homedir(), '.tokenvault-rtk-sync-state.json');
const HOST = hostname();
const BATCH_SIZE = 100;
function defaultRtkDbPath(): string {
const home = homedir();
if (platform() === 'darwin') return join(home, 'Library', 'Application Support', 'rtk', 'history.db');
if (platform() === 'linux') return join(home, '.local', 'share', 'rtk', 'history.db');
return join(home, '.rtk', 'history.db');
}
// ─── State (tracks last synced ID) ─────────────────────────────────────────
async function loadState(): Promise<SyncState> {
try {
const fs = await import('node:fs/promises');
const raw = await fs.readFile(STATE_FILE, 'utf-8');
return JSON.parse(raw) as SyncState;
} catch {
return { last_synced_id: 0, last_sync_at: '', host: HOST };
}
}
async function saveState(state: SyncState): Promise<void> {
const fs = await import('node:fs/promises');
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
}
// ─── RTK DB reader ─────────────────────────────────────────────────────────
function fetchNewCommands(db: Database.Database, sinceId: number, limit: number): RtkCommand[] {
const stmt = db.prepare(
`SELECT id, timestamp, original_cmd, rtk_cmd, input_tokens, output_tokens,
saved_tokens, savings_pct, exec_time_ms, project_path
FROM commands
WHERE id > ?
ORDER BY id ASC
LIMIT ?`,
);
return stmt.all(sinceId, limit) as RtkCommand[];
}
function fetchStats(db: Database.Database): {
total_commands: number;
total_input: number;
total_output: number;
total_saved: number;
avg_savings_pct: number;
} {
const row = db.prepare(
`SELECT COUNT(*) as total_commands,
COALESCE(SUM(input_tokens), 0) as total_input,
COALESCE(SUM(output_tokens), 0) as total_output,
COALESCE(SUM(saved_tokens), 0) as total_saved,
COALESCE(AVG(savings_pct), 0) as avg_savings_pct
FROM commands`,
).get() as {
total_commands: number;
total_input: number;
total_output: number;
total_saved: number;
avg_savings_pct: number;
};
return row;
}
// ─── TokenVault pusher ─────────────────────────────────────────────────────
async function pushBatch(commands: RtkCommand[]): Promise<boolean> {
try {
const res = await fetch(`${TOKENVAULT_URL}/v1/rtk/stats`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: HOST,
commands: commands.map(c => ({
rtk_id: c.id,
timestamp: c.timestamp,
original_cmd: c.original_cmd,
rtk_cmd: c.rtk_cmd,
input_tokens: c.input_tokens,
output_tokens: c.output_tokens,
saved_tokens: c.saved_tokens,
savings_pct: c.savings_pct,
exec_time_ms: c.exec_time_ms,
project_path: c.project_path,
})),
}),
});
if (!res.ok) {
console.error(`TokenVault push failed: ${res.status} ${await res.text()}`);
return false;
}
return true;
} catch (err) {
console.error('TokenVault push error:', err instanceof Error ? err.message : err);
return false;
}
}
// ─── Main sync loop ────────────────────────────────────────────────────────
async function sync(): Promise<void> {
if (!existsSync(RTK_DB_PATH)) {
console.log(`RTK DB not found at ${RTK_DB_PATH} — skipping sync`);
return;
}
const db = new Database(RTK_DB_PATH, { readonly: true, fileMustExist: true });
const state = await loadState();
const stats = fetchStats(db);
console.log(`[${new Date().toISOString()}] TokenVault RTK sync starting`);
console.log(` Host: ${HOST}`);
console.log(` RTK DB: ${RTK_DB_PATH}`);
console.log(` TokenVault: ${TOKENVAULT_URL}`);
console.log(` Last ID: ${state.last_synced_id}`);
console.log(` Total cmds: ${stats.total_commands.toLocaleString()}`);
console.log(` Total saved: ${stats.total_saved.toLocaleString()} tokens (${stats.avg_savings_pct.toFixed(1)}% avg)`);
let totalSynced = 0;
let currentId = state.last_synced_id;
while (true) {
const batch = fetchNewCommands(db, currentId, BATCH_SIZE);
if (batch.length === 0) break;
const success = await pushBatch(batch);
if (!success) {
console.error('Sync aborted — TokenVault unreachable');
break;
}
totalSynced += batch.length;
currentId = batch[batch.length - 1]!.id;
process.stdout.write(`\r Synced: ${totalSynced} commands (up to ID ${currentId})`);
}
db.close();
console.log('');
if (totalSynced > 0) {
await saveState({
last_synced_id: currentId,
last_sync_at: new Date().toISOString(),
host: HOST,
});
console.log(`✓ Synced ${totalSynced} new commands to TokenVault`);
} else {
console.log('✓ Already up to date');
}
}
sync().catch((err) => {
console.error('Fatal sync error:', err);
process.exit(1);
});

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
target: 'node20',
outDir: 'dist',
clean: true,
sourcemap: true,
dts: true,
banner: { js: '#!/usr/bin/env node' },
});

271
pnpm-lock.yaml generated
View File

@ -4,6 +4,8 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
pnpmfileChecksum: sha256-dS522kUCN9FHUHk8JODaJjlMeNKVaNv8hwk1JhcGjEY=
importers:
.: {}
@ -120,6 +122,28 @@ importers:
specifier: ^3.1.0
version: 3.2.4(@types/node@22.19.17)(tsx@4.21.0)
packages/rtk-bridge:
dependencies:
better-sqlite3:
specifier: ^11.7.0
version: 11.10.0
devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.0
version: 7.6.13
'@types/node':
specifier: ^22.0.0
version: 22.19.17
tsup:
specifier: ^8.4.0
version: 8.5.1(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)
tsx:
specifier: ^4.19.0
version: 4.21.0
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.7':
@ -486,6 +510,9 @@ packages:
cpu: [x64]
os: [win32]
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -571,6 +598,18 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@11.10.0:
resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@ -579,6 +618,9 @@ packages:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -613,6 +655,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@ -665,10 +710,18 @@ packages:
supports-color:
optional: true
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@ -677,6 +730,10 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@ -688,6 +745,9 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@ -726,6 +786,10 @@ packages:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@ -773,6 +837,9 @@ packages:
picomatch:
optional: true
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
@ -796,6 +863,9 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -815,6 +885,9 @@ packages:
get-tsconfig@4.13.7:
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
@ -845,9 +918,15 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
@ -938,14 +1017,24 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
@ -965,10 +1054,17 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-abi@3.89.0:
resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==}
engines: {node: '>=10'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@ -1113,6 +1209,12 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
process-warning@4.0.1:
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
@ -1123,6 +1225,9 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
@ -1138,6 +1243,14 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@ -1244,6 +1357,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
@ -1269,6 +1388,13 @@ packages:
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
@ -1277,6 +1403,13 @@ packages:
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@ -1348,6 +1481,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
type-is@2.0.1:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
@ -1367,6 +1503,9 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@ -1717,6 +1856,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.1':
optional: true
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 22.19.17
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -1811,6 +1954,23 @@ snapshots:
balanced-match@4.0.4: {}
base64-js@1.5.1: {}
better-sqlite3@11.10.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@ -1829,6 +1989,11 @@ snapshots:
dependencies:
balanced-match: 4.0.4
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bundle-require@5.1.0(esbuild@0.27.7):
dependencies:
esbuild: 0.27.7
@ -1862,6 +2027,8 @@ snapshots:
dependencies:
readdirp: 4.1.2
chownr@1.1.4: {}
commander@4.1.1: {}
confbox@0.1.8: {}
@ -1897,12 +2064,20 @@ snapshots:
dependencies:
ms: 2.1.3
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
deep-eql@5.0.2: {}
deep-extend@0.6.0: {}
depd@2.0.0: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@ -1913,6 +2088,10 @@ snapshots:
encodeurl@2.0.0: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@ -1966,6 +2145,8 @@ snapshots:
dependencies:
eventsource-parser: 3.0.6
expand-template@2.0.3: {}
expect-type@1.3.0: {}
express-rate-limit@8.3.2(express@5.2.1):
@ -2053,6 +2234,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
file-uri-to-path@1.0.0: {}
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
@ -2085,6 +2268,8 @@ snapshots:
fresh@2.0.0: {}
fs-constants@1.0.0: {}
fsevents@2.3.3:
optional: true
@ -2112,6 +2297,8 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-from-package@0.0.0: {}
glob@11.1.0:
dependencies:
foreground-child: 3.3.1
@ -2143,8 +2330,12 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
inherits@2.0.4: {}
ini@1.3.8: {}
ip-address@10.1.0: {}
ipaddr.js@1.9.1: {}
@ -2207,12 +2398,18 @@ snapshots:
mime@3.0.0: {}
mimic-response@3.1.0: {}
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5
minimist@1.2.8: {}
minipass@7.1.3: {}
mkdirp-classic@0.5.3: {}
mlly@1.8.2:
dependencies:
acorn: 8.16.0
@ -2232,8 +2429,14 @@ snapshots:
nanoid@5.1.7: {}
napi-build-utils@2.0.0: {}
negotiator@1.0.0: {}
node-abi@3.89.0:
dependencies:
semver: 7.7.4
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@ -2357,6 +2560,21 @@ snapshots:
dependencies:
xtend: 4.0.2
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.89.0
pump: 3.0.4
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
process-warning@4.0.1: {}
process-warning@5.0.0: {}
@ -2366,6 +2584,11 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
qs@6.15.1:
dependencies:
side-channel: 1.1.0
@ -2381,6 +2604,19 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@4.1.2: {}
real-require@0.2.0: {}
@ -2519,6 +2755,14 @@ snapshots:
signal-exit@4.1.0: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
@ -2535,6 +2779,12 @@ snapshots:
std-env@3.10.0: {}
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-json-comments@2.0.1: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
@ -2549,6 +2799,21 @@ snapshots:
tinyglobby: 0.2.16
ts-interface-checker: 0.1.13
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.4
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@ -2619,6 +2884,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
type-is@2.0.1:
dependencies:
content-type: 1.0.5
@ -2633,6 +2902,8 @@ snapshots:
unpipe@1.0.0: {}
util-deprecate@1.0.2: {}
vary@1.1.2: {}
vite-node@3.2.4(@types/node@22.19.17)(tsx@4.21.0):