portable-agent-layer 0.97.0 → 0.98.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 +1 -1
- 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/events.ts +44 -3
- 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/lib/backfill/git.ts +184 -0
- package/src/tools/lib/backfill/index.ts +32 -0
- package/src/tools/lib/backfill/ledger.ts +28 -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 +14 -6
- 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 +7 -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/session-usage.ts +11 -7
- package/src/tools/lib/usage-buckets.ts +10 -5
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every commit that still exists in a registered project's repository, as a
|
|
3
|
+
* commit event whose id is its hash. Names, emails and the subject are text,
|
|
4
|
+
* so they go to the content store; the event keeps counts and flags.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { appendEvent } from "../../../hooks/lib/events";
|
|
9
|
+
import { PAL_REPO_URL } from "../../../hooks/lib/remote";
|
|
10
|
+
|
|
11
|
+
const RECORD = "\x1e";
|
|
12
|
+
const FIELD = "\x1f";
|
|
13
|
+
const FORMAT = [
|
|
14
|
+
"%H",
|
|
15
|
+
"%aI",
|
|
16
|
+
"%cI",
|
|
17
|
+
"%an",
|
|
18
|
+
"%ae",
|
|
19
|
+
"%cn",
|
|
20
|
+
"%ce",
|
|
21
|
+
"%D",
|
|
22
|
+
"%P",
|
|
23
|
+
"%s",
|
|
24
|
+
"%b",
|
|
25
|
+
"",
|
|
26
|
+
]
|
|
27
|
+
.join(FIELD)
|
|
28
|
+
.replace(/^/, RECORD);
|
|
29
|
+
const AI_CO_AUTHOR = new RegExp(
|
|
30
|
+
String.raw`noreply@anthropic\.com|${PAL_REPO_URL.replace(/[.]/g, String.raw`\.`)}|copilot|cursor|openai|codex|gemini`,
|
|
31
|
+
"i"
|
|
32
|
+
);
|
|
33
|
+
const CONVENTIONAL = /^(\w+)(\([^)]*\))?(!)?:/;
|
|
34
|
+
|
|
35
|
+
interface Commit {
|
|
36
|
+
hash: string;
|
|
37
|
+
authoredAt: string;
|
|
38
|
+
committedAt: string;
|
|
39
|
+
author: string;
|
|
40
|
+
authorEmail: string;
|
|
41
|
+
committer: string;
|
|
42
|
+
committerEmail: string;
|
|
43
|
+
subject: string;
|
|
44
|
+
files: number;
|
|
45
|
+
added: number;
|
|
46
|
+
deleted: number;
|
|
47
|
+
commitType: string | null;
|
|
48
|
+
breaking: boolean;
|
|
49
|
+
revert: boolean;
|
|
50
|
+
merge: boolean;
|
|
51
|
+
tags: string[];
|
|
52
|
+
coAuthors: number;
|
|
53
|
+
aiCoAuthored: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function gitLog(repo: string, since: string | undefined): string | null {
|
|
57
|
+
const args = [
|
|
58
|
+
"-C",
|
|
59
|
+
repo,
|
|
60
|
+
"log",
|
|
61
|
+
"--branches",
|
|
62
|
+
"--tags",
|
|
63
|
+
"--remotes",
|
|
64
|
+
"--numstat",
|
|
65
|
+
`--format=${FORMAT}`,
|
|
66
|
+
];
|
|
67
|
+
if (since) args.push(`--since=${since}`);
|
|
68
|
+
const result = spawnSync("git", args, {
|
|
69
|
+
encoding: "utf-8",
|
|
70
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
71
|
+
});
|
|
72
|
+
return result.status === 0 ? result.stdout : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function numstatOf(text: string) {
|
|
76
|
+
const rows = text
|
|
77
|
+
.split("\n")
|
|
78
|
+
.map((line) => line.split("\t"))
|
|
79
|
+
.filter((cells) => cells.length === 3);
|
|
80
|
+
const count = (cell: string) => (cell === "-" ? 0 : Number(cell));
|
|
81
|
+
return {
|
|
82
|
+
files: rows.length,
|
|
83
|
+
added: rows.reduce((sum, [added]) => sum + count(added), 0),
|
|
84
|
+
deleted: rows.reduce((sum, [, deleted]) => sum + count(deleted), 0),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function coAuthorLines(body: string): string[] {
|
|
89
|
+
return body.split("\n").filter((line) => /^co-authored[- ]by\b/i.test(line.trim()));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function tagsOf(refs: string): string[] {
|
|
93
|
+
return refs
|
|
94
|
+
.split(", ")
|
|
95
|
+
.filter((ref) => ref.startsWith("tag: "))
|
|
96
|
+
.map((ref) => ref.slice("tag: ".length));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function commitOf(record: string): Commit | null {
|
|
100
|
+
const [hash, aI, cI, an, ae, cn, ce, refs, parents, subject, body, numstat] =
|
|
101
|
+
record.split(FIELD);
|
|
102
|
+
if (!hash || !aI || !cI) return null;
|
|
103
|
+
const conventional = CONVENTIONAL.exec(subject ?? "");
|
|
104
|
+
const coAuthors = coAuthorLines(body ?? "");
|
|
105
|
+
return {
|
|
106
|
+
hash,
|
|
107
|
+
authoredAt: new Date(aI).toISOString(),
|
|
108
|
+
committedAt: new Date(cI).toISOString(),
|
|
109
|
+
author: an ?? "",
|
|
110
|
+
authorEmail: ae ?? "",
|
|
111
|
+
committer: cn ?? "",
|
|
112
|
+
committerEmail: ce ?? "",
|
|
113
|
+
subject: subject ?? "",
|
|
114
|
+
...numstatOf(numstat ?? ""),
|
|
115
|
+
commitType: conventional?.[1] ?? null,
|
|
116
|
+
breaking: !!conventional?.[3] || /^BREAKING CHANGE:/m.test(body ?? ""),
|
|
117
|
+
revert: conventional?.[1] === "revert" || (subject ?? "").startsWith("Revert "),
|
|
118
|
+
merge: (parents ?? "").trim().split(" ").length > 1,
|
|
119
|
+
tags: tagsOf(refs ?? ""),
|
|
120
|
+
coAuthors: coAuthors.length,
|
|
121
|
+
aiCoAuthored: coAuthors.some((line) => AI_CO_AUTHOR.test(line)),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readCommits(repo: string, since?: string): Commit[] {
|
|
126
|
+
const log = gitLog(repo, since);
|
|
127
|
+
if (log === null) return [];
|
|
128
|
+
return log
|
|
129
|
+
.split(RECORD)
|
|
130
|
+
.map(commitOf)
|
|
131
|
+
.filter((commit): commit is Commit => commit !== null);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const commitEventId = (hash: string) => `git:${hash}`;
|
|
135
|
+
|
|
136
|
+
function emitCommit(repo: string, commit: Commit): void {
|
|
137
|
+
appendEvent(
|
|
138
|
+
"commit",
|
|
139
|
+
{
|
|
140
|
+
hash: commit.hash,
|
|
141
|
+
committedAt: commit.committedAt,
|
|
142
|
+
files: commit.files,
|
|
143
|
+
added: commit.added,
|
|
144
|
+
deleted: commit.deleted,
|
|
145
|
+
commitType: commit.commitType,
|
|
146
|
+
breaking: commit.breaking,
|
|
147
|
+
revert: commit.revert,
|
|
148
|
+
merge: commit.merge,
|
|
149
|
+
tags: commit.tags,
|
|
150
|
+
coAuthors: commit.coAuthors,
|
|
151
|
+
aiCoAuthored: commit.aiCoAuthored,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: commitEventId(commit.hash),
|
|
155
|
+
ts: commit.authoredAt,
|
|
156
|
+
session: null,
|
|
157
|
+
runtime: "git",
|
|
158
|
+
channel: null,
|
|
159
|
+
cwd: repo,
|
|
160
|
+
content: {
|
|
161
|
+
author: commit.author,
|
|
162
|
+
authorEmail: commit.authorEmail,
|
|
163
|
+
committer: commit.committer,
|
|
164
|
+
committerEmail: commit.committerEmail,
|
|
165
|
+
subject: commit.subject,
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Appends the commits not yet logged and returns how many. */
|
|
172
|
+
export function backfillGit(repos: string[], known: Set<string>, since?: string): number {
|
|
173
|
+
let emitted = 0;
|
|
174
|
+
for (const repo of repos) {
|
|
175
|
+
for (const commit of readCommits(repo, since)) {
|
|
176
|
+
const id = commitEventId(commit.hash);
|
|
177
|
+
if (known.has(id)) continue;
|
|
178
|
+
known.add(id);
|
|
179
|
+
emitCommit(repo, commit);
|
|
180
|
+
emitted++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return emitted;
|
|
184
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything that still exists, read into the event log once. Every event's id
|
|
3
|
+
* comes from its source's own id, so a second run appends nothing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { loggedEventIds } from "../../../hooks/lib/events";
|
|
7
|
+
import { readAllProjects } from "../../../hooks/lib/projects";
|
|
8
|
+
import { eventLogEnabled } from "../../../hooks/lib/settings";
|
|
9
|
+
import { backfillGit } from "./git";
|
|
10
|
+
import { backfillLedger } from "./ledger";
|
|
11
|
+
import { backfillRatings } from "./ratings";
|
|
12
|
+
import { backfillSessions } from "./sessions";
|
|
13
|
+
import { backfillTurns } from "./turns";
|
|
14
|
+
|
|
15
|
+
function projectRepos(): string[] {
|
|
16
|
+
const paths = readAllProjects().flatMap((project) =>
|
|
17
|
+
project.path ? [project.path] : []
|
|
18
|
+
);
|
|
19
|
+
return [...new Set(paths)];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function backfillEvents(since?: string): Record<string, number> {
|
|
23
|
+
if (!eventLogEnabled()) return {};
|
|
24
|
+
const known = loggedEventIds();
|
|
25
|
+
return {
|
|
26
|
+
git: backfillGit(projectRepos(), known, since),
|
|
27
|
+
sessions: backfillSessions(since),
|
|
28
|
+
turns: backfillTurns(known, since),
|
|
29
|
+
ratings: backfillRatings(known, since),
|
|
30
|
+
ledger: backfillLedger(known, since),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every ledger entry, as the action event it would have streamed. Rows the
|
|
3
|
+
* test suite leaked into the real ledger before it was sandboxed are left out.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
actionEventId,
|
|
8
|
+
type LedgerEntry,
|
|
9
|
+
logActionEvent,
|
|
10
|
+
} from "../../../hooks/lib/ledger";
|
|
11
|
+
import { readLedger } from "../../ledger/query";
|
|
12
|
+
import { emitUnseen, recordedAttribution } from "./rows";
|
|
13
|
+
|
|
14
|
+
const SUITE_SANDBOXED_AT = "2026-09-23T14:35:01.000Z";
|
|
15
|
+
|
|
16
|
+
function leakedFromTestSuite(entry: LedgerEntry): boolean {
|
|
17
|
+
return entry.command === "rm -rf /" && entry.ts < SUITE_SANDBOXED_AT;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function emitAction(entry: LedgerEntry): void {
|
|
21
|
+
logActionEvent(entry, recordedAttribution(entry));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Appends the ledger entries not yet logged and returns how many. */
|
|
25
|
+
export function backfillLedger(known: Set<string>, since?: string): number {
|
|
26
|
+
const entries = readLedger().filter((entry) => !leakedFromTestSuite(entry));
|
|
27
|
+
return emitUnseen(entries, actionEventId, emitAction, known, since);
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every rating in the signals file, as a rating event. The rated context and
|
|
3
|
+
* the reply preview are text, so they go to the content store.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
logRatingEvent,
|
|
8
|
+
type RatingSignal,
|
|
9
|
+
ratingEventId,
|
|
10
|
+
readRatings,
|
|
11
|
+
} from "../../../hooks/lib/signals";
|
|
12
|
+
import { emitUnseen, recordedAttribution } from "./rows";
|
|
13
|
+
|
|
14
|
+
function emitRating(row: RatingSignal): void {
|
|
15
|
+
logRatingEvent(row, { cwd: "", attribution: recordedAttribution(row) });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Appends the ratings not yet logged and returns how many. */
|
|
19
|
+
export function backfillRatings(known: Set<string>, since?: string): number {
|
|
20
|
+
return emitUnseen(readRatings(), ratingEventId, emitRating, known, since);
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RecordAttribution } from "../../../hooks/lib/actor";
|
|
2
|
+
|
|
3
|
+
export type AttributedRow = Partial<RecordAttribution> & { ts: string };
|
|
4
|
+
|
|
5
|
+
/** The origin a row was written with; a field it never carried is unknown, not this machine's. */
|
|
6
|
+
export function recordedAttribution(row: AttributedRow): Partial<RecordAttribution> {
|
|
7
|
+
return {
|
|
8
|
+
machine: row.machine ?? "unknown",
|
|
9
|
+
actor: row.actor ?? "unknown",
|
|
10
|
+
runtime: row.runtime ?? "unknown",
|
|
11
|
+
...(row.authority && { authority: row.authority }),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Emits each row whose id is not logged yet and that falls on or after `since`; returns how many. */
|
|
16
|
+
export function emitUnseen<Row extends AttributedRow>(
|
|
17
|
+
rows: Row[],
|
|
18
|
+
idOf: (row: Row) => string,
|
|
19
|
+
emit: (row: Row, id: string) => void,
|
|
20
|
+
known: Set<string>,
|
|
21
|
+
since?: string
|
|
22
|
+
): number {
|
|
23
|
+
const from = new Date(since ?? 0).toISOString();
|
|
24
|
+
let emitted = 0;
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
const id = idOf(row);
|
|
27
|
+
if (known.has(id) || row.ts < from) continue;
|
|
28
|
+
known.add(id);
|
|
29
|
+
emit(row, id);
|
|
30
|
+
emitted++;
|
|
31
|
+
}
|
|
32
|
+
return emitted;
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every session PAL still has a record of, as the session_end it would have
|
|
3
|
+
* logged. A record is the session's last known state, so it carries no reason,
|
|
4
|
+
* duration or turn count; its name, summary and handoff are text and go to the
|
|
5
|
+
* content store.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendEvent, eventsSince } from "../../../hooks/lib/events";
|
|
9
|
+
import { readSessionHistory, type SessionRecord } from "../../../hooks/lib/work-tracking";
|
|
10
|
+
|
|
11
|
+
const sessionEventId = (sessionId: string) => `session:${sessionId}`;
|
|
12
|
+
|
|
13
|
+
function endedSessions(): Set<string> {
|
|
14
|
+
return new Set(
|
|
15
|
+
eventsSince("session_end", new Date(0)).flatMap((e) => (e.session ? [e.session] : []))
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function emitSession(record: SessionRecord): void {
|
|
20
|
+
appendEvent(
|
|
21
|
+
"session_end",
|
|
22
|
+
{
|
|
23
|
+
reason: "backfill",
|
|
24
|
+
durationSec: null,
|
|
25
|
+
turns: null,
|
|
26
|
+
messages: record.messageCount,
|
|
27
|
+
status: record.status,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: sessionEventId(record.sessionId),
|
|
31
|
+
ts: record.ts,
|
|
32
|
+
session: record.sessionId,
|
|
33
|
+
runtime: "unknown",
|
|
34
|
+
channel: null,
|
|
35
|
+
cwd: record.cwd,
|
|
36
|
+
content: {
|
|
37
|
+
name: record.name,
|
|
38
|
+
summary: record.summary,
|
|
39
|
+
handoff: record.handoff,
|
|
40
|
+
artifacts: record.artifacts,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Appends the sessions with no session_end yet and returns how many. */
|
|
47
|
+
export function backfillSessions(since?: string): number {
|
|
48
|
+
const ended = endedSessions();
|
|
49
|
+
const fresh = readSessionHistory().filter(
|
|
50
|
+
(record) =>
|
|
51
|
+
!ended.has(record.sessionId) &&
|
|
52
|
+
(!since || record.ts >= new Date(since).toISOString())
|
|
53
|
+
);
|
|
54
|
+
for (const record of fresh) emitSession(record);
|
|
55
|
+
return fresh.length;
|
|
56
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every turn measured before turns streamed into the event log. A row keeps
|
|
3
|
+
* the machine, actor and runtime it was written with, since an imported file
|
|
4
|
+
* holds other machines' rows too.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { appendEvent } from "../../../hooks/lib/events";
|
|
8
|
+
import {
|
|
9
|
+
legacyTurnId,
|
|
10
|
+
legacyTurns,
|
|
11
|
+
type TurnEvent,
|
|
12
|
+
} from "../../../hooks/lib/interaction";
|
|
13
|
+
import { type AttributedRow, emitUnseen, recordedAttribution } from "./rows";
|
|
14
|
+
|
|
15
|
+
type LegacyTurn = TurnEvent & AttributedRow;
|
|
16
|
+
|
|
17
|
+
function emitTurn(turn: LegacyTurn, id: string): void {
|
|
18
|
+
const { ts, session, channel, ...fields } = turn;
|
|
19
|
+
appendEvent("turn", fields, {
|
|
20
|
+
id,
|
|
21
|
+
ts,
|
|
22
|
+
session,
|
|
23
|
+
channel: channel ?? null,
|
|
24
|
+
cwd: "",
|
|
25
|
+
attribution: recordedAttribution(turn),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Appends the legacy turns not yet logged and returns how many. */
|
|
30
|
+
export function backfillTurns(known: Set<string>, since?: string): number {
|
|
31
|
+
return emitUnseen(legacyTurns() as LegacyTurn[], legacyTurnId, emitTurn, known, since);
|
|
32
|
+
}
|
|
@@ -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;
|
|
@@ -12,6 +12,7 @@ import { relative, 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 &&
|
|
@@ -79,7 +82,7 @@ function settledMessages(
|
|
|
79
82
|
settled: boolean
|
|
80
83
|
): SettledRead {
|
|
81
84
|
const { lines, end } = completeLinesFrom(filepath, offset);
|
|
82
|
-
const groups = new Map<string, { start: number; line:
|
|
85
|
+
const groups = new Map<string, { start: number; line: ModelCallLine }>();
|
|
83
86
|
let newest: string | undefined;
|
|
84
87
|
for (const { text, start } of lines) {
|
|
85
88
|
if (!text.includes('"usage"')) continue;
|
|
@@ -97,11 +100,12 @@ function settledMessages(
|
|
|
97
100
|
};
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
function emitModelCall(line: ClaudeLine): void {
|
|
103
|
+
function emitModelCall(line: ClaudeLine, billing: Billing): void {
|
|
101
104
|
const usage = line.message?.usage ?? {};
|
|
102
105
|
appendEvent(
|
|
103
106
|
"model_call",
|
|
104
107
|
{
|
|
108
|
+
...billing,
|
|
105
109
|
model: line.message?.model,
|
|
106
110
|
requestedModel: line.requestedModel,
|
|
107
111
|
inputTokens: usage.input_tokens,
|
|
@@ -132,7 +136,11 @@ function isSettled(filepath: string, now: Date): boolean {
|
|
|
132
136
|
return now.getTime() - statSync(filepath).mtimeMs > SETTLE_MS;
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
export function collectClaude(
|
|
139
|
+
export function collectClaude(
|
|
140
|
+
projectsDir: string,
|
|
141
|
+
now: Date = new Date(),
|
|
142
|
+
billingAt: (at: string) => Billing = () => UNKNOWN
|
|
143
|
+
): number {
|
|
136
144
|
const cursor = readCursor("claude");
|
|
137
145
|
let emitted = 0;
|
|
138
146
|
for (const filepath of claudeTranscripts(projectsDir)) {
|
|
@@ -140,7 +148,7 @@ export function collectClaude(projectsDir: string, now: Date = new Date()): numb
|
|
|
140
148
|
const offset = Number(cursor[key] ?? 0);
|
|
141
149
|
const read = settledMessages(filepath, offset, isSettled(filepath, now));
|
|
142
150
|
if (read.resumeAt === offset) continue;
|
|
143
|
-
for (const line of read.messages) emitModelCall(line);
|
|
151
|
+
for (const line of read.messages) emitModelCall(line, billingAt(line.timestamp));
|
|
144
152
|
emitted += read.messages.length;
|
|
145
153
|
cursor[key] = read.resumeAt;
|
|
146
154
|
writeCursor("claude", cursor);
|
|
@@ -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,12 @@ 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
|
+
),
|
|
24
30
|
opencode: () => collectOpencode(opencodeDatabase()),
|
|
25
31
|
codex: () => collectCodex(platform.codexDir()),
|
|
26
32
|
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;
|