Usage: add cost summaries to /usage + mac menu

This commit is contained in:
Peter Steinberger
2026-01-19 00:04:58 +00:00
parent 1ea3ac0a1d
commit 3ce1ee84ac
14 changed files with 706 additions and 10 deletions

View File

@@ -2,6 +2,11 @@
Docs: https://docs.clawd.bot
## 2026.1.19-1
### Changes
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
## 2026.1.18-5
### Changes

View File

@@ -0,0 +1,99 @@
import Charts
import SwiftUI
struct CostUsageHistoryMenuView: View {
let summary: GatewayCostUsageSummary
let width: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: 10) {
self.header
self.chart
self.footer
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(width: max(1, self.width), alignment: .leading)
}
private var header: some View {
let todayKey = CostUsageMenuDateParser.format(Date())
let todayEntry = self.summary.daily.first { $0.date == todayKey }
let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a"
let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a"
return HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Today")
.font(.caption2)
.foregroundStyle(.secondary)
Text(todayCost)
.font(.system(size: 14, weight: .semibold))
}
VStack(alignment: .leading, spacing: 2) {
Text("Last \(self.summary.days)d")
.font(.caption2)
.foregroundStyle(.secondary)
Text(totalCost)
.font(.system(size: 14, weight: .semibold))
}
Spacer()
}
}
private var chart: some View {
let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in
guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil }
return (date, entry.totalCost)
}
return Chart(entries, id: \.0) { entry in
BarMark(
x: .value("Day", entry.0),
y: .value("Cost", entry.1))
.foregroundStyle(Color.accentColor)
.cornerRadius(3)
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 7)) {
AxisGridLine().foregroundStyle(.clear)
AxisValueLabel(format: .dateTime.month().day())
}
}
.chartYAxis {
AxisMarks(position: .leading) {
AxisGridLine()
AxisValueLabel()
}
}
.frame(height: 110)
}
private var footer: some View {
if self.summary.totals.missingCostEntries == 0 {
return AnyView(EmptyView())
}
return AnyView(
Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost")
.font(.caption2)
.foregroundStyle(.secondary))
}
}
private enum CostUsageMenuDateParser {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
return formatter
}()
static func parse(_ value: String) -> Date? {
self.formatter.date(from: value)
}
static func format(_ date: Date) -> String {
self.formatter.string(from: date)
}
}

View File

@@ -27,6 +27,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private var cachedUsageErrorText: String?
private var usageCacheUpdatedAt: Date?
private let usageRefreshIntervalSeconds: TimeInterval = 30
private var cachedCostSummary: GatewayCostUsageSummary?
private var cachedCostErrorText: String?
private var costCacheUpdatedAt: Date?
private let costRefreshIntervalSeconds: TimeInterval = 45
private let nodesStore = NodesStore.shared
#if DEBUG
private var testControlChannelConnected: Bool?
@@ -64,6 +68,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let self else { return }
await self.refreshCache(force: forceRefresh)
await self.refreshUsageCache(force: forceRefresh)
await self.refreshCostUsageCache(force: forceRefresh)
await MainActor.run {
guard self.isMenuOpen else { return }
self.inject(into: menu)
@@ -200,6 +205,7 @@ extension MenuSessionsInjector {
}
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width)
DispatchQueue.main.async { [weak self, weak headerView] in
guard let self, let headerView else { return }
@@ -344,6 +350,28 @@ extension MenuSessionsInjector {
return cursor
}
private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
guard self.isControlChannelConnected else { return cursor }
guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor }
var cursor = cursor
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
let separator = NSMenuItem.separator()
separator.tag = self.tag
menu.insertItem(separator, at: cursor)
cursor += 1
}
let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "")
item.tag = self.tag
item.isEnabled = true
item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil)
item.submenu = submenu
menu.insertItem(item, at: cursor)
cursor += 1
return cursor
}
private var selectedUsageProviderId: String? {
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -393,6 +421,36 @@ extension MenuSessionsInjector {
}
}
private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? {
if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil {
let menu = NSMenu()
let item = NSMenuItem(title: error, action: nil, keyEquivalent: "")
item.isEnabled = false
menu.addItem(item)
return menu
}
guard let summary = self.cachedCostSummary else { return nil }
guard !summary.daily.isEmpty else { return nil }
let menu = NSMenu()
menu.delegate = self
let chartView = CostUsageHistoryMenuView(summary: summary, width: width)
let hosting = NSHostingView(rootView: AnyView(chartView))
let controller = NSHostingController(rootView: AnyView(chartView))
let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
let chartItem = NSMenuItem()
chartItem.view = hosting
chartItem.isEnabled = false
chartItem.representedObject = "costUsageChart"
menu.addItem(chartItem)
return menu
}
private func gatewayEntry() -> NodeInfo? {
let mode = AppStateStore.shared.connectionMode
let isConnected = self.isControlChannelConnected
@@ -581,6 +639,31 @@ extension MenuSessionsInjector {
self.usageCacheUpdatedAt = Date()
}
private func refreshCostUsageCache(force: Bool) async {
if !force,
let updated = self.costCacheUpdatedAt,
Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds
{
return
}
guard self.isControlChannelConnected else {
self.cachedCostSummary = nil
self.cachedCostErrorText = nil
self.costCacheUpdatedAt = Date()
return
}
do {
self.cachedCostSummary = try await CostUsageLoader.loadSummary()
self.cachedCostErrorText = nil
} catch {
self.cachedCostSummary = nil
self.cachedCostErrorText = self.compactUsageError(error)
}
self.costCacheUpdatedAt = Date()
}
private func compactUsageError(_ error: Error) -> String {
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
if message.isEmpty { return "Usage unavailable" }

View File

@@ -0,0 +1,60 @@
import Foundation
struct GatewayCostUsageTotals: Codable {
let input: Int
let output: Int
let cacheRead: Int
let cacheWrite: Int
let totalTokens: Int
let totalCost: Double
let missingCostEntries: Int
}
struct GatewayCostUsageDay: Codable {
let date: String
let input: Int
let output: Int
let cacheRead: Int
let cacheWrite: Int
let totalTokens: Int
let totalCost: Double
let missingCostEntries: Int
}
struct GatewayCostUsageSummary: Codable {
let updatedAt: Double
let days: Int
let daily: [GatewayCostUsageDay]
let totals: GatewayCostUsageTotals
}
enum CostUsageFormatting {
static func formatUsd(_ value: Double?) -> String? {
guard let value, value.isFinite else { return nil }
if value >= 1 { return String(format: "$%.2f", value) }
if value >= 0.01 { return String(format: "$%.2f", value) }
return String(format: "$%.4f", value)
}
static func formatTokenCount(_ value: Int?) -> String? {
guard let value else { return nil }
let safe = max(0, value)
if safe >= 1_000_000 { return String(format: "%.1fm", Double(safe) / 1_000_000.0) }
if safe >= 1000 { return safe >= 10000
? String(format: "%.0fk", Double(safe) / 1000.0)
: String(format: "%.1fk", Double(safe) / 1000.0)
}
return String(safe)
}
}
@MainActor
enum CostUsageLoader {
static func loadSummary() async throws -> GatewayCostUsageSummary {
let data = try await ControlChannel.shared.request(
method: "usage.cost",
params: nil,
timeoutMs: 7000)
return try JSONDecoder().decode(GatewayCostUsageSummary.self, from: data)
}
}

View File

@@ -13,6 +13,7 @@ read_when:
## Where it shows up
- `/status` in chats: emojirich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
- `/usage cost` in chats: local cost summary aggregated from Clawdbot session logs.
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: “Usage” section under Context (only if available).

View File

@@ -45,6 +45,7 @@ Use these in chat:
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
- Persists per session (stored as `responseUsage`).
- OAuth auth **hides cost** (tokens only).
- `/usage cost` → shows a local cost summary from Clawdbot session logs.
Other surfaces:

View File

@@ -64,7 +64,7 @@ Text + native (when enabled):
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/usage off|tokens|full` (per-response usage footer)
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
- `/stop`
- `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
@@ -91,7 +91,7 @@ Text-only:
Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- For full provider usage breakdown, use `clawdbot status --usage`.
- `/usage` controls the per-response usage footer. It only shows dollar cost when the model uses an API key (OAuth hides cost).
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.

View File

@@ -59,14 +59,14 @@ describe("commands registry args", () => {
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full"],
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full"]);
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
});
it("does not show menus when arg already provided", () => {
@@ -82,7 +82,7 @@ describe("commands registry args", () => {
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full"],
choices: ["off", "tokens", "full", "cost"],
},
],
};
@@ -141,7 +141,7 @@ describe("commands registry args", () => {
name: "mode",
description: "on or off",
type: "string",
choices: ["off", "tokens", "full"],
choices: ["off", "tokens", "full", "cost"],
},
],
};

View File

@@ -233,14 +233,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
defineChatCommand({
key: "usage",
nativeName: "usage",
description: "Toggle per-response usage line.",
description: "Usage footer or cost summary.",
textAlias: "/usage",
args: [
{
name: "mode",
description: "off, tokens, or full",
description: "off, tokens, full, or cost",
type: "string",
choices: ["off", "tokens", "full"],
choices: ["off", "tokens", "full", "cost"],
},
],
argsMenu: "auto",

View File

@@ -7,6 +7,8 @@ import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../inf
import { parseActivationCommand } from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import { normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js";
import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js";
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import {
formatAbortReplyText,
isAbortTrigger,
@@ -141,10 +143,48 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
const rawArgs = normalized === "/usage" ? "" : normalized.slice("/usage".length).trim();
const requested = rawArgs ? normalizeUsageDisplay(rawArgs) : undefined;
if (rawArgs.toLowerCase().startsWith("cost")) {
const sessionSummary = await loadSessionCostSummary({
sessionId: params.sessionEntry?.sessionId,
sessionEntry: params.sessionEntry,
sessionFile: params.sessionEntry?.sessionFile,
config: params.cfg,
});
const summary = await loadCostUsageSummary({ days: 30, config: params.cfg });
const sessionCost = formatUsd(sessionSummary?.totalCost);
const sessionTokens = sessionSummary?.totalTokens
? formatTokenCount(sessionSummary.totalTokens)
: undefined;
const sessionMissing = sessionSummary?.missingCostEntries ?? 0;
const sessionSuffix = sessionMissing > 0 ? " (partial)" : "";
const sessionLine =
sessionCost || sessionTokens
? `Session ${sessionCost ?? "n/a"}${sessionSuffix}${sessionTokens ? ` · ${sessionTokens} tokens` : ""}`
: "Session n/a";
const todayKey = new Date().toLocaleDateString("en-CA");
const todayEntry = summary.daily.find((entry) => entry.date === todayKey);
const todayCost = formatUsd(todayEntry?.totalCost);
const todayMissing = todayEntry?.missingCostEntries ?? 0;
const todaySuffix = todayMissing > 0 ? " (partial)" : "";
const todayLine = `Today ${todayCost ?? "n/a"}${todaySuffix}`;
const last30Cost = formatUsd(summary.totals.totalCost);
const last30Missing = summary.totals.missingCostEntries;
const last30Suffix = last30Missing > 0 ? " (partial)" : "";
const last30Line = `Last 30d ${last30Cost ?? "n/a"}${last30Suffix}`;
return {
shouldContinue: false,
reply: { text: `💸 Usage cost\n${sessionLine}\n${todayLine}\n${last30Line}` },
};
}
if (rawArgs && !requested) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /usage off|tokens|full" },
reply: { text: "⚙️ Usage: /usage off|tokens|full|cost" },
};
}

View File

@@ -7,6 +7,7 @@ const BASE_METHODS = [
"channels.logout",
"status",
"usage.status",
"usage.cost",
"config.get",
"config.set",
"config.apply",

View File

@@ -1,3 +1,5 @@
import { loadConfig } from "../../config/config.js";
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -6,4 +8,9 @@ export const usageHandlers: GatewayRequestHandlers = {
const summary = await loadProviderUsageSummary();
respond(true, summary, undefined);
},
"usage.cost": async ({ respond }) => {
const config = loadConfig();
const summary = await loadCostUsageSummary({ days: 30, config });
respond(true, summary, undefined);
},
};

View File

@@ -0,0 +1,142 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { loadCostUsageSummary, loadSessionCostSummary } from "./session-cost-usage.js";
describe("session cost usage", () => {
it("aggregates daily totals with log cost and pricing fallback", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cost-"));
const sessionsDir = path.join(root, "agents", "main", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "sess-1.jsonl");
const now = new Date();
const older = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000);
const entries = [
{
type: "message",
timestamp: now.toISOString(),
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 10,
output: 20,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 30,
cost: { total: 0.03 },
},
},
},
{
type: "message",
timestamp: now.toISOString(),
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 10,
output: 10,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 20,
},
},
},
{
type: "message",
timestamp: older.toISOString(),
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 5,
output: 5,
totalTokens: 10,
cost: { total: 0.01 },
},
},
},
];
await fs.writeFile(
sessionFile,
entries.map((entry) => JSON.stringify(entry)).join("\n"),
"utf-8",
);
const config = {
models: {
providers: {
openai: {
models: [
{
id: "gpt-5.2",
cost: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
},
},
],
},
},
},
} as ClawdbotConfig;
const originalState = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = root;
try {
const summary = await loadCostUsageSummary({ days: 30, config });
expect(summary.daily.length).toBe(1);
expect(summary.totals.totalTokens).toBe(50);
expect(summary.totals.totalCost).toBeCloseTo(0.03003, 5);
} finally {
if (originalState === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = originalState;
}
});
it("summarizes a single session file", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cost-session-"));
const sessionFile = path.join(root, "session.jsonl");
const now = new Date();
await fs.writeFile(
sessionFile,
JSON.stringify({
type: "message",
timestamp: now.toISOString(),
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 10,
output: 20,
totalTokens: 30,
cost: { total: 0.03 },
},
},
}),
"utf-8",
);
const summary = await loadSessionCostSummary({
sessionFile,
});
expect(summary?.totalCost).toBeCloseTo(0.03, 5);
expect(summary?.totalTokens).toBe(30);
expect(summary?.lastActivity).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,257 @@
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<string, unknown>;
const cost = record.cost as Record<string, unknown> | undefined;
const total = toFiniteNumber(cost?.total);
if (total === undefined) return undefined;
if (total < 0) return undefined;
return total;
};
const parseTimestamp = (entry: Record<string, unknown>): 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<string, unknown> | 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<string, unknown>): ParsedUsageEntry | null => {
const message = entry.message as Record<string, unknown> | 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<void> {
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<string, unknown>;
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<CostUsageSummary> {
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<string, CostUsageTotals>();
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<SessionCostSummary | null> {
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,
};
}