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.
- package/AGENTS.md +4 -2
- package/CONTRIBUTING.md +24 -8
- package/README.md +55 -22
- package/README.zh-CN.md +155 -92
- package/cache-hit.config.example.json +7 -2
- package/docs/assets/cache-hit-panel.v3.png +0 -0
- package/docs/en/design.md +30 -8
- package/docs/en/timeline-duplicate-writes.md +125 -0
- package/docs/en/timeline.md +26 -21
- package/docs/zh-CN/design.md +31 -9
- package/docs/zh-CN/timeline.md +28 -24
- package/package.json +1 -2
- package/scripts/README.md +64 -1
- package/scripts/plot-hit-rate.ts +4 -3
- package/scripts/timeline-dashboard.ts +728 -0
- package/src/agents-view.tsx +24 -10
- package/src/cache-ttl-view.tsx +128 -0
- package/src/format-cost.ts +83 -1
- package/src/format-model.ts +227 -0
- package/src/i18n.ts +18 -3
- package/src/load-config.ts +24 -5
- package/src/main-session-view.tsx +43 -3
- package/src/plugin-config.ts +59 -1
- package/src/plugin.tsx +4 -1
- package/src/pricing.ts +57 -0
- package/src/sidebar-host.tsx +13 -7
- package/src/stats.ts +6 -15
- package/src/timeline/collector.ts +40 -87
- package/src/timeline/records.ts +18 -29
- package/src/timeline/types.ts +3 -3
- package/src/timeline/writer.ts +5 -4
- package/src/tui-panel/README.md +2 -2
- package/src/tui-panel/README.zh-CN.md +2 -2
- package/src/tui-panel/components.tsx +31 -4
- package/src/tui-panel/index.ts +6 -1
- package/src/tui-panel/palette.ts +5 -0
- package/src/types.ts +16 -0
- package/src/use-cache-hit-metrics.ts +8 -9
- package/src/version.ts +4 -1
- package/src/widget.tsx +11 -3
- 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:
|
|
47
|
-
- Config file: `cache-hit.config.json` at plugin root (
|
|
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
|
|
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
|
-
**
|
|
205
|
+
**Pricing & Saved**
|
|
188
206
|
|
|
189
|
-
-
|
|
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)
|
package/docs/en/timeline.md
CHANGED
|
@@ -36,14 +36,14 @@ flowchart LR
|
|
|
36
36
|
```typescript
|
|
37
37
|
export type LlmCallRecord = {
|
|
38
38
|
schema: 1
|
|
39
|
-
recordedAt:
|
|
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:
|
|
46
|
-
completedAt?:
|
|
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
|
-
~/.
|
|
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.
|
|
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 →
|
|
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.
|
|
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
|
-
###
|
|
172
|
+
### Collection
|
|
173
173
|
|
|
174
|
-
-
|
|
175
|
-
- Switching main session:
|
|
176
|
-
-
|
|
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
|
|
184
|
+
participant C as timeline/collector
|
|
185
185
|
participant W as timeline/writer
|
|
186
186
|
|
|
187
|
-
E->>H:
|
|
188
|
-
H->>
|
|
189
|
-
alt
|
|
190
|
-
|
|
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
|
|
195
|
-
-
|
|
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=~/.
|
|
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":
|
|
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
|
```
|
package/docs/zh-CN/design.md
CHANGED
|
@@ -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:
|
|
47
|
-
-
|
|
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`
|
|
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
|
-
|
|
206
|
+
**单价与节省(Pricing & Saved)**
|
|
189
207
|
|
|
190
|
-
-
|
|
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
|
-
|
|
244
|
+
实现日志时使用 `message.updated` 事件直接驱动的 `handleMessage()`;落盘异步、勿阻塞 TUI(见 timeline.md)。
|
|
223
245
|
|
|
224
246
|
## 插件缓存
|
|
225
247
|
|
package/docs/zh-CN/timeline.md
CHANGED
|
@@ -37,8 +37,8 @@ flowchart LR
|
|
|
37
37
|
/** 单条记录;JSONL 一行一个 */
|
|
38
38
|
export type LlmCallRecord = {
|
|
39
39
|
schema: 1
|
|
40
|
-
/**
|
|
41
|
-
recordedAt:
|
|
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:
|
|
51
|
-
completedAt?:
|
|
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
|
-
~/.
|
|
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` 非空时可改到例如
|
|
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` | `""` |
|
|
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.
|
|
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
|
-
###
|
|
196
|
+
### 收集
|
|
197
197
|
|
|
198
|
-
-
|
|
199
|
-
-
|
|
200
|
-
-
|
|
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
|
|
208
|
+
participant C as timeline/collector
|
|
209
209
|
participant W as timeline/writer
|
|
210
210
|
|
|
211
|
-
E->>H:
|
|
212
|
-
H->>
|
|
213
|
-
|
|
214
|
-
|
|
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`
|
|
220
|
-
-
|
|
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=~/.
|
|
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
|
-
|
|
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":
|
|
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
|
|
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"
|