diff --git a/config.sample.toml b/config.sample.toml index 770019d..02da10c 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -16,6 +16,13 @@ projectsDir = "~/.claude/projects" [codex] home = "~/.codex" +[codex.billing] +# Billing mode for Codex CLI usage. +# "subscription" for ChatGPT Plus/Team/Enterprise plans, "api" for pay-per-token, "estimate" (default) +# defaultMode = "subscription" +# monthlyCost = 200.00 +# plan = "pro" + [cursor.dashboard] # Auth is auto-extracted from Cursor's state.vscdb - no manual config needed! # Override only if auto-detection doesn't work: @@ -51,6 +58,11 @@ etagTtlMinutes = 15 # Defaults to /openclaw-sessions # sessionsDir = "~/Library/Application Support/thinktax/data/openclaw-sessions" +[yabaiOrganize] +# Directory containing usage.jsonl +# Defaults to ~/.local/share/yabai-organize +# usageDir = "~/.local/share/yabai-organize" + [[projects.mappings]] match.instanceId = "claude-instance-folder" id = "project-foo" diff --git a/scripts/dump-cursor-kinds.ts b/scripts/dump-cursor-kinds.ts new file mode 100644 index 0000000..86709c0 --- /dev/null +++ b/scripts/dump-cursor-kinds.ts @@ -0,0 +1,175 @@ +#!/usr/bin/env npx tsx +/** + * Dump distinct `kind` (and model) values from Cursor's Dashboard API. + * Run: npx tsx scripts/dump-cursor-kinds.ts + */ + +import { execSync } from "node:child_process"; +import fs from "node:fs"; + +const DB_PATH = + process.platform === "darwin" + ? `${process.env.HOME}/Library/Application Support/Cursor/User/globalStorage/state.vscdb` + : `${process.env.HOME}/.config/Cursor/User/globalStorage/state.vscdb`; + +const DASHBOARD_API = + "https://cursor.com/api/dashboard/get-filtered-usage-events"; + +async function main() { + // ── 1. Extract auth from state.vscdb ────────────────────────────────── + if (!fs.existsSync(DB_PATH)) { + console.error("state.vscdb not found at", DB_PATH); + process.exit(1); + } + + const accessToken = execSync( + `sqlite3 "${DB_PATH}" "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'"`, + { encoding: "utf8", timeout: 5000 } + ).trim(); + + if (!accessToken) { + console.error("No access token found in state.vscdb"); + process.exit(1); + } + + const payloadB64 = accessToken.split(".")[1]; + const payload = JSON.parse(Buffer.from(payloadB64, "base64").toString()); + const userId = payload.sub?.replace("auth0|", ""); + const sessionToken = `${userId}::${accessToken}`; + + // ── 2. Fetch team ID ────────────────────────────────────────────────── + const profileRes = await fetch( + "https://api2.cursor.sh/auth/full_stripe_profile", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + if (!profileRes.ok) { + console.error("Profile API failed:", profileRes.status); + process.exit(1); + } + const profile = (await profileRes.json()) as { teamId?: number }; + const teamId = profile.teamId; + if (!teamId) { + console.error("No teamId in profile response"); + process.exit(1); + } + + console.log(`Team ID: ${teamId}\n`); + + // ── 3. Fetch dashboard events (last 30 days, paginated) ────────────── + const endDate = Date.now(); + const startDate = endDate - 30 * 24 * 60 * 60 * 1000; + const pageSize = 500; + + interface DashboardEvent { + timestamp: string; + model: string; + kind: string; + tokenUsage?: { + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; + totalCents: number; + }; + owningUser?: string; + [key: string]: unknown; + } + + const allEvents: DashboardEvent[] = []; + let page = 1; + + while (true) { + const res = await fetch(DASHBOARD_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://cursor.com", + Referer: "https://cursor.com/dashboard?tab=usage", + Cookie: `WorkosCursorSessionToken=${encodeURIComponent(sessionToken)}; team_id=${teamId}`, + }, + body: JSON.stringify({ + teamId, + startDate: String(startDate), + endDate: String(endDate), + page, + pageSize, + }), + }); + + if (!res.ok) { + console.error("Dashboard API error:", res.status, await res.text()); + break; + } + + const data = (await res.json()) as { + totalUsageEventsCount: number; + usageEventsDisplay: DashboardEvent[]; + }; + const events = data.usageEventsDisplay ?? []; + allEvents.push(...events); + + console.log( + `Page ${page}: ${events.length} events (${allEvents.length}/${data.totalUsageEventsCount} total)` + ); + + if (events.length < pageSize || allEvents.length >= data.totalUsageEventsCount) break; + if (++page > 100) break; + } + + console.log(`\nFetched ${allEvents.length} events total\n`); + + // ── 4. Aggregate by kind ────────────────────────────────────────────── + const byKind = new Map; extraKeys: Set }>(); + + for (const ev of allEvents) { + const kind = ev.kind ?? "(null)"; + let entry = byKind.get(kind); + if (!entry) { + entry = { count: 0, models: new Map(), extraKeys: new Set() }; + byKind.set(kind, entry); + } + entry.count++; + const model = ev.model ?? "(null)"; + entry.models.set(model, (entry.models.get(model) ?? 0) + 1); + + // Capture any non-standard keys for inspection + for (const key of Object.keys(ev)) { + if (!["timestamp", "model", "kind", "tokenUsage", "requestsCosts", "usageBasedCosts", "owningUser", "owningTeam"].includes(key)) { + entry.extraKeys.add(key); + } + } + } + + // ── 5. Print results ───────────────────────────────────────────────── + const sorted = [...byKind.entries()].sort((a, b) => b[1].count - a[1].count); + + console.log("═══ Distinct `kind` values ═══\n"); + for (const [kind, { count, models, extraKeys }] of sorted) { + console.log(` ${kind} (${count} events)`); + const sortedModels = [...models.entries()].sort((a, b) => b[1] - a[1]); + for (const [model, n] of sortedModels) { + console.log(` ├─ model: ${model} (${n})`); + } + if (extraKeys.size > 0) { + console.log(` └─ extra keys: ${[...extraKeys].join(", ")}`); + } + console.log(); + } + + // ── 6. Print one sample event per kind for full schema inspection ──── + console.log("═══ Sample event per kind ═══\n"); + const seen = new Set(); + for (const ev of allEvents) { + const kind = ev.kind ?? "(null)"; + if (seen.has(kind)) continue; + seen.add(kind); + console.log(`── ${kind} ──`); + console.log(JSON.stringify(ev, null, 2)); + console.log(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/dump-cursor-recent.ts b/scripts/dump-cursor-recent.ts new file mode 100644 index 0000000..51f5964 --- /dev/null +++ b/scripts/dump-cursor-recent.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env npx tsx +/** + * Dump the 20 most recent Cursor Dashboard events with all fields. + * Run: npx tsx scripts/dump-cursor-recent.ts + */ + +import { execSync } from "node:child_process"; +import fs from "node:fs"; + +const DB_PATH = + process.platform === "darwin" + ? `${process.env.HOME}/Library/Application Support/Cursor/User/globalStorage/state.vscdb` + : `${process.env.HOME}/.config/Cursor/User/globalStorage/state.vscdb`; + +const DASHBOARD_API = + "https://cursor.com/api/dashboard/get-filtered-usage-events"; + +async function main() { + const accessToken = execSync( + `sqlite3 "${DB_PATH}" "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'"`, + { encoding: "utf8", timeout: 5000 } + ).trim(); + + const payloadB64 = accessToken.split(".")[1]; + const payload = JSON.parse(Buffer.from(payloadB64, "base64").toString()); + const userId = payload.sub?.replace("auth0|", ""); + const sessionToken = `${userId}::${accessToken}`; + + const profileRes = await fetch( + "https://api2.cursor.sh/auth/full_stripe_profile", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const profile = (await profileRes.json()) as { teamId?: number }; + const teamId = profile.teamId!; + + // Fetch just page 1 (most recent events) + const endDate = Date.now(); + const startDate = endDate - 30 * 24 * 60 * 60 * 1000; + + const res = await fetch(DASHBOARD_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://cursor.com", + Referer: "https://cursor.com/dashboard?tab=usage", + Cookie: `WorkosCursorSessionToken=${encodeURIComponent(sessionToken)}; team_id=${teamId}`, + }, + body: JSON.stringify({ + teamId, + startDate: String(startDate), + endDate: String(endDate), + page: 1, + pageSize: 20, + }), + }); + + const data = (await res.json()) as { + totalUsageEventsCount: number; + usageEventsDisplay: any[]; + }; + + console.log(`Total events: ${data.totalUsageEventsCount}\n`); + console.log(`═══ 20 most recent events ═══\n`); + + for (const ev of data.usageEventsDisplay) { + const ts = new Date(parseInt(ev.timestamp, 10)).toISOString(); + console.log(`${ts} model=${ev.model} kind=${ev.kind} headless=${ev.isHeadless} maxMode=${ev.maxMode ?? "-"}`); + // Print all keys that aren't in the standard set + const extras: Record = {}; + for (const [k, v] of Object.entries(ev)) { + if (!["timestamp", "model", "kind", "tokenUsage", "requestsCosts", "usageBasedCosts", "owningUser", "owningTeam", "isHeadless", "maxMode", "isTokenBasedCall", "cursorTokenFee", "isChargeable"].includes(k)) { + extras[k] = v; + } + } + if (Object.keys(extras).length > 0) { + console.log(` extras: ${JSON.stringify(extras)}`); + } + console.log(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/cli.ts b/src/cli.ts index cc52183..4f5a463 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,9 +13,12 @@ import { collectOpenClaw } from "./collectors/openclaw.js"; import { collectApprentice } from "./collectors/apprentice.js"; import { collectGlean } from "./collectors/glean.js"; import { collectReviewCrew } from "./collectors/review-crew.js"; +import { collectYabaiOrganize } from "./collectors/yabai-organize.js"; import { loadConfig, resolveTimezone, resolveBillingSessionsFile } from "./core/config.js"; +import { applyBillingMetadata } from "./core/billing-metadata.js"; +import type { BillingConfidence, BillingMode, BillingSource } from "./core/billing-metadata.js"; import { applyCosting } from "./core/cost.js"; -import { loadSummaries, loadEventsForRange, aggregateEvents } from "./core/aggregate.js"; +import { loadSummaries, loadEventsForRange, aggregateEvents, isWorkSource } from "./core/aggregate.js"; import { getPaths, ensurePaths } from "./core/paths.js"; import { loadPricingTable } from "./core/pricing.js"; import { readSyncState, writeSyncState } from "./core/state.js"; @@ -31,6 +34,19 @@ function isBreakdownKey(value: string): value is BreakdownKey { return ["provider", "project", "model", "source", "billing"].includes(value); } +function billingMetaMatches( + event: UsageEvent, + mode: BillingMode, + source: BillingSource, + confidence: BillingConfidence +): boolean { + return ( + event.meta?.billing === mode && + event.meta?.billing_source === source && + event.meta?.billing_confidence === confidence + ); +} + const program = new Command(); program @@ -64,7 +80,7 @@ program const includeUnknown = config.ui?.includeUnknown ?? false; debug("Starting collectors..."); - const [claudeEvents, codexEvents, cursorEvents, openclawEvents, apprenticeEvents, gleanEvents, reviewCrewEvents] = await Promise.all([ + const [claudeEvents, codexEvents, cursorEvents, openclawEvents, apprenticeEvents, gleanEvents, reviewCrewEvents, yabaiOrganizeEvents] = await Promise.all([ collectClaude(config).then((events) => { debug("Claude collector returned", events.length, "events"); return events; @@ -93,9 +109,13 @@ program debug("ReviewCrew collector returned", events.length, "events"); return events; }), + collectYabaiOrganize(config).then((events) => { + debug("YabaiOrganize collector returned", events.length, "events"); + return events; + }), ]); - const rawEvents = [...claudeEvents, ...codexEvents, ...cursorEvents, ...openclawEvents, ...apprenticeEvents, ...gleanEvents, ...reviewCrewEvents]; + const rawEvents = [...claudeEvents, ...codexEvents, ...cursorEvents, ...openclawEvents, ...apprenticeEvents, ...gleanEvents, ...reviewCrewEvents, ...yabaiOrganizeEvents]; debug("Total raw events:", rawEvents.length); const costed = rawEvents.map((event) => @@ -116,6 +136,7 @@ program apprentice: new Date().toISOString(), glean: new Date().toISOString(), reviewCrew: new Date().toISOString(), + yabaiOrganize: new Date().toISOString(), }; sync.counts = { ...(sync.counts ?? {}), @@ -126,11 +147,12 @@ program apprentice: apprenticeEvents.length, glean: gleanEvents.length, reviewCrew: reviewCrewEvents.length, + yabaiOrganize: yabaiOrganizeEvents.length, }; writeSyncState(sync); console.log( - `Collected ${rawEvents.length} events (${written} new). Claude ${claudeEvents.length}, Codex ${codexEvents.length}, Cursor ${cursorEvents.length}, OpenClaw ${openclawEvents.length}, Apprentice ${apprenticeEvents.length}, Glean ${gleanEvents.length}, ReviewCrew ${reviewCrewEvents.length}.` + `Collected ${rawEvents.length} events (${written} new). Claude ${claudeEvents.length}, Codex ${codexEvents.length}, Cursor ${cursorEvents.length}, OpenClaw ${openclawEvents.length}, Apprentice ${apprenticeEvents.length}, Glean ${gleanEvents.length}, ReviewCrew ${reviewCrewEvents.length}, YabaiOrganize ${yabaiOrganizeEvents.length}.` ); }); @@ -143,13 +165,18 @@ program .option("--mtd", "only show month-to-date") .option("--ytd", "only show year-to-date") .option("--all", "only show all-time") + .option("--work", "only include work sources (excludes OpenClaw etc.)") .action(async (cmd) => { const options = program.opts(); const { config } = loadConfig(options.config); const timezone = options.timezone ?? resolveTimezone(config); const now = DateTime.now().setZone(timezone); + const workFilter = cmd.work + ? (e: UsageEvent) => isWorkSource(e.source) + : undefined; - const summaries = await loadSummaries(timezone, now); + const summaries = await loadSummaries(timezone, now, workFilter); + const label = cmd.work ? " (work)" : ""; if (cmd.json) { const payload = { @@ -173,7 +200,7 @@ program typeof cmd.breakdown === "string" ? cmd.breakdown : null; if (showToday) { - console.log(formatTotalsLine("Today", summaries.today.totals)); + console.log(formatTotalsLine(`Today${label}`, summaries.today.totals)); if (breakdownKey && isBreakdownKey(breakdownKey)) { const lines = formatBreakdown( summaries.today.breakdowns[breakdownKey] ?? {}, @@ -184,7 +211,7 @@ program } if (showMtd) { - console.log(formatTotalsLine("MTD", summaries.mtd.totals)); + console.log(formatTotalsLine(`MTD${label}`, summaries.mtd.totals)); if (breakdownKey && isBreakdownKey(breakdownKey)) { const lines = formatBreakdown( summaries.mtd.breakdowns[breakdownKey] ?? {}, @@ -195,7 +222,7 @@ program } if (showYtd) { - console.log(formatTotalsLine("YTD", summaries.ytd.totals)); + console.log(formatTotalsLine(`YTD${label}`, summaries.ytd.totals)); if (breakdownKey && isBreakdownKey(breakdownKey)) { const lines = formatBreakdown( summaries.ytd.breakdowns[breakdownKey] ?? {}, @@ -206,7 +233,7 @@ program } if (showAll) { - console.log(formatTotalsLine("All Time", summaries.all.totals)); + console.log(formatTotalsLine(`All Time${label}`, summaries.all.totals)); if (breakdownKey && isBreakdownKey(breakdownKey)) { const lines = formatBreakdown( summaries.all.breakdowns[breakdownKey] ?? {}, @@ -221,12 +248,16 @@ program .command("sketchybar") .description("Output Sketchybar payload") .option("--format ", "plain|json", "plain") + .option("--work", "only include work sources (excludes OpenClaw etc.)") .action(async (cmd) => { const options = program.opts(); const { config } = loadConfig(options.config); const timezone = options.timezone ?? resolveTimezone(config); const now = DateTime.now().setZone(timezone); - const { today, mtd } = await loadSummaries(timezone, now); + const workFilter = cmd.work + ? (e: UsageEvent) => isWorkSource(e.source) + : undefined; + const { today, mtd } = await loadSummaries(timezone, now, workFilter); const todayProvider = today.breakdowns.provider; const todayCursor = todayProvider.cursor?.final_usd ?? 0; @@ -291,12 +322,17 @@ program .option("--mtd", "only show month-to-date") .option("--ytd", "only show year-to-date") .option("--all", "only show all-time") + .option("--work", "only include work sources (excludes OpenClaw etc.)") .action(async (cmd) => { const options = program.opts(); const { config } = loadConfig(options.config); const timezone = options.timezone ?? resolveTimezone(config); const now = DateTime.now().setZone(timezone); - const summaries = await loadSummaries(timezone, now); + const workFilter = cmd.work + ? (e: UsageEvent) => isWorkSource(e.source) + : undefined; + const summaries = await loadSummaries(timezone, now, workFilter); + const label = cmd.work ? " (work)" : ""; if (cmd.format === "json") { console.log(JSON.stringify(summaries, null, 2)); @@ -311,28 +347,28 @@ program const showAll = explicit ? cmd.all : false; if (showToday) { - console.log(formatTotalsLine("Today", summaries.today.totals)); + console.log(formatTotalsLine(`Today${label}`, summaries.today.totals)); formatBreakdown(summaries.today.breakdowns.provider).forEach((line) => console.log(` ${line}`) ); } if (showMtd) { - console.log(formatTotalsLine("MTD", summaries.mtd.totals)); + console.log(formatTotalsLine(`MTD${label}`, summaries.mtd.totals)); formatBreakdown(summaries.mtd.breakdowns.provider).forEach((line) => console.log(` ${line}`) ); } if (showYtd) { - console.log(formatTotalsLine("YTD", summaries.ytd.totals)); + console.log(formatTotalsLine(`YTD${label}`, summaries.ytd.totals)); formatBreakdown(summaries.ytd.breakdowns.provider).forEach((line) => console.log(` ${line}`) ); } if (showAll) { - console.log(formatTotalsLine("All Time", summaries.all.totals)); + console.log(formatTotalsLine(`All Time${label}`, summaries.all.totals)); formatBreakdown(summaries.all.breakdowns.provider).forEach((line) => console.log(` ${line}`) ); @@ -389,10 +425,10 @@ program // Load billing registry for Claude Code sessions const billingFile = resolveBillingSessionsFile(); const billingEntries = await readJsonl<{ session_id: string; billing: string }>(billingFile); - const billingRegistry = new Map(); + const billingRegistry = new Map(); for (const entry of billingEntries) { if (entry.session_id && entry.billing) { - billingRegistry.set(entry.session_id, entry.billing); + billingRegistry.set(entry.session_id, entry.billing as BillingMode); } } const defaultBilling = config.claude?.billing?.defaultMode ?? "estimate"; @@ -415,9 +451,30 @@ program if (event.source === "claude_code") { const filePath = (event.meta?.file as string) ?? ""; const sessionId = path.basename(filePath, ".jsonl"); - const billing = billingRegistry.get(sessionId) ?? defaultBilling; - if (event.meta?.billing !== billing) { - event.meta = { ...event.meta, billing }; + const registryBilling = billingRegistry.get(sessionId); + const billing = registryBilling ?? defaultBilling; + const billingSource: BillingSource = registryBilling ? "session_registry" : "config_default"; + const billingConfidence: BillingConfidence = registryBilling ? "high" : "default"; + if (!billingMetaMatches(event, billing, billingSource, billingConfidence)) { + event.meta = applyBillingMetadata(event, { + mode: billing, + source: billingSource, + confidence: billingConfidence, + }).meta; + billingTagged++; + updated = true; + } + } + + // Apply billing tag to Codex events + if (event.source === "codex_cli") { + const codexBilling = config.codex?.billing?.defaultMode ?? "estimate"; + if (!billingMetaMatches(event, codexBilling, "config_default", "default")) { + event.meta = applyBillingMetadata(event, { + mode: codexBilling, + source: "config_default", + confidence: "default", + }).meta; billingTagged++; updated = true; } @@ -426,8 +483,12 @@ program // Apply billing tag to OpenClaw events if (event.source === "openclaw") { const openclawBilling = config.openclaw?.billing?.defaultMode ?? "estimate"; - if (event.meta?.billing !== openclawBilling) { - event.meta = { ...event.meta, billing: openclawBilling }; + if (!billingMetaMatches(event, openclawBilling, "config_default", "default")) { + event.meta = applyBillingMetadata(event, { + mode: openclawBilling, + source: "config_default", + confidence: "default", + }).meta; billingTagged++; updated = true; } @@ -485,6 +546,7 @@ program .option("--sparkline", "compact sparkline output") .option("--image [path]", "generate PNG image (default: /tmp/thinktax-graph.png)") .option("--open", "open generated image (macOS)") + .option("--work", "only include work sources (excludes OpenClaw etc.)") .action(async (cmd) => { const options = program.opts(); const { config } = loadConfig(options.config); @@ -496,7 +558,12 @@ program const providerFilter = cmd.provider as UsageProvider | undefined; const startDate = now.minus({ days: days - 1 }).startOf("day"); - const events = await loadEventsForRange(timezone, startDate, now); + let events = await loadEventsForRange(timezone, startDate, now); + + // Filter to work sources if --work flag is set + if (cmd.work) { + events = events.filter((e) => isWorkSource(e.source)); + } // Filter by provider if specified const filtered = providerFilter diff --git a/src/cli/utils.ts b/src/cli/utils.ts index d1c2556..773ec67 100644 --- a/src/cli/utils.ts +++ b/src/cli/utils.ts @@ -6,10 +6,20 @@ export function formatUsd(value: number | null | undefined): string { } export function formatTotalsLine(label: string, totals: Totals): string { - let line = `${label}: ${formatUsd(totals.final_usd)} (in ${totals.tokens_in}, out ${totals.tokens_out})`; + const uncertaintyPrefix = totals.lower_confidence_billing_count > 0 ? "~" : ""; + let line = `${label}: ${uncertaintyPrefix}${formatUsd(totals.final_usd)} (in ${totals.tokens_in}, out ${totals.tokens_out})`; if (totals.subscription_count > 0 && totals.subscription_saved_usd > 0) { line += ` [plan saved ${formatUsd(totals.subscription_saved_usd)}]`; } + if (totals.default_billing_count > 0) { + line += ` [${totals.default_billing_count} default-billed]`; + } + if (totals.mixed_billing_count > 0) { + line += ` [${totals.mixed_billing_count} mixed-billing]`; + } + if (totals.unknown_billing_count > 0) { + line += ` [${totals.unknown_billing_count} unknown-billing]`; + } return line; } diff --git a/src/collectors/codex.ts b/src/collectors/codex.ts index 1ab6a35..e936365 100644 --- a/src/collectors/codex.ts +++ b/src/collectors/codex.ts @@ -114,7 +114,8 @@ export async function collectCodex( ): Promise { const codexHome = resolveCodexHome(config); const sessionsDir = path.join(codexHome, "sessions"); - debug("Codex: scanning", sessionsDir); + const billing = config.codex?.billing?.defaultMode ?? "estimate"; + debug("Codex: scanning", sessionsDir, "billing:", billing); const pattern = path.join(sessionsDir, "**/*.jsonl").replace(/\\/g, "/"); const files = await fg(pattern, { onlyFiles: true, dot: true }); @@ -223,6 +224,7 @@ export async function collectCodex( meta: { file: filePath, session: instanceId, + billing, }, }; diff --git a/src/collectors/yabai-organize.ts b/src/collectors/yabai-organize.ts new file mode 100644 index 0000000..bb64ff0 --- /dev/null +++ b/src/collectors/yabai-organize.ts @@ -0,0 +1,96 @@ +import path from "node:path"; +import { + UsageEvent, + UsageProvider, + emptyCost, + createEventId, + readJsonl, +} from "../core/events.js"; +import { debug } from "../core/logger.js"; +import { resolveYabaiOrganizeUsageDir } from "../core/config.js"; +import type { ThinktaxConfig } from "../core/config.js"; + +interface YabaiOrganizeEntry { + model: string; + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + ts: string; + tool: string; +} + +function inferProvider(model: string): UsageProvider { + const m = model.toLowerCase(); + if (m.startsWith("claude")) return "anthropic"; + if (m.startsWith("gpt") || m.startsWith("o1") || m.startsWith("o3")) + return "openai"; + if (m.startsWith("moonshot")) return "moonshot"; + return "anthropic"; +} + +export async function collectYabaiOrganize( + config: ThinktaxConfig +): Promise { + const usageDir = resolveYabaiOrganizeUsageDir(config); + const filePath = path.join(usageDir, "usage.jsonl"); + debug("YabaiOrganize: reading", filePath); + + const entries = await readJsonl(filePath); + debug("YabaiOrganize:", entries.length, "entries"); + + const events: UsageEvent[] = []; + + for (const entry of entries) { + if (!entry.ts || !entry.model) continue; + + const inTok = entry.input_tokens ?? 0; + const outTok = entry.output_tokens ?? 0; + const cacheWrite = entry.cache_creation_input_tokens ?? 0; + const cacheRead = entry.cache_read_input_tokens ?? 0; + + if (inTok === 0 && outTok === 0 && cacheWrite === 0 && cacheRead === 0) { + continue; + } + + const event: UsageEvent = { + id: createEventId({ + source: "yabai_organize", + ts: entry.ts, + model: entry.model, + tokens: { + in: inTok, + out: outTok, + cache_write: cacheWrite, + cache_read: cacheRead, + }, + }), + ts: entry.ts, + source: "yabai_organize", + provider: inferProvider(entry.model), + model: entry.model, + tokens: { + in: inTok, + out: outTok, + cache_write: cacheWrite, + cache_read: cacheRead, + }, + cost: emptyCost(), + project: { + id: "yabai-organize", + name: "Yabai Organize", + root: null, + }, + meta: { + file: filePath, + tool: entry.tool ?? "yabai-organize", + billing: "api", + }, + }; + + events.push(event); + } + + debug("YabaiOrganize:", events.length, "events"); + return events; +} diff --git a/src/core/aggregate.ts b/src/core/aggregate.ts index 0543d67..5abc89e 100644 --- a/src/core/aggregate.ts +++ b/src/core/aggregate.ts @@ -1,9 +1,25 @@ import fs from "node:fs"; import path from "node:path"; import { DateTime } from "luxon"; -import { readJsonl, UsageEvent, UsageProvider } from "./events.js"; +import { readJsonl, UsageEvent, UsageProvider, UsageSource } from "./events.js"; import { getPaths } from "./paths.js"; +/** Sources that count as "work" for the --work filter. */ +const WORK_SOURCES: ReadonlySet = new Set([ + "claude_code", + "cursor_ide", + "cursor_agent_cli", + "codex_cli", + "apprentice", + "glean", + "review_crew", + "yabai_organize", +]); + +export function isWorkSource(source: UsageSource): boolean { + return WORK_SOURCES.has(source); +} + export interface Totals { count: number; tokens_in: number; @@ -16,6 +32,10 @@ export interface Totals { unknown_cost: number; subscription_count: number; subscription_saved_usd: number; + default_billing_count: number; + lower_confidence_billing_count: number; + mixed_billing_count: number; + unknown_billing_count: number; } export interface SummaryBreakdowns { @@ -47,6 +67,10 @@ export function emptyTotals(): Totals { unknown_cost: 0, subscription_count: 0, subscription_saved_usd: 0, + default_billing_count: 0, + lower_confidence_billing_count: 0, + mixed_billing_count: 0, + unknown_billing_count: 0, }; } @@ -66,8 +90,34 @@ function addTotals(target: Totals, event: UsageEvent): void { target.subscription_count += 1; target.subscription_saved_usd += event.cost.estimated_usd ?? 0; } + + const billingConfidence = event.meta?.billing_confidence; + const billingMode = event.meta?.billing; + if (billingConfidence === "default") { + target.default_billing_count += 1; + } + if (billingMode === "mixed") { + target.mixed_billing_count += 1; + } + if (billingMode === "unknown") { + target.unknown_billing_count += 1; + } + if ( + billingConfidence === "default" || + billingConfidence === "low" || + billingConfidence === "unknown" || + billingMode === "mixed" || + billingMode === "unknown" + ) { + target.lower_confidence_billing_count += 1; + } } +/** Display names for source breakdown labels. */ +const SOURCE_DISPLAY: Record = { + openclaw: "sedge", +}; + function bucketKey(value: string | null, fallback: string): string { return value && value.length > 0 ? value : fallback; } @@ -102,7 +152,7 @@ export function aggregateEvents( if (eventTime < from || eventTime > to) continue; addTotals(totals, event); addBreakdown(breakdowns.provider, event.provider, event); - addBreakdown(breakdowns.source, event.source, event); + addBreakdown(breakdowns.source, SOURCE_DISPLAY[event.source] ?? event.source, event); addBreakdown(breakdowns.model, bucketKey(event.model, "unknown"), event); addBreakdown( breakdowns.project, @@ -188,13 +238,15 @@ export async function loadAllEvents( export async function loadSummaries( timezone: string, - now: DateTime + now: DateTime, + filter?: (event: UsageEvent) => boolean ): Promise<{ today: Summary; mtd: Summary; ytd: Summary; all: Summary }> { const startOfDay = now.setZone(timezone).startOf("day"); const startOfMonth = now.setZone(timezone).startOf("month"); const startOfYear = now.setZone(timezone).startOf("year"); - const { events, earliest } = await loadAllEvents(timezone, now); + const { events: rawEvents, earliest } = await loadAllEvents(timezone, now); + const events = filter ? rawEvents.filter(filter) : rawEvents; const today = aggregateEvents(events, timezone, startOfDay, now); const mtd = aggregateEvents(events, timezone, startOfMonth, now); const ytd = aggregateEvents(events, timezone, startOfYear, now); diff --git a/src/core/billing-metadata.ts b/src/core/billing-metadata.ts new file mode 100644 index 0000000..cf3286f --- /dev/null +++ b/src/core/billing-metadata.ts @@ -0,0 +1,31 @@ +import { UsageEvent } from "./events.js"; + +export type BillingMode = "subscription" | "api" | "estimate" | "unknown" | "mixed"; +export type BillingSource = + | "session_registry" + | "config_default" + | "collector" + | "manual_override" + | "unknown"; +export type BillingConfidence = "high" | "default" | "low" | "unknown"; + +export interface BillingMetadataInput { + mode: BillingMode; + source: BillingSource; + confidence: BillingConfidence; +} + +export function applyBillingMetadata( + event: UsageEvent, + billing: BillingMetadataInput +): UsageEvent { + return { + ...event, + meta: { + ...event.meta, + billing: billing.mode, + billing_source: billing.source, + billing_confidence: billing.confidence, + }, + }; +} diff --git a/src/core/config.ts b/src/core/config.ts index f46b54d..740a53f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -31,6 +31,11 @@ export interface ThinktaxConfig { }; codex?: { home?: string; + billing?: { + defaultMode?: "subscription" | "api" | "estimate"; + monthlyCost?: number; + plan?: string; + }; }; cursor?: { dashboard?: { @@ -86,6 +91,9 @@ export interface ThinktaxConfig { reviewCrew?: { historyDir?: string; }; + yabaiOrganize?: { + usageDir?: string; + }; projects?: { mappings?: ProjectMapping[]; }; @@ -172,6 +180,11 @@ export function resolveGleanUsageDir(config: ThinktaxConfig): string { return path.join(stateBase, "glean", "usage"); } +export function resolveYabaiOrganizeUsageDir(config: ThinktaxConfig): string { + if (config.yabaiOrganize?.usageDir) return config.yabaiOrganize.usageDir; + return path.join(process.env.HOME ?? "", ".local", "share", "yabai-organize"); +} + export function resolveBillingSessionsFile(): string { const xdgConfig = process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? "", ".config"); return path.join(xdgConfig, "thinktax", "billing-sessions.jsonl"); diff --git a/src/core/events.ts b/src/core/events.ts index a17308e..d9aba5f 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -11,7 +11,8 @@ export type UsageSource = | "openclaw" | "apprentice" | "glean" - | "review_crew"; + | "review_crew" + | "yabai_organize"; export type UsageProvider = "cursor" | "anthropic" | "openai" | "moonshot"; diff --git a/tests/aggregation.test.ts b/tests/aggregation.test.ts index 133b4b2..f165b4e 100644 --- a/tests/aggregation.test.ts +++ b/tests/aggregation.test.ts @@ -35,4 +35,61 @@ describe("aggregation boundaries", () => { expect(summary.totals.count).toBe(1); expect(summary.totals.final_usd).toBeCloseTo(2); }); + + it("surfaces lower-confidence billing totals instead of hiding them in the final spend", () => { + const tz = "America/Los_Angeles"; + const now = DateTime.fromISO("2026-02-02T10:00:00", { zone: tz }); + const start = now.startOf("day"); + const registryEvent = buildEvent(now.toISO() ?? "", 1); + registryEvent.meta = { + billing: "api", + billing_source: "session_registry", + billing_confidence: "high", + }; + const defaultEvent = buildEvent(now.plus({ minutes: 1 }).toISO() ?? "", 2); + defaultEvent.meta = { + billing: "subscription", + billing_source: "config_default", + billing_confidence: "default", + }; + defaultEvent.cost = { + ...emptyCost(), + estimated_usd: 2, + final_usd: 0, + mode: "subscription", + }; + + const summary = aggregateEvents([registryEvent, defaultEvent], tz, start, now.plus({ minutes: 2 })); + + expect(summary.totals.default_billing_count).toBe(1); + expect(summary.totals.lower_confidence_billing_count).toBe(1); + expect(summary.breakdowns.billing.subscription.default_billing_count).toBe(1); + }); + + it("tracks mixed and unknown billing windows as uncertainty instead of subscription certainty", () => { + const tz = "America/Los_Angeles"; + const now = DateTime.fromISO("2026-02-02T10:00:00", { zone: tz }); + const start = now.startOf("day"); + const mixedEvent = buildEvent(now.toISO() ?? "", 3); + mixedEvent.meta = { + billing: "mixed", + billing_source: "manual_override", + billing_confidence: "low", + }; + const unknownEvent = buildEvent(now.plus({ minutes: 1 }).toISO() ?? "", 4); + unknownEvent.meta = { + billing: "unknown", + billing_source: "unknown", + billing_confidence: "unknown", + }; + + const summary = aggregateEvents([mixedEvent, unknownEvent], tz, start, now.plus({ minutes: 2 })); + + expect(summary.totals.mixed_billing_count).toBe(1); + expect(summary.totals.unknown_billing_count).toBe(1); + expect(summary.totals.lower_confidence_billing_count).toBe(2); + expect(summary.totals.subscription_count).toBe(0); + expect(summary.breakdowns.billing.mixed.mixed_billing_count).toBe(1); + expect(summary.breakdowns.billing.unknown.unknown_billing_count).toBe(1); + }); }); diff --git a/tests/billing-metadata.test.ts b/tests/billing-metadata.test.ts new file mode 100644 index 0000000..353d224 --- /dev/null +++ b/tests/billing-metadata.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { applyBillingMetadata } from "../src/core/billing-metadata.js"; +import { UsageEvent, emptyCost, emptyProject } from "../src/core/events.js"; + +function buildEvent(meta: Record = {}): UsageEvent { + return { + id: "test-event", + ts: "2026-02-02T10:00:00Z", + source: "claude_code", + provider: "anthropic", + model: "claude-3-5-sonnet", + tokens: { in: 1000, out: 500, cache_write: 0, cache_read: 0 }, + cost: emptyCost(), + project: emptyProject(), + meta, + }; +} + +describe("applyBillingMetadata", () => { + it("records explicit registry billing with source and high confidence", () => { + const event = buildEvent({ existing: true }); + + const result = applyBillingMetadata(event, { + mode: "api", + source: "session_registry", + confidence: "high", + }); + + expect(result.meta).toMatchObject({ + existing: true, + billing: "api", + billing_source: "session_registry", + billing_confidence: "high", + }); + expect(event.meta).toEqual({ existing: true }); + }); + + it("keeps default billing visibly lower-confidence instead of flattening it into a bare mode", () => { + const result = applyBillingMetadata(buildEvent(), { + mode: "subscription", + source: "config_default", + confidence: "default", + }); + + expect(result.meta.billing).toBe("subscription"); + expect(result.meta.billing_source).toBe("config_default"); + expect(result.meta.billing_confidence).toBe("default"); + }); +}); diff --git a/tests/cli-utils.test.ts b/tests/cli-utils.test.ts new file mode 100644 index 0000000..1255e1e --- /dev/null +++ b/tests/cli-utils.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { emptyTotals } from "../src/core/aggregate.js"; +import { formatTotalsLine } from "../src/cli/utils.js"; + +describe("formatTotalsLine", () => { + it("labels mixed and unknown billing windows in human-readable totals", () => { + const totals = emptyTotals(); + totals.final_usd = 7; + totals.tokens_in = 100; + totals.tokens_out = 50; + totals.lower_confidence_billing_count = 2; + totals.mixed_billing_count = 1; + totals.unknown_billing_count = 1; + + expect(formatTotalsLine("Today", totals)).toBe( + "Today: ~$7.00 (in 100, out 50) [1 mixed-billing] [1 unknown-billing]" + ); + }); +}); diff --git a/tests/cost.test.ts b/tests/cost.test.ts index f6f9b95..3987683 100644 --- a/tests/cost.test.ts +++ b/tests/cost.test.ts @@ -41,8 +41,9 @@ const mockPricing: PricingTable = { }; describe("applyCosting", () => { - it("uses reported cost when available", () => { + it("uses reported cost when available (no pricing match)", () => { const event = buildEvent({ + model: "unknown-model", cost: { ...emptyCost(), reported_usd: 0.05 }, });