opencode-cache-hit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -40,8 +40,9 @@ After moving or renaming exports: run full `bun test`; `tests/module-load.test.t
40
40
  ## Configuration
41
41
 
42
42
  - Example: [cache-hit.config.example.json](cache-hit.config.example.json) — **included in npm** `files`.
43
- - Runtime: `cache-hit.config.json` beside package root (`CONFIG_PATH` in `load-config.ts`); **not** published; gitignored.
43
+ - Runtime: `~/.config/opencode/cache-hit.json` (preferred) or `cache-hit.config.json` beside package root (legacy fallback); **not** published; gitignored.
44
44
  - Defaults: [src/plugin-config.ts](src/plugin-config.ts).
45
+ - Timeline log dir default: `~/.local/share/opencode/logs/cache-hit/`. Supports `~/` expansion in `timeline.dir`.
45
46
 
46
47
  ## npm publish
47
48
 
package/CONTRIBUTING.md CHANGED
@@ -16,25 +16,41 @@ Coding agents: [AGENTS.md](AGENTS.md).
16
16
 
17
17
  After `bun install`, a **pre-push** hook runs `bun test` (skip with `git push --no-verify`). **CI** (GitHub Actions on `main` and PRs) runs the same tests on the server.
18
18
 
19
- ## Configuration file (local)
19
+ ## Configuration file
20
20
 
21
- The plugin reads **`cache-hit.config.json`** from the package root (`PLUGIN_ROOT`, same directory as `index.tsx`). Code defaults apply if the file is missing.
21
+ The plugin reads config from two locations (priority order):
22
+
23
+ 1. **XDG**: `~/.config/opencode/cache-hit.json` (preferred — persists across plugin updates)
24
+ 2. **Legacy**: `cache-hit.config.json` beside plugin root (backward compatible)
25
+
26
+ The XDG path is recommended for npm global installs; the legacy path still works for local installs and existing setups.
22
27
 
23
28
  | File | In npm tarball? | Purpose |
24
29
  |------|-----------------|--------|
25
30
  | `cache-hit.config.example.json` | **Yes** | Template; copy and edit |
26
- | `cache-hit.config.json` | **No** | Your overrides (gitignored here) |
27
- | `logs/` | **No** | Timeline output when enabled |
31
+ | `cache-hit.config.json` | **No** | Legacy plugin-root overrides |
32
+ | `logs/` | **No** | Timeline output (default: `~/.local/share/opencode/logs/cache-hit/`) |
28
33
 
29
- After OpenCode installs the package (npm or cache):
34
+ After installing:
30
35
 
31
36
  ```bash
32
- cd ~/.cache/opencode/packages/opencode-cache-hit@latest
33
- cp cache-hit.config.example.json cache-hit.config.json
37
+ cp cache-hit.config.example.json ~/.config/opencode/cache-hit.json
34
38
  # edit, then restart OpenCode
