@saadnvd1/agent-os 0.3.499 → 0.3.502
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 +77 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Chat/Activity.test.ts +22 -0
- package/components/Chat/Activity.tsx +17 -5
- package/components/Chat/ChatPanel.tsx +7 -1
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/SessionList/RowParts.tsx +13 -4
- package/components/SessionList/SessionList.types.ts +1 -0
- 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/components/views/types.ts +2 -0
- package/data/chat/useChat.ts +7 -0
- package/data/usage/index.ts +28 -0
- package/lib/chat/activity.ts +24 -2
- package/lib/chat/drivers/claude-mapper.ts +8 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/drivers/codex-mapper.ts +5 -1
- package/lib/chat/drivers/pi-mapper.ts +4 -0
- package/lib/chat/events.ts +15 -1
- package/lib/chat/registry.ts +8 -1
- package/lib/chat/runner.ts +27 -0
- package/lib/chat/worker/host.ts +47 -0
- package/lib/chat/worker/main.ts +1 -0
- package/lib/chat/worker/protocol.ts +14 -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/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/sidebar/shelves.ts +7 -0
- package/lib/status/collect.ts +7 -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,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quality of the work that merged, from data AgentOS already keeps (the
|
|
3
|
+
* orchestrator's checks, gate failures, asks and brief amendments) and, for
|
|
4
|
+
* escapes, the merged PR list from gh. No model is involved.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { db } from "../db";
|
|
8
|
+
import { run as runCmd } from "../tasks/gh";
|
|
9
|
+
|
|
10
|
+
export interface MergedTask {
|
|
11
|
+
id: string;
|
|
12
|
+
projectId: string | null;
|
|
13
|
+
prNumber: number | null;
|
|
14
|
+
mergedAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TaskQuality {
|
|
18
|
+
id: string;
|
|
19
|
+
reviewed: boolean;
|
|
20
|
+
clean: boolean;
|
|
21
|
+
// Heads checked after the first reviewed one.
|
|
22
|
+
fixRounds: number;
|
|
23
|
+
gateFailures: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DayQuality {
|
|
27
|
+
merged: number;
|
|
28
|
+
clean: number;
|
|
29
|
+
fixRounds: number;
|
|
30
|
+
escalations: number;
|
|
31
|
+
scopeChanges: number;
|
|
32
|
+
// Null when gh couldn't say.
|
|
33
|
+
escapes: number | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CheckRow {
|
|
37
|
+
id: number;
|
|
38
|
+
session_id: string;
|
|
39
|
+
sha: string;
|
|
40
|
+
kind: string;
|
|
41
|
+
status: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Clean: the first reviewed head passed review and CI, nothing failed a
|
|
45
|
+
// gate, and no later head was checked. A merge with no review at all isn't
|
|
46
|
+
// clean: it can't be told apart from one that skipped the gate.
|
|
47
|
+
export function taskQuality(
|
|
48
|
+
id: string,
|
|
49
|
+
checks: CheckRow[],
|
|
50
|
+
gateFailures: number
|
|
51
|
+
): TaskQuality {
|
|
52
|
+
const mine = checks
|
|
53
|
+
.filter((c) => c.session_id === id)
|
|
54
|
+
.sort((a, b) => a.id - b.id);
|
|
55
|
+
const first = mine.find(
|
|
56
|
+
(c) => c.kind === "review" && (c.status === "pass" || c.status === "block")
|
|
57
|
+
);
|
|
58
|
+
if (!first)
|
|
59
|
+
return { id, reviewed: false, clean: false, fixRounds: 0, gateFailures };
|
|
60
|
+
const ci = mine.find((c) => c.kind === "ci" && c.sha === first.sha);
|
|
61
|
+
const later = new Set(
|
|
62
|
+
mine.filter((c) => c.id > first.id && c.sha !== first.sha).map((c) => c.sha)
|
|
63
|
+
);
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
reviewed: true,
|
|
67
|
+
clean:
|
|
68
|
+
first.status === "pass" &&
|
|
69
|
+
ci?.status === "pass" &&
|
|
70
|
+
gateFailures === 0 &&
|
|
71
|
+
later.size === 0,
|
|
72
|
+
fixRounds: later.size,
|
|
73
|
+
gateFailures,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sqlTime = (ms: number) =>
|
|
78
|
+
new Date(ms).toISOString().slice(0, 19).replace("T", " ");
|
|
79
|
+
|
|
80
|
+
export function mergedTasks(from: number, to: number): MergedTask[] {
|
|
81
|
+
return (
|
|
82
|
+
db
|
|
83
|
+
.prepare(
|
|
84
|
+
`SELECT id, project_id AS projectId, pr_number AS prNumber, merged_at AS mergedAt
|
|
85
|
+
FROM sessions WHERE task_status = 'merged' AND merged_at >= ? AND merged_at < ?`
|
|
86
|
+
)
|
|
87
|
+
.all(sqlTime(from), sqlTime(to)) as MergedTask[]
|
|
88
|
+
).filter((t) => t.mergedAt);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function qualityOf(tasks: MergedTask[]): TaskQuality[] {
|
|
92
|
+
if (!tasks.length) return [];
|
|
93
|
+
const marks = tasks.map(() => "?").join(",");
|
|
94
|
+
const ids = tasks.map((t) => t.id);
|
|
95
|
+
const checks = db
|
|
96
|
+
.prepare(
|
|
97
|
+
`SELECT id, session_id, sha, kind, status FROM orchestrator_checks
|
|
98
|
+
WHERE session_id IN (${marks})`
|
|
99
|
+
)
|
|
100
|
+
.all(...ids) as CheckRow[];
|
|
101
|
+
const gates = new Map(
|
|
102
|
+
(
|
|
103
|
+
db
|
|
104
|
+
.prepare(
|
|
105
|
+
`SELECT session_id, SUM(count) AS n FROM orchestrator_gate_failures
|
|
106
|
+
WHERE session_id IN (${marks}) GROUP BY session_id`
|
|
107
|
+
)
|
|
108
|
+
.all(...ids) as { session_id: string; n: number }[]
|
|
109
|
+
).map((g) => [g.session_id, g.n])
|
|
110
|
+
);
|
|
111
|
+
return tasks.map((t) => taskQuality(t.id, checks, gates.get(t.id) ?? 0));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Escalations to Saad in the range: gates that gave up on a task, and gate
|
|
115
|
+
// asks, counted once per task.
|
|
116
|
+
export function escalationsIn(from: number, to: number): number {
|
|
117
|
+
const f = sqlTime(from);
|
|
118
|
+
const t = sqlTime(to);
|
|
119
|
+
const rows = db
|
|
120
|
+
.prepare(
|
|
121
|
+
`SELECT session_id AS task FROM orchestrator_gate_failures
|
|
122
|
+
WHERE escalated_at >= ? AND escalated_at < ?
|
|
123
|
+
UNION
|
|
124
|
+
SELECT REPLACE(subject, 'task:', '') AS task FROM orchestrator_asks
|
|
125
|
+
WHERE kind = 'gate' AND created_at >= ? AND created_at < ?`
|
|
126
|
+
)
|
|
127
|
+
.all(f, t, f, t);
|
|
128
|
+
return rows.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function scopeChangesIn(from: number, to: number): number {
|
|
132
|
+
return (
|
|
133
|
+
db
|
|
134
|
+
.prepare(
|
|
135
|
+
`SELECT COUNT(*) AS n FROM brief_amendments WHERE created_at >= ? AND created_at < ?`
|
|
136
|
+
)
|
|
137
|
+
.get(sqlTime(from), sqlTime(to)) as { n: number }
|
|
138
|
+
).n;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface MergedPR {
|
|
142
|
+
number: number;
|
|
143
|
+
title: string;
|
|
144
|
+
mergedAt: number;
|
|
145
|
+
files: string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const FIX = /^(revert|fix)(\([^)]*\))?!?:|^revert\b/i;
|
|
149
|
+
export const ESCAPE_WINDOW_MS = 3 * 24 * 3600 * 1000;
|
|
150
|
+
|
|
151
|
+
// PRs in `merged` that a later revert or fix PR touched the same files of,
|
|
152
|
+
// within three days of the merge.
|
|
153
|
+
export function escapesOf(merged: MergedPR[], all: MergedPR[]): MergedPR[] {
|
|
154
|
+
return merged.filter((p) =>
|
|
155
|
+
all.some(
|
|
156
|
+
(q) =>
|
|
157
|
+
q.number !== p.number &&
|
|
158
|
+
q.mergedAt > p.mergedAt &&
|
|
159
|
+
q.mergedAt - p.mergedAt <= ESCAPE_WINDOW_MS &&
|
|
160
|
+
FIX.test(q.title.trim()) &&
|
|
161
|
+
q.files.some((f) => p.files.includes(f))
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type Gh = (args: string[], cwd: string) => Promise<string>;
|
|
167
|
+
const realGh: Gh = (args, cwd) => runCmd("gh", args, cwd, 60_000);
|
|
168
|
+
|
|
169
|
+
function repoOf(projectId: string | null): string | null {
|
|
170
|
+
if (!projectId) return null;
|
|
171
|
+
const r = db
|
|
172
|
+
.prepare(
|
|
173
|
+
`SELECT path FROM project_repositories WHERE project_id = ?
|
|
174
|
+
ORDER BY is_primary DESC, sort_order LIMIT 1`
|
|
175
|
+
)
|
|
176
|
+
.get(projectId) as { path: string } | undefined;
|
|
177
|
+
return r?.path ?? null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Escapes among the tasks merged in [from, to), looking three days past each
|
|
181
|
+
// merge. Null when gh couldn't say for any repo: unknown is not zero.
|
|
182
|
+
export async function escapesIn(
|
|
183
|
+
tasks: MergedTask[],
|
|
184
|
+
gh: Gh = realGh
|
|
185
|
+
): Promise<number | null> {
|
|
186
|
+
const withPR = tasks.filter((t) => t.prNumber !== null);
|
|
187
|
+
if (!withPR.length) return 0;
|
|
188
|
+
const byRepo = new Map<string, MergedTask[]>();
|
|
189
|
+
for (const t of withPR) {
|
|
190
|
+
const repo = repoOf(t.projectId);
|
|
191
|
+
if (!repo) return null;
|
|
192
|
+
byRepo.set(repo, [...(byRepo.get(repo) ?? []), t]);
|
|
193
|
+
}
|
|
194
|
+
let total = 0;
|
|
195
|
+
for (const [repo, ts] of byRepo) {
|
|
196
|
+
try {
|
|
197
|
+
const since = new Date(
|
|
198
|
+
Math.min(
|
|
199
|
+
...ts.map((t) => Date.parse(t.mergedAt.replace(" ", "T") + "Z"))
|
|
200
|
+
) - 86_400_000
|
|
201
|
+
)
|
|
202
|
+
.toISOString()
|
|
203
|
+
.slice(0, 10);
|
|
204
|
+
const out = await gh(
|
|
205
|
+
[
|
|
206
|
+
"pr",
|
|
207
|
+
"list",
|
|
208
|
+
"--state",
|
|
209
|
+
"merged",
|
|
210
|
+
"--search",
|
|
211
|
+
`merged:>=${since}`,
|
|
212
|
+
"--json",
|
|
213
|
+
"number,title,mergedAt,files",
|
|
214
|
+
"--limit",
|
|
215
|
+
"300",
|
|
216
|
+
],
|
|
217
|
+
repo
|
|
218
|
+
);
|
|
219
|
+
const all = (
|
|
220
|
+
JSON.parse(out) as {
|
|
221
|
+
number: number;
|
|
222
|
+
title: string;
|
|
223
|
+
mergedAt: string;
|
|
224
|
+
files?: { path: string }[];
|
|
225
|
+
}[]
|
|
226
|
+
).map((p) => ({
|
|
227
|
+
number: p.number,
|
|
228
|
+
title: p.title,
|
|
229
|
+
mergedAt: Date.parse(p.mergedAt),
|
|
230
|
+
files: (p.files ?? []).map((f) => f.path),
|
|
231
|
+
}));
|
|
232
|
+
const mine = all.filter((p) => ts.some((t) => t.prNumber === p.number));
|
|
233
|
+
total += escapesOf(mine, all).length;
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return total;
|
|
239
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ratchet. The baseline is the first week of rollups, and from then on
|
|
3
|
+
* only ever moves to a better value. An alert fires, once, when cost per
|
|
4
|
+
* merged PR or the clean-merge rate has drifted past the configured margin,
|
|
5
|
+
* naming the supporting metric that moved most.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db } from "../db";
|
|
9
|
+
import { spanOf, type Rollup, type Span } from "./span";
|
|
10
|
+
import type { UsageConfig } from "./config";
|
|
11
|
+
|
|
12
|
+
export type Direction = "lower" | "higher";
|
|
13
|
+
|
|
14
|
+
// The better of the two. A missing or invalid candidate keeps what was.
|
|
15
|
+
export function ratchet(
|
|
16
|
+
prev: number | null,
|
|
17
|
+
candidate: number | null,
|
|
18
|
+
better: Direction
|
|
19
|
+
): number | null {
|
|
20
|
+
if (candidate === null || !Number.isFinite(candidate)) return prev;
|
|
21
|
+
if (prev === null || !Number.isFinite(prev)) return candidate;
|
|
22
|
+
return better === "lower"
|
|
23
|
+
? Math.min(prev, candidate)
|
|
24
|
+
: Math.max(prev, candidate);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// What the baseline is judged on.
|
|
28
|
+
export const HEADLINE = {
|
|
29
|
+
costPerMerged: "lower",
|
|
30
|
+
costPerClean: "lower",
|
|
31
|
+
cleanRate: "higher",
|
|
32
|
+
} as const satisfies Record<string, Direction>;
|
|
33
|
+
export type HeadlineKey = keyof typeof HEADLINE;
|
|
34
|
+
|
|
35
|
+
// Supporting metrics, each where lower is better, per PR where it is a count.
|
|
36
|
+
export interface Supporting {
|
|
37
|
+
reviewPerPr: number | null;
|
|
38
|
+
over200kShare: number | null;
|
|
39
|
+
orchestratorShare: number | null;
|
|
40
|
+
opusShare: number | null;
|
|
41
|
+
fixRoundsPerPr: number | null;
|
|
42
|
+
escalationsPerPr: number | null;
|
|
43
|
+
scopeChangesPerPr: number | null;
|
|
44
|
+
escapesPerPr: number | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const SUPPORTING_LABELS: Record<keyof Supporting, string> = {
|
|
48
|
+
reviewPerPr: "review cost per PR",
|
|
49
|
+
over200kShare: "share of calls above 200k context",
|
|
50
|
+
orchestratorShare: "orchestrator share",
|
|
51
|
+
opusShare: "Opus share of the model mix",
|
|
52
|
+
fixRoundsPerPr: "fix rounds per PR",
|
|
53
|
+
escalationsPerPr: "escalations per PR",
|
|
54
|
+
scopeChangesPerPr: "scope changes per PR",
|
|
55
|
+
escapesPerPr: "escapes per PR",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const per = (n: number | null, merged: number) =>
|
|
59
|
+
n !== null && merged > 0 ? n / merged : null;
|
|
60
|
+
|
|
61
|
+
export function supportingOf(s: Span): Supporting {
|
|
62
|
+
return {
|
|
63
|
+
reviewPerPr: s.reviewPerPr,
|
|
64
|
+
over200kShare: s.over200kShare,
|
|
65
|
+
orchestratorShare: s.orchestratorShare,
|
|
66
|
+
opusShare: s.opusShare,
|
|
67
|
+
fixRoundsPerPr: s.fixRoundsPerPr,
|
|
68
|
+
escalationsPerPr: per(s.escalations, s.merged),
|
|
69
|
+
scopeChangesPerPr: per(s.scopeChanges, s.merged),
|
|
70
|
+
escapesPerPr: per(s.escapes, s.merged),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Baseline {
|
|
75
|
+
values: Record<HeadlineKey, number | null>;
|
|
76
|
+
supporting: Supporting;
|
|
77
|
+
setAt: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The next baseline. None until `cfg.baselineDays` complete days exist with
|
|
81
|
+
// at least `cfg.minMerges` merges among them: a quiet week would lock in a
|
|
82
|
+
// baseline nothing can meet, so the seed waits for the first week that has
|
|
83
|
+
// enough work, and a ratchet step skips a week that doesn't. The first is
|
|
84
|
+
// those first days, and each night after it, the trailing week of days is
|
|
85
|
+
// ratcheted in. The supporting snapshot follows cost per merged PR:
|
|
86
|
+
// it is replaced only when that improves.
|
|
87
|
+
export function nextBaseline(
|
|
88
|
+
prev: Baseline | null,
|
|
89
|
+
complete: Rollup[],
|
|
90
|
+
cfg: UsageConfig,
|
|
91
|
+
now = Date.now()
|
|
92
|
+
): Baseline | null {
|
|
93
|
+
const days = [...complete].sort((a, b) => a.day.localeCompare(b.day));
|
|
94
|
+
if (days.length < cfg.baselineDays) return prev;
|
|
95
|
+
if (!prev) {
|
|
96
|
+
let s = spanOf(days.slice(0, cfg.baselineDays));
|
|
97
|
+
if (s.merged < cfg.minMerges) s = spanOf(days.slice(-cfg.baselineDays));
|
|
98
|
+
if (s.merged < cfg.minMerges) return null;
|
|
99
|
+
return {
|
|
100
|
+
values: {
|
|
101
|
+
costPerMerged: s.costPerMerged,
|
|
102
|
+
costPerClean: s.costPerClean,
|
|
103
|
+
cleanRate: s.cleanRate,
|
|
104
|
+
},
|
|
105
|
+
supporting: supportingOf(s),
|
|
106
|
+
setAt: now,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const s = spanOf(days.slice(-cfg.baselineDays));
|
|
110
|
+
if (s.merged < cfg.minMerges) return prev;
|
|
111
|
+
const values = { ...prev.values };
|
|
112
|
+
for (const k of Object.keys(HEADLINE) as HeadlineKey[])
|
|
113
|
+
values[k] = ratchet(prev.values[k], s[k], HEADLINE[k]);
|
|
114
|
+
const improved =
|
|
115
|
+
s.costPerMerged !== null &&
|
|
116
|
+
(prev.values.costPerMerged === null ||
|
|
117
|
+
s.costPerMerged < prev.values.costPerMerged);
|
|
118
|
+
return {
|
|
119
|
+
values,
|
|
120
|
+
supporting: improved ? supportingOf(s) : prev.supporting,
|
|
121
|
+
setAt: improved ? now : prev.setAt,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface Alert {
|
|
126
|
+
kind: "cost" | "quality";
|
|
127
|
+
message: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const round = (x: number) => Math.round(x * 1e6) / 1e6;
|
|
131
|
+
|
|
132
|
+
// The supporting metric that moved furthest the wrong way since the baseline.
|
|
133
|
+
export function biggestMover(
|
|
134
|
+
base: Supporting,
|
|
135
|
+
now: Supporting
|
|
136
|
+
): { label: string; change: string } | null {
|
|
137
|
+
let best: { key: keyof Supporting; score: number } | null = null;
|
|
138
|
+
for (const key of Object.keys(base) as (keyof Supporting)[]) {
|
|
139
|
+
const b = base[key];
|
|
140
|
+
const c = now[key];
|
|
141
|
+
if (b === null || c === null) continue;
|
|
142
|
+
const score = b > 0 ? (c - b) / b : c > 0 ? 1 : 0;
|
|
143
|
+
if (score > 0 && (!best || score > best.score)) best = { key, score };
|
|
144
|
+
}
|
|
145
|
+
if (!best) return null;
|
|
146
|
+
const b = base[best.key] as number;
|
|
147
|
+
return {
|
|
148
|
+
label: SUPPORTING_LABELS[best.key],
|
|
149
|
+
change: b > 0 ? `+${Math.round(best.score * 100)}%` : "up from none",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Conditions that hold now, over the last `avgDays` complete days.
|
|
154
|
+
export function evaluateAlerts(
|
|
155
|
+
complete: Rollup[],
|
|
156
|
+
baseline: Baseline | null,
|
|
157
|
+
cfg: UsageConfig
|
|
158
|
+
): Alert[] {
|
|
159
|
+
if (!baseline) return [];
|
|
160
|
+
const days = [...complete]
|
|
161
|
+
.sort((a, b) => a.day.localeCompare(b.day))
|
|
162
|
+
.slice(-cfg.avgDays);
|
|
163
|
+
const s = spanOf(days);
|
|
164
|
+
if (days.length < cfg.avgDays || s.merged < cfg.minMerges) return [];
|
|
165
|
+
const mover = biggestMover(baseline.supporting, supportingOf(s));
|
|
166
|
+
const moved = mover ? ` Biggest mover: ${mover.label} ${mover.change}.` : "";
|
|
167
|
+
const out: Alert[] = [];
|
|
168
|
+
const baseCost = baseline.values.costPerMerged;
|
|
169
|
+
if (s.costPerMerged !== null && baseCost !== null && baseCost > 0) {
|
|
170
|
+
const over = round(((s.costPerMerged - baseCost) / baseCost) * 100);
|
|
171
|
+
if (over > cfg.costPct)
|
|
172
|
+
out.push({
|
|
173
|
+
kind: "cost",
|
|
174
|
+
message: `Usage: cost per merged PR, ${cfg.avgDays}-day average, is ${Math.round(over)}% over baseline.${moved}`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const baseClean = baseline.values.cleanRate;
|
|
178
|
+
if (s.cleanRate !== null && baseClean !== null) {
|
|
179
|
+
const under = round((baseClean - s.cleanRate) * 100);
|
|
180
|
+
if (under > cfg.cleanPoints)
|
|
181
|
+
out.push({
|
|
182
|
+
kind: "quality",
|
|
183
|
+
message: `Usage: clean-merge rate, ${cfg.avgDays}-day average, is ${Math.round(under)} points under baseline.${moved}`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Which firing alerts are new (to send), and which open ones have cleared.
|
|
190
|
+
export function nextAlertState(
|
|
191
|
+
open: string[],
|
|
192
|
+
firing: Alert[]
|
|
193
|
+
): { notify: Alert[]; clear: string[] } {
|
|
194
|
+
const now = new Set(firing.map((a) => a.kind));
|
|
195
|
+
return {
|
|
196
|
+
notify: firing.filter((a) => !open.includes(a.kind)),
|
|
197
|
+
clear: open.filter((k) => !now.has(k as Alert["kind"])),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function loadBaseline(): Baseline | null {
|
|
202
|
+
const rows = db
|
|
203
|
+
.prepare(`SELECT metric, value, set_at, supporting FROM usage_baseline`)
|
|
204
|
+
.all() as {
|
|
205
|
+
metric: string;
|
|
206
|
+
value: number;
|
|
207
|
+
set_at: number;
|
|
208
|
+
supporting: string | null;
|
|
209
|
+
}[];
|
|
210
|
+
if (!rows.length) return null;
|
|
211
|
+
const values: Baseline["values"] = {
|
|
212
|
+
costPerMerged: null,
|
|
213
|
+
costPerClean: null,
|
|
214
|
+
cleanRate: null,
|
|
215
|
+
};
|
|
216
|
+
let supporting: Supporting | null = null;
|
|
217
|
+
let setAt = 0;
|
|
218
|
+
for (const r of rows) {
|
|
219
|
+
if (r.metric in values) values[r.metric as HeadlineKey] = r.value;
|
|
220
|
+
if (r.metric === "costPerMerged" && r.supporting)
|
|
221
|
+
supporting = JSON.parse(r.supporting) as Supporting;
|
|
222
|
+
setAt = Math.max(setAt, r.set_at);
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
values,
|
|
226
|
+
supporting: supporting ?? supportingOf(spanOf([])),
|
|
227
|
+
setAt,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function saveBaseline(b: Baseline): void {
|
|
232
|
+
db.transaction(() => {
|
|
233
|
+
for (const k of Object.keys(HEADLINE) as HeadlineKey[]) {
|
|
234
|
+
const v = b.values[k];
|
|
235
|
+
if (v === null) continue;
|
|
236
|
+
db.prepare(
|
|
237
|
+
`INSERT INTO usage_baseline (metric, value, set_at, supporting) VALUES (?, ?, ?, ?)
|
|
238
|
+
ON CONFLICT(metric) DO UPDATE SET value = excluded.value, set_at = excluded.set_at,
|
|
239
|
+
supporting = excluded.supporting`
|
|
240
|
+
).run(
|
|
241
|
+
k,
|
|
242
|
+
v,
|
|
243
|
+
b.setAt,
|
|
244
|
+
k === "costPerMerged" ? JSON.stringify(b.supporting) : null
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
})();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function openAlerts(): string[] {
|
|
251
|
+
return (
|
|
252
|
+
db
|
|
253
|
+
.prepare(`SELECT kind FROM usage_alerts WHERE cleared_at IS NULL`)
|
|
254
|
+
.all() as { kind: string }[]
|
|
255
|
+
).map((r) => r.kind);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function applyAlertState(
|
|
259
|
+
state: { notify: Alert[]; clear: string[] },
|
|
260
|
+
now = Date.now()
|
|
261
|
+
): void {
|
|
262
|
+
db.transaction(() => {
|
|
263
|
+
for (const a of state.notify)
|
|
264
|
+
db.prepare(
|
|
265
|
+
`INSERT INTO usage_alerts (kind, opened_at, cleared_at, message) VALUES (?, ?, NULL, ?)
|
|
266
|
+
ON CONFLICT(kind) DO UPDATE SET opened_at = excluded.opened_at, cleared_at = NULL,
|
|
267
|
+
message = excluded.message`
|
|
268
|
+
).run(a.kind, now, a.message);
|
|
269
|
+
for (const k of state.clear)
|
|
270
|
+
db.prepare(`UPDATE usage_alerts SET cleared_at = ? WHERE kind = ?`).run(
|
|
271
|
+
now,
|
|
272
|
+
k
|
|
273
|
+
);
|
|
274
|
+
})();
|
|
275
|
+
}
|
|
@@ -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
|
+
}
|