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/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)
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..6ca16076
--- /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": ">=20.19.4",
+ "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..848be18f
--- /dev/null
+++ b/apps/droid/src/_consts.ts
@@ -0,0 +1,15 @@
+/**
+ * @fileoverview Default paths and constants for Factory Droid usage tracking.
+ */
+
+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..ba57bb25
--- /dev/null
+++ b/apps/droid/src/_macro.ts
@@ -0,0 +1,37 @@
+/**
+ * @fileoverview Lightweight helper for prefetching Factory model pricing.
+ */
+
+import type { LiteLLMModelPricing } from '@ccusage/internal/pricing';
+import {
+ createPricingDataset,
+ fetchLiteLLMPricingDataset,
+ filterPricingDataset,
+} from '@ccusage/internal/pricing-fetch-utils';
+import { logger } from './logger.ts';
+
+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) {
+ logger.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..8e6805b8
--- /dev/null
+++ b/apps/droid/src/_shared-args.ts
@@ -0,0 +1,64 @@
+/**
+ * @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',
+ 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..0e903242
--- /dev/null
+++ b/apps/droid/src/_types.ts
@@ -0,0 +1,100 @@
+/**
+ * @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;
+ projectKey: string;
+ modelId: string;
+ modelIdSource: ModelIdSource;
+ pricingModel: string;
+ inputTokens: number;
+ outputTokens: number;
+ thinkingTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ totalTokens: number;
+};
+
+/**
+ * Token usage structure used for aggregation and pricing.
+ */
+export type ModelUsage = {
+ inputTokens: number;
+ outputTokens: number;
+ thinkingTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ 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;
+ outputTokens: number;
+ thinkingTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ totalTokens: number;
+ costUSD: number;
+ modelsUsed: string[];
+};
+
+/**
+ * A single row in the monthly report.
+ */
+export type MonthlyReportRow = {
+ month: string;
+ inputTokens: number;
+ outputTokens: number;
+ thinkingTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ totalTokens: number;
+ costUSD: number;
+ modelsUsed: string[];
+};
+
+/**
+ * A single row in the session report.
+ */
+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..1099f501
--- /dev/null
+++ b/apps/droid/src/commands/daily.ts
@@ -0,0 +1,198 @@
+/**
+ * @fileoverview `daily` command for Factory Droid usage.
+ */
+
+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;
+
+/**
+ * Logs a short warning for models that could not be priced (treated as $0).
+ */
+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..636a7c08
--- /dev/null
+++ b/apps/droid/src/commands/monthly.ts
@@ -0,0 +1,196 @@
+/**
+ * @fileoverview `monthly` command for Factory Droid usage.
+ */
+
+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;
+
+/**
+ * Logs a short warning for models that could not be priced (treated as $0).
+ */
+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..b0fe2007
--- /dev/null
+++ b/apps/droid/src/commands/session.ts
@@ -0,0 +1,231 @@
+/**
+ * @fileoverview `session` command for Factory Droid usage.
+ */
+
+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;
+
+/**
+ * Logs a short warning for models that could not be priced (treated as $0).
+ */
+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..eec0ea30
--- /dev/null
+++ b/apps/droid/src/daily-report.ts
@@ -0,0 +1,202 @@
+/**
+ * @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';
+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[];
+};
+
+/**
+ * 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:')) {
+ const base = event.pricingModel.trim() !== '' ? event.pricingModel : event.modelId;
+ return `${base} [custom]${suffix}`;
+ }
+
+ 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,
+ outputTokens: event.outputTokens,
+ thinkingTokens: event.thinkingTokens,
+ cacheReadTokens: event.cacheReadTokens,
+ cacheCreationTokens: event.cacheCreationTokens,
+ });
+}
+
+/**
+ * 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) {
+ return existing;
+ }
+ const created = createEmptyUsage();
+ map.set(key, created);
+ 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,
+): 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..18ad1f98
--- /dev/null
+++ b/apps/droid/src/data-loader.ts
@@ -0,0 +1,548 @@
+/**
+ * @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';
+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';
+
+/**
+ * Normalizes unknown errors into `Error` instances.
+ */
+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()),
+});
+
+/**
+ * Returns a trimmed string if it is non-empty, otherwise `undefined`.
+ */
+function asNonEmptyString(value: unknown): string | undefined {
+ if (typeof value !== 'string') {
+ return undefined;
+ }
+
+ const trimmed = value.trim();
+ 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;
+ }
+ const end = line.indexOf(']');
+ if (end === -1) {
+ return undefined;
+ }
+ const raw = line.slice(1, end).trim();
+ 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);
+ 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;
+};
+
+/**
+ * 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,
+): 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;
+}
+
+/**
+ * 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;
+ }
+
+ 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,
+ },
+ };
+}
+
+/**
+ * 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 {
+ 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..69837044
--- /dev/null
+++ b/apps/droid/src/date-utils.ts
@@ -0,0 +1,138 @@
+/**
+ * @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';
+ }
+
+ try {
+ Intl.DateTimeFormat('en-US', { timeZone: timezone });
+ return timezone;
+ } catch {
+ return 'UTC';
+ }
+}
+
+/**
+ * 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);
+ const formatter = new Intl.DateTimeFormat('en-CA', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ timeZone: tz,
+ });
+ 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);
+ const formatter = new Intl.DateTimeFormat('en-CA', {
+ year: 'numeric',
+ month: '2-digit',
+ timeZone: tz,
+ });
+ const [year, month] = formatter.format(date).split('-');
+ 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;
+ }
+
+ 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)}`;
+}
+
+/**
+ * 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('-', '');
+ const untilValue = until?.replaceAll('-', '');
+
+ if (sinceValue != null && value < sinceValue) {
+ return false;
+ }
+
+ if (untilValue != null && value > untilValue) {
+ return false;
+ }
+
+ 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);
+ 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);
+}
+
+/**
+ * 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);
+ 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);
+}
+
+/**
+ * Formats an ISO timestamp for display in a given timezone.
+ */
+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..9a20c63e
--- /dev/null
+++ b/apps/droid/src/factory-settings.ts
@@ -0,0 +1,115 @@
+/**
+ * @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';
+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';
+
+/**
+ * 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;
+}
+
+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;
+
+/**
+ * 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