mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
fix(claude): attribute each call to the project of its own cwd (#1689)
* fix(claude): attribute each call to the project of its own cwd * fix(claude): keep subfolder cwds in their project, count and show a split session once * fix(claude): a deleted subfolder stays its own project
This commit is contained in:
parent
d3fecffa9a
commit
d49a6a4eba
32 changed files with 242050 additions and 69 deletions
24
dist-b81e58e/cli.js
Executable file
24
dist-b81e58e/cli.js
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env node
|
||||
// This launcher must stay parseable by Node 18. Do NOT add static imports.
|
||||
const [major, minor] = process.versions.node.split('.').map(Number)
|
||||
if (major < 22 || (major === 22 && minor < 13)) {
|
||||
process.stderr.write(
|
||||
`codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` +
|
||||
'Upgrade at https://nodejs.org/\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// React (loaded by Ink) picks its development build unless NODE_ENV is exactly
|
||||
// "production". That build records a performance.measure() entry on every
|
||||
// render into Node's user-timing buffer, which nothing ever trims, so an
|
||||
// interactive dashboard left open for days grows until V8 aborts with
|
||||
// "JavaScript heap out of memory". Default to the production build; an
|
||||
// explicit NODE_ENV in the shell still wins. This must run before main.js is
|
||||
// imported because React reads NODE_ENV at module load.
|
||||
if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production'
|
||||
|
||||
import('./main.js').catch((err) => {
|
||||
process.stderr.write(String(err?.message ?? err) + '\n')
|
||||
process.exit(1)
|
||||
})
|
||||
62384
dist-b81e58e/main.js
Normal file
62384
dist-b81e58e/main.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-b81e58e/main.js.map
Normal file
1
dist-b81e58e/main.js.map
Normal file
File diff suppressed because one or more lines are too long
18077
dist-b81e58e/parse-worker.js
Normal file
18077
dist-b81e58e/parse-worker.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-b81e58e/parse-worker.js.map
Normal file
1
dist-b81e58e/parse-worker.js.map
Normal file
File diff suppressed because one or more lines are too long
24
dist-main/cli.js
Executable file
24
dist-main/cli.js
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env node
|
||||
// This launcher must stay parseable by Node 18. Do NOT add static imports.
|
||||
const [major, minor] = process.versions.node.split('.').map(Number)
|
||||
if (major < 22 || (major === 22 && minor < 13)) {
|
||||
process.stderr.write(
|
||||
`codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` +
|
||||
'Upgrade at https://nodejs.org/\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// React (loaded by Ink) picks its development build unless NODE_ENV is exactly
|
||||
// "production". That build records a performance.measure() entry on every
|
||||
// render into Node's user-timing buffer, which nothing ever trims, so an
|
||||
// interactive dashboard left open for days grows until V8 aborts with
|
||||
// "JavaScript heap out of memory". Default to the production build; an
|
||||
// explicit NODE_ENV in the shell still wins. This must run before main.js is
|
||||
// imported because React reads NODE_ENV at module load.
|
||||
if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production'
|
||||
|
||||
import('./main.js').catch((err) => {
|
||||
process.stderr.write(String(err?.message ?? err) + '\n')
|
||||
process.exit(1)
|
||||
})
|
||||
62202
dist-main/main.js
Normal file
62202
dist-main/main.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-main/main.js.map
Normal file
1
dist-main/main.js.map
Normal file
File diff suppressed because one or more lines are too long
18066
dist-main/parse-worker.js
Normal file
18066
dist-main/parse-worker.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-main/parse-worker.js.map
Normal file
1
dist-main/parse-worker.js.map
Normal file
File diff suppressed because one or more lines are too long
24
dist-trial/cli.js
Executable file
24
dist-trial/cli.js
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env node
|
||||
// This launcher must stay parseable by Node 18. Do NOT add static imports.
|
||||
const [major, minor] = process.versions.node.split('.').map(Number)
|
||||
if (major < 22 || (major === 22 && minor < 13)) {
|
||||
process.stderr.write(
|
||||
`codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` +
|
||||
'Upgrade at https://nodejs.org/\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// React (loaded by Ink) picks its development build unless NODE_ENV is exactly
|
||||
// "production". That build records a performance.measure() entry on every
|
||||
// render into Node's user-timing buffer, which nothing ever trims, so an
|
||||
// interactive dashboard left open for days grows until V8 aborts with
|
||||
// "JavaScript heap out of memory". Default to the production build; an
|
||||
// explicit NODE_ENV in the shell still wins. This must run before main.js is
|
||||
// imported because React reads NODE_ENV at module load.
|
||||
if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production'
|
||||
|
||||
import('./main.js').catch((err) => {
|
||||
process.stderr.write(String(err?.message ?? err) + '\n')
|
||||
process.exit(1)
|
||||
})
|
||||
62649
dist-trial/main.js
Normal file
62649
dist-trial/main.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-trial/main.js.map
Normal file
1
dist-trial/main.js.map
Normal file
File diff suppressed because one or more lines are too long
18082
dist-trial/parse-worker.js
Normal file
18082
dist-trial/parse-worker.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-trial/parse-worker.js.map
Normal file
1
dist-trial/parse-worker.js.map
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -9,7 +9,7 @@
|
|||
// supplementary accounting must never be filtered out, only weightless.
|
||||
|
||||
type WeightedCall = { supplementaryAccounting?: boolean; requestCount?: number }
|
||||
type WeightedTurn = { assistantCalls: readonly WeightedCall[] }
|
||||
type WeightedTurn = { assistantCalls: readonly WeightedCall[]; projectContinuation?: boolean }
|
||||
|
||||
/** True when the call is a real request; false for supplementary accounting (weight 0). */
|
||||
export function isBehavioralCall(call: WeightedCall): boolean {
|
||||
|
|
@ -37,6 +37,7 @@ export function behavioralCallCount(calls: readonly WeightedCall[]): number {
|
|||
|
||||
/** True when the turn holds at least one behavioral call — supplementary-only turns add no turn/edit weight. */
|
||||
export function isBehavioralTurn(turn: WeightedTurn): boolean {
|
||||
if (turn.projectContinuation) return false
|
||||
return turn.assistantCalls.some(call => !call.supplementaryAccounting)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -331,7 +331,12 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// v68: Kimi Code sessions whose state.json carries `cwd` but no `workDir` land
|
||||
// on that folder. Only the per-project split moves; cost, tokens and calls are
|
||||
// unchanged, so the bump re-derives surviving days.
|
||||
export const DAILY_CACHE_VERSION = 68
|
||||
// v69: Claude calls go to the project of their own cwd, so a session that
|
||||
// moved folders splits across projects per call. Settled days re-derive their
|
||||
// project rows; provider call counts and cost are unchanged, so no
|
||||
// PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed. Days whose transcripts
|
||||
// are gone keep their old single-project attribution.
|
||||
export const DAILY_CACHE_VERSION = 69
|
||||
const MIN_SUPPORTED_VERSION = 28
|
||||
|
||||
/// Providers whose per-day CALL COUNT means something different at
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { planDisplayName } from './plans.js'
|
|||
import { formatDayRangeLabel, getDateRange, parseDayFlag, PERIODS, PERIOD_LABELS, shiftDay, type Period } from './cli-date.js'
|
||||
import { BSU, patchStdoutForWindows } from './ink-win.js'
|
||||
import { startUserTimingGuard } from './user-timing-guard.js'
|
||||
import { countSessions } from './session-output.js'
|
||||
|
||||
type View = 'dashboard' | 'optimize' | 'compare'
|
||||
|
||||
|
|
@ -604,7 +605,7 @@ function Overview({ projects, label, width, planUsages, durable }: { projects: P
|
|||
const totalCost = durable ? durable.cost : projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
const totalSavings = durable ? durable.savingsUSD : projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
|
||||
const totalCalls = durable ? durable.calls : projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
||||
const totalSessions = durable ? durable.sessions : projects.reduce((s, p) => s + p.sessions.length, 0)
|
||||
const totalSessions = durable ? durable.sessions : countSessions(projects)
|
||||
const allSessions = projects.flatMap(p => p.sessions)
|
||||
const totalInput = durable ? durable.inputTokens : allSessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
|
||||
const totalOutput = durable ? durable.outputTokens : allSessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { projectDayKey, type DailyEntry, type ProjectDayStats, type 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 { behavioralCallWeight, isBehavioralTurn } from './behavioral-weight.js'
|
||||
import { billableOutputTokens, modelRowKey } from './models.js'
|
||||
|
||||
|
|
@ -78,17 +78,31 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[], dateKeyFn:
|
|||
return p
|
||||
}
|
||||
|
||||
// A session split across projects counts once per day and provider, on its
|
||||
// earliest slice; each project it touched still counts it.
|
||||
const splitHeads = new Map<string, SessionSummary>()
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
if (!session.projectSplit) continue
|
||||
const key = `${session.projectSplit.primaryProjectPath}\0${session.sessionId}`
|
||||
const head = splitHeads.get(key)
|
||||
if (!head || session.firstTimestamp < head.firstTimestamp) splitHeads.set(key, session)
|
||||
}
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
const sessionDate = dateKeyFn(session.firstTimestamp)
|
||||
const sessionDay = ensure(sessionDate)
|
||||
sessionDay.sessions += 1
|
||||
const countsOnce = !session.projectSplit
|
||||
|| splitHeads.get(`${session.projectSplit.primaryProjectPath}\0${session.sessionId}`) === session
|
||||
if (countsOnce) sessionDay.sessions += 1
|
||||
ensureProject(sessionDay, session.project, project.projectPath).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
|
||||
if (countsOnce) slice.sessions! += 1
|
||||
ensureProject(slice, session.project, project.projectPath).sessions += 1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { getCurrency, convertCost, roundForActiveCurrency } from './currency.js'
|
|||
import { dateKey } from './day-aggregator.js'
|
||||
import { behavioralCallWeight, behavioralTurnCount } from './behavioral-weight.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { callBillableOutputTokens, sessionModelBillableOutputTokens } from './session-output.js'
|
||||
import { callBillableOutputTokens, countSessions, sessionModelBillableOutputTokens } from './session-output.js'
|
||||
import { findUnpricedModels } from './models.js'
|
||||
|
||||
function escCsv(s: string): string {
|
||||
|
|
@ -330,7 +330,7 @@ function buildSummaryRows(periods: PeriodExport[]): Row[] {
|
|||
const cost = p.projects.reduce((s, proj) => s + proj.totalCostUSD, 0)
|
||||
const savings = p.projects.reduce((s, proj) => s + proj.totalSavingsUSD, 0)
|
||||
const calls = p.projects.reduce((s, proj) => s + proj.totalApiCalls, 0)
|
||||
const sessions = p.projects.reduce((s, proj) => s + proj.sessions.length, 0)
|
||||
const sessions = countSessions(p.projects)
|
||||
const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0 || proj.totalSavingsUSD > 0).length
|
||||
return {
|
||||
Period: p.label,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { cachedProjectIdentitiesForRange } from './daily-cache.js'
|
|||
import { reportUnmatchedProjectPatterns } from './project-filter-warnings.js'
|
||||
import { getVercelGatewayApiKey } from './providers/vercel-gateway.js'
|
||||
import { BILLING_FILTER_VALUES, ROUTE_FILTER_VALUES, filterProjectsByBillingRoute } from './billing-filter.js'
|
||||
import { AGGREGATE_ONLY_PROVIDER, aggregateOnlyCostUSD, excludesAggregateOnlyProviders, parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI, computeCorpusFingerprint, isSessionHydrationComplete, startProgressKeepalive, stopProgressKeepalive, withLoadWindow } from './parser.js'
|
||||
import { AGGREGATE_ONLY_PROVIDER, aggregateOnlyCostUSD, excludesAggregateOnlyProviders, parseAllSessions, filterProjectsByName, filterProjectsByDateRange, mergeProjectSplits, clearSessionCache, setInteractiveScanUI, computeCorpusFingerprint, isSessionHydrationComplete, startProgressKeepalive, stopProgressKeepalive, withLoadWindow } from './parser.js'
|
||||
import { allProviderNames, getAllProviders, safeDiscoverSessions } from './providers/index.js'
|
||||
import { getProvider } from './providers/index.js'
|
||||
import { getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
|
||||
|
|
@ -770,7 +770,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
|
|||
const sortedMap = (m: Record<string, number>) =>
|
||||
Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
|
||||
|
||||
const topSessions = projects
|
||||
const topSessions = mergeProjectSplits(projects)
|
||||
.flatMap(p => p.sessions.map(s => ({
|
||||
project: p.project,
|
||||
sessionId: s.sessionId,
|
||||
|
|
@ -2973,10 +2973,10 @@ program
|
|||
const parsed = await parseAllSessions(range, opts.provider)
|
||||
await reportUnmatchedProjectPatterns(parsed, opts.project, opts.exclude, () => cachedProjectIdentitiesForRange(range))
|
||||
await reportExcludedGatewayCost(range, opts.provider)
|
||||
const projects = filterProjectsByBillingRoute(
|
||||
const projects = mergeProjectSplits(filterProjectsByBillingRoute(
|
||||
filterProjectsByName(parsed, opts.project, opts.exclude),
|
||||
{ route: opts.route, billing: opts.billing },
|
||||
)
|
||||
))
|
||||
if (opts.byPr) {
|
||||
const { rows: prRows, totals } = buildPrAttribution(projects)
|
||||
if (opts.format === 'json') {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { formatTokens } from './format.js'
|
|||
import { recommendModelDefault, type ModelDefaultRecommendation } from './act/model-defaults.js'
|
||||
import { appliedFixGlyph, formatAppliedFix, type AppliedFix } from './act/types.js'
|
||||
import { isUserStartedSession, userStartedProjects } from './session-population.js'
|
||||
import { inferSessionProvider, sessionBillableOutputTokens } from './session-output.js'
|
||||
import { countSessions, inferSessionProvider, sessionBillableOutputTokens } from './session-output.js'
|
||||
import { aggregateFileChurn, buildCoachingNotes, scanUserCorrections, medianTimeToFirstEditMs, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js'
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -2513,7 +2513,7 @@ export function detectUnusedMcp(
|
|||
|
||||
if (unused.length === 0) return null
|
||||
|
||||
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
|
||||
const totalSessions = countSessions(projects)
|
||||
const schemaTokensPerSession = unused.length * TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL
|
||||
const tokensSaved = schemaTokensPerSession * Math.max(totalSessions, 1)
|
||||
|
||||
|
|
@ -2866,7 +2866,7 @@ export function detectMcpAlwaysLoadHygiene(
|
|||
const versions = apiCalls.map(c => c.version).filter(v => v.length > 0)
|
||||
if (versions.length > 0 && versions.every(v => versionPredates(v, ALWAYSLOAD_MIN_VERSION))) return null
|
||||
|
||||
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
|
||||
const totalSessions = countSessions(projects)
|
||||
if (totalSessions === 0) return null
|
||||
|
||||
const coverageByServer = new Map(mcpCoverage.map(c => [c.server, c]))
|
||||
|
|
@ -4006,14 +4006,14 @@ export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | unde
|
|||
// stale findings when cost/tokens moved (e.g. a re-price) while call count
|
||||
// held - reachable in the long-lived menubar process within the 60s TTL.
|
||||
// Cost is scaled to whole micro-dollars so float jitter cannot thrash the key.
|
||||
let calls = 0, cost = 0, savings = 0, proxied = 0, sessions = 0, sidechains = 0
|
||||
let calls = 0, cost = 0, savings = 0, proxied = 0, sidechains = 0
|
||||
const sessions = countSessions(projects)
|
||||
const sidechainIdentities: string[] = []
|
||||
for (const p of projects) {
|
||||
calls += p.totalApiCalls
|
||||
cost += p.totalCostUSD
|
||||
savings += p.totalSavingsUSD
|
||||
proxied += p.totalProxiedCostUSD
|
||||
sessions += p.sessions.length
|
||||
for (const session of p.sessions) {
|
||||
if (session.isSidechain !== true) continue
|
||||
sidechains++
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { homedir } from 'os'
|
|||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { formatCost as baseCost, getCurrency } from './currency.js'
|
||||
import { findUnpricedModels, modelRowKey, unpricedModelHint } from './models.js'
|
||||
import { callBillableOutputTokens, sessionBillableOutputTokens, sessionModelBillableOutputTokens } from './session-output.js'
|
||||
import { callBillableOutputTokens, countSessions, sessionBillableOutputTokens, sessionModelBillableOutputTokens } from './session-output.js'
|
||||
import { markEstimated, excludedGatewayNote, isEstimatedCost, ESTIMATED_COST_LEGEND } from './format.js'
|
||||
import { AGGREGATE_ONLY_PROVIDER } from './parser.js'
|
||||
import { maxOf } from './math-utils.js'
|
||||
|
|
@ -163,11 +163,11 @@ export function renderOverview(
|
|||
const byDay = new Map<string, { cost: number; tokens: number; providers: Set<string> }>()
|
||||
const byProject = new Map<string, { cost: number; sessions: number; sample: ProjectSummary }>()
|
||||
|
||||
sessions = countSessions(projects)
|
||||
for (const p of projects) {
|
||||
cost += p.totalCostUSD
|
||||
savings += p.totalSavingsUSD
|
||||
calls += p.totalApiCalls
|
||||
sessions += p.sessions.length
|
||||
const pkey = projectAggKey(p)
|
||||
const pe = byProject.get(pkey) ?? { cost: 0, sessions: 0, sample: p }
|
||||
pe.cost += p.totalCostUSD
|
||||
|
|
|
|||
270
src/parser.ts
270
src/parser.ts
|
|
@ -130,6 +130,60 @@ function projectNameFromPath(projectPath: string, fallback: string): string {
|
|||
return normalized.split('/').filter(Boolean).pop() ?? fallback
|
||||
}
|
||||
|
||||
type CallProject = { key: string; path: string; name: string }
|
||||
|
||||
async function resolveClaudeCallProject(cwd: string, filePath: string): Promise<CallProject | null> {
|
||||
if (isCoworkSession(cwd, filePath)) return null
|
||||
const canonical = await resolveCanonicalProjectPath(cwd)
|
||||
return {
|
||||
key: normalizeProjectPathKey(canonical.path),
|
||||
path: canonical.path,
|
||||
// Claude Code names a project directory by replacing every non-alphanumeric
|
||||
// character of its cwd with '-', so a slice lands under the same label.
|
||||
name: canonical.isWorktree ? projectNameFromPath(canonical.path, cwd) : canonical.path.replace(/[^a-zA-Z0-9]/g, '-'),
|
||||
}
|
||||
}
|
||||
|
||||
// True when cwd lies below projectPath without crossing into another repository
|
||||
// (a .git entry between the two): `cd app` inside a repo stays in the repo,
|
||||
// while `cd Projects/x` out of a home folder reaches another project. A folder
|
||||
// that is gone cannot show a boundary (a deleted worktree), so it stays apart.
|
||||
async function insideProjectTree(projectPath: string, cwd: string): Promise<boolean> {
|
||||
const root = normalizeProjectPathKey(projectPath)
|
||||
if (!normalizeProjectPathKey(cwd).startsWith(root + '/') || !existsSync(cwd)) return false
|
||||
for (let dir = cwd.trim(); normalizeProjectPathKey(dir) !== root; dir = dirname(dir)) {
|
||||
if (await lstat(join(dir, '.git')).catch(() => null)) return false
|
||||
if (dirname(dir) === dir) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Partition turns by each call's project. A turn whose calls span projects is
|
||||
// cut per call; only the piece holding its first call keeps the turn weight
|
||||
// (turn, edit and retry counts), so those stay whole. Every piece keeps the
|
||||
// turn's timestamp and category, so per-day and per-category cost do too.
|
||||
function splitTurnsByProject(turns: ClassifiedTurn[], projectOf: (call: ParsedApiCall) => CallProject): Array<CallProject & { turns: ClassifiedTurn[] }> {
|
||||
const groups = new Map<string, CallProject & { turns: ClassifiedTurn[] }>()
|
||||
for (const turn of turns) {
|
||||
const byKey = new Map<string, ParsedApiCall[]>()
|
||||
for (const call of turn.assistantCalls) {
|
||||
const project = projectOf(call)
|
||||
if (!groups.has(project.key)) groups.set(project.key, { ...project, turns: [] })
|
||||
const calls = byKey.get(project.key)
|
||||
if (calls) calls.push(call)
|
||||
else byKey.set(project.key, [call])
|
||||
}
|
||||
let first = true
|
||||
for (const [key, calls] of byKey) {
|
||||
groups.get(key)!.turns.push(calls.length === turn.assistantCalls.length
|
||||
? turn
|
||||
: { ...turn, assistantCalls: calls, ...(first ? {} : { projectContinuation: true, hasEdits: false, retries: 0 }) })
|
||||
first = false
|
||||
}
|
||||
}
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
|
||||
// Returns true for sessions whose canonical project key must NOT be derived
|
||||
// from the cwd. Cowork sessions come in two flavours:
|
||||
|
|
@ -1684,12 +1738,17 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
|
|||
const msgId = getMessageId(entry)
|
||||
if (msgId && seenMsgIds.has(msgId)) continue
|
||||
if (msgId) seenMsgIds.add(msgId)
|
||||
const cwd = typeof entry.cwd === 'string' && entry.cwd.trim() ? entry.cwd.trim() : undefined
|
||||
const call = parseApiCall(entry, toolResultMeta)
|
||||
if (call) {
|
||||
if (cwd) call.cwd = cwd
|
||||
currentCalls.push(call)
|
||||
if (call.spawnToolUseIds) for (const id of call.spawnToolUseIds) if (!currentSpawnIds.includes(id)) currentSpawnIds.push(id)
|
||||
}
|
||||
for (const advisorCall of parseAdvisorCalls(entry)) currentCalls.push(advisorCall)
|
||||
for (const advisorCall of parseAdvisorCalls(entry)) {
|
||||
if (cwd) advisorCall.cwd = cwd
|
||||
currentCalls.push(advisorCall)
|
||||
}
|
||||
} else if (entry.type === 'pr-link') {
|
||||
const url = (entry as Record<string, unknown>)['prUrl']
|
||||
if (typeof url === 'string' && url && !currentPrRefs.includes(url)) currentPrRefs.push(url)
|
||||
|
|
@ -2504,8 +2563,13 @@ async function scanProjectDirs(
|
|||
// it is pure waste (on a week view that is nearly all of history). The
|
||||
// carries above still run over the FULL ordered turn list.
|
||||
const classifiedTurns: ClassifiedTurn[] = []
|
||||
// Each call's own cwd, carried forward over the full turn list like the
|
||||
// branch (the cache stores it only when it changes).
|
||||
let carriedCwd = cachedFile.workingDirectory
|
||||
const callCwds = new Map<ParsedApiCall, string>()
|
||||
for (const turn of cachedFile.turns) {
|
||||
if (turn.gitBranch) carriedBranch = turn.gitBranch
|
||||
const turnCwds = turn.calls.map(call => (carriedCwd = call.cwd ?? carriedCwd))
|
||||
if (dateRange && !frozePrRefs) {
|
||||
const firstTs = turn.calls[0]?.timestamp
|
||||
if (firstTs && new Date(firstTs) >= dateRange.start) {
|
||||
|
|
@ -2516,6 +2580,12 @@ async function scanProjectDirs(
|
|||
if (turn.prRefs?.length) carriedPrRefs = turn.prRefs
|
||||
if (dateRange && !callsInRange(turn.calls, dateRange)) continue
|
||||
const classified = cachedTurnToClassified(turn, carriedBranch)
|
||||
if (cachedFile.canonicalCwd) {
|
||||
classified.assistantCalls.forEach((call, i) => {
|
||||
const cwd = turnCwds[i]
|
||||
if (cwd) callCwds.set(call, cwd)
|
||||
})
|
||||
}
|
||||
// Slice rather than drop: a turn spanning local midnight would otherwise
|
||||
// lose every call that lands in the requested day (issue #852). Only
|
||||
// `assistantCalls`/`timestamp` are touched — see classifiedTurnSlicedToRange.
|
||||
|
|
@ -2546,51 +2616,99 @@ async function scanProjectDirs(
|
|||
const sessionId = basename(filePath, '.jsonl')
|
||||
const projectPath = cachedFile.canonicalCwd ?? claudeSlugFallbackPath(dirName)
|
||||
const projectName = cachedFile.canonicalProjectName ?? dirName
|
||||
const projectKey = cachedFile.canonicalCwd
|
||||
? normalizeProjectPathKey(cachedFile.canonicalCwd)
|
||||
: `slug:${dirName}`
|
||||
const fileProject: CallProject = { key: projectKey, path: projectPath, name: projectName }
|
||||
const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined
|
||||
const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv, source)
|
||||
if (cachedFile.workingDirectory && !isCoworkSession(cachedFile.workingDirectory, filePath)) {
|
||||
session.workingDirectory = cachedFile.workingDirectory
|
||||
const decorate = (session: SessionSummary): SessionSummary => {
|
||||
if (cachedFile.workingDirectory && !isCoworkSession(cachedFile.workingDirectory, filePath)) {
|
||||
session.workingDirectory = cachedFile.workingDirectory
|
||||
}
|
||||
session.agentType = cachedFile.agentType
|
||||
if (everHadBranch) session.everHadBranch = true
|
||||
const observedPrLinks = new Set(classifiedTurns.flatMap(turn => turn.prRefs ?? []))
|
||||
for (const link of cachedFile.prLinks ?? []) observedPrLinks.add(link)
|
||||
if (observedPrLinks.size) {
|
||||
session.prLinks = [...observedPrLinks].sort()
|
||||
session.prAttributionSource = cachedFile.prLinks?.length ? 'transcript' : 'explicit-reference'
|
||||
}
|
||||
if (prRefsAtRangeStart?.length) session.prRefsAtRangeStart = prRefsAtRangeStart
|
||||
if (cachedFile.title) session.title = cachedFile.title
|
||||
// Sidechain linkage: carry the parent id (the transcript's internal
|
||||
// `sessionId`, authoritative even when it disagrees with the owning directory
|
||||
// on a resumed session) and derive the agent id from the `agent-<agentId>`
|
||||
// filename. A sidechain whose parent id was never captured stays standalone.
|
||||
if (cachedFile.isSidechain) {
|
||||
session.isSidechain = true
|
||||
if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId
|
||||
session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId
|
||||
}
|
||||
// Parent linkage maps (only present on sessions that spawned subagents).
|
||||
if (cachedFile.agentSpawnLinks && Object.keys(cachedFile.agentSpawnLinks).length > 0) {
|
||||
session.agentSpawnLinks = cachedFile.agentSpawnLinks
|
||||
}
|
||||
if (cachedFile.ambiguousSpawnAgentIds?.length) session.ambiguousSpawnAgentIds = cachedFile.ambiguousSpawnAgentIds
|
||||
if (Object.keys(spawnPrSets).length > 0) session.spawnPrSets = spawnPrSets
|
||||
// Provider-recorded parent/child lineage (CB-1, slice 1). Mirrors whatever
|
||||
// the install path stored on the cached file; absent when no evidence.
|
||||
if (cachedFile.lineage) session.lineage = cachedFile.lineage
|
||||
return session
|
||||
}
|
||||
session.agentType = cachedFile.agentType
|
||||
if (everHadBranch) session.everHadBranch = true
|
||||
const observedPrLinks = new Set(classifiedTurns.flatMap(turn => turn.prRefs ?? []))
|
||||
for (const link of cachedFile.prLinks ?? []) observedPrLinks.add(link)
|
||||
if (observedPrLinks.size) {
|
||||
session.prLinks = [...observedPrLinks].sort()
|
||||
session.prAttributionSource = cachedFile.prLinks?.length ? 'transcript' : 'explicit-reference'
|
||||
}
|
||||
if (prRefsAtRangeStart?.length) session.prRefsAtRangeStart = prRefsAtRangeStart
|
||||
if (cachedFile.title) session.title = cachedFile.title
|
||||
// Sidechain linkage: carry the parent id (the transcript's internal
|
||||
// `sessionId`, authoritative even when it disagrees with the owning directory
|
||||
// on a resumed session) and derive the agent id from the `agent-<agentId>`
|
||||
// filename. A sidechain whose parent id was never captured stays standalone.
|
||||
if (cachedFile.isSidechain) {
|
||||
session.isSidechain = true
|
||||
if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId
|
||||
session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId
|
||||
}
|
||||
// Parent linkage maps (only present on sessions that spawned subagents).
|
||||
if (cachedFile.agentSpawnLinks && Object.keys(cachedFile.agentSpawnLinks).length > 0) {
|
||||
session.agentSpawnLinks = cachedFile.agentSpawnLinks
|
||||
}
|
||||
if (cachedFile.ambiguousSpawnAgentIds?.length) session.ambiguousSpawnAgentIds = cachedFile.ambiguousSpawnAgentIds
|
||||
if (Object.keys(spawnPrSets).length > 0) session.spawnPrSets = spawnPrSets
|
||||
// Provider-recorded parent/child lineage (CB-1, slice 1). Mirrors whatever
|
||||
// the install path stored on the cached file; absent when no evidence.
|
||||
if (cachedFile.lineage) session.lineage = cachedFile.lineage
|
||||
|
||||
if (session.apiCalls > 0 || anchorOnly) {
|
||||
const projectKey = cachedFile.canonicalCwd
|
||||
? normalizeProjectPathKey(cachedFile.canonicalCwd)
|
||||
: `slug:${dirName}`
|
||||
const existing = projectMap.get(projectKey)
|
||||
const addToProject = (project: CallProject, session: SessionSummary, anchor: boolean): void => {
|
||||
const existing = projectMap.get(project.key)
|
||||
// An anchor (no in-range spend) goes into a separate bucket, never `sessions`.
|
||||
const target = existing ?? { project: projectName, projectPath, sessions: [], anchors: [], dirNames: new Set([dirName]) }
|
||||
if (anchorOnly) target.anchors.push(session)
|
||||
const target = existing ?? { project: project.name, projectPath: project.path, sessions: [], anchors: [], dirNames: new Set<string>() }
|
||||
if (anchor) target.anchors.push(session)
|
||||
else target.sessions.push(session)
|
||||
target.dirNames.add(dirName)
|
||||
if (!existing) projectMap.set(projectKey, target)
|
||||
// Only the transcript's own directory may later absorb its slug-keyed rows.
|
||||
if (project.key === projectKey) target.dirNames.add(dirName)
|
||||
if (!existing) projectMap.set(project.key, target)
|
||||
}
|
||||
|
||||
// A session that moved folders splits per call: each call goes to the
|
||||
// project of its own cwd, and every slice keeps the session's metadata.
|
||||
const callProjects = new Map<string, CallProject>()
|
||||
for (const cwd of new Set(callCwds.values())) {
|
||||
const resolved = await resolveClaudeCallProject(cwd, filePath)
|
||||
callProjects.set(cwd, resolved && resolved.key !== projectKey ? resolved : fileProject)
|
||||
}
|
||||
// A subfolder of a folder this session worked in joins that folder's
|
||||
// project (the outermost one), so `cd app` inside a repo is not a move.
|
||||
const roots = [...new Map([fileProject, ...callProjects.values()].map(p => [p.key, p])).values()]
|
||||
.sort((a, b) => a.path.length - b.path.length)
|
||||
for (const [cwd, project] of callProjects) {
|
||||
if (project === fileProject) continue
|
||||
for (const root of roots) {
|
||||
if (await insideProjectTree(root.path, cwd)) {
|
||||
callProjects.set(cwd, root)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const groups = [...callProjects.values()].some(p => p !== fileProject)
|
||||
? splitTurnsByProject(classifiedTurns, call => {
|
||||
const cwd = callCwds.get(call)
|
||||
return (cwd && callProjects.get(cwd)) || fileProject
|
||||
})
|
||||
: [{ ...fileProject, turns: classifiedTurns }]
|
||||
if (groups.length === 1) {
|
||||
const only = groups[0]!
|
||||
const name = only === fileProject || only.key === projectKey ? projectName : projectMap.get(only.key)?.project ?? only.name
|
||||
const session = decorate(buildSessionSummary(sessionId, name, classifiedTurns, mcpInv, source))
|
||||
if (session.apiCalls > 0 || anchorOnly) addToProject({ ...only, name }, session, anchorOnly)
|
||||
continue
|
||||
}
|
||||
const slices = groups.map(group => {
|
||||
const name = group.key === projectKey ? projectName : projectMap.get(group.key)?.project ?? group.name
|
||||
return { project: { ...group, name }, session: decorate(buildSessionSummary(sessionId, name, group.turns, mcpInv, source)) }
|
||||
})
|
||||
// Primary = the slice holding the most cost, so the session row sits where
|
||||
// the money went and a late `cd` cannot move it. Ties keep the earlier slice.
|
||||
const primary = slices.reduce((best, slice) => slice.session.totalCostUSD > best.session.totalCostUSD ? slice : best)
|
||||
for (const { project, session } of slices) {
|
||||
session.projectSplit = { primaryProject: primary.project.name, primaryProjectPath: primary.project.path, primary: session === primary.session }
|
||||
if (session.apiCalls > 0) addToProject(project, session, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2785,17 +2903,23 @@ function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn {
|
|||
}
|
||||
|
||||
// Convert a batch of parsed turns to cached turns, storing each turn's gitBranch
|
||||
// only when it differs from the previous turn's branch in this batch. A report
|
||||
// (and each call's cwd) only when it differs from the previous one in this batch. A report
|
||||
// reconstructs a turn's branch by carrying the last stored value forward. The
|
||||
// dedup is per-batch, so the first turn of an appended region always restates
|
||||
// its branch (harmless: a redundant restatement, never a wrong value).
|
||||
export function parsedTurnsToCachedTurns(turns: ParsedTurn[]): CachedTurn[] {
|
||||
const out: CachedTurn[] = []
|
||||
let prevBranch: string | undefined
|
||||
let prevCwd: string | undefined
|
||||
for (const turn of turns) {
|
||||
const cached = parsedTurnToCachedTurn(turn)
|
||||
if (turn.gitBranch && turn.gitBranch !== prevBranch) cached.gitBranch = turn.gitBranch
|
||||
if (turn.gitBranch) prevBranch = turn.gitBranch
|
||||
turn.assistantCalls.forEach((call, i) => {
|
||||
if (!call.cwd || call.cwd === prevCwd) return
|
||||
cached.calls[i]!.cwd = call.cwd
|
||||
prevCwd = call.cwd
|
||||
})
|
||||
out.push(cached)
|
||||
}
|
||||
return out
|
||||
|
|
@ -3206,6 +3330,13 @@ function getOrCreateProviderSection(cache: SessionCache, provider: string): Prov
|
|||
section.files[path] = { ...rest, fingerprint: { dev: 0, ino: 0, mtimeMs: 0, sizeBytes: -1 } }
|
||||
}
|
||||
}
|
||||
// A Claude transcript that is gone can never re-parse, and its cached entry
|
||||
// (kept only when PR-bearing) is the last record of it: a parse bump keeps it.
|
||||
if (existing && provider === 'claude') {
|
||||
for (const [path, file] of Object.entries(existing.files)) {
|
||||
if (!existsSync(path)) section.files[path] = file
|
||||
}
|
||||
}
|
||||
cache.providers[provider] = section
|
||||
markCacheDirty(cache, provider)
|
||||
return section
|
||||
|
|
@ -4999,6 +5130,57 @@ function carryLinkageFields(rebuilt: SessionSummary, original: SessionSummary):
|
|||
if (original.agentName) rebuilt.agentName = original.agentName
|
||||
if (original.agentStartedAt) rebuilt.agentStartedAt = original.agentStartedAt
|
||||
if (original.lineage) rebuilt.lineage = original.lineage
|
||||
if (original.projectSplit) rebuilt.projectSplit = original.projectSplit
|
||||
}
|
||||
|
||||
/// Session-level views (session rows, PR attribution, subagent folds) need one
|
||||
/// record per session. Rejoin the per-project slices of a split session into the
|
||||
/// project holding its primary slice, or the first slice present under a filter.
|
||||
export function mergeProjectSplits(projects: ProjectSummary[]): ProjectSummary[] {
|
||||
const groups = new Map<string, { home: ProjectSummary; slices: SessionSummary[] }>()
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
const split = session.projectSplit
|
||||
if (!split) continue
|
||||
const key = `${split.primaryProjectPath}\0${session.sessionId}`
|
||||
const group = groups.get(key)
|
||||
if (!group) groups.set(key, { home: project, slices: [session] })
|
||||
else {
|
||||
group.slices.push(session)
|
||||
if (split.primary) group.home = project
|
||||
}
|
||||
}
|
||||
}
|
||||
if (groups.size === 0) return projects
|
||||
const added = new Map<ProjectSummary, SessionSummary[]>()
|
||||
const turnStart = (turn: ClassifiedTurn): number => Date.parse(turn.timestamp || turn.assistantCalls[0]?.timestamp || '') || 0
|
||||
for (const { home, slices } of groups.values()) {
|
||||
const base = slices.find(s => s.projectSplit!.primary) ?? slices[0]!
|
||||
const turns = slices.flatMap(s => s.turns)
|
||||
.sort((a, b) => turnStart(a) - turnStart(b) || Number(!!a.projectContinuation) - Number(!!b.projectContinuation))
|
||||
const session = buildSessionSummary(base.sessionId, base.projectSplit!.primaryProject, turns, base.mcpInventory, base.source)
|
||||
carryLinkageFields(session, base)
|
||||
delete session.projectSplit
|
||||
if (base.prRefsAtRangeStart?.length) session.prRefsAtRangeStart = base.prRefsAtRangeStart
|
||||
const links = new Set(slices.flatMap(s => s.prLinks ?? []))
|
||||
if (links.size) session.prLinks = [...links].sort()
|
||||
const list = added.get(home)
|
||||
if (list) list.push(session)
|
||||
else added.set(home, [session])
|
||||
}
|
||||
const out: ProjectSummary[] = []
|
||||
for (const project of projects) {
|
||||
const sessions = project.sessions.filter(s => !s.projectSplit)
|
||||
const extra = added.get(project)
|
||||
if (!extra && sessions.length === project.sessions.length) {
|
||||
out.push(project)
|
||||
continue
|
||||
}
|
||||
if (extra) sessions.push(...extra)
|
||||
if (sessions.length === 0 && !project.subagentAnchors?.length) continue
|
||||
out.push({ ...project, ...summarizeProject(project.project, project.projectPath, sessions, project.subagentAnchors) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The "PR active entering this slice", recomputed by replaying the ORIGINAL full
|
||||
|
|
@ -5327,7 +5509,7 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo
|
|||
if (bucket) bucket.push(s)
|
||||
else unlinkedByAgentId.set(s.agentId, [s])
|
||||
}
|
||||
for (const resolved of resolveSubagentAttribution(projects).values()) {
|
||||
for (const resolved of resolveSubagentAttribution(mergeProjectSplits(projects)).values()) {
|
||||
for (const child of resolved) {
|
||||
// A multi-PR spawn set is valid for folding the child's own cost, but is
|
||||
// too broad to identify which PR an independently saved nested review was
|
||||
|
|
|
|||
|
|
@ -512,6 +512,14 @@ export function diffSessions(
|
|||
): SessionDiffRow[] {
|
||||
type Acc = { provider: string; sessionId: string; project: string; title?: string; costA: number; costB: number; callsA: number; callsB: number }
|
||||
const accs = new Map<string, Acc>()
|
||||
// A session split across projects names its primary slice per range, so the
|
||||
// canonical key can differ between A and B: join those sessions on the id.
|
||||
const splitIds = new Set<string>()
|
||||
for (const project of [...projectsA, ...projectsB]) {
|
||||
for (const session of project.sessions) {
|
||||
if (session.projectSplit) splitIds.add(`${inferSessionProvider(session)}\0${session.sessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
const fold = (projects: ProjectSummary[], side: 'A' | 'B') => {
|
||||
for (const project of projects) {
|
||||
|
|
@ -524,7 +532,8 @@ export function diffSessions(
|
|||
if (dimension === 'project' && spendProjectIdentity(project).id !== key) continue
|
||||
const cost = dimension === 'model' ? modelEntry!.costUSD : session.totalCostUSD
|
||||
const calls = dimension === 'model' ? modelEntry!.calls : session.apiCalls
|
||||
const identity = canonicalSessionCountKey(session, project.projectPath)
|
||||
const splitId = `${inferSessionProvider(session)}\0${session.sessionId}`
|
||||
const identity = splitIds.has(splitId) ? `${splitId}\0split` : canonicalSessionCountKey(session, project.projectPath)
|
||||
const acc = accs.get(identity) ?? {
|
||||
provider: inferSessionProvider(session),
|
||||
sessionId: session.sessionId,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ export type CachedCall = {
|
|||
/** Present only when workingDirectory came from a dedicated provider field. */
|
||||
workingDirectoryProvenance?: 'provider-field'
|
||||
workingDirectory?: string
|
||||
/// Claude: the call's raw cwd, stored only when it differs from the previous
|
||||
/// call's in the same parse batch (carried forward like gitBranch).
|
||||
cwd?: string
|
||||
toolSequence?: ToolCall[][]
|
||||
// Rich-session-capture (capture-only; no report consumes these yet). All
|
||||
// optional and omitted at zero/false to keep the per-call cache cost minimal.
|
||||
|
|
@ -372,7 +375,9 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// byte-identical to a build that omits it (see parser-lineage-capture test).
|
||||
// queued-human-prompts-v1: cached turns need to be regrouped around Claude's
|
||||
// queued_command prompt attachments, including classification and PR links.
|
||||
claude: 'advisor-usage-v1-skills-rich-capture-v1-cross-provider-pr-v1-session-lineage-capture-v1-queued-human-prompts-v1',
|
||||
// per-call-cwd-v1: cached calls carry their cwd so a session that moved
|
||||
// folders splits across projects per call.
|
||||
claude: 'advisor-usage-v1-skills-rich-capture-v1-cross-provider-pr-v1-session-lineage-capture-v1-queued-human-prompts-v1-per-call-cwd-v1',
|
||||
cline: 'worktree-project-grouping-v1',
|
||||
// reported-cost-v1: the CLI reports its own per-message cost, so entries
|
||||
// cached before cline-cli joined the reported-cost allowlist in parser.ts
|
||||
|
|
@ -1187,6 +1192,7 @@ function validateCall(c: unknown): c is CachedCall {
|
|||
&& isOptionalString(o['projectPath'])
|
||||
&& (o['workingDirectoryProvenance'] === undefined || o['workingDirectoryProvenance'] === 'provider-field')
|
||||
&& isOptionalString(o['workingDirectory'])
|
||||
&& isOptionalString(o['cwd'])
|
||||
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s))))
|
||||
&& isOptionalNum(o['locAdded'])
|
||||
&& isOptionalNum(o['locRemoved'])
|
||||
|
|
|
|||
|
|
@ -116,3 +116,16 @@ export function callBillableOutputTokens(call: CallLike): number {
|
|||
export function sessionBillableOutputTokens(session: SessionSummary): number {
|
||||
return sessionBillableOutput(session).total
|
||||
}
|
||||
|
||||
/** Sessions across projects, counting a session split across projects once. */
|
||||
export function countSessions(projects: ReadonlyArray<{ sessions: SessionSummary[] }>): number {
|
||||
const split = new Set<string>()
|
||||
let count = 0
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
if (session.projectSplit) split.add(`${session.projectSplit.primaryProjectPath}\0${session.sessionId}`)
|
||||
else count++
|
||||
}
|
||||
}
|
||||
return count + split.size
|
||||
}
|
||||
|
|
|
|||
11
src/types.ts
11
src/types.ts
|
|
@ -129,6 +129,9 @@ export type ParsedApiCall = {
|
|||
/// this call's assistant message. Transient (built at parse time, aggregated
|
||||
/// into the turn's `spawnToolUseIds`); never cached per-call.
|
||||
spawnToolUseIds?: string[]
|
||||
/// Claude Code: the `cwd` recorded on this call's assistant line. Transient
|
||||
/// (parse time only); the cache stores it per call only when it changes.
|
||||
cwd?: string
|
||||
/// When set, `costUSD` is the actual local call (forced to 0) and
|
||||
/// `savingsUSD` is the counterfactual cost the same tokens would have
|
||||
/// incurred against `savingsBaselineModel`. Set by the savings
|
||||
|
|
@ -209,6 +212,10 @@ export type ClassifiedTurn = ParsedTurn & {
|
|||
subCategory?: string
|
||||
retries: number
|
||||
hasEdits: boolean
|
||||
/// The calls of a turn that moved to another project mid-turn. The piece that
|
||||
/// holds the turn's first call carries the turn's weight; this one only cost
|
||||
/// (it reads as no edits and no retries).
|
||||
projectContinuation?: boolean
|
||||
}
|
||||
|
||||
export type SessionSourceMetadata = {
|
||||
|
|
@ -333,6 +340,10 @@ export type SessionSummary = {
|
|||
/// from a provider that never captures branches (→ contributes nothing).
|
||||
/// Claude only; absent otherwise.
|
||||
everHadBranch?: boolean
|
||||
/// Claude Code: set on each per-project slice of a session whose calls ran
|
||||
/// in more than one project (per-call `cwd`). Session rows merge the slices
|
||||
/// back into one row labeled with the primary (highest-cost) project.
|
||||
projectSplit?: { primaryProject: string; primaryProjectPath: string; primary: boolean }
|
||||
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number; activeDurationMs?: number; activeGeneratedTokens?: number; toolWaitMs?: number }>
|
||||
toolBreakdown: Record<string, { calls: number }>
|
||||
mcpBreakdown: Record<string, { calls: number }>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { CATEGORY_LABELS, type ProjectSummary, type SessionSummary, type TaskCat
|
|||
import { behavioralCallWeight } from './behavioral-weight.js'
|
||||
import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, type ClaudeConfigSelector, type HydrationState, buildMenubarPayload } from './menubar-json.js'
|
||||
import { type SessionCountBasis } from './session-count-label.js'
|
||||
import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, filterProjectsByDateRange, isSessionHydrationComplete, makeProjectFilter, type ProjectFilterTarget, sessionHydrationSnapshot } from './parser.js'
|
||||
import { parseAllSessions, filterProjectsByName, mergeProjectSplits, filterProjectsByDays, filterProjectsByClaudeConfigSource, filterProjectsByDateRange, isSessionHydrationComplete, makeProjectFilter, type ProjectFilterTarget, sessionHydrationSnapshot } from './parser.js'
|
||||
type ProjectFilter = (entry: ProjectFilterTarget) => boolean
|
||||
|
||||
import { findUnpricedModels, getFlatRateModelsConfigHash, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel, billableOutputTokens, modelRowKey } from './models.js'
|
||||
|
|
@ -1098,7 +1098,7 @@ type PayloadSessionDetail = NonNullable<PayloadProject['sessionDetails']>[number
|
|||
/// Filename-colliding `sess.jsonl` in two folders stay distinct. Same file
|
||||
/// spanning days stays one. Not fingerprint/cost/calls.
|
||||
export function canonicalSessionCountKey(session: SessionSummary, projectPath?: string): string {
|
||||
const loc = projectPath || session.workingDirectory || session.project
|
||||
const loc = session.projectSplit?.primaryProjectPath || projectPath || session.workingDirectory || session.project
|
||||
return `${inferSessionProvider(session)}\0${loc}\0${session.sessionId}`
|
||||
}
|
||||
|
||||
|
|
@ -1836,7 +1836,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
|
||||
const retryTax = buildRetryTax(effMap.values())
|
||||
|
||||
currentData.topSessions = scanProjects.flatMap(p =>
|
||||
currentData.topSessions = mergeProjectSplits(scanProjects).flatMap(p =>
|
||||
p.sessions.map(s => ({
|
||||
project: friendlyProject(p),
|
||||
cost: s.totalCostUSD,
|
||||
|
|
@ -1864,7 +1864,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
// sessions) so this stays the genuine unscoped all-provider aggregation.
|
||||
if (isAllProviders && !effectivelyScoped) {
|
||||
// One pass yields both rows and totals, so they never disagree.
|
||||
const { rows: prRows, totals: prTotals } = buildPrAttribution(scanProjects)
|
||||
const { rows: prRows, totals: prTotals } = buildPrAttribution(mergeProjectSplits(scanProjects))
|
||||
if (prRows.length > 0) {
|
||||
currentData.pullRequests = {
|
||||
// PRs are user-auditable spend records, so never collapse the tail into
|
||||
|
|
|
|||
|
|
@ -221,6 +221,22 @@ describe('detectUnusedMcp', () => {
|
|||
expect(finding!.explanation).toContain('ghost')
|
||||
})
|
||||
|
||||
it('counts a session split across projects once', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ mcpServers: { ghost: { command: 'x' } } }))
|
||||
touchOld(join(projectDir, '.mcp.json'), 30)
|
||||
const slice = (primary: boolean) => ({ sessionId: 's1', mcpBreakdown: {}, projectSplit: { primaryProject: 'a', primaryProjectPath: '/a', primary } })
|
||||
const projects = [
|
||||
{ project: 'a', projectPath: '/a', sessions: [slice(true)] },
|
||||
{ project: 'b', projectPath: '/b', sessions: [slice(false)] },
|
||||
] as unknown as ProjectSummary[]
|
||||
const split = detectUnusedMcp([], projects, new Set([projectDir]), [])
|
||||
const single = detectUnusedMcp([], projects.slice(0, 1), new Set([projectDir]), [])
|
||||
expect(split!.tokensSaved).toBe(single!.tokensSaved)
|
||||
})
|
||||
|
||||
it('does not flag servers configured within 24 hours', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
|
|
|
|||
174
tests/parser-claude-per-call-cwd.test.ts
Normal file
174
tests/parser-claude-per-call-cwd.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { appendFile, mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { clearSessionCache, filterProjectsByName, mergeProjectSplits, parseAllSessions } from '../src/parser.js'
|
||||
import { aggregateSessions } from '../src/sessions-report.js'
|
||||
import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
|
||||
import { countSessions } from '../src/session-output.js'
|
||||
import { buildMenubarPayloadForRange, uniqueCanonicalSessionCountFromProjects } from '../src/usage-aggregator.js'
|
||||
import type { DateRange, ProjectSummary } from '../src/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'claude-per-call-cwd-'))
|
||||
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
|
||||
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions')
|
||||
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
|
||||
clearSessionCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearSessionCache()
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const RANGE: DateRange = { start: new Date('2099-06-01T00:00:00.000Z'), end: new Date('2099-06-01T23:59:59.999Z') }
|
||||
const HOME = '/work/home'
|
||||
const APP = '/work/app'
|
||||
|
||||
const user = (text: string, cwd: string, ts: string) => JSON.stringify({
|
||||
type: 'user', sessionId: 's1', timestamp: ts, cwd, message: { role: 'user', content: text },
|
||||
})
|
||||
const assistant = (id: string, cwd: string, ts: string, input: number) => JSON.stringify({
|
||||
type: 'assistant', sessionId: 's1', timestamp: ts, cwd,
|
||||
message: { id, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: input, output_tokens: 0 } },
|
||||
})
|
||||
|
||||
async function writeSession(lines: string[]): Promise<string> {
|
||||
const dir = join(tmpDir, 'projects', '-work-home')
|
||||
await mkdir(dir, { recursive: true })
|
||||
const file = join(dir, 's1.jsonl')
|
||||
await writeFile(file, lines.join('\n') + '\n')
|
||||
return file
|
||||
}
|
||||
|
||||
const byPath = (projects: ProjectSummary[]) => Object.fromEntries(projects.map(p => [p.projectPath, p]))
|
||||
|
||||
describe('Claude per-call cwd', () => {
|
||||
it('splits a session that moved folders by call, keeping totals, turns and the session count whole', async () => {
|
||||
await writeSession([
|
||||
user('start', HOME, '2099-06-01T10:00:00.000Z'),
|
||||
assistant('m1', HOME, '2099-06-01T10:00:01.000Z', 1_000_000),
|
||||
// The working directory changes mid-turn, then a new turn starts there.
|
||||
assistant('m2', APP, '2099-06-01T10:00:02.000Z', 2_000_000),
|
||||
user('next', APP, '2099-06-01T10:05:00.000Z'),
|
||||
assistant('m3', APP, '2099-06-01T10:05:01.000Z', 3_000_000),
|
||||
])
|
||||
|
||||
const projects = await parseAllSessions(RANGE, 'claude')
|
||||
const paths = byPath(projects)
|
||||
expect(Object.keys(paths).sort()).toEqual([APP, HOME])
|
||||
expect(paths[HOME]!.totalApiCalls).toBe(1)
|
||||
expect(paths[APP]!.totalApiCalls).toBe(2)
|
||||
expect(paths[APP]!.totalCostUSD).toBeCloseTo(paths[HOME]!.totalCostUSD * 5, 10)
|
||||
|
||||
const allTurns = projects.flatMap(p => p.sessions).reduce((n, s) => n + Object.values(s.categoryBreakdown).reduce((m, c) => m + c.turns, 0), 0)
|
||||
expect(allTurns).toBe(2)
|
||||
expect(countSessions(projects)).toBe(1)
|
||||
expect(uniqueCanonicalSessionCountFromProjects(projects)).toBe(1)
|
||||
|
||||
const [day] = aggregateProjectsIntoDays(projects)
|
||||
expect(day!.sessions).toBe(1)
|
||||
expect(day!.calls).toBe(3)
|
||||
expect(Object.values(day!.projects!).map(p => p.calls).sort()).toEqual([1, 2])
|
||||
|
||||
const rows = aggregateSessions(mergeProjectSplits(projects))
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.project).toBe('-work-app')
|
||||
expect(rows[0]!.calls).toBe(3)
|
||||
expect(rows[0]!.turns).toBe(2)
|
||||
expect(rows[0]!.cost).toBeCloseTo(paths[HOME]!.totalCostUSD + paths[APP]!.totalCostUSD, 10)
|
||||
|
||||
const payload = await buildMenubarPayloadForRange({ range: RANGE, label: 'p' }, { provider: 'claude', optimize: false, timeline: false })
|
||||
expect(payload.current.topSessions.map(s => [s.sessionId, s.calls])).toEqual([['s1', 3]])
|
||||
|
||||
const onlyApp = filterProjectsByName(projects, ['app'], [])
|
||||
expect(onlyApp.map(p => p.projectPath)).toEqual([APP])
|
||||
expect(countSessions(onlyApp)).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a cd into a subfolder of the same repository on the transcript project', async () => {
|
||||
const repo = join(tmpDir, 'repo')
|
||||
await mkdir(join(repo, '.git'), { recursive: true })
|
||||
await mkdir(join(repo, 'app', 'src'), { recursive: true })
|
||||
await writeSession([
|
||||
user('start', repo, '2099-06-01T10:00:00.000Z'),
|
||||
assistant('m1', repo, '2099-06-01T10:00:01.000Z', 1_000_000),
|
||||
assistant('m2', join(repo, 'app'), '2099-06-01T10:00:02.000Z', 2_000_000),
|
||||
assistant('m3', join(repo, 'app', 'src'), '2099-06-01T10:00:03.000Z', 3_000_000),
|
||||
])
|
||||
const projects = await parseAllSessions(RANGE, 'claude')
|
||||
expect(projects.map(p => p.projectPath)).toEqual([repo])
|
||||
expect(projects[0]!.totalApiCalls).toBe(3)
|
||||
expect(projects[0]!.sessions[0]!.projectSplit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('splits a cd from a plain folder into a repository below it, and keeps the repository whole', async () => {
|
||||
const home = join(tmpDir, 'home')
|
||||
const repo = join(home, 'Projects', 'crew')
|
||||
await mkdir(join(repo, '.git'), { recursive: true })
|
||||
await mkdir(join(repo, 'src'), { recursive: true })
|
||||
await mkdir(join(home, 'notes'), { recursive: true })
|
||||
await writeSession([
|
||||
user('start', home, '2099-06-01T10:00:00.000Z'),
|
||||
assistant('m1', home, '2099-06-01T10:00:01.000Z', 1_000_000),
|
||||
assistant('m2', join(home, 'notes'), '2099-06-01T10:00:02.000Z', 1_000_000),
|
||||
assistant('m3', repo, '2099-06-01T10:00:03.000Z', 2_000_000),
|
||||
assistant('m4', join(repo, 'src'), '2099-06-01T10:00:04.000Z', 2_000_000),
|
||||
// A deleted worktree cannot prove it belonged to home, so it stays apart.
|
||||
assistant('m5', join(home, 'gone-wt'), '2099-06-01T10:00:05.000Z', 1_000_000),
|
||||
])
|
||||
const paths = byPath(await parseAllSessions(RANGE, 'claude'))
|
||||
expect(Object.keys(paths).sort()).toEqual([home, repo, join(home, 'gone-wt')].sort())
|
||||
expect(paths[home]!.totalApiCalls).toBe(2)
|
||||
expect(paths[repo]!.totalApiCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('reads the same split from an appended transcript as from a fresh parse', async () => {
|
||||
const file = await writeSession([
|
||||
user('start', HOME, '2099-06-01T10:00:00.000Z'),
|
||||
assistant('m1', HOME, '2099-06-01T10:00:01.000Z', 1_000_000),
|
||||
])
|
||||
await parseAllSessions(RANGE, 'claude')
|
||||
await appendFile(file, [
|
||||
assistant('m2', APP, '2099-06-01T10:00:02.000Z', 2_000_000),
|
||||
assistant('m3', HOME, '2099-06-01T10:00:03.000Z', 4_000_000),
|
||||
].join('\n') + '\n')
|
||||
clearSessionCache()
|
||||
const appended = byPath(await parseAllSessions(RANGE, 'claude'))
|
||||
|
||||
await rm(join(tmpDir, 'cache'), { recursive: true, force: true })
|
||||
clearSessionCache()
|
||||
const fresh = byPath(await parseAllSessions(RANGE, 'claude'))
|
||||
|
||||
for (const paths of [appended, fresh]) {
|
||||
expect(paths[HOME]!.totalApiCalls).toBe(2)
|
||||
expect(paths[APP]!.totalApiCalls).toBe(1)
|
||||
}
|
||||
expect(appended[HOME]!.totalCostUSD).toBeCloseTo(fresh[HOME]!.totalCostUSD, 10)
|
||||
expect(appended[APP]!.totalCostUSD).toBeCloseTo(fresh[APP]!.totalCostUSD, 10)
|
||||
})
|
||||
|
||||
it('keeps a PR-bearing session whose transcript is gone across a parse bump', async () => {
|
||||
const file = await writeSession([
|
||||
user('start', HOME, '2099-06-01T10:00:00.000Z'),
|
||||
assistant('m1', HOME, '2099-06-01T10:00:01.000Z', 1_000_000),
|
||||
JSON.stringify({ type: 'pr-link', sessionId: 's1', timestamp: '2099-06-01T10:00:02.000Z', prUrl: 'https://github.com/o/r/pull/1' }),
|
||||
])
|
||||
await parseAllSessions(RANGE, 'claude')
|
||||
await rm(file)
|
||||
// Any change to the provider's env fingerprint rebuilds its cache section,
|
||||
// exactly like a parse-version bump.
|
||||
process.env['APPDATA'] = join(tmpDir, 'appdata')
|
||||
clearSessionCache()
|
||||
try {
|
||||
const projects = await parseAllSessions(RANGE, 'claude')
|
||||
expect(projects.flatMap(p => p.sessions).map(s => s.sessionId)).toEqual(['s1'])
|
||||
} finally {
|
||||
delete process.env['APPDATA']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -311,6 +311,17 @@ describe('sessions spanning the A/B boundary', () => {
|
|||
expect(rows[0]!.identity).toContain('straddle-1')
|
||||
})
|
||||
|
||||
it('joins a split session whose primary project differs between the ranges', () => {
|
||||
const slice = (cost: number, primaryProjectPath: string, primary: boolean): SessionSummary =>
|
||||
({ ...makeSession({ sessionId: 'moved-1', model: 'model-x', costUSD: cost, calls: 1 }), projectSplit: { primaryProject: primaryProjectPath, primaryProjectPath, primary } })
|
||||
const projectsA = [makeProject('/home', [slice(30, '/home', true)]), makeProject('/app', [slice(10, '/home', false)])]
|
||||
const projectsB = [makeProject('/home', [slice(5, '/app', false)]), makeProject('/app', [slice(50, '/app', true)])]
|
||||
expect(diffSessions(projectsA, projectsB, 'model', 'model-x')).toMatchObject([{ sessionId: 'moved-1', costA: 40, costB: 55 }])
|
||||
expect(diffSessions(projectsA, projectsB, 'project', '/home')).toMatchObject([{ sessionId: 'moved-1', costA: 30, costB: 5 }])
|
||||
const unsplitA = [makeProject('/home', [makeSession({ sessionId: 'moved-1', model: 'model-x', costUSD: 30, calls: 1 })])]
|
||||
expect(diffSessions(unsplitA, projectsB, 'project', '/home')).toMatchObject([{ sessionId: 'moved-1', costA: 30, costB: 5 }])
|
||||
})
|
||||
|
||||
it('drills by model across projects and by project across models', () => {
|
||||
const a = [makeProject('/w1', [makeSession({ sessionId: 's1', modelCosts: { 'm-a': { costUSD: 10, calls: 1 }, 'm-b': { costUSD: 5, calls: 1 } } })])]
|
||||
const b = [makeProject('/w2', [makeSession({ sessionId: 's2', modelCosts: { 'm-a': { costUSD: 25, calls: 2 } } })])]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue