opencode-cache-hit 0.1.0 → 0.2.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.
Files changed (41) hide show
  1. package/AGENTS.md +4 -2
  2. package/CONTRIBUTING.md +24 -8
  3. package/README.md +55 -22
  4. package/README.zh-CN.md +155 -92
  5. package/cache-hit.config.example.json +7 -2
  6. package/docs/assets/cache-hit-panel.v3.png +0 -0
  7. package/docs/en/design.md +30 -8
  8. package/docs/en/timeline-duplicate-writes.md +125 -0
  9. package/docs/en/timeline.md +26 -21
  10. package/docs/zh-CN/design.md +31 -9
  11. package/docs/zh-CN/timeline.md +28 -24
  12. package/package.json +1 -2
  13. package/scripts/README.md +64 -1
  14. package/scripts/plot-hit-rate.ts +4 -3
  15. package/scripts/timeline-dashboard.ts +728 -0
  16. package/src/agents-view.tsx +24 -10
  17. package/src/cache-ttl-view.tsx +128 -0
  18. package/src/format-cost.ts +83 -1
  19. package/src/format-model.ts +227 -0
  20. package/src/i18n.ts +18 -3
  21. package/src/load-config.ts +24 -5
  22. package/src/main-session-view.tsx +43 -3
  23. package/src/plugin-config.ts +59 -1
  24. package/src/plugin.tsx +4 -1
  25. package/src/pricing.ts +57 -0
  26. package/src/sidebar-host.tsx +13 -7
  27. package/src/stats.ts +6 -15
  28. package/src/timeline/collector.ts +40 -87
  29. package/src/timeline/records.ts +18 -29
  30. package/src/timeline/types.ts +3 -3
  31. package/src/timeline/writer.ts +5 -4
  32. package/src/tui-panel/README.md +2 -2
  33. package/src/tui-panel/README.zh-CN.md +2 -2
  34. package/src/tui-panel/components.tsx +31 -4
  35. package/src/tui-panel/index.ts +6 -1
  36. package/src/tui-panel/palette.ts +5 -0
  37. package/src/types.ts +16 -0
  38. package/src/use-cache-hit-metrics.ts +8 -9
  39. package/src/version.ts +4 -1
  40. package/src/widget.tsx +11 -3
  41. package/docs/assets/cache-hit-panel.png +0 -0
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
 
@@ -57,8 +57,8 @@ sequenceDiagram
57
57
 
58
58
  Slot->>Host: sessionId, display, api
59
59
  Host->>API: session.list → childIds
60
- API-->>Host: message.updated
61
- Host->>Host: refreshTick++
60
+ API-->>Host: message.updated { info: Message }
61
+ Host->>Host: refreshTick++, timeline.handleMessage(info)
62
62
  Host->>API: session.messages(sid / cid)
63
63
  Host->>W: main, messages, subAgents
64
64
  W->>W: aggregate / format / TuiPanel
@@ -77,6 +77,7 @@ sequenceDiagram
77
77
  | `stats.ts` | Pure aggregation (no UI) |
78
78
  | `session-list.ts` | Parse `session.list`, `childSessionIdsForParent` |
79
79
  | `format-cost.ts` / `format-tokens.ts` / `format-cache-ui.ts` | Display formatting (`computeHitBarWidth` lives in `tui-panel/layout.ts`) |
80
+ | `format-model.ts` | Sub-agent row label (`formatSubAgentLabel`) and vendor brand colors (`modelRowColor`) |
80
81
  | `message-timing.ts` | SDK time fields |
81
82
  | `timeline/` | Per-call JSONL (`records` / `writer` / `collector`) |
82
83
  | `plugin-config.ts` / `load-config.ts` | Config normalization |
