opencode-cache-hit 0.2.0 → 0.2.2
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 +3 -1
- package/README.md +5 -18
- package/README.zh-CN.md +155 -96
- package/docs/README.md +4 -4
- package/docs/assets/cache-hit-panel.v3.png +0 -0
- package/docs/en/design.md +30 -11
- package/docs/en/frontend-migration-plan.md +100 -0
- package/docs/en/timeline-duplicate-writes.md +125 -0
- package/docs/en/timeline.md +17 -13
- package/docs/zh-CN/design.md +31 -12
- package/docs/zh-CN/frontend-migration-plan.md +100 -0
- package/docs/zh-CN/timeline.md +18 -15
- package/package.json +1 -1
- package/scripts/README.md +63 -0
- package/scripts/timeline-dashboard.ts +728 -0
- package/src/agents-view.tsx +8 -8
- package/src/cache-ttl-view.tsx +3 -8
- package/src/format-cost.ts +70 -0
- package/src/format-model.ts +227 -0
- package/src/sidebar-host.tsx +34 -12
- package/src/stats.ts +41 -1
- package/src/timeline/collector.ts +40 -87
- package/src/timeline/records.ts +0 -30
- 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 +14 -1
- package/src/version.ts +4 -1
- package/docs/assets/.gitkeep +0 -0
- package/docs/assets/cache-hit-panel.png +0 -0
|
@@ -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 up to 100 messages (capped by TUI sync); no cursor, `since`, or limit parameter on the plugin side |
|
|
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()` (TUI-synced window, up to 100 messages) → `buildCallRecords()` (all returned 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
|
@@ -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,19 +181,18 @@ 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
|
|
|
@@ -214,8 +213,13 @@ python3 -c "import json,sys; r=[json.loads(x) for x in open(sys.argv[1]) if x.st
|
|
|
214
213
|
|
|
215
214
|
bun scripts/plot-hit-rate.ts "$LOG" -o /tmp/hit.svg
|
|
216
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
|
|
217
219
|
```
|
|
218
220
|
|
|
221
|
+
Default log dir matches `timeline.dir` in plugin config (`~/.local/share/opencode/logs/cache-hit/`).
|
|
222
|
+
|
|
219
223
|
### Phase 2 — sidebar Timeline section (planned)
|
|
220
224
|
|
|
221
225
|
### Phase 3 — metric window linkage (planned)
|
package/docs/zh-CN/design.md
CHANGED
|
@@ -57,9 +57,10 @@ 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
|
|
62
|
-
Host->>API: session.
|
|
60
|
+
API-->>Host: message.updated { info: Message }
|
|
61
|
+
Host->>Host: refreshTick++, timeline.handleMessage(info)
|
|
62
|
+
Host->>API: session.get(sid / cid) → 聚合值
|
|
63
|
+
Host->>API: session.messages(sid / cid) → fallback / 趋势
|
|
63
64
|
Host->>W: main, messages, subAgents
|
|
64
65
|
W->>W: aggregate / format / TuiPanel
|
|
65
66
|
```
|
|
@@ -77,6 +78,7 @@ sequenceDiagram
|
|
|
77
78
|
| `stats.ts` | 纯函数聚合(无 UI) |
|
|
78
79
|
| `session-list.ts` | `session.list` 响应解析、`childSessionIdsForParent` |
|
|
79
80
|
| `format-cost.ts` / `format-tokens.ts` / `format-cache-ui.ts` | 展示格式化(**不含** `computeHitBarWidth`,其在 `tui-panel/layout.ts`) |
|
|
81
|
+
| `format-model.ts` | 子 agent 行 label(`formatSubAgentLabel`)与厂商品牌色(`modelRowColor`) |
|
|
80
82
|
| `message-timing.ts` | SDK 时间字段辅助 |
|
|
81
83
|
| `timeline/` | 按次 JSONL(`records` / `writer` / `collector`) |
|
|
82
84
|
| `plugin-config.ts` / `load-config.ts` | 配置归一化与默认值 |
|
|
@@ -88,9 +90,9 @@ sequenceDiagram
|
|
|
88
90
|
| 模块 | 职责 |
|
|
89
91
|
|------|------|
|
|
90
92
|
| `layout.ts` | 视觉列宽、`justifyRow`、`computeHitBarWidth`、分隔线 |
|
|
91
|
-
| `palette.ts` | 主题色 →
|
|
93
|
+
| `palette.ts` | 主题色 → 面板调色板;`toneBrandHex` 用于深色面板上的厂商色 |
|
|
92
94
|
| `use-panel-layout.ts` | `createPanelLayout`(测宽)、`createSectionFold` |
|
|
93
|
-
| `components.tsx` | `TuiPanel`、`TuiHitRow`、`TuiMetricRow`
|
|
95
|
+
| `components.tsx` | `TuiPanel`、`TuiHitRow`、`TuiMetricRow`(`labelFg` / `valueFg` 分段上色)等(需 `@opentui/solid`) |
|
|
94
96
|
| `index.ts` | 对外 barrel |
|
|
95
97
|
|
|
96
98
|
纯逻辑模块(如 `use-cache-hit-metrics`)应从 `layout.ts` / `palette.ts` 直接 import,避免经 `index.ts` 拉入 JSX(便于 `bun test` 冒烟)。
|
|
@@ -112,14 +114,14 @@ flowchart TD
|
|
|
112
114
|
F -->|否| R
|
|
113
115
|
C --> H[childSessionIdsForParent]
|
|
114
116
|
C2 --> H
|
|
115
|
-
H --> I[
|
|
117
|
+
H --> I["session.get 聚合 + messages 兜底"]
|
|
116
118
|
```
|
|
117
119
|
|
|
118
120
|
- **唯一来源**:`childIds` 始终由 `session.list` 结果**覆盖**写入,不再 `session.get` + 追加(避免漏发现、僵尸 id)。
|
|
119
121
|
- **竞态**:`listGen` 在 parent 切换时递增;回调校验 generation 与 `parentId` 未变。
|
|
120
122
|
- **流式**:外国 session 的 `message.updated` 很密,用 `CHILD_LIST_DEBOUNCE_MS`(200ms)合并 list 请求。
|
|
121
123
|
- **一层子 session**:`parentID === sid` 的直接子节点;嵌套子 agent 见「未来方向」。
|
|
122
|
-
- **子 session
|
|
124
|
+
- **子 session 数据**:`session.get(cid)` → `aggregateFromSessionObject`(主路径);不可用时降级到 `messages(cid)` → `aggregateSessionFromMessages`。若 session 聚合缺少 model/provider 元数据,从 messages 末尾补齐(`withModelFallback`)。
|
|
123
125
|
|
|
124
126
|
### Agents 合计语义(实现正确,勿与「全场总账」混淆)
|
|
125
127
|
|
|
@@ -130,18 +132,35 @@ flowchart TD
|
|
|
130
132
|
|
|
131
133
|
主 session 若仍有编排类调用,其 token/费用不会出现在 Agents 合计中;UI 通过 `agentsScopeHint`(「仅子会话 / sub-sessions」)标明。与 visual-cache 对主 session 的展示互补,不是漏算 bug。
|
|
132
134
|
|
|
135
|
+
### 子 session 行展示
|
|
136
|
+
|
|
137
|
+
多个子 session 并行时,**Agents** 段下每个有活动的子 session 一行(见截图 `docs/assets/cache-hit-panel.v3.png`)。
|
|
138
|
+
|
|
139
|
+
| 方面 | 行为 |
|
|
140
|
+
|------|------|
|
|
141
|
+
| **目的** | 在窄侧栏中辨认「哪条子 session、用的什么模型」 |
|
|
142
|
+
| **Label 文案** | `{displayModelName} …{sessionIdTail}` — 与主块 **Model** 行同源(`shortModelName` + 去日期尾);**不做**语义缩写(如 `ds-flash`) |
|
|
143
|
+
| **截断** | `gauge` ≈ 实测面板宽 − 边框 gutter;label 预算再减去右侧 cost/tok。优先保留**模型前缀**;先缩短 ID 尾(6 → 4 字符) |
|
|
144
|
+
| **Label 颜色** | 厂商近似品牌色(`MODEL_BRAND_HEX`,经 `toneBrandHex` 压低饱和度以适配深色终端) |
|
|
145
|
+
| **金额颜色** | `muted`,与 label 分开,避免与 Agents 合计 **Cost** 的 `success` 绿色混淆 |
|
|
146
|
+
| **Family 匹配** | `MODEL_FAMILY_RULES`(claude、deepseek、openai、gemini、qwen、glm、kimi、minimax、grok、mimo、meta、mistral);模型 slug 与 `providerID` **大小写不敏感** |
|
|
147
|
+
| **未知模型** | 稳定 hash → 中性 fallback 色(不用 `success`) |
|
|
148
|
+
| **配置** | v1 无 `cache-hit.json` 项;在代码中扩展 `MODEL_FAMILY_RULES` / `MODEL_BRAND_HEX` |
|
|
149
|
+
|
|
150
|
+
实现:`agents-view.tsx` 调用 `formatSubAgentLabel`、`modelRowColor`;`TuiMetricRow` 使用 `labelFg` / `valueFg`。
|
|
151
|
+
|
|
133
152
|
## 聚合与刷新
|
|
134
153
|
|
|
135
154
|
### 何时重算
|
|
136
155
|
|
|
137
156
|
| 数据 | 触发方式 |
|
|
138
157
|
|------|----------|
|
|
139
|
-
| 主 session snapshot | `createMemo` 内读 `refreshTick` + `
|
|
140
|
-
| 主 session 消息列表(Hit 趋势) |
|
|
141
|
-
| 子 agent 列表内容 | `
|
|
158
|
+
| 主 session snapshot | `createMemo` 内读 `refreshTick` + `session.get(sid)`(聚合);fallback `messages(sid)` |
|
|
159
|
+
| 主 session 消息列表(Hit 趋势) | `createMemo` 内读 `refreshTick` + `messages(sid)` |
|
|
160
|
+
| 子 agent 列表内容 | `refreshTick` + `childIds` → 每个 child 的 `session.get(cid)`;fallback `messages(cid)` |
|
|
142
161
|
| 子 agent id 集合 | `session.list` 完成回调 / `message.updated` 发现新 child |
|
|
143
162
|
|
|
144
|
-
主 session **显式**订阅 `message.updated`(在 `sidebar-host`):每次事件 `refreshTick
|
|
163
|
+
主 session **显式**订阅 `message.updated`(在 `sidebar-host`):每次事件 `refreshTick++`,触发相关 memo 重算;会话总量以 `session.get()` 聚合为准,逐轮趋势使用最近 messages。
|
|
145
164
|
|
|
146
165
|
### 累加规则
|
|
147
166
|
|
|
@@ -223,7 +242,7 @@ flowchart TD
|
|
|
223
242
|
| 指标切换 | 累计 / 最近 N 轮 / 滑动窗口;与时间轴 Phase 3 联动 | timeline.md § Phase 3 |
|
|
224
243
|
| 子 agent | 递归子 session、按 agent 类型过滤 | timeline.md § 风险;侧栏另议 |
|
|
225
244
|
|
|
226
|
-
|
|
245
|
+
实现日志时使用 `message.updated` 事件直接驱动的 `handleMessage()`;落盘异步、勿阻塞 TUI(见 timeline.md)。
|
|
227
246
|
|
|
228
247
|
## 插件缓存
|
|
229
248
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# 前端迁移方案:session.get() 聚合数据
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
cache-hit 插件此前通过遍历 `api.state.session.messages()` 逐条累加 per-message 字段来统计 cost/token。这个路径受 OpenCode TUI sync 的 **100 条消息上限** 限制([issue #31513]),超过 100 条消息的会话数据被静默截断。
|
|
6
|
+
|
|
7
|
+
**影响**:对于超过 100 条消息的会话,截断导致大量数据丢失。在一个实际的长会话中,插件仅显示了约 43% 的缓存读 token 和约 39% 的实际费用。
|
|
8
|
+
|
|
9
|
+
| 指标 | 旧(messages,100 条截断) | 新(session.get,无截断) |
|
|
10
|
+
|------|--------------------------|-------------------------|
|
|
11
|
+
| 缓存读 | 59.6M tok | **139.0M tok** |
|
|
12
|
+
| 缓存写 | 89.8K tok | **1.4M tok** |
|
|
13
|
+
| 未命中 | 118 tok | **329 tok** |
|
|
14
|
+
| 输出 | 32.7K tok | **91.9K tok** |
|
|
15
|
+
| 可见子 Agent | 3 | **10** |
|
|
16
|
+
|
|
17
|
+
修复方案是将数据来源从逐条消息累加切换为 `api.state.session.get()`,该接口返回 **数据库层聚合值**,由 session projector 从全部消息累加得出 —— 不受 100 条上限影响。
|
|
18
|
+
|
|
19
|
+
## 根因
|
|
20
|
+
|
|
21
|
+
OpenCode TUI sync(`packages/opencode/src/cli/cmd/tui/context/sync.tsx`)在获取会话消息时硬编码了 `limit: 100`:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// 第 559 行 — 最多获取 100 条消息
|
|
25
|
+
sdk.client.session.messages({ sessionID, limit: 100 }),
|
|
26
|
+
|
|
27
|
+
// 第 580-581 行 — TUI store 也只保留最后 100 条
|
|
28
|
+
const visible = infos.slice(-100)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`api.state.session.messages(sid)` 读取的是 `sync.data.message[sid]`,最多 100 条。超过的会话,插件看到的是不完整数据,且没有任何截断提示。
|
|
32
|
+
|
|
33
|
+
与此同时,OpenCode SQLite 数据库的 `session` 表存储了**预聚合**列(`cost`、`tokens_input`、`tokens_output`、`tokens_cache_read`、`tokens_cache_write`),由 session projector 通过 SQL 增量更新 —— 每条消息都会被计入,无上限。
|
|
34
|
+
|
|
35
|
+
`api.state.session.get(sid)` 返回完整 `Session` 对象(SDK 类型 `Session`),包含来自数据库聚合的 `cost` 和 `tokens` 字段。
|
|
36
|
+
|
|
37
|
+
## 方案
|
|
38
|
+
|
|
39
|
+
### 设计
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
之前:
|
|
43
|
+
api.state.session.messages(sid) → 遍历 100 条消息 → 累加 ❌ 截断
|
|
44
|
+
|
|
45
|
+
现在:
|
|
46
|
+
api.state.session.get(sid) → 直接读 cost/tokens → 完成 ✅ 全量
|
|
47
|
+
└─ session 不可用时 → fallback 到消息累加 (兼容)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 改动
|
|
51
|
+
|
|
52
|
+
| 文件 | 内容 |
|
|
53
|
+
|------|------|
|
|
54
|
+
| `src/types.ts` | 新增 `SessionObject` 类型(`model`、`cost`、`tokens`、`parentID`);更新 `session.get` 返回类型 |
|
|
55
|
+
| `src/stats.ts` | 新增 `aggregateFromSessionObject()` — O(1) 从 `SessionObject` 提取 `SessionSnapshot` |
|
|
56
|
+
| `src/sidebar-host.tsx` | `mainSnap` 和 `subAgentList` 优先使用 `session.get()`,不可用时 fallback 到消息累加 |
|
|
57
|
+
| `tests/stats.test.ts` | 3 个测试用例覆盖空对象、完整对象、部分字段对象 |
|
|
58
|
+
|
|
59
|
+
### 数据流
|
|
60
|
+
|
|
61
|
+
1. **`mainSnap`**:调用 `api.state.session.get(sid)` → `aggregateFromSessionObject()` → 有数据则返回。若 session 不可用或为空,fallback 到 `aggregateSessionFromMessages()`。
|
|
62
|
+
|
|
63
|
+
2. **`subAgentList`**:每个子会话同上。
|
|
64
|
+
|
|
65
|
+
3. **逐轮趋势**(`computePerCallHitTrend`):仍使用 `api.state.session.messages()` —— 趋势只看最近几轮,100 条足够。
|
|
66
|
+
|
|
67
|
+
### 为什么 resume 会话立即可见
|
|
68
|
+
|
|
69
|
+
resume 会话时,OpenCode TUI `sync()` 立即调用 `sdk.client.session.get({ sessionID })` 获取完整 `Session` 对象(含数据库级聚合值)。该对象存入 TUI state store,`api.state.session.get(sid)` 读取之。插件从会话加载的瞬间就能看到准确总数。
|
|
70
|
+
|
|
71
|
+
### Fallback 策略
|
|
72
|
+
|
|
73
|
+
`session.get()` 对从未被 sync 到 TUI state 的会话返回 `undefined`(极少见,多见于特殊上下文中加载的子会话)。fallback 到 `api.state.session.messages()` 作为安全网保留原有行为。
|
|
74
|
+
|
|
75
|
+
## 性能
|
|
76
|
+
|
|
77
|
+
| 维度 | 旧(消息遍历) | 新(session.get) |
|
|
78
|
+
|------|---------------|-------------------|
|
|
79
|
+
| 时间复杂度 | O(n),n ≤ 100 | O(1) |
|
|
80
|
+
| 内存 | 读取整个消息数组 | 读取一个小对象 |
|
|
81
|
+
| 响应式触发 | `message.updated` → 重算 | 同一事件,同一时机 |
|
|
82
|
+
| 数据完整性 | ≤ 100 条消息 | 全部消息(数据库聚合) |
|
|
83
|
+
|
|
84
|
+
## 上线
|
|
85
|
+
|
|
86
|
+
1. **无需配置变更** —— 插件自动优先使用 `session.get()`。
|
|
87
|
+
2. **存量用户无需迁移** —— 旧配置文件和 timeline 日志不受影响。
|
|
88
|
+
3. **重启 OpenCode** 加载更新后的插件代码。
|
|
89
|
+
4. **验证**:打开一个超过 100 条消息的会话,侧边栏数据应与数据库聚合值一致。
|
|
90
|
+
|
|
91
|
+
## 相关链接
|
|
92
|
+
|
|
93
|
+
- [anomalyco/opencode#31513] — v1 TUI sync 硬编码 limit:100
|
|
94
|
+
- [anomalyco/opencode#26861] — 基于游标的分页 PR(待合入)
|
|
95
|
+
- [anomalyco/opencode#6548] — 分页消息加载功能请求
|
|
96
|
+
|
|
97
|
+
[issue #31513]: https://github.com/anomalyco/opencode/issues/31513
|
|
98
|
+
[anomalyco/opencode#31513]: https://github.com/anomalyco/opencode/issues/31513
|
|
99
|
+
[anomalyco/opencode#26861]: https://github.com/anomalyco/opencode/pull/26861
|
|
100
|
+
[anomalyco/opencode#6548]: https://github.com/anomalyco/opencode/issues/6548
|
package/docs/zh-CN/timeline.md
CHANGED
|
@@ -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
|
|
|
@@ -234,15 +232,20 @@ tail -f $LOG
|
|
|
234
232
|
jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost]|@tsv' $LOG
|
|
235
233
|
```
|
|
236
234
|
|
|
237
|
-
|
|
235
|
+
**画图 / 分析(可选脚本)** — 见 [scripts/README.md](../../scripts/README.md):
|
|
238
236
|
|
|
239
237
|
```bash
|
|
240
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
|
|
241
239
|
|
|
242
240
|
bun scripts/plot-hit-rate.ts $LOG -o /tmp/hit.svg
|
|
243
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
|
|
244
245
|
```
|
|
245
246
|
|
|
247
|
+
默认日志目录与插件 `timeline.dir` 一致(`~/.local/share/opencode/logs/cache-hit/`)。
|
|
248
|
+
|
|
246
249
|
### Phase 2 — 侧栏「Timeline」折叠段
|
|
247
250
|
|
|
248
251
|
- 在 `widget.tsx` 增加 `TuiSection`,展示最近 `maxMemoryRows` 条(窄屏每行一条):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-cache-hit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
package/scripts/README.md
CHANGED
|
@@ -20,6 +20,69 @@ Export TSV for spreadsheets (time fields are ISO 8601 strings):
|
|
|
20
20
|
jq -r 'select(.hitPercent!=null) | [.completedAt,.scope,.hitPercent,.cost]|@tsv' logs/timeline-2026-05-31.jsonl
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
## `timeline-dashboard.ts` (Bun, no install)
|
|
24
|
+
|
|
25
|
+
Interactive HTML dashboard with charts, filters, and data tables:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bun scripts/timeline-dashboard.ts # auto-detect logs/ (no browser)
|
|
29
|
+
bun scripts/timeline-dashboard.ts --open # write HTML, then open browser
|
|
30
|
+
bun scripts/timeline-dashboard.ts -o /tmp/report.html # custom output path
|
|
31
|
+
bun scripts/timeline-dashboard.ts ~/logs/timeline-*.jsonl # globs expanded by script
|
|
32
|
+
bun scripts/timeline-dashboard.ts --output /tmp/report.html --open # combined
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Default output: `/tmp/timeline-dashboard-YYYY-MM-DD-HHmmss.html` (timestamp suffix to avoid overwrites).
|
|
36
|
+
|
|
37
|
+
**Browser:** not opened by default; pass `--open` (macOS `open`, Linux `xdg-open`, Windows `start`).
|
|
38
|
+
|
|
39
|
+
**Features:**
|
|
40
|
+
- Summary cards follow active filters (records, tokens, cost, avg hit rate)
|
|
41
|
+
- Time / session / scope / model / text search filters
|
|
42
|
+
- 3 Chart.js charts: token volume (stacked bar), hit rate + cost (dual axis), duration (bar)
|
|
43
|
+
- Session summary table (mixed main+child scope shown as `main+child`)
|
|
44
|
+
- Per-call detail table with expandable rows (all JSONL fields)
|
|
45
|
+
- Embedded data — no server needed, just open the HTML file
|
|
46
|
+
|
|
47
|
+
**How it works:**
|
|
48
|
+
|
|
49
|
+
1. Reads `timeline-*.jsonl` and rotation backups `timeline-*.jsonl.N` from the default log dir (`~/.local/share/opencode/logs/cache-hit/`) or user-supplied paths/globs
|
|
50
|
+
2. Parses each JSONL line (`schema: 1` validation), sorts by `completedAt` / `created`
|
|
51
|
+
3. Aggregates per-session statistics in the browser when filters change
|
|
52
|
+
4. Generates a self-contained HTML file with:
|
|
53
|
+
- All data embedded as JSON in a `<script>` tag (`<` escaped for safety)
|
|
54
|
+
- Chart.js 4.4.7 from CDN (`cdn.jsdelivr.net`)
|
|
55
|
+
- Vanilla JS for interactivity (no framework dependency)
|
|
56
|
+
5. Optional `--open` opens the output file in the default browser
|
|
57
|
+
|
|
58
|
+
**Avg hit rate:** excludes `skippedForHit` rows (same rule as `plot-hit-rate.ts`); null `hitPercent` still appear in tables/charts.
|
|
59
|
+
|
|
60
|
+
**Cost display:** reads `currency` / `costUnit` / `rate` from `~/.config/opencode/cache-hit.json` when present (same as the TUI sidebar). **No config file** → defaults (`CNY` display, `USD` JSONL unit, rate `6.77`). Invalid or partial cost fields are normalized; corrupt config falls back without failing the script. JSONL always stores raw `cost` in `costUnit` (usually USD).
|
|
61
|
+
|
|
62
|
+
**Note:** The generated HTML is self-contained except Chart.js CDN. Re-run the script to refresh data (static snapshot).
|
|
63
|
+
|
|
64
|
+
### Design approaches
|
|
65
|
+
|
|
66
|
+
**Option A (current — static HTML):**
|
|
67
|
+
Data is embedded into the HTML at build time by Bun. The browser just renders.
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
bun scripts/timeline-dashboard.ts → /tmp/timeline-dashboard-YYYY-MM-DD-HHmmss.html (self-contained)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- Pros: No server needed, zero runtime deps, can email/share the file
|
|
74
|
+
- Cons: Snapshot only — re-run to refresh data
|
|
75
|
+
|
|
76
|
+
**Option B (live server):**
|
|
77
|
+
Starts an HTTP server; the browser fetches JSONL via `fetch("/api/logs")`.
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
bun scripts/timeline-dashboard.ts serve → http://localhost:PORT
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- Pros: Live reload — refresh the page to see new logs
|
|
84
|
+
- Cons: Must keep a process running, cannot send the page as a file
|
|
85
|
+
|
|
23
86
|
## `plot-hit-rate.ts` (Bun, no install)
|
|
24
87
|
|
|
25
88
|
Terminal ASCII chart; optional SVG (`open /tmp/hit.svg`):
|