diff --git a/docs/providers/zed.md b/docs/providers/zed.md index 5c6d8b95..21354e9a 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` = 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. diff --git a/src/act/model-defaults.ts b/src/act/model-defaults.ts index 537f6a0e..04ece511 100644 --- a/src/act/model-defaults.ts +++ b/src/act/model-defaults.ts @@ -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 !== '' && s.editTurns >= MIN_EDIT_TURNS) diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 1c6bf57b..b99b9a00 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -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 @@ -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 @@ -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 @@ -275,12 +288,12 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } { if (!isRecord(raw)) return {} const out: NonNullable = {} 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), @@ -288,6 +301,7 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } { 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 } : {} @@ -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. @@ -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) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index d46d785c..72aabd92 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -445,7 +445,7 @@ function ProjectBreakdown({ projects, pw, bw, budgets, rows = 14 }: { projects: return ( - {fit(shortProject(project.projectPath), nw)} + {fit(shortProject(project.projectPath || project.project), nw)} {formatCost(project.totalCostUSD).padStart(8)} {avgCost.padStart(PROJECT_COL_AVG)} {String(project.sessions.length).padStart(6)} diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index cdac6a20..b1f8b773 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -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 { @@ -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 }, project: string, path?: string): ProjectDayStats => { + const ensureProject = ( + holder: { projects?: Record }, + 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) { @@ -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 @@ -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 diff --git a/src/export.ts b/src/export.ts index 08cd795b..f179e7c1 100644 --- a/src/export.ts +++ b/src/export.ts @@ -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 @@ -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, @@ -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)) : '', @@ -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)), diff --git a/src/granular-history.ts b/src/granular-history.ts index aeecd565..12861267 100644 --- a/src/granular-history.ts +++ b/src/granular-history.ts @@ -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 @@ -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 diff --git a/src/guard/flags.ts b/src/guard/flags.ts index 00370458..9fc3ea7b 100644 --- a/src/guard/flags.ts +++ b/src/guard/flags.ts @@ -23,7 +23,9 @@ export async function buildFlags(projects: ProjectSummary[]): Promise 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 } } diff --git a/src/overview.ts b/src/overview.ts index 4f6cabdc..e9412a1d 100644 --- a/src/overview.ts +++ b/src/overview.ts @@ -35,12 +35,11 @@ function isAbsoluteProjectPath(path: string): boolean { } function projectName(p: ProjectSummary): string { const path = p.projectPath - if (path) { - if (path === homedir()) return 'Home' - if (!isAbsoluteProjectPath(path)) return p.project || path - const base = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop() - if (base) return base - } + if (!path) return p.project + if (path === homedir()) return 'Home' + if (!isAbsoluteProjectPath(path)) return p.project || path + const base = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop() + if (base) return base return p.project.split('-').filter(Boolean).pop() || p.project } diff --git a/src/parser.ts b/src/parser.ts index 004f25c2..a508fa68 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -47,6 +47,7 @@ import type { } from './types.js' import { classifyTurn, BASH_TOOLS, EDIT_TOOLS } from './classifier.js' import { extractBashCommands } from './bash-utils.js' +import { crossProviderProjectKey, normalizeProjectIdentity, projectIdentityOf } from './project-identity.js' function unsanitizePath(dirName: string): string { return dirName.replace(/-/g, '/') @@ -59,11 +60,6 @@ function claudeSlugFallbackPath(dirName: string): string { return dirName } -function normalizeProjectPathKey(projectPath: string): string { - const normalized = projectPath.trim().replace(/\\/g, '/') - return (normalized.replace(/\/+$/, '') || normalized).toLowerCase() -} - function projectNameFromPath(projectPath: string, fallback: string): string { const normalized = projectPath.trim().replace(/\\/g, '/').replace(/\/+$/, '') return normalized.split('/').filter(Boolean).pop() ?? fallback @@ -2263,7 +2259,7 @@ async function scanProjectDirs( if (session.apiCalls > 0 || anchorOnly) { const projectKey = cachedFile.canonicalCwd - ? normalizeProjectPathKey(cachedFile.canonicalCwd) + ? normalizeProjectIdentity(cachedFile.canonicalCwd) : `slug:${dirName}` const existing = projectMap.get(projectKey) // An anchor (no in-range spend) goes into a separate bucket, never `sessions`. @@ -2308,11 +2304,19 @@ async function scanProjectDirs( /// `totalProxiedCostUSD` (subscription-covered). All ProjectSummary callers go /// through here so the rule stays consistent across the fresh, cached, and /// date/day-filtered paths. -function summarizeProject(project: string, projectPath: string, sessions: SessionSummary[], anchors: SessionSummary[] = []): ProjectSummary { +function summarizeProject( + project: string, + projectPath: string, + sessions: SessionSummary[], + anchors: SessionSummary[] = [], + projectIdentity?: string, +): ProjectSummary { const totalCostUSD = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0) + const identity = projectIdentity || projectPath || project return { project, projectPath, + projectIdentity: identity, sessions, totalCostUSD, totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0), @@ -2399,6 +2403,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { deduplicationKey: call.deduplicationKey, project: call.project, projectPath: call.projectPath, + projectIdentity: call.projectIdentity, workingDirectory: call.workingDirectory, toolSequence: call.toolSequence, ...(call.locAdded ? { locAdded: call.locAdded } : {}), @@ -2421,6 +2426,7 @@ async function canonicalizeProviderCallProject(call: ParsedProviderCall): Promis workingDirectory: call.workingDirectory ?? call.projectPath, project: projectNameFromPath(canonical.path, call.project ?? canonical.path), projectPath: canonical.path, + projectIdentity: canonical.path, } } @@ -2859,7 +2865,10 @@ function classifiedTurnSlicedToDays(turn: ClassifiedTurn, days: Set): Cl return { ...turn, assistantCalls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp } } -async function parseProviderSources( +/// Parse one provider's sources into per-project summaries. Exported so +/// aggregation-level regressions (identical display labels on distinct roots) +/// can be asserted without spinning up the whole scan pipeline. +export async function parseProviderSources( providerName: string, sources: SessionSource[], seenKeys: Set, @@ -3058,7 +3067,7 @@ async function parseProviderSources( // Query-time: derive SessionSummary from all cached turns. // Uses seenKeys (shared across providers) for cross-provider dedup. - const sessionMap = new Map; title?: string }>() + const sessionMap = new Map; title?: string }>() for (const source of servedSources) { const cachedFile = section.files[source.path] @@ -3087,7 +3096,14 @@ async function parseProviderSources( ? (classifiedTurnSlicedToRange(classifiedFull, dateRange) ?? classifiedFull) : classifiedFull const project = slicedTurn.calls[0]?.project ?? source.project - const key = `${providerName}:${turn.sessionId}:${project}` + // Grouping identity: the stable project identity when the call carries + // one, falling back to the path and then display label. Label-only keying + // merged distinct roots that happened to share a basename (e.g. two + // machines both working in "repo") and kept the first project path. + const projectPath = slicedTurn.calls[0]?.projectPath + const projectIdentity = slicedTurn.calls[0]?.projectIdentity + const identity = projectIdentityOf(slicedTurn.calls[0] ?? {}, project) + const key = `${providerName}:${turn.sessionId}:${identity}` const existing = sessionMap.get(key) if (existing) { @@ -3095,6 +3111,9 @@ async function parseProviderSources( if (!existing.projectPath && slicedTurn.calls[0]?.projectPath) { existing.projectPath = slicedTurn.calls[0]!.projectPath } + if (!existing.projectIdentity && slicedTurn.calls[0]?.projectIdentity) { + existing.projectIdentity = slicedTurn.calls[0]!.projectIdentity + } if (!existing.workingDirectory && slicedTurn.calls[0]?.workingDirectory) existing.workingDirectory = slicedTurn.calls[0].workingDirectory if (cachedFile.prLinks?.length) { const links = (existing.prLinks ??= new Set()) @@ -3105,6 +3124,7 @@ async function parseProviderSources( sessionMap.set(key, { project, projectPath: slicedTurn.calls[0]?.projectPath, + projectIdentity: slicedTurn.calls[0]?.projectIdentity, workingDirectory: slicedTurn.calls[0]?.workingDirectory, turns: [classified], ...(cachedFile.prLinks?.length ? { prLinks: new Set(cachedFile.prLinks) } : {}), @@ -3142,7 +3162,10 @@ async function parseProviderSources( ? (classifiedTurnSlicedToRange(classifiedFull, dateRange) ?? classifiedFull) : classifiedFull const project = slicedTurn.calls[0]?.project ?? providerName - const key = `${providerName}:${turn.sessionId}:${project}` + const projectPath = slicedTurn.calls[0]?.projectPath + const projectIdentity = slicedTurn.calls[0]?.projectIdentity + const identity = projectIdentityOf(slicedTurn.calls[0] ?? {}, project) + const key = `${providerName}:${turn.sessionId}:${identity}` const existingEntry = sessionMap.get(key) if (existingEntry) { @@ -3150,15 +3173,28 @@ async function parseProviderSources( if (!existingEntry.projectPath && slicedTurn.calls[0]?.projectPath) { existingEntry.projectPath = slicedTurn.calls[0]!.projectPath } + if (!existingEntry.projectIdentity && slicedTurn.calls[0]?.projectIdentity) { + existingEntry.projectIdentity = slicedTurn.calls[0]!.projectIdentity + } } else { - sessionMap.set(key, { project, projectPath: slicedTurn.calls[0]?.projectPath, workingDirectory: slicedTurn.calls[0]?.workingDirectory, turns: [classified] }) + sessionMap.set(key, { + project, + projectPath: slicedTurn.calls[0]?.projectPath, + projectIdentity: slicedTurn.calls[0]?.projectIdentity, + workingDirectory: slicedTurn.calls[0]?.workingDirectory, + turns: [classified], + }) } } } } - const projectMap = new Map() - for (const [key, { project, projectPath, workingDirectory, turns, prLinks, title }] of sessionMap) { + // Merge sessions into per-project summaries keyed by the stable identity + // (project path / root-set when known, display label otherwise). The display + // label and real filesystem path are preserved separately so identical + // basenames on distinct roots yield separate projects. + const projectMap = new Map() + for (const [key, { project, projectPath, projectIdentity, workingDirectory, turns, prLinks, title }] of sessionMap) { const sessionId = key.split(':')[1] ?? key const session = buildSessionSummary(sessionId, project, turns) const explicitLinks = new Set(turns.flatMap(turn => turn.prRefs ?? [])) @@ -3170,19 +3206,21 @@ async function parseProviderSources( if (workingDirectory) session.workingDirectory = workingDirectory if (title) session.title = title if (session.apiCalls > 0) { - const existing = projectMap.get(project) + const identityKey = normalizeProjectIdentity(projectIdentityOf({ project, projectPath, projectIdentity })) + const existing = projectMap.get(identityKey) if (existing) { existing.sessions.push(session) if (!existing.projectPath && projectPath) existing.projectPath = projectPath + if (!existing.projectIdentity && projectIdentity) existing.projectIdentity = projectIdentity } else { - projectMap.set(project, { projectPath, sessions: [session] }) + projectMap.set(identityKey, { project, projectPath, projectIdentity, sessions: [session] }) } } } const projects: ProjectSummary[] = [] - for (const [dirName, { projectPath, sessions }] of projectMap) { - projects.push(summarizeProject(dirName, projectPath ?? unsanitizePath(dirName), sessions)) + for (const { project, projectPath, projectIdentity, sessions } of projectMap.values()) { + projects.push(summarizeProject(project, projectPath ?? (projectIdentity ? '' : unsanitizePath(project)), sessions, [], projectIdentity)) } return projects @@ -3230,7 +3268,8 @@ export function filterProjectsByName( result = result.filter(p => { const name = p.project.toLowerCase() const path = p.projectPath.toLowerCase() - return patterns.some(pat => name.includes(pat) || path.includes(pat)) + const identity = p.projectIdentity?.toLowerCase() ?? '' + return patterns.some(pat => name.includes(pat) || path.includes(pat) || identity.includes(pat)) }) } if (exclude && exclude.length > 0) { @@ -3238,7 +3277,8 @@ export function filterProjectsByName( result = result.filter(p => { const name = p.project.toLowerCase() const path = p.projectPath.toLowerCase() - return !patterns.some(pat => name.includes(pat) || path.includes(pat)) + const identity = p.projectIdentity?.toLowerCase() ?? '' + return !patterns.some(pat => name.includes(pat) || path.includes(pat) || identity.includes(pat)) }) } return result @@ -3395,7 +3435,7 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set b.totalCostUSD - a.totalCostUSD) } @@ -3408,16 +3448,14 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set { - const crossProviderKey = (p: ProjectSummary): string => { - const path = p.projectPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase() - return path.includes('/') ? path : p.project.toLowerCase() - } + const crossProviderKey = (p: ProjectSummary): string => crossProviderProjectKey(p) const mergedMap = new Map() for (const p of projects) { const key = crossProviderKey(p) const existing = mergedMap.get(key) if (existing) { existing.sessions.push(...p.sessions) + if (!existing.projectIdentity && p.projectIdentity) existing.projectIdentity = p.projectIdentity if (p.subagentAnchors?.length) existing.subagentAnchors = [...(existing.subagentAnchors ?? []), ...p.subagentAnchors] existing.totalCostUSD += p.totalCostUSD existing.totalEstimatedCostUSD = (existing.totalEstimatedCostUSD ?? 0) + (p.totalEstimatedCostUSD ?? 0) @@ -3570,7 +3608,7 @@ export function filterProjectsByClaudeConfigSource(projects: ProjectSummary[], s // config's children. const anchors = (project.subagentAnchors ?? []).filter(anchor => anchor.source?.id === sourceId) if (sessions.length === 0 && anchors.length === 0) continue - filtered.push(summarizeProject(project.project, project.projectPath, sessions, anchors)) + filtered.push(summarizeProject(project.project, project.projectPath, sessions, anchors, project.projectIdentity)) } return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD) } @@ -3604,7 +3642,7 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: } const dedupedAnchors = dedupeAnchors(anchors, survivingIdentities) if (sessions.length === 0 && dedupedAnchors.length === 0) continue - filtered.push(summarizeProject(project.project, project.projectPath, sessions, dedupedAnchors)) + filtered.push(summarizeProject(project.project, project.projectPath, sessions, dedupedAnchors, project.projectIdentity)) } return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD) } @@ -3831,13 +3869,19 @@ async function runParse( // Resolve at the ProjectSummary level here: prepend '/' if needed to get // an absolute path, then run the same worktree-detection logic. const resolvedOtherProjects = await Promise.all(otherProjects.map(async p => { + if (!p.projectPath) return p const absPath = p.projectPath.startsWith('/') || p.projectPath.startsWith('\\') ? p.projectPath : '/' + p.projectPath const canonical = await resolveCanonicalProjectPath(absPath) // Skip if path is unchanged: same location, not a worktree, not a subdir if (!canonical.isWorktree && canonical.path === absPath.replace(/[/\\]+$/, '')) return p - return { ...p, project: projectNameFromPath(canonical.path, p.project), projectPath: canonical.path } + return { + ...p, + project: projectNameFromPath(canonical.path, p.project), + projectPath: canonical.path, + projectIdentity: canonical.path, + } })) const mergedMap = mergeProjectsByCrossProviderKey([...claudeProjects, ...resolvedOtherProjects]) diff --git a/src/project-identity.ts b/src/project-identity.ts new file mode 100644 index 00000000..59a8edb4 --- /dev/null +++ b/src/project-identity.ts @@ -0,0 +1,31 @@ +export type ProjectIdentitySource = { + project?: string + projectPath?: string + projectIdentity?: string +} + +/// Normalize an aggregation identity without applying case folding on a +/// case-sensitive filesystem. Path separators are normalized everywhere so +/// provider-specific Windows spellings still converge. +export function normalizeProjectIdentity(identity: string, platform = process.platform): string { + const normalized = identity.trim().replace(/\\/g, '/') + const withoutTrailingSlash = normalized.replace(/\/+$/, '') + const stable = withoutTrailingSlash || normalized + const containsWindowsRoot = stable.split('\n').some(root => /^[A-Za-z]:\//.test(root)) + return platform === 'darwin' || platform === 'win32' || containsWindowsRoot + ? stable.toLowerCase() + : stable +} + +export function projectIdentityOf(source: ProjectIdentitySource, fallback = ''): string { + return source.projectIdentity || source.projectPath || source.project || fallback +} + +/// Cross-provider paths historically ignored leading slashes so Claude's +/// absolute paths and Codex's sanitized paths could merge. Keep that rule for +/// path identities while preserving case on case-sensitive platforms. +export function crossProviderProjectKey(source: ProjectIdentitySource): string { + const identity = normalizeProjectIdentity(projectIdentityOf(source)) + const path = identity.replace(/^\/+/, '') + return path.includes('/') ? path : (source.project ?? identity).toLowerCase() +} diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 7191c6fe..ce4efa8f 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -641,7 +641,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars // Guarded for the same reason as discoverSessionFile: parseCodexLine // hands back an unchecked JSON.parse cast, and a non-string cwd would // ride into projectPath/workingDirectory where the parser's path - // helpers (normalizeProjectPathKey, resolveCanonicalProjectPath) call + // helpers (normalizeProjectIdentity, resolveCanonicalProjectPath) call // string methods on it. const rawSessionCwd: unknown = entry.payload?.cwd if (typeof rawSessionCwd === 'string' && rawSessionCwd) sessionCwd = rawSessionCwd diff --git a/src/providers/types.ts b/src/providers/types.ts index 8f9e902c..4f189df5 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -50,6 +50,9 @@ export type ParsedProviderCall = { sessionId: string project?: string projectPath?: string + /// Stable aggregation identity, distinct from projectPath for multi-root + /// workspaces. It must not be treated as a filesystem path. + projectIdentity?: string // Exact provider-recorded cwd, kept separately because projectPath may later // canonicalize a linked worktree to its main repository. workingDirectory?: string diff --git a/src/providers/zed.ts b/src/providers/zed.ts index 7164149e..abc33ce2 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,106 @@ 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 root-set identity because basenames are not unique: two +// threads rooted at /Users/alice/repo and /Users/bob/repo both display as +// "repo" but must aggregate under distinct identities (single root: the +// normalized absolute path; multi-root: the sorted normalized roots joined by +// '\n'). `projectIdentity` carries that identity; `projectPath` is populated +// only for single-root threads because multi-root sets are not filesystem +// paths. +type ThreadProject = { + project: string + projectPath?: string + projectIdentity: 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('/') +} + +// Normalize one workspace root for the stable identity: slashes unified, +// trailing separators stripped, and the filesystem root ('/') kept intact so +// a root-only thread still carries a non-empty identity. +function normalizeRoot(path: string): string { + const trimmed = path.trim() + const unified = trimmed.replace(/\\/g, '/') + if (/^\/+$/u.test(unified)) return '/' + if (/^[A-Za-z]:\/+$/u.test(unified)) return `${unified.slice(0, 2)}/` + return unified.replace(/\/+$/, '') || trimmed || '/' +} + +// Stable root-set identity: ordered, deduplicated normalization of the thread's +// workspace roots. Distinct from the display label built by displayNames(), +// which keeps basenames and is therefore not unique across machines. +function projectIdentity(paths: string[]): string { + const roots = [...new Set(paths.map(normalizeRoot).filter(Boolean))].sort() + return roots.join('\n') +} + +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 + + // Display label and aggregation identity deliberately diverge: the label is + // the disambiguated basename list, the identity is the normalized root-set + // (a single normalized path, or the sorted roots joined by '\n'). + const normalizedRoots = [...new Set(paths.map(normalizeRoot))] + const names = displayNames(normalizedRoots).filter(Boolean) + return { + project: names.length > 0 ? names.join(', ') : 'Empty Workspace', + ...(normalizedRoots.length === 1 ? { projectPath: normalizedRoots[0]! } : {}), + projectIdentity: projectIdentity(normalizedRoots), + } +} + +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 +183,9 @@ function buildCall(opts: { model: string timestamp: string userMessage: string + project?: string + projectPath?: string + projectIdentity?: string }): ParsedProviderCall { const input = num(opts.usage.input_tokens) const output = num(opts.usage.output_tokens) @@ -96,16 +209,20 @@ 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 } : {}), + ...(opts.projectIdentity ? { projectIdentity: opts.projectIdentity } : {}), } } 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 +270,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..99897c9e 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -36,6 +36,7 @@ export type CachedCall = { deduplicationKey: string project?: string projectPath?: string + projectIdentity?: string workingDirectory?: string toolSequence?: ToolCall[][] // Rich-session-capture (capture-only; no report consumes these yet). All @@ -237,6 +238,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 ────────────────────────────────────────────────────────── @@ -355,6 +362,7 @@ function validateCall(c: unknown): c is CachedCall { && (o['subagentTypes'] === undefined || isStringArray(o['subagentTypes'])) && isOptionalString(o['project']) && isOptionalString(o['projectPath']) + && isOptionalString(o['projectIdentity']) && isOptionalString(o['workingDirectory']) && (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s)))) && isOptionalNum(o['locAdded']) diff --git a/src/types.ts b/src/types.ts index a51ae672..505a41de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -288,7 +288,12 @@ export type SessionSummary = { export type ProjectSummary = { project: string + /// A real filesystem path when the project has one. Multi-root workspaces + /// leave this empty because a root set is not a valid single path. projectPath: string + /// Stable aggregation identity. For a multi-root workspace this is the + /// normalized root-set serialization; it is never passed to filesystem APIs. + projectIdentity?: string sessions: SessionSummary[] totalCostUSD: number totalSavingsUSD: number diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 17dd2afe..5b37d695 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -14,6 +14,7 @@ import { buildPrAttribution, aggregateByBranch } from './sessions-report.js' import { scanAndDetect } from './optimize.js' import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js' import { buildGranularHistory } from './granular-history.js' +import { projectIdentityOf } from './project-identity.js' // Row caps for the by-PR / by-branch payload aggregations, ranked by cost. const TOP_BRANCHES = 15 @@ -228,15 +229,14 @@ function sliceDayToProvider(day: DailyEntry, provider: string): DailyEntry { /// Does a cached day's project entry pass the active name filters? Mirrors /// parser.filterProjectsByName exactly — case-insensitive substring match -/// against the project name OR its filesystem path, include first then exclude — -/// so a filter selects the same projects whether it is resolved against a fresh -/// parse or against the day cache. Patterns arrive pre-lowercased. `path` is -/// absent on entries whose sessions were gone before it could be recorded; the -/// name is then all there is to match on, as it is for the display layers. -function dayProjectMatches(name: string, path: string | undefined, include: string[], exclude: string[]): boolean { - const n = name.toLowerCase() - const p = (path ?? '').toLowerCase() - const hit = (pattern: string): boolean => n.includes(pattern) || (p !== '' && p.includes(pattern)) +/// against the project name, stable identity, or filesystem path, include first +/// then exclude, so a filter selects the same projects whether it is resolved +/// against a fresh parse or against the day cache. Patterns arrive pre-lowercased. +function dayProjectMatches(identity: string, project: ProjectDayStats, include: string[], exclude: string[]): boolean { + const candidates = [identity, project.name ?? '', project.path ?? ''] + .map(value => value.toLowerCase()) + .filter(Boolean) + const hit = (pattern: string): boolean => candidates.some(candidate => candidate.includes(pattern)) if (include.length > 0 && !include.some(hit)) return false if (exclude.length > 0 && exclude.some(hit)) return false return true @@ -252,7 +252,7 @@ function sumMatchingProjects( ): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record; matched: number } { const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record, matched: 0 } for (const [name, p] of Object.entries(projects)) { - if (!dayProjectMatches(name, p.path, include, exclude)) continue + if (!dayProjectMatches(name, p, include, exclude)) continue out.cost += p.cost out.calls += p.calls out.savingsUSD += p.savingsUSD ?? 0 @@ -751,7 +751,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: // path. Days recorded before the projects rollup existed have totals but // no project split, so this list can sum to less than the headline — an // honest gap, not a bug. - type CachedProjectTotal = { cost: number; savingsUSD: number; sessions: number; path?: string } + type CachedProjectTotal = { cost: number; savingsUSD: number; sessions: number; path?: string; name?: string } const cachedTotals = new Map() for (const d of cacheDaysForPeriod) { for (const [name, p] of Object.entries(d.projects ?? {})) { @@ -760,16 +760,20 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: acc.savingsUSD += p.savingsUSD acc.sessions += p.sessions if (!acc.path && p.path) acc.path = p.path + if (!acc.name && p.name) acc.name = p.name cachedTotals.set(name, acc) } } - const liveByName = new Map(scanProjects.map(p => [p.project, p])) + // Live projects are keyed by the same identity (path/root-set when known, + // label otherwise) the day entries use, so a cached-only and a live entry + // for the same workspace reconcile to one row instead of double counting. + const liveByName = new Map(scanProjects.map(p => [projectIdentityOf(p), p])) const names = new Set([...cachedTotals.keys(), ...liveByName.keys()]) currentData.projects = [...names].map(name => { const cached = cachedTotals.get(name) const live = liveByName.get(name) return { - name: live ? friendlyProject(live) : friendlyFromPath(cached?.path, name), + name: live ? friendlyProject(live) : cached?.name ?? friendlyFromPath(cached?.path, name), cost: cached?.cost ?? live!.totalCostUSD, savingsUSD: cached?.savingsUSD ?? live!.totalSavingsUSD, // max for the same reason as the headline: start-day bucketing vs diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index 7d70d14b..291a8535 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -307,9 +307,11 @@ describe('aggregateProjectsIntoDays', () => { expect(day.categories['coding']).toMatchObject({ turns: 1, cost: 10 }) // Per-project rollup at day level and inside each provider slice; path is // stored so display layers can derive a friendly name once sessions expire. - expect(day.projects!['p']).toEqual({ cost: 10, calls: 2, savingsUSD: 0, sessions: 1, path: '/p' }) - expect(day.providers['claude']!.projects!['p']).toMatchObject({ cost: 7, calls: 1 }) - expect(day.providers['codex']!.projects!['p']).toMatchObject({ cost: 3, calls: 1 }) + // Keys are the stable identity (path) so distinct roots sharing a basename + // never merge under one display label. + expect(day.projects!['/p']).toEqual({ cost: 10, calls: 2, savingsUSD: 0, sessions: 1, path: '/p' }) + expect(day.providers['claude']!.projects!['/p']).toMatchObject({ cost: 7, calls: 1 }) + expect(day.providers['codex']!.projects!['/p']).toMatchObject({ cost: 3, calls: 1 }) }) it('attributes a multi-provider turn to the majority provider exactly once', () => { diff --git a/tests/overview.test.ts b/tests/overview.test.ts index 8fcb9525..acd65b30 100644 --- a/tests/overview.test.ts +++ b/tests/overview.test.ts @@ -222,6 +222,20 @@ describe('renderOverview', () => { expect(out).toContain('Projects-Content-OS') expect(out).not.toContain(' OS ') }) + + it('uses the display label when a project has no single filesystem path', () => { + const out = renderOverview([makeProject({ + project: 'codeburn, website', + projectPath: '', + cost: 3.25, + calls: 1, + model: 'claude-sonnet-4-5', + provider: 'zed', + tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 }, + })], { label: 'June 2026', color: false }) + + expect(out).toContain('codeburn, website') + }) }) describe('renderOverview unpriced models', () => { diff --git a/tests/providers/zed.test.ts b/tests/providers/zed.test.ts index 4762cd78..5dd7b916 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,199 @@ 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]!.projectIdentity).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() + expect(calls[0]!.projectIdentity).toBe('/Users/dev/codeburn\n/Users/dev/website') + }) + + it('keeps the root-set identity sorted and normalized independently of the display label', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-unsorted', + folderPaths: ['/Users/zeta/website', '/Users/alpha/codeburn/'], + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } }, + }, + }) + insertThread(db, { + id: 'thread-dup', + folderPaths: ['/Users/alpha/codeburn', '/Users/alpha/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.find(c => c.sessionId === 'thread-dup')!.projectPath).toBe('/Users/alpha/codeburn') + expect(calls.find(c => c.sessionId === 'thread-dup')!.projectIdentity).toBe('/Users/alpha/codeburn') + // Stored order is preserved for the display label; the identity sorts the + // roots so the same workspace is unambiguously keyed on any machine. + expect(calls.find(c => c.sessionId === 'thread-unsorted')!.project).toBe('website, codeburn') + expect(calls.find(c => c.sessionId === 'thread-unsorted')!.projectPath).toBeUndefined() + expect(calls.find(c => c.sessionId === 'thread-unsorted')!.projectIdentity).toBe('/Users/alpha/codeburn\n/Users/zeta/website') + }) + + it('carries identical basenames under distinct roots as distinct identities', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-alice', + folderPaths: ['/Users/alice/repo'], + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } }, + }, + }) + insertThread(db, { + id: 'thread-bob', + folderPaths: ['/Users/bob/repo'], + 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) + const alice = calls.find(c => c.sessionId === 'thread-alice')! + const bob = calls.find(c => c.sessionId === 'thread-bob')! + expect(alice.project).toBe('repo') + expect(bob.project).toBe('repo') + expect(alice.projectPath).toBe('/Users/alice/repo') + expect(bob.projectPath).toBe('/Users/bob/repo') + expect(alice.projectIdentity).toBe('/Users/alice/repo') + expect(bob.projectIdentity).toBe('/Users/bob/repo') + expect(alice.projectPath).not.toBe(bob.projectPath) + }) + + 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-triple')!.projectIdentity).toBe('/Users/dev/codeburn\n/Users/dev/design\n/Users/dev/website') + expect(calls.find(c => c.sessionId === 'thread-root-only')!.project).toBe('website') + // A bare '/' root keeps itself in the identity (it is the filesystem + // root, not an empty string) so root-only workspaces stay keyable. + expect(calls.find(c => c.sessionId === 'thread-root-only')!.projectPath).toBeUndefined() + expect(calls.find(c => c.sessionId === 'thread-root-only')!.projectIdentity).toBe('/\n/Users/dev/website') + }) + + 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, { diff --git a/tests/zed-aggregation-identity.test.ts b/tests/zed-aggregation-identity.test.ts new file mode 100644 index 00000000..66152adc --- /dev/null +++ b/tests/zed-aggregation-identity.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest' + +import { aggregateProjectsIntoDays } from '../src/day-aggregator.js' +import { mergeProjectsByCrossProviderKey, filterProjectsByName } from '../src/parser.js' +import { normalizeProjectIdentity } from '../src/project-identity.js' +import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary } from '../src/types.js' + +// Regression: aggregation must group by the stable identity, never by the +// basename display label. This test intentionally uses plain summaries so the +// identity behavior is covered even when SQLite or Zed's zstd support is not +// available on the test runner. + +const TIMESTAMP = '2026-06-20T10:00:00.000Z' +const DISPLAY_NAME = 'codeburn, website' +const ALICE_IDENTITY = '/Users/alice/codeburn\n/Users/alice/website' +const BOB_IDENTITY = '/Users/bob/codeburn\n/Users/bob/website' + +function makeCall(sessionId: string, inputTokens: number, outputTokens: number): ParsedApiCall { + return { + provider: 'zed', + model: 'claude-opus-4-8', + usage: { + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: inputTokens / 100, + tools: [], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: TIMESTAMP, + bashCommands: [], + deduplicationKey: `zed:${sessionId}:request-1`, + } +} + +function makeSession(sessionId: string, inputTokens: number, outputTokens: number): SessionSummary { + const call = makeCall(sessionId, inputTokens, outputTokens) + const turn: ClassifiedTurn = { + userMessage: 'work on the project', + assistantCalls: [call], + timestamp: TIMESTAMP, + sessionId, + category: 'coding', + retries: 0, + hasEdits: false, + } + const costUSD = call.costUSD + return { + sessionId, + project: DISPLAY_NAME, + firstTimestamp: TIMESTAMP, + lastTimestamp: TIMESTAMP, + totalCostUSD: costUSD, + totalSavingsUSD: 0, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [turn], + modelBreakdown: { + 'claude-opus-4-8': { + calls: 1, + costUSD, + tokens: call.usage, + savingsUSD: 0, + }, + }, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: { + coding: { turns: 1, costUSD, savingsUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + }, + skillBreakdown: {}, + subagentBreakdown: {}, + } +} + +function makeProject(projectIdentity: string, sessionId: string, inputTokens: number, outputTokens: number): ProjectSummary { + const session = makeSession(sessionId, inputTokens, outputTokens) + return { + project: DISPLAY_NAME, + // A multi-root workspace has no single filesystem path. + projectPath: '', + projectIdentity, + sessions: [session], + totalCostUSD: session.totalCostUSD, + totalSavingsUSD: 0, + totalApiCalls: 1, + totalProxiedCostUSD: 0, + } +} + +describe('Zed project identity aggregation', () => { + it('keeps identical multi-root labels separate in daily attribution', () => { + const projects = [ + makeProject(ALICE_IDENTITY, 'alice-session', 100, 50), + makeProject(BOB_IDENTITY, 'bob-session', 200, 80), + ] + + const day = aggregateProjectsIntoDays(projects)[0]! + + expect(Object.keys(day.projects ?? {})).toEqual([ALICE_IDENTITY, BOB_IDENTITY]) + expect(day.projects![ALICE_IDENTITY]).toMatchObject({ + calls: 1, + name: DISPLAY_NAME, + }) + expect(day.projects![BOB_IDENTITY]).toMatchObject({ + calls: 1, + name: DISPLAY_NAME, + }) + expect(day.projects![ALICE_IDENTITY]!.path).toBeUndefined() + expect(day.projects![BOB_IDENTITY]!.path).toBeUndefined() + }) + + it('uses the stable identity for filters and cross-provider merging', () => { + const projects = [ + makeProject(ALICE_IDENTITY, 'alice-session', 100, 50), + makeProject(BOB_IDENTITY, 'bob-session', 200, 80), + ] + + expect(filterProjectsByName(projects, ['/Users/alice'])).toEqual([projects[0]]) + + const merged = mergeProjectsByCrossProviderKey([...projects, ...projects]) + expect(merged).toHaveLength(2) + expect([...merged.values()].find(p => p.projectIdentity === ALICE_IDENTITY)?.totalCostUSD) + .toBe(projects[0]!.totalCostUSD * 2) + expect([...merged.values()].find(p => p.projectIdentity === BOB_IDENTITY)?.totalCostUSD) + .toBe(projects[1]!.totalCostUSD * 2) + }) +}) + +describe('project identity normalization', () => { + it('preserves case distinctions on case-sensitive filesystems', () => { + expect(normalizeProjectIdentity('/home/A/repo', 'linux')) + .not.toBe(normalizeProjectIdentity('/home/a/repo', 'linux')) + }) + + it('folds case for macOS, Windows, and Windows paths in foreign fixtures', () => { + expect(normalizeProjectIdentity('/Users/A/repo', 'darwin')) + .toBe(normalizeProjectIdentity('/Users/a/repo', 'darwin')) + expect(normalizeProjectIdentity('C:\\Work\\Repo', 'linux')) + .toBe(normalizeProjectIdentity('c:/work/repo', 'linux')) + }) +})