@alex123bob/opencode-cache-stats 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Li
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,71 @@
1
+ # opencode-cache-stats
2
+
3
+ An [opencode](https://opencode.ai) plugin that displays a live **cache hit rate** widget
4
+ in the TUI sidebar and writes per-turn stats to a JSONL file.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm i opencode-cache-stats
10
+ ```
11
+
12
+ Then add to `~/.config/opencode/opencode.json`:
13
+
14
+ ```json
15
+ {
16
+ "plugin": ["opencode-cache-stats"]
17
+ }
18
+ ```
19
+
20
+ Restart opencode. After the first assistant response, the right-column sidebar shows:
21
+
22
+ ```
23
+ ── Cache ─────────────────────
24
+ Hit rate: 68.9%
25
+ Read: 1,240 tok
26
+ Written: 512 tok
27
+ Raw input: 308 tok
28
+ Output: 320 tok
29
+ Turns: 3
30
+ ```
31
+
32
+ ## Stats file
33
+
34
+ Each completed turn appends one JSON line to:
35
+
36
+ ```
37
+ ~/.config/opencode/cache-stats.jsonl
38
+ ```
39
+
40
+ Example record:
41
+
42
+ ```json
43
+ {"ts":"2026-07-13T10:23:01.000Z","sessionID":"ses_abc123","providerID":"anthropic","modelID":"claude-sonnet-4-6","turn":3,"cacheRead":1240,"cacheWrite":512,"inputRaw":308,"output":320,"totalInput":2060,"hitRate":60.2}
44
+ ```
45
+
46
+ ## Cache hit rate definition
47
+
48
+ ```
49
+ hitRate = cacheRead / (cacheRead + cacheWrite + inputRaw) × 100
50
+ ```
51
+
52
+ This is the fraction of total input tokens served from cache rather than processed fresh.
53
+
54
+ ## Provider compatibility
55
+
56
+ | Provider | cache read | cache write |
57
+ |---|---|---|
58
+ | Anthropic | Yes | Yes |
59
+ | OpenAI | Yes | Conditional |
60
+ | Google Vertex / Gemini | Yes | No |
61
+ | Amazon Bedrock | Yes | Yes |
62
+ | Groq | Yes | No |
63
+ | xAI (Grok) | Yes | No |
64
+ | Mistral | Yes | No |
65
+ | Cohere | No | No |
66
+
67
+ When cache data is unavailable the sidebar shows `No cache data` instead of a misleading 0%.
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,88 @@
1
+ import { mkdirSync, appendFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+
5
+ // src/shared.ts
6
+ var STATS_FILE = join(homedir(), ".config", "opencode", "cache-stats.jsonl");
7
+ function computeHitRate(cacheRead, totalInput) {
8
+ if (totalInput <= 0) return 0;
9
+ return Math.round(cacheRead / totalInput * 1e3) / 10;
10
+ }
11
+ function fmt(n) {
12
+ return n.toLocaleString();
13
+ }
14
+ function renderCacheStats(stats) {
15
+ if (!stats) return "";
16
+ const totalInput = stats.cacheRead + stats.cacheWrite + stats.inputRaw;
17
+ const sep = "\u2500\u2500 Cache " + "\u2500".repeat(20);
18
+ if (totalInput <= 0) {
19
+ return [
20
+ sep,
21
+ ` No cache data (turn ${stats.turnCount})`,
22
+ ` (provider may not report cache tokens)`
23
+ ].join("\n");
24
+ }
25
+ const hitRate = computeHitRate(stats.cacheRead, totalInput);
26
+ const lines = [
27
+ sep,
28
+ ` Hit rate: ${hitRate.toFixed(1)}%`,
29
+ ` Read: ${fmt(stats.cacheRead)} tok`
30
+ ];
31
+ if (stats.cacheWrite > 0) {
32
+ lines.push(` Written: ${fmt(stats.cacheWrite)} tok`);
33
+ }
34
+ lines.push(
35
+ ` Raw input: ${fmt(stats.inputRaw)} tok`,
36
+ ` Output: ${fmt(stats.output)} tok`,
37
+ ` Turns: ${stats.turnCount}`
38
+ );
39
+ return lines.join("\n");
40
+ }
41
+ var _statsDirEnsured = false;
42
+ function appendJsonl(record) {
43
+ try {
44
+ if (!_statsDirEnsured) {
45
+ mkdirSync(join(homedir(), ".config", "opencode"), { recursive: true });
46
+ _statsDirEnsured = true;
47
+ }
48
+ appendFileSync(STATS_FILE, JSON.stringify(record) + "\n", "utf8");
49
+ } catch {
50
+ }
51
+ }
52
+ function extractTokens(info) {
53
+ return {
54
+ cacheRead: info?.tokens?.cache?.read ?? 0,
55
+ cacheWrite: info?.tokens?.cache?.write ?? 0,
56
+ inputRaw: info?.tokens?.input ?? 0,
57
+ output: info?.tokens?.output ?? 0,
58
+ providerID: info?.providerID ?? "unknown",
59
+ modelID: info?.modelID ?? "unknown"
60
+ };
61
+ }
62
+ function isCompletedAssistant(event) {
63
+ if (event?.type !== "message.updated") return false;
64
+ const info = event?.properties?.info;
65
+ return info?.role === "assistant" && !!info?.time?.completed && !!info?.sessionID;
66
+ }
67
+ function accumulateStats(prev, tokens) {
68
+ const base = prev ?? {
69
+ cacheRead: 0,
70
+ cacheWrite: 0,
71
+ inputRaw: 0,
72
+ output: 0,
73
+ turnCount: 0,
74
+ providerID: tokens.providerID,
75
+ modelID: tokens.modelID
76
+ };
77
+ return {
78
+ cacheRead: base.cacheRead + tokens.cacheRead,
79
+ cacheWrite: base.cacheWrite + tokens.cacheWrite,
80
+ inputRaw: base.inputRaw + tokens.inputRaw,
81
+ output: base.output + tokens.output,
82
+ turnCount: base.turnCount + 1,
83
+ providerID: tokens.providerID,
84
+ modelID: tokens.modelID
85
+ };
86
+ }
87
+
88
+ export { accumulateStats, appendJsonl, computeHitRate, extractTokens, isCompletedAssistant, renderCacheStats };
@@ -0,0 +1,13 @@
1
+ import { Plugin } from '@opencode-ai/plugin';
2
+ import { TuiPlugin } from '@opencode-ai/plugin/tui';
3
+
4
+ declare const server: Plugin;
5
+ declare const tui: TuiPlugin;
6
+ declare const id = "opencode-cache-stats";
7
+ declare const _default: {
8
+ id: string;
9
+ server: Plugin;
10
+ tui: TuiPlugin;
11
+ };
12
+
13
+ export { _default as default, id, server, tui };
package/dist/index.js ADDED
@@ -0,0 +1,39 @@
1
+ import { isCompletedAssistant, extractTokens, accumulateStats, computeHitRate, appendJsonl } from './chunk-OFH6IIJI.js';
2
+
3
+ // src/index.ts
4
+ var server = async (_input) => {
5
+ const sessionStats = /* @__PURE__ */ new Map();
6
+ return {
7
+ event: async ({ event }) => {
8
+ if (!isCompletedAssistant(event)) return;
9
+ const info = event.properties.info;
10
+ const sessionID = info.sessionID;
11
+ const tokens = extractTokens(info);
12
+ const next = accumulateStats(sessionStats.get(sessionID), tokens);
13
+ sessionStats.set(sessionID, next);
14
+ const totalInput = tokens.cacheRead + tokens.cacheWrite + tokens.inputRaw;
15
+ const record = {
16
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
17
+ sessionID,
18
+ providerID: tokens.providerID,
19
+ modelID: tokens.modelID,
20
+ turn: next.turnCount,
21
+ cacheRead: tokens.cacheRead,
22
+ cacheWrite: tokens.cacheWrite,
23
+ inputRaw: tokens.inputRaw,
24
+ output: tokens.output,
25
+ totalInput,
26
+ hitRate: computeHitRate(tokens.cacheRead, totalInput)
27
+ };
28
+ appendJsonl(record);
29
+ }
30
+ };
31
+ };
32
+ var tui = async (...args) => {
33
+ const mod = await import('./tui.js');
34
+ return mod.tui(...args);
35
+ };
36
+ var id = "opencode-cache-stats";
37
+ var src_default = { id, server, tui };
38
+
39
+ export { src_default as default, id, server, tui };
package/dist/tui.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { TuiPlugin } from '@opencode-ai/plugin/tui';
2
+
3
+ declare const tui: TuiPlugin;
4
+
5
+ export { tui as default, tui };
package/dist/tui.js ADDED
@@ -0,0 +1,70 @@
1
+ import { isCompletedAssistant, extractTokens, accumulateStats, renderCacheStats } from './chunk-OFH6IIJI.js';
2
+
3
+ // src/tui.ts
4
+ var tui = async (api) => {
5
+ const sessionStats = /* @__PURE__ */ new Map();
6
+ const listeners = /* @__PURE__ */ new Set();
7
+ const bump = () => {
8
+ for (const fn of listeners) fn();
9
+ };
10
+ const subscribe = (fn) => {
11
+ listeners.add(fn);
12
+ return () => listeners.delete(fn);
13
+ };
14
+ const offMessage = api.event.on("message.updated", (evt) => {
15
+ if (!isCompletedAssistant(evt)) return;
16
+ const info = evt.properties.info;
17
+ const sessionID = info.sessionID;
18
+ const tokens = extractTokens(info);
19
+ sessionStats.set(sessionID, accumulateStats(sessionStats.get(sessionID), tokens));
20
+ bump();
21
+ });
22
+ let offSlots;
23
+ try {
24
+ let CacheStatsText2 = function(props) {
25
+ const sessionID = props.sessionID;
26
+ const propApi = props.api;
27
+ const propSub = props.subscribe;
28
+ if (!propSub || !propApi) return null;
29
+ let textNode;
30
+ const sync = () => {
31
+ if (!textNode) return;
32
+ const content = renderCacheStats(sessionStats.get(sessionID));
33
+ textNode.content = content;
34
+ textNode.visible = content.length > 0;
35
+ textNode.height = content.length > 0 ? "auto" : 0;
36
+ propApi.renderer.requestRender();
37
+ };
38
+ onCleanup(propSub(sync));
39
+ return jsx("text", {
40
+ ref: (ref) => {
41
+ textNode = ref;
42
+ sync();
43
+ },
44
+ fg: propApi.theme.current.textMuted,
45
+ children: renderCacheStats(sessionStats.get(sessionID)) ?? ""
46
+ });
47
+ };
48
+ var CacheStatsText = CacheStatsText2;
49
+ const { jsx } = await import('@opentui/solid/jsx-runtime');
50
+ const { onCleanup } = await import('solid-js');
51
+ const registration = api.slots.register({
52
+ slots: {
53
+ sidebar_content: (_ctx, slotProps) => jsx(CacheStatsText2, {
54
+ sessionID: slotProps.session_id ?? "",
55
+ api,
56
+ subscribe
57
+ })
58
+ }
59
+ });
60
+ offSlots = typeof registration === "function" ? registration : void 0;
61
+ } catch {
62
+ }
63
+ api.lifecycle.onDispose(() => {
64
+ offMessage();
65
+ offSlots?.();
66
+ });
67
+ };
68
+ var tui_default = tui;
69
+
70
+ export { tui_default as default, tui };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@alex123bob/opencode-cache-stats",
4
+ "version": "1.0.1",
5
+ "description": "opencode plugin — live cache hit rate widget in the TUI sidebar + per-session JSONL stats log",
6
+ "type": "module",
7
+ "author": "Alexander Li",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/alex123bob/opencode-cache-stats.git"
12
+ },
13
+ "homepage": "https://github.com/alex123bob/opencode-cache-stats#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/alex123bob/opencode-cache-stats/issues"
16
+ },
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./tui": {
26
+ "types": "./dist/tui.d.ts",
27
+ "import": "./dist/tui.js",
28
+ "default": "./dist/tui.js"
29
+ }
30
+ },
31
+ "files": ["dist", "README.md", "LICENSE"],
32
+ "keywords": ["opencode", "plugin", "cache", "llm", "tui", "cache-hit-rate"],
33
+ "engines": { "node": ">=22.13" },
34
+ "scripts": {
35
+ "build": "tsup",
36
+ "typecheck": "tsc --noEmit",
37
+ "prepublishOnly": "npm run typecheck && npm run build"
38
+ },
39
+ "peerDependencies": {
40
+ "@opencode-ai/plugin": ">=1.15.0 <2",
41
+ "@opentui/core": ">=0.4.0 <0.5",
42
+ "@opentui/solid": ">=0.4.0 <0.5",
43
+ "solid-js": ">=1.9.0 <2"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@opentui/core": { "optional": true },
47
+ "@opentui/solid": { "optional": true },
48
+ "solid-js": { "optional": true }
49
+ },
50
+ "devDependencies": {
51
+ "@opencode-ai/plugin": "^1.17.18",
52
+ "@opentui/core": "^0.4.3",
53
+ "@opentui/solid": "^0.4.3",
54
+ "@types/node": "^24.0.0",
55
+ "solid-js": "^1.9.14",
56
+ "tsup": "^8.5.1",
57
+ "typescript": "^5.8.3"
58
+ }
59
+ }