opencode-usage-insights 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ import { type LineFields, type LineIdentity, type LineLabels, type TurnStats, type TurnTotalsView } from "./core";
2
+ import type { CacheDropRow, LatencyReport, ModelUsage, Overview, Range, SessionUsage, TurnUsage, TtlEvidenceRow } from "./stats/queries";
3
+ import type { DbInfo } from "./stats/maintenance";
4
+ import type { AffinityReport, CacheStats, CoverageReport, EndpointRow, EndpointSummary, GapReport, LatencyGroupRow, PeriodGranularity, PeriodRow } from "./stats/aggregates";
5
+ /**
6
+ * View models and formatting for the stats panel. Everything here is pure:
7
+ * the panel component feeds query results in and renders the lines out, which
8
+ * keeps the layout testable without a terminal. Lines may carry a `row`, the
9
+ * identity the cursor selects and Enter drills into.
10
+ */
11
+ export declare const PANEL_NAME = "usage-insights";
12
+ export declare const PANEL_VIEWS: readonly ["overview", "models", "endpoints", "affinity", "cache", "periods", "latency", "sessions", "turns", "db"];
13
+ export type PanelView = (typeof PANEL_VIEWS)[number];
14
+ export declare const RANGE_KEYS: readonly ["session", "today", "7d", "30d", "all"];
15
+ export type RangeKey = (typeof RANGE_KEYS)[number];
16
+ export declare const GRANULARITIES: readonly ["day", "week", "month"];
17
+ export type Granularity = (typeof GRANULARITIES)[number];
18
+ export type PanelTone = "base" | "muted" | "warning" | "accent";
19
+ /** What a selectable line stands for; Enter drills into it. */
20
+ export type PanelRow = {
21
+ kind: "model" | "session" | "period" | "endpoint" | "provider" | "turn" | "prune";
22
+ value: string;
23
+ variant?: string | undefined;
24
+ };
25
+ export type PanelLine = {
26
+ text: string;
27
+ tone?: PanelTone;
28
+ row?: PanelRow | undefined;
29
+ };
30
+ /** The views and ranges, as the command and the panel header understand them. */
31
+ export declare function parseArgs(input: string | undefined): {
32
+ view?: PanelView;
33
+ range?: RangeKey;
34
+ };
35
+ export declare function rangeFor(key: RangeKey, now: number, sessionStart?: number | undefined): Range | undefined;
36
+ export declare function rangeLabel(key: RangeKey): string;
37
+ export declare function nextIn<Value>(values: readonly Value[], current: Value, step: number): Value;
38
+ /** Which turns the `turns` view lists: the open session, or every session. */
39
+ export type TurnsScope = "session" | "all";
40
+ /**
41
+ * Everything the panel remembers between openings. Stored through the host's
42
+ * durable storage, so the panel reopens where the user left it.
43
+ */
44
+ export type PanelSettings = {
45
+ view: PanelView;
46
+ range: RangeKey;
47
+ sort: string;
48
+ sortDesc: boolean;
49
+ granularity: Granularity;
50
+ turnsScope: TurnsScope;
51
+ /** Empty string means "no filter". */
52
+ modelID: string;
53
+ /** The routing endpoint that served the request. */
54
+ routerID: string;
55
+ };
56
+ export declare const DEFAULT_SETTINGS: PanelSettings;
57
+ /** Merge persisted state with the defaults, dropping anything unrecognized. */
58
+ export declare function sanitizeSettings(raw: Partial<PanelSettings> | undefined): PanelSettings;
59
+ export declare function formatPercent(value: number | undefined, digits?: number): string;
60
+ export declare function formatCount(value: number): string;
61
+ export declare function formatMs(value: number | undefined): string;
62
+ export declare function formatTps(value: number | undefined): string;
63
+ /** A rate that has to fit a sidebar: whole numbers from 10 up. */
64
+ export declare function formatTpsCompact(value: number | undefined): string;
65
+ /** Idle gaps are read at a glance: seconds, minutes, hours, days. */
66
+ export declare function formatGap(value: number | undefined): string;
67
+ /** Cut a value to the width, dropping control characters first. */
68
+ export declare function truncate(value: string, width: number): string;
69
+ /** Keep both ends of a long id: the family reads first, the `#variant` last. */
70
+ export declare function truncateMiddle(value: string, width: number): string;
71
+ /**
72
+ * Pick the widest column layout that fits the panel. A table that overflows
73
+ * wraps and stops reading as a table, so the columns are chosen, not squeezed.
74
+ */
75
+ export declare function tableWidths(width: number, layouts: readonly (readonly number[])[]): number[];
76
+ /**
77
+ * A model cell keeps the variant visible: two rows of the same family must not
78
+ * read alike after truncation.
79
+ */
80
+ export declare function modelCell(modelID: string, variant: string | undefined, width: number): string;
81
+ export declare function padCell(value: string, width: number, align?: "left" | "right"): string;
82
+ export declare function formatTableRow(cells: readonly string[], widths: readonly number[], aligns?: readonly ("left" | "right")[]): string;
83
+ export declare function count(n: number, singular: string, plural?: string): string;
84
+ /** Two label/value pairs per row, collapsed to one pair on narrow panels. */
85
+ export declare function kvGrid(pairs: readonly (readonly [string, string])[], width: number): PanelLine[];
86
+ /** Fold a series into at most `maxBars` summed buckets, so a sparkline stays a line. */
87
+ export declare function reduceSeries(values: readonly number[], maxBars?: number): number[];
88
+ /** Text sparkline: trends at a glance without a chart renderable. */
89
+ export declare function sparkline(values: readonly number[], maxBars?: number): string;
90
+ /**
91
+ * The live line's buckets, so every aggregate the panel shows reconciles with
92
+ * it: `in` is the uncached prompt (fresh input *and* cache writes), `prompt` is
93
+ * what the provider saw, `out` is everything generated (visible *and*
94
+ * reasoning). `write` and `reasoning` are the parts of `in` and `out`.
95
+ */
96
+ export type UsageBuckets = {
97
+ steps: number;
98
+ input: number;
99
+ prompt: number;
100
+ cached: number;
101
+ cacheWrite: number;
102
+ output: number;
103
+ reasoning: number;
104
+ cost: number;
105
+ hit: number | undefined;
106
+ };
107
+ export declare function usageBuckets(usage: {
108
+ steps: number;
109
+ input: number;
110
+ cached: number;
111
+ cacheWrite: number;
112
+ output: number;
113
+ reasoning: number;
114
+ cost: number;
115
+ }): UsageBuckets;
116
+ /**
117
+ * The view tabs. The active one is bracketed so a single tone still reads
118
+ * clearly; on a narrow panel the names shorten instead of wrapping, and the
119
+ * active view always keeps its full name.
120
+ */
121
+ export declare function viewBar(view: PanelView, width?: number): PanelLine;
122
+ export declare function keysFor(of: PanelView): string;
123
+ export declare function filterSummary(filters: {
124
+ modelID?: string | undefined;
125
+ routerID?: string | undefined;
126
+ }): string | undefined;
127
+ export declare function granularityLabel(granularity: PeriodGranularity): string;
128
+ /** Help overlay: the keys and the exact meaning of every number. */
129
+ export declare function helpLines(width: number): PanelLine[];
130
+ export declare function overviewLines(data: {
131
+ usage: Overview;
132
+ session: Overview | undefined;
133
+ /** Child sessions of the open session, when it has any. */
134
+ subagents?: readonly SessionUsage[] | undefined;
135
+ range: RangeKey;
136
+ activity?: readonly number[] | undefined;
137
+ coverage?: CoverageReport | undefined;
138
+ }, width: number): PanelLine[];
139
+ export type ModelExtras = {
140
+ /** Reuse stats per `modelKey`, computed from the step facts. */
141
+ cache?: Map<string, CacheStats> | undefined;
142
+ /** Identity changes per `modelKey`, from the affinity report. */
143
+ changes?: Map<string, number> | undefined;
144
+ };
145
+ /**
146
+ * Usage per model with the cache it actually got, where it was served from and
147
+ * how often its identity changed. One line per model; `providers` keeps the
148
+ * per-provider detail.
149
+ */
150
+ export declare function modelsLines(rows: readonly ModelUsage[], width: number, extras?: ModelExtras, limit?: number): PanelLine[];
151
+ /** One row per endpoint, over every model it served. */
152
+ export declare function endpointSummaryLines(rows: readonly EndpointSummary[], width: number, limit?: number): PanelLine[];
153
+ /** One row per model × endpoint: the endpoints that served each model. */
154
+ export declare function endpointsLines(rows: readonly EndpointRow[], width: number, limit?: number): PanelLine[];
155
+ export declare function affinityLines(report: AffinityReport, width: number, limit?: number): PanelLine[];
156
+ /** One model's reuse row, shared by the models and cache views. */
157
+ export type CacheModelRow = {
158
+ usage: {
159
+ modelID: string;
160
+ variant: string | undefined;
161
+ };
162
+ cache: CacheStats;
163
+ };
164
+ export declare function cacheLines(data: {
165
+ drops: readonly CacheDropRow[];
166
+ evidence: readonly TtlEvidenceRow[];
167
+ gap?: GapReport | undefined;
168
+ models?: readonly CacheModelRow[] | undefined;
169
+ }, width: number): PanelLine[];
170
+ export declare function periodLines(rows: readonly PeriodRow[], width: number, granularity: PeriodGranularity, limit?: number): PanelLine[];
171
+ export declare function latencyLines(report: LatencyReport, width: number, groups?: readonly LatencyGroupRow[] | undefined, limit?: number): PanelLine[];
172
+ export declare function sessionsLines(rows: readonly SessionUsage[], width: number, options?: {
173
+ limit?: number | undefined;
174
+ rollup?: {
175
+ subagentSteps: number;
176
+ subagentCost: number;
177
+ } | undefined;
178
+ }): PanelLine[];
179
+ /** The database view: where it lives, what it holds, and the two cleanups. */
180
+ export declare function dbLines(data: {
181
+ info: DbInfo;
182
+ busy?: boolean | undefined;
183
+ now: number;
184
+ }, width: number): PanelLine[];
185
+ export type LineFormatOptions = {
186
+ labels?: Partial<LineLabels> | undefined;
187
+ fields?: Partial<LineFields> | undefined;
188
+ turnTotals?: TurnTotalsView | undefined;
189
+ identity?: LineIdentity | undefined;
190
+ };
191
+ /** The line's own view of a recorded turn, so both render identically. */
192
+ export declare function turnStats(turn: TurnUsage): TurnStats;
193
+ /**
194
+ * Per-prompt history: one line per finished turn, rendered by the same
195
+ * formatter as the live line (without the session segment, which would repeat
196
+ * the same totals on every row).
197
+ */
198
+ export declare function turnsLines(rows: readonly TurnUsage[], width: number, options?: LineFormatOptions & {
199
+ sessionTitles?: Map<string, string> | undefined;
200
+ }): PanelLine[];
201
+ /**
202
+ * The accumulated usage of one session, as compact lines for the sidebar. The
203
+ * buckets are the live line's, so the sidebar, the line and the panel agree.
204
+ */
205
+ export declare function sessionSummaryLines(usage: Overview, budget?: number): PanelLine[];
206
+ /**
207
+ * The sidebar history: two compact lines per turn — when and through which
208
+ * endpoint, then the prompt volumes — truncated to the sidebar budget.
209
+ */
210
+ export declare function sidebarLines(rows: readonly TurnUsage[], options?: LineFormatOptions & {
211
+ budget?: number | undefined;
212
+ }): PanelLine[];
@@ -0,0 +1,39 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { Database } from "bun:sqlite";
3
+ import type { Context, PanelInput } from "@opencode/plugin/tui/context";
4
+ import { type LineFormatOptions, type PanelSettings } from "./panel-data";
5
+ /**
6
+ * The `/usage` panel. One read-only view over the stats database at a time,
7
+ * driven entirely from the keyboard: a cursor over the selectable lines,
8
+ * Enter drills into the row under it, and the views, range, sort, bucket and
9
+ * filters are part of the state the host persists between openings.
10
+ *
11
+ * The panel owns input while it is open, so every key lives in the layer this
12
+ * component registers — the prompt is not involved.
13
+ */
14
+ export type PanelDeps = {
15
+ /** Lazily opened stats database; undefined when it cannot be opened. */
16
+ db: () => Database | undefined;
17
+ /** Where that database lives, for the `db` view. */
18
+ dbPath: () => string;
19
+ /**
20
+ * Short-lived write connection for maintenance (prune, compact). The panel
21
+ * itself only reads; opening a writer on demand is what keeps a read from
22
+ * ever taking the recorder's lock. Undefined when the write cannot be made.
23
+ */
24
+ withWrite?: (<Value>(run: (db: Database) => Value) => Value | undefined) | undefined;
25
+ /** Injectable clock, for tests. */
26
+ now: () => number;
27
+ /** Creation time of a session, used by the `session` range. */
28
+ sessionStart: (sessionID: string) => number | undefined;
29
+ /** Bumped by the TUI when a step or an execution ends, so views re-read. */
30
+ refresh?: (() => number) | undefined;
31
+ };
32
+ export declare function Panel(props: {
33
+ readonly input: PanelInput;
34
+ readonly ctx: Context;
35
+ readonly deps: PanelDeps;
36
+ readonly settings: PanelSettings;
37
+ readonly update: (mutation: (draft: PanelSettings) => void) => void;
38
+ readonly format?: LineFormatOptions | undefined;
39
+ }): any;
@@ -0,0 +1,82 @@
1
+ import type { Database } from "bun:sqlite";
2
+ /**
3
+ * Event-driven recorder. Every handler is idempotent and defensive: the
4
+ * plugin must never break a session because an event arrived in an
5
+ * unexpected shape, so unknown events are ignored and malformed payloads are
6
+ * dropped rather than thrown.
7
+ *
8
+ * Pure with respect to its inputs (database plus events) so the whole
9
+ * recording policy can be unit-tested without the plugin runtime.
10
+ */
11
+ export type SessionModelRef = {
12
+ id: string;
13
+ providerID: string;
14
+ variant?: string | undefined;
15
+ };
16
+ export type TokenUsageLike = {
17
+ input?: number | undefined;
18
+ output?: number | undefined;
19
+ reasoning?: number | undefined;
20
+ cache?: {
21
+ read?: number | undefined;
22
+ write?: number | undefined;
23
+ } | undefined;
24
+ };
25
+ export type RecorderEventData = {
26
+ sessionID?: string | undefined;
27
+ assistantMessageID?: string | undefined;
28
+ inboxID?: string | undefined;
29
+ item?: {
30
+ type?: string | undefined;
31
+ } | undefined;
32
+ started?: number | undefined;
33
+ model?: SessionModelRef | undefined;
34
+ previous?: SessionModelRef | undefined;
35
+ tokens?: TokenUsageLike | undefined;
36
+ cost?: number | undefined;
37
+ finish?: string | undefined;
38
+ rawFinish?: string | undefined;
39
+ reason?: string | undefined;
40
+ location?: {
41
+ directory?: string | undefined;
42
+ } | undefined;
43
+ parentID?: string | undefined;
44
+ title?: string | undefined;
45
+ };
46
+ export type RecorderEvent = {
47
+ type: string;
48
+ created?: number | undefined;
49
+ data?: RecorderEventData | undefined;
50
+ };
51
+ export type HttpResponseInput = {
52
+ sessionID?: string | undefined;
53
+ kind?: string | undefined;
54
+ model?: SessionModelRef | undefined;
55
+ request?: {
56
+ method?: string | undefined;
57
+ url?: string | undefined;
58
+ } | undefined;
59
+ response?: {
60
+ status?: number | undefined;
61
+ headers?: Headers | Record<string, string | undefined> | undefined;
62
+ } | undefined;
63
+ };
64
+ export type PendingStep = {
65
+ messageID: string;
66
+ sessionID: string;
67
+ started: number | undefined;
68
+ streamed: number | undefined;
69
+ model: SessionModelRef | undefined;
70
+ };
71
+ export type RecorderOptions = {
72
+ /** Clock override, for tests. */
73
+ now?: (() => number) | undefined;
74
+ };
75
+ export type Recorder = {
76
+ /** Apply one session event. Unknown or malformed events are ignored. */
77
+ handleEvent(event: RecorderEvent): void;
78
+ /** Learn the routing endpoint from one model response. */
79
+ handleHttpResponse(event: HttpResponseInput): void;
80
+ pendingFor(sessionID: string): PendingStep | undefined;
81
+ };
82
+ export declare function createRecorder(db: Database, options?: RecorderOptions): Recorder;
@@ -0,0 +1,25 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { Database } from "bun:sqlite";
3
+ import type { Context } from "@opencode/plugin/tui/context";
4
+ import { type LineFormatOptions } from "./panel-data";
5
+ /**
6
+ * Sidebar history: the session's accumulated usage, then the last finished
7
+ * turns, two compact lines each (when + endpoint, then the prompt volumes).
8
+ * Reads the same database, the same buckets and the same formatter as the panel
9
+ * and the live line, and refreshes whenever a turn ends.
10
+ */
11
+ export type TurnsSidebarDeps = {
12
+ db: () => Database | undefined;
13
+ /** Bumped by the TUI when a step or an execution ends. */
14
+ refresh: () => number;
15
+ /** How many turns to show. */
16
+ limit: number;
17
+ /** Column budget of the sidebar, shared with the summary. */
18
+ budget?: number | undefined;
19
+ };
20
+ export declare function TurnsSidebar(props: {
21
+ readonly sessionID: string;
22
+ readonly ctx: Context;
23
+ readonly deps: TurnsSidebarDeps;
24
+ readonly options?: LineFormatOptions | undefined;
25
+ }): any;
@@ -0,0 +1,282 @@
1
+ /**
2
+ * The analysis layer: the reports the panel and the CLI show.
3
+ *
4
+ * Two shapes, chosen by measurement rather than taste:
5
+ *
6
+ * 1. **Aggregates in SQLite** — totals, counts, distinct values. `coverageOf`
7
+ * and the filter pickers never move a row into JavaScript.
8
+ * 2. **Narrow rows plus arithmetic in JS** — percentiles, pair rules, calendar
9
+ * buckets. SQLite's window functions cost about 17 µs per row per report
10
+ * here (a sort plus a materialisation), while reading the four to twelve
11
+ * columns a report actually needs costs 1–4 µs per row. The percentiles and
12
+ * the pair comparisons are therefore computed over lean projections: on the
13
+ * audit's 120k-step database a whole view costs a few hundred milliseconds,
14
+ * and on a normal one it is instant.
15
+ *
16
+ * The rules are not restated here. Cache reuse, idle gaps and the noise
17
+ * threshold come from `core.ts` — the same functions the live line uses — so
18
+ * the panel and the line can never disagree about what a cache loss is.
19
+ * `test/reports.test.ts` holds every report to those rules.
20
+ */
21
+ import type { Database } from "bun:sqlite";
22
+ import { type Filters, type LatencyStats, type Range, type Usage } from "./queries";
23
+ /** The step's own clock, the expression the `steps_at` index covers. */
24
+ export declare const STEP_AT = "COALESCE(created_at, recorded_at)";
25
+ type Options = {
26
+ range?: Range | undefined;
27
+ filters?: Filters | undefined;
28
+ };
29
+ /** One recorded step, as the reports read it. */
30
+ export type StepRow = {
31
+ messageID: string;
32
+ sessionID: string;
33
+ providerID: string | undefined;
34
+ modelID: string | undefined;
35
+ variant: string | undefined;
36
+ router: string | undefined;
37
+ input: number;
38
+ cached: number;
39
+ cacheWrite: number;
40
+ output: number;
41
+ reasoning: number;
42
+ cost: number;
43
+ createdAt: number | undefined;
44
+ streamedAt: number | undefined;
45
+ completedAt: number | undefined;
46
+ /** `COALESCE(created_at, recorded_at)`: the ordering time of the step. */
47
+ at: number;
48
+ /** Set on the single step loaded before the range, as a reference. */
49
+ boundary?: boolean | undefined;
50
+ };
51
+ /**
52
+ * A step as the pair reports read it. Their projection leaves the generated
53
+ * tokens out (nothing they compute sums them), and the type says so: an
54
+ * aggregate that reads `output` cannot be handed a pair row by mistake.
55
+ */
56
+ export type PairRow = Omit<StepRow, "output" | "reasoning">;
57
+ /** The steps of the range, narrow. Ordering is only paid for where it is read. */
58
+ export declare function stepRows(db: Database, options?: Options): StepRow[];
59
+ /**
60
+ * The steps of the range plus, for every session with a step inside it, the one
61
+ * step before the range: the pair reports compare a step with the one that
62
+ * preceded it, so the first step of a range has to see it.
63
+ *
64
+ * The boundary is one seek per session on the `(session_id, time)` index — a
65
+ * few milliseconds for a far wider history — never the whole history of the
66
+ * sessions in range.
67
+ */
68
+ export declare function cachePairs(db: Database, options?: Options): PairRow[];
69
+ export type CoverageReport = {
70
+ steps: number;
71
+ models: number;
72
+ endpoints: number;
73
+ missingModel: number;
74
+ missingEndpoint: number;
75
+ unsettled: number;
76
+ missingTps: number;
77
+ firstAt: number | undefined;
78
+ lastAt: number | undefined;
79
+ };
80
+ /**
81
+ * What the panel cannot answer honestly: how many steps lack a model or an
82
+ * observed endpoint, and how many never settled. A zero in the affinity view
83
+ * means "never changed" only when this coverage is complete.
84
+ */
85
+ export declare function coverageOf(db: Database, options?: Options): CoverageReport;
86
+ /** The steps a latency report is built from. It reads no generated tokens. */
87
+ type GroupedRow = StepRow;
88
+ export type LatencyReport = {
89
+ wall: LatencyStats;
90
+ stream: LatencyStats;
91
+ tps: LatencyStats;
92
+ };
93
+ /** The timings of the range: one read, shared by the overall report and the groups. */
94
+ export declare function timingRows(db: Database, options?: Options): GroupedRow[];
95
+ /** Overall wall time, streaming window and throughput percentiles. */
96
+ export declare function latencyOf(rows: readonly StepRow[]): LatencyReport;
97
+ export type LatencyGroupRow = {
98
+ key: string;
99
+ label: string;
100
+ count: number;
101
+ usage: Usage;
102
+ wall: LatencyStats;
103
+ stream: LatencyStats;
104
+ tps: LatencyStats;
105
+ };
106
+ export type Dimension = "model" | "provider" | "endpoint";
107
+ /** Wall, streaming window and throughput percentiles per group. */
108
+ export declare function latencyGroupsOf(rows: readonly StepRow[], by: Dimension): LatencyGroupRow[];
109
+ export type CacheStats = {
110
+ steps: number;
111
+ input: number;
112
+ cached: number;
113
+ cacheWrite: number;
114
+ /** The whole prompt the provider saw: `cached + input + cache_write`. */
115
+ prompt: number;
116
+ /** Token-weighted reuse over the whole selection. */
117
+ hit: number | undefined;
118
+ /** Median of the per-step reuse ratios: a few huge steps cannot hide a split pattern. */
119
+ hitP50: number | undefined;
120
+ };
121
+ /** Reuse of the prompt cache over the range, as one row. */
122
+ export declare function cacheStatsOf(db: Database, options?: Options): CacheStats;
123
+ export type CacheModelRow = {
124
+ modelID: string;
125
+ variant: string | undefined;
126
+ cache: CacheStats;
127
+ };
128
+ /**
129
+ * Reuse of the prompt cache per model, most cached first, over the steps of the
130
+ * range. A boundary step is not part of the range, so it is left out.
131
+ */
132
+ export declare function cacheStatsByModel(rows: readonly PairRow[]): CacheModelRow[];
133
+ export type AffinityRow = {
134
+ modelID: string;
135
+ variant: string | undefined;
136
+ steps: number;
137
+ pairs: number;
138
+ changes: number;
139
+ modelChanges: number;
140
+ endpointChanges: number;
141
+ cacheLost: number;
142
+ cacheLostEvents: number;
143
+ };
144
+ export type AffinityTransition = {
145
+ dimension: "model" | "endpoint";
146
+ from: string;
147
+ to: string;
148
+ count: number;
149
+ };
150
+ export type AffinityReport = {
151
+ rows: AffinityRow[];
152
+ transitions: AffinityTransition[];
153
+ totals: {
154
+ pairs: number;
155
+ changes: number;
156
+ cacheLost: number;
157
+ cacheLostEvents: number;
158
+ };
159
+ };
160
+ /**
161
+ * Identity changes between consecutive steps of a session, by the two
162
+ * dimensions that decide where the work ran: the model and the routing
163
+ * endpoint. A change is not by itself a fault — a gateway may re-route
164
+ * deliberately — so the report pairs the count with the cache it actually cost.
165
+ */
166
+ export declare function affinityOf(rows: readonly PairRow[]): AffinityReport;
167
+ export type GapBucket = {
168
+ label: string;
169
+ samples: number;
170
+ /** Pairs that still reused the prompt cache. */
171
+ retained: number;
172
+ drops: number;
173
+ lostTokens: number;
174
+ retainedRatio: number | undefined;
175
+ };
176
+ export type EndpointReuseRow = {
177
+ /** The routing endpoint, or `(unknown)` when it was never observed. */
178
+ endpoint: string;
179
+ samples: number;
180
+ retained: number;
181
+ drops: number;
182
+ lostTokens: number;
183
+ retainedRatio: number | undefined;
184
+ };
185
+ export type GapReport = {
186
+ buckets: GapBucket[];
187
+ endpoints: EndpointReuseRow[];
188
+ samples: number;
189
+ };
190
+ /** Idle-gap buckets, coarse enough to read at a glance. */
191
+ export declare const GAP_BUCKETS: ReadonlyArray<readonly [string, number, number | undefined]>;
192
+ /**
193
+ * How long an idle gap the prompt cache survives, per gap bucket and per
194
+ * endpoint. Only pairs of the same model are comparable, which is exactly the
195
+ * set the cache rule already uses.
196
+ */
197
+ export declare function gapReportOf(rows: readonly PairRow[]): GapReport;
198
+ export type TtlEvidenceRow = {
199
+ providerID: string;
200
+ modelID: string;
201
+ samples: number;
202
+ /** Largest idle gap that still hit the cache. */
203
+ maxHitGapMs: number | undefined;
204
+ /** Smallest idle gap after which reuse was lost. */
205
+ minDropGapMs: number | undefined;
206
+ };
207
+ /**
208
+ * Empirical bounds on the provider cache TTL: the longest idle gap that still
209
+ * reused the cache, and the shortest gap that lost it. Comparing the two per
210
+ * provider turns TTL guesses into observations.
211
+ */
212
+ export declare function ttlEvidenceOf(rows: readonly PairRow[]): TtlEvidenceRow[];
213
+ export type DropReason = "compaction" | "expired" | "unknown";
214
+ export type CacheDropRow = {
215
+ sessionID: string;
216
+ messageID: string;
217
+ providerID: string | undefined;
218
+ modelID: string | undefined;
219
+ variant: string | undefined;
220
+ at: number;
221
+ tokens: number;
222
+ gapMs: number | undefined;
223
+ prevCached: number;
224
+ reason: DropReason;
225
+ };
226
+ /**
227
+ * Cache reuse losses with a best-effort reason: a compaction in between, an
228
+ * idle gap long enough to be provider-side expiry, or unknown.
229
+ *
230
+ * Compactions come from the `events` table: one query for the sessions in
231
+ * play, never one per loss.
232
+ */
233
+ export declare function cacheDropsOf(db: Database, rows: readonly PairRow[], options?: {
234
+ ttlHintMs?: number;
235
+ }): CacheDropRow[];
236
+ export type EndpointSummary = {
237
+ /** The routing endpoint, or undefined for requests whose destination was never observed. */
238
+ endpoint: string | undefined;
239
+ /** Models this endpoint served. */
240
+ models: string[];
241
+ share: number;
242
+ usage: Usage;
243
+ cache: CacheStats;
244
+ tps: number | undefined;
245
+ wall: number | undefined;
246
+ firstAt: number | undefined;
247
+ lastAt: number | undefined;
248
+ };
249
+ export type EndpointRow = EndpointSummary & {
250
+ modelID: string;
251
+ variant: string | undefined;
252
+ };
253
+ /** One row per endpoint, over every model it served. */
254
+ export declare function endpointSummaryOf(rows: readonly StepRow[]): EndpointSummary[];
255
+ /** One row per model × endpoint: where each model was served, and how much. */
256
+ export declare function endpointBreakdownOf(rows: readonly StepRow[]): EndpointRow[];
257
+ export type PeriodGranularity = "day" | "week" | "month";
258
+ export type PeriodRow = {
259
+ key: string;
260
+ label: string;
261
+ /** Start of the bucket, local time. */
262
+ at: number;
263
+ steps: number;
264
+ sessions: number;
265
+ usage: Usage;
266
+ cache: CacheStats;
267
+ };
268
+ /** ISO-8601 week number and its Monday, in local time. */
269
+ export declare function isoWeek(date: Date): {
270
+ year: number;
271
+ week: number;
272
+ monday: Date;
273
+ };
274
+ /** Key, label and local start of the period `at` falls in. */
275
+ export declare function periodStart(at: number, granularity: PeriodGranularity): {
276
+ key: string;
277
+ label: string;
278
+ start: number;
279
+ };
280
+ /** Totals per local calendar day, week or month, oldest first. */
281
+ export declare function periodsOf(rows: readonly StepRow[], granularity: PeriodGranularity): PeriodRow[];
282
+ export {};