opencode-usage-insights 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +323 -2
- package/dist/core.d.ts +259 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +1227 -0
- package/dist/index.js.map +16 -0
- package/dist/line.d.ts +71 -0
- package/dist/panel-build.d.ts +46 -0
- package/dist/panel-data.d.ts +216 -0
- package/dist/panel.d.ts +39 -0
- package/dist/recorder.d.ts +69 -0
- package/dist/sidebar.d.ts +25 -0
- package/dist/stats/aggregates.d.ts +283 -0
- package/dist/stats/db.d.ts +99 -0
- package/dist/stats/maintenance.d.ts +49 -0
- package/dist/stats/paths.d.ts +35 -0
- package/dist/stats/queries.d.ts +239 -0
- package/dist/text.d.ts +25 -0
- package/dist/tui.d.ts +53 -0
- package/dist/tui.js +3515 -0
- package/dist/tui.js.map +22 -0
- package/package.json +84 -4
package/dist/panel.d.ts
ADDED
|
@@ -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,69 @@
|
|
|
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
|
+
* Everything written comes from the event stream: the step's provider, model
|
|
9
|
+
* and variant, its tokens, its timing and its outcome. Pure with respect to
|
|
10
|
+
* its inputs (database plus events) so the whole recording policy can be
|
|
11
|
+
* unit-tested without the plugin runtime.
|
|
12
|
+
*/
|
|
13
|
+
export type SessionModelRef = {
|
|
14
|
+
id: string;
|
|
15
|
+
providerID: string;
|
|
16
|
+
variant?: string | undefined;
|
|
17
|
+
};
|
|
18
|
+
export type TokenUsageLike = {
|
|
19
|
+
input?: number | undefined;
|
|
20
|
+
output?: number | undefined;
|
|
21
|
+
reasoning?: number | undefined;
|
|
22
|
+
cache?: {
|
|
23
|
+
read?: number | undefined;
|
|
24
|
+
write?: number | undefined;
|
|
25
|
+
} | undefined;
|
|
26
|
+
};
|
|
27
|
+
export type RecorderEventData = {
|
|
28
|
+
sessionID?: string | undefined;
|
|
29
|
+
assistantMessageID?: string | undefined;
|
|
30
|
+
inboxID?: string | undefined;
|
|
31
|
+
item?: {
|
|
32
|
+
type?: string | undefined;
|
|
33
|
+
} | undefined;
|
|
34
|
+
started?: number | undefined;
|
|
35
|
+
model?: SessionModelRef | undefined;
|
|
36
|
+
previous?: SessionModelRef | undefined;
|
|
37
|
+
tokens?: TokenUsageLike | undefined;
|
|
38
|
+
cost?: number | undefined;
|
|
39
|
+
finish?: string | undefined;
|
|
40
|
+
rawFinish?: string | undefined;
|
|
41
|
+
reason?: string | undefined;
|
|
42
|
+
location?: {
|
|
43
|
+
directory?: string | undefined;
|
|
44
|
+
} | undefined;
|
|
45
|
+
parentID?: string | undefined;
|
|
46
|
+
title?: string | undefined;
|
|
47
|
+
};
|
|
48
|
+
export type RecorderEvent = {
|
|
49
|
+
type: string;
|
|
50
|
+
created?: number | undefined;
|
|
51
|
+
data?: RecorderEventData | undefined;
|
|
52
|
+
};
|
|
53
|
+
export type PendingStep = {
|
|
54
|
+
messageID: string;
|
|
55
|
+
sessionID: string;
|
|
56
|
+
started: number | undefined;
|
|
57
|
+
streamed: number | undefined;
|
|
58
|
+
model: SessionModelRef | undefined;
|
|
59
|
+
};
|
|
60
|
+
export type RecorderOptions = {
|
|
61
|
+
/** Clock override, for tests. */
|
|
62
|
+
now?: (() => number) | undefined;
|
|
63
|
+
};
|
|
64
|
+
export type Recorder = {
|
|
65
|
+
/** Apply one session event. Unknown or malformed events are ignored. */
|
|
66
|
+
handleEvent(event: RecorderEvent): void;
|
|
67
|
+
pendingFor(sessionID: string): PendingStep | undefined;
|
|
68
|
+
};
|
|
69
|
+
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 — when and through which provider, the model when it changed, and the
|
|
8
|
+
* prompt volumes. Reads the same database, the same buckets and the same
|
|
9
|
+
* formatter as the panel 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,283 @@
|
|
|
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
|
+
input: number;
|
|
37
|
+
cached: number;
|
|
38
|
+
cacheWrite: number;
|
|
39
|
+
output: number;
|
|
40
|
+
reasoning: number;
|
|
41
|
+
cost: number;
|
|
42
|
+
createdAt: number | undefined;
|
|
43
|
+
streamedAt: number | undefined;
|
|
44
|
+
completedAt: number | undefined;
|
|
45
|
+
/** `COALESCE(created_at, recorded_at)`: the ordering time of the step. */
|
|
46
|
+
at: number;
|
|
47
|
+
/** Set on the single step loaded before the range, as a reference. */
|
|
48
|
+
boundary?: boolean | undefined;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* A step as the pair reports read it. Their projection leaves the generated
|
|
52
|
+
* tokens out (nothing they compute sums them), and the type says so: an
|
|
53
|
+
* aggregate that reads `output` cannot be handed a pair row by mistake.
|
|
54
|
+
*/
|
|
55
|
+
export type PairRow = Omit<StepRow, "output" | "reasoning">;
|
|
56
|
+
/** The steps of the range, narrow. Ordering is only paid for where it is read. */
|
|
57
|
+
export declare function stepRows(db: Database, options?: Options): StepRow[];
|
|
58
|
+
/**
|
|
59
|
+
* The steps of the range plus, for every session with a step inside it, the one
|
|
60
|
+
* step before the range: the pair reports compare a step with the one that
|
|
61
|
+
* preceded it, so the first step of a range has to see it.
|
|
62
|
+
*
|
|
63
|
+
* The boundary is one seek per session on the `(session_id, time)` index — a
|
|
64
|
+
* few milliseconds for a far wider history — never the whole history of the
|
|
65
|
+
* sessions in range.
|
|
66
|
+
*/
|
|
67
|
+
export declare function cachePairs(db: Database, options?: Options): PairRow[];
|
|
68
|
+
export type CoverageReport = {
|
|
69
|
+
steps: number;
|
|
70
|
+
models: number;
|
|
71
|
+
/** Providers the steps ran through. A step whose provider is unknown is counted nowhere. */
|
|
72
|
+
providers: number;
|
|
73
|
+
missingModel: number;
|
|
74
|
+
unsettled: number;
|
|
75
|
+
missingTps: number;
|
|
76
|
+
firstAt: number | undefined;
|
|
77
|
+
lastAt: number | undefined;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* What the panel cannot answer honestly: how many steps lack a model, and how
|
|
81
|
+
* many never settled. A zero in the switch counts means "never switched" only
|
|
82
|
+
* when this coverage is complete.
|
|
83
|
+
*/
|
|
84
|
+
export declare function coverageOf(db: Database, options?: Options): CoverageReport;
|
|
85
|
+
/** The steps a latency report is built from. It reads no generated tokens. */
|
|
86
|
+
type GroupedRow = StepRow;
|
|
87
|
+
export type LatencyReport = {
|
|
88
|
+
wall: LatencyStats;
|
|
89
|
+
stream: LatencyStats;
|
|
90
|
+
tps: LatencyStats;
|
|
91
|
+
};
|
|
92
|
+
/** The timings of the range: one read, shared by the overall report and the groups. */
|
|
93
|
+
export declare function timingRows(db: Database, options?: Options): GroupedRow[];
|
|
94
|
+
/** Overall wall time, streaming window and throughput percentiles. */
|
|
95
|
+
export declare function latencyOf(rows: readonly StepRow[]): LatencyReport;
|
|
96
|
+
export type LatencyGroupRow = {
|
|
97
|
+
key: string;
|
|
98
|
+
label: string;
|
|
99
|
+
count: number;
|
|
100
|
+
usage: Usage;
|
|
101
|
+
wall: LatencyStats;
|
|
102
|
+
stream: LatencyStats;
|
|
103
|
+
tps: LatencyStats;
|
|
104
|
+
};
|
|
105
|
+
export type Dimension = "model" | "provider";
|
|
106
|
+
/** Wall, streaming window and throughput percentiles per group. */
|
|
107
|
+
export declare function latencyGroupsOf(rows: readonly StepRow[], by: Dimension): LatencyGroupRow[];
|
|
108
|
+
export type CacheStats = {
|
|
109
|
+
steps: number;
|
|
110
|
+
input: number;
|
|
111
|
+
cached: number;
|
|
112
|
+
cacheWrite: number;
|
|
113
|
+
/** The whole prompt the provider saw: `cached + input + cache_write`. */
|
|
114
|
+
prompt: number;
|
|
115
|
+
/** Token-weighted reuse over the whole selection. */
|
|
116
|
+
hit: number | undefined;
|
|
117
|
+
/** Median of the per-step reuse ratios: a few huge steps cannot hide a split pattern. */
|
|
118
|
+
hitP50: number | undefined;
|
|
119
|
+
};
|
|
120
|
+
/** Reuse of the prompt cache over the range, as one row. */
|
|
121
|
+
export declare function cacheStatsOf(db: Database, options?: Options): CacheStats;
|
|
122
|
+
export type CacheModelRow = {
|
|
123
|
+
modelID: string;
|
|
124
|
+
variant: string | undefined;
|
|
125
|
+
cache: CacheStats;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Reuse of the prompt cache per model, most cached first, over the steps of the
|
|
129
|
+
* range. A boundary step is not part of the range, so it is left out.
|
|
130
|
+
*/
|
|
131
|
+
export declare function cacheStatsByModel(rows: readonly PairRow[]): CacheModelRow[];
|
|
132
|
+
export type SwitchRow = {
|
|
133
|
+
modelID: string;
|
|
134
|
+
variant: string | undefined;
|
|
135
|
+
steps: number;
|
|
136
|
+
pairs: number;
|
|
137
|
+
/** Steps whose model, variant or provider differed from the step before. */
|
|
138
|
+
changes: number;
|
|
139
|
+
/** Of those, the ones whose model (or variant) differed. */
|
|
140
|
+
modelChanges: number;
|
|
141
|
+
/** Of those, the ones whose provider differed. */
|
|
142
|
+
providerChanges: number;
|
|
143
|
+
/** Cache the changes cost: only a provider switch can forfeit a reusable cache. */
|
|
144
|
+
cacheLost: number;
|
|
145
|
+
cacheLostEvents: number;
|
|
146
|
+
};
|
|
147
|
+
export type SwitchReport = {
|
|
148
|
+
rows: SwitchRow[];
|
|
149
|
+
totals: {
|
|
150
|
+
pairs: number;
|
|
151
|
+
changes: number;
|
|
152
|
+
cacheLost: number;
|
|
153
|
+
cacheLostEvents: number;
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Switches between consecutive steps of a session, by the two dimensions that
|
|
158
|
+
* decide which cache holds the work: the model (with its variant) and the
|
|
159
|
+
* provider.
|
|
160
|
+
*
|
|
161
|
+
* A switch is not by itself a fault — it is usually the user choosing another
|
|
162
|
+
* model — so the report only names a cost where one is measurable: a provider
|
|
163
|
+
* switch that leaves the model intact forfeits the cache the previous provider
|
|
164
|
+
* held. A model switch has no measurable cost by definition, because two models
|
|
165
|
+
* never share a cache to lose.
|
|
166
|
+
*/
|
|
167
|
+
export declare function switchesOf(rows: readonly PairRow[]): SwitchReport;
|
|
168
|
+
export type GapBucket = {
|
|
169
|
+
label: string;
|
|
170
|
+
samples: number;
|
|
171
|
+
/** Pairs that still reused the prompt cache. */
|
|
172
|
+
retained: number;
|
|
173
|
+
drops: number;
|
|
174
|
+
lostTokens: number;
|
|
175
|
+
retainedRatio: number | undefined;
|
|
176
|
+
};
|
|
177
|
+
export type ProviderReuseRow = {
|
|
178
|
+
/** The provider, or `(unknown)` when a step ran without one. */
|
|
179
|
+
provider: string;
|
|
180
|
+
samples: number;
|
|
181
|
+
retained: number;
|
|
182
|
+
drops: number;
|
|
183
|
+
lostTokens: number;
|
|
184
|
+
retainedRatio: number | undefined;
|
|
185
|
+
};
|
|
186
|
+
export type GapReport = {
|
|
187
|
+
buckets: GapBucket[];
|
|
188
|
+
providers: ProviderReuseRow[];
|
|
189
|
+
samples: number;
|
|
190
|
+
};
|
|
191
|
+
/** Idle-gap buckets, coarse enough to read at a glance. */
|
|
192
|
+
export declare const GAP_BUCKETS: ReadonlyArray<readonly [string, number, number | undefined]>;
|
|
193
|
+
/**
|
|
194
|
+
* How long an idle gap the prompt cache survives, per gap bucket and per
|
|
195
|
+
* provider. Only pairs of the same model are comparable, which is exactly the
|
|
196
|
+
* set the cache rule already uses.
|
|
197
|
+
*/
|
|
198
|
+
export declare function gapReportOf(rows: readonly PairRow[]): GapReport;
|
|
199
|
+
export type TtlEvidenceRow = {
|
|
200
|
+
providerID: string;
|
|
201
|
+
modelID: string;
|
|
202
|
+
samples: number;
|
|
203
|
+
/** Largest idle gap that still hit the cache. */
|
|
204
|
+
maxHitGapMs: number | undefined;
|
|
205
|
+
/** Smallest idle gap after which reuse was lost. */
|
|
206
|
+
minDropGapMs: number | undefined;
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Empirical bounds on the provider cache TTL: the longest idle gap that still
|
|
210
|
+
* reused the cache, and the shortest gap that lost it. Comparing the two per
|
|
211
|
+
* provider turns TTL guesses into observations.
|
|
212
|
+
*/
|
|
213
|
+
export declare function ttlEvidenceOf(rows: readonly PairRow[]): TtlEvidenceRow[];
|
|
214
|
+
export type DropReason = "compaction" | "expired" | "unknown";
|
|
215
|
+
export type CacheDropRow = {
|
|
216
|
+
sessionID: string;
|
|
217
|
+
messageID: string;
|
|
218
|
+
providerID: string | undefined;
|
|
219
|
+
modelID: string | undefined;
|
|
220
|
+
variant: string | undefined;
|
|
221
|
+
at: number;
|
|
222
|
+
tokens: number;
|
|
223
|
+
gapMs: number | undefined;
|
|
224
|
+
prevCached: number;
|
|
225
|
+
reason: DropReason;
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* Cache reuse losses with a best-effort reason: a compaction in between, an
|
|
229
|
+
* idle gap long enough to be provider-side expiry, or unknown.
|
|
230
|
+
*
|
|
231
|
+
* Compactions come from the `events` table: one query for the sessions in
|
|
232
|
+
* play, never one per loss.
|
|
233
|
+
*/
|
|
234
|
+
export declare function cacheDropsOf(db: Database, rows: readonly PairRow[], options?: {
|
|
235
|
+
ttlHintMs?: number;
|
|
236
|
+
}): CacheDropRow[];
|
|
237
|
+
export type ProviderSummary = {
|
|
238
|
+
/** The provider, or undefined for steps recorded without one. */
|
|
239
|
+
provider: string | undefined;
|
|
240
|
+
/** Models this provider served. */
|
|
241
|
+
models: string[];
|
|
242
|
+
share: number;
|
|
243
|
+
usage: Usage;
|
|
244
|
+
cache: CacheStats;
|
|
245
|
+
tps: number | undefined;
|
|
246
|
+
wall: number | undefined;
|
|
247
|
+
firstAt: number | undefined;
|
|
248
|
+
lastAt: number | undefined;
|
|
249
|
+
};
|
|
250
|
+
export type ProviderRow = ProviderSummary & {
|
|
251
|
+
modelID: string;
|
|
252
|
+
variant: string | undefined;
|
|
253
|
+
};
|
|
254
|
+
/** One row per provider, over every model it served. */
|
|
255
|
+
export declare function providerSummaryOf(rows: readonly StepRow[]): ProviderSummary[];
|
|
256
|
+
/** One row per model × provider: through which provider each model ran, and how much. */
|
|
257
|
+
export declare function providerBreakdownOf(rows: readonly StepRow[]): ProviderRow[];
|
|
258
|
+
export type PeriodGranularity = "day" | "week" | "month";
|
|
259
|
+
export type PeriodRow = {
|
|
260
|
+
key: string;
|
|
261
|
+
label: string;
|
|
262
|
+
/** Start of the bucket, local time. */
|
|
263
|
+
at: number;
|
|
264
|
+
steps: number;
|
|
265
|
+
sessions: number;
|
|
266
|
+
usage: Usage;
|
|
267
|
+
cache: CacheStats;
|
|
268
|
+
};
|
|
269
|
+
/** ISO-8601 week number and its Monday, in local time. */
|
|
270
|
+
export declare function isoWeek(date: Date): {
|
|
271
|
+
year: number;
|
|
272
|
+
week: number;
|
|
273
|
+
monday: Date;
|
|
274
|
+
};
|
|
275
|
+
/** Key, label and local start of the period `at` falls in. */
|
|
276
|
+
export declare function periodStart(at: number, granularity: PeriodGranularity): {
|
|
277
|
+
key: string;
|
|
278
|
+
label: string;
|
|
279
|
+
start: number;
|
|
280
|
+
};
|
|
281
|
+
/** Totals per local calendar day, week or month, oldest first. */
|
|
282
|
+
export declare function periodsOf(rows: readonly StepRow[], granularity: PeriodGranularity): PeriodRow[];
|
|
283
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
/**
|
|
3
|
+
* Persistent usage store. One SQLite database holds every recorded step,
|
|
4
|
+
* prompt, event and session row — and nothing else: the recorder only writes
|
|
5
|
+
* what a view reads.
|
|
6
|
+
*
|
|
7
|
+
* The database is opened in WAL mode with a busy timeout so the server
|
|
8
|
+
* recorder process and the TUI reader process can share it safely. The two
|
|
9
|
+
* sides open it differently on purpose:
|
|
10
|
+
*
|
|
11
|
+
* - `openStatsDb` writes: schema, migrations and recording (the server).
|
|
12
|
+
* - `openStatsDbReadOnly` only reads (the TUI), so no view can change the file
|
|
13
|
+
* and no reader pays for a schema write.
|
|
14
|
+
* - `openStatsDbWrite` is a short-lived maintenance connection (prune, vacuum)
|
|
15
|
+
* that gives up quickly instead of holding the recorder up.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SCHEMA_VERSION = 9;
|
|
18
|
+
/** Tables carried by `exportNdjson` / `importNdjson`, in import-safe order. */
|
|
19
|
+
export declare const TABLES: readonly ["sessions", "prompts", "events", "steps"];
|
|
20
|
+
export type TableName = (typeof TABLES)[number];
|
|
21
|
+
export type StepRecord = {
|
|
22
|
+
messageID: string;
|
|
23
|
+
sessionID: string;
|
|
24
|
+
parentID?: string | undefined;
|
|
25
|
+
/** The provider the model ran through, as OpenCode configured it (`opencode-go`). */
|
|
26
|
+
providerID?: string | undefined;
|
|
27
|
+
modelID?: string | undefined;
|
|
28
|
+
variant?: string | undefined;
|
|
29
|
+
input?: number | undefined;
|
|
30
|
+
output?: number | undefined;
|
|
31
|
+
reasoning?: number | undefined;
|
|
32
|
+
cacheRead?: number | undefined;
|
|
33
|
+
cacheWrite?: number | undefined;
|
|
34
|
+
cost?: number | undefined;
|
|
35
|
+
createdAt?: number | undefined;
|
|
36
|
+
streamedAt?: number | undefined;
|
|
37
|
+
completedAt?: number | undefined;
|
|
38
|
+
finish?: string | undefined;
|
|
39
|
+
/** Clock of the recording itself, defaults to `Date.now()`. */
|
|
40
|
+
recordedAt?: number | undefined;
|
|
41
|
+
};
|
|
42
|
+
export type PromptRecord = {
|
|
43
|
+
sessionID: string;
|
|
44
|
+
messageID: string;
|
|
45
|
+
createdAt?: number | undefined;
|
|
46
|
+
};
|
|
47
|
+
export type EventRecord = {
|
|
48
|
+
sessionID: string;
|
|
49
|
+
kind: string;
|
|
50
|
+
messageID?: string | undefined;
|
|
51
|
+
at?: number | undefined;
|
|
52
|
+
data?: unknown;
|
|
53
|
+
};
|
|
54
|
+
export type SessionRecord = {
|
|
55
|
+
sessionID: string;
|
|
56
|
+
parentID?: string | undefined;
|
|
57
|
+
directory?: string | undefined;
|
|
58
|
+
title?: string | undefined;
|
|
59
|
+
startedAt?: number | undefined;
|
|
60
|
+
updatedAt?: number | undefined;
|
|
61
|
+
};
|
|
62
|
+
/** Open (creating when needed) the stats database and apply the schema. */
|
|
63
|
+
export declare function openStatsDb(path: string): Database;
|
|
64
|
+
/**
|
|
65
|
+
* Reader connection for the TUI: no schema, no migration, no write. It throws
|
|
66
|
+
* when the database is absent or was never written by the recorder, so the
|
|
67
|
+
* caller can say so instead of showing an empty panel.
|
|
68
|
+
*/
|
|
69
|
+
export declare function openStatsDbReadOnly(path: string): Database;
|
|
70
|
+
/**
|
|
71
|
+
* Short-lived write connection for maintenance. The busy timeout is short on
|
|
72
|
+
* purpose: a prune or a vacuum that cannot take the lock must fail in front of
|
|
73
|
+
* the user, not hold the recorder's response path for seconds.
|
|
74
|
+
*/
|
|
75
|
+
export declare function openStatsDbWrite(path: string): Database;
|
|
76
|
+
/**
|
|
77
|
+
* Insert or refresh an assistant step. Idempotent by message ID so replayed
|
|
78
|
+
* events, restarts and NDJSON imports never duplicate usage. A non-null field
|
|
79
|
+
* (the provider, the model, the finish reason) survives a re-record with `NULL`
|
|
80
|
+
* through COALESCE.
|
|
81
|
+
*
|
|
82
|
+
* The provider is the identity every view groups by, so it is validated as a
|
|
83
|
+
* token before it is stored: a value that is not a provider id is recorded as
|
|
84
|
+
* absent instead of becoming a grouping key.
|
|
85
|
+
*/
|
|
86
|
+
export declare function recordStep(db: Database, record: StepRecord): void;
|
|
87
|
+
export declare function recordPrompt(db: Database, record: PromptRecord): void;
|
|
88
|
+
export declare function recordEvent(db: Database, record: EventRecord): void;
|
|
89
|
+
export declare function upsertSession(db: Database, record: SessionRecord): void;
|
|
90
|
+
/** Dump every table as NDJSON (`{"table": "...", "row": {...}}` per line). */
|
|
91
|
+
export declare function exportNdjson(db: Database): string;
|
|
92
|
+
/**
|
|
93
|
+
* Load an `exportNdjson` dump back in. Rows are inserted with `OR IGNORE`, so
|
|
94
|
+
* importing the same dump twice is harmless. Unknown tables and rows that are
|
|
95
|
+
* not objects are rejected; a row that cannot fill a required column is
|
|
96
|
+
* skipped rather than aborting the rest of the import. A column the row does
|
|
97
|
+
* not carry is left to its default, the same way a hand-written insert is.
|
|
98
|
+
*/
|
|
99
|
+
export declare function importNdjson(db: Database, text: string): number;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { type TableName } from "./db";
|
|
3
|
+
/**
|
|
4
|
+
* Database maintenance: what the file holds, how much of it is worth keeping,
|
|
5
|
+
* and the two operations that give the space back.
|
|
6
|
+
*
|
|
7
|
+
* Everything is explicit — nothing here runs on its own. The panel's `db` view
|
|
8
|
+
* and `bun run report` are the only callers.
|
|
9
|
+
*/
|
|
10
|
+
export type TableSize = {
|
|
11
|
+
name: string;
|
|
12
|
+
rows: number;
|
|
13
|
+
};
|
|
14
|
+
export type DbInfo = {
|
|
15
|
+
path: string;
|
|
16
|
+
/** Size of the database file, in bytes. */
|
|
17
|
+
bytes: number;
|
|
18
|
+
/** Size of its write-ahead log, when one exists. */
|
|
19
|
+
walBytes: number | undefined;
|
|
20
|
+
schemaVersion: number;
|
|
21
|
+
tables: TableSize[];
|
|
22
|
+
/** Oldest and newest recorded step, the window the views can answer for. */
|
|
23
|
+
firstAt: number | undefined;
|
|
24
|
+
lastAt: number | undefined;
|
|
25
|
+
/** Steps a prune at each cutoff would remove, for the panel to offer choices. */
|
|
26
|
+
removable: Array<{
|
|
27
|
+
days: number;
|
|
28
|
+
steps: number;
|
|
29
|
+
}>;
|
|
30
|
+
};
|
|
31
|
+
export declare function dbInfo(db: Database, path: string, now?: number): DbInfo;
|
|
32
|
+
export type PruneResult = {
|
|
33
|
+
steps: number;
|
|
34
|
+
prompts: number;
|
|
35
|
+
events: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Delete recorded usage older than `cutoff`. Steps, their prompts and the
|
|
39
|
+
* compaction events that explain their cache losses go together: a compaction
|
|
40
|
+
* that no step references answers nothing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function pruneOlderThan(db: Database, cutoff: number): PruneResult;
|
|
43
|
+
/** Reclaim the space a prune freed. Returns the bytes the database lost. */
|
|
44
|
+
export declare function vacuum(db: Database, path: string): {
|
|
45
|
+
before: number;
|
|
46
|
+
after: number;
|
|
47
|
+
freed: number;
|
|
48
|
+
};
|
|
49
|
+
export type { TableName };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite database used when no `db` option is configured: the per-user state
|
|
3
|
+
* directory of the current OS, never a shared temp directory.
|
|
4
|
+
*
|
|
5
|
+
* The subdirectory is the plugin's package name, never a generic one: the
|
|
6
|
+
* `opencode` tree is shared with OpenCode itself and with every other plugin,
|
|
7
|
+
* so a `stats/` of our own would be a collision waiting to happen.
|
|
8
|
+
*
|
|
9
|
+
* - Windows: `%LOCALAPPDATA%\opencode\opencode-usage-insights\usage.db`
|
|
10
|
+
* - macOS: `~/Library/Application Support/opencode/opencode-usage-insights/usage.db`
|
|
11
|
+
* - Linux/other: `$XDG_STATE_HOME/opencode/opencode-usage-insights/usage.db`,
|
|
12
|
+
* falling back to `~/.local/state/opencode/opencode-usage-insights/usage.db`
|
|
13
|
+
*
|
|
14
|
+
* Pure by design so every OS can be asserted from any machine.
|
|
15
|
+
*/
|
|
16
|
+
export declare function defaultDbPath(env?: Record<string, string | undefined>, platform?: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Expand a leading `~` (or `~/`), so a configured path reads the way every
|
|
19
|
+
* shell does. Windows accepts the backslash form too.
|
|
20
|
+
*/
|
|
21
|
+
export declare function expandHome(path: string, home?: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the effective database path: explicit plugin option first, then the
|
|
24
|
+
* `OPENCODE_USAGE_INSIGHTS_DB` escape hatch (handy for dev and tests), then
|
|
25
|
+
* the per-OS state directory. A leading `~` is expanded in all three.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveDbPath(configured?: string | undefined, env?: Record<string, string | undefined>, platform?: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* A configured path must not be a directory, a device or a FIFO: SQLite would
|
|
30
|
+
* fail obscurely, or write where nobody expects it. Symlinks to a regular file
|
|
31
|
+
* are followed (`statSync`, not `lstat`) because moving the state directory to
|
|
32
|
+
* another disk is a legitimate setup. A missing file is fine — the plugin
|
|
33
|
+
* creates it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function ensureRegularFile(path: string): void;
|