Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/providers/zed.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ 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:

- `model`: `{ "provider": ..., "model": ... }`
- `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
Expand All @@ -32,18 +34,18 @@ None.

## Deduplication

Per `zed:<threadId>:<requestKey>` (`zed.ts:96`), where `requestKey` is the user-message id from `request_token_usage` or the synthetic `cumulative-remainder`.
Per `zed:<threadId>:<requestKey>` (`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` = the normalized folder path, `projectIdentity` = the same stable identity, `project` = the folder basename display label); a thread with two or more entries maps to a synthetic project named after the joined basenames (`codeburn, website`, matching `ProjectGroupKey::display_name`), with an empty `projectPath` and `projectIdentity` = the sorted normalized root-set joined by newlines. The root-set is an aggregation key, not a filesystem path, so identical basenames on distinct machines (`/Users/alice/repo` vs `/Users/bob/repo`) never merge; 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.
1 change: 1 addition & 0 deletions src/act/model-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function isDebuggingHeavy(project: ProjectSummary): boolean {
}

export function recommendModelDefault(project: ProjectSummary, opts: { now?: Date } = {}): ModelDefaultRecommendation | null {
if (!project.projectPath) return null
const now = opts.now ?? new Date()
const stats = aggregateModelStats([project])
.filter(s => s.model !== '<synthetic>' && s.editTurns >= MIN_EDIT_TURNS)
Expand Down
58 changes: 40 additions & 18 deletions src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'

// Project day rollups are keyed by the stable identity (the project's path,
// or the joined root-set identity for multi-folder Zed workspaces) rather than the
// display label, which was not unique: two Zed threads rooted at
// /Users/alice/repo and /Users/bob/repo both displayed as "repo" and would
// otherwise merge into one daily entry under the first-seen path.
//
// 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
Expand Down Expand Up @@ -67,8 +80,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
Expand All @@ -88,10 +101,10 @@ export type ModelDayStats = {

export type CategoryDayStats = { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number }

/// `path` is the project's filesystem path when known — it is what display
/// layers derive a friendly name from once the sessions that carried the
/// mapping are gone.
export type ProjectDayStats = { cost: number; calls: number; savingsUSD: number; sessions: number; path?: string }
/// `path` is the project's filesystem path when known. `name` preserves a
/// display label for identities such as multi-root workspaces that have no
/// single filesystem path.
export type ProjectDayStats = { cost: number; calls: number; savingsUSD: number; sessions: number; path?: string; name?: string }

export type ProviderDaySlice = {
calls: number
Expand Down Expand Up @@ -275,19 +288,20 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
if (!isRecord(raw)) return {}
const out: NonNullable<DailyEntry['projects']> = {}
for (const [name, p] of Object.entries(raw)) {
// A project key is a directory basename, so it can legitimately be a
// prototype-member name ("constructor", "valueOf", ...). `setOwn` writes it
// as an own property via defineProperty, so keeping it is pollution-safe —
// and dropping it would silently subtract that project's cost from a
// --project/--exclude total (the day's split would no longer sum to its own
// cost, which the filtered headline relies on).
// An identity key can legitimately be a prototype-member name
// ("constructor", "valueOf", ...). `setOwn` writes it as an own property
// via defineProperty, so keeping it is pollution-safe — and dropping it
// would silently subtract that project's cost from a --project/--exclude
// total (the day's split would no longer sum to its own cost, which the
// filtered headline relies on).
if (!isRecord(p)) continue
setOwn(out, name, {
cost: num(p.cost),
calls: num(p.calls),
savingsUSD: num(p.savingsUSD),
sessions: num(p.sessions),
...(typeof p.path === 'string' && p.path.length > 0 ? { path: p.path } : {}),
...(typeof p.name === 'string' && p.name.length > 0 ? { name: p.name } : {}),
})
}
return Object.keys(out).length > 0 ? { projects: out } : {}
Expand Down Expand Up @@ -568,6 +582,7 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl
acc.calls += num(p.calls)
acc.savingsUSD += num(p.savingsUSD)
if (!acc.path && typeof p.path === 'string') acc.path = p.path
if (!acc.name && typeof p.name === 'string') acc.name = p.name
// Same session dedup as the slice-level sessions above: a placeholder's
// project sessions were already counted into the day when the fresh day
// was built, so only the excess is added.
Expand All @@ -580,12 +595,19 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl
const mergedProjects = merged.projects
if (mergedProjects) {
for (const [name, p] of Object.entries(placeholderProjects)) {
if (!p || typeof p !== 'object') continue
if (Object.hasOwn(mergedProjects, name)) {
if (num(p.sessions) > num(mergedProjects[name]!.sessions)) mergedProjects[name]!.sessions = num(p.sessions)
} else {
setOwn(mergedProjects, name, { cost: 0, calls: 0, savingsUSD: 0, sessions: num(p.sessions) })
}
if (!p || typeof p !== 'object') continue
if (Object.hasOwn(mergedProjects, name)) {
if (num(p.sessions) > num(mergedProjects[name]!.sessions)) mergedProjects[name]!.sessions = num(p.sessions)
if (!mergedProjects[name]!.name && typeof p.name === 'string') mergedProjects[name]!.name = p.name
} else {
setOwn(mergedProjects, name, {
cost: 0,
calls: 0,
savingsUSD: 0,
sessions: num(p.sessions),
...(typeof p.name === 'string' ? { name: p.name } : {}),
})
}
}
} else if (placeholder?.projects) {
merged.projects = structuredClone(placeholder.projects)
Expand Down
2 changes: 1 addition & 1 deletion src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ function ProjectBreakdown({ projects, pw, bw, budgets, rows = 14 }: { projects:
return (
<Text key={`${project.project}-${i}`} wrap="truncate-end">
<HBar value={project.totalCostUSD} max={maxCost} width={bw} />
<Text dimColor> {fit(shortProject(project.projectPath), nw)}</Text>
<Text dimColor> {fit(shortProject(project.projectPath || project.project), nw)}</Text>
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
<Text color={GOLD}>{avgCost.padStart(PROJECT_COL_AVG)}</Text>
<Text>{String(project.sessions.length).padStart(6)}</Text>
Expand Down
38 changes: 31 additions & 7 deletions src/day-aggregator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DailyEntry, ProjectDayStats, ProviderDaySlice } from './daily-cache.js'
import type { PeriodData } from './menubar-json.js'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { CATEGORY_LABELS, type ProjectSummary, type SessionSummary, type TaskCategory } from './types.js'
import { projectIdentityOf } from './project-identity.js'

function emptyEntry(date: string): DailyEntry {
return {
Expand Down Expand Up @@ -46,31 +47,54 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
if (!s) { s = emptySlice(); day.providers[provider] = s }
return s
}
const ensureProject = (holder: { projects?: Record<string, ProjectDayStats> }, project: string, path?: string): ProjectDayStats => {
const ensureProject = (
holder: { projects?: Record<string, ProjectDayStats> },
project: string,
path?: string,
name?: string,
): ProjectDayStats => {
const projects = (holder.projects ??= {})
// defineProperty so a project directory named "__proto__" becomes an own
// key instead of mutating the prototype link.
let p = Object.hasOwn(projects, project) ? projects[project] : undefined
if (!p) {
p = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0 }
p = {
cost: 0,
calls: 0,
savingsUSD: 0,
sessions: 0,
...(name ? { name } : {}),
}
Object.defineProperty(projects, project, { value: p, enumerable: true, writable: true, configurable: true })
}
if (!p.path && path) p.path = path
if (!p.name && name) p.name = name
return p
}

// Key per-day per-project rollups by the stable identity (path/root-set when
// known, display label otherwise) so two projects sharing a basename — e.g.
// Zed threads rooted at /Users/alice/repo and /Users/bob/repo, both labelled
// "repo" — stay separate in the persisted daily attribution instead of
// merging under the first-seen path. Multi-root labels are stored in `name`
// because their identity is not a filesystem path.
const projectRollupKey = (project: ProjectSummary, session: SessionSummary): string =>
projectIdentityOf(project, session.project)
const projectPath = (project: ProjectSummary): string | undefined => project.projectPath || undefined
const projectName = (project: ProjectSummary): string | undefined => project.projectPath ? undefined : project.project

for (const project of projects) {
for (const session of project.sessions) {
const sessionDate = dateKey(session.firstTimestamp)
const sessionDay = ensure(sessionDate)
sessionDay.sessions += 1
ensureProject(sessionDay, session.project, project.projectPath).sessions += 1
ensureProject(sessionDay, projectRollupKey(project, session), projectPath(project), projectName(project)).sessions += 1
// A session belongs to exactly one provider; its calls all carry it.
const sessionProvider = session.turns.flatMap(t => t.assistantCalls)[0]?.provider
if (sessionProvider) {
const slice = ensureSlice(sessionDay, sessionProvider)
slice.sessions! += 1
ensureProject(slice, session.project, project.projectPath).sessions += 1
ensureProject(slice, projectRollupKey(project, session), projectPath(project), projectName(project)).sessions += 1
}

for (const turn of session.turns) {
Expand Down Expand Up @@ -165,7 +189,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
callDay.cacheReadTokens += call.usage.cacheReadInputTokens
callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens

const dayProject = ensureProject(callDay, session.project, project.projectPath)
const dayProject = ensureProject(callDay, projectRollupKey(project, session), projectPath(project), projectName(project))
dayProject.cost += call.costUSD
dayProject.calls += 1
dayProject.savingsUSD += callSavings
Expand Down Expand Up @@ -193,7 +217,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
slice.cacheReadTokens! += call.usage.cacheReadInputTokens
slice.cacheWriteTokens! += call.usage.cacheCreationInputTokens

const sliceProject = ensureProject(slice, session.project, project.projectPath)
const sliceProject = ensureProject(slice, projectRollupKey(project, session), projectPath(project), projectName(project))
sliceProject.cost += call.costUSD
sliceProject.calls += 1
sliceProject.savingsUSD += callSavings
Expand Down
10 changes: 7 additions & 3 deletions src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ function pct(n: number, total: number): number {
return total > 0 ? round2((n / total) * 100) : 0
}

function projectLabel(project: ProjectSummary): string {
return project.projectPath || project.project
}

type DailyAgg = {
cost: number
savings: number
Expand Down Expand Up @@ -90,7 +94,7 @@ function buildRecordRows(projects: ProjectSummary[]): Row[] {
for (const turn of session.turns) {
for (const call of turn.assistantCalls) {
rows.push({
project: project.projectPath,
project: projectLabel(project),
sessionId: session.sessionId,
timestamp: call.timestamp || turn.timestamp || undefined,
category: turn.category,
Expand Down Expand Up @@ -245,7 +249,7 @@ function buildProjectRows(projects: ProjectSummary[]): Row[] {
.slice()
.sort((a, b) => (b.totalCostUSD + b.totalSavingsUSD) - (a.totalCostUSD + a.totalSavingsUSD))
.map(p => ({
Project: p.projectPath,
Project: projectLabel(p),
[`Cost (${code})`]: roundForActiveCurrency(convertCost(p.totalCostUSD)),
[`Saved (${code})`]: roundForActiveCurrency(convertCost(p.totalSavingsUSD)),
[`Avg/Session (${code})`]: p.sessions.length > 0 ? roundForActiveCurrency(convertCost(p.totalCostUSD / p.sessions.length)) : '',
Expand All @@ -264,7 +268,7 @@ function buildSessionRows(projects: ProjectSummary[]): Row[] {
s.turns.flatMap(turn => turn.assistantCalls.map(call => call.model).filter(Boolean)),
)
rows.push({
Project: p.projectPath,
Project: projectLabel(p),
'Session ID': s.sessionId,
'Started At': s.firstTimestamp ?? '',
[`Cost (${code})`]: roundForActiveCurrency(convertCost(s.totalCostUSD)),
Expand Down
3 changes: 2 additions & 1 deletion src/granular-history.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DateRange, ProjectSummary } from './types.js'
import { projectIdentityOf } from './project-identity.js'

const FIFTEEN_MINUTES = 15
const ONE_HOUR = 60
Expand Down Expand Up @@ -204,7 +205,7 @@ export function buildGranularHistory(
// Session ids are usually globally unique, but a few providers scope
// them to a workspace. Include the project path so two workspaces do
// not collapse into one line when they reuse the same local id.
const sessionKey = `${call.provider}\0${project.projectPath}\0${session.sessionId}`
const sessionKey = `${call.provider}\0${projectIdentityOf(project)}\0${session.sessionId}`
const projectName = session.project || project.project || 'Unknown project'

bucket.cost += cost
Expand Down
4 changes: 3 additions & 1 deletion src/guard/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ export async function buildFlags(projects: ProjectSummary[]): Promise<GuardFlags
const openers: string[] = []
if (lowWorth.has(project.project)) openers.push(LOW_WORTH_OPENER)
if (contextHeavy.has(project.project)) openers.push(CONTEXT_HEAVY_OPENER)
if (openers.length > 0) flags.push({ path: project.projectPath, openers })
// A multi-root workspace has no single settings path to install a guard
// against, so do not persist an empty path that could match unexpectedly.
if (openers.length > 0 && project.projectPath) flags.push({ path: project.projectPath, openers })
}
return { generatedAt: new Date().toISOString(), projects: flags }
}
Expand Down
Loading