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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hugo Batista
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,324 @@
1
- # Temporary Holding Version
1
+ # opencode-usage-insights ๐Ÿ“Š
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![GitHub Tag](https://img.shields.io/github/v/tag/hugobatista/opencode-usage-insights?logo=github&label=latest)](https://github.com/hugobatista/opencode-usage-insights/releases)
4
+ [![Test](https://img.shields.io/github/actions/workflow/status/hugobatista/opencode-usage-insights/test.yml?label=Test)](https://github.com/hugobatista/opencode-usage-insights/actions/workflows/test.yml)
5
+ [![Lint](https://img.shields.io/github/actions/workflow/status/hugobatista/opencode-usage-insights/lint.yml?label=Lint)](https://github.com/hugobatista/opencode-usage-insights/actions/workflows/lint.yml)
6
+ [![npm](https://img.shields.io/npm/v/opencode-usage-insights.svg)](https://www.npmjs.com/package/opencode-usage-insights)
7
+
8
+ OpenCode plugin for one question the host cannot answer: **which provider is
9
+ actually serving each model** โ€” and what that costs. It shows a telemetry line
10
+ above the composer after every finished turn and records every assistant step
11
+ in a local SQLite file, read back through an interactive `/usage` panel.
12
+
13
+ ![The telemetry line after a turn](docs/images/line.png)
14
+
15
+ Everything stays local: one SQLite file under the OpenCode state directory,
16
+ written by the server-side recorder and read by the TUI.
17
+
18
+ ## What it does
19
+
20
+ - **The line** (`session.composer.top`): end time, the gap since the previous
21
+ prompt, the provider and model that ran the turn, turn and session token
22
+ totals, and warnings when the prompt cache or the provider identity changed.
23
+ It belongs to finished work, so it stays hidden while a turn is running.
24
+ - **The `/usage` panel**: nine keyboard-driven views โ€” `overview`, `models`,
25
+ `providers`, `cache`, `periods`, `latency`, `sessions`, `turns`,
26
+ `db` โ€” over five ranges (`session`, `today`, `7d`, `30d`, `all`), with
27
+ drill-downs, sorting, filters and state remembered between openings.
28
+ `/usage models 7d`, `/usage providers 30d`, `/usage turns 7d`.
29
+ - **The session sidebar**: the session's own totals and its last turns, in the
30
+ same buckets as the line. Three lines per turn โ€” when and through which
31
+ provider, the prompt volumes, then `hit ยท tok/s ยท cost` โ€” plus the model at
32
+ both ends of the block and on any turn that changed it, so the history reads
33
+ whether it is the whole session or a window of a long one:
34
+ `16:54 opencode-go` / `deepseek-v4.1-flash max` /
35
+ `in 1.7k (427.3k) ยท out 1.06k` / `hit 99.7% ยท 1.2k/s ยท $0.0006`.
36
+ - **The record** (server entry): every assistant step with tokens, cost,
37
+ timings, finish reason, the provider and model it ran as, and its session and
38
+ parent ids; prompts, session lifecycle and compaction events too.
39
+ - **Portable data** (from a checkout): `bun run report --ndjson` exports the
40
+ whole database, and `--import` merges it back on another machine.
41
+ - **Observational by design**: the plugin never rewrites prompts, requests or
42
+ responses, and a failure inside the recorder is swallowed โ€” telemetry can
43
+ never interrupt a session.
44
+
45
+ ## Requirements
46
+
47
+ - **OpenCode V2** (`>=2.0.0`). The V2 release changed the plugin API (slots,
48
+ panels, event stream).
49
+ - [Bun](https://bun.sh) to install dependencies (development only).
50
+
51
+ | | |
52
+ | --- | --- |
53
+ | **OS** | Linux, macOS and Windows. The database lives in the per-user state directory of each (table below); `test/paths.test.ts` asserts the path rules of all three from any machine, and `bun run check:platform` exercises the data layer โ€” paths, database, recorder, queries, panel assembly, dump round-trip, file permissions โ€” on the OS it runs on. CI runs the whole suite (including the TUI render tests) on Linux. |
54
+ | **Runtime** | Bun, the runtime OpenCode ships with. SQLite comes from `bun:sqlite`: there is no native module to build and nothing to compile on install. |
55
+
56
+ ## Install
57
+
58
+ > [!IMPORTANT]
59
+ > **Not on the npm registry yet.** Until the first release is published,
60
+ > `opencode plugin add` and the package-name form below cannot resolve it โ€” use
61
+ > the local checkout, the last form of this section.
62
+
63
+ ```sh
64
+ opencode plugin add opencode-usage-insights
65
+ ```
66
+
67
+ Or add the package to `~/.config/opencode/opencode.jsonc` (server entry) and
68
+ `~/.config/opencode/cli.json` (TUI entry):
69
+
70
+ ```jsonc
71
+ // ~/.config/opencode/opencode.jsonc
72
+ {
73
+ "$schema": "https://opencode.ai/config.json",
74
+ "plugins": ["opencode-usage-insights"]
75
+ }
76
+ ```
77
+
78
+ ```jsonc
79
+ // ~/.config/opencode/cli.json
80
+ {
81
+ "$schema": "https://opencode.ai/v2/cli.json",
82
+ "plugins": ["opencode-usage-insights"]
83
+ }
84
+ ```
85
+
86
+ To pass [options](#configuration), use the object form:
87
+
88
+ ```jsonc
89
+ {
90
+ "plugins": [
91
+ { "package": "opencode-usage-insights", "options": { "sidebarTurns": 5 } }
92
+ ]
93
+ }
94
+ ```
95
+
96
+ Or register a local checkout. V2 resolves a local *directory* plugin through its
97
+ entry files, so pointing at `src/` loads the server recorder (`src/index.ts`)
98
+ and the TUI contributions (`src/tui.tsx`) from source:
99
+
100
+ ```jsonc
101
+ // ~/.config/opencode/opencode.jsonc
102
+ { "plugins": [{ "package": "/home/you/code/projects/opencode-usage-insights/src" }] }
103
+ ```
104
+
105
+ ```jsonc
106
+ // ~/.config/opencode/cli.json
107
+ { "plugins": [{ "package": "/home/you/code/projects/opencode-usage-insights/src" }] }
108
+ ```
109
+
110
+ Configuration changes reload on their own; after changing the plugin's source,
111
+ restart the background service with `opencode service restart`.
112
+
113
+ Send a prompt to verify: the telemetry line appears above the composer when the
114
+ turn finishes, and `/usage` (alias `/insights`) opens the panel.
115
+
116
+ ## The line
117
+
118
+ ```text
119
+ 14:32:07 ยท ฮ” 3m12s ยท opencode-go/deepseek-v4.1-flash max ยท in 85.4k (1.62M), cached 1.53M, out 3.7k, cost $0.21 ยท sess in 2.1M (14.5M), cached 12.4M, out 88k, cost $1.90 ยท โš  cache expired (โˆ’11.7k)
120
+ ```
121
+
122
+ | Segment | Meaning |
123
+ | --- | --- |
124
+ | `14:32:07` | When the response ended (`completed`, else `streamed`, else `created`). |
125
+ | `ฮ” 3m12s` | Time since the previous prompt of the same session. |
126
+ | `opencode-go/deepseek-v4.1-flash max` | The identity of the turn: the provider the model ran through, as OpenCode is configured, and the model (the variant as a trailing word; `fields.model`/`fields.variant` hide either). Yellow with `changed` when the provider differs from the previous prompt, `model changed` when only the model does. |
127
+ | `in 85.4k (1.62M), cached 1.53M, out 3.7k, cost $0.21` | `in` is the uncached part of the prompt (fresh input + cache writes), the parenthesised value the whole prompt โ€” shown only when the two differ โ€” `cached` its cached part, `out` everything generated (visible output + reasoning), and `cost` what the turn was billed. With several steps the segment reads `3 steps ยท โ€ฆ` and reports the step the turn ended with (`latest`) for the tokens, while the cost is always the turn's own โ€” every step is paid for; `turnTotals: "sum"` adds every step's tokens instead. |
128
+ | `cost $0.21` | Money, not tokens: a turn sums every step's cost whatever the token view shows. A cost of zero is left out, so a free model reads as free. `fields.cost: false` hides it. |
129
+ | `sess in 2.1M (14.5M), cached 12.4M, out 88k, cost $1.90` | The session totals reported by the host, in the same buckets, with the cost of the whole session. |
130
+ | `โš  cache expired (โˆ’11.7k)` | Yellow. The prompt cache read fell by that many tokens against a comparable previous step; `ร—N` when several steps in the turn lost cache. |
131
+
132
+ The turn segment carries no label by default โ€” the numbers are the turn's, and
133
+ `sess` marks the session's; `labels.turn` (e.g. `"turn"`) brings a prefix back.
134
+
135
+ A turn is every consecutive assistant step after a prompt. Compaction, shell,
136
+ `idle` and info messages do not split a turn; `user`, `synthetic` and `system`
137
+ messages do. From a checkout, `bun run demo` prints one example per case,
138
+ including the streaming one and both turn-total views.
139
+
140
+ ## The panel
141
+
142
+ `/usage [view] [range]` opens the panel (`/usage` alone opens the last view, or
143
+ `overview` over `all` the first time). While the panel is open it owns input, so
144
+ the prompt cannot take the command again: `escape` closes it, `f` toggles full
145
+ screen, and the palette entry (`ctrl+p` โ†’ "Usage insights") toggles it closed.
146
+ With the composer focused, `/usage` with no arguments also toggles it closed,
147
+ and any argument โ€” including an unknown token, which is ignored โ€” keeps it open.
148
+ There is no default key binding โ€” the command is palette-only, so it never
149
+ shadows a key you use.
150
+
151
+ ![The /usage providers view](docs/images/panel-providers.png)
152
+
153
+ The view bar at the top names every view and brackets the active one; `?` opens
154
+ a help overlay that repeats the keys and defines every number.
155
+
156
+ ### Keys
157
+
158
+ | Key | Does |
159
+ | --- | --- |
160
+ | `โ†‘` `โ†“` / `j` `k` | Move the cursor; the row under it is highlighted. |
161
+ | `pageup` `pagedown` / `home` `end` | Move by ten rows / to the first or last row. |
162
+ | `enter` | Drill into the row: a model โ†’ the providers it ran through and their latency; a provider โ†’ the models it served and its latency; a session โ†’ its turns; a period โ†’ the providers of the period. In `db`, prune the steps recorded before the selected retention's cutoff date (asks first). |
163
+ | `backspace` | Go back from a drill-down. |
164
+ | `1`โ€“`9` / `[` `]` | Pick a view (`9` is `db`) / cycle views. |
165
+ | `t` / `shift+t` | Next / previous range. |
166
+ | `s` / `shift+s` | Cycle the sort column / reverse it. |
167
+ | `b` | `periods` bucket: day โ†’ week โ†’ month. |
168
+ | `a` | `turns` scope: this session โ†” every session. |
169
+ | `m` `p` | Filter by model or by **provider** (a picker of the values actually observed). |
170
+ | `x` | Clear the filters. |
171
+ | `r` | Re-read the database. |
172
+ | `v` | `db`: compact the file, giving the space of deleted rows back. |
173
+ | `?` | Help overlay. |
174
+ | `f` / `escape` | Full screen / close. |
175
+
176
+ The view, range, sort, bucket, scope and filters are stored through the host's
177
+ durable storage, so the panel reopens where it was left.
178
+
179
+ ### Views
180
+
181
+ | View | Answers |
182
+ | --- | --- |
183
+ | `overview` | Steps, sessions, prompts, `in`/`cached`/`write`/`out`, `hit`, cost, an activity sparkline, a `this session` block and a **data coverage** block (how many models and providers were observed, and how many steps lack a model or a completion โ€” a zero elsewhere is only trustworthy when this is complete). |
184
+ | `models` | Every model with its cache (`hit` and the per-step `hit p50`), tokens, cost, the **providers it ran through** and how often it switched model or provider โ€” with the cache a provider switch cost. `enter` opens the per-provider detail. |
185
+ | `providers` | **Who ran the work**: one row per provider, with the models it served, steps, share, prompt, cached, `hit`, p50 throughput, wall time and cost. `enter` lists every model it served. Steps recorded without a provider (a model that was never observed) are dimmed and cannot be drilled into. |
186
+ | `cache` | Reuse by model, losses grouped by reason (`expired`, `compaction`, `unknown`), **cache survival against idle time**, reuse kept per provider, the observed TTL bounds (`hits up to` / `drops from`) and the most recent losses. |
187
+ | `periods` | Day, week or month totals โ€” steps, sessions, `in`, `cached`, `write`, `hit`, `out`, cost โ€” with a sparkline per metric and a totals row. `enter` lists the providers of a period. |
188
+ | `latency` | Wall time, streaming window and throughput as p50/p90/p99 overall and per model (per provider when a model is filtered). `n < 5` is dimmed. |
189
+ | `sessions` | Each session with steps, prompts, tokens, `hit`, cost, its directory and how many models it used; subagent sessions are indented under their parent and rolled up. |
190
+ | `turns` | The per-prompt history: one line per finished turn, rendered exactly like the live line. `a` widens it to every session, titled. |
191
+ | `db` | Where the file lives, what it holds and how much of it is old. The retention table lists, per preset (`30`/`90`/`365` days), how many steps a prune would remove and the cutoff date it prunes from โ€” pick one with `โ†‘`/`โ†“` and `enter` prunes it (a confirmation first); `v` gives the space back. |
192
+
193
+ The panel sums the stored per-step buckets over the selected range, so a number
194
+ read in one place means the same in the other: `prompt` is everything the
195
+ provider saw (`cached + fresh + writes`), `in` the uncached part, `hit` is
196
+ `cached / prompt`, and `out` everything generated (visible output + reasoning).
197
+
198
+ The identity of a step is the **provider the model ran through** โ€” `opencode`,
199
+ `opencode-go`, `deepseek`, whatever the provider is called in the OpenCode
200
+ configuration โ€” and the model itself. That is what every view groups by, and it
201
+ is also the whole answer available: a gateway such as OpenCode Console may
202
+ re-route a request to another operator, but that upstream is not observable from
203
+ the plugin, and the identity a request *would* have been routed to is not
204
+ evidence of who ran it. The provider is: the same model through `deepseek` and
205
+ through `opencode-go` are two rows, two caches and two latency profiles, and a
206
+ switch between them forfeits the cache the previous one held (the switch counts
207
+ and the cache losses in the `models` view count exactly that).
208
+
209
+ `tps`/`wall`/`stream` are produced tokens per streaming second / request to
210
+ completed step / request to the last chunk. The sidebar's rate (per turn, and
211
+ the session average on its totals) is the **weighted** one โ€” produced tokens
212
+ divided by the summed streaming windows โ€” so it answers "how fast did this
213
+ produce"; the `latency` view reports the p50 of the per-step rates instead, which
214
+ answers "how fast is a typical step". A switch from one step to the next is not
215
+ by itself a fault โ€” moving to another model or provider is a deliberate choice โ€”
216
+ so the `models` view pairs the count with the cache the switch actually cost,
217
+ which only a provider switch can: two models never share a cache to lose. The
218
+ panel's `?` overlay defines every number in place.
219
+
220
+ ## Configuration
221
+
222
+ Options apply to both entries (each side reads the ones it needs):
223
+
224
+ | Option | Default | Meaning |
225
+ | --- | --- | --- |
226
+ | `enabled` | `true` | `false` disables that side completely โ€” no line, no panel, no recording. |
227
+ | `db` | per-OS state path | SQLite path override. Must match between the server and TUI entries. |
228
+ | `turnTotals` | `"latest"` | How the turn segment counts prompt usage: `latest` shows the prompt size the turn ended with; `sum` adds every step of the turn. |
229
+ | `sidebar` | `true` | Show the session summary and the per-prompt history โ€” cache hit and cost per turn โ€” in the session sidebar (the slot is invisible while the sidebar is hidden). |
230
+ | `sidebarTurns` | `5` | How many turns the sidebar history shows. |
231
+ | `panel` | `true` | Show the `/usage` statistics panel and its command. `false` keeps the line and the sidebar. |
232
+ | `fields` | see below | Toggle line segments. |
233
+ | `labels` | see below | Override line labels (the plugin is English-only by default). |
234
+
235
+ ### Fields and labels
236
+
237
+ ```jsonc
238
+ {
239
+ "options": {
240
+ "fields": { "clock": true, "delta": true, "model": true, "variant": true, "turn": true, "session": true, "reasoning": false, "cost": true, "cacheExpiry": true },
241
+ "turnTotals": "latest",
242
+ "labels": { "delta": "ฮ”", "turn": "", "session": "sess", "step": "step", "steps": "steps", "input": "in", "cached": "cached", "output": "out", "reasoning": "reason", "cost": "cost", "cacheExpired": "cache expired", "providerChanged": "changed", "modelChanged": "model changed" }
243
+ }
244
+ }
245
+ ```
246
+
247
+ `fields: { "clock": false, "delta": false, "session": false }` turns the line
248
+ into a pure `provider/model ยท turn โ€ฆ` reading.
249
+
250
+ ## Database and privacy
251
+
252
+ The recorder writes to the per-user state directory:
253
+
254
+ | OS | Path |
255
+ | --- | --- |
256
+ | Linux / other | `$XDG_STATE_HOME/opencode/opencode-usage-insights/usage.db` (default `~/.local/state/opencode/opencode-usage-insights/usage.db`) |
257
+ | macOS | `~/Library/Application Support/opencode/opencode-usage-insights/usage.db` |
258
+ | Windows | `%LOCALAPPDATA%\opencode\opencode-usage-insights\usage.db` |
259
+
260
+ `OPENCODE_USAGE_INSIGHTS_DB=/path/to/usage.db` overrides the path, and the `db`
261
+ option overrides both. The file is private to the user: its directory is created
262
+ `0700` and the file `0600` (POSIX), re-applied on every open. The TUI reads
263
+ through a read-only connection, so it can never write, migrate or take the
264
+ recorder's lock.
265
+
266
+ Per step the database holds tokens, cost, timings, finish reason, the provider
267
+ and the model it ran as, and the session title and working directory. **Prompts
268
+ and responses are not stored**, and nothing leaves the machine. The schema, the
269
+ indexes, the identity rules and the security notes live in
270
+ [docs/internals.md](docs/internals.md).
271
+
272
+ ## CLI report
273
+
274
+ From a checkout, the database can be read without opening the TUI:
275
+
276
+ ```sh
277
+ bun run report # overview over all time
278
+ bun run report models 7d # one view, one range
279
+ bun run report all # every view
280
+ bun run report providers --model deepseek-v4.1-flash
281
+ bun run report periods --granularity month
282
+ bun run report models --provider opencode-go
283
+ bun run report --json # raw rows and every analysis, for scripting
284
+ bun run report --ndjson > backup.jsonl # export the whole database
285
+ bun run report --import backup.jsonl # merge an export (idempotent)
286
+ bun run report --help
287
+ ```
288
+
289
+ The views come from the same `buildPanel` the TUI renders, so the terminal and
290
+ the panel never drift apart. `--width`, `--granularity`, `--model`,
291
+ `--provider`, `--sort` and `--asc` mirror what the panel's keys do.
292
+
293
+ ## Development
294
+
295
+ ```sh
296
+ bun install
297
+ bun run typecheck # tsc --noEmit, strict
298
+ bun test # pure logic + database + TUI harness + render tests
299
+ bun run build # dist/index.js, dist/tui.js and declarations
300
+ bun run demo # print the line and every view from a seeded database
301
+ bun run docs:images # re-render docs/images/*.png (needs ImageMagick)
302
+ ```
303
+
304
+ - `src/core.ts`, `src/line.ts`, `src/panel-data.ts` and `src/stats/` are pure:
305
+ turn accounting, the host-shape adapter, the view models, and the database,
306
+ queries and analysis layer. No host imports, so they are unit-testable without
307
+ a TUI runtime.
308
+ - `src/text.ts` โ€” text hygiene and the token shape a provider id is validated
309
+ against.
310
+ - `src/recorder.ts` โ€” the event-stream state machine that fills the database.
311
+ - `src/panel-build.ts`, `src/panel.tsx`, `src/sidebar.tsx` โ€” panel assembly, the
312
+ panel component, and the sidebar contribution.
313
+ - `src/index.ts`, `src/tui.tsx` โ€” the server and TUI entries.
314
+ - `scripts/` โ€” the demo, report, bench, platform-check and documentation-image
315
+ CLIs, plus the two-entry `Bun.build`.
316
+
317
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the ground rules and review
318
+ expectations, and [docs/internals.md](docs/internals.md) for the database
319
+ design, performance rules and how the TUI side is tested.
320
+
321
+ ## License
322
+
323
+ MIT โ€” see [LICENSE](./LICENSE). Author: Hugo Batista
324
+ (<https://github.com/hugobatista>).
package/dist/core.d.ts ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Pure turn accounting shared by the TUI line, the server recorder and the
3
+ * stats engine.
4
+ *
5
+ * Everything here operates on structural shapes that mirror the session
6
+ * messages published by OpenCode, so the logic stays testable without a host
7
+ * runtime and without the effect-based schema types.
8
+ */
9
+ export type ModelRefLike = {
10
+ providerID: string;
11
+ id: string;
12
+ variant?: string | undefined;
13
+ };
14
+ export type AssistantMessage = {
15
+ type: "assistant";
16
+ id: string;
17
+ time?: {
18
+ created?: number | undefined;
19
+ streamed?: number | undefined;
20
+ completed?: number | undefined;
21
+ } | undefined;
22
+ model?: ModelRefLike | undefined;
23
+ tokens?: {
24
+ input?: number | undefined;
25
+ output?: number | undefined;
26
+ reasoning?: number | undefined;
27
+ cache?: {
28
+ read?: number | undefined;
29
+ write?: number | undefined;
30
+ } | undefined;
31
+ } | undefined;
32
+ cost?: number | undefined;
33
+ finish?: string | null | undefined;
34
+ rawFinish?: string | null | undefined;
35
+ };
36
+ export type PromptMessage = {
37
+ type: "user" | "synthetic" | "system";
38
+ id?: string | undefined;
39
+ time?: {
40
+ created?: number | undefined;
41
+ } | undefined;
42
+ };
43
+ export type TranscriptMessage = AssistantMessage | PromptMessage | {
44
+ type: string;
45
+ id?: string | undefined;
46
+ time?: {
47
+ created?: number | undefined;
48
+ } | undefined;
49
+ };
50
+ /** Accumulated token usage for a group of assistant steps. */
51
+ export type Totals = {
52
+ input: number;
53
+ cached: number;
54
+ output: number;
55
+ reasoning: number;
56
+ cacheWrite: number;
57
+ cost: number;
58
+ steps: number;
59
+ };
60
+ export type CacheDrop = {
61
+ messageID: string;
62
+ /** Tokens that were expected to hit the prompt cache but did not. */
63
+ tokens: number;
64
+ /** `cache.read` of the step that was used as the reuse reference. */
65
+ prevCached: number;
66
+ /** Time between the reference step's completion and this step's request. */
67
+ gapMs: number | undefined;
68
+ };
69
+ export type TurnStats = {
70
+ /** Wall clock time of the last step's completion. */
71
+ endAt: number | undefined;
72
+ /** Creation time of the first settled step of the turn. */
73
+ startAt: number | undefined;
74
+ /** Creation time of the prompt that opened this turn. */
75
+ promptAt: number | undefined;
76
+ /** Time between the previous prompt and this turn's prompt. */
77
+ promptGapMs: number | undefined;
78
+ providerID: string | undefined;
79
+ modelID: string | undefined;
80
+ variant: string | undefined;
81
+ /** Provider ID differs from the previous turn in the same transcript. */
82
+ providerChanged: boolean;
83
+ /** Provider is the same but the model or variant differs. */
84
+ modelChanged: boolean;
85
+ totals: Totals;
86
+ /** Passed through from the caller (session-wide accumulated usage). */
87
+ session: Totals | undefined;
88
+ /**
89
+ * Prompt-side usage of the last finished step: the context the turn ended
90
+ * with. Summing `input`/`cached` over the steps of a turn counts the same
91
+ * re-sent prompt once per step, which is not what a context view means.
92
+ */
93
+ latest: Totals;
94
+ drops: CacheDrop[];
95
+ cacheDropTokens: number;
96
+ cacheExpired: boolean;
97
+ /** Assistant steps in the turn. */
98
+ steps: number;
99
+ };
100
+ export type TurnSegment = {
101
+ assistants: AssistantMessage[];
102
+ prompt: TranscriptMessage | undefined;
103
+ };
104
+ /** Message types that open a turn: a run of assistants belongs to the last one. */
105
+ export declare const PROMPT_BOUNDARIES: ReadonlySet<string>;
106
+ /**
107
+ * Split a transcript into turns. A turn is a run of assistant steps opened by
108
+ * a prompt boundary (`user`, `synthetic`, `system`). Idle notices, info
109
+ * messages, compactions and shell entries keep the current turn open so a
110
+ * single execution is never split in two.
111
+ */
112
+ export declare function transcriptSegments(messages: readonly TranscriptMessage[]): TurnSegment[];
113
+ /**
114
+ * The last `count` segments, found by walking the transcript backwards. The
115
+ * live line only needs the turn that just ended and the one before it, and a
116
+ * long session holds tens of thousands of messages: the tail walk stops as
117
+ * soon as it has them, instead of building a segment for every turn.
118
+ */
119
+ export declare function transcriptTail(messages: readonly TranscriptMessage[], count: number): TurnSegment[];
120
+ export declare function tokenCounts(message: AssistantMessage): {
121
+ input: number;
122
+ output: number;
123
+ reasoning: number;
124
+ read: number;
125
+ write: number;
126
+ };
127
+ export declare function totalize(steps: readonly AssistantMessage[]): Totals;
128
+ /**
129
+ * The identity of a model reference: the provider it runs through, the model
130
+ * and its variant. Two steps with the same key ran the same thing the same way,
131
+ * which is what `modelChanged` compares.
132
+ */
133
+ export declare function identityKey(model: ModelRefLike | undefined): string | undefined;
134
+ export declare function cacheRead(message: AssistantMessage | undefined): number;
135
+ export declare function stepCreated(message: AssistantMessage | undefined): number | undefined;
136
+ export declare function stepEnd(message: AssistantMessage | undefined): number | undefined;
137
+ /**
138
+ * Which identity the two steps must share for a reuse loss to be measurable.
139
+ * `provider` is the strict one: two providers are two caches, so a switch
140
+ * between them is not a loss but a different cache. `model` drops that test,
141
+ * for the reports that measure what an identity change costs: forfeiting the
142
+ * previous provider's cache is exactly the cost they report.
143
+ */
144
+ export type CacheDropScope = "provider" | "model";
145
+ /**
146
+ * Cache reuse loss against a reference step, mirroring OpenCode's own
147
+ * `turn-usage` heuristic: only comparable when the model is identical, and
148
+ * OpenAI's API reports small reads that are not worth flagging.
149
+ */
150
+ export declare function cacheDrop(reference: AssistantMessage | undefined, current: AssistantMessage, scope?: CacheDropScope): number | undefined;
151
+ /** Milliseconds between a reference step's completion and the next request. */
152
+ export declare function stepGap(reference: AssistantMessage | undefined, current: AssistantMessage): number | undefined;
153
+ /**
154
+ * Streaming duration of a step: request start to the last streamed chunk. The
155
+ * host's `streamed` stamp marks the end of streaming, so this is the window a
156
+ * generation rate belongs to โ€” not a time to first token, which the session
157
+ * messages do not record.
158
+ */
159
+ export declare function stepStreamMs(message: AssistantMessage): number | undefined;
160
+ /**
161
+ * Tokens produced per second over a window. For a step the window is the
162
+ * streaming one: request start to the last streamed chunk (`created` โ†’
163
+ * `streamed`) โ€” `completed` marks the end of the step, which for a tool-call
164
+ * step happens after the tool calls were handled. Returns undefined when the
165
+ * window or the produced tokens are unknown. Single definition, shared by the
166
+ * recorder (which stores it per step) and the latency view.
167
+ */
168
+ export declare function tokensPerSecond(produced: number, from: number | undefined, to: number | undefined): number | undefined;
169
+ /** Output-plus-reasoning tokens per second of a step. */
170
+ export declare function stepTps(message: AssistantMessage): number | undefined;
171
+ /**
172
+ * True when a step has final numbers. A step that is still streaming reports
173
+ * partial tokens (usually zero) and no cache read yet, so reading it would
174
+ * render an empty line and invent a cache-expiry warning against it.
175
+ */
176
+ export declare function isSettledStep(message: AssistantMessage): boolean;
177
+ /**
178
+ * Compute the stats of the most recent turn in a transcript, including the
179
+ * comparison against the previous turn (a provider or model switch, and cache
180
+ * reuse).
181
+ *
182
+ * Returns undefined while the newest turn is still being generated: the line
183
+ * appears when a turn ends, never with the zeroes of an in-flight response.
184
+ */
185
+ export declare function computeTurn(messages: readonly TranscriptMessage[], session?: Totals): TurnStats | undefined;
186
+ /** True when a cache drop is small enough to be provider noise. */
187
+ export declare function isNoiseDrop(providerID: string | undefined, drop: number): boolean;
188
+ export type LineTone = "base" | "muted" | "warning";
189
+ export type LineSegment = {
190
+ text: string;
191
+ tone: LineTone;
192
+ };
193
+ export type LineLabels = {
194
+ delta: string;
195
+ turn: string;
196
+ session: string;
197
+ step: string;
198
+ steps: string;
199
+ input: string;
200
+ cached: string;
201
+ output: string;
202
+ reasoning: string;
203
+ cost: string;
204
+ cacheExpired: string;
205
+ providerChanged: string;
206
+ modelChanged: string;
207
+ };
208
+ /**
209
+ * How the turn segment counts prompt usage. `latest` shows the prompt size the
210
+ * turn ended with โ€” the context view, and what OpenCode's own turn-token debug
211
+ * line reports. `sum` adds every step of the turn: the billing view, where a
212
+ * tool-call loop re-sends (and re-reads) the same context once per step.
213
+ */
214
+ export type TurnTotalsView = "latest" | "sum";
215
+ export type LineFields = {
216
+ clock: boolean;
217
+ delta: boolean;
218
+ model: boolean;
219
+ variant: boolean;
220
+ turn: boolean;
221
+ session: boolean;
222
+ reasoning: boolean;
223
+ cost: boolean;
224
+ cacheExpiry: boolean;
225
+ };
226
+ export declare const DEFAULT_LABELS: LineLabels;
227
+ export declare const DEFAULT_FIELDS: LineFields;
228
+ export declare function formatClock(ms: number): string;
229
+ export declare function formatDuration(ms: number): string;
230
+ export declare function formatTokens(value: number): string;
231
+ export declare function formatCost(value: number): string;
232
+ /**
233
+ * The buckets the line displays, derived from a totals group. `input` is the
234
+ * *uncached* part of the prompt (fresh input plus cache writes, i.e. what the
235
+ * debug line's `new` counts on the prompt side), `prompt` is the whole prompt
236
+ * of the request and `output` is everything the model generated (visible output
237
+ * + reasoning). OpenCode's own debug line reconciles with these:
238
+ * `new = in + out` and `total = prompt + out`, with `prompt = in + cached`.
239
+ * The stored buckets stay raw; only the display folds.
240
+ */
241
+ export type DisplayTotals = Totals & {
242
+ /** Whole prompt of the request: fresh input + cached reads + cache writes. */
243
+ prompt: number;
244
+ };
245
+ export declare function displayTotals(totals: Totals): DisplayTotals;
246
+ /**
247
+ * Render the telemetry line as toned segments. The caller maps tones onto
248
+ * theme colors; `formatLine` joins the segments as plain text.
249
+ */
250
+ export declare function formatLineSegments(stats: TurnStats, options?: {
251
+ labels?: Partial<LineLabels>;
252
+ fields?: Partial<LineFields>;
253
+ turnTotals?: TurnTotalsView;
254
+ }): LineSegment[];
255
+ export declare function formatLine(stats: TurnStats, options?: {
256
+ labels?: Partial<LineLabels>;
257
+ fields?: Partial<LineFields>;
258
+ turnTotals?: TurnTotalsView;
259
+ }): string;
@@ -0,0 +1,20 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+ /**
3
+ * Server-side recorder. Subscribes to the session event stream and persists
4
+ * every assistant step, prompt and lifecycle event into the stats database;
5
+ * the TUI entry reads the same database for its live line and stats panel.
6
+ *
7
+ * Everything the recorder stores comes from the event stream โ€” the provider
8
+ * and model a step ran as, the tokens and the timing. The plugin is
9
+ * observational: it never rewrites prompts, requests or responses, registers
10
+ * no hook on the request path, and a failure inside the recorder is swallowed
11
+ * so a session can never be interrupted by its own telemetry.
12
+ */
13
+ export type Options = {
14
+ /** Turn recording off: no event subscription, no database. */
15
+ enabled?: boolean | undefined;
16
+ /** SQLite database path override (defaults to the per-OS state directory). */
17
+ db?: string | undefined;
18
+ };
19
+ declare const _default: Plugin.Plugin;
20
+ export default _default;