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 +50 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/cache-timer.tsx +335 -0
- package/index.js +12 -0
- package/package.json +51 -0
- package/tui.js +320 -0
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>
|
package/cache-timer.tsx
ADDED
|
@@ -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
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;
|