@toninho09/opencode-cache-scope 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 toninho09
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 ADDED
@@ -0,0 +1,204 @@
1
+ # opencode-cache-scope
2
+
3
+ An [opencode](https://opencode.ai) plugin that warns you when prompt caching
4
+ breaks mid-session, so you notice a token/cost spike before it surprises you
5
+ in the bill.
6
+
7
+ It ships two independent pieces:
8
+
9
+ - **Server plugin** (`index.ts`) — listens to session events and shows a TUI
10
+ toast the moment the model stops hitting the prompt cache.
11
+ - **TUI sidebar plugin** (`tui-sidebar.tsx`) — an optional always-visible
12
+ widget showing live cache hit rate / read / write stats for the current
13
+ session.
14
+
15
+ You can use either one alone or both together.
16
+
17
+ ## Why
18
+
19
+ Providers like Anthropic cache your growing conversation context so you only
20
+ pay full price for the *new* tokens on each turn. If something invalidates
21
+ that cache (a system prompt change, a tool definition change, hitting a TTL,
22
+ provider-side issues, etc.), every subsequent turn re-sends the *entire*
23
+ context at full price — often 10-50x more expensive — without any obvious
24
+ signal in the UI. This plugin makes that moment visible.
25
+
26
+ ## Installation
27
+
28
+ ### From npm (recommended)
29
+
30
+ Add the package name to your `opencode.json` (project-level or global
31
+ `~/.config/opencode/opencode.json`). opencode installs it automatically via
32
+ Bun at startup:
33
+
34
+ ```json
35
+ {
36
+ "$schema": "https://opencode.ai/config.json",
37
+ "plugin": ["@toninho09/opencode-cache-scope"]
38
+ }
39
+ ```
40
+
41
+ Or with options (see [Configuration](#configuration) below):
42
+
43
+ ```json
44
+ {
45
+ "$schema": "https://opencode.ai/config.json",
46
+ "plugin": [["@toninho09/opencode-cache-scope", { "toast": true, "minTokens": 500 }]]
47
+ }
48
+ ```
49
+
50
+ (Optional) Enable the sidebar widget in your `tui.json`:
51
+
52
+ ```json
53
+ {
54
+ "$schema": "https://opencode.ai/tui.json",
55
+ "plugin": ["@toninho09/opencode-cache-scope/tui"]
56
+ }
57
+ ```
58
+
59
+ ### From a local clone
60
+
61
+ 1. Clone or copy this repo somewhere on disk.
62
+ 2. Install its dependencies:
63
+
64
+ ```bash
65
+ bun install
66
+ ```
67
+
68
+ 3. Reference it from your `opencode.json` by path instead of package name:
69
+
70
+ ```json
71
+ {
72
+ "$schema": "https://opencode.ai/config.json",
73
+ "plugin": ["/absolute/path/to/opencode-cache-scope"]
74
+ }
75
+ ```
76
+
77
+ 4. (Optional) Enable the sidebar widget in your `tui.json`:
78
+
79
+ ```json
80
+ {
81
+ "$schema": "https://opencode.ai/tui.json",
82
+ "plugin": ["/absolute/path/to/opencode-cache-scope/tui-sidebar.tsx"]
83
+ }
84
+ ```
85
+
86
+ The sidebar has peer dependencies (`@opentui/core`, `@opentui/keymap`,
87
+ `@opentui/solid`) that must be resolvable from the plugin's `node_modules`
88
+ — running `bun install` in this repo covers that.
89
+
90
+ ## Configuration
91
+
92
+ The server plugin accepts an options object as the second element of the
93
+ plugin tuple in `opencode.json`:
94
+
95
+ ```json
96
+ "plugin": [["/absolute/path/to/opencode-cache-scope", { "toast": true, "minTokens": 0, "dropThreshold": 0.5 }]]
97
+ ```
98
+
99
+ | Option | Type | Default | Description |
100
+ | -------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
101
+ | `toast` | `boolean` | `true` | Whether to show the toast notification when a cache miss is detected. Set to `false` to silence popups while keeping the plugin loaded (e.g. if you only care about the sidebar stats). |
102
+ | `minTokens` | `number` | `0` | Minimum number of input tokens a miss must involve before it's worth alerting. Applies to both total misses and drop-based misses (see below). Misses below this threshold are ignored entirely (no toast, no state change), so a small miss won't suppress a later, larger miss in the same streak. |
103
+ | `dropThreshold`| `number` | `0.5` | Fraction (0-1) of relative drop in hit rate, compared to the previous turn, that counts as a (partial) cache miss even when `cache.read` is still greater than zero. `0.5` means a 50%-or-greater relative drop triggers an alert (e.g. hit rate falling from 90% to 40% is a ~56% relative drop). |
104
+
105
+ If you pass no options (or omit the tuple entirely and use a plain string
106
+ path), both defaults apply: every cache miss triggers a toast.
107
+
108
+ ## How it works
109
+
110
+ The plugin tracks per-session state from `message.updated` events on
111
+ assistant messages:
112
+
113
+ 1. **Baseline detection** — it never alerts until the session has proven it
114
+ supports caching at least once (`cache.read > 0` or `cache.write > 0`).
115
+ This avoids false alarms on models/providers without cache support.
116
+ 2. **Two kinds of miss are detected**, once caching is active for the session:
117
+ - **Total miss**: `cache.read === 0`. The cache didn't get reused at all
118
+ this turn.
119
+ - **Partial miss (percentage drop)**: the turn's hit rate
120
+ (`cache.read / (cache.read + cache.write + input)`) drops by `dropThreshold` or more
121
+ *relative to the previous turn's hit rate*, even though `cache.read` is
122
+ still greater than zero. This catches cases where a chunk of the cached
123
+ context was invalidated but not the whole thing — e.g. hit rate falling
124
+ from 90% to 40% between two consecutive turns.
125
+ Both kinds of miss go through the same downstream logic (dedup, `minTokens`
126
+ filter, toast).
127
+ 3. **Debounced per streak** — only the *first* miss (of either kind) after a
128
+ normal turn triggers a toast. Consecutive misses don't spam you again. The
129
+ streak resets as soon as a turn is neither a total miss nor a relative drop
130
+ from the turn before it — note this compares strictly turn-to-turn, so a
131
+ sustained-but-stable degraded hit rate (e.g. stuck at 40% after the initial
132
+ drop) won't re-alert unless it drops further or hits zero.
133
+ 4. **Threshold filter** — if `minTokens` is set, a miss (of either kind) is
134
+ only considered "alert-worthy" once its `input` token count reaches that
135
+ threshold.
136
+ 5. **Streaming-safe** — partial/streaming message updates (no
137
+ `time.completed`) are ignored; only the final, completed message is
138
+ evaluated.
139
+ 6. **Session cleanup** — state is cleared on `session.deleted` to avoid
140
+ unbounded memory growth across long-running opencode processes.
141
+
142
+ ### Toast examples
143
+
144
+ Total miss:
145
+
146
+ ```
147
+ Cache miss
148
+ anthropic/claude-sonnet-4-6: cache broke (~3100 tokens with no cache hit)
149
+ ```
150
+
151
+ Partial miss (percentage drop):
152
+
153
+ ```
154
+ Cache miss
155
+ anthropic/claude-sonnet-4-6: cache broke (hit rate dropped from 99% to 20% (~8000 tokens))
156
+ ```
157
+
158
+ ### Sidebar widget
159
+
160
+ When `tui-sidebar.tsx` is loaded, it renders a small "Usage" block in the
161
+ session sidebar, accumulated across the session's assistant messages:
162
+
163
+ - `hit rate`
164
+ - cache `read` and cache `write` tokens (with their $ cost)
165
+ - `input` and `output` tokens (with their $ cost)
166
+ - a `total` cost line
167
+
168
+ Cost per token type is computed locally from each message's model pricing
169
+ (`$ per 1M tokens`), since providers don't return a dollar cost in their API
170
+ responses. This is an approximation: it doesn't reproduce opencode's tiered
171
+ pricing for very large contexts, so the sum of the parts may differ slightly
172
+ from the real cost in that edge case.
173
+
174
+ The widget only appears once the session has used caching at least once (same
175
+ baseline logic as the server plugin), and it's read-only/passive — it doesn't
176
+ have its own configuration options.
177
+
178
+ ![Sidebar widget showing hit rate, cache read/write, input/output tokens and total cost](cost.png)
179
+
180
+ ## Development
181
+
182
+ ```bash
183
+ bun install
184
+ bun test # runs index.test.ts
185
+ bunx tsc --noEmit # type-check
186
+ ```
187
+
188
+ `index.test.ts` covers: no-cache-support models never alert, first
189
+ cache-write doesn't alert, normal reads don't alert, a total miss after a hit
190
+ alerts once, consecutive misses don't repeat, recovery + a new miss alerts
191
+ again, user messages and streaming partials are ignored, session cleanup
192
+ resets state, independent sessions don't interfere, the `toast` / `minTokens`
193
+ / `dropThreshold` options behave as documented above, a large relative hit
194
+ rate drop alerts even without `cache.read` hitting zero, a small drop below
195
+ the threshold doesn't alert, and a stable (non-worsening) degraded hit rate
196
+ doesn't re-alert on the following turn.
197
+
198
+ ## Requirements
199
+
200
+ - opencode with plugin support (`@opencode-ai/plugin`).
201
+ - Bun (used for install/tests; the plugin itself runs under opencode's own
202
+ runtime).
203
+ - For the sidebar: `@opentui/core`, `@opentui/keymap`, `@opentui/solid`
204
+ (`>=0.3.4`) available in the plugin's dependency tree.
package/index.ts ADDED
@@ -0,0 +1,110 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+
3
+ type SessionCacheState = {
4
+ // true as soon as the session has had at least one cache.read or cache.write > 0,
5
+ // i.e. it has proven that the model/session supports caching. Until that
6
+ // happens, we never alert (covers models without cache support).
7
+ cacheEverActive: boolean
8
+ // true while we've already alerted for the current cache miss streak.
9
+ // Resets to false as soon as the hit rate returns to normal (no total miss
10
+ // nor large drop), allowing us to alert again if it breaks another time.
11
+ missing: boolean
12
+ // Hit rate (cache.read / (cache.read + cache.write + input)) of the last turn processed
13
+ // after the cache became active. Used as a reference to detect a
14
+ // percentage drop in the following turn. undefined until the first turn
15
+ // after the baseline.
16
+ lastHitRate?: number
17
+ }
18
+
19
+ export type CacheMissAlertOptions = {
20
+ // Show the alert toast. Default: true.
21
+ toast?: boolean
22
+ // Only alert if the miss involves at least this many input tokens.
23
+ // Default: 0 (alert on any miss).
24
+ minTokens?: number
25
+ // Fraction (0-1) of the hit rate drop, compared to the previous turn,
26
+ // that already counts as a miss even with cache.read > 0 (partial miss).
27
+ // Default: 0.5 (a drop of 50% or more). E.g.: hit rate dropping from 90%
28
+ // to 40% is a ~56% drop -> triggers with the default.
29
+ dropThreshold?: number
30
+ }
31
+
32
+ export const CacheMissAlertPlugin: Plugin = async ({ client }, options) => {
33
+ const opts = options as CacheMissAlertOptions | undefined
34
+ const toastEnabled = opts?.toast ?? true
35
+ const minTokens = opts?.minTokens ?? 0
36
+ const dropThreshold = opts?.dropThreshold ?? 0.5
37
+
38
+ const sessions = new Map<string, SessionCacheState>()
39
+
40
+ return {
41
+ event: async ({ event }) => {
42
+ if (event.type === "session.deleted") {
43
+ sessions.delete(event.properties.info.id)
44
+ return
45
+ }
46
+
47
+ if (event.type !== "message.updated") return
48
+
49
+ const info = event.properties.info
50
+ if (info.role !== "assistant") return
51
+ // Ignore partial streaming updates; only process the final message.
52
+ if (!info.time?.completed || !info.tokens) return
53
+
54
+ const cacheRead = info.tokens.cache?.read ?? 0
55
+ const cacheWrite = info.tokens.cache?.write ?? 0
56
+ const input = info.tokens.input ?? 0
57
+
58
+ let state = sessions.get(info.sessionID)
59
+ if (!state) {
60
+ state = { cacheEverActive: false, missing: false }
61
+ sessions.set(info.sessionID, state)
62
+ }
63
+
64
+ if (!state.cacheEverActive) {
65
+ // No baseline yet: if the cache shows up now, just record that it
66
+ // exists for this session. Never alert at this point.
67
+ if (cacheRead > 0 || cacheWrite > 0) state.cacheEverActive = true
68
+ return
69
+ }
70
+
71
+ const denom = cacheRead + cacheWrite + input
72
+ const hitRate = denom > 0 ? cacheRead / denom : 0
73
+
74
+ const totalMiss = cacheRead === 0
75
+ const previousHitRate = state.lastHitRate
76
+ const dropMiss =
77
+ previousHitRate !== undefined &&
78
+ previousHitRate > 0 &&
79
+ (previousHitRate - hitRate) / previousHitRate >= dropThreshold
80
+
81
+ state.lastHitRate = hitRate
82
+
83
+ if (!totalMiss && !dropMiss) {
84
+ // Cache reading normally, no relevant drop compared to the previous turn.
85
+ state.missing = false
86
+ return
87
+ }
88
+
89
+ if (input < minTokens) return // miss too small to bother about
90
+ if (state.missing) return // already alerted for this streak
91
+ state.missing = true
92
+
93
+ if (!toastEnabled) return
94
+
95
+ const detail = totalMiss
96
+ ? `~${input} tokens with no cache hit`
97
+ : `hit rate dropped from ${Math.round(previousHitRate! * 100)}% to ${Math.round(hitRate * 100)}% (~${input} tokens)`
98
+
99
+ await client.tui.showToast({
100
+ body: {
101
+ title: "Cache miss",
102
+ message: `${info.providerID}/${info.modelID}: cache broke (${detail})`,
103
+ variant: "warning",
104
+ },
105
+ })
106
+ },
107
+ }
108
+ }
109
+
110
+ export default CacheMissAlertPlugin
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@toninho09/opencode-cache-scope",
3
+ "version": "0.1.0",
4
+ "description": "opencode plugin that alerts you when prompt caching breaks mid-session",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "main": "index.ts",
10
+ "exports": {
11
+ ".": "./index.ts",
12
+ "./tui": "./tui-sidebar.tsx"
13
+ },
14
+ "files": [
15
+ "index.ts",
16
+ "tui-sidebar.tsx",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "opencode",
22
+ "opencode-plugin",
23
+ "plugin",
24
+ "cache",
25
+ "prompt-caching",
26
+ "toast",
27
+ "alert"
28
+ ],
29
+ "author": "toninho09",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/toninho09/opencode-cache-scope.git"
34
+ },
35
+ "homepage": "https://github.com/toninho09/opencode-cache-scope#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/toninho09/opencode-cache-scope/issues"
38
+ },
39
+ "dependencies": {
40
+ "@opencode-ai/plugin": "latest"
41
+ },
42
+ "peerDependencies": {
43
+ "@opentui/core": ">=0.3.4",
44
+ "@opentui/keymap": ">=0.3.4",
45
+ "@opentui/solid": ">=0.3.4"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@opentui/core": { "optional": true },
49
+ "@opentui/keymap": { "optional": true },
50
+ "@opentui/solid": { "optional": true }
51
+ },
52
+ "devDependencies": {
53
+ "typescript": "^5.9.3",
54
+ "@types/node": "^25.2.2",
55
+ "@types/bun": "latest",
56
+ "@opentui/core": "0.3.4",
57
+ "@opentui/keymap": "0.3.4",
58
+ "@opentui/solid": "0.3.4",
59
+ "solid-js": "1.9.12"
60
+ },
61
+ "scripts": {
62
+ "test": "bun test"
63
+ }
64
+ }
@@ -0,0 +1,121 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
+ import { createMemo, Show } from "solid-js"
4
+
5
+ function fmt(n: number): string {
6
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "m"
7
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "k"
8
+ return String(n)
9
+ }
10
+
11
+ function fmtCost(n: number): string {
12
+ return "$" + n.toFixed(4)
13
+ }
14
+
15
+ function View(props: { api: TuiPluginApi; session_id: string }) {
16
+ const theme = () => props.api.theme.current
17
+
18
+ // Sums tokens (by type) and cost (by type) of all assistant messages in the
19
+ // session, and keeps track of whether the last message had cache.read > 0
20
+ // (cache ok) or 0 (miss).
21
+ //
22
+ // Per-type cost is computed locally from each message's model pricing
23
+ // (price per 1M tokens), since providers don't return a dollar cost in
24
+ // their API responses. This is an approximation: it doesn't reproduce the
25
+ // tiered pricing opencode applies for very large contexts
26
+ // (`model.cost.experimentalOver200K`), so the sum of these parts may
27
+ // differ slightly from the message's own `cost` field in that edge case.
28
+ const stats = createMemo(() => {
29
+ let input = 0
30
+ let output = 0
31
+ let cacheRead = 0
32
+ let cacheWrite = 0
33
+ let costInput = 0
34
+ let costOutput = 0
35
+ let costCacheRead = 0
36
+ let costCacheWrite = 0
37
+ let lastCacheRead: number | undefined
38
+
39
+ for (const m of props.api.state.session.messages(props.session_id)) {
40
+ if (m.role !== "assistant" || !m.tokens) continue
41
+ input += m.tokens.input
42
+ output += m.tokens.output
43
+ cacheRead += m.tokens.cache.read
44
+ cacheWrite += m.tokens.cache.write
45
+ lastCacheRead = m.tokens.cache.read
46
+
47
+ const cost = props.api.state.provider.find((p) => p.id === m.providerID)?.models[m.modelID]?.cost
48
+ if (cost) {
49
+ costInput += (m.tokens.input * cost.input) / 1_000_000
50
+ costOutput += (m.tokens.output * cost.output) / 1_000_000
51
+ costCacheRead += (m.tokens.cache.read * cost.cache.read) / 1_000_000
52
+ costCacheWrite += (m.tokens.cache.write * cost.cache.write) / 1_000_000
53
+ }
54
+ }
55
+
56
+ const active = cacheRead > 0 || cacheWrite > 0
57
+ const miss = active && lastCacheRead === 0
58
+ const denom = cacheRead + cacheWrite + input
59
+ const hitRate = denom > 0 ? Math.round((cacheRead / denom) * 100) : 0
60
+ const totalCost = costInput + costOutput + costCacheRead + costCacheWrite
61
+
62
+ return {
63
+ input,
64
+ output,
65
+ cacheRead,
66
+ cacheWrite,
67
+ active,
68
+ miss,
69
+ hitRate,
70
+ costInput,
71
+ costOutput,
72
+ costCacheRead,
73
+ costCacheWrite,
74
+ totalCost,
75
+ }
76
+ })
77
+
78
+ return (
79
+ <Show when={stats().active}>
80
+ <box>
81
+ <box flexDirection="row" gap={1}>
82
+ <text fg={theme().text}>
83
+ <b>Usage</b>
84
+ </text>
85
+ </box>
86
+ <text fg={theme().textMuted}>hit rate: {stats().hitRate}%</text>
87
+ <text fg={theme().textMuted}>
88
+ cache read: {fmt(stats().cacheRead)} ({fmtCost(stats().costCacheRead)})
89
+ </text>
90
+ <text fg={theme().textMuted}>
91
+ cache write: {fmt(stats().cacheWrite)} ({fmtCost(stats().costCacheWrite)})
92
+ </text>
93
+ <text fg={theme().textMuted}>
94
+ input: {fmt(stats().input)} ({fmtCost(stats().costInput)})
95
+ </text>
96
+ <text fg={theme().textMuted}>
97
+ output: {fmt(stats().output)} ({fmtCost(stats().costOutput)})
98
+ </text>
99
+ <text fg={theme().textMuted}>total: {fmtCost(stats().totalCost)}</text>
100
+ </box>
101
+ </Show>
102
+ )
103
+ }
104
+
105
+ const tui: TuiPlugin = async (api) => {
106
+ api.slots.register({
107
+ order: 450,
108
+ slots: {
109
+ sidebar_content(_ctx, props) {
110
+ return <View api={api} session_id={props.session_id} />
111
+ },
112
+ },
113
+ })
114
+ }
115
+
116
+ const plugin: TuiPluginModule & { id: string } = {
117
+ id: "opencode-cache-scope.sidebar",
118
+ tui,
119
+ }
120
+
121
+ export default plugin