portable-agent-layer 0.97.0 → 0.99.0
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/README.md +1 -0
- package/package.json +2 -2
- package/src/cli/cost.ts +18 -0
- package/src/cli/events.ts +22 -0
- package/src/cli/tree.ts +2 -0
- package/src/hooks/lib/commitment-events.ts +3 -3
- package/src/hooks/lib/entity-events.ts +18 -5
- package/src/hooks/lib/events.ts +54 -4
- package/src/hooks/lib/interaction.ts +25 -5
- package/src/hooks/lib/ledger.ts +61 -2
- package/src/hooks/lib/models.ts +42 -10
- package/src/hooks/lib/remote.ts +3 -0
- package/src/hooks/lib/signals.ts +54 -4
- package/src/hooks/lib/work-tracking.ts +39 -2
- package/src/targets/lib.ts +1 -3
- package/src/tools/agent/project.ts +7 -2
- package/src/tools/knowledge/lib.ts +11 -2
- package/src/tools/lib/backfill/entities.ts +31 -0
- package/src/tools/lib/backfill/git.ts +184 -0
- package/src/tools/lib/backfill/index.ts +38 -0
- package/src/tools/lib/backfill/ledger.ts +28 -0
- package/src/tools/lib/backfill/projects.ts +40 -0
- package/src/tools/lib/backfill/promises.ts +91 -0
- package/src/tools/lib/backfill/ratings.ts +21 -0
- package/src/tools/lib/backfill/rows.ts +33 -0
- package/src/tools/lib/backfill/sessions.ts +56 -0
- package/src/tools/lib/backfill/turns.ts +32 -0
- package/src/tools/lib/collectors/billing.ts +105 -0
- package/src/tools/lib/collectors/claude.ts +61 -21
- package/src/tools/lib/collectors/codex.ts +6 -3
- package/src/tools/lib/collectors/copilot.ts +2 -0
- package/src/tools/lib/collectors/index.ts +8 -1
- package/src/tools/lib/collectors/opencode.ts +10 -2
- package/src/tools/lib/cost-report.ts +134 -0
- package/src/tools/lib/plan-prices.ts +24 -0
- package/src/tools/lib/project-history.ts +100 -0
- package/src/tools/lib/session-usage.ts +11 -7
- package/src/tools/lib/usage-buckets.ts +10 -5
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How each agent's model calls are paid for: a subscription, an API key, or
|
|
3
|
+
* unknown. Each reader takes the few fields that say so and nothing else from
|
|
4
|
+
* files that also hold the agent's credentials.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, resolve } from "node:path";
|
|
9
|
+
import { platform } from "../../../hooks/lib/paths";
|
|
10
|
+
|
|
11
|
+
export interface Billing {
|
|
12
|
+
billing: "subscription" | "api" | "unknown";
|
|
13
|
+
plan: string | null;
|
|
14
|
+
extraUsage: boolean | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const UNKNOWN: Billing = { billing: "unknown", plan: null, extraUsage: null };
|
|
18
|
+
const API: Billing = { billing: "api", plan: null, extraUsage: null };
|
|
19
|
+
const SUBSCRIPTION: Billing = { billing: "subscription", plan: null, extraUsage: null };
|
|
20
|
+
|
|
21
|
+
type Json = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
function readJson(path: string): Json {
|
|
24
|
+
try {
|
|
25
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, "utf-8")) : {};
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stringField(record: unknown, name: string): string | null {
|
|
32
|
+
const value = (record as Json | undefined)?.[name];
|
|
33
|
+
return typeof value === "string" ? value : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function booleanField(record: unknown, name: string): boolean | null {
|
|
37
|
+
const value = (record as Json | undefined)?.[name];
|
|
38
|
+
return typeof value === "boolean" ? value : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ClaudeSubscription {
|
|
42
|
+
plan: string | null;
|
|
43
|
+
extraUsage: boolean | null;
|
|
44
|
+
since: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function claudeSubscriptionOf(config: Json): ClaudeSubscription | null {
|
|
48
|
+
const account = config.oauthAccount;
|
|
49
|
+
if (!stringField(account, "billingType")) return null;
|
|
50
|
+
return {
|
|
51
|
+
plan:
|
|
52
|
+
stringField(account, "organizationRateLimitTier") ??
|
|
53
|
+
stringField(account, "organizationType"),
|
|
54
|
+
extraUsage: booleanField(account, "hasExtraUsageEnabled"),
|
|
55
|
+
since: Date.parse(stringField(account, "subscriptionCreatedAt") ?? ""),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function claudeConfigFile(): string {
|
|
60
|
+
return resolve(dirname(platform.claudeDir()), ".claude.json");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Billing for a call at `at`; a call older than the subscription is unknown. */
|
|
64
|
+
export function claudeBilling(configPath: string): (at: string) => Billing {
|
|
65
|
+
const config = readJson(configPath);
|
|
66
|
+
const subscription = claudeSubscriptionOf(config);
|
|
67
|
+
const apiKeyLogin = "primaryApiKey" in config;
|
|
68
|
+
return (at) => {
|
|
69
|
+
if (!subscription) return apiKeyLogin ? API : UNKNOWN;
|
|
70
|
+
if (Date.parse(at) < subscription.since) return UNKNOWN;
|
|
71
|
+
return {
|
|
72
|
+
billing: "subscription",
|
|
73
|
+
plan: subscription.plan,
|
|
74
|
+
extraUsage: subscription.extraUsage,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const CODEX_AUTH_MODES: Record<string, Billing> = { apikey: API, chatgpt: SUBSCRIPTION };
|
|
80
|
+
|
|
81
|
+
export function codexBilling(authPath: string): Billing {
|
|
82
|
+
return CODEX_AUTH_MODES[stringField(readJson(authPath), "auth_mode") ?? ""] ?? UNKNOWN;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const OPENCODE_AUTH_TYPES: Record<string, Billing> = {
|
|
86
|
+
oauth: SUBSCRIPTION,
|
|
87
|
+
api: API,
|
|
88
|
+
wellknown: API,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Billing per provider, from the type of the login opencode holds for it. */
|
|
92
|
+
export function opencodeBilling(
|
|
93
|
+
authPath: string
|
|
94
|
+
): (provider: string | undefined) => Billing {
|
|
95
|
+
const logins = readJson(authPath);
|
|
96
|
+
const billingOf = new Map(
|
|
97
|
+
Object.entries(logins).map(([provider, login]) => [
|
|
98
|
+
provider,
|
|
99
|
+
OPENCODE_AUTH_TYPES[stringField(login, "type") ?? ""] ?? UNKNOWN,
|
|
100
|
+
])
|
|
101
|
+
);
|
|
102
|
+
return (provider) => (provider && billingOf.get(provider)) || UNKNOWN;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const COPILOT_BILLING = SUBSCRIPTION;
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* cursor stops where it starts.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFileSync, statSync } from "node:fs";
|
|
11
|
-
import { relative, sep } from "node:path";
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { relative, resolve, sep } from "node:path";
|
|
12
12
|
import { appendEvent } from "../../../hooks/lib/events";
|
|
13
13
|
import { cacheWritesOf, type TranscriptUsage } from "../transcript-usage";
|
|
14
14
|
import { claudeTranscripts } from "../usage-buckets";
|
|
15
|
+
import { type Billing, UNKNOWN } from "./billing";
|
|
15
16
|
import { readCursor, writeCursor } from "./cursors";
|
|
16
17
|
|
|
17
18
|
const SETTLE_MS = 5 * 60_000;
|
|
@@ -34,12 +35,14 @@ interface ClaudeLine {
|
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
type ModelCallLine = ClaudeLine & { timestamp: string };
|
|
39
|
+
|
|
37
40
|
interface SettledRead {
|
|
38
|
-
messages:
|
|
41
|
+
messages: ModelCallLine[];
|
|
39
42
|
resumeAt: number;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
function isModelCall(line: ClaudeLine | null): line is
|
|
45
|
+
function isModelCall(line: ClaudeLine | null): line is ModelCallLine {
|
|
43
46
|
return (
|
|
44
47
|
line?.type === "assistant" &&
|
|
45
48
|
!!line.message?.usage &&
|
|
@@ -57,8 +60,7 @@ function parsed(text: string): ClaudeLine | null {
|
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
function completeLinesFrom(
|
|
61
|
-
const bytes = readFileSync(filepath);
|
|
63
|
+
function completeLinesFrom(bytes: Buffer, offset: number) {
|
|
62
64
|
const end = bytes.lastIndexOf(10) + 1;
|
|
63
65
|
if (end <= offset) return { lines: [], end: offset };
|
|
64
66
|
const lines: { text: string; start: number }[] = [];
|
|
@@ -73,13 +75,9 @@ function completeLinesFrom(filepath: string, offset: number) {
|
|
|
73
75
|
return { lines, end };
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
function settledMessages(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
settled: boolean
|
|
80
|
-
): SettledRead {
|
|
81
|
-
const { lines, end } = completeLinesFrom(filepath, offset);
|
|
82
|
-
const groups = new Map<string, { start: number; line: ClaudeLine }>();
|
|
78
|
+
function settledMessages(bytes: Buffer, offset: number, settled: boolean): SettledRead {
|
|
79
|
+
const { lines, end } = completeLinesFrom(bytes, offset);
|
|
80
|
+
const groups = new Map<string, { start: number; line: ModelCallLine }>();
|
|
83
81
|
let newest: string | undefined;
|
|
84
82
|
for (const { text, start } of lines) {
|
|
85
83
|
if (!text.includes('"usage"')) continue;
|
|
@@ -97,11 +95,12 @@ function settledMessages(
|
|
|
97
95
|
};
|
|
98
96
|
}
|
|
99
97
|
|
|
100
|
-
function emitModelCall(line: ClaudeLine): void {
|
|
98
|
+
function emitModelCall(line: ClaudeLine, billing: Billing): void {
|
|
101
99
|
const usage = line.message?.usage ?? {};
|
|
102
100
|
appendEvent(
|
|
103
101
|
"model_call",
|
|
104
102
|
{
|
|
103
|
+
...billing,
|
|
105
104
|
model: line.message?.model,
|
|
106
105
|
requestedModel: line.requestedModel,
|
|
107
106
|
inputTokens: usage.input_tokens,
|
|
@@ -132,17 +131,58 @@ function isSettled(filepath: string, now: Date): boolean {
|
|
|
132
131
|
return now.getTime() - statSync(filepath).mtimeMs > SETTLE_MS;
|
|
133
132
|
}
|
|
134
133
|
|
|
135
|
-
|
|
134
|
+
interface Transcript {
|
|
135
|
+
key: string;
|
|
136
|
+
bytes: () => Buffer;
|
|
137
|
+
settled: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function liveTranscripts(projectsDir: string, now: Date): Transcript[] {
|
|
141
|
+
return claudeTranscripts(projectsDir).map((filepath) => ({
|
|
142
|
+
key: relative(projectsDir, filepath).split(sep).join("/"),
|
|
143
|
+
bytes: () => readFileSync(filepath),
|
|
144
|
+
settled: isSettled(filepath, now),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function copiesOfDeletedTranscripts(
|
|
149
|
+
copiesDir: string,
|
|
150
|
+
projectsDir: string
|
|
151
|
+
): Transcript[] {
|
|
152
|
+
if (!existsSync(copiesDir)) return [];
|
|
153
|
+
return readdirSync(copiesDir, { recursive: true, encoding: "utf-8" })
|
|
154
|
+
.map((file) => file.split(sep).join("/"))
|
|
155
|
+
.filter((file) => file.endsWith(".jsonl.gz"))
|
|
156
|
+
.map((file) => file.slice(0, -".gz".length))
|
|
157
|
+
.filter((key) => !existsSync(resolve(projectsDir, key)))
|
|
158
|
+
.map((key) => ({
|
|
159
|
+
key,
|
|
160
|
+
bytes: () =>
|
|
161
|
+
Buffer.from(Bun.gunzipSync(readFileSync(resolve(copiesDir, `${key}.gz`)))),
|
|
162
|
+
settled: true,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Reads each transcript from its cursor; one Claude Code already deleted is read from PAL's copy. */
|
|
167
|
+
export function collectClaude(
|
|
168
|
+
projectsDir: string,
|
|
169
|
+
now: Date = new Date(),
|
|
170
|
+
billingAt: (at: string) => Billing = () => UNKNOWN,
|
|
171
|
+
copiesDir?: string
|
|
172
|
+
): number {
|
|
136
173
|
const cursor = readCursor("claude");
|
|
137
174
|
let emitted = 0;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
175
|
+
const transcripts = [
|
|
176
|
+
...liveTranscripts(projectsDir, now),
|
|
177
|
+
...(copiesDir ? copiesOfDeletedTranscripts(copiesDir, projectsDir) : []),
|
|
178
|
+
];
|
|
179
|
+
for (const transcript of transcripts) {
|
|
180
|
+
const offset = Number(cursor[transcript.key] ?? 0);
|
|
181
|
+
const read = settledMessages(transcript.bytes(), offset, transcript.settled);
|
|
142
182
|
if (read.resumeAt === offset) continue;
|
|
143
|
-
for (const line of read.messages) emitModelCall(line);
|
|
183
|
+
for (const line of read.messages) emitModelCall(line, billingAt(line.timestamp));
|
|
144
184
|
emitted += read.messages.length;
|
|
145
|
-
cursor[key] = read.resumeAt;
|
|
185
|
+
cursor[transcript.key] = read.resumeAt;
|
|
146
186
|
writeCursor("claude", cursor);
|
|
147
187
|
}
|
|
148
188
|
return emitted;
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { readFileSync, statSync } from "node:fs";
|
|
8
|
-
import { relative, sep } from "node:path";
|
|
8
|
+
import { relative, resolve, sep } from "node:path";
|
|
9
9
|
import { appendEvent } from "../../../hooks/lib/events";
|
|
10
10
|
import { codexRollouts } from "../agent-usage";
|
|
11
|
+
import { type Billing, codexBilling } from "./billing";
|
|
11
12
|
import { readCursor, writeCursor } from "./cursors";
|
|
12
13
|
|
|
13
14
|
interface CodexUsage {
|
|
@@ -90,12 +91,13 @@ function callsOf(filepath: string): CodexCall[] {
|
|
|
90
91
|
return calls;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
function emitModelCall({ ts, usage, context }: CodexCall): void {
|
|
94
|
+
function emitModelCall({ ts, usage, context }: CodexCall, billing: Billing): void {
|
|
94
95
|
const cacheRead = usage.cached_input_tokens ?? 0;
|
|
95
96
|
const cacheWrite = usage.cache_write_input_tokens ?? 0;
|
|
96
97
|
appendEvent(
|
|
97
98
|
"model_call",
|
|
98
99
|
{
|
|
100
|
+
...billing,
|
|
99
101
|
model: context.model,
|
|
100
102
|
provider: context.provider,
|
|
101
103
|
inputTokens: (usage.input_tokens ?? 0) - cacheRead - cacheWrite,
|
|
@@ -111,6 +113,7 @@ function emitModelCall({ ts, usage, context }: CodexCall): void {
|
|
|
111
113
|
|
|
112
114
|
export function collectCodex(codexDir: string): number {
|
|
113
115
|
const cursor = readCursor("codex");
|
|
116
|
+
const billing = codexBilling(resolve(codexDir, "auth.json"));
|
|
114
117
|
let emitted = 0;
|
|
115
118
|
for (const filepath of codexRollouts(codexDir)) {
|
|
116
119
|
const key = relative(codexDir, filepath).split(sep).join("/");
|
|
@@ -119,7 +122,7 @@ export function collectCodex(codexDir: string): number {
|
|
|
119
122
|
const collectedTotal = Number(cursor[key] ?? -1);
|
|
120
123
|
for (const call of callsOf(filepath)) {
|
|
121
124
|
if (call.total <= collectedTotal) continue;
|
|
122
|
-
emitModelCall(call);
|
|
125
|
+
emitModelCall(call, billing);
|
|
123
126
|
emitted++;
|
|
124
127
|
cursor[key] = call.total;
|
|
125
128
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { appendEvent } from "../../../hooks/lib/events";
|
|
10
|
+
import { COPILOT_BILLING } from "./billing";
|
|
10
11
|
import { readCursor, writeCursor } from "./cursors";
|
|
11
12
|
import { type AllowList, selectAllowed } from "./sqlite";
|
|
12
13
|
|
|
@@ -66,6 +67,7 @@ function emitModelCall(row: UsageRow, session: SessionRow | undefined): void {
|
|
|
66
67
|
appendEvent(
|
|
67
68
|
"model_call",
|
|
68
69
|
{
|
|
70
|
+
...COPILOT_BILLING,
|
|
69
71
|
model: row.model,
|
|
70
72
|
inputTokens: num(row.input_tokens) - cacheRead,
|
|
71
73
|
outputTokens: row.output_tokens,
|
|
@@ -12,6 +12,7 @@ import { paths, platform } from "../../../hooks/lib/paths";
|
|
|
12
12
|
import { eventLogEnabled } from "../../../hooks/lib/settings";
|
|
13
13
|
import { opencodeDatabase } from "../agent-usage";
|
|
14
14
|
import { antigravityDatabase, collectAntigravity } from "./antigravity";
|
|
15
|
+
import { claudeBilling, claudeConfigFile } from "./billing";
|
|
15
16
|
import { collectClaude } from "./claude";
|
|
16
17
|
import { collectCodex } from "./codex";
|
|
17
18
|
import { collectCopilot, copilotDatabase } from "./copilot";
|
|
@@ -20,7 +21,13 @@ import { collectOpencode } from "./opencode";
|
|
|
20
21
|
const LOCK_STALE_MS = 10 * 60_000;
|
|
21
22
|
|
|
22
23
|
const COLLECTORS: Record<string, () => number> = {
|
|
23
|
-
claude: () =>
|
|
24
|
+
claude: () =>
|
|
25
|
+
collectClaude(
|
|
26
|
+
resolve(platform.claudeDir(), "projects"),
|
|
27
|
+
new Date(),
|
|
28
|
+
claudeBilling(claudeConfigFile()),
|
|
29
|
+
resolve(paths.content(), "claude")
|
|
30
|
+
),
|
|
24
31
|
opencode: () => collectOpencode(opencodeDatabase()),
|
|
25
32
|
codex: () => collectCodex(platform.codexDir()),
|
|
26
33
|
copilot: () => collectCopilot(copilotDatabase(platform.copilotDir())),
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
|
+
import { dirname, resolve } from "node:path";
|
|
8
9
|
import { appendEvent } from "../../../hooks/lib/events";
|
|
10
|
+
import { type Billing, opencodeBilling } from "./billing";
|
|
9
11
|
import { readCursor, writeCursor } from "./cursors";
|
|
10
12
|
import { type AllowList, selectAllowed } from "./sqlite";
|
|
11
13
|
|
|
@@ -57,11 +59,16 @@ function durationOf(message: AssistantMessage): number | undefined {
|
|
|
57
59
|
return created && completed ? completed - created : undefined;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
function emitModelCall(
|
|
62
|
+
function emitModelCall(
|
|
63
|
+
row: MessageRow,
|
|
64
|
+
message: AssistantMessage,
|
|
65
|
+
billing: Billing
|
|
66
|
+
): void {
|
|
61
67
|
const tokens = message.tokens ?? {};
|
|
62
68
|
appendEvent(
|
|
63
69
|
"model_call",
|
|
64
70
|
{
|
|
71
|
+
...billing,
|
|
65
72
|
model: message.modelID,
|
|
66
73
|
provider: message.providerID,
|
|
67
74
|
inputTokens: tokens.input,
|
|
@@ -99,12 +106,13 @@ export function collectOpencode(dbPath: string, now: Date = new Date()): number
|
|
|
99
106
|
id: String(cursor.id ?? ""),
|
|
100
107
|
}
|
|
101
108
|
);
|
|
109
|
+
const billingOf = opencodeBilling(resolve(dirname(dbPath), "auth.json"));
|
|
102
110
|
let emitted = 0;
|
|
103
111
|
for (const row of rows) {
|
|
104
112
|
const message = assistantMessageOf(row);
|
|
105
113
|
if (message && stillStreaming(message, row, now)) break;
|
|
106
114
|
if (message) {
|
|
107
|
-
emitModelCall(row, message);
|
|
115
|
+
emitModelCall(row, message, billingOf(message.providerID));
|
|
108
116
|
emitted++;
|
|
109
117
|
}
|
|
110
118
|
cursor.order = row.time_created;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each month cost against what the same calls would have cost at API
|
|
3
|
+
* prices, read off the model_call events. A call is priced exactly as
|
|
4
|
+
* pal cli usage prices it, so the two never disagree about the same data.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LoggedEvent } from "../../hooks/lib/events";
|
|
8
|
+
import { costOfUsage } from "../../hooks/lib/models";
|
|
9
|
+
import { planPriceFor } from "./plan-prices";
|
|
10
|
+
import { fmtCost } from "./token-report";
|
|
11
|
+
|
|
12
|
+
export interface AgentMonth {
|
|
13
|
+
calls: number;
|
|
14
|
+
unpriced: number;
|
|
15
|
+
apiEquivalent: number;
|
|
16
|
+
billing: Set<string>;
|
|
17
|
+
plans: Map<string, string>;
|
|
18
|
+
apiBilled: number;
|
|
19
|
+
extraUsage: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CostMonths = Map<string, Map<string, AgentMonth>>;
|
|
23
|
+
|
|
24
|
+
const numberOf = (value: unknown) => (typeof value === "number" ? value : 0);
|
|
25
|
+
|
|
26
|
+
/** Claude Code at its dated rates, opencode at the cost it recorded, anything else unknown. */
|
|
27
|
+
function apiPriceOf(event: LoggedEvent): number | null {
|
|
28
|
+
if (event.runtime === "opencode")
|
|
29
|
+
return typeof event.costUsd === "number" ? event.costUsd : null;
|
|
30
|
+
if (event.runtime !== "claude" || typeof event.model !== "string") return null;
|
|
31
|
+
return costOfUsage(
|
|
32
|
+
event.model,
|
|
33
|
+
{
|
|
34
|
+
input: numberOf(event.inputTokens),
|
|
35
|
+
output: numberOf(event.outputTokens),
|
|
36
|
+
cacheWrite5m: numberOf(event.cacheWrite5m),
|
|
37
|
+
cacheWrite1h: numberOf(event.cacheWrite1h),
|
|
38
|
+
cacheRead: numberOf(event.cacheRead),
|
|
39
|
+
},
|
|
40
|
+
event.ts
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emptyAgentMonth(): AgentMonth {
|
|
45
|
+
return {
|
|
46
|
+
calls: 0,
|
|
47
|
+
unpriced: 0,
|
|
48
|
+
apiEquivalent: 0,
|
|
49
|
+
billing: new Set(),
|
|
50
|
+
plans: new Map(),
|
|
51
|
+
apiBilled: 0,
|
|
52
|
+
extraUsage: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function addCall(month: AgentMonth, event: LoggedEvent): void {
|
|
57
|
+
const price = apiPriceOf(event);
|
|
58
|
+
const billing = typeof event.billing === "string" ? event.billing : "unknown";
|
|
59
|
+
month.calls++;
|
|
60
|
+
month.billing.add(billing);
|
|
61
|
+
if (price === null) month.unpriced++;
|
|
62
|
+
else month.apiEquivalent += price;
|
|
63
|
+
if (billing === "api") month.apiBilled += price ?? 0;
|
|
64
|
+
if (billing === "subscription" && typeof event.plan === "string")
|
|
65
|
+
month.plans.set(event.plan, month.plans.get(event.plan) ?? event.ts);
|
|
66
|
+
if (event.extraUsage === true) month.extraUsage = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function costMonths(events: LoggedEvent[]): CostMonths {
|
|
70
|
+
const months: CostMonths = new Map();
|
|
71
|
+
for (const event of events) {
|
|
72
|
+
const month = event.ts.slice(0, 7);
|
|
73
|
+
const agents = months.get(month) ?? new Map<string, AgentMonth>();
|
|
74
|
+
months.set(month, agents);
|
|
75
|
+
const agent = agents.get(event.runtime) ?? emptyAgentMonth();
|
|
76
|
+
agents.set(event.runtime, agent);
|
|
77
|
+
addCall(agent, event);
|
|
78
|
+
}
|
|
79
|
+
return months;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Null when any part of what was paid is unknown. */
|
|
83
|
+
function paidFor(month: AgentMonth): number | null {
|
|
84
|
+
if (month.billing.has("unknown")) return null;
|
|
85
|
+
let paid = month.billing.has("api") ? month.apiBilled : 0;
|
|
86
|
+
if (!month.billing.has("subscription")) return paid;
|
|
87
|
+
if (month.plans.size === 0) return null;
|
|
88
|
+
for (const [plan, since] of month.plans) {
|
|
89
|
+
const price = planPriceFor(plan, since);
|
|
90
|
+
if (price === null) return null;
|
|
91
|
+
paid += price;
|
|
92
|
+
}
|
|
93
|
+
return paid;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Null unless both sides are known and something was paid. */
|
|
97
|
+
function leverageOf(month: AgentMonth): number | null {
|
|
98
|
+
const paid = paidFor(month);
|
|
99
|
+
if (paid === null || paid === 0 || month.unpriced > 0) return null;
|
|
100
|
+
return month.apiEquivalent / paid;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function apiEquivalentLabel(month: AgentMonth): string {
|
|
104
|
+
if (month.unpriced === month.calls) return "unknown";
|
|
105
|
+
const priced = fmtCost(month.apiEquivalent);
|
|
106
|
+
return month.unpriced > 0 ? `${priced} + ${month.unpriced} unpriced calls` : priced;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function paidLabel(month: AgentMonth): string {
|
|
110
|
+
const paid = paidFor(month);
|
|
111
|
+
if (paid === null) return "unknown";
|
|
112
|
+
const extra = month.extraUsage ? " + extra usage (unknown)" : "";
|
|
113
|
+
return `${fmtCost(paid)}${extra}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function agentLine(label: string, month: AgentMonth): string {
|
|
117
|
+
const leverage = leverageOf(month);
|
|
118
|
+
const bound = month.extraUsage ? "up to " : "";
|
|
119
|
+
const leverageText =
|
|
120
|
+
leverage === null ? "" : ` leverage ${bound}${leverage.toFixed(1)}x`;
|
|
121
|
+
return ` ${label.padEnd(16)} paid ${paidLabel(month)} API-equivalent ${apiEquivalentLabel(month)}${leverageText}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function costLines(months: CostMonths, labelOf: (runtime: string) => string) {
|
|
125
|
+
if (months.size === 0) return ["\n No model calls in the event log yet\n"];
|
|
126
|
+
return [...months.keys()]
|
|
127
|
+
.sort()
|
|
128
|
+
.flatMap((month) => [
|
|
129
|
+
`\n ${month}\n`,
|
|
130
|
+
...[...(months.get(month) ?? [])]
|
|
131
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
132
|
+
.map(([runtime, agent]) => agentLine(labelOf(runtime), agent)),
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a subscription costs per month, keyed by the plan recorded on each
|
|
3
|
+
* model_call. Prices from support.claude.com's Max plan article, checked
|
|
4
|
+
* 2026-10-12; the Max launch date from Bloomberg, 2025-04-09.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface PlanPrice {
|
|
8
|
+
from: string;
|
|
9
|
+
monthlyUsd: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PLAN_PRICES: Record<string, PlanPrice[]> = {
|
|
13
|
+
default_claude_max_5x: [{ from: "2025-04-09", monthlyUsd: 100 }],
|
|
14
|
+
default_claude_max_20x: [{ from: "2025-04-09", monthlyUsd: 200 }],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The monthly price in force at `at`, or null when the plan or its price then is unknown. */
|
|
18
|
+
export function planPriceFor(plan: string | null, at: string): number | null {
|
|
19
|
+
const when = Date.parse(at);
|
|
20
|
+
const inForce = (PLAN_PRICES[plan ?? ""] ?? []).filter(
|
|
21
|
+
(p) => Date.parse(p.from) <= when
|
|
22
|
+
);
|
|
23
|
+
return inForce.at(-1)?.monthlyUsd ?? null;
|
|
24
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project's history as events: its registration, its dated decisions, and
|
|
3
|
+
* each ISC it closed or retired as the commitment kept or dropped. The live
|
|
4
|
+
* commands and the backfill build the same rows, so both give one id.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RecordAttribution } from "../../hooks/lib/actor";
|
|
8
|
+
import { appendEvent } from "../../hooks/lib/events";
|
|
9
|
+
import type { ProjectProgress } from "../../hooks/lib/projects";
|
|
10
|
+
import { parseIscs } from "./project-isc";
|
|
11
|
+
|
|
12
|
+
export interface HistoryRow {
|
|
13
|
+
id: string;
|
|
14
|
+
ts: string;
|
|
15
|
+
type: "project" | "commitment" | "decision";
|
|
16
|
+
project: string;
|
|
17
|
+
fields: Record<string, unknown>;
|
|
18
|
+
content?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ARCHIVE_HEADING = /^### (Archived|Retired) (\d{4}-\d{2}-\d{2})$/;
|
|
22
|
+
const DECISION_LINE = /^- (\d{4}-\d{2}-\d{2}): (.+)$/;
|
|
23
|
+
|
|
24
|
+
const dayStart = (day: string) => `${day}T00:00:00.000Z`;
|
|
25
|
+
|
|
26
|
+
function shortHash(text: string): string {
|
|
27
|
+
return new Bun.CryptoHasher("sha256").update(text).digest("hex").slice(0, 12);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registrationRow(
|
|
31
|
+
project: Pick<ProjectProgress, "name" | "created" | "status">
|
|
32
|
+
): HistoryRow {
|
|
33
|
+
return {
|
|
34
|
+
id: `project:${project.name}:registered`,
|
|
35
|
+
ts: project.created,
|
|
36
|
+
type: "project",
|
|
37
|
+
project: project.name,
|
|
38
|
+
fields: { change: "registered", status: project.status },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decisionRow(project: string, day: string, text: string): HistoryRow {
|
|
43
|
+
return {
|
|
44
|
+
id: `decision:${project}:${day}:${shortHash(text)}`,
|
|
45
|
+
ts: dayStart(day),
|
|
46
|
+
type: "decision",
|
|
47
|
+
project,
|
|
48
|
+
fields: { words: text.split(/\s+/).length },
|
|
49
|
+
content: { text },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function decisionRows(project: ProjectProgress): HistoryRow[] {
|
|
54
|
+
return (project.decisions ?? "").split("\n").flatMap((line) => {
|
|
55
|
+
const decision = DECISION_LINE.exec(line);
|
|
56
|
+
return decision ? [decisionRow(project.name, decision[1], decision[2])] : [];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function closedIscRows(project: ProjectProgress): HistoryRow[] {
|
|
61
|
+
const rows: HistoryRow[] = [];
|
|
62
|
+
let heading: RegExpExecArray | null = null;
|
|
63
|
+
for (const line of (project.changelog ?? "").split("\n")) {
|
|
64
|
+
heading = ARCHIVE_HEADING.exec(line) ?? heading;
|
|
65
|
+
const [isc] = parseIscs(line);
|
|
66
|
+
if (!heading || !isc) continue;
|
|
67
|
+
const outcome = heading[1] === "Retired" ? "dropped" : "kept";
|
|
68
|
+
rows.push({
|
|
69
|
+
id: `commitment:${project.name}:${isc.id}:${outcome}:${heading[2]}`,
|
|
70
|
+
ts: dayStart(heading[2]),
|
|
71
|
+
type: "commitment",
|
|
72
|
+
project: project.name,
|
|
73
|
+
fields: {
|
|
74
|
+
iscProject: project.name,
|
|
75
|
+
isc: isc.id,
|
|
76
|
+
outcome,
|
|
77
|
+
surface: null,
|
|
78
|
+
snoozeDays: null,
|
|
79
|
+
},
|
|
80
|
+
content: { text: isc.text },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return rows;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function emitHistoryRow(
|
|
87
|
+
row: HistoryRow,
|
|
88
|
+
attribution: Partial<RecordAttribution> = {}
|
|
89
|
+
): string | null {
|
|
90
|
+
return appendEvent(row.type, row.fields, {
|
|
91
|
+
id: row.id,
|
|
92
|
+
ts: row.ts,
|
|
93
|
+
session: null,
|
|
94
|
+
channel: null,
|
|
95
|
+
cwd: "",
|
|
96
|
+
project: row.project,
|
|
97
|
+
attribution,
|
|
98
|
+
...(row.content && { content: row.content }),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -147,13 +147,17 @@ export function accumulateUsage(transcript: string, sessionId: string): SessionU
|
|
|
147
147
|
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
148
148
|
const { cacheWrite5m, cacheWrite1h } = cacheWritesOf(usage);
|
|
149
149
|
|
|
150
|
-
total.cost += costOfUsage(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
total.cost += costOfUsage(
|
|
151
|
+
model,
|
|
152
|
+
{
|
|
153
|
+
input,
|
|
154
|
+
output,
|
|
155
|
+
cacheWrite5m,
|
|
156
|
+
cacheWrite1h,
|
|
157
|
+
cacheRead,
|
|
158
|
+
},
|
|
159
|
+
entry.timestamp
|
|
160
|
+
);
|
|
157
161
|
total.input += input;
|
|
158
162
|
total.output += output;
|
|
159
163
|
total.cacheWrite5m += cacheWrite5m;
|