portable-agent-layer 0.95.1 → 0.96.1
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 +2 -2
- package/src/cli/events.ts +19 -0
- package/src/cli/install-flow.ts +2 -2
- 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/control-room/static.ts +86 -11
- 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/transcript-usage.ts +4 -0
- package/src/tools/lib/usage-buckets.ts +7 -0
- package/src/tools/token-cost.ts +6 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.96.1",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"type-check": "tsc --noEmit",
|
|
40
|
-
"test": "bun test
|
|
40
|
+
"test": "bun .agents/scripts/test.ts",
|
|
41
41
|
"test:mutate": "stryker run",
|
|
42
42
|
"test:mutate:diff": "bun .agents/scripts/mutate-diff.ts",
|
|
43
43
|
"format": "biome format",
|
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>",
|
package/src/cli/install-flow.ts
CHANGED
|
@@ -198,8 +198,8 @@ async function finishShared(docs: number): Promise<Done> {
|
|
|
198
198
|
*/
|
|
199
199
|
async function refreshControlRoom(): Promise<Done | null> {
|
|
200
200
|
const { isRepoMode } = await import("../hooks/handlers/update-check");
|
|
201
|
-
const {
|
|
202
|
-
const rebuilt = isRepoMode()
|
|
201
|
+
const { refreshPage } = await import("../tools/control-room/static");
|
|
202
|
+
const rebuilt = refreshPage(isRepoMode());
|
|
203
203
|
if (rebuilt === false)
|
|
204
204
|
return {
|
|
205
205
|
detail: ["page not rebuilt"],
|
|
@@ -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(
|
|
@@ -5,35 +5,110 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
10
|
import { isInside } from "../../hooks/lib/paths";
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..");
|
|
13
|
+
const UI_ROOT = resolve(import.meta.dir, "ui");
|
|
14
|
+
const UI_DIST = resolve(UI_ROOT, "dist");
|
|
15
|
+
const UI_INDEX = resolve(UI_DIST, "index.html");
|
|
16
|
+
export const ABANDONED_LOCK_MS = 120_000;
|
|
13
17
|
|
|
14
18
|
export const BUILD_COMMAND = "bun run build:ui";
|
|
15
19
|
|
|
20
|
+
/** Where the page is built from and to, and how; tests point it at a sandbox. */
|
|
21
|
+
export interface PageBuild {
|
|
22
|
+
sources: string;
|
|
23
|
+
lockfile: string;
|
|
24
|
+
dist: string;
|
|
25
|
+
lock: string;
|
|
26
|
+
build: () => boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const THE_PAGE: PageBuild = {
|
|
30
|
+
sources: resolve(REPO_ROOT, "src"),
|
|
31
|
+
lockfile: resolve(REPO_ROOT, "bun.lock"),
|
|
32
|
+
dist: UI_DIST,
|
|
33
|
+
lock: resolve(UI_ROOT, "dist.lock"),
|
|
34
|
+
build: runViteBuild,
|
|
35
|
+
};
|
|
36
|
+
|
|
16
37
|
export function isBuilt(): boolean {
|
|
17
|
-
return existsSync(
|
|
38
|
+
return existsSync(UI_INDEX);
|
|
18
39
|
}
|
|
19
40
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
function newestSourceChange(page: PageBuild): number {
|
|
42
|
+
const sources = readdirSync(page.sources, { recursive: true, withFileTypes: true })
|
|
43
|
+
.filter((entry) => entry.isFile() && !isInside(page.dist, entry.parentPath))
|
|
44
|
+
.map((entry) => resolve(entry.parentPath, entry.name));
|
|
45
|
+
if (existsSync(page.lockfile)) sources.push(page.lockfile);
|
|
46
|
+
return Math.max(...sources.map((file) => statSync(file).mtimeMs));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isUpToDate(page: PageBuild = THE_PAGE): boolean {
|
|
50
|
+
const index = resolve(page.dist, "index.html");
|
|
51
|
+
return existsSync(index) && statSync(index).mtimeMs >= newestSourceChange(page);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function clearAbandonedLock(lock: string): void {
|
|
55
|
+
try {
|
|
56
|
+
if (Date.now() - statSync(lock).mtimeMs > ABANDONED_LOCK_MS)
|
|
57
|
+
rmSync(lock, { recursive: true, force: true });
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tryTakeBuildLock(lock: string): boolean {
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(lock);
|
|
64
|
+
return true;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function whileHoldingBuildLock(lock: string, build: () => boolean): boolean {
|
|
72
|
+
while (!tryTakeBuildLock(lock)) {
|
|
73
|
+
clearAbandonedLock(lock);
|
|
74
|
+
Bun.sleepSync(50);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return build();
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(lock, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A clone has vite; a published install has the prepacked dist and never needs it. */
|
|
84
|
+
function runViteBuild(): boolean {
|
|
26
85
|
// node_modules/.bin/vite is a shell shim that Windows cannot spawn directly;
|
|
27
86
|
// the package's own JS entry runs the same build under any runtime.
|
|
28
|
-
const vite = resolve(
|
|
87
|
+
const vite = resolve(REPO_ROOT, "node_modules", "vite", "bin", "vite.js");
|
|
29
88
|
if (!existsSync(vite)) return false;
|
|
30
89
|
const built = spawnSync(process.execPath, [vite, "build"], {
|
|
31
|
-
cwd:
|
|
90
|
+
cwd: UI_ROOT,
|
|
32
91
|
encoding: "utf-8",
|
|
33
92
|
});
|
|
34
93
|
return built.status === 0 && isBuilt();
|
|
35
94
|
}
|
|
36
95
|
|
|
96
|
+
/**
|
|
97
|
+
* vite empties dist before writing it, so one build at a time, and none once
|
|
98
|
+
* another process has already brought it up to date.
|
|
99
|
+
*/
|
|
100
|
+
export function buildPage(page: PageBuild = THE_PAGE): boolean {
|
|
101
|
+
return whileHoldingBuildLock(page.lock, () => isUpToDate(page) || page.build());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A checkout's dist is whatever was built last, so an install rebuilds it if the sources moved on. */
|
|
105
|
+
export function refreshPage(
|
|
106
|
+
inCheckout: boolean,
|
|
107
|
+
page: PageBuild = THE_PAGE
|
|
108
|
+
): boolean | null {
|
|
109
|
+
return inCheckout && !isUpToDate(page) ? buildPage(page) : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
37
112
|
function unbuiltPage(): Response {
|
|
38
113
|
return new Response(
|
|
39
114
|
`<!doctype html><meta charset="utf-8"><title>PAL control room</title>` +
|
|
@@ -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
|
+
}
|
|
@@ -16,6 +16,10 @@ 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;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/**
|
|
@@ -208,6 +208,13 @@ 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 {
|
|
212
219
|
messageId?: string;
|
|
213
220
|
ts: string;
|
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,
|