@@ -88,9 +89,9 @@ Reusable **page** skeleton aligned with visual-cache (layout, colors, foldable s
88
89
  | Module | Role |
89
90
  |--------|------|
90
91
  | `layout.ts` | Column widths, `justifyRow`, `computeHitBarWidth`, separators |
91
- | `palette.ts` | Theme → panel palette |
92
+ | `palette.ts` | Theme → panel palette; `toneBrandHex` for vendor colors on dark panels |
92
93
  | `use-panel-layout.ts` | `createPanelLayout`, `createSectionFold` |
93
- | `components.tsx` | `TuiPanel`, `TuiHitRow`, `TuiMetricRow`, … (`@opentui/solid`) |
94
+ | `components.tsx` | `TuiPanel`, `TuiHitRow`, `TuiMetricRow` (`labelFg` / `valueFg` split colors), … (`@opentui/solid`) |
94
95
  | `index.ts` | Barrel export |
95
96
 
96
97
  Import `layout.ts` / `palette.ts` directly from non-JSX code (e.g. `use-cache-hit-metrics`) so `bun test` smoke tests do not pull JSX.
@@ -130,6 +131,23 @@ flowchart TD
130
131
 
131
132
  The UI shows `agentsScopeHint` (“sub-sessions only”). This complements visual-cache’s main-session view—it is not a missing-aggregation bug.
132
133
 
134
+ ### Sub-agent row display
135
+
136
+ When multiple child sessions run in parallel, each active sub-agent gets a metric row under **Agents** (see screenshot `docs/assets/cache-hit-panel.v3.png`).
137
+
138
+ | Aspect | Behavior |
139
+ |--------|----------|
140
+ | **Purpose** | Distinguish which child session and which model in a narrow sidebar |
141
+ | **Label text** | `{displayModelName} …{sessionIdTail}` — same naming rules as the main **Model** row (`shortModelName` + date-suffix strip); **no** semantic nicknames (`ds-flash`, etc.) |
142
+ | **Truncation** | `gauge` ≈ measured panel width − border gutter; label budget subtracts right-side cost/tokens. Prefer keeping the **model prefix**; shrink ID tail (6 → 4 chars) before dropping the tail |
143
+ | **Label color** | Approximate **vendor brand** hex (`MODEL_BRAND_HEX` in `format-model.ts`), passed through `toneBrandHex` for legibility on dark terminals |
144
+ | **Value color** | `muted` — separate from label so row cost is not confused with Agents total **Cost** (`success` green) |
145
+ | **Family match** | `MODEL_FAMILY_RULES` (claude, deepseek, openai, gemini, qwen, glm, kimi, minimax, grok, mimo, meta, mistral); matching is **case-insensitive** on model slug and `providerID` |
146
+ | **Unknown models** | Stable hash → neutral fallback hues (never `success`) |
147
+ | **Config** | No `cache-hit.json` keys in v1; extend `MODEL_FAMILY_RULES` / `MODEL_BRAND_HEX` in code |
148
+
149
+ Implementation: `agents-view.tsx` calls `formatSubAgentLabel` + `modelRowColor`; `TuiMetricRow` uses `labelFg` / `valueFg`.
150
+
133
151
  ## Aggregation and refresh
134
152
 
135
153
  ### When values recompute
@@ -184,9 +202,13 @@ Sum input and cache.read over assistant messages
184
202
  - `computePerCallHitTrend`: one rate per assistant turn; skip `summary: true`.
185
203
  - Display the **last** non-summary turn; compare to previous for ↑ / ↓ / `-`.
186
204
 
187
- **Combined Hit**
205
+ **Pricing & Saved**
188
206
 
189
- - Shown only when main block is visible, sub-agents exist, and combined vs session total differs by ≥ 0.05%.
207
+ - Provider pricing is read from `api.state.provider` (SDK runtime data, not hardcoded).
208
+ - `computePricing` looks up per-million rates by `providerID` + `modelID`; computes `saved = (inputRate - cacheReadRate) * cacheRead / 1M`.
209
+ - Main session: **Saved** row in Detail section; per-million rates (`/M in`, `/M cache`, `/M out`) in Model section.
210
+ - Agents section: **Saved** row sums savings across all child sessions (`computeSubsSaved`).
211
+ - All pricing rows hidden when rates are unavailable or saved is zero.
190
212
 
191
213
  ## Time fields (OpenCode SDK v2)
192
214
 
@@ -0,0 +1,125 @@
1
+ # Timeline Duplicate Writes: Analysis and Fix
2
+
3
+ ## Overview
4
+
5
+ Restarting OpenCode used to cause previously-written timeline records to be re-appended to JSONL log files, creating duplicates. **Impact**: 2,727 duplicate records found across 1,218 unique message keys in production logs.
6
+
7
+ The fix eliminates polling entirely — using `message.updated` events as the data source instead of `api.state.session.messages()`.
8
+
9
+ ## Root Cause
10
+
11
+ The OpenCode TUI plugin API has two data access patterns:
12
+
13
+ | API | Behavior |
14
+ |-----|----------|
15
+ | `api.state.session.messages(sessionId)` | Returns **all** messages — no cursor, `since`, or limit |
16
+ | `api.event.on("message.updated", handler)` | Fires per-message, carries the **full `Message` object** (cost, tokens, time, modelID, providerID) — see [`types.gen.ts` L1064](https://github.com/anomalyco/opencode/blob/dev/packages/sdk/js/src/v2/gen/types.gen.ts#L1064) |
17
+
18
+ The original collector used the **polling path**: subscribe to `message.updated` → call `schedule()` (500ms debounce) → `collectNow()` → `getMessages()` (full history) → `buildCallRecords()` (all messages) → `shouldFlushToDisk()` (dedup filter). This required a `flushedKeys` Set to skip already-written records.
19
+
20
+ On restart, `flushedKeys` was lost (it lived in memory only). Every message in the session would be re-flushed to JSONL. A startup scan of all JSONL files was proposed to rebuild the set, adding complexity without addressing the core issue: **polling is the wrong pattern for a write-only log**.
21
+
22
+ ## Solution: Event-Driven Collection
23
+
24
+ The `message.updated` event fires once per message with the complete `Message` object. The collector subscribes to events directly — no polling, no dedup, no startup scan.
25
+
26
+ ```
27
+ message.updated({ sessionID, info: Message })
28
+
29
+ handleMessage(sessionID, info)
30
+
31
+ if (assistant && complete) → assistantMessageToRecord → appendFile
32
+ ```
33
+
34
+ ### Implementation
35
+
36
+ **`collector.ts`** — `handleMessage(sessionID, msg)`:
37
+
38
+ - Determines scope (`main` / `child`) by comparing `sessionID` against root and child IDs
39
+ - Skips non-assistant, incomplete (unless `flushIncomplete`), and summary messages (per config)
40
+ - Converts to `LlmCallRecord` via `assistantMessageToRecord`
41
+ - Writes immediately via `appendTimelineRecord` (fire-and-forget)
42
+ - Maintains a bounded in-memory cache (`memoryRecords`)
43
+
44
+ **`sidebar-host.tsx`** — event wiring:
45
+
46
+ ```typescript
47
+ const timeline = createTimelineCollector({
48
+ config: props.timeline,
49
+ getRootSessionId: () => props.sessionId,
50
+ getChildIds: childIds,
51
+ })
52
+
53
+ props.api.event.on("message.updated", (event) => {
54
+ const sid = event.properties?.info?.sessionID
55
+ if (sid && event.properties?.info) {
56
+ timeline.handleMessage(sid, event.properties.info as AssistantMessage)
57
+ }
58
+ })
59
+ ```
60
+
61
+ ### Key Properties
62
+
63
+ - **No polling**: Zero calls to `getMessages()`. Events drive all writes.
64
+ - **No dedup**: Each `message.updated` fires once per message. No `flushedKeys` Set, no JSONL scanning.
65
+ - **No startup scan**: The collector is stateless between restarts. Messages written before startup were already logged.
66
+ - **Concurrent-safe by design**: Each event is an independent append. `appendFile` uses `O_APPEND` — records (~300 bytes) are well under the 4KB `PIPE_BUF` atomic threshold.
67
+ - **Purge unchanged**: `maxAgeDays` / `maxLogFiles` / `maxLinesPerFile` / `rotateMaxBytes` still run as before on collector construction and per-write.
68
+
69
+ ### What #27663 means (and doesn't mean)
70
+
71
+ [Issue #27663](https://github.com/anomalyco/opencode/issues/27663) reports that `message.part.delta` (a **BusEvent**) is lost on the second `prompt_async` call. This does **not** affect the collector — `message.updated` is a **SyncEvent**, which the issue explicitly confirms is delivered correctly.
72
+
73
+ ## Performance
74
+
75
+ No startup cost. No memory overhead beyond the in-memory record cache (`maxMemoryRows`, default 50). No per-message IO beyond the JSONL append itself.
76
+
77
+ ## Limitations
78
+
79
+ Events are forward-only: messages existing before plugin load are not replayed. This is intentional — they were already written to JSONL during the previous session.
80
+
81
+ When `flushIncomplete` is enabled, a record written before completion will not be updated later. This is the same behavior as before and is documented in the config schema.
82
+
83
+ ## Upstream Context (as of 2026-06-02)
84
+
85
+ | Issue/PR | Relevance |
86
+ |----------|-----------|
87
+ | [PR #8535](https://github.com/anomalyco/opencode/pull/8535) — TUI paginated message loading | Open (conflicts). Adds cursor-based pagination to TUI internals. Does not expose pagination to plugin API. |
88
+ | [Issue #6548](https://github.com/anomalyco/opencode/issues/6548) — Paginated message loading feature request | Open. Discussion spawned PR #8535. Plugin API not mentioned. |
89
+ | [Issue #27663](https://github.com/anomalyco/opencode/issues/27663) — `message.part.delta` lost on second `prompt_async` | Open (v1.15.6). Affects BusEvents only; SyncEvents (`message.updated`) confirmed working. |
90
+ | [Issue #26097](https://github.com/anomalyco/opencode/issues/26097) — Plugin API: session projection adapters | Open. Session-level extensions, not message-level. |
91
+
92
+ No issue or PR proposes `messages(since)` for the plugin API. The `message.updated` event already carries full message data — this capability was underutilized by plugins.
93
+
94
+ ### Ecosystem Patterns
95
+
96
+ | Plugin | Strategy | Dedup? |
97
+ |--------|----------|--------|
98
+ | [opencode-visual-cache](https://github.com/Hotakus/opencode-visual-cache) | SolidJS `createEffect` — reactive full recompute | None |
99
+ | [opencode-usage](https://github.com/cosmiclasagnadev/opencode-usage) | Per-session reconciliation — clear and rebuild | None |
100
+ | **opencode-cache-hit** (this plugin) | Memory stats: `createMemo` full recompute | None |
101
+ | | Timeline writes: event-driven via `handleMessage` | None needed |
102
+
103
+ ### Reference Implementations
104
+
105
+ | Project | Pattern |
106
+ |---------|---------|
107
+ | OpenCode TUI `sync.tsx` | `message.updated` → `reconcile(info)` → update store |
108
+ | [DanWahlin/agent-sdk-core](https://github.com/DanWahlin/agent-sdk-core) | SSE `client.event.subscribe()` → per-event handler |
109
+ | [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent) | `client.event.subscribe()` + `client.session.messages()` hybrid |
110
+
111
+ ## Alternatives Considered
112
+
113
+ | Approach | Verdict |
114
+ |----------|---------|
115
+ | **Poll + `flushedKeys` Set (memory only)** | Original approach. Restart loses state → duplicates. |
116
+ | **Poll + JSONL startup scan** | Implemented on `fix/timeline-dedup-scan-jsonl`. Works correctly but adds scan logic for a problem that shouldn't exist. |
117
+ | **Separate `.flushed-keys` file** | Extra state file, extra IO per write, must sync with purge. |
118
+ | **Event-driven (current)** | `message.updated` carries full message. No polling, no dedup, no scan. ✅ |
119
+
120
+ ## References
121
+
122
+ - Linux `open(2)` man page — `O_APPEND` semantics
123
+ - Node.js `fs.appendFile` — uses `O_APPEND` internally
124
+ - `PIPE_BUF` — POSIX guarantee for atomic writes (4096 bytes on Linux)
125
+ - [OpenCode SDK types: `message.updated` event](https://github.com/anomalyco/opencode/blob/dev/packages/sdk/js/src/v2/gen/types.gen.ts#L1064)
@@ -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 |
@@ -131,7 +131,7 @@ Example values above; code defaults below (`enabled: false`, rotation `0` except
131
131
  1. Optional size roll **before** append.
132
132
  2. `appendFile` one JSON line.
133
133
  3. Optional line trim **after** append.
134
- 4. Async flush in `queueMicrotask`; `flushedKeys` dedupe by `messageKey` (not cleared on session switch; cleared on date change).
134
+ 4. Event-driven: `message.updated` `handleMessage()` fire-and-forget `appendFile`. No polling, no dedup.
135
135
 
136
136
  ## Rotation and retention
137
137
 
@@ -169,11 +169,11 @@ Does **not** match legacy `ses_*.jsonl` names.
169
169
 
170
170
  New filename after midnight; previous days remain until cleanup runs.
171
171
 
172
- ### Dedup and session switch
172
+ ### Collection
173
173
 
174
- - One append per `messageKey` per process (`flushedKeys`).
175
- - Switching main session: same day file; filter by `rootSessionId`.
176
- - **Restart** clears `flushedKeys`; completed messages may be written again (no persistent dedupe yet).
174
+ - `message.updated` event carries the full `Message` object. The collector subscribes directly — no polling, no dedup.
175
+ - Switching main session: `resetForRootChange()` clears in-memory cache; events for the new session arrive naturally.
176
+ - Restarts are safe: messages before startup were already written to JSONL in the previous session. No replay, no scan.
177
177
 
178
178
  ## Runtime wiring
179
179
 
@@ -181,27 +181,27 @@ New filename after midnight; previous days remain until cleanup runs.
181
181
  sequenceDiagram
182
182
  participant E as message.updated
183
183
  participant H as sidebar-host
184
- participant B as timeline/build
184
+ participant C as timeline/collector
185
185
  participant W as timeline/writer
186
186
 
187
- E->>H: refreshTick++
188
- H->>B: debounce 500ms buildCallRecords
189
- alt enabled and complete and not flushed
190
- B->>W: append JSONL
187
+ E->>H: { sessionID, info: Message }
188
+ H->>C: handleMessage(sessionID, info)
189
+ alt assistant and complete
190
+ C->>W: append JSONL (fire-and-forget)
191
191
  end
192
192
  ```
193
193
 
194
- - Child ids from `child-session-sync` / `session.list`; timeline only reads `messages()`.
195
- - Debounce 500ms when enabled.
196
- - Scope: current TUI root session + its children.
194
+ - Child ids from `child-session-sync` / `session.list`; timeline writes main and child sessions from events.
195
+ - No debounce, no polling. Scope: current TUI root session + its children.
197
196
 
198
197
  ## UI phases
199
198
 
200
199
  ### Phase 1 — disk only (current)
201
200
 
202
201
  ```bash
203
- LOG=~/.config/opencode/plugins/opencode-cache-hit/logs/timeline-$(date +%Y-%m-%d).jsonl
202
+ LOG=~/.local/share/opencode/logs/cache-hit/timeline-$(date +%Y-%m-%d).jsonl
204
203
  tail -f "$LOG"
204
+ # time fields are ISO 8601 strings with local timezone (e.g. "2024-05-30T08:00:00.000+08:00")
205
205
  jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost]|@tsv' "$LOG"
206
206
  ```
207
207
 
@@ -213,8 +213,13 @@ python3 -c "import json,sys; r=[json.loads(x) for x in open(sys.argv[1]) if x.st
213
213
 
214
214
  bun scripts/plot-hit-rate.ts "$LOG" -o /tmp/hit.svg
215
215
  bun scripts/plot-hit-rate.ts "$LOG" --by-root -o /tmp/hit-multi.svg
216
+
217
+ # interactive HTML dashboard (filters, Chart.js); add --open to launch browser
218
+ bun scripts/timeline-dashboard.ts --open
216
219
  ```
217
220
 
221
+ Default log dir matches `timeline.dir` in plugin config (`~/.local/share/opencode/logs/cache-hit/`).
222
+
218
223
  ### Phase 2 — sidebar Timeline section (planned)
219
224
 
220
225
  ### Phase 3 — metric window linkage (planned)
@@ -249,5 +254,5 @@ bun scripts/plot-hit-rate.ts "$LOG" --by-root -o /tmp/hit-multi.svg
249
254
  ## Example line
250
255
 
251
256
  ```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}
257
+ {"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
258
  ```
@@ -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
 
@@ -57,8 +57,8 @@ sequenceDiagram
57
57
 
58
58
  Slot->>Host: sessionId, display, api
59
59
  Host->>API: session.list → childIds
60
- API-->>Host: message.updated
61
- Host->>Host: refreshTick++
60
+ API-->>Host: message.updated { info: Message }
61
+ Host->>Host: refreshTick++, timeline.handleMessage(info)
62
62
  Host->>API: session.messages(sid / cid)
63
63
  Host->>W: main, messages, subAgents
64
64
  W->>W: aggregate / format / TuiPanel
@@ -77,6 +77,7 @@ sequenceDiagram
77
77
  | `stats.ts` | 纯函数聚合(无 UI) |
78
78
  | `session-list.ts` | `session.list` 响应解析、`childSessionIdsForParent` |
79
79
  | `format-cost.ts` / `format-tokens.ts` / `format-cache-ui.ts` | 展示格式化(**不含** `computeHitBarWidth`,其在 `tui-panel/layout.ts`) |
80
+ | `format-model.ts` | 子 agent 行 label(`formatSubAgentLabel`)与厂商品牌色(`modelRowColor`) |
80
81
  | `message-timing.ts` | SDK 时间字段辅助 |
81
82
  | `timeline/` | 按次 JSONL(`records` / `writer` / `collector`) |
82
83
  | `plugin-config.ts` / `load-config.ts` | 配置归一化与默认值 |
@@ -88,9 +89,9 @@ sequenceDiagram
88
89
  | 模块 | 职责 |
89
90
  |------|------|
90
91
  | `layout.ts` | 视觉列宽、`justifyRow`、`computeHitBarWidth`、分隔线 |
91
- | `palette.ts` | 主题色 → 面板调色板 |
92
+ | `palette.ts` | 主题色 → 面板调色板;`toneBrandHex` 用于深色面板上的厂商色 |
92
93
  | `use-panel-layout.ts` | `createPanelLayout`(测宽)、`createSectionFold` |
93
- | `components.tsx` | `TuiPanel`、`TuiHitRow`、`TuiMetricRow` 等(需 `@opentui/solid`) |
94
+ | `components.tsx` | `TuiPanel`、`TuiHitRow`、`TuiMetricRow`(`labelFg` / `valueFg` 分段上色)等(需 `@opentui/solid`) |
94
95
  | `index.ts` | 对外 barrel |
95
96
 
96
97
  纯逻辑模块(如 `use-cache-hit-metrics`)应从 `layout.ts` / `palette.ts` 直接 import,避免经 `index.ts` 拉入 JSX(便于 `bun test` 冒烟)。
@@ -130,6 +131,23 @@ flowchart TD
130
131
 
131
132
  主 session 若仍有编排类调用,其 token/费用不会出现在 Agents 合计中;UI 通过 `agentsScopeHint`(「仅子会话 / sub-sessions」)标明。与 visual-cache 对主 session 的展示互补,不是漏算 bug。
132
133
 
134
+ ### 子 session 行展示
135
+
136
+ 多个子 session 并行时,**Agents** 段下每个有活动的子 session 一行(见截图 `docs/assets/cache-hit-panel.v3.png`)。
137
+
138
+ | 方面 | 行为 |
139
+ |------|------|
140
+ | **目的** | 在窄侧栏中辨认「哪条子 session、用的什么模型」 |
141
+ | **Label 文案** | `{displayModelName} …{sessionIdTail}` — 与主块 **Model** 行同源(`shortModelName` + 去日期尾);**不做**语义缩写(如 `ds-flash`) |
142
+ | **截断** | `gauge` ≈ 实测面板宽 − 边框 gutter;label 预算再减去右侧 cost/tok。优先保留**模型前缀**;先缩短 ID 尾(6 → 4 字符) |
143
+ | **Label 颜色** | 厂商近似品牌色(`MODEL_BRAND_HEX`,经 `toneBrandHex` 压低饱和度以适配深色终端) |
144
+ | **金额颜色** | `muted`,与 label 分开,避免与 Agents 合计 **Cost** 的 `success` 绿色混淆 |
145
+ | **Family 匹配** | `MODEL_FAMILY_RULES`(claude、deepseek、openai、gemini、qwen、glm、kimi、minimax、grok、mimo、meta、mistral);模型 slug 与 `providerID` **大小写不敏感** |
146
+ | **未知模型** | 稳定 hash → 中性 fallback 色(不用 `success`) |
147
+ | **配置** | v1 无 `cache-hit.json` 项;在代码中扩展 `MODEL_FAMILY_RULES` / `MODEL_BRAND_HEX` |
148
+
149
+ 实现:`agents-view.tsx` 调用 `formatSubAgentLabel`、`modelRowColor`;`TuiMetricRow` 使用 `labelFg` / `valueFg`。
150
+
133
151
  ## 聚合与刷新
134
152
 
135
153
  ### 何时重算
@@ -185,9 +203,13 @@ flowchart TD
185
203
  - `computePerCallHitTrend(messages)`:每条 assistant 一轮命中率;`summary: true` 跳过。
186
204
  - 展示**最后一条**非 summary 轮的命中率;与前一条比较得趋势(↑ / ↓ / `-`)。
187
205
 
188
- **Combined Hit**
206
+ **单价与节省(Pricing & Saved)**
189
207
 
190
- - 存在子 agent 且与会话累计命中率差异 ≥ 0.05% 时显示(主+子合并口径)。
208
+ - Provider 单价从 `api.state.provider`(SDK 运行时数据)读取,非硬编码。
209
+ - `computePricing` 根据 `providerID` + `modelID` 查找百万 token 单价;计算 `saved = (inputRate - cacheReadRate) * cacheRead / 1M`。
210
+ - 主 session:Detail 段展示 **Saved** 行;Model 段展示百万 token 单价(`/M 输入`、`/M 缓存`、`/M 输出`)。
211
+ - Agents 段:**Saved** 行汇总所有子 session 的节省金额(`computeSubsSaved`)。
212
+ - 单价不可用或节省为零时,所有 pricing 行隐藏。
191
213
 
192
214
  ## 时间字段(OpenCode SDK v2)
193
215
 
@@ -219,7 +241,7 @@ flowchart TD
219
241
  | 指标切换 | 累计 / 最近 N 轮 / 滑动窗口;与时间轴 Phase 3 联动 | timeline.md § Phase 3 |
220
242
  | 子 agent | 递归子 session、按 agent 类型过滤 | timeline.md § 风险;侧栏另议 |
221
243
 
222
- 实现日志时继续复用 `message.updated` + `messages()`;落盘异步、勿阻塞 TUI(见 timeline.md)。
244
+ 实现日志时使用 `message.updated` 事件直接驱动的 `handleMessage()`;落盘异步、勿阻塞 TUI(见 timeline.md)。
223
245
 
224
246
  ## 插件缓存
225
247
 
@@ -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 内存中保留条数(全量仍可从文件读) |
@@ -153,7 +153,7 @@ export function buildCallRecords(
153
153
  1. 可选 `rotateMaxBytes`:写**前**若当日活跃文件 ≥ 阈值 → 链式 rename(见 § 轮转与清理)。
154
154
  2. `appendFile` 一行 JSON。
155
155
  3. 可选 `maxLinesPerFile`:写**后**读回活跃文件,只保留最后 N 行(**删行**,不生成 `.1`)。
156
- 4. 异步:`collector` `queueMicrotask` 里写盘;`flushedKeys` `messageKey` 去重(切换主 session **不**清空;跨日换文件名时清空)。
156
+ 4. 事件驱动:`message.updated` `handleMessage()` fire-and-forget `appendFile`。无轮询,无去重。
157
157
 
158
158
  ## 轮转与清理
159
159
 
@@ -193,11 +193,11 @@ export function buildCallRecords(
193
193
 
194
194
  午夜后自动写入新文件名;昨日文件保留,直至上述清理策略删除。
195
195
 
196
- ### 去重与切换 session
196
+ ### 收集
197
197
 
198
- - 同一 `messageKey` append 一次(进程内 `flushedKeys`)。
199
- - 切换 TUI session:仍写**同日**文件,用 `rootSessionId` 过滤;**不**因切换而重复写同一 `messageKey`。
200
- - 插件重启后 `flushedKeys` 为空,可能对**同一批已完成消息**再写一遍(若需避免,需另做持久化去重,当前未做)。
198
+ - `message.updated` 事件携带完整 `Message` 对象。collector 直接订阅事件——无轮询,无去重。
199
+ - 切换主 session:`resetForRootChange()` 清空内存缓存;新 session 的事件自然到达。
200
+ - 重启安全:启动前的消息已在上次 session 中写入 JSONL。无需回放,无需扫描。
201
201
 
202
202
  ## 运行时接入
203
203
 
@@ -205,20 +205,18 @@ export function buildCallRecords(
205
205
  sequenceDiagram
206
206
  participant E as message.updated
207
207
  participant H as sidebar-host
208
- participant B as timeline/build
208
+ participant C as timeline/collector
209
209
  participant W as timeline/writer
210
210
 
211
- E->>H: refreshTick++(现有)
212
- H->>B: debounce 500ms buildCallRecords(main+children)
213
- B->>H: 更新 memoryRecords(Signal)
214
- alt enabled and isComplete and not flushed
215
- B->>W: append JSONL
211
+ E->>H: { sessionID, info: Message }
212
+ H->>C: handleMessage(sessionID, info)
213
+ alt assistant and complete
214
+ C->>W: append JSONL(fire-and-forget)
216
215
  end
217
216
  ```
218
217
 
219
- - **与 `child-session-sync` 分工**:子 id 列表仍由 `session.list` 负责;时间轴只读 `messages()`,不额外 list
220
- - **Debounce**:500ms(比 child list 200ms 略长,减少流式写盘);仅 `timeline.enabled` 时注册。
221
- - **作用域**:只记录「当前 TUI 绑定的 `rootSessionId`」及其子 session;落盘路径按**当天**不变,切换主 session 仍写同一日文件。
218
+ - **与 `child-session-sync` 分工**:子 id 列表仍由 `session.list` 负责;时间轴从事件中写 main 和 child session
219
+ - debounce,无轮询。**作用域**:当前 TUI root session + 其子 session。
222
220
 
223
221
  ## UI(分阶段)
224
222
 
@@ -228,20 +226,26 @@ sequenceDiagram
228
226
  - 文档示例:
229
227
 
230
228
  ```bash
231
- LOG=~/.config/opencode/plugins/opencode-cache-hit/logs/timeline-$(date +%Y-%m-%d).jsonl
229
+ LOG=~/.local/share/opencode/logs/cache-hit/timeline-$(date +%Y-%m-%d).jsonl
232
230
  tail -f $LOG
231
+ # 时间字段为 ISO 8601 含本地时区(如 "2024-05-30T08:00:00.000+08:00")
233
232
  jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost]|@tsv' $LOG
234
233
  ```
235
234
 
236
- **画图(可选脚本)** — 见 [scripts/README.md](../../scripts/README.md):
235
+ **画图 / 分析(可选脚本)** — 见 [scripts/README.md](../../scripts/README.md):
237
236
 
238
237
  ```bash
239
238
  python3 -c "import json,sys; r=[json.loads(x) for x in open(sys.argv[1]) if x.strip()]; h=[x['hitPercent'] for x in r if x.get('hitPercent') is not None]; print(f\"{len(r)} calls, avg hit {sum(h)/len(h):.1f}%\")" $LOG
240
239
 
241
240
  bun scripts/plot-hit-rate.ts $LOG -o /tmp/hit.svg
242
241
  bun scripts/plot-hit-rate.ts $LOG --by-root -o /tmp/hit-multi.svg
242
+
243
+ # 交互式 HTML 仪表盘(筛选、Chart.js);加 --open 才会打开浏览器
244
+ bun scripts/timeline-dashboard.ts --open
243
245
  ```
244
246
 
247
+ 默认日志目录与插件 `timeline.dir` 一致(`~/.local/share/opencode/logs/cache-hit/`)。
248
+
245
249
  ### Phase 2 — 侧栏「Timeline」折叠段
246
250
 
247
251
  - 在 `widget.tsx` 增加 `TuiSection`,展示最近 `maxMemoryRows` 条(窄屏每行一条):
@@ -293,7 +297,7 @@ bun scripts/plot-hit-rate.ts $LOG --by-root -o /tmp/hit-multi.svg
293
297
  ## 示例 JSONL 行
294
298
 
295
299
  ```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}
300
+ {"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
301
  ```
298
302
 
299
303
  ---
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.1",
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"