import fs from "node:fs"; import path from "node:path"; import readline from "node:readline"; import type { NormalizedUsage, UsageLike } from "../agents/usage.js"; import { normalizeUsage } from "../agents/usage.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { resolveSessionFilePath, resolveSessionTranscriptsDirForAgent, } from "../config/sessions/paths.js"; import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; type ParsedUsageEntry = { usage: NormalizedUsage; costTotal?: number; provider?: string; model?: string; timestamp?: Date; }; export type CostUsageTotals = { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; totalCost: number; missingCostEntries: number; }; export type CostUsageDailyEntry = CostUsageTotals & { date: string; }; export type CostUsageSummary = { updatedAt: number; days: number; daily: CostUsageDailyEntry[]; totals: CostUsageTotals; }; export type SessionCostSummary = CostUsageTotals & { sessionId?: string; sessionFile?: string; lastActivity?: number; }; const emptyTotals = (): CostUsageTotals => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, totalCost: 0, missingCostEntries: 0, }); const toFiniteNumber = (value: unknown): number | undefined => { if (typeof value !== "number") return undefined; if (!Number.isFinite(value)) return undefined; return value; }; const extractCostTotal = (usageRaw?: UsageLike | null): number | undefined => { if (!usageRaw || typeof usageRaw !== "object") return undefined; const record = usageRaw as Record; const cost = record.cost as Record | undefined; const total = toFiniteNumber(cost?.total); if (total === undefined) return undefined; if (total < 0) return undefined; return total; }; const parseTimestamp = (entry: Record): Date | undefined => { const raw = entry.timestamp; if (typeof raw === "string") { const parsed = new Date(raw); if (!Number.isNaN(parsed.valueOf())) return parsed; } const message = entry.message as Record | undefined; const messageTimestamp = toFiniteNumber(message?.timestamp); if (messageTimestamp !== undefined) { const parsed = new Date(messageTimestamp); if (!Number.isNaN(parsed.valueOf())) return parsed; } return undefined; }; const parseUsageEntry = (entry: Record): ParsedUsageEntry | null => { const message = entry.message as Record | undefined; const role = message?.role; if (role !== "assistant") return null; const usageRaw = (message?.usage as UsageLike | undefined) ?? (entry.usage as UsageLike | undefined); const usage = normalizeUsage(usageRaw); if (!usage) return null; const provider = (typeof message?.provider === "string" ? message?.provider : undefined) ?? (typeof entry.provider === "string" ? entry.provider : undefined); const model = (typeof message?.model === "string" ? message?.model : undefined) ?? (typeof entry.model === "string" ? entry.model : undefined); return { usage, costTotal: extractCostTotal(usageRaw), provider, model, timestamp: parseTimestamp(entry), }; }; const formatDayKey = (date: Date): string => date.toLocaleDateString("en-CA", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }); const applyUsageTotals = (totals: CostUsageTotals, usage: NormalizedUsage) => { totals.input += usage.input ?? 0; totals.output += usage.output ?? 0; totals.cacheRead += usage.cacheRead ?? 0; totals.cacheWrite += usage.cacheWrite ?? 0; const totalTokens = usage.total ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); totals.totalTokens += totalTokens; }; const applyCostTotal = (totals: CostUsageTotals, costTotal: number | undefined) => { if (costTotal === undefined) { totals.missingCostEntries += 1; return; } totals.totalCost += costTotal; }; async function scanUsageFile(params: { filePath: string; config?: ClawdbotConfig; onEntry: (entry: ParsedUsageEntry) => void; }): Promise { const fileStream = fs.createReadStream(params.filePath, { encoding: "utf-8" }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; try { const parsed = JSON.parse(trimmed) as Record; const entry = parseUsageEntry(parsed); if (!entry) continue; if (entry.costTotal === undefined) { const cost = resolveModelCostConfig({ provider: entry.provider, model: entry.model, config: params.config, }); entry.costTotal = estimateUsageCost({ usage: entry.usage, cost }); } params.onEntry(entry); } catch { // Ignore malformed lines } } } export async function loadCostUsageSummary(params?: { days?: number; config?: ClawdbotConfig; agentId?: string; }): Promise { const days = Math.max(1, Math.floor(params?.days ?? 30)); const now = new Date(); const since = new Date(now); since.setDate(since.getDate() - (days - 1)); const sinceTime = since.getTime(); const dailyMap = new Map(); const totals = emptyTotals(); const sessionsDir = resolveSessionTranscriptsDirForAgent(params?.agentId); const entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true }).catch(() => []); const files = entries .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) .map((entry) => path.join(sessionsDir, entry.name)); for (const filePath of files) { await scanUsageFile({ filePath, config: params?.config, onEntry: (entry) => { const ts = entry.timestamp?.getTime(); if (!ts || ts < sinceTime) return; const dayKey = formatDayKey(entry.timestamp ?? now); const bucket = dailyMap.get(dayKey) ?? emptyTotals(); applyUsageTotals(bucket, entry.usage); applyCostTotal(bucket, entry.costTotal); dailyMap.set(dayKey, bucket); applyUsageTotals(totals, entry.usage); applyCostTotal(totals, entry.costTotal); }, }); } const daily = Array.from(dailyMap.entries()) .map(([date, bucket]) => ({ date, ...bucket })) .sort((a, b) => a.date.localeCompare(b.date)); return { updatedAt: Date.now(), days, daily, totals, }; } export async function loadSessionCostSummary(params: { sessionId?: string; sessionEntry?: SessionEntry; sessionFile?: string; config?: ClawdbotConfig; }): Promise { const sessionFile = params.sessionFile ?? (params.sessionId ? resolveSessionFilePath(params.sessionId, params.sessionEntry) : undefined); if (!sessionFile || !fs.existsSync(sessionFile)) return null; const totals = emptyTotals(); let lastActivity: number | undefined; await scanUsageFile({ filePath: sessionFile, config: params.config, onEntry: (entry) => { applyUsageTotals(totals, entry.usage); applyCostTotal(totals, entry.costTotal); const ts = entry.timestamp?.getTime(); if (ts && (!lastActivity || ts > lastActivity)) { lastActivity = ts; } }, }); return { sessionId: params.sessionId, sessionFile, lastActivity, ...totals, }; }