35
39
  ```
36
40
 
37
- For a **local path** in `tui.json`, put `cache-hit.config.json` in that folder (e.g. `~/.config/opencode/plugins/opencode-cache-hit/`).
41
+ For a **local path** plugin in `~/.config/opencode/plugins/opencode-cache-hit/`, placing `cache-hit.config.json` in that folder also works (matches the legacy fallback).
42
+
43
+ ## Versioning
44
+
45
+ Follow [Semantic Versioning](https://semver.org/):
46
+
47
+ | Change | Bump | Example |
48
+ |--------|------|---------|
49
+ | Bug fix (backward-compatible) | **patch** | `0.1.0` → `0.1.1` |
50
+ | New feature (backward-compatible) | **minor** | `0.1.0` → `0.2.0` |
51
+ | Breaking change | **major** | `0.2.0` → `1.0.0` |
52
+
53
+ Default changes (rate, paths) accompanied by new features → minor. Pure bugfixes only → patch.
38
54
 
39
55
  ## Publishing to npm
40
56
 
package/README.md CHANGED
@@ -19,7 +19,9 @@ Roadmap items (sidebar Timeline section, metric windows, nested sub-agents) are
19
19
 
20
20
  ## Acknowledgments
21
21
 
22
- This plugin is **not** part of opencode-visual-cache. Its sidebar layout, panel components (`src/tui-panel/`), and coexistence patterns are **heavily inspired by** [opencode-visual-cache](https://www.npmjs.com/package/opencode-visual-cache). visual-cache focuses on **main-session context / token distribution**; cache-hit focuses on **per-turn metrics and sub-agent totals**. We recommend installing both.
22
+ This plugin is **not** part of opencode-visual-cache. Its sidebar layout, panel components (`src/tui-panel/`), and coexistence patterns are **heavily inspired by** [opencode-visual-cache](https://www.npmjs.com/package/opencode-visual-cache). visual-cache focuses on **main-session context / token distribution**; cache-hit focuses on **per-turn metrics and sub-agent totals**.
23
+
24
+ The **cache TTL** feature (elapsed time display with color-coded status) is inspired by [opencode-cache-timer](https://github.com/nero-sensei/opencode-cache-timer) by nero-sensei. The original plugin provides a standalone sidebar countdown for prompt cache expiration; this plugin integrates the concept directly into the cache-hit panel.
23
25
 
24
26
  ## Screenshots
25
27
 
@@ -29,7 +31,7 @@ This plugin is **not** part of opencode-visual-cache. Its sidebar layout, panel
29
31
 
30
32
  - **Cache hit rate**: session total + **per-turn** rate with trend (↑ / ↓ / `-`) on the main block
31
33
  - **Token breakdown**: cache read / write / miss / output (aligned rows with visual-cache)
32
- - **Cost**: session cost with multi-currency config (`USD`, `CNY`, `EUR`, `GBP`, `JPY`)
34
+ - **Cost**: session cost with multi-currency config (`USD`, `CNY`, `EUR`, `GBP`, `JPY`); per-million rates and cache savings from provider config
33
35
  - **Sub-agents**: **Agents** section rolls up **child sessions only** (scope labeled in UI)
34
36
  - **Main + Agents**: main block always shown; **Agents** section when sub-agents exist (foldable)
35
37
  - **Collapsible sections**: Detail / Model (and Agents); theme-adaptive hit bar colors
@@ -44,8 +46,8 @@ This plugin is **not** part of opencode-visual-cache. Its sidebar layout, panel
44
46
  |---|----------------|-------------------|
45
47
  | Main session context / token **distribution** estimate | Yes | No — use visual-cache |
46
48
  | Per-role token breakdown (system / tools / …) | Yes | No |
47
- | Cache **savings** estimate | Yes | No |
48
- | Model **per-million** pricing from provider | Yes | Model name + session cost only |
49
+ | Cache **savings** estimate | Yes | Yes (from provider pricing) |
50
+ | Model **per-million** pricing from provider | Yes | Yes (from SDK provider config) |
49
51
  | **Slash commands** (`/cache-lang`, `/cache-currency`, …) | Yes | Config file only |
50
52
  | Fold state in `api.kv` | Yes | In-session (not persisted) |
51
53
  | Loaded **skills** panel | Yes | No |
@@ -57,7 +59,9 @@ This plugin is **not** part of opencode-visual-cache. Its sidebar layout, panel
57
59
 
58
60
  ### Option A: OpenCode command palette (recommended)
59
61
 
60
- `Ctrl+P` → **install plugin** → `opencode-cache-hit@latest` (when published) or your local path.
62
+ `Ctrl+P` → type **install plugin** → press `Tab` to switch scope to **global** (default is local) → type `opencode-cache-hit@latest` press Enter.
63
+
64
+ Global plugins install to `~/.cache/opencode/packages/opencode-cache-hit@latest/`. Create config at `~/.config/opencode/cache-hit.json`:
61
65
 
62
66
  ### Option B: Manual
63
67
 
@@ -146,11 +150,53 @@ Per assistant turn → JSONL. [docs/en/timeline.md](docs/en/timeline.md) · [中
146
150
  ```bash
147
151
  LOG=~/.config/opencode/plugins/opencode-cache-hit/logs/timeline-$(date +%Y-%m-%d).jsonl
148
152
  tail -f "$LOG"
153
+ # time fields are ISO 8601 strings with local timezone (e.g. "2024-05-30T08:00:00.000+08:00")
149
154
  jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost]|@tsv' "$LOG"
