portable-agent-layer 0.95.0 → 0.96.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/package.json +1 -1
- package/src/cli/events.ts +19 -0
- package/src/hooks/lib/collect-trigger.ts +50 -0
- package/src/hooks/lib/events.ts +33 -1
- package/src/hooks/lib/stop.ts +2 -0
- package/src/tools/lib/agent-usage.ts +14 -6
- package/src/tools/lib/collectors/antigravity.ts +107 -0
- package/src/tools/lib/collectors/claude.ts +149 -0
- package/src/tools/lib/collectors/codex.ts +130 -0
- package/src/tools/lib/collectors/copilot.ts +113 -0
- package/src/tools/lib/collectors/cursors.ts +34 -0
- package/src/tools/lib/collectors/index.ts +79 -0
- package/src/tools/lib/collectors/opencode.ts +115 -0
- package/src/tools/lib/collectors/sqlite.ts +62 -0
- package/src/tools/lib/session-usage.ts +7 -3
- package/src/tools/lib/transcript-usage.ts +18 -0
- package/src/tools/lib/usage-buckets.ts +22 -8
- package/src/tools/token-cost.ts +6 -7
package/package.json
CHANGED
package/src/cli/events.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isNagStage, logDiscordNag, NAG_STAGES } from "../hooks/lib/discord-events";
|
|
2
|
+
import { collectEvents } from "../tools/lib/collectors";
|
|
2
3
|
import { group, leaf, UsageError } from "../tools/lib/command";
|
|
3
4
|
|
|
4
5
|
function secondsFrom(raw: string | undefined): number | null {
|
|
@@ -26,9 +27,27 @@ function recordDiscordNag(
|
|
|
26
27
|
console.log(id ? `Recorded ${id}` : "The event log is off; nothing recorded.");
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
function reportCollected(): undefined {
|
|
31
|
+
const emitted = collectEvents();
|
|
32
|
+
if (emitted === null) {
|
|
33
|
+
console.log("Another collect is running; nothing collected.");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const sources = Object.entries(emitted);
|
|
37
|
+
if (sources.length === 0) {
|
|
38
|
+
console.log("The event log is off; nothing collected.");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const [source, count] of sources) console.log(`${source}: ${count}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
export const eventsCommand = group({
|
|
30
45
|
summary: "PAL's local event log",
|
|
31
46
|
commands: {
|
|
47
|
+
collect: leaf({
|
|
48
|
+
summary: "Read every agent's own records into the event log",
|
|
49
|
+
run: () => reportCollected(),
|
|
50
|
+
}),
|
|
32
51
|
"discord-nag": leaf({
|
|
33
52
|
summary: "Record a nag the Discord bot sent, or how long its answer took",
|
|
34
53
|
args: "<sent|answered>",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session stop runs `pal cli events collect` at most every quarter hour, in its
|
|
3
|
+
* own process, so a first backfill never holds up the agent that is closing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { statSync, utimesSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { logDebug, logError } from "./log";
|
|
10
|
+
import { palPkg, paths } from "./paths";
|
|
11
|
+
import { eventLogEnabled } from "./settings";
|
|
12
|
+
|
|
13
|
+
const COLLECT_INTERVAL_MS = 15 * 60_000;
|
|
14
|
+
|
|
15
|
+
function lastCollectPath(): string {
|
|
16
|
+
return resolve(paths.state(), "collect-last-run");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function collectDue(now: number = Date.now()): boolean {
|
|
20
|
+
try {
|
|
21
|
+
return now - statSync(lastCollectPath()).mtimeMs >= COLLECT_INTERVAL_MS;
|
|
22
|
+
} catch {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function markCollected(now: number = Date.now()): void {
|
|
28
|
+
writeFileSync(lastCollectPath(), "");
|
|
29
|
+
utimesSync(lastCollectPath(), now / 1000, now / 1000);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readsRealAgentDirs(): boolean {
|
|
33
|
+
return !process.env.PAL_TEST_SANDBOX;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function spawnCollectIfDue(): void {
|
|
37
|
+
if (!eventLogEnabled() || !readsRealAgentDirs() || !collectDue()) return;
|
|
38
|
+
try {
|
|
39
|
+
markCollected();
|
|
40
|
+
const cli = resolve(palPkg(), "src", "cli", "index.ts");
|
|
41
|
+
spawn("bun", [cli, "cli", "events", "collect"], {
|
|
42
|
+
detached: true,
|
|
43
|
+
stdio: "ignore",
|
|
44
|
+
windowsHide: true,
|
|
45
|
+
}).unref();
|
|
46
|
+
logDebug("collect", "detached collect spawned");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
logError("collect:spawn", err);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/hooks/lib/events.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { currentAttribution } from "./actor";
|
|
10
|
+
import type { AgentType } from "./agent";
|
|
10
11
|
import { logError } from "./log";
|
|
11
12
|
import { ensureDir, paths } from "./paths";
|
|
12
13
|
import { readAllProjects, resolveProjectFromCwd } from "./projects";
|
|
@@ -68,6 +69,35 @@ export const EVENT_FIELDS = {
|
|
|
68
69
|
commitment: ["iscProject", "isc", "outcome", "surface", "snoozeDays"],
|
|
69
70
|
entity_first_seen: ["domain", "entityType"],
|
|
70
71
|
discord_nag: ["stage", "kind", "responseSec"],
|
|
72
|
+
model_call: [
|
|
73
|
+
"model",
|
|
74
|
+
"requestedModel",
|
|
75
|
+
"provider",
|
|
76
|
+
"inputTokens",
|
|
77
|
+
"outputTokens",
|
|
78
|
+
"cacheWrite5m",
|
|
79
|
+
"cacheWrite1h",
|
|
80
|
+
"cacheRead",
|
|
81
|
+
"thinkingTokens",
|
|
82
|
+
"thinkingMs",
|
|
83
|
+
"webSearches",
|
|
84
|
+
"webFetches",
|
|
85
|
+
"serviceTier",
|
|
86
|
+
"speed",
|
|
87
|
+
"effort",
|
|
88
|
+
"stopReason",
|
|
89
|
+
"entrypoint",
|
|
90
|
+
"sidechain",
|
|
91
|
+
"costUsd",
|
|
92
|
+
"agent",
|
|
93
|
+
"mode",
|
|
94
|
+
"durationMs",
|
|
95
|
+
"ttftMs",
|
|
96
|
+
"aiu",
|
|
97
|
+
"initiator",
|
|
98
|
+
"host",
|
|
99
|
+
],
|
|
100
|
+
conversation: ["steps", "status", "depth", "subagent", "killed"],
|
|
71
101
|
permission_prompt: ["tool"],
|
|
72
102
|
notification: ["kind"],
|
|
73
103
|
tool_call: [
|
|
@@ -118,6 +148,7 @@ export interface AppendOptions {
|
|
|
118
148
|
id?: string;
|
|
119
149
|
ts?: string;
|
|
120
150
|
session?: string | null;
|
|
151
|
+
runtime?: AgentType;
|
|
121
152
|
channel?: string | null;
|
|
122
153
|
cwd?: string;
|
|
123
154
|
content?: Record<string, unknown>;
|
|
@@ -177,9 +208,10 @@ function envelope(type: EventType, options: AppendOptions): EventEnvelope {
|
|
|
177
208
|
type,
|
|
178
209
|
session: options.session ?? null,
|
|
179
210
|
...currentAttribution(),
|
|
211
|
+
...(options.runtime && { runtime: options.runtime }),
|
|
180
212
|
channel:
|
|
181
213
|
options.channel !== undefined ? options.channel : process.env.PAL_CHANNEL || null,
|
|
182
|
-
project: projectOf(cwd),
|
|
214
|
+
project: cwd ? projectOf(cwd) : null,
|
|
183
215
|
cwd,
|
|
184
216
|
};
|
|
185
217
|
}
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { runSynthesis } from "../handlers/synthesis";
|
|
|
19
19
|
import { resetTab } from "../handlers/tab";
|
|
20
20
|
import { updateCounts } from "../handlers/update-counts";
|
|
21
21
|
import { captureWorkSession } from "../handlers/work-session";
|
|
22
|
+
import { spawnCollectIfDue } from "./collect-trigger";
|
|
22
23
|
import { acknowledgeMentioned } from "./daily-nudge";
|
|
23
24
|
import { spawnDetachedInference } from "./detached-inference";
|
|
24
25
|
import { startHookTiming } from "./hook-timing";
|
|
@@ -193,6 +194,7 @@ export async function runStopHandlers(
|
|
|
193
194
|
"project-touch": projectTouch(options.lastAssistantMessage),
|
|
194
195
|
"desktop-notify": notifyDesktop(options.sessionId),
|
|
195
196
|
"context-digests": Promise.resolve(writeContextDigests()),
|
|
197
|
+
"collect-events": Promise.resolve(spawnCollectIfDue()),
|
|
196
198
|
};
|
|
197
199
|
const names = Object.keys(handlers);
|
|
198
200
|
const results = await Promise.allSettled(
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { Database } from "bun:sqlite";
|
|
8
8
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
9
10
|
import { basename, resolve } from "node:path";
|
|
10
11
|
import {
|
|
11
12
|
type AgentUsage,
|
|
@@ -107,14 +108,16 @@ export function readCodex(
|
|
|
107
108
|
now: Date = new Date()
|
|
108
109
|
): AgentUsage {
|
|
109
110
|
const tally = usageTally(projectFilter, now);
|
|
111
|
+
for (const filepath of codexRollouts(codexDir)) recordCodexRollout(filepath, tally);
|
|
112
|
+
return tally.usage;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function codexRollouts(codexDir: string): string[] {
|
|
110
116
|
const isRollout = (name: string) =>
|
|
111
117
|
name.startsWith("rollout-") && name.endsWith(".jsonl");
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return tally.usage;
|
|
118
|
+
return ["sessions", "archived_sessions"].flatMap((sub) =>
|
|
119
|
+
filesUnder(resolve(codexDir, sub), isRollout)
|
|
120
|
+
);
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
interface OpencodeMessage {
|
|
@@ -144,6 +147,11 @@ function opencodeTokens(message: OpencodeMessage): Tokens {
|
|
|
144
147
|
|
|
145
148
|
const anyTokens = (tokens: Tokens) => Object.values(tokens).some((n) => n > 0);
|
|
146
149
|
|
|
150
|
+
export function opencodeDatabase(): string {
|
|
151
|
+
const dataHome = process.env.XDG_DATA_HOME || resolve(homedir(), ".local", "share");
|
|
152
|
+
return resolve(dataHome, "opencode", "opencode.db");
|
|
153
|
+
}
|
|
154
|
+
|
|
147
155
|
interface OpencodeRow {
|
|
148
156
|
time_created: number;
|
|
149
157
|
data: string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity's conversations, read off its summaries table into the event
|
|
3
|
+
* log. A row is rewritten as the conversation goes on, so each change is a
|
|
4
|
+
* new snapshot and the latest per session is its current state. PAL's own
|
|
5
|
+
* inference spawns are left out: inference_call already records them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { appendEvent } from "../../../hooks/lib/events";
|
|
12
|
+
import { readCursor, writeCursor } from "./cursors";
|
|
13
|
+
import { type AllowList, selectAllowed } from "./sqlite";
|
|
14
|
+
|
|
15
|
+
const PAL_INFERENCE_AGENT = "pal-inference";
|
|
16
|
+
const SUMMARY_COLUMNS = [
|
|
17
|
+
"conversation_id",
|
|
18
|
+
"step_count",
|
|
19
|
+
"last_modified_time",
|
|
20
|
+
"workspace_uris",
|
|
21
|
+
"status",
|
|
22
|
+
"agent_name",
|
|
23
|
+
"parent_conversation_id",
|
|
24
|
+
"nesting_depth",
|
|
25
|
+
"killed",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export const ANTIGRAVITY_ALLOW_LIST = {
|
|
29
|
+
conversation_summaries: SUMMARY_COLUMNS,
|
|
30
|
+
} satisfies AllowList;
|
|
31
|
+
|
|
32
|
+
interface SummaryRow {
|
|
33
|
+
conversation_id: string;
|
|
34
|
+
step_count: number;
|
|
35
|
+
last_modified_time: string;
|
|
36
|
+
workspace_uris: string;
|
|
37
|
+
status: string;
|
|
38
|
+
agent_name: string;
|
|
39
|
+
parent_conversation_id: string;
|
|
40
|
+
nesting_depth: number;
|
|
41
|
+
killed: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function antigravityDatabase(antigravityCliDir: string): string {
|
|
45
|
+
return resolve(antigravityCliDir, "conversation_summaries.db");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isoOf(antigravityTime: string): string {
|
|
49
|
+
return new Date(
|
|
50
|
+
antigravityTime.replace(" ", "T").replace(/(\.\d{3})\d*/, "$1")
|
|
51
|
+
).toISOString();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function workspaceOf(row: SummaryRow): string {
|
|
55
|
+
try {
|
|
56
|
+
const [first] = JSON.parse(row.workspace_uris) as string[];
|
|
57
|
+
return first?.startsWith("file://") ? fileURLToPath(first) : "";
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function emitConversation(row: SummaryRow): void {
|
|
64
|
+
appendEvent(
|
|
65
|
+
"conversation",
|
|
66
|
+
{
|
|
67
|
+
steps: row.step_count,
|
|
68
|
+
status: row.status.replace("CASCADE_RUN_STATUS_", "").toLowerCase(),
|
|
69
|
+
depth: row.nesting_depth,
|
|
70
|
+
subagent: row.parent_conversation_id !== "",
|
|
71
|
+
killed: row.killed === 1,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
ts: isoOf(row.last_modified_time),
|
|
75
|
+
session: row.conversation_id,
|
|
76
|
+
runtime: "antigravity",
|
|
77
|
+
cwd: workspaceOf(row),
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function collectAntigravity(dbPath: string): number {
|
|
83
|
+
if (!existsSync(dbPath)) return 0;
|
|
84
|
+
const cursor = readCursor("antigravity");
|
|
85
|
+
const rows = selectAllowed<SummaryRow>(
|
|
86
|
+
dbPath,
|
|
87
|
+
ANTIGRAVITY_ALLOW_LIST,
|
|
88
|
+
"conversation_summaries",
|
|
89
|
+
SUMMARY_COLUMNS,
|
|
90
|
+
{
|
|
91
|
+
orderColumn: "last_modified_time",
|
|
92
|
+
idColumn: "conversation_id",
|
|
93
|
+
order: String(cursor.modified ?? ""),
|
|
94
|
+
id: String(cursor.id ?? ""),
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
const conversations = rows.filter((row) => row.agent_name !== PAL_INFERENCE_AGENT);
|
|
98
|
+
for (const row of conversations) emitConversation(row);
|
|
99
|
+
const last = rows.at(-1);
|
|
100
|
+
if (last) {
|
|
101
|
+
writeCursor("antigravity", {
|
|
102
|
+
modified: last.last_modified_time,
|
|
103
|
+
id: last.conversation_id,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return conversations.length;
|
|
107
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code's model calls, read off its transcripts into the event log.
|
|
3
|
+
*
|
|
4
|
+
* A message's lines are not all written at once: tool results land between
|
|
5
|
+
* them, and an early line can carry the usage mid-stream. So the newest
|
|
6
|
+
* message of a file still being written is left for the next run, and the
|
|
7
|
+
* cursor stops where it starts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { relative, sep } from "node:path";
|
|
12
|
+
import { appendEvent } from "../../../hooks/lib/events";
|
|
13
|
+
import { cacheWritesOf, type TranscriptUsage } from "../transcript-usage";
|
|
14
|
+
import { claudeTranscripts } from "../usage-buckets";
|
|
15
|
+
import { readCursor, writeCursor } from "./cursors";
|
|
16
|
+
|
|
17
|
+
const SETTLE_MS = 5 * 60_000;
|
|
18
|
+
|
|
19
|
+
interface ClaudeLine {
|
|
20
|
+
type?: string;
|
|
21
|
+
timestamp?: string;
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
cwd?: string;
|
|
24
|
+
isSidechain?: boolean;
|
|
25
|
+
effort?: string;
|
|
26
|
+
entrypoint?: string;
|
|
27
|
+
requestedModel?: string;
|
|
28
|
+
thinkingDurationMs?: number;
|
|
29
|
+
message?: {
|
|
30
|
+
id?: string;
|
|
31
|
+
model?: string;
|
|
32
|
+
stop_reason?: string;
|
|
33
|
+
usage?: TranscriptUsage;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SettledRead {
|
|
38
|
+
messages: ClaudeLine[];
|
|
39
|
+
resumeAt: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isModelCall(line: ClaudeLine | null): line is ClaudeLine {
|
|
43
|
+
return (
|
|
44
|
+
line?.type === "assistant" &&
|
|
45
|
+
!!line.message?.usage &&
|
|
46
|
+
!!line.message.model &&
|
|
47
|
+
!!line.timestamp &&
|
|
48
|
+
!!line.cwd
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parsed(text: string): ClaudeLine | null {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(text) as ClaudeLine;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function completeLinesFrom(filepath: string, offset: number) {
|
|
61
|
+
const bytes = readFileSync(filepath);
|
|
62
|
+
const end = bytes.lastIndexOf(10) + 1;
|
|
63
|
+
if (end <= offset) return { lines: [], end: offset };
|
|
64
|
+
const lines: { text: string; start: number }[] = [];
|
|
65
|
+
let start = offset;
|
|
66
|
+
for (const text of bytes
|
|
67
|
+
.subarray(offset, end - 1)
|
|
68
|
+
.toString("utf-8")
|
|
69
|
+
.split("\n")) {
|
|
70
|
+
lines.push({ text, start });
|
|
71
|
+
start += Buffer.byteLength(text) + 1;
|
|
72
|
+
}
|
|
73
|
+
return { lines, end };
|
|
74
|
+
}
|
|
75
|
+
|
|
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 }>();
|
|
83
|
+
let newest: string | undefined;
|
|
84
|
+
for (const { text, start } of lines) {
|
|
85
|
+
if (!text.includes('"usage"')) continue;
|
|
86
|
+
const line = parsed(text);
|
|
87
|
+
if (!isModelCall(line)) continue;
|
|
88
|
+
const id = line.message?.id ?? `@${start}`;
|
|
89
|
+
groups.set(id, { start: groups.get(id)?.start ?? start, line });
|
|
90
|
+
newest = id;
|
|
91
|
+
}
|
|
92
|
+
const unfinished = settled || !newest ? undefined : groups.get(newest);
|
|
93
|
+
if (unfinished && newest) groups.delete(newest);
|
|
94
|
+
return {
|
|
95
|
+
messages: [...groups.values()].map((group) => group.line),
|
|
96
|
+
resumeAt: unfinished?.start ?? end,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function emitModelCall(line: ClaudeLine): void {
|
|
101
|
+
const usage = line.message?.usage ?? {};
|
|
102
|
+
appendEvent(
|
|
103
|
+
"model_call",
|
|
104
|
+
{
|
|
105
|
+
model: line.message?.model,
|
|
106
|
+
requestedModel: line.requestedModel,
|
|
107
|
+
inputTokens: usage.input_tokens,
|
|
108
|
+
outputTokens: usage.output_tokens,
|
|
109
|
+
...cacheWritesOf(usage),
|
|
110
|
+
cacheRead: usage.cache_read_input_tokens,
|
|
111
|
+
thinkingTokens: usage.output_tokens_details?.thinking_tokens,
|
|
112
|
+
thinkingMs: line.thinkingDurationMs,
|
|
113
|
+
webSearches: usage.server_tool_use?.web_search_requests,
|
|
114
|
+
webFetches: usage.server_tool_use?.web_fetch_requests,
|
|
115
|
+
serviceTier: usage.service_tier,
|
|
116
|
+
speed: usage.speed,
|
|
117
|
+
effort: line.effort,
|
|
118
|
+
stopReason: line.message?.stop_reason,
|
|
119
|
+
entrypoint: line.entrypoint,
|
|
120
|
+
sidechain: line.isSidechain,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
ts: line.timestamp,
|
|
124
|
+
session: line.sessionId ?? null,
|
|
125
|
+
runtime: "claude",
|
|
126
|
+
cwd: line.cwd,
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isSettled(filepath: string, now: Date): boolean {
|
|
132
|
+
return now.getTime() - statSync(filepath).mtimeMs > SETTLE_MS;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function collectClaude(projectsDir: string, now: Date = new Date()): number {
|
|
136
|
+
const cursor = readCursor("claude");
|
|
137
|
+
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));
|
|
142
|
+
if (read.resumeAt === offset) continue;
|
|
143
|
+
for (const line of read.messages) emitModelCall(line);
|
|
144
|
+
emitted += read.messages.length;
|
|
145
|
+
cursor[key] = read.resumeAt;
|
|
146
|
+
writeCursor("claude", cursor);
|
|
147
|
+
}
|
|
148
|
+
return emitted;
|
|
149
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex's model calls, read off its rollout files into the event log. Codex
|
|
3
|
+
* repeats a token_count without a new call, and its running total only grows,
|
|
4
|
+
* so the total is both the dedupe and the cursor.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { relative, sep } from "node:path";
|
|
9
|
+
import { appendEvent } from "../../../hooks/lib/events";
|
|
10
|
+
import { codexRollouts } from "../agent-usage";
|
|
11
|
+
import { readCursor, writeCursor } from "./cursors";
|
|
12
|
+
|
|
13
|
+
interface CodexUsage {
|
|
14
|
+
input_tokens?: number;
|
|
15
|
+
cached_input_tokens?: number;
|
|
16
|
+
cache_write_input_tokens?: number;
|
|
17
|
+
output_tokens?: number;
|
|
18
|
+
reasoning_output_tokens?: number;
|
|
19
|
+
total_tokens?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RolloutLine {
|
|
23
|
+
timestamp?: string;
|
|
24
|
+
type?: string;
|
|
25
|
+
payload?: {
|
|
26
|
+
type?: string;
|
|
27
|
+
id?: string;
|
|
28
|
+
session_id?: string;
|
|
29
|
+
cwd?: string;
|
|
30
|
+
model?: string;
|
|
31
|
+
effort?: string;
|
|
32
|
+
model_provider?: string;
|
|
33
|
+
info?: { last_token_usage?: CodexUsage; total_token_usage?: CodexUsage } | null;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RolloutContext {
|
|
38
|
+
session?: string;
|
|
39
|
+
cwd?: string;
|
|
40
|
+
model?: string;
|
|
41
|
+
effort?: string;
|
|
42
|
+
provider?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface CodexCall {
|
|
46
|
+
ts: string;
|
|
47
|
+
total: number;
|
|
48
|
+
usage: CodexUsage;
|
|
49
|
+
context: RolloutContext;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rolloutLines(filepath: string): RolloutLine[] {
|
|
53
|
+
return readFileSync(filepath, "utf-8")
|
|
54
|
+
.split("\n")
|
|
55
|
+
.flatMap((text) => {
|
|
56
|
+
try {
|
|
57
|
+
return [JSON.parse(text) as RolloutLine];
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function withContext(context: RolloutContext, line: RolloutLine): RolloutContext {
|
|
65
|
+
const payload = line.payload ?? {};
|
|
66
|
+
if (line.type === "session_meta")
|
|
67
|
+
return {
|
|
68
|
+
...context,
|
|
69
|
+
session: payload.session_id ?? payload.id,
|
|
70
|
+
cwd: payload.cwd,
|
|
71
|
+
provider: payload.model_provider,
|
|
72
|
+
};
|
|
73
|
+
if (line.type === "turn_context")
|
|
74
|
+
return { ...context, model: payload.model, effort: payload.effort };
|
|
75
|
+
return context;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function callsOf(filepath: string): CodexCall[] {
|
|
79
|
+
const calls: CodexCall[] = [];
|
|
80
|
+
let context: RolloutContext = {};
|
|
81
|
+
let lastTotal = -1;
|
|
82
|
+
for (const line of rolloutLines(filepath)) {
|
|
83
|
+
context = withContext(context, line);
|
|
84
|
+
const info = line.payload?.type === "token_count" ? line.payload.info : null;
|
|
85
|
+
const total = info?.total_token_usage?.total_tokens ?? -1;
|
|
86
|
+
if (!info?.last_token_usage || !line.timestamp || total === lastTotal) continue;
|
|
87
|
+
lastTotal = total;
|
|
88
|
+
calls.push({ ts: line.timestamp, total, usage: info.last_token_usage, context });
|
|
89
|
+
}
|
|
90
|
+
return calls;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function emitModelCall({ ts, usage, context }: CodexCall): void {
|
|
94
|
+
const cacheRead = usage.cached_input_tokens ?? 0;
|
|
95
|
+
const cacheWrite = usage.cache_write_input_tokens ?? 0;
|
|
96
|
+
appendEvent(
|
|
97
|
+
"model_call",
|
|
98
|
+
{
|
|
99
|
+
model: context.model,
|
|
100
|
+
provider: context.provider,
|
|
101
|
+
inputTokens: (usage.input_tokens ?? 0) - cacheRead - cacheWrite,
|
|
102
|
+
outputTokens: usage.output_tokens,
|
|
103
|
+
thinkingTokens: usage.reasoning_output_tokens,
|
|
104
|
+
cacheWrite5m: cacheWrite,
|
|
105
|
+
cacheRead,
|
|
106
|
+
effort: context.effort,
|
|
107
|
+
},
|
|
108
|
+
{ ts, session: context.session ?? null, runtime: "codex", cwd: context.cwd ?? "" }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function collectCodex(codexDir: string): number {
|
|
113
|
+
const cursor = readCursor("codex");
|
|
114
|
+
let emitted = 0;
|
|
115
|
+
for (const filepath of codexRollouts(codexDir)) {
|
|
116
|
+
const key = relative(codexDir, filepath).split(sep).join("/");
|
|
117
|
+
const size = statSync(filepath).size;
|
|
118
|
+
if (cursor[`${key}@size`] === size) continue;
|
|
119
|
+
const collectedTotal = Number(cursor[key] ?? -1);
|
|
120
|
+
for (const call of callsOf(filepath)) {
|
|
121
|
+
if (call.total <= collectedTotal) continue;
|
|
122
|
+
emitModelCall(call);
|
|
123
|
+
emitted++;
|
|
124
|
+
cursor[key] = call.total;
|
|
125
|
+
}
|
|
126
|
+
cursor[`${key}@size`] = size;
|
|
127
|
+
writeCursor("codex", cursor);
|
|
128
|
+
}
|
|
129
|
+
return emitted;
|
|
130
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copilot's model calls, read off its session store into the event log. A row
|
|
3
|
+
* is written once the call has finished, so its id is the cursor. Copilot
|
|
4
|
+
* counts cache reads inside input_tokens and reasoning inside output_tokens.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { appendEvent } from "../../../hooks/lib/events";
|
|
10
|
+
import { readCursor, writeCursor } from "./cursors";
|
|
11
|
+
import { type AllowList, selectAllowed } from "./sqlite";
|
|
12
|
+
|
|
13
|
+
const NANO = 1_000_000_000;
|
|
14
|
+
const USAGE_COLUMNS = [
|
|
15
|
+
"id",
|
|
16
|
+
"session_id",
|
|
17
|
+
"agent_id",
|
|
18
|
+
"model",
|
|
19
|
+
"input_tokens",
|
|
20
|
+
"output_tokens",
|
|
21
|
+
"cache_read_tokens",
|
|
22
|
+
"cache_write_tokens",
|
|
23
|
+
"reasoning_tokens",
|
|
24
|
+
"total_nano_aiu",
|
|
25
|
+
"duration_ms",
|
|
26
|
+
"time_to_first_token_ms",
|
|
27
|
+
"initiator",
|
|
28
|
+
"reasoning_effort",
|
|
29
|
+
"finish_reason",
|
|
30
|
+
"created_at",
|
|
31
|
+
] as const;
|
|
32
|
+
const SESSION_COLUMNS = ["id", "cwd", "host_type", "created_at"] as const;
|
|
33
|
+
|
|
34
|
+
export const COPILOT_ALLOW_LIST = {
|
|
35
|
+
assistant_usage_events: USAGE_COLUMNS,
|
|
36
|
+
sessions: SESSION_COLUMNS,
|
|
37
|
+
} satisfies AllowList;
|
|
38
|
+
|
|
39
|
+
type UsageRow = Record<(typeof USAGE_COLUMNS)[number], string | number | null>;
|
|
40
|
+
|
|
41
|
+
interface SessionRow {
|
|
42
|
+
id: string;
|
|
43
|
+
cwd: string | null;
|
|
44
|
+
host_type: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function copilotDatabase(copilotDir: string): string {
|
|
48
|
+
return resolve(copilotDir, "session-store.db");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sessionsOf(dbPath: string): Map<string, SessionRow> {
|
|
52
|
+
const rows = selectAllowed<SessionRow>(
|
|
53
|
+
dbPath,
|
|
54
|
+
COPILOT_ALLOW_LIST,
|
|
55
|
+
"sessions",
|
|
56
|
+
SESSION_COLUMNS,
|
|
57
|
+
{ orderColumn: "created_at", idColumn: "id", order: "", id: "" }
|
|
58
|
+
);
|
|
59
|
+
return new Map(rows.map((row) => [row.id, row]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const num = (value: unknown) => (typeof value === "number" ? value : 0);
|
|
63
|
+
|
|
64
|
+
function emitModelCall(row: UsageRow, session: SessionRow | undefined): void {
|
|
65
|
+
const cacheRead = num(row.cache_read_tokens);
|
|
66
|
+
appendEvent(
|
|
67
|
+
"model_call",
|
|
68
|
+
{
|
|
69
|
+
model: row.model,
|
|
70
|
+
inputTokens: num(row.input_tokens) - cacheRead,
|
|
71
|
+
outputTokens: row.output_tokens,
|
|
72
|
+
thinkingTokens: row.reasoning_tokens,
|
|
73
|
+
cacheWrite5m: row.cache_write_tokens,
|
|
74
|
+
cacheRead,
|
|
75
|
+
aiu: num(row.total_nano_aiu) / NANO,
|
|
76
|
+
durationMs: row.duration_ms,
|
|
77
|
+
ttftMs:
|
|
78
|
+
typeof row.time_to_first_token_ms === "number"
|
|
79
|
+
? Math.round(row.time_to_first_token_ms)
|
|
80
|
+
: undefined,
|
|
81
|
+
initiator: row.initiator,
|
|
82
|
+
effort: row.reasoning_effort,
|
|
83
|
+
stopReason: row.finish_reason,
|
|
84
|
+
sidechain: row.agent_id !== null,
|
|
85
|
+
host: session?.host_type ?? "local",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
ts: String(row.created_at),
|
|
89
|
+
session: String(row.session_id),
|
|
90
|
+
runtime: "copilot",
|
|
91
|
+
cwd: session?.cwd ?? "",
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function collectCopilot(dbPath: string): number {
|
|
97
|
+
if (!existsSync(dbPath)) return 0;
|
|
98
|
+
const cursor = readCursor("copilot");
|
|
99
|
+
const lastId = Number(cursor.usageId ?? 0);
|
|
100
|
+
const rows = selectAllowed<UsageRow>(
|
|
101
|
+
dbPath,
|
|
102
|
+
COPILOT_ALLOW_LIST,
|
|
103
|
+
"assistant_usage_events",
|
|
104
|
+
USAGE_COLUMNS,
|
|
105
|
+
{ orderColumn: "id", idColumn: "id", order: lastId, id: lastId }
|
|
106
|
+
);
|
|
107
|
+
if (rows.length === 0) return 0;
|
|
108
|
+
const sessions = sessionsOf(dbPath);
|
|
109
|
+
for (const row of rows) emitModelCall(row, sessions.get(String(row.session_id)));
|
|
110
|
+
cursor.usageId = num(rows.at(-1)?.id);
|
|
111
|
+
writeCursor("copilot", cursor);
|
|
112
|
+
return rows.length;
|
|
113
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far each collector has read, per source, so a second run emits nothing
|
|
3
|
+
* the first one already did.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { paths } from "../../../hooks/lib/paths";
|
|
9
|
+
|
|
10
|
+
export type SourceCursor = Record<string, number | string>;
|
|
11
|
+
|
|
12
|
+
function cursorsFile(): string {
|
|
13
|
+
return resolve(paths.state(), "collector-cursors.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readAll(): Record<string, SourceCursor> {
|
|
17
|
+
if (!existsSync(cursorsFile())) return {};
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(cursorsFile(), "utf-8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readCursor(source: string): SourceCursor {
|
|
26
|
+
return readAll()[source] ?? {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeCursor(source: string, cursor: SourceCursor): void {
|
|
30
|
+
writeFileSync(
|
|
31
|
+
cursorsFile(),
|
|
32
|
+
`${JSON.stringify({ ...readAll(), [source]: cursor }, null, 2)}\n`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every agent's own records, read into the event log. A collector that fails
|
|
3
|
+
* is logged and skipped; the others still run. One collect at a time: two
|
|
4
|
+
* would read from the same cursors and emit the same events twice.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { markCollected } from "../../../hooks/lib/collect-trigger";
|
|
10
|
+
import { logError } from "../../../hooks/lib/log";
|
|
11
|
+
import { paths, platform } from "../../../hooks/lib/paths";
|
|
12
|
+
import { eventLogEnabled } from "../../../hooks/lib/settings";
|
|
13
|
+
import { opencodeDatabase } from "../agent-usage";
|
|
14
|
+
import { antigravityDatabase, collectAntigravity } from "./antigravity";
|
|
15
|
+
import { collectClaude } from "./claude";
|
|
16
|
+
import { collectCodex } from "./codex";
|
|
17
|
+
import { collectCopilot, copilotDatabase } from "./copilot";
|
|
18
|
+
import { collectOpencode } from "./opencode";
|
|
19
|
+
|
|
20
|
+
const LOCK_STALE_MS = 10 * 60_000;
|
|
21
|
+
|
|
22
|
+
const COLLECTORS: Record<string, () => number> = {
|
|
23
|
+
claude: () => collectClaude(resolve(platform.claudeDir(), "projects")),
|
|
24
|
+
opencode: () => collectOpencode(opencodeDatabase()),
|
|
25
|
+
codex: () => collectCodex(platform.codexDir()),
|
|
26
|
+
copilot: () => collectCopilot(copilotDatabase(platform.copilotDir())),
|
|
27
|
+
antigravity: () =>
|
|
28
|
+
collectAntigravity(antigravityDatabase(platform.antigravityCliDir())),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function lockPath(): string {
|
|
32
|
+
return resolve(paths.state(), "collect.lock");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lockIsFresh(now: number): boolean {
|
|
36
|
+
try {
|
|
37
|
+
return now - statSync(lockPath()).mtimeMs < LOCK_STALE_MS;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function takeLock(now: number): boolean {
|
|
44
|
+
try {
|
|
45
|
+
writeFileSync(lockPath(), String(process.pid), { flag: "wx" });
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
if (lockIsFresh(now)) return false;
|
|
49
|
+
writeFileSync(lockPath(), String(process.pid));
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function runCollectors(): Record<string, number | "failed"> {
|
|
55
|
+
const emitted: Record<string, number | "failed"> = {};
|
|
56
|
+
for (const [source, collect] of Object.entries(COLLECTORS)) {
|
|
57
|
+
try {
|
|
58
|
+
emitted[source] = collect();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
logError(`collect:${source}`, err);
|
|
61
|
+
emitted[source] = "failed";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return emitted;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Null while another collect holds the lock. */
|
|
68
|
+
export function collectEvents(
|
|
69
|
+
now: number = Date.now()
|
|
70
|
+
): Record<string, number | "failed"> | null {
|
|
71
|
+
if (!eventLogEnabled()) return {};
|
|
72
|
+
if (!takeLock(now)) return null;
|
|
73
|
+
try {
|
|
74
|
+
markCollected(now);
|
|
75
|
+
return runCollectors();
|
|
76
|
+
} finally {
|
|
77
|
+
rmSync(lockPath(), { force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode's model calls, read off its message table into the event log. A
|
|
3
|
+
* message is written while it streams, so one not yet completed stops the
|
|
4
|
+
* read until it is, or until it is old enough to have been abandoned.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { appendEvent } from "../../../hooks/lib/events";
|
|
9
|
+
import { readCursor, writeCursor } from "./cursors";
|
|
10
|
+
import { type AllowList, selectAllowed } from "./sqlite";
|
|
11
|
+
|
|
12
|
+
const ABANDONED_MS = 60 * 60_000;
|
|
13
|
+
const MESSAGE_COLUMNS = ["id", "session_id", "time_created", "data"] as const;
|
|
14
|
+
|
|
15
|
+
export const OPENCODE_ALLOW_LIST = { message: MESSAGE_COLUMNS } satisfies AllowList;
|
|
16
|
+
|
|
17
|
+
interface MessageRow {
|
|
18
|
+
id: string;
|
|
19
|
+
session_id: string;
|
|
20
|
+
time_created: number;
|
|
21
|
+
data: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface AssistantMessage {
|
|
25
|
+
role?: string;
|
|
26
|
+
modelID?: string;
|
|
27
|
+
providerID?: string;
|
|
28
|
+
cost?: number;
|
|
29
|
+
mode?: string;
|
|
30
|
+
agent?: string;
|
|
31
|
+
finish?: string;
|
|
32
|
+
path?: { cwd?: string };
|
|
33
|
+
time?: { created?: number; completed?: number };
|
|
34
|
+
tokens?: {
|
|
35
|
+
input?: number;
|
|
36
|
+
output?: number;
|
|
37
|
+
reasoning?: number;
|
|
38
|
+
cache?: { read?: number; write?: number };
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assistantMessageOf(row: MessageRow): AssistantMessage | null {
|
|
43
|
+
try {
|
|
44
|
+
const message = JSON.parse(row.data) as AssistantMessage;
|
|
45
|
+
return message.role === "assistant" ? message : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stillStreaming(message: AssistantMessage, row: MessageRow, now: Date): boolean {
|
|
52
|
+
return !message.time?.completed && now.getTime() - row.time_created < ABANDONED_MS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function durationOf(message: AssistantMessage): number | undefined {
|
|
56
|
+
const { created, completed } = message.time ?? {};
|
|
57
|
+
return created && completed ? completed - created : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function emitModelCall(row: MessageRow, message: AssistantMessage): void {
|
|
61
|
+
const tokens = message.tokens ?? {};
|
|
62
|
+
appendEvent(
|
|
63
|
+
"model_call",
|
|
64
|
+
{
|
|
65
|
+
model: message.modelID,
|
|
66
|
+
provider: message.providerID,
|
|
67
|
+
inputTokens: tokens.input,
|
|
68
|
+
outputTokens: (tokens.output ?? 0) + (tokens.reasoning ?? 0),
|
|
69
|
+
thinkingTokens: tokens.reasoning,
|
|
70
|
+
cacheWrite5m: tokens.cache?.write,
|
|
71
|
+
cacheRead: tokens.cache?.read,
|
|
72
|
+
costUsd: message.cost,
|
|
73
|
+
agent: message.agent,
|
|
74
|
+
mode: message.mode,
|
|
75
|
+
stopReason: message.finish,
|
|
76
|
+
durationMs: durationOf(message),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
ts: new Date(row.time_created).toISOString(),
|
|
80
|
+
session: row.session_id,
|
|
81
|
+
runtime: "opencode",
|
|
82
|
+
cwd: message.path?.cwd ?? "",
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function collectOpencode(dbPath: string, now: Date = new Date()): number {
|
|
88
|
+
if (!existsSync(dbPath)) return 0;
|
|
89
|
+
const cursor = readCursor("opencode");
|
|
90
|
+
const rows = selectAllowed<MessageRow>(
|
|
91
|
+
dbPath,
|
|
92
|
+
OPENCODE_ALLOW_LIST,
|
|
93
|
+
"message",
|
|
94
|
+
MESSAGE_COLUMNS,
|
|
95
|
+
{
|
|
96
|
+
orderColumn: "time_created",
|
|
97
|
+
idColumn: "id",
|
|
98
|
+
order: Number(cursor.order ?? 0),
|
|
99
|
+
id: String(cursor.id ?? ""),
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
let emitted = 0;
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
const message = assistantMessageOf(row);
|
|
105
|
+
if (message && stillStreaming(message, row, now)) break;
|
|
106
|
+
if (message) {
|
|
107
|
+
emitModelCall(row, message);
|
|
108
|
+
emitted++;
|
|
109
|
+
}
|
|
110
|
+
cursor.order = row.time_created;
|
|
111
|
+
cursor.id = row.id;
|
|
112
|
+
}
|
|
113
|
+
writeCursor("opencode", cursor);
|
|
114
|
+
return emitted;
|
|
115
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only way a collector reads an agent's database: named columns of named
|
|
3
|
+
* tables, read-only, from an allow-list that may never name a credential.
|
|
4
|
+
* Agents keep login tokens in the same file as their usage, so a query is
|
|
5
|
+
* built here from the allow-list rather than written by the collector.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
|
|
10
|
+
export type AllowList = Readonly<Record<string, readonly string[]>>;
|
|
11
|
+
|
|
12
|
+
const NEVER_READ_TABLES = ["account", "control_account", "credential", "session_share"];
|
|
13
|
+
const NEVER_READ_COLUMN = /token$|secret|credential|password|api_?key|^value$/i;
|
|
14
|
+
|
|
15
|
+
export function assertNoCredentials(allowList: AllowList): void {
|
|
16
|
+
for (const [table, columns] of Object.entries(allowList)) {
|
|
17
|
+
if (NEVER_READ_TABLES.includes(table.toLowerCase()))
|
|
18
|
+
throw new Error(`table ${table} holds credentials and is never read`);
|
|
19
|
+
const secret = columns.find((column) => NEVER_READ_COLUMN.test(column));
|
|
20
|
+
if (secret) throw new Error(`column ${table}.${secret} is never read`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResumeAfter {
|
|
25
|
+
orderColumn: string;
|
|
26
|
+
idColumn: string;
|
|
27
|
+
order: number | string;
|
|
28
|
+
id: number | string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function quoted(identifier: string): string {
|
|
32
|
+
return `"${identifier.replaceAll('"', '""')}"`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function allowedColumns(allowList: AllowList, table: string, columns: readonly string[]) {
|
|
36
|
+
const allowed = allowList[table];
|
|
37
|
+
if (!allowed) throw new Error(`table ${table} is not on the allow-list`);
|
|
38
|
+
const stray = columns.find((column) => !allowed.includes(column));
|
|
39
|
+
if (stray) throw new Error(`column ${table}.${stray} is not on the allow-list`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function selectAllowed<Row>(
|
|
43
|
+
dbPath: string,
|
|
44
|
+
allowList: AllowList,
|
|
45
|
+
table: string,
|
|
46
|
+
columns: readonly string[],
|
|
47
|
+
after: ResumeAfter
|
|
48
|
+
): Row[] {
|
|
49
|
+
assertNoCredentials(allowList);
|
|
50
|
+
allowedColumns(allowList, table, [...columns, after.orderColumn, after.idColumn]);
|
|
51
|
+
const order = quoted(after.orderColumn);
|
|
52
|
+
const id = quoted(after.idColumn);
|
|
53
|
+
const sql = `SELECT ${columns.map(quoted).join(", ")} FROM ${quoted(table)} WHERE ${order} > ?1 OR (${order} = ?1 AND ${id} > ?2) ORDER BY ${order}, ${id}`;
|
|
54
|
+
const db = new Database(dbPath, { readonly: true });
|
|
55
|
+
try {
|
|
56
|
+
return db
|
|
57
|
+
.query<Row, [number | string, number | string]>(sql)
|
|
58
|
+
.all(after.order, after.id);
|
|
59
|
+
} finally {
|
|
60
|
+
db.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -10,7 +10,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { costOfUsage } from "../../hooks/lib/models";
|
|
13
|
-
import { cacheWritesOf, type TranscriptUsage } from "./transcript-usage";
|
|
13
|
+
import { cacheWritesOf, finalPerMessage, type TranscriptUsage } from "./transcript-usage";
|
|
14
14
|
|
|
15
15
|
export interface SessionUsage {
|
|
16
16
|
input: number;
|
|
@@ -94,7 +94,7 @@ interface TranscriptLine {
|
|
|
94
94
|
type?: string;
|
|
95
95
|
timestamp?: string;
|
|
96
96
|
sessionId?: string;
|
|
97
|
-
message?: { model?: string; usage?: TranscriptUsage };
|
|
97
|
+
message?: { id?: string; model?: string; usage?: TranscriptUsage };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
function emptyUsage(): SessionUsage {
|
|
@@ -113,6 +113,7 @@ function emptyUsage(): SessionUsage {
|
|
|
113
113
|
|
|
114
114
|
export function accumulateUsage(transcript: string, sessionId: string): SessionUsage {
|
|
115
115
|
const total = emptyUsage();
|
|
116
|
+
const assistantTurns: TranscriptLine[] = [];
|
|
116
117
|
let firstTs = "";
|
|
117
118
|
let lastTs = "";
|
|
118
119
|
|
|
@@ -133,7 +134,10 @@ export function accumulateUsage(transcript: string, sessionId: string): SessionU
|
|
|
133
134
|
lastTs = entry.timestamp;
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
if (entry.type
|
|
137
|
+
if (entry.type === "assistant") assistantTurns.push(entry);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const entry of finalPerMessage(assistantTurns, (e) => e.message?.id)) {
|
|
137
141
|
const usage = entry.message?.usage;
|
|
138
142
|
const model = entry.message?.model;
|
|
139
143
|
if (!usage || !model) continue;
|
|
@@ -16,6 +16,24 @@ export interface TranscriptUsage {
|
|
|
16
16
|
ephemeral_5m_input_tokens?: number;
|
|
17
17
|
ephemeral_1h_input_tokens?: number;
|
|
18
18
|
};
|
|
19
|
+
output_tokens_details?: { thinking_tokens?: number };
|
|
20
|
+
server_tool_use?: { web_search_requests?: number; web_fetch_requests?: number };
|
|
21
|
+
service_tier?: string;
|
|
22
|
+
speed?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Claude Code writes one transcript line per content block of a message, each
|
|
27
|
+
* repeating the message's usage — and an early line can carry the usage while
|
|
28
|
+
* the message was still streaming. A message is billed once, at its last line.
|
|
29
|
+
*/
|
|
30
|
+
export function finalPerMessage<T>(
|
|
31
|
+
calls: Iterable<T>,
|
|
32
|
+
messageIdOf: (call: T) => string | undefined
|
|
33
|
+
): T[] {
|
|
34
|
+
const byMessage = new Map<unknown, T>();
|
|
35
|
+
for (const call of calls) byMessage.set(messageIdOf(call) ?? call, call);
|
|
36
|
+
return [...byMessage.values()];
|
|
19
37
|
}
|
|
20
38
|
|
|
21
39
|
export interface CacheWrites {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { costOfUsage } from "../../hooks/lib/models";
|
|
10
|
-
import { cacheWritesOf, type TranscriptUsage } from "./transcript-usage";
|
|
10
|
+
import { cacheWritesOf, finalPerMessage, type TranscriptUsage } from "./transcript-usage";
|
|
11
11
|
|
|
12
12
|
export interface Bucket {
|
|
13
13
|
input: number;
|
|
@@ -180,7 +180,7 @@ export function usageTally(projectFilter?: string, now: Date = new Date()): Usag
|
|
|
180
180
|
interface TranscriptLine {
|
|
181
181
|
type?: string;
|
|
182
182
|
timestamp?: string;
|
|
183
|
-
message?: { model?: string; usage?: TranscriptUsage };
|
|
183
|
+
message?: { id?: string; model?: string; usage?: TranscriptUsage };
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
/**
|
|
@@ -208,7 +208,15 @@ function transcriptsIn(projPath: string): string[] {
|
|
|
208
208
|
return files;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
export function claudeTranscripts(projectsDir: string): string[] {
|
|
212
|
+
if (!existsSync(projectsDir)) return [];
|
|
213
|
+
return readdirSync(projectsDir, { withFileTypes: true })
|
|
214
|
+
.filter((entry) => entry.isDirectory())
|
|
215
|
+
.flatMap((entry) => transcriptsIn(resolve(projectsDir, entry.name)));
|
|
216
|
+
}
|
|
217
|
+
|
|
211
218
|
interface PricedCall {
|
|
219
|
+
messageId?: string;
|
|
212
220
|
ts: string;
|
|
213
221
|
model: string;
|
|
214
222
|
tokens: Tokens;
|
|
@@ -230,6 +238,7 @@ function pricedCallOf(line: string): PricedCall | null {
|
|
|
230
238
|
if (!usage || !model || !ts) return null;
|
|
231
239
|
|
|
232
240
|
return {
|
|
241
|
+
messageId: entry.message?.id,
|
|
233
242
|
ts,
|
|
234
243
|
model,
|
|
235
244
|
tokens: {
|
|
@@ -248,6 +257,7 @@ export function readClaudeCode(
|
|
|
248
257
|
): AgentUsage {
|
|
249
258
|
const tally = usageTally(projectFilter, now);
|
|
250
259
|
if (!existsSync(claudeDir)) return tally.usage;
|
|
260
|
+
const calls: (PricedCall & { project: string })[] = [];
|
|
251
261
|
|
|
252
262
|
const projectDirs = readdirSync(claudeDir, { withFileTypes: true })
|
|
253
263
|
.filter((entry) => entry.isDirectory())
|
|
@@ -269,16 +279,20 @@ export function readClaudeCode(
|
|
|
269
279
|
// Cheap reject before the parse: most lines are not model turns.
|
|
270
280
|
if (!line.includes('"usage"')) continue;
|
|
271
281
|
const call = pricedCallOf(line);
|
|
272
|
-
if (
|
|
273
|
-
tally.record({
|
|
274
|
-
...call,
|
|
275
|
-
project: projName,
|
|
276
|
-
cost: costOfUsage(call.model, call.tokens),
|
|
277
|
-
});
|
|
282
|
+
if (call) calls.push({ ...call, project: projName });
|
|
278
283
|
}
|
|
279
284
|
}
|
|
280
285
|
}
|
|
281
286
|
|
|
287
|
+
for (const call of finalPerMessage(calls, (c) => c.messageId)) {
|
|
288
|
+
tally.record({
|
|
289
|
+
ts: call.ts,
|
|
290
|
+
model: call.model,
|
|
291
|
+
tokens: call.tokens,
|
|
292
|
+
project: call.project,
|
|
293
|
+
cost: costOfUsage(call.model, call.tokens),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
282
296
|
return tally.usage;
|
|
283
297
|
}
|
|
284
298
|
|
package/src/tools/token-cost.ts
CHANGED
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
7
|
import { resolve } from "node:path";
|
|
9
8
|
import { AGENT_REGISTRY, type AgentName } from "../hooks/lib/agent-registry";
|
|
10
9
|
import { palHome, platform } from "../hooks/lib/paths";
|
|
11
10
|
import { findBinaryOnPath } from "../hooks/lib/which";
|
|
12
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
opencodeDatabase,
|
|
13
|
+
readCodex,
|
|
14
|
+
readCopilot,
|
|
15
|
+
readOpencode,
|
|
16
|
+
} from "./lib/agent-usage";
|
|
13
17
|
import {
|
|
14
18
|
type AgentReport,
|
|
15
19
|
parseRtkSummary,
|
|
@@ -20,11 +24,6 @@ import { readClaudeCode, readPalInference } from "./lib/usage-buckets";
|
|
|
20
24
|
|
|
21
25
|
const UNTRACKED_AGENTS: AgentName[] = ["cursor", "antigravity"];
|
|
22
26
|
|
|
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
27
|
function agentReports(project: string | undefined): AgentReport[] {
|
|
29
28
|
const report = (agent: AgentName, read: () => AgentReport["usage"]) => ({
|
|
30
29
|
label: AGENT_REGISTRY[agent].label,
|