portable-agent-layer 0.91.5 → 0.91.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.91.5",
3
+ "version": "0.91.6",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/tree.ts CHANGED
@@ -13,7 +13,6 @@ import {
13
13
  type OptionSpec,
14
14
  type OptionSpecs,
15
15
  } from "../tools/lib/command";
16
- import { usageCommand } from "../tools/token-cost";
17
16
  import { builtinTools, builtinToolVerbs } from "./builtin-tools";
18
17
  import { identityCommand } from "./identity";
19
18
  import { knowledgeCommand } from "./knowledge";
@@ -24,6 +23,7 @@ import { ruleCommand } from "./rule";
24
23
  import { serverCommand } from "./server";
25
24
  import { skillCommand } from "./skill";
26
25
  import { subagentCommand } from "./subagent";
26
+ import { usageCommand } from "./usage";
27
27
 
28
28
  type Handler = Leaf["run"];
29
29
  export type DebugState = "on" | "off" | "status";
@@ -0,0 +1,168 @@
1
+ import {
2
+ type AgentReport,
3
+ costLabel,
4
+ fmt,
5
+ fmtCost,
6
+ grandBucket,
7
+ inferenceLabel,
8
+ modelLabel,
9
+ modelsByCost,
10
+ projectsByCost,
11
+ type RtkGain,
12
+ type UsageData,
13
+ unpricedAgents,
14
+ usageLines,
15
+ usedAgents,
16
+ usedInferenceModels,
17
+ WINDOWS,
18
+ } from "../tools/lib/token-report";
19
+ import { type Bucket, type TimeBuckets, totalTokens } from "../tools/lib/usage-buckets";
20
+ import { mark } from "./ui/rail";
21
+ import type { Style } from "./ui/style";
22
+
23
+ const LABEL_WIDTH = 12;
24
+
25
+ interface RowCells {
26
+ label: string;
27
+ tokens: string;
28
+ calls: string;
29
+ cost: string;
30
+ }
31
+
32
+ function rowCells(style: Style, label: string, b: Bucket, width: number): RowCells {
33
+ return {
34
+ label: ` ${label.padEnd(width)}`,
35
+ tokens: fmt(totalTokens(b)).padStart(9),
36
+ calls: fmt(b.calls).padStart(7),
37
+ cost: costLabel(b, style.glyph.dash).padStart(12),
38
+ };
39
+ }
40
+
41
+ /** A window without calls fades whole, so the ones that spent stand out. */
42
+ function row(style: Style, label: string, b: Bucket, width = LABEL_WIDTH): string {
43
+ const cells = rowCells(style, label, b, width);
44
+ if (b.calls === 0)
45
+ return style.dim(
46
+ `${cells.label}${cells.tokens} tok${cells.calls} calls${cells.cost}`
47
+ );
48
+ const cost = b.unpriced === b.calls ? style.dim(cells.cost) : style.cmd(cells.cost);
49
+ return [
50
+ cells.label,
51
+ style.soft(cells.tokens),
52
+ style.dim(" tok"),
53
+ style.soft(cells.calls),
54
+ style.dim(" calls"),
55
+ cost,
56
+ ].join("");
57
+ }
58
+
59
+ const heading = (style: Style, text: string) => ` ${style.bold(style.gradient(text))}`;
60
+
61
+ const subtle = (style: Style, title: string, rest: string) =>
62
+ heading(style, title) + style.dim(` ${style.glyph.dot} ${rest}`);
63
+
64
+ function windowRows(style: Style, buckets: TimeBuckets): string[] {
65
+ return WINDOWS.map(([label, window]) => row(style, label, buckets[window]));
66
+ }
67
+
68
+ function agentSections(style: Style, agents: AgentReport[]): string[] {
69
+ const used = usedAgents(agents);
70
+ if (used.length === 0) return [` ${style.dim("No agent usage recorded")}`, ""];
71
+ return used.flatMap(({ label, usage }) => [
72
+ heading(style, label),
73
+ ...windowRows(style, usage.buckets),
74
+ "",
75
+ ]);
76
+ }
77
+
78
+ function modelSection(style: Style, agents: AgentReport[]): string[] {
79
+ const sorted = modelsByCost(agents);
80
+ if (sorted.length === 0) return [];
81
+ const names = sorted.map(([model]) => modelLabel(model));
82
+ const width = Math.max(LABEL_WIDTH, ...names.map((name) => name.length + 2));
83
+ return [
84
+ subtle(style, "By model", "all time"),
85
+ ...sorted.map(([, bucket], i) => row(style, names[i], bucket, width)),
86
+ "",
87
+ ];
88
+ }
89
+
90
+ function projectSection(style: Style, agents: AgentReport[]): string[] {
91
+ const sorted = projectsByCost(agents);
92
+ if (sorted.length === 0) return [];
93
+ const width = Math.max(LABEL_WIDTH, ...sorted.map(([name]) => name.length + 2));
94
+ return [
95
+ subtle(style, "By project", "all time"),
96
+ ...sorted.map(([project, buckets]) => row(style, project, buckets.total, width)),
97
+ "",
98
+ ];
99
+ }
100
+
101
+ function inferenceSections(style: Style, data: UsageData): string[] {
102
+ return usedInferenceModels(data.pal).flatMap(([model, buckets]) => [
103
+ subtle(style, "PAL inference", inferenceLabel(model)),
104
+ ...windowRows(style, buckets),
105
+ "",
106
+ ]);
107
+ }
108
+
109
+ function rtkLine(style: Style, gain: RtkGain): string {
110
+ const summary = gain.summary;
111
+ if (!gain.installed) return style.dim("not installed");
112
+ if (!summary || summary.total_commands === 0)
113
+ return style.dim("installed, no savings recorded yet");
114
+ const dot = ` ${style.dim(style.glyph.dot)} `;
115
+ const average = `${summary.avg_savings_pct.toFixed(1)}%`;
116
+ return [
117
+ `${style.soft(fmt(summary.total_saved))}${style.dim(" tokens saved")}`,
118
+ `${style.soft(average)}${style.dim(" avg")}`,
119
+ `${style.soft(fmt(summary.total_commands))}${style.dim(" commands")}`,
120
+ ].join(dot);
121
+ }
122
+
123
+ function rtkSection(style: Style, gain: RtkGain): string[] {
124
+ return [subtle(style, "rtk", "compression"), ` ${rtkLine(style, gain)}`, ""];
125
+ }
126
+
127
+ function noteLines(style: Style, data: UsageData): string[] {
128
+ const join = (names: string[]) => names.join(` ${style.glyph.dot} `);
129
+ const unpriced = unpricedAgents(data.agents);
130
+ return [
131
+ ...(data.untracked.length > 0
132
+ ? [
133
+ ` ${mark(style, "info")} ${style.soft(join(data.untracked))} ${style.dim("keep no token counts")}`,
134
+ ]
135
+ : []),
136
+ ...(unpriced.length > 0
137
+ ? [
138
+ ` ${mark(style, "info")} ${style.soft(join(unpriced))} ${style.dim("bill without a per-token price, so the total leaves them out")}`,
139
+ ]
140
+ : []),
141
+ ];
142
+ }
143
+
144
+ function headline(style: Style, data: UsageData): string {
145
+ const agents = usedAgents(data.agents).map((agent) => agent.label);
146
+ const names = agents.length > 0 ? agents.join(` ${style.glyph.dot} `) : "no agent";
147
+ const total = style.bold(style.cmd(fmtCost(grandBucket(data).cost)));
148
+ return ` ${mark(style, "ask")} ${style.gradient("PAL usage")} ${total} ${style.soft(names)}`;
149
+ }
150
+
151
+ function richLines(style: Style, data: UsageData): string[] {
152
+ const notes = noteLines(style, data);
153
+ return [
154
+ "",
155
+ headline(style, data),
156
+ "",
157
+ ...agentSections(style, data.agents),
158
+ ...modelSection(style, data.agents),
159
+ ...projectSection(style, data.agents),
160
+ ...inferenceSections(style, data),
161
+ ...rtkSection(style, data.rtk),
162
+ ...(notes.length > 0 ? [...notes, ""] : []),
163
+ ];
164
+ }
165
+
166
+ export function renderUsage(style: Style, data: UsageData): string[] {
167
+ return style.term.rich ? richLines(style, data) : usageLines(data);
168
+ }
@@ -0,0 +1,24 @@
1
+ import { leaf, runCommand } from "../tools/lib/command";
2
+ import { scriptArgs } from "../tools/lib/script-args";
3
+ import { collectUsage } from "../tools/token-cost";
4
+ import { createStyle } from "./ui/style";
5
+ import { renderUsage } from "./usage-render";
6
+
7
+ function printUsage(project: string | undefined): undefined {
8
+ for (const line of renderUsage(createStyle(), collectUsage(project))) console.log(line);
9
+ }
10
+
11
+ export const usageCommand = leaf({
12
+ summary: "Summarize every agent's token usage and cost for today, 7 and 30 days",
13
+ options: {
14
+ project: {
15
+ type: "string",
16
+ value: "<name>",
17
+ description: "Only sessions of this project",
18
+ },
19
+ },
20
+ run: ({ values }) => printUsage(values.project),
21
+ });
22
+
23
+ if (import.meta.main)
24
+ process.exit(await runCommand(usageCommand, scriptArgs(), ["pal", "cli", "usage"]));
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Token usage of the agents other than Claude Code, read off what each one
3
+ * keeps on disk. None of them is billed per token at a price PAL knows, except
4
+ * opencode, which records its own cost per message.
5
+ */
6
+
7
+ import { Database } from "bun:sqlite";
8
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { basename, resolve } from "node:path";
10
+ import {
11
+ type AgentUsage,
12
+ type Tokens,
13
+ type UsageTally,
14
+ usageTally,
15
+ } from "./usage-buckets";
16
+
17
+ function linesOf(filepath: string): string[] {
18
+ try {
19
+ return readFileSync(filepath, "utf-8").split("\n");
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ function parsed<T>(line: string): T | null {
26
+ try {
27
+ return JSON.parse(line) as T;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function filesUnder(dir: string, matches: (name: string) => boolean): string[] {
34
+ if (!existsSync(dir)) return [];
35
+ return readdirSync(dir, { recursive: true, encoding: "utf-8" })
36
+ .filter((path) => matches(basename(path)))
37
+ .map((path) => resolve(dir, path));
38
+ }
39
+
40
+ const projectOf = (cwd: string | undefined) => (cwd && basename(cwd)) || "unknown";
41
+
42
+ interface CodexTokenUsage {
43
+ input_tokens?: number;
44
+ cached_input_tokens?: number;
45
+ output_tokens?: number;
46
+ total_tokens?: number;
47
+ }
48
+
49
+ interface CodexLine {
50
+ timestamp?: string;
51
+ type?: string;
52
+ payload?: {
53
+ type?: string;
54
+ cwd?: string;
55
+ model?: string;
56
+ info?: {
57
+ total_token_usage?: CodexTokenUsage;
58
+ last_token_usage?: CodexTokenUsage;
59
+ } | null;
60
+ };
61
+ }
62
+
63
+ /** Codex counts cached input inside input_tokens, and reasoning inside output_tokens. */
64
+ function codexTokens(usage: CodexTokenUsage): Tokens {
65
+ const cached = usage.cached_input_tokens ?? 0;
66
+ return {
67
+ input: (usage.input_tokens ?? 0) - cached,
68
+ output: usage.output_tokens ?? 0,
69
+ cacheWrite5m: 0,
70
+ cacheWrite1h: 0,
71
+ cacheRead: cached,
72
+ };
73
+ }
74
+
75
+ const CODEX_KEYS = ['"session_meta"', '"turn_context"', '"token_count"'];
76
+
77
+ /** Codex repeats a token_count event without a new call; the running total tells them apart. */
78
+ function recordCodexRollout(filepath: string, tally: UsageTally): void {
79
+ let project = "unknown";
80
+ let model = "unknown";
81
+ let lastTotal = -1;
82
+ for (const line of linesOf(filepath)) {
83
+ if (!CODEX_KEYS.some((key) => line.includes(key))) continue;
84
+ const entry = parsed<CodexLine>(line);
85
+ const payload = entry?.payload;
86
+ if (!entry || !payload) continue;
87
+ if (entry.type === "session_meta") project = projectOf(payload.cwd);
88
+ if (entry.type === "turn_context" && payload.model) model = payload.model;
89
+ const info = payload.type === "token_count" ? payload.info : null;
90
+ const last = info?.last_token_usage;
91
+ const total = info?.total_token_usage?.total_tokens ?? -1;
92
+ if (!last || !entry.timestamp || total === lastTotal) continue;
93
+ lastTotal = total;
94
+ tally.record({
95
+ ts: entry.timestamp,
96
+ model,
97
+ project,
98
+ tokens: codexTokens(last),
99
+ cost: null,
100
+ });
101
+ }
102
+ }
103
+
104
+ export function readCodex(
105
+ codexDir: string,
106
+ projectFilter?: string,
107
+ now: Date = new Date()
108
+ ): AgentUsage {
109
+ const tally = usageTally(projectFilter, now);
110
+ const isRollout = (name: string) =>
111
+ name.startsWith("rollout-") && name.endsWith(".jsonl");
112
+ for (const sub of ["sessions", "archived_sessions"]) {
113
+ for (const filepath of filesUnder(resolve(codexDir, sub), isRollout)) {
114
+ recordCodexRollout(filepath, tally);
115
+ }
116
+ }
117
+ return tally.usage;
118
+ }
119
+
120
+ interface OpencodeMessage {
121
+ role?: string;
122
+ modelID?: string;
123
+ cost?: number;
124
+ path?: { root?: string };
125
+ tokens?: {
126
+ input?: number;
127
+ output?: number;
128
+ reasoning?: number;
129
+ cache?: { read?: number; write?: number };
130
+ };
131
+ }
132
+
133
+ /** opencode keeps reasoning apart from output, and writes its cache without a TTL. */
134
+ function opencodeTokens(message: OpencodeMessage): Tokens {
135
+ const t = message.tokens ?? {};
136
+ return {
137
+ input: t.input ?? 0,
138
+ output: (t.output ?? 0) + (t.reasoning ?? 0),
139
+ cacheWrite5m: t.cache?.write ?? 0,
140
+ cacheWrite1h: 0,
141
+ cacheRead: t.cache?.read ?? 0,
142
+ };
143
+ }
144
+
145
+ const anyTokens = (tokens: Tokens) => Object.values(tokens).some((n) => n > 0);
146
+
147
+ interface OpencodeRow {
148
+ time_created: number;
149
+ data: string;
150
+ }
151
+
152
+ function opencodeAssistantRows(dbPath: string): OpencodeRow[] {
153
+ const db = new Database(dbPath, { readonly: true });
154
+ try {
155
+ return db
156
+ .query<OpencodeRow, []>(
157
+ "SELECT time_created, data FROM message WHERE json_extract(data, '$.role') = 'assistant'"
158
+ )
159
+ .all();
160
+ } finally {
161
+ db.close();
162
+ }
163
+ }
164
+
165
+ export function readOpencode(
166
+ dbPath: string,
167
+ projectFilter?: string,
168
+ now: Date = new Date()
169
+ ): AgentUsage {
170
+ const tally = usageTally(projectFilter, now);
171
+ if (!existsSync(dbPath)) return tally.usage;
172
+ let rows: OpencodeRow[];
173
+ try {
174
+ rows = opencodeAssistantRows(dbPath);
175
+ } catch {
176
+ return tally.usage;
177
+ }
178
+ for (const row of rows) {
179
+ const message = parsed<OpencodeMessage>(row.data);
180
+ if (!message) continue;
181
+ const tokens = opencodeTokens(message);
182
+ if (!anyTokens(tokens)) continue;
183
+ tally.record({
184
+ ts: new Date(row.time_created).toISOString(),
185
+ model: message.modelID ?? "unknown",
186
+ project: projectOf(message.path?.root),
187
+ tokens,
188
+ cost: typeof message.cost === "number" ? message.cost : null,
189
+ });
190
+ }
191
+ return tally.usage;
192
+ }
193
+
194
+ interface CopilotModelMetrics {
195
+ requests?: { count?: number };
196
+ usage?: {
197
+ inputTokens?: number;
198
+ outputTokens?: number;
199
+ cacheReadTokens?: number;
200
+ cacheWriteTokens?: number;
201
+ };
202
+ }
203
+
204
+ interface CopilotEvent {
205
+ type?: string;
206
+ timestamp?: string;
207
+ data?: {
208
+ context?: { cwd?: string };
209
+ modelMetrics?: Record<string, CopilotModelMetrics>;
210
+ };
211
+ }
212
+
213
+ /** Copilot counts cache reads inside inputTokens. */
214
+ function copilotTokens(usage: CopilotModelMetrics["usage"] = {}): Tokens {
215
+ const cacheRead = usage.cacheReadTokens ?? 0;
216
+ return {
217
+ input: (usage.inputTokens ?? 0) - cacheRead,
218
+ output: usage.outputTokens ?? 0,
219
+ cacheWrite5m: usage.cacheWriteTokens ?? 0,
220
+ cacheWrite1h: 0,
221
+ cacheRead,
222
+ };
223
+ }
224
+
225
+ /** Each shutdown carries the totals of its own run, so a resumed session adds, not repeats. */
226
+ function recordCopilotSession(filepath: string, tally: UsageTally): void {
227
+ let project = "unknown";
228
+ for (const line of linesOf(filepath)) {
229
+ if (!line.includes('"session.')) continue;
230
+ const event = parsed<CopilotEvent>(line);
231
+ if (!event?.data) continue;
232
+ if (event.data.context?.cwd) project = projectOf(event.data.context.cwd);
233
+ if (event.type !== "session.shutdown" || !event.timestamp) continue;
234
+ for (const [model, metrics] of Object.entries(event.data.modelMetrics ?? {})) {
235
+ tally.record({
236
+ ts: event.timestamp,
237
+ model,
238
+ project,
239
+ tokens: copilotTokens(metrics.usage),
240
+ cost: null,
241
+ calls: metrics.requests?.count ?? 1,
242
+ });
243
+ }
244
+ }
245
+ }
246
+
247
+ export function readCopilot(
248
+ copilotDir: string,
249
+ projectFilter?: string,
250
+ now: Date = new Date()
251
+ ): AgentUsage {
252
+ const tally = usageTally(projectFilter, now);
253
+ const sessionsDir = resolve(copilotDir, "session-state");
254
+ if (!existsSync(sessionsDir)) return tally.usage;
255
+ for (const session of readdirSync(sessionsDir)) {
256
+ recordCopilotSession(resolve(sessionsDir, session, "events.jsonl"), tally);
257
+ }
258
+ return tally.usage;
259
+ }
@@ -7,13 +7,28 @@
7
7
  */
8
8
 
9
9
  import {
10
+ type AgentUsage,
10
11
  type Bucket,
11
- type ClaudeCodeUsage,
12
12
  grandTotal,
13
+ mergeTimeBuckets,
13
14
  type PalInferenceUsage,
15
+ type TimeBuckets,
14
16
  totalTokens,
15
17
  } from "./usage-buckets";
16
18
 
19
+ export interface AgentReport {
20
+ label: string;
21
+ usage: AgentUsage;
22
+ }
23
+
24
+ /** `untracked` names the agents on this machine that keep no token counts at all. */
25
+ export interface UsageData {
26
+ agents: AgentReport[];
27
+ pal: PalInferenceUsage;
28
+ rtk: RtkGain;
29
+ untracked: string[];
30
+ }
31
+
17
32
  export interface RtkSummary {
18
33
  total_commands: number;
19
34
  total_saved: number;
@@ -44,6 +59,11 @@ export function fmtCost(n: number): string {
44
59
  return `$${n.toFixed(4)}`;
45
60
  }
46
61
 
62
+ /** A bucket whose every call went unpriced has no cost to show, which is not $0. */
63
+ export function costLabel(b: Bucket, dash = "-"): string {
64
+ return b.calls > 0 && b.unpriced === b.calls ? dash : fmtCost(b.cost);
65
+ }
66
+
47
67
  export function rowLine(
48
68
  label: string,
49
69
  b: Bucket,
@@ -51,7 +71,7 @@ export function rowLine(
51
71
  ): string {
52
72
  const tokens = fmt(totalTokens(b)).padStart(8);
53
73
  const calls = fmt(b.calls).padStart(5);
54
- const cost = fmtCost(b.cost).padStart(8);
74
+ const cost = costLabel(b).padStart(8);
55
75
  return ` ${label.padEnd(labelWidth)} ${tokens} tok ${calls} calls ${cost}`;
56
76
  }
57
77
 
@@ -65,22 +85,19 @@ export function detailedLine(
65
85
  const write5m = fmt(b.cacheWrite5m).padStart(7);
66
86
  const write1h = fmt(b.cacheWrite1h).padStart(7);
67
87
  const read = fmt(b.cacheRead).padStart(8);
68
- const cost = fmtCost(b.cost).padStart(8);
88
+ const cost = costLabel(b).padStart(8);
69
89
  return ` ${label.padEnd(labelWidth)} ${input} in ${output} out ${write5m} cw5m ${write1h} cw1h ${read} cr ${cost}`;
70
90
  }
71
91
 
72
- function windowLines(buckets: {
73
- today: Bucket;
74
- week: Bucket;
75
- month: Bucket;
76
- total: Bucket;
77
- }): string[] {
78
- return [
79
- rowLine("Today", buckets.today),
80
- rowLine("7d", buckets.week),
81
- rowLine("30d", buckets.month),
82
- rowLine("Total", buckets.total),
83
- ];
92
+ export const WINDOWS = [
93
+ ["Today", "today"],
94
+ ["7d", "week"],
95
+ ["30d", "month"],
96
+ ["Total", "total"],
97
+ ] as const satisfies readonly (readonly [string, keyof TimeBuckets])[];
98
+
99
+ function windowLines(buckets: TimeBuckets): string[] {
100
+ return WINDOWS.map(([label, window]) => rowLine(label, buckets[window]));
84
101
  }
85
102
 
86
103
  /** Costliest first — the point of the section is what to look at. */
@@ -88,10 +105,45 @@ function byCost<T>(entries: [string, T][], costOf: (value: T) => number): [strin
88
105
  return entries.sort((a, b) => costOf(b[1]) - costOf(a[1]));
89
106
  }
90
107
 
91
- function byModelLines(byModel: ClaudeCodeUsage["byModel"]): string[] {
92
- const sorted = byCost(Object.entries(byModel), (bucket) => bucket.cost);
108
+ /** Drops the vendor's prefix and any provider path, so `accounts/x/models/kimi` reads `kimi`. */
109
+ export function modelLabel(model: string): string {
110
+ return (model.split("/").at(-1) ?? model).replace("claude-", "");
111
+ }
112
+
113
+ export const usedAgents = (agents: AgentReport[]) =>
114
+ agents.filter((agent) => agent.usage.buckets.total.calls > 0);
115
+
116
+ function mergedEntries<T>(
117
+ agents: AgentReport[],
118
+ pick: (usage: AgentUsage) => Record<string, T>,
119
+ merge: (all: T[]) => T
120
+ ): [string, T][] {
121
+ const grouped = new Map<string, T[]>();
122
+ for (const { usage } of agents) {
123
+ for (const [key, value] of Object.entries(pick(usage))) {
124
+ grouped.set(key, [...(grouped.get(key) ?? []), value]);
125
+ }
126
+ }
127
+ return [...grouped].map(([key, all]) => [key, merge(all)]);
128
+ }
129
+
130
+ export function modelsByCost(agents: AgentReport[]): [string, Bucket][] {
131
+ return byCost(
132
+ mergedEntries(agents, (usage) => usage.byModel, grandTotal),
133
+ (bucket) => bucket.cost
134
+ );
135
+ }
136
+
137
+ /** One project is the project you are in; a breakdown of it says nothing new. */
138
+ export function projectsByCost(agents: AgentReport[]): [string, TimeBuckets][] {
139
+ const entries = mergedEntries(agents, (usage) => usage.byProject, mergeTimeBuckets);
140
+ return entries.length <= 1 ? [] : byCost(entries, (buckets) => buckets.total.cost);
141
+ }
142
+
143
+ function byModelLines(agents: AgentReport[]): string[] {
144
+ const sorted = modelsByCost(agents);
93
145
  if (sorted.length === 0) return [];
94
- const names = sorted.map(([model]) => model.replace("claude-", ""));
146
+ const names = sorted.map(([model]) => modelLabel(model));
95
147
  const width = Math.max(DEFAULT_LABEL_WIDTH, ...names.map((name) => name.length + 2));
96
148
  return [
97
149
  "\n By Model (all time)\n",
@@ -99,30 +151,65 @@ function byModelLines(byModel: ClaudeCodeUsage["byModel"]): string[] {
99
151
  ];
100
152
  }
101
153
 
102
- /** One project is the project you are in; a breakdown of it says nothing new. */
103
- function byProjectLines(byProject: ClaudeCodeUsage["byProject"]): string[] {
104
- const entries = Object.entries(byProject);
105
- if (entries.length <= 1) return [];
106
- const sorted = byCost(entries, (buckets) => buckets.total.cost);
154
+ function byProjectLines(agents: AgentReport[]): string[] {
155
+ const sorted = projectsByCost(agents);
156
+ if (sorted.length === 0) return [];
107
157
  return [
108
158
  "\n By Project (all time)\n",
109
159
  ...sorted.map(([project, buckets]) => rowLine(project, buckets.total)),
110
160
  ];
111
161
  }
112
162
 
113
- function inferenceLabel(model: string): string {
163
+ export function inferenceLabel(model: string): string {
114
164
  if (model.includes("haiku")) return "Haiku";
115
165
  if (model.includes("sonnet")) return "Sonnet";
116
166
  return model.replace("claude-", "");
117
167
  }
118
168
 
119
- function palInferenceLines(byModel: PalInferenceUsage["byModel"]): string[] {
120
- const lines: string[] = [];
121
- for (const [model, buckets] of Object.entries(byModel)) {
122
- if (buckets.total.calls === 0) continue;
123
- lines.push(`\n PAL Inference (${inferenceLabel(model)})\n`, ...windowLines(buckets));
124
- }
125
- return lines;
169
+ export const usedInferenceModels = (pal: PalInferenceUsage) =>
170
+ Object.entries(pal.byModel).filter(([, buckets]) => buckets.total.calls > 0);
171
+
172
+ function palInferenceLines(pal: PalInferenceUsage): string[] {
173
+ return usedInferenceModels(pal).flatMap(([model, buckets]) => [
174
+ `\n PAL Inference (${inferenceLabel(model)})\n`,
175
+ ...windowLines(buckets),
176
+ ]);
177
+ }
178
+
179
+ function agentLines(agents: AgentReport[]): string[] {
180
+ const used = usedAgents(agents);
181
+ if (used.length === 0) return ["\n No agent usage recorded\n"];
182
+ return used.flatMap(({ label, usage }) => [
183
+ `\n ${label} Usage\n`,
184
+ ...windowLines(usage.buckets),
185
+ ]);
186
+ }
187
+
188
+ function untrackedLines(untracked: string[]): string[] {
189
+ return untracked.length === 0
190
+ ? []
191
+ : [`\n ${untracked.join(", ")}: no token counts recorded`];
192
+ }
193
+
194
+ export function grandBucket(data: UsageData): Bucket {
195
+ return grandTotal([
196
+ ...data.agents.map((agent) => agent.usage.buckets.total),
197
+ data.pal.buckets.total,
198
+ ]);
199
+ }
200
+
201
+ /** The agents whose spend the grand total cannot include, because they bill otherwise. */
202
+ export function unpricedAgents(agents: AgentReport[]): string[] {
203
+ return agents
204
+ .filter((agent) => agent.usage.buckets.total.unpriced > 0)
205
+ .map((agent) => agent.label);
206
+ }
207
+
208
+ function unpricedLines(agents: AgentReport[]): string[] {
209
+ const unpriced = unpricedAgents(agents);
210
+ return unpriced.length === 0
211
+ ? []
212
+ : [`\n Not priced: ${unpriced.join(", ")} (no per-token price)`];
126
213
  }
127
214
 
128
215
  export function rtkLines(gain: RtkGain): string[] {
@@ -155,19 +242,15 @@ export function parseRtkSummary(
155
242
  }
156
243
  }
157
244
 
158
- export function usageLines(
159
- claudeCode: ClaudeCodeUsage,
160
- pal: PalInferenceUsage,
161
- rtk: RtkGain
162
- ): string[] {
163
- const grand = grandTotal([claudeCode.buckets.total, pal.buckets.total]);
245
+ export function usageLines(data: UsageData): string[] {
164
246
  return [
165
- "\n Claude Code Usage\n",
166
- ...windowLines(claudeCode.buckets),
167
- ...byModelLines(claudeCode.byModel),
168
- ...byProjectLines(claudeCode.byProject),
169
- ...palInferenceLines(pal.byModel),
170
- ...rtkLines(rtk),
171
- `\n Grand Total: ${fmtCost(grand.cost)}\n`,
247
+ ...agentLines(data.agents),
248
+ ...untrackedLines(data.untracked),
249
+ ...byModelLines(data.agents),
250
+ ...byProjectLines(data.agents),
251
+ ...palInferenceLines(data.pal),
252
+ ...rtkLines(data.rtk),
253
+ ...unpricedLines(data.agents),
254
+ `\n Grand Total: ${fmtCost(grandBucket(data).cost)}\n`,
172
255
  ];
173
256
  }
@@ -1,10 +1,7 @@
1
1
  /**
2
- * What has been spent, across the two places PAL can learn it from: Claude Code's
3
- * own transcripts and PAL's inference log.
4
- *
5
- * The tool around this is spawned, so the arithmetic that decides what a month
6
- * costs was never checked. Both readers take the directory to read rather than
7
- * finding it themselves, which is the only thing that made them testable.
2
+ * What has been spent: the buckets every agent's reader fills, Claude Code's
3
+ * transcripts, and PAL's inference log. Readers take the directory to read
4
+ * rather than finding it themselves, which is what makes them testable.
8
5
  */
9
6
 
10
7
  import { existsSync, readdirSync, readFileSync } from "node:fs";
@@ -20,6 +17,7 @@ export interface Bucket {
20
17
  cacheRead: number;
21
18
  cost: number;
22
19
  calls: number;
20
+ unpriced: number;
23
21
  }
24
22
 
25
23
  export interface TimeBuckets {
@@ -55,6 +53,7 @@ export function emptyBucket(): Bucket {
55
53
  cacheRead: 0,
56
54
  cost: 0,
57
55
  calls: 0,
56
+ unpriced: 0,
58
57
  };
59
58
  }
60
59
 
@@ -79,20 +78,38 @@ export function horizonsFrom(now: Date): Horizons {
79
78
  };
80
79
  }
81
80
 
82
- export function addToBucket(bucket: Bucket, model: string, tokens: Tokens): void {
81
+ /** A null cost is a call the agent does not bill per token; its tokens still count. */
82
+ function addCall(bucket: Bucket, tokens: Tokens, cost: number | null, calls = 1): void {
83
83
  bucket.input += tokens.input;
84
84
  bucket.output += tokens.output;
85
85
  bucket.cacheWrite5m += tokens.cacheWrite5m;
86
86
  bucket.cacheWrite1h += tokens.cacheWrite1h;
87
87
  bucket.cacheRead += tokens.cacheRead;
88
- bucket.cost += costOfUsage(model, tokens);
89
- bucket.calls++;
88
+ bucket.cost += cost ?? 0;
89
+ bucket.calls += calls;
90
+ if (cost === null) bucket.unpriced += calls;
91
+ }
92
+
93
+ export function addToBucket(bucket: Bucket, model: string, tokens: Tokens): void {
94
+ addCall(bucket, tokens, costOfUsage(model, tokens));
90
95
  }
91
96
 
92
97
  /**
93
98
  * The windows nest: everything in today is also in the week, the month and the
94
99
  * total, so a call is added to every window it falls inside rather than to one.
95
100
  */
101
+ function addToWindows(
102
+ buckets: TimeBuckets,
103
+ ts: string,
104
+ horizons: Horizons,
105
+ add: (bucket: Bucket) => void
106
+ ): void {
107
+ add(buckets.total);
108
+ if (ts >= horizons.monthAgo) add(buckets.month);
109
+ if (ts >= horizons.weekAgo) add(buckets.week);
110
+ if (ts.startsWith(horizons.todayPrefix)) add(buckets.today);
111
+ }
112
+
96
113
  export function addToTimeBuckets(
97
114
  buckets: TimeBuckets,
98
115
  ts: string,
@@ -100,10 +117,7 @@ export function addToTimeBuckets(
100
117
  tokens: Tokens,
101
118
  horizons: Horizons
102
119
  ): void {
103
- addToBucket(buckets.total, model, tokens);
104
- if (ts >= horizons.monthAgo) addToBucket(buckets.month, model, tokens);
105
- if (ts >= horizons.weekAgo) addToBucket(buckets.week, model, tokens);
106
- if (ts.startsWith(horizons.todayPrefix)) addToBucket(buckets.today, model, tokens);
120
+ addToWindows(buckets, ts, horizons, (bucket) => addToBucket(bucket, model, tokens));
107
121
  }
108
122
 
109
123
  export function totalTokens(bucket: Bucket): number {
@@ -122,12 +136,47 @@ export function projectNameOf(dirName: string): string {
122
136
  return segments.length > 1 ? segments.slice(-1)[0] : dirName;
123
137
  }
124
138
 
125
- export interface ClaudeCodeUsage {
139
+ export interface AgentUsage {
126
140
  buckets: TimeBuckets;
127
141
  byModel: Record<string, Bucket>;
128
142
  byProject: Record<string, TimeBuckets>;
129
143
  }
130
144
 
145
+ export function emptyAgentUsage(): AgentUsage {
146
+ return { buckets: emptyTimeBuckets(), byModel: {}, byProject: {} };
147
+ }
148
+
149
+ /** One or more model calls of a session, as every agent's reader hands them over. */
150
+ interface UsageCall {
151
+ ts: string;
152
+ model: string;
153
+ project: string;
154
+ tokens: Tokens;
155
+ cost: number | null;
156
+ calls?: number;
157
+ }
158
+
159
+ export interface UsageTally {
160
+ usage: AgentUsage;
161
+ record: (call: UsageCall) => void;
162
+ }
163
+
164
+ export function usageTally(projectFilter?: string, now: Date = new Date()): UsageTally {
165
+ const horizons = horizonsFrom(now);
166
+ const usage = emptyAgentUsage();
167
+ const record = (call: UsageCall) => {
168
+ if (typeof projectFilter === "string" && !call.project.includes(projectFilter))
169
+ return;
170
+ const add = (bucket: Bucket) => addCall(bucket, call.tokens, call.cost, call.calls);
171
+ addToWindows(usage.buckets, call.ts, horizons, add);
172
+ usage.byModel[call.model] ??= emptyBucket();
173
+ add(usage.byModel[call.model]);
174
+ usage.byProject[call.project] ??= emptyTimeBuckets();
175
+ addToWindows(usage.byProject[call.project], call.ts, horizons, add);
176
+ };
177
+ return { usage, record };
178
+ }
179
+
131
180
  interface TranscriptLine {
132
181
  type?: string;
133
182
  timestamp?: string;
@@ -196,14 +245,9 @@ export function readClaudeCode(
196
245
  claudeDir: string,
197
246
  projectFilter?: string,
198
247
  now: Date = new Date()
199
- ): ClaudeCodeUsage {
200
- const horizons = horizonsFrom(now);
201
- const result: ClaudeCodeUsage = {
202
- buckets: emptyTimeBuckets(),
203
- byModel: {},
204
- byProject: {},
205
- };
206
- if (!existsSync(claudeDir)) return result;
248
+ ): AgentUsage {
249
+ const tally = usageTally(projectFilter, now);
250
+ if (!existsSync(claudeDir)) return tally.usage;
207
251
 
208
252
  const projectDirs = readdirSync(claudeDir, { withFileTypes: true })
209
253
  .filter((entry) => entry.isDirectory())
@@ -226,25 +270,16 @@ export function readClaudeCode(
226
270
  if (!line.includes('"usage"')) continue;
227
271
  const call = pricedCallOf(line);
228
272
  if (!call) continue;
229
-
230
- addToTimeBuckets(result.buckets, call.ts, call.model, call.tokens, horizons);
231
-
232
- result.byModel[call.model] ??= emptyBucket();
233
- addToBucket(result.byModel[call.model], call.model, call.tokens);
234
-
235
- result.byProject[projName] ??= emptyTimeBuckets();
236
- addToTimeBuckets(
237
- result.byProject[projName],
238
- call.ts,
239
- call.model,
240
- call.tokens,
241
- horizons
242
- );
273
+ tally.record({
274
+ ...call,
275
+ project: projName,
276
+ cost: costOfUsage(call.model, call.tokens),
277
+ });
243
278
  }
244
279
  }
245
280
  }
246
281
 
247
- return result;
282
+ return tally.usage;
248
283
  }
249
284
 
250
285
  export interface PalInferenceUsage {
@@ -324,6 +359,16 @@ export function grandTotal(buckets: Bucket[]): Bucket {
324
359
  grand.cacheRead += bucket.cacheRead;
325
360
  grand.cost += bucket.cost;
326
361
  grand.calls += bucket.calls;
362
+ grand.unpriced += bucket.unpriced;
327
363
  }
328
364
  return grand;
329
365
  }
366
+
367
+ export function mergeTimeBuckets(all: TimeBuckets[]): TimeBuckets {
368
+ return {
369
+ today: grandTotal(all.map((b) => b.today)),
370
+ week: grandTotal(all.map((b) => b.week)),
371
+ month: grandTotal(all.map((b) => b.month)),
372
+ total: grandTotal(all.map((b) => b.total)),
373
+ };
374
+ }
@@ -1,21 +1,50 @@
1
1
  /**
2
- * Summarize token usage and estimated cost.
3
- *
4
- * Reads from two sources:
5
- * 1. Claude Code session transcripts (~/.claude/projects/)
6
- * 2. PAL Haiku inference logs (memory/signals/token-usage.jsonl)
2
+ * Summarize token usage and estimated cost: every agent's own session records,
3
+ * PAL's inference log, and rtk's savings.
7
4
  */
8
5
 
9
6
  import { spawnSync } from "node:child_process";
10
7
  import { homedir } from "node:os";
11
8
  import { resolve } from "node:path";
12
- import { palHome } from "../hooks/lib/paths";
9
+ import { AGENT_REGISTRY, type AgentName } from "../hooks/lib/agent-registry";
10
+ import { palHome, platform } from "../hooks/lib/paths";
13
11
  import { findBinaryOnPath } from "../hooks/lib/which";
14
- import { leaf, runCommand } from "./lib/command";
15
- import { scriptArgs } from "./lib/script-args";
16
- import { parseRtkSummary, type RtkGain, usageLines } from "./lib/token-report";
12
+ import { readCodex, readCopilot, readOpencode } from "./lib/agent-usage";
13
+ import {
14
+ type AgentReport,
15
+ parseRtkSummary,
16
+ type RtkGain,
17
+ type UsageData,
18
+ } from "./lib/token-report";
17
19
  import { readClaudeCode, readPalInference } from "./lib/usage-buckets";
18
20
 
21
+ const UNTRACKED_AGENTS: AgentName[] = ["cursor", "antigravity"];
22
+
23
+ function opencodeDatabase(): string {
24
+ const dataHome = process.env.XDG_DATA_HOME || resolve(homedir(), ".local", "share");
25
+ return resolve(dataHome, "opencode", "opencode.db");
26
+ }
27
+
28
+ function agentReports(project: string | undefined): AgentReport[] {
29
+ const report = (agent: AgentName, read: () => AgentReport["usage"]) => ({
30
+ label: AGENT_REGISTRY[agent].label,
31
+ usage: read(),
32
+ });
33
+ return [
34
+ report("claude", () =>
35
+ readClaudeCode(resolve(platform.claudeDir(), "projects"), project)
36
+ ),
37
+ report("codex", () => readCodex(platform.codexDir(), project)),
38
+ report("opencode", () => readOpencode(opencodeDatabase(), project)),
39
+ report("copilot", () => readCopilot(platform.copilotDir(), project)),
40
+ ];
41
+ }
42
+
43
+ const installedUntracked = () =>
44
+ UNTRACKED_AGENTS.filter((agent) => findBinaryOnPath(AGENT_REGISTRY[agent].binary)).map(
45
+ (agent) => AGENT_REGISTRY[agent].label
46
+ );
47
+
19
48
  function rtkGain(): RtkGain {
20
49
  const rtk = findBinaryOnPath("rtk");
21
50
  if (!rtk) return { installed: false, summary: null };
@@ -29,26 +58,11 @@ function rtkGain(): RtkGain {
29
58
  };
30
59
  }
31
60
 
32
- function printUsage(project: string | undefined): undefined {
33
- const lines = usageLines(
34
- readClaudeCode(resolve(homedir(), ".claude", "projects"), project),
35
- readPalInference(resolve(palHome(), "memory", "signals", "token-usage.jsonl")),
36
- rtkGain()
37
- );
38
- for (const line of lines) console.log(line);
61
+ export function collectUsage(project: string | undefined): UsageData {
62
+ return {
63
+ agents: agentReports(project),
64
+ pal: readPalInference(resolve(palHome(), "memory", "signals", "token-usage.jsonl")),
65
+ rtk: rtkGain(),
66
+ untracked: installedUntracked(),
67
+ };
39
68
  }
40
-
41
- export const usageCommand = leaf({
42
- summary: "Summarize token usage and estimated cost for today, 7 and 30 days",
43
- options: {
44
- project: {
45
- type: "string",
46
- value: "<name>",
47
- description: "Only Claude Code sessions of this project",
48
- },
49
- },
50
- run: ({ values }) => printUsage(values.project),
51
- });
52
-
53
- if (import.meta.main)
54
- process.exit(await runCommand(usageCommand, scriptArgs(), ["pal", "cli", "usage"]));