opencode-cache-hit 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -1
- package/README.md +5 -18
- package/README.zh-CN.md +155 -96
- package/docs/README.md +4 -4
- package/docs/assets/cache-hit-panel.v3.png +0 -0
- package/docs/en/design.md +30 -11
- package/docs/en/frontend-migration-plan.md +100 -0
- package/docs/en/timeline-duplicate-writes.md +125 -0
- package/docs/en/timeline.md +17 -13
- package/docs/zh-CN/design.md +31 -12
- package/docs/zh-CN/frontend-migration-plan.md +100 -0
- package/docs/zh-CN/timeline.md +18 -15
- package/package.json +1 -1
- package/scripts/README.md +63 -0
- package/scripts/timeline-dashboard.ts +728 -0
- package/src/agents-view.tsx +8 -8
- package/src/cache-ttl-view.tsx +3 -8
- package/src/format-cost.ts +70 -0
- package/src/format-model.ts +227 -0
- package/src/sidebar-host.tsx +34 -12
- package/src/stats.ts +41 -1
- package/src/timeline/collector.ts +40 -87
- package/src/timeline/records.ts +0 -30
- package/src/tui-panel/README.md +2 -2
- package/src/tui-panel/README.zh-CN.md +2 -2
- package/src/tui-panel/components.tsx +31 -4
- package/src/tui-panel/index.ts +6 -1
- package/src/tui-panel/palette.ts +5 -0
- package/src/types.ts +14 -1
- package/src/version.ts +4 -1
- package/docs/assets/.gitkeep +0 -0
- package/docs/assets/cache-hit-panel.png +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TimelineConfig } from "../plugin-config.ts"
|
|
2
2
|
import type { AssistantMessage } from "../types.ts"
|
|
3
|
-
import {
|
|
3
|
+
import { assistantMessageToRecord } from "./records.ts"
|
|
4
4
|
import {
|
|
5
5
|
appendTimelineRecord,
|
|
6
6
|
localDateKey,
|
|
@@ -10,10 +10,9 @@ import {
|
|
|
10
10
|
} from "./writer.ts"
|
|
11
11
|
import type { LlmCallRecord } from "./types.ts"
|
|
12
12
|
|
|
13
|
-
export const TIMELINE_DEBOUNCE_MS = 500
|
|
14
|
-
|
|
15
13
|
export type TimelineCollector = {
|
|
16
|
-
|
|
14
|
+
/** Process a single message from a message.updated event. */
|
|
15
|
+
handleMessage: (sessionID: string, msg: AssistantMessage) => void
|
|
17
16
|
resetForRootChange: () => void
|
|
18
17
|
dispose: () => void
|
|
19
18
|
memoryRecords: () => readonly LlmCallRecord[]
|
|
@@ -23,13 +22,12 @@ export function createTimelineCollector(opts: {
|
|
|
23
22
|
config: TimelineConfig
|
|
24
23
|
getRootSessionId: () => string
|
|
25
24
|
getChildIds: () => readonly string[]
|
|
26
|
-
getMessages: (sessionId: string) => readonly AssistantMessage[]
|
|
27
25
|
/** Test hook: replace disk append */
|
|
28
26
|
append?: (logPath: string, record: LlmCallRecord) => Promise<void>
|
|
29
27
|
}): TimelineCollector {
|
|
30
28
|
if (!opts.config.enabled) {
|
|
31
29
|
return {
|
|
32
|
-
|
|
30
|
+
handleMessage: () => {},
|
|
33
31
|
resetForRootChange: () => {},
|
|
34
32
|
dispose: () => {},
|
|
35
33
|
memoryRecords: () => [],
|
|
@@ -51,106 +49,61 @@ export function createTimelineCollector(opts: {
|
|
|
51
49
|
maxLogFiles: opts.config.maxLogFiles,
|
|
52
50
|
})
|
|
53
51
|
}
|
|
54
|
-
|
|
52
|
+
|
|
55
53
|
let activeDateKey = localDateKey()
|
|
56
|
-
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
57
54
|
let memory: LlmCallRecord[] = []
|
|
58
|
-
let
|
|
55
|
+
let disposed = false
|
|
59
56
|
|
|
60
57
|
const ensureDateKey = () => {
|
|
61
58
|
const today = localDateKey()
|
|
62
59
|
if (today !== activeDateKey) {
|
|
63
|
-
flushedKeys.clear()
|
|
64
60
|
activeDateKey = today
|
|
65
61
|
}
|
|
66
62
|
return today
|
|
67
63
|
}
|
|
68
64
|
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
debounceTimer = undefined
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const shouldFlushToDisk = (rec: LlmCallRecord): boolean => {
|
|
75
|
-
if (flushedKeys.has(rec.messageKey)) return false
|
|
76
|
-
if (rec.isComplete) return true
|
|
77
|
-
return opts.config.flushIncomplete
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const flushRecords = async (records: LlmCallRecord[], rootId: string, gen: number) => {
|
|
81
|
-
const logPath = timelineDailyLogPath(logsDir, ensureDateKey())
|
|
82
|
-
for (const rec of records) {
|
|
83
|
-
if (gen !== collectGen || opts.getRootSessionId() !== rootId) return
|
|
84
|
-
if (!shouldFlushToDisk(rec)) continue
|
|
85
|
-
flushedKeys.add(rec.messageKey)
|
|
86
|
-
try {
|
|
87
|
-
await append(logPath, rec)
|
|
88
|
-
} catch {
|
|
89
|
-
flushedKeys.delete(rec.messageKey)
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const collectNow = () => {
|
|
95
|
-
clearDebounce()
|
|
65
|
+
const handleMessage = (sessionID: string, msg: AssistantMessage) => {
|
|
66
|
+
if (disposed) return
|
|
96
67
|
const rootId = opts.getRootSessionId()
|
|
97
|
-
if (!rootId)
|
|
98
|
-
|
|
68
|
+
if (!rootId) return
|
|
69
|
+
|
|
70
|
+
let scope: "main" | "child"
|
|
71
|
+
if (sessionID === rootId) {
|
|
72
|
+
scope = "main"
|
|
73
|
+
} else if (opts.getChildIds().includes(sessionID)) {
|
|
74
|
+
scope = "child"
|
|
75
|
+
} else {
|
|
99
76
|
return
|
|
100
77
|
}
|
|
101
|
-
const gen = collectGen
|
|
102
|
-
const chunks: LlmCallRecord[][] = []
|
|
103
|
-
const mainMsgs = opts.getMessages(rootId)
|
|
104
|
-
if (mainMsgs.length) {
|
|
105
|
-
chunks.push(
|
|
106
|
-
buildCallRecords(rootId, rootId, "main", mainMsgs, {
|
|
107
|
-
logSummaryMessages: opts.config.logSummaryMessages,
|
|
108
|
-
}),
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
for (const cid of opts.getChildIds()) {
|
|
112
|
-
const msgs = opts.getMessages(cid)
|
|
113
|
-
if (msgs.length) {
|
|
114
|
-
chunks.push(
|
|
115
|
-
buildCallRecords(cid, rootId, "child", msgs, {
|
|
116
|
-
logSummaryMessages: opts.config.logSummaryMessages,
|
|
117
|
-
}),
|
|
118
|
-
)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (gen !== collectGen || opts.getRootSessionId() !== rootId) return
|
|
122
|
-
memory = mergeAndSortRecords(chunks)
|
|
123
|
-
const toFlush = memory.filter(shouldFlushToDisk)
|
|
124
|
-
if (toFlush.length > 0) {
|
|
125
|
-
queueMicrotask(() => flushRecords(toFlush, rootId, gen))
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
78
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (!opts.getRootSessionId()) return
|
|
132
|
-
debounceTimer = setTimeout(() => {
|
|
133
|
-
debounceTimer = undefined
|
|
134
|
-
collectNow()
|
|
135
|
-
}, TIMELINE_DEBOUNCE_MS)
|
|
136
|
-
}
|
|
79
|
+
if (msg.role !== "assistant") return
|
|
80
|
+
if (!opts.config.logSummaryMessages && msg.summary === true) return
|
|
137
81
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
memory = []
|
|
142
|
-
}
|
|
82
|
+
const rec = assistantMessageToRecord(msg, sessionID, rootId, scope, Date.now())
|
|
83
|
+
if (!rec) return
|
|
84
|
+
if (!opts.config.flushIncomplete && !rec.isComplete) return
|
|
143
85
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
clearDebounce()
|
|
147
|
-
}
|
|
86
|
+
const logPath = timelineDailyLogPath(logsDir, ensureDateKey())
|
|
87
|
+
void append(logPath, rec).catch(() => {})
|
|
148
88
|
|
|
149
|
-
|
|
89
|
+
memory.push(rec)
|
|
150
90
|
const max = opts.config.maxMemoryRows
|
|
151
|
-
|
|
152
|
-
return memory.slice(-max)
|
|
91
|
+
while (memory.length > max) memory.shift()
|
|
153
92
|
}
|
|
154
93
|
|
|
155
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
handleMessage,
|
|
96
|
+
resetForRootChange: () => {
|
|
97
|
+
memory = []
|
|
98
|
+
},
|
|
99
|
+
dispose: () => {
|
|
100
|
+
disposed = true
|
|
101
|
+
memory = []
|
|
102
|
+
},
|
|
103
|
+
memoryRecords: () => {
|
|
104
|
+
const max = opts.config.maxMemoryRows
|
|
105
|
+
if (memory.length <= max) return memory
|
|
106
|
+
return memory.slice(-max)
|
|
107
|
+
},
|
|
108
|
+
}
|
|
156
109
|
}
|
package/src/timeline/records.ts
CHANGED
|
@@ -25,18 +25,6 @@ export function messageKeyFor(msg: AssistantMessage, sessionId: string): string
|
|
|
25
25
|
return `${sessionId}:${created}:${msg.modelID ?? ""}`
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function sortKeyForRecord(r: LlmCallRecord): number {
|
|
29
|
-
const ts = r.completedAt ?? r.created
|
|
30
|
-
return new Date(ts).getTime()
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function mergeAndSortRecords(chunks: readonly LlmCallRecord[][]): LlmCallRecord[] {
|
|
34
|
-
const all = chunks.flat()
|
|
35
|
-
const keyed = all.map(r => ({ r, k: sortKeyForRecord(r) }))
|
|
36
|
-
keyed.sort((a, b) => a.k - b.k)
|
|
37
|
-
return keyed.map(x => x.r)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
28
|
export function assistantMessageToRecord(
|
|
41
29
|
msg: AssistantMessage,
|
|
42
30
|
sessionId: string,
|
|
@@ -72,21 +60,3 @@ export function assistantMessageToRecord(
|
|
|
72
60
|
}
|
|
73
61
|
}
|
|
74
62
|
|
|
75
|
-
export function buildCallRecords(
|
|
76
|
-
sessionId: string,
|
|
77
|
-
rootSessionId: string,
|
|
78
|
-
scope: "main" | "child",
|
|
79
|
-
messages: readonly AssistantMessage[],
|
|
80
|
-
opts?: { logSummaryMessages?: boolean; recordedAt?: number },
|
|
81
|
-
): LlmCallRecord[] {
|
|
82
|
-
const now = opts?.recordedAt ?? Date.now()
|
|
83
|
-
const logSummary = opts?.logSummaryMessages !== false
|
|
84
|
-
const out: LlmCallRecord[] = []
|
|
85
|
-
for (const msg of messages) {
|
|
86
|
-
if (!logSummary && msg.summary === true) continue
|
|
87
|
-
const rec = assistantMessageToRecord(msg, sessionId, rootSessionId, scope, now)
|
|
88
|
-
if (rec) out.push(rec)
|
|
89
|
-
}
|
|
90
|
-
return out
|
|
91
|
-
}
|
|
92
|
-
|
package/src/tui-panel/README.md
CHANGED
|
@@ -36,7 +36,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
|
|
|
36
36
|
open={open()}
|
|
37
37
|
onToggle={() => setOpen((o) => !o)}
|
|
38
38
|
title="My Panel"
|
|
39
|
-
version="0.
|
|
39
|
+
version="0.2.0"
|
|
40
40
|
/>
|
|
41
41
|
<Show when={open()}>
|
|
42
42
|
<Show
|
|
@@ -69,7 +69,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
|
|
|
69
69
|
| `TuiPanel` | Outer border + padding |
|
|
70
70
|
| `TuiPanelTitle` | Foldable title; optional `collapsed` summary |
|
|
71
71
|
| `TuiSection` | `▼` section header + separator fill |
|
|
72
|
-
| `TuiMetricRow` | Label left, value (+ unit) right |
|
|
72
|
+
| `TuiMetricRow` | Label left, value (+ unit) right; optional `labelFg` / `valueFg` for split colors |
|
|
73
73
|
| `TuiHitRow` | Hit bar + % + trend |
|
|
74
74
|
| `computeHitBarWidth` | Dynamic bar width |
|
|
75
75
|
|
|
@@ -36,7 +36,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
|
|
|
36
36
|
open={open()}
|
|
37
37
|
onToggle={() => setOpen((o) => !o)}
|
|
38
38
|
title="My Panel"
|
|
39
|
-
version="0.
|
|
39
|
+
version="0.2.0"
|
|
40
40
|
/>
|
|
41
41
|
<Show when={open()}>
|
|
42
42
|
<Show
|
|
@@ -69,7 +69,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
|
|
|
69
69
|
| `TuiPanel` | 外框 + padding |
|
|
70
70
|
| `TuiPanelTitle` | 可折叠标题;可选 `collapsed` 摘要 |
|
|
71
71
|
| `TuiSection` | `▼` 区块标题 + 分隔线填充 |
|
|
72
|
-
| `TuiMetricRow` | 左标签右数值(可选 unit
|
|
72
|
+
| `TuiMetricRow` | 左标签右数值(可选 unit);可选 `labelFg` / `valueFg` 分段上色 |
|
|
73
73
|
| `TuiHitRow` | Hit 条 + % + 趋势 |
|
|
74
74
|
| `computeHitBarWidth` | 动态进度条宽度 |
|
|
75
75
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { Show, type JSX } from "solid-js"
|
|
3
|
-
import { padBeforeTitleSummary, sepAfterPrefix, visualWidth } from "./layout.ts"
|
|
3
|
+
import { padBeforeTitleSummary, sepAfterPrefix, UNIT_GAP, visualWidth } from "./layout.ts"
|
|
4
4
|
import type { PanelLayout } from "./use-panel-layout.ts"
|
|
5
5
|
import type { PanelPalette } from "./palette.ts"
|
|
6
6
|
|
|
@@ -126,18 +126,45 @@ export function TuiSection(props: {
|
|
|
126
126
|
)
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
function metricRowGap(label: string, value: string, unit: string, gauge: number): number {
|
|
130
|
+
const used =
|
|
131
|
+
visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
|
|
132
|
+
return Math.max(1, gauge - used)
|
|
133
|
+
}
|
|
134
|
+
|
|
129
135
|
export function TuiMetricRow(props: {
|
|
130
136
|
pal: PanelPalette
|
|
131
137
|
layout: PanelLayout
|
|
132
138
|
label: string
|
|
133
139
|
value: string
|
|
134
140
|
unit?: string
|
|
141
|
+
/** Whole line (label + value + unit). Ignored when `labelFg` / `valueFg` set. */
|
|
135
142
|
fg?: string
|
|
143
|
+
/** Label-only color (e.g. sub-agent model); value stays `valueFg` or muted. */
|
|
144
|
+
labelFg?: string
|
|
145
|
+
valueFg?: string
|
|
136
146
|
}) {
|
|
137
|
-
const
|
|
147
|
+
const unit = props.unit ?? ""
|
|
148
|
+
const unitSuffix = unit ? " " + unit : ""
|
|
149
|
+
const split = props.labelFg !== undefined || props.valueFg !== undefined
|
|
150
|
+
if (split) {
|
|
151
|
+
const gap = metricRowGap(props.label, props.value, unit, props.layout.gauge())
|
|
152
|
+
const labelColor = props.labelFg ?? props.fg ?? props.pal.muted
|
|
153
|
+
const valueColor = props.valueFg ?? props.fg ?? props.pal.muted
|
|
154
|
+
return (
|
|
155
|
+
<text>
|
|
156
|
+
<span style={{ fg: labelColor }}>{props.label}</span>
|
|
157
|
+
{" ".repeat(gap)}
|
|
158
|
+
<span style={{ fg: valueColor }}>
|
|
159
|
+
{props.value}
|
|
160
|
+
{unitSuffix}
|
|
161
|
+
</span>
|
|
162
|
+
</text>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
138
165
|
return (
|
|
139
|
-
<text fg={fg}>
|
|
140
|
-
{props.layout.row(props.label, props.value,
|
|
166
|
+
<text fg={props.fg ?? props.pal.muted}>
|
|
167
|
+
{props.layout.row(props.label, props.value, unit)}
|
|
141
168
|
</text>
|
|
142
169
|
)
|
|
143
170
|
}
|
package/src/tui-panel/index.ts
CHANGED
|
@@ -19,7 +19,12 @@ export {
|
|
|
19
19
|
padBeforeTitleSummary,
|
|
20
20
|
} from "./layout.ts"
|
|
21
21
|
|
|
22
|
-
export {
|
|
22
|
+
export {
|
|
23
|
+
buildPanelPalette,
|
|
24
|
+
themeColorToHex,
|
|
25
|
+
toneBrandHex,
|
|
26
|
+
type PanelPalette,
|
|
27
|
+
} from "./palette.ts"
|
|
23
28
|
|
|
24
29
|
export {
|
|
25
30
|
createPanelLayout,
|
package/src/tui-panel/palette.ts
CHANGED
|
@@ -79,6 +79,11 @@ export function themeColorToHex(raw: unknown, fallback: string): string {
|
|
|
79
79
|
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** Tone a vendor brand hex for dark TUI panels (same max saturation as theme colors). */
|
|
83
|
+
export function toneBrandHex(hex: string, fallback: string): string {
|
|
84
|
+
return desaturateTo(hex, MAX_SAT, fallback)
|
|
85
|
+
}
|
|
86
|
+
|
|
82
87
|
export function buildPanelPalette(theme: Record<string, unknown>): PanelPalette {
|
|
83
88
|
const sat = (k: string, fb: string) => desaturateTo(theme[k], MAX_SAT, fb)
|
|
84
89
|
return {
|
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: {
|
package/src/version.ts
CHANGED
package/docs/assets/.gitkeep
DELETED
|
File without changes
|
|
Binary file
|