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.
Files changed (38) hide show
  1. package/README.md +1 -0
  2. package/package.json +2 -2
  3. package/src/cli/cost.ts +18 -0
  4. package/src/cli/events.ts +22 -0
  5. package/src/cli/tree.ts +2 -0
  6. package/src/hooks/lib/commitment-events.ts +3 -3
  7. package/src/hooks/lib/entity-events.ts +18 -5
  8. package/src/hooks/lib/events.ts +54 -4
  9. package/src/hooks/lib/interaction.ts +25 -5
  10. package/src/hooks/lib/ledger.ts +61 -2
  11. package/src/hooks/lib/models.ts +42 -10
  12. package/src/hooks/lib/remote.ts +3 -0
  13. package/src/hooks/lib/signals.ts +54 -4
  14. package/src/hooks/lib/work-tracking.ts +39 -2
  15. package/src/targets/lib.ts +1 -3
  16. package/src/tools/agent/project.ts +7 -2
  17. package/src/tools/knowledge/lib.ts +11 -2
  18. package/src/tools/lib/backfill/entities.ts +31 -0
  19. package/src/tools/lib/backfill/git.ts +184 -0
  20. package/src/tools/lib/backfill/index.ts +38 -0
  21. package/src/tools/lib/backfill/ledger.ts +28 -0
  22. package/src/tools/lib/backfill/projects.ts +40 -0
  23. package/src/tools/lib/backfill/promises.ts +91 -0
  24. package/src/tools/lib/backfill/ratings.ts +21 -0
  25. package/src/tools/lib/backfill/rows.ts +33 -0
  26. package/src/tools/lib/backfill/sessions.ts +56 -0
  27. package/src/tools/lib/backfill/turns.ts +32 -0
  28. package/src/tools/lib/collectors/billing.ts +105 -0
  29. package/src/tools/lib/collectors/claude.ts +61 -21
  30. package/src/tools/lib/collectors/codex.ts +6 -3
  31. package/src/tools/lib/collectors/copilot.ts +2 -0
  32. package/src/tools/lib/collectors/index.ts +8 -1
  33. package/src/tools/lib/collectors/opencode.ts +10 -2
  34. package/src/tools/lib/cost-report.ts +134 -0
  35. package/src/tools/lib/plan-prices.ts +24 -0
  36. package/src/tools/lib/project-history.ts +100 -0
  37. package/src/tools/lib/session-usage.ts +11 -7
  38. 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: ClaudeLine[];
41
+ messages: ModelCallLine[];
39
42
  resumeAt: number;
40
43
  }
41
44
 
42
- function isModelCall(line: ClaudeLine | null): line is ClaudeLine {
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(filepath: string, offset: number) {
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
- filepath: string,
78
- offset: number,
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
- export function collectClaude(projectsDir: string, now: Date = new Date()): number {
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
- for (const filepath of claudeTranscripts(projectsDir)) {
139
- const key = relative(projectsDir, filepath).split(sep).join("/");
140
- const offset = Number(cursor[key] ?? 0);
141
- const read = settledMessages(filepath, offset, isSettled(filepath, now));
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: () => collectClaude(resolve(platform.claudeDir(), "projects")),
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(row: MessageRow, message: AssistantMessage): void {
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(model, {
151
- input,
152
- output,
153
- cacheWrite5m,
154
- cacheWrite1h,
155
- cacheRead,
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;