From 8b9b35a0b78b8d3f3f9b2303c88fbbb401fad0a7 Mon Sep 17 00:00:00 2001 From: Vlad Ionescu Date: Wed, 5 Aug 2026 14:43:06 +0300 Subject: [PATCH] feat(zed): add per-project attribution --- docs/providers/zed.md | 12 +-- src/daily-cache.ts | 11 ++- src/providers/zed.ts | 98 +++++++++++++++++++- src/session-cache.ts | 6 ++ tests/providers/zed.test.ts | 174 ++++++++++++++++++++++++++++++++---- 5 files changed, 276 insertions(+), 25 deletions(-) diff --git a/docs/providers/zed.md b/docs/providers/zed.md index 5c6d8b95..2b626733 100644 --- a/docs/providers/zed.md +++ b/docs/providers/zed.md @@ -16,7 +16,7 @@ One SQLite database with one row per agent thread (`zed.ts:19`): ## Storage format -The `threads` table stores each thread's `data` BLOB as zstd-compressed JSON (`data_type = "zstd"`; legacy rows may be uncompressed `"json"`, both are read, `zed.ts:117-127`). Decompression uses Node's built-in `zlib.zstdDecompressSync` (`zed.ts:17`), no extra dependency. +The `threads` table stores each thread's `data` BLOB as zstd-compressed JSON (`data_type = "zstd"`; legacy rows may be uncompressed `"json"`, both are read, `zed.ts:153-165`). Decompression uses Node's built-in `zlib.zstdDecompressSync` (`zed.ts:17`), no extra dependency. The decompressed thread JSON carries: @@ -24,6 +24,8 @@ The decompressed thread JSON carries: - `request_token_usage`: map of user-message id to `{ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens }` (zero-valued fields are omitted) - `cumulative_token_usage`: same shape, whole-thread totals +Each row's `folder_paths` column carries the workspace folder roots the thread was created against — absolute paths, one per line, lexicographically sorted (Zed's `PathList` serialization; the `folder_paths_order` column is display-only and unused here). The column is absent on databases written by older Zed, which added it via `ALTER TABLE`; the parser detects it with `PRAGMA table_info` and degrades gracefully (`zed.ts:94-101`). + Token semantics match Anthropic's (separate cache-creation and cache-read fields), so pricing maps directly onto the LiteLLM engine. Shapes verified against Zed's serialization source (`crates/agent/src/db.rs`: `DbThread`, `TokenUsage`, `SerializedLanguageModel`, `DataType`) and a real store. ## Caching @@ -32,18 +34,18 @@ None. ## Deduplication -Per `zed::` (`zed.ts:96`), where `requestKey` is the user-message id from `request_token_usage` or the synthetic `cumulative-remainder`. +Per `zed::` (`zed.ts:132`), where `requestKey` is the user-message id from `request_token_usage` or the synthetic `cumulative-remainder`. ## Quirks -- `request_token_usage` is keyed by user message and does not cover every request a thread made (verified on a real thread: cumulative was ~3x the map sum). One remainder entry per thread tops usage up to the exact `cumulative_token_usage` (`zed.ts:133-153`), so totals always match the store. +- `request_token_usage` is keyed by user message and does not cover every request a thread made (verified on a real thread: cumulative was ~3x the map sum). One remainder entry per thread tops usage up to the exact `cumulative_token_usage` (`zed.ts:170-192`), so totals always match the store. - The per-request map carries no timestamps, so every call in a thread uses the thread's `updated_at`; day-level attribution inside long-running threads is approximate. - Node's zlib gained zstd in 22.15. On older Nodes the provider skips with a notice instead of failing (`zed.ts:14-17`). -- All Zed usage currently lands under a single `zed` project; `folder_paths` is not yet mapped to per-project attribution. +- Project attribution mirrors Zed's own sidebar grouping (`zed.ts:79-101`): a thread with exactly one `folder_paths` entry maps to that folder's project (`projectPath` = folder path, `project` = folder basename); a thread with two or more entries maps to a synthetic project named after the joined basenames (`codeburn, website`, matching `ProjectGroupKey::display_name`), with no `projectPath` since a multi-folder set has no one path; rows without the column (older schemas) keep the single `zed` bucket. Zed records which workspace roots a thread was created against, but not which folder it actually used. ## When fixing a bug here -1. If discovery returns no sessions, confirm `threads.db` exists at the platform path and the `threads` table still has `id`, `summary`, `updated_at`, `data_type`, `data`. +1. If discovery returns no sessions, confirm `threads.db` exists at the platform path and the `threads` table still has `id`, `summary`, `updated_at`, `data_type`, `data` (`folder_paths` is optional and only read when present). 2. If threads are skipped, check `data_type` values on disk; only `zstd` and `json` are read. 3. If totals disagree with the store, compare against `cumulative_token_usage` per thread; the remainder logic must bring each thread exactly to it. 4. If model names stop pricing, inspect `model.model` strings in a real thread and add aliases if Zed introduces new hosted-model ids. diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 1c6bf57b..1e18bd99 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -5,6 +5,13 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' +// Bumped to 17: Zed threads now attribute to their recorded workspace +// folder(s) — a single folder becomes that project, multi-folder workspaces +// become a joined-basename project ("codeburn, website") — instead of the +// shared `zed` bucket, so days finalized at v16 carry the old single-project +// split. Raising MIN_SUPPORTED_VERSION forces the one-time re-derivation of +// days whose threads.db still exists. +// // Bumped to 16: Codex discovery is structural instead of originator-gated // (#873/#626), so rollouts written by third-party frontends driving // `codex app-server` ("t3code_desktop", "JetBrains.IntelliJ IDEA", ...) now @@ -67,8 +74,8 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 16 -const MIN_SUPPORTED_VERSION = 16 +export const DAILY_CACHE_VERSION = 17 +const MIN_SUPPORTED_VERSION = 17 // Version-suffixed so different binaries each own a distinct file and never // clobber an incompatible schema. Bumping the version mints a fresh filename; // adoptOlderDailyCaches then unions days out of every previous file (including diff --git a/src/providers/zed.ts b/src/providers/zed.ts index 7164149e..8f6666eb 100644 --- a/src/providers/zed.ts +++ b/src/providers/zed.ts @@ -32,12 +32,22 @@ const THREADS_QUERY = ` ORDER BY updated_at ASC ` +// Newer Zed adds `folder_paths` (newline-separated absolute workspace roots, +// lexicographically sorted) via ALTER TABLE, so databases written by older +// versions do not have the column. Query it only when the schema has it. +const THREADS_QUERY_WITH_FOLDER_PATHS = ` + SELECT id, summary, updated_at, data_type, data, folder_paths + 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 + folder_paths: unknown } type TokenUsage = { @@ -66,6 +76,75 @@ function usageIsEmpty(usage: TokenUsage): boolean { ) } +// A thread carries the workspace folder roots it was created against as a +// newline-separated list. The display label is intentionally kept separate +// from the stable path-set key because basenames are not unique. +type ThreadProject = { + project: string + projectPath?: string +} + +function pathComponents(path: string): string[] { + const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '') + if (!normalized || /^[A-Za-z]:$/.test(normalized)) return [] + const components = normalized.split('/').filter(Boolean) + const last = components.length - 1 + if (last >= 0 && components[last]!.endsWith('.git')) { + components[last] = components[last]!.slice(0, -'.git'.length) + } + return components.filter(Boolean) +} + +function pathDisplaySuffix(path: string, detail: number): string { + return pathComponents(path).slice(-(detail + 1)).join('/') +} + +function displayNames(paths: string[]): string[] { + const names = paths.map(path => pathDisplaySuffix(path, 0)) + const counts = new Map() + for (const name of names) { + if (name) counts.set(name, (counts.get(name) ?? 0) + 1) + } + + return paths.map((path, index) => { + const name = names[index] ?? '' + if (!name || counts.get(name) === 1) return name + + const components = pathComponents(path) + for (let detail = 1; detail < components.length; detail++) { + const candidate = pathDisplaySuffix(path, detail) + const conflicts = paths.some((otherPath, otherIndex) => { + if (otherIndex === index) return false + const otherName = names[otherIndex] ?? '' + return otherName === name && pathDisplaySuffix(otherPath, detail) === candidate + }) + if (!conflicts) return candidate + } + return pathDisplaySuffix(path, components.length - 1) || name + }) +} + +function resolveThreadProject(folderPaths: unknown): ThreadProject | undefined { + if (typeof folderPaths !== 'string') return undefined + const paths = folderPaths.split('\n').map(p => p.trim()).filter(Boolean) + if (paths.length === 0) return undefined + + const names = displayNames(paths).filter(Boolean) + return { + project: names.length > 0 ? names.join(', ') : 'Empty Workspace', + ...(paths.length === 1 ? { projectPath: paths[0]! } : {}), + } +} + +function hasFolderPathsColumn(db: SqliteDatabase): boolean { + try { + const columns = db.query<{ name: string }>('PRAGMA table_info(threads)') + return columns.some(c => c.name === 'folder_paths') + } catch { + return false + } +} + function buildCall(opts: { threadId: string requestKey: string @@ -73,6 +152,8 @@ function buildCall(opts: { model: string timestamp: string userMessage: string + project?: string + projectPath?: string }): ParsedProviderCall { const input = num(opts.usage.input_tokens) const output = num(opts.usage.output_tokens) @@ -96,16 +177,19 @@ function buildCall(opts: { deduplicationKey: `zed:${opts.threadId}:${opts.requestKey}`, userMessage: opts.userMessage, sessionId: opts.threadId, + ...(opts.project ? { project: opts.project } : {}), + ...(opts.projectPath ? { projectPath: opts.projectPath } : {}), } } function parseThreads(db: SqliteDatabase, seenKeys: Set): ParsedProviderCall[] { const calls: ParsedProviderCall[] = [] let skipped = 0 + const withFolderPaths = hasFolderPathsColumn(db) let rows: ThreadRow[] try { - rows = db.query(THREADS_QUERY) + rows = db.query(withFolderPaths ? THREADS_QUERY_WITH_FOLDER_PATHS : THREADS_QUERY) } catch { return calls } @@ -153,8 +237,18 @@ function parseThreads(db: SqliteDatabase, seenKeys: Set): ParsedProvider if (!usageIsEmpty(remainder)) entries.push(['cumulative-remainder', remainder]) } + const project = resolveThreadProject(withFolderPaths ? row.folder_paths : null) + for (const [requestKey, usage] of entries) { - const call = buildCall({ threadId: row.id, requestKey, usage, model, timestamp, userMessage }) + const call = buildCall({ + threadId: row.id, + requestKey, + usage, + model, + timestamp, + userMessage, + ...(project ?? {}), + }) if (seenKeys.has(call.deduplicationKey)) continue seenKeys.add(call.deduplicationKey) calls.push(call) diff --git a/src/session-cache.ts b/src/session-cache.ts index 4d0e31b7..070ac508 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -237,6 +237,12 @@ export const PROVIDER_PARSE_VERSIONS: Record = { 'roo-code': 'worktree-project-grouping-v1', warp: 'worktree-project-grouping-v1-est-cost', antigravity: 'worktree-project-grouping-v5', + // folder-path-project-grouping-v1: threads attribute to the recorded + // workspace folder(s) — single folder becomes that project, multi-folder + // becomes a joined-basename project — instead of the shared `zed` bucket, + // so already-cached threads must re-parse once (the session cache would + // otherwise serve the old single-project turns without invoking the parser). + zed: 'folder-path-project-grouping-v1', } // ── Cache Dir ────────────────────────────────────────────────────────── diff --git a/tests/providers/zed.test.ts b/tests/providers/zed.test.ts index 4762cd78..27e5e4e0 100644 --- a/tests/providers/zed.test.ts +++ b/tests/providers/zed.test.ts @@ -33,18 +33,30 @@ function buildDb(fn: (db: { exec(sql: string): void prepare(sql: string): { run(...params: unknown[]): void } close(): void -}) => void): string { +}) => void, opts: { legacySchema?: boolean } = {}): 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 - )`) + if (opts.legacySchema) { + // Older Zed writes only the base columns; `folder_paths` was added later + // via ALTER TABLE, so databases opened by old versions lack it. + 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 + )`) + } else { + 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 @@ -59,15 +71,21 @@ function insertThread(db: { dataType?: string thread?: unknown rawData?: Buffer + folderPaths?: string[] }): 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, - ) + const summary = opts.summary ?? 'a thread' + const updatedAt = opts.updatedAt ?? '2026-06-20T10:00:00Z' + const dataType = opts.dataType ?? 'zstd' + if (opts.folderPaths !== undefined) { + db.prepare('INSERT INTO threads (id, summary, updated_at, data_type, data, folder_paths) VALUES (?, ?, ?, ?, ?, ?)').run( + opts.id, summary, updatedAt, dataType, data, opts.folderPaths.join('\n'), + ) + } else { + db.prepare('INSERT INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)').run( + opts.id, summary, updatedAt, dataType, data, + ) + } } async function collectCalls(dbPath: string, seenKeys = new Set()): Promise { @@ -198,6 +216,130 @@ describe.skipIf(skipReason !== null)('zed provider (#480)', () => { expect(calls[0]!.model).toBe('claude-sonnet-4-6') }) + it('attributes a single-folder thread to the recorded folder', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-single', + folderPaths: ['/Users/dev/codeburn'], + thread: { + model: { provider: 'anthropic', model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 1200, output_tokens: 300 } }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(1) + expect(calls[0]!.projectPath).toBe('/Users/dev/codeburn') + expect(calls[0]!.project).toBe('codeburn') + }) + + it('groups multi-folder threads under a joined-basename project name', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-multi', + folderPaths: ['/Users/dev/codeburn', '/Users/dev/website'], + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(1) + expect(calls[0]!.project).toBe('codeburn, website') + expect(calls[0]!.projectPath).toBeUndefined() + }) + + it('joins multi-folder basenames in stored order and drops empty names', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-triple', + folderPaths: ['/Users/dev/codeburn', '/Users/dev/design', '/Users/dev/website'], + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } }, + }, + }) + insertThread(db, { + id: 'thread-root-only', + folderPaths: ['/', '/Users/dev/website'], + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(2) + expect(calls.find(c => c.sessionId === 'thread-triple')!.project).toBe('codeburn, design, website') + expect(calls.find(c => c.sessionId === 'thread-triple')!.projectPath).toBeUndefined() + expect(calls.find(c => c.sessionId === 'thread-root-only')!.project).toBe('website') + expect(calls.find(c => c.sessionId === 'thread-root-only')!.projectPath).toBeUndefined() + }) + + it('falls back to the zed bucket when folder_paths is empty or absent', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-empty', + folderPaths: [], + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } }, + }, + }) + insertThread(db, { + id: 'thread-absent', + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(2) + expect(calls.every(c => c.projectPath === undefined && c.project === undefined)).toBe(true) + }) + + it('tolerates whitespace and trailing newlines in folder_paths', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-messy', + folderPaths: [' /Users/dev/codeburn ', ''], + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(1) + expect(calls[0]!.projectPath).toBe('/Users/dev/codeburn') + expect(calls[0]!.project).toBe('codeburn') + }) + + it('still parses databases without the folder_paths column (older Zed schemas)', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-old-schema', + thread: { + model: { model: 'claude-sonnet-4-6' }, + request_token_usage: { 'req-1': { input_tokens: 40, output_tokens: 8 } }, + }, + }) + }, { legacySchema: true }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(1) + expect(calls[0]!.model).toBe('claude-sonnet-4-6') + expect(calls[0]!.projectPath).toBeUndefined() + expect(calls[0]!.project).toBeUndefined() + }) + it('dedupes across repeat parses via the shared seenKeys set', async () => { const dbPath = buildDb((db) => { insertThread(db, {