mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(stats): count one assistant reply once instead of once per content block
Claude Code writes an assistant message as one JSONL line per content block — thinking, text, and each tool_use separately — and every one of those lines repeats the same complete `usage` object. Both stats paths summed them, so a reply cost as many times as it had blocks. One real message in this repo's own transcripts spans 52 lines carrying 50.8K tokens, and was counted as 2.65M. Scanning ~/.claude/projects with 1927 sources: the total drops from 9.76B to 4.59B tokens, and the busiest day from 2.60B to 1.46B. 39,290 of 69,306 usage lines were repeats. Usage records now deduplicate on (message.id, requestId), scoped per transcript, and survive an incremental read. Lines with no message id are still counted, matching ccusage. Two paths compute these stats — the local index reducer and the direct scan in stats.ts — and a parity test pins them to identical output. Both carried the bug, so the rules that decide what counts now live in one module, `utils/usageAccounting.ts`, rather than being written twice and drifting. Also fixed, all surfaced while verifying the above: Cost was dead code. `costUSD` and `webSearchRequests` were initialized to 0 in the reducer and never assigned, so 0 travelled to SQLite and out again. Now estimated from the rates in modelCost.ts — but with an unknown model returning null instead of falling back to the default model's rates, because 12.7% of the tokens here come from third-party providers (k3, glm, MiniMax, deepseek, grok) that would otherwise be billed at Claude prices. Those models keep their tokens in the activity totals and are named in `unpricedModels` so the UI can say the dollar figure is a floor. `MODEL_COSTS` has no entry for claude-opus-5 — the canonical-name resolver maps it to `claude-opus`, which isn't a key either — so the largest model by usage priced through the unknown-model fallback. The new module handles it explicitly. The CLI's own calculateUSDCost still takes that fallback; left alone here, it reaches well beyond activity stats. "Longest task" measured `last - first`, a calendar span rather than a duration: a session resumed the next morning reported the whole night as time on task. The panel read 436 hours for a 17-message session, and the worst case on this machine was 1137 hours. Sessions now accumulate working time across gaps under 30 minutes, stored in a new `active_duration_ms` column. Workflow subagent transcripts were never indexed. Discovery read one level of `subagents/`, but workflow agents nest a group deeper at `subagents/workflows/<id>/`; 79 files here were invisible to both the index and the direct scan, which additionally attributed them to a session named "workflows". Ties in "longest session" now break on session id. The two paths iterate sessions in different orders, and ties became reachable once an out-of-order session scored 0 rather than a distinct negative span. The parser version bump is what makes any of this reach an existing install: `detectSourceChange` rebuilds a source only when its stored version differs.
This commit is contained in:
@@ -34,6 +34,9 @@ export type ModelUsage = {
|
||||
outputTokens?: number
|
||||
cacheReadInputTokens?: number
|
||||
cacheCreationInputTokens?: number
|
||||
webSearchRequests?: number
|
||||
/** Estimated dollars. Always 0 for models listed in `unpricedModels`. */
|
||||
costUSD?: number
|
||||
}
|
||||
|
||||
export type ActivityStats = {
|
||||
@@ -46,6 +49,12 @@ export type ActivityStats = {
|
||||
dailyModelTokens: DailyModelTokens[]
|
||||
longestSession: SessionStats | null
|
||||
modelUsage: Record<string, ModelUsage>
|
||||
/**
|
||||
* Models whose tokens count toward the totals but whose dollars don't, because no published
|
||||
* rates exist for them (third-party providers). The cost figure is a floor when this is
|
||||
* non-empty, and the UI has to say so rather than presenting it as the whole bill.
|
||||
*/
|
||||
unpricedModels?: string[]
|
||||
toolUsage: Record<string, number>
|
||||
skillUsage: Record<string, number>
|
||||
firstSessionDate: string | null
|
||||
|
||||
@@ -419,6 +419,11 @@ export const en = {
|
||||
'settings.activity.exploredSkills': 'Skills explored',
|
||||
'settings.activity.totalSkillUses': 'Skill uses',
|
||||
'settings.activity.totalToolCalls': 'Tool calls',
|
||||
'settings.activity.freshTokens': 'New tokens',
|
||||
'settings.activity.cachedTokens': 'Cache hits',
|
||||
'settings.activity.estimatedCost': 'Estimated cost',
|
||||
'settings.activity.ofTotal': '{percent} of total',
|
||||
'settings.activity.costExcludesModels': 'Excludes {count} unpriced models',
|
||||
'settings.activity.totalSessions': 'Total sessions',
|
||||
'settings.activity.mostUsedPluginsAndSkills': 'Most used plugins & skills',
|
||||
'settings.activity.none': 'None',
|
||||
|
||||
@@ -421,6 +421,11 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.activity.exploredSkills': '使用したスキル',
|
||||
'settings.activity.totalSkillUses': 'スキル使用数',
|
||||
'settings.activity.totalToolCalls': 'ツール呼び出し',
|
||||
'settings.activity.freshTokens': '新規トークン',
|
||||
'settings.activity.cachedTokens': 'キャッシュヒット',
|
||||
'settings.activity.estimatedCost': '推定コスト',
|
||||
'settings.activity.ofTotal': '全体の {percent}',
|
||||
'settings.activity.costExcludesModels': '未価格モデル {count} 件を除く',
|
||||
'settings.activity.totalSessions': 'セッション総数',
|
||||
'settings.activity.mostUsedPluginsAndSkills': 'よく使うプラグイン/スキル',
|
||||
'settings.activity.none': 'なし',
|
||||
|
||||
@@ -421,6 +421,11 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.activity.exploredSkills': '사용한 스킬',
|
||||
'settings.activity.totalSkillUses': '스킬 사용 수',
|
||||
'settings.activity.totalToolCalls': '도구 호출',
|
||||
'settings.activity.freshTokens': '신규 토큰',
|
||||
'settings.activity.cachedTokens': '캐시 적중',
|
||||
'settings.activity.estimatedCost': '예상 비용',
|
||||
'settings.activity.ofTotal': '전체의 {percent}',
|
||||
'settings.activity.costExcludesModels': '가격 미책정 모델 {count}개 제외',
|
||||
'settings.activity.totalSessions': '총 세션',
|
||||
'settings.activity.mostUsedPluginsAndSkills': '자주 쓰는 플러그인/스킬',
|
||||
'settings.activity.none': '없음',
|
||||
|
||||
@@ -421,6 +421,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.activity.exploredSkills': '已使用的技能',
|
||||
'settings.activity.totalSkillUses': '技能使用總數',
|
||||
'settings.activity.totalToolCalls': '工具呼叫',
|
||||
'settings.activity.freshTokens': '新增 Token',
|
||||
'settings.activity.cachedTokens': '快取命中 Token',
|
||||
'settings.activity.estimatedCost': '估算成本',
|
||||
'settings.activity.ofTotal': '佔 {percent}',
|
||||
'settings.activity.costExcludesModels': '不含 {count} 個未定價模型',
|
||||
'settings.activity.totalSessions': '會話總數',
|
||||
'settings.activity.mostUsedPluginsAndSkills': '最常用的外掛和技能',
|
||||
'settings.activity.none': '暫無',
|
||||
|
||||
@@ -421,6 +421,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.activity.exploredSkills': '已使用的技能',
|
||||
'settings.activity.totalSkillUses': '技能使用总数',
|
||||
'settings.activity.totalToolCalls': '工具调用',
|
||||
'settings.activity.freshTokens': '新增 Token',
|
||||
'settings.activity.cachedTokens': '缓存命中 Token',
|
||||
'settings.activity.estimatedCost': '估算成本',
|
||||
'settings.activity.ofTotal': '占 {percent}',
|
||||
'settings.activity.costExcludesModels': '不含 {count} 个未定价模型',
|
||||
'settings.activity.totalSessions': '会话总数',
|
||||
'settings.activity.mostUsedPluginsAndSkills': '最常用的插件和技能',
|
||||
'settings.activity.none': '暂无',
|
||||
|
||||
@@ -172,6 +172,36 @@ function getModelTokenTotal(usage: ActivityStatsResponse['modelUsage'][string] |
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens the model actually had to process, as opposed to prefix it read back from cache. Cache
|
||||
* reads dominate the headline total — over 90% of it on a heavy agentic workload — and bill at a
|
||||
* tenth of input, so the raw total on its own says very little about what was spent.
|
||||
*/
|
||||
function getFreshTokenTotal(usage: ActivityStatsResponse['modelUsage'][string] | undefined) {
|
||||
if (!usage) return 0
|
||||
return (
|
||||
(usage.inputTokens ?? 0) +
|
||||
(usage.outputTokens ?? 0) +
|
||||
(usage.cacheCreationInputTokens ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
function formatCostUSD(cost: number, locale: Locale) {
|
||||
return new Intl.NumberFormat(DATE_LOCALES[locale], {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: cost >= 100 ? 0 : 2,
|
||||
}).format(cost)
|
||||
}
|
||||
|
||||
function formatShare(part: number, whole: number, locale: Locale) {
|
||||
if (whole <= 0) return '0%'
|
||||
return new Intl.NumberFormat(DATE_LOCALES[locale], {
|
||||
maximumFractionDigits: part / whole < 0.1 ? 1 : 0,
|
||||
style: 'percent',
|
||||
}).format(part / whole)
|
||||
}
|
||||
|
||||
function formatModelName(model: string) {
|
||||
return model
|
||||
.replace(/^claude-/i, '')
|
||||
@@ -573,6 +603,18 @@ export function ActivitySettings() {
|
||||
return Math.max(peak, dayTotal)
|
||||
}, 0)
|
||||
}, [stats])
|
||||
const tokenBreakdown = useMemo(() => {
|
||||
let fresh = 0
|
||||
let cached = 0
|
||||
let costUSD = 0
|
||||
for (const usage of Object.values(stats?.modelUsage ?? {})) {
|
||||
fresh += getFreshTokenTotal(usage)
|
||||
cached += usage.cacheReadInputTokens ?? 0
|
||||
costUSD += usage.costUSD ?? 0
|
||||
}
|
||||
return { fresh, cached, costUSD }
|
||||
}, [stats])
|
||||
const unpricedModelCount = stats?.unpricedModels?.length ?? 0
|
||||
const topPluginItems = useMemo(() => buildPluginAndSkillRankItems(stats), [stats])
|
||||
const metrics: SummaryMetric[] = [
|
||||
{
|
||||
@@ -611,6 +653,29 @@ export function ActivitySettings() {
|
||||
value: topModel ? formatModelName(topModel.model) : t('settings.activity.none'),
|
||||
detail: topModel ? `${formatTokens(topModel.tokens)} ${t('settings.activity.tokens')}` : undefined,
|
||||
},
|
||||
{
|
||||
label: t('settings.activity.freshTokens'),
|
||||
value: formatTokens(tokenBreakdown.fresh),
|
||||
detail: t('settings.activity.ofTotal', {
|
||||
percent: formatShare(tokenBreakdown.fresh, totalTokens, locale),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: t('settings.activity.cachedTokens'),
|
||||
value: formatTokens(tokenBreakdown.cached),
|
||||
detail: t('settings.activity.ofTotal', {
|
||||
percent: formatShare(tokenBreakdown.cached, totalTokens, locale),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: t('settings.activity.estimatedCost'),
|
||||
value: formatCostUSD(tokenBreakdown.costUSD, locale),
|
||||
// A partial total presented as a complete one would understate spend for anyone running
|
||||
// third-party models, so say what it leaves out.
|
||||
detail: unpricedModelCount > 0
|
||||
? t('settings.activity.costExcludesModels', { count: unpricedModelCount })
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: t('settings.activity.exploredSkills'),
|
||||
value: formatInteger(exploredSkillsCount, locale),
|
||||
|
||||
@@ -223,21 +223,25 @@ export function writeActivityProjection(
|
||||
const lastTime = activity.lastTimestamp
|
||||
? Date.parse(activity.lastTimestamp)
|
||||
: Number.NaN
|
||||
// Calendar span from first to last message. Kept for callers that want the wall-clock reach of a
|
||||
// session; `active_duration_ms` is what "task length" should be measured with, because a session
|
||||
// resumed the next morning spans the whole night without anyone working through it.
|
||||
const duration = Number.isFinite(firstTime) && Number.isFinite(lastTime)
|
||||
? lastTime - firstTime
|
||||
: 0
|
||||
operation.run(`
|
||||
INSERT INTO activity_sessions (
|
||||
transcript_path, session_id, first_timestamp, last_timestamp,
|
||||
duration_ms, message_count, start_hour, speculation_time_saved_ms,
|
||||
shot_count
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
duration_ms, active_duration_ms, message_count, start_hour,
|
||||
speculation_time_saved_ms, shot_count
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
source.path,
|
||||
source.parentSessionId,
|
||||
activity.firstTimestamp,
|
||||
activity.lastTimestamp,
|
||||
duration,
|
||||
activity.activeDurationMs,
|
||||
activity.messageCount,
|
||||
activity.startHour,
|
||||
activity.speculationTimeSavedMs,
|
||||
@@ -521,12 +525,14 @@ export function createActivityIndex(
|
||||
const sessionStats = operation.all<{
|
||||
session_id: string
|
||||
duration_ms: number
|
||||
active_duration_ms: number
|
||||
message_count: number
|
||||
first_timestamp: string
|
||||
start_hour: number | null
|
||||
source_mtime_ms: number
|
||||
}>(`
|
||||
SELECT activity_sessions.session_id, activity_sessions.duration_ms,
|
||||
activity_sessions.active_duration_ms,
|
||||
activity_sessions.message_count, activity_sessions.first_timestamp,
|
||||
activity_sessions.start_hour,
|
||||
activity_sources.mtime_ms AS source_mtime_ms
|
||||
@@ -541,7 +547,8 @@ export function createActivityIndex(
|
||||
`, ...sessionRange.bindings)
|
||||
const sessions: SessionStats[] = sessionStats.map(row => ({
|
||||
sessionId: row.session_id,
|
||||
duration: row.duration_ms,
|
||||
// Active working time, not the calendar span — see active_duration_ms in migrations.
|
||||
duration: row.active_duration_ms,
|
||||
messageCount: row.message_count,
|
||||
timestamp: row.first_timestamp,
|
||||
}))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
|
||||
import type { LocalIndexDatabase } from './database.js'
|
||||
import {
|
||||
createLocalIndexCoordinator,
|
||||
discoverActivityTranscriptSources,
|
||||
discoverTranscriptSources,
|
||||
type LocalIndexCoordinator,
|
||||
} from './coordinator.js'
|
||||
@@ -20,7 +21,9 @@ import {
|
||||
type ProjectionProgress,
|
||||
type SessionProjector,
|
||||
type SessionSourceCandidate,
|
||||
SESSION_SUMMARY_PARSER_VERSION,
|
||||
} from './sessionProjector.js'
|
||||
import { LOCAL_INDEX_SCHEMA_VERSION } from './migrations.js'
|
||||
import type {
|
||||
ReconciliationWatcher,
|
||||
ReconciliationWatcherOptions,
|
||||
@@ -373,7 +376,7 @@ describe('local index coordinator', () => {
|
||||
expect(coordinator.listSessions({ limit: 10 }).sessions[0]?.messageCount).toBe(1)
|
||||
expect(coordinator.getSessionEntryLocators?.(first.path, ['user']))
|
||||
.toMatchObject({
|
||||
source: { path: first.path, parserVersion: 2 },
|
||||
source: { path: first.path, parserVersion: SESSION_SUMMARY_PARSER_VERSION },
|
||||
entries: [{ ordinal: 0, jsonlLine: 1, entryType: 'user' }],
|
||||
})
|
||||
|
||||
@@ -853,7 +856,7 @@ describe('local index coordinator', () => {
|
||||
|
||||
const { Database } = await import('bun:sqlite')
|
||||
const future = new Database(databasePath)
|
||||
future.exec('PRAGMA user_version = 4')
|
||||
future.exec(`PRAGMA user_version = ${LOCAL_INDEX_SCHEMA_VERSION + 1}`)
|
||||
future.close(true)
|
||||
const unsupported = createCoordinator()
|
||||
await unsupported.start()
|
||||
@@ -2081,3 +2084,67 @@ async function createRealTranscript(
|
||||
modifiedAtMs: snapshot.mtimeMs,
|
||||
}
|
||||
}
|
||||
|
||||
describe('discoverActivityTranscriptSources', () => {
|
||||
async function seedSubagentTree(root: string): Promise<void> {
|
||||
const project = join(root, 'projects', '-repo')
|
||||
const session = join(project, 'session-a')
|
||||
await mkdir(join(session, 'subagents', 'workflows', 'wf_abc123'), { recursive: true })
|
||||
await mkdir(join(session, 'subagents', 'nested', 'deeper', 'too-deep'), { recursive: true })
|
||||
await writeFile(join(project, 'session-a.jsonl'), '')
|
||||
await writeFile(join(session, 'subagents', 'agent-plain.jsonl'), '')
|
||||
await writeFile(join(session, 'subagents', 'workflows', 'wf_abc123', 'agent-wf.jsonl'), '')
|
||||
await writeFile(join(session, 'subagents', 'nested', 'agent-nested.jsonl'), '')
|
||||
await writeFile(join(session, 'subagents', 'nested', 'deeper', 'too-deep', 'agent-x.jsonl'), '')
|
||||
// Neither of these is a subagent transcript and both must stay out of the index.
|
||||
await writeFile(join(session, 'subagents', 'notes.txt'), '')
|
||||
await writeFile(join(session, 'subagents', 'summary.jsonl'), '')
|
||||
}
|
||||
|
||||
it('finds workflow agent transcripts nested under subagents/', async () => {
|
||||
const root = await createTempDir('activity-discovery')
|
||||
await seedSubagentTree(root)
|
||||
|
||||
const result = await discoverActivityTranscriptSources(root, new AbortController().signal)
|
||||
|
||||
const names = result.candidates.map(candidate => candidate.path.split('/').pop()).sort()
|
||||
// agent-wf.jsonl lives at subagents/workflows/<wf_id>/ — the level that used to be skipped
|
||||
// entirely, leaving every workflow agent's tokens and tool calls out of the stats.
|
||||
expect(names).toEqual([
|
||||
'agent-nested.jsonl',
|
||||
'agent-plain.jsonl',
|
||||
'agent-wf.jsonl',
|
||||
'session-a.jsonl',
|
||||
])
|
||||
expect(result.complete).toBe(true)
|
||||
})
|
||||
|
||||
it('attributes nested workflow agents to their owning session', async () => {
|
||||
const root = await createTempDir('activity-discovery-owner')
|
||||
await seedSubagentTree(root)
|
||||
|
||||
const result = await discoverActivityTranscriptSources(root, new AbortController().signal)
|
||||
|
||||
const workflowAgent = result.candidates.find(candidate =>
|
||||
candidate.path.endsWith('agent-wf.jsonl'))
|
||||
expect(workflowAgent).toMatchObject({
|
||||
sessionId: 'session-a',
|
||||
projectPath: '-repo',
|
||||
isSubagent: true,
|
||||
})
|
||||
expect(result.candidates.find(candidate => candidate.path.endsWith('session-a.jsonl')))
|
||||
.toMatchObject({ isSubagent: false })
|
||||
})
|
||||
|
||||
it('stops walking below the bounded subagent depth', async () => {
|
||||
const root = await createTempDir('activity-discovery-depth')
|
||||
await seedSubagentTree(root)
|
||||
|
||||
const result = await discoverActivityTranscriptSources(root, new AbortController().signal)
|
||||
|
||||
// A deeper tree than workflows need is a sign of something unexpected; discovery must not turn
|
||||
// into a full filesystem walk.
|
||||
expect(result.candidates.some(candidate => candidate.path.endsWith('agent-x.jsonl')))
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -263,6 +263,49 @@ export async function discoverTranscriptSources(
|
||||
return { complete }
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth a `subagents/` tree is walked. Plain subagents sit directly under it; a workflow nests
|
||||
* them one group directory deeper (`subagents/workflows/<wf_id>/agent-*.jsonl`). The bound keeps
|
||||
* an unexpected deep tree from turning discovery into a full filesystem walk.
|
||||
*/
|
||||
const SUBAGENT_SCAN_MAX_DEPTH = 3
|
||||
|
||||
/**
|
||||
* Every `agent-*.jsonl` beneath one session's `subagents/` directory, at any nesting level up to
|
||||
* `SUBAGENT_SCAN_MAX_DEPTH`. Walking rather than reading one level is what brings workflow agent
|
||||
* transcripts into the index — their tokens and tool calls were previously invisible.
|
||||
*/
|
||||
async function collectSubagentPaths(
|
||||
directory: string,
|
||||
signal: AbortSignal,
|
||||
depth = 1,
|
||||
): Promise<{ complete: boolean; paths: string[] }> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return { complete: true, paths: [] }
|
||||
return { complete: false, paths: [] }
|
||||
}
|
||||
|
||||
const paths: string[] = []
|
||||
let complete = true
|
||||
for (const entry of entries) {
|
||||
if (signal.aborted) return { complete: false, paths }
|
||||
if (entry.isFile()) {
|
||||
if (entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')) {
|
||||
paths.push(join(directory, entry.name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!entry.isDirectory() || depth >= SUBAGENT_SCAN_MAX_DEPTH) continue
|
||||
const nested = await collectSubagentPaths(join(directory, entry.name), signal, depth + 1)
|
||||
if (!nested.complete) complete = false
|
||||
paths.push(...nested.paths)
|
||||
}
|
||||
return { complete, paths }
|
||||
}
|
||||
|
||||
export async function discoverActivityTranscriptSources(
|
||||
scope: string,
|
||||
signal: AbortSignal,
|
||||
@@ -320,24 +363,10 @@ export async function discoverActivityTranscriptSources(
|
||||
}
|
||||
if (!entry.isDirectory()) continue
|
||||
const subagentsDir = join(projectDir, entry.name, 'subagents')
|
||||
let subagentEntries
|
||||
try {
|
||||
subagentEntries = await readdir(subagentsDir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (isMissing(error)) continue
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
for (const subagent of subagentEntries) {
|
||||
if (
|
||||
signal.aborted ||
|
||||
!subagent.isFile() ||
|
||||
!subagent.name.startsWith('agent-') ||
|
||||
!subagent.name.endsWith('.jsonl')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const path = join(subagentsDir, subagent.name)
|
||||
const found = await collectSubagentPaths(subagentsDir, signal)
|
||||
if (!found.complete) complete = false
|
||||
for (const path of found.paths) {
|
||||
if (signal.aborted) return { complete: false, candidates }
|
||||
try {
|
||||
const snapshot = await stat(path)
|
||||
candidates.push({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import type { LocalIndexWriteOperation } from './database.js'
|
||||
import { LOCAL_INDEX_SCHEMA_VERSION } from './migrations.js'
|
||||
|
||||
type EnvironmentName = 'HOME' | 'CLAUDE_CONFIG_DIR' | 'CC_HAHA_LOCAL_INDEX'
|
||||
|
||||
@@ -172,6 +173,111 @@ function seedFrozenV2(database: Database): void {
|
||||
`)
|
||||
}
|
||||
|
||||
// A frozen snapshot of the v3 activity schema, kept verbatim so the v4 migration is exercised
|
||||
// against the shape real databases were left in rather than against today's CREATE TABLE.
|
||||
function seedFrozenV3(database: Database): void {
|
||||
seedFrozenV2(database)
|
||||
database.exec(`
|
||||
CREATE TABLE activity_sources (
|
||||
path TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT NOT NULL,
|
||||
project_path TEXT NOT NULL,
|
||||
is_subagent INTEGER NOT NULL CHECK (is_subagent IN (0, 1)),
|
||||
size_bytes INTEGER NOT NULL,
|
||||
mtime_ms INTEGER NOT NULL,
|
||||
file_identity TEXT,
|
||||
prefix_hash TEXT NOT NULL,
|
||||
indexed_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
parser_version INTEGER NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('ready', 'pending', 'degraded')),
|
||||
last_error_code TEXT,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE activity_sessions (
|
||||
transcript_path TEXT PRIMARY KEY REFERENCES activity_sources(path) ON DELETE CASCADE,
|
||||
session_id TEXT NOT NULL,
|
||||
first_timestamp TEXT,
|
||||
last_timestamp TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
message_count INTEGER NOT NULL DEFAULT 0,
|
||||
start_hour INTEGER,
|
||||
speculation_time_saved_ms INTEGER NOT NULL DEFAULT 0,
|
||||
shot_count INTEGER
|
||||
);
|
||||
CREATE TABLE activity_daily (
|
||||
transcript_path TEXT NOT NULL REFERENCES activity_sources(path) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
message_count INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (transcript_path, date)
|
||||
);
|
||||
CREATE TABLE activity_daily_models (
|
||||
transcript_path TEXT NOT NULL REFERENCES activity_sources(path) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
web_search_requests INTEGER NOT NULL DEFAULT 0,
|
||||
cost_usd REAL NOT NULL DEFAULT 0,
|
||||
context_window INTEGER NOT NULL DEFAULT 0,
|
||||
max_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (transcript_path, date, model)
|
||||
);
|
||||
CREATE TABLE activity_daily_tools (
|
||||
transcript_path TEXT NOT NULL REFERENCES activity_sources(path) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
call_count INTEGER NOT NULL,
|
||||
PRIMARY KEY (transcript_path, date, tool_name)
|
||||
);
|
||||
CREATE TABLE activity_daily_skills (
|
||||
transcript_path TEXT NOT NULL REFERENCES activity_sources(path) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
skill_name TEXT NOT NULL,
|
||||
call_count INTEGER NOT NULL,
|
||||
PRIMARY KEY (transcript_path, date, skill_name)
|
||||
);
|
||||
CREATE TABLE activity_backfill_state (
|
||||
scope TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL,
|
||||
watermark TEXT,
|
||||
discovered INTEGER NOT NULL DEFAULT 0,
|
||||
indexed INTEGER NOT NULL DEFAULT 0,
|
||||
degraded INTEGER NOT NULL DEFAULT 0,
|
||||
last_error_code TEXT,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX activity_daily_date_idx
|
||||
ON activity_daily(date, transcript_path);
|
||||
CREATE INDEX activity_models_date_idx
|
||||
ON activity_daily_models(date, model, transcript_path);
|
||||
CREATE INDEX activity_tools_date_idx
|
||||
ON activity_daily_tools(date, tool_name, transcript_path);
|
||||
CREATE INDEX activity_skills_date_idx
|
||||
ON activity_daily_skills(date, skill_name, transcript_path);
|
||||
CREATE INDEX activity_sources_parent_idx
|
||||
ON activity_sources(parent_session_id, path);
|
||||
INSERT INTO activity_sources (
|
||||
path, parent_session_id, project_path, is_subagent, size_bytes, mtime_ms,
|
||||
file_identity, prefix_hash, indexed_bytes, parser_version, state,
|
||||
last_error_code, updated_at_ms
|
||||
) VALUES (
|
||||
'/fixture/activity.jsonl', 'fixture-session', '-repo', 0, 512, 1750000000000,
|
||||
'1:2', 'fixture-hash', 512, 2, 'ready', NULL, 1750000000000
|
||||
);
|
||||
INSERT INTO activity_sessions (
|
||||
transcript_path, session_id, first_timestamp, last_timestamp,
|
||||
duration_ms, message_count, start_hour, speculation_time_saved_ms, shot_count
|
||||
) VALUES (
|
||||
'/fixture/activity.jsonl', 'fixture-session', '2026-07-01T00:00:00.000Z',
|
||||
'2026-07-19T00:00:00.000Z', 1571040000, 17, 9, 0, NULL
|
||||
);
|
||||
PRAGMA user_version = 3;
|
||||
`)
|
||||
}
|
||||
|
||||
async function transcriptHashes(paths: string[]): Promise<Record<string, string>> {
|
||||
return Object.fromEntries(await Promise.all(paths.map(async path => [
|
||||
path,
|
||||
@@ -298,7 +404,7 @@ describe('local index database', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('applies ordered v0 to v3 migrations and reopens v3 idempotently', async () => {
|
||||
it('applies ordered v0 to current migrations and reopens idempotently', async () => {
|
||||
const databasePath = join(process.env.CLAUDE_CONFIG_DIR!, 'index.sqlite')
|
||||
const { openLocalIndexDatabase } = await loadDatabase()
|
||||
const first = openLocalIndexDatabase({ path: databasePath })
|
||||
@@ -306,7 +412,7 @@ describe('local index database', () => {
|
||||
expect(first.read(operation =>
|
||||
operation.get<{ user_version: number }>('PRAGMA user_version')
|
||||
?.user_version,
|
||||
)).toBe(3)
|
||||
)).toBe(LOCAL_INDEX_SCHEMA_VERSION)
|
||||
expect(first.read(operation =>
|
||||
operation.all<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
|
||||
@@ -381,13 +487,13 @@ describe('local index database', () => {
|
||||
expect(second.read(operation =>
|
||||
operation.get<{ user_version: number }>('PRAGMA user_version')
|
||||
?.user_version,
|
||||
)).toBe(3)
|
||||
)).toBe(LOCAL_INDEX_SCHEMA_VERSION)
|
||||
} finally {
|
||||
second.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('upgrades a frozen real v1 database to v3 without replaying v1 or losing rows', async () => {
|
||||
it('upgrades a frozen real v1 database to current without replaying v1 or losing rows', async () => {
|
||||
const databasePath = join(process.env.CLAUDE_CONFIG_DIR!, 'frozen-v1.sqlite')
|
||||
await mkdir(dirname(databasePath), { recursive: true })
|
||||
const seed = await openRawDatabase(databasePath)
|
||||
@@ -399,7 +505,7 @@ describe('local index database', () => {
|
||||
try {
|
||||
expect(upgraded.read(operation => operation.get<{ user_version: number }>(
|
||||
'PRAGMA user_version',
|
||||
)?.user_version)).toBe(3)
|
||||
)?.user_version)).toBe(LOCAL_INDEX_SCHEMA_VERSION)
|
||||
expect(upgraded.read(operation => operation.get<{ value: string }>(
|
||||
"SELECT value FROM schema_meta WHERE key = 'fixture'",
|
||||
)?.value)).toBe('v1-preserved')
|
||||
@@ -426,7 +532,7 @@ describe('local index database', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('upgrades a frozen real v2 database to v3 without losing session or locator rows', async () => {
|
||||
it('upgrades a frozen real v2 database to current without losing session or locator rows', async () => {
|
||||
const databasePath = join(process.env.CLAUDE_CONFIG_DIR!, 'frozen-v2.sqlite')
|
||||
await mkdir(dirname(databasePath), { recursive: true })
|
||||
const seed = await openRawDatabase(databasePath)
|
||||
@@ -438,7 +544,7 @@ describe('local index database', () => {
|
||||
try {
|
||||
expect(upgraded.read(operation => operation.get<{ user_version: number }>(
|
||||
'PRAGMA user_version',
|
||||
)?.user_version)).toBe(3)
|
||||
)?.user_version)).toBe(LOCAL_INDEX_SCHEMA_VERSION)
|
||||
expect(upgraded.read(operation => operation.get<{ title: string }>(
|
||||
"SELECT title FROM sessions WHERE transcript_path = '/fixture/session.jsonl'",
|
||||
)?.title)).toBe('Frozen v1')
|
||||
@@ -464,6 +570,40 @@ describe('local index database', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('upgrades a frozen real v3 database to current, keeping activity rows', async () => {
|
||||
const databasePath = join(process.env.CLAUDE_CONFIG_DIR!, 'frozen-v3.sqlite')
|
||||
await mkdir(dirname(databasePath), { recursive: true })
|
||||
const seed = await openRawDatabase(databasePath)
|
||||
seedFrozenV3(seed)
|
||||
seed.close(true)
|
||||
const { openLocalIndexDatabase } = await loadDatabase()
|
||||
|
||||
const upgraded = openLocalIndexDatabase({ path: databasePath })
|
||||
try {
|
||||
expect(upgraded.read(operation => operation.get<{ user_version: number }>(
|
||||
'PRAGMA user_version',
|
||||
)?.user_version)).toBe(LOCAL_INDEX_SCHEMA_VERSION)
|
||||
expect(upgraded.read(operation => operation.all<{ name: string }>(
|
||||
'PRAGMA table_info(activity_sessions)',
|
||||
).map(row => row.name))).toContain('active_duration_ms')
|
||||
const session = upgraded.read(operation => operation.get<{
|
||||
duration_ms: number
|
||||
active_duration_ms: number
|
||||
message_count: number
|
||||
}>(
|
||||
`SELECT duration_ms, active_duration_ms, message_count FROM activity_sessions
|
||||
WHERE transcript_path = '/fixture/activity.jsonl'`,
|
||||
))
|
||||
// Pre-existing rows survive with the calendar span they were written with; the new column
|
||||
// backfills to 0 and is refilled once the bumped parser version rebuilds the source.
|
||||
expect(session?.duration_ms).toBe(1571040000)
|
||||
expect(session?.message_count).toBe(17)
|
||||
expect(session?.active_duration_ms).toBe(0)
|
||||
} finally {
|
||||
upgraded.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back an interrupted v2 to v3 migration without changing v2 data', async () => {
|
||||
const databasePath = join(process.env.CLAUDE_CONFIG_DIR!, 'blocked-v3.sqlite')
|
||||
await mkdir(dirname(databasePath), { recursive: true })
|
||||
@@ -939,7 +1079,7 @@ describe('local index database', () => {
|
||||
await mkdir(dirname(databasePath), { recursive: true })
|
||||
const seed = await openRawDatabase(databasePath)
|
||||
seed.exec('CREATE TABLE future_schema_sentinel (value TEXT)')
|
||||
seed.exec('PRAGMA user_version = 4')
|
||||
seed.exec(`PRAGMA user_version = ${LOCAL_INDEX_SCHEMA_VERSION + 1}`)
|
||||
const journalModeBefore = queryOne<{ journal_mode: string }>(
|
||||
seed,
|
||||
'PRAGMA journal_mode',
|
||||
@@ -961,7 +1101,7 @@ describe('local index database', () => {
|
||||
const inspection = await openRawDatabase(databasePath)
|
||||
try {
|
||||
expect(queryOne<{ user_version: number }>(inspection, 'PRAGMA user_version')
|
||||
?.user_version).toBe(4)
|
||||
?.user_version).toBe(LOCAL_INDEX_SCHEMA_VERSION + 1)
|
||||
expect(queryAll<{ name: string }>(
|
||||
inspection,
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Database } from 'bun:sqlite'
|
||||
|
||||
export const LOCAL_INDEX_SCHEMA_VERSION = 3
|
||||
export const LOCAL_INDEX_SCHEMA_VERSION = 4
|
||||
export const LOCAL_INDEX_SCHEMA_UNSUPPORTED =
|
||||
'LOCAL_INDEX_SCHEMA_UNSUPPORTED' as const
|
||||
|
||||
@@ -175,10 +175,18 @@ CREATE INDEX activity_sources_parent_idx
|
||||
ON activity_sources(parent_session_id, path);
|
||||
`
|
||||
|
||||
// Time actually spent working, as opposed to the calendar span between a session's first and last
|
||||
// message. Existing rows default to 0 and are refilled when the bumped parser version rebuilds
|
||||
// them; nothing reads the column before that rebuild lands.
|
||||
const SCHEMA_V4 = `
|
||||
ALTER TABLE activity_sessions ADD COLUMN active_duration_ms INTEGER NOT NULL DEFAULT 0;
|
||||
`
|
||||
|
||||
const MIGRATIONS = [
|
||||
{ version: 1, sql: SCHEMA_V1 },
|
||||
{ version: 2, sql: SCHEMA_V2 },
|
||||
{ version: 3, sql: SCHEMA_V3 },
|
||||
{ version: 4, sql: SCHEMA_V4 },
|
||||
] as const
|
||||
|
||||
export class UnsupportedLocalIndexSchemaError extends Error {
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('session entry projection', () => {
|
||||
const projector = createSessionProjector({ database, index, scope: root })
|
||||
|
||||
try {
|
||||
expect(SESSION_SUMMARY_PARSER_VERSION).toBe(2)
|
||||
expect(SESSION_SUMMARY_PARSER_VERSION).toBeGreaterThan(0)
|
||||
await projector.projectSource(candidate)
|
||||
await projector.projectSource(untouched)
|
||||
const firstBefore = index.getSessionEntryLocators(candidate.path, ['user'])
|
||||
|
||||
@@ -30,7 +30,11 @@ import type {
|
||||
TranscriptProjection,
|
||||
} from './types.js'
|
||||
|
||||
export const SESSION_SUMMARY_PARSER_VERSION = 2
|
||||
// Bump whenever the reducer's output changes for input it has already seen: a mismatch against a
|
||||
// source's stored version makes `detectSourceChange` return `rebuild`, which is the only thing
|
||||
// that refreshes already-indexed transcripts.
|
||||
// 3: usage is deduplicated per (message.id, requestId), and sessions carry active working time.
|
||||
export const SESSION_SUMMARY_PARSER_VERSION = 3
|
||||
|
||||
export type SessionSourceCandidate = {
|
||||
path: string
|
||||
|
||||
@@ -388,3 +388,294 @@ describe('reduceTranscript', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// One assistant reply, written the way Claude Code actually writes it: one JSONL line per content
|
||||
// block, every line repeating the same complete `usage`. Real transcripts hit 52 lines for a
|
||||
// single reply, and counting each one is what inflated the token totals 2.2x.
|
||||
function assistantBlockLines(options: {
|
||||
messageId: string
|
||||
requestId: string
|
||||
timestamp: string
|
||||
blocks: Array<Record<string, unknown>>
|
||||
usage: Record<string, unknown>
|
||||
model?: string
|
||||
extra?: Record<string, unknown>
|
||||
}) {
|
||||
return options.blocks.map(block => ({
|
||||
type: 'assistant',
|
||||
requestId: options.requestId,
|
||||
version: '1.0.24',
|
||||
message: {
|
||||
id: options.messageId,
|
||||
role: 'assistant',
|
||||
model: options.model ?? 'claude-opus-5',
|
||||
content: [block],
|
||||
usage: options.usage,
|
||||
},
|
||||
timestamp: options.timestamp,
|
||||
...options.extra,
|
||||
}))
|
||||
}
|
||||
|
||||
const STANDARD_USAGE = {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 200,
|
||||
cache_read_input_tokens: 50_000,
|
||||
cache_creation_input_tokens: 300,
|
||||
}
|
||||
|
||||
function modelTotals(projection: ReturnType<typeof reduceTranscript>) {
|
||||
return (projection.activity?.models ?? []).map(model => ({
|
||||
model: model.model,
|
||||
inputTokens: model.inputTokens,
|
||||
outputTokens: model.outputTokens,
|
||||
cacheReadInputTokens: model.cacheReadInputTokens,
|
||||
cacheCreationInputTokens: model.cacheCreationInputTokens,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('reduceTranscript activity usage', () => {
|
||||
it('counts one reply once no matter how many content-block lines it was written as', () => {
|
||||
const chunks = completeChunks(assistantBlockLines({
|
||||
messageId: 'msg_one',
|
||||
requestId: 'req_one',
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
usage: STANDARD_USAGE,
|
||||
blocks: [
|
||||
{ type: 'thinking', thinking: 'planning' },
|
||||
{ type: 'text', text: 'here goes' },
|
||||
...Array.from({ length: 10 }, (_, index) => ({
|
||||
type: 'tool_use',
|
||||
id: `toolu_${index}`,
|
||||
name: 'Bash',
|
||||
input: {},
|
||||
})),
|
||||
],
|
||||
}))
|
||||
|
||||
const result = reduceTranscript(chunks, initialProjection())
|
||||
|
||||
expect(modelTotals(result)).toEqual([{
|
||||
model: 'claude-opus-5',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 200,
|
||||
cacheReadInputTokens: 50_000,
|
||||
cacheCreationInputTokens: 300,
|
||||
}])
|
||||
// The tool calls themselves are real and must still all be counted — only usage deduplicates.
|
||||
expect(result.activity?.daily[0]?.toolCallCount).toBe(10)
|
||||
expect(result.activity?.daily[0]?.messageCount).toBe(12)
|
||||
})
|
||||
|
||||
it('counts a genuinely new reply separately', () => {
|
||||
const chunks = completeChunks([
|
||||
...assistantBlockLines({
|
||||
messageId: 'msg_one',
|
||||
requestId: 'req_one',
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
usage: STANDARD_USAGE,
|
||||
blocks: [{ type: 'text', text: 'first' }, { type: 'text', text: 'also first' }],
|
||||
}),
|
||||
...assistantBlockLines({
|
||||
messageId: 'msg_two',
|
||||
requestId: 'req_two',
|
||||
timestamp: '2026-01-01T10:05:00.000Z',
|
||||
usage: STANDARD_USAGE,
|
||||
blocks: [{ type: 'text', text: 'second' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const result = reduceTranscript(chunks, initialProjection())
|
||||
|
||||
expect(modelTotals(result)[0]).toMatchObject({ inputTokens: 2000, outputTokens: 400 })
|
||||
})
|
||||
|
||||
it('keeps deduplicating across an incremental read', () => {
|
||||
const [firstLine, ...restLines] = assistantBlockLines({
|
||||
messageId: 'msg_split',
|
||||
requestId: 'req_split',
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
usage: STANDARD_USAGE,
|
||||
blocks: [
|
||||
{ type: 'text', text: 'part one' },
|
||||
{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: {} },
|
||||
{ type: 'tool_use', id: 'toolu_b', name: 'Read', input: {} },
|
||||
],
|
||||
})
|
||||
const head = completeChunks([firstLine!])
|
||||
const first = reduceTranscript(head, initialProjection())
|
||||
const tail = completeChunks(restLines, first.indexedBytes)
|
||||
|
||||
const second = reduceTranscript(tail, first)
|
||||
|
||||
// The trailing block lines arrive in a later read; their repeated usage must stay uncounted.
|
||||
expect(modelTotals(second)[0]).toMatchObject({ inputTokens: 1000, outputTokens: 200 })
|
||||
})
|
||||
|
||||
it('counts every line when the log carries no message id to deduplicate on', () => {
|
||||
const chunks = completeChunks([0, 1].map(() => ({
|
||||
type: 'assistant',
|
||||
requestId: 'req_anon',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: 'claude-opus-5',
|
||||
content: [{ type: 'text', text: 'x' }],
|
||||
usage: STANDARD_USAGE,
|
||||
},
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
})))
|
||||
|
||||
const result = reduceTranscript(chunks, initialProjection())
|
||||
|
||||
expect(modelTotals(result)[0]).toMatchObject({ inputTokens: 2000 })
|
||||
})
|
||||
|
||||
it('skips usage from foreign log formats but keeps their activity', () => {
|
||||
const chunks = completeChunks([
|
||||
...assistantBlockLines({
|
||||
messageId: 'msg_foreign',
|
||||
requestId: 'req_foreign',
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
usage: STANDARD_USAGE,
|
||||
blocks: [{ type: 'tool_use', id: 'toolu_x', name: 'Bash', input: {} }],
|
||||
extra: { version: 'unknown' },
|
||||
}),
|
||||
...assistantBlockLines({
|
||||
messageId: '',
|
||||
requestId: 'req_empty_id',
|
||||
timestamp: '2026-01-01T10:01:00.000Z',
|
||||
usage: STANDARD_USAGE,
|
||||
blocks: [{ type: 'text', text: 'malformed' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const result = reduceTranscript(chunks, initialProjection())
|
||||
|
||||
expect(result.activity?.models).toEqual([])
|
||||
// Rejecting a line for billing must not erase it from the activity heatmap.
|
||||
expect(result.activity?.daily[0]?.toolCallCount).toBe(1)
|
||||
expect(result.activity?.messageCount).toBe(2)
|
||||
})
|
||||
|
||||
it('estimates cost for Claude models and leaves third-party models unpriced', () => {
|
||||
const chunks = completeChunks([
|
||||
...assistantBlockLines({
|
||||
messageId: 'msg_claude',
|
||||
requestId: 'req_claude',
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
model: 'claude-opus-5',
|
||||
usage: {
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
server_tool_use: { web_search_requests: 3 },
|
||||
},
|
||||
blocks: [{ type: 'text', text: 'priced' }],
|
||||
}),
|
||||
...assistantBlockLines({
|
||||
messageId: 'msg_glm',
|
||||
requestId: 'req_glm',
|
||||
timestamp: '2026-01-01T10:01:00.000Z',
|
||||
model: 'glm-5.2',
|
||||
usage: { input_tokens: 9_000_000, output_tokens: 9_000_000 },
|
||||
blocks: [{ type: 'text', text: 'unpriced' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const result = reduceTranscript(chunks, initialProjection())
|
||||
const byModel = Object.fromEntries(
|
||||
(result.activity?.models ?? []).map(model => [model.model, model]),
|
||||
)
|
||||
|
||||
expect(byModel['claude-opus-5']?.costUSD).toBeCloseTo(5.03, 6)
|
||||
expect(byModel['claude-opus-5']?.webSearchRequests).toBe(3)
|
||||
// Tokens still count toward activity; dollars stay at zero rather than being invented.
|
||||
expect(byModel['glm-5.2']?.inputTokens).toBe(9_000_000)
|
||||
expect(byModel['glm-5.2']?.costUSD).toBe(0)
|
||||
})
|
||||
|
||||
it('reads the split cache_creation buckets when the legacy total is absent', () => {
|
||||
const chunks = completeChunks(assistantBlockLines({
|
||||
messageId: 'msg_split_cache',
|
||||
requestId: 'req_split_cache',
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: 700,
|
||||
ephemeral_1h_input_tokens: 300,
|
||||
},
|
||||
},
|
||||
blocks: [{ type: 'text', text: 'cached' }],
|
||||
}))
|
||||
|
||||
const result = reduceTranscript(chunks, initialProjection())
|
||||
|
||||
expect(modelTotals(result)[0]?.cacheCreationInputTokens).toBe(1000)
|
||||
})
|
||||
|
||||
it('bills advisor iterations under their own model without recounting the parent', () => {
|
||||
const chunks = completeChunks(assistantBlockLines({
|
||||
messageId: 'msg_advisor',
|
||||
requestId: 'req_advisor',
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
usage: {
|
||||
...STANDARD_USAGE,
|
||||
iterations: [
|
||||
{ type: 'message', input_tokens: 999_999, output_tokens: 999_999 },
|
||||
{
|
||||
type: 'advisor_message',
|
||||
model: 'claude-opus-4-8',
|
||||
input_tokens: 400,
|
||||
output_tokens: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
blocks: [
|
||||
{ type: 'text', text: 'one' },
|
||||
{ type: 'tool_use', id: 'toolu_adv', name: 'Bash', input: {} },
|
||||
],
|
||||
}))
|
||||
|
||||
const result = reduceTranscript(chunks, initialProjection())
|
||||
const byModel = Object.fromEntries(
|
||||
(result.activity?.models ?? []).map(model => [model.model, model]),
|
||||
)
|
||||
|
||||
// A plain `message` iteration is already inside the parent's totals — counting it would
|
||||
// double-bill the turn.
|
||||
expect(byModel['claude-opus-5']).toMatchObject({ inputTokens: 1000, outputTokens: 200 })
|
||||
expect(byModel['claude-opus-4-8']).toMatchObject({ inputTokens: 400, outputTokens: 20 })
|
||||
})
|
||||
|
||||
it('sums working time across gaps and drops the overnight break', () => {
|
||||
const chunks = completeChunks([
|
||||
user('start', '2026-01-01T09:00:00.000Z'),
|
||||
user('still going', '2026-01-01T09:20:00.000Z'),
|
||||
user('after a long break', '2026-01-02T09:00:00.000Z'),
|
||||
user('wrapping up', '2026-01-02T09:10:00.000Z'),
|
||||
])
|
||||
|
||||
const result = reduceTranscript(chunks, initialProjection())
|
||||
|
||||
// 20 min + 10 min of work; the 23h40m the user was asleep is not task time.
|
||||
expect(result.activity?.activeDurationMs).toBe(30 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('carries working time across an incremental read', () => {
|
||||
const head = completeChunks([
|
||||
user('start', '2026-01-01T09:00:00.000Z'),
|
||||
user('next', '2026-01-01T09:05:00.000Z'),
|
||||
])
|
||||
const first = reduceTranscript(head, initialProjection())
|
||||
const tail = completeChunks([user('later', '2026-01-01T09:11:00.000Z')], first.indexedBytes)
|
||||
|
||||
const second = reduceTranscript(tail, first)
|
||||
|
||||
expect(first.activity?.activeDurationMs).toBe(5 * 60 * 1000)
|
||||
expect(second.activity?.activeDurationMs).toBe(11 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,12 @@ import { cleanSessionTitleSource } from '../../../utils/sessionTitleText.js'
|
||||
import { SYNTHETIC_MODEL } from '../../../utils/messages.js'
|
||||
import { extractShotCountFromAssistantContent } from '../../../utils/shotStats.js'
|
||||
import { normalizeDriveRootPathForPlatform } from '../windowsDrivePath.js'
|
||||
import {
|
||||
activeGapMs,
|
||||
estimateCostUSD,
|
||||
isBillableUsageRecord,
|
||||
usageRecordKey,
|
||||
} from '../../../utils/usageAccounting.js'
|
||||
import type {
|
||||
ActivityDailyProjection,
|
||||
ActivityModelProjection,
|
||||
@@ -25,6 +31,22 @@ export class TranscriptRebuildRequiredError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type ReducerUsage = {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
server_tool_use?: {
|
||||
web_search_requests?: number
|
||||
}
|
||||
speed?: string
|
||||
iterations?: unknown
|
||||
}
|
||||
|
||||
type ReducerEntry = {
|
||||
type?: string
|
||||
subtype?: string
|
||||
@@ -35,17 +57,16 @@ type ReducerEntry = {
|
||||
customTitle?: unknown
|
||||
aiTitle?: unknown
|
||||
permissionMode?: unknown
|
||||
requestId?: unknown
|
||||
version?: unknown
|
||||
sessionId?: unknown
|
||||
worktreeSession?: PersistedWorktreeSession | null
|
||||
message?: {
|
||||
id?: unknown
|
||||
role?: string
|
||||
content?: unknown
|
||||
model?: string
|
||||
usage?: {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
}
|
||||
usage?: ReducerUsage
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -78,6 +99,8 @@ type ReducerState = {
|
||||
activityLastTimestampValid: boolean
|
||||
activityFirstTimestamp: string | null
|
||||
activityLastTimestamp: string | null
|
||||
activityLastMessageTime: number | null
|
||||
activityActiveDurationMs: number
|
||||
activityMessageCount: number
|
||||
activityStartHour: number | null
|
||||
activitySpeculationTimeSavedMs: number
|
||||
@@ -86,6 +109,13 @@ type ReducerState = {
|
||||
activityModels: Map<string, ActivityModelProjection>
|
||||
activityTools: Map<string, ActivityNamedUsageProjection>
|
||||
activitySkills: Map<string, ActivityNamedUsageProjection>
|
||||
// (message.id, requestId) pairs whose usage has already been counted for this source. Claude
|
||||
// Code writes one JSONL line per content block of an assistant message — a turn with thinking,
|
||||
// text and 12 tool_use blocks is 14 lines — and every one of them repeats the same complete
|
||||
// `usage` object. Without this the tokens of a single reply get counted once per block; on real
|
||||
// transcripts that inflated the total by 2.2x. Lives only in memory alongside the projection
|
||||
// Maps: a full re-read starts empty (correct), and an incremental read clones it forward.
|
||||
activityUsageKeys: Set<string>
|
||||
}
|
||||
|
||||
export type TranscriptReductionOptions = {
|
||||
@@ -190,6 +220,7 @@ function cloneState(state: ReducerState): ReducerState {
|
||||
activitySkills: new Map(
|
||||
[...state.activitySkills].map(([key, value]) => [key, { ...value }]),
|
||||
),
|
||||
activityUsageKeys: new Set(state.activityUsageKeys),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +258,8 @@ function createInitialState(
|
||||
activityLastTimestampValid: activity?.lastTimestamp !== null && activity?.lastTimestamp !== undefined,
|
||||
activityFirstTimestamp: activity?.firstTimestamp ?? null,
|
||||
activityLastTimestamp: activity?.lastTimestamp ?? null,
|
||||
activityLastMessageTime: parseTimestampMs(activity?.lastTimestamp),
|
||||
activityActiveDurationMs: activity?.activeDurationMs ?? 0,
|
||||
activityMessageCount: activity?.messageCount ?? 0,
|
||||
activityStartHour: activity?.startHour ?? null,
|
||||
activitySpeculationTimeSavedMs: activity?.speculationTimeSavedMs ?? 0,
|
||||
@@ -252,9 +285,16 @@ function createInitialState(
|
||||
{ ...skill },
|
||||
]),
|
||||
),
|
||||
activityUsageKeys: new Set(),
|
||||
}
|
||||
}
|
||||
|
||||
function parseTimestampMs(timestamp: string | null | undefined): number | null {
|
||||
if (typeof timestamp !== 'string') return null
|
||||
const parsed = Date.parse(timestamp)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function activityDateKey(timestamp: unknown): { date: string; time: number } | null {
|
||||
if (typeof timestamp !== 'string') return null
|
||||
const parsed = new Date(timestamp)
|
||||
@@ -277,6 +317,113 @@ function incrementNamedActivity(
|
||||
else values.set(key, { date, name: normalized, count: 1 })
|
||||
}
|
||||
|
||||
function usageIdentity(entry: ReducerEntry) {
|
||||
return {
|
||||
version: entry.version,
|
||||
sessionId: entry.sessionId,
|
||||
requestId: entry.requestId,
|
||||
messageId: entry.message?.id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim this usage record, returning false when its key was already counted — i.e. this line is
|
||||
* another content block of a reply already accounted for.
|
||||
*/
|
||||
function claimUsageRecord(state: ReducerState, entry: ReducerEntry, suffix = ''): boolean {
|
||||
const key = usageRecordKey(usageIdentity(entry), suffix)
|
||||
if (key === null) return true
|
||||
if (state.activityUsageKeys.has(key)) return false
|
||||
state.activityUsageKeys.add(key)
|
||||
return true
|
||||
}
|
||||
|
||||
function cacheCreationTokens(usage: ReducerUsage): number {
|
||||
const split = usage.cache_creation
|
||||
if (split) {
|
||||
return (split.ephemeral_5m_input_tokens ?? 0) + (split.ephemeral_1h_input_tokens ?? 0)
|
||||
}
|
||||
return usage.cache_creation_input_tokens ?? 0
|
||||
}
|
||||
|
||||
function accumulateUsage(
|
||||
state: ReducerState,
|
||||
date: string,
|
||||
model: string,
|
||||
usage: ReducerUsage,
|
||||
): void {
|
||||
const key = `${date}\0${model}`
|
||||
let aggregate = state.activityModels.get(key)
|
||||
if (!aggregate) {
|
||||
aggregate = {
|
||||
date,
|
||||
model,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0,
|
||||
contextWindow: 0,
|
||||
maxOutputTokens: 0,
|
||||
}
|
||||
state.activityModels.set(key, aggregate)
|
||||
}
|
||||
const inputTokens = usage.input_tokens ?? 0
|
||||
const outputTokens = usage.output_tokens ?? 0
|
||||
const cacheReadInputTokens = usage.cache_read_input_tokens ?? 0
|
||||
const cacheCreationInputTokens = cacheCreationTokens(usage)
|
||||
const webSearchRequests = usage.server_tool_use?.web_search_requests ?? 0
|
||||
|
||||
aggregate.inputTokens += inputTokens
|
||||
aggregate.outputTokens += outputTokens
|
||||
aggregate.cacheReadInputTokens += cacheReadInputTokens
|
||||
aggregate.cacheCreationInputTokens += cacheCreationInputTokens
|
||||
aggregate.webSearchRequests += webSearchRequests
|
||||
|
||||
// A model we have no published rates for (every third-party provider) contributes tokens but no
|
||||
// dollars — see modelPricing.ts for why a null must never be folded in as a zero.
|
||||
const cost = estimateCostUSD(
|
||||
model,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadInputTokens,
|
||||
cacheCreationInputTokens,
|
||||
webSearchRequests,
|
||||
},
|
||||
usage.speed,
|
||||
)
|
||||
if (cost !== null) aggregate.costUSD += cost
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested advisor work rides along in `usage.iterations` under its own model. Only
|
||||
* `advisor_message` iterations become separate records — the other iteration types are already
|
||||
* represented by the parent's totals, so counting them would double-bill the same turn.
|
||||
*/
|
||||
function accumulateAdvisorUsage(
|
||||
state: ReducerState,
|
||||
entry: ReducerEntry,
|
||||
date: string,
|
||||
usage: ReducerUsage,
|
||||
): void {
|
||||
if (!Array.isArray(usage.iterations)) return
|
||||
let advisorIndex = 0
|
||||
for (const iteration of usage.iterations) {
|
||||
if (!iteration || typeof iteration !== 'object') continue
|
||||
const record = iteration as { type?: unknown; model?: unknown } & ReducerUsage
|
||||
if (record.type !== 'advisor_message') continue
|
||||
if (typeof record.model !== 'string' || !record.model) continue
|
||||
// Index before claiming, so a repeated content-block line maps its Nth advisor onto the same
|
||||
// key as the first line's Nth advisor rather than shifting by the number already claimed.
|
||||
const index = advisorIndex
|
||||
advisorIndex += 1
|
||||
if (!claimUsageRecord(state, entry, `\0advisor:${index}`)) continue
|
||||
accumulateUsage(state, date, record.model, record)
|
||||
}
|
||||
}
|
||||
|
||||
function applyActivityEntry(state: ReducerState, entry: ReducerEntry): void {
|
||||
if (entry.type === 'speculation-accept') {
|
||||
const timeSavedMs = (entry as Record<string, unknown>).timeSavedMs
|
||||
@@ -309,6 +456,10 @@ function applyActivityEntry(state: ReducerState, entry: ReducerEntry): void {
|
||||
state.activityLastTimestampValid = timestamp !== null
|
||||
state.activityLastTimestamp = timestamp ? entry.timestamp! : null
|
||||
state.activityMessageCount += 1
|
||||
if (timestamp) {
|
||||
state.activityActiveDurationMs += activeGapMs(state.activityLastMessageTime, timestamp.time)
|
||||
state.activityLastMessageTime = timestamp.time
|
||||
}
|
||||
if (!timestamp) return
|
||||
|
||||
let daily = state.activityDaily.get(timestamp.date)
|
||||
@@ -346,27 +497,11 @@ function applyActivityEntry(state: ReducerState, entry: ReducerEntry): void {
|
||||
const usage = entry.message?.usage
|
||||
const model = entry.message?.model || 'unknown'
|
||||
if (!usage || model === SYNTHETIC_MODEL) return
|
||||
const key = `${timestamp.date}\0${model}`
|
||||
let aggregate = state.activityModels.get(key)
|
||||
if (!aggregate) {
|
||||
aggregate = {
|
||||
date: timestamp.date,
|
||||
model,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0,
|
||||
contextWindow: 0,
|
||||
maxOutputTokens: 0,
|
||||
}
|
||||
state.activityModels.set(key, aggregate)
|
||||
}
|
||||
aggregate.inputTokens += usage.input_tokens ?? 0
|
||||
aggregate.outputTokens += usage.output_tokens ?? 0
|
||||
aggregate.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0
|
||||
aggregate.cacheCreationInputTokens += usage.cache_creation_input_tokens ?? 0
|
||||
if (!isBillableUsageRecord(usageIdentity(entry))) return
|
||||
// Every content-block line of this reply carries the same usage; bill only the first one.
|
||||
if (!claimUsageRecord(state, entry)) return
|
||||
accumulateUsage(state, timestamp.date, model, usage)
|
||||
accumulateAdvisorUsage(state, entry, timestamp.date, usage)
|
||||
}
|
||||
|
||||
function applyEntry(state: ReducerState, entry: ReducerEntry): void {
|
||||
@@ -641,6 +776,9 @@ export function reduceTranscriptWithLocators(
|
||||
lastTimestamp: state.activityFirstTimestampValid && state.activityLastTimestampValid
|
||||
? state.activityLastTimestamp
|
||||
: null,
|
||||
activeDurationMs: state.activityFirstTimestampValid && state.activityLastTimestampValid
|
||||
? state.activityActiveDurationMs
|
||||
: 0,
|
||||
messageCount: state.activityFirstTimestampValid && state.activityLastTimestampValid
|
||||
? state.activityMessageCount
|
||||
: 0,
|
||||
|
||||
@@ -98,6 +98,12 @@ export type TranscriptActivityProjection = {
|
||||
isSubagent: boolean
|
||||
firstTimestamp: string | null
|
||||
lastTimestamp: string | null
|
||||
/**
|
||||
* Time actually spent working, summed over gaps between consecutive messages that are shorter
|
||||
* than `ACTIVE_SESSION_GAP_MS`. `lastTimestamp - firstTimestamp` is a calendar span, not a
|
||||
* duration: a session resumed the next morning would report the whole night as task time.
|
||||
*/
|
||||
activeDurationMs: number
|
||||
messageCount: number
|
||||
startHour: number | null
|
||||
speculationTimeSavedMs: number
|
||||
|
||||
+182
-26
@@ -10,6 +10,13 @@ import { readJSONLFile } from './json.js'
|
||||
import { SYNTHETIC_MODEL } from './messages.js'
|
||||
import { getProjectsDir, isTranscriptMessage } from './sessionStorage.js'
|
||||
import { extractShotCountFromAssistantContent } from './shotStats.js'
|
||||
import {
|
||||
activeGapMs,
|
||||
estimateCostUSD,
|
||||
isBillableUsageRecord,
|
||||
resolveModelCosts,
|
||||
usageRecordKey,
|
||||
} from './usageAccounting.js'
|
||||
import { jsonParse } from './slowOperations.js'
|
||||
import {
|
||||
getTodayDateString,
|
||||
@@ -86,6 +93,11 @@ export type ClaudeCodeStats = {
|
||||
peakActivityDay: string | null
|
||||
peakActivityHour: number | null
|
||||
|
||||
// Models whose tokens are counted but whose dollars are not, because no published rates exist
|
||||
// for them (third-party providers). Lets the UI say what the cost figure leaves out instead of
|
||||
// presenting a partial total as if it were complete.
|
||||
unpricedModels: string[]
|
||||
|
||||
// Speculation time saved
|
||||
totalSpeculationTimeSavedMs: number
|
||||
|
||||
@@ -125,6 +137,24 @@ type UsageLike = {
|
||||
output_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
server_tool_use?: { web_search_requests?: number }
|
||||
speed?: string
|
||||
}
|
||||
|
||||
/** The 5m/1h split when the API sends it, else the legacy aggregate field. */
|
||||
function cacheCreationTokens(usage: UsageLike): number {
|
||||
const split = usage.cache_creation
|
||||
if (split) {
|
||||
return (
|
||||
(split.ephemeral_5m_input_tokens || 0) +
|
||||
(split.ephemeral_1h_input_tokens || 0)
|
||||
)
|
||||
}
|
||||
return usage.cache_creation_input_tokens || 0
|
||||
}
|
||||
|
||||
function getTotalUsageTokens(usage: UsageLike): number {
|
||||
@@ -132,10 +162,48 @@ function getTotalUsageTokens(usage: UsageLike): number {
|
||||
(usage.input_tokens || 0) +
|
||||
(usage.output_tokens || 0) +
|
||||
(usage.cache_read_input_tokens || 0) +
|
||||
(usage.cache_creation_input_tokens || 0)
|
||||
cacheCreationTokens(usage)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `candidate` should replace the current longest session. Ties break on session id rather
|
||||
* than on whichever the caller happened to visit first: the two stats paths iterate sessions in
|
||||
* different orders (file order vs. indexed order), so an order-dependent tie would make them
|
||||
* disagree — and ties are common now that a session with out-of-order timestamps scores 0 rather
|
||||
* than a distinct negative span.
|
||||
*/
|
||||
function isLongerSession(
|
||||
candidate: SessionStats,
|
||||
current: SessionStats | null,
|
||||
): boolean {
|
||||
if (!current) return true
|
||||
if (candidate.duration !== current.duration) {
|
||||
return candidate.duration > current.duration
|
||||
}
|
||||
return candidate.sessionId < current.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Models that contributed tokens but no dollars. Both stats paths funnel through this so the
|
||||
* indexed and direct-scan results agree.
|
||||
*/
|
||||
function collectUnpricedModels(modelUsage: {
|
||||
[modelName: string]: ModelUsage
|
||||
}): string[] {
|
||||
return Object.entries(modelUsage)
|
||||
.filter(([model, usage]) => {
|
||||
const tokens =
|
||||
usage.inputTokens +
|
||||
usage.outputTokens +
|
||||
usage.cacheReadInputTokens +
|
||||
usage.cacheCreationInputTokens
|
||||
return tokens > 0 && resolveModelCosts(model) === null
|
||||
})
|
||||
.map(([model]) => model)
|
||||
.sort()
|
||||
}
|
||||
|
||||
function incrementUsageCount(counts: Map<string, number>, name: string) {
|
||||
const normalizedName = name.trim()
|
||||
if (!normalizedName) return
|
||||
@@ -294,8 +362,14 @@ async function processSessionFiles(
|
||||
// Subagent transcripts mark all messages as sidechain. We still want
|
||||
// their token usage counted, but not as separate sessions.
|
||||
const isSubagentFile = sessionFile.includes(`${sep}subagents${sep}`)
|
||||
const parentSessionId = isSubagentFile
|
||||
? basename(dirname(dirname(sessionFile)))
|
||||
// The owning session is the directory just above `subagents/`. Taking two levels up instead
|
||||
// breaks for workflow agents, which nest one group deeper
|
||||
// (`<session>/subagents/workflows/<wf_id>/agent-*.jsonl`) and would be attributed to a
|
||||
// session literally named "workflows".
|
||||
const pathSegments = sessionFile.split(sep)
|
||||
const subagentsIndex = pathSegments.indexOf('subagents')
|
||||
const parentSessionId = isSubagentFile && subagentsIndex > 0
|
||||
? pathSegments[subagentsIndex - 1]!
|
||||
: sessionId
|
||||
|
||||
// Extract shot count from PR attribution in gh pr create calls (ant-only)
|
||||
@@ -345,7 +419,16 @@ async function processSessionFiles(
|
||||
// sessions. Daily activity is tracked below by each message date so token
|
||||
// totals and visible per-day session counts share one date bucket.
|
||||
if (!isSubagentFile && includeSessionInRange) {
|
||||
const duration = lastTimestamp.getTime() - firstTimestamp.getTime()
|
||||
// Time actually worked, not `last - first`: a session picked up the next morning spans
|
||||
// the whole night, and reporting that as task length produced figures like "436 hours".
|
||||
let duration = 0
|
||||
let previousMessageMs: number | null = null
|
||||
for (const message of mainMessages) {
|
||||
const messageMs = new Date(message.timestamp).getTime()
|
||||
if (!Number.isFinite(messageMs)) continue
|
||||
duration += activeGapMs(previousMessageMs, messageMs)
|
||||
previousMessageMs = messageMs
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
sessionId,
|
||||
@@ -360,6 +443,26 @@ async function processSessionFiles(
|
||||
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
|
||||
}
|
||||
|
||||
// One assistant reply is written as one JSONL line per content block, each repeating the
|
||||
// same complete `usage`. Scoped per transcript, matching how the local index reducer keys
|
||||
// its own deduplication, so both paths produce identical totals.
|
||||
const countedUsageKeys = new Set<string>()
|
||||
const claimUsage = (message: TranscriptMessage, suffix = ''): boolean => {
|
||||
const record = message as unknown as Record<string, unknown>
|
||||
const identity = {
|
||||
version: record.version,
|
||||
sessionId: record.sessionId,
|
||||
requestId: record.requestId,
|
||||
messageId: (message.message as { id?: unknown } | undefined)?.id,
|
||||
}
|
||||
if (!isBillableUsageRecord(identity)) return false
|
||||
const key = usageRecordKey(identity, suffix)
|
||||
if (key === null) return true
|
||||
if (countedUsageKeys.has(key)) return false
|
||||
countedUsageKeys.add(key)
|
||||
return true
|
||||
}
|
||||
|
||||
// Process messages for tool usage and model stats
|
||||
for (const message of mainMessages) {
|
||||
const messageDateKey = getMessageDateKey(message)
|
||||
@@ -412,6 +515,10 @@ async function processSessionFiles(
|
||||
continue
|
||||
}
|
||||
|
||||
if (!claimUsage(message)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!modelUsageAgg[model]) {
|
||||
modelUsageAgg[model] = {
|
||||
inputTokens: 0,
|
||||
@@ -425,12 +532,31 @@ async function processSessionFiles(
|
||||
}
|
||||
}
|
||||
|
||||
const cacheCreationInputTokens = cacheCreationTokens(usage)
|
||||
const webSearchRequests =
|
||||
(usage as UsageLike).server_tool_use?.web_search_requests || 0
|
||||
modelUsageAgg[model]!.inputTokens += usage.input_tokens || 0
|
||||
modelUsageAgg[model]!.outputTokens += usage.output_tokens || 0
|
||||
modelUsageAgg[model]!.cacheReadInputTokens +=
|
||||
usage.cache_read_input_tokens || 0
|
||||
modelUsageAgg[model]!.cacheCreationInputTokens +=
|
||||
usage.cache_creation_input_tokens || 0
|
||||
cacheCreationInputTokens
|
||||
modelUsageAgg[model]!.webSearchRequests += webSearchRequests
|
||||
|
||||
// A model with no published rates (every third-party provider) contributes tokens but
|
||||
// no dollars — a null must never be folded in as a zero.
|
||||
const cost = estimateCostUSD(
|
||||
model,
|
||||
{
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheReadInputTokens: usage.cache_read_input_tokens || 0,
|
||||
cacheCreationInputTokens,
|
||||
webSearchRequests,
|
||||
},
|
||||
(usage as UsageLike).speed,
|
||||
)
|
||||
if (cost !== null) modelUsageAgg[model]!.costUSD += cost
|
||||
|
||||
// Track daily tokens per model
|
||||
const totalTokens = getTotalUsageTokens(usage)
|
||||
@@ -470,6 +596,46 @@ async function processSessionFiles(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth a `subagents/` tree is walked. Plain subagents sit directly under it; a workflow nests
|
||||
* them one group directory deeper. The bound keeps an unexpected deep tree from turning file
|
||||
* discovery into a full filesystem walk.
|
||||
*/
|
||||
const SUBAGENT_SCAN_MAX_DEPTH = 3
|
||||
|
||||
/** Every `agent-*.jsonl` beneath one session's `subagents/` directory, at any nesting level. */
|
||||
async function collectSubagentFiles(
|
||||
directory: string,
|
||||
depth = 1,
|
||||
): Promise<string[]> {
|
||||
const fs = getFsImplementation()
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(directory)
|
||||
} catch {
|
||||
// No subagents directory for this session, or it became unreadable — nothing to collect.
|
||||
return []
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
const nestedDirs: string[] = []
|
||||
for (const dirent of entries) {
|
||||
if (dirent.isFile()) {
|
||||
if (dirent.name.startsWith('agent-') && dirent.name.endsWith('.jsonl')) {
|
||||
files.push(join(directory, dirent.name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (dirent.isDirectory() && depth < SUBAGENT_SCAN_MAX_DEPTH) {
|
||||
nestedDirs.push(join(directory, dirent.name))
|
||||
}
|
||||
}
|
||||
const nested = await Promise.all(
|
||||
nestedDirs.map(nestedDir => collectSubagentFiles(nestedDir, depth + 1)),
|
||||
)
|
||||
return [...files, ...nested.flat()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session files from all project directories.
|
||||
* Includes both main session files and subagent transcript files.
|
||||
@@ -501,27 +667,14 @@ async function getAllSessionFiles(): Promise<string[]> {
|
||||
.filter(dirent => dirent.isFile() && dirent.name.endsWith('.jsonl'))
|
||||
.map(dirent => join(projectDir, dirent.name))
|
||||
|
||||
// Collect subagent files from session subdirectories in parallel
|
||||
// Structure: {projectDir}/{sessionId}/subagents/agent-{agentId}.jsonl
|
||||
// Collect subagent files from session subdirectories in parallel.
|
||||
// Structure: {projectDir}/{sessionId}/subagents/agent-{agentId}.jsonl, plus workflow
|
||||
// agents one group deeper at subagents/workflows/{workflowId}/agent-{agentId}.jsonl.
|
||||
const sessionDirs = entries.filter(dirent => dirent.isDirectory())
|
||||
const subagentResults = await Promise.all(
|
||||
sessionDirs.map(async sessionDir => {
|
||||
const subagentsDir = join(projectDir, sessionDir.name, 'subagents')
|
||||
try {
|
||||
const subagentEntries = await fs.readdir(subagentsDir)
|
||||
return subagentEntries
|
||||
.filter(
|
||||
dirent =>
|
||||
dirent.isFile() &&
|
||||
dirent.name.endsWith('.jsonl') &&
|
||||
dirent.name.startsWith('agent-'),
|
||||
)
|
||||
.map(dirent => join(subagentsDir, dirent.name))
|
||||
} catch {
|
||||
// subagents directory doesn't exist for this session, skip
|
||||
return []
|
||||
}
|
||||
}),
|
||||
sessionDirs.map(sessionDir =>
|
||||
collectSubagentFiles(join(projectDir, sessionDir.name, 'subagents')),
|
||||
),
|
||||
)
|
||||
|
||||
return [...mainFiles, ...subagentResults.flat()]
|
||||
@@ -645,7 +798,7 @@ function cacheToStats(
|
||||
let longestSession = cache.longestSession
|
||||
if (todayStats) {
|
||||
for (const session of todayStats.sessionStats) {
|
||||
if (!longestSession || session.duration > longestSession.duration) {
|
||||
if (isLongerSession(session, longestSession)) {
|
||||
longestSession = session
|
||||
}
|
||||
}
|
||||
@@ -699,6 +852,7 @@ function cacheToStats(
|
||||
dailyModelTokens,
|
||||
longestSession,
|
||||
modelUsage,
|
||||
unpricedModels: collectUnpricedModels(modelUsage),
|
||||
toolUsage,
|
||||
skillUsage,
|
||||
firstSessionDate,
|
||||
@@ -909,7 +1063,7 @@ export function processedStatsToClaudeCodeStats(
|
||||
// Find longest session
|
||||
let longestSession: SessionStats | null = null
|
||||
for (const session of stats.sessionStats) {
|
||||
if (!longestSession || session.duration > longestSession.duration) {
|
||||
if (isLongerSession(session, longestSession)) {
|
||||
longestSession = session
|
||||
}
|
||||
}
|
||||
@@ -959,6 +1113,7 @@ export function processedStatsToClaudeCodeStats(
|
||||
dailyModelTokens: dailyModelTokensSorted,
|
||||
longestSession,
|
||||
modelUsage: stats.modelUsage,
|
||||
unpricedModels: collectUnpricedModels(stats.modelUsage),
|
||||
toolUsage: stats.toolUsage,
|
||||
skillUsage: stats.skillUsage,
|
||||
firstSessionDate,
|
||||
@@ -1188,6 +1343,7 @@ function getEmptyStats(): ClaudeCodeStats {
|
||||
modelUsage: {},
|
||||
toolUsage: {},
|
||||
skillUsage: {},
|
||||
unpricedModels: [],
|
||||
firstSessionDate: null,
|
||||
lastSessionDate: null,
|
||||
peakActivityDay: null,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { estimateCostUSD, resolveModelCosts } from './usageAccounting.js'
|
||||
|
||||
const ONE_MILLION = 1_000_000
|
||||
|
||||
function tokens(overrides: Partial<Parameters<typeof estimateCostUSD>[1]> = {}) {
|
||||
return {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveModelCosts', () => {
|
||||
it('prices every Claude family the app can run', () => {
|
||||
expect(resolveModelCosts('claude-opus-5')).toMatchObject({
|
||||
inputTokens: 5,
|
||||
outputTokens: 25,
|
||||
promptCacheReadTokens: 0.5,
|
||||
promptCacheWriteTokens: 6.25,
|
||||
})
|
||||
expect(resolveModelCosts('claude-opus-4-8')?.inputTokens).toBe(5)
|
||||
expect(resolveModelCosts('claude-opus-4-1')?.inputTokens).toBe(15)
|
||||
expect(resolveModelCosts('claude-fable-5')?.outputTokens).toBe(50)
|
||||
expect(resolveModelCosts('claude-sonnet-5')?.outputTokens).toBe(15)
|
||||
expect(resolveModelCosts('claude-haiku-4-5')?.outputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it('sees through the decorations gateways and dated snapshots add', () => {
|
||||
const opus = resolveModelCosts('claude-opus-4-8')
|
||||
expect(resolveModelCosts('claude-opus-4-8-r')).toEqual(opus!)
|
||||
expect(resolveModelCosts('CLAUDE-OPUS-4-8')).toEqual(opus!)
|
||||
expect(resolveModelCosts('anthropic/claude-opus-4-8')).toEqual(opus!)
|
||||
expect(resolveModelCosts('claude-haiku-4-5-20251001')?.outputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it('picks the longest matching prefix rather than the first', () => {
|
||||
// `claude-opus-4-1` bills at the old Opus tier; a bare `claude-opus-4` must not swallow it.
|
||||
expect(resolveModelCosts('claude-opus-4-1')?.inputTokens).toBe(15)
|
||||
expect(resolveModelCosts('claude-opus-4-5')?.inputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it('returns null for third-party models instead of guessing Claude rates', () => {
|
||||
for (const model of [
|
||||
'k3',
|
||||
'glm-5.2',
|
||||
'MiniMax-M3',
|
||||
'deepseek-v4-flash',
|
||||
'kimi-k2.7-code',
|
||||
'gpt-5.6-sol',
|
||||
'grok-4.5',
|
||||
'google/gemini-3.6-flash',
|
||||
'doubao-seed-2.0-code',
|
||||
'<synthetic>',
|
||||
'',
|
||||
' ',
|
||||
]) {
|
||||
expect(resolveModelCosts(model)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('bills fast mode at its own rate only where fast mode exists', () => {
|
||||
expect(resolveModelCosts('claude-opus-5', 'fast')?.inputTokens).toBe(10)
|
||||
expect(resolveModelCosts('claude-opus-5', 'standard')?.inputTokens).toBe(5)
|
||||
// Sonnet has no fast mode — a stray `speed` must not change what it costs.
|
||||
expect(resolveModelCosts('claude-sonnet-5', 'fast')?.inputTokens).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateCostUSD', () => {
|
||||
it('bills each token bucket at its own rate', () => {
|
||||
const cost = estimateCostUSD('claude-opus-5', tokens({
|
||||
inputTokens: ONE_MILLION,
|
||||
outputTokens: ONE_MILLION,
|
||||
cacheReadInputTokens: ONE_MILLION,
|
||||
cacheCreationInputTokens: ONE_MILLION,
|
||||
}))
|
||||
// 5 input + 25 output + 0.50 cache read + 6.25 cache write
|
||||
expect(cost).toBeCloseTo(36.75, 10)
|
||||
})
|
||||
|
||||
it('prices cache reads at a tenth of input, which is why token totals overstate spend', () => {
|
||||
const cacheRead = estimateCostUSD('claude-opus-5', tokens({ cacheReadInputTokens: ONE_MILLION }))
|
||||
const input = estimateCostUSD('claude-opus-5', tokens({ inputTokens: ONE_MILLION }))
|
||||
expect(cacheRead).toBeCloseTo(input! / 10, 10)
|
||||
})
|
||||
|
||||
it('charges per web search request', () => {
|
||||
expect(estimateCostUSD('claude-opus-5', { ...tokens(), webSearchRequests: 100 }))
|
||||
.toBeCloseTo(1, 10)
|
||||
})
|
||||
|
||||
it('returns null — never zero — for an unpriceable model', () => {
|
||||
const cost = estimateCostUSD('glm-5.2', tokens({
|
||||
inputTokens: ONE_MILLION,
|
||||
outputTokens: ONE_MILLION,
|
||||
}))
|
||||
// A zero here would silently understate spend for anyone on third-party providers; callers
|
||||
// must be able to tell "no rates published" apart from "this turn was free".
|
||||
expect(cost).toBeNull()
|
||||
})
|
||||
|
||||
it('costs nothing for a zeroed usage record on a known model', () => {
|
||||
expect(estimateCostUSD('claude-opus-5', tokens())).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
COST_HAIKU_35,
|
||||
COST_HAIKU_45,
|
||||
COST_TIER_3_15,
|
||||
COST_TIER_5_25,
|
||||
COST_TIER_10_50,
|
||||
COST_TIER_15_75,
|
||||
COST_TIER_30_150,
|
||||
type ModelCosts,
|
||||
} from './modelCost.js'
|
||||
|
||||
/**
|
||||
* Shared token-accounting rules for activity stats.
|
||||
*
|
||||
* Two independent code paths compute these stats — the local index reducer and the direct
|
||||
* transcript scan in `stats.ts` — and a parity test pins them to identical output. Rules that
|
||||
* decide what counts (deduplication, validity, working time, dollars) therefore live here rather
|
||||
* than being implemented twice and drifting apart.
|
||||
*
|
||||
* ## Cost estimation
|
||||
*
|
||||
* Deliberately does NOT reuse `calculateUSDCost()` from the CLI: that helper falls back to the
|
||||
* default model's rates for anything it doesn't recognize and fires an analytics event per call.
|
||||
* cc-haha is a multi-provider client, so a third-party model (glm, k3, deepseek, grok, gpt, ...)
|
||||
* priced at Claude rates would report wildly wrong dollars, and indexing runs this tens of
|
||||
* thousands of times per rebuild. Here an unknown model returns `null` instead — callers keep its
|
||||
* tokens in the activity totals but leave it out of the cost total, matching how ccusage and
|
||||
* openusage refuse to mix measured tokens with unpriceable ones.
|
||||
*
|
||||
* Rates come from `src/utils/modelCost.ts` so the dollar figures live in exactly one place; only
|
||||
* the model -> tier mapping is maintained here, because the CLI's canonical-name resolver pulls in
|
||||
* runtime state (settings, bootstrap) that has no business running inside the indexer.
|
||||
*/
|
||||
|
||||
export type PricedTokens = {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadInputTokens: number
|
||||
cacheCreationInputTokens: number
|
||||
webSearchRequests?: number
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: add the new model's canonical prefix here alongside its entry in MODEL_COSTS.
|
||||
// Longest match wins, so a bare family name may sit next to its versioned variants.
|
||||
const MODEL_TIERS: ReadonlyArray<readonly [prefix: string, costs: ModelCosts]> = [
|
||||
['claude-opus-5', COST_TIER_5_25],
|
||||
['claude-opus-4-8', COST_TIER_5_25],
|
||||
['claude-opus-4-7', COST_TIER_5_25],
|
||||
['claude-opus-4-6', COST_TIER_5_25],
|
||||
['claude-opus-4-5', COST_TIER_5_25],
|
||||
['claude-opus-4-1', COST_TIER_15_75],
|
||||
['claude-opus-4', COST_TIER_15_75],
|
||||
['claude-fable-5', COST_TIER_10_50],
|
||||
['claude-mythos-5', COST_TIER_10_50],
|
||||
['claude-sonnet-5', COST_TIER_3_15],
|
||||
['claude-sonnet-4-6', COST_TIER_3_15],
|
||||
['claude-sonnet-4-5', COST_TIER_3_15],
|
||||
['claude-sonnet-4', COST_TIER_3_15],
|
||||
['claude-3-7-sonnet', COST_TIER_3_15],
|
||||
['claude-3-5-sonnet', COST_TIER_3_15],
|
||||
['claude-haiku-4-5', COST_HAIKU_45],
|
||||
['claude-3-5-haiku', COST_HAIKU_35],
|
||||
['claude-3-haiku', COST_HAIKU_35],
|
||||
['claude-3-opus', COST_TIER_15_75],
|
||||
]
|
||||
|
||||
// Fast mode bills at its own rate on the models that offer it; everything else ignores `speed`.
|
||||
const FAST_MODE_TIERS: ReadonlyArray<readonly [prefix: string, costs: ModelCosts]> = [
|
||||
['claude-opus-5', COST_TIER_10_50],
|
||||
['claude-opus-4-7', COST_TIER_30_150],
|
||||
]
|
||||
|
||||
/**
|
||||
* Strip the decorations third-party gateways and dated snapshots add to an otherwise standard
|
||||
* model id: `claude-opus-4-8-20260101`, `claude-opus-4-8-r`, `anthropic/claude-sonnet-5`.
|
||||
*/
|
||||
function normalizeModelId(model: string): string {
|
||||
const trimmed = model.trim().toLowerCase()
|
||||
if (!trimmed) return ''
|
||||
const withoutVendor = trimmed.slice(trimmed.lastIndexOf('/') + 1)
|
||||
return withoutVendor
|
||||
.replace(/-\d{8}$/, '')
|
||||
.replace(/-(r|thinking|latest)$/, '')
|
||||
}
|
||||
|
||||
function matchTier(
|
||||
normalized: string,
|
||||
tiers: ReadonlyArray<readonly [string, ModelCosts]>,
|
||||
): ModelCosts | null {
|
||||
let best: ModelCosts | null = null
|
||||
let bestLength = 0
|
||||
for (const [prefix, costs] of tiers) {
|
||||
if (prefix.length <= bestLength) continue
|
||||
if (normalized === prefix || normalized.startsWith(`${prefix}-`)) {
|
||||
best = costs
|
||||
bestLength = prefix.length
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Rates for a model, or `null` when we have no published pricing for it (every third-party
|
||||
* provider, and any Claude model released after this table was last updated).
|
||||
*/
|
||||
export function resolveModelCosts(model: string, speed?: string): ModelCosts | null {
|
||||
const normalized = normalizeModelId(model)
|
||||
if (!normalized) return null
|
||||
if (speed === 'fast') {
|
||||
const fast = matchTier(normalized, FAST_MODE_TIERS)
|
||||
if (fast) return fast
|
||||
}
|
||||
return matchTier(normalized, MODEL_TIERS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest silence between two messages still counted as one continuous stretch of work. Beyond
|
||||
* this the user has stepped away and come back — a resumed session, not a long-running task.
|
||||
* Without this bound "task length" is really a calendar span: a session picked up the next
|
||||
* morning reports the whole night as time on task.
|
||||
*/
|
||||
export const ACTIVE_SESSION_GAP_MS = 30 * 60 * 1000
|
||||
|
||||
/** Identity fields a transcript line carries for validity checks and deduplication. */
|
||||
export type UsageRecordIdentity = {
|
||||
version?: unknown
|
||||
sessionId?: unknown
|
||||
requestId?: unknown
|
||||
messageId?: unknown
|
||||
}
|
||||
|
||||
/** `1.0.24`, `2.3.4-beta` — anything else marks a log written by something that isn't Claude Code. */
|
||||
function isSemverPrefix(value: string): boolean {
|
||||
return /^\d+\.\d+\.\d/.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a line's `usage` should be counted at all. Mirrors ccusage's validity rules: a `version`
|
||||
* that isn't semver-ish means a foreign log format, and an id that is present but empty means a
|
||||
* malformed line. Deliberately does not reject an empty `model` the way ccusage does — cc-haha
|
||||
* files those under `unknown` and still shows their tokens.
|
||||
*
|
||||
* Only gates token accounting; the entry still counts toward messages and tool calls, which are
|
||||
* activity signals rather than billing ones.
|
||||
*/
|
||||
export function isBillableUsageRecord(identity: UsageRecordIdentity): boolean {
|
||||
if (typeof identity.version === 'string' && !isSemverPrefix(identity.version)) return false
|
||||
for (const value of [identity.sessionId, identity.requestId, identity.messageId]) {
|
||||
if (typeof value === 'string' && value.length === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplication key for one usage record, or `null` when the line carries no message id to key on
|
||||
* (those are always counted, matching ccusage).
|
||||
*
|
||||
* Claude Code writes one JSONL line per content block of an assistant message — a reply with
|
||||
* thinking, text and 12 tool_use blocks is 14 lines — and every one repeats the same complete
|
||||
* `usage` object. Counting each line once per block inflated real transcripts by 2.2x.
|
||||
*/
|
||||
export function usageRecordKey(
|
||||
identity: UsageRecordIdentity,
|
||||
suffix = '',
|
||||
): string | null {
|
||||
const messageId = identity.messageId
|
||||
if (typeof messageId !== 'string' || !messageId) return null
|
||||
const requestId = typeof identity.requestId === 'string' ? identity.requestId : ''
|
||||
return `${messageId}\0${requestId}${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Time actually worked between two consecutive messages, or 0 when the gap is a break rather than
|
||||
* a stretch of work. Non-positive gaps (out-of-order timestamps) contribute nothing.
|
||||
*/
|
||||
export function activeGapMs(previousMs: number | null, currentMs: number): number {
|
||||
if (previousMs === null) return 0
|
||||
const gap = currentMs - previousMs
|
||||
return gap > 0 && gap <= ACTIVE_SESSION_GAP_MS ? gap : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated dollars for one usage record, or `null` when the model can't be priced. Callers must
|
||||
* treat `null` as "exclude from the cost total", never as zero — a zero would quietly understate
|
||||
* spend for anyone running mostly third-party models.
|
||||
*/
|
||||
export function estimateCostUSD(
|
||||
model: string,
|
||||
tokens: PricedTokens,
|
||||
speed?: string,
|
||||
): number | null {
|
||||
const costs = resolveModelCosts(model, speed)
|
||||
if (!costs) return null
|
||||
return (
|
||||
(tokens.inputTokens / 1_000_000) * costs.inputTokens +
|
||||
(tokens.outputTokens / 1_000_000) * costs.outputTokens +
|
||||
(tokens.cacheReadInputTokens / 1_000_000) * costs.promptCacheReadTokens +
|
||||
(tokens.cacheCreationInputTokens / 1_000_000) * costs.promptCacheWriteTokens +
|
||||
(tokens.webSearchRequests ?? 0) * costs.webSearchRequests
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user