diff --git a/src/providers/index.ts b/src/providers/index.ts index 8a0921c..fe81f42 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -171,11 +171,26 @@ async function loadZcode(): Promise { } } +let zedProvider: Provider | null = null +let zedLoadAttempted = false + +async function loadZed(): Promise { + if (zedLoadAttempted) return zedProvider + zedLoadAttempted = true + try { + const { zed } = await import('./zed.js') + zedProvider = zed + return zed + } catch { + return null + } +} + const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, rooCode, zerostack, grok] // Lazily loaded providers, listed by name so --provider validation works even // when an optional module fails to load. Must stay in sync with getAllProviders. -const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode'] +const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode', 'zed'] // Canonical set of every provider name (core + lazy), used to validate the // --provider CLI flag. Computed lazily so importing this module never depends on @@ -190,8 +205,8 @@ export function allProviderNames(): readonly string[] { } export async function getAllProviders(): Promise { - const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw, zc] = await Promise.all([ - loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), loadZcode(), + const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw, zc, zd] = await Promise.all([ + loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), loadZcode(), loadZed(), ]) const all = [...coreProviders] if (ag) all.push(ag) @@ -204,6 +219,7 @@ export async function getAllProviders(): Promise { if (warp) all.push(warp) if (vercelGw) all.push(vercelGw) if (zc) all.push(zc) + if (zd) all.push(zd) return all } @@ -263,5 +279,9 @@ export async function getProvider(name: string): Promise { const z = await loadZcode() return z ?? undefined } + if (name === 'zed') { + const z = await loadZed() + return z ?? undefined + } return coreProviders.find(p => p.name === name) } diff --git a/src/providers/zed.ts b/src/providers/zed.ts new file mode 100644 index 0000000..bcbf143 --- /dev/null +++ b/src/providers/zed.ts @@ -0,0 +1,209 @@ +import { existsSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' +import zlib from 'zlib' + +import { calculateCost } from '../models.js' +import { getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' + +// Zed's built-in agent stores one row per thread in a single SQLite database; +// the `data` blob is zstd-compressed JSON carrying `request_token_usage` +// (per-request Anthropic-shaped token counts) and the thread's model. +// Format documented in issue #480. + +// zstd landed in node:zlib in 22.15 / 23.8; the package floor is 22.13, so the +// provider degrades with a notice instead of assuming the export exists. +const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }).zstdDecompressSync + +function getZedThreadsDbPath(): string { + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Zed', 'threads', 'threads.db') + } + if (process.platform === 'win32') { + return join(homedir(), 'AppData', 'Local', 'Zed', 'threads', 'threads.db') + } + return join(homedir(), '.local', 'share', 'zed', 'threads', 'threads.db') +} + +const THREADS_QUERY = ` + SELECT id, summary, updated_at, data_type, data + FROM threads + ORDER BY updated_at ASC +` + +type ThreadRow = { + id: string + summary: string | null + updated_at: string | null + data_type: string | null + data: Uint8Array | null +} + +type TokenUsage = { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +} + +type ThreadJson = { + model?: { provider?: string; model?: string } + request_token_usage?: Record + cumulative_token_usage?: TokenUsage +} + +function num(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0 +} + +function usageIsEmpty(usage: TokenUsage): boolean { + return ( + num(usage.input_tokens) === 0 && + num(usage.output_tokens) === 0 && + num(usage.cache_creation_input_tokens) === 0 && + num(usage.cache_read_input_tokens) === 0 + ) +} + +function buildCall(opts: { + threadId: string + requestKey: string + usage: TokenUsage + model: string + timestamp: string + userMessage: string +}): ParsedProviderCall { + const input = num(opts.usage.input_tokens) + const output = num(opts.usage.output_tokens) + const cacheWrite = num(opts.usage.cache_creation_input_tokens) + const cacheRead = num(opts.usage.cache_read_input_tokens) + return { + provider: 'zed', + model: opts.model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: calculateCost(opts.model, input, output, cacheWrite, cacheRead, 0), + tools: [], + bashCommands: [], + timestamp: opts.timestamp, + speed: 'standard', + deduplicationKey: `zed:${opts.threadId}:${opts.requestKey}`, + userMessage: opts.userMessage, + sessionId: opts.threadId, + } +} + +function parseThreads(db: SqliteDatabase, seenKeys: Set): ParsedProviderCall[] { + const calls: ParsedProviderCall[] = [] + let skipped = 0 + + let rows: ThreadRow[] + try { + rows = db.query(THREADS_QUERY) + } catch { + return calls + } + + for (const row of rows) { + try { + if (!row.id || !row.data || row.data_type !== 'zstd') { + if (row.data != null) skipped++ + continue + } + const parsedAt = new Date(row.updated_at ?? '') + if (Number.isNaN(parsedAt.getTime())) continue + const timestamp = parsedAt.toISOString() + + const thread = JSON.parse(zstdDecompress!(Buffer.from(row.data)).toString('utf-8')) as ThreadJson + const model = thread.model?.model || 'unknown' + const userMessage = row.summary ?? '' + + const requests = Object.entries(thread.request_token_usage ?? {}).filter(([, usage]) => usage != null && !usageIsEmpty(usage)) + // In-progress threads can have an empty per-request map while the + // cumulative counter already carries tokens; fall back so they count. + const entries: Array<[string, TokenUsage]> = requests.length > 0 + ? requests + : thread.cumulative_token_usage && !usageIsEmpty(thread.cumulative_token_usage) + ? [['cumulative', thread.cumulative_token_usage]] + : [] + + for (const [requestKey, usage] of entries) { + const call = buildCall({ threadId: row.id, requestKey, usage, model, timestamp, userMessage }) + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + calls.push(call) + } + } catch { + skipped++ + } + } + + if (skipped > 0) { + process.stderr.write(`codeburn: skipped ${skipped} unreadable Zed threads\n`) + } + return calls +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + if (!zstdDecompress) { + process.stderr.write('codeburn: Zed threads need Node >= 22.15 (zstd support); skipping Zed usage.\n') + return + } + + let db: SqliteDatabase + try { + db = openDatabase(source.path) + } catch (err) { + process.stderr.write(`codeburn: cannot open Zed database: ${err instanceof Error ? err.message : err}\n`) + return + } + try { + for (const call of parseThreads(db, seenKeys)) { + yield call + } + } finally { + db.close() + } + }, + } +} + +export function createZedProvider(dbPathOverride?: string): Provider { + return { + name: 'zed', + displayName: 'Zed', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + const dbPath = dbPathOverride ?? getZedThreadsDbPath() + if (!existsSync(dbPath)) return [] + return [{ path: dbPath, project: 'zed', provider: 'zed' }] + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const zed = createZedProvider() diff --git a/tests/providers/zed.test.ts b/tests/providers/zed.test.ts new file mode 100644 index 0000000..7a80e8a --- /dev/null +++ b/tests/providers/zed.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'node:module' +import zlib from 'zlib' + +import { createZedProvider } from '../../src/providers/zed.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +const zstd = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync + +const skipReason = !isSqliteAvailable() + ? 'node:sqlite not available — needs Node 22+; skipping' + : !zstd + ? 'zlib zstd not available — needs Node 22.15+; skipping' + : null + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'zed-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function buildDb(fn: (db: { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +}) => void): string { + const dbPath = join(tmpDir, 'threads.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, + summary TEXT NOT NULL, + updated_at TEXT NOT NULL, + data_type TEXT NOT NULL, + data BLOB NOT NULL, + parent_id TEXT, folder_paths TEXT, folder_paths_order TEXT, created_at TEXT + )`) + fn(db) + db.close() + return dbPath +} + +function insertThread(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + id: string + summary?: string + updatedAt?: string + dataType?: string + thread?: unknown + rawData?: Buffer +}): void { + const data = opts.rawData ?? zstd!(Buffer.from(JSON.stringify(opts.thread ?? {}))) + db.prepare('INSERT INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)').run( + opts.id, + opts.summary ?? 'a thread', + opts.updatedAt ?? '2026-06-20T10:00:00Z', + opts.dataType ?? 'zstd', + data, + ) +} + +async function collectCalls(dbPath: string, seenKeys = new Set()): Promise { + const provider = createZedProvider(dbPath) + const sources = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + calls.push(call) + } + } + return calls +} + +describe.skipIf(skipReason !== null)('zed provider (#480)', () => { + it('emits one call per request with exact Anthropic-shaped token fields', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-1', + summary: 'refactor the parser', + updatedAt: '2026-06-21T09:30:00Z', + thread: { + model: { provider: 'anthropic', model: 'claude-opus-4-8' }, + request_token_usage: { + 'req-1': { input_tokens: 1200, output_tokens: 300, cache_creation_input_tokens: 5000, cache_read_input_tokens: 90000 }, + 'req-2': { input_tokens: 800, output_tokens: 150, cache_creation_input_tokens: 0, cache_read_input_tokens: 95000 }, + }, + cumulative_token_usage: { input_tokens: 2000, output_tokens: 450 }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(2) + const first = calls.find(c => c.deduplicationKey === 'zed:thread-1:req-1') + expect(first).toBeDefined() + expect(first!.inputTokens).toBe(1200) + expect(first!.outputTokens).toBe(300) + expect(first!.cacheCreationInputTokens).toBe(5000) + expect(first!.cacheReadInputTokens).toBe(90000) + expect(first!.model).toBe('claude-opus-4-8') + expect(first!.costUSD).toBeGreaterThan(0) + expect(first!.sessionId).toBe('thread-1') + expect(first!.userMessage).toBe('refactor the parser') + expect(first!.timestamp).toBe('2026-06-21T09:30:00.000Z') + // Cumulative must not be counted on top of per-request entries. + expect(calls.reduce((s, c) => s + c.inputTokens, 0)).toBe(2000) + }) + + it('falls back to cumulative_token_usage when the per-request map is empty', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-2', + thread: { + model: { provider: 'anthropic', model: 'claude-sonnet-4-6' }, + request_token_usage: {}, + cumulative_token_usage: { input_tokens: 5400, output_tokens: 900, cache_read_input_tokens: 20000 }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(1) + expect(calls[0]!.deduplicationKey).toBe('zed:thread-2:cumulative') + expect(calls[0]!.inputTokens).toBe(5400) + expect(calls[0]!.cacheReadInputTokens).toBe(20000) + }) + + it('skips non-zstd rows and malformed blobs without dropping healthy threads', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { id: 'bad-type', dataType: 'json', rawData: Buffer.from('{}') }) + insertThread(db, { id: 'bad-blob', rawData: Buffer.from('not zstd at all') }) + insertThread(db, { + id: 'good', + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 10, output_tokens: 5 } }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(1) + expect(calls[0]!.sessionId).toBe('good') + }) + + it('dedupes across repeat parses via the shared seenKeys set', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-3', + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } }, + }, + }) + }) + + const seen = new Set() + expect((await collectCalls(dbPath, seen)).length).toBe(1) + expect((await collectCalls(dbPath, seen)).length).toBe(0) + }) + + it('skips threads whose usage is entirely zero instead of emitting empty calls', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-4', + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 0, output_tokens: 0 } }, + cumulative_token_usage: { input_tokens: 0, output_tokens: 0 }, + }, + }) + }) + + expect((await collectCalls(dbPath)).length).toBe(0) + }) + + it('discovers nothing when the database does not exist', async () => { + const provider = createZedProvider(join(tmpDir, 'missing.db')) + expect(await provider.discoverSessions()).toEqual([]) + }) +})