- @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.
200 lines
6.5 KiB
TypeScript
200 lines
6.5 KiB
TypeScript
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);
|
|
});
|