opencode-cache-hit 0.2.1 → 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 +1 -0
- package/docs/README.md +4 -4
- package/docs/en/design.md +8 -7
- package/docs/en/frontend-migration-plan.md +100 -0
- package/docs/en/timeline-duplicate-writes.md +2 -2
- package/docs/zh-CN/design.md +8 -7
- package/docs/zh-CN/frontend-migration-plan.md +100 -0
- package/package.json +1 -1
- package/src/sidebar-host.tsx +28 -6
- package/src/stats.ts +41 -1
- package/src/types.ts +14 -1
package/AGENTS.md
CHANGED
|
@@ -16,6 +16,7 @@ OpenCode TUI sidebar plugin: **cache hit rate**, **tokens**, **cost**, with **su
|
|
|
16
16
|
| Architecture | [docs/en/design.md](docs/en/design.md) | [docs/zh-CN/design.md](docs/zh-CN/design.md) |
|
|
17
17
|
| Timeline / JSONL | [docs/en/timeline.md](docs/en/timeline.md) | [docs/zh-CN/timeline.md](docs/zh-CN/timeline.md) |
|
|
18
18
|
| TUI panel | [src/tui-panel/README.md](src/tui-panel/README.md) | [src/tui-panel/README.zh-CN.md](src/tui-panel/README.zh-CN.md) |
|
|
19
|
+
| Migration plan | [docs/en/frontend-migration-plan.md](docs/en/frontend-migration-plan.md) | [docs/zh-CN/frontend-migration-plan.md](docs/zh-CN/frontend-migration-plan.md) |
|
|
19
20
|
| Contributing / npm | [CONTRIBUTING.md](CONTRIBUTING.md) | — |
|
|
20
21
|
| Index | [docs/README.md](docs/README.md) | |
|
|
21
22
|
|
package/docs/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Documentation
|
|
2
2
|
|
|
3
|
-
| | User guide | Design | Timeline | TUI panel |
|
|
4
|
-
|
|
5
|
-
| English | [README.md](../README.md) | [en/design.md](./en/design.md) | [en/timeline.md](./en/timeline.md) | [../src/tui-panel/README.md](../src/tui-panel/README.md) |
|
|
6
|
-
| 中文 | [README.zh-CN.md](../README.zh-CN.md) | [zh-CN/design.md](./zh-CN/design.md) | [zh-CN/timeline.md](./zh-CN/timeline.md) | [../src/tui-panel/README.zh-CN.md](../src/tui-panel/README.zh-CN.md) |
|
|
3
|
+
| | User guide | Design | Timeline | TUI panel | Migration |
|
|
4
|
+
|--|------------|--------|----------|-----------|-----------|
|
|
5
|
+
| English | [README.md](../README.md) | [en/design.md](./en/design.md) | [en/timeline.md](./en/timeline.md) | [../src/tui-panel/README.md](../src/tui-panel/README.md) | [en/frontend-migration-plan.md](./en/frontend-migration-plan.md) |
|
|
6
|
+
| 中文 | [README.zh-CN.md](../README.zh-CN.md) | [zh-CN/design.md](./zh-CN/design.md) | [zh-CN/timeline.md](./zh-CN/timeline.md) | [../src/tui-panel/README.zh-CN.md](../src/tui-panel/README.zh-CN.md) | [zh-CN/frontend-migration-plan.md](./zh-CN/frontend-migration-plan.md) |
|
|
7
7
|
|
|
8
8
|
Contributing: [CONTRIBUTING.md](../CONTRIBUTING.md).
|
package/docs/en/design.md
CHANGED
|
@@ -59,7 +59,8 @@ sequenceDiagram
|
|
|
59
59
|
Host->>API: session.list → childIds
|
|
60
60
|
API-->>Host: message.updated { info: Message }
|
|
61
61
|
Host->>Host: refreshTick++, timeline.handleMessage(info)
|
|
62
|
-
Host->>API: session.
|
|
62
|
+
Host->>API: session.get(sid / cid) → aggregates
|
|
63
|
+
Host->>API: session.messages(sid / cid) → fallback / trend
|
|
63
64
|
Host->>W: main, messages, subAgents
|
|
64
65
|
W->>W: aggregate / format / TuiPanel
|
|
65
66
|
```
|
|
@@ -113,14 +114,14 @@ flowchart TD
|
|
|
113
114
|
F -->|no| R
|
|
114
115
|
C --> H[childSessionIdsForParent]
|
|
115
116
|
C2 --> H
|
|
116
|
-
H --> I[
|
|
117
|
+
H --> I["session.get aggregate + messages fallback"]
|
|
117
118
|
```
|
|
118
119
|
|
|
119
120
|
- **Single source of truth**: `childIds` always **replaced** from `session.list` (no `session.get` append).
|
|
120
121
|
- **Races**: `listGen` increments on parent change; callbacks check generation and `parentId`.
|
|
121
122
|
- **Streaming**: foreign-session `message.updated` is debounced (`CHILD_LIST_DEBOUNCE_MS` = 200ms).
|
|
122
123
|
- **Depth**: direct children only (`parentID === sid`); nested sub-agents are future work.
|
|
123
|
-
- **Data**: `messages(cid)` → `aggregateSessionFromMessages
|
|
124
|
+
- **Data**: `session.get(cid)` → `aggregateFromSessionObject` (primary); falls back to `messages(cid)` → `aggregateSessionFromMessages`. Model/provider metadata is patched from messages when missing from session aggregates (`withModelFallback`).
|
|
124
125
|
|
|
125
126
|
### Agents block semantics
|
|
126
127
|
|
|
@@ -154,12 +155,12 @@ Implementation: `agents-view.tsx` calls `formatSubAgentLabel` + `modelRowColor`;
|
|
|
154
155
|
|
|
155
156
|
| Data | Trigger |
|
|
156
157
|
|------|---------|
|
|
157
|
-
| Main snapshot | `createMemo` reads `refreshTick` + `messages(sid)` |
|
|
158
|
-
| Main messages (Hit trend) |
|
|
159
|
-
| Sub-agent content | `
|
|
158
|
+
| Main snapshot | `createMemo` reads `refreshTick` + `session.get(sid)` (aggregate); fallback `messages(sid)` |
|
|
159
|
+
| Main messages (Hit trend) | `createMemo` reads `refreshTick` + `messages(sid)` |
|
|
160
|
+
| Sub-agent content | `refreshTick` + `childIds` → `session.get(cid)` per child; fallback `messages(cid)` |
|
|
160
161
|
| Sub-agent ids | `session.list` callback / debounced refresh on foreign activity |
|
|
161
162
|
|
|
162
|
-
`sidebar-host` subscribes to `message.updated` and bumps `refreshTick` so
|
|
163
|
+
`sidebar-host` subscribes to `message.updated` and bumps `refreshTick` so dependent memos recompute; session totals follow `session.get()` aggregates, while per-turn trend follows recent messages.
|
|
163
164
|
|
|
164
165
|
### Accumulation rules
|
|
165
166
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Frontend Migration: session.get() Aggregates
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Cache-hit previously derived all cost/token statistics by iterating `api.state.session.messages()` and summing per-message fields. This path is capped at **100 messages** by the OpenCode TUI sync layer ([issue #31513]), silently truncating sessions with >100 messages.
|
|
6
|
+
|
|
7
|
+
**Impact**: For a session exceeding 100 messages, the cap causes significant data loss. In a real long-running session, the plugin showed only ~43% of actual cache-read tokens and ~39% of actual cost.
|
|
8
|
+
|
|
9
|
+
| Metric | Old (messages, 100-cap) | New (session.get, no cap) |
|
|
10
|
+
|--------|------------------------|--------------------------|
|
|
11
|
+
| Cache read | 59.6M tok | **139.0M tok** |
|
|
12
|
+
| Cache write | 89.8K tok | **1.4M tok** |
|
|
13
|
+
| Miss (input) | 118 tok | **329 tok** |
|
|
14
|
+
| Output | 32.7K tok | **91.9K tok** |
|
|
15
|
+
| Sub-agents visible | 3 | **10** |
|
|
16
|
+
|
|
17
|
+
The fix swaps the data source from per-message iteration to `api.state.session.get()`, which returns **database-level aggregates** computed from all messages by the session projector — not affected by the 100-message cap.
|
|
18
|
+
|
|
19
|
+
## Root Cause
|
|
20
|
+
|
|
21
|
+
OpenCode's TUI sync (`packages/opencode/src/cli/cmd/tui/context/sync.tsx`) hardcodes `limit: 100` when fetching session messages:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// Line 559 — fetches at most 100 messages
|
|
25
|
+
sdk.client.session.messages({ sessionID, limit: 100 }),
|
|
26
|
+
|
|
27
|
+
// Lines 580-581 — TUI store also keeps only last 100
|
|
28
|
+
const visible = infos.slice(-100)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`api.state.session.messages(sid)` reads from `sync.data.message[sid]`, which holds at most 100 entries. For sessions exceeding this, plugins see incomplete data with no indication of truncation.
|
|
32
|
+
|
|
33
|
+
Meanwhile, the `session` table in OpenCode's SQLite database stores **pre-aggregated** columns (`cost`, `tokens_input`, `tokens_output`, `tokens_cache_read`, `tokens_cache_write`) updated via SQL increments by the session projector — every message, no cap.
|
|
34
|
+
|
|
35
|
+
`api.state.session.get(sid)` returns the full `Session` object (SDK type `Session`), which includes `cost` and `tokens` fields populated from these database aggregates.
|
|
36
|
+
|
|
37
|
+
## Solution
|
|
38
|
+
|
|
39
|
+
### Design
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Before:
|
|
43
|
+
api.state.session.messages(sid) → iterate 100 msgs → sum ❌ capped
|
|
44
|
+
|
|
45
|
+
After:
|
|
46
|
+
api.state.session.get(sid) → read cost/tokens directly → done ✅ full
|
|
47
|
+
└─ session not available? → fallback to messages (compat)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Changes
|
|
51
|
+
|
|
52
|
+
| File | Change |
|
|
53
|
+
|------|--------|
|
|
54
|
+
| `src/types.ts` | New `SessionObject` type (`model`, `cost`, `tokens`, `parentID`); updated `session.get` return type |
|
|
55
|
+
| `src/stats.ts` | New `aggregateFromSessionObject()` — O(1) extraction from `SessionObject` → `SessionSnapshot` |
|
|
56
|
+
| `src/sidebar-host.tsx` | `mainSnap` and `subAgentList` preferred `session.get()` with fallback to message iteration |
|
|
57
|
+
| `tests/stats.test.ts` | 3 test cases covering empty, full, and partial session objects |
|
|
58
|
+
|
|
59
|
+
### Data Flow
|
|
60
|
+
|
|
61
|
+
1. **`mainSnap`**: Calls `api.state.session.get(sid)` → `aggregateFromSessionObject()` → returns snapshot if stats present. If session unavailable or empty, falls back to `aggregateSessionFromMessages()`.
|
|
62
|
+
|
|
63
|
+
2. **`subAgentList`**: Same pattern per child session ID.
|
|
64
|
+
|
|
65
|
+
3. **Per-call trend** (`computePerCallHitTrend`): Still uses `api.state.session.messages()` — trend only needs the last few turns, so 100 messages is sufficient.
|
|
66
|
+
|
|
67
|
+
### Why session.get() works for resumed sessions
|
|
68
|
+
|
|
69
|
+
When resuming a session, OpenCode's TUI `sync()` immediately calls `sdk.client.session.get({ sessionID })` which returns the full `Session` object with database-level aggregates. This object is stored in the TUI state store, and `api.state.session.get(sid)` reads it. The plugin sees accurate totals from the moment the session loads.
|
|
70
|
+
|
|
71
|
+
### Fallback rationale
|
|
72
|
+
|
|
73
|
+
`session.get()` returns `undefined` for sessions that have never been synced to the TUI state (rare, but possible for child sessions loaded in a different context). The fallback to `api.state.session.messages()` preserves existing behavior as a safety net.
|
|
74
|
+
|
|
75
|
+
## Performance
|
|
76
|
+
|
|
77
|
+
| Metric | Old (message iteration) | New (session.get) |
|
|
78
|
+
|--------|------------------------|-------------------|
|
|
79
|
+
| Time complexity | O(n) where n ≤ 100 | O(1) |
|
|
80
|
+
| Memory | Reads entire message array | Reads one small object |
|
|
81
|
+
| Reactivity trigger | `message.updated` → recompute | Same event, same timing |
|
|
82
|
+
| Data completeness | ≤ 100 messages | All messages (DB aggregate) |
|
|
83
|
+
|
|
84
|
+
## Rollout
|
|
85
|
+
|
|
86
|
+
1. **No config changes required** — the plugin automatically uses `session.get()` when available.
|
|
87
|
+
2. **No migration step** for existing users — old config files and timeline logs are unaffected.
|
|
88
|
+
3. **Restart OpenCode** to pick up the updated plugin code.
|
|
89
|
+
4. **Verify**: Open a session with >100 messages; the sidebar should show totals matching database aggregates.
|
|
90
|
+
|
|
91
|
+
## Related
|
|
92
|
+
|
|
93
|
+
- [anomalyco/opencode#31513] — v1 TUI sync hardcodes limit:100
|
|
94
|
+
- [anomalyco/opencode#26861] — PR adding cursor-based pagination (pending)
|
|
95
|
+
- [anomalyco/opencode#6548] — Paginated message loading feature request
|
|
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
|
|
@@ -12,10 +12,10 @@ The OpenCode TUI plugin API has two data access patterns:
|
|
|
12
12
|
|
|
13
13
|
| API | Behavior |
|
|
14
14
|
|-----|----------|
|
|
15
|
-
| `api.state.session.messages(sessionId)` | Returns
|
|
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
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
17
|
|
|
18
|
-
The original collector used the **polling path**: subscribe to `message.updated` → call `schedule()` (500ms debounce) → `collectNow()` → `getMessages()` (
|
|
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
19
|
|
|
20
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
21
|
|
package/docs/zh-CN/design.md
CHANGED
|
@@ -59,7 +59,8 @@ sequenceDiagram
|
|
|
59
59
|
Host->>API: session.list → childIds
|
|
60
60
|
API-->>Host: message.updated { info: Message }
|
|
61
61
|
Host->>Host: refreshTick++, timeline.handleMessage(info)
|
|
62
|
-
Host->>API: session.
|
|
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
|
```
|
|
@@ -113,14 +114,14 @@ flowchart TD
|
|
|
113
114
|
F -->|否| R
|
|
114
115
|
C --> H[childSessionIdsForParent]
|
|
115
116
|
C2 --> H
|
|
116
|
-
H --> I[
|
|
117
|
+
H --> I["session.get 聚合 + messages 兜底"]
|
|
117
118
|
```
|
|
118
119
|
|
|
119
120
|
- **唯一来源**:`childIds` 始终由 `session.list` 结果**覆盖**写入,不再 `session.get` + 追加(避免漏发现、僵尸 id)。
|
|
120
121
|
- **竞态**:`listGen` 在 parent 切换时递增;回调校验 generation 与 `parentId` 未变。
|
|
121
122
|
- **流式**:外国 session 的 `message.updated` 很密,用 `CHILD_LIST_DEBOUNCE_MS`(200ms)合并 list 请求。
|
|
122
123
|
- **一层子 session**:`parentID === sid` 的直接子节点;嵌套子 agent 见「未来方向」。
|
|
123
|
-
- **子 session
|
|
124
|
+
- **子 session 数据**:`session.get(cid)` → `aggregateFromSessionObject`(主路径);不可用时降级到 `messages(cid)` → `aggregateSessionFromMessages`。若 session 聚合缺少 model/provider 元数据,从 messages 末尾补齐(`withModelFallback`)。
|
|
124
125
|
|
|
125
126
|
### Agents 合计语义(实现正确,勿与「全场总账」混淆)
|
|
126
127
|
|
|
@@ -154,12 +155,12 @@ flowchart TD
|
|
|
154
155
|
|
|
155
156
|
| 数据 | 触发方式 |
|
|
156
157
|
|------|----------|
|
|
157
|
-
| 主 session snapshot | `createMemo` 内读 `refreshTick` + `
|
|
158
|
-
| 主 session 消息列表(Hit 趋势) |
|
|
159
|
-
| 子 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)` |
|
|
160
161
|
| 子 agent id 集合 | `session.list` 完成回调 / `message.updated` 发现新 child |
|
|
161
162
|
|
|
162
|
-
主 session **显式**订阅 `message.updated`(在 `sidebar-host`):每次事件 `refreshTick
|
|
163
|
+
主 session **显式**订阅 `message.updated`(在 `sidebar-host`):每次事件 `refreshTick++`,触发相关 memo 重算;会话总量以 `session.get()` 聚合为准,逐轮趋势使用最近 messages。
|
|
163
164
|
|
|
164
165
|
### 累加规则
|
|
165
166
|
|
|
@@ -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/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/src/sidebar-host.tsx
CHANGED
|
@@ -6,16 +6,20 @@ import { createTimelineCollector } from "./timeline/collector.ts"
|
|
|
6
6
|
import type { AssistantMessage, OpenCodeTuiApi, SubAgentSummary } from "./types.ts"
|
|
7
7
|
import {
|
|
8
8
|
emptySessionSnapshot,
|
|
9
|
+
aggregateFromSessionObject,
|
|
9
10
|
aggregateSessionFromMessages,
|
|
11
|
+
mainSessionHasStats,
|
|
10
12
|
subAgentHasStats,
|
|
11
13
|
toSubAgentSummary,
|
|
14
|
+
withModelFallback,
|
|
12
15
|
} from "./stats.ts"
|
|
13
16
|
import { createChildSessionSync } from "./child-session-sync.ts"
|
|
14
17
|
import { loadPluginConfig } from "./load-config.ts"
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
|
-
* Session-scoped sidebar host. Bumps `refreshTick` on message.updated
|
|
18
|
-
* so memos re-
|
|
20
|
+
* Session-scoped sidebar host. Bumps `refreshTick` on message.updated
|
|
21
|
+
* so memos re-compute. Prefers session.get() for aggregate cost/tokens
|
|
22
|
+
* (DB-level, not capped at 100 messages), falls back to session.messages().
|
|
19
23
|
* Timeline writes are event-driven: message.updated → handleMessage → appendFile.
|
|
20
24
|
*/
|
|
21
25
|
export function CacheHitSidebarHost(props: {
|
|
@@ -63,6 +67,14 @@ export function CacheHitSidebarHost(props: {
|
|
|
63
67
|
void refreshTick()
|
|
64
68
|
const sid = props.sessionId
|
|
65
69
|
if (!sid) return emptySessionSnapshot()
|
|
70
|
+
const session = props.api.state.session.get(sid)
|
|
71
|
+
if (session) {
|
|
72
|
+
const snap = aggregateFromSessionObject(session)
|
|
73
|
+
if (mainSessionHasStats(snap)) {
|
|
74
|
+
const msgs = props.api.state.session.messages(sid) as AssistantMessage[] | undefined
|
|
75
|
+
return msgs ? withModelFallback(snap, msgs) : snap
|
|
76
|
+
}
|
|
77
|
+
}
|
|
66
78
|
const msgs = props.api.state.session.messages(sid)
|
|
67
79
|
return msgs?.length
|
|
68
80
|
? aggregateSessionFromMessages(msgs as AssistantMessage[])
|
|
@@ -76,17 +88,27 @@ export function CacheHitSidebarHost(props: {
|
|
|
76
88
|
return (props.api.state.session.messages(sid) ?? []) as AssistantMessage[]
|
|
77
89
|
})
|
|
78
90
|
|
|
79
|
-
const subAgentList = createMemo(() =>
|
|
80
|
-
|
|
91
|
+
const subAgentList = createMemo(() => {
|
|
92
|
+
void refreshTick()
|
|
93
|
+
return childIds()
|
|
81
94
|
.map((cid) => {
|
|
95
|
+
const session = props.api.state.session.get(cid)
|
|
96
|
+
if (session) {
|
|
97
|
+
const snap = aggregateFromSessionObject(session)
|
|
98
|
+
if (subAgentHasStats(snap)) {
|
|
99
|
+
const msgs = props.api.state.session.messages(cid) as AssistantMessage[] | undefined
|
|
100
|
+
const merged = msgs ? withModelFallback(snap, msgs) : snap
|
|
101
|
+
return toSubAgentSummary(cid, merged)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
82
104
|
const msgs = props.api.state.session.messages(cid)
|
|
83
105
|
if (!msgs?.length) return null
|
|
84
106
|
const snap = aggregateSessionFromMessages(msgs as AssistantMessage[])
|
|
85
107
|
if (!subAgentHasStats(snap)) return null
|
|
86
108
|
return toSubAgentSummary(cid, snap)
|
|
87
109
|
})
|
|
88
|
-
.filter(Boolean) as SubAgentSummary[]
|
|
89
|
-
)
|
|
110
|
+
.filter(Boolean) as SubAgentSummary[]
|
|
111
|
+
})
|
|
90
112
|
|
|
91
113
|
createEffect(() => {
|
|
92
114
|
const sid = props.sessionId
|
package/src/stats.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AssistantMessage, SessionSnapshot, SubAgentSummary } from "./types.ts"
|
|
1
|
+
import type { AssistantMessage, SessionObject, SessionSnapshot, SubAgentSummary } from "./types.ts"
|
|
2
2
|
|
|
3
3
|
export function mainSessionHasStats(main: SessionSnapshot): boolean {
|
|
4
4
|
return (
|
|
@@ -14,6 +14,21 @@ export function emptySessionSnapshot(): SessionSnapshot {
|
|
|
14
14
|
return { model: "", providerID: "", input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export function aggregateFromSessionObject(session: SessionObject): SessionSnapshot {
|
|
18
|
+
const t = session.tokens
|
|
19
|
+
const c = t?.cache
|
|
20
|
+
return {
|
|
21
|
+
model: session.model?.id ?? "",
|
|
22
|
+
providerID: session.model?.providerID ?? "",
|
|
23
|
+
input: t?.input ?? 0,
|
|
24
|
+
output: t?.output ?? 0,
|
|
25
|
+
reasoning: t?.reasoning ?? 0,
|
|
26
|
+
cacheRead: c?.read ?? 0,
|
|
27
|
+
cacheWrite: c?.write ?? 0,
|
|
28
|
+
cost: session.cost ?? 0,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
export function aggregateSessionFromMessages(messages: readonly AssistantMessage[]): SessionSnapshot {
|
|
18
33
|
let model = "",
|
|
19
34
|
providerID = "",
|
|
@@ -81,6 +96,31 @@ export function subAgentHasStats(snap: SessionSnapshot): boolean {
|
|
|
81
96
|
)
|
|
82
97
|
}
|
|
83
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Fill missing model / providerID from the last assistant message.
|
|
101
|
+
* Session aggregates may have cost/tokens but lack model metadata;
|
|
102
|
+
* this avoids losing pricing/display when session.get() is used.
|
|
103
|
+
*/
|
|
104
|
+
export function withModelFallback(
|
|
105
|
+
snap: SessionSnapshot,
|
|
106
|
+
messages: readonly AssistantMessage[],
|
|
107
|
+
): SessionSnapshot {
|
|
108
|
+
if (snap.model && snap.providerID) return snap
|
|
109
|
+
|
|
110
|
+
let model = snap.model
|
|
111
|
+
let providerID = snap.providerID
|
|
112
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
113
|
+
const m = messages[i]
|
|
114
|
+
if (m.role !== "assistant") continue
|
|
115
|
+
if (!model && m.modelID) model = m.modelID
|
|
116
|
+
if (!providerID && m.providerID) providerID = m.providerID
|
|
117
|
+
if (model && providerID) break
|
|
118
|
+
}
|
|
119
|
+
return model === snap.model && providerID === snap.providerID
|
|
120
|
+
? snap
|
|
121
|
+
: { ...snap, model, providerID }
|
|
122
|
+
}
|
|
123
|
+
|
|
84
124
|
export function sidebarShouldShow(
|
|
85
125
|
main: SessionSnapshot,
|
|
86
126
|
subs: readonly SubAgentSummary[],
|
package/src/types.ts
CHANGED
|
@@ -53,13 +53,26 @@ export type ProviderInfo = {
|
|
|
53
53
|
models: { [key: string]: { cost: ModelCost } }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Session aggregate from `api.state.session.get()` — DB-level totals, not capped by message limit. */
|
|
57
|
+
export type SessionObject = {
|
|
58
|
+
model?: { id: string; providerID: string }
|
|
59
|
+
cost?: number
|
|
60
|
+
tokens?: {
|
|
61
|
+
input?: number
|
|
62
|
+
output?: number
|
|
63
|
+
reasoning?: number
|
|
64
|
+
cache?: { read?: number; write?: number }
|
|
65
|
+
}
|
|
66
|
+
parentID?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
export type OpenCodeTuiApi = {
|
|
57
70
|
state: {
|
|
58
71
|
path: { directory: string }
|
|
59
72
|
provider: ReadonlyArray<ProviderInfo>
|
|
60
73
|
session: {
|
|
61
74
|
messages: (id: string) => unknown[] | undefined
|
|
62
|
-
get: (id: string) =>
|
|
75
|
+
get: (id: string) => SessionObject | undefined
|
|
63
76
|
}
|
|
64
77
|
}
|
|
65
78
|
client: {
|