opencode-usage-report 0.3.0 → 0.4.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/README.md +204 -69
- package/package.json +1 -2
- package/src/context/collect.ts +210 -0
- package/src/context/dialog.tsx +184 -0
- package/src/context/estimate.ts +11 -0
- package/src/context/format.ts +240 -0
- package/src/context/grid.ts +76 -0
- package/src/context/headroom.ts +39 -0
- package/src/context/system.ts +115 -0
- package/src/context/types.ts +50 -0
- package/src/fallback.ts +1 -6
- package/src/index.ts +27 -9
- package/src/normalize.ts +17 -27
- package/src/providers/chatgpt.ts +24 -78
- package/src/providers/copilot.ts +28 -86
- package/src/providers/http.ts +90 -0
- package/src/providers/kimi.ts +47 -59
- package/src/providers/opencode-go.ts +33 -89
- package/src/quota-lines.ts +99 -0
- package/src/render.ts +11 -14
- package/src/tui-format.ts +12 -0
- package/src/tui.tsx +93 -116
package/README.md
CHANGED
|
@@ -4,64 +4,85 @@
|
|
|
4
4
|
[](https://github.com/kaanchinar/opencode-usage-report/actions/workflows/ci.yml)
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
|
|
7
|
-
An [opencode](https://opencode.ai) plugin that
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
**
|
|
12
|
-
**
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
An [opencode](https://opencode.ai) plugin that shows how much of your inference
|
|
8
|
+
subscriptions you have left. It adds a `/usage` command and a `usage_report`
|
|
9
|
+
tool covering both the **context window** of the current session and the
|
|
10
|
+
**quota windows** (5-hour, weekly, monthly) of your provider plans —
|
|
11
|
+
**Kimi Code** (`kimi-code-plan-global` on kimi.ai and `kimi-code-plan-cn` on
|
|
12
|
+
kimi.com), **OpenCode Go** (`opencode-go`), **GitHub Copilot**
|
|
13
|
+
(`github-copilot`) and **ChatGPT** (`openai`). Results are cached on disk, fall
|
|
14
|
+
back to a local estimate when an API is unreachable, and raise background
|
|
15
|
+
low-quota toasts in the TUI.
|
|
15
16
|
|
|
16
17
|
## Features
|
|
17
18
|
|
|
18
19
|
- `/usage` command and `usage_report` tool for on-demand quota reports, with
|
|
19
|
-
JSON and single-provider filtering.
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
20
|
+
JSON output and single-provider filtering.
|
|
21
|
+
- **Context usage panel** (new in 0.4.0) — the exact prompt-token total against
|
|
22
|
+
the model's context window, a cell grid that fills by category, the
|
|
23
|
+
auto-compaction point with live headroom, and session cost.
|
|
24
|
+
- TUI sidebar panel with live progress bars, `NN%` usage and reset countdowns
|
|
25
|
+
for every quota window.
|
|
26
|
+
- On-disk caching with a configurable TTL, plus an explicit refresh that
|
|
27
|
+
bypasses it.
|
|
28
|
+
- Local fallback estimate when a provider API is unreachable and no cache
|
|
24
29
|
exists.
|
|
25
|
-
-
|
|
30
|
+
- Low-quota warnings on startup and on `session.idle`.
|
|
26
31
|
- Privacy-first: API keys are never logged, cached, or rendered.
|
|
27
32
|
|
|
28
33
|
## Install
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
The plugin exposes **two entrypoints** and opencode loads them from two
|
|
36
|
+
different config files. Install both for the full experience:
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
| Config | Entrypoint | Provides |
|
|
39
|
+
| --------------- | ------------- | ------------------------------------------------------- |
|
|
40
|
+
| `opencode.json` | server plugin | `usage_report` tool, low-quota warnings, prompt capture |
|
|
41
|
+
| `tui.json` | TUI plugin | `/usage` command, sidebar panel |
|
|
42
|
+
|
|
43
|
+
The quickest path is opencode's own installer, which detects both entrypoints
|
|
44
|
+
and writes both configs for you:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
opencode plugin opencode-usage-report # project scope (./.opencode)
|
|
48
|
+
opencode plugin -g opencode-usage-report # global scope
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or add the plugin manually to each file and restart opencode:
|
|
33
52
|
|
|
34
53
|
```jsonc
|
|
54
|
+
// opencode.json — server plugin
|
|
35
55
|
{
|
|
36
56
|
"plugin": ["opencode-usage-report"],
|
|
37
57
|
}
|
|
38
58
|
```
|
|
39
59
|
|
|
40
|
-
Credentials are read from the same places your other opencode providers already
|
|
41
|
-
use (see [Data sources & privacy](#data-sources--privacy)). Optionally pass
|
|
42
|
-
options in the tuple form:
|
|
43
|
-
|
|
44
60
|
```jsonc
|
|
61
|
+
// tui.json — TUI plugin
|
|
45
62
|
{
|
|
46
|
-
"plugin": [
|
|
63
|
+
"plugin": ["opencode-usage-report"],
|
|
47
64
|
}
|
|
48
65
|
```
|
|
49
66
|
|
|
50
|
-
|
|
67
|
+
> **Use the bare package name in both files.** opencode reads the package
|
|
68
|
+
> `exports` map itself and resolves `exports["./tui"]` for the TUI and
|
|
69
|
+
> `exports["./server"]` (or `main`) for the server. A subpath spec such as
|
|
70
|
+
> `opencode-usage-report/tui` is **not** valid and will silently load nothing.
|
|
71
|
+
> `opencode plugin opencode-usage-report` prints `Detected server + tui targets`
|
|
72
|
+
> when the resolution works.
|
|
51
73
|
|
|
52
|
-
|
|
74
|
+
Options can be passed in the tuple form, independently per entrypoint:
|
|
53
75
|
|
|
54
76
|
```jsonc
|
|
55
77
|
{
|
|
56
|
-
"plugin": ["opencode-usage-report"],
|
|
78
|
+
"plugin": [["opencode-usage-report", { "thresholdPercent": 75 }]],
|
|
57
79
|
}
|
|
58
80
|
```
|
|
59
81
|
|
|
60
|
-
|
|
61
|
-
package `exports` on its own. (`opencode-usage-report/tui` is **not** a valid
|
|
62
|
-
spec and will silently not load.)
|
|
82
|
+
### Local development
|
|
63
83
|
|
|
64
|
-
|
|
84
|
+
Point the configs at the source instead of the published package. Config is read
|
|
85
|
+
once at startup and is not hot-reloaded, so restart opencode after every edit:
|
|
65
86
|
|
|
66
87
|
```jsonc
|
|
67
88
|
// opencode.json
|
|
@@ -70,34 +91,80 @@ For local development, point the configs at the source instead:
|
|
|
70
91
|
{ "plugin": ["file:///abs/path/to/opencode-usage-report/src/tui.tsx"] }
|
|
71
92
|
```
|
|
72
93
|
|
|
73
|
-
The panel renders under opencode's native Context block in the session sidebar
|
|
74
|
-
(order 150) and shows a colored progress bar per quota window, `NN%`, and a
|
|
75
|
-
live reset countdown — refreshed every 60s, on `session.idle`, and on demand via
|
|
76
|
-
the `Usage: refresh now` command (default binding `ctrl+shift+u`). It reuses the
|
|
77
|
-
same cache/fallback pipeline as the command; stale results are marked `(stale)`
|
|
78
|
-
and local estimates `(est)`.
|
|
79
|
-
|
|
80
|
-
TUI options (tuple form): `providers`, `cacheTtlSeconds`, `thresholdPercent`,
|
|
81
|
-
`refreshIntervalSeconds` (default `60`), `barWidth` (default `14`).
|
|
82
|
-
|
|
83
94
|
## Commands
|
|
84
95
|
|
|
85
|
-
- `/usage` —
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
provider
|
|
93
|
-
-
|
|
94
|
-
|
|
96
|
+
- `/usage` — open the report dialog: the context panel for the current session
|
|
97
|
+
on top, and every configured provider's quota windows below. This is a local
|
|
98
|
+
TUI command, so it costs no model tokens. Requires the TUI plugin. Providers
|
|
99
|
+
whose credential cannot be resolved are skipped in this view.
|
|
100
|
+
- `/usage` dialog keys — `esc` close, `r` refresh now, `tab` cycle provider
|
|
101
|
+
scope (all → each provider), `j` toggle the raw JSON view.
|
|
102
|
+
- `usage_report` tool — the same report for agents and headless runs. Takes
|
|
103
|
+
`provider` (exact id: `kimi-code-plan-global`, `kimi-code-plan-cn`,
|
|
104
|
+
`opencode-go`, `github-copilot` or `openai`; an unknown id returns an error
|
|
105
|
+
listing the known ids), `json` (raw `ProviderReport[]`) and `refresh` (bypass
|
|
106
|
+
the on-disk cache).
|
|
107
|
+
|
|
108
|
+
> **Changed in 0.4.0.** `/usage` used to be an LLM-mediated prompt template that
|
|
109
|
+
> routed through the model, and it accepted `--json` / `--refresh` / a provider
|
|
110
|
+
> id as slash arguments. The TUI command now owns the name, so `/usage` reaches
|
|
111
|
+
> no model and those capabilities moved to the dialog keys and the
|
|
112
|
+
> `usage_report` tool arguments. Running `/usage --json` is no longer supported.
|
|
113
|
+
|
|
114
|
+
## Context usage panel
|
|
115
|
+
|
|
116
|
+
The top of the `/usage` dialog reports how full the model's context window is
|
|
117
|
+
for the current session:
|
|
95
118
|
|
|
96
|
-
|
|
97
|
-
|
|
119
|
+
```
|
|
120
|
+
Context
|
|
121
|
+
Kimi K2 (High) · 42,318 / 200,000 tokens (21.2%)
|
|
122
|
+
|
|
123
|
+
████████████████████████████ ████████████████████████████████ ████████████ ░░░░░░░░░░░░░░
|
|
124
|
+
████████████████████████████ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░
|
|
125
|
+
░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░
|
|
126
|
+
░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░
|
|
127
|
+
░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░
|
|
128
|
+
░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░
|
|
129
|
+
|
|
130
|
+
● User messages 8,204 4.1% ● System & tools 8,144 4.1%
|
|
131
|
+
● Agent responses 11,650 5.8% ● Free space 157,682 78.8%
|
|
132
|
+
● Reasoning 2,140 1.1%
|
|
133
|
+
● Tool calls 12,180 6.1%
|
|
134
|
+
|
|
135
|
+
Auto-compacts at 180,000 · 137,682 headroom $0.42 spent
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**What is exact.** The headline total, the percentage, free space, the
|
|
139
|
+
compaction point, the cost and the number of pruned tool outputs all come from
|
|
140
|
+
numbers the provider and opencode report. The total is the prompt size —
|
|
141
|
+
`input + cache.read + cache.write` — which is what actually occupies the context
|
|
142
|
+
window. opencode's own sidebar Context block sums five fields including the
|
|
143
|
+
completion, so it reads slightly higher than this panel by design.
|
|
144
|
+
|
|
145
|
+
**What is estimated.** The per-category rows. opencode records no token count
|
|
146
|
+
per message or part, so each category is estimated with the same `characters / 4`
|
|
147
|
+
heuristic opencode itself uses, then normalized so the rows agree with the exact
|
|
148
|
+
total.
|
|
149
|
+
|
|
150
|
+
**System & tools.** opencode assembles the system prompt and tool definitions on
|
|
151
|
+
every request and never persists them. The server plugin measures the real
|
|
152
|
+
system string through the `experimental.chat.system.transform` hook and stores
|
|
153
|
+
only its size, so the row can be split into `system prompt` and
|
|
154
|
+
`tools & framing`. That hook is undocumented, so it is treated as best-effort:
|
|
155
|
+
if it stops firing, the sidecar goes stale, or anything fails to validate, the
|
|
156
|
+
panel falls back to the derived residual and labels the row `(derived)`. The
|
|
157
|
+
plugin never breaks a request over it.
|
|
158
|
+
|
|
159
|
+
**Compaction headroom.** `Auto-compacts at N` mirrors opencode's own overflow
|
|
160
|
+
check, including the configured `compaction.reserved` buffer. This is the point
|
|
161
|
+
at which opencode automatically summarizes the session — a number opencode
|
|
162
|
+
exposes nowhere else.
|
|
98
163
|
|
|
99
164
|
## Options
|
|
100
165
|
|
|
166
|
+
Shared by both entrypoints:
|
|
167
|
+
|
|
101
168
|
| Option | Type | Default | Meaning |
|
|
102
169
|
| ------------------ | ------------------ | ------- | ------------------------------------------------------------ |
|
|
103
170
|
| `thresholdPercent` | `number` | `80` | Warn when a window is at/above this percent used. |
|
|
@@ -105,41 +172,77 @@ tool can also be invoked directly by an agent.
|
|
|
105
172
|
| `providers` | `string[] \| null` | `null` | Providers to report; `null` = every registered adapter. |
|
|
106
173
|
| `fallback` | `boolean` | `true` | Use a local estimate when the API fails and no cache exists. |
|
|
107
174
|
|
|
108
|
-
|
|
175
|
+
TUI only:
|
|
176
|
+
|
|
177
|
+
| Option | Type | Default | Meaning |
|
|
178
|
+
| ------------------------ | -------- | ------- | -------------------------------------------------- |
|
|
179
|
+
| `refreshIntervalSeconds` | `number` | `60` | Sidebar/dialog refresh cadence, clamped to 5–3600. |
|
|
180
|
+
| `barWidth` | `number` | `14` | Progress-bar width in cells, clamped to 4–40. |
|
|
181
|
+
|
|
182
|
+
`fallback` is fixed at `true` for the TUI entrypoint. Malformed option values
|
|
183
|
+
are ignored and the defaults are kept.
|
|
109
184
|
|
|
110
185
|
## Warnings
|
|
111
186
|
|
|
112
|
-
On startup and again on `session.idle` (both throttled to at most once every
|
|
113
|
-
minutes, sharing
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
187
|
+
On startup and again on `session.idle` (both throttled to at most once every
|
|
188
|
+
10 minutes, sharing one timer), the plugin checks the cached reports. Any window
|
|
189
|
+
at or above `thresholdPercent`, or whose status is `rate-limited` / `frozen`,
|
|
190
|
+
produces a TUI toast. A warning fires once per window until that window resets;
|
|
191
|
+
it re-arms after usage drops below `thresholdPercent - 10`. Toast failures
|
|
192
|
+
(headless/server mode) are swallowed.
|
|
118
193
|
|
|
119
194
|
## Data sources & privacy
|
|
120
195
|
|
|
121
|
-
- Credential resolution order
|
|
196
|
+
- **Credential resolution order.** First `OPENCODE_USAGE_<ID>_KEY` (optionally
|
|
122
197
|
paired with `OPENCODE_USAGE_<ID>_ACCOUNT_ID`, e.g.
|
|
123
198
|
`OPENCODE_USAGE_OPENAI_ACCOUNT_ID`, to supply the ChatGPT account id), then
|
|
124
|
-
`~/.local/share/opencode/auth.json
|
|
125
|
-
`.access
|
|
126
|
-
|
|
127
|
-
overrides the data
|
|
128
|
-
|
|
199
|
+
`~/.local/share/opencode/auth.json`: a `type: "api"` entry's `.key`, or a
|
|
200
|
+
`type: "oauth"` entry's `.access` (falling back to `.refresh` for GitHub
|
|
201
|
+
Copilot, which stores its token there). For ChatGPT the oauth entry's
|
|
202
|
+
`.accountId` is read automatically. `$OPENCODE_DATA_HOME` overrides the data
|
|
203
|
+
directory.
|
|
204
|
+
- **APIs.** `https://api.kimi.ai/coding/v1/usages` (global plan),
|
|
129
205
|
`https://api.kimi.com/coding/v1/usages` (China plan),
|
|
130
|
-
`https://opencode.ai/zen/go/v1/usage` (custom `User-Agent`
|
|
131
|
-
|
|
206
|
+
`https://opencode.ai/zen/go/v1/usage` (requires a custom `User-Agent`),
|
|
207
|
+
`https://api.github.com/copilot_internal/user` (GitHub Copilot) and
|
|
132
208
|
`https://chatgpt.com/backend-api/wham/usage` (ChatGPT).
|
|
133
|
-
- Auth
|
|
209
|
+
- **Auth.** Run `opencode auth login` and pick **GitHub Copilot** or **OpenAI
|
|
134
210
|
ChatGPT**. ChatGPT additionally needs the account id that login writes to
|
|
135
211
|
`auth.json`; the plugin reads it automatically.
|
|
136
|
-
- Cache and state live in `<data-home>/usage-report
|
|
137
|
-
warn state
|
|
138
|
-
|
|
212
|
+
- **Cache and state** live in `<data-home>/usage-report/`: the TTL cache, the
|
|
213
|
+
session id, the warn state, and `context/<sessionID>.json` — the system-prompt
|
|
214
|
+
size sidecar, which holds counts only and never prompt text.
|
|
215
|
+
- **Local fallback** reads `opencode.db` read-only.
|
|
139
216
|
- **API keys are never logged, cached, or rendered.** Adapter errors are
|
|
140
217
|
sanitized (key substrings replaced with `<redacted>`) before they reach any
|
|
141
218
|
output, and the live smoke script never prints credentials.
|
|
142
219
|
|
|
220
|
+
## Troubleshooting
|
|
221
|
+
|
|
222
|
+
**`/usage` does not appear in the command list.**
|
|
223
|
+
Both configs are required — the command comes from the TUI plugin, not the
|
|
224
|
+
server plugin. Check that `tui.json` lists `"opencode-usage-report"` (bare name)
|
|
225
|
+
and restart opencode; config is not hot-reloaded. To confirm the package exposes
|
|
226
|
+
what opencode expects, run `opencode plugin opencode-usage-report` — it should
|
|
227
|
+
print `Detected server + tui targets`.
|
|
228
|
+
|
|
229
|
+
**The sidebar is empty and no providers are reported.**
|
|
230
|
+
Every window belongs to a provider whose credential could not be resolved, so it
|
|
231
|
+
is skipped. Run `opencode auth login`, or set
|
|
232
|
+
`OPENCODE_USAGE_<ID>_KEY` for the provider in question.
|
|
233
|
+
|
|
234
|
+
**An old version keeps loading after you upgrade.**
|
|
235
|
+
opencode caches installed plugin packages under
|
|
236
|
+
`~/.cache/opencode/packages/<name>@latest/` and re-resolves versions on install,
|
|
237
|
+
not on every start. Re-run `opencode plugin opencode-usage-report`, or delete
|
|
238
|
+
that cache directory, to force a refresh. This is the usual cause of "I
|
|
239
|
+
published a new version but opencode still runs the old one".
|
|
240
|
+
|
|
241
|
+
**Quota numbers look stale or marked `(stale)` / `(est)`.**
|
|
242
|
+
`(stale)` means a cached result was served past its TTL without a successful
|
|
243
|
+
refresh; `(est)` means the API was unreachable and a local estimate was used.
|
|
244
|
+
Press `r` in the dialog (or `ctrl+shift+u` in the sidebar) to force a refetch.
|
|
245
|
+
|
|
143
246
|
## Extending
|
|
144
247
|
|
|
145
248
|
New providers are trivial:
|
|
@@ -170,6 +273,38 @@ npm run check # lint + format:check + typecheck + test
|
|
|
170
273
|
npm run smoke -- --yes-live # manual live check (real keys; opt-in)
|
|
171
274
|
```
|
|
172
275
|
|
|
276
|
+
## Releasing
|
|
277
|
+
|
|
278
|
+
Releases are **staged**, not published directly. Pushing a `v*` tag triggers
|
|
279
|
+
`.github/workflows/publish.yml`, which verifies that the tag matches
|
|
280
|
+
`package.json`, runs `npm ci`, and uploads the tarball with
|
|
281
|
+
`npm stage publish --provenance --access public` using npm Trusted Publishing
|
|
282
|
+
(OIDC — no `NPM_TOKEN` secret). Nothing goes live until a maintainer approves
|
|
283
|
+
the staged release with 2FA:
|
|
284
|
+
|
|
285
|
+
```sh
|
|
286
|
+
git tag v0.4.1 && git push origin main v0.4.1
|
|
287
|
+
npm stage list # find the stage id
|
|
288
|
+
npm stage approve <stage-id> # 2FA prompt; or approve in the npm UI
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`prepublishOnly` runs lint, format check, typecheck and tests before the upload,
|
|
292
|
+
so a failing check blocks the stage.
|
|
293
|
+
|
|
294
|
+
## Changelog
|
|
295
|
+
|
|
296
|
+
- **0.4.1** — internal: shared provider HTTP plumbing (`src/providers/http.ts`)
|
|
297
|
+
replaces the copy-pasted fetch/retry/error code; context estimates simplified;
|
|
298
|
+
reset times render as `Wed, Sep 16`.
|
|
299
|
+
- **0.4.0** — context usage panel (`/usage` now shows context breakdown, grid,
|
|
300
|
+
compaction headroom and cost); `/usage` became a local TUI command that costs
|
|
301
|
+
no model tokens; sidebar rendering extracted to `src/quota-lines.ts`.
|
|
302
|
+
- **0.3.0** — GitHub Copilot and ChatGPT (OpenAI) adapters.
|
|
303
|
+
- **0.2.0** — Kimi Code split into the kimi.ai (`kimi-code-plan-global`) and
|
|
304
|
+
kimi.com (`kimi-code-plan-cn`) plans.
|
|
305
|
+
|
|
173
306
|
## License
|
|
174
307
|
|
|
175
308
|
MIT © [Kaan Chinar](https://github.com/kaanchinar)
|
|
309
|
+
|
|
310
|
+
Security policy: [SECURITY.md](./SECURITY.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-usage-report",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kimi",
|
|
@@ -49,7 +49,6 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@opencode-ai/plugin": "^1.0.0",
|
|
51
51
|
"@opentui/core": "^0.5.11",
|
|
52
|
-
"@opentui/keymap": "^0.5.11",
|
|
53
52
|
"@opentui/solid": "^0.5.11",
|
|
54
53
|
"@types/node": "^26.6.2",
|
|
55
54
|
"oxfmt": "^0.70.0",
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { AssistantMessage, Part } from "@opencode-ai/sdk/v2";
|
|
2
|
+
import type { CollectInput, ContextBreakdown, ContextRow, RowKey, SystemCapture } from "./types";
|
|
3
|
+
import { estimateJson, estimateTokens } from "./estimate";
|
|
4
|
+
import { finiteOrNull, isRecord, safeString } from "../normalize";
|
|
5
|
+
import { buildGrid } from "./grid";
|
|
6
|
+
import { computeHeadroom } from "./headroom";
|
|
7
|
+
|
|
8
|
+
const ROW_LABELS: Record<RowKey, string> = {
|
|
9
|
+
user: "User messages",
|
|
10
|
+
agent: "Agent responses",
|
|
11
|
+
reasoning: "Reasoning",
|
|
12
|
+
tools: "Tool calls",
|
|
13
|
+
system: "System & tools",
|
|
14
|
+
free: "Free space",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const MEASURED_KEYS: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
|
|
18
|
+
|
|
19
|
+
function isAssistant(value: unknown): value is AssistantMessage {
|
|
20
|
+
return isRecord(value) && value.role === "assistant";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readParts(accessor: CollectInput["part"], messageID: string): readonly Part[] {
|
|
24
|
+
if (typeof accessor !== "function") return [];
|
|
25
|
+
try {
|
|
26
|
+
const parts = accessor(messageID);
|
|
27
|
+
return Array.isArray(parts) ? parts : [];
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function lastReportingAssistant(messages: readonly unknown[]): AssistantMessage | null {
|
|
34
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
35
|
+
const message = messages[i];
|
|
36
|
+
if (!isAssistant(message)) continue;
|
|
37
|
+
const output = finiteOrNull(isRecord(message.tokens) ? message.tokens.output : undefined);
|
|
38
|
+
if (output !== null && output > 0) return message;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function summaryStartIndex(messages: readonly unknown[]): number {
|
|
44
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
45
|
+
const message = messages[i];
|
|
46
|
+
if (isAssistant(message) && message.summary === true) return i + 1;
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isUsableCapture(capture: SystemCapture | null | undefined): capture is SystemCapture {
|
|
52
|
+
if (!isRecord(capture)) return false;
|
|
53
|
+
const tokens = finiteOrNull(capture.systemTokens);
|
|
54
|
+
return capture.version === 1 && tokens !== null && tokens >= 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Scales `values` proportionally so they sum to at most `target`; each row is an
|
|
59
|
+
* estimate, so flooring may leave the five rows a few tokens short of the total.
|
|
60
|
+
*/
|
|
61
|
+
function scaleToTotal(values: readonly number[], target: number, divisor: number): number[] {
|
|
62
|
+
if (!Number.isFinite(divisor) || divisor <= 0) return values.map(() => 0);
|
|
63
|
+
const total = Math.max(0, Math.round(target));
|
|
64
|
+
return values.map((value) => (value > 0 ? Math.floor((value * total) / divisor) : 0));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeRow(key: RowKey, tokens: number | null, limit: number | null): ContextRow {
|
|
68
|
+
const percent =
|
|
69
|
+
tokens !== null && limit !== null && limit > 0
|
|
70
|
+
? Math.min(100, Math.max(0, Math.round((tokens / limit) * 100)))
|
|
71
|
+
: null;
|
|
72
|
+
return { key, label: ROW_LABELS[key], tokens, percent };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function emptyRows(limit: number | null): ContextRow[] {
|
|
76
|
+
return [...MEASURED_KEYS, "free" as RowKey].map((key) => makeRow(key, null, limit));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Builds a `ContextBreakdown` from session messages. Exact prompt totals come
|
|
81
|
+
* from the last reporting assistant message; the remaining rows are `chars/4`
|
|
82
|
+
* estimates normalized against that total. Never throws and never emits NaN.
|
|
83
|
+
*/
|
|
84
|
+
export function collectContext(input: CollectInput): ContextBreakdown {
|
|
85
|
+
const source = (isRecord(input) ? input : {}) as CollectInput;
|
|
86
|
+
const messages: readonly unknown[] = Array.isArray(source.messages) ? source.messages : [];
|
|
87
|
+
const limit = finiteOrNull(source.model?.limit?.context);
|
|
88
|
+
const modelName = typeof source.model?.name === "string" ? source.model.name : null;
|
|
89
|
+
const cost = finiteOrNull(source.sessionCost) ?? 0;
|
|
90
|
+
const systemDerived = !isUsableCapture(source.system);
|
|
91
|
+
const systemCaptured = systemDerived ? null : (source.system as SystemCapture).systemTokens;
|
|
92
|
+
const cols = typeof source.cols === "number" && Number.isFinite(source.cols) ? source.cols : null;
|
|
93
|
+
|
|
94
|
+
const last = lastReportingAssistant(messages);
|
|
95
|
+
if (last === null) {
|
|
96
|
+
const rows = emptyRows(limit);
|
|
97
|
+
return {
|
|
98
|
+
ready: false,
|
|
99
|
+
modelName,
|
|
100
|
+
total: null,
|
|
101
|
+
limit,
|
|
102
|
+
cost,
|
|
103
|
+
rows,
|
|
104
|
+
grid: buildGrid({ rows, limit, total: null, cols }),
|
|
105
|
+
headroom: computeHeadroom({
|
|
106
|
+
limit: source.model?.limit ?? null,
|
|
107
|
+
total: null,
|
|
108
|
+
compaction: source.compaction ?? null,
|
|
109
|
+
}),
|
|
110
|
+
systemDerived,
|
|
111
|
+
systemCaptured,
|
|
112
|
+
prunedToolOutputs: 0,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const tokens: Record<string, unknown> = isRecord(last.tokens) ? last.tokens : {};
|
|
117
|
+
const cache: Record<string, unknown> = isRecord(tokens.cache) ? tokens.cache : {};
|
|
118
|
+
const total = Math.max(
|
|
119
|
+
0,
|
|
120
|
+
(finiteOrNull(tokens.input) ?? 0) +
|
|
121
|
+
(finiteOrNull(cache.read) ?? 0) +
|
|
122
|
+
(finiteOrNull(cache.write) ?? 0),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
let userTokens = 0;
|
|
126
|
+
let agentTokens = 0;
|
|
127
|
+
let reasoningTokens = 0;
|
|
128
|
+
let toolsTokens = 0;
|
|
129
|
+
let prunedToolOutputs = 0;
|
|
130
|
+
|
|
131
|
+
const start = summaryStartIndex(messages);
|
|
132
|
+
for (let i = start; i < messages.length; i++) {
|
|
133
|
+
const message = messages[i];
|
|
134
|
+
if (!isRecord(message)) continue;
|
|
135
|
+
const role = message.role;
|
|
136
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
137
|
+
const messageID = typeof message.id === "string" ? message.id : "";
|
|
138
|
+
|
|
139
|
+
for (const part of readParts(source.part, messageID)) {
|
|
140
|
+
if (!isRecord(part)) continue;
|
|
141
|
+
const type = part.type;
|
|
142
|
+
|
|
143
|
+
if (type === "text") {
|
|
144
|
+
const estimated = estimateTokens(safeString(part.text));
|
|
145
|
+
if (role === "user") userTokens += estimated;
|
|
146
|
+
else agentTokens += estimated;
|
|
147
|
+
} else if (type === "reasoning") {
|
|
148
|
+
if (role === "assistant") reasoningTokens += estimateTokens(safeString(part.text));
|
|
149
|
+
} else if (type === "file") {
|
|
150
|
+
if (role === "user") {
|
|
151
|
+
userTokens += estimateTokens(safeString(part.filename) + safeString(part.mime));
|
|
152
|
+
}
|
|
153
|
+
} else if (type === "tool") {
|
|
154
|
+
const state = part.state;
|
|
155
|
+
const stateRecord: Record<string, unknown> = isRecord(state) ? state : {};
|
|
156
|
+
const timeRecord: Record<string, unknown> = isRecord(stateRecord.time)
|
|
157
|
+
? stateRecord.time
|
|
158
|
+
: {};
|
|
159
|
+
if (timeRecord.compacted !== undefined && timeRecord.compacted !== null) {
|
|
160
|
+
prunedToolOutputs += 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
toolsTokens +=
|
|
164
|
+
estimateJson(stateRecord.input) + estimateTokens(safeString(stateRecord.output));
|
|
165
|
+
}
|
|
166
|
+
// step-start, step-finish, snapshot and every other part type are skipped.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const estimatedSum = userTokens + agentTokens + reasoningTokens + toolsTokens;
|
|
171
|
+
let systemTokens = Math.max(0, total - estimatedSum);
|
|
172
|
+
|
|
173
|
+
// chars/4 is an estimate in both directions; when it overshoots the exact
|
|
174
|
+
// total, scale all five measured rows down so they sum to at most `total` (6.5).
|
|
175
|
+
if (estimatedSum > total) {
|
|
176
|
+
const scaled = scaleToTotal(
|
|
177
|
+
[userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens],
|
|
178
|
+
total,
|
|
179
|
+
estimatedSum,
|
|
180
|
+
);
|
|
181
|
+
userTokens = scaled[0];
|
|
182
|
+
agentTokens = scaled[1];
|
|
183
|
+
reasoningTokens = scaled[2];
|
|
184
|
+
toolsTokens = scaled[3];
|
|
185
|
+
systemTokens = scaled[4];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const measured = [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens];
|
|
189
|
+
const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index], limit));
|
|
190
|
+
const freeTokens = limit !== null ? Math.max(0, limit - total) : null;
|
|
191
|
+
rows.push(makeRow("free", freeTokens, limit));
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
ready: true,
|
|
195
|
+
modelName,
|
|
196
|
+
total,
|
|
197
|
+
limit,
|
|
198
|
+
cost,
|
|
199
|
+
rows,
|
|
200
|
+
grid: buildGrid({ rows, limit, total, cols }),
|
|
201
|
+
headroom: computeHeadroom({
|
|
202
|
+
limit: source.model?.limit ?? null,
|
|
203
|
+
total,
|
|
204
|
+
compaction: source.compaction ?? null,
|
|
205
|
+
}),
|
|
206
|
+
systemDerived,
|
|
207
|
+
systemCaptured,
|
|
208
|
+
prunedToolOutputs,
|
|
209
|
+
};
|
|
210
|
+
}
|