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.
@@ -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)
@@ -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,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 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
 
@@ -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)
@@ -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.messages(sid / cid)
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` 等(需 `@opentui/solid`) |
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[messages 重读 + 聚合]
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 数据**:对每个 `cid` 调用 `messages(cid)` → `aggregateSessionFromMessages`;无统计的条目过滤掉。
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` + `api.state.session.messages(sid)` |
140
- | 主 session 消息列表(Hit 趋势) | 同上 |
141
- | 子 agent 列表内容 | `childIds` 变化或 `refreshTick` 后各 `messages(cid)` 重读 |
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++`,保证流式过程中 `tokens` 更新会重算(不仅依赖 store 是否自动触发 Solid memo)。
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
- 实现日志时继续复用 `message.updated` + `messages()`;落盘异步、勿阻塞 TUI(见 timeline.md)。
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
@@ -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
 
@@ -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
- **画图(可选脚本)** — 见 [scripts/README.md](../../scripts/README.md):
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.0",
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`):