cache-timer 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to `cache-timer` are documented here. The format is
4
+ loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
+ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [1.0.0] - 2026-05-26
10
+
11
+ Initial public release.
12
+
13
+ ### Added
14
+ - **Visual cache countdown** rendered in `session_prompt_right`. Shows time
15
+ remaining until the prompt cache goes cold, with `HOT`, near-expiry warning,
16
+ and `COLD` states. Per-model duration based on substring-matched provider
17
+ family.
18
+ - **Opt-in pre-emptive auto-summary.** When `enableAutoPrompt: true` is set in
19
+ the sidecar config, the plugin fires a tiny "Summarize our progress and next
20
+ steps. Be brief." prompt 15 seconds before the cache expires. The summary
21
+ reads from the hot cache (cheap), giving you an artifact to paste into a
22
+ fresh session and skip the cold-write tax of resuming the bloated original.
23
+ Off by default — the plugin never spends tokens without explicit consent.
24
+ - **Sidecar config** at `~/.config/opencode/cache-timer.json` (global) and
25
+ `./.opencode/cache-timer.json` (project). Project values win per-field over
26
+ global. Supports `enableAutoPrompt`, `durations` (per-family seconds), and
27
+ `defaultDuration`.
28
+ - **Family-key duration map** with substring matching. A single `"claude": 300`
29
+ entry covers `claude-opus-4-7`, `claude-haiku-4-5`, `claude-3-5-sonnet`, etc.
30
+ - **Race-safe auto-prompt detection.** The plugin tracks pending auto-prompts
31
+ via a synchronous user-message-count snapshot
32
+ (`pendingAutoPromptUserCount: Map<sessionId, number>`) taken at trigger
33
+ time, then ledgers the next user message whose index exceeds the snapshot
34
+ as automated. The ledgering branch runs on every tick — including busy
35
+ ticks — so the auto-prompt cannot mistakenly re-arm itself and re-fire.
36
+ - **File-based debug log** at `/tmp/cache-timer-debug.log` capturing `tui()`
37
+ init and config-resolution events. Per-tick writes intentionally omitted
38
+ to keep the log near-free.
39
+
40
+ ### Notes
41
+ - Configuration is intentionally a sidecar JSON file rather than inline in
42
+ `opencode.json`. Released opencode (v1.15.x) does not forward the second
43
+ tuple element of `["file://path", { ...options }]` plugin entries to TUI
44
+ plugins' `tui()` entrypoint, and `opencode.json` itself rejects unknown
45
+ top-level keys. An empirical survey of nine ecosystem plugins
46
+ (`opencode-dcp`, `opencode-vibeguard`, `opencode-sentry-monitor`,
47
+ `opencode-notificator`, `opencode-wakatime`, `opencode-morph-fast-apply`,
48
+ `opencode-helicone-session`, `opencode-md-table-formatter`, and the
49
+ official plugin template) found zero using the tuple-options mechanism;
50
+ the four config-heavy plugins all use sidecar JSON files.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicholas Cejda
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,130 @@
1
+ <div align="center">
2
+ <h1 align="center">cache-timer ⚡🕒</h1>
3
+
4
+ <p align="center">
5
+ <strong>Live prompt-cache countdown and pre-emptive cache-saving for <a href="https://opencode.ai">OpenCode</a></strong>
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="https://www.npmjs.com/package/cache-timer">
10
+ <img src="https://img.shields.io/npm/v/cache-timer?color=9b59b6" alt="npm" />
11
+ </a>
12
+ <a href="./CHANGELOG.md">
13
+ <img src="https://img.shields.io/badge/changelog-Latest%20Changes-blue" alt="Changelog" />
14
+ </a>
15
+ <a href="./LICENSE">
16
+ <img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
17
+ </a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="#what-is-it">What is it?</a> •
22
+ <a href="#-quick-start">Quick Start</a> •
23
+ <a href="#configuration">Configuration</a> •
24
+ <a href="./CHANGELOG.md">Changelog</a>
25
+ </p>
26
+ </div>
27
+
28
+ ---
29
+
30
+ ## What is it?
31
+
32
+ A TUI plugin that shows a live countdown to your prompt-cache expiry in the OpenCode session bar.
33
+
34
+ The countdown indicator on the right side of your prompt:
35
+
36
+ - 🔥 **Cache: HOT (05:00)** — healthy, time remaining
37
+ - ⚠️ **Cache: HOT (00:59)** — under one minute, about to expire
38
+ - ❄️ **Cache: COLD** — expired or model changed
39
+
40
+ <p align="center">
41
+ <img src="docs/assets/hot_cache.png" alt="Hot cache, time remaining" width="420" />
42
+ </p>
43
+ <p align="center">
44
+ <img src="docs/assets/less_than_one_min.png" alt="Less than one minute remaining" width="420" />
45
+ </p>
46
+ <p align="center">
47
+ <img src="docs/assets/cold.png" alt="Cache cold" width="420" />
48
+ </p>
49
+
50
+ When your session is COLD, you have three choices:
51
+
52
+ 1. **Pay the cold-start tax** to continue your session, or
53
+ 2. **Start a new session** and rebuild context.
54
+ 3. **Drop the session entirely**. Move on. Might not be worth continuing this session at all.
55
+
56
+ * On **cost**, starting fresh almost always wins — especially over 100k input tokens (see chart).
57
+ * On **latency**, resuming wins — one up-front cold-write hit beats several `Read` round-trips in a fresh session. Worth paying if you're coming back to do one quick thing and don't need to maximize savings.
58
+
59
+ The plugin also **optionally** helps make it easier to start a fresh session (off by default — see [Configuration](#configuration) to enable). If enabled, it fires a tiny "summarize progress" prompt 15 seconds before the cache goes cold, capturing a hot-read summary you can paste into a fresh session to skip the cold-start write tax of resuming the bloated original.
60
+
61
+ ## Why this exists
62
+
63
+ When a 500k-token session goes cold, just *resuming* it costs **$3.12** in cold-write fees before you've done any new work. The auto-summary path — hot-read the existing context to produce a summary, paste it into a fresh session — costs **$0.69** under realistic assumptions (50k startup tokens, 1k summary paste, 5 file re-reads at 1000 LOC each). **Savings of ~$2.43 per timeout you would have otherwise resumed,** scaling linearly with session size:
64
+
65
+ <p align="center">
66
+ <img src="docs/assets/cost-comparison.svg" alt="Cost of resuming a cold session vs. summary + fresh session — under realistic fresh-session assumptions the summary path saves ~$2.43 at 500k tokens and the gap widens with larger originals" width="700" />
67
+ </p>
68
+
69
+ > **When resume actually wins:** rarely on cost — you'd need to re-read essentially the entire original session in the fresh one for the math to flip, which almost never happens. The real argument for resuming is **latency**: a cold-write is a single up-front wall-clock hit, while a fresh session can spend the first several turns making `Read` calls to rebuild context the original already had. If you're coming back to do one quick thing and want to start immediately, just pay the tax if you can afford the extra cost.
70
+
71
+ > **The auto-summary is OFF by default.** It requires explicit opt-in so the plugin never spends your tokens without permission. See [Configuration](#configuration) to enable it.
72
+
73
+ ## 🚀 Quick Start
74
+
75
+ Add the package to the `plugin` array in your `opencode.json`:
76
+
77
+ ```json
78
+ {
79
+ "$schema": "https://opencode.ai/config.json",
80
+ "plugin": ["cache-timer"]
81
+ }
82
+ ```
83
+
84
+ OpenCode auto-installs npm plugins via Bun at startup. Restart OpenCode and you should see a green "Cache Timer loaded" toast.
85
+
86
+ ## Configuration
87
+
88
+ The visual countdown works out of the box with no configuration. To enable the auto-summary, create `~/.config/opencode/cache-timer.json`:
89
+
90
+ ```json
91
+ {
92
+ "enableAutoPrompt": true,
93
+ "durations": {
94
+ "claude": 300,
95
+ "gemini": 300,
96
+ "gpt": 300
97
+ }
98
+ }
99
+ ```
100
+
101
+ | Field | Type | Default | Notes |
102
+ | ------------------ | ------- | --------------------------------------------- | ---------------------------------------------------------------------- |
103
+ | `enableAutoPrompt` | boolean | `false` | **Off by default.** Set `true` to send the auto-summary 15s pre-expiry |
104
+ | `durations` | object | `{ claude: 300, gemini: 300, gpt: 300 }` | Seconds, keyed by provider family (substring-matched against model id) |
105
+ | `defaultDuration` | number | `300` | Fallback when the model doesn't match any family |
106
+
107
+ Per-project overrides live at `./.opencode/cache-timer.json` and win field-by-field over the global file. `"claude": 300` covers `claude-opus-4-7`, `claude-haiku-4-5`, `claude-3-5-sonnet`, etc.
108
+
109
+ <details>
110
+ <summary><b>Why a sidecar file instead of <code>opencode.json</code>?</b></summary>
111
+
112
+ Opencode's `opencode.json` validates against a strict JSON schema with `additionalProperties: false` at the top level, so an inline `cacheTimer: { ... }` block would prevent opencode from starting. The documented `["file://path", { ...options }]` plugin-tuple form does not forward options to TUI plugins in released opencode (verified against v1.15.x; an empirical survey of nine ecosystem plugins found that zero of them use that mechanism). Sidecar JSON files are the de-facto idiomatic pattern, used by `opencode-dcp`, `opencode-vibeguard`, `opencode-sentry-monitor`, and `opencode-notificator`.
113
+
114
+ </details>
115
+
116
+ ## 🛠️ Building from Source
117
+
118
+ ```bash
119
+ npm install
120
+ npm run build # compiles cache-timer.tsx -> tui.js
121
+ ```
122
+
123
+ ---
124
+
125
+ <div align="center">
126
+ <p>
127
+ <a href="https://github.com/ncejda-g2/opencode-cache-timer/issues">Report Bug</a> •
128
+ <a href="https://github.com/ncejda-g2/opencode-cache-timer/issues">Request Feature</a>
129
+ </p>
130
+ </div>
@@ -0,0 +1,335 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { type JSX } from "@opentui/solid"
3
+ import { createSignal, onCleanup } from "solid-js"
4
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
5
+ import { appendFileSync, readFileSync, existsSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import { join } from "node:path"
8
+
9
+ // DEBUG: file-based logger. Useful when toast stacking obscures init details
10
+ // and as a permanent troubleshooting aid for the cache-timer config loader.
11
+ // Safe to leave in; never throws (writes silently to /tmp).
12
+ const DEBUG_LOG_PATH = "/tmp/cache-timer-debug.log";
13
+ function debugLog(line: string): void {
14
+ try {
15
+ appendFileSync(DEBUG_LOG_PATH, `[${new Date().toISOString()}] ${line}\n`);
16
+ } catch {
17
+ // Intentionally swallow; debug instrumentation must never break the plugin.
18
+ }
19
+ }
20
+
21
+ // Shape of the optional sidecar config file users can drop next to opencode.json.
22
+ interface CacheTimerConfig {
23
+ enableAutoPrompt?: boolean;
24
+ defaultDuration?: number;
25
+ durations?: Record<string, number>;
26
+ }
27
+
28
+ // Read cache-timer config from a sidecar JSON file. Surveys of real opencode
29
+ // plugins (opencode-dcp, opencode-vibeguard, opencode-sentry-monitor,
30
+ // opencode-notificator, etc.) confirm that the inline `["file://path", {...}]`
31
+ // tuple form in opencode.json's `plugin` array does NOT actually forward the
32
+ // options object to TUI plugins in released opencode (1.15.x). Every config-
33
+ // heavy plugin in the ecosystem instead reads its own sidecar file with the
34
+ // plugin's slug as the filename. We follow that established pattern.
35
+ //
36
+ // Search order matches opencode's own project-then-global precedence:
37
+ // 1. ./.opencode/cache-timer.json (project override)
38
+ // 2. ~/.config/opencode/cache-timer.json (global)
39
+ function loadCacheTimerConfig(): CacheTimerConfig {
40
+ const candidatePaths = [
41
+ join(process.cwd(), ".opencode", "cache-timer.json"),
42
+ join(homedir(), ".config", "opencode", "cache-timer.json"),
43
+ ];
44
+
45
+ const merged: CacheTimerConfig = {};
46
+ for (const path of candidatePaths) {
47
+ if (!existsSync(path)) continue;
48
+ try {
49
+ const raw = readFileSync(path, "utf8");
50
+ const parsed = JSON.parse(raw);
51
+ if (parsed && typeof parsed === "object") {
52
+ debugLog(`loaded cache-timer config from ${path}: ${JSON.stringify(parsed)}`);
53
+ // Project-level (encountered first) wins over global for scalar fields.
54
+ if (merged.enableAutoPrompt === undefined && typeof parsed.enableAutoPrompt === "boolean") {
55
+ merged.enableAutoPrompt = parsed.enableAutoPrompt;
56
+ }
57
+ if (merged.defaultDuration === undefined && typeof parsed.defaultDuration === "number") {
58
+ merged.defaultDuration = parsed.defaultDuration;
59
+ }
60
+ // For the durations map, project values win per-key.
61
+ if (parsed.durations && typeof parsed.durations === "object") {
62
+ merged.durations = { ...parsed.durations, ...(merged.durations ?? {}) };
63
+ }
64
+ }
65
+ } catch (err) {
66
+ debugLog(`failed to read/parse ${path}: ${String(err)}`);
67
+ }
68
+ }
69
+ return merged;
70
+ }
71
+
72
+ // Default model cache durations in seconds, keyed by *provider family*.
73
+ // The matcher in getCacheDuration() does a case-insensitive substring match
74
+ // against the model id, so a single family key (e.g. "claude") covers every
75
+ // variant of that family (claude-3-5-sonnet, claude-opus-4-7, claude-haiku-4-5,
76
+ // etc.). Users override per-family via opencode.json `options.durations`.
77
+ let cacheDurations: Record<string, number> = {
78
+ "claude": 300,
79
+ "gemini": 300,
80
+ "gpt": 300,
81
+ };
82
+ let defaultDuration = 300; // Fallback to 5 minutes (300s)
83
+
84
+ // Helper to determine cache duration for a specific model ID
85
+ function getCacheDuration(modelId: string | undefined): number {
86
+ if (!modelId) return defaultDuration;
87
+
88
+ const normalizedId = modelId.toLowerCase();
89
+ for (const [key, duration] of Object.entries(cacheDurations)) {
90
+ if (normalizedId.includes(key)) {
91
+ return duration;
92
+ }
93
+ }
94
+ return defaultDuration;
95
+ }
96
+
97
+ // Helper to format remaining seconds as MM:SS
98
+ function formatTimeText(totalSeconds: number): string {
99
+ const minutes = Math.floor(totalSeconds / 60)
100
+ const calculatedSeconds = Math.floor(totalSeconds % 60)
101
+ return `${String(minutes).padStart(2, "0")}:${String(calculatedSeconds).padStart(2, "0")}`
102
+ }
103
+
104
+ // Module-level session-keyed states to ensure absolute isolation across multiple sessions
105
+ const triggeredSessions = new Set<string>();
106
+ const healthySessions = new Set<string>();
107
+ const lastUserMsgIds = new Map<string, string>();
108
+ const autoPromptIds = new Set<string>(); // Immutable ledger of all generated auto-prompt message IDs
109
+
110
+ // When we fire an auto-summary, we record the user-message count of the session
111
+ // AT trigger time. The next user message that appears beyond this count is
112
+ // definitionally the auto-prompt itself, so we ledger it as auto regardless of
113
+ // whether the session was "busy" during ledgering or whether the prompt API
114
+ // promise has already resolved. This eliminates the race where the auto-prompt's
115
+ // user-message id was never ledgered (because ticks during busy state were
116
+ // skipped) and was then incorrectly treated as a human input on the next tick,
117
+ // causing the trigger to re-arm and the summary to fire again in an infinite loop.
118
+ const pendingAutoPromptUserCount = new Map<string, number>();
119
+
120
+ const tui: TuiPlugin = async (api, _options, _meta) => {
121
+ // Auto-prompt is opt-in. Defaults stay safe.
122
+ let enableAutoPrompt = false;
123
+
124
+ debugLog("=== tui() invoked ===");
125
+ const userConfig = loadCacheTimerConfig();
126
+ debugLog(`resolved userConfig=${JSON.stringify(userConfig)}`);
127
+
128
+ if (typeof userConfig.defaultDuration === "number") {
129
+ defaultDuration = userConfig.defaultDuration;
130
+ }
131
+ if (userConfig.durations) {
132
+ cacheDurations = { ...cacheDurations, ...userConfig.durations };
133
+ }
134
+ if (typeof userConfig.enableAutoPrompt === "boolean") {
135
+ enableAutoPrompt = userConfig.enableAutoPrompt;
136
+ }
137
+
138
+ debugLog(`post-merge cacheDurations=${JSON.stringify(cacheDurations)} enableAutoPrompt=${enableAutoPrompt} defaultDuration=${defaultDuration}`);
139
+
140
+ api.ui.toast({
141
+ variant: "success",
142
+ title: "Cache Timer loaded",
143
+ duration: 3000,
144
+ });
145
+
146
+ api.slots.register({
147
+ slots: {
148
+ session_prompt_right(ctx, value) {
149
+ // Resolve initial active model duration
150
+ const session_id = value?.session_id;
151
+ const messagesOnMount = api.state.session.messages(session_id);
152
+ let initialDuration = defaultDuration;
153
+ if (messagesOnMount && messagesOnMount.length > 0) {
154
+ const lastMsg = messagesOnMount[messagesOnMount.length - 1];
155
+ const modelId = lastMsg.role === "user" ? lastMsg.model?.modelID : lastMsg.modelID;
156
+ initialDuration = getCacheDuration(modelId);
157
+ }
158
+
159
+ const [timeText, setTimeText] = createSignal(`Cache: HOT (${formatTimeText(initialDuration)})`)
160
+ const [color, setColor] = createSignal("#EF4444") // Red (HOT)
161
+
162
+ // Local interval ticker that directly updates this component's reactive signals.
163
+ // This guarantees the UI text updates flawlessly and never freezes.
164
+ const interval = setInterval(() => {
165
+ if (!session_id) return
166
+
167
+ try {
168
+ const sessionStatus = api.state.session.status(session_id);
169
+ const messages = api.state.session.messages(session_id);
170
+
171
+ // User-message ledgering MUST run every tick, including while the session
172
+ // is busy. If we skip it during busy, the auto-prompt's user-message id
173
+ // never gets recorded as "auto", and the next non-busy tick sees a "new"
174
+ // user message it treats as human, clearing the trigger lock and causing
175
+ // the auto-summary to fire again on the next cycle (infinite loop).
176
+ if (messages && messages.length > 0) {
177
+ const userMessages = messages.filter(m => m.role === "user");
178
+ if (userMessages.length > 0) {
179
+ const lastUserMsg = userMessages[userMessages.length - 1];
180
+ const prevUserMsgId = lastUserMsgIds.get(session_id);
181
+ if (lastUserMsg.id && prevUserMsgId !== lastUserMsg.id) {
182
+ lastUserMsgIds.set(session_id, lastUserMsg.id);
183
+
184
+ // Is this new user message ours (the auto-prompt) or a human's?
185
+ // It's ours iff:
186
+ // (a) we have a pending auto-prompt snapshot for this session, AND
187
+ // (b) the current user-message count exceeds that snapshot.
188
+ const expectedAutoCount = pendingAutoPromptUserCount.get(session_id);
189
+ const isAutoPrompt =
190
+ expectedAutoCount !== undefined &&
191
+ userMessages.length > expectedAutoCount;
192
+
193
+ if (isAutoPrompt) {
194
+ autoPromptIds.add(lastUserMsg.id);
195
+ // Consume the pending snapshot; further user messages beyond
196
+ // this point are real humans and should re-arm the trigger.
197
+ pendingAutoPromptUserCount.delete(session_id);
198
+ } else if (prevUserMsgId && !autoPromptIds.has(lastUserMsg.id)) {
199
+ // A human sent a fresh prompt: re-arm the auto-summary trigger.
200
+ triggeredSessions.delete(session_id);
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ // If the session is actively processing or streaming, freeze visual countdown and display "Busy"
207
+ if (sessionStatus?.type === "busy") {
208
+ setTimeText("Cache: HOT (Busy)")
209
+ setColor("#EF4444") // Red (HOT)
210
+ return
211
+ }
212
+
213
+ if (!messages || messages.length === 0) {
214
+ setTimeText("Cache: COLD")
215
+ setColor("#3B82F6") // Blue (COLD)
216
+ return
217
+ }
218
+
219
+ const lastMsg = messages[messages.length - 1]
220
+ const currentModelId = lastMsg.role === "user" ? lastMsg.model?.modelID : lastMsg.modelID;
221
+ const totalDurationSec = getCacheDuration(currentModelId);
222
+
223
+ // Robustly resolve the most recent valid timestamp in the session history (handles active streaming)
224
+ let lastTime: number | undefined;
225
+ for (let i = messages.length - 1; i >= 0; i--) {
226
+ const msg = messages[i];
227
+ const t = msg.time?.completed ?? msg.time?.created;
228
+ if (t) {
229
+ lastTime = t;
230
+ break;
231
+ }
232
+ }
233
+
234
+ if (!lastTime) {
235
+ setTimeText(`Cache: HOT (${formatTimeText(totalDurationSec)})`)
236
+ setColor("#EF4444") // Red (HOT)
237
+ return
238
+ }
239
+
240
+ const elapsedMs = Date.now() - lastTime
241
+ const remainingMs = totalDurationSec * 1000 - elapsedMs
242
+
243
+ if (remainingMs <= 0) {
244
+ setTimeText("Cache: COLD")
245
+ setColor("#3B82F6") // Blue (COLD)
246
+ } else {
247
+ const minutes = Math.floor(remainingMs / 1000 / 60)
248
+ const seconds = Math.floor((remainingMs / 1000) % 60)
249
+ const formattedTime = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
250
+ setTimeText(`Cache: HOT (${formattedTime})`)
251
+
252
+ // Dynamic color feedback: yellow when under 1 minute (almost cold)
253
+ if (remainingMs < 60 * 1000) {
254
+ setColor("#FBBF24") // Yellow (WARNING - almost cold)
255
+ } else {
256
+ setColor("#EF4444") // Red (HOT)
257
+ }
258
+
259
+ // Arm the trigger when the timer is healthy (above trigger zone)
260
+ if (remainingMs > 15 * 1000) {
261
+ healthySessions.add(session_id);
262
+ }
263
+
264
+ // Trigger auto-compaction 15 seconds before cache expires to utilize HOT cache.
265
+ // Only triggers if explicitly opted-in AND we have seen a healthy state this cycle (no stale triggers).
266
+ if (enableAutoPrompt && healthySessions.has(session_id) && remainingMs <= 15 * 1000 && remainingMs > 0 && !triggeredSessions.has(session_id)) {
267
+ triggeredSessions.add(session_id);
268
+ healthySessions.delete(session_id); // Require a new healthy cycle reset before triggering again
269
+
270
+ // Snapshot the user-message count BEFORE the auto-prompt lands.
271
+ // The next user message that appears beyond this count is
272
+ // definitionally our auto-prompt and will be ledgered as auto by
273
+ // the user-message branch above (which now runs every tick,
274
+ // including during the busy state that follows this call).
275
+ const userMsgCountAtTrigger = messages.filter(m => m.role === "user").length;
276
+ pendingAutoPromptUserCount.set(session_id, userMsgCountAtTrigger);
277
+
278
+ api.ui.toast({
279
+ variant: "warning",
280
+ title: "Cache Expiring",
281
+ message: "Cache expiring in 15s! Automatically generating summary to preserve hot cache.",
282
+ duration: 5000,
283
+ });
284
+
285
+ api.client.session.prompt({
286
+ sessionID: session_id,
287
+ parts: [{
288
+ type: "text",
289
+ text: "Summarize our progress and next steps. Be brief."
290
+ }]
291
+ }).then(() => {
292
+ api.ui.toast({
293
+ variant: "success",
294
+ title: "Summary Triggered",
295
+ message: "Successfully requested auto-summary to preserve prompt cache.",
296
+ duration: 5000,
297
+ });
298
+ }).catch((err) => {
299
+ // Roll back the snapshot if the prompt was rejected outright;
300
+ // no auto-prompt user message will ever land for this trigger.
301
+ pendingAutoPromptUserCount.delete(session_id);
302
+ api.ui.toast({
303
+ variant: "error",
304
+ title: "Summary Request Failed",
305
+ message: String(err?.message || err),
306
+ duration: 10000,
307
+ });
308
+ });
309
+ }
310
+ }
311
+ } catch (err) {
312
+ console.error("[Cache-Timer Error]", err);
313
+ }
314
+ }, 1000)
315
+
316
+ onCleanup(() => {
317
+ clearInterval(interval)
318
+ })
319
+
320
+ return (
321
+ <box paddingLeft={1} paddingRight={1}>
322
+ <text fg={color()}><b>{timeText()}</b></text>
323
+ </box>
324
+ )
325
+ }
326
+ }
327
+ })
328
+ }
329
+
330
+ const plugin: TuiPluginModule & { id: string } = {
331
+ id: "cache-timer",
332
+ tui,
333
+ }
334
+
335
+ export default plugin
package/index.js ADDED
@@ -0,0 +1,12 @@
1
+ const server = async (api, options) => {
2
+ // Return an empty hooks object to satisfy the backend loader constraint
3
+ return {};
4
+ };
5
+
6
+ const plugin = {
7
+ id: "cache-timer",
8
+ server,
9
+ };
10
+
11
+ export default plugin;
12
+ export { server };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "cache-timer",
3
+ "version": "1.0.0",
4
+ "description": "Pre-emptive prompt-cache saving extension for OpenCode",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./tui": "./tui.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "tui.js",
14
+ "cache-timer.tsx",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "node compile_plugin.js"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/ncejda-g2/opencode-cache-timer.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/ncejda-g2/opencode-cache-timer/issues"
28
+ },
29
+ "homepage": "https://github.com/ncejda-g2/opencode-cache-timer#readme",
30
+ "author": "Nicholas Cejda <ncejda@g2.com>",
31
+ "license": "MIT",
32
+ "keywords": [
33
+ "opencode",
34
+ "opencode-plugin",
35
+ "cache",
36
+ "claude",
37
+ "gemini",
38
+ "gpt",
39
+ "prompt-cache",
40
+ "tui"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ },
46
+ "devDependencies": {
47
+ "@babel/core": "^7.24.0",
48
+ "@babel/preset-typescript": "^7.24.0",
49
+ "babel-preset-solid": "^1.8.0"
50
+ }
51
+ }
package/tui.js ADDED
@@ -0,0 +1,320 @@
1
+ import { effect as _$effect } from "@opentui/solid";
2
+ import { insertNode as _$insertNode } from "@opentui/solid";
3
+ import { insert as _$insert } from "@opentui/solid";
4
+ import { setProp as _$setProp } from "@opentui/solid";
5
+ import { createElement as _$createElement } from "@opentui/solid";
6
+ /** @jsxImportSource @opentui/solid */
7
+
8
+ import { createSignal, onCleanup } from "solid-js";
9
+ import { appendFileSync, readFileSync, existsSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ // DEBUG: file-based logger. Useful when toast stacking obscures init details
14
+ // and as a permanent troubleshooting aid for the cache-timer config loader.
15
+ // Safe to leave in; never throws (writes silently to /tmp).
16
+ const DEBUG_LOG_PATH = "/tmp/cache-timer-debug.log";
17
+ function debugLog(line) {
18
+ try {
19
+ appendFileSync(DEBUG_LOG_PATH, `[${new Date().toISOString()}] ${line}\n`);
20
+ } catch {
21
+ // Intentionally swallow; debug instrumentation must never break the plugin.
22
+ }
23
+ }
24
+
25
+ // Shape of the optional sidecar config file users can drop next to opencode.json.
26
+
27
+ // Read cache-timer config from a sidecar JSON file. Surveys of real opencode
28
+ // plugins (opencode-dcp, opencode-vibeguard, opencode-sentry-monitor,
29
+ // opencode-notificator, etc.) confirm that the inline `["file://path", {...}]`
30
+ // tuple form in opencode.json's `plugin` array does NOT actually forward the
31
+ // options object to TUI plugins in released opencode (1.15.x). Every config-
32
+ // heavy plugin in the ecosystem instead reads its own sidecar file with the
33
+ // plugin's slug as the filename. We follow that established pattern.
34
+ //
35
+ // Search order matches opencode's own project-then-global precedence:
36
+ // 1. ./.opencode/cache-timer.json (project override)
37
+ // 2. ~/.config/opencode/cache-timer.json (global)
38
+ function loadCacheTimerConfig() {
39
+ const candidatePaths = [join(process.cwd(), ".opencode", "cache-timer.json"), join(homedir(), ".config", "opencode", "cache-timer.json")];
40
+ const merged = {};
41
+ for (const path of candidatePaths) {
42
+ if (!existsSync(path)) continue;
43
+ try {
44
+ const raw = readFileSync(path, "utf8");
45
+ const parsed = JSON.parse(raw);
46
+ if (parsed && typeof parsed === "object") {
47
+ debugLog(`loaded cache-timer config from ${path}: ${JSON.stringify(parsed)}`);
48
+ // Project-level (encountered first) wins over global for scalar fields.
49
+ if (merged.enableAutoPrompt === undefined && typeof parsed.enableAutoPrompt === "boolean") {
50
+ merged.enableAutoPrompt = parsed.enableAutoPrompt;
51
+ }
52
+ if (merged.defaultDuration === undefined && typeof parsed.defaultDuration === "number") {
53
+ merged.defaultDuration = parsed.defaultDuration;
54
+ }
55
+ // For the durations map, project values win per-key.
56
+ if (parsed.durations && typeof parsed.durations === "object") {
57
+ merged.durations = {
58
+ ...parsed.durations,
59
+ ...(merged.durations ?? {})
60
+ };
61
+ }
62
+ }
63
+ } catch (err) {
64
+ debugLog(`failed to read/parse ${path}: ${String(err)}`);
65
+ }
66
+ }
67
+ return merged;
68
+ }
69
+
70
+ // Default model cache durations in seconds, keyed by *provider family*.
71
+ // The matcher in getCacheDuration() does a case-insensitive substring match
72
+ // against the model id, so a single family key (e.g. "claude") covers every
73
+ // variant of that family (claude-3-5-sonnet, claude-opus-4-7, claude-haiku-4-5,
74
+ // etc.). Users override per-family via opencode.json `options.durations`.
75
+ let cacheDurations = {
76
+ "claude": 300,
77
+ "gemini": 300,
78
+ "gpt": 300
79
+ };
80
+ let defaultDuration = 300; // Fallback to 5 minutes (300s)
81
+
82
+ // Helper to determine cache duration for a specific model ID
83
+ function getCacheDuration(modelId) {
84
+ if (!modelId) return defaultDuration;
85
+ const normalizedId = modelId.toLowerCase();
86
+ for (const [key, duration] of Object.entries(cacheDurations)) {
87
+ if (normalizedId.includes(key)) {
88
+ return duration;
89
+ }
90
+ }
91
+ return defaultDuration;
92
+ }
93
+
94
+ // Helper to format remaining seconds as MM:SS
95
+ function formatTimeText(totalSeconds) {
96
+ const minutes = Math.floor(totalSeconds / 60);
97
+ const calculatedSeconds = Math.floor(totalSeconds % 60);
98
+ return `${String(minutes).padStart(2, "0")}:${String(calculatedSeconds).padStart(2, "0")}`;
99
+ }
100
+
101
+ // Module-level session-keyed states to ensure absolute isolation across multiple sessions
102
+ const triggeredSessions = new Set();
103
+ const healthySessions = new Set();
104
+ const lastUserMsgIds = new Map();
105
+ const autoPromptIds = new Set(); // Immutable ledger of all generated auto-prompt message IDs
106
+
107
+ // When we fire an auto-summary, we record the user-message count of the session
108
+ // AT trigger time. The next user message that appears beyond this count is
109
+ // definitionally the auto-prompt itself, so we ledger it as auto regardless of
110
+ // whether the session was "busy" during ledgering or whether the prompt API
111
+ // promise has already resolved. This eliminates the race where the auto-prompt's
112
+ // user-message id was never ledgered (because ticks during busy state were
113
+ // skipped) and was then incorrectly treated as a human input on the next tick,
114
+ // causing the trigger to re-arm and the summary to fire again in an infinite loop.
115
+ const pendingAutoPromptUserCount = new Map();
116
+ const tui = async (api, _options, _meta) => {
117
+ // Auto-prompt is opt-in. Defaults stay safe.
118
+ let enableAutoPrompt = false;
119
+ debugLog("=== tui() invoked ===");
120
+ const userConfig = loadCacheTimerConfig();
121
+ debugLog(`resolved userConfig=${JSON.stringify(userConfig)}`);
122
+ if (typeof userConfig.defaultDuration === "number") {
123
+ defaultDuration = userConfig.defaultDuration;
124
+ }
125
+ if (userConfig.durations) {
126
+ cacheDurations = {
127
+ ...cacheDurations,
128
+ ...userConfig.durations
129
+ };
130
+ }
131
+ if (typeof userConfig.enableAutoPrompt === "boolean") {
132
+ enableAutoPrompt = userConfig.enableAutoPrompt;
133
+ }
134
+ debugLog(`post-merge cacheDurations=${JSON.stringify(cacheDurations)} enableAutoPrompt=${enableAutoPrompt} defaultDuration=${defaultDuration}`);
135
+ api.ui.toast({
136
+ variant: "success",
137
+ title: "Cache Timer loaded",
138
+ duration: 3000
139
+ });
140
+ api.slots.register({
141
+ slots: {
142
+ session_prompt_right(ctx, value) {
143
+ // Resolve initial active model duration
144
+ const session_id = value?.session_id;
145
+ const messagesOnMount = api.state.session.messages(session_id);
146
+ let initialDuration = defaultDuration;
147
+ if (messagesOnMount && messagesOnMount.length > 0) {
148
+ const lastMsg = messagesOnMount[messagesOnMount.length - 1];
149
+ const modelId = lastMsg.role === "user" ? lastMsg.model?.modelID : lastMsg.modelID;
150
+ initialDuration = getCacheDuration(modelId);
151
+ }
152
+ const [timeText, setTimeText] = createSignal(`Cache: HOT (${formatTimeText(initialDuration)})`);
153
+ const [color, setColor] = createSignal("#EF4444"); // Red (HOT)
154
+
155
+ // Local interval ticker that directly updates this component's reactive signals.
156
+ // This guarantees the UI text updates flawlessly and never freezes.
157
+ const interval = setInterval(() => {
158
+ if (!session_id) return;
159
+ try {
160
+ const sessionStatus = api.state.session.status(session_id);
161
+ const messages = api.state.session.messages(session_id);
162
+
163
+ // User-message ledgering MUST run every tick, including while the session
164
+ // is busy. If we skip it during busy, the auto-prompt's user-message id
165
+ // never gets recorded as "auto", and the next non-busy tick sees a "new"
166
+ // user message it treats as human, clearing the trigger lock and causing
167
+ // the auto-summary to fire again on the next cycle (infinite loop).
168
+ if (messages && messages.length > 0) {
169
+ const userMessages = messages.filter(m => m.role === "user");
170
+ if (userMessages.length > 0) {
171
+ const lastUserMsg = userMessages[userMessages.length - 1];
172
+ const prevUserMsgId = lastUserMsgIds.get(session_id);
173
+ if (lastUserMsg.id && prevUserMsgId !== lastUserMsg.id) {
174
+ lastUserMsgIds.set(session_id, lastUserMsg.id);
175
+
176
+ // Is this new user message ours (the auto-prompt) or a human's?
177
+ // It's ours iff:
178
+ // (a) we have a pending auto-prompt snapshot for this session, AND
179
+ // (b) the current user-message count exceeds that snapshot.
180
+ const expectedAutoCount = pendingAutoPromptUserCount.get(session_id);
181
+ const isAutoPrompt = expectedAutoCount !== undefined && userMessages.length > expectedAutoCount;
182
+ if (isAutoPrompt) {
183
+ autoPromptIds.add(lastUserMsg.id);
184
+ // Consume the pending snapshot; further user messages beyond
185
+ // this point are real humans and should re-arm the trigger.
186
+ pendingAutoPromptUserCount.delete(session_id);
187
+ } else if (prevUserMsgId && !autoPromptIds.has(lastUserMsg.id)) {
188
+ // A human sent a fresh prompt: re-arm the auto-summary trigger.
189
+ triggeredSessions.delete(session_id);
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // If the session is actively processing or streaming, freeze visual countdown and display "Busy"
196
+ if (sessionStatus?.type === "busy") {
197
+ setTimeText("Cache: HOT (Busy)");
198
+ setColor("#EF4444"); // Red (HOT)
199
+ return;
200
+ }
201
+ if (!messages || messages.length === 0) {
202
+ setTimeText("Cache: COLD");
203
+ setColor("#3B82F6"); // Blue (COLD)
204
+ return;
205
+ }
206
+ const lastMsg = messages[messages.length - 1];
207
+ const currentModelId = lastMsg.role === "user" ? lastMsg.model?.modelID : lastMsg.modelID;
208
+ const totalDurationSec = getCacheDuration(currentModelId);
209
+
210
+ // Robustly resolve the most recent valid timestamp in the session history (handles active streaming)
211
+ let lastTime;
212
+ for (let i = messages.length - 1; i >= 0; i--) {
213
+ const msg = messages[i];
214
+ const t = msg.time?.completed ?? msg.time?.created;
215
+ if (t) {
216
+ lastTime = t;
217
+ break;
218
+ }
219
+ }
220
+ if (!lastTime) {
221
+ setTimeText(`Cache: HOT (${formatTimeText(totalDurationSec)})`);
222
+ setColor("#EF4444"); // Red (HOT)
223
+ return;
224
+ }
225
+ const elapsedMs = Date.now() - lastTime;
226
+ const remainingMs = totalDurationSec * 1000 - elapsedMs;
227
+ if (remainingMs <= 0) {
228
+ setTimeText("Cache: COLD");
229
+ setColor("#3B82F6"); // Blue (COLD)
230
+ } else {
231
+ const minutes = Math.floor(remainingMs / 1000 / 60);
232
+ const seconds = Math.floor(remainingMs / 1000 % 60);
233
+ const formattedTime = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
234
+ setTimeText(`Cache: HOT (${formattedTime})`);
235
+
236
+ // Dynamic color feedback: yellow when under 1 minute (almost cold)
237
+ if (remainingMs < 60 * 1000) {
238
+ setColor("#FBBF24"); // Yellow (WARNING - almost cold)
239
+ } else {
240
+ setColor("#EF4444"); // Red (HOT)
241
+ }
242
+
243
+ // Arm the trigger when the timer is healthy (above trigger zone)
244
+ if (remainingMs > 15 * 1000) {
245
+ healthySessions.add(session_id);
246
+ }
247
+
248
+ // Trigger auto-compaction 15 seconds before cache expires to utilize HOT cache.
249
+ // Only triggers if explicitly opted-in AND we have seen a healthy state this cycle (no stale triggers).
250
+ if (enableAutoPrompt && healthySessions.has(session_id) && remainingMs <= 15 * 1000 && remainingMs > 0 && !triggeredSessions.has(session_id)) {
251
+ triggeredSessions.add(session_id);
252
+ healthySessions.delete(session_id); // Require a new healthy cycle reset before triggering again
253
+
254
+ // Snapshot the user-message count BEFORE the auto-prompt lands.
255
+ // The next user message that appears beyond this count is
256
+ // definitionally our auto-prompt and will be ledgered as auto by
257
+ // the user-message branch above (which now runs every tick,
258
+ // including during the busy state that follows this call).
259
+ const userMsgCountAtTrigger = messages.filter(m => m.role === "user").length;
260
+ pendingAutoPromptUserCount.set(session_id, userMsgCountAtTrigger);
261
+ api.ui.toast({
262
+ variant: "warning",
263
+ title: "Cache Expiring",
264
+ message: "Cache expiring in 15s! Automatically generating summary to preserve hot cache.",
265
+ duration: 5000
266
+ });
267
+ api.client.session.prompt({
268
+ sessionID: session_id,
269
+ parts: [{
270
+ type: "text",
271
+ text: "Summarize our progress and next steps. Be brief."
272
+ }]
273
+ }).then(() => {
274
+ api.ui.toast({
275
+ variant: "success",
276
+ title: "Summary Triggered",
277
+ message: "Successfully requested auto-summary to preserve prompt cache.",
278
+ duration: 5000
279
+ });
280
+ }).catch(err => {
281
+ // Roll back the snapshot if the prompt was rejected outright;
282
+ // no auto-prompt user message will ever land for this trigger.
283
+ pendingAutoPromptUserCount.delete(session_id);
284
+ api.ui.toast({
285
+ variant: "error",
286
+ title: "Summary Request Failed",
287
+ message: String(err?.message || err),
288
+ duration: 10000
289
+ });
290
+ });
291
+ }
292
+ }
293
+ } catch (err) {
294
+ console.error("[Cache-Timer Error]", err);
295
+ }
296
+ }, 1000);
297
+ onCleanup(() => {
298
+ clearInterval(interval);
299
+ });
300
+ return (() => {
301
+ var _el$ = _$createElement("box"),
302
+ _el$2 = _$createElement("text"),
303
+ _el$3 = _$createElement("b");
304
+ _$insertNode(_el$, _el$2);
305
+ _$setProp(_el$, "paddingLeft", 1);
306
+ _$setProp(_el$, "paddingRight", 1);
307
+ _$insertNode(_el$2, _el$3);
308
+ _$insert(_el$3, timeText);
309
+ _$effect(_$p => _$setProp(_el$2, "fg", color(), _$p));
310
+ return _el$;
311
+ })();
312
+ }
313
+ }
314
+ });
315
+ };
316
+ const plugin = {
317
+ id: "cache-timer",
318
+ tui
319
+ };
320
+ export default plugin;