portable-agent-layer 0.91.4 → 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 +1 -1
- package/src/cli/tree.ts +1 -1
- package/src/cli/usage-render.ts +168 -0
- package/src/cli/usage.ts +24 -0
- package/src/tools/lib/agent-usage.ts +259 -0
- package/src/tools/lib/token-report.ts +127 -44
- package/src/tools/lib/usage-buckets.ts +82 -37
- package/src/tools/token-cost.ts +45 -31
package/package.json
CHANGED
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
|
+
}
|
package/src/cli/usage.ts
ADDED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
73
|
-
today
|
|
74
|
-
week
|
|
75
|
-
month
|
|
76
|
-
total
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
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
|
-
|
|
166
|
-
...
|
|
167
|
-
...byModelLines(
|
|
168
|
-
...byProjectLines(
|
|
169
|
-
...palInferenceLines(pal
|
|
170
|
-
...rtkLines(rtk),
|
|
171
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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
|
|
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
|
-
):
|
|
200
|
-
const
|
|
201
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
+
}
|
package/src/tools/token-cost.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
15
|
-
import {
|
|
16
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
readPalInference(resolve(palHome(), "memory", "signals", "token-usage.jsonl")),
|
|
36
|
-
rtkGain()
|
|
37
|
-
|
|
38
|
-
|
|
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"]));
|