150
155
  ```
151
156
 
152
157
  Retention details: [Rotation and retention](docs/en/timeline.md#rotation-and-retention). Charts: [scripts/README.md](scripts/README.md).
153
158
 
159
+ ### Cache TTL (`cacheTTL`, default on)
160
+
161
+ Shows how long the prompt cache has been alive. Color changes when exceeding TTL:
162
+
163
+ - Green: elapsed < TTL
164
+ - Yellow: TTL ≤ elapsed < 2×TTL
165
+ - Red: elapsed ≥ 2×TTL
166
+
167
+ ```json
168
+ "cacheTTL": {
169
+ "enabled": true,
170
+ "providers": {
171
+ "anthropic": "5m",
172
+ "openai": "5m",
173
+ "deepseek": "2h",
174
+ "google": "1h"
175
+ }
176
+ }
177
+ ```
178
+
179
+ | Field | Default | Meaning |
180
+ |-------|---------|---------|
181
+ | `enabled` | `true` | Master switch |
182
+ | `providers` | `{}` | TTL per provider (or `provider:model`). Human-readable: `30s`, `5m`, `1.5h` |
183
+
184
+ **Built-in defaults** (used when provider not in config):
185
+
186
+ | Provider | Default TTL | Source |
187
+ |----------|-------------|--------|
188
+ | anthropic | 5 min | [Anthropic docs](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) |
189
+ | openai | 5 min | [OpenAI docs](https://platform.openai.com/docs/guides/prompt-caching) |
190
+ | deepseek | 2 hours | [DeepSeek docs](https://api-docs.deepseek.com/guides/kv_cache) |
191
+ | google | 1 hour | [Google docs](https://ai.google.dev/api/caching) |
192
+ | xai | 5 min | [xAI docs](https://docs.x.ai/developers/advanced-api-usage/prompt-caching) |
193
+ | minimax | 5 min | [MiniMax docs](https://platform.minimax.io/docs/api-reference/text-prompt-caching) |
194
+ | xiaomi | 5 min | Implicit caching |
195
+ | qwen | 5 min | Implicit caching |
196
+ | moonshot | 5 min | Implicit caching |
197
+
198
+ **Default TTL**: 5 minutes for all providers not listed above. Color changes based on elapsed time vs TTL: green (< TTL), yellow (TTL-2x TTL), red (≥ 2x TTL).
199
+
154
200
  ## Updating
155
201
 
156
202
  OpenCode may [cache plugins at first install](https://github.com/anomalyco/opencode/issues/6774) and not auto-refresh npm versions.
package/README.zh-CN.md CHANGED
@@ -24,7 +24,7 @@ OpenCode **TUI 侧边栏插件**:展示 prompt cache 命中率、token 用量
24
24
  - visual-cache:主 session **上下文 / token 分布预估**
25
25
  - cache-hit:**按轮次指标、成本、子 agent 汇总**
26
26
 
27
- 建议两个插件一起安装。
27
+ **缓存存活时间**功能(显示已存活时间 + 颜色状态)借鉴自 [opencode-cache-timer](https://github.com/nero-sensei/opencode-cache-timer)(作者:nero-sensei)。原插件提供独立的侧边栏倒计时;本插件将该概念直接集成到缓存命中面板中。
28
28
 
29
29
  ## 截图
30
30
 
@@ -46,7 +46,7 @@ OpenCode **TUI 侧边栏插件**:展示 prompt cache 命中率、token 用量
46
46
 
47
47
  - **命中率**:会话累计 + 主块**单轮**命中率与趋势
48
48
  - **Token 明细**:缓存读/写/未命中/输出
49
- - **费用**:多币种配置(`USD` / `CNY` / `EUR` / `GBP` / `JPY`)
49
+ - **费用**:多币种配置(`USD` / `CNY` / `EUR` / `GBP` / `JPY`);从 provider 配置读取百万 token 单价及缓存节省
50
50
  - **子 agent**:**Agents** 段仅汇总**子 session**(UI 有范围提示)
51
51
  - **主 session + Agents**:主块始终显示;有子 agent 时出现可折叠的 **Agents** 段
52
52
  - **可选时间轴**:按天 JSONL 落盘
@@ -58,7 +58,8 @@ OpenCode **TUI 侧边栏插件**:展示 prompt cache 命中率、token 用量
58
58
  | | visual-cache | cache-hit |
59
59
  |---|----------------|-----------|
60
60
  | 主 session 上下文 / Token **分布**估算 | 有 | 无 |
61
- | 按角色 Token 分布、缓存**节省**、百万 token **单价** | 有 | 无 |
61
+ | 按角色 Token 分布 | 有 | 无 |
62
+ | 缓存**节省**、百万 token **单价** | 有 | 有(读 provider 配置) |
62
63
  | **斜杠命令**改配置 | 有 | 仅配置文件 |
63
64
  | **子 agent** 汇总 | 无 | **有** |
64
65
  | 按次 **JSONL** | 无 | 可选 |
@@ -80,7 +81,9 @@ OpenCode **TUI 侧边栏插件**:展示 prompt cache 命中率、token 用量
80
81
 
81
82
  ## 安装
82
83
 
83
- **方式一:** `Ctrl+P` → install plugin → `opencode-cache-hit@latest`(发布后)或本地路径。
84
+ **方式一:** `Ctrl+P` → 输入 **install plugin**`Tab` 将范围切换为 **global**(默认是 local)→ 输入 `opencode-cache-hit@latest` → 回车。
85
+
86
+ 全局插件安装到 `~/.cache/opencode/packages/opencode-cache-hit@latest/`。在 `~/.config/opencode/cache-hit.json` 创建配置:
84
87
 
85
88
  **方式二:** 编辑 `~/.config/opencode/tui.json` / `tui.jsonc`:
86
89
 
@@ -153,6 +156,7 @@ OpenCode **TUI 侧边栏插件**:展示 prompt cache 命中率、token 用量
153
156
  ```fish
154
157
  set log ~/.config/opencode/plugins/opencode-cache-hit/logs/timeline-(date +%Y-%m-%d).jsonl
155
158
  tail -f $log
159
+ # 时间字段为 ISO 8601 含本地时区(如 "2024-05-30T08:00:00.000+08:00")
156
160
  jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost]|@tsv' $log
157
161
  ```
158
162
 
@@ -2,14 +2,14 @@
2
2
  "$comment": "lang: en | zh | auto (system locale). Default en.",
3
3
  "currency": "CNY",
4
4
  "costUnit": "USD",
5
- "rate": 7.2,
5
+ "rate": 6.77,
6
6
  "display": {
7
7
  "lang": "en",
8
8
  "panelBorder": true
9
9
  },
10
10
  "timeline": {
11
11
  "enabled": false,
12
- "dir": "",
12
+ "dir": "~/.local/share/opencode/logs/cache-hit",
13
13
  "flushIncomplete": false,
14
14
  "logSummaryMessages": true,
15
15
  "maxMemoryRows": 50,
@@ -18,5 +18,10 @@
18
18
  "retainRotated": 5,
19
19
  "maxAgeDays": 30,
20
20
  "maxLogFiles": 20
21
+ },
22
+ "cacheTTL": {
23
+ "enabled": true,
24
+ "providers": {
25
+ }
21
26
  }
22
27
  }
File without changes
package/docs/en/design.md CHANGED
@@ -43,8 +43,8 @@ flowchart TB
43
43
  ## Cost model
44
44
 
45
45
  - OpenCode: `msg.cost` accumulates assistant messages using **USD** list prices from `opencode.json`.
46
- - Plugin: `createCostFormatter(loadPluginConfig().cost)`; default `costUnit: USD` → `currency: CNY`, `rate: 7.2`.
47
- - Config file: `cache-hit.config.json` at plugin root (`load-config.ts`); defaults in `plugin-config.ts`.
46
+ - Plugin: `createCostFormatter(loadPluginConfig().cost)`; default `costUnit: USD` → `currency: CNY`, `rate: 6.77`.
47
+ - Config file: `~/.config/opencode/cache-hit.json` (preferred) or `cache-hit.config.json` at plugin root (legacy). Defaults in `plugin-config.ts`.
48
48
 
49
49
  ## Runtime architecture
50
50
 
@@ -184,9 +184,13 @@ Sum input and cache.read over assistant messages
184
184
  - `computePerCallHitTrend`: one rate per assistant turn; skip `summary: true`.
185
185
  - Display the **last** non-summary turn; compare to previous for ↑ / ↓ / `-`.
186
186
 
187
- **Combined Hit**
187
+ **Pricing & Saved**
188
188
 
189
- - Shown only when main block is visible, sub-agents exist, and combined vs session total differs by ≥ 0.05%.
189
+ - Provider pricing is read from `api.state.provider` (SDK runtime data, not hardcoded).
190
+ - `computePricing` looks up per-million rates by `providerID` + `modelID`; computes `saved = (inputRate - cacheReadRate) * cacheRead / 1M`.
191
+ - Main session: **Saved** row in Detail section; per-million rates (`/M in`, `/M cache`, `/M out`) in Model section.
192
+ - Agents section: **Saved** row sums savings across all child sessions (`computeSubsSaved`).
193
+ - All pricing rows hidden when rates are unavailable or saved is zero.
190
194
 
191
195
  ## Time fields (OpenCode SDK v2)
192
196
 
@@ -36,14 +36,14 @@ flowchart LR
36
36
  ```typescript
37
37
  export type LlmCallRecord = {
38
38
  schema: 1
39
- recordedAt: number // local write time (ms)
39
+ recordedAt: string // ISO 8601 with local timezone offset
40
40
  sessionId: string
41
41
  rootSessionId: string // main session; differs for child scope
42
42
  scope: "main" | "child"
43
43
  messageKey: string
44
44
  modelId: string
45
- created: number
46
- completedAt?: number
45
+ created: string
46
+ completedAt?: string
47
47
  durationMs?: number
48
48
  isComplete: boolean
49
49
  input: number
@@ -81,14 +81,14 @@ Module: `src/timeline/records.ts` — `buildCallRecords(sessionId, rootSessionId
81
81
  **Default layout**
82
82
 
83
83
  ```
84
- ~/.config/opencode/plugins/opencode-cache-hit/logs/
84
+ ~/.local/share/opencode/logs/cache-hit/
85
85
  timeline-2026-05-31.jsonl # one active file per local calendar day
86
86
  timeline-2026-05-31.jsonl.1 # size rotation backup for that day
87
87
  ```
88
88
 
89
89
  All main and child sessions for a day share one file; filter by `rootSessionId` / `sessionId` / `scope`. A new date gets a new filename at midnight.
90
90
 
91
- Optional `dir` (e.g. `~/.local/share/opencode/cache-hit/logs/`).
91
+ Optional `dir` (e.g. `~/my-logs/`). Supports `~/` expansion to home directory.
92
92
 
93
93
  **Legacy**: older builds used `<rootSessionId>.jsonl` per main session; not migrated automatically.
94
94
 
@@ -116,7 +116,7 @@ Example values above; code defaults below (`enabled: false`, rotation `0` except
116
116
  | Field | Code default | Description |
117
117
  |-------|--------------|-------------|
118
118
  | `enabled` | `false` | No IO when off |
119
- | `dir` | `""` | Empty → plugin `logs/` |
119
+ | `dir` | `""` | Empty → `~/.local/share/opencode/logs/cache-hit` |
120
120
  | `flushIncomplete` | `false` | Write only completed turns |
121
121
  | `logSummaryMessages` | `true` | Include summary rows (flagged) |
122
122
  | `maxMemoryRows` | `50` | In-memory rows for future UI |
@@ -200,8 +200,9 @@ sequenceDiagram
200
200
  ### Phase 1 — disk only (current)
201
201
 
202
202
  ```bash
203
- LOG=~/.config/opencode/plugins/opencode-cache-hit/logs/timeline-$(date +%Y-%m-%d).jsonl
203
+ LOG=~/.local/share/opencode/logs/cache-hit/timeline-$(date +%Y-%m-%d).jsonl
204
204
  tail -f "$LOG"
205
+ # time fields are ISO 8601 strings with local timezone (e.g. "2024-05-30T08:00:00.000+08:00")
205
206
  jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost]|@tsv' "$LOG"
206
207
  ```
207
208
 
@@ -249,5 +250,5 @@ bun scripts/plot-hit-rate.ts "$LOG" --by-root -o /tmp/hit-multi.svg
249
250
  ## Example line
250
251
 
251
252
  ```json
252
- {"schema":1,"recordedAt":1717000000000,"sessionId":"sess_main","rootSessionId":"sess_main","scope":"main","messageKey":"sess_main:1716999990000:deepseek/v4","modelId":"deepseek/v4","created":1716999990000,"completedAt":1717000000000,"durationMs":10000,"isComplete":true,"input":1200,"output":80,"reasoning":0,"cacheRead":38000,"cacheWrite":0,"cost":0.012,"hitPercent":96.9,"skippedForHit":false}
253
+ {"schema":1,"recordedAt":"2024-05-30T08:00:00.000+08:00","sessionId":"sess_main","rootSessionId":"sess_main","scope":"main","messageKey":"sess_main:1716999990000:deepseek/v4","modelId":"deepseek/v4","created":"2024-05-30T07:59:50.000+08:00","completedAt":"2024-05-30T08:00:00.000+08:00","durationMs":10000,"isComplete":true,"input":1200,"output":80,"reasoning":0,"cacheRead":38000,"cacheWrite":0,"cost":0.012,"hitPercent":96.9,"skippedForHit":false}
253
254
  ```
@@ -43,8 +43,8 @@ flowchart TB
43
43
  ## 成本模型
44
44
 
45
45
  - OpenCode:`msg.cost` = 按 `opencode.json` 中**美元**单价对 assistant 消息累加。
46
- - 插件:`createCostFormatter(loadPluginConfig().cost)`;默认 `costUnit: USD` → `currency: CNY`,`rate: 7.2`。
47
- - 配置路径:插件根目录 `cache-hit.config.json`(`load-config.ts`);缺省见 `plugin-config.ts` 的 `DEFAULT_PLUGIN_CONFIG`。
46
+ - 插件:`createCostFormatter(loadPluginConfig().cost)`;默认 `costUnit: USD` → `currency: CNY`,`rate: 6.77`。
47
+ - 配置路径:优先 `~/.config/opencode/cache-hit.json`,兜底插件根目录 `cache-hit.config.json`。缺省见 `plugin-config.ts` 的 `DEFAULT_PLUGIN_CONFIG`。
48
48
 
49
49
  ## 运行时架构
50
50
 
@@ -185,9 +185,13 @@ flowchart TD
185
185
  - `computePerCallHitTrend(messages)`:每条 assistant 一轮命中率;`summary: true` 跳过。
186
186
  - 展示**最后一条**非 summary 轮的命中率;与前一条比较得趋势(↑ / ↓ / `-`)。
187
187
 
188
- **Combined Hit**
188
+ **单价与节省(Pricing & Saved)**
189
189
 
190
- - 存在子 agent 且与会话累计命中率差异 ≥ 0.05% 时显示(主+子合并口径)。
190
+ - Provider 单价从 `api.state.provider`(SDK 运行时数据)读取,非硬编码。
191
+ - `computePricing` 根据 `providerID` + `modelID` 查找百万 token 单价;计算 `saved = (inputRate - cacheReadRate) * cacheRead / 1M`。
192
+ - 主 session:Detail 段展示 **Saved** 行;Model 段展示百万 token 单价(`/M 输入`、`/M 缓存`、`/M 输出`)。
193
+ - Agents 段:**Saved** 行汇总所有子 session 的节省金额(`computeSubsSaved`)。
194
+ - 单价不可用或节省为零时,所有 pricing 行隐藏。
191
195
 
192
196
  ## 时间字段(OpenCode SDK v2)
193
197
 
@@ -37,8 +37,8 @@ flowchart LR
37
37
  /** 单条记录;JSONL 一行一个 */
38
38
  export type LlmCallRecord = {
39
39
  schema: 1
40
- /** 写入时间(本机 ms),非 LLM 时间 */
41
- recordedAt: number
40
+ /** 写入时间(ISO 8601,含时区),非 LLM 时间 */
41
+ recordedAt: string
42
42
  /** 所属 session */
43
43
  sessionId: string
44
44
  /** 主 session id;子 session 时与 sessionId 不同 */
@@ -47,8 +47,8 @@ export type LlmCallRecord = {
47
47
  /** OpenCode message id;SDK 若无则用稳定合成键,见下文 */
48
48
  messageKey: string
49
49
  modelId: string
50
- created: number
51
- completedAt?: number
50
+ created: string
51
+ completedAt?: string
52
52
  durationMs?: number
53
53
  isComplete: boolean
54
54
  input: number
@@ -101,14 +101,14 @@ export function buildCallRecords(
101
101
  **默认路径(可配置)**
102
102
 
103
103
  ```
104
- ~/.config/opencode/plugins/opencode-cache-hit/logs/
104
+ ~/.local/share/opencode/logs/cache-hit/
105
105
  timeline-2026-05-31.jsonl # 按本地日历日一个活跃文件
106
106
  timeline-2026-05-31.jsonl.1 # 当日超过 rotateMaxBytes 时链式备份
107
107
  ```
108
108
 
109
109
  所有主/子 session 的调用写入**同一天**的同一文件;用行内 `rootSessionId` / `sessionId` / `scope` 筛某场对话。跨日自动切到新文件名。
110
110
 
111
- `dir` 非空时可改到例如 `~/.local/share/opencode/cache-hit/logs/`。
111
+ `dir` 非空时可改到例如 `~/my-logs/`,支持 `~/` 展开为 home 目录。
112
112
 
113
113
  推荐 **JSONL** 第一期:实现简单、`tail -f` / `jq` 友好;SQLite 留给第二期索引查询。
114
114
 
@@ -138,7 +138,7 @@ export function buildCallRecords(
138
138
  | 字段 | 代码默认 | 说明 |
139
139
  |------|----------|------|
140
140
  | `enabled` | `false` | 关闭时零 IO,不影响侧栏 |
141
- | `dir` | `""` | 空则用插件目录下 `logs/` |
141
+ | `dir` | `""` | 空则用 `~/.local/share/opencode/logs/cache-hit` |
142
142
  | `flushIncomplete` | `false` | 是否在未完成时写 JSONL |
143
143
  | `logSummaryMessages` | `true` | 是否记录 summary 行 |
144
144
  | `maxMemoryRows` | `50` | TUI 内存中保留条数(全量仍可从文件读) |
@@ -228,8 +228,9 @@ sequenceDiagram
228
228
  - 文档示例:
229
229
 
230
230
  ```bash
231
- LOG=~/.config/opencode/plugins/opencode-cache-hit/logs/timeline-$(date +%Y-%m-%d).jsonl
231
+ LOG=~/.local/share/opencode/logs/cache-hit/timeline-$(date +%Y-%m-%d).jsonl
232
232
  tail -f $LOG
233
+ # 时间字段为 ISO 8601 含本地时区(如 "2024-05-30T08:00:00.000+08:00")
233
234
  jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost]|@tsv' $LOG
234
235
  ```
235
236
 
@@ -293,7 +294,7 @@ bun scripts/plot-hit-rate.ts $LOG --by-root -o /tmp/hit-multi.svg
293
294
  ## 示例 JSONL 行
294
295
 
295
296
  ```json
296
- {"schema":1,"recordedAt":1717000000000,"sessionId":"sess_main","rootSessionId":"sess_main","scope":"main","messageKey":"sess_main:1716999990000:deepseek/v4","modelId":"deepseek/v4","created":1716999990000,"completedAt":1717000000000,"durationMs":10000,"isComplete":true,"input":1200,"output":80,"reasoning":0,"cacheRead":38000,"cacheWrite":0,"cost":0.012,"hitPercent":96.9,"skippedForHit":false}
297
+ {"schema":1,"recordedAt":"2024-05-30T08:00:00.000+08:00","sessionId":"sess_main","rootSessionId":"sess_main","scope":"main","messageKey":"sess_main:1716999990000:deepseek/v4","modelId":"deepseek/v4","created":"2024-05-30T07:59:50.000+08:00","completedAt":"2024-05-30T08:00:00.000+08:00","durationMs":10000,"isComplete":true,"input":1200,"output":80,"reasoning":0,"cacheRead":38000,"cacheWrite":0,"cost":0.012,"hitPercent":96.9,"skippedForHit":false}
297
298
  ```
298
299
 
299
300
  ---
package/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "opencode-cache-hit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode TUI sidebar: prompt cache hit rate, tokens & cost with sub-agent rollup. Works with opencode-visual-cache; optional per-call JSONL timeline.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "author": "zhumengzhu <mengzhu.loveyou@gmail.com>",
8
7
  "repository": {
9
8
  "type": "git",
10
9
  "url": "git+https://github.com/zhumengzhu/opencode-cache-hit.git"
package/scripts/README.md CHANGED
@@ -14,7 +14,7 @@ python3 -c "import json,sys; r=[json.loads(x) for x in open(sys.argv[1]) if x.st
14
14
  bun -e "const t=await Bun.file(process.argv[1]).text();const rows=t.trim().split('\n').filter(Boolean).map(l=>JSON.parse(l));const h=rows.map(r=>r.hitPercent).filter((p):p is number=>p!=null);console.log(rows.length+' calls, avg hit '+(h.reduce((a,b)=>a+b,0)/h.length).toFixed(1)+'%')" logs/timeline-2026-05-31.jsonl
15
15
  ```
16
16
 
17
- Export TSV for spreadsheets:
17
+ Export TSV for spreadsheets (time fields are ISO 8601 strings):
18
18
 
19
19
  ```bash
20
20
  jq -r 'select(.hitPercent!=null) | [.completedAt,.scope,.hitPercent,.cost]|@tsv' logs/timeline-2026-05-31.jsonl
@@ -10,8 +10,8 @@
10
10
  type Row = {
11
11
  rootSessionId?: string
12
12
  scope?: string
13
- created?: number
14
- completedAt?: number
13
+ created?: string
14
+ completedAt?: string
15
15
  hitPercent?: number | null
16
16
  skippedForHit?: boolean
17
17
  }
@@ -57,7 +57,8 @@ async function loadRecords(path: string, root?: string): Promise<Row[]> {
57
57
  }
58
58
 
59
59
  function timeOf(r: Row): number {
60
- return r.completedAt ?? r.created ?? 0
60
+ const ts = r.completedAt ?? r.created
61
+ return ts ? new Date(ts).getTime() : 0
61
62
  }
62
63
 
63
64
  function groupByRoot(rows: Row[]): Map<string, Row[]> {
@@ -3,9 +3,10 @@ import { For, Show } from "solid-js"
3
3
  import { TokenDetailRows } from "./cache-hit-rows.tsx"
4
4
  import type { CacheHitMetrics } from "./use-cache-hit-metrics.ts"
5
5
  import { aggregateSubAgents } from "./stats.ts"
6
+ import { computeSubsSaved } from "./pricing.ts"
6
7
  import { formatTokenCount } from "./format-tokens.ts"
7
8
  import { TuiMetricRow, truncateVisual, type PanelLayout } from "./tui-panel/index.ts"
8
- import type { SubAgentSummary } from "./types.ts"
9
+ import type { ProviderInfo, SubAgentSummary } from "./types.ts"
9
10
 
10
11
  function agentRowLabel(id: string, gauge: number): string {
11
12
  const tail = id.length > 10 ? id.slice(-8) : id
@@ -20,14 +21,27 @@ function subHasActivity(sub: SubAgentSummary): boolean {
20
21
  export function AgentsView(props: {
21
22
  m: CacheHitMetrics
22
23
  layout: PanelLayout
24
+ providers: ReadonlyArray<ProviderInfo>
23
25
  formatCost: (n: number) => string
24
26
  }) {
25
27
  const { m, layout } = props
26
28
  const total = () => aggregateSubAgents(m.subs())
27
29
 
30
+ const subsSaved = () => computeSubsSaved(m.subs(), props.providers)
31
+
28
32
  return (
29
33
  <>
30
- <TokenDetailRows pal={m.pal()} layout={layout} t={m.t()} snap={total()} />
34
+ <TokenDetailRows pal={m.pal()} layout={layout} t={m.t()} snap={total()}>
35
+ <Show when={subsSaved() > 0}>
36
+ <TuiMetricRow
37
+ pal={m.pal()}
38
+ layout={layout}
39
+ label={m.t().saved}
40
+ value={props.formatCost(subsSaved())}
41
+ fg={m.pal().success}
42
+ />
43
+ </Show>
44
+ </TokenDetailRows>
31
45
  <Show when={total().cost > 0}>
32
46
  <TuiMetricRow
33
47
  pal={m.pal()}
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Cache TTL elapsed time display.
3
+ * Inspired by opencode-cache-timer (https://github.com/nero-sensei/opencode-cache-timer)
4
+ * by nero-sensei.
5
+ */
6
+ /** @jsxImportSource @opentui/solid */
7
+ import { createMemo, createSignal, onCleanup, Show, type Accessor } from "solid-js"
8
+ import type { AssistantMessage } from "./types.ts"
9
+ import type { CacheTTLConfig } from "./plugin-config.ts"
10
+ import { parseDuration } from "./plugin-config.ts"
11
+ import type { PanelPalette, PanelLayout } from "./tui-panel/index.ts"
12
+ import { TuiMetricRow } from "./tui-panel/index.ts"
13
+
14
+ const SECOND = 1000
15
+ const MINUTE = 60 * SECOND
16
+ const HOUR = 60 * MINUTE
17
+
18
+ const DEFAULT_TTL_MS = 5 * MINUTE
19
+
20
+ const BUILT_IN_TTL: Record<string, number> = {
21
+ anthropic: 5 * MINUTE,
22
+ openai: 5 * MINUTE,
23
+ deepseek: 2 * HOUR,
24
+ google: 1 * HOUR,
25
+ xai: 5 * MINUTE,
26
+ minimax: 5 * MINUTE,
27
+ xiaomi: 5 * MINUTE,
28
+ qwen: 5 * MINUTE,
29
+ moonshot: 5 * MINUTE,
30
+ }
31
+
32
+ function findLastCacheActivity(messages: Accessor<AssistantMessage[]>): AssistantMessage | null {
33
+ const msgs = messages()
34
+ for (let i = msgs.length - 1; i >= 0; i--) {
35
+ const m = msgs[i]
36
+ if (
37
+ m.role === "assistant" &&
38
+ m.time?.completed !== undefined &&
39
+ ((m.tokens?.cache?.read ?? 0) > 0 || (m.tokens?.cache?.write ?? 0) > 0)
40
+ ) {
41
+ return m
42
+ }
43
+ }
44
+ return null
45
+ }
46
+
47
+ function getTTL(
48
+ providerID: string,
49
+ modelID: string,
50
+ config: CacheTTLConfig,
51
+ ): number {
52
+ const userProviders = config.providers
53
+ const specific = userProviders[`${providerID}:${modelID}`]
54
+ if (specific !== undefined) {
55
+ const parsed = parseDuration(specific)
56
+ if (parsed !== null) return parsed
57
+ }
58
+ const userProvider = userProviders[providerID]
59
+ if (userProvider !== undefined) {
60
+ const parsed = parseDuration(userProvider)
61
+ if (parsed !== null) return parsed
62
+ }
63
+ const builtIn = BUILT_IN_TTL[providerID]
64
+ if (builtIn !== undefined) return builtIn
65
+ return DEFAULT_TTL_MS
66
+ }
67
+
68
+ function formatElapsed(ms: number): string {
69
+ if (ms <= 0) return "0s"
70
+ const totalSeconds = Math.floor(ms / 1000)
71
+ const hours = Math.floor(totalSeconds / 3600)
72
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
73
+ const seconds = totalSeconds % 60
74
+ if (hours > 0) return `${hours}h ${minutes}m`
75
+ if (minutes > 0) return `${minutes}m ${seconds}s`
76
+ return `${seconds}s`
77
+ }
78
+
79
+ export function CacheTTLView(props: {
80
+ messages: Accessor<AssistantMessage[]>
81
+ config: CacheTTLConfig
82
+ pal: PanelPalette
83
+ layout: PanelLayout
84
+ label: string
85
+ }) {
86
+ const [now, setNow] = createSignal(Date.now())
87
+ const tick = setInterval(() => setNow(Date.now()), 1000)
88
+ onCleanup(() => clearInterval(tick))
89
+
90
+ const lastCache = createMemo(() => findLastCacheActivity(props.messages))
91
+
92
+ const ttlMs = createMemo(() => {
93
+ const m = lastCache()
94
+ if (!m || !m.providerID) return DEFAULT_TTL_MS
95
+ return getTTL(m.providerID, m.modelID ?? "", props.config)
96
+ })
97
+
98
+ const elapsed = createMemo(() => {
99
+ const m = lastCache()
100
+ if (!m || m.time.completed === undefined) return null
101
+ return now() - m.time.completed
102
+ })
103
+
104
+ const statusIcon = createMemo(() => {
105
+ const e = elapsed()
106
+ const ttl = ttlMs()
107
+ if (e === null) return ""
108
+ if (e < ttl) return "●"
109
+ if (e < ttl * 2) return "◐"
110
+ return "○"
111
+ })
112
+
113
+ const statusColor = createMemo(() => {
114
+ const e = elapsed()
115
+ const ttl = ttlMs()
116
+ if (e === null) return props.pal.textMuted
117
+ if (e < ttl) return props.pal.success
118
+ if (e < ttl * 2) return props.pal.warning
119
+ return props.pal.error
120
+ })
121
+
122
+ return (
123
+ <Show when={elapsed() !== null}>
124
+ <TuiMetricRow
125
+ pal={props.pal}
126
+ layout={props.layout}
127
+ label={props.label}
128
+ value={`${statusIcon()} ${formatElapsed(elapsed()!)}`}
129
+ fg={statusColor()}
130
+ />
131
+ </Show>
132
+ )
133
+ }