From e71f374d7f4172d428a977661b0d0ca61d4f7fca Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Wed, 27 May 2026 00:25:27 +0530 Subject: [PATCH 1/2] add provider coverage parity --- README.md | 169 ++++++- packages/cli/src/cli.test.ts | 35 ++ packages/cli/src/cli.ts | 66 ++- packages/mcp/src/server.ts | 24 + packages/registry/src/index.ts | 12 + .../registry/src/providers/antigravity.ts | 104 ++++ packages/registry/src/providers/codebuff.ts | 139 ++++++ packages/registry/src/providers/crush.ts | 126 +++++ packages/registry/src/providers/droid.ts | 119 +++++ packages/registry/src/providers/goose.ts | 119 +++++ packages/registry/src/providers/hermes.ts | 59 ++- packages/registry/src/providers/index.ts | 12 + packages/registry/src/providers/kilo.ts | 95 ++++ packages/registry/src/providers/kimi.ts | 118 +++++ packages/registry/src/providers/kiro.ts | 139 ++++++ packages/registry/src/providers/mux.ts | 114 +++++ .../src/providers/provider-parity.test.ts | 443 ++++++++++++++++++ packages/registry/src/providers/synthetic.ts | 109 +++++ packages/registry/src/providers/trae.ts | 94 ++++ packages/registry/src/providers/zed.ts | 141 ++++++ .../src/live/wrapped-live-template.ts | 12 + .../renderers/src/svg/wrapped-single-page.ts | 12 + packages/tui/src/lib/data.ts | 24 + packages/tui/src/lib/theme.ts | 12 + 24 files changed, 2273 insertions(+), 24 deletions(-) create mode 100644 packages/registry/src/providers/antigravity.ts create mode 100644 packages/registry/src/providers/codebuff.ts create mode 100644 packages/registry/src/providers/crush.ts create mode 100644 packages/registry/src/providers/droid.ts create mode 100644 packages/registry/src/providers/goose.ts create mode 100644 packages/registry/src/providers/kilo.ts create mode 100644 packages/registry/src/providers/kimi.ts create mode 100644 packages/registry/src/providers/kiro.ts create mode 100644 packages/registry/src/providers/mux.ts create mode 100644 packages/registry/src/providers/synthetic.ts create mode 100644 packages/registry/src/providers/trae.ts create mode 100644 packages/registry/src/providers/zed.ts diff --git a/README.md b/README.md index d415f39..b3ff1ac 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Tokenleak -See where your AI tokens actually go. Tokenleak reads usage data from **Claude Code**, **Codex**, **Cursor**, **Gemini**, **GitHub Copilot**, **Amp**, **Qwen**, **Roo Code**, **Kilo Code**, **OpenClaw**, **Hermes**, **Pi (`pi-mono`)**, and **OpenCode**, then renders terminal dashboards, heatmaps, compare reports, explain/focus reports, and shareable image cards from the CLI. +See where your AI tokens actually go. Tokenleak reads usage data from **Claude Code**, **Codex**, **Cursor**, **Gemini**, **GitHub Copilot**, **Amp**, **Codebuff**, **Droid**, **Qwen**, **Roo Code**, **Kilo Code**, **Kimi**, **Kilo CLI**, **Mux**, **Crush**, **OpenClaw**, **Hermes**, **Goose**, **Antigravity**, **Zed Agent**, **Kiro**, **Trae**, **Synthetic**, **Pi (`pi-mono`)**, and **OpenCode**, then renders terminal dashboards, heatmaps, compare reports, explain/focus reports, and shareable image cards from the CLI. ![Tokenleak OpenTUI overview](./docs/tui-overview.png) @@ -16,18 +16,30 @@ Tokenleak auto-detects supported providers from their local logs and storage. Th | Gemini icon | Gemini | `~/.gemini/tmp/**/*.{json,jsonl}` | `gemini`, `google` | Yes | | GitHub Copilot icon | GitHub Copilot | `~/.copilot/otel/**/*.jsonl` | `copilot`, `github-copilot`, `copilot-otel` | Yes | | Amp icon | Amp | `${XDG_DATA_HOME:-~/.local/share}/amp/threads/T-*.json` | `amp`, `sourcegraph-amp` | Yes | +| Codebuff icon | Codebuff | `~/.config/manicode/projects/**/chats/**/chat-messages.json` | `codebuff`, `manicode` | Yes | +| Droid icon | Droid | `~/.factory/sessions/*.settings.json` | `droid`, `factory` | Yes | | Qwen icon | Qwen | `~/.qwen/projects/**/*.jsonl` | `qwen` | Yes | | Roo Code icon | Roo Code | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/**/ui_messages.json` | `roo-code`, `roo`, `roocode` | Yes | -| Kilo Code icon | Kilo Code | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/**/ui_messages.json` | `kilo-code`, `kilo`, `kilocode` | Yes | +| Kilo Code icon | Kilo Code | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/**/ui_messages.json` | `kilo-code`, `kilocode` | Yes | +| Kimi icon | Kimi CLI | `~/.kimi/sessions/**/wire.jsonl` | `kimi`, `kimi-cli` | Yes | +| Kilo CLI icon | Kilo CLI | `~/.local/share/kilo/kilo.db` | `kilo`, `kilo-cli` | Yes | +| Mux icon | Mux | `~/.mux/sessions/**/session-usage.json` | `mux` | Yes | +| Crush icon | Crush | `${XDG_DATA_HOME:-~/.local/share}/crush/projects.json` project databases | `crush` | Yes | | OpenClaw icon | OpenClaw | `~/.openclaw/agents/**/*.jsonl*` | `openclaw`, `open-claw` | Yes | -| Hermes icon | Hermes | `${HERMES_HOME:-~/.hermes}/state.db` | `hermes` | Yes | +| Hermes icon | Hermes | `${HERMES_HOME:-~/.hermes}/state.db` | `hermes`, `hermes-agent` | Yes | +| Goose icon | Goose | `${XDG_DATA_HOME:-~/.local/share}/goose/sessions/sessions.db` | `goose` | Yes | +| Antigravity icon | Antigravity | `~/.config/tokenleak/antigravity-cache/sessions/*.jsonl` cache | `antigravity` | Yes | +| Zed icon | Zed Agent | `${XDG_DATA_HOME:-~/.local/share}/zed/threads/threads.db` | `zed`, `zed-agent` | Yes | +| Kiro icon | Kiro | `~/.kiro/sessions/cli/*.json` | `kiro` | Yes | +| Trae icon | Trae | `~/.config/tokenleak/trae-cache/sessions/*.json` cache | `trae` | Yes | +| Synthetic icon | Synthetic | `~/.local/share/octofriend/sqlite.db` | `synthetic`, `octofriend` | Yes | | OpenCode icon | OpenCode | `~/.local/share/opencode/storage/message//*.json` or `~/.config/opencode/storage/message//*.json`
Legacy: `~/.opencode/opencode.db`, `~/.opencode/sessions.db`, `~/.opencode/sessions/*.json` | `open-code`, `opencode`, `open_code` | Yes | | Pi icon | Pi (`pi-mono`) | `~/.pi/agent/sessions/**/*.jsonl` | `pi`, `pi-mono` | Yes | - Use `CLAUDE_CONFIG_DIR` to override the Claude Code base directory. - Use `CODEX_HOME` to override the Codex base directory. - Use `TOKENLEAK_CURSOR_DIR` to override the Cursor credentials/cache directory. -- Use `TOKENLEAK_GEMINI_DIR`, `TOKENLEAK_COPILOT_OTEL_DIR`, `TOKENLEAK_AMP_DIR`, `TOKENLEAK_QWEN_DIR`, `TOKENLEAK_ROO_CODE_DIR`, `TOKENLEAK_KILO_CODE_DIR`, `TOKENLEAK_OPENCLAW_DIR`, and `TOKENLEAK_HERMES_DIR` to override the new provider data locations. +- Use `TOKENLEAK_GEMINI_DIR`, `TOKENLEAK_COPILOT_OTEL_DIR`, `TOKENLEAK_AMP_DIR`, `TOKENLEAK_CODEBUFF_DIR`, `TOKENLEAK_DROID_DIR`, `TOKENLEAK_QWEN_DIR`, `TOKENLEAK_ROO_CODE_DIR`, `TOKENLEAK_KILO_CODE_DIR`, `TOKENLEAK_KIMI_DIR`, `TOKENLEAK_KILO_DIR`, `TOKENLEAK_MUX_DIR`, `TOKENLEAK_CRUSH_DIR`, `TOKENLEAK_OPENCLAW_DIR`, `TOKENLEAK_HERMES_DIR`, `TOKENLEAK_GOOSE_DIR`, `TOKENLEAK_ANTIGRAVITY_DIR`, `TOKENLEAK_ZED_DIR`, `TOKENLEAK_KIRO_DIR`, `TOKENLEAK_TRAE_DIR`, and `TOKENLEAK_SYNTHETIC_DIR` to override provider data locations. - Hermes also honors `HERMES_HOME`. - Use `PI_CODING_AGENT_DIR` to override the Pi base directory. - See [Provider details](#provider-details) for the parser behavior and per-provider notes. @@ -592,6 +604,28 @@ Reads Sourcegraph Amp thread JSON files and combines usage ledger rows with mess | **Provider name** | `amp` | | **Aliases** | `sourcegraph-amp` | +### Codebuff + +Reads Codebuff/Manicode chat history files and parses assistant usage from direct metadata or provider-options run-state fallbacks. + +| | | +| ----------------- | -------------------------------------------------------------------- | +| **Data location** | `~/.config/manicode/projects/**/chats/**/chat-messages.json` | +| **Override** | Set `TOKENLEAK_CODEBUFF_DIR` environment variable | +| **Provider name** | `codebuff` | +| **Aliases** | `manicode` | + +### Droid + +Reads Factory Droid session settings files and uses sibling JSONL session text to recover a model name when Droid only records a provider lock. + +| | | +| ----------------- | ------------------------------------------------- | +| **Data location** | `~/.factory/sessions/*.settings.json` | +| **Override** | Set `TOKENLEAK_DROID_DIR` environment variable | +| **Provider name** | `droid` | +| **Aliases** | `factory` | + ### Qwen Reads Qwen CLI project JSONL logs. Assistant records with `usageMetadata` are parsed for prompt, candidate, thought, and cached-content tokens. @@ -613,7 +647,51 @@ Reads VS Code extension task logs from `ui_messages.json` and uses sibling `api_ | **Kilo data** | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/**/ui_messages.json` | | **Override** | Set `TOKENLEAK_ROO_CODE_DIR` or `TOKENLEAK_KILO_CODE_DIR` environment variable | | **Provider names** | `roo-code`, `kilo-code` | -| **Aliases** | `roo`, `roocode`, `kilo`, `kilocode` | +| **Aliases** | `roo`, `roocode`, `kilocode` | + +### Kimi CLI + +Reads Kimi CLI wire-protocol JSONL files. `StatusUpdate` messages provide input, output, and cache token counts. + +| | | +| ----------------- | ------------------------------------------------- | +| **Data location** | `~/.kimi/sessions/**/wire.jsonl` | +| **Override** | Set `TOKENLEAK_KIMI_DIR` environment variable | +| **Provider name** | `kimi` | +| **Aliases** | `kimi-cli` | + +### Kilo CLI + +Reads Kilo CLI SQLite usage rows from the local Kilo data directory. This is separate from the VS Code Kilo Code extension provider (`kilo-code`). + +| | | +| ----------------- | ------------------------------------------------- | +| **Data location** | `~/.local/share/kilo/kilo.db` | +| **Override** | Set `TOKENLEAK_KILO_DIR` environment variable | +| **Provider name** | `kilo` | +| **Aliases** | `kilo-cli` | + +### Mux + +Reads Mux session usage JSON files and preserves provider-reported per-model cost totals when present. + +| | | +| ----------------- | ------------------------------------------------- | +| **Data location** | `~/.mux/sessions/**/session-usage.json` | +| **Override** | Set `TOKENLEAK_MUX_DIR` environment variable | +| **Provider name** | `mux` | +| **Aliases** | None | + +### Crush + +Reads project-level Crush SQLite databases discovered from the Crush project registry. Crush exposes reliable session-level cost even when token breakdown is unavailable, so Tokenleak preserves cost without fabricating tokens. + +| | | +| ----------------- | ------------------------------------------------------------------------ | +| **Data location** | `${XDG_DATA_HOME:-~/.local/share}/crush/projects.json` project registry | +| **Override** | Set `TOKENLEAK_CRUSH_DIR` environment variable | +| **Provider name** | `crush` | +| **Aliases** | None | ### OpenClaw @@ -628,15 +706,81 @@ Reads OpenClaw agent transcripts and optional `sessions.json` indexes. Model-cha ### Hermes -Reads aggregated Hermes Agent session rows from the local SQLite state database. +Reads aggregated Hermes Agent session rows from the local SQLite state database. The parser accepts schema variants where optional cost or cache columns are absent, as long as `id`, `model`, `started_at`, and at least one token or cost signal are present. | | | | ----------------- | ------------------------------------------------- | | **Data location** | `${HERMES_HOME:-~/.hermes}/state.db` | | **Override** | Set `HERMES_HOME` or `TOKENLEAK_HERMES_DIR` | | **Provider name** | `hermes` | +| **Aliases** | `hermes-agent` | + +### Goose + +Reads Goose session totals from the local `sessions.db` SQLite database. Tokenleak scans the XDG path by default; use the override for macOS Application Support or legacy Block/goose locations. + +| | | +| ----------------- | --------------------------------------------------------------- | +| **Data location** | `${XDG_DATA_HOME:-~/.local/share}/goose/sessions/sessions.db` | +| **Override** | Set `TOKENLEAK_GOOSE_DIR` or `GOOSE_PATH_ROOT` | +| **Provider name** | `goose` | +| **Aliases** | None | + +### Antigravity + +Reads normalized Antigravity JSONL cache files. Tokenleak does not currently connect to the local Antigravity language-server RPC or create this cache; place compatible JSONL artifacts in the cache directory first. + +| | | +| ----------------- | ------------------------------------------------------------------ | +| **Data location** | `~/.config/tokenleak/antigravity-cache/sessions/*.jsonl` | +| **Override** | Set `TOKENLEAK_ANTIGRAVITY_DIR` environment variable | +| **Provider name** | `antigravity` | +| **Aliases** | None | + +### Zed Agent + +Reads hosted Zed Agent rows from Zed's `threads.db`. External ACP agent rows are ignored to avoid double-counting usage that belongs to another provider. + +| | | +| ----------------- | --------------------------------------------------------------------------- | +| **Data location** | `${XDG_DATA_HOME:-~/.local/share}/zed/threads/threads.db` | +| **Override** | Set `TOKENLEAK_ZED_DIR` environment variable | +| **Provider name** | `zed` | +| **Aliases** | `zed-agent` | + +### Kiro + +Reads Kiro CLI session JSON files with explicit turn token metadata. SQLite import can be enabled by pointing the override at a compatible local data source. + +| | | +| ----------------- | ------------------------------------------------- | +| **Data location** | `~/.kiro/sessions/cli/*.json` | +| **Override** | Set `TOKENLEAK_KIRO_DIR` environment variable | +| **Provider name** | `kiro` | | **Aliases** | None | +### Trae + +Reads cached Trae usage API JSON files. Tokenleak does not currently authenticate with Trae or fetch this cache; export or sync compatible JSON first, then point Tokenleak at the cache. + +| | | +| ----------------- | ---------------------------------------------------------- | +| **Data location** | `~/.config/tokenleak/trae-cache/sessions/*.json` | +| **Override** | Set `TOKENLEAK_TRAE_DIR` environment variable | +| **Provider name** | `trae` | +| **Aliases** | None | + +### Synthetic + +Reads Octofriend/Synthetic SQLite token rows when available. Synthetic is also kept as an explicit provider filter so it does not duplicate unrelated provider usage during normal all-provider scans. + +| | | +| ----------------- | ----------------------------------------------------- | +| **Data location** | `~/.local/share/octofriend/sqlite.db` | +| **Override** | Set `TOKENLEAK_SYNTHETIC_DIR` environment variable | +| **Provider name** | `synthetic` | +| **Aliases** | `octofriend` | + ### OpenCode Reads usage data from current OpenCode message storage when available. Falls back to legacy SQLite databases or legacy JSON session files. @@ -813,12 +957,25 @@ All fields are optional. Only include the ones you want to override. | `TOKENLEAK_GEMINI_DIR` | `~/.gemini/tmp` | Gemini CLI temp/session directory | | `TOKENLEAK_COPILOT_OTEL_DIR` | `~/.copilot/otel` | GitHub Copilot OTEL JSONL directory | | `TOKENLEAK_AMP_DIR` | `${XDG_DATA_HOME:-~/.local/share}/amp/threads` | Amp thread directory | +| `TOKENLEAK_CODEBUFF_DIR` | `~/.config/manicode` | Codebuff/Manicode project chat root | +| `TOKENLEAK_DROID_DIR` | `~/.factory/sessions` | Droid session settings directory | | `TOKENLEAK_QWEN_DIR` | `~/.qwen/projects` | Qwen project log directory | | `TOKENLEAK_ROO_CODE_DIR` | VS Code Roo Code task storage | Roo Code task-log directory | | `TOKENLEAK_KILO_CODE_DIR` | VS Code Kilo Code task storage | Kilo Code task-log directory | +| `TOKENLEAK_KIMI_DIR` | `~/.kimi` | Kimi CLI root directory | +| `TOKENLEAK_KILO_DIR` | `~/.local/share/kilo/kilo.db` | Kilo CLI SQLite database path | +| `TOKENLEAK_MUX_DIR` | `~/.mux/sessions` | Mux session usage directory | +| `TOKENLEAK_CRUSH_DIR` | Crush project registry discovery | Crush data directory or `crush.db` path | | `TOKENLEAK_OPENCLAW_DIR` | `~/.openclaw/agents` | OpenClaw agent transcript directory | | `TOKENLEAK_HERMES_DIR` | `~/.hermes` | Hermes directory containing `state.db` | | `HERMES_HOME` | `~/.hermes` | Hermes home directory | +| `TOKENLEAK_GOOSE_DIR` | `${XDG_DATA_HOME:-~/.local/share}/goose/sessions/sessions.db` | Goose SQLite database path | +| `GOOSE_PATH_ROOT` | unset | Goose root directory override | +| `TOKENLEAK_ANTIGRAVITY_DIR` | `~/.config/tokenleak/antigravity-cache/sessions` | Antigravity normalized JSONL cache directory | +| `TOKENLEAK_ZED_DIR` | `${XDG_DATA_HOME:-~/.local/share}/zed/threads/threads.db` | Zed Agent SQLite database path | +| `TOKENLEAK_KIRO_DIR` | `~/.kiro/sessions/cli` | Kiro CLI session JSON directory | +| `TOKENLEAK_TRAE_DIR` | `~/.config/tokenleak/trae-cache/sessions` | Trae cached usage JSON directory | +| `TOKENLEAK_SYNTHETIC_DIR` | `~/.local/share/octofriend/sqlite.db` | Synthetic/Octofriend SQLite database path | | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Pi coding agent directory (sessions live under `sessions/`) | ## What Tokenleak tracks diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index aeb9298..c69575d 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -48,6 +48,7 @@ import { } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { Database } from 'bun:sqlite'; const REGISTRY_FIXTURES_DIR = join(import.meta.dir, '..', '..', 'registry', 'src', '__fixtures__'); @@ -760,6 +761,25 @@ describe('run', () => { cleanup(); } }); + + test('resolveTabbedDashboardProviders keeps synthetic selection scoped', async () => { + const root = mkdtempSync(join(tmpdir(), 'tokenleak-synthetic-cli-')); + const dbPath = join(root, 'sqlite.db'); + const db = new Database(dbPath); + db.run('CREATE TABLE messages (id TEXT, model TEXT, input_tokens INTEGER, output_tokens INTEGER, timestamp INTEGER, session_id TEXT)'); + db.run("INSERT INTO messages VALUES ('m1', 'hf:org/model', 1, 1, 1770724800000, 's1')"); + db.close(); + const previousEnv = process.env; + + try { + process.env = { ...process.env, TOKENLEAK_SYNTHETIC_DIR: dbPath }; + const providers = await resolveTabbedDashboardProviders({ providerNames: ['synthetic'] }); + expect(providers.map((provider) => provider.name)).toEqual(['synthetic']); + } finally { + process.env = previousEnv; + rmSync(root, { recursive: true, force: true }); + } + }); }); describe('runFocus', () => { @@ -925,15 +945,30 @@ describe('CLI invocation', () => { expect(stdout).toContain('gemini'); expect(stdout).toContain('copilot'); expect(stdout).toContain('amp'); + expect(stdout).toContain('codebuff'); + expect(stdout).toContain('droid'); expect(stdout).toContain('qwen'); expect(stdout).toContain('roo-code'); expect(stdout).toContain('kilo-code'); + expect(stdout).toContain('kimi'); + expect(stdout).toContain('kilo'); + expect(stdout).toContain('mux'); + expect(stdout).toContain('crush'); expect(stdout).toContain('openclaw'); expect(stdout).toContain('hermes'); + expect(stdout).toContain('goose'); + expect(stdout).toContain('antigravity'); + expect(stdout).toContain('zed'); + expect(stdout).toContain('kiro'); + expect(stdout).toContain('trae'); + expect(stdout).toContain('synthetic'); expect(stdout).toContain('pi'); expect(stdout).toContain('open-code'); expect(stdout).toContain('github-copilot'); expect(stdout).toContain('sourcegraph-amp'); + expect(stdout).toContain('manicode'); + expect(stdout).toContain('kilo-cli'); + expect(stdout).toContain('octofriend'); }); test('--all-providers with provider filter exits with code 1', async () => { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3ca5d60..733f60a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -39,11 +39,23 @@ import { GeminiProvider, CopilotProvider, AmpProvider, + CodebuffProvider, + DroidProvider, QwenProvider, RooCodeProvider, KiloCodeProvider, + KimiProvider, + KiloProvider, + MuxProvider, + CrushProvider, OpenClawProvider, HermesProvider, + GooseProvider, + AntigravityProvider, + ZedProvider, + KiroProvider, + TraeProvider, + SyntheticProvider, OpenCodeProvider, PiProvider, MODEL_PRICING, @@ -133,16 +145,34 @@ const PROVIDER_ALIASES: Record = { 'copilot-otel': 'copilot', amp: 'amp', 'sourcegraph-amp': 'amp', + codebuff: 'codebuff', + manicode: 'codebuff', + droid: 'droid', + factory: 'droid', qwen: 'qwen', 'roo-code': 'roo-code', roo: 'roo-code', roocode: 'roo-code', 'kilo-code': 'kilo-code', - kilo: 'kilo-code', kilocode: 'kilo-code', + kimi: 'kimi', + 'kimi-cli': 'kimi', + kilo: 'kilo', + 'kilo-cli': 'kilo', + mux: 'mux', + crush: 'crush', openclaw: 'openclaw', 'open-claw': 'openclaw', hermes: 'hermes', + 'hermes-agent': 'hermes', + goose: 'goose', + antigravity: 'antigravity', + zed: 'zed', + 'zed-agent': 'zed', + kiro: 'kiro', + trae: 'trae', + synthetic: 'synthetic', + octofriend: 'synthetic', }; const PROVIDER_ALIAS_GROUPS: Record = { 'claude-code': ['anthropic', 'claude', 'claudecode'], @@ -153,9 +183,16 @@ const PROVIDER_ALIAS_GROUPS: Record = { gemini: ['google'], copilot: ['github-copilot', 'copilot-otel'], amp: ['sourcegraph-amp'], + codebuff: ['manicode'], + droid: ['factory'], 'roo-code': ['roo', 'roocode'], - 'kilo-code': ['kilo', 'kilocode'], + 'kilo-code': ['kilocode'], + kimi: ['kimi-cli'], + kilo: ['kilo-cli'], openclaw: ['open-claw'], + hermes: ['hermes-agent'], + zed: ['zed-agent'], + synthetic: ['octofriend'], }; interface ProviderFilterConfig { @@ -590,11 +627,23 @@ function registerBuiltInProviders(registry: ProviderRegistry): void { registry.register(new GeminiProvider()); registry.register(new CopilotProvider()); registry.register(new AmpProvider()); + registry.register(new CodebuffProvider()); + registry.register(new DroidProvider()); registry.register(new QwenProvider()); registry.register(new RooCodeProvider()); registry.register(new KiloCodeProvider()); + registry.register(new KimiProvider()); + registry.register(new KiloProvider()); + registry.register(new MuxProvider()); + registry.register(new CrushProvider()); registry.register(new OpenClawProvider()); registry.register(new HermesProvider()); + registry.register(new GooseProvider()); + registry.register(new AntigravityProvider()); + registry.register(new ZedProvider()); + registry.register(new KiroProvider()); + registry.register(new TraeProvider()); + registry.register(new SyntheticProvider()); registry.register(new PiProvider()); registry.register(new OpenCodeProvider()); } @@ -1165,6 +1214,19 @@ const PROVIDER_COLORS: Record = { 'claude-code': 179, // amber codex: 71, // green cursor: 78, // spring green + codebuff: 33, + droid: 208, + kimi: 244, + kilo: 214, + mux: 205, + crush: 196, + goose: 37, + antigravity: 99, + zed: 38, + kiro: 63, + trae: 44, + synthetic: 42, + hermes: 35, pi: 73, // cyan/teal 'open-code': 68, // indigo/steel blue }; diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 91116a6..e419b7f 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -7,11 +7,23 @@ import { GeminiProvider, CopilotProvider, AmpProvider, + CodebuffProvider, + DroidProvider, QwenProvider, RooCodeProvider, KiloCodeProvider, + KimiProvider, + KiloProvider, + MuxProvider, + CrushProvider, OpenClawProvider, HermesProvider, + GooseProvider, + AntigravityProvider, + ZedProvider, + KiroProvider, + TraeProvider, + SyntheticProvider, PiProvider, OpenCodeProvider, } from '@tokenleak/registry'; @@ -46,11 +58,23 @@ function createDefaultRegistry(): ProviderRegistry { registry.register(new GeminiProvider()); registry.register(new CopilotProvider()); registry.register(new AmpProvider()); + registry.register(new CodebuffProvider()); + registry.register(new DroidProvider()); registry.register(new QwenProvider()); registry.register(new RooCodeProvider()); registry.register(new KiloCodeProvider()); + registry.register(new KimiProvider()); + registry.register(new KiloProvider()); + registry.register(new MuxProvider()); + registry.register(new CrushProvider()); registry.register(new OpenClawProvider()); registry.register(new HermesProvider()); + registry.register(new GooseProvider()); + registry.register(new AntigravityProvider()); + registry.register(new ZedProvider()); + registry.register(new KiroProvider()); + registry.register(new TraeProvider()); + registry.register(new SyntheticProvider()); registry.register(new PiProvider()); registry.register(new OpenCodeProvider()); return registry; diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts index c0177da..8631e72 100644 --- a/packages/registry/src/index.ts +++ b/packages/registry/src/index.ts @@ -30,11 +30,23 @@ export { GeminiProvider, CopilotProvider, AmpProvider, + CodebuffProvider, + DroidProvider, QwenProvider, RooCodeProvider, KiloCodeProvider, + KimiProvider, + KiloProvider, + MuxProvider, + CrushProvider, OpenClawProvider, HermesProvider, + GooseProvider, + AntigravityProvider, + ZedProvider, + KiroProvider, + TraeProvider, + SyntheticProvider, OpenCodeProvider, PiProvider, } from './providers/index'; diff --git a/packages/registry/src/providers/antigravity.ts b/packages/registry/src/providers/antigravity.ts new file mode 100644 index 0000000..6c7ad5f --- /dev/null +++ b/packages/registry/src/providers/antigravity.ts @@ -0,0 +1,104 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { splitJsonlRecords } from '../parsers/jsonl-splitter'; +import { isInRange } from '../utils'; +import { + buildProviderData, + collectFiles, + extractDate, + nonNegativeNumber, + objectValue, + sessionIdFromFile, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'antigravity'; +const DISPLAY_NAME = 'Antigravity'; +const DEFAULT_BASE_DIR = join(homedir(), '.config', 'tokenleak', 'antigravity-cache', 'sessions'); +const COLORS: ProviderColors = { + primary: '#7c3aed', + secondary: '#c084fc', + gradient: ['#7c3aed', '#c084fc'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +function resolveBaseDir(baseDir?: string): string { + return baseDir ?? process.env['TOKENLEAK_ANTIGRAVITY_DIR'] ?? DEFAULT_BASE_DIR; +} + +function isAntigravityFile(_path: string, name: string): boolean { + return name.endsWith('.jsonl'); +} + +async function parseAntigravityFile(file: string, range: DateRange): Promise { + const records: LocalUsageRecord[] = []; + let fallbackModel: string | null = null; + const fallbackSessionId = sessionIdFromFile(file); + + try { + for await (const record of splitJsonlRecords(file)) { + const entry = objectValue(record); + if (!entry) continue; + + if (entry['type'] === 'session_meta') { + fallbackModel = stringValue(entry['modelId']) ?? stringValue(entry['model_id']) ?? fallbackModel; + continue; + } + + if (entry['type'] !== 'usage') continue; + const timestamp = timestampToIso(entry['timestamp']); + const date = timestamp ? extractDate(timestamp) : null; + if (!timestamp || !date || !isInRange(date, range)) continue; + + const inputTokens = nonNegativeNumber(entry['input']); + const outputTokens = nonNegativeNumber(entry['output']) + nonNegativeNumber(entry['reasoning']); + const cacheReadTokens = nonNegativeNumber(entry['cacheRead']) || nonNegativeNumber(entry['cache_read']); + const cacheWriteTokens = nonNegativeNumber(entry['cacheWrite']) || nonNegativeNumber(entry['cache_write']); + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) continue; + + records.push({ + date, + timestamp, + model: stringValue(entry['modelId']) ?? stringValue(entry['model_id']) ?? fallbackModel ?? 'unknown', + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + sessionId: stringValue(entry['sessionId']) ?? stringValue(entry['session_id']) ?? fallbackSessionId, + }); + } + } catch { + return records; + } + + return records; +} + +export class AntigravityProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly baseDir: string; + + constructor(baseDir?: string) { + this.baseDir = resolveBaseDir(baseDir); + } + + async isAvailable(): Promise { + return existsSync(this.baseDir) && collectFiles(this.baseDir, isAntigravityFile).length > 0; + } + + async load(range: DateRange): Promise { + const records: LocalUsageRecord[] = []; + for (const file of collectFiles(this.baseDir, isAntigravityFile)) { + records.push(...await parseAntigravityFile(file, range)); + } + return buildProviderData(METADATA, records); + } +} diff --git a/packages/registry/src/providers/codebuff.ts b/packages/registry/src/providers/codebuff.ts new file mode 100644 index 0000000..ec9dc18 --- /dev/null +++ b/packages/registry/src/providers/codebuff.ts @@ -0,0 +1,139 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { isInRange } from '../utils'; +import { + buildProviderData, + collectFiles, + extractDate, + fileModifiedTimestamp, + nonNegativeNumber, + objectValue, + safeNumber, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'codebuff'; +const DISPLAY_NAME = 'Codebuff'; +const DEFAULT_BASE_DIR = join(homedir(), '.config', 'manicode'); +const COLORS: ProviderColors = { + primary: '#2563eb', + secondary: '#38bdf8', + gradient: ['#2563eb', '#38bdf8'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +function resolveBaseDir(baseDir?: string): string { + return baseDir ?? process.env['TOKENLEAK_CODEBUFF_DIR'] ?? DEFAULT_BASE_DIR; +} + +function isCodebuffFile(_path: string, name: string): boolean { + return name === 'chat-messages.json'; +} + +function chatIdToIso(chatId: string): string | null { + const t = chatId.indexOf('T'); + if (t < 0) return null; + const rebuilt = `${chatId.slice(0, t)}${chatId.slice(t).replace('-', ':').replace('-', ':')}`; + return timestampToIso(rebuilt); +} + +function contextFromPath(file: string): { projectId: string; sessionId: string; chatId: string } { + const parts = file.split(/[\\/]/); + const chatId = parts.at(-2) ?? 'unknown'; + const chatsIndex = parts.lastIndexOf('chats'); + const projectId = chatsIndex > 0 ? parts[chatsIndex - 1]! : 'unknown'; + return { projectId, chatId, sessionId: `${projectId}/${chatId}` }; +} + +function usageFromMessage(message: Record): Record | null { + const metadata = objectValue(message['metadata']); + const direct = objectValue(metadata?.['usage']) ?? objectValue(objectValue(metadata?.['codebuff'])?.['usage']); + if (direct) return direct; + + const history = objectValue(objectValue(objectValue(metadata?.['runState'])?.['sessionState'])?.['mainAgentState'])?.['messageHistory']; + if (Array.isArray(history)) { + for (const entryValue of [...history].reverse()) { + const entry = objectValue(entryValue); + const providerOptions = objectValue(entry?.['providerOptions']); + const usage = objectValue(providerOptions?.['usage']) ?? objectValue(providerOptions?.['tokenUsage']); + if (usage) return usage; + } + } + + return null; +} + +function parseCodebuffFile(file: string, range: DateRange): LocalUsageRecord[] { + let messages: unknown[]; + try { + const parsed = JSON.parse(readFileSync(file, 'utf-8')); + messages = Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + + const context = contextFromPath(file); + const fallbackTimestamp = chatIdToIso(context.chatId) ?? fileModifiedTimestamp(file); + const records: LocalUsageRecord[] = []; + + for (const messageValue of messages) { + const message = objectValue(messageValue); + if (!message) continue; + const role = stringValue(message['variant']) ?? stringValue(message['role']); + if (role !== 'assistant' && role !== 'agent' && role !== 'ai') continue; + const usage = usageFromMessage(message); + if (!usage) continue; + + const timestamp = timestampToIso(message['timestamp']) ?? timestampToIso(message['createdAt']) ?? fallbackTimestamp; + const date = extractDate(timestamp); + if (!date || !isInRange(date, range)) continue; + + const inputTokens = nonNegativeNumber(usage['inputTokens']) || nonNegativeNumber(usage['input']); + const outputTokens = nonNegativeNumber(usage['outputTokens']) || nonNegativeNumber(usage['output']); + const cacheReadTokens = + nonNegativeNumber(usage['cacheReadInputTokens']) || nonNegativeNumber(usage['cacheReadTokens']); + const cacheWriteTokens = + nonNegativeNumber(usage['cacheCreationInputTokens']) || nonNegativeNumber(usage['cacheWriteTokens']); + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) continue; + + records.push({ + date, + timestamp, + model: stringValue(usage['model']) ?? stringValue(usage['modelName']) ?? 'codebuff-unknown', + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + explicitCost: safeNumber(usage['credits']) ?? safeNumber(usage['cost']) ?? undefined, + sessionId: context.sessionId, + projectId: context.projectId, + }); + } + + return records; +} + +export class CodebuffProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly baseDir: string; + + constructor(baseDir?: string) { + this.baseDir = resolveBaseDir(baseDir); + } + + async isAvailable(): Promise { + return existsSync(this.baseDir) && collectFiles(this.baseDir, isCodebuffFile).length > 0; + } + + async load(range: DateRange): Promise { + return buildProviderData(METADATA, collectFiles(this.baseDir, isCodebuffFile).flatMap((file) => parseCodebuffFile(file, range))); + } +} diff --git a/packages/registry/src/providers/crush.ts b/packages/registry/src/providers/crush.ts new file mode 100644 index 0000000..41b6e98 --- /dev/null +++ b/packages/registry/src/providers/crush.ts @@ -0,0 +1,126 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { Database } from 'bun:sqlite'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { isInRange } from '../utils'; +import { + buildProviderData, + extractDate, + safeNumber, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'crush'; +const DISPLAY_NAME = 'Crush'; +const COLORS: ProviderColors = { + primary: '#ef4444', + secondary: '#fca5a5', + gradient: ['#ef4444', '#fca5a5'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +interface CrushSession { + id: string; + cost: number; + created_at: number; + updated_at: number; +} + +function defaultRegistryPath(): string { + return join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'crush', 'projects.json'); +} + +function discoverCrushDbs(): string[] { + const override = process.env['TOKENLEAK_CRUSH_DIR']; + if (override && existsSync(override)) { + return override.endsWith('.db') ? [override] : [join(override, 'crush.db')].filter(existsSync); + } + + const registryPath = defaultRegistryPath(); + try { + const parsed = JSON.parse(readFileSync(registryPath, 'utf-8')); + const projects = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.projects) ? parsed.projects : []; + return projects.flatMap((project: unknown) => { + if (typeof project !== 'object' || project === null) return []; + const p = project as Record; + const projectPath = stringValue(p['path']); + const dataDir = stringValue(p['data_dir']) ?? '.crush'; + if (!projectPath) return []; + const resolvedDir = isAbsolute(dataDir) ? dataDir : resolve(projectPath, dataDir); + const db = join(resolvedDir, 'crush.db'); + return existsSync(db) ? [db] : []; + }); + } catch { + return []; + } +} + +function loadRows(dbPath: string): CrushSession[] { + let db: InstanceType; + try { + db = new Database(dbPath, { readonly: true }); + } catch { + return []; + } + try { + return db.query(` + SELECT id, cost, created_at, updated_at + FROM sessions + WHERE parent_session_id IS NULL + AND (COALESCE(message_count, 0) > 0 OR COALESCE(cost, 0) > 0) + `).all() as CrushSession[]; + } catch { + return []; + } finally { + db.close(); + } +} + +function rowToRecord(dbPath: string, row: CrushSession, range: DateRange): LocalUsageRecord | null { + const timestamp = timestampToIso(row.updated_at || row.created_at); + const date = timestamp ? extractDate(timestamp) : null; + if (!timestamp || !date || !isInRange(date, range)) return null; + const cost = safeNumber(row.cost) ?? 0; + if (cost <= 0) return null; + return { + date, + timestamp, + model: 'session-total', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + explicitCost: cost, + sessionId: `${dbPath}:${row.id}`, + projectId: dirname(dbPath).split(/[\\/]/).at(-1), + }; +} + +export class CrushProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly dbPaths: string[]; + + constructor(dbPaths?: string[]) { + this.dbPaths = dbPaths ?? discoverCrushDbs(); + } + + async isAvailable(): Promise { + return this.dbPaths.some(existsSync); + } + + async load(range: DateRange): Promise { + const records = this.dbPaths.flatMap((dbPath) => + loadRows(dbPath) + .map((row) => rowToRecord(dbPath, row, range)) + .filter((record): record is LocalUsageRecord => record !== null), + ); + return buildProviderData(METADATA, records); + } +} diff --git a/packages/registry/src/providers/droid.ts b/packages/registry/src/providers/droid.ts new file mode 100644 index 0000000..1f0db72 --- /dev/null +++ b/packages/registry/src/providers/droid.ts @@ -0,0 +1,119 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { isInRange } from '../utils'; +import { + buildProviderData, + collectFiles, + extractDate, + fileModifiedTimestamp, + nonNegativeNumber, + objectValue, + sessionIdFromFile, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'droid'; +const DISPLAY_NAME = 'Droid'; +const DEFAULT_BASE_DIR = join(homedir(), '.factory', 'sessions'); +const COLORS: ProviderColors = { + primary: '#f97316', + secondary: '#fdba74', + gradient: ['#f97316', '#fdba74'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +function resolveBaseDir(baseDir?: string): string { + return baseDir ?? process.env['TOKENLEAK_DROID_DIR'] ?? DEFAULT_BASE_DIR; +} + +function isDroidSettings(_path: string, name: string): boolean { + return name.endsWith('.settings.json'); +} + +function normalizeDroidModel(model: string): string { + return model + .replace(/^custom:/i, '') + .replace(/\[[^\]]*]/g, '') + .trim() + .replace(/-+$/g, '') + .toLowerCase() + .replace(/\./g, '-') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); +} + +function modelFromJsonl(settingsFile: string): string | null { + try { + const text = readFileSync(settingsFile.replace(/\.settings\.json$/, '.jsonl'), 'utf-8'); + const match = /Model:\s*([^["\\\[]+)/i.exec(text); + return match?.[1] ? normalizeDroidModel(match[1]) : null; + } catch { + return null; + } +} + +function parseDroidFile(file: string, range: DateRange): LocalUsageRecord[] { + let settings: Record; + try { + const parsed = JSON.parse(readFileSync(file, 'utf-8')); + const obj = objectValue(parsed); + if (!obj) return []; + settings = obj; + } catch { + return []; + } + + const usage = objectValue(settings['tokenUsage']); + if (!usage) return []; + const timestamp = timestampToIso(settings['providerLockTimestamp']) ?? fileModifiedTimestamp(file); + const date = extractDate(timestamp); + if (!date || !isInRange(date, range)) return []; + + const inputTokens = nonNegativeNumber(usage['inputTokens']); + const outputTokens = nonNegativeNumber(usage['outputTokens']) + nonNegativeNumber(usage['thinkingTokens']); + const cacheReadTokens = nonNegativeNumber(usage['cacheReadTokens']); + const cacheWriteTokens = nonNegativeNumber(usage['cacheCreationTokens']); + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) return []; + + const provider = stringValue(settings['providerLock']); + const model = settings['model'] + ? normalizeDroidModel(String(settings['model'])) + : modelFromJsonl(file) ?? `${provider ?? 'droid'}-unknown`; + + return [{ + date, + timestamp, + model, + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + sessionId: sessionIdFromFile(file).replace(/\.settings$/i, ''), + projectId: provider ?? undefined, + }]; +} + +export class DroidProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly baseDir: string; + + constructor(baseDir?: string) { + this.baseDir = resolveBaseDir(baseDir); + } + + async isAvailable(): Promise { + return existsSync(this.baseDir) && collectFiles(this.baseDir, isDroidSettings).length > 0; + } + + async load(range: DateRange): Promise { + return buildProviderData(METADATA, collectFiles(this.baseDir, isDroidSettings).flatMap((file) => parseDroidFile(file, range))); + } +} diff --git a/packages/registry/src/providers/goose.ts b/packages/registry/src/providers/goose.ts new file mode 100644 index 0000000..3a4e245 --- /dev/null +++ b/packages/registry/src/providers/goose.ts @@ -0,0 +1,119 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { Database } from 'bun:sqlite'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { isInRange } from '../utils'; +import { + buildProviderData, + extractDate, + nonNegativeNumber, + objectValue, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'goose'; +const DISPLAY_NAME = 'Goose'; +const COLORS: ProviderColors = { + primary: '#0f766e', + secondary: '#5eead4', + gradient: ['#0f766e', '#5eead4'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +interface GooseRow { + id: string; + model_config_json?: string | null; + provider_name?: string | null; + created_at: string; + total_tokens?: number | null; + input_tokens?: number | null; + output_tokens?: number | null; + accumulated_total_tokens?: number | null; + accumulated_input_tokens?: number | null; + accumulated_output_tokens?: number | null; +} + +function defaultDbPaths(): string[] { + const override = process.env['TOKENLEAK_GOOSE_DIR'] ?? process.env['GOOSE_PATH_ROOT']; + if (override) return [override.endsWith('.db') ? override : join(override, 'sessions', 'sessions.db')]; + return [ + join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'goose', 'sessions', 'sessions.db'), + join(homedir(), 'Library', 'Application Support', 'goose', 'sessions', 'sessions.db'), + join(homedir(), 'Library', 'Application Support', 'Block', 'goose', 'sessions', 'sessions.db'), + join(homedir(), '.local', 'share', 'Block', 'goose', 'sessions', 'sessions.db'), + ]; +} + +function loadRows(dbPath: string): GooseRow[] { + let db: InstanceType; + try { + db = new Database(dbPath, { readonly: true }); + } catch { + return []; + } + try { + return db.query('SELECT * FROM sessions').all() as GooseRow[]; + } catch { + return []; + } finally { + db.close(); + } +} + +function rowToRecord(row: GooseRow, range: DateRange): LocalUsageRecord | null { + const config = objectValue(row.model_config_json ? JSON.parse(row.model_config_json) : null); + const model = stringValue(config?.['model_name']); + if (!model) return null; + const timestamp = timestampToIso(row.created_at); + const date = timestamp ? extractDate(timestamp) : null; + if (!timestamp || !date || !isInRange(date, range)) return null; + const inputTokens = nonNegativeNumber(row.accumulated_input_tokens ?? row.input_tokens); + const baseOutput = nonNegativeNumber(row.accumulated_output_tokens ?? row.output_tokens); + const total = nonNegativeNumber(row.accumulated_total_tokens ?? row.total_tokens); + const outputTokens = baseOutput + Math.max(0, total - inputTokens - baseOutput); + if (inputTokens + outputTokens === 0) return null; + return { + date, + timestamp, + model, + inputTokens, + outputTokens, + cacheReadTokens: 0, + cacheWriteTokens: 0, + sessionId: row.id, + projectId: stringValue(row.provider_name) ?? undefined, + }; +} + +export class GooseProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly dbPaths: string[]; + + constructor(dbPath?: string) { + this.dbPaths = dbPath ? [dbPath] : defaultDbPaths(); + } + + async isAvailable(): Promise { + return this.dbPaths.some(existsSync); + } + + async load(range: DateRange): Promise { + const records = this.dbPaths.flatMap((dbPath) => loadRows(dbPath)) + .map((row) => { + try { + return rowToRecord(row, range); + } catch { + return null; + } + }) + .filter((record): record is LocalUsageRecord => record !== null); + return buildProviderData(METADATA, records); + } +} diff --git a/packages/registry/src/providers/hermes.ts b/packages/registry/src/providers/hermes.ts index 6e1df79..c1d3e35 100644 --- a/packages/registry/src/providers/hermes.ts +++ b/packages/registry/src/providers/hermes.ts @@ -43,6 +43,39 @@ interface HermesRow { actual_cost_usd?: number | null; } +function tableColumns(db: InstanceType, table: string): Set { + try { + return new Set( + (db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>) + .map((row) => row.name), + ); + } catch { + return new Set(); + } +} + +function selectColumn(columns: Set, name: keyof HermesRow, fallback: string = 'NULL'): string { + return columns.has(name) ? name : `${fallback} AS ${name}`; +} + +function signalPredicate(columns: Set): string { + const tokenColumns = [ + 'input_tokens', + 'output_tokens', + 'cache_read_tokens', + 'cache_write_tokens', + 'reasoning_tokens', + ].filter((name) => columns.has(name)); + const predicates = tokenColumns.map((name) => `COALESCE(${name}, 0) > 0`); + if (columns.has('actual_cost_usd') || columns.has('estimated_cost_usd')) { + const actual = columns.has('actual_cost_usd') ? 'actual_cost_usd' : 'NULL'; + const estimated = columns.has('estimated_cost_usd') ? 'estimated_cost_usd' : 'NULL'; + predicates.push(`COALESCE(${actual}, ${estimated}, 0) > 0`); + } + + return predicates.length > 0 ? predicates.join(' OR ') : '0'; +} + function resolveDbPath(dbPath?: string): string { if (dbPath) { return dbPath; @@ -71,31 +104,25 @@ function loadRows(dbPath: string): HermesRow[] { return []; } + const columns = tableColumns(db, 'sessions'); return db .query(` SELECT id, model, - billing_provider, + ${selectColumn(columns, 'billing_provider')}, started_at, - input_tokens, - output_tokens, - cache_read_tokens, - cache_write_tokens, - reasoning_tokens, - estimated_cost_usd, - actual_cost_usd + ${selectColumn(columns, 'input_tokens', '0')}, + ${selectColumn(columns, 'output_tokens', '0')}, + ${selectColumn(columns, 'cache_read_tokens', '0')}, + ${selectColumn(columns, 'cache_write_tokens', '0')}, + ${selectColumn(columns, 'reasoning_tokens', '0')}, + ${selectColumn(columns, 'estimated_cost_usd')}, + ${selectColumn(columns, 'actual_cost_usd')} FROM sessions WHERE model IS NOT NULL AND TRIM(model) != '' - AND ( - COALESCE(input_tokens, 0) > 0 OR - COALESCE(output_tokens, 0) > 0 OR - COALESCE(cache_read_tokens, 0) > 0 OR - COALESCE(cache_write_tokens, 0) > 0 OR - COALESCE(reasoning_tokens, 0) > 0 OR - COALESCE(actual_cost_usd, estimated_cost_usd, 0) > 0 - ) + AND (${signalPredicate(columns)}) `) .all() as HermesRow[]; } catch { diff --git a/packages/registry/src/providers/index.ts b/packages/registry/src/providers/index.ts index 0da9a8a..8b7d561 100644 --- a/packages/registry/src/providers/index.ts +++ b/packages/registry/src/providers/index.ts @@ -4,9 +4,21 @@ export { CursorProvider } from './cursor'; export { GeminiProvider } from './gemini'; export { CopilotProvider } from './copilot'; export { AmpProvider } from './amp'; +export { CodebuffProvider } from './codebuff'; +export { DroidProvider } from './droid'; export { QwenProvider } from './qwen'; export { RooCodeProvider, KiloCodeProvider } from './roo-kilo-code'; +export { KimiProvider } from './kimi'; +export { KiloProvider } from './kilo'; +export { MuxProvider } from './mux'; +export { CrushProvider } from './crush'; export { OpenClawProvider } from './openclaw'; export { HermesProvider } from './hermes'; +export { GooseProvider } from './goose'; +export { AntigravityProvider } from './antigravity'; +export { ZedProvider } from './zed'; +export { KiroProvider } from './kiro'; +export { TraeProvider } from './trae'; +export { SyntheticProvider } from './synthetic'; export { OpenCodeProvider } from './open-code'; export { PiProvider } from './pi'; diff --git a/packages/registry/src/providers/kilo.ts b/packages/registry/src/providers/kilo.ts new file mode 100644 index 0000000..404a67f --- /dev/null +++ b/packages/registry/src/providers/kilo.ts @@ -0,0 +1,95 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { Database } from 'bun:sqlite'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { isInRange } from '../utils'; +import { + buildProviderData, + extractDate, + nonNegativeNumber, + safeNumber, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'kilo'; +const DISPLAY_NAME = 'Kilo CLI'; +const DEFAULT_DB_PATH = join(homedir(), '.local', 'share', 'kilo', 'kilo.db'); +const COLORS: ProviderColors = { + primary: '#f59e0b', + secondary: '#fde68a', + gradient: ['#f59e0b', '#fde68a'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +function resolveDbPath(dbPath?: string): string { + return dbPath ?? process.env['TOKENLEAK_KILO_DIR'] ?? DEFAULT_DB_PATH; +} + +function loadRows(dbPath: string): Array> { + let db: InstanceType; + try { + db = new Database(dbPath, { readonly: true }); + } catch { + return []; + } + try { + const tables = db.query("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + const table = tables.find((row) => ['usage', 'token_usage', 'messages'].includes(row.name))?.name; + if (!table) return []; + return db.query(`SELECT * FROM ${table}`).all() as Array>; + } catch { + return []; + } finally { + db.close(); + } +} + +function rowToRecord(row: Record, range: DateRange): LocalUsageRecord | null { + const timestamp = timestampToIso(row['timestamp'] ?? row['created_at']); + const date = timestamp ? extractDate(timestamp) : null; + if (!timestamp || !date || !isInRange(date, range)) return null; + const inputTokens = nonNegativeNumber(row['input_tokens'] ?? row['input']); + const outputTokens = nonNegativeNumber(row['output_tokens'] ?? row['output']) + nonNegativeNumber(row['reasoning_tokens']); + const cacheReadTokens = nonNegativeNumber(row['cache_read_tokens'] ?? row['cache_read']); + const cacheWriteTokens = nonNegativeNumber(row['cache_write_tokens'] ?? row['cache_write']); + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) return null; + return { + date, + timestamp, + model: stringValue(row['model']) ?? stringValue(row['model_id']) ?? 'kilo-unknown', + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + explicitCost: safeNumber(row['cost']) ?? safeNumber(row['cost_usd']) ?? undefined, + sessionId: stringValue(row['session_id']) ?? stringValue(row['sessionId']) ?? stringValue(row['id']) ?? undefined, + projectId: stringValue(row['provider']) ?? stringValue(row['provider_id']) ?? undefined, + }; +} + +export class KiloProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly dbPath: string; + + constructor(dbPath?: string) { + this.dbPath = resolveDbPath(dbPath); + } + + async isAvailable(): Promise { + return existsSync(this.dbPath); + } + + async load(range: DateRange): Promise { + const records = loadRows(this.dbPath) + .map((row) => rowToRecord(row, range)) + .filter((record): record is LocalUsageRecord => record !== null); + return buildProviderData(METADATA, records); + } +} diff --git a/packages/registry/src/providers/kimi.ts b/packages/registry/src/providers/kimi.ts new file mode 100644 index 0000000..a52e9e6 --- /dev/null +++ b/packages/registry/src/providers/kimi.ts @@ -0,0 +1,118 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { splitJsonlRecords } from '../parsers/jsonl-splitter'; +import { isInRange } from '../utils'; +import { + buildProviderData, + collectFiles, + extractDate, + nonNegativeNumber, + objectValue, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'kimi'; +const DISPLAY_NAME = 'Kimi'; +const DEFAULT_BASE_DIR = join(homedir(), '.kimi'); +const COLORS: ProviderColors = { + primary: '#111827', + secondary: '#9ca3af', + gradient: ['#111827', '#9ca3af'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +function resolveBaseDir(baseDir?: string): string { + return baseDir ?? process.env['TOKENLEAK_KIMI_DIR'] ?? DEFAULT_BASE_DIR; +} + +function isKimiFile(_path: string, name: string): boolean { + return name === 'wire.jsonl'; +} + +function readModel(file: string): string { + let dir = dirname(file); + for (let i = 0; i < 4; i++) { + const config = join(dir, 'config.json'); + try { + const model = stringValue(JSON.parse(readFileSync(config, 'utf-8'))?.model); + if (model) return model; + } catch { + // keep walking + } + dir = dirname(dir); + } + return 'kimi-for-coding'; +} + +async function parseKimiFile(file: string, range: DateRange): Promise { + const records: LocalUsageRecord[] = []; + const model = readModel(file); + const sessionId = dirname(file).split(/[\\/]/).at(-1) ?? 'unknown'; + + try { + for await (const record of splitJsonlRecords(file)) { + const entry = objectValue(record); + if (!entry || entry['type'] === 'metadata') continue; + const message = objectValue(entry['message']); + if (message?.['type'] !== 'StatusUpdate') continue; + const payload = objectValue(message['payload']); + const usage = objectValue(payload?.['token_usage']); + if (!usage) continue; + + const timestamp = timestampToIso(entry['timestamp']); + const date = timestamp ? extractDate(timestamp) : null; + if (!timestamp || !date || !isInRange(date, range)) continue; + + const inputTokens = nonNegativeNumber(usage['input_other']); + const outputTokens = nonNegativeNumber(usage['output']); + const cacheReadTokens = nonNegativeNumber(usage['input_cache_read']); + const cacheWriteTokens = nonNegativeNumber(usage['input_cache_creation']); + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) continue; + + records.push({ + date, + timestamp, + model, + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + sessionId, + projectId: dirname(dirname(file)).split(/[\\/]/).at(-1), + }); + } + } catch { + return records; + } + + return records; +} + +export class KimiProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly baseDir: string; + + constructor(baseDir?: string) { + this.baseDir = resolveBaseDir(baseDir); + } + + async isAvailable(): Promise { + return existsSync(this.baseDir) && collectFiles(this.baseDir, isKimiFile).length > 0; + } + + async load(range: DateRange): Promise { + const records: LocalUsageRecord[] = []; + for (const file of collectFiles(this.baseDir, isKimiFile)) { + records.push(...await parseKimiFile(file, range)); + } + return buildProviderData(METADATA, records); + } +} diff --git a/packages/registry/src/providers/kiro.ts b/packages/registry/src/providers/kiro.ts new file mode 100644 index 0000000..6d25401 --- /dev/null +++ b/packages/registry/src/providers/kiro.ts @@ -0,0 +1,139 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { Database } from 'bun:sqlite'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { isInRange } from '../utils'; +import { + buildProviderData, + collectFiles, + extractDate, + nonNegativeNumber, + objectValue, + sessionIdFromFile, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'kiro'; +const DISPLAY_NAME = 'Kiro'; +const COLORS: ProviderColors = { + primary: '#6366f1', + secondary: '#a5b4fc', + gradient: ['#6366f1', '#a5b4fc'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +function defaultBaseDir(): string { + const override = process.env['TOKENLEAK_KIRO_DIR']; + if (override && !override.endsWith('.sqlite3') && !override.endsWith('.db')) return override; + return join(homedir(), '.kiro', 'sessions', 'cli'); +} + +function defaultDbPaths(): string[] { + const override = process.env['TOKENLEAK_KIRO_DIR']; + if (override && (override.endsWith('.sqlite3') || override.endsWith('.db'))) return [override]; + return [ + join(homedir(), '.local', 'share', 'kiro-cli', 'data.sqlite3'), + join(homedir(), 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3'), + ]; +} + +function isKiroFile(_path: string, name: string): boolean { + return name.endsWith('.json'); +} + +function projectFromCwd(cwd: unknown): string | undefined { + return stringValue(cwd)?.split(/[\\/]/).filter(Boolean).at(-1); +} + +function parseKiroSessionObject(value: Record, fallbackSessionId: string, range: DateRange): LocalUsageRecord[] { + const state = objectValue(value['session_state']); + const modelInfo = objectValue(objectValue(state?.['rts_model_state'])?.['model_info']); + const model = stringValue(modelInfo?.['model_id']) ?? 'kiro-unknown'; + const sessionId = stringValue(value['session_id']) ?? fallbackSessionId; + const projectId = projectFromCwd(value['cwd']); + const metadata = objectValue(state?.['conversation_metadata']); + const turns = Array.isArray(metadata?.['user_turn_metadatas']) ? metadata['user_turn_metadatas'] as unknown[] : []; + + return turns.flatMap((turnValue, index) => { + const turn = objectValue(turnValue); + if (!turn) return []; + const timestamp = timestampToIso(turn['end_timestamp']); + const date = timestamp ? extractDate(timestamp) : null; + if (!timestamp || !date || !isInRange(date, range)) return []; + const inputTokens = nonNegativeNumber(turn['input_token_count']); + const outputTokens = nonNegativeNumber(turn['output_token_count']); + if (inputTokens + outputTokens === 0) return []; + return [{ + date, + timestamp, + model, + inputTokens, + outputTokens, + cacheReadTokens: 0, + cacheWriteTokens: 0, + sessionId: `${sessionId}:${index}`, + projectId, + }]; + }); +} + +function parseKiroFile(file: string, range: DateRange): LocalUsageRecord[] { + try { + const value = objectValue(JSON.parse(readFileSync(file, 'utf-8'))); + return value ? parseKiroSessionObject(value, sessionIdFromFile(file), range) : []; + } catch { + return []; + } +} + +function parseKiroSqlite(dbPath: string, range: DateRange): LocalUsageRecord[] { + if (!existsSync(dbPath)) return []; + let db: InstanceType; + try { + db = new Database(dbPath, { readonly: true }); + } catch { + return []; + } + try { + const rows = db.query('SELECT id, history FROM conversations_v2').all() as Array>; + return rows.flatMap((row) => { + const history = objectValue(JSON.parse(String(row['history'] ?? '{}'))); + return history ? parseKiroSessionObject(history, stringValue(row['id']) ?? 'kiro-sqlite', range) : []; + }); + } catch { + return []; + } finally { + db.close(); + } +} + +export class KiroProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly baseDir: string; + private readonly dbPaths: string[]; + + constructor(baseDir?: string, dbPath?: string) { + this.baseDir = baseDir ?? defaultBaseDir(); + this.dbPaths = dbPath ? [dbPath] : defaultDbPaths(); + } + + async isAvailable(): Promise { + return (existsSync(this.baseDir) && collectFiles(this.baseDir, isKiroFile).length > 0) || + this.dbPaths.some(existsSync); + } + + async load(range: DateRange): Promise { + const records = collectFiles(this.baseDir, isKiroFile).flatMap((file) => parseKiroFile(file, range)); + for (const dbPath of this.dbPaths) { + records.push(...parseKiroSqlite(dbPath, range)); + } + return buildProviderData(METADATA, records); + } +} diff --git a/packages/registry/src/providers/mux.ts b/packages/registry/src/providers/mux.ts new file mode 100644 index 0000000..57a732b --- /dev/null +++ b/packages/registry/src/providers/mux.ts @@ -0,0 +1,114 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { isInRange } from '../utils'; +import { + buildProviderData, + collectFiles, + extractDate, + nonNegativeNumber, + objectValue, + safeNumber, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'mux'; +const DISPLAY_NAME = 'Mux'; +const DEFAULT_BASE_DIR = join(homedir(), '.mux', 'sessions'); +const COLORS: ProviderColors = { + primary: '#ec4899', + secondary: '#f9a8d4', + gradient: ['#ec4899', '#f9a8d4'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +function resolveBaseDir(baseDir?: string): string { + return baseDir ?? process.env['TOKENLEAK_MUX_DIR'] ?? DEFAULT_BASE_DIR; +} + +function isMuxFile(_path: string, name: string): boolean { + return name === 'session-usage.json'; +} + +function bucketTokens(bucket: unknown): number { + return nonNegativeNumber(objectValue(bucket)?.['tokens']); +} + +function bucketCost(bucket: unknown): number { + return safeNumber(objectValue(bucket)?.['cost_usd']) ?? 0; +} + +function stableCost(value: number): number { + return Math.round(value * 1_000_000_000_000) / 1_000_000_000_000; +} + +function parseMuxFile(file: string, range: DateRange): LocalUsageRecord[] { + let root: Record; + try { + const obj = objectValue(JSON.parse(readFileSync(file, 'utf-8'))); + if (!obj) return []; + root = obj; + } catch { + return []; + } + const timestamp = timestampToIso(objectValue(root['lastRequest'])?.['timestamp']); + const date = timestamp ? extractDate(timestamp) : null; + if (!timestamp || !date || !isInRange(date, range)) return []; + const byModel = objectValue(root['byModel']); + if (!byModel) return []; + const projectId = dirname(file).split(/[\\/]/).at(-1); + + return Object.entries(byModel).flatMap(([modelKey, value]) => { + const entry = objectValue(value); + if (!entry) return []; + const [providerPart, modelPart] = modelKey.includes(':') ? modelKey.split(/:(.*)/s) : ['', modelKey]; + const inputTokens = bucketTokens(entry['input']); + const outputTokens = bucketTokens(entry['output']) + bucketTokens(entry['reasoning']); + const cacheReadTokens = bucketTokens(entry['cached']); + const cacheWriteTokens = bucketTokens(entry['cacheCreate']); + const total = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; + if (total === 0) return []; + return [{ + date, + timestamp, + model: modelPart || modelKey, + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + explicitCost: stableCost( + bucketCost(entry['input']) + + bucketCost(entry['output']) + + bucketCost(entry['cached']) + + bucketCost(entry['cacheCreate']) + + bucketCost(entry['reasoning']), + ), + sessionId: projectId, + projectId, + directory: providerPart || undefined, + }]; + }); +} + +export class MuxProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly baseDir: string; + + constructor(baseDir?: string) { + this.baseDir = resolveBaseDir(baseDir); + } + + async isAvailable(): Promise { + return existsSync(this.baseDir) && collectFiles(this.baseDir, isMuxFile).length > 0; + } + + async load(range: DateRange): Promise { + return buildProviderData(METADATA, collectFiles(this.baseDir, isMuxFile).flatMap((file) => parseMuxFile(file, range))); + } +} diff --git a/packages/registry/src/providers/provider-parity.test.ts b/packages/registry/src/providers/provider-parity.test.ts index ec66994..b9e28e5 100644 --- a/packages/registry/src/providers/provider-parity.test.ts +++ b/packages/registry/src/providers/provider-parity.test.ts @@ -4,13 +4,25 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Database } from 'bun:sqlite'; import type { DateRange } from '@tokenleak/core'; +import { AntigravityProvider } from './antigravity'; import { AmpProvider } from './amp'; +import { CodebuffProvider } from './codebuff'; import { CopilotProvider } from './copilot'; +import { CrushProvider } from './crush'; +import { DroidProvider } from './droid'; import { GeminiProvider } from './gemini'; +import { GooseProvider } from './goose'; import { HermesProvider } from './hermes'; +import { KiloProvider } from './kilo'; +import { KimiProvider } from './kimi'; +import { KiroProvider } from './kiro'; import { KiloCodeProvider, RooCodeProvider } from './roo-kilo-code'; +import { MuxProvider } from './mux'; import { OpenClawProvider } from './openclaw'; import { QwenProvider } from './qwen'; +import { SyntheticProvider } from './synthetic'; +import { TraeProvider } from './trae'; +import { ZedProvider } from './zed'; const RANGE: DateRange = { since: '2026-02-01', until: '2026-02-28' }; @@ -353,4 +365,435 @@ describe('provider parity providers', () => { expect(data.totalCost).toBe(0.08); expect(data.events?.[0]?.projectId).toBe('anthropic'); }); + + it('loads Hermes SQLite rows when optional cost columns are absent', async () => { + const root = tempDir('hermes-minimal'); + const dbPath = join(root, 'state.db'); + const db = new Database(dbPath); + db.run(` + CREATE TABLE sessions ( + id TEXT, + model TEXT, + started_at REAL, + input_tokens INTEGER, + output_tokens INTEGER + ) + `); + db.run(` + INSERT INTO sessions VALUES ( + 'hermes-minimal-session', + 'claude-sonnet-4', + 1770724800, + 10, + 3 + ) + `); + db.close(); + + const data = await new HermesProvider(dbPath).load(RANGE); + + expect(data.provider).toBe('hermes'); + expect(data.totalTokens).toBe(13); + expect(data.events?.[0]?.sessionId).toBe('hermes-minimal-session'); + }); + + it('loads Codebuff chat message usage from project chat files', async () => { + const root = tempDir('codebuff'); + const chatDir = join(root, 'projects', 'repo-a', 'chats', '2026-02-10T12-00-00.000Z'); + mkdirSync(chatDir, { recursive: true }); + writeFileSync(join(chatDir, 'chat-messages.json'), JSON.stringify([ + { variant: 'user', text: 'hello' }, + { + id: 'assistant-1', + variant: 'assistant', + timestamp: '2026-02-10T12:00:00.000Z', + metadata: { + usage: { + model: 'claude-sonnet-4', + inputTokens: 11, + outputTokens: 7, + cacheReadInputTokens: 5, + cacheCreationInputTokens: 2, + credits: 0.04, + }, + }, + }, + ])); + + const data = await new CodebuffProvider(root).load(RANGE); + + expect(data.provider).toBe('codebuff'); + expect(data.totalTokens).toBe(25); + expect(data.totalCost).toBe(0.04); + expect(data.events?.[0]?.projectId).toBe('repo-a'); + expect(data.events?.[0]?.sessionId).toContain('2026-02-10T12-00-00.000Z'); + }); + + it('loads Droid settings token usage and JSONL model fallback', async () => { + const root = tempDir('droid'); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'session-a.settings.json'), JSON.stringify({ + providerLock: 'anthropic', + providerLockTimestamp: '2026-02-10T12:00:00.000Z', + tokenUsage: { + inputTokens: 20, + outputTokens: 6, + cacheReadTokens: 4, + cacheCreationTokens: 3, + thinkingTokens: 2, + }, + })); + writeFileSync(join(root, 'session-a.jsonl'), JSON.stringify({ + type: 'system-reminder', + text: 'Model: Claude Sonnet 4 [Anthropic]', + })); + + const data = await new DroidProvider(root).load(RANGE); + + expect(data.provider).toBe('droid'); + expect(data.totalTokens).toBe(35); + expect(data.daily[0]!.models[0]!.model).toBe('claude-sonnet-4'); + expect(data.daily[0]!.outputTokens).toBe(8); + }); + + it('loads Kimi wire protocol StatusUpdate usage', async () => { + const root = tempDir('kimi'); + const sessionDir = join(root, 'sessions', 'group-a', 'session-a'); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(root, 'config.json'), JSON.stringify({ model: 'kimi-k2' })); + writeFileSync(join(sessionDir, 'wire.jsonl'), [ + JSON.stringify({ type: 'metadata' }), + JSON.stringify({ + timestamp: 1770724800, + message: { + type: 'StatusUpdate', + payload: { + message_id: 'msg-1', + token_usage: { + input_other: 30, + output: 9, + input_cache_read: 6, + input_cache_creation: 3, + }, + }, + }, + }), + ].join('\n')); + + const data = await new KimiProvider(root).load(RANGE); + + expect(data.provider).toBe('kimi'); + expect(data.totalTokens).toBe(48); + expect(data.events?.[0]?.sessionId).toBe('session-a'); + }); + + it('loads Kilo CLI SQLite usage rows', async () => { + const root = tempDir('kilo'); + const dbPath = join(root, 'kilo.db'); + const db = new Database(dbPath); + db.run(` + CREATE TABLE usage ( + id TEXT, + session_id TEXT, + model TEXT, + provider TEXT, + timestamp INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + cost REAL + ) + `); + db.run(` + INSERT INTO usage VALUES ( + 'usage-1', + 'kilo-session', + 'gpt-5', + 'openai', + 1770724800000, + 14, + 5, + 4, + 2, + 0.06 + ) + `); + db.close(); + + const data = await new KiloProvider(dbPath).load(RANGE); + + expect(data.provider).toBe('kilo'); + expect(data.totalTokens).toBe(25); + expect(data.totalCost).toBe(0.06); + expect(data.events?.[0]?.sessionId).toBe('kilo-session'); + }); + + it('loads Mux session usage JSON by model', async () => { + const root = tempDir('mux'); + const sessionDir = join(root, 'sessions', 'workspace-a'); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, 'session-usage.json'), JSON.stringify({ + byModel: { + 'anthropic:claude-sonnet-4': { + input: { tokens: 10, cost_usd: 0.01 }, + output: { tokens: 4, cost_usd: 0.02 }, + cached: { tokens: 3, cost_usd: 0.001 }, + cacheCreate: { tokens: 2, cost_usd: 0.002 }, + reasoning: { tokens: 1, cost_usd: 0.003 }, + }, + }, + lastRequest: { timestamp: 1770724800000 }, + })); + + const data = await new MuxProvider(root).load(RANGE); + + expect(data.provider).toBe('mux'); + expect(data.totalTokens).toBe(20); + expect(data.totalCost).toBe(0.036); + expect(data.events?.[0]?.projectId).toBe('workspace-a'); + }); + + it('loads Crush root session costs without fabricating tokens', async () => { + const dbPath = join(tempDir('crush'), 'crush.db'); + const db = new Database(dbPath); + db.run(` + CREATE TABLE sessions ( + id TEXT, + parent_session_id TEXT, + cost REAL, + created_at INTEGER, + updated_at INTEGER, + message_count INTEGER + ) + `); + db.run(` + CREATE TABLE messages ( + session_id TEXT, + role TEXT, + created_at INTEGER + ) + `); + db.run("INSERT INTO sessions VALUES ('root', NULL, 0.5, 1770724800, 1770724900, 2)"); + db.run("INSERT INTO messages VALUES ('root', 'assistant', 1770724800)"); + db.close(); + + const data = await new CrushProvider([dbPath]).load(RANGE); + + expect(data.provider).toBe('crush'); + expect(data.totalTokens).toBe(0); + expect(data.totalCost).toBe(0.5); + expect(data.events?.[0]?.model).toBe('session-total'); + }); + + it('loads Goose SQLite session totals', async () => { + const dbPath = join(tempDir('goose'), 'sessions.db'); + const db = new Database(dbPath); + db.run(` + CREATE TABLE sessions ( + id TEXT, + model_config_json TEXT, + provider_name TEXT, + created_at TEXT, + total_tokens INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + accumulated_total_tokens INTEGER, + accumulated_input_tokens INTEGER, + accumulated_output_tokens INTEGER + ) + `); + db.run(` + INSERT INTO sessions VALUES ( + 'goose-session', + '{"model_name":"claude-sonnet-4"}', + 'anthropic', + '2026-02-10T12:00:00.000Z', + 25, + 10, + 5, + 30, + 12, + 7 + ) + `); + db.close(); + + const data = await new GooseProvider(dbPath).load(RANGE); + + expect(data.provider).toBe('goose'); + expect(data.totalTokens).toBe(30); + expect(data.daily[0]!.outputTokens).toBe(18); + expect(data.events?.[0]?.sessionId).toBe('goose-session'); + }); + + it('loads cached Antigravity JSONL usage rows', async () => { + const root = tempDir('antigravity'); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'session.jsonl'), [ + JSON.stringify({ type: 'session_meta', sessionId: 'ag-session', modelId: 'claude-sonnet-4' }), + JSON.stringify({ + type: 'usage', + sessionId: 'ag-session', + timestamp: 1770724800000, + input: 22, + output: 8, + cacheRead: 4, + cacheWrite: 2, + reasoning: 1, + responseId: 'resp-1', + }), + ].join('\n')); + + const data = await new AntigravityProvider(root).load(RANGE); + + expect(data.provider).toBe('antigravity'); + expect(data.totalTokens).toBe(37); + expect(data.events?.[0]?.sessionId).toBe('ag-session'); + }); + + it('loads hosted Zed Agent threads and ignores external providers', async () => { + const dbPath = join(tempDir('zed'), 'threads.db'); + const db = new Database(dbPath); + db.run(` + CREATE TABLE threads ( + id TEXT, + updated_at TEXT, + folder_paths TEXT, + folder_paths_order TEXT, + data_type TEXT, + data BLOB + ) + `); + const hosted = JSON.stringify({ + model: { provider: 'zed.dev', model: 'claude-sonnet-4' }, + usage: { input_tokens: 10, output_tokens: 5, cache_read_tokens: 2, cache_write_tokens: 1 }, + }); + const external = JSON.stringify({ + model: { provider: 'anthropic', model: 'claude-sonnet-4' }, + usage: { input_tokens: 999, output_tokens: 999 }, + }); + db.run('INSERT INTO threads VALUES (?, ?, ?, ?, ?, ?)', [ + 'zed-hosted', + '2026-02-10T12:00:00.000Z', + JSON.stringify(['/repo/zed']), + JSON.stringify([0]), + 'json', + Buffer.from(hosted), + ]); + db.run('INSERT INTO threads VALUES (?, ?, ?, ?, ?, ?)', [ + 'zed-external', + '2026-02-10T12:00:00.000Z', + null, + null, + 'json', + Buffer.from(external), + ]); + db.close(); + + const data = await new ZedProvider(dbPath).load(RANGE); + + expect(data.provider).toBe('zed'); + expect(data.totalTokens).toBe(18); + expect(data.events?.[0]?.projectId).toBe('zed'); + }); + + it('loads Kiro CLI file sessions with explicit turn token counts', async () => { + const root = tempDir('kiro'); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'session.json'), JSON.stringify({ + session_id: 'kiro-session', + cwd: '/repo/kiro', + session_state: { + rts_model_state: { + model_info: { model_id: 'claude-sonnet-4', context_window_tokens: 200000 }, + }, + conversation_metadata: { + user_turn_metadatas: [ + { + input_token_count: 44, + output_token_count: 11, + end_timestamp: 1770724800000, + total_request_count: 1, + }, + ], + }, + }, + })); + + const data = await new KiroProvider(root).load(RANGE); + + expect(data.provider).toBe('kiro'); + expect(data.totalTokens).toBe(55); + expect(data.events?.[0]?.projectId).toBe('kiro'); + }); + + it('loads cached Trae usage API JSON', async () => { + const root = tempDir('trae'); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'usage.json'), JSON.stringify([ + { + model_name: 'GPT-5.4', + session_id: 'trae-session', + usage_time: 1770724800, + dollar_float: 0.09, + extra_info: { + input_token: 21, + output_token: 8, + cache_read_token: 4, + cache_write_token: 2, + }, + }, + ])); + + const data = await new TraeProvider(root).load(RANGE); + + expect(data.provider).toBe('trae'); + expect(data.totalTokens).toBe(35); + expect(data.totalCost).toBe(0.09); + expect(data.daily[0]!.models[0]!.model).toBe('gpt-5.4'); + }); + + it('loads Synthetic Octofriend SQLite token rows', async () => { + const dbPath = join(tempDir('synthetic'), 'sqlite.db'); + const db = new Database(dbPath); + db.run(` + CREATE TABLE messages ( + id TEXT, + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + reasoning_tokens INTEGER, + cost REAL, + timestamp INTEGER, + session_id TEXT, + provider TEXT + ) + `); + db.run(` + INSERT INTO messages VALUES ( + 'synthetic-message', + 'hf:deepseek-ai/DeepSeek-V3-0324', + 13, + 7, + 3, + 1, + 2, + 0.04, + 1770724800000, + 'synthetic-session', + 'synthetic' + ) + `); + db.close(); + + const data = await new SyntheticProvider(dbPath).load(RANGE); + + expect(data.provider).toBe('synthetic'); + expect(data.totalTokens).toBe(26); + expect(data.totalCost).toBe(0.04); + expect(data.daily[0]!.models[0]!.model).toBe('deepseek-v3-0324'); + }); }); diff --git a/packages/registry/src/providers/synthetic.ts b/packages/registry/src/providers/synthetic.ts new file mode 100644 index 0000000..998c360 --- /dev/null +++ b/packages/registry/src/providers/synthetic.ts @@ -0,0 +1,109 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { Database } from 'bun:sqlite'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { isInRange } from '../utils'; +import { + buildProviderData, + extractDate, + nonNegativeNumber, + safeNumber, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'synthetic'; +const DISPLAY_NAME = 'Synthetic'; +const DEFAULT_DB_PATH = join(homedir(), '.local', 'share', 'octofriend', 'sqlite.db'); +const COLORS: ProviderColors = { + primary: '#10b981', + secondary: '#86efac', + gradient: ['#10b981', '#86efac'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +function resolveDbPath(dbPath?: string): string { + return dbPath ?? process.env['TOKENLEAK_SYNTHETIC_DIR'] ?? DEFAULT_DB_PATH; +} + +function normalizeSyntheticModel(model: string): string { + const lower = model.toLowerCase(); + if (lower.startsWith('hf:')) { + return lower.split('/').at(-1) ?? lower.slice(3); + } + const modelsMarker = '/models/'; + const index = lower.indexOf(modelsMarker); + return index >= 0 ? lower.slice(index + modelsMarker.length) : lower; +} + +function loadRows(dbPath: string): Array> { + let db: InstanceType; + try { + db = new Database(dbPath, { readonly: true }); + } catch { + return []; + } + try { + const tables = db.query("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + if (tables.some((row) => row.name === 'messages')) { + return db.query('SELECT * FROM messages').all() as Array>; + } + if (tables.some((row) => row.name === 'token_usage')) { + return db.query('SELECT * FROM token_usage').all() as Array>; + } + return []; + } catch { + return []; + } finally { + db.close(); + } +} + +function rowToRecord(row: Record, range: DateRange): LocalUsageRecord | null { + const timestamp = timestampToIso(row['timestamp'] ?? row['created_at']); + const date = timestamp ? extractDate(timestamp) : null; + if (!timestamp || !date || !isInRange(date, range)) return null; + const inputTokens = nonNegativeNumber(row['input_tokens'] ?? row['input']); + const outputTokens = nonNegativeNumber(row['output_tokens'] ?? row['output']) + nonNegativeNumber(row['reasoning_tokens']); + const cacheReadTokens = nonNegativeNumber(row['cache_read_tokens'] ?? row['cache_read']); + const cacheWriteTokens = nonNegativeNumber(row['cache_write_tokens'] ?? row['cache_write']); + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) return null; + return { + date, + timestamp, + model: normalizeSyntheticModel(stringValue(row['model']) ?? stringValue(row['model_id']) ?? 'synthetic-unknown'), + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + explicitCost: safeNumber(row['cost']) ?? safeNumber(row['cost_usd']) ?? undefined, + sessionId: stringValue(row['session_id']) ?? stringValue(row['sessionId']) ?? undefined, + projectId: stringValue(row['provider']) ?? 'synthetic', + }; +} + +export class SyntheticProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly dbPath: string; + + constructor(dbPath?: string) { + this.dbPath = resolveDbPath(dbPath); + } + + async isAvailable(): Promise { + return existsSync(this.dbPath); + } + + async load(range: DateRange): Promise { + const records = loadRows(this.dbPath) + .map((row) => rowToRecord(row, range)) + .filter((record): record is LocalUsageRecord => record !== null); + return buildProviderData(METADATA, records); + } +} diff --git a/packages/registry/src/providers/trae.ts b/packages/registry/src/providers/trae.ts new file mode 100644 index 0000000..460d04c --- /dev/null +++ b/packages/registry/src/providers/trae.ts @@ -0,0 +1,94 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { isInRange } from '../utils'; +import { + buildProviderData, + collectFiles, + extractDate, + nonNegativeNumber, + safeNumber, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'trae'; +const DISPLAY_NAME = 'Trae'; +const DEFAULT_BASE_DIR = join(homedir(), '.config', 'tokenleak', 'trae-cache', 'sessions'); +const COLORS: ProviderColors = { + primary: '#06b6d4', + secondary: '#67e8f9', + gradient: ['#06b6d4', '#67e8f9'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +function resolveBaseDir(baseDir?: string): string { + return baseDir ?? process.env['TOKENLEAK_TRAE_DIR'] ?? DEFAULT_BASE_DIR; +} + +function isTraeFile(_path: string, name: string): boolean { + return name.endsWith('.json'); +} + +function normalizeModel(name: string, mode?: string | null): string { + if (!name.trim()) return mode ? `trae-${mode.toLowerCase()}` : 'trae-unknown'; + return name.toLowerCase().replace(/\s+/g, '-'); +} + +function parseTraeFile(file: string, range: DateRange): LocalUsageRecord[] { + let sessions: unknown[]; + try { + const parsed = JSON.parse(readFileSync(file, 'utf-8')); + sessions = Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + + return sessions.flatMap((sessionValue) => { + const session = typeof sessionValue === 'object' && sessionValue !== null ? sessionValue as Record : null; + if (!session) return []; + const extra = typeof session['extra_info'] === 'object' && session['extra_info'] !== null ? session['extra_info'] as Record : {}; + const timestamp = timestampToIso(session['usage_time']); + const date = timestamp ? extractDate(timestamp) : null; + if (!timestamp || !date || !isInRange(date, range)) return []; + const inputTokens = nonNegativeNumber(extra['input_token']); + const outputTokens = nonNegativeNumber(extra['output_token']); + const cacheReadTokens = nonNegativeNumber(extra['cache_read_token']); + const cacheWriteTokens = nonNegativeNumber(extra['cache_write_token']); + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) return []; + return [{ + date, + timestamp, + model: normalizeModel(stringValue(session['model_name']) ?? '', stringValue(session['mode'])), + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + explicitCost: safeNumber(session['dollar_float']) ?? undefined, + sessionId: stringValue(session['session_id']) ?? undefined, + }]; + }); +} + +export class TraeProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly baseDir: string; + + constructor(baseDir?: string) { + this.baseDir = resolveBaseDir(baseDir); + } + + async isAvailable(): Promise { + return existsSync(this.baseDir) && collectFiles(this.baseDir, isTraeFile).length > 0; + } + + async load(range: DateRange): Promise { + return buildProviderData(METADATA, collectFiles(this.baseDir, isTraeFile).flatMap((file) => parseTraeFile(file, range))); + } +} diff --git a/packages/registry/src/providers/zed.ts b/packages/registry/src/providers/zed.ts new file mode 100644 index 0000000..b09c58d --- /dev/null +++ b/packages/registry/src/providers/zed.ts @@ -0,0 +1,141 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { Database } from 'bun:sqlite'; +import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { IProvider } from '../provider'; +import { isInRange } from '../utils'; +import { + buildProviderData, + extractDate, + nonNegativeNumber, + objectValue, + stringValue, + timestampToIso, + type LocalProviderMetadata, + type LocalUsageRecord, +} from './local-usage'; + +const PROVIDER_NAME = 'zed'; +const DISPLAY_NAME = 'Zed Agent'; +const ZED_HOSTED_PROVIDER = 'zed.dev'; +const COLORS: ProviderColors = { + primary: '#0891b2', + secondary: '#22d3ee', + gradient: ['#0891b2', '#22d3ee'], +}; +const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS }; + +interface ZedRow { + id: string; + updated_at: string; + folder_paths?: string | null; + folder_paths_order?: string | null; + data_type: string; + data: Uint8Array | Buffer; +} + +function defaultDbPaths(): string[] { + if (process.env['TOKENLEAK_ZED_DIR']) return [process.env['TOKENLEAK_ZED_DIR']]; + const paths = [ + join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'zed', 'threads', 'threads.db'), + join(homedir(), 'Library', 'Application Support', 'Zed', 'threads', 'threads.db'), + ]; + if (process.env['LOCALAPPDATA']) { + paths.push(join(process.env['LOCALAPPDATA'], 'Zed', 'threads', 'threads.db')); + } + return paths; +} + +function loadRows(dbPath: string): ZedRow[] { + let db: InstanceType; + try { + db = new Database(dbPath, { readonly: true }); + } catch { + return []; + } + try { + return db.query('SELECT id, updated_at, folder_paths, folder_paths_order, data_type, data FROM threads').all() as ZedRow[]; + } catch { + return []; + } finally { + db.close(); + } +} + +function decodeData(row: ZedRow): Record | null { + if (row.data_type.toLowerCase() !== 'json') return null; + try { + return objectValue(JSON.parse(Buffer.from(row.data).toString('utf-8'))); + } catch { + return null; + } +} + +function projectFromFolders(folderPaths?: string | null): string | undefined { + if (!folderPaths) return undefined; + try { + const folders = JSON.parse(folderPaths); + if (!Array.isArray(folders)) return undefined; + const first = stringValue(folders[0]); + return first?.split(/[\\/]/).filter(Boolean).at(-1); + } catch { + return undefined; + } +} + +function usageFromThread(thread: Record): Record | null { + return objectValue(thread['usage']) ?? objectValue(thread['token_usage']) ?? objectValue(thread['usageData']); +} + +function rowToRecord(row: ZedRow, range: DateRange): LocalUsageRecord | null { + const thread = decodeData(row); + if (!thread || thread['imported'] === true) return null; + const modelObj = objectValue(thread['model']); + if ((stringValue(modelObj?.['provider']) ?? '').toLowerCase() !== ZED_HOSTED_PROVIDER) return null; + const model = stringValue(modelObj?.['model']) ?? stringValue(modelObj?.['id']); + if (!model) return null; + const usage = usageFromThread(thread); + if (!usage) return null; + const timestamp = timestampToIso(thread['updated_at']) ?? timestampToIso(row.updated_at); + const date = timestamp ? extractDate(timestamp) : null; + if (!timestamp || !date || !isInRange(date, range)) return null; + const inputTokens = nonNegativeNumber(usage['input_tokens'] ?? usage['input']); + const outputTokens = nonNegativeNumber(usage['output_tokens'] ?? usage['output']) + nonNegativeNumber(usage['reasoning_tokens']); + const cacheReadTokens = nonNegativeNumber(usage['cache_read_tokens'] ?? usage['cacheRead']); + const cacheWriteTokens = nonNegativeNumber(usage['cache_write_tokens'] ?? usage['cacheWrite']); + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) return null; + return { + date, + timestamp, + model, + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + sessionId: row.id, + projectId: projectFromFolders(row.folder_paths), + }; +} + +export class ZedProvider implements IProvider { + readonly name = PROVIDER_NAME; + readonly displayName = DISPLAY_NAME; + readonly colors = COLORS; + private readonly dbPaths: string[]; + + constructor(dbPath?: string) { + this.dbPaths = dbPath ? [dbPath] : defaultDbPaths(); + } + + async isAvailable(): Promise { + return this.dbPaths.some(existsSync); + } + + async load(range: DateRange): Promise { + const records = this.dbPaths.flatMap((dbPath) => loadRows(dbPath)) + .map((row) => rowToRecord(row, range)) + .filter((record): record is LocalUsageRecord => record !== null); + return buildProviderData(METADATA, records); + } +} diff --git a/packages/renderers/src/live/wrapped-live-template.ts b/packages/renderers/src/live/wrapped-live-template.ts index 52a7659..ddf7ba3 100644 --- a/packages/renderers/src/live/wrapped-live-template.ts +++ b/packages/renderers/src/live/wrapped-live-template.ts @@ -57,11 +57,23 @@ const PROVIDER_COLORS: Record = { gemini: '#4285f4', copilot: '#6e7781', amp: '#ff5a1f', + codebuff: '#2563eb', + droid: '#f97316', qwen: '#7c3aed', 'roo-code': '#0f766e', 'kilo-code': '#2563eb', + kimi: '#111827', + kilo: '#f59e0b', + mux: '#ec4899', + crush: '#ef4444', openclaw: '#dc2626', hermes: '#16a34a', + goose: '#0f766e', + antigravity: '#7c3aed', + zed: '#0891b2', + kiro: '#6366f1', + trae: '#06b6d4', + synthetic: '#10b981', pi: '#5a4a70', }; diff --git a/packages/renderers/src/svg/wrapped-single-page.ts b/packages/renderers/src/svg/wrapped-single-page.ts index 3300c83..18fcf8f 100644 --- a/packages/renderers/src/svg/wrapped-single-page.ts +++ b/packages/renderers/src/svg/wrapped-single-page.ts @@ -80,6 +80,18 @@ const PROVIDER_COLORS: Record = { codex: { dark: '#3a5070', light: '#4a6a90' }, google: { dark: '#6a2535', light: '#8a3548' }, cursor: { dark: '#7c5cbf', light: '#6a4aaa' }, + codebuff: { dark: '#2563eb', light: '#1d4ed8' }, + droid: { dark: '#f97316', light: '#c2410c' }, + kimi: { dark: '#9ca3af', light: '#374151' }, + kilo: { dark: '#f59e0b', light: '#b45309' }, + mux: { dark: '#ec4899', light: '#be185d' }, + crush: { dark: '#ef4444', light: '#b91c1c' }, + goose: { dark: '#0f766e', light: '#0f766e' }, + antigravity: { dark: '#7c3aed', light: '#6d28d9' }, + zed: { dark: '#0891b2', light: '#0e7490' }, + kiro: { dark: '#6366f1', light: '#4f46e5' }, + trae: { dark: '#06b6d4', light: '#0891b2' }, + synthetic: { dark: '#10b981', light: '#047857' }, pi: { dark: '#5a4a70', light: '#706088' }, }; diff --git a/packages/tui/src/lib/data.ts b/packages/tui/src/lib/data.ts index 40e1787..671cba9 100644 --- a/packages/tui/src/lib/data.ts +++ b/packages/tui/src/lib/data.ts @@ -47,11 +47,23 @@ import { GeminiProvider, CopilotProvider, AmpProvider, + CodebuffProvider, + DroidProvider, QwenProvider, RooCodeProvider, KiloCodeProvider, + KimiProvider, + KiloProvider, + MuxProvider, + CrushProvider, OpenClawProvider, HermesProvider, + GooseProvider, + AntigravityProvider, + ZedProvider, + KiroProvider, + TraeProvider, + SyntheticProvider, OpenCodeProvider, PiProvider, MODEL_PRICING, @@ -229,11 +241,23 @@ function createRegistry(): ProviderRegistry { registry.register(new GeminiProvider()); registry.register(new CopilotProvider()); registry.register(new AmpProvider()); + registry.register(new CodebuffProvider()); + registry.register(new DroidProvider()); registry.register(new QwenProvider()); registry.register(new RooCodeProvider()); registry.register(new KiloCodeProvider()); + registry.register(new KimiProvider()); + registry.register(new KiloProvider()); + registry.register(new MuxProvider()); + registry.register(new CrushProvider()); registry.register(new OpenClawProvider()); registry.register(new HermesProvider()); + registry.register(new GooseProvider()); + registry.register(new AntigravityProvider()); + registry.register(new ZedProvider()); + registry.register(new KiroProvider()); + registry.register(new TraeProvider()); + registry.register(new SyntheticProvider()); registry.register(new OpenCodeProvider()); registry.register(new PiProvider()); return registry; diff --git a/packages/tui/src/lib/theme.ts b/packages/tui/src/lib/theme.ts index 16c1f4f..9e36570 100644 --- a/packages/tui/src/lib/theme.ts +++ b/packages/tui/src/lib/theme.ts @@ -25,11 +25,23 @@ export const PROVIDER_COLORS: Record = { gemini: '#4285f4', copilot: '#6e7781', amp: '#ff5a1f', + codebuff: '#2563eb', + droid: '#f97316', qwen: '#7c3aed', 'roo-code': '#0f766e', 'kilo-code': '#2563eb', + kimi: '#111827', + kilo: '#f59e0b', + mux: '#ec4899', + crush: '#ef4444', openclaw: '#dc2626', hermes: '#16a34a', + goose: '#0f766e', + antigravity: '#7c3aed', + zed: '#0891b2', + kiro: '#6366f1', + trae: '#06b6d4', + synthetic: '#10b981', pi: '#06b6d4', 'open-code': '#ef4444', }; From 36dc3da2bcf1e85e9a8ad72a7fc56f0f7b31db74 Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Wed, 27 May 2026 00:35:34 +0530 Subject: [PATCH 2/2] address provider parity review feedback --- packages/cli/src/cli.test.ts | 19 +++++ packages/cli/src/cli.ts | 9 +++ packages/mcp/src/resources/overview.ts | 4 +- packages/mcp/src/shared/provider-load.ts | 15 +++- packages/mcp/src/tools/compare-periods.ts | 4 +- packages/mcp/src/tools/get-cost-breakdown.ts | 4 +- packages/mcp/src/tools/get-daily-usage.ts | 7 +- .../mcp/src/tools/get-efficiency-advice.ts | 7 +- packages/mcp/src/tools/get-receipt-lines.ts | 7 +- .../mcp/src/tools/get-streaks-and-habits.ts | 4 +- packages/mcp/src/tools/get-usage-summary.ts | 11 +-- packages/registry/src/providers/hermes.ts | 8 +- .../src/providers/provider-parity.test.ts | 74 +++++++++++++++++++ packages/registry/src/providers/zed.ts | 7 +- packages/tui/src/lib/data.ts | 2 - 15 files changed, 148 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index c69575d..49c9e7e 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -780,6 +780,25 @@ describe('run', () => { rmSync(root, { recursive: true, force: true }); } }); + + test('resolveTabbedDashboardProviders excludes synthetic from default provider scans', async () => { + const root = mkdtempSync(join(tmpdir(), 'tokenleak-synthetic-default-cli-')); + const dbPath = join(root, 'sqlite.db'); + const db = new Database(dbPath); + db.run('CREATE TABLE messages (id TEXT, model TEXT, input_tokens INTEGER, output_tokens INTEGER, timestamp INTEGER, session_id TEXT)'); + db.run("INSERT INTO messages VALUES ('m1', 'hf:org/model', 1, 1, 1770724800000, 's1')"); + db.close(); + const previousEnv = process.env; + + try { + process.env = { ...process.env, TOKENLEAK_SYNTHETIC_DIR: dbPath }; + const providers = await resolveTabbedDashboardProviders({ providerNames: [] }); + expect(providers.map((provider) => provider.name)).not.toContain('synthetic'); + } finally { + process.env = previousEnv; + rmSync(root, { recursive: true, force: true }); + } + }); }); describe('runFocus', () => { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 733f60a..d9cea7c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -194,6 +194,7 @@ const PROVIDER_ALIAS_GROUPS: Record = { zed: ['zed-agent'], synthetic: ['octofriend'], }; +const EXPLICIT_ONLY_PROVIDERS = new Set(['synthetic']); interface ProviderFilterConfig { provider?: string; @@ -256,6 +257,10 @@ function providerMatchesFilter(provider: IProvider, requested: Set): boo return candidates.some((candidate) => requested.has(candidate)); } +function isExplicitOnlyProvider(provider: IProvider): boolean { + return EXPLICIT_ONLY_PROVIDERS.has(provider.name); +} + function buildHelpText(): string { return [ `tokenleak ${VERSION}`, @@ -712,6 +717,10 @@ async function selectAvailableProviders( const registry = createRegistry(); let available = await registry.getAvailable(); + if (!requestedProviders.has('synthetic')) { + available = available.filter((provider) => !isExplicitOnlyProvider(provider)); + } + if (!config.allProviders && requestedProviders.size > 0) { if ( config.provider && diff --git a/packages/mcp/src/resources/overview.ts b/packages/mcp/src/resources/overview.ts index 03aa7b1..8cc8413 100644 --- a/packages/mcp/src/resources/overview.ts +++ b/packages/mcp/src/resources/overview.ts @@ -6,11 +6,11 @@ import { } from '@tokenleak/core'; import type { ProviderRegistry } from '@tokenleak/registry'; import { resolveRange } from '../shared/date-range.js'; -import { loadProviderData } from '../shared/provider-load.js'; +import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js'; export async function handleOverview(registry: ProviderRegistry): Promise { const range = resolveRange({}); - const available = await registry.getAvailable(); + const available = await getAvailableProvidersForRequest(registry); const { data, warnings } = await loadProviderData(available, range); diff --git a/packages/mcp/src/shared/provider-load.ts b/packages/mcp/src/shared/provider-load.ts index 3d41a6d..054a24c 100644 --- a/packages/mcp/src/shared/provider-load.ts +++ b/packages/mcp/src/shared/provider-load.ts @@ -1,11 +1,24 @@ import type { DateRange, ProviderData, ProviderWarning } from '@tokenleak/core'; -import type { IProvider } from '@tokenleak/registry'; +import type { IProvider, ProviderRegistry } from '@tokenleak/registry'; + +const EXPLICIT_ONLY_PROVIDERS = new Set(['synthetic']); export interface LoadedProviderData { data: ProviderData[]; warnings: ProviderWarning[]; } +export async function getAvailableProvidersForRequest( + registry: ProviderRegistry, + providerName?: string, +): Promise { + const available = await registry.getAvailable(); + if (providerName) { + return available.filter((provider) => provider.name === providerName); + } + return available.filter((provider) => !EXPLICIT_ONLY_PROVIDERS.has(provider.name)); +} + export async function loadProviderData( providers: IProvider[], range: DateRange, diff --git a/packages/mcp/src/tools/compare-periods.ts b/packages/mcp/src/tools/compare-periods.ts index 96923a2..7cd8a82 100644 --- a/packages/mcp/src/tools/compare-periods.ts +++ b/packages/mcp/src/tools/compare-periods.ts @@ -9,7 +9,7 @@ import { } from '@tokenleak/core'; import type { DateRange } from '@tokenleak/core'; import type { ProviderRegistry } from '@tokenleak/registry'; -import { loadProviderData } from '../shared/provider-load.js'; +import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js'; async function loadAndAggregate( providers: Awaited>, @@ -51,7 +51,7 @@ export async function handleComparePeriods( previousRange = computePreviousPeriod(currentRange); } - const available = await registry.getAvailable(); + const available = await getAvailableProvidersForRequest(registry); const [currentResult, previousResult] = await Promise.all([ loadAndAggregate(available, currentRange), diff --git a/packages/mcp/src/tools/get-cost-breakdown.ts b/packages/mcp/src/tools/get-cost-breakdown.ts index 33c52eb..a8a7811 100644 --- a/packages/mcp/src/tools/get-cost-breakdown.ts +++ b/packages/mcp/src/tools/get-cost-breakdown.ts @@ -1,7 +1,7 @@ import { aggregate, mergeProviderData } from '@tokenleak/core'; import type { ProviderRegistry } from '@tokenleak/registry'; import { resolveRange } from '../shared/date-range.js'; -import { loadProviderData } from '../shared/provider-load.js'; +import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js'; export async function handleGetCostBreakdown( args: { days?: number; since?: string; until?: string }, @@ -9,7 +9,7 @@ export async function handleGetCostBreakdown( ) { try { const range = resolveRange(args); - const available = await registry.getAvailable(); + const available = await getAvailableProvidersForRequest(registry); const { data, warnings } = await loadProviderData(available, range); diff --git a/packages/mcp/src/tools/get-daily-usage.ts b/packages/mcp/src/tools/get-daily-usage.ts index 6ad535f..615d62b 100644 --- a/packages/mcp/src/tools/get-daily-usage.ts +++ b/packages/mcp/src/tools/get-daily-usage.ts @@ -1,7 +1,7 @@ import { buildDailyCostCompleteness, mergeProviderData } from '@tokenleak/core'; import type { ProviderRegistry } from '@tokenleak/registry'; import { resolveRange } from '../shared/date-range.js'; -import { loadProviderData } from '../shared/provider-load.js'; +import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js'; const DEFAULT_DAILY_DAYS = 14; @@ -11,10 +11,7 @@ export async function handleGetDailyUsage( ) { try { const range = resolveRange(args, DEFAULT_DAILY_DAYS); - const available = await registry.getAvailable(); - const filtered = args.provider - ? available.filter((p) => p.name === args.provider) - : available; + const filtered = await getAvailableProvidersForRequest(registry, args.provider); const { data, warnings } = await loadProviderData(filtered, range); diff --git a/packages/mcp/src/tools/get-efficiency-advice.ts b/packages/mcp/src/tools/get-efficiency-advice.ts index d016983..7dd6433 100644 --- a/packages/mcp/src/tools/get-efficiency-advice.ts +++ b/packages/mcp/src/tools/get-efficiency-advice.ts @@ -8,7 +8,7 @@ import { import { MODEL_PRICING } from '@tokenleak/registry'; import type { ProviderRegistry } from '@tokenleak/registry'; import { resolveRange } from '../shared/date-range.js'; -import { loadProviderData } from '../shared/provider-load.js'; +import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js'; export async function handleGetEfficiencyAdvice( args: { days?: number; since?: string; until?: string; provider?: string }, @@ -16,10 +16,7 @@ export async function handleGetEfficiencyAdvice( ) { try { const range = resolveRange(args); - const available = await registry.getAvailable(); - const filtered = args.provider - ? available.filter((p) => p.name === args.provider) - : available; + const filtered = await getAvailableProvidersForRequest(registry, args.provider); const { data, warnings } = await loadProviderData(filtered, range); diff --git a/packages/mcp/src/tools/get-receipt-lines.ts b/packages/mcp/src/tools/get-receipt-lines.ts index 946de1d..da14a5f 100644 --- a/packages/mcp/src/tools/get-receipt-lines.ts +++ b/packages/mcp/src/tools/get-receipt-lines.ts @@ -2,7 +2,7 @@ import { buildReceipt, SCHEMA_VERSION } from '@tokenleak/core'; import type { UsageEvent } from '@tokenleak/core'; import type { ProviderRegistry } from '@tokenleak/registry'; import { resolveRange } from '../shared/date-range.js'; -import { loadProviderData } from '../shared/provider-load.js'; +import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js'; export async function handleGetReceiptLines( args: { days?: number; since?: string; until?: string; provider?: string; topLines?: number }, @@ -10,10 +10,7 @@ export async function handleGetReceiptLines( ) { try { const range = resolveRange(args); - const available = await registry.getAvailable(); - const filtered = args.provider - ? available.filter((p) => p.name === args.provider) - : available; + const filtered = await getAvailableProvidersForRequest(registry, args.provider); const { data, warnings } = await loadProviderData(filtered, range); diff --git a/packages/mcp/src/tools/get-streaks-and-habits.ts b/packages/mcp/src/tools/get-streaks-and-habits.ts index e83f942..23b8853 100644 --- a/packages/mcp/src/tools/get-streaks-and-habits.ts +++ b/packages/mcp/src/tools/get-streaks-and-habits.ts @@ -1,7 +1,7 @@ import { aggregate, mergeProviderData, buildMoreStats } from '@tokenleak/core'; import type { ProviderRegistry } from '@tokenleak/registry'; import { resolveRange } from '../shared/date-range.js'; -import { loadProviderData } from '../shared/provider-load.js'; +import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js'; const DEFAULT_HABITS_DAYS = 90; @@ -11,7 +11,7 @@ export async function handleGetStreaksAndHabits( ) { try { const range = resolveRange(args, DEFAULT_HABITS_DAYS); - const available = await registry.getAvailable(); + const available = await getAvailableProvidersForRequest(registry); const { data, warnings } = await loadProviderData(available, range); diff --git a/packages/mcp/src/tools/get-usage-summary.ts b/packages/mcp/src/tools/get-usage-summary.ts index df66756..72130da 100644 --- a/packages/mcp/src/tools/get-usage-summary.ts +++ b/packages/mcp/src/tools/get-usage-summary.ts @@ -5,7 +5,11 @@ import { } from '@tokenleak/core'; import type { ProviderRegistry } from '@tokenleak/registry'; import { resolveRange } from '../shared/date-range.js'; -import { loadProviderData, summarizeProviderData } from '../shared/provider-load.js'; +import { + getAvailableProvidersForRequest, + loadProviderData, + summarizeProviderData, +} from '../shared/provider-load.js'; export async function handleGetUsageSummary( args: { days?: number; since?: string; until?: string; provider?: string }, @@ -13,10 +17,7 @@ export async function handleGetUsageSummary( ) { try { const range = resolveRange(args); - const available = await registry.getAvailable(); - const filtered = args.provider - ? available.filter((p) => p.name === args.provider) - : available; + const filtered = await getAvailableProvidersForRequest(registry, args.provider); const { data, warnings } = await loadProviderData(filtered, range); diff --git a/packages/registry/src/providers/hermes.ts b/packages/registry/src/providers/hermes.ts index c1d3e35..70b67eb 100644 --- a/packages/registry/src/providers/hermes.ts +++ b/packages/registry/src/providers/hermes.ts @@ -145,7 +145,11 @@ function rowToRecord(row: HermesRow, range: DateRange): LocalUsageRecord | null nonNegativeNumber(row.reasoning_tokens); const cacheReadTokens = nonNegativeNumber(row.cache_read_tokens); const cacheWriteTokens = nonNegativeNumber(row.cache_write_tokens); - if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) { + const explicitCost = safeNumber(row.actual_cost_usd) ?? safeNumber(row.estimated_cost_usd) ?? undefined; + if ( + inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0 && + !(explicitCost !== undefined && explicitCost > 0) + ) { return null; } @@ -157,7 +161,7 @@ function rowToRecord(row: HermesRow, range: DateRange): LocalUsageRecord | null outputTokens, cacheReadTokens, cacheWriteTokens, - explicitCost: safeNumber(row.actual_cost_usd) ?? safeNumber(row.estimated_cost_usd) ?? undefined, + explicitCost, sessionId: row.id, projectId: stringValue(row.billing_provider) ?? undefined, }; diff --git a/packages/registry/src/providers/provider-parity.test.ts b/packages/registry/src/providers/provider-parity.test.ts index b9e28e5..43c584f 100644 --- a/packages/registry/src/providers/provider-parity.test.ts +++ b/packages/registry/src/providers/provider-parity.test.ts @@ -397,6 +397,36 @@ describe('provider parity providers', () => { expect(data.events?.[0]?.sessionId).toBe('hermes-minimal-session'); }); + it('preserves Hermes provider-reported cost when token columns are absent', async () => { + const root = tempDir('hermes-cost-only'); + const dbPath = join(root, 'state.db'); + const db = new Database(dbPath); + db.run(` + CREATE TABLE sessions ( + id TEXT, + model TEXT, + started_at REAL, + actual_cost_usd REAL + ) + `); + db.run(` + INSERT INTO sessions VALUES ( + 'hermes-cost-only-session', + 'claude-sonnet-4', + 1770724800, + 1.25 + ) + `); + db.close(); + + const data = await new HermesProvider(dbPath).load(RANGE); + + expect(data.provider).toBe('hermes'); + expect(data.totalTokens).toBe(0); + expect(data.totalCost).toBe(1.25); + expect(data.events?.[0]?.sessionId).toBe('hermes-cost-only-session'); + }); + it('loads Codebuff chat message usage from project chat files', async () => { const root = tempDir('codebuff'); const chatDir = join(root, 'projects', 'repo-a', 'chats', '2026-02-10T12-00-00.000Z'); @@ -698,6 +728,50 @@ describe('provider parity providers', () => { expect(data.events?.[0]?.projectId).toBe('zed'); }); + it('loads Zed threads when TOKENLEAK_ZED_DIR points at the data directory', async () => { + const previousEnv = process.env; + const root = tempDir('zed-env-dir'); + const threadsDir = join(root, 'threads'); + mkdirSync(threadsDir, { recursive: true }); + const dbPath = join(threadsDir, 'threads.db'); + const db = new Database(dbPath); + db.run(` + CREATE TABLE threads ( + id TEXT, + updated_at TEXT, + folder_paths TEXT, + folder_paths_order TEXT, + data_type TEXT, + data BLOB + ) + `); + db.run('INSERT INTO threads VALUES (?, ?, ?, ?, ?, ?)', [ + 'zed-env-hosted', + '2026-02-10T12:00:00.000Z', + JSON.stringify(['/repo/zed-env']), + JSON.stringify([0]), + 'json', + Buffer.from(JSON.stringify({ + model: { provider: 'zed.dev', model: 'claude-sonnet-4' }, + usage: { input_tokens: 5, output_tokens: 4 }, + })), + ]); + db.close(); + + try { + process.env = { ...process.env, TOKENLEAK_ZED_DIR: root }; + const provider = new ZedProvider(); + const data = await provider.load(RANGE); + + expect(await provider.isAvailable()).toBe(true); + expect(data.provider).toBe('zed'); + expect(data.totalTokens).toBe(9); + expect(data.events?.[0]?.sessionId).toBe('zed-env-hosted'); + } finally { + process.env = previousEnv; + } + }); + it('loads Kiro CLI file sessions with explicit turn token counts', async () => { const root = tempDir('kiro'); mkdirSync(root, { recursive: true }); diff --git a/packages/registry/src/providers/zed.ts b/packages/registry/src/providers/zed.ts index b09c58d..9af5d97 100644 --- a/packages/registry/src/providers/zed.ts +++ b/packages/registry/src/providers/zed.ts @@ -36,7 +36,12 @@ interface ZedRow { } function defaultDbPaths(): string[] { - if (process.env['TOKENLEAK_ZED_DIR']) return [process.env['TOKENLEAK_ZED_DIR']]; + const override = process.env['TOKENLEAK_ZED_DIR']; + if (override) { + return override.endsWith('.db') + ? [override] + : [join(override, 'threads', 'threads.db'), join(override, 'threads.db')]; + } const paths = [ join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'zed', 'threads', 'threads.db'), join(homedir(), 'Library', 'Application Support', 'Zed', 'threads', 'threads.db'), diff --git a/packages/tui/src/lib/data.ts b/packages/tui/src/lib/data.ts index 671cba9..7eab2b3 100644 --- a/packages/tui/src/lib/data.ts +++ b/packages/tui/src/lib/data.ts @@ -63,7 +63,6 @@ import { ZedProvider, KiroProvider, TraeProvider, - SyntheticProvider, OpenCodeProvider, PiProvider, MODEL_PRICING, @@ -257,7 +256,6 @@ function createRegistry(): ProviderRegistry { registry.register(new ZedProvider()); registry.register(new KiroProvider()); registry.register(new TraeProvider()); - registry.register(new SyntheticProvider()); registry.register(new OpenCodeProvider()); registry.register(new PiProvider()); return registry;