opencode-cache-coach 0.0.0-stage → 0.2.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/README.md CHANGED
@@ -1,3 +1,148 @@
1
- # Temporary Holding Version
1
+ # opencode-cache-coach
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
+ An [OpenCode](https://opencode.ai) plugin that coaches you on making better use of your tokens.
4
+ It connects token usage and estimated costs to practical choices: when to compact, which model to
5
+ compact on, and how to avoid carrying oversized tool results through the rest of a session.
6
+
7
+ Your conversation and tool results become input to later requests. Prompt caching makes reusing
8
+ that context cheaper, but switching models, letting the cache expire, or crossing a higher-price
9
+ context threshold can make the next request more expensive. Cache Coach explains these trade-offs
10
+ as you work, with cost comparisons and suggestions based on your session and model prices.
11
+
12
+ The cache indicator and `/cache` details show where you stand. Tips and expiry messages help you
13
+ decide what to do next, such as compacting while the cache is warm or using a cheaper model when
14
+ the context must be written again.
15
+
16
+ Requires OpenCode 2.2 or later.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ opencode plugin add opencode-cache-coach
22
+ ```
23
+
24
+ This adds the plugin to your `opencode.json`:
25
+
26
+ ```json
27
+ {
28
+ "$schema": "https://opencode.ai/config.json",
29
+ "plugins": ["opencode-cache-coach"]
30
+ }
31
+ ```
32
+
33
+ The CLI part shows the indicator, tips, and expiry messages. The server part only keeps the expiry
34
+ messages out of model requests.
35
+
36
+ ## Cache indicator
37
+
38
+ The prompt footer shows the cache state of the session on screen:
39
+
40
+ | Footer | Meaning |
41
+ | -------------------------------------- | ------------------------------------------------------------------------ |
42
+ | `● cache 3:42` | Warm. The next message reads the context from the cache. |
43
+ | `◐ cache 0:45` | Warm, with less than a minute left. |
44
+ | `● cache` | A request is running and using the cache. |
45
+ | `○ cache cold · rewrite ~180K ($0.90)` | Expired, or another model is selected. The next message writes it again. |
46
+ | `○ cache cold · … · GPT-6.1 Sol $0.45` | As above, and compacting on your `compactionModel` costs less. |
47
+ | `● cache 3:42 · warming?` | Warm, in a session that OpenCode warms. See [Warming](#warming). |
48
+
49
+ The footer stays empty until the session has a request whose provider reports cache tokens. Click
50
+ the indicator, or run `/cache`, to see the last request's cache hit rate, what the next message
51
+ costs, and the session's cost so far.
52
+
53
+ The countdown starts when the last request started, and uses the cache lifetime from the model's
54
+ or provider's `cache.ttl` setting, or 5 minutes. Providers can evict a cache earlier, so a warm
55
+ indicator means the cache is likely warm.
56
+
57
+ ### Warming
58
+
59
+ When OpenCode's [`warming`](https://opencode.ai/docs/warming/) option covers a session, OpenCode
60
+ sends keep-alive requests while you're idle. Those requests add no messages, and the plugin can't
61
+ see whether they renew the cache. With GitLab Duo models, requests after a keep-alive request still
62
+ missed the cache in testing. So the countdown doesn't count on warming, and ends in `· warming?` to
63
+ show that the cache might last longer.
64
+
65
+ The plugin reads the `warming` setting from the top level of your configuration and from the
66
+ model's or provider's `settings.cache.warming`, once per directory. Restart OpenCode after you
67
+ change `warming`.
68
+
69
+ ## Expiry messages
70
+
71
+ Shortly before the cache of a large context expires, the plugin adds a message to the session, so
72
+ it's the last thing you see when you come back:
73
+
74
+ ```text
75
+ ◈ Prompt cache expires at 14:32. After that, the next message writes ~225K tokens to the cache
76
+ again (~$1.13 on Claude Opus 5.5). To compact, switch to GPT-6.1 Sol first (~$0.56), run
77
+ /compact, then switch back.
78
+ ```
79
+
80
+ A compaction after the cache expires writes the whole context again, so compacting on a cheaper
81
+ model saves most of that cost. Set `compactionModel` to name the model. Without it, the message
82
+ only says that a cheaper model costs less.
83
+
84
+ The message appears once each time the session goes idle, in top-level sessions whose last request
85
+ the TUI saw finish, and only when the next message writes at least `minTokens` and costs at least
86
+ `minCost`. The server part removes these messages from every model request, so the model never
87
+ sees them and the cache isn't affected.
88
+
89
+ ## Tips
90
+
91
+ The plugin shows a toast when it spots one of these patterns. Each tip appears at most once per
92
+ session, except the model-switch tips, which appear for every switch away from a new request:
93
+
94
+ - **Model switch on a warm cache.** Selecting another model for a large context means the next
95
+ message writes it to a new cache. The tip compares that with staying on the current model and
96
+ suggests compacting first.
97
+ - **Model switch after the cache expired.** The next message writes the context again on any model,
98
+ so the tip says when the new model, or your `compactionModel`, can compact it for less.
99
+ - **Close to the context ceiling.** At 80% of the ceiling (`contextRatio`), the tip suggests
100
+ compacting while the cache is warm, and shows what the same compaction costs after the cache
101
+ expires. The ceiling is 250K tokens by default. For a model that charges more above a prompt
102
+ size, such as Claude Haiku 5.5 above 100K tokens, the default ceiling is that size instead.
103
+ - **Large tool output.** A tool result stays in every later request in the session. The tip
104
+ suggests asking for a narrower result next time.
105
+ - **Warming didn't keep the cache.** When warming is on and a request within the warming window
106
+ still writes the context again, the tip says what that cost, and suggests turning warming off for
107
+ the model, because its keep-alive requests cost money too. A provider can also drop a cache early,
108
+ so one miss isn't proof that warming never works.
109
+
110
+ Tips stay in the TUI and never enter the conversation, so they don't add tokens or change the
111
+ cache. Subagent sessions don't get tips.
112
+
113
+ ## Options
114
+
115
+ The options belong to the CLI part, which reads them from `cli.json`. Add the same package there
116
+ with the object form. OpenCode still loads the plugin once:
117
+
118
+ ```json
119
+ {
120
+ "plugins": [
121
+ {
122
+ "package": "opencode-cache-coach",
123
+ "options": { "compactionModel": "gitlab/duo-chat-gpt-6-1-sol", "contextCeiling": 180000 }
124
+ }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ | Option | Default | Description |
130
+ | ----------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | `tips` | `true` | Set to `false` to keep only the indicator. |
132
+ | `notices` | `true` | Set to `false` to turn off the expiry messages. |
133
+ | `compactionModel` | | Model to suggest for compacting after the cache expires, such as `"gitlab/duo-chat-gpt-6-1-sol"`, or a list in order of preference. The plugin suggests the first one that fits the context and saves at least `minCost`. |
134
+ | `ttl` | | Cache lifetime, such as `"1h"`. Overrides the model and provider `cache.ttl`. |
135
+ | `contextCeiling` | `250000`, or where a higher price starts | Context size the ceiling tip measures against. Setting it applies to every model, including tiered ones. Capped at the model's input limit. |
136
+ | `contextRatio` | `0.8` | Share of the ceiling at which the ceiling tip appears. |
137
+ | `minTokens` | `50000` | Smallest context that a model-switch tip or an expiry message covers. |
138
+ | `minCost` | `0.1` | Smallest cost or cost difference, in USD, that a model-switch tip or an expiry message covers. |
139
+ | `largeOutput` | `20000` | Estimated tokens in a tool result at which the large-output tip appears. |
140
+
141
+ Costs come from the model prices OpenCode knows, including cache and long-context prices. Without
142
+ them, the indicator and tips leave the amounts out.
143
+
144
+ ## Install a local build
145
+
146
+ Run `mise run build cache-coach` from the repository root, then point the plugin entries in your
147
+ `opencode.json` and `cli.json` at the package directory, such as
148
+ `"package": "/path/to/opencode-plugins/packages/cache-coach"`, and restart OpenCode.
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ // @bun
2
+ // packages/cache-coach/src/notice.ts
3
+ var NOTICE_TEXT = "[cache-coach: a prompt cache notice for the user. Ignore it.]";
4
+ var isNotice = (message) => {
5
+ if (message.role !== "user" || message.content.length !== 1)
6
+ return false;
7
+ const part = message.content[0];
8
+ return part?.type === "text" && part.text === NOTICE_TEXT;
9
+ };
10
+ var withoutNotices = (messages) => messages.filter((message) => !isNotice(message));
11
+
12
+ // packages/cache-coach/src/index.ts
13
+ var src_default = {
14
+ id: "opencode-cache-coach",
15
+ async setup(ctx) {
16
+ for (const kind of ["context", "generate", "compaction", "title"])
17
+ await ctx.session.hook(kind, (event) => {
18
+ event.messages = withoutNotices(event.messages);
19
+ });
20
+ }
21
+ };
22
+ export {
23
+ src_default as default
24
+ };
package/dist/tui.js ADDED
@@ -0,0 +1,851 @@
1
+ // @bun
2
+ // packages/cache-coach/src/tui.tsx
3
+ import { effect as _$effect } from "@opentui/solid";
4
+ import { insert as _$insert } from "@opentui/solid";
5
+ import { setProp as _$setProp } from "@opentui/solid";
6
+ import { createElement as _$createElement } from "@opentui/solid";
7
+ import { createComponent as _$createComponent } from "@opentui/solid";
8
+ import { createEffect, createMemo, createSignal, Show, untrack } from "solid-js";
9
+ // packages/_shared/src/location.ts
10
+ function eventSessionID(event) {
11
+ const data = event.data;
12
+ if (typeof data?.sessionID === "string")
13
+ return data.sessionID;
14
+ const formSessionID = data?.form?.sessionID;
15
+ return typeof formSessionID === "string" ? formSessionID : undefined;
16
+ }
17
+ // packages/_shared/src/view.ts
18
+ function createViewFilter(context, tracks) {
19
+ function visible(sessionID) {
20
+ const root = context.data.session.root(sessionID);
21
+ const route = context.ui.router.current();
22
+ if (route.type === "session" && context.data.session.root(route.sessionID) === root) {
23
+ return true;
24
+ }
25
+ return context.ui.tabs.enabled() && context.ui.tabs.list().some((tab) => tab.sessionID === root);
26
+ }
27
+ return function shows(event) {
28
+ const sessionID = eventSessionID(event);
29
+ return sessionID !== undefined && (tracks(sessionID) || visible(sessionID));
30
+ };
31
+ }
32
+ // packages/cache-coach/src/cache.ts
33
+ var DEFAULT_TTL = 5 * 60000;
34
+ var EXPIRING = 60000;
35
+ var sameModel = (a, b) => !!a && !!b && a.providerID === b.providerID && a.id === b.id;
36
+ var promptTokens = (tokens) => tokens.input + tokens.cache.read + tokens.cache.write;
37
+ var contextTokens = (tokens) => promptTokens(tokens) + tokens.output;
38
+ var reportsCache = (tokens) => tokens.cache.read + tokens.cache.write > 0;
39
+ function lastStep(messages, started, skip) {
40
+ for (let index = messages.length - 1;index >= 0; index -= 1) {
41
+ const message = messages[index];
42
+ if (message.type === "compaction" && message.status === "completed")
43
+ return;
44
+ if (message.id === skip)
45
+ continue;
46
+ if (message.type !== "assistant" || !message.tokens || !message.model)
47
+ continue;
48
+ if (promptTokens(message.tokens) === 0)
49
+ continue;
50
+ const start = started?.(message.id) ?? message.time.created;
51
+ return {
52
+ id: message.id,
53
+ model: message.model,
54
+ started: start,
55
+ ...message.time.completed === undefined ? {} : { ended: Math.max(start, message.time.completed) },
56
+ tokens: message.tokens
57
+ };
58
+ }
59
+ return;
60
+ }
61
+ function priceFor(costs, prompt) {
62
+ let chosen;
63
+ for (const cost of costs ?? []) {
64
+ if (cost.tier && prompt <= cost.tier.size)
65
+ continue;
66
+ if (!chosen || (cost.tier?.size ?? 0) > (chosen.tier?.size ?? 0))
67
+ chosen = cost;
68
+ }
69
+ if (!chosen || chosen.input <= 0)
70
+ return;
71
+ return {
72
+ input: chosen.input,
73
+ output: chosen.output,
74
+ read: chosen.cache.read || chosen.input,
75
+ write: chosen.cache.write || chosen.input
76
+ };
77
+ }
78
+ var usd = (tokens, perMillion) => tokens * perMillion / 1e6;
79
+ var DEFAULT_CEILING = 250000;
80
+ function priceTier(costs) {
81
+ const sizes = (costs ?? []).flatMap((cost) => cost.tier?.type === "context" && cost.tier.size > 0 ? [cost.tier.size] : []);
82
+ return sizes.length ? Math.min(...sizes) : undefined;
83
+ }
84
+ function contextCeiling(input) {
85
+ const cap = (tokens) => input.modelLimit && input.modelLimit > 0 ? Math.min(tokens, input.modelLimit) : tokens;
86
+ if (input.configured)
87
+ return { tokens: cap(input.configured), reason: "limit" };
88
+ const tier = priceTier(input.costs);
89
+ if (tier && (!input.modelLimit || tier < input.modelLimit))
90
+ return { tokens: tier, reason: "price" };
91
+ return { tokens: cap(DEFAULT_CEILING), reason: "limit" };
92
+ }
93
+ var UNITS = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 };
94
+ function parseTtl(value) {
95
+ if (typeof value !== "string")
96
+ return;
97
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(value.trim());
98
+ if (!match)
99
+ return;
100
+ const ttl = Number(match[1]) * UNITS[match[2]];
101
+ return ttl > 0 ? ttl : undefined;
102
+ }
103
+ function cacheState(input) {
104
+ const { step } = input;
105
+ if (!step || !reportsCache(step.tokens))
106
+ return { kind: "unknown" };
107
+ if (input.running)
108
+ return { kind: "running", step };
109
+ if (input.selected && !sameModel(input.selected, step.model))
110
+ return { kind: "cold", step, reason: "model" };
111
+ const remaining = step.started + input.ttl - input.now;
112
+ return remaining > 0 ? { kind: "warm", step, remaining, warming: !!input.warming } : { kind: "cold", step, reason: "expired" };
113
+ }
114
+ function formatTokens(count) {
115
+ if (count >= 1e6)
116
+ return `${Number((count / 1e6).toFixed(1))}M`;
117
+ if (count >= 1000)
118
+ return `${Math.round(count / 1000)}K`;
119
+ return String(Math.round(count));
120
+ }
121
+ var formatUsd = (amount) => amount < 0.005 ? "<$0.01" : `$${amount.toFixed(2)}`;
122
+ var aboutUsd = (amount) => amount < 0.005 ? "under $0.01" : `~$${amount.toFixed(2)}`;
123
+ function formatRemaining(ms) {
124
+ const seconds = Math.max(0, Math.ceil(ms / 1000));
125
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
126
+ }
127
+ function formatDuration(ms) {
128
+ if (ms >= 3600000 && ms % 3600000 === 0)
129
+ return `${ms / 3600000}h`;
130
+ const seconds = Math.round(ms / 1000);
131
+ const minutes = Math.floor(seconds / 60);
132
+ if (minutes === 0)
133
+ return `${seconds}s`;
134
+ return seconds % 60 ? `${minutes}m${seconds % 60}s` : `${minutes}m`;
135
+ }
136
+ var shortName = (name) => /\(([^()]+)\)\s*$/.exec(name)?.[1] ?? name;
137
+ function indicator(state, next, alternative) {
138
+ if (state.kind === "unknown")
139
+ return;
140
+ if (state.kind === "running")
141
+ return { text: "\u25CF cache", tone: "success" };
142
+ if (state.kind === "warm") {
143
+ const time = `${formatRemaining(state.remaining)}${state.warming ? " \xB7 warming?" : ""}`;
144
+ return state.remaining <= EXPIRING ? { text: `\u25D0 cache ${time}`, tone: "warning" } : { text: `\u25CF cache ${time}`, tone: "success" };
145
+ }
146
+ const rewrite = next ? ` \xB7 rewrite ~${formatTokens(next.tokens)}${next.cost === undefined ? "" : ` (${formatUsd(next.cost)})`}` : "";
147
+ const cheaper = alternative ? ` \xB7 ${shortName(alternative.name)} ${formatUsd(alternative.cost)}` : "";
148
+ return { text: `\u25CB cache cold${rewrite}${cheaper}`, tone: "warning" };
149
+ }
150
+ function nextCost(state, tokens, price) {
151
+ if (!price)
152
+ return { tokens };
153
+ const warm = state.kind === "warm" || state.kind === "running";
154
+ return { tokens, cost: usd(tokens, warm ? price.read : price.write) };
155
+ }
156
+ function details(input) {
157
+ const { state, warming } = input;
158
+ if (state.kind === "unknown")
159
+ return ["No cache data yet. The provider may not report cache tokens for this session."];
160
+ const lines = [];
161
+ if (state.kind === "running")
162
+ lines.push("Cache: in use by the running request");
163
+ else if (state.kind === "warm")
164
+ lines.push(`Cache: warm, about ${formatRemaining(state.remaining)} left`);
165
+ else if (state.reason === "expired")
166
+ lines.push("Cache: cold, it expired");
167
+ else
168
+ lines.push(`Cache: cold, ${input.selectedModel ?? "the selected model"} has no cache for this session`);
169
+ lines.push(`Cache lifetime: ${formatDuration(input.ttl)} after each request`);
170
+ lines.push(warming ? `Warming: every ${formatDuration(warming.interval)} while idle, until ${formatDuration(warming.duration)} after the last request. Warming requests may not renew the cache, so the countdown doesn't count on them.` : "Warming: off");
171
+ const { tokens } = state.step;
172
+ const prompt = promptTokens(tokens);
173
+ const hit = prompt ? Math.round(tokens.cache.read / prompt * 100) : 0;
174
+ lines.push("", `Last request${input.stepModel ? ` on ${input.stepModel}` : ""}: ${formatTokens(prompt)} prompt tokens, ${hit}% read from the cache`, `Read ${formatTokens(tokens.cache.read)} \xB7 written ${formatTokens(tokens.cache.write)} \xB7 uncached ${formatTokens(tokens.input)} \xB7 output ${formatTokens(tokens.output)}`);
175
+ const cost = input.next.cost === undefined ? "" : ` (${aboutUsd(input.next.cost)})`;
176
+ const warm = state.kind === "warm" || state.kind === "running";
177
+ lines.push(warm ? `Next message: reads ~${formatTokens(input.next.tokens)} tokens from the cache${cost}` : `Next message: writes ~${formatTokens(input.next.tokens)} tokens to a new cache${cost}`);
178
+ if (input.sessionCost !== undefined)
179
+ lines.push(`Session cost so far: ${formatUsd(input.sessionCost)}`);
180
+ return lines;
181
+ }
182
+
183
+ // packages/cache-coach/src/options.ts
184
+ var DEFAULTS = {
185
+ tips: true,
186
+ notices: true,
187
+ compactionModels: [],
188
+ contextRatio: 0.8,
189
+ minTokens: 50000,
190
+ minCost: 0.1,
191
+ largeOutput: 20000
192
+ };
193
+ var isPositive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
194
+ var positive = (value, fallback) => isPositive(value) ? value : fallback;
195
+ function modelRef(value) {
196
+ if (typeof value !== "string")
197
+ return [];
198
+ const slash = value.indexOf("/");
199
+ if (slash <= 0 || slash === value.length - 1)
200
+ return [];
201
+ return [{ providerID: value.slice(0, slash), id: value.slice(slash + 1) }];
202
+ }
203
+ var modelRefs = (value) => (Array.isArray(value) ? value : [value]).flatMap((entry) => modelRef(entry));
204
+ function parseOptions(options) {
205
+ const value = options ?? {};
206
+ const ratio = positive(value["contextRatio"], DEFAULTS.contextRatio);
207
+ return {
208
+ tips: value["tips"] !== false,
209
+ notices: value["notices"] !== false,
210
+ compactionModels: modelRefs(value["compactionModel"]),
211
+ ttl: parseTtl(value["ttl"]),
212
+ contextCeiling: isPositive(value["contextCeiling"]) ? value["contextCeiling"] : undefined,
213
+ contextRatio: ratio <= 1 ? ratio : DEFAULTS.contextRatio,
214
+ minTokens: positive(value["minTokens"], DEFAULTS.minTokens),
215
+ minCost: positive(value["minCost"], DEFAULTS.minCost),
216
+ largeOutput: positive(value["largeOutput"], DEFAULTS.largeOutput)
217
+ };
218
+ }
219
+
220
+ // packages/cache-coach/src/tips.ts
221
+ function modelSwitchTip(input) {
222
+ const { tokens, from, to } = input;
223
+ if (tokens < input.minTokens)
224
+ return;
225
+ const size = `~${formatTokens(tokens)} tokens`;
226
+ const move = to.price && usd(tokens, to.price.write);
227
+ if (input.warm) {
228
+ const stay2 = from.price && usd(tokens, from.price.read);
229
+ if (move !== undefined && stay2 !== undefined && move - stay2 < input.minCost)
230
+ return;
231
+ const costs2 = move !== undefined && stay2 !== undefined ? ` (${aboutUsd(move)}). Staying on ${from.name} reads them from its cache (${aboutUsd(stay2)})` : `. Staying on ${from.name} reads them from its cache instead`;
232
+ return {
233
+ key: "model-switch",
234
+ title: "This model switch rewrites the cache",
235
+ variant: "warning",
236
+ message: `Your next message on ${to.name} writes ${size} to a new cache${costs2}. To compact, switch back, compact, and then switch.`
237
+ };
238
+ }
239
+ const stay = from.price && usd(tokens, from.price.write);
240
+ if (stay === undefined)
241
+ return;
242
+ const cheaper = input.compaction ?? (move !== undefined && stay - move >= input.minCost ? { name: to.name, cost: move } : undefined);
243
+ if (!cheaper)
244
+ return;
245
+ const costs = `${move === undefined ? "" : `${aboutUsd(move)} on ${to.name}, `}${aboutUsd(stay)} on ${from.name}`;
246
+ return {
247
+ key: "model-switch-cold",
248
+ title: "The cache already expired",
249
+ variant: "info",
250
+ message: `The next message rewrites ${size} on any model: ${costs}. If you plan to compact, compacting on ${cheaper.name} costs less (${aboutUsd(cheaper.cost)}).`
251
+ };
252
+ }
253
+ function contextCeilingTip(input) {
254
+ const { tokens, ceiling, price } = input;
255
+ if (ceiling.tokens <= 0 || tokens < ceiling.tokens * input.ratio)
256
+ return;
257
+ const costs = price ? ` reads the context from the cache for ${aboutUsd(usd(tokens, price.read))}. After the cache expires, the same compaction pays ${aboutUsd(usd(tokens, price.write))} to write it again.` : " reads the context from the cache instead of writing it again.";
258
+ const position = ceiling.reason === "price" ? `~${formatTokens(tokens)} tokens, and this model charges a higher price for prompts over ${formatTokens(ceiling.tokens)} tokens` : `~${formatTokens(tokens)} of ${formatTokens(ceiling.tokens)} tokens`;
259
+ return {
260
+ key: "context-limit",
261
+ title: ceiling.reason === "price" ? "Close to a higher price" : "Close to the context limit",
262
+ variant: "info",
263
+ message: `This session's context is at ${position}. Compacting now, while the cache is warm,${costs}`
264
+ };
265
+ }
266
+ function largeOutputTip(input) {
267
+ const { tool, tokens, price } = input;
268
+ if (tokens < input.threshold)
269
+ return;
270
+ const cost = price ? `, adding ${aboutUsd(usd(tokens, price.read))} to each request as cache reads` : "";
271
+ return {
272
+ key: `large-output:${tool}`,
273
+ title: "Large tool output",
274
+ variant: "info",
275
+ message: `${tool} returned ~${formatTokens(tokens)} tokens. They stay in every later request in this session${cost}. Next time, ask for a narrower result, for example with grep, head, or a line range.`
276
+ };
277
+ }
278
+ function warmingTip(input) {
279
+ const { gap, written, price } = input;
280
+ if (gap <= input.ttl || gap >= input.warming.duration)
281
+ return;
282
+ if (input.prompt < input.minTokens || written < input.prompt / 2)
283
+ return;
284
+ const rewrite = price && usd(written, price.write);
285
+ if (rewrite !== undefined && rewrite < input.minCost)
286
+ return;
287
+ const minutes = Math.round(gap / 60000);
288
+ const away = `${minutes} minute${minutes === 1 ? "" : "s"}`;
289
+ const cost = rewrite === undefined ? "" : ` (${aboutUsd(rewrite)})`;
290
+ return {
291
+ key: "warming",
292
+ title: "Warming didn't keep the cache",
293
+ variant: "warning",
294
+ message: `Warming didn't keep the cache this time: this request wrote ~${formatTokens(written)} tokens again${cost} after ${away} away. Warming requests also cost money, so consider turning warming off for ${input.model}.`
295
+ };
296
+ }
297
+ var estimateTokens = (text) => Math.ceil(text.length / 4);
298
+
299
+ // packages/cache-coach/src/warming.ts
300
+ var DEFAULT_WARMING = { interval: 4 * 60000, duration: 30 * 60000 };
301
+ var UNITS2 = {
302
+ ms: 1,
303
+ millisecond: 1,
304
+ s: 1000,
305
+ sec: 1000,
306
+ second: 1000,
307
+ m: 60000,
308
+ min: 60000,
309
+ minute: 60000,
310
+ h: 3600000,
311
+ hr: 3600000,
312
+ hour: 3600000,
313
+ d: 86400000,
314
+ day: 86400000
315
+ };
316
+ function parseDuration(value) {
317
+ if (typeof value !== "string")
318
+ return;
319
+ const match = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i.exec(value.trim());
320
+ if (!match)
321
+ return;
322
+ const name = match[2].toLowerCase();
323
+ const unit = UNITS2[name] ?? UNITS2[name.replace(/s$/, "")];
324
+ const duration = unit === undefined ? undefined : Number(match[1]) * unit;
325
+ return duration && duration > 0 ? duration : undefined;
326
+ }
327
+ var fieldsOf = (value) => value === true ? {} : value && typeof value === "object" ? value : undefined;
328
+ function schedule(fields, top, ttl) {
329
+ const rawInterval = fields.interval ?? top.interval;
330
+ const interval = rawInterval === "auto" ? ttl && ttl * 0.9 : rawInterval === undefined ? DEFAULT_WARMING.interval : parseDuration(rawInterval);
331
+ const rawDuration = fields.duration ?? top.duration;
332
+ const duration = rawDuration === undefined ? DEFAULT_WARMING.duration : parseDuration(rawDuration);
333
+ if (!interval || !duration || interval >= duration)
334
+ return;
335
+ return { interval, duration };
336
+ }
337
+ function resolveWarming(input) {
338
+ const top = fieldsOf(input.top);
339
+ const chosen = [input.model, input.provider, input.top].find((value) => value !== undefined);
340
+ if (chosen === undefined || chosen === false)
341
+ return;
342
+ const fields = fieldsOf(chosen);
343
+ if (fields) {
344
+ const resolved = schedule(fields, top ?? {}, input.ttl);
345
+ if (resolved || chosen === input.top)
346
+ return resolved;
347
+ }
348
+ return top ? schedule(top, {}, input.ttl) : undefined;
349
+ }
350
+
351
+ // packages/cache-coach/src/compaction.ts
352
+ function compactionChoice(input) {
353
+ if (!input.price)
354
+ return;
355
+ const rewrite = usd(input.tokens, input.price.write);
356
+ for (const candidate of input.candidates) {
357
+ if (sameModel(candidate.ref, input.current))
358
+ continue;
359
+ if (candidate.limit && candidate.limit < input.tokens)
360
+ continue;
361
+ const price = priceFor(candidate.costs, input.tokens);
362
+ if (!price)
363
+ continue;
364
+ const cost = usd(input.tokens, price.write);
365
+ if (rewrite - cost >= input.minCost)
366
+ return { ref: candidate.ref, name: candidate.name, cost };
367
+ }
368
+ return;
369
+ }
370
+ function formatClock(ms) {
371
+ const date = new Date(ms);
372
+ const pad = (value) => String(value).padStart(2, "0");
373
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
374
+ }
375
+ function expiryNotice(input) {
376
+ const { tokens, from, choice } = input;
377
+ if (tokens < input.minTokens)
378
+ return;
379
+ const rewrite = from.price && usd(tokens, from.price.write);
380
+ if (rewrite !== undefined && rewrite < input.minCost)
381
+ return;
382
+ const cost = rewrite === undefined ? "" : ` (${aboutUsd(rewrite)} on ${from.name})`;
383
+ const advice = choice ? `To compact, switch to ${choice.name} first (${aboutUsd(choice.cost)}), run /compact, then switch back.` : "Compacting on a cheaper model costs less.";
384
+ return `Prompt cache expires at ${input.time}. After that, the next message writes ~${formatTokens(tokens)} tokens to the cache again${cost}. ${advice}`;
385
+ }
386
+
387
+ // packages/cache-coach/src/notice.ts
388
+ var NOTICE_TEXT = "[cache-coach: a prompt cache notice for the user. Ignore it.]";
389
+
390
+ // packages/cache-coach/src/tui.tsx
391
+ var tui_default = {
392
+ id: "opencode-cache-coach",
393
+ async setup(context) {
394
+ const settings = parseOptions(context.options);
395
+ const [now, setNow] = createSignal(Date.now());
396
+ const clock = setInterval(() => {
397
+ const time = Date.now();
398
+ setNow(time);
399
+ checkNotices(time);
400
+ }, 1000);
401
+ const [starts, setStarts] = context.storage.memory("step-starts", {
402
+ initial: {}
403
+ });
404
+ const [shown, setShown] = context.storage.memory("tips-shown", {
405
+ initial: {}
406
+ });
407
+ const session = (sessionID) => context.data.session.get(sessionID);
408
+ const locationOf = (sessionID) => {
409
+ const directory = session(sessionID)?.location.directory;
410
+ return directory ? {
411
+ directory
412
+ } : undefined;
413
+ };
414
+ const synced = new Set;
415
+ function catalog(sessionID) {
416
+ const location = locationOf(sessionID);
417
+ const models = context.data.location.model.list(location);
418
+ const providers = context.data.location.provider.list(location);
419
+ const key = location?.directory ?? "";
420
+ if ((!models || !providers) && !synced.has(key)) {
421
+ synced.add(key);
422
+ context.data.location.model.sync(location).catch(() => {});
423
+ context.data.location.provider.sync(location).catch(() => {});
424
+ }
425
+ return {
426
+ models: models ?? [],
427
+ providers: providers ?? []
428
+ };
429
+ }
430
+ const findModel = (models, ref) => ref ? models.find((model) => model.providerID === ref.providerID && (model.id === ref.id || model.modelID === ref.id)) : undefined;
431
+ const ttlFor = (model, provider) => settings.ttl ?? parseTtl(model?.settings?.cache?.ttl) ?? parseTtl(provider?.settings?.cache?.ttl) ?? DEFAULT_TTL;
432
+ const nameOf = (model, ref) => model?.name ?? ref.id;
433
+ function compactionFor(models, current, tokens, price) {
434
+ const candidates = settings.compactionModels.flatMap((ref) => {
435
+ const model = findModel(models, ref);
436
+ if (!model)
437
+ return [];
438
+ const limit = model.limit.input ?? model.limit.context;
439
+ return [{
440
+ ref,
441
+ name: nameOf(model, ref),
442
+ costs: model.cost,
443
+ limit
444
+ }];
445
+ });
446
+ return compactionChoice({
447
+ candidates,
448
+ current,
449
+ tokens,
450
+ price,
451
+ minCost: settings.minCost
452
+ });
453
+ }
454
+ const [configured, setConfigured] = createSignal({});
455
+ const loading = new Set;
456
+ function topWarming(directory) {
457
+ const key = directory ?? "";
458
+ const loaded = configured()[key];
459
+ if (!loaded && !loading.has(key)) {
460
+ loading.add(key);
461
+ context.client.config.get(directory ? {
462
+ location: {
463
+ directory
464
+ }
465
+ } : undefined).then((entries) => {
466
+ const warming = [...entries].reverse().map((entry) => entry.type === "document" ? entry.info.warming : undefined).find((value) => value !== undefined);
467
+ setConfigured((current) => ({
468
+ ...current,
469
+ [key]: {
470
+ warming
471
+ }
472
+ }));
473
+ }).catch(() => loading.delete(key));
474
+ }
475
+ return loaded?.warming;
476
+ }
477
+ const cacheSettings = (settings2) => settings2?.cache;
478
+ function warmingFor(sessionID, model, provider) {
479
+ if (session(sessionID)?.parentID)
480
+ return;
481
+ const fromModel = cacheSettings(model?.settings);
482
+ const fromProvider = cacheSettings(provider?.settings);
483
+ return resolveWarming({
484
+ top: topWarming(locationOf(sessionID)?.directory),
485
+ provider: fromProvider?.warming,
486
+ model: fromModel?.warming,
487
+ ttl: parseTtl(fromModel?.ttl) ?? parseTtl(fromProvider?.ttl)
488
+ });
489
+ }
490
+ function selectedModel(sessionID) {
491
+ const route = context.ui.router.current();
492
+ if (route.type === "session" && route.sessionID === sessionID) {
493
+ const current = context.ui.model.current();
494
+ if (current)
495
+ return {
496
+ providerID: current.providerID,
497
+ id: current.modelID
498
+ };
499
+ }
500
+ return session(sessionID)?.model;
501
+ }
502
+ const startedAt = (sessionID) => (id) => {
503
+ const start = starts[sessionID];
504
+ return start?.id === id ? start.at : undefined;
505
+ };
506
+ const stepOf = (sessionID) => lastStep(context.data.session.message.list(sessionID), startedAt(sessionID));
507
+ function view(sessionID, time) {
508
+ const step = stepOf(sessionID);
509
+ const {
510
+ models,
511
+ providers
512
+ } = catalog(sessionID);
513
+ const selected = selectedModel(sessionID);
514
+ const stepModel = step && findModel(models, step.model);
515
+ const target = selected ? findModel(models, selected) : stepModel;
516
+ const provider = step && providers.find((entry) => entry.id === step.model.providerID);
517
+ const ttl = ttlFor(stepModel, provider);
518
+ const warming = warmingFor(sessionID, stepModel, provider);
519
+ const state = cacheState({
520
+ step,
521
+ now: time,
522
+ ttl,
523
+ running: context.data.session.status(sessionID) === "running",
524
+ selected,
525
+ warming
526
+ });
527
+ const tokens = step ? contextTokens(step.tokens) : 0;
528
+ const next = nextCost(state, tokens, target && priceFor(target.cost, tokens));
529
+ const alternative = state.kind === "cold" && step ? compactionFor(models, selected ?? step.model, tokens, target && priceFor(target.cost, tokens)) : undefined;
530
+ return {
531
+ state,
532
+ ttl,
533
+ warming,
534
+ next,
535
+ alternative,
536
+ stepModel: step && nameOf(stepModel, step.model),
537
+ selectedModel: selected && nameOf(target, selected)
538
+ };
539
+ }
540
+ const color = (tone) => {
541
+ if (tone === "success")
542
+ return context.theme.text.feedback.success.base;
543
+ if (tone === "warning")
544
+ return context.theme.text.feedback.warning.base;
545
+ return context.theme.text.muted;
546
+ };
547
+ function showDetails(sessionID) {
548
+ const route = context.ui.router.current();
549
+ const id = sessionID ?? (route.type === "session" ? route.sessionID : undefined);
550
+ if (!id) {
551
+ context.ui.toast.show({
552
+ message: "Open a session to see its cache",
553
+ variant: "info"
554
+ });
555
+ return;
556
+ }
557
+ const current = view(id, Date.now());
558
+ context.ui.dialog.alert({
559
+ title: "Prompt cache",
560
+ message: details({
561
+ ...current,
562
+ sessionCost: context.data.session.cost(id)
563
+ }).join(`
564
+ `)
565
+ });
566
+ }
567
+ function showTip(sessionID, tip) {
568
+ if (!tip || !settings.tips)
569
+ return;
570
+ const key = `${sessionID}:${tip.key}`;
571
+ if (shown[key])
572
+ return;
573
+ setShown((draft) => {
574
+ draft[key] = true;
575
+ });
576
+ context.ui.toast.show({
577
+ title: tip.title,
578
+ message: tip.message,
579
+ variant: tip.variant,
580
+ duration: 12000,
581
+ sessionID
582
+ });
583
+ }
584
+ const pending = {};
585
+ function scheduleNotice(input) {
586
+ const {
587
+ sessionID,
588
+ ref,
589
+ model,
590
+ tokens
591
+ } = input;
592
+ delete pending[sessionID];
593
+ if (!settings.notices || !ref)
594
+ return;
595
+ const price = model && priceFor(model.cost, tokens);
596
+ const choice = compactionFor(input.models, ref, tokens, price);
597
+ const description = expiryNotice({
598
+ time: formatClock(input.expires),
599
+ tokens,
600
+ from: {
601
+ name: shortName(nameOf(model, ref)),
602
+ price
603
+ },
604
+ choice: choice && {
605
+ ...choice,
606
+ name: shortName(choice.name)
607
+ },
608
+ minTokens: settings.minTokens,
609
+ minCost: settings.minCost
610
+ });
611
+ if (!description)
612
+ return;
613
+ pending[sessionID] = {
614
+ stepID: input.stepID,
615
+ at: input.expires - EXPIRING,
616
+ expires: input.expires,
617
+ description
618
+ };
619
+ }
620
+ function checkNotices(time) {
621
+ for (const [sessionID, notice] of Object.entries(pending)) {
622
+ if (!notice || time < notice.at)
623
+ continue;
624
+ delete pending[sessionID];
625
+ if (time >= notice.expires || context.data.session.status(sessionID) === "running")
626
+ continue;
627
+ context.client.session.synthetic({
628
+ sessionID,
629
+ id: `${notice.stepID}-cache-coach`,
630
+ text: NOTICE_TEXT,
631
+ description: notice.description,
632
+ metadata: {
633
+ cacheCoach: "expiry"
634
+ },
635
+ resume: false
636
+ }).catch(() => {});
637
+ }
638
+ }
639
+ function checkSwitch(sessionID, selected) {
640
+ const step = stepOf(sessionID);
641
+ if (!step || !reportsCache(step.tokens) || sameModel(step.model, selected))
642
+ return;
643
+ const {
644
+ models,
645
+ providers
646
+ } = catalog(sessionID);
647
+ const from = findModel(models, step.model);
648
+ const to = findModel(models, selected);
649
+ const provider = providers.find((entry) => entry.id === step.model.providerID);
650
+ const tokens = contextTokens(step.tokens);
651
+ const price = from && priceFor(from.cost, tokens);
652
+ const tip = modelSwitchTip({
653
+ tokens,
654
+ warm: step.started + ttlFor(from, provider) > Date.now(),
655
+ from: {
656
+ name: nameOf(from, step.model),
657
+ price
658
+ },
659
+ to: {
660
+ name: nameOf(to, selected),
661
+ price: to && priceFor(to.cost, tokens)
662
+ },
663
+ compaction: compactionFor(models, step.model, tokens, price),
664
+ minTokens: settings.minTokens,
665
+ minCost: settings.minCost
666
+ });
667
+ showTip(sessionID, tip && {
668
+ ...tip,
669
+ key: `${tip.key}:${step.id}`
670
+ });
671
+ }
672
+ const shows = createViewFilter(context, () => false);
673
+ const coached = (sessionID) => !session(sessionID)?.parentID;
674
+ function onStepEnded(event) {
675
+ const {
676
+ sessionID,
677
+ assistantMessageID,
678
+ tokens
679
+ } = event.data;
680
+ if (!coached(sessionID) || !reportsCache(tokens))
681
+ return;
682
+ const message = context.data.session.message.get(sessionID, assistantMessageID);
683
+ const ref = message?.type === "assistant" ? message.model : session(sessionID)?.model ?? undefined;
684
+ const {
685
+ models,
686
+ providers
687
+ } = catalog(sessionID);
688
+ const model = findModel(models, ref);
689
+ const total = contextTokens(tokens);
690
+ const provider = ref && providers.find((entry) => entry.id === ref.providerID);
691
+ const start = starts[sessionID];
692
+ const started = start?.id === assistantMessageID ? start.at : message?.time.created ?? Date.now();
693
+ scheduleNotice({
694
+ sessionID,
695
+ stepID: assistantMessageID,
696
+ expires: started + ttlFor(model, provider),
697
+ tokens: total,
698
+ ref,
699
+ model,
700
+ models
701
+ });
702
+ if (!shows(event))
703
+ return;
704
+ const previous = lastStep(context.data.session.message.list(sessionID), startedAt(sessionID), assistantMessageID);
705
+ const warming = warmingFor(sessionID, model, provider);
706
+ if (previous && ref && warming && sameModel(previous.model, ref)) {
707
+ showTip(sessionID, warmingTip({
708
+ gap: started - (previous.ended ?? previous.started),
709
+ ttl: ttlFor(model, provider),
710
+ warming,
711
+ model: shortName(nameOf(model, ref)),
712
+ written: tokens.cache.write,
713
+ prompt: promptTokens(tokens),
714
+ minTokens: settings.minTokens,
715
+ minCost: settings.minCost,
716
+ price: model && priceFor(model.cost, total)
717
+ }));
718
+ }
719
+ showTip(sessionID, contextCeilingTip({
720
+ tokens: total,
721
+ ceiling: contextCeiling({
722
+ configured: settings.contextCeiling,
723
+ costs: model?.cost,
724
+ modelLimit: model ? model.limit.input ?? model.limit.context : undefined
725
+ }),
726
+ ratio: settings.contextRatio,
727
+ price: model && priceFor(model.cost, total)
728
+ }));
729
+ }
730
+ function onToolSuccess(event) {
731
+ const {
732
+ sessionID,
733
+ assistantMessageID,
734
+ id,
735
+ content
736
+ } = event.data;
737
+ if (!shows(event) || !coached(sessionID))
738
+ return;
739
+ const text = content.reduce((sum, part) => sum + (part.type === "text" ? part.text : ""), "");
740
+ const tokens = estimateTokens(text);
741
+ if (tokens < settings.largeOutput)
742
+ return;
743
+ const message = context.data.session.message.get(sessionID, assistantMessageID);
744
+ const assistant = message?.type === "assistant" ? message : undefined;
745
+ const tool = assistant?.content.find((part) => part.type === "tool" && part.id === id);
746
+ const model = findModel(catalog(sessionID).models, assistant?.model);
747
+ showTip(sessionID, largeOutputTip({
748
+ tool: tool?.type === "tool" ? tool.name : "A tool",
749
+ tokens,
750
+ threshold: settings.largeOutput,
751
+ price: model && priceFor(model.cost, tokens)
752
+ }));
753
+ }
754
+ function Indicator(props) {
755
+ const current = createMemo(() => {
756
+ if (!props.sessionID)
757
+ return;
758
+ const value = view(props.sessionID, now());
759
+ return indicator(value.state, value.next, value.alternative);
760
+ });
761
+ return _$createComponent(Show, {
762
+ get when() {
763
+ return current();
764
+ },
765
+ children: (value) => (() => {
766
+ var _el$ = _$createElement("text");
767
+ _$setProp(_el$, "wrapMode", "none");
768
+ _$setProp(_el$, "flexShrink", 0);
769
+ _$setProp(_el$, "onMouseUp", () => showDetails(props.sessionID));
770
+ _$insert(_el$, () => value().text);
771
+ _$effect((_$p) => _$setProp(_el$, "fg", color(value().tone), _$p));
772
+ return _el$;
773
+ })()
774
+ });
775
+ }
776
+ context.ui.slot({
777
+ append: "prompt.footer.status",
778
+ render: (input) => _$createComponent(Indicator, {
779
+ get sessionID() {
780
+ return input.sessionID;
781
+ }
782
+ })
783
+ });
784
+ context.ui.slot({
785
+ append: "app",
786
+ render: () => {
787
+ let previous;
788
+ createEffect(() => {
789
+ const route = context.ui.router.current();
790
+ const current = context.ui.model.current();
791
+ if (route.type !== "session" || !current) {
792
+ previous = undefined;
793
+ return;
794
+ }
795
+ const key = `${current.providerID}/${current.modelID}`;
796
+ const last = previous;
797
+ previous = {
798
+ sessionID: route.sessionID,
799
+ key
800
+ };
801
+ if (!last || last.sessionID !== route.sessionID || last.key === key)
802
+ return;
803
+ untrack(() => checkSwitch(route.sessionID, {
804
+ providerID: current.providerID,
805
+ id: current.modelID
806
+ }));
807
+ });
808
+ context.keymap.layer(() => ({
809
+ mode: "global",
810
+ commands: [{
811
+ id: "cache-coach.details",
812
+ title: "Show prompt cache status",
813
+ group: "Cache",
814
+ palette: true,
815
+ slash: {
816
+ name: "cache"
817
+ },
818
+ run: () => showDetails()
819
+ }]
820
+ }));
821
+ return null;
822
+ }
823
+ });
824
+ const stops = [context.data.on("session.step.started", (event) => {
825
+ const {
826
+ sessionID,
827
+ assistantMessageID
828
+ } = event.data;
829
+ delete pending[sessionID];
830
+ setStarts((draft) => {
831
+ draft[sessionID] = {
832
+ id: assistantMessageID,
833
+ at: Date.now()
834
+ };
835
+ });
836
+ }), context.data.on("session.step.ended", onStepEnded), context.data.on("session.tool.success", onToolSuccess), context.data.on("session.compaction.ended", (event) => {
837
+ const key = `${event.data.sessionID}:context-limit`;
838
+ setShown((draft) => {
839
+ delete draft[key];
840
+ });
841
+ })];
842
+ return () => {
843
+ clearInterval(clock);
844
+ for (const stop of stops)
845
+ stop();
846
+ };
847
+ }
848
+ };
849
+ export {
850
+ tui_default as default
851
+ };
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "opencode-cache-coach",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "OpenCode plugin that shows whether a session's prompt cache is still warm and suggests cheaper habits when a costly pattern shows up",
5
+ "author": {
6
+ "name": "Pedro Pombeiro",
7
+ "email": "pedropombeiro@users.noreply.github.com"
8
+ },
9
+ "type": "module",
10
+ "exports": {
11
+ ".": {
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./tui": {
15
+ "default": "./dist/tui.js"
16
+ }
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/pedropombeiro/opencode-plugins.git",
21
+ "directory": "packages/cache-coach"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "keywords": [
30
+ "opencode",
31
+ "opencode-plugin",
32
+ "prompt-cache",
33
+ "cost",
34
+ "tokens"
35
+ ],
36
+ "license": "MIT",
37
+ "peerDependencies": {
38
+ "@opencode/plugin": "^2.2.0",
39
+ "@opentui/core": ">=0.7.0",
40
+ "@opentui/solid": ">=0.7.0",
41
+ "solid-js": ">=1.9.0"
42
+ }
43
+ }