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,228 @@
1
+ # Design
2
+
3
+ For **maintainers** of this plugin: data flow, refresh rules, and boundaries vs [opencode-visual-cache](https://www.npmjs.com/package/opencode-visual-cache). User setup: [README.md](../../README.md).
4
+
5
+ ## Relationship to opencode-visual-cache
6
+
7
+ This is a **separate project**, not maintained by the visual-cache authors. It **borrows heavily** from [opencode-visual-cache](https://www.npmjs.com/package/opencode-visual-cache):
8
+
9
+ - **Sidebar panel layout** (`src/tui-panel/`): borders, hit bar, foldable sections, theme palette mapping
10
+ - **Layout**: main session block always visible; collapsible **Agents** section when sub-agents exist
11
+ - **Hit rate convention**: session totals align with visual-cache’s `cache.read / (cache.read + input)` rule
12
+
13
+ **Division of labor**: visual-cache focuses on **main-session context / token distribution estimates**; cache-hit focuses on **per-turn metrics, cost, and sub-agent aggregation**. Install both for a complete picture.
14
+
15
+ See the [documentation index](../README.md).
16
+
17
+ ## Product boundary
18
+
19
+ ```mermaid
20
+ flowchart TB
21
+ subgraph ours [opencode-cache-hit]
22
+ DISC[Child session discovery]
23
+ AGG[Per-message token/cost aggregation]
24
+ UI[Cache Hit sidebar]
25
+ end
26
+ subgraph ref [opencode-visual-cache reference]
27
+ EST[Context token estimate]
28
+ VCUI[Token Cache sidebar]
29
+ end
30
+ OC[OpenCode session / messages API] --> DISC
31
+ OC --> AGG
32
+ AGG --> UI
33
+ OC -.->|UI patterns only| EST
34
+ EST -.-> VCUI
35
+ ```
36
+
37
+ | Role | Responsibility |
38
+ |------|----------------|
39
+ | **cache-hit** | Sub-agent discovery and rollup; cache, tokens, cost for main/child sessions |
40
+ | **visual-cache** | Main-session context and estimates; not maintained in this repo |
41
+ | **Default** | Main block + foldable **Agents** (child-session totals) |
42
+
43
+ ## Cost model
44
+
45
+ - OpenCode: `msg.cost` accumulates assistant messages using **USD** list prices from `opencode.json`.
46
+ - Plugin: `createCostFormatter(loadPluginConfig().cost)`; default `costUnit: USD` → `currency: CNY`, `rate: 7.2`.
47
+ - Config file: `cache-hit.config.json` at plugin root (`load-config.ts`); defaults in `plugin-config.ts`.
48
+
49
+ ## Runtime architecture
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
+ ### Module map
68
+
69
+ | File | Role |
70
+ |------|------|
71
+ | `plugin.tsx` | `api.slots.register` (`order: 56`, next to visual-cache); load config |
72
+ | `sidebar-host.tsx` | Bind `sessionId`; `mainSnap` / `mainMessages` / `subAgentList`; `refreshTick` + `message.updated` |
73
+ | `widget.tsx` | Render panel when `sessionId` set; `noData` when empty |
74
+ | `use-cache-hit-metrics.ts` | Hit row, trend, main block visibility |
75
+ | `main-session-view.tsx` / `agents-view.tsx` | Business sections |
76
+ | `cache-hit-rows.tsx` | Shared token rows in Detail |
77
+ | `stats.ts` | Pure aggregation (no UI) |
78
+ | `session-list.ts` | Parse `session.list`, `childSessionIdsForParent` |
79
+ | `format-cost.ts` / `format-tokens.ts` / `format-cache-ui.ts` | Display formatting (`computeHitBarWidth` lives in `tui-panel/layout.ts`) |
80
+ | `message-timing.ts` | SDK time fields |
81
+ | `timeline/` | Per-call JSONL (`records` / `writer` / `collector`) |
82
+ | `plugin-config.ts` / `load-config.ts` | Config normalization |
83
+
84
+ ### TUI panel framework (`src/tui-panel/`)
85
+
86
+ Reusable **page** skeleton aligned with visual-cache (layout, colors, foldable sections)—no skills estimate, slash, or kv business logic.
87
+
88
+ | Module | Role |
89
+ |--------|------|
90
+ | `layout.ts` | Column widths, `justifyRow`, `computeHitBarWidth`, separators |
91
+ | `palette.ts` | Theme → panel palette |
92
+ | `use-panel-layout.ts` | `createPanelLayout`, `createSectionFold` |
93
+ | `components.tsx` | `TuiPanel`, `TuiHitRow`, `TuiMetricRow`, … (`@opentui/solid`) |
94
+ | `index.ts` | Barrel export |
95
+
96
+ Import `layout.ts` / `palette.ts` directly from non-JSX code (e.g. `use-cache-hit-metrics`) so `bun test` smoke tests do not pull JSX.
97
+
98
+ See [src/tui-panel/README.md](../../src/tui-panel/README.md) · [中文](../../src/tui-panel/README.zh-CN.md).
99
+
100
+ ## Sub-agent discovery
101
+
102
+ Implementation: `src/child-session-sync.ts` (used by `sidebar-host`).
103
+
104
+ ```mermaid
105
+ flowchart TD
106
+ A[sessionId changes] --> B[resetForParentChange]
107
+ B --> C[loadChildren: session.list]
108
+ E[message.updated any session] --> R[refreshTick++]
109
+ E --> F{sessionID !== parent?}
110
+ F -->|yes| G[debounce 200ms]
111
+ G --> C2[loadChildren overwrites childIds]
112
+ F -->|no| R
113
+ C --> H[childSessionIdsForParent]
114
+ C2 --> H
115
+ H --> I[re-read messages + aggregate]
116
+ ```
117
+
118
+ - **Single source of truth**: `childIds` always **replaced** from `session.list` (no `session.get` append).
119
+ - **Races**: `listGen` increments on parent change; callbacks check generation and `parentId`.
120
+ - **Streaming**: foreign-session `message.updated` is debounced (`CHILD_LIST_DEBOUNCE_MS` = 200ms).
121
+ - **Depth**: direct children only (`parentID === sid`); nested sub-agents are future work.
122
+ - **Data**: `messages(cid)` → `aggregateSessionFromMessages`; entries without stats are filtered out.
123
+
124
+ ### Agents block semantics
125
+
126
+ | Scope | Included in Agents total? |
127
+ |-------|-------------------------|
128
+ | Each child session’s assistant messages | Yes (`aggregateSubAgents`) |
129
+ | Main session assistant messages | **No** (still in `mainSnap` when hidden in UI) |
130
+
131
+ The UI shows `agentsScopeHint` (“sub-sessions only”). This complements visual-cache’s main-session view—it is not a missing-aggregation bug.
132
+
133
+ ## Aggregation and refresh
134
+
135
+ ### When values recompute
136
+
137
+ | Data | Trigger |
138
+ |------|---------|
139
+ | Main snapshot | `createMemo` reads `refreshTick` + `messages(sid)` |
140
+ | Main messages (Hit trend) | Same |
141
+ | Sub-agent content | `childIds` or `refreshTick` → re-read each `messages(cid)` |
142
+ | Sub-agent ids | `session.list` callback / debounced refresh on foreign activity |
143
+
144
+ `sidebar-host` subscribes to `message.updated` and bumps `refreshTick` so streaming token updates always recompute.
145
+
146
+ ### Accumulation rules
147
+
148
+ - Every `role === assistant` message in the session is included—not only the last turn.
149
+ - Streaming updates the same message’s `tokens` repeatedly.
150
+ - `reasoning` tokens are **excluded** from hit-rate denominator.
151
+ - `summary: true`: skipped in `computePerCallHitTrend`; **not** yet excluded in `aggregateSessionFromMessages`.
152
+
153
+ ### Sidebar visibility
154
+
155
+ ```mermaid
156
+ flowchart TD
157
+ S{sessionId set?}
158
+ S -->|no| H[no panel]
159
+ S -->|yes| P[TuiPanel]
160
+ P --> D{hasData?}
161
+ D -->|no| ND[noData]
162
+ D -->|yes| MAIN[Main / Detail / Model]
163
+ D -->|has subs| AG[Agents (foldable)]
164
+ ```
165
+
166
+ | Concept | Implementation |
167
+ |---------|----------------|
168
+ | Whole panel | `widget.tsx`: `Show when={sessionId().length > 0}` |
169
+ | Has data | `mainSessionHasStats(main) \|\| subs.length > 0` |
170
+ | Main **block** | Always rendered |
171
+ | **Agents** | Shown when `subs.length > 0`; foldable |
172
+
173
+ ## Hit rate (current)
174
+
175
+ **Session total (Total Hit, aligned with visual-cache)**
176
+
177
+ ```
178
+ Sum input and cache.read over assistant messages
179
+ → cacheRead / (cacheRead + input)
180
+ ```
181
+
182
+ **Header Hit (per-turn + trend)**
183
+
184
+ - `computePerCallHitTrend`: one rate per assistant turn; skip `summary: true`.
185
+ - Display the **last** non-summary turn; compare to previous for ↑ / ↓ / `-`.
186
+
187
+ **Combined Hit**
188
+
189
+ - Shown only when main block is visible, sub-agents exist, and combined vs session total differs by ≥ 0.05%.
190
+
191
+ ## Time fields (OpenCode SDK v2)
192
+
193
+ | Source | Field | Notes |
194
+ |--------|-------|-------|
195
+ | `AssistantMessage` | `time.created` | ms epoch |
196
+ | `AssistantMessage` | `time.completed?` | end of LLM turn; often missing while streaming |
197
+ | `ReasoningPart` | `time.start` / `time.end?` | thought segments |
198
+ | `ToolStateCompleted` | `time.start` / `time.end` | tool run |
199
+ | `StepFinishPart` | (no time) | tokens/cost; time falls back to message |
200
+
201
+ **Per-call JSONL (Phase 1, default off)**: one row per assistant turn; daily file under `logs/`. See **[timeline.md](./timeline.md)** § Storage, § Rotation and retention.
202
+
203
+ ## Testing
204
+
205
+ | Layer | Files | Purpose |
206
+ |-------|-------|---------|
207
+ | Unit | `tests/*.test.ts` | `stats`, `format-*`, layout, timeline |
208
+ | Smoke | `tests/module-load.test.ts` | Real import graph |
209
+ | Runtime | OpenCode logs | JSX / peer load errors |
210
+
211
+ After moving exports: `rg` + `bun test`.
212
+
213
+ ## Roadmap (not implemented)
214
+
215
+ | Item | Notes | Doc |
216
+ |------|-------|-----|
217
+ | Per-call JSONL | **Phase 1 done** (`src/timeline/`, default off) | [timeline.md](./timeline.md) |
218
+ | Metric modes | Session / last N / sliding window | timeline.md § Phase 3 |
219
+ | Sub-agents | Recursive children, filter by agent type | timeline.md § Risks |
220
+
221
+ Timeline writes must stay async and non-blocking (see timeline.md).
222
+
223
+ ## Plugin cache
224
+
225
+ | Install | After code change |
226
+ |---------|-------------------|
227
+ | Local path | Restart OpenCode |
228
+ | npm package | Restart; clear `~/.cache/opencode/packages/opencode-cache-hit@latest` if needed |
@@ -0,0 +1,253 @@
1
+ # Timeline / per-call JSONL
2
+
3
+ For developers. Sidebar aggregation: [design.md](./design.md). User guide: [README.md](../../README.md).
4
+
5
+ **Phase 1 (JSONL on disk) is implemented**; default `timeline.enabled: false`. Phase 2 sidebar Timeline section and Phase 3 metric modes are not done.
6
+
7
+ ## Goals and non-goals
8
+
9
+ | Goals | Non-goals |
10
+ |-------|-----------|
11
+ | Inspect each assistant call’s tokens / cache / cost / hit % over time | Replace OpenCode platform logs (`~/.local/share/opencode/log`) |
12
+ | Distinguish main vs child sessions | Spam the TUI with `console.log` |
13
+ | Local JSONL for `jq` / scripts | Cloud upload or team sharing |
14
+ | Same rules as `stats.ts` (including `summary` skip) | SQLite, charts, or recursive sub-agents in v1 |
15
+
16
+ ## Core concept
17
+
18
+ **One timeline event = one billable assistant turn**, same source as the sidebar **Hit** row—not tool parts or user messages.
19
+
20
+ ```mermaid
21
+ flowchart LR
22
+ MSG[AssistantMessage] --> REC[LlmCallRecord]
23
+ REC --> MEM[in-memory ring last N]
24
+ REC --> JSONL[JSONL append]
25
+ MEM --> UI[sidebar Timeline section optional]
26
+ ```
27
+
28
+ | Field | Source |
29
+ |-------|--------|
30
+ | Sort key | `time.completed ?? time.created` (`timingFromAssistantMessage`) |
31
+ | Hit trend eligibility | `summary !== true` and `input + cache.read > 0` |
32
+ | Session totals | `aggregateSessionFromMessages` (may later skip `summary` too) |
33
+
34
+ ## Data model
35
+
36
+ ```typescript
37
+ export type LlmCallRecord = {
38
+ schema: 1
39
+ recordedAt: number // local write time (ms)
40
+ sessionId: string
41
+ rootSessionId: string // main session; differs for child scope
42
+ scope: "main" | "child"
43
+ messageKey: string
44
+ modelId: string
45
+ created: number
46
+ completedAt?: number
47
+ durationMs?: number
48
+ isComplete: boolean
49
+ input: number
50
+ output: number
51
+ reasoning: number
52
+ cacheRead: number
53
+ cacheWrite: number
54
+ cost: number
55
+ hitPercent: number | null
56
+ skippedForHit: boolean // compaction / summary
57
+ }
58
+ ```
59
+
60
+ **`messageKey` (dedupe)**
61
+
62
+ 1. Prefer `message.id` / `messageID`.
63
+ 2. Fallback: `${sessionId}:${created}:${modelID ?? ""}`.
64
+
65
+ **Streaming**
66
+
67
+ - In-memory map keyed by `messageKey` is overwritten on each build.
68
+ - **Disk**: append when `isComplete === true` unless `flushIncomplete: true`.
69
+
70
+ ## Building records
71
+
72
+ Module: `src/timeline/records.ts` — `buildCallRecords(sessionId, rootSessionId, scope, messages)`.
73
+
74
+ - Only `role === assistant`.
75
+ - `skippedForHit = msg.summary === true`.
76
+ - `hitPercent` matches `computePerCallHitTrend` per message.
77
+ - Children: `buildCallRecords(cid, rootSid, "child", …)` merged and sorted by `completedAt ?? created`.
78
+
79
+ ## Storage
80
+
81
+ **Default layout**
82
+
83
+ ```
84
+ ~/.config/opencode/plugins/opencode-cache-hit/logs/
85
+ timeline-2026-05-31.jsonl # one active file per local calendar day
86
+ timeline-2026-05-31.jsonl.1 # size rotation backup for that day
87
+ ```
88
+
89
+ All main and child sessions for a day share one file; filter by `rootSessionId` / `sessionId` / `scope`. A new date gets a new filename at midnight.
90
+
91
+ Optional `dir` (e.g. `~/.local/share/opencode/cache-hit/logs/`).
92
+
93
+ **Legacy**: older builds used `<rootSessionId>.jsonl` per main session; not migrated automatically.
94
+
95
+ **Config** (`cache-hit.config.json` → `timeline`):
96
+
97
+ ```json
98
+ {
99
+ "timeline": {
100
+ "enabled": false,
101
+ "dir": "",
102
+ "flushIncomplete": false,
103
+ "logSummaryMessages": true,
104
+ "maxMemoryRows": 50,
105
+ "maxLinesPerFile": 100000,
106
+ "rotateMaxBytes": 16777216,
107
+ "retainRotated": 5,
108
+ "maxAgeDays": 30,
109
+ "maxLogFiles": 20
110
+ }
111
+ }
112
+ ```
113
+
114
+ Example values above; code defaults below (`enabled: false`, rotation `0` except `retainRotated: 5`).
115
+
116
+ | Field | Code default | Description |
117
+ |-------|--------------|-------------|
118
+ | `enabled` | `false` | No IO when off |
119
+ | `dir` | `""` | Empty → plugin `logs/` |
120
+ | `flushIncomplete` | `false` | Write only completed turns |
121
+ | `logSummaryMessages` | `true` | Include summary rows (flagged) |
122
+ | `maxMemoryRows` | `50` | In-memory rows for future UI |
123
+ | `maxLinesPerFile` | `0` | Trim active file to last N lines (`0` = off) |
124
+ | `rotateMaxBytes` | `0` | Size roll to `.jsonl.1` (`0` = off) |
125
+ | `retainRotated` | `5` | Same-day backup files to keep (not counting active) |
126
+ | `maxAgeDays` | `0` | On collector start: delete files older than N days (mtime) |
127
+ | `maxLogFiles` | `0` | Cap total `timeline-*.jsonl*` files (each `.1` counts) |
128
+
129
+ **Write pipeline** (`writer.ts` + `rotation.ts`)
130
+
131
+ 1. Optional size roll **before** append.
132
+ 2. `appendFile` one JSON line.
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).
135
+
136
+ ## Rotation and retention
137
+
138
+ ### Same-day size rotation (`rotateMaxBytes` + `retainRotated`)
139
+
140
+ Before each append, if the active file ≥ threshold:
141
+
142
+ ```
143
+ active (full) → rename → .1
144
+ old .1 → rename → .2
145
+ old .N → delete when at retainRotated and rolling again
146
+ new empty active file, then append
147
+ ```
148
+
149
+ | `retainRotated` | Approx. max per day |
150
+ |-----------------|---------------------|
151
+ | `5` (default / example) | active + `.1`…`.5` ≈ 6× `rotateMaxBytes` |
152
+ | `1` | active + `.1` ≈ 2× |
153
+ | `0` | delete active file on roll, no backup |
154
+
155
+ Oldest backup is **deleted** on further rolls; data is gone permanently.
156
+
157
+ ### Line cap (`maxLinesPerFile`)
158
+
159
+ Rewrites the **active** file in place; dropped lines are **not** moved to `.1`. With ~500 B/line records, **16MB size roll usually happens before 100k lines**.
160
+
161
+ ### Directory cleanup (once per collector start)
162
+
163
+ 1. `maxAgeDays`: delete `timeline-*.jsonl*` with mtime older than N days.
164
+ 2. `maxLogFiles`: if still over cap, delete **earliest logs first**: oldest **date in filename**, then highest backup index (`.5` before `.1` before active); mtime only as tie-breaker.
165
+
166
+ Does **not** match legacy `ses_*.jsonl` names.
167
+
168
+ ### Cross-day
169
+
170
+ New filename after midnight; previous days remain until cleanup runs.
171
+
172
+ ### Dedup and session switch
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).
177
+
178
+ ## Runtime wiring
179
+
180
+ ```mermaid
181
+ sequenceDiagram
182
+ participant E as message.updated
183
+ participant H as sidebar-host
184
+ participant B as timeline/build
185
+ participant W as timeline/writer
186
+
187
+ E->>H: refreshTick++
188
+ H->>B: debounce 500ms buildCallRecords
189
+ alt enabled and complete and not flushed
190
+ B->>W: append JSONL
191
+ end
192
+ ```
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.
197
+
198
+ ## UI phases
199
+
200
+ ### Phase 1 — disk only (current)
201
+
202
+ ```bash
203
+ LOG=~/.config/opencode/plugins/opencode-cache-hit/logs/timeline-$(date +%Y-%m-%d).jsonl
204
+ tail -f "$LOG"
205
+ jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost]|@tsv' "$LOG"
206
+ ```
207
+
208
+ **Charts (optional scripts)** — see [scripts/README.md](../../scripts/README.md):
209
+
210
+ ```bash
211
+ # one-liner: call count + average hit %
212
+ 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"
213
+
214
+ bun scripts/plot-hit-rate.ts "$LOG" -o /tmp/hit.svg
215
+ bun scripts/plot-hit-rate.ts "$LOG" --by-root -o /tmp/hit-multi.svg
216
+ ```
217
+
218
+ ### Phase 2 — sidebar Timeline section (planned)
219
+
220
+ ### Phase 3 — metric window linkage (planned)
221
+
222
+ ## Module map
223
+
224
+ | Module | Role |
225
+ |--------|------|
226
+ | `message-timing.ts` | `created` / `completed` |
227
+ | `stats.ts` | shared per-message hit % |
228
+ | `sidebar-host.tsx` | `createTimelineCollector` |
229
+ | `plugin.tsx` | reads config |
230
+
231
+ ## Tests
232
+
233
+ | Case | File |
234
+ |------|------|
235
+ | `buildCallRecords` | `tests/timeline-records.test.ts` |
236
+ | writer / rotation / purge | `tests/timeline-writer.test.ts`, `timeline-rotation.test.ts` |
237
+ | collector | `tests/timeline-collector.test.ts` |
238
+
239
+ ## Risks
240
+
241
+ | Risk | Mitigation |
242
+ |------|------------|
243
+ | Too many writes while streaming | `isComplete` only; debounce |
244
+ | No stable message id | synthetic `messageKey` |
245
+ | Nested sub-agents | flat `child` scope only in v1 |
246
+ | Disk growth | rotation + age + file count caps |
247
+ | SDK changes | `schema: 1` |
248
+
249
+ ## Example line
250
+
251
+ ```json
252
+ {"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}
253
+ ```