From c0d87841dab0cd6ea15adf48add62704f988d26d Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Sat, 10 Jan 2026 23:54:05 +0100 Subject: [PATCH 1/7] feat(droid): add Factory Droid usage tracker --- apps/droid/eslint.config.js | 16 + apps/droid/package.json | 78 +++++ apps/droid/src/_consts.ts | 11 + apps/droid/src/_macro.ts | 32 ++ apps/droid/src/_shared-args.ts | 57 ++++ apps/droid/src/_types.ts | 72 ++++ apps/droid/src/commands/daily.ts | 191 +++++++++++ apps/droid/src/commands/index.ts | 3 + apps/droid/src/commands/monthly.ts | 189 +++++++++++ apps/droid/src/commands/session.ts | 224 +++++++++++++ apps/droid/src/daily-report.ts | 183 +++++++++++ apps/droid/src/data-loader.ts | 506 +++++++++++++++++++++++++++++ apps/droid/src/date-utils.ts | 108 ++++++ apps/droid/src/factory-settings.ts | 92 ++++++ apps/droid/src/index.ts | 6 + apps/droid/src/logger.ts | 7 + apps/droid/src/monthly-report.ts | 120 +++++++ apps/droid/src/pricing.ts | 138 ++++++++ apps/droid/src/run.ts | 29 ++ apps/droid/src/session-report.ts | 128 ++++++++ apps/droid/src/token-utils.ts | 155 +++++++++ apps/droid/tsconfig.json | 25 ++ apps/droid/tsdown.config.ts | 25 ++ apps/droid/vitest.config.ts | 15 + pnpm-lock.yaml | 63 ++++ 25 files changed, 2473 insertions(+) create mode 100644 apps/droid/eslint.config.js create mode 100644 apps/droid/package.json create mode 100644 apps/droid/src/_consts.ts create mode 100644 apps/droid/src/_macro.ts create mode 100644 apps/droid/src/_shared-args.ts create mode 100644 apps/droid/src/_types.ts create mode 100644 apps/droid/src/commands/daily.ts create mode 100644 apps/droid/src/commands/index.ts create mode 100644 apps/droid/src/commands/monthly.ts create mode 100644 apps/droid/src/commands/session.ts create mode 100644 apps/droid/src/daily-report.ts create mode 100644 apps/droid/src/data-loader.ts create mode 100644 apps/droid/src/date-utils.ts create mode 100644 apps/droid/src/factory-settings.ts create mode 100644 apps/droid/src/index.ts create mode 100644 apps/droid/src/logger.ts create mode 100644 apps/droid/src/monthly-report.ts create mode 100644 apps/droid/src/pricing.ts create mode 100644 apps/droid/src/run.ts create mode 100644 apps/droid/src/session-report.ts create mode 100644 apps/droid/src/token-utils.ts create mode 100644 apps/droid/tsconfig.json create mode 100644 apps/droid/tsdown.config.ts create mode 100644 apps/droid/vitest.config.ts diff --git a/apps/droid/eslint.config.js b/apps/droid/eslint.config.js new file mode 100644 index 00000000..bf7ac51b --- /dev/null +++ b/apps/droid/eslint.config.js @@ -0,0 +1,16 @@ +import { ryoppippi } from '@ryoppippi/eslint-config'; + +/** @type {import('eslint').Linter.FlatConfig[]} */ +const config = ryoppippi( + { + type: 'app', + stylistic: false, + }, + { + rules: { + 'test/no-importing-vitest-globals': 'error', + }, + }, +); + +export default config; diff --git a/apps/droid/package.json b/apps/droid/package.json new file mode 100644 index 00000000..6853ab69 --- /dev/null +++ b/apps/droid/package.json @@ -0,0 +1,78 @@ +{ + "name": "@ccusage/droid", + "type": "module", + "version": "18.0.5", + "description": "Usage analysis tool for Factory Droid sessions", + "author": "ryoppippi", + "license": "MIT", + "funding": "https://github.com/ryoppippi/ccusage?sponsor=1", + "homepage": "https://github.com/ryoppippi/ccusage#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/ryoppippi/ccusage.git", + "directory": "apps/droid" + }, + "bugs": { + "url": "https://github.com/ryoppippi/ccusage/issues" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "bin": { + "ccusage-droid": "./src/index.ts" + }, + "files": [ + "dist" + ], + "publishConfig": { + "bin": { + "ccusage-droid": "./dist/index.js" + } + }, + "engines": { + "node": ">=20.19.4" + }, + "scripts": { + "build": "tsdown", + "format": "pnpm run lint --fix", + "lint": "eslint --cache .", + "prepack": "pnpm run build && clean-pkg-json", + "prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build", + "start": "bun ./src/index.ts", + "test": "TZ=UTC vitest", + "typecheck": "tsgo --noEmit" + }, + "devDependencies": { + "@ccusage/internal": "workspace:*", + "@ccusage/terminal": "workspace:*", + "@praha/byethrow": "catalog:runtime", + "@ryoppippi/eslint-config": "catalog:lint", + "@typescript/native-preview": "catalog:types", + "clean-pkg-json": "catalog:release", + "eslint": "catalog:lint", + "fast-sort": "catalog:runtime", + "fs-fixture": "catalog:testing", + "gunshi": "catalog:runtime", + "path-type": "catalog:runtime", + "picocolors": "catalog:runtime", + "tinyglobby": "catalog:runtime", + "tsdown": "catalog:build", + "unplugin-macros": "catalog:build", + "unplugin-unused": "catalog:build", + "valibot": "catalog:runtime", + "vitest": "catalog:testing" + }, + "devEngines": { + "runtime": [ + { + "name": "node", + "version": "^24.11.0", + "onFail": "download" + }, + { + "name": "bun", + "version": "^1.3.2", + "onFail": "download" + } + ] + } +} diff --git a/apps/droid/src/_consts.ts b/apps/droid/src/_consts.ts new file mode 100644 index 00000000..5a336a8d --- /dev/null +++ b/apps/droid/src/_consts.ts @@ -0,0 +1,11 @@ +import os from 'node:os'; +import path from 'node:path'; + +export const FACTORY_DIR_ENV = 'FACTORY_DIR'; +export const DEFAULT_FACTORY_DIR = path.join(os.homedir(), '.factory'); +export const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'; +export const DEFAULT_LOCALE = 'en-CA'; + +export const DROID_LOG_GLOB = 'droid-log-*.log'; +export const FACTORY_LOGS_SUBDIR = 'logs'; +export const FACTORY_SESSIONS_SUBDIR = 'sessions'; diff --git a/apps/droid/src/_macro.ts b/apps/droid/src/_macro.ts new file mode 100644 index 00000000..8734408a --- /dev/null +++ b/apps/droid/src/_macro.ts @@ -0,0 +1,32 @@ +import type { LiteLLMModelPricing } from '@ccusage/internal/pricing'; +import { + createPricingDataset, + fetchLiteLLMPricingDataset, + filterPricingDataset, +} from '@ccusage/internal/pricing-fetch-utils'; + +const FACTORY_MODEL_PREFIXES = [ + 'openai/', + 'azure/', + 'anthropic/', + 'openrouter/', + 'gpt-', + 'claude-', + 'gemini-', + 'google/', + 'vertex_ai/', +]; + +function isFactoryModel(modelName: string, _pricing: LiteLLMModelPricing): boolean { + return FACTORY_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix)); +} + +export async function prefetchFactoryPricing(): Promise> { + try { + const dataset = await fetchLiteLLMPricingDataset(); + return filterPricingDataset(dataset, isFactoryModel); + } catch (error) { + console.warn('Failed to prefetch Factory pricing data, proceeding with empty cache.', error); + return createPricingDataset(); + } +} diff --git a/apps/droid/src/_shared-args.ts b/apps/droid/src/_shared-args.ts new file mode 100644 index 00000000..58f28d99 --- /dev/null +++ b/apps/droid/src/_shared-args.ts @@ -0,0 +1,57 @@ +import type { Args } from 'gunshi'; +import { DEFAULT_LOCALE, DEFAULT_TIMEZONE } from './_consts.ts'; + +export const sharedArgs = { + json: { + type: 'boolean', + short: 'j', + description: 'Output report as JSON', + default: false, + }, + since: { + type: 'string', + short: 's', + description: 'Filter from date (YYYY-MM-DD or YYYYMMDD)', + }, + until: { + type: 'string', + short: 'u', + description: 'Filter until date (inclusive)', + }, + timezone: { + type: 'string', + short: 'z', + description: 'Timezone for date grouping (IANA)', + default: DEFAULT_TIMEZONE, + }, + locale: { + type: 'string', + short: 'l', + description: 'Locale for formatting', + default: DEFAULT_LOCALE, + }, + offline: { + type: 'boolean', + short: 'O', + description: 'Use cached pricing data instead of fetching from LiteLLM', + default: false, + negatable: true, + }, + compact: { + type: 'boolean', + description: 'Force compact table layout for narrow terminals', + default: false, + }, + factoryDir: { + type: 'string', + description: 'Path to Factory data directory (default: ~/.factory)', + }, + color: { + type: 'boolean', + description: 'Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.', + }, + noColor: { + type: 'boolean', + description: 'Disable colored output (default: auto). NO_COLOR=1 has the same effect.', + }, +} as const satisfies Args; diff --git a/apps/droid/src/_types.ts b/apps/droid/src/_types.ts new file mode 100644 index 00000000..42e833fe --- /dev/null +++ b/apps/droid/src/_types.ts @@ -0,0 +1,72 @@ +export type ModelIdSource = 'tag' | 'settings' | 'session' | 'unknown'; + +export type TokenUsageEvent = { + timestamp: string; + sessionId: string; + projectKey: string; + modelId: string; + modelIdSource: ModelIdSource; + pricingModel: string; + inputTokens: number; + outputTokens: number; + thinkingTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; +}; + +export type ModelUsage = { + inputTokens: number; + outputTokens: number; + thinkingTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; +}; + +export type PricingResult = { + costUSD: number; + usedPricingModel: string; +}; + +export type PricingSource = { + calculateCost: (pricingModel: string, usage: ModelUsage) => Promise; +}; + +export type DailyReportRow = { + date: string; + inputTokens: number; + outputTokens: number; + thinkingTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + costUSD: number; + modelsUsed: string[]; +}; + +export type MonthlyReportRow = { + month: string; + inputTokens: number; + outputTokens: number; + thinkingTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + costUSD: number; + modelsUsed: string[]; +}; + +export type SessionReportRow = { + directory: string; + sessionId: string; + modelsUsed: string[]; + inputTokens: number; + outputTokens: number; + thinkingTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + costUSD: number; + lastActivity: string; +}; diff --git a/apps/droid/src/commands/daily.ts b/apps/droid/src/commands/daily.ts new file mode 100644 index 00000000..4742e512 --- /dev/null +++ b/apps/droid/src/commands/daily.ts @@ -0,0 +1,191 @@ +import process from 'node:process'; +import { + addEmptySeparatorRow, + formatCurrency, + formatDateCompact, + formatModelsDisplayMultiline, + formatNumber, + ResponsiveTable, +} from '@ccusage/terminal/table'; +import { define } from 'gunshi'; +import pc from 'picocolors'; +import { DEFAULT_TIMEZONE } from '../_consts.ts'; +import { sharedArgs } from '../_shared-args.ts'; +import { buildDailyReport } from '../daily-report.ts'; +import { loadFactoryTokenUsageEvents } from '../data-loader.ts'; +import { normalizeFilterDate } from '../date-utils.ts'; +import { log, logger } from '../logger.ts'; +import { FactoryPricingSource } from '../pricing.ts'; + +const TABLE_COLUMN_COUNT = 9; + +function summarizeMissingPricing(models: string[]): void { + if (models.length === 0) { + return; + } + const preview = models.slice(0, 5).join(', '); + const suffix = models.length > 5 ? ', …' : ''; + logger.warn( + `Missing pricing for ${models.length} models (cost treated as $0): ${preview}${suffix}`, + ); +} + +export const dailyCommand = define({ + name: 'daily', + description: 'Show Factory Droid token usage grouped by day', + args: sharedArgs, + async run(ctx) { + const jsonOutput = Boolean(ctx.values.json); + if (jsonOutput) { + logger.level = 0; + } + + let since: string | undefined; + let until: string | undefined; + + try { + since = normalizeFilterDate(ctx.values.since); + until = normalizeFilterDate(ctx.values.until); + } catch (error) { + logger.error(String(error)); + process.exit(1); + } + + const { events, missingLogsDirectory } = await loadFactoryTokenUsageEvents({ + factoryDir: ctx.values.factoryDir, + }); + if (missingLogsDirectory != null) { + logger.warn(`Factory logs directory not found: ${missingLogsDirectory}`); + } + + if (events.length === 0) { + log( + jsonOutput ? JSON.stringify({ daily: [], totals: null }) : 'No Factory usage data found.', + ); + return; + } + + const pricingSource = new FactoryPricingSource({ offline: ctx.values.offline }); + try { + const report = await buildDailyReport(events, { + pricingSource, + timezone: ctx.values.timezone, + locale: ctx.values.locale, + since, + until, + }); + + const rows = report.rows; + if (rows.length === 0) { + log( + jsonOutput + ? JSON.stringify({ daily: [], totals: null }) + : 'No Factory usage data found for provided filters.', + ); + return; + } + + summarizeMissingPricing(report.missingPricingModels); + + const totals = rows.reduce( + (acc, row) => { + acc.inputTokens += row.inputTokens; + acc.outputTokens += row.outputTokens; + acc.thinkingTokens += row.thinkingTokens; + acc.cacheReadTokens += row.cacheReadTokens; + acc.cacheCreationTokens += row.cacheCreationTokens; + acc.totalTokens += row.totalTokens; + acc.costUSD += row.costUSD; + return acc; + }, + { + inputTokens: 0, + outputTokens: 0, + thinkingTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + costUSD: 0, + }, + ); + + if (jsonOutput) { + log( + JSON.stringify( + { + daily: rows, + totals, + missingPricingModels: report.missingPricingModels, + }, + null, + 2, + ), + ); + return; + } + + logger.box( + `Factory Droid Usage Report - Daily (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`, + ); + + const table: ResponsiveTable = new ResponsiveTable({ + head: [ + 'Date', + 'Models', + 'Input', + 'Output', + 'Thinking', + 'Cache Create', + 'Cache Read', + 'Total Tokens', + 'Cost (USD)', + ], + colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'], + compactHead: ['Date', 'Models', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'right', 'right', 'right'], + compactThreshold: 100, + forceCompact: ctx.values.compact, + style: { head: ['cyan'] }, + dateFormatter: (dateStr: string) => formatDateCompact(dateStr), + }); + + for (const row of rows) { + table.push([ + row.date, + formatModelsDisplayMultiline(row.modelsUsed), + formatNumber(row.inputTokens), + formatNumber(row.outputTokens), + formatNumber(row.thinkingTokens), + formatNumber(row.cacheCreationTokens), + formatNumber(row.cacheReadTokens), + formatNumber(row.totalTokens), + formatCurrency(row.costUSD), + ]); + } + + addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); + table.push([ + pc.yellow('Total'), + '', + pc.yellow(formatNumber(totals.inputTokens)), + pc.yellow(formatNumber(totals.outputTokens)), + pc.yellow(formatNumber(totals.thinkingTokens)), + pc.yellow(formatNumber(totals.cacheCreationTokens)), + pc.yellow(formatNumber(totals.cacheReadTokens)), + pc.yellow(formatNumber(totals.totalTokens)), + pc.yellow(formatCurrency(totals.costUSD)), + ]); + + log(table.toString()); + + if (table.isCompactMode()) { + logger.info('\nRunning in Compact Mode'); + logger.info( + 'Expand terminal width to see cache metrics, thinking tokens, and total tokens', + ); + } + } finally { + pricingSource[Symbol.dispose](); + } + }, +}); diff --git a/apps/droid/src/commands/index.ts b/apps/droid/src/commands/index.ts new file mode 100644 index 00000000..f126c9ef --- /dev/null +++ b/apps/droid/src/commands/index.ts @@ -0,0 +1,3 @@ +export { dailyCommand } from './daily.ts'; +export { monthlyCommand } from './monthly.ts'; +export { sessionCommand } from './session.ts'; diff --git a/apps/droid/src/commands/monthly.ts b/apps/droid/src/commands/monthly.ts new file mode 100644 index 00000000..a35c5983 --- /dev/null +++ b/apps/droid/src/commands/monthly.ts @@ -0,0 +1,189 @@ +import process from 'node:process'; +import { + addEmptySeparatorRow, + formatCurrency, + formatModelsDisplayMultiline, + formatNumber, + ResponsiveTable, +} from '@ccusage/terminal/table'; +import { define } from 'gunshi'; +import pc from 'picocolors'; +import { DEFAULT_TIMEZONE } from '../_consts.ts'; +import { sharedArgs } from '../_shared-args.ts'; +import { loadFactoryTokenUsageEvents } from '../data-loader.ts'; +import { normalizeFilterDate } from '../date-utils.ts'; +import { log, logger } from '../logger.ts'; +import { buildMonthlyReport } from '../monthly-report.ts'; +import { FactoryPricingSource } from '../pricing.ts'; + +const TABLE_COLUMN_COUNT = 9; + +function summarizeMissingPricing(models: string[]): void { + if (models.length === 0) { + return; + } + const preview = models.slice(0, 5).join(', '); + const suffix = models.length > 5 ? ', …' : ''; + logger.warn( + `Missing pricing for ${models.length} models (cost treated as $0): ${preview}${suffix}`, + ); +} + +export const monthlyCommand = define({ + name: 'monthly', + description: 'Show Factory Droid token usage grouped by month', + args: sharedArgs, + async run(ctx) { + const jsonOutput = Boolean(ctx.values.json); + if (jsonOutput) { + logger.level = 0; + } + + let since: string | undefined; + let until: string | undefined; + + try { + since = normalizeFilterDate(ctx.values.since); + until = normalizeFilterDate(ctx.values.until); + } catch (error) { + logger.error(String(error)); + process.exit(1); + } + + const { events, missingLogsDirectory } = await loadFactoryTokenUsageEvents({ + factoryDir: ctx.values.factoryDir, + }); + if (missingLogsDirectory != null) { + logger.warn(`Factory logs directory not found: ${missingLogsDirectory}`); + } + + if (events.length === 0) { + log( + jsonOutput ? JSON.stringify({ monthly: [], totals: null }) : 'No Factory usage data found.', + ); + return; + } + + const pricingSource = new FactoryPricingSource({ offline: ctx.values.offline }); + try { + const report = await buildMonthlyReport(events, { + pricingSource, + timezone: ctx.values.timezone, + locale: ctx.values.locale, + since, + until, + }); + + const rows = report.rows; + if (rows.length === 0) { + log( + jsonOutput + ? JSON.stringify({ monthly: [], totals: null }) + : 'No Factory usage data found for provided filters.', + ); + return; + } + + summarizeMissingPricing(report.missingPricingModels); + + const totals = rows.reduce( + (acc, row) => { + acc.inputTokens += row.inputTokens; + acc.outputTokens += row.outputTokens; + acc.thinkingTokens += row.thinkingTokens; + acc.cacheReadTokens += row.cacheReadTokens; + acc.cacheCreationTokens += row.cacheCreationTokens; + acc.totalTokens += row.totalTokens; + acc.costUSD += row.costUSD; + return acc; + }, + { + inputTokens: 0, + outputTokens: 0, + thinkingTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + costUSD: 0, + }, + ); + + if (jsonOutput) { + log( + JSON.stringify( + { + monthly: rows, + totals, + missingPricingModels: report.missingPricingModels, + }, + null, + 2, + ), + ); + return; + } + + logger.box( + `Factory Droid Usage Report - Monthly (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`, + ); + + const table: ResponsiveTable = new ResponsiveTable({ + head: [ + 'Month', + 'Models', + 'Input', + 'Output', + 'Thinking', + 'Cache Create', + 'Cache Read', + 'Total Tokens', + 'Cost (USD)', + ], + colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'], + compactHead: ['Month', 'Models', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'right', 'right', 'right'], + compactThreshold: 100, + forceCompact: ctx.values.compact, + style: { head: ['cyan'] }, + }); + + for (const row of rows) { + table.push([ + row.month, + formatModelsDisplayMultiline(row.modelsUsed), + formatNumber(row.inputTokens), + formatNumber(row.outputTokens), + formatNumber(row.thinkingTokens), + formatNumber(row.cacheCreationTokens), + formatNumber(row.cacheReadTokens), + formatNumber(row.totalTokens), + formatCurrency(row.costUSD), + ]); + } + + addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); + table.push([ + pc.yellow('Total'), + '', + pc.yellow(formatNumber(totals.inputTokens)), + pc.yellow(formatNumber(totals.outputTokens)), + pc.yellow(formatNumber(totals.thinkingTokens)), + pc.yellow(formatNumber(totals.cacheCreationTokens)), + pc.yellow(formatNumber(totals.cacheReadTokens)), + pc.yellow(formatNumber(totals.totalTokens)), + pc.yellow(formatCurrency(totals.costUSD)), + ]); + + log(table.toString()); + + if (table.isCompactMode()) { + logger.info('\nRunning in Compact Mode'); + logger.info( + 'Expand terminal width to see cache metrics, thinking tokens, and total tokens', + ); + } + } finally { + pricingSource[Symbol.dispose](); + } + }, +}); diff --git a/apps/droid/src/commands/session.ts b/apps/droid/src/commands/session.ts new file mode 100644 index 00000000..d16d317b --- /dev/null +++ b/apps/droid/src/commands/session.ts @@ -0,0 +1,224 @@ +import process from 'node:process'; +import { + addEmptySeparatorRow, + formatCurrency, + formatModelsDisplayMultiline, + formatNumber, + ResponsiveTable, +} from '@ccusage/terminal/table'; +import { define } from 'gunshi'; +import pc from 'picocolors'; +import { DEFAULT_TIMEZONE } from '../_consts.ts'; +import { sharedArgs } from '../_shared-args.ts'; +import { loadFactoryTokenUsageEvents } from '../data-loader.ts'; +import { + formatDisplayDate, + formatDisplayDateTime, + normalizeFilterDate, + toDateKey, +} from '../date-utils.ts'; +import { log, logger } from '../logger.ts'; +import { FactoryPricingSource } from '../pricing.ts'; +import { buildSessionReport } from '../session-report.ts'; + +const TABLE_COLUMN_COUNT = 12; + +function summarizeMissingPricing(models: string[]): void { + if (models.length === 0) { + return; + } + const preview = models.slice(0, 5).join(', '); + const suffix = models.length > 5 ? ', …' : ''; + logger.warn( + `Missing pricing for ${models.length} models (cost treated as $0): ${preview}${suffix}`, + ); +} + +export const sessionCommand = define({ + name: 'session', + description: 'Show Factory Droid token usage grouped by session', + args: sharedArgs, + async run(ctx) { + const jsonOutput = Boolean(ctx.values.json); + if (jsonOutput) { + logger.level = 0; + } + + let since: string | undefined; + let until: string | undefined; + + try { + since = normalizeFilterDate(ctx.values.since); + until = normalizeFilterDate(ctx.values.until); + } catch (error) { + logger.error(String(error)); + process.exit(1); + } + + const { events, missingLogsDirectory } = await loadFactoryTokenUsageEvents({ + factoryDir: ctx.values.factoryDir, + }); + if (missingLogsDirectory != null) { + logger.warn(`Factory logs directory not found: ${missingLogsDirectory}`); + } + + if (events.length === 0) { + log( + jsonOutput + ? JSON.stringify({ sessions: [], totals: null }) + : 'No Factory usage data found.', + ); + return; + } + + const pricingSource = new FactoryPricingSource({ offline: ctx.values.offline }); + try { + const report = await buildSessionReport(events, { + pricingSource, + timezone: ctx.values.timezone, + locale: ctx.values.locale, + since, + until, + }); + + const rows = report.rows; + if (rows.length === 0) { + log( + jsonOutput + ? JSON.stringify({ sessions: [], totals: null }) + : 'No Factory usage data found for provided filters.', + ); + return; + } + + summarizeMissingPricing(report.missingPricingModels); + + const totals = rows.reduce( + (acc, row) => { + acc.inputTokens += row.inputTokens; + acc.outputTokens += row.outputTokens; + acc.thinkingTokens += row.thinkingTokens; + acc.cacheReadTokens += row.cacheReadTokens; + acc.cacheCreationTokens += row.cacheCreationTokens; + acc.totalTokens += row.totalTokens; + acc.costUSD += row.costUSD; + return acc; + }, + { + inputTokens: 0, + outputTokens: 0, + thinkingTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + costUSD: 0, + }, + ); + + if (jsonOutput) { + log( + JSON.stringify( + { + sessions: rows, + totals, + missingPricingModels: report.missingPricingModels, + }, + null, + 2, + ), + ); + return; + } + + logger.box( + `Factory Droid Usage Report - Sessions (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`, + ); + + const table: ResponsiveTable = new ResponsiveTable({ + head: [ + 'Date', + 'Directory', + 'Session', + 'Models', + 'Input', + 'Output', + 'Thinking', + 'Cache Create', + 'Cache Read', + 'Total Tokens', + 'Cost (USD)', + 'Last Activity', + ], + colAligns: [ + 'left', + 'left', + 'left', + 'left', + 'right', + 'right', + 'right', + 'right', + 'right', + 'right', + 'right', + 'left', + ], + compactHead: ['Date', 'Directory', 'Session', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'left', 'right', 'right', 'right'], + compactThreshold: 120, + forceCompact: ctx.values.compact, + style: { head: ['cyan'] }, + }); + + for (const row of rows) { + const dateKey = toDateKey(row.lastActivity, ctx.values.timezone); + const displayDate = formatDisplayDate(dateKey, ctx.values.locale, ctx.values.timezone); + const directoryDisplay = row.directory === '' ? '-' : row.directory; + const shortSession = + row.sessionId.length > 8 ? `…${row.sessionId.slice(-8)}` : row.sessionId; + + table.push([ + displayDate, + directoryDisplay, + shortSession, + formatModelsDisplayMultiline(row.modelsUsed), + formatNumber(row.inputTokens), + formatNumber(row.outputTokens), + formatNumber(row.thinkingTokens), + formatNumber(row.cacheCreationTokens), + formatNumber(row.cacheReadTokens), + formatNumber(row.totalTokens), + formatCurrency(row.costUSD), + formatDisplayDateTime(row.lastActivity, ctx.values.locale, ctx.values.timezone), + ]); + } + + addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); + table.push([ + '', + '', + pc.yellow('Total'), + '', + pc.yellow(formatNumber(totals.inputTokens)), + pc.yellow(formatNumber(totals.outputTokens)), + pc.yellow(formatNumber(totals.thinkingTokens)), + pc.yellow(formatNumber(totals.cacheCreationTokens)), + pc.yellow(formatNumber(totals.cacheReadTokens)), + pc.yellow(formatNumber(totals.totalTokens)), + pc.yellow(formatCurrency(totals.costUSD)), + '', + ]); + + log(table.toString()); + + if (table.isCompactMode()) { + logger.info('\nRunning in Compact Mode'); + logger.info( + 'Expand terminal width to see cache metrics, thinking tokens, total tokens, and last activity', + ); + } + } finally { + pricingSource[Symbol.dispose](); + } + }, +}); diff --git a/apps/droid/src/daily-report.ts b/apps/droid/src/daily-report.ts new file mode 100644 index 00000000..c1703942 --- /dev/null +++ b/apps/droid/src/daily-report.ts @@ -0,0 +1,183 @@ +import type { DailyReportRow, ModelUsage, PricingSource, TokenUsageEvent } from './_types.ts'; +import { sort } from 'fast-sort'; +import { formatDisplayDate, isWithinRange, toDateKey } from './date-utils.ts'; +import { addUsage, createEmptyUsage } from './token-utils.ts'; + +type DailySummary = { + dateKey: string; + totalUsage: ModelUsage; + modelsUsed: Set; + pricingModels: Map; +}; + +export type DailyReportOptions = { + timezone?: string; + locale?: string; + since?: string; + until?: string; + pricingSource: PricingSource; +}; + +export type DailyReportResult = { + rows: DailyReportRow[]; + missingPricingModels: string[]; +}; + +function formatModelDisplay(event: TokenUsageEvent): string { + const suffix = event.modelIdSource === 'settings' ? ' [inferred]' : ''; + if (event.modelId.startsWith('custom:')) { + const base = event.pricingModel.trim() !== '' ? event.pricingModel : event.modelId; + return `${base} [custom]${suffix}`; + } + + return `${event.modelId}${suffix}`; +} + +function addEventUsage(target: ModelUsage, event: TokenUsageEvent): void { + addUsage(target, { + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + thinkingTokens: event.thinkingTokens, + cacheReadTokens: event.cacheReadTokens, + cacheCreationTokens: event.cacheCreationTokens, + }); +} + +function getOrCreateModelUsage(map: Map, key: string): ModelUsage { + const existing = map.get(key); + if (existing != null) { + return existing; + } + const created = createEmptyUsage(); + map.set(key, created); + return created; +} + +export async function buildDailyReport( + events: TokenUsageEvent[], + options: DailyReportOptions, +): Promise { + const summaries = new Map(); + const missingPricingModels = new Set(); + + for (const event of events) { + const dateKey = toDateKey(event.timestamp, options.timezone); + if (!isWithinRange(dateKey, options.since, options.until)) { + continue; + } + + const summary = summaries.get(dateKey) ?? { + dateKey, + totalUsage: createEmptyUsage(), + modelsUsed: new Set(), + pricingModels: new Map(), + }; + if (!summaries.has(dateKey)) { + summaries.set(dateKey, summary); + } + + summary.modelsUsed.add(formatModelDisplay(event)); + addEventUsage(summary.totalUsage, event); + + if (event.pricingModel.trim() !== '') { + const usage = getOrCreateModelUsage(summary.pricingModels, event.pricingModel); + addEventUsage(usage, event); + } + } + + const rows: DailyReportRow[] = []; + + for (const summary of sort(Array.from(summaries.values())).asc((s) => s.dateKey)) { + let costUSD = 0; + for (const [pricingModel, usage] of summary.pricingModels) { + try { + const priced = await options.pricingSource.calculateCost(pricingModel, usage); + costUSD += priced.costUSD; + } catch { + missingPricingModels.add(pricingModel); + } + } + + rows.push({ + date: formatDisplayDate(summary.dateKey, options.locale, options.timezone), + inputTokens: summary.totalUsage.inputTokens, + outputTokens: summary.totalUsage.outputTokens, + thinkingTokens: summary.totalUsage.thinkingTokens, + cacheReadTokens: summary.totalUsage.cacheReadTokens, + cacheCreationTokens: summary.totalUsage.cacheCreationTokens, + totalTokens: summary.totalUsage.totalTokens, + costUSD, + modelsUsed: sort(Array.from(summary.modelsUsed)).asc((model) => model), + }); + } + + return { + rows, + missingPricingModels: sort(Array.from(missingPricingModels)).asc((model) => model), + }; +} + +if (import.meta.vitest != null) { + describe('buildDailyReport', () => { + it('aggregates events by day and tolerates missing pricing', async () => { + const stubPricingSource: PricingSource = { + async calculateCost(pricingModel, usage) { + if (pricingModel !== 'gpt-5.2(high)') { + throw new Error('missing'); + } + return { + costUSD: usage.inputTokens * 1e-6 + (usage.outputTokens + usage.thinkingTokens) * 2e-6, + usedPricingModel: pricingModel, + }; + }, + }; + + const report = await buildDailyReport( + [ + { + timestamp: '2026-01-01T00:00:00.000Z', + sessionId: 's1', + projectKey: 'proj', + modelId: 'custom:GPT-5.2-(High)-18', + modelIdSource: 'tag', + pricingModel: 'gpt-5.2(high)', + inputTokens: 100, + outputTokens: 50, + thinkingTokens: 10, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 160, + }, + { + timestamp: '2026-01-01T00:10:00.000Z', + sessionId: 's1', + projectKey: 'proj', + modelId: 'custom:Unknown', + modelIdSource: 'tag', + pricingModel: 'unknown-model', + inputTokens: 100, + outputTokens: 50, + thinkingTokens: 10, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 160, + }, + ], + { + pricingSource: stubPricingSource, + since: '2026-01-01', + until: '2026-01-01', + }, + ); + + expect(report.rows).toHaveLength(1); + expect(report.missingPricingModels).toEqual(['unknown-model']); + expect(report.rows[0]?.inputTokens).toBe(200); + expect(report.rows[0]?.modelsUsed).toEqual([ + 'gpt-5.2(high) [custom]', + 'unknown-model [custom]', + ]); + expect(report.rows[0]?.costUSD).toBeGreaterThan(0); + }); + }); +} diff --git a/apps/droid/src/data-loader.ts b/apps/droid/src/data-loader.ts new file mode 100644 index 00000000..72754cb0 --- /dev/null +++ b/apps/droid/src/data-loader.ts @@ -0,0 +1,506 @@ +import type { ModelIdSource, TokenUsageEvent } from './_types.ts'; +import { createReadStream } from 'node:fs'; +import { readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createInterface } from 'node:readline'; +import { Result } from '@praha/byethrow'; +import { createFixture } from 'fs-fixture'; +import { isDirectorySync } from 'path-type'; +import { glob } from 'tinyglobby'; +import * as v from 'valibot'; +import { DROID_LOG_GLOB, FACTORY_LOGS_SUBDIR, FACTORY_SESSIONS_SUBDIR } from './_consts.ts'; +import { loadFactoryCustomModels, resolveFactoryDir } from './factory-settings.ts'; +import { logger } from './logger.ts'; +import { createEmptyUsage, subtractUsage, toTotalTokens } from './token-utils.ts'; + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +type ParsedSessionSettings = { + timestamp: string; + sessionId: string; + settingsPath: string; + modelId: string; + usage: { + inputTokens: number; + outputTokens: number; + thinkingTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + }; +}; + +const recordSchema = v.record(v.string(), v.unknown()); + +const tokenUsageSchema = v.object({ + inputTokens: v.optional(v.number()), + outputTokens: v.optional(v.number()), + thinkingTokens: v.optional(v.number()), + cacheReadTokens: v.optional(v.number()), + cacheCreationTokens: v.optional(v.number()), +}); + +const sessionValueSchema = v.object({ + sessionId: v.string(), + path: v.string(), + hasTokenUsage: v.optional(v.boolean()), + tokenUsage: v.optional(tokenUsageSchema), +}); + +const sessionContextSchema = v.object({ + value: sessionValueSchema, + tags: v.optional(recordSchema), +}); + +const sessionSettingsSchema = v.object({ + model: v.optional(v.string()), +}); + +function asNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + return trimmed === '' ? undefined : trimmed; +} + +function extractTimestampFromLogLine(line: string): string | undefined { + if (!line.startsWith('[')) { + return undefined; + } + const end = line.indexOf(']'); + if (end === -1) { + return undefined; + } + const raw = line.slice(1, end).trim(); + return raw === '' ? undefined : raw; +} + +function extractProjectKeyFromSettingsPath(settingsPath: string): string { + const normalized = path.normalize(settingsPath); + const segments = normalized.split(path.sep); + const sessionsIndex = segments.findIndex((segment) => segment === FACTORY_SESSIONS_SUBDIR); + if (sessionsIndex === -1 || sessionsIndex + 1 >= segments.length) { + return 'unknown'; + } + + const projectKey = segments[sessionsIndex + 1]; + return projectKey != null && projectKey.trim() !== '' ? projectKey : 'unknown'; +} + +type ModelIdCacheEntry = { + mtimeMs: number; + modelId: string | null; +}; + +async function loadModelIdFromSessionSettings( + settingsPath: string, + cache: Map, +): Promise { + const statResult = await Result.try({ + try: stat(settingsPath), + catch: (error) => toError(error), + }); + if (Result.isFailure(statResult) || !statResult.value.isFile()) { + return undefined; + } + + const mtimeMs = statResult.value.mtimeMs; + const cached = cache.get(settingsPath); + if (cached != null && cached.mtimeMs === mtimeMs) { + return cached.modelId ?? undefined; + } + + const raw = await Result.try({ + try: readFile(settingsPath, 'utf8'), + catch: (error) => toError(error), + }); + if (Result.isFailure(raw)) { + return undefined; + } + + const parsedJson = Result.try({ + try: () => JSON.parse(raw.value) as unknown, + catch: (error) => toError(error), + })(); + if (Result.isFailure(parsedJson)) { + return undefined; + } + + const parsed = v.safeParse(sessionSettingsSchema, parsedJson.value); + if (!parsed.success) { + return undefined; + } + + const modelId = asNonEmptyString(parsed.output.model) ?? null; + cache.set(settingsPath, { mtimeMs, modelId }); + return modelId ?? undefined; +} + +export function parseSessionSettingsLogLine(line: string): ParsedSessionSettings | null { + if (!line.includes('[Session] Saving session settings')) { + return null; + } + + const timestamp = extractTimestampFromLogLine(line); + if (timestamp == null) { + return null; + } + + const contextIndex = line.indexOf('| Context:'); + if (contextIndex === -1) { + return null; + } + const contextRaw = line.slice(contextIndex + '| Context:'.length).trim(); + if (contextRaw === '') { + return null; + } + + const jsonResult = Result.try({ + try: () => JSON.parse(contextRaw) as unknown, + catch: (error) => error, + })(); + if (Result.isFailure(jsonResult)) { + return null; + } + + const parsed = v.safeParse(sessionContextSchema, jsonResult.value); + if (!parsed.success) { + return null; + } + + const payload = parsed.output; + const value = payload.value; + if (value.hasTokenUsage === false) { + return null; + } + + const tokenUsage = value.tokenUsage; + if (tokenUsage == null) { + return null; + } + + const modelId = + asNonEmptyString(payload.tags?.modelId) ?? asNonEmptyString(payload.tags?.model) ?? 'unknown'; + + return { + timestamp, + sessionId: value.sessionId, + settingsPath: value.path, + modelId, + usage: { + inputTokens: tokenUsage.inputTokens ?? 0, + outputTokens: tokenUsage.outputTokens ?? 0, + thinkingTokens: tokenUsage.thinkingTokens ?? 0, + cacheReadTokens: tokenUsage.cacheReadTokens ?? 0, + cacheCreationTokens: tokenUsage.cacheCreationTokens ?? 0, + }, + }; +} + +export type LoadFactoryOptions = { + factoryDir?: string; +}; + +export type LoadFactoryResult = { + events: TokenUsageEvent[]; + missingLogsDirectory: string | null; +}; + +export async function loadFactoryTokenUsageEvents( + options: LoadFactoryOptions = {}, +): Promise { + const factoryDir = resolveFactoryDir(options.factoryDir); + const logsDir = path.join(factoryDir, FACTORY_LOGS_SUBDIR); + if (!isDirectorySync(logsDir)) { + return { events: [], missingLogsDirectory: logsDir }; + } + + const customModels = await loadFactoryCustomModels(factoryDir); + const logPaths = await glob(DROID_LOG_GLOB, { + cwd: logsDir, + absolute: true, + }); + + const logFileStats = await Promise.all( + logPaths.map(async (filePath) => { + const statResult = await Result.try({ + try: stat(filePath), + catch: (error) => toError(error), + }); + if (Result.isFailure(statResult)) { + return null; + } + if (!statResult.value.isFile()) { + return null; + } + return { filePath, mtimeMs: statResult.value.mtimeMs }; + }), + ); + + const sortedPaths = logFileStats + .filter((entry): entry is { filePath: string; mtimeMs: number } => entry != null) + .sort((a, b) => a.mtimeMs - b.mtimeMs) + .map((entry) => entry.filePath); + + const previousTotals = new Map< + string, + { + inputTokens: number; + outputTokens: number; + thinkingTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + } + >(); + + const lastKnownModelIdBySessionId = new Map(); + const modelIdBySettingsPathCache = new Map(); + + const events: TokenUsageEvent[] = []; + + for (const logPath of sortedPaths) { + const stream = createReadStream(logPath, { encoding: 'utf8' }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + try { + for await (const line of rl) { + const parsed = parseSessionSettingsLogLine(line); + if (parsed == null) { + continue; + } + + let modelId = parsed.modelId; + let modelIdSource: ModelIdSource = 'unknown'; + if (modelId === 'unknown') { + const modelIdFromSettings = await loadModelIdFromSessionSettings( + parsed.settingsPath, + modelIdBySettingsPathCache, + ); + if (modelIdFromSettings != null) { + modelId = modelIdFromSettings; + modelIdSource = 'settings'; + lastKnownModelIdBySessionId.set(parsed.sessionId, modelId); + } else { + const previousModelId = lastKnownModelIdBySessionId.get(parsed.sessionId); + if (previousModelId != null) { + modelId = previousModelId; + modelIdSource = 'session'; + } + } + } else { + modelIdSource = 'tag'; + lastKnownModelIdBySessionId.set(parsed.sessionId, modelId); + } + + const model = customModels.get(modelId); + const pricingModel = model?.model ?? modelId; + + const current = parsed.usage; + const currentTotals = { + ...current, + totalTokens: toTotalTokens(current), + }; + const previous = previousTotals.get(parsed.sessionId) ?? createEmptyUsage(); + const delta = subtractUsage(current, previous); + + if (delta.totalTokens <= 0) { + previousTotals.set(parsed.sessionId, currentTotals); + continue; + } + + previousTotals.set(parsed.sessionId, currentTotals); + + events.push({ + timestamp: parsed.timestamp, + sessionId: parsed.sessionId, + projectKey: extractProjectKeyFromSettingsPath(parsed.settingsPath), + modelId, + modelIdSource, + pricingModel, + inputTokens: delta.inputTokens, + outputTokens: delta.outputTokens, + thinkingTokens: delta.thinkingTokens, + cacheReadTokens: delta.cacheReadTokens, + cacheCreationTokens: delta.cacheCreationTokens, + totalTokens: delta.totalTokens, + }); + } + } catch (error) { + logger.debug('Failed to read Factory log file', logPath, error); + } finally { + rl.close(); + stream.destroy(); + } + } + + return { events, missingLogsDirectory: null }; +} + +if (import.meta.vitest != null) { + describe('loadFactoryTokenUsageEvents', () => { + it('parses session settings lines and computes deltas', async () => { + const fixture = await createFixture({ + 'settings.json': JSON.stringify( + { + customModels: [{ id: 'custom:Test-0', model: 'gpt-5.2', provider: 'openai' }], + }, + null, + 2, + ), + 'logs/droid-log-single.log': [ + `[2026-01-01T00:00:00.000Z] INFO: [Session] Saving session settings | Context: ${JSON.stringify( + { + value: { + sessionId: 's1', + path: '/Users/me/.factory/sessions/-Users-me-proj/s1.settings.json', + hasTokenUsage: true, + tokenUsage: { + inputTokens: 10, + outputTokens: 5, + thinkingTokens: 2, + cacheReadTokens: 100, + cacheCreationTokens: 3, + }, + }, + tags: { modelId: 'custom:Test-0' }, + }, + )}`, + `[2026-01-01T00:01:00.000Z] INFO: [Session] Saving session settings | Context: ${JSON.stringify( + { + value: { + sessionId: 's1', + path: '/Users/me/.factory/sessions/-Users-me-proj/s1.settings.json', + hasTokenUsage: true, + tokenUsage: { + inputTokens: 15, + outputTokens: 7, + thinkingTokens: 2, + cacheReadTokens: 130, + cacheCreationTokens: 4, + }, + }, + tags: { modelId: 'custom:Test-0' }, + }, + )}`, + ].join('\n'), + }); + + const result = await loadFactoryTokenUsageEvents({ factoryDir: fixture.path }); + expect(result.missingLogsDirectory).toBeNull(); + expect(result.events).toHaveLength(2); + expect(result.events[0]?.pricingModel).toBe('gpt-5.2'); + expect(result.events[0]?.totalTokens).toBe(10 + 5 + 2 + 100 + 3); + expect(result.events[1]?.inputTokens).toBe(5); + expect(result.events[1]?.cacheReadTokens).toBe(30); + }); + + it('treats token counter resets as new totals', async () => { + const fixture = await createFixture({ + 'logs/droid-log-single.log': [ + `[2026-01-01T00:00:00.000Z] INFO: [Session] Saving session settings | Context: ${JSON.stringify( + { + value: { + sessionId: 's2', + path: '/Users/me/.factory/sessions/-Users-me-proj/s2.settings.json', + hasTokenUsage: true, + tokenUsage: { inputTokens: 100, outputTokens: 50 }, + }, + tags: { modelId: 'gpt-5.2' }, + }, + )}`, + `[2026-01-01T00:01:00.000Z] INFO: [Session] Saving session settings | Context: ${JSON.stringify( + { + value: { + sessionId: 's2', + path: '/Users/me/.factory/sessions/-Users-me-proj/s2.settings.json', + hasTokenUsage: true, + tokenUsage: { inputTokens: 20, outputTokens: 10 }, + }, + tags: { modelId: 'gpt-5.2' }, + }, + )}`, + ].join('\n'), + }); + + const result = await loadFactoryTokenUsageEvents({ factoryDir: fixture.path }); + expect(result.events).toHaveLength(2); + expect(result.events[0]?.inputTokens).toBe(100); + expect(result.events[1]?.inputTokens).toBe(20); + }); + + it('reuses last known model id when tags omit modelId', async () => { + const fixture = await createFixture({ + 'logs/droid-log-single.log': [ + `[2026-01-01T00:00:00.000Z] INFO: [Session] Saving session settings | Context: ${JSON.stringify( + { + value: { + sessionId: 's3', + path: '/Users/me/.factory/sessions/-Users-me-proj/s3.settings.json', + hasTokenUsage: true, + tokenUsage: { inputTokens: 10, outputTokens: 0 }, + }, + tags: { modelId: 'gpt-5.2' }, + }, + )}`, + `[2026-01-01T00:01:00.000Z] INFO: [Session] Saving session settings | Context: ${JSON.stringify( + { + value: { + sessionId: 's3', + path: '/Users/me/.factory/sessions/-Users-me-proj/s3.settings.json', + hasTokenUsage: true, + tokenUsage: { inputTokens: 20, outputTokens: 0 }, + }, + }, + )}`, + ].join('\n'), + }); + + const result = await loadFactoryTokenUsageEvents({ factoryDir: fixture.path }); + expect(result.events).toHaveLength(2); + expect(result.events[1]?.modelId).toBe('gpt-5.2'); + expect(result.events[1]?.modelIdSource).toBe('session'); + expect(result.events[1]?.pricingModel).toBe('gpt-5.2'); + }); + + it('uses model from session settings file when tags omit modelId', async () => { + const fixture = await createFixture({ + 'sessions/proj/s4.settings.json': JSON.stringify( + { + model: 'gpt-5.2', + tokenUsage: { inputTokens: 0, outputTokens: 0 }, + }, + null, + 2, + ), + 'logs/droid-log-single.log': '', + }); + + const settingsPath = path.join(fixture.path, 'sessions/proj/s4.settings.json'); + const logPath = path.join(fixture.path, 'logs/droid-log-single.log'); + await writeFile( + logPath, + `[2026-01-01T00:00:00.000Z] INFO: [Session] Saving session settings | Context: ${JSON.stringify( + { + value: { + sessionId: 's4', + path: settingsPath, + hasTokenUsage: true, + tokenUsage: { inputTokens: 10, outputTokens: 5 }, + }, + }, + )}`, + 'utf8', + ); + + const result = await loadFactoryTokenUsageEvents({ factoryDir: fixture.path }); + expect(result.events).toHaveLength(1); + expect(result.events[0]?.projectKey).toBe('proj'); + expect(result.events[0]?.modelId).toBe('gpt-5.2'); + expect(result.events[0]?.modelIdSource).toBe('settings'); + expect(result.events[0]?.pricingModel).toBe('gpt-5.2'); + }); + }); +} diff --git a/apps/droid/src/date-utils.ts b/apps/droid/src/date-utils.ts new file mode 100644 index 00000000..9491ce77 --- /dev/null +++ b/apps/droid/src/date-utils.ts @@ -0,0 +1,108 @@ +function safeTimeZone(timezone?: string): string { + if (timezone == null || timezone.trim() === '') { + return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'; + } + + try { + Intl.DateTimeFormat('en-US', { timeZone: timezone }); + return timezone; + } catch { + return 'UTC'; + } +} + +export function toDateKey(timestamp: string, timezone?: string): string { + const tz = safeTimeZone(timezone); + const date = new Date(timestamp); + const formatter = new Intl.DateTimeFormat('en-CA', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: tz, + }); + return formatter.format(date); +} + +export function toMonthKey(timestamp: string, timezone?: string): string { + const tz = safeTimeZone(timezone); + const date = new Date(timestamp); + const formatter = new Intl.DateTimeFormat('en-CA', { + year: 'numeric', + month: '2-digit', + timeZone: tz, + }); + const [year, month] = formatter.format(date).split('-'); + return `${year}-${month}`; +} + +export function normalizeFilterDate(value?: string): string | undefined { + if (value == null) { + return undefined; + } + + const compact = value.replaceAll('-', '').trim(); + if (!/^\d{8}$/.test(compact)) { + throw new Error(`Invalid date format: ${value}. Expected YYYYMMDD or YYYY-MM-DD.`); + } + + return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`; +} + +export function isWithinRange(dateKey: string, since?: string, until?: string): boolean { + const value = dateKey.replaceAll('-', ''); + const sinceValue = since?.replaceAll('-', ''); + const untilValue = until?.replaceAll('-', ''); + + if (sinceValue != null && value < sinceValue) { + return false; + } + + if (untilValue != null && value > untilValue) { + return false; + } + + return true; +} + +export function formatDisplayDate(dateKey: string, locale?: string, _timezone?: string): string { + const [yearStr = '0', monthStr = '1', dayStr = '1'] = dateKey.split('-'); + const year = Number.parseInt(yearStr, 10); + const month = Number.parseInt(monthStr, 10); + const day = Number.parseInt(dayStr, 10); + const date = new Date(Date.UTC(year, month - 1, day)); + const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { + year: 'numeric', + month: 'short', + day: '2-digit', + timeZone: 'UTC', + }); + return formatter.format(date); +} + +export function formatDisplayMonth(monthKey: string, locale?: string, _timezone?: string): string { + const [yearStr = '0', monthStr = '1'] = monthKey.split('-'); + const year = Number.parseInt(yearStr, 10); + const month = Number.parseInt(monthStr, 10); + const date = new Date(Date.UTC(year, month - 1, 1)); + const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { + year: 'numeric', + month: 'short', + timeZone: 'UTC', + }); + return formatter.format(date); +} + +export function formatDisplayDateTime( + timestamp: string, + locale?: string, + timezone?: string, +): string { + const tz = safeTimeZone(timezone); + const date = new Date(timestamp); + const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { + dateStyle: 'short', + timeStyle: 'short', + timeZone: tz, + }); + return formatter.format(date); +} diff --git a/apps/droid/src/factory-settings.ts b/apps/droid/src/factory-settings.ts new file mode 100644 index 00000000..f84ffee9 --- /dev/null +++ b/apps/droid/src/factory-settings.ts @@ -0,0 +1,92 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { Result } from '@praha/byethrow'; +import { createFixture } from 'fs-fixture'; +import * as v from 'valibot'; +import { DEFAULT_FACTORY_DIR, FACTORY_DIR_ENV } from './_consts.ts'; +import { logger } from './logger.ts'; + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +const customModelSchema = v.object({ + id: v.string(), + model: v.string(), + provider: v.optional(v.string()), + displayName: v.optional(v.string()), +}); + +const settingsSchema = v.object({ + customModels: v.optional(v.array(customModelSchema)), +}); + +export type FactoryCustomModel = v.InferOutput; + +export function resolveFactoryDir(cliFactoryDir?: string): string { + return cliFactoryDir ?? process.env[FACTORY_DIR_ENV] ?? DEFAULT_FACTORY_DIR; +} + +export async function loadFactoryCustomModels( + factoryDir: string, +): Promise> { + const settingsPath = path.join(factoryDir, 'settings.json'); + const raw = await Result.try({ + try: readFile(settingsPath, 'utf8'), + catch: (error) => toError(error), + }); + + if (Result.isFailure(raw)) { + const error = raw.error; + if (isErrnoException(error) && error.code === 'ENOENT') { + return new Map(); + } + logger.warn(`Failed to read Factory settings at ${settingsPath}:`, error); + return new Map(); + } + + const parsedJson = Result.try({ + try: () => JSON.parse(raw.value) as unknown, + catch: (error) => toError(error), + })(); + if (Result.isFailure(parsedJson)) { + logger.warn(`Failed to parse Factory settings at ${settingsPath}:`, parsedJson.error); + return new Map(); + } + + const parsed = v.safeParse(settingsSchema, parsedJson.value); + if (!parsed.success) { + logger.warn(`Invalid Factory settings schema at ${settingsPath}`); + return new Map(); + } + + const map = new Map(); + for (const model of parsed.output.customModels ?? []) { + map.set(model.id, model); + } + return map; +} + +if (import.meta.vitest != null) { + describe('loadFactoryCustomModels', () => { + it('loads custom model ids from settings.json', async () => { + const fixture = await createFixture({ + 'settings.json': JSON.stringify( + { + customModels: [{ id: 'custom:Test-0', model: 'gpt-5.2', provider: 'openai' }], + }, + null, + 2, + ), + }); + + const models = await loadFactoryCustomModels(fixture.path); + expect(models.get('custom:Test-0')?.model).toBe('gpt-5.2'); + }); + }); +} diff --git a/apps/droid/src/index.ts b/apps/droid/src/index.ts new file mode 100644 index 00000000..c77c0ed5 --- /dev/null +++ b/apps/droid/src/index.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { run } from './run.ts'; + +// eslint-disable-next-line antfu/no-top-level-await +await run(); diff --git a/apps/droid/src/logger.ts b/apps/droid/src/logger.ts new file mode 100644 index 00000000..ce7384d0 --- /dev/null +++ b/apps/droid/src/logger.ts @@ -0,0 +1,7 @@ +import { createLogger, log as internalLog } from '@ccusage/internal/logger'; + +import { name } from '../package.json'; + +export const logger = createLogger(name); + +export const log = internalLog; diff --git a/apps/droid/src/monthly-report.ts b/apps/droid/src/monthly-report.ts new file mode 100644 index 00000000..7e373ab1 --- /dev/null +++ b/apps/droid/src/monthly-report.ts @@ -0,0 +1,120 @@ +import type { ModelUsage, MonthlyReportRow, PricingSource, TokenUsageEvent } from './_types.ts'; +import { sort } from 'fast-sort'; +import { formatDisplayMonth, isWithinRange, toDateKey, toMonthKey } from './date-utils.ts'; +import { addUsage, createEmptyUsage } from './token-utils.ts'; + +type MonthlySummary = { + monthKey: string; + totalUsage: ModelUsage; + modelsUsed: Set; + pricingModels: Map; +}; + +export type MonthlyReportOptions = { + timezone?: string; + locale?: string; + since?: string; + until?: string; + pricingSource: PricingSource; +}; + +export type MonthlyReportResult = { + rows: MonthlyReportRow[]; + missingPricingModels: string[]; +}; + +function formatModelDisplay(event: TokenUsageEvent): string { + const suffix = event.modelIdSource === 'settings' ? ' [inferred]' : ''; + if (event.modelId.startsWith('custom:')) { + const base = event.pricingModel.trim() !== '' ? event.pricingModel : event.modelId; + return `${base} [custom]${suffix}`; + } + + return `${event.modelId}${suffix}`; +} + +function addEventUsage(target: ModelUsage, event: TokenUsageEvent): void { + addUsage(target, { + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + thinkingTokens: event.thinkingTokens, + cacheReadTokens: event.cacheReadTokens, + cacheCreationTokens: event.cacheCreationTokens, + }); +} + +function getOrCreateModelUsage(map: Map, key: string): ModelUsage { + const existing = map.get(key); + if (existing != null) { + return existing; + } + const created = createEmptyUsage(); + map.set(key, created); + return created; +} + +export async function buildMonthlyReport( + events: TokenUsageEvent[], + options: MonthlyReportOptions, +): Promise { + const summaries = new Map(); + const missingPricingModels = new Set(); + + for (const event of events) { + const dateKey = toDateKey(event.timestamp, options.timezone); + if (!isWithinRange(dateKey, options.since, options.until)) { + continue; + } + + const monthKey = toMonthKey(event.timestamp, options.timezone); + + const summary = summaries.get(monthKey) ?? { + monthKey, + totalUsage: createEmptyUsage(), + modelsUsed: new Set(), + pricingModels: new Map(), + }; + if (!summaries.has(monthKey)) { + summaries.set(monthKey, summary); + } + + summary.modelsUsed.add(formatModelDisplay(event)); + addEventUsage(summary.totalUsage, event); + + if (event.pricingModel.trim() !== '') { + const usage = getOrCreateModelUsage(summary.pricingModels, event.pricingModel); + addEventUsage(usage, event); + } + } + + const rows: MonthlyReportRow[] = []; + + for (const summary of sort(Array.from(summaries.values())).asc((s) => s.monthKey)) { + let costUSD = 0; + for (const [pricingModel, usage] of summary.pricingModels) { + try { + const priced = await options.pricingSource.calculateCost(pricingModel, usage); + costUSD += priced.costUSD; + } catch { + missingPricingModels.add(pricingModel); + } + } + + rows.push({ + month: formatDisplayMonth(summary.monthKey, options.locale, options.timezone), + inputTokens: summary.totalUsage.inputTokens, + outputTokens: summary.totalUsage.outputTokens, + thinkingTokens: summary.totalUsage.thinkingTokens, + cacheReadTokens: summary.totalUsage.cacheReadTokens, + cacheCreationTokens: summary.totalUsage.cacheCreationTokens, + totalTokens: summary.totalUsage.totalTokens, + costUSD, + modelsUsed: sort(Array.from(summary.modelsUsed)).asc((model) => model), + }); + } + + return { + rows, + missingPricingModels: sort(Array.from(missingPricingModels)).asc((model) => model), + }; +} diff --git a/apps/droid/src/pricing.ts b/apps/droid/src/pricing.ts new file mode 100644 index 00000000..df552753 --- /dev/null +++ b/apps/droid/src/pricing.ts @@ -0,0 +1,138 @@ +import type { LiteLLMModelPricing } from '@ccusage/internal/pricing'; +import type { ModelUsage, PricingResult, PricingSource } from './_types.ts'; +import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; +import { createPricingDataset } from '@ccusage/internal/pricing-fetch-utils'; +import { Result } from '@praha/byethrow'; +import { prefetchFactoryPricing } from './_macro.ts'; +import { logger } from './logger.ts'; + +const FACTORY_PROVIDER_PREFIXES = [ + 'openai/', + 'azure/', + 'anthropic/', + 'openrouter/', + 'openrouter/openai/', + 'openrouter/anthropic/', + 'gemini/', + 'google/', + 'vertex_ai/', +]; + +export type FactoryPricingSourceOptions = { + offline?: boolean; + offlineLoader?: () => Promise>; +}; + +const EMPTY_PRICING_DATASET: Record = createPricingDataset(); + +let prefetchedPricingPromise: Promise> | null = null; + +async function loadPrefetchedFactoryPricing(): Promise> { + if (prefetchedPricingPromise == null) { + prefetchedPricingPromise = prefetchFactoryPricing(); + } + return prefetchedPricingPromise; +} + +function normalizeModelCandidates(rawModel: string): string[] { + const trimmed = rawModel.trim(); + if (trimmed === '') { + return []; + } + + const candidates = new Set([trimmed]); + + const withoutParens = trimmed.replaceAll(/\([^)]*\)/g, '').trim(); + if (withoutParens !== '') { + candidates.add(withoutParens); + } + + const thinkingSuffix = withoutParens.match(/^(.*?)(-thinking)(-\d+)?$/); + if (thinkingSuffix != null) { + const base = thinkingSuffix[1]?.trim(); + if (base != null && base !== '') { + candidates.add(base); + } + candidates.add(`${thinkingSuffix[1]}${thinkingSuffix[2]}`); + } + + return Array.from(candidates); +} + +export class FactoryPricingSource implements PricingSource, Disposable { + private readonly fetcher: LiteLLMPricingFetcher; + + constructor(options: FactoryPricingSourceOptions = {}) { + const offline = options.offline ?? false; + this.fetcher = new LiteLLMPricingFetcher({ + offline, + offlineLoader: + options.offlineLoader ?? + (offline ? async () => EMPTY_PRICING_DATASET : loadPrefetchedFactoryPricing), + logger, + providerPrefixes: FACTORY_PROVIDER_PREFIXES, + }); + } + + [Symbol.dispose](): void { + this.fetcher[Symbol.dispose](); + } + + async calculateCost(pricingModel: string, usage: ModelUsage): Promise { + const candidates = normalizeModelCandidates(pricingModel); + if (candidates.length === 0) { + return { costUSD: 0, usedPricingModel: pricingModel }; + } + + let lastError: Error | undefined; + for (const candidate of candidates) { + const result = await this.fetcher.calculateCostFromTokens( + { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens + usage.thinkingTokens, + cache_creation_input_tokens: usage.cacheCreationTokens, + cache_read_input_tokens: usage.cacheReadTokens, + }, + candidate, + ); + + if (Result.isSuccess(result)) { + return { costUSD: result.value, usedPricingModel: candidate }; + } + + lastError = result.error; + } + + throw lastError ?? new Error(`Pricing not found for model ${pricingModel}`); + } +} + +if (import.meta.vitest != null) { + describe('FactoryPricingSource', () => { + it('normalizes parentheses suffixes for pricing lookups', async () => { + using source = new FactoryPricingSource({ + offline: true, + offlineLoader: async () => ({ + 'openai/gpt-5.2': { + input_cost_per_token: 1e-6, + output_cost_per_token: 2e-6, + cache_read_input_token_cost: 1e-7, + cache_creation_input_token_cost: 1.5e-6, + }, + }), + }); + + const cost = await source.calculateCost('gpt-5.2(high)', { + inputTokens: 1000, + outputTokens: 500, + thinkingTokens: 100, + cacheReadTokens: 200, + cacheCreationTokens: 50, + totalTokens: 0, + }); + + const expected = 1000 * 1e-6 + (500 + 100) * 2e-6 + 200 * 1e-7 + 50 * 1.5e-6; + expect(cost.costUSD).toBeCloseTo(expected); + }); + }); +} diff --git a/apps/droid/src/run.ts b/apps/droid/src/run.ts new file mode 100644 index 00000000..eb239fcb --- /dev/null +++ b/apps/droid/src/run.ts @@ -0,0 +1,29 @@ +import process from 'node:process'; +import { cli } from 'gunshi'; +import { description, name, version } from '../package.json'; +import { dailyCommand, monthlyCommand, sessionCommand } from './commands/index.ts'; + +const subCommands = new Map([ + ['daily', dailyCommand], + ['monthly', monthlyCommand], + ['session', sessionCommand], +]); + +const mainCommand = dailyCommand; + +export async function run(): Promise { + // When invoked through npx, the binary name might be passed as the first argument + // Filter it out if it matches the expected binary name + let args = process.argv.slice(2); + if (args[0] === 'ccusage-droid') { + args = args.slice(1); + } + + await cli(args, mainCommand, { + name, + version, + description, + subCommands, + renderHeader: null, + }); +} diff --git a/apps/droid/src/session-report.ts b/apps/droid/src/session-report.ts new file mode 100644 index 00000000..4a2f6f32 --- /dev/null +++ b/apps/droid/src/session-report.ts @@ -0,0 +1,128 @@ +import type { ModelUsage, PricingSource, SessionReportRow, TokenUsageEvent } from './_types.ts'; +import { sort } from 'fast-sort'; +import { isWithinRange, toDateKey } from './date-utils.ts'; +import { addUsage, createEmptyUsage } from './token-utils.ts'; + +type SessionSummary = { + directory: string; + sessionId: string; + modelsUsed: Set; + totalUsage: ModelUsage; + pricingModels: Map; + lastActivity: string; +}; + +export type SessionReportOptions = { + timezone?: string; + locale?: string; + since?: string; + until?: string; + pricingSource: PricingSource; +}; + +export type SessionReportResult = { + rows: SessionReportRow[]; + missingPricingModels: string[]; +}; + +function formatModelDisplay(event: TokenUsageEvent): string { + const suffix = event.modelIdSource === 'settings' ? ' [inferred]' : ''; + if (event.modelId.startsWith('custom:')) { + const base = event.pricingModel.trim() !== '' ? event.pricingModel : event.modelId; + return `${base} [custom]${suffix}`; + } + + return `${event.modelId}${suffix}`; +} + +function addEventUsage(target: ModelUsage, event: TokenUsageEvent): void { + addUsage(target, { + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + thinkingTokens: event.thinkingTokens, + cacheReadTokens: event.cacheReadTokens, + cacheCreationTokens: event.cacheCreationTokens, + }); +} + +function getOrCreateModelUsage(map: Map, key: string): ModelUsage { + const existing = map.get(key); + if (existing != null) { + return existing; + } + const created = createEmptyUsage(); + map.set(key, created); + return created; +} + +export async function buildSessionReport( + events: TokenUsageEvent[], + options: SessionReportOptions, +): Promise { + const summaries = new Map(); + const missingPricingModels = new Set(); + + for (const event of events) { + const dateKey = toDateKey(event.timestamp, options.timezone); + if (!isWithinRange(dateKey, options.since, options.until)) { + continue; + } + + const key = `${event.projectKey}::${event.sessionId}`; + const summary = summaries.get(key) ?? { + directory: event.projectKey, + sessionId: event.sessionId, + modelsUsed: new Set(), + totalUsage: createEmptyUsage(), + pricingModels: new Map(), + lastActivity: event.timestamp, + }; + if (!summaries.has(key)) { + summaries.set(key, summary); + } + + summary.modelsUsed.add(formatModelDisplay(event)); + addEventUsage(summary.totalUsage, event); + if (event.timestamp > summary.lastActivity) { + summary.lastActivity = event.timestamp; + } + + if (event.pricingModel.trim() !== '') { + const usage = getOrCreateModelUsage(summary.pricingModels, event.pricingModel); + addEventUsage(usage, event); + } + } + + const rows: SessionReportRow[] = []; + + for (const summary of sort(Array.from(summaries.values())).desc((s) => s.lastActivity)) { + let costUSD = 0; + for (const [pricingModel, usage] of summary.pricingModels) { + try { + const priced = await options.pricingSource.calculateCost(pricingModel, usage); + costUSD += priced.costUSD; + } catch { + missingPricingModels.add(pricingModel); + } + } + + rows.push({ + directory: summary.directory, + sessionId: summary.sessionId, + modelsUsed: sort(Array.from(summary.modelsUsed)).asc((model) => model), + inputTokens: summary.totalUsage.inputTokens, + outputTokens: summary.totalUsage.outputTokens, + thinkingTokens: summary.totalUsage.thinkingTokens, + cacheReadTokens: summary.totalUsage.cacheReadTokens, + cacheCreationTokens: summary.totalUsage.cacheCreationTokens, + totalTokens: summary.totalUsage.totalTokens, + costUSD, + lastActivity: summary.lastActivity, + }); + } + + return { + rows, + missingPricingModels: sort(Array.from(missingPricingModels)).asc((model) => model), + }; +} diff --git a/apps/droid/src/token-utils.ts b/apps/droid/src/token-utils.ts new file mode 100644 index 00000000..d9905a2c --- /dev/null +++ b/apps/droid/src/token-utils.ts @@ -0,0 +1,155 @@ +import type { ModelUsage } from './_types.ts'; + +function ensureNonNegativeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; +} + +export function toTotalTokens(usage: { + inputTokens: number; + outputTokens: number; + thinkingTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; +}): number { + return ( + usage.inputTokens + + usage.outputTokens + + usage.thinkingTokens + + usage.cacheReadTokens + + usage.cacheCreationTokens + ); +} + +export function createEmptyUsage(): ModelUsage { + return { + inputTokens: 0, + outputTokens: 0, + thinkingTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + }; +} + +export function normalizeUsage(value: unknown): Omit { + if (value == null || typeof value !== 'object') { + return { + inputTokens: 0, + outputTokens: 0, + thinkingTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }; + } + + const record = value as Record; + return { + inputTokens: ensureNonNegativeNumber(record.inputTokens), + outputTokens: ensureNonNegativeNumber(record.outputTokens), + thinkingTokens: ensureNonNegativeNumber(record.thinkingTokens), + cacheReadTokens: ensureNonNegativeNumber(record.cacheReadTokens), + cacheCreationTokens: ensureNonNegativeNumber(record.cacheCreationTokens), + }; +} + +export function addUsage(target: ModelUsage, add: Omit): void { + target.inputTokens += add.inputTokens; + target.outputTokens += add.outputTokens; + target.thinkingTokens += add.thinkingTokens; + target.cacheReadTokens += add.cacheReadTokens; + target.cacheCreationTokens += add.cacheCreationTokens; + target.totalTokens = toTotalTokens(target); +} + +export function subtractUsage( + current: Omit, + previous: ModelUsage, +): ModelUsage { + const inputTokens = current.inputTokens - previous.inputTokens; + const outputTokens = current.outputTokens - previous.outputTokens; + const thinkingTokens = current.thinkingTokens - previous.thinkingTokens; + const cacheReadTokens = current.cacheReadTokens - previous.cacheReadTokens; + const cacheCreationTokens = current.cacheCreationTokens - previous.cacheCreationTokens; + + const isReset = + inputTokens < 0 || + outputTokens < 0 || + thinkingTokens < 0 || + cacheReadTokens < 0 || + cacheCreationTokens < 0; + + if (isReset) { + return { + inputTokens: current.inputTokens, + outputTokens: current.outputTokens, + thinkingTokens: current.thinkingTokens, + cacheReadTokens: current.cacheReadTokens, + cacheCreationTokens: current.cacheCreationTokens, + totalTokens: toTotalTokens(current), + }; + } + + const delta = { + inputTokens, + outputTokens, + thinkingTokens, + cacheReadTokens, + cacheCreationTokens, + }; + + return { + ...delta, + totalTokens: toTotalTokens(delta), + }; +} + +if (import.meta.vitest != null) { + describe('subtractUsage', () => { + it('computes deltas', () => { + const delta = subtractUsage( + { + inputTokens: 15, + outputTokens: 7, + thinkingTokens: 2, + cacheReadTokens: 130, + cacheCreationTokens: 4, + }, + { + inputTokens: 10, + outputTokens: 5, + thinkingTokens: 2, + cacheReadTokens: 100, + cacheCreationTokens: 3, + totalTokens: 0, + }, + ); + + expect(delta.inputTokens).toBe(5); + expect(delta.cacheReadTokens).toBe(30); + expect(delta.totalTokens).toBe(5 + 2 + 0 + 30 + 1); + }); + + it('treats negative deltas as reset', () => { + const delta = subtractUsage( + { + inputTokens: 20, + outputTokens: 10, + thinkingTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, + { + inputTokens: 100, + outputTokens: 50, + thinkingTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + }, + ); + + expect(delta.inputTokens).toBe(20); + expect(delta.totalTokens).toBe(30); + }); + }); +} diff --git a/apps/droid/tsconfig.json b/apps/droid/tsconfig.json new file mode 100644 index 00000000..67a71ac2 --- /dev/null +++ b/apps/droid/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext"], + "moduleDetection": "force", + "module": "Preserve", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "types": ["vitest/globals", "vitest/importMeta"], + "allowImportingTsExtensions": true, + "allowJs": false, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "skipLibCheck": true + }, + "exclude": ["dist"] +} diff --git a/apps/droid/tsdown.config.ts b/apps/droid/tsdown.config.ts new file mode 100644 index 00000000..08f5e4e5 --- /dev/null +++ b/apps/droid/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown'; +import Macros from 'unplugin-macros/rolldown'; + +export default defineConfig({ + entry: ['src/index.ts'], + outDir: 'dist', + format: 'esm', + clean: true, + sourcemap: false, + minify: 'dce-only', + treeshake: true, + dts: false, + publint: true, + unused: true, + fixedExtension: false, + nodeProtocol: true, + plugins: [ + Macros({ + include: ['src/index.ts', 'src/pricing.ts'], + }), + ], + define: { + 'import.meta.vitest': 'undefined', + }, +}); diff --git a/apps/droid/vitest.config.ts b/apps/droid/vitest.config.ts new file mode 100644 index 00000000..1c59406f --- /dev/null +++ b/apps/droid/vitest.config.ts @@ -0,0 +1,15 @@ +import Macros from 'unplugin-macros/vite'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + watch: false, + includeSource: ['src/**/*.{js,ts}'], + globals: true, + }, + plugins: [ + Macros({ + include: ['src/index.ts', 'src/pricing.ts'], + }) as any, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d33a20bd..30864304 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -457,6 +457,69 @@ importers: specifier: catalog:testing version: 4.0.15(@types/node@24.5.1)(happy-dom@16.8.1)(jiti@2.6.1)(yaml@2.8.1) + apps/droid: + devDependencies: + '@ccusage/internal': + specifier: workspace:* + version: link:../../packages/internal + '@ccusage/terminal': + specifier: workspace:* + version: link:../../packages/terminal + '@praha/byethrow': + specifier: catalog:runtime + version: 0.6.3 + '@ryoppippi/eslint-config': + specifier: catalog:lint + version: 0.4.0(@vue/compiler-sfc@3.5.21)(eslint-plugin-format@1.0.2(eslint@9.35.0(jiti@2.6.1)))(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2)(vitest@4.0.15(@types/node@24.5.1)(happy-dom@16.8.1)(jiti@2.6.1)(yaml@2.8.1)) + '@typescript/native-preview': + specifier: catalog:types + version: 7.0.0-dev.20260107.1 + bun: + specifier: runtime:^1.3.2 + version: runtime:1.3.5 + clean-pkg-json: + specifier: catalog:release + version: 1.3.0 + eslint: + specifier: catalog:lint + version: 9.35.0(jiti@2.6.1) + fast-sort: + specifier: catalog:runtime + version: 3.4.1 + fs-fixture: + specifier: catalog:testing + version: 2.8.1 + gunshi: + specifier: catalog:runtime + version: 0.26.3 + node: + specifier: runtime:^24.11.0 + version: runtime:24.12.0 + path-type: + specifier: catalog:runtime + version: 6.0.0 + picocolors: + specifier: catalog:runtime + version: 1.1.1 + tinyglobby: + specifier: catalog:runtime + version: 0.2.15 + tsdown: + specifier: catalog:build + version: 0.16.6(@typescript/native-preview@7.0.0-dev.20260107.1)(publint@0.3.12)(synckit@0.11.11)(typescript@5.9.2)(unplugin-unused@0.5.3) + unplugin-macros: + specifier: catalog:build + version: 0.18.2(@types/node@24.5.1)(jiti@2.6.1)(yaml@2.8.1) + unplugin-unused: + specifier: catalog:build + version: 0.5.3 + valibot: + specifier: catalog:runtime + version: 1.1.0(typescript@5.9.2) + vitest: + specifier: catalog:testing + version: 4.0.15(@types/node@24.5.1)(happy-dom@16.8.1)(jiti@2.6.1)(yaml@2.8.1) + apps/mcp: dependencies: '@ccusage/codex': From 567fe898e80f47fdb7fb92516bfa29aa83200e59 Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Sat, 10 Jan 2026 23:59:44 +0100 Subject: [PATCH 2/7] docs(droid): add README --- apps/droid/README.md | 91 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 apps/droid/README.md diff --git a/apps/droid/README.md b/apps/droid/README.md new file mode 100644 index 00000000..7d0a6c2f --- /dev/null +++ b/apps/droid/README.md @@ -0,0 +1,91 @@ +
+ ccusage logo +

@ccusage/droid

+
+ +> Analyze Factory Droid usage logs with the same reporting experience as `ccusage`. + +## Quick Start + +```bash +# Recommended - always include @latest +npx @ccusage/droid@latest --help +bunx @ccusage/droid@latest --help + +# Alternative package runners +pnpm dlx @ccusage/droid +pnpx @ccusage/droid +``` + +## Common Commands + +```bash +# Daily usage grouped by date (default command) +npx @ccusage/droid@latest daily + +# Monthly usage grouped by month +npx @ccusage/droid@latest monthly + +# Session-level usage grouped by Factory session +npx @ccusage/droid@latest session + +# JSON output for scripting +npx @ccusage/droid@latest daily --json + +# Filter by date range +npx @ccusage/droid@latest daily --since 2026-01-01 --until 2026-01-10 + +# Read from a custom Factory data dir +npx @ccusage/droid@latest daily --factoryDir /path/to/.factory +``` + +## Data Source + +This CLI reads Factory Droid logs from: + +- `~/.factory/logs/droid-log-*.log` + +You can override the Factory data directory via: + +- `--factoryDir /path/to/.factory` +- `FACTORY_DIR=/path/to/.factory` + +## Pricing + +Costs are calculated from token counts using LiteLLM's pricing dataset. + +- Use `--offline` to avoid fetching updated pricing. +- If a model is missing pricing data, its cost is treated as `$0` and reported as a warning. + +## Custom Models + +Factory supports custom model IDs (often prefixed with `custom:`). This CLI resolves them using: + +- `~/.factory/settings.json` → `customModels[]` + +Example: + +```json +{ + "customModels": [ + { + "id": "custom:GPT-5.2-(High)-18", + "model": "gpt-5.2(high)", + "provider": "openai" + } + ] +} +``` + +In tables, custom models are displayed as `gpt-5.2(high) [custom]`. + +When a log line is missing a model tag, the CLI resolves the model from the session settings file and marks it as `[...] [inferred]`. + +## Environment Variables + +- `FACTORY_DIR` - override the Factory data directory +- `LOG_LEVEL` - control log verbosity (0 silent … 5 trace) + +## License + +MIT © [@ryoppippi](https://github.com/ryoppippi) From df233385925a690a04bc6ab2ffa0be246e270e2b Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Sun, 11 Jan 2026 00:15:41 +0100 Subject: [PATCH 3/7] fix(droid): align engines and logger --- apps/droid/package.json | 2 +- apps/droid/src/_macro.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/droid/package.json b/apps/droid/package.json index 6853ab69..6ca16076 100644 --- a/apps/droid/package.json +++ b/apps/droid/package.json @@ -65,7 +65,7 @@ "runtime": [ { "name": "node", - "version": "^24.11.0", + "version": ">=20.19.4", "onFail": "download" }, { diff --git a/apps/droid/src/_macro.ts b/apps/droid/src/_macro.ts index 8734408a..67cf9738 100644 --- a/apps/droid/src/_macro.ts +++ b/apps/droid/src/_macro.ts @@ -4,6 +4,7 @@ import { fetchLiteLLMPricingDataset, filterPricingDataset, } from '@ccusage/internal/pricing-fetch-utils'; +import { logger } from './logger.ts'; const FACTORY_MODEL_PREFIXES = [ 'openai/', @@ -26,7 +27,7 @@ export async function prefetchFactoryPricing(): Promise Date: Sun, 11 Jan 2026 00:22:58 +0100 Subject: [PATCH 4/7] fix(terminal): measure multiline cells by max line --- packages/terminal/src/table.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/terminal/src/table.ts b/packages/terminal/src/table.ts index 7e8f2ddd..10953991 100644 --- a/packages/terminal/src/table.ts +++ b/packages/terminal/src/table.ts @@ -4,6 +4,15 @@ import { uniq } from 'es-toolkit'; import pc from 'picocolors'; import stringWidth from 'string-width'; +function getCellDisplayWidth(value: string): number { + const lines = value.split(/\r?\n/); + let maxWidth = 0; + for (const line of lines) { + maxWidth = Math.max(maxWidth, stringWidth(line)); + } + return maxWidth; +} + /** * Default locale used for date formatting when not specified * en-CA provides YYYY-MM-DD ISO format @@ -212,7 +221,9 @@ export class ResponsiveTable { ]; const contentWidths = head.map((_, colIndex) => { - const maxLength = Math.max(...allRows.map((row) => stringWidth(String(row[colIndex] ?? '')))); + const maxLength = Math.max( + ...allRows.map((row) => getCellDisplayWidth(String(row[colIndex] ?? ''))), + ); return maxLength; }); @@ -1079,6 +1090,17 @@ if (import.meta.vitest != null) { }); }); + describe('getCellDisplayWidth', () => { + it('uses max line width for multiline cells', () => { + expect(getCellDisplayWidth('aaa\nb')).toBe(3); + expect(getCellDisplayWidth('a\nbbbb')).toBe(4); + }); + + it('matches stringWidth for single-line cells', () => { + expect(getCellDisplayWidth('hello')).toBe(stringWidth('hello')); + }); + }); + describe('formatDateCompact', () => { it('should format date to compact format with newline', () => { const result = formatDateCompact('2024-08-04', undefined, 'en-US'); From df35e1d4048bd946873ebf02d20939d5db75f72f Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Sun, 11 Jan 2026 00:33:21 +0100 Subject: [PATCH 5/7] docs(droid): add docstrings --- apps/droid/src/_consts.ts | 4 ++++ apps/droid/src/_macro.ts | 4 ++++ apps/droid/src/_shared-args.ts | 7 +++++++ apps/droid/src/_types.ts | 28 ++++++++++++++++++++++++++++ apps/droid/src/commands/daily.ts | 4 ++++ apps/droid/src/commands/monthly.ts | 4 ++++ apps/droid/src/commands/session.ts | 4 ++++ apps/droid/src/daily-report.ts | 10 ++++++++++ apps/droid/src/data-loader.ts | 25 +++++++++++++++++++++++++ apps/droid/src/date-utils.ts | 30 ++++++++++++++++++++++++++++++ apps/droid/src/factory-settings.ts | 17 +++++++++++++++++ apps/droid/src/index.ts | 4 ++++ apps/droid/src/logger.ts | 7 +++++++ apps/droid/src/monthly-report.ts | 7 +++++++ apps/droid/src/pricing.ts | 18 ++++++++++++++++++ apps/droid/src/run.ts | 4 ++++ apps/droid/src/session-report.ts | 9 +++++++++ apps/droid/src/token-utils.ts | 23 +++++++++++++++++++++++ 18 files changed, 209 insertions(+) diff --git a/apps/droid/src/_consts.ts b/apps/droid/src/_consts.ts index 5a336a8d..848be18f 100644 --- a/apps/droid/src/_consts.ts +++ b/apps/droid/src/_consts.ts @@ -1,3 +1,7 @@ +/** + * @fileoverview Default paths and constants for Factory Droid usage tracking. + */ + import os from 'node:os'; import path from 'node:path'; diff --git a/apps/droid/src/_macro.ts b/apps/droid/src/_macro.ts index 67cf9738..ba57bb25 100644 --- a/apps/droid/src/_macro.ts +++ b/apps/droid/src/_macro.ts @@ -1,3 +1,7 @@ +/** + * @fileoverview Lightweight helper for prefetching Factory model pricing. + */ + import type { LiteLLMModelPricing } from '@ccusage/internal/pricing'; import { createPricingDataset, diff --git a/apps/droid/src/_shared-args.ts b/apps/droid/src/_shared-args.ts index 58f28d99..8e6805b8 100644 --- a/apps/droid/src/_shared-args.ts +++ b/apps/droid/src/_shared-args.ts @@ -1,6 +1,13 @@ +/** + * @fileoverview Shared CLI arguments for `@ccusage/droid` commands. + */ + import type { Args } from 'gunshi'; import { DEFAULT_LOCALE, DEFAULT_TIMEZONE } from './_consts.ts'; +/** + * Common CLI args shared by `daily`, `monthly`, and `session` commands. + */ export const sharedArgs = { json: { type: 'boolean', diff --git a/apps/droid/src/_types.ts b/apps/droid/src/_types.ts index 42e833fe..0e903242 100644 --- a/apps/droid/src/_types.ts +++ b/apps/droid/src/_types.ts @@ -1,5 +1,15 @@ +/** + * @fileoverview Shared types for the Factory Droid usage pipeline. + */ + +/** + * Indicates where a model identifier was resolved from. + */ export type ModelIdSource = 'tag' | 'settings' | 'session' | 'unknown'; +/** + * A single token usage event derived from Factory Droid logs. + */ export type TokenUsageEvent = { timestamp: string; sessionId: string; @@ -15,6 +25,9 @@ export type TokenUsageEvent = { totalTokens: number; }; +/** + * Token usage structure used for aggregation and pricing. + */ export type ModelUsage = { inputTokens: number; outputTokens: number; @@ -24,15 +37,24 @@ export type ModelUsage = { totalTokens: number; }; +/** + * Result of a pricing calculation. + */ export type PricingResult = { costUSD: number; usedPricingModel: string; }; +/** + * Pricing provider interface used by report builders. + */ export type PricingSource = { calculateCost: (pricingModel: string, usage: ModelUsage) => Promise; }; +/** + * A single row in the daily report. + */ export type DailyReportRow = { date: string; inputTokens: number; @@ -45,6 +67,9 @@ export type DailyReportRow = { modelsUsed: string[]; }; +/** + * A single row in the monthly report. + */ export type MonthlyReportRow = { month: string; inputTokens: number; @@ -57,6 +82,9 @@ export type MonthlyReportRow = { modelsUsed: string[]; }; +/** + * A single row in the session report. + */ export type SessionReportRow = { directory: string; sessionId: string; diff --git a/apps/droid/src/commands/daily.ts b/apps/droid/src/commands/daily.ts index 4742e512..d2f0aebd 100644 --- a/apps/droid/src/commands/daily.ts +++ b/apps/droid/src/commands/daily.ts @@ -1,3 +1,7 @@ +/** + * @fileoverview `daily` command for Factory Droid usage. + */ + import process from 'node:process'; import { addEmptySeparatorRow, diff --git a/apps/droid/src/commands/monthly.ts b/apps/droid/src/commands/monthly.ts index a35c5983..901a9ecf 100644 --- a/apps/droid/src/commands/monthly.ts +++ b/apps/droid/src/commands/monthly.ts @@ -1,3 +1,7 @@ +/** + * @fileoverview `monthly` command for Factory Droid usage. + */ + import process from 'node:process'; import { addEmptySeparatorRow, diff --git a/apps/droid/src/commands/session.ts b/apps/droid/src/commands/session.ts index d16d317b..9ab2e7a1 100644 --- a/apps/droid/src/commands/session.ts +++ b/apps/droid/src/commands/session.ts @@ -1,3 +1,7 @@ +/** + * @fileoverview `session` command for Factory Droid usage. + */ + import process from 'node:process'; import { addEmptySeparatorRow, diff --git a/apps/droid/src/daily-report.ts b/apps/droid/src/daily-report.ts index c1703942..e8f5087a 100644 --- a/apps/droid/src/daily-report.ts +++ b/apps/droid/src/daily-report.ts @@ -1,3 +1,7 @@ +/** + * @fileoverview Daily aggregation for Factory Droid token usage. + */ + import type { DailyReportRow, ModelUsage, PricingSource, TokenUsageEvent } from './_types.ts'; import { sort } from 'fast-sort'; import { formatDisplayDate, isWithinRange, toDateKey } from './date-utils.ts'; @@ -53,6 +57,12 @@ function getOrCreateModelUsage(map: Map, key: string): Model return created; } +/** + * Builds a daily report from raw token usage events. + * + * Events are grouped by day (timezone-aware), aggregated per model, and priced + * via the provided `PricingSource`. + */ export async function buildDailyReport( events: TokenUsageEvent[], options: DailyReportOptions, diff --git a/apps/droid/src/data-loader.ts b/apps/droid/src/data-loader.ts index 72754cb0..3ac9c04e 100644 --- a/apps/droid/src/data-loader.ts +++ b/apps/droid/src/data-loader.ts @@ -1,3 +1,10 @@ +/** + * @fileoverview Factory Droid log loader. + * + * Parses `droid-log-*.log` files, extracts cumulative token counters, converts them + * into per-interval deltas, and resolves model identifiers (including custom models). + */ + import type { ModelIdSource, TokenUsageEvent } from './_types.ts'; import { createReadStream } from 'node:fs'; import { readFile, stat, writeFile } from 'node:fs/promises'; @@ -139,6 +146,11 @@ async function loadModelIdFromSessionSettings( return modelId ?? undefined; } +/** + * Parses a single Factory Droid log line that contains session settings. + * + * Returns `null` for unrelated lines or malformed payloads. + */ export function parseSessionSettingsLogLine(line: string): ParsedSessionSettings | null { if (!line.includes('[Session] Saving session settings')) { return null; @@ -200,15 +212,28 @@ export function parseSessionSettingsLogLine(line: string): ParsedSessionSettings }; } +/** + * Options for loading Factory Droid events. + */ export type LoadFactoryOptions = { factoryDir?: string; }; +/** + * Result of loading Factory Droid events. + */ export type LoadFactoryResult = { events: TokenUsageEvent[]; missingLogsDirectory: string | null; }; +/** + * Loads token usage events from Factory Droid logs. + * + * - Reads log files from `~/.factory/logs` (or a provided `factoryDir`) + * - Parses session settings lines with cumulative counters + * - Computes deltas per session, treating counter decreases as resets + */ export async function loadFactoryTokenUsageEvents( options: LoadFactoryOptions = {}, ): Promise { diff --git a/apps/droid/src/date-utils.ts b/apps/droid/src/date-utils.ts index 9491ce77..69837044 100644 --- a/apps/droid/src/date-utils.ts +++ b/apps/droid/src/date-utils.ts @@ -1,3 +1,10 @@ +/** + * @fileoverview Date utilities for grouping Factory Droid events. + * + * Input timestamps are ISO strings from logs; these helpers normalize them into + * date/month keys and format display labels. + */ + function safeTimeZone(timezone?: string): string { if (timezone == null || timezone.trim() === '') { return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'; @@ -11,6 +18,9 @@ function safeTimeZone(timezone?: string): string { } } +/** + * Converts a timestamp into a `YYYY-MM-DD` key in the given timezone. + */ export function toDateKey(timestamp: string, timezone?: string): string { const tz = safeTimeZone(timezone); const date = new Date(timestamp); @@ -23,6 +33,9 @@ export function toDateKey(timestamp: string, timezone?: string): string { return formatter.format(date); } +/** + * Converts a timestamp into a `YYYY-MM` key in the given timezone. + */ export function toMonthKey(timestamp: string, timezone?: string): string { const tz = safeTimeZone(timezone); const date = new Date(timestamp); @@ -35,6 +48,11 @@ export function toMonthKey(timestamp: string, timezone?: string): string { return `${year}-${month}`; } +/** + * Normalizes filter inputs into `YYYY-MM-DD`. + * + * Accepts `YYYYMMDD` or `YYYY-MM-DD`. + */ export function normalizeFilterDate(value?: string): string | undefined { if (value == null) { return undefined; @@ -48,6 +66,9 @@ export function normalizeFilterDate(value?: string): string | undefined { return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`; } +/** + * Returns true if `dateKey` (YYYY-MM-DD) is within the inclusive range. + */ export function isWithinRange(dateKey: string, since?: string, until?: string): boolean { const value = dateKey.replaceAll('-', ''); const sinceValue = since?.replaceAll('-', ''); @@ -64,6 +85,9 @@ export function isWithinRange(dateKey: string, since?: string, until?: string): return true; } +/** + * Formats a `YYYY-MM-DD` key for display (timezone-independent). + */ export function formatDisplayDate(dateKey: string, locale?: string, _timezone?: string): string { const [yearStr = '0', monthStr = '1', dayStr = '1'] = dateKey.split('-'); const year = Number.parseInt(yearStr, 10); @@ -79,6 +103,9 @@ export function formatDisplayDate(dateKey: string, locale?: string, _timezone?: return formatter.format(date); } +/** + * Formats a `YYYY-MM` key for display (timezone-independent). + */ export function formatDisplayMonth(monthKey: string, locale?: string, _timezone?: string): string { const [yearStr = '0', monthStr = '1'] = monthKey.split('-'); const year = Number.parseInt(yearStr, 10); @@ -92,6 +119,9 @@ export function formatDisplayMonth(monthKey: string, locale?: string, _timezone? return formatter.format(date); } +/** + * Formats an ISO timestamp for display in a given timezone. + */ export function formatDisplayDateTime( timestamp: string, locale?: string, diff --git a/apps/droid/src/factory-settings.ts b/apps/droid/src/factory-settings.ts index f84ffee9..137309a3 100644 --- a/apps/droid/src/factory-settings.ts +++ b/apps/droid/src/factory-settings.ts @@ -1,3 +1,10 @@ +/** + * @fileoverview Factory settings loader. + * + * Factory stores global configuration under `~/.factory/settings.json`, including + * `customModels[]` mappings used to resolve `custom:*` model IDs. + */ + import { readFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; @@ -28,10 +35,20 @@ const settingsSchema = v.object({ export type FactoryCustomModel = v.InferOutput; +/** + * Resolves the Factory data directory. + * + * Precedence: CLI `--factoryDir` → `FACTORY_DIR` → `~/.factory`. + */ export function resolveFactoryDir(cliFactoryDir?: string): string { return cliFactoryDir ?? process.env[FACTORY_DIR_ENV] ?? DEFAULT_FACTORY_DIR; } +/** + * Loads Factory custom model mappings from `settings.json`. + * + * Returns an empty map if the file is missing or invalid. + */ export async function loadFactoryCustomModels( factoryDir: string, ): Promise> { diff --git a/apps/droid/src/index.ts b/apps/droid/src/index.ts index c77c0ed5..467e0635 100644 --- a/apps/droid/src/index.ts +++ b/apps/droid/src/index.ts @@ -1,5 +1,9 @@ #!/usr/bin/env node +/** + * @fileoverview Package entrypoint for the `ccusage-droid` binary. + */ + import { run } from './run.ts'; // eslint-disable-next-line antfu/no-top-level-await diff --git a/apps/droid/src/logger.ts b/apps/droid/src/logger.ts index ce7384d0..c88ac9cf 100644 --- a/apps/droid/src/logger.ts +++ b/apps/droid/src/logger.ts @@ -1,7 +1,14 @@ +/** + * @fileoverview App-scoped logger binding. + */ + import { createLogger, log as internalLog } from '@ccusage/internal/logger'; import { name } from '../package.json'; export const logger = createLogger(name); +/** + * Unscoped low-level log helper used by other ccusage apps. + */ export const log = internalLog; diff --git a/apps/droid/src/monthly-report.ts b/apps/droid/src/monthly-report.ts index 7e373ab1..9f68b32c 100644 --- a/apps/droid/src/monthly-report.ts +++ b/apps/droid/src/monthly-report.ts @@ -1,3 +1,7 @@ +/** + * @fileoverview Monthly aggregation for Factory Droid token usage. + */ + import type { ModelUsage, MonthlyReportRow, PricingSource, TokenUsageEvent } from './_types.ts'; import { sort } from 'fast-sort'; import { formatDisplayMonth, isWithinRange, toDateKey, toMonthKey } from './date-utils.ts'; @@ -53,6 +57,9 @@ function getOrCreateModelUsage(map: Map, key: string): Model return created; } +/** + * Builds a monthly report from raw token usage events. + */ export async function buildMonthlyReport( events: TokenUsageEvent[], options: MonthlyReportOptions, diff --git a/apps/droid/src/pricing.ts b/apps/droid/src/pricing.ts index df552753..f896a742 100644 --- a/apps/droid/src/pricing.ts +++ b/apps/droid/src/pricing.ts @@ -1,3 +1,10 @@ +/** + * @fileoverview Pricing adapter for Factory Droid. + * + * Resolves per-token costs using the shared LiteLLM pricing fetcher, scoped to + * provider prefixes typically used by Factory. + */ + import type { LiteLLMModelPricing } from '@ccusage/internal/pricing'; import type { ModelUsage, PricingResult, PricingSource } from './_types.ts'; import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; @@ -62,6 +69,11 @@ function normalizeModelCandidates(rawModel: string): string[] { export class FactoryPricingSource implements PricingSource, Disposable { private readonly fetcher: LiteLLMPricingFetcher; + /** + * Creates a pricing source. + * + * When `offline` is enabled, the source uses `offlineLoader` if provided. + */ constructor(options: FactoryPricingSourceOptions = {}) { const offline = options.offline ?? false; this.fetcher = new LiteLLMPricingFetcher({ @@ -78,6 +90,12 @@ export class FactoryPricingSource implements PricingSource, Disposable { this.fetcher[Symbol.dispose](); } + /** + * Calculates cost for a model usage payload. + * + * This attempts a few normalized model candidates (e.g., stripping parentheses) + * to match LiteLLM pricing entries. + */ async calculateCost(pricingModel: string, usage: ModelUsage): Promise { const candidates = normalizeModelCandidates(pricingModel); if (candidates.length === 0) { diff --git a/apps/droid/src/run.ts b/apps/droid/src/run.ts index eb239fcb..7137d68b 100644 --- a/apps/droid/src/run.ts +++ b/apps/droid/src/run.ts @@ -1,3 +1,7 @@ +/** + * @fileoverview CLI runner for `@ccusage/droid`. + */ + import process from 'node:process'; import { cli } from 'gunshi'; import { description, name, version } from '../package.json'; diff --git a/apps/droid/src/session-report.ts b/apps/droid/src/session-report.ts index 4a2f6f32..45b0e9ba 100644 --- a/apps/droid/src/session-report.ts +++ b/apps/droid/src/session-report.ts @@ -1,3 +1,7 @@ +/** + * @fileoverview Session aggregation for Factory Droid token usage. + */ + import type { ModelUsage, PricingSource, SessionReportRow, TokenUsageEvent } from './_types.ts'; import { sort } from 'fast-sort'; import { isWithinRange, toDateKey } from './date-utils.ts'; @@ -55,6 +59,11 @@ function getOrCreateModelUsage(map: Map, key: string): Model return created; } +/** + * Builds a session report from raw token usage events. + * + * Rows are grouped by `(projectKey, sessionId)` and sorted by most recent activity. + */ export async function buildSessionReport( events: TokenUsageEvent[], options: SessionReportOptions, diff --git a/apps/droid/src/token-utils.ts b/apps/droid/src/token-utils.ts index d9905a2c..c93a6299 100644 --- a/apps/droid/src/token-utils.ts +++ b/apps/droid/src/token-utils.ts @@ -1,3 +1,10 @@ +/** + * @fileoverview Token-usage helpers for Factory Droid reports. + * + * Factory logs expose cumulative token counters; these utilities normalize raw values, + * compute deltas, and keep totals consistent. + */ + import type { ModelUsage } from './_types.ts'; function ensureNonNegativeNumber(value: unknown): number { @@ -20,6 +27,9 @@ export function toTotalTokens(usage: { ); } +/** + * Creates an empty, zeroed token usage structure. + */ export function createEmptyUsage(): ModelUsage { return { inputTokens: 0, @@ -31,6 +41,11 @@ export function createEmptyUsage(): ModelUsage { }; } +/** + * Normalizes an unknown token usage payload into a numeric token usage structure. + * + * Missing/invalid values are treated as `0`. + */ export function normalizeUsage(value: unknown): Omit { if (value == null || typeof value !== 'object') { return { @@ -52,6 +67,9 @@ export function normalizeUsage(value: unknown): Omit }; } +/** + * Adds token usage values to a mutable target and updates `totalTokens`. + */ export function addUsage(target: ModelUsage, add: Omit): void { target.inputTokens += add.inputTokens; target.outputTokens += add.outputTokens; @@ -61,6 +79,11 @@ export function addUsage(target: ModelUsage, add: Omit, previous: ModelUsage, From 7a5c24eaf6b949a8d0b7e1d5e16ce60c94723bf6 Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Sun, 11 Jan 2026 00:36:54 +0100 Subject: [PATCH 6/7] docs(droid): improve docstring coverage --- apps/droid/src/commands/daily.ts | 3 +++ apps/droid/src/commands/monthly.ts | 3 +++ apps/droid/src/commands/session.ts | 3 +++ apps/droid/src/daily-report.ts | 9 +++++++++ apps/droid/src/data-loader.ts | 17 +++++++++++++++++ apps/droid/src/factory-settings.ts | 6 ++++++ apps/droid/src/monthly-report.ts | 9 +++++++++ apps/droid/src/pricing.ts | 6 ++++++ apps/droid/src/run.ts | 3 +++ apps/droid/src/session-report.ts | 9 +++++++++ apps/droid/src/token-utils.ts | 6 ++++++ 11 files changed, 74 insertions(+) diff --git a/apps/droid/src/commands/daily.ts b/apps/droid/src/commands/daily.ts index d2f0aebd..1099f501 100644 --- a/apps/droid/src/commands/daily.ts +++ b/apps/droid/src/commands/daily.ts @@ -23,6 +23,9 @@ import { FactoryPricingSource } from '../pricing.ts'; const TABLE_COLUMN_COUNT = 9; +/** + * Logs a short warning for models that could not be priced (treated as $0). + */ function summarizeMissingPricing(models: string[]): void { if (models.length === 0) { return; diff --git a/apps/droid/src/commands/monthly.ts b/apps/droid/src/commands/monthly.ts index 901a9ecf..636a7c08 100644 --- a/apps/droid/src/commands/monthly.ts +++ b/apps/droid/src/commands/monthly.ts @@ -22,6 +22,9 @@ import { FactoryPricingSource } from '../pricing.ts'; const TABLE_COLUMN_COUNT = 9; +/** + * Logs a short warning for models that could not be priced (treated as $0). + */ function summarizeMissingPricing(models: string[]): void { if (models.length === 0) { return; diff --git a/apps/droid/src/commands/session.ts b/apps/droid/src/commands/session.ts index 9ab2e7a1..b0fe2007 100644 --- a/apps/droid/src/commands/session.ts +++ b/apps/droid/src/commands/session.ts @@ -27,6 +27,9 @@ import { buildSessionReport } from '../session-report.ts'; const TABLE_COLUMN_COUNT = 12; +/** + * Logs a short warning for models that could not be priced (treated as $0). + */ function summarizeMissingPricing(models: string[]): void { if (models.length === 0) { return; diff --git a/apps/droid/src/daily-report.ts b/apps/droid/src/daily-report.ts index e8f5087a..eec0ea30 100644 --- a/apps/droid/src/daily-report.ts +++ b/apps/droid/src/daily-report.ts @@ -27,6 +27,9 @@ export type DailyReportResult = { missingPricingModels: string[]; }; +/** + * Formats a model identifier for display, including custom/inferred labels. + */ function formatModelDisplay(event: TokenUsageEvent): string { const suffix = event.modelIdSource === 'settings' ? ' [inferred]' : ''; if (event.modelId.startsWith('custom:')) { @@ -37,6 +40,9 @@ function formatModelDisplay(event: TokenUsageEvent): string { return `${event.modelId}${suffix}`; } +/** + * Adds a single event's token usage to an aggregate. + */ function addEventUsage(target: ModelUsage, event: TokenUsageEvent): void { addUsage(target, { inputTokens: event.inputTokens, @@ -47,6 +53,9 @@ function addEventUsage(target: ModelUsage, event: TokenUsageEvent): void { }); } +/** + * Gets an existing model usage aggregate or inserts a new empty one. + */ function getOrCreateModelUsage(map: Map, key: string): ModelUsage { const existing = map.get(key); if (existing != null) { diff --git a/apps/droid/src/data-loader.ts b/apps/droid/src/data-loader.ts index 3ac9c04e..18ad1f98 100644 --- a/apps/droid/src/data-loader.ts +++ b/apps/droid/src/data-loader.ts @@ -20,6 +20,9 @@ import { loadFactoryCustomModels, resolveFactoryDir } from './factory-settings.t import { logger } from './logger.ts'; import { createEmptyUsage, subtractUsage, toTotalTokens } from './token-utils.ts'; +/** + * Normalizes unknown errors into `Error` instances. + */ function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } @@ -64,6 +67,9 @@ const sessionSettingsSchema = v.object({ model: v.optional(v.string()), }); +/** + * Returns a trimmed string if it is non-empty, otherwise `undefined`. + */ function asNonEmptyString(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; @@ -73,6 +79,9 @@ function asNonEmptyString(value: unknown): string | undefined { return trimmed === '' ? undefined : trimmed; } +/** + * Extracts an ISO timestamp from a log line prefix like `[2026-01-01T00:00:00Z]`. + */ function extractTimestampFromLogLine(line: string): string | undefined { if (!line.startsWith('[')) { return undefined; @@ -85,6 +94,9 @@ function extractTimestampFromLogLine(line: string): string | undefined { return raw === '' ? undefined : raw; } +/** + * Derives a stable "project key" from a settings path under `.../sessions//...`. + */ function extractProjectKeyFromSettingsPath(settingsPath: string): string { const normalized = path.normalize(settingsPath); const segments = normalized.split(path.sep); @@ -102,6 +114,11 @@ type ModelIdCacheEntry = { modelId: string | null; }; +/** + * Loads the model ID from a per-session settings file (`*.settings.json`). + * + * Results are cached by `(settingsPath, mtimeMs)`. + */ async function loadModelIdFromSessionSettings( settingsPath: string, cache: Map, diff --git a/apps/droid/src/factory-settings.ts b/apps/droid/src/factory-settings.ts index 137309a3..9a20c63e 100644 --- a/apps/droid/src/factory-settings.ts +++ b/apps/droid/src/factory-settings.ts @@ -14,10 +14,16 @@ import * as v from 'valibot'; import { DEFAULT_FACTORY_DIR, FACTORY_DIR_ENV } from './_consts.ts'; import { logger } from './logger.ts'; +/** + * Normalizes unknown errors into `Error` instances. + */ function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } +/** + * Type guard for Node.js `ErrnoException` errors. + */ function isErrnoException(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error; } diff --git a/apps/droid/src/monthly-report.ts b/apps/droid/src/monthly-report.ts index 9f68b32c..0bfba153 100644 --- a/apps/droid/src/monthly-report.ts +++ b/apps/droid/src/monthly-report.ts @@ -27,6 +27,9 @@ export type MonthlyReportResult = { missingPricingModels: string[]; }; +/** + * Formats a model identifier for display, including custom/inferred labels. + */ function formatModelDisplay(event: TokenUsageEvent): string { const suffix = event.modelIdSource === 'settings' ? ' [inferred]' : ''; if (event.modelId.startsWith('custom:')) { @@ -37,6 +40,9 @@ function formatModelDisplay(event: TokenUsageEvent): string { return `${event.modelId}${suffix}`; } +/** + * Adds a single event's token usage to an aggregate. + */ function addEventUsage(target: ModelUsage, event: TokenUsageEvent): void { addUsage(target, { inputTokens: event.inputTokens, @@ -47,6 +53,9 @@ function addEventUsage(target: ModelUsage, event: TokenUsageEvent): void { }); } +/** + * Gets an existing model usage aggregate or inserts a new empty one. + */ function getOrCreateModelUsage(map: Map, key: string): ModelUsage { const existing = map.get(key); if (existing != null) { diff --git a/apps/droid/src/pricing.ts b/apps/droid/src/pricing.ts index f896a742..9688f148 100644 --- a/apps/droid/src/pricing.ts +++ b/apps/droid/src/pricing.ts @@ -34,6 +34,9 @@ const EMPTY_PRICING_DATASET: Record = createPricing let prefetchedPricingPromise: Promise> | null = null; +/** + * Loads a prefetched pricing dataset and caches the promise. + */ async function loadPrefetchedFactoryPricing(): Promise> { if (prefetchedPricingPromise == null) { prefetchedPricingPromise = prefetchFactoryPricing(); @@ -41,6 +44,9 @@ async function loadPrefetchedFactoryPricing(): Promise { // When invoked through npx, the binary name might be passed as the first argument // Filter it out if it matches the expected binary name diff --git a/apps/droid/src/session-report.ts b/apps/droid/src/session-report.ts index 45b0e9ba..fa51a231 100644 --- a/apps/droid/src/session-report.ts +++ b/apps/droid/src/session-report.ts @@ -29,6 +29,9 @@ export type SessionReportResult = { missingPricingModels: string[]; }; +/** + * Formats a model identifier for display, including custom/inferred labels. + */ function formatModelDisplay(event: TokenUsageEvent): string { const suffix = event.modelIdSource === 'settings' ? ' [inferred]' : ''; if (event.modelId.startsWith('custom:')) { @@ -39,6 +42,9 @@ function formatModelDisplay(event: TokenUsageEvent): string { return `${event.modelId}${suffix}`; } +/** + * Adds a single event's token usage to an aggregate. + */ function addEventUsage(target: ModelUsage, event: TokenUsageEvent): void { addUsage(target, { inputTokens: event.inputTokens, @@ -49,6 +55,9 @@ function addEventUsage(target: ModelUsage, event: TokenUsageEvent): void { }); } +/** + * Gets an existing model usage aggregate or inserts a new empty one. + */ function getOrCreateModelUsage(map: Map, key: string): ModelUsage { const existing = map.get(key); if (existing != null) { diff --git a/apps/droid/src/token-utils.ts b/apps/droid/src/token-utils.ts index c93a6299..a03babc0 100644 --- a/apps/droid/src/token-utils.ts +++ b/apps/droid/src/token-utils.ts @@ -7,10 +7,16 @@ import type { ModelUsage } from './_types.ts'; +/** + * Coerces unknown values into a non-negative finite number. + */ function ensureNonNegativeNumber(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; } +/** + * Computes `totalTokens` for a usage record. + */ export function toTotalTokens(usage: { inputTokens: number; outputTokens: number; From 9b12dd07174c8fd16fcd01a07b9a8f7fc6b9da39 Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Sun, 11 Jan 2026 01:03:25 +0100 Subject: [PATCH 7/7] chore: sync pnpm lockfile --- pnpm-lock.yaml | 96 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30864304..49b8969f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -493,8 +493,8 @@ importers: specifier: catalog:runtime version: 0.26.3 node: - specifier: runtime:^24.11.0 - version: runtime:24.12.0 + specifier: runtime:>=20.19.4 + version: runtime:25.2.1 path-type: specifier: catalog:runtime version: 6.0.0 @@ -4476,6 +4476,96 @@ packages: version: 24.12.0 hasBin: true + node@runtime:25.2.1: + resolution: + type: variations + variants: + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-/NtF/ktuWCsL7pudQN7rY5H1+nvpWp3yCkptSiqY1dM= + type: binary + url: https://nodejs.org/download/release/v25.2.1/node-v25.2.1-aix-ppc64.tar.gz + targets: + - cpu: ppc64 + os: aix + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-vofiG9I1pFH60CyJ5b98sX4gbkzYndVmTyDRnn395vk= + type: binary + url: https://nodejs.org/download/release/v25.2.1/node-v25.2.1-darwin-arm64.tar.gz + targets: + - cpu: arm64 + os: darwin + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-wmbaWpB1pW4aoCRgzo35b8qeeWw4ir6UqN9JSZRd9rY= + type: binary + url: https://nodejs.org/download/release/v25.2.1/node-v25.2.1-darwin-x64.tar.gz + targets: + - cpu: x64 + os: darwin + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-kFI4oXvprmLBbllgGSaNjKnw/DFCYCofhg3Ep8Hdv4I= + type: binary + url: https://nodejs.org/download/release/v25.2.1/node-v25.2.1-linux-arm64.tar.gz + targets: + - cpu: arm64 + os: linux + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-D/271DVfQCIUI69vBDqTcOG20u+z7CfrThDket/6Qn8= + type: binary + url: https://nodejs.org/download/release/v25.2.1/node-v25.2.1-linux-ppc64le.tar.gz + targets: + - cpu: ppc64le + os: linux + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-uotgbaWipo56krjlfAo6T3J/aP2NPMlst2VhM1OoygQ= + type: binary + url: https://nodejs.org/download/release/v25.2.1/node-v25.2.1-linux-s390x.tar.gz + targets: + - cpu: s390x + os: linux + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-IJTs3IROoR6Xd8rEJnKw2JzWPScgQZOlh9xaLSdruUA= + type: binary + url: https://nodejs.org/download/release/v25.2.1/node-v25.2.1-linux-x64.tar.gz + targets: + - cpu: x64 + os: linux + - resolution: + archive: zip + bin: node.exe + integrity: sha256-4qPtqfq/l5KSdMycu/TzdHQ3ZWMMuxaRoi0BBAsM8jo= + prefix: node-v25.2.1-win-arm64 + type: binary + url: https://nodejs.org/download/release/v25.2.1/node-v25.2.1-win-arm64.zip + targets: + - cpu: arm64 + os: win32 + - resolution: + archive: zip + bin: node.exe + integrity: sha256-+XunXq13IGUvOSXZz4Zh4IOijGuY6nesyDkD13qd1og= + prefix: node-v25.2.1-win-x64 + type: binary + url: https://nodejs.org/download/release/v25.2.1/node-v25.2.1-win-x64.zip + targets: + - cpu: x64 + os: win32 + version: 25.2.1 + hasBin: true + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -9353,6 +9443,8 @@ snapshots: node@runtime:24.12.0: {} + node@runtime:25.2.1: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1