opencode-cache-hit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +67 -0
  2. package/CONTRIBUTING.md +73 -0
  3. package/LICENSE +21 -0
  4. package/README.md +216 -0
  5. package/README.zh-CN.md +186 -0
  6. package/cache-hit.config.example.json +22 -0
  7. package/docs/README.md +8 -0
  8. package/docs/assets/cache-hit-panel.png +0 -0
  9. package/docs/en/design.md +228 -0
  10. package/docs/en/timeline.md +253 -0
  11. package/docs/zh-CN/design.md +229 -0
  12. package/docs/zh-CN/timeline.md +301 -0
  13. package/index.tsx +1 -0
  14. package/package.json +71 -0
  15. package/scripts/README.md +39 -0
  16. package/scripts/plot-hit-rate.ts +222 -0
  17. package/src/agents-view.tsx +55 -0
  18. package/src/cache-hit-rows.tsx +68 -0
  19. package/src/child-session-sync.ts +93 -0
  20. package/src/format-cache-ui.ts +21 -0
  21. package/src/format-cost.ts +90 -0
  22. package/src/format-tokens.ts +5 -0
  23. package/src/i18n.ts +82 -0
  24. package/src/load-config.ts +29 -0
  25. package/src/main-session-view.tsx +76 -0
  26. package/src/message-timing.ts +35 -0
  27. package/src/plugin-config.ts +116 -0
  28. package/src/plugin.tsx +33 -0
  29. package/src/session-list.ts +11 -0
  30. package/src/sidebar-host.tsx +121 -0
  31. package/src/stats.ts +141 -0
  32. package/src/timeline/collector.ts +156 -0
  33. package/src/timeline/records.ts +73 -0
  34. package/src/timeline/rotation.ts +47 -0
  35. package/src/timeline/types.ts +22 -0
  36. package/src/timeline/writer.ts +134 -0
  37. package/src/tui-panel/README.md +78 -0
  38. package/src/tui-panel/README.zh-CN.md +76 -0
  39. package/src/tui-panel/components.tsx +163 -0
  40. package/src/tui-panel/index.ts +41 -0
  41. package/src/tui-panel/layout.ts +107 -0
  42. package/src/tui-panel/palette.ts +93 -0
  43. package/src/tui-panel/use-panel-layout.ts +69 -0
  44. package/src/types.ts +71 -0
  45. package/src/use-cache-hit-metrics.ts +103 -0
  46. package/src/version.ts +1 -0
  47. package/src/widget.tsx +117 -0
@@ -0,0 +1,229 @@
1
+ # 设计说明
2
+
3
+ 面向**维护本插件的开发者**:说明数据从哪来、何时重算、与 visual-cache 的边界。使用与配置见 [README.zh-CN.md](../../README.zh-CN.md)。
4
+
5
+ ## 与 opencode-visual-cache 的关系
6
+
7
+ 本插件是**独立项目**,并非 visual-cache 官方维护。实现上**大量借鉴**了 [opencode-visual-cache](https://www.npmjs.com/package/opencode-visual-cache) 的思路,包括但不限于:
8
+
9
+ - **侧栏面板布局**(`src/tui-panel/`):边框、命中率条、折叠段、主题色映射等页面骨架
10
+ - **布局**:主 session 块始终显示;有子 agent 时增加可折叠的 **Agents** 段
11
+ - **命中率口径**:会话累计与 visual-cache 的 `cache.read / (cache.read + input)` 约定对齐
12
+
13
+ **分工**:visual-cache 侧重主 session **上下文 / token 分布预估**;cache-hit 侧重**按轮次指标、成本与子 agent 汇总**。推荐两个插件一起装。
14
+
15
+ 用法见 [文档索引](../README.md)。
16
+
17
+ ## 产品边界
18
+
19
+ ```mermaid
20
+ flowchart TB
21
+ subgraph ours [opencode-cache-hit]
22
+ DISC[子 session 发现]
23
+ AGG[消息级 token/cost 聚合]
24
+ UI[Cache Hit 侧边栏]
25
+ end
26
+ subgraph ref [opencode-visual-cache 参考]
27
+ EST[上下文 token 预估]
28
+ VCUI[Token Cache 侧边栏]
29
+ end
30
+ OC[OpenCode session / messages API] --> DISC
31
+ OC --> AGG
32
+ AGG --> UI
33
+ OC -.->|只读参考 UI 语言| EST
34
+ EST -.-> VCUI
35
+ ```
36
+
37
+ | 角色 | 职责 |
38
+ |------|------|
39
+ | **cache-hit** | 子 agent 发现与汇总;主/子 session 的 cache、token、成本;可独立演进 |
40
+ | **visual-cache** | 主 session 上下文与预估;非本仓库维护 |
41
+ | **默认** | 主 session + 可折叠 **Agents**(子 session 合计) |
42
+
43
+ ## 成本模型
44
+
45
+ - OpenCode:`msg.cost` = 按 `opencode.json` 中**美元**单价对 assistant 消息累加。
46
+ - 插件:`createCostFormatter(loadPluginConfig().cost)`;默认 `costUnit: USD` → `currency: CNY`,`rate: 7.2`。
47
+ - 配置路径:插件根目录 `cache-hit.config.json`(`load-config.ts`);缺省见 `plugin-config.ts` 的 `DEFAULT_PLUGIN_CONFIG`。
48
+
49
+ ## 运行时架构
50
+
51
+ ```mermaid
52
+ sequenceDiagram
53
+ participant Slot as sidebar_content slot
54
+ participant Host as sidebar-host
55
+ participant API as OpenCode API
56
+ participant W as widget + metrics
57
+
58
+ Slot->>Host: sessionId, display, api
59
+ Host->>API: session.list → childIds
60
+ API-->>Host: message.updated
61
+ Host->>Host: refreshTick++
62
+ Host->>API: session.messages(sid / cid)
63
+ Host->>W: main, messages, subAgents
64
+ W->>W: aggregate / format / TuiPanel
65
+ ```
66
+
67
+ ### 模块职责
68
+
69
+ | 文件 | 职责 |
70
+ |------|------|
71
+ | `plugin.tsx` | `api.slots.register`(`order: 56`,紧邻 visual-cache);加载配置与 `formatCost` |
72
+ | `sidebar-host.tsx` | 绑定 `sessionId`;`mainSnap` / `mainMessages` / `subAgentList`;`refreshTick` + `message.updated` |
73
+ | `widget.tsx` | `sessionId` 非空则渲染面板;`hasData` 否则 noData |
74
+ | `use-cache-hit-metrics.ts` | Hit 条、趋势、Combined Hit、hasData |
75
+ | `main-session-view.tsx` / `agents-view.tsx` | 业务区块 |
76
+ | `cache-hit-rows.tsx` | Detail 区共用 token 行 |
77
+ | `stats.ts` | 纯函数聚合(无 UI) |
78
+ | `session-list.ts` | `session.list` 响应解析、`childSessionIdsForParent` |
79
+ | `format-cost.ts` / `format-tokens.ts` / `format-cache-ui.ts` | 展示格式化(**不含** `computeHitBarWidth`,其在 `tui-panel/layout.ts`) |
80
+ | `message-timing.ts` | SDK 时间字段辅助 |
81
+ | `timeline/` | 按次 JSONL(`records` / `writer` / `collector`) |
82
+ | `plugin-config.ts` / `load-config.ts` | 配置归一化与默认值 |
83
+
84
+ ### TUI 面板框架(`src/tui-panel/`)
85
+
86
+ 可复用的 visual-cache **页面**骨架(布局、配色、折叠段),**不含** skills 预估、slash、kv 等业务。
87
+
88
+ | 模块 | 职责 |
89
+ |------|------|
90
+ | `layout.ts` | 视觉列宽、`justifyRow`、`computeHitBarWidth`、分隔线 |
91
+ | `palette.ts` | 主题色 → 面板调色板 |
92
+ | `use-panel-layout.ts` | `createPanelLayout`(测宽)、`createSectionFold` |
93
+ | `components.tsx` | `TuiPanel`、`TuiHitRow`、`TuiMetricRow` 等(需 `@opentui/solid`) |
94
+ | `index.ts` | 对外 barrel |
95
+
96
+ 纯逻辑模块(如 `use-cache-hit-metrics`)应从 `layout.ts` / `palette.ts` 直接 import,避免经 `index.ts` 拉入 JSX(便于 `bun test` 冒烟)。
97
+
98
+ 用法见 [src/tui-panel/README.zh-CN.md](../../src/tui-panel/README.zh-CN.md)。
99
+
100
+ ## 子 agent 发现
101
+
102
+ 实现:`src/child-session-sync.ts`(`sidebar-host` 调用)。
103
+
104
+ ```mermaid
105
+ flowchart TD
106
+ A[sessionId 变化] --> B[resetForParentChange]
107
+ B --> C[loadChildren 立即 session.list]
108
+ E[message.updated 任意会话] --> R[refreshTick++]
109
+ E --> F{sessionID !== parent?}
110
+ F -->|是| G[debounce 200ms]
111
+ G --> C2[loadChildren 覆盖 childIds]
112
+ F -->|否| R
113
+ C --> H[childSessionIdsForParent]
114
+ C2 --> H
115
+ H --> I[messages 重读 + 聚合]
116
+ ```
117
+
118
+ - **唯一来源**:`childIds` 始终由 `session.list` 结果**覆盖**写入,不再 `session.get` + 追加(避免漏发现、僵尸 id)。
119
+ - **竞态**:`listGen` 在 parent 切换时递增;回调校验 generation 与 `parentId` 未变。
120
+ - **流式**:外国 session 的 `message.updated` 很密,用 `CHILD_LIST_DEBOUNCE_MS`(200ms)合并 list 请求。
121
+ - **一层子 session**:`parentID === sid` 的直接子节点;嵌套子 agent 见「未来方向」。
122
+ - **子 session 数据**:对每个 `cid` 调用 `messages(cid)` → `aggregateSessionFromMessages`;无统计的条目过滤掉。
123
+
124
+ ### Agents 合计语义(实现正确,勿与「全场总账」混淆)
125
+
126
+ | 范围 | 是否计入 Agents 段 |
127
+ |------|-------------------|
128
+ | 各子 session 的 assistant 消息 | 是(`aggregateSubAgents`) |
129
+ | 主 session 的 assistant 消息 | **否**(即使在 auto 下仍会计入 `mainSnap`,仅 UI 隐藏主块) |
130
+
131
+ 主 session 若仍有编排类调用,其 token/费用不会出现在 Agents 合计中;UI 通过 `agentsScopeHint`(「仅子会话 / sub-sessions」)标明。与 visual-cache 对主 session 的展示互补,不是漏算 bug。
132
+
133
+ ## 聚合与刷新
134
+
135
+ ### 何时重算
136
+
137
+ | 数据 | 触发方式 |
138
+ |------|----------|
139
+ | 主 session snapshot | `createMemo` 内读 `refreshTick` + `api.state.session.messages(sid)` |
140
+ | 主 session 消息列表(Hit 趋势) | 同上 |
141
+ | 子 agent 列表内容 | `childIds` 变化或 `refreshTick` 后各 `messages(cid)` 重读 |
142
+ | 子 agent id 集合 | `session.list` 完成回调 / `message.updated` 发现新 child |
143
+
144
+ 主 session **显式**订阅 `message.updated`(在 `sidebar-host`):每次事件 `refreshTick++`,保证流式过程中 `tokens` 更新会重算(不仅依赖 store 是否自动触发 Solid memo)。
145
+
146
+ ### 累加规则
147
+
148
+ - **不是**只在「最后一轮」算一次;session 内**每条** `role === assistant` 的消息都进入累加。
149
+ - **流式中**:同一条 message 的 `tokens` 可能多次变化;每次 `message.updated` 后重算。
150
+ - `reasoning` token **不参与**命中率分母。
151
+ - `summary: true` 的 assistant:在 `computePerCallHitTrend` 中**跳过**;会话累计器 `aggregateSessionFromMessages` **暂未**排除。
152
+
153
+ ### 侧边栏可见性(避免与 README 混淆)
154
+
155
+ ```mermaid
156
+ flowchart TD
157
+ S{sessionId 非空?}
158
+ S -->|否| H[不渲染面板]
159
+ S -->|是| P[渲染 TuiPanel]
160
+ P --> D{hasData?<br/>主或子有统计}
161
+ D -->|否| ND[noData]
162
+ D -->|是| MAIN[Main / Detail / Model]
163
+ D -->|有子 agent| AG[Agents 段(可折叠)]
164
+ ```
165
+
166
+ | 概念 | 实现 |
167
+ |------|------|
168
+ | 整个面板 | `widget.tsx`:`Show when={sessionId().length > 0}` |
169
+ | 有无可显示数据 | `hasData` = `mainSessionHasStats(main) \|\| subs.length > 0` |
170
+ | 主 session **区块** | 始终渲染(Hit / Detail / Model) |
171
+ | **Agents 段** | `subs.length > 0` 时显示,各段可独立折叠 |
172
+ | `sidebarShouldShow` | `mainSessionHasStats(main) \|\| subs.length > 0`(测试用) |
173
+
174
+ ## 命中率(当前实现)
175
+
176
+ **会话累计(Total Hit 口径,对齐 visual-cache)**
177
+
178
+ ```
179
+ 对所有 assistant 消息累加 input、cache.read
180
+ → cacheRead / (cacheRead + input)
181
+ ```
182
+
183
+ **顶栏 Hit(单轮 + 趋势)**
184
+
185
+ - `computePerCallHitTrend(messages)`:每条 assistant 一轮命中率;`summary: true` 跳过。
186
+ - 展示**最后一条**非 summary 轮的命中率;与前一条比较得趋势(↑ / ↓ / `-`)。
187
+
188
+ **Combined Hit**
189
+
190
+ - 存在子 agent 且与会话累计命中率差异 ≥ 0.05% 时显示(主+子合并口径)。
191
+
192
+ ## 时间字段(OpenCode SDK v2)
193
+
194
+ | 来源 | 字段 | 说明 |
195
+ |------|------|------|
196
+ | `AssistantMessage` | `time.created` | ms epoch,消息创建 |
197
+ | `AssistantMessage` | `time.completed?` | ms epoch,本轮 LLM 结束;流式中常缺失 |
198
+ | `ReasoningPart` | `time.start` / `time.end?` | thought 片段 |
199
+ | `ToolStateCompleted` | `time.start` / `time.end` | 工具执行 |
200
+ | `StepFinishPart` | (无 time) | 有 tokens/cost,时间回退到 message |
201
+
202
+ 按次日志(**Phase 1 已实现**,默认关闭):以 `AssistantMessage` 为一行,用 `created` + `completed` 排序;按**本地日历日**单文件 JSONL 落盘。实现:`src/timeline/`、`src/message-timing.ts`。路径、轮转与清理见 **[timeline.md](./timeline.md)**(本文档同目录)§ 存储、§ 轮转与清理。
203
+
204
+ ## 测试策略
205
+
206
+ | 层级 | 文件 | 作用 |
207
+ |------|------|------|
208
+ | 单元 | `tests/*.test.ts` | `stats`、`format-*`、`tui-panel/layout` 等纯函数 |
209
+ | 冒烟 | `tests/module-load.test.ts` | `import` 消费者模块,捕获「符号已迁移但 import 路径未改」 |
210
+ | 运行时 | OpenCode 日志 | JSX / peer(`@opentui/solid`)加载错误;本地 `bun test` 不覆盖 |
211
+
212
+ 重构移动 `export` 后:`rg` 旧符号名 + `bun test`。
213
+
214
+ ## 未来方向(未实现)
215
+
216
+ | 方向 | 说明 | 设计文档 |
217
+ |------|------|----------|
218
+ | 按次 LLM + 时间轴 / JSONL | **Phase 1 已实现**(`src/timeline/`,默认关闭) | [timeline.md](./timeline.md) |
219
+ | 指标切换 | 累计 / 最近 N 轮 / 滑动窗口;与时间轴 Phase 3 联动 | timeline.md § Phase 3 |
220
+ | 子 agent | 递归子 session、按 agent 类型过滤 | timeline.md § 风险;侧栏另议 |
221
+
222
+ 实现日志时继续复用 `message.updated` + `messages()`;落盘异步、勿阻塞 TUI(见 timeline.md)。
223
+
224
+ ## 插件缓存
225
+
226
+ | 安装 | 更新 |
227
+ |------|------|
228
+ | 本地路径 | 重启 OpenCode |
229
+ | npm 包 | 重启;必要时删除 `~/.cache/opencode/packages/opencode-cache-hit@latest` |
@@ -0,0 +1,301 @@
1
+ # 时间轴 / 按次日志 — 设计方案
2
+
3
+ 面向开发者。侧栏聚合见 [design.md](./design.md)。用户指南见 [README.zh-CN.md](../../README.zh-CN.md)。
4
+
5
+ **Phase 1(JSONL 落盘)已实现**,默认 `timeline.enabled: false`。Phase 2 侧栏 Timeline 段、Phase 3 指标切换仍未做。
6
+
7
+ ## 目标与非目标
8
+
9
+ | 目标 | 非目标 |
10
+ |------|--------|
11
+ | 按时间查看每次 assistant 调用的 token / cache / cost / 命中率 | 替代 OpenCode 平台日志(`~/.local/share/opencode/log`) |
12
+ | 区分主 session 与子 session 的调用 | 在 TUI 里实时 `console.log` 刷屏 |
13
+ | 本地落盘,便于事后用 jq / 脚本分析 | 上传云端、团队共享 |
14
+ | 与现有 `stats.ts` 口径一致(含 `summary` 跳过规则) | 第一期就做 SQLite、图表、递归子 agent |
15
+
16
+ ## 核心概念
17
+
18
+ **一条时间轴事件 = 一次「可计费的 assistant 轮次」**,与侧栏顶栏 **Hit** 行同源,不是 tool part、不是 user 消息。
19
+
20
+ ```mermaid
21
+ flowchart LR
22
+ MSG[AssistantMessage] --> REC[LlmCallRecord]
23
+ REC --> MEM[内存 ring 最近 N 条]
24
+ REC --> JSONL[JSONL 追加写]
25
+ MEM --> UI[侧栏 Timeline 段 可选]
26
+ ```
27
+
28
+ | 字段 | 来源 |
29
+ |------|------|
30
+ | 时间排序键 | `time.completed ?? time.created`(已有 `timingFromAssistantMessage`) |
31
+ | 是否计入 Hit 趋势 | `summary !== true` 且 `input + cache.read > 0`(对齐 `computePerCallHitTrend`) |
32
+ | 会话累计 | 仍用 `aggregateSessionFromMessages`(可后续让累计也跳过 `summary`) |
33
+
34
+ ## 数据模型
35
+
36
+ ```typescript
37
+ /** 单条记录;JSONL 一行一个 */
38
+ export type LlmCallRecord = {
39
+ schema: 1
40
+ /** 写入时间(本机 ms),非 LLM 时间 */
41
+ recordedAt: number
42
+ /** 所属 session */
43
+ sessionId: string
44
+ /** 主 session id;子 session 时与 sessionId 不同 */
45
+ rootSessionId: string
46
+ scope: "main" | "child"
47
+ /** OpenCode message id;SDK 若无则用稳定合成键,见下文 */
48
+ messageKey: string
49
+ modelId: string
50
+ created: number
51
+ completedAt?: number
52
+ durationMs?: number
53
+ isComplete: boolean
54
+ input: number
55
+ output: number
56
+ reasoning: number
57
+ cacheRead: number
58
+ cacheWrite: number
59
+ cost: number
60
+ /** 单轮 cache 命中率 0–100;无分母时为 null */
61
+ hitPercent: number | null
62
+ /** compaction / summary 消息 */
63
+ skippedForHit: boolean
64
+ }
65
+ ```
66
+
67
+ **`messageKey`(去重键)**
68
+
69
+ 1. 优先:`message.id` / `messageID`(实现前用真实 SDK 样本确认字段名,扩展 `AssistantMessage` 类型)。
70
+ 2. 回退:`${sessionId}:${created}:${modelID ?? ""}`(同一 created 极罕见碰撞;流式更新时 created 不变,可覆盖同键)。
71
+
72
+ **流式更新策略**
73
+
74
+ - `message.updated` 频繁触发时,**内存 Map<messageKey, LlmCallRecord>** 覆盖更新同一键。
75
+ - **落盘**:仅在 `isComplete === true` 时 append 一行;或配置 `flushIncomplete: true` 时也写,行带 `isComplete`,便于分析 in-flight(默认 `false`,减少 JSONL 噪音)。
76
+ - 未完成行在 TUI 时间轴里可显示为 `…` 后缀(可选)。
77
+
78
+ ## 从消息构建记录
79
+
80
+ 新建纯函数模块 `src/timeline/records.ts`(不依赖 JSX):
81
+
82
+ ```typescript
83
+ export function buildCallRecords(
84
+ sessionId: string,
85
+ rootSessionId: string,
86
+ scope: "main" | "child",
87
+ messages: readonly AssistantMessage[],
88
+ ): LlmCallRecord[]
89
+ ```
90
+
91
+ 逻辑要点:
92
+
93
+ - 只处理 `role === assistant`。
94
+ - `skippedForHit = msg.summary === true`。
95
+ - `hitPercent`:与 `computePerCallHitTrend` 单条算法一致;`skippedForHit` 时可为 `null` 仍保留 token/cost 行(配置项 `logSummaryMessages`,默认 `true` 但标记 `skippedForHit`)。
96
+
97
+ 子 session:在 `sidebar-host` 已有 `childIds` 与 `refreshTick` 上,对每个 `cid` 调用 `buildCallRecords(cid, rootSid, "child", messages)`,与主 session 记录合并后按 `sortKey = completedAt ?? created` 排序。
98
+
99
+ ## 存储
100
+
101
+ **默认路径(可配置)**
102
+
103
+ ```
104
+ ~/.config/opencode/plugins/opencode-cache-hit/logs/
105
+ timeline-2026-05-31.jsonl # 按本地日历日一个活跃文件
106
+ timeline-2026-05-31.jsonl.1 # 当日超过 rotateMaxBytes 时链式备份
107
+ ```
108
+
109
+ 所有主/子 session 的调用写入**同一天**的同一文件;用行内 `rootSessionId` / `sessionId` / `scope` 筛某场对话。跨日自动切到新文件名。
110
+
111
+ `dir` 非空时可改到例如 `~/.local/share/opencode/cache-hit/logs/`。
112
+
113
+ 推荐 **JSONL** 第一期:实现简单、`tail -f` / `jq` 友好;SQLite 留给第二期索引查询。
114
+
115
+ **与旧版**:曾用 `<rootSessionId>.jsonl` 按主会话分文件;现改为按天。旧文件不会被自动迁移,可手动删除或保留。
116
+
117
+ **配置**(并入 `cache-hit.config.json` 的 `timeline` 段):
118
+
119
+ ```json
120
+ {
121
+ "timeline": {
122
+ "enabled": false,
123
+ "dir": "",
124
+ "flushIncomplete": false,
125
+ "logSummaryMessages": true,
126
+ "maxMemoryRows": 50,
127
+ "maxLinesPerFile": 100000,
128
+ "rotateMaxBytes": 16777216,
129
+ "retainRotated": 5,
130
+ "maxAgeDays": 30,
131
+ "maxLogFiles": 20
132
+ }
133
+ }
134
+ ```
135
+
136
+ 上表为 **example 推荐值**;代码默认见下表(`enabled: false`,轮转项为 `0`)。
137
+
138
+ | 字段 | 代码默认 | 说明 |
139
+ |------|----------|------|
140
+ | `enabled` | `false` | 关闭时零 IO,不影响侧栏 |
141
+ | `dir` | `""` | 空则用插件目录下 `logs/` |
142
+ | `flushIncomplete` | `false` | 是否在未完成时写 JSONL |
143
+ | `logSummaryMessages` | `true` | 是否记录 summary 行 |
144
+ | `maxMemoryRows` | `50` | TUI 内存中保留条数(全量仍可从文件读) |
145
+ | `maxLinesPerFile` | `0` | 活跃文件只保留最后 N 行(`0` = 不限) |
146
+ | `rotateMaxBytes` | `0` | 活跃文件 ≥ 该字节数时滚到 `.jsonl.1`(`0` = 关闭) |
147
+ | `retainRotated` | `5` | 同日大小轮转保留的**备份**个数(不含正在写的活跃文件) |
148
+ | `maxAgeDays` | `0` | collector **启动时**删除超 N 天的 `timeline-*.jsonl*` |
149
+ | `maxLogFiles` | `0` | 日志目录内 `timeline-*.jsonl*` 总数上限(每个 `.1` 单独计数) |
150
+
151
+ **写入流程**(`src/timeline/writer.ts` + `rotation.ts`)
152
+
153
+ 1. 可选 `rotateMaxBytes`:写**前**若当日活跃文件 ≥ 阈值 → 链式 rename(见 § 轮转与清理)。
154
+ 2. `appendFile` 一行 JSON。
155
+ 3. 可选 `maxLinesPerFile`:写**后**读回活跃文件,只保留最后 N 行(**删行**,不生成 `.1`)。
156
+ 4. 异步:`collector` 在 `queueMicrotask` 里写盘;`flushedKeys` 按 `messageKey` 去重(切换主 session **不**清空;跨日换文件名时清空)。
157
+
158
+ ## 轮转与清理
159
+
160
+ ### 同日大小轮转(`rotateMaxBytes` + `retainRotated`)
161
+
162
+ 仅作用于**当天**活跃文件 `timeline-YYYY-MM-DD.jsonl`。写下一条记录**之前**检查大小。
163
+
164
+ ```
165
+ 活跃 (将满) → rename → .1
166
+ 原 .1 → rename → .2
167
+ 原 .N → 删除(当备份数已达 retainRotated 且再次轮转时)
168
+ 然后新建空的 活跃 文件,继续 append
169
+ ```
170
+
171
+ | `retainRotated` | 当日最多占用(约) |
172
+ |-----------------|-------------------|
173
+ | `5`(默认 / example) | 活跃 + `.1`…`.5` ≈ 6× `rotateMaxBytes` |
174
+ | `1` | 活跃 + `.1` ≈ 2× `rotateMaxBytes` |
175
+ | `0` | 满则**删掉**活跃文件,不保留备份 |
176
+
177
+ 再满时最老备份**整文件删除**,更早的调用不可恢复。同时注意 `maxLogFiles`(每个备份各占 1 个文件槽);繁忙日 + `retainRotated: 5` 时更易触达目录文件数上限。
178
+
179
+ ### 行数截断(`maxLinesPerFile`)
180
+
181
+ 写**后**对**当日活跃文件**原地重写,只留最后 N 行;**不会**把删掉的行挪到 `.1`。
182
+
183
+ 与 `rotateMaxBytes` 同时开启时,通常**先碰到字节上限**(当前记录约 500B/行,16MB ≈ 3.4 万行,远小于 example 的 10 万行)。
184
+
185
+ ### 目录清理(collector 启动时一次)
186
+
187
+ 1. `maxAgeDays`:删除 mtime 超过 N 天的所有 `timeline-*.jsonl*`。
188
+ 2. `maxLogFiles`:若仍多于 N 个文件,按**日志时间先后**删到剩 N 个:先删文件名里**最早日期**的;同一天先删 `.5`、`.4`…再删活跃文件(与 mtime 无关,避免 `touch` 误留旧日文件)。
189
+
190
+ **不匹配**旧版 `<rootSessionId>.jsonl`,不会自动删;可手动清理。
191
+
192
+ ### 跨日
193
+
194
+ 午夜后自动写入新文件名;昨日文件保留,直至上述清理策略删除。
195
+
196
+ ### 去重与切换 session
197
+
198
+ - 同一 `messageKey` 只 append 一次(进程内 `flushedKeys`)。
199
+ - 切换 TUI 主 session:仍写**同日**文件,用 `rootSessionId` 过滤;**不**因切换而重复写同一 `messageKey`。
200
+ - 插件重启后 `flushedKeys` 为空,可能对**同一批已完成消息**再写一遍(若需避免,需另做持久化去重,当前未做)。
201
+
202
+ ## 运行时接入
203
+
204
+ ```mermaid
205
+ sequenceDiagram
206
+ participant E as message.updated
207
+ participant H as sidebar-host
208
+ participant B as timeline/build
209
+ participant W as timeline/writer
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
216
+ end
217
+ ```
218
+
219
+ - **与 `child-session-sync` 分工**:子 id 列表仍由 `session.list` 负责;时间轴只读 `messages()`,不额外 list。
220
+ - **Debounce**:500ms(比 child list 的 200ms 略长,减少流式写盘);仅 `timeline.enabled` 时注册。
221
+ - **作用域**:只记录「当前 TUI 绑定的 `rootSessionId`」及其子 session;落盘路径按**当天**不变,切换主 session 仍写同一日文件。
222
+
223
+ ## UI(分阶段)
224
+
225
+ ### Phase 1 — 仅落盘(推荐先做)
226
+
227
+ - 无侧栏改动;用户 `tail -f` / `jq` 分析。
228
+ - 文档示例:
229
+
230
+ ```bash
231
+ LOG=~/.config/opencode/plugins/opencode-cache-hit/logs/timeline-$(date +%Y-%m-%d).jsonl
232
+ tail -f $LOG
233
+ jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost]|@tsv' $LOG
234
+ ```
235
+
236
+ **画图(可选脚本)** — 见 [scripts/README.md](../../scripts/README.md):
237
+
238
+ ```bash
239
+ 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
+
241
+ bun scripts/plot-hit-rate.ts $LOG -o /tmp/hit.svg
242
+ bun scripts/plot-hit-rate.ts $LOG --by-root -o /tmp/hit-multi.svg
243
+ ```
244
+
245
+ ### Phase 2 — 侧栏「Timeline」折叠段
246
+
247
+ - 在 `widget.tsx` 增加 `TuiSection`,展示最近 `maxMemoryRows` 条(窄屏每行一条):
248
+ - `HH:mm:ss · main · 99.2% · ¥0.02`
249
+ - `HH:mm:ss · child …abc · 85.0% · 12k tok`
250
+ - 不打开文件即可扫一眼;点击/快捷键打开文件路径(若 OpenCode 支持 `open` 再议)。
251
+
252
+ ### Phase 3 — 指标切换联动
253
+
254
+ - 与 design 里「累计 / 最近 N 轮」共用 `buildCallRecords`:
255
+ - `window: "session" | "last1" | "lastN"`
256
+ - 侧栏 Hit 行可选显示「最近一轮」而非「最后一条非 summary」(与 JSONL 一致)。
257
+
258
+ ## 与现有模块关系
259
+
260
+ | 模块 | 关系 |
261
+ |------|------|
262
+ | `message-timing.ts` | 提供 `created` / `completed` / `formatTimingShort` |
263
+ | `stats.ts` | 抽出共享 `perMessageHitPercent(msg)`,供 `computePerCallHitTrend` 与 `buildCallRecords` 共用 |
264
+ | `sidebar-host.tsx` | 挂载 `createTimelineCollector`(enabled 时) |
265
+ | `plugin.tsx` | 无改动或仅读 config |
266
+
267
+ ## 测试
268
+
269
+ | 用例 | 文件 |
270
+ |------|------|
271
+ | `buildCallRecords` 排序、summary、hitPercent | `tests/timeline-records.test.ts` |
272
+ | 合成 `messageKey`、完成才 flush | `tests/timeline-writer.test.ts`(临时目录) |
273
+ | 不启用时 writer 不被调用 | 可选 mock |
274
+
275
+ ## 风险与约束
276
+
277
+ | 风险 | 缓解 |
278
+ |------|------|
279
+ | 流式写盘过多 | 默认仅 `isComplete` 落盘;debounce |
280
+ | 无 message id | 合成键 + 完成时覆盖内存 |
281
+ | 子 agent 嵌套 | 第一期只 `scope: child` 平铺;递归列入 Phase 4 |
282
+ | 磁盘膨胀 | `maxLinesPerFile` / `rotateMaxBytes` / `maxAgeDays`(已实现) |
283
+ | SDK 字段变更 | `schema: 1`;迁移时新文件或兼容读取 |
284
+
285
+ ## 实施顺序(建议)
286
+
287
+ 1. `timeline/records.ts` + 测试 + `stats` 抽取单条命中率
288
+ 2. `timeline/writer.ts` + config + `sidebar-host` 接入(**enabled: false 默认**)
289
+ 3. README 一段:如何开启、JSONL 路径、jq 示例
290
+ 4. Phase 2 侧栏 Timeline 段(可选)
291
+ 5. SQLite / 图表(远期)
292
+
293
+ ## 示例 JSONL 行
294
+
295
+ ```json
296
+ {"schema":1,"recordedAt":1717000000000,"sessionId":"sess_main","rootSessionId":"sess_main","scope":"main","messageKey":"sess_main:1716999990000:deepseek/v4","modelId":"deepseek/v4","created":1716999990000,"completedAt":1717000000000,"durationMs":10000,"isComplete":true,"input":1200,"output":80,"reasoning":0,"cacheRead":38000,"cacheWrite":0,"cost":0.012,"hitPercent":96.9,"skippedForHit":false}
297
+ ```
298
+
299
+ ---
300
+
301
+ 维护说明:design.md「未来方向」中与按次日志相关的条目以本文 Phase 状态为准。
package/index.tsx ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/plugin.tsx"
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "opencode-cache-hit",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "zhumengzhu <mengzhu.loveyou@gmail.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/zhumengzhu/opencode-cache-hit.git"
11
+ },
12
+ "homepage": "https://github.com/zhumengzhu/opencode-cache-hit#readme",
13
+ "bugs": "https://github.com/zhumengzhu/opencode-cache-hit/issues",
14
+ "keywords": [
15
+ "opencode",
16
+ "opencode-plugin",
17
+ "tui",
18
+ "sidebar",
19
+ "cache",
20
+ "cache-hit",
21
+ "cache-hit-rate",
22
+ "prompt-cache",
23
+ "token",
24
+ "cost",
25
+ "cost-tracking",
26
+ "sub-agent",
27
+ "agent",
28
+ "opencode-visual-cache",
29
+ "jsonl",
30
+ "timeline"
31
+ ],
32
+ "exports": {
33
+ ".": "./index.tsx",
34
+ "./tui": "./index.tsx",
35
+ "./tui-panel": "./src/tui-panel/index.ts"
36
+ },
37
+ "files": [
38
+ "index.tsx",
39
+ "src",
40
+ "cache-hit.config.example.json",
41
+ "LICENSE",
42
+ "README.md",
43
+ "README.zh-CN.md",
44
+ "AGENTS.md",
45
+ "CONTRIBUTING.md",
46
+ "docs",
47
+ "scripts"
48
+ ],
49
+ "scripts": {
50
+ "test": "bun test tests/",
51
+ "check": "bun test tests/",
52
+ "syntax": "bun test tests/module-load.test.ts",
53
+ "prepublishOnly": "bun test tests/",
54
+ "prepare": "simple-git-hooks"
55
+ },
56
+ "simple-git-hooks": {
57
+ "pre-push": "bun test tests/"
58
+ },
59
+ "dependencies": {
60
+ "solid-js": "^1.9.0"
61
+ },
62
+ "peerDependencies": {
63
+ "@opencode-ai/plugin": ">=1.14.0",
64
+ "@opencode-ai/sdk": ">=1.14.0",
65
+ "@opentui/core": ">=0.2.0",
66
+ "@opentui/solid": ">=0.2.0"
67
+ },
68
+ "devDependencies": {
69
+ "simple-git-hooks": "^2.13.1"
70
+ }
71
+ }
@@ -0,0 +1,39 @@
1
+ # Timeline analysis scripts
2
+
3
+ Optional tools; not part of the OpenCode plugin runtime.
4
+
5
+ ## One-liners
6
+
7
+ Quick stats (no extra deps):
8
+
9
+ ```bash
10
+ # Python
11
+ 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}%\")" logs/timeline-2026-05-31.jsonl
12
+
13
+ # Bun
14
+ bun -e "const t=await Bun.file(process.argv[1]).text();const rows=t.trim().split('\n').filter(Boolean).map(l=>JSON.parse(l));const h=rows.map(r=>r.hitPercent).filter((p):p is number=>p!=null);console.log(rows.length+' calls, avg hit '+(h.reduce((a,b)=>a+b,0)/h.length).toFixed(1)+'%')" logs/timeline-2026-05-31.jsonl
15
+ ```
16
+
17
+ Export TSV for spreadsheets:
18
+
19
+ ```bash
20
+ jq -r 'select(.hitPercent!=null) | [.completedAt,.scope,.hitPercent,.cost]|@tsv' logs/timeline-2026-05-31.jsonl
21
+ ```
22
+
23
+ ## `plot-hit-rate.ts` (Bun, no install)
24
+
25
+ Terminal ASCII chart; optional SVG (`open /tmp/hit.svg`):
26
+
27
+ ```bash
28
+ bun scripts/plot-hit-rate.ts logs/timeline-2026-05-31.jsonl
29
+ bun scripts/plot-hit-rate.ts logs/timeline-2026-05-31.jsonl --root YOUR_ROOT_SESSION_ID -o /tmp/hit.svg
30
+ # one SVG, one colored line per rootSessionId (time-aligned)
31
+ bun scripts/plot-hit-rate.ts logs/timeline-2026-05-31.jsonl --by-root -o /tmp/hit.svg
32
+ ```
33
+
34
+ Use a real path, not `$LOG`, unless you exported it first:
35
+
36
+ ```bash
37
+ set -x LOG logs/timeline-(date +%Y-%m-%d).jsonl # fish: set log ...
38
+ bun scripts/plot-hit-rate.ts $LOG -o /tmp/hit.svg
39
+ ```