opencode-cache-hit 0.1.0 → 0.2.1
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 +4 -2
- package/CONTRIBUTING.md +24 -8
- package/README.md +55 -22
- package/README.zh-CN.md +155 -92
- package/cache-hit.config.example.json +7 -2
- package/docs/assets/cache-hit-panel.v3.png +0 -0
- package/docs/en/design.md +30 -8
- package/docs/en/timeline-duplicate-writes.md +125 -0
- package/docs/en/timeline.md +26 -21
- package/docs/zh-CN/design.md +31 -9
- package/docs/zh-CN/timeline.md +28 -24
- package/package.json +1 -2
- package/scripts/README.md +64 -1
- package/scripts/plot-hit-rate.ts +4 -3
- package/scripts/timeline-dashboard.ts +728 -0
- package/src/agents-view.tsx +24 -10
- package/src/cache-ttl-view.tsx +128 -0
- package/src/format-cost.ts +83 -1
- package/src/format-model.ts +227 -0
- package/src/i18n.ts +18 -3
- package/src/load-config.ts +24 -5
- package/src/main-session-view.tsx +43 -3
- package/src/plugin-config.ts +59 -1
- package/src/plugin.tsx +4 -1
- package/src/pricing.ts +57 -0
- package/src/sidebar-host.tsx +13 -7
- package/src/stats.ts +6 -15
- package/src/timeline/collector.ts +40 -87
- package/src/timeline/records.ts +18 -29
- package/src/timeline/types.ts +3 -3
- package/src/timeline/writer.ts +5 -4
- 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 +16 -0
- package/src/use-cache-hit-metrics.ts +8 -9
- package/src/version.ts +4 -1
- package/src/widget.tsx +11 -3
- package/docs/assets/cache-hit-panel.png +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { Show } from "solid-js"
|
|
3
3
|
import { TokenDetailRows } from "./cache-hit-rows.tsx"
|
|
4
|
+
import { CacheTTLView } from "./cache-ttl-view.tsx"
|
|
4
5
|
import type { CacheHitMetrics } from "./use-cache-hit-metrics.ts"
|
|
6
|
+
import type { CacheTTLConfig } from "./plugin-config.ts"
|
|
7
|
+
import type { AssistantMessage } from "./types.ts"
|
|
5
8
|
import {
|
|
6
9
|
TuiHitRow,
|
|
7
10
|
TuiMetricRow,
|
|
@@ -9,6 +12,7 @@ import {
|
|
|
9
12
|
type PanelLayout,
|
|
10
13
|
type SectionFold,
|
|
11
14
|
} from "./tui-panel/index.ts"
|
|
15
|
+
import type { Accessor } from "solid-js"
|
|
12
16
|
|
|
13
17
|
export function MainSessionView(props: {
|
|
14
18
|
m: CacheHitMetrics
|
|
@@ -16,6 +20,9 @@ export function MainSessionView(props: {
|
|
|
16
20
|
detail: SectionFold
|
|
17
21
|
model: SectionFold
|
|
18
22
|
formatCost: (n: number) => string
|
|
23
|
+
formatRate: (perMillion: number) => string
|
|
24
|
+
cacheTTL?: CacheTTLConfig
|
|
25
|
+
messages?: Accessor<AssistantMessage[]>
|
|
19
26
|
}) {
|
|
20
27
|
const { m, layout } = props
|
|
21
28
|
return (
|
|
@@ -31,6 +38,15 @@ export function MainSessionView(props: {
|
|
|
31
38
|
}
|
|
32
39
|
/>
|
|
33
40
|
<TuiMetricRow pal={m.pal()} layout={layout} label={m.t().totalHit} value={m.sessionPct()} />
|
|
41
|
+
<Show when={props.cacheTTL?.enabled && props.messages}>
|
|
42
|
+
<CacheTTLView
|
|
43
|
+
messages={props.messages!}
|
|
44
|
+
config={props.cacheTTL!}
|
|
45
|
+
pal={m.pal()}
|
|
46
|
+
layout={layout}
|
|
47
|
+
label={m.t().secTTL}
|
|
48
|
+
/>
|
|
49
|
+
</Show>
|
|
34
50
|
|
|
35
51
|
<TuiSection
|
|
36
52
|
pal={m.pal()}
|
|
@@ -40,12 +56,13 @@ export function MainSessionView(props: {
|
|
|
40
56
|
onToggle={props.detail.toggle}
|
|
41
57
|
>
|
|
42
58
|
<TokenDetailRows pal={m.pal()} layout={layout} t={m.t()} snap={m.main()}>
|
|
43
|
-
<Show when={m.
|
|
59
|
+
<Show when={m.pricing().saved > 0}>
|
|
44
60
|
<TuiMetricRow
|
|
45
61
|
pal={m.pal()}
|
|
46
62
|
layout={layout}
|
|
47
|
-
label={m.t().
|
|
48
|
-
value={m.
|
|
63
|
+
label={m.t().saved}
|
|
64
|
+
value={props.formatCost(m.pricing().saved)}
|
|
65
|
+
fg={m.pal().success}
|
|
49
66
|
/>
|
|
50
67
|
</Show>
|
|
51
68
|
</TokenDetailRows>
|
|
@@ -70,6 +87,29 @@ export function MainSessionView(props: {
|
|
|
70
87
|
<Show when={m.modelShort()}>
|
|
71
88
|
<TuiMetricRow pal={m.pal()} layout={layout} label={m.t().model} value={m.modelShort()} />
|
|
72
89
|
</Show>
|
|
90
|
+
<Show when={m.pricing().inputRate > 0}>
|
|
91
|
+
<TuiMetricRow
|
|
92
|
+
pal={m.pal()}
|
|
93
|
+
layout={layout}
|
|
94
|
+
label={m.t().rate}
|
|
95
|
+
value={`${props.formatRate(m.pricing().inputRate)}${m.t().rateIn}`}
|
|
96
|
+
fg={m.pal().muted}
|
|
97
|
+
/>
|
|
98
|
+
<TuiMetricRow
|
|
99
|
+
pal={m.pal()}
|
|
100
|
+
layout={layout}
|
|
101
|
+
label=""
|
|
102
|
+
value={`${props.formatRate(m.pricing().cacheReadRate)}${m.t().rateCache}`}
|
|
103
|
+
fg={m.pal().muted}
|
|
104
|
+
/>
|
|
105
|
+
<TuiMetricRow
|
|
106
|
+
pal={m.pal()}
|
|
107
|
+
layout={layout}
|
|
108
|
+
label=""
|
|
109
|
+
value={`${props.formatRate(m.pricing().outputRate)}${m.t().rateOut}`}
|
|
110
|
+
fg={m.pal().muted}
|
|
111
|
+
/>
|
|
112
|
+
</Show>
|
|
73
113
|
</TuiSection>
|
|
74
114
|
</>
|
|
75
115
|
)
|
package/src/plugin-config.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const DEFAULT_DISPLAY: DisplayConfig = {
|
|
|
19
19
|
|
|
20
20
|
export type TimelineConfig = {
|
|
21
21
|
enabled: boolean
|
|
22
|
-
/** Empty →
|
|
22
|
+
/** Empty → `~/.local/share/opencode/logs/cache-hit`. Supports `~/…` expansion. */
|
|
23
23
|
dir: string
|
|
24
24
|
flushIncomplete: boolean
|
|
25
25
|
logSummaryMessages: boolean
|
|
@@ -49,16 +49,29 @@ export const DEFAULT_TIMELINE: TimelineConfig = {
|
|
|
49
49
|
maxLogFiles: 0,
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export type CacheTTLConfig = {
|
|
53
|
+
enabled: boolean
|
|
54
|
+
/** TTL per provider (or provider:model). Values like "5m", "1h", "30s". Falls back to built-in defaults. */
|
|
55
|
+
providers: Record<string, string>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_CACHE_TTL: CacheTTLConfig = {
|
|
59
|
+
enabled: true,
|
|
60
|
+
providers: {},
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
export type PluginConfig = {
|
|
53
64
|
cost: CostDisplayConfig
|
|
54
65
|
display: DisplayConfig
|
|
55
66
|
timeline: TimelineConfig
|
|
67
|
+
cacheTTL: CacheTTLConfig
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
|
|
59
71
|
cost: { ...DEFAULT_COST_DISPLAY },
|
|
60
72
|
display: { ...DEFAULT_DISPLAY },
|
|
61
73
|
timeline: { ...DEFAULT_TIMELINE },
|
|
74
|
+
cacheTTL: { ...DEFAULT_CACHE_TTL },
|
|
62
75
|
}
|
|
63
76
|
|
|
64
77
|
export function normalizeTimelineConfig(raw: unknown): TimelineConfig {
|
|
@@ -103,6 +116,50 @@ export function normalizeDisplayConfig(raw: unknown): DisplayConfig {
|
|
|
103
116
|
return d
|
|
104
117
|
}
|
|
105
118
|
|
|
119
|
+
export function normalizeCacheTTLConfig(raw: unknown): CacheTTLConfig {
|
|
120
|
+
const t: CacheTTLConfig = { enabled: DEFAULT_CACHE_TTL.enabled, providers: {} }
|
|
121
|
+
if (!raw || typeof raw !== "object") return t
|
|
122
|
+
const o = raw as Record<string, unknown>
|
|
123
|
+
if (typeof o.enabled === "boolean") t.enabled = o.enabled
|
|
124
|
+
if (o.providers && typeof o.providers === "object") {
|
|
125
|
+
const providers = o.providers as Record<string, unknown>
|
|
126
|
+
for (const [key, value] of Object.entries(providers)) {
|
|
127
|
+
if (typeof value === "string") {
|
|
128
|
+
t.providers[key] = value
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return t
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const TIME_UNITS: Record<string, number> = {
|
|
136
|
+
s: 1000,
|
|
137
|
+
sec: 1000,
|
|
138
|
+
second: 1000,
|
|
139
|
+
seconds: 1000,
|
|
140
|
+
m: 60_000,
|
|
141
|
+
min: 60_000,
|
|
142
|
+
minute: 60_000,
|
|
143
|
+
minutes: 60_000,
|
|
144
|
+
h: 3_600_000,
|
|
145
|
+
hr: 3_600_000,
|
|
146
|
+
hour: 3_600_000,
|
|
147
|
+
hours: 3_600_000,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseDuration(raw: string): number | null {
|
|
151
|
+
const match = raw.trim().match(/^(\d+(?:\.\d+)?)\s*([a-z]+)$/i)
|
|
152
|
+
if (!match) {
|
|
153
|
+
const num = Number(raw)
|
|
154
|
+
return Number.isFinite(num) && num > 0 ? Math.floor(num) : null
|
|
155
|
+
}
|
|
156
|
+
const value = Number(match[1])
|
|
157
|
+
const unit = match[2].toLowerCase()
|
|
158
|
+
const multiplier = TIME_UNITS[unit]
|
|
159
|
+
if (!multiplier || !Number.isFinite(value) || value <= 0) return null
|
|
160
|
+
return Math.floor(value * multiplier)
|
|
161
|
+
}
|
|
162
|
+
|
|
106
163
|
export function normalizePluginConfig(raw: unknown): PluginConfig {
|
|
107
164
|
if (!raw || typeof raw !== "object") return { ...DEFAULT_PLUGIN_CONFIG }
|
|
108
165
|
const o = raw as Record<string, unknown>
|
|
@@ -112,5 +169,6 @@ export function normalizePluginConfig(raw: unknown): PluginConfig {
|
|
|
112
169
|
cost,
|
|
113
170
|
display: normalizeDisplayConfig(displayRaw),
|
|
114
171
|
timeline: normalizeTimelineConfig(o.timeline),
|
|
172
|
+
cacheTTL: normalizeCacheTTLConfig(o.cacheTTL),
|
|
115
173
|
}
|
|
116
174
|
}
|
package/src/plugin.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { CacheHitSidebarHost } from "./sidebar-host.tsx"
|
|
3
3
|
import { loadPluginConfig } from "./load-config.ts"
|
|
4
|
-
import { createCostFormatter } from "./format-cost.ts"
|
|
4
|
+
import { createCostFormatter, createRateFormatter } from "./format-cost.ts"
|
|
5
5
|
import type { OpenCodeTuiApi } from "./types.ts"
|
|
6
6
|
|
|
7
7
|
export const PLUGIN_ID = "opencode-cache-hit"
|
|
@@ -9,6 +9,7 @@ export const PLUGIN_ID = "opencode-cache-hit"
|
|
|
9
9
|
export const tui = async (api: OpenCodeTuiApi) => {
|
|
10
10
|
const pluginConfig = loadPluginConfig()
|
|
11
11
|
const formatCost = createCostFormatter(pluginConfig.cost)
|
|
12
|
+
const formatRate = createRateFormatter(pluginConfig.cost)
|
|
12
13
|
|
|
13
14
|
api.slots.register({
|
|
14
15
|
order: 56,
|
|
@@ -20,7 +21,9 @@ export const tui = async (api: OpenCodeTuiApi) => {
|
|
|
20
21
|
theme={ctx.theme.current}
|
|
21
22
|
display={pluginConfig.display}
|
|
22
23
|
timeline={pluginConfig.timeline}
|
|
24
|
+
cacheTTL={pluginConfig.cacheTTL}
|
|
23
25
|
formatCost={formatCost}
|
|
26
|
+
formatRate={formatRate}
|
|
24
27
|
api={api}
|
|
25
28
|
/>
|
|
26
29
|
)
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ModelCost, ProviderInfo, SubAgentSummary } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
export type PricingInfo = {
|
|
4
|
+
inputRate: number
|
|
5
|
+
outputRate: number
|
|
6
|
+
cacheReadRate: number
|
|
7
|
+
cacheWriteRate: number
|
|
8
|
+
saved: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const EMPTY_PRICING: PricingInfo = {
|
|
12
|
+
inputRate: 0,
|
|
13
|
+
outputRate: 0,
|
|
14
|
+
cacheReadRate: 0,
|
|
15
|
+
cacheWriteRate: 0,
|
|
16
|
+
saved: 0,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function lookupModelCost(
|
|
20
|
+
providers: ReadonlyArray<ProviderInfo>,
|
|
21
|
+
providerID: string | undefined,
|
|
22
|
+
modelID: string | undefined,
|
|
23
|
+
): ModelCost | null {
|
|
24
|
+
if (!providerID || !modelID) return null
|
|
25
|
+
for (const p of providers) {
|
|
26
|
+
if (p.id !== providerID) continue
|
|
27
|
+
const model = p.models[modelID]
|
|
28
|
+
return model?.cost ?? null
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function computePricing(
|
|
34
|
+
providers: ReadonlyArray<ProviderInfo>,
|
|
35
|
+
providerID: string | undefined,
|
|
36
|
+
modelID: string | undefined,
|
|
37
|
+
cacheRead: number,
|
|
38
|
+
): PricingInfo {
|
|
39
|
+
const cost = lookupModelCost(providers, providerID, modelID)
|
|
40
|
+
if (!cost) return EMPTY_PRICING
|
|
41
|
+
const inputRate = cost.input
|
|
42
|
+
const outputRate = cost.output
|
|
43
|
+
const cacheReadRate = cost.cache.read
|
|
44
|
+
const cacheWriteRate = cost.cache.write
|
|
45
|
+
const saved =
|
|
46
|
+
inputRate > cacheReadRate ? (cacheRead * (inputRate - cacheReadRate)) / 1_000_000 : 0
|
|
47
|
+
return { inputRate, outputRate, cacheReadRate, cacheWriteRate, saved }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function computeSubsSaved(subs: readonly SubAgentSummary[], providers: ReadonlyArray<ProviderInfo>): number {
|
|
51
|
+
let total = 0
|
|
52
|
+
for (const sub of subs) {
|
|
53
|
+
const p = computePricing(providers, sub.providerID, sub.model, sub.cacheRead)
|
|
54
|
+
total += p.saved
|
|
55
|
+
}
|
|
56
|
+
return total
|
|
57
|
+
}
|
package/src/sidebar-host.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
|
3
3
|
import { CacheHitSidebar } from "./widget.tsx"
|
|
4
|
-
import type { DisplayConfig, TimelineConfig } from "./plugin-config.ts"
|
|
4
|
+
import type { DisplayConfig, TimelineConfig, CacheTTLConfig } from "./plugin-config.ts"
|
|
5
5
|
import { createTimelineCollector } from "./timeline/collector.ts"
|
|
6
6
|
import type { AssistantMessage, OpenCodeTuiApi, SubAgentSummary } from "./types.ts"
|
|
7
7
|
import {
|
|
@@ -16,13 +16,16 @@ import { loadPluginConfig } from "./load-config.ts"
|
|
|
16
16
|
/**
|
|
17
17
|
* Session-scoped sidebar host. Bumps `refreshTick` on message.updated (same as visual-cache)
|
|
18
18
|
* so memos re-read api.state.session.messages.
|
|
19
|
+
* Timeline writes are event-driven: message.updated → handleMessage → appendFile.
|
|
19
20
|
*/
|
|
20
21
|
export function CacheHitSidebarHost(props: {
|
|
21
22
|
sessionId: string
|
|
22
23
|
theme: Record<string, unknown>
|
|
23
24
|
display: DisplayConfig
|
|
24
25
|
timeline: TimelineConfig
|
|
26
|
+
cacheTTL: CacheTTLConfig
|
|
25
27
|
formatCost: (amount: number) => string
|
|
28
|
+
formatRate: (perMillion: number) => string
|
|
26
29
|
api: OpenCodeTuiApi
|
|
27
30
|
}) {
|
|
28
31
|
const [refreshTick, setRefreshTick] = createSignal(0)
|
|
@@ -34,6 +37,7 @@ export function CacheHitSidebarHost(props: {
|
|
|
34
37
|
return loadPluginConfig()
|
|
35
38
|
})
|
|
36
39
|
const display = createMemo(() => runtimeConfig().display)
|
|
40
|
+
const cacheTTL = createMemo(() => runtimeConfig().cacheTTL)
|
|
37
41
|
|
|
38
42
|
const bumpRefresh = () => setRefreshTick((v) => v + 1)
|
|
39
43
|
|
|
@@ -41,8 +45,6 @@ export function CacheHitSidebarHost(props: {
|
|
|
41
45
|
config: props.timeline,
|
|
42
46
|
getRootSessionId: () => props.sessionId,
|
|
43
47
|
getChildIds: childIds,
|
|
44
|
-
getMessages: (id) =>
|
|
45
|
-
(props.api.state.session.messages(id) ?? []) as AssistantMessage[],
|
|
46
48
|
})
|
|
47
49
|
onCleanup(() => timeline.dispose())
|
|
48
50
|
|
|
@@ -53,7 +55,6 @@ export function CacheHitSidebarHost(props: {
|
|
|
53
55
|
setChildIds,
|
|
54
56
|
onSynced: () => {
|
|
55
57
|
bumpRefresh()
|
|
56
|
-
timeline.schedule()
|
|
57
58
|
},
|
|
58
59
|
})
|
|
59
60
|
onCleanup(() => childSync.dispose())
|
|
@@ -94,15 +95,17 @@ export function CacheHitSidebarHost(props: {
|
|
|
94
95
|
timeline.resetForRootChange()
|
|
95
96
|
if (sid) {
|
|
96
97
|
childSync.loadChildren()
|
|
97
|
-
timeline.schedule()
|
|
98
98
|
}
|
|
99
99
|
})
|
|
100
100
|
|
|
101
101
|
createEffect(() => {
|
|
102
102
|
const unsub = props.api.event.on("message.updated", (event) => {
|
|
103
103
|
bumpRefresh()
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
const sid = event.properties?.info?.sessionID
|
|
105
|
+
childSync.onForeignSessionActivity(sid)
|
|
106
|
+
if (sid && event.properties?.info) {
|
|
107
|
+
timeline.handleMessage(sid, event.properties.info as AssistantMessage)
|
|
108
|
+
}
|
|
106
109
|
})
|
|
107
110
|
onCleanup(() => unsub?.())
|
|
108
111
|
})
|
|
@@ -112,10 +115,13 @@ export function CacheHitSidebarHost(props: {
|
|
|
112
115
|
sessionId={() => props.sessionId}
|
|
113
116
|
theme={props.theme}
|
|
114
117
|
display={display()}
|
|
118
|
+
cacheTTL={cacheTTL()}
|
|
115
119
|
messages={mainMessages}
|
|
116
120
|
main={mainSnap}
|
|
117
121
|
subAgents={subAgentList}
|
|
122
|
+
providers={() => props.api.state.provider ?? []}
|
|
118
123
|
formatCost={props.formatCost}
|
|
124
|
+
formatRate={props.formatRate}
|
|
119
125
|
/>
|
|
120
126
|
)
|
|
121
127
|
}
|
package/src/stats.ts
CHANGED
|
@@ -11,11 +11,12 @@ export function mainSessionHasStats(main: SessionSnapshot): boolean {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function emptySessionSnapshot(): SessionSnapshot {
|
|
14
|
-
return { model: "", input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
|
|
14
|
+
return { model: "", providerID: "", input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function aggregateSessionFromMessages(messages: readonly AssistantMessage[]): SessionSnapshot {
|
|
18
18
|
let model = "",
|
|
19
|
+
providerID = "",
|
|
19
20
|
input = 0,
|
|
20
21
|
output = 0,
|
|
21
22
|
reasoning = 0,
|
|
@@ -32,13 +33,16 @@ export function aggregateSessionFromMessages(messages: readonly AssistantMessage
|
|
|
32
33
|
cacheWrite += t.cache?.write ?? 0
|
|
33
34
|
cost += msg.cost ?? 0
|
|
34
35
|
if (msg.modelID) model = msg.modelID
|
|
36
|
+
if (msg.providerID) providerID = msg.providerID
|
|
35
37
|
}
|
|
36
|
-
return { model, input, output, reasoning, cacheRead, cacheWrite, cost }
|
|
38
|
+
return { model, providerID, input, output, reasoning, cacheRead, cacheWrite, cost }
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export function toSubAgentSummary(id: string, snap: SessionSnapshot): SubAgentSummary {
|
|
40
42
|
return {
|
|
41
43
|
id,
|
|
44
|
+
model: snap.model,
|
|
45
|
+
providerID: snap.providerID,
|
|
42
46
|
cost: snap.cost,
|
|
43
47
|
input: snap.input,
|
|
44
48
|
output: snap.output,
|
|
@@ -66,19 +70,6 @@ export function cacheHitRatio(cacheRead: number, input: number): number {
|
|
|
66
70
|
return denom > 0 ? cacheRead / denom : 0
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
export function combinedCacheHitRatio(
|
|
70
|
-
main: SessionSnapshot,
|
|
71
|
-
subs: readonly Pick<SubAgentSummary, "cacheRead" | "input">[],
|
|
72
|
-
): number {
|
|
73
|
-
let cacheRead = main.cacheRead
|
|
74
|
-
let input = main.input
|
|
75
|
-
for (const s of subs) {
|
|
76
|
-
cacheRead += s.cacheRead
|
|
77
|
-
input += s.input
|
|
78
|
-
}
|
|
79
|
-
return cacheHitRatio(cacheRead, input)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
73
|
export function subAgentHasStats(snap: SessionSnapshot): boolean {
|
|
83
74
|
return (
|
|
84
75
|
snap.cost > 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
|
@@ -3,6 +3,21 @@ import { perMessageHitPercent } from "../stats.ts"
|
|
|
3
3
|
import type { AssistantMessage } from "../types.ts"
|
|
4
4
|
import type { LlmCallRecord } from "./types.ts"
|
|
5
5
|
|
|
6
|
+
/** Convert milliseconds timestamp to ISO 8601 with local timezone offset. */
|
|
7
|
+
export function msToISOString(ms: number): string {
|
|
8
|
+
const d = new Date(ms)
|
|
9
|
+
const off = -d.getTimezoneOffset()
|
|
10
|
+
const sign = off >= 0 ? "+" : "-"
|
|
11
|
+
const hh = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0")
|
|
12
|
+
const mm = String(Math.abs(off) % 60).padStart(2, "0")
|
|
13
|
+
const pad = (n: number, len = 2) => String(n).padStart(len, "0")
|
|
14
|
+
return (
|
|
15
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
16
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
|
|
17
|
+
`.${pad(d.getMilliseconds(), 3)}${sign}${hh}:${mm}`
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
export function messageKeyFor(msg: AssistantMessage, sessionId: string): string {
|
|
7
22
|
const id = msg.id ?? msg.messageID
|
|
8
23
|
if (typeof id === "string" && id.length > 0) return `${sessionId}:${id}`
|
|
@@ -10,10 +25,6 @@ export function messageKeyFor(msg: AssistantMessage, sessionId: string): string
|
|
|
10
25
|
return `${sessionId}:${created}:${msg.modelID ?? ""}`
|
|
11
26
|
}
|
|
12
27
|
|
|
13
|
-
export function sortKeyForRecord(r: LlmCallRecord): number {
|
|
14
|
-
return r.completedAt ?? r.created
|
|
15
|
-
}
|
|
16
|
-
|
|
17
28
|
export function assistantMessageToRecord(
|
|
18
29
|
msg: AssistantMessage,
|
|
19
30
|
sessionId: string,
|
|
@@ -28,14 +39,14 @@ export function assistantMessageToRecord(
|
|
|
28
39
|
const skippedForHit = msg.summary === true
|
|
29
40
|
return {
|
|
30
41
|
schema: 1,
|
|
31
|
-
recordedAt,
|
|
42
|
+
recordedAt: msToISOString(recordedAt),
|
|
32
43
|
sessionId,
|
|
33
44
|
rootSessionId,
|
|
34
45
|
scope,
|
|
35
46
|
messageKey: messageKeyFor(msg, sessionId),
|
|
36
47
|
modelId: msg.modelID ?? "",
|
|
37
|
-
created: timing.created,
|
|
38
|
-
completedAt: timing.completedAt,
|
|
48
|
+
created: msToISOString(timing.created),
|
|
49
|
+
completedAt: timing.completedAt !== undefined ? msToISOString(timing.completedAt) : undefined,
|
|
39
50
|
durationMs: timing.durationMs,
|
|
40
51
|
isComplete: timing.isComplete,
|
|
41
52
|
input: t.input ?? 0,
|
|
@@ -49,25 +60,3 @@ export function assistantMessageToRecord(
|
|
|
49
60
|
}
|
|
50
61
|
}
|
|
51
62
|
|
|
52
|
-
export function buildCallRecords(
|
|
53
|
-
sessionId: string,
|
|
54
|
-
rootSessionId: string,
|
|
55
|
-
scope: "main" | "child",
|
|
56
|
-
messages: readonly AssistantMessage[],
|
|
57
|
-
opts?: { logSummaryMessages?: boolean; recordedAt?: number },
|
|
58
|
-
): LlmCallRecord[] {
|
|
59
|
-
const now = opts?.recordedAt ?? Date.now()
|
|
60
|
-
const logSummary = opts?.logSummaryMessages !== false
|
|
61
|
-
const out: LlmCallRecord[] = []
|
|
62
|
-
for (const msg of messages) {
|
|
63
|
-
if (!logSummary && msg.summary === true) continue
|
|
64
|
-
const rec = assistantMessageToRecord(msg, sessionId, rootSessionId, scope, now)
|
|
65
|
-
if (rec) out.push(rec)
|
|
66
|
-
}
|
|
67
|
-
return out
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function mergeAndSortRecords(chunks: readonly LlmCallRecord[][]): LlmCallRecord[] {
|
|
71
|
-
const all = chunks.flat()
|
|
72
|
-
return all.sort((a, b) => sortKeyForRecord(a) - sortKeyForRecord(b))
|
|
73
|
-
}
|
package/src/timeline/types.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/** Single LLM call row (one JSONL line). */
|
|
2
2
|
export type LlmCallRecord = {
|
|
3
3
|
schema: 1
|
|
4
|
-
recordedAt:
|
|
4
|
+
recordedAt: string
|
|
5
5
|
sessionId: string
|
|
6
6
|
rootSessionId: string
|
|
7
7
|
scope: "main" | "child"
|
|
8
8
|
messageKey: string
|
|
9
9
|
modelId: string
|
|
10
|
-
created:
|
|
11
|
-
completedAt?:
|
|
10
|
+
created: string
|
|
11
|
+
completedAt?: string
|
|
12
12
|
durationMs?: number
|
|
13
13
|
isComplete: boolean
|
|
14
14
|
input: number
|