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.
- package/AGENTS.md +67 -0
- package/CONTRIBUTING.md +73 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/README.zh-CN.md +186 -0
- package/cache-hit.config.example.json +22 -0
- package/docs/README.md +8 -0
- package/docs/assets/cache-hit-panel.png +0 -0
- package/docs/en/design.md +228 -0
- package/docs/en/timeline.md +253 -0
- package/docs/zh-CN/design.md +229 -0
- package/docs/zh-CN/timeline.md +301 -0
- package/index.tsx +1 -0
- package/package.json +71 -0
- package/scripts/README.md +39 -0
- package/scripts/plot-hit-rate.ts +222 -0
- package/src/agents-view.tsx +55 -0
- package/src/cache-hit-rows.tsx +68 -0
- package/src/child-session-sync.ts +93 -0
- package/src/format-cache-ui.ts +21 -0
- package/src/format-cost.ts +90 -0
- package/src/format-tokens.ts +5 -0
- package/src/i18n.ts +82 -0
- package/src/load-config.ts +29 -0
- package/src/main-session-view.tsx +76 -0
- package/src/message-timing.ts +35 -0
- package/src/plugin-config.ts +116 -0
- package/src/plugin.tsx +33 -0
- package/src/session-list.ts +11 -0
- package/src/sidebar-host.tsx +121 -0
- package/src/stats.ts +141 -0
- package/src/timeline/collector.ts +156 -0
- package/src/timeline/records.ts +73 -0
- package/src/timeline/rotation.ts +47 -0
- package/src/timeline/types.ts +22 -0
- package/src/timeline/writer.ts +134 -0
- package/src/tui-panel/README.md +78 -0
- package/src/tui-panel/README.zh-CN.md +76 -0
- package/src/tui-panel/components.tsx +163 -0
- package/src/tui-panel/index.ts +41 -0
- package/src/tui-panel/layout.ts +107 -0
- package/src/tui-panel/palette.ts +93 -0
- package/src/tui-panel/use-panel-layout.ts +69 -0
- package/src/types.ts +71 -0
- package/src/use-cache-hit-metrics.ts +103 -0
- package/src/version.ts +1 -0
- package/src/widget.tsx +117 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Plot cache hit % from timeline JSONL (Bun, no npm deps).
|
|
4
|
+
*
|
|
5
|
+
* bun scripts/plot-hit-rate.ts logs/timeline-2026-05-31.jsonl
|
|
6
|
+
* bun scripts/plot-hit-rate.ts logs/timeline-2026-05-31.jsonl --root ses_xxx -o hit.svg
|
|
7
|
+
* bun scripts/plot-hit-rate.ts logs/timeline-2026-05-31.jsonl --by-root -o hit.svg
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type Row = {
|
|
11
|
+
rootSessionId?: string
|
|
12
|
+
scope?: string
|
|
13
|
+
created?: number
|
|
14
|
+
completedAt?: number
|
|
15
|
+
hitPercent?: number | null
|
|
16
|
+
skippedForHit?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Point = { t: number; y: number }
|
|
20
|
+
|
|
21
|
+
const COLORS = ["#3fb950", "#58a6ff", "#d29922", "#f85149", "#a371f7", "#79c0ff"]
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv: string[]) {
|
|
24
|
+
const positional: string[] = []
|
|
25
|
+
let root: string | undefined
|
|
26
|
+
let output: string | undefined
|
|
27
|
+
let byRoot = false
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i]
|
|
30
|
+
if (a === "--root") root = argv[++i]
|
|
31
|
+
else if (a === "--by-root") byRoot = true
|
|
32
|
+
else if (a === "-o" || a === "--output") output = argv[++i]
|
|
33
|
+
else if (!a.startsWith("-")) positional.push(a)
|
|
34
|
+
}
|
|
35
|
+
if (!positional[0]) {
|
|
36
|
+
console.error(
|
|
37
|
+
"usage: bun scripts/plot-hit-rate.ts <file.jsonl> [--root ID | --by-root] [-o out.svg]",
|
|
38
|
+
)
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
41
|
+
return { file: positional[0], root, byRoot, output }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function loadRecords(path: string, root?: string): Promise<Row[]> {
|
|
45
|
+
const text = await Bun.file(path).text()
|
|
46
|
+
const rows: Row[] = []
|
|
47
|
+
for (const line of text.split("\n")) {
|
|
48
|
+
const s = line.trim()
|
|
49
|
+
if (!s) continue
|
|
50
|
+
const rec = JSON.parse(s) as Row
|
|
51
|
+
if (root && rec.rootSessionId !== root) continue
|
|
52
|
+
if (rec.skippedForHit) continue
|
|
53
|
+
if (rec.hitPercent == null) continue
|
|
54
|
+
rows.push(rec)
|
|
55
|
+
}
|
|
56
|
+
return rows
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function timeOf(r: Row): number {
|
|
60
|
+
return r.completedAt ?? r.created ?? 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function groupByRoot(rows: Row[]): Map<string, Row[]> {
|
|
64
|
+
const map = new Map<string, Row[]>()
|
|
65
|
+
for (const r of rows) {
|
|
66
|
+
const id = r.rootSessionId ?? "(unknown)"
|
|
67
|
+
const list = map.get(id) ?? []
|
|
68
|
+
list.push(r)
|
|
69
|
+
map.set(id, list)
|
|
70
|
+
}
|
|
71
|
+
for (const list of map.values()) {
|
|
72
|
+
list.sort((a, b) => timeOf(a) - timeOf(b))
|
|
73
|
+
}
|
|
74
|
+
return new Map([...map.entries()].sort((a, b) => a[0].localeCompare(b[0])))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function shortId(id: string, max = 20): string {
|
|
78
|
+
return id.length <= max ? id : `…${id.slice(-max)}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function asciiChart(values: number[], width = 48, height = 8): string {
|
|
82
|
+
const grid: string[][] = Array.from({ length: height }, () => Array(width).fill(" "))
|
|
83
|
+
const row = (v: number) =>
|
|
84
|
+
Math.min(height - 1, Math.max(0, Math.round((v / 100) * (height - 1))))
|
|
85
|
+
for (let i = 0; i < values.length; i++) {
|
|
86
|
+
const x = Math.round((i / Math.max(1, values.length - 1)) * (width - 1))
|
|
87
|
+
grid[height - 1 - row(values[i])][x] = "●"
|
|
88
|
+
}
|
|
89
|
+
const yLabels = ["100", " 50", " 0"]
|
|
90
|
+
return grid.map((line, i) => `${yLabels[i] ?? " "} │${line.join("")}`).join("\n")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function esc(s: string): string {
|
|
94
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function svgChartSingle(values: number[], title: string, w = 640, h = 240): string {
|
|
98
|
+
const pad = { l: 48, r: 16, t: 24, b: 32 }
|
|
99
|
+
const innerW = w - pad.l - pad.r
|
|
100
|
+
const innerH = h - pad.t - pad.b
|
|
101
|
+
const pts = values.map((v, i) => {
|
|
102
|
+
const x = pad.l + (i / Math.max(1, values.length - 1)) * innerW
|
|
103
|
+
const y = pad.t + innerH - (v / 100) * innerH
|
|
104
|
+
return { x, y, v }
|
|
105
|
+
})
|
|
106
|
+
const poly = pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")
|
|
107
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
108
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
|
109
|
+
<rect width="100%" height="100%" fill="#0d1117"/>
|
|
110
|
+
<text x="${pad.l}" y="16" fill="#e6edf3" font-family="system-ui,sans-serif" font-size="12">${esc(title)}</text>
|
|
111
|
+
<line x1="${pad.l}" y1="${pad.t + innerH}" x2="${pad.l + innerW}" y2="${pad.t + innerH}" stroke="#30363d"/>
|
|
112
|
+
<line x1="${pad.l}" y1="${pad.t}" x2="${pad.l}" y2="${pad.t + innerH}" stroke="#30363d"/>
|
|
113
|
+
<text x="8" y="${pad.t + 4}" fill="#8b949e" font-size="10">100%</text>
|
|
114
|
+
<text x="8" y="${pad.t + innerH}" fill="#8b949e" font-size="10">0%</text>
|
|
115
|
+
<polyline fill="none" stroke="${COLORS[0]}" stroke-width="2" points="${poly}"/>
|
|
116
|
+
${pts.map((p) => `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="${COLORS[0]}"/>`).join("\n ")}
|
|
117
|
+
</svg>`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function svgChartByRoot(
|
|
121
|
+
series: { id: string; points: Point[] }[],
|
|
122
|
+
title: string,
|
|
123
|
+
w = 720,
|
|
124
|
+
h = 280,
|
|
125
|
+
): string {
|
|
126
|
+
const pad = { l: 48, r: 16, t: 28, b: 56 }
|
|
127
|
+
const innerW = w - pad.l - pad.r
|
|
128
|
+
const innerH = h - pad.t - pad.b
|
|
129
|
+
let tMin = Infinity
|
|
130
|
+
let tMax = -Infinity
|
|
131
|
+
for (const s of series) {
|
|
132
|
+
for (const p of s.points) {
|
|
133
|
+
if (p.t < tMin) tMin = p.t
|
|
134
|
+
if (p.t > tMax) tMax = p.t
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const span = Math.max(1, tMax - tMin)
|
|
138
|
+
const toXY = (p: Point) => ({
|
|
139
|
+
x: pad.l + ((p.t - tMin) / span) * innerW,
|
|
140
|
+
y: pad.t + innerH - (p.y / 100) * innerH,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const bodies: string[] = []
|
|
144
|
+
const legend: string[] = []
|
|
145
|
+
series.forEach((s, i) => {
|
|
146
|
+
const color = COLORS[i % COLORS.length]
|
|
147
|
+
const xy = s.points.map(toXY)
|
|
148
|
+
const poly = xy.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")
|
|
149
|
+
bodies.push(
|
|
150
|
+
`<polyline fill="none" stroke="${color}" stroke-width="2" points="${poly}"/>`,
|
|
151
|
+
)
|
|
152
|
+
for (const p of xy) {
|
|
153
|
+
bodies.push(`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="2.5" fill="${color}"/>`)
|
|
154
|
+
}
|
|
155
|
+
const ly = pad.t + innerH + 18 + i * 14
|
|
156
|
+
legend.push(
|
|
157
|
+
`<line x1="${pad.l}" y1="${ly - 4}" x2="${pad.l + 20}" y2="${ly - 4}" stroke="${color}" stroke-width="2"/>`,
|
|
158
|
+
`<text x="${pad.l + 26}" y="${ly}" fill="#e6edf3" font-size="11" font-family="ui-monospace,monospace">${esc(shortId(s.id))} (${s.points.length})</text>`,
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
163
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
|
164
|
+
<rect width="100%" height="100%" fill="#0d1117"/>
|
|
165
|
+
<text x="${pad.l}" y="18" fill="#e6edf3" font-family="system-ui,sans-serif" font-size="12">${esc(title)}</text>
|
|
166
|
+
<line x1="${pad.l}" y1="${pad.t + innerH}" x2="${pad.l + innerW}" y2="${pad.t + innerH}" stroke="#30363d"/>
|
|
167
|
+
<line x1="${pad.l}" y1="${pad.t}" x2="${pad.l}" y2="${pad.t + innerH}" stroke="#30363d"/>
|
|
168
|
+
<text x="8" y="${pad.t + 4}" fill="#8b949e" font-size="10">100%</text>
|
|
169
|
+
<text x="8" y="${pad.t + innerH}" fill="#8b949e" font-size="10">0%</text>
|
|
170
|
+
${bodies.join("\n ")}
|
|
171
|
+
${legend.join("\n ")}
|
|
172
|
+
</svg>`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const { file, root, byRoot, output } = parseArgs(process.argv.slice(2))
|
|
176
|
+
const allRows = await loadRecords(file, root)
|
|
177
|
+
|
|
178
|
+
if (allRows.length === 0) {
|
|
179
|
+
console.error("no plottable rows (check --root or hitPercent)")
|
|
180
|
+
process.exit(1)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const useByRoot = byRoot && !root
|
|
184
|
+
|
|
185
|
+
if (useByRoot) {
|
|
186
|
+
const groups = groupByRoot(allRows)
|
|
187
|
+
console.log(`${groups.size} root session(s), ${allRows.length} calls total`)
|
|
188
|
+
for (const [id, list] of groups) {
|
|
189
|
+
const hits = list.map((r) => r.hitPercent as number)
|
|
190
|
+
const avg = hits.reduce((a, b) => a + b, 0) / hits.length
|
|
191
|
+
console.log(` ${shortId(id, 28)}: ${list.length} calls, avg hit ${avg.toFixed(1)}%`)
|
|
192
|
+
}
|
|
193
|
+
if (output) {
|
|
194
|
+
const series = [...groups.entries()].map(([id, list]) => ({
|
|
195
|
+
id,
|
|
196
|
+
points: list.map((r) => ({ t: timeOf(r), y: r.hitPercent as number })),
|
|
197
|
+
}))
|
|
198
|
+
await Bun.write(output, svgChartByRoot(series, `${file} · by rootSessionId`))
|
|
199
|
+
console.log("")
|
|
200
|
+
console.log(`wrote ${output} (${groups.size} series)`)
|
|
201
|
+
} else {
|
|
202
|
+
console.log("")
|
|
203
|
+
console.log("tip: add -o /tmp/hit.svg (time-aligned, one color per rootSessionId)")
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
const sorted = [...allRows].sort((a, b) => timeOf(a) - timeOf(b))
|
|
207
|
+
const hits = sorted.map((r) => r.hitPercent as number)
|
|
208
|
+
const avg = hits.reduce((a, b) => a + b, 0) / hits.length
|
|
209
|
+
const label = root ? ` (${shortId(root)})` : " (all roots merged)"
|
|
210
|
+
console.log(`${sorted.length} calls, avg hit ${avg.toFixed(1)}%${label}`)
|
|
211
|
+
console.log("")
|
|
212
|
+
console.log(asciiChart(hits))
|
|
213
|
+
if (output) {
|
|
214
|
+
const title = root ? `${file} · ${shortId(root)}` : `${file} · merged`
|
|
215
|
+
await Bun.write(output, svgChartSingle(hits, title))
|
|
216
|
+
console.log("")
|
|
217
|
+
console.log(`wrote ${output}`)
|
|
218
|
+
} else {
|
|
219
|
+
console.log("")
|
|
220
|
+
console.log("tip: -o /tmp/hit.svg | --by-root for multi-session chart")
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { For, Show } from "solid-js"
|
|
3
|
+
import { TokenDetailRows } from "./cache-hit-rows.tsx"
|
|
4
|
+
import type { CacheHitMetrics } from "./use-cache-hit-metrics.ts"
|
|
5
|
+
import { aggregateSubAgents } from "./stats.ts"
|
|
6
|
+
import { formatTokenCount } from "./format-tokens.ts"
|
|
7
|
+
import { TuiMetricRow, truncateVisual, type PanelLayout } from "./tui-panel/index.ts"
|
|
8
|
+
import type { SubAgentSummary } from "./types.ts"
|
|
9
|
+
|
|
10
|
+
function agentRowLabel(id: string, gauge: number): string {
|
|
11
|
+
const tail = id.length > 10 ? id.slice(-8) : id
|
|
12
|
+
const raw = id.length > 10 ? "\u2026" + tail : tail
|
|
13
|
+
return truncateVisual(raw, Math.max(6, gauge - 14))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function subHasActivity(sub: SubAgentSummary): boolean {
|
|
17
|
+
return sub.cost > 0 || sub.cacheRead > 0 || sub.cacheWrite > 0 || sub.input > 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function AgentsView(props: {
|
|
21
|
+
m: CacheHitMetrics
|
|
22
|
+
layout: PanelLayout
|
|
23
|
+
formatCost: (n: number) => string
|
|
24
|
+
}) {
|
|
25
|
+
const { m, layout } = props
|
|
26
|
+
const total = () => aggregateSubAgents(m.subs())
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<TokenDetailRows pal={m.pal()} layout={layout} t={m.t()} snap={total()} />
|
|
31
|
+
<Show when={total().cost > 0}>
|
|
32
|
+
<TuiMetricRow
|
|
33
|
+
pal={m.pal()}
|
|
34
|
+
layout={layout}
|
|
35
|
+
label={m.t().cost}
|
|
36
|
+
value={props.formatCost(total().cost)}
|
|
37
|
+
fg={m.pal().success}
|
|
38
|
+
/>
|
|
39
|
+
</Show>
|
|
40
|
+
<For each={m.subs()}>
|
|
41
|
+
{(sub) => (
|
|
42
|
+
<Show when={subHasActivity(sub)}>
|
|
43
|
+
<TuiMetricRow
|
|
44
|
+
pal={m.pal()}
|
|
45
|
+
layout={layout}
|
|
46
|
+
label={" " + agentRowLabel(sub.id, layout.gauge())}
|
|
47
|
+
value={sub.cost > 0 ? props.formatCost(sub.cost) : formatTokenCount(sub.input)}
|
|
48
|
+
unit={sub.cost > 0 ? "" : m.t().tok}
|
|
49
|
+
/>
|
|
50
|
+
</Show>
|
|
51
|
+
)}
|
|
52
|
+
</For>
|
|
53
|
+
</>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { Show, type JSX } from "solid-js"
|
|
3
|
+
import type { UiStrings } from "./i18n.ts"
|
|
4
|
+
import { formatTokenCount } from "./format-tokens.ts"
|
|
5
|
+
import { TuiMetricRow } from "./tui-panel/index.ts"
|
|
6
|
+
import type { PanelLayout, PanelPalette } from "./tui-panel/index.ts"
|
|
7
|
+
import type { SessionSnapshot } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
export type TokenSnap = Pick<
|
|
10
|
+
SessionSnapshot,
|
|
11
|
+
"cacheRead" | "cacheWrite" | "input" | "output" | "reasoning"
|
|
12
|
+
>
|
|
13
|
+
|
|
14
|
+
export function TokenDetailRows(props: {
|
|
15
|
+
pal: PanelPalette
|
|
16
|
+
layout: PanelLayout
|
|
17
|
+
t: UiStrings
|
|
18
|
+
snap: TokenSnap
|
|
19
|
+
children?: JSX.Element
|
|
20
|
+
}) {
|
|
21
|
+
const tok = (n: number) => formatTokenCount(n)
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
<Show when={props.snap.cacheRead > 0}>
|
|
25
|
+
<TuiMetricRow
|
|
26
|
+
pal={props.pal}
|
|
27
|
+
layout={props.layout}
|
|
28
|
+
label={props.t.read}
|
|
29
|
+
value={tok(props.snap.cacheRead)}
|
|
30
|
+
unit={props.t.tok}
|
|
31
|
+
/>
|
|
32
|
+
</Show>
|
|
33
|
+
<Show when={props.snap.cacheWrite > 0}>
|
|
34
|
+
<TuiMetricRow
|
|
35
|
+
pal={props.pal}
|
|
36
|
+
layout={props.layout}
|
|
37
|
+
label={props.t.write}
|
|
38
|
+
value={tok(props.snap.cacheWrite)}
|
|
39
|
+
unit={props.t.tok}
|
|
40
|
+
/>
|
|
41
|
+
</Show>
|
|
42
|
+
<TuiMetricRow
|
|
43
|
+
pal={props.pal}
|
|
44
|
+
layout={props.layout}
|
|
45
|
+
label={props.t.miss}
|
|
46
|
+
value={tok(props.snap.input)}
|
|
47
|
+
unit={props.t.tok}
|
|
48
|
+
/>
|
|
49
|
+
<TuiMetricRow
|
|
50
|
+
pal={props.pal}
|
|
51
|
+
layout={props.layout}
|
|
52
|
+
label={props.t.out}
|
|
53
|
+
value={tok(props.snap.output)}
|
|
54
|
+
unit={props.t.tok}
|
|
55
|
+
/>
|
|
56
|
+
<Show when={props.snap.reasoning > 0}>
|
|
57
|
+
<TuiMetricRow
|
|
58
|
+
pal={props.pal}
|
|
59
|
+
layout={props.layout}
|
|
60
|
+
label={props.t.reasoning}
|
|
61
|
+
value={tok(props.snap.reasoning)}
|
|
62
|
+
unit={props.t.tok}
|
|
63
|
+
/>
|
|
64
|
+
</Show>
|
|
65
|
+
{props.children}
|
|
66
|
+
</>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { childSessionIdsForParent, parseSessionListResponse } from "./session-list.ts"
|
|
2
|
+
import type { OpenCodeTuiApi } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
/** Debounce for session.list after foreign-session message.updated (streaming fires often). */
|
|
5
|
+
export const CHILD_LIST_DEBOUNCE_MS = 200
|
|
6
|
+
|
|
7
|
+
/** Pass `api.client.session`, never `api.client`. */
|
|
8
|
+
export type ChildSessionListClient = OpenCodeTuiApi["client"]["session"]
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Keeps child session ids in sync with session.list for a single parent session.
|
|
12
|
+
* - Parent change: invalidate in-flight work, clear ids, list immediately.
|
|
13
|
+
* - Foreign message.updated: debounced list (source of truth; no append-only).
|
|
14
|
+
*/
|
|
15
|
+
export function createChildSessionSync(opts: {
|
|
16
|
+
client: ChildSessionListClient
|
|
17
|
+
getDirectory: () => string
|
|
18
|
+
getParentId: () => string
|
|
19
|
+
setChildIds: (ids: string[]) => void
|
|
20
|
+
onSynced?: () => void
|
|
21
|
+
debounceMs?: number
|
|
22
|
+
}) {
|
|
23
|
+
let listGen = 0
|
|
24
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
25
|
+
|
|
26
|
+
const clearDebounce = () => {
|
|
27
|
+
if (debounceTimer !== undefined) clearTimeout(debounceTimer)
|
|
28
|
+
debounceTimer = undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const loadChildren = () => {
|
|
32
|
+
clearDebounce()
|
|
33
|
+
const parentId = opts.getParentId()
|
|
34
|
+
if (!parentId) {
|
|
35
|
+
opts.setChildIds([])
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
const gen = listGen
|
|
39
|
+
const directory = opts.getDirectory()
|
|
40
|
+
opts.client
|
|
41
|
+
.list({ query: { directory } })
|
|
42
|
+
.then(
|
|
43
|
+
(all) => {
|
|
44
|
+
if (gen !== listGen || opts.getParentId() !== parentId) return
|
|
45
|
+
opts.setChildIds(childSessionIdsForParent(parseSessionListResponse(all), parentId))
|
|
46
|
+
opts.onSynced?.()
|
|
47
|
+
},
|
|
48
|
+
() => {
|
|
49
|
+
if (gen !== listGen || opts.getParentId() !== parentId) return
|
|
50
|
+
opts.setChildIds([])
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const scheduleLoad = () => {
|
|
56
|
+
clearDebounce()
|
|
57
|
+
if (!opts.getParentId()) return
|
|
58
|
+
const ms = opts.debounceMs ?? CHILD_LIST_DEBOUNCE_MS
|
|
59
|
+
debounceTimer = setTimeout(() => {
|
|
60
|
+
debounceTimer = undefined
|
|
61
|
+
loadChildren()
|
|
62
|
+
}, ms)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Call when the sidebar parent session id changes. */
|
|
66
|
+
const resetForParentChange = () => {
|
|
67
|
+
listGen++
|
|
68
|
+
clearDebounce()
|
|
69
|
+
opts.setChildIds([])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** message.updated on a session other than the current parent. */
|
|
73
|
+
const onForeignSessionActivity = (sessionId: string | undefined) => {
|
|
74
|
+
const parentId = opts.getParentId()
|
|
75
|
+
if (!parentId || !sessionId || sessionId === parentId) return
|
|
76
|
+
scheduleLoad()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const dispose = () => {
|
|
80
|
+
listGen++
|
|
81
|
+
clearDebounce()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
loadChildren,
|
|
86
|
+
scheduleLoad,
|
|
87
|
+
resetForParentChange,
|
|
88
|
+
onForeignSessionActivity,
|
|
89
|
+
dispose,
|
|
90
|
+
/** Test hook: current generation token. */
|
|
91
|
+
_generation: () => listGen,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** deltaPercent: change in hit % points. Visual-cache trendLabel (0 → "-"). */
|
|
2
|
+
export function formatTrendLabel(deltaPercent: number): string {
|
|
3
|
+
const t = deltaPercent
|
|
4
|
+
return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "")
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** One decimal place, e.g. 98.8% */
|
|
8
|
+
export function formatPercentOneDecimal(percent0to100: number): string {
|
|
9
|
+
return (Math.floor(percent0to100 * 10) / 10).toFixed(1) + "%"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatRatioAsPercent(ratio0to1: number): string {
|
|
13
|
+
return formatPercentOneDecimal(ratio0to1 * 100)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Block chars only — wrap with `[` `]` in UI. */
|
|
17
|
+
export function formatHitBar(ratio: number, width = 16): string {
|
|
18
|
+
const filled = Math.max(0, Math.min(width, Math.round(ratio * width)))
|
|
19
|
+
const empty = Math.max(0, width - filled)
|
|
20
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(empty)
|
|
21
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export type CurrencyCode = "USD" | "CNY" | "EUR" | "GBP" | "JPY"
|
|
2
|
+
|
|
3
|
+
/** How to render msg.cost in the sidebar. */
|
|
4
|
+
export type CostDisplayConfig = {
|
|
5
|
+
/** Display currency (symbol / decimals). */
|
|
6
|
+
currency: CurrencyCode
|
|
7
|
+
symbol?: string
|
|
8
|
+
decimals?: number
|
|
9
|
+
minDisplay?: number
|
|
10
|
+
/** Unit of msg.cost from OpenCode (typically USD per opencode.json). */
|
|
11
|
+
costUnit?: CurrencyCode
|
|
12
|
+
/** USD → display currency. Shorthand for convert.rate. */
|
|
13
|
+
rate?: number
|
|
14
|
+
convert?: { from: CurrencyCode; rate: number }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const CURRENCY_PRESETS: Record<CurrencyCode, { symbol: string; decimals: number; minDisplay: number }> = {
|
|
18
|
+
USD: { symbol: "$", decimals: 4, minDisplay: 0.0001 },
|
|
19
|
+
CNY: { symbol: "¥", decimals: 3, minDisplay: 0.01 },
|
|
20
|
+
EUR: { symbol: "€", decimals: 3, minDisplay: 0.01 },
|
|
21
|
+
GBP: { symbol: "£", decimals: 3, minDisplay: 0.01 },
|
|
22
|
+
JPY: { symbol: "¥", decimals: 2, minDisplay: 1 },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** OpenCode msg.cost is USD; show RMB by default. */
|
|
26
|
+
export const DEFAULT_COST_DISPLAY: CostDisplayConfig = {
|
|
27
|
+
currency: "CNY",
|
|
28
|
+
costUnit: "USD",
|
|
29
|
+
rate: 7.2,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveExchangeRate(cfg: CostDisplayConfig): number {
|
|
33
|
+
if (cfg.convert?.rate && cfg.convert.rate > 0) return cfg.convert.rate
|
|
34
|
+
if (cfg.rate && cfg.rate > 0) return cfg.rate
|
|
35
|
+
const unit = cfg.costUnit ?? "USD"
|
|
36
|
+
if (unit === cfg.currency) return 1
|
|
37
|
+
return 1
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeCostDisplay(raw: unknown): CostDisplayConfig {
|
|
41
|
+
if (!raw || typeof raw !== "object") return { ...DEFAULT_COST_DISPLAY }
|
|
42
|
+
const o = raw as Record<string, unknown>
|
|
43
|
+
const currency =
|
|
44
|
+
typeof o.currency === "string" && o.currency in CURRENCY_PRESETS
|
|
45
|
+
? (o.currency as CurrencyCode)
|
|
46
|
+
: DEFAULT_COST_DISPLAY.currency
|
|
47
|
+
|
|
48
|
+
const cfg: CostDisplayConfig = { currency }
|
|
49
|
+
|
|
50
|
+
if (typeof o.symbol === "string" && o.symbol.length > 0) cfg.symbol = o.symbol
|
|
51
|
+
if (typeof o.decimals === "number" && o.decimals >= 0) cfg.decimals = o.decimals
|
|
52
|
+
if (typeof o.minDisplay === "number" && o.minDisplay > 0) cfg.minDisplay = o.minDisplay
|
|
53
|
+
|
|
54
|
+
if (typeof o.costUnit === "string" && o.costUnit in CURRENCY_PRESETS) {
|
|
55
|
+
cfg.costUnit = o.costUnit as CurrencyCode
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof o.rate === "number" && o.rate > 0) cfg.rate = o.rate
|
|
59
|
+
|
|
60
|
+
const c = o.convert
|
|
61
|
+
if (c && typeof c === "object") {
|
|
62
|
+
const co = c as Record<string, unknown>
|
|
63
|
+
if (typeof co.from === "string" && co.from in CURRENCY_PRESETS && typeof co.rate === "number" && co.rate > 0) {
|
|
64
|
+
cfg.convert = { from: co.from as CurrencyCode, rate: co.rate }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!cfg.costUnit && !cfg.convert) cfg.costUnit = DEFAULT_COST_DISPLAY.costUnit
|
|
69
|
+
if (!cfg.rate && !cfg.convert?.rate && cfg.costUnit !== cfg.currency) {
|
|
70
|
+
cfg.rate = DEFAULT_COST_DISPLAY.rate
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return cfg
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createCostFormatter(config: CostDisplayConfig): (amountUsd: number) => string {
|
|
77
|
+
const preset = CURRENCY_PRESETS[config.currency]
|
|
78
|
+
const symbol = config.symbol ?? preset.symbol
|
|
79
|
+
const decimals = config.decimals ?? preset.decimals
|
|
80
|
+
const minDisplay = config.minDisplay ?? preset.minDisplay
|
|
81
|
+
const unit = config.costUnit ?? config.convert?.from ?? "USD"
|
|
82
|
+
const rate = unit === config.currency ? 1 : resolveExchangeRate(config)
|
|
83
|
+
|
|
84
|
+
return (amount: number) => {
|
|
85
|
+
if (amount <= 0) return ""
|
|
86
|
+
const v = amount * rate
|
|
87
|
+
if (v < minDisplay) return `<${symbol}${minDisplay}`
|
|
88
|
+
return "~" + symbol + v.toFixed(decimals)
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type Lang = "en" | "zh"
|
|
2
|
+
|
|
3
|
+
export type UiStrings = {
|
|
4
|
+
title: string
|
|
5
|
+
hit: string
|
|
6
|
+
totalHit: string
|
|
7
|
+
read: string
|
|
8
|
+
write: string
|
|
9
|
+
miss: string
|
|
10
|
+
out: string
|
|
11
|
+
reasoning: string
|
|
12
|
+
cost: string
|
|
13
|
+
withAgents: string
|
|
14
|
+
hitFolded: string
|
|
15
|
+
noData: string
|
|
16
|
+
secDetail: string
|
|
17
|
+
secModel: string
|
|
18
|
+
model: string
|
|
19
|
+
secAgents: string
|
|
20
|
+
/** Shown in Agents section header: totals are child sessions only, not the parent session. */
|
|
21
|
+
agentsScopeHint: string
|
|
22
|
+
tok: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const EN: UiStrings = {
|
|
26
|
+
title: "Cache Hit",
|
|
27
|
+
hit: "Hit",
|
|
28
|
+
totalHit: "Total Hit:",
|
|
29
|
+
read: "Read:",
|
|
30
|
+
write: "Write:",
|
|
31
|
+
miss: "Miss:",
|
|
32
|
+
out: "Out:",
|
|
33
|
+
reasoning: "Reason:",
|
|
34
|
+
cost: "Cost:",
|
|
35
|
+
withAgents: "w/ Agents:",
|
|
36
|
+
hitFolded: "hit",
|
|
37
|
+
noData: "Waiting for cache data...",
|
|
38
|
+
secDetail: "Detail",
|
|
39
|
+
secModel: "Model",
|
|
40
|
+
model: "Model:",
|
|
41
|
+
secAgents: "Agents",
|
|
42
|
+
agentsScopeHint: " · sub-sessions",
|
|
43
|
+
tok: "tok",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ZH: UiStrings = {
|
|
47
|
+
title: "缓存命中",
|
|
48
|
+
hit: "命中率",
|
|
49
|
+
totalHit: "总命中:",
|
|
50
|
+
read: "缓存读:",
|
|
51
|
+
write: "缓存写:",
|
|
52
|
+
miss: "未命中:",
|
|
53
|
+
out: "输出:",
|
|
54
|
+
reasoning: "推理:",
|
|
55
|
+
cost: "费用:",
|
|
56
|
+
withAgents: "含 Agents:",
|
|
57
|
+
hitFolded: "命中",
|
|
58
|
+
noData: "等待缓存数据...",
|
|
59
|
+
secDetail: "明细",
|
|
60
|
+
secModel: "模型",
|
|
61
|
+
model: "模型:",
|
|
62
|
+
secAgents: "子 Agent",
|
|
63
|
+
agentsScopeHint: " · 仅子会话",
|
|
64
|
+
tok: "tok",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveLang(raw: unknown): Lang {
|
|
68
|
+
if (raw === "zh" || raw === "cn" || raw === "zh-CN") return "zh"
|
|
69
|
+
if (raw === "en") return "en"
|
|
70
|
+
if (raw === "auto") {
|
|
71
|
+
try {
|
|
72
|
+
return Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase().startsWith("zh") ? "zh" : "en"
|
|
73
|
+
} catch {
|
|
74
|
+
return "en"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return "en"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getUiStrings(lang: Lang): UiStrings {
|
|
81
|
+
return lang === "zh" ? ZH : EN
|
|
82
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import {
|
|
5
|
+
type PluginConfig,
|
|
6
|
+
normalizePluginConfig,
|
|
7
|
+
DEFAULT_PLUGIN_CONFIG,
|
|
8
|
+
} from "./plugin-config.ts"
|
|
9
|
+
|
|
10
|
+
/** Parent of `src/` (plugin package root). Do not wrap in `dirname` — `..` already resolves there. */
|
|
11
|
+
export const PLUGIN_ROOT = fileURLToPath(new URL("..", import.meta.url))
|
|
12
|
+
export const CONFIG_PATH = join(PLUGIN_ROOT, "cache-hit.config.json")
|
|
13
|
+
|
|
14
|
+
function cloneDefault(): PluginConfig {
|
|
15
|
+
return {
|
|
16
|
+
cost: { ...DEFAULT_PLUGIN_CONFIG.cost },
|
|
17
|
+
display: { ...DEFAULT_PLUGIN_CONFIG.display },
|
|
18
|
+
timeline: { ...DEFAULT_PLUGIN_CONFIG.timeline },
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadPluginConfig(): PluginConfig {
|
|
23
|
+
if (!existsSync(CONFIG_PATH)) return cloneDefault()
|
|
24
|
+
try {
|
|
25
|
+
return normalizePluginConfig(JSON.parse(readFileSync(CONFIG_PATH, "utf8")))
|
|
26
|
+
} catch {
|
|
27
|
+
return cloneDefault()
|
|
28
|
+
}
|
|
29
|
+
}
|