@saadnvd1/agent-os 0.3.499 → 0.3.501
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Usage/RatchetView.tsx +249 -0
- package/components/Usage/UsageDialog.tsx +7 -1
- package/components/Usage/WindowCard.tsx +2 -1
- package/data/usage/index.ts +28 -0
- package/lib/chat/drivers/claude-mapper.ts +1 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/events.ts +3 -0
- package/lib/chat/worker/host.ts +21 -0
- package/lib/chat/worker/protocol.ts +8 -1
- package/lib/connect/cli.ts +51 -72
- package/lib/connect/cloudflared.ts +172 -0
- package/lib/connect/config.ts +75 -26
- package/lib/connect/control-api.ts +107 -48
- package/lib/connect/enrol.ts +33 -107
- package/lib/connect/serve.ts +177 -94
- package/lib/connect/supervise.ts +281 -0
- package/lib/db/migrations.ts +72 -0
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/tasks/index.ts +8 -0
- package/lib/usage/calls.ts +284 -0
- package/lib/usage/check-runs.ts +40 -19
- package/lib/usage/config.ts +56 -0
- package/lib/usage/nightly.ts +157 -0
- package/lib/usage/quality.ts +239 -0
- package/lib/usage/ratchet.ts +275 -0
- package/lib/usage/rate-limits.ts +192 -0
- package/lib/usage/rollup.ts +90 -0
- package/lib/usage/span.ts +152 -0
- package/lib/usage/transcript-import.ts +226 -0
- package/lib/usage/weights.ts +64 -0
- package/lib/usage/windows.ts +27 -2
- package/package.json +2 -4
- package/server.ts +7 -4
- package/stores/usageUi.ts +3 -3
- package/lib/connect/cert-check.ts +0 -32
- package/lib/connect/cloudflare-dns.ts +0 -56
- package/lib/connect/frames.ts +0 -72
- package/lib/connect/identity.ts +0 -98
- package/lib/connect/mux.ts +0 -110
- package/lib/connect/stream.ts +0 -106
- package/lib/connect/tunnel.ts +0 -98
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account's limit windows as Claude Code reports them: its
|
|
3
|
+
* rate_limit_event carries what the anthropic-ratelimit-unified-* headers
|
|
4
|
+
* said on a response, for the window that matters most at that moment. Each
|
|
5
|
+
* reading is kept with its time; between readings the weekly figure is an
|
|
6
|
+
* estimate from recorded usage, and labelled as one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { db } from "../db";
|
|
10
|
+
import { weighted, type TokenRow } from "./weights";
|
|
11
|
+
import { readUsageConfig } from "./config";
|
|
12
|
+
|
|
13
|
+
export type LimitWindow = "five_hour" | "seven_day";
|
|
14
|
+
|
|
15
|
+
export interface Reading {
|
|
16
|
+
window: LimitWindow;
|
|
17
|
+
// Percent used, 0-100.
|
|
18
|
+
utilization: number;
|
|
19
|
+
raw: number;
|
|
20
|
+
// Epoch ms.
|
|
21
|
+
resetsAt: number | null;
|
|
22
|
+
status: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const WINDOWS: LimitWindow[] = ["five_hour", "seven_day"];
|
|
26
|
+
const num = (v: unknown) =>
|
|
27
|
+
typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
28
|
+
|
|
29
|
+
// A rate_limit_event carries what the headers said: utilization as a
|
|
30
|
+
// fraction (0.42) and the reset in epoch seconds. A plan's per-window object
|
|
31
|
+
// carries a percent (42) and an ISO time. Each source has one unit.
|
|
32
|
+
const eventPercent = (fraction: number) => fraction * 100;
|
|
33
|
+
|
|
34
|
+
// Readings in what the CLI emits: a rate_limit_event's info, or a plan's
|
|
35
|
+
// per-window object. Anything else gives none.
|
|
36
|
+
export function parseRateLimit(info: unknown): Reading[] {
|
|
37
|
+
if (!info || typeof info !== "object") return [];
|
|
38
|
+
const i = info as Record<string, unknown>;
|
|
39
|
+
const out: Reading[] = [];
|
|
40
|
+
const status = typeof i.status === "string" ? i.status : null;
|
|
41
|
+
const type = i.rateLimitType;
|
|
42
|
+
const raw = num(i.utilization);
|
|
43
|
+
if ((type === "five_hour" || type === "seven_day") && raw !== null) {
|
|
44
|
+
const reset = num(i.resetsAt);
|
|
45
|
+
out.push({
|
|
46
|
+
window: type,
|
|
47
|
+
utilization: eventPercent(raw),
|
|
48
|
+
raw,
|
|
49
|
+
resetsAt: reset === null ? null : reset * 1000,
|
|
50
|
+
status,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
for (const w of WINDOWS) {
|
|
54
|
+
const o = i[w] as Record<string, unknown> | null | undefined;
|
|
55
|
+
const u = o && num(o.utilization);
|
|
56
|
+
if (!o || u === null || u === undefined) continue;
|
|
57
|
+
const reset =
|
|
58
|
+
typeof o.resets_at === "string" ? Date.parse(o.resets_at) : NaN;
|
|
59
|
+
out.push({
|
|
60
|
+
window: w,
|
|
61
|
+
utilization: u,
|
|
62
|
+
raw: u,
|
|
63
|
+
resetsAt: Number.isFinite(reset) ? reset : null,
|
|
64
|
+
status,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Stores a reading only when it differs from that window's last, so a busy
|
|
71
|
+
// day adds a handful of rows, not one per response.
|
|
72
|
+
export function recordReadings(
|
|
73
|
+
readings: Reading[],
|
|
74
|
+
source: string,
|
|
75
|
+
sessionId: string | null = null,
|
|
76
|
+
at = Date.now()
|
|
77
|
+
): number {
|
|
78
|
+
let added = 0;
|
|
79
|
+
for (const r of readings) {
|
|
80
|
+
const last = db
|
|
81
|
+
.prepare(
|
|
82
|
+
`SELECT utilization, resets_at, status FROM rate_limit_readings
|
|
83
|
+
WHERE window = ? ORDER BY id DESC LIMIT 1`
|
|
84
|
+
)
|
|
85
|
+
.get(r.window) as
|
|
86
|
+
| { utilization: number; resets_at: number | null; status: string | null }
|
|
87
|
+
| undefined;
|
|
88
|
+
if (
|
|
89
|
+
last &&
|
|
90
|
+
last.utilization === r.utilization &&
|
|
91
|
+
last.resets_at === r.resetsAt &&
|
|
92
|
+
last.status === r.status
|
|
93
|
+
)
|
|
94
|
+
continue;
|
|
95
|
+
db.prepare(
|
|
96
|
+
`INSERT INTO rate_limit_readings (at, window, utilization, raw, resets_at, status, session_id, source)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
98
|
+
).run(
|
|
99
|
+
at,
|
|
100
|
+
r.window,
|
|
101
|
+
r.utilization,
|
|
102
|
+
r.raw,
|
|
103
|
+
r.resetsAt,
|
|
104
|
+
r.status,
|
|
105
|
+
sessionId,
|
|
106
|
+
source
|
|
107
|
+
);
|
|
108
|
+
added++;
|
|
109
|
+
}
|
|
110
|
+
return added;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface WindowReading {
|
|
114
|
+
pct: number;
|
|
115
|
+
resetsAt: number | null;
|
|
116
|
+
// When it was read (or, for an estimate, worked out).
|
|
117
|
+
at: number;
|
|
118
|
+
estimated: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function lastReading(
|
|
122
|
+
window: LimitWindow,
|
|
123
|
+
before = Number.MAX_SAFE_INTEGER
|
|
124
|
+
): { at: number; utilization: number; resetsAt: number | null } | null {
|
|
125
|
+
const r = db
|
|
126
|
+
.prepare(
|
|
127
|
+
`SELECT at, utilization, resets_at FROM rate_limit_readings
|
|
128
|
+
WHERE window = ? AND at <= ? ORDER BY at DESC, id DESC LIMIT 1`
|
|
129
|
+
)
|
|
130
|
+
.get(window, before) as
|
|
131
|
+
| { at: number; utilization: number; resets_at: number | null }
|
|
132
|
+
| undefined;
|
|
133
|
+
return r
|
|
134
|
+
? { at: r.at, utilization: r.utilization, resetsAt: r.resets_at }
|
|
135
|
+
: null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const WEEK = 7 * 24 * 3600 * 1000;
|
|
139
|
+
|
|
140
|
+
export function weightedBetween(from: number, to: number): number {
|
|
141
|
+
const rows = db
|
|
142
|
+
.prepare(
|
|
143
|
+
`SELECT model, provider, input_tokens, cache_read_tokens, cache_write_5m_tokens,
|
|
144
|
+
cache_write_1h_tokens, output_tokens FROM usage_calls WHERE at > ? AND at <= ?`
|
|
145
|
+
)
|
|
146
|
+
.all(from, to) as TokenRow[];
|
|
147
|
+
let t = 0;
|
|
148
|
+
for (const r of rows) t += weighted(r);
|
|
149
|
+
return t;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The weekly percent now, worked out from the last real reading: the usage
|
|
153
|
+
// recorded in the week up to that reading stands for its percent, so the
|
|
154
|
+
// week up to now stands for this one. Null before any reading.
|
|
155
|
+
export function estimateWeekly(now: number): WindowReading | null {
|
|
156
|
+
const last = lastReading("seven_day", now);
|
|
157
|
+
if (!last || last.utilization <= 0) return null;
|
|
158
|
+
const then = weightedBetween(last.at - WEEK, last.at);
|
|
159
|
+
if (then <= 0) return null;
|
|
160
|
+
const pct = (weightedBetween(now - WEEK, now) / then) * last.utilization;
|
|
161
|
+
return {
|
|
162
|
+
pct: Math.round(pct * 10) / 10,
|
|
163
|
+
resetsAt: last.resetsAt,
|
|
164
|
+
at: now,
|
|
165
|
+
estimated: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The window from a reading when it's fresh enough, else (weekly only) the
|
|
170
|
+
// estimate, labelled.
|
|
171
|
+
export function currentWindow(
|
|
172
|
+
window: LimitWindow,
|
|
173
|
+
now = Date.now()
|
|
174
|
+
): WindowReading | null {
|
|
175
|
+
const last = lastReading(window, now);
|
|
176
|
+
const maxAge =
|
|
177
|
+
window === "five_hour"
|
|
178
|
+
? 30 * 60_000
|
|
179
|
+
: readUsageConfig().weeklyStaleHours * 3_600_000;
|
|
180
|
+
// A reading from before its window reset describes the old window.
|
|
181
|
+
const live =
|
|
182
|
+
last && now - last.at <= maxAge && (!last.resetsAt || last.resetsAt > now);
|
|
183
|
+
if (last && live)
|
|
184
|
+
return {
|
|
185
|
+
pct: last.utilization,
|
|
186
|
+
resetsAt: last.resetsAt,
|
|
187
|
+
at: last.at,
|
|
188
|
+
estimated: false,
|
|
189
|
+
};
|
|
190
|
+
if (window === "seven_day") return estimateWeekly(now);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One day of usage and quality, from the ledger and the quality readers.
|
|
3
|
+
* Rows keep their sums as well as their ratios, so any span of days
|
|
4
|
+
* aggregates as a ratio of sums: three days' cost per PR is their total cost
|
|
5
|
+
* over their total PRs, never a mean of three ratios.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db } from "../db";
|
|
9
|
+
import { currentWindow } from "./rate-limits";
|
|
10
|
+
import {
|
|
11
|
+
escalationsIn,
|
|
12
|
+
escapesIn,
|
|
13
|
+
mergedTasks,
|
|
14
|
+
qualityOf,
|
|
15
|
+
scopeChangesIn,
|
|
16
|
+
type Gh,
|
|
17
|
+
} from "./quality";
|
|
18
|
+
|
|
19
|
+
export * from "./span";
|
|
20
|
+
import { computeRollup, type CallGroup, type Rollup } from "./span";
|
|
21
|
+
|
|
22
|
+
export const dayKey = (ms: number) => {
|
|
23
|
+
const d = new Date(ms);
|
|
24
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
25
|
+
};
|
|
26
|
+
export const dayStart = (day: string) => {
|
|
27
|
+
const [y, m, d] = day.split("-").map(Number);
|
|
28
|
+
return new Date(y, m - 1, d).getTime();
|
|
29
|
+
};
|
|
30
|
+
export const dayEnd = (day: string) => {
|
|
31
|
+
const [y, m, d] = day.split("-").map(Number);
|
|
32
|
+
return new Date(y, m - 1, d + 1).getTime();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function groupsIn(from: number, to: number): CallGroup[] {
|
|
36
|
+
return db
|
|
37
|
+
.prepare(
|
|
38
|
+
`SELECT kind, role, model, provider, COUNT(*) AS calls,
|
|
39
|
+
SUM(input_tokens) AS input_tokens, SUM(cache_read_tokens) AS cache_read_tokens,
|
|
40
|
+
SUM(cache_write_5m_tokens) AS cache_write_5m_tokens,
|
|
41
|
+
SUM(cache_write_1h_tokens) AS cache_write_1h_tokens,
|
|
42
|
+
SUM(output_tokens) AS output_tokens,
|
|
43
|
+
SUM(ctx_tokens IS NOT NULL) AS ctxKnown, SUM(ctx_tokens > 200000) AS over200k
|
|
44
|
+
FROM usage_calls WHERE at >= ? AND at < ?
|
|
45
|
+
GROUP BY kind, role, model, provider`
|
|
46
|
+
)
|
|
47
|
+
.all(from, to) as CallGroup[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function rollupDay(
|
|
51
|
+
day: string,
|
|
52
|
+
now = Date.now(),
|
|
53
|
+
gh?: Gh
|
|
54
|
+
): Promise<Rollup> {
|
|
55
|
+
const from = dayStart(day);
|
|
56
|
+
const to = dayEnd(day);
|
|
57
|
+
const tasks = mergedTasks(from, to);
|
|
58
|
+
const q = qualityOf(tasks);
|
|
59
|
+
const weekly = currentWindow("seven_day", Math.min(now, to));
|
|
60
|
+
return computeRollup({
|
|
61
|
+
day,
|
|
62
|
+
groups: groupsIn(from, to),
|
|
63
|
+
merged: tasks.length,
|
|
64
|
+
clean: q.filter((t) => t.clean).length,
|
|
65
|
+
fixRounds: q.reduce((t, x) => t + x.fixRounds, 0),
|
|
66
|
+
escalations: escalationsIn(from, to),
|
|
67
|
+
scopeChanges: scopeChangesIn(from, to),
|
|
68
|
+
escapes: await escapesIn(tasks, gh),
|
|
69
|
+
weeklyPct: weekly?.pct ?? null,
|
|
70
|
+
weeklyEstimated: weekly?.estimated ?? false,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function saveRollup(r: Rollup, final: boolean, now = Date.now()): void {
|
|
75
|
+
db.prepare(
|
|
76
|
+
`INSERT INTO usage_rollups (day, data, final, computed_at) VALUES (?, ?, ?, ?)
|
|
77
|
+
ON CONFLICT(day) DO UPDATE SET data = excluded.data, final = excluded.final,
|
|
78
|
+
computed_at = excluded.computed_at`
|
|
79
|
+
).run(r.day, JSON.stringify(r), final ? 1 : 0, now);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function loadRollups(limit = 60): Rollup[] {
|
|
83
|
+
return (
|
|
84
|
+
db
|
|
85
|
+
.prepare(`SELECT data FROM usage_rollups ORDER BY day DESC LIMIT ?`)
|
|
86
|
+
.all(limit) as { data: string }[]
|
|
87
|
+
)
|
|
88
|
+
.map((r) => JSON.parse(r.data) as Rollup)
|
|
89
|
+
.reverse();
|
|
90
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of the daily rollup: one day from its inputs, and any span
|
|
3
|
+
* of days as ratios of sums. No database, so the Usage view can use it too.
|
|
4
|
+
* A day keeps its sums as well as its ratios, so three days' cost per PR is
|
|
5
|
+
* their total cost over their total PRs, never a mean of three ratios.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { weighted, family, type TokenRow } from "./weights";
|
|
9
|
+
|
|
10
|
+
export interface CallGroup extends TokenRow {
|
|
11
|
+
kind: string;
|
|
12
|
+
role: string | null;
|
|
13
|
+
calls: number;
|
|
14
|
+
// Calls with a known context size, and those of them above 200k.
|
|
15
|
+
ctxKnown: number;
|
|
16
|
+
over200k: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Rollup {
|
|
20
|
+
day: string;
|
|
21
|
+
weighted: number;
|
|
22
|
+
// Everything but a person's own sessions.
|
|
23
|
+
work: number;
|
|
24
|
+
review: number;
|
|
25
|
+
orchestrator: number;
|
|
26
|
+
mix: { opus: number; sonnet: number; haiku: number };
|
|
27
|
+
calls: number;
|
|
28
|
+
ctxKnown: number;
|
|
29
|
+
over200k: number;
|
|
30
|
+
merged: number;
|
|
31
|
+
clean: number;
|
|
32
|
+
fixRounds: number;
|
|
33
|
+
escalations: number;
|
|
34
|
+
scopeChanges: number;
|
|
35
|
+
escapes: number | null;
|
|
36
|
+
weeklyPct: number | null;
|
|
37
|
+
weeklyEstimated: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DayInputs {
|
|
41
|
+
day: string;
|
|
42
|
+
groups: CallGroup[];
|
|
43
|
+
merged: number;
|
|
44
|
+
clean: number;
|
|
45
|
+
fixRounds: number;
|
|
46
|
+
escalations: number;
|
|
47
|
+
scopeChanges: number;
|
|
48
|
+
escapes: number | null;
|
|
49
|
+
weeklyPct: number | null;
|
|
50
|
+
weeklyEstimated: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function computeRollup(i: DayInputs): Rollup {
|
|
54
|
+
const r: Rollup = {
|
|
55
|
+
day: i.day,
|
|
56
|
+
weighted: 0,
|
|
57
|
+
work: 0,
|
|
58
|
+
review: 0,
|
|
59
|
+
orchestrator: 0,
|
|
60
|
+
mix: { opus: 0, sonnet: 0, haiku: 0 },
|
|
61
|
+
calls: 0,
|
|
62
|
+
ctxKnown: 0,
|
|
63
|
+
over200k: 0,
|
|
64
|
+
merged: i.merged,
|
|
65
|
+
clean: i.clean,
|
|
66
|
+
fixRounds: i.fixRounds,
|
|
67
|
+
escalations: i.escalations,
|
|
68
|
+
scopeChanges: i.scopeChanges,
|
|
69
|
+
escapes: i.escapes,
|
|
70
|
+
weeklyPct: i.weeklyPct,
|
|
71
|
+
weeklyEstimated: i.weeklyEstimated,
|
|
72
|
+
};
|
|
73
|
+
for (const g of i.groups) {
|
|
74
|
+
const w = weighted(g);
|
|
75
|
+
if (w <= 0 && g.provider && g.provider !== "anthropic") continue;
|
|
76
|
+
r.weighted += w;
|
|
77
|
+
r.mix[family(g.model)] += w;
|
|
78
|
+
r.calls += g.calls;
|
|
79
|
+
r.ctxKnown += g.ctxKnown;
|
|
80
|
+
r.over200k += g.over200k;
|
|
81
|
+
if (g.role !== "interactive") r.work += w;
|
|
82
|
+
if (g.kind === "review" || g.kind === "gate") r.review += w;
|
|
83
|
+
if (g.role === "orchestrator") r.orchestrator += w;
|
|
84
|
+
}
|
|
85
|
+
return r;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A span of days, as ratios of sums.
|
|
89
|
+
export interface Span {
|
|
90
|
+
days: number;
|
|
91
|
+
weighted: number;
|
|
92
|
+
work: number;
|
|
93
|
+
merged: number;
|
|
94
|
+
clean: number;
|
|
95
|
+
costPerMerged: number | null;
|
|
96
|
+
costPerClean: number | null;
|
|
97
|
+
cleanRate: number | null;
|
|
98
|
+
reviewPerPr: number | null;
|
|
99
|
+
over200kShare: number | null;
|
|
100
|
+
orchestratorShare: number | null;
|
|
101
|
+
opusShare: number | null;
|
|
102
|
+
fixRoundsPerPr: number | null;
|
|
103
|
+
escalations: number;
|
|
104
|
+
scopeChanges: number;
|
|
105
|
+
escapes: number | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ratio = (a: number, b: number) => (b > 0 ? a / b : null);
|
|
109
|
+
|
|
110
|
+
export function spanOf(rows: Rollup[]): Span {
|
|
111
|
+
const sum = (f: (r: Rollup) => number) => rows.reduce((t, r) => t + f(r), 0);
|
|
112
|
+
const work = sum((r) => r.work);
|
|
113
|
+
const merged = sum((r) => r.merged);
|
|
114
|
+
const clean = sum((r) => r.clean);
|
|
115
|
+
const weightedTotal = sum((r) => r.weighted);
|
|
116
|
+
const known = rows.filter((r) => r.escapes !== null);
|
|
117
|
+
return {
|
|
118
|
+
days: rows.length,
|
|
119
|
+
weighted: weightedTotal,
|
|
120
|
+
work,
|
|
121
|
+
merged,
|
|
122
|
+
clean,
|
|
123
|
+
costPerMerged: ratio(work, merged),
|
|
124
|
+
costPerClean: ratio(work, clean),
|
|
125
|
+
cleanRate: ratio(clean, merged),
|
|
126
|
+
reviewPerPr: ratio(
|
|
127
|
+
sum((r) => r.review),
|
|
128
|
+
merged
|
|
129
|
+
),
|
|
130
|
+
over200kShare: ratio(
|
|
131
|
+
sum((r) => r.over200k),
|
|
132
|
+
sum((r) => r.ctxKnown)
|
|
133
|
+
),
|
|
134
|
+
orchestratorShare: ratio(
|
|
135
|
+
sum((r) => r.orchestrator),
|
|
136
|
+
weightedTotal
|
|
137
|
+
),
|
|
138
|
+
opusShare: ratio(
|
|
139
|
+
sum((r) => r.mix.opus),
|
|
140
|
+
weightedTotal
|
|
141
|
+
),
|
|
142
|
+
fixRoundsPerPr: ratio(
|
|
143
|
+
sum((r) => r.fixRounds),
|
|
144
|
+
merged
|
|
145
|
+
),
|
|
146
|
+
escalations: sum((r) => r.escalations),
|
|
147
|
+
scopeChanges: sum((r) => r.scopeChanges),
|
|
148
|
+
escapes: known.length
|
|
149
|
+
? known.reduce((t, r) => t + (r.escapes ?? 0), 0)
|
|
150
|
+
: null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage from Claude's own transcripts, for the sessions AgentOS runs that it
|
|
3
|
+
* can't watch live (a terminal session) and for the days before live capture.
|
|
4
|
+
* Messages are deduped by id against what the workers recorded, and each file
|
|
5
|
+
* is read from where the last pass stopped.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { db } from "../db";
|
|
12
|
+
import {
|
|
13
|
+
callFromAssistant,
|
|
14
|
+
recordCall,
|
|
15
|
+
sessionContext,
|
|
16
|
+
type CallKind,
|
|
17
|
+
} from "./calls";
|
|
18
|
+
|
|
19
|
+
const claudeDir = () =>
|
|
20
|
+
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
21
|
+
|
|
22
|
+
interface TranscriptFile {
|
|
23
|
+
file: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
// Set for a subagent's file.
|
|
26
|
+
agentType: string | null;
|
|
27
|
+
sidechain: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Every transcript of a session AgentOS knows, modified since `since`.
|
|
31
|
+
export async function transcriptFiles(
|
|
32
|
+
since: number,
|
|
33
|
+
root = path.join(claudeDir(), "projects")
|
|
34
|
+
): Promise<TranscriptFile[]> {
|
|
35
|
+
const known = new Map<string, string>();
|
|
36
|
+
const rows = db
|
|
37
|
+
.prepare(
|
|
38
|
+
`SELECT id, claude_session_id FROM sessions WHERE claude_session_id IS NOT NULL`
|
|
39
|
+
)
|
|
40
|
+
.all() as { id: string; claude_session_id: string }[];
|
|
41
|
+
for (const r of rows) known.set(r.claude_session_id, r.id);
|
|
42
|
+
const out: TranscriptFile[] = [];
|
|
43
|
+
const dirs = await fs.promises.readdir(root).catch(() => [] as string[]);
|
|
44
|
+
for (const d of dirs) {
|
|
45
|
+
const dir = path.join(root, d);
|
|
46
|
+
const entries = await fs.promises
|
|
47
|
+
.readdir(dir, { withFileTypes: true })
|
|
48
|
+
.catch(() => []);
|
|
49
|
+
for (const e of entries) {
|
|
50
|
+
if (e.isFile() && e.name.endsWith(".jsonl")) {
|
|
51
|
+
const claudeId = e.name.slice(0, -".jsonl".length);
|
|
52
|
+
const sid = known.get(claudeId);
|
|
53
|
+
if (!sid || !(await newer(path.join(dir, e.name), since))) continue;
|
|
54
|
+
out.push({
|
|
55
|
+
file: path.join(dir, e.name),
|
|
56
|
+
sessionId: sid,
|
|
57
|
+
agentType: null,
|
|
58
|
+
sidechain: false,
|
|
59
|
+
});
|
|
60
|
+
} else if (e.isDirectory() && known.has(e.name)) {
|
|
61
|
+
const sid = known.get(e.name)!;
|
|
62
|
+
const sub = path.join(dir, e.name, "subagents");
|
|
63
|
+
const files = await fs.promises
|
|
64
|
+
.readdir(sub)
|
|
65
|
+
.catch(() => [] as string[]);
|
|
66
|
+
for (const f of files) {
|
|
67
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
68
|
+
const file = path.join(sub, f);
|
|
69
|
+
if (!(await newer(file, since))) continue;
|
|
70
|
+
out.push({
|
|
71
|
+
file,
|
|
72
|
+
sessionId: sid,
|
|
73
|
+
agentType: await agentTypeOf(file),
|
|
74
|
+
sidechain: true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function newer(file: string, since: number): Promise<boolean> {
|
|
84
|
+
const st = await fs.promises.stat(file).catch(() => null);
|
|
85
|
+
return !!st && st.mtimeMs >= since;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function agentTypeOf(file: string): Promise<string | null> {
|
|
89
|
+
try {
|
|
90
|
+
const meta = JSON.parse(
|
|
91
|
+
await fs.promises.readFile(file.replace(/\.jsonl$/, ".meta.json"), "utf8")
|
|
92
|
+
) as { agentType?: unknown };
|
|
93
|
+
return typeof meta.agentType === "string" ? meta.agentType : null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Each line of the file from `start` that ends in a newline, as bytes; a last
|
|
100
|
+
// line still being written is left for the next pass.
|
|
101
|
+
async function* completeLines(
|
|
102
|
+
file: string,
|
|
103
|
+
start: number,
|
|
104
|
+
end: number
|
|
105
|
+
): AsyncGenerator<Buffer> {
|
|
106
|
+
if (end <= start) return;
|
|
107
|
+
let rest: Buffer = Buffer.alloc(0);
|
|
108
|
+
// `end` is inclusive, so a line appended during the read waits too.
|
|
109
|
+
const stream = fs.createReadStream(file, { start, end: end - 1 });
|
|
110
|
+
for await (const chunk of stream) {
|
|
111
|
+
rest = rest.length
|
|
112
|
+
? Buffer.concat([rest, chunk as Buffer])
|
|
113
|
+
: (chunk as Buffer);
|
|
114
|
+
let from = 0;
|
|
115
|
+
for (let i = rest.indexOf(10, from); i !== -1; i = rest.indexOf(10, from)) {
|
|
116
|
+
yield rest.subarray(from, i);
|
|
117
|
+
from = i + 1;
|
|
118
|
+
}
|
|
119
|
+
rest = rest.subarray(from);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface Line {
|
|
124
|
+
type?: string;
|
|
125
|
+
timestamp?: string;
|
|
126
|
+
attributionAgent?: string;
|
|
127
|
+
attributionSkill?: string;
|
|
128
|
+
message?: Parameters<typeof callFromAssistant>[0];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Imports one file from its stored offset. Returns the calls it saw.
|
|
132
|
+
export async function importFile(
|
|
133
|
+
f: TranscriptFile,
|
|
134
|
+
since: number
|
|
135
|
+
): Promise<number> {
|
|
136
|
+
const st = await fs.promises.stat(f.file);
|
|
137
|
+
const saved = db
|
|
138
|
+
.prepare(`SELECT offset, mtime FROM usage_import_offsets WHERE path = ?`)
|
|
139
|
+
.get(f.file) as { offset: number; mtime: number } | undefined;
|
|
140
|
+
// A file shorter than where we stopped was rewritten: start again.
|
|
141
|
+
const start = saved && saved.offset <= st.size ? saved.offset : 0;
|
|
142
|
+
if (
|
|
143
|
+
saved &&
|
|
144
|
+
saved.offset === st.size &&
|
|
145
|
+
saved.mtime === Math.floor(st.mtimeMs)
|
|
146
|
+
)
|
|
147
|
+
return 0;
|
|
148
|
+
const ctx = sessionContext(f.sessionId);
|
|
149
|
+
let offset = start;
|
|
150
|
+
let n = 0;
|
|
151
|
+
const pending: (() => void)[] = [];
|
|
152
|
+
for await (const raw of completeLines(f.file, start, st.size)) {
|
|
153
|
+
// Counted in bytes as they sit in the file, whatever ends the line.
|
|
154
|
+
offset += raw.length + 1;
|
|
155
|
+
const line = raw.toString("utf8");
|
|
156
|
+
if (!line.includes('"assistant"')) continue;
|
|
157
|
+
let d: Line;
|
|
158
|
+
try {
|
|
159
|
+
d = JSON.parse(line) as Line;
|
|
160
|
+
} catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (d.type !== "assistant") continue;
|
|
164
|
+
const at = d.timestamp ? Date.parse(d.timestamp) : NaN;
|
|
165
|
+
if (!Number.isFinite(at) || at < since) continue;
|
|
166
|
+
const agent = f.agentType ?? d.attributionAgent ?? null;
|
|
167
|
+
const kind: CallKind = !f.sidechain
|
|
168
|
+
? "main"
|
|
169
|
+
: agent?.startsWith("review-") || d.attributionSkill === "do-code-review"
|
|
170
|
+
? "review"
|
|
171
|
+
: "sub";
|
|
172
|
+
const call = callFromAssistant(
|
|
173
|
+
d.message,
|
|
174
|
+
kind,
|
|
175
|
+
f.sidechain ? agent : null,
|
|
176
|
+
at
|
|
177
|
+
);
|
|
178
|
+
if (!call) continue;
|
|
179
|
+
pending.push(() => recordCall(call, ctx, "transcript"));
|
|
180
|
+
n++;
|
|
181
|
+
}
|
|
182
|
+
db.transaction(() => {
|
|
183
|
+
for (const w of pending) w();
|
|
184
|
+
db.prepare(
|
|
185
|
+
`INSERT INTO usage_import_offsets (path, offset, mtime) VALUES (?, ?, ?)
|
|
186
|
+
ON CONFLICT(path) DO UPDATE SET offset = excluded.offset, mtime = excluded.mtime`
|
|
187
|
+
).run(f.file, offset, Math.floor(st.mtimeMs));
|
|
188
|
+
})();
|
|
189
|
+
return n;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Whether a file is as the last pass left it.
|
|
193
|
+
function unchanged(f: TranscriptFile, st: fs.Stats): boolean {
|
|
194
|
+
const saved = db
|
|
195
|
+
.prepare(`SELECT offset, mtime FROM usage_import_offsets WHERE path = ?`)
|
|
196
|
+
.get(f.file) as { offset: number; mtime: number } | undefined;
|
|
197
|
+
return (
|
|
198
|
+
!!saved &&
|
|
199
|
+
saved.offset === st.size &&
|
|
200
|
+
saved.mtime === Math.floor(st.mtimeMs)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// One pass over everything modified since `since`, yielding between files so
|
|
205
|
+
// the server keeps answering. Files already up to date don't count against
|
|
206
|
+
// the cap, and the rest go oldest first, so a backlog drains over passes
|
|
207
|
+
// instead of the same first files being taken every time.
|
|
208
|
+
export async function importTranscripts(
|
|
209
|
+
since: number,
|
|
210
|
+
maxFiles = 400,
|
|
211
|
+
root?: string
|
|
212
|
+
): Promise<{ files: number; calls: number }> {
|
|
213
|
+
const todo: { f: TranscriptFile; mtime: number }[] = [];
|
|
214
|
+
for (const f of await transcriptFiles(since, root)) {
|
|
215
|
+
const st = await fs.promises.stat(f.file).catch(() => null);
|
|
216
|
+
if (st && !unchanged(f, st)) todo.push({ f, mtime: st.mtimeMs });
|
|
217
|
+
}
|
|
218
|
+
todo.sort((a, b) => a.mtime - b.mtime);
|
|
219
|
+
let calls = 0;
|
|
220
|
+
const batch = todo.slice(0, maxFiles);
|
|
221
|
+
for (const { f } of batch) {
|
|
222
|
+
calls += await importFile(f, since).catch(() => 0);
|
|
223
|
+
await new Promise((r) => setImmediate(r));
|
|
224
|
+
}
|
|
225
|
+
return { files: batch.length, calls };
|
|
226
|
+
}
|