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,76 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { Show } from "solid-js"
|
|
3
|
+
import { TokenDetailRows } from "./cache-hit-rows.tsx"
|
|
4
|
+
import type { CacheHitMetrics } from "./use-cache-hit-metrics.ts"
|
|
5
|
+
import {
|
|
6
|
+
TuiHitRow,
|
|
7
|
+
TuiMetricRow,
|
|
8
|
+
TuiSection,
|
|
9
|
+
type PanelLayout,
|
|
10
|
+
type SectionFold,
|
|
11
|
+
} from "./tui-panel/index.ts"
|
|
12
|
+
|
|
13
|
+
export function MainSessionView(props: {
|
|
14
|
+
m: CacheHitMetrics
|
|
15
|
+
layout: PanelLayout
|
|
16
|
+
detail: SectionFold
|
|
17
|
+
model: SectionFold
|
|
18
|
+
formatCost: (n: number) => string
|
|
19
|
+
}) {
|
|
20
|
+
const { m, layout } = props
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<TuiHitRow
|
|
24
|
+
label={m.hitLabel()}
|
|
25
|
+
bar={m.bar()}
|
|
26
|
+
pct={m.pctLabel()}
|
|
27
|
+
barColor={m.hitColor()}
|
|
28
|
+
textColor={m.pal().text}
|
|
29
|
+
trend={
|
|
30
|
+
m.perCall().hasTrend ? { text: m.trendLabel(), color: m.trendFg() } : undefined
|
|
31
|
+
}
|
|
32
|
+
/>
|
|
33
|
+
<TuiMetricRow pal={m.pal()} layout={layout} label={m.t().totalHit} value={m.sessionPct()} />
|
|
34
|
+
|
|
35
|
+
<TuiSection
|
|
36
|
+
pal={m.pal()}
|
|
37
|
+
layout={layout}
|
|
38
|
+
open={props.detail.open()}
|
|
39
|
+
title={m.t().secDetail}
|
|
40
|
+
onToggle={props.detail.toggle}
|
|
41
|
+
>
|
|
42
|
+
<TokenDetailRows pal={m.pal()} layout={layout} t={m.t()} snap={m.main()}>
|
|
43
|
+
<Show when={m.showCombinedHit()}>
|
|
44
|
+
<TuiMetricRow
|
|
45
|
+
pal={m.pal()}
|
|
46
|
+
layout={layout}
|
|
47
|
+
label={m.t().withAgents}
|
|
48
|
+
value={m.combinedPct()}
|
|
49
|
+
/>
|
|
50
|
+
</Show>
|
|
51
|
+
</TokenDetailRows>
|
|
52
|
+
</TuiSection>
|
|
53
|
+
|
|
54
|
+
<TuiSection
|
|
55
|
+
pal={m.pal()}
|
|
56
|
+
layout={layout}
|
|
57
|
+
open={props.model.open()}
|
|
58
|
+
title={m.t().secModel}
|
|
59
|
+
onToggle={props.model.toggle}
|
|
60
|
+
>
|
|
61
|
+
<Show when={m.main().cost > 0}>
|
|
62
|
+
<TuiMetricRow
|
|
63
|
+
pal={m.pal()}
|
|
64
|
+
layout={layout}
|
|
65
|
+
label={m.t().cost}
|
|
66
|
+
value={props.formatCost(m.main().cost)}
|
|
67
|
+
fg={m.pal().text}
|
|
68
|
+
/>
|
|
69
|
+
</Show>
|
|
70
|
+
<Show when={m.modelShort()}>
|
|
71
|
+
<TuiMetricRow pal={m.pal()} layout={layout} label={m.t().model} value={m.modelShort()} />
|
|
72
|
+
</Show>
|
|
73
|
+
</TuiSection>
|
|
74
|
+
</>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AssistantMessage } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
/** Milliseconds since epoch (OpenCode SDK v2). */
|
|
4
|
+
export type MessageTiming = {
|
|
5
|
+
created: number
|
|
6
|
+
completedAt?: number
|
|
7
|
+
durationMs?: number
|
|
8
|
+
isComplete: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Per-call timing from AssistantMessage.time (SDK).
|
|
13
|
+
* - created: always set when message exists
|
|
14
|
+
* - completed: set when the LLM turn finishes (reliable for finished calls)
|
|
15
|
+
* Use completed ?? created for ordering finished calls; in-flight calls lack completed.
|
|
16
|
+
*/
|
|
17
|
+
export function timingFromAssistantMessage(msg: AssistantMessage): MessageTiming | null {
|
|
18
|
+
const t = msg.time
|
|
19
|
+
if (!t || typeof t.created !== "number") return null
|
|
20
|
+
const completedAt = typeof t.completed === "number" ? t.completed : undefined
|
|
21
|
+
return {
|
|
22
|
+
created: t.created,
|
|
23
|
+
completedAt,
|
|
24
|
+
durationMs: completedAt !== undefined ? completedAt - t.created : undefined,
|
|
25
|
+
isComplete: completedAt !== undefined,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatTimingShort(ms: number): string {
|
|
30
|
+
const d = new Date(ms)
|
|
31
|
+
const h = String(d.getHours()).padStart(2, "0")
|
|
32
|
+
const m = String(d.getMinutes()).padStart(2, "0")
|
|
33
|
+
const s = String(d.getSeconds()).padStart(2, "0")
|
|
34
|
+
return `${h}:${m}:${s}`
|
|
35
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type CostDisplayConfig, normalizeCostDisplay, DEFAULT_COST_DISPLAY } from "./format-cost.ts"
|
|
2
|
+
import { resolveLang, type Lang } from "./i18n.ts"
|
|
3
|
+
|
|
4
|
+
export type DisplayConfig = {
|
|
5
|
+
/** `en` | `zh` | `auto` (follow system locale). Default `en`. */
|
|
6
|
+
lang: Lang | "auto"
|
|
7
|
+
/** Optional override for the hit-rate line prefix (default from i18n). */
|
|
8
|
+
mainHitLabel?: string
|
|
9
|
+
/** Outer panel border (visual-cache style). Default true. */
|
|
10
|
+
panelBorder: boolean
|
|
11
|
+
/** @deprecated Use panelBorder */
|
|
12
|
+
agentsBorder?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_DISPLAY: DisplayConfig = {
|
|
16
|
+
lang: "en",
|
|
17
|
+
panelBorder: true,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TimelineConfig = {
|
|
21
|
+
enabled: boolean
|
|
22
|
+
/** Empty → plugin `logs/` directory */
|
|
23
|
+
dir: string
|
|
24
|
+
flushIncomplete: boolean
|
|
25
|
+
logSummaryMessages: boolean
|
|
26
|
+
maxMemoryRows: number
|
|
27
|
+
/** 0 = unlimited; after each append keep only the last N lines in the active file */
|
|
28
|
+
maxLinesPerFile: number
|
|
29
|
+
/** 0 = off; when active file reaches this size (bytes), roll to `.jsonl.1` before append */
|
|
30
|
+
rotateMaxBytes: number
|
|
31
|
+
/** How many rotated backups to keep (`file.jsonl.1` … `.N`); 0 = delete on roll */
|
|
32
|
+
retainRotated: number
|
|
33
|
+
/** 0 = off; delete `*.jsonl*` in log dir older than N days (on collector start) */
|
|
34
|
+
maxAgeDays: number
|
|
35
|
+
/** 0 = unlimited; max number of `*.jsonl*` files in log dir (oldest mtime deleted first) */
|
|
36
|
+
maxLogFiles: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_TIMELINE: TimelineConfig = {
|
|
40
|
+
enabled: false,
|
|
41
|
+
dir: "",
|
|
42
|
+
flushIncomplete: false,
|
|
43
|
+
logSummaryMessages: true,
|
|
44
|
+
maxMemoryRows: 50,
|
|
45
|
+
maxLinesPerFile: 0,
|
|
46
|
+
rotateMaxBytes: 0,
|
|
47
|
+
retainRotated: 5,
|
|
48
|
+
maxAgeDays: 0,
|
|
49
|
+
maxLogFiles: 0,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type PluginConfig = {
|
|
53
|
+
cost: CostDisplayConfig
|
|
54
|
+
display: DisplayConfig
|
|
55
|
+
timeline: TimelineConfig
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
|
|
59
|
+
cost: { ...DEFAULT_COST_DISPLAY },
|
|
60
|
+
display: { ...DEFAULT_DISPLAY },
|
|
61
|
+
timeline: { ...DEFAULT_TIMELINE },
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeTimelineConfig(raw: unknown): TimelineConfig {
|
|
65
|
+
const t = { ...DEFAULT_TIMELINE }
|
|
66
|
+
if (!raw || typeof raw !== "object") return t
|
|
67
|
+
const o = raw as Record<string, unknown>
|
|
68
|
+
if (typeof o.enabled === "boolean") t.enabled = o.enabled
|
|
69
|
+
if (typeof o.dir === "string") t.dir = o.dir
|
|
70
|
+
if (typeof o.flushIncomplete === "boolean") t.flushIncomplete = o.flushIncomplete
|
|
71
|
+
if (typeof o.logSummaryMessages === "boolean") t.logSummaryMessages = o.logSummaryMessages
|
|
72
|
+
if (typeof o.maxMemoryRows === "number" && o.maxMemoryRows > 0) {
|
|
73
|
+
t.maxMemoryRows = Math.floor(o.maxMemoryRows)
|
|
74
|
+
}
|
|
75
|
+
if (typeof o.maxLinesPerFile === "number" && o.maxLinesPerFile >= 0) {
|
|
76
|
+
t.maxLinesPerFile = Math.floor(o.maxLinesPerFile)
|
|
77
|
+
}
|
|
78
|
+
if (typeof o.rotateMaxBytes === "number" && o.rotateMaxBytes >= 0) {
|
|
79
|
+
t.rotateMaxBytes = Math.floor(o.rotateMaxBytes)
|
|
80
|
+
}
|
|
81
|
+
if (typeof o.retainRotated === "number" && o.retainRotated >= 0) {
|
|
82
|
+
t.retainRotated = Math.floor(o.retainRotated)
|
|
83
|
+
}
|
|
84
|
+
if (typeof o.maxAgeDays === "number" && o.maxAgeDays >= 0) {
|
|
85
|
+
t.maxAgeDays = Math.floor(o.maxAgeDays)
|
|
86
|
+
}
|
|
87
|
+
if (typeof o.maxLogFiles === "number" && o.maxLogFiles >= 0) {
|
|
88
|
+
t.maxLogFiles = Math.floor(o.maxLogFiles)
|
|
89
|
+
}
|
|
90
|
+
return t
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function normalizeDisplayConfig(raw: unknown): DisplayConfig {
|
|
94
|
+
const d = { ...DEFAULT_DISPLAY }
|
|
95
|
+
if (!raw || typeof raw !== "object") return d
|
|
96
|
+
const o = raw as Record<string, unknown>
|
|
97
|
+
if (typeof o.lang === "string") {
|
|
98
|
+
d.lang = o.lang === "auto" ? "auto" : resolveLang(o.lang)
|
|
99
|
+
}
|
|
100
|
+
if (typeof o.mainHitLabel === "string" && o.mainHitLabel.length > 0) d.mainHitLabel = o.mainHitLabel
|
|
101
|
+
if (typeof o.panelBorder === "boolean") d.panelBorder = o.panelBorder
|
|
102
|
+
else if (typeof o.agentsBorder === "boolean") d.panelBorder = o.agentsBorder
|
|
103
|
+
return d
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function normalizePluginConfig(raw: unknown): PluginConfig {
|
|
107
|
+
if (!raw || typeof raw !== "object") return { ...DEFAULT_PLUGIN_CONFIG }
|
|
108
|
+
const o = raw as Record<string, unknown>
|
|
109
|
+
const cost = normalizeCostDisplay(raw)
|
|
110
|
+
const displayRaw = o.display
|
|
111
|
+
return {
|
|
112
|
+
cost,
|
|
113
|
+
display: normalizeDisplayConfig(displayRaw),
|
|
114
|
+
timeline: normalizeTimelineConfig(o.timeline),
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/plugin.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { CacheHitSidebarHost } from "./sidebar-host.tsx"
|
|
3
|
+
import { loadPluginConfig } from "./load-config.ts"
|
|
4
|
+
import { createCostFormatter } from "./format-cost.ts"
|
|
5
|
+
import type { OpenCodeTuiApi } from "./types.ts"
|
|
6
|
+
|
|
7
|
+
export const PLUGIN_ID = "opencode-cache-hit"
|
|
8
|
+
|
|
9
|
+
export const tui = async (api: OpenCodeTuiApi) => {
|
|
10
|
+
const pluginConfig = loadPluginConfig()
|
|
11
|
+
const formatCost = createCostFormatter(pluginConfig.cost)
|
|
12
|
+
|
|
13
|
+
api.slots.register({
|
|
14
|
+
order: 56,
|
|
15
|
+
slots: {
|
|
16
|
+
sidebar_content(ctx, props) {
|
|
17
|
+
return (
|
|
18
|
+
<CacheHitSidebarHost
|
|
19
|
+
sessionId={props.session_id ?? ""}
|
|
20
|
+
theme={ctx.theme.current}
|
|
21
|
+
display={pluginConfig.display}
|
|
22
|
+
timeline={pluginConfig.timeline}
|
|
23
|
+
formatCost={formatCost}
|
|
24
|
+
api={api}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const plugin = { id: PLUGIN_ID, tui }
|
|
33
|
+
export default plugin
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type SessionListEntry = { id: string; parentID?: string }
|
|
2
|
+
|
|
3
|
+
export function parseSessionListResponse(all: unknown): SessionListEntry[] {
|
|
4
|
+
const list = Array.isArray(all) ? all : ((all as { data?: unknown })?.data ?? [])
|
|
5
|
+
if (!Array.isArray(list)) return []
|
|
6
|
+
return list as SessionListEntry[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function childSessionIdsForParent(list: SessionListEntry[], parentId: string): string[] {
|
|
10
|
+
return list.filter((s) => s.parentID === parentId).map((s) => s.id)
|
|
11
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
|
3
|
+
import { CacheHitSidebar } from "./widget.tsx"
|
|
4
|
+
import type { DisplayConfig, TimelineConfig } from "./plugin-config.ts"
|
|
5
|
+
import { createTimelineCollector } from "./timeline/collector.ts"
|
|
6
|
+
import type { AssistantMessage, OpenCodeTuiApi, SubAgentSummary } from "./types.ts"
|
|
7
|
+
import {
|
|
8
|
+
emptySessionSnapshot,
|
|
9
|
+
aggregateSessionFromMessages,
|
|
10
|
+
subAgentHasStats,
|
|
11
|
+
toSubAgentSummary,
|
|
12
|
+
} from "./stats.ts"
|
|
13
|
+
import { createChildSessionSync } from "./child-session-sync.ts"
|
|
14
|
+
import { loadPluginConfig } from "./load-config.ts"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Session-scoped sidebar host. Bumps `refreshTick` on message.updated (same as visual-cache)
|
|
18
|
+
* so memos re-read api.state.session.messages.
|
|
19
|
+
*/
|
|
20
|
+
export function CacheHitSidebarHost(props: {
|
|
21
|
+
sessionId: string
|
|
22
|
+
theme: Record<string, unknown>
|
|
23
|
+
display: DisplayConfig
|
|
24
|
+
timeline: TimelineConfig
|
|
25
|
+
formatCost: (amount: number) => string
|
|
26
|
+
api: OpenCodeTuiApi
|
|
27
|
+
}) {
|
|
28
|
+
const [refreshTick, setRefreshTick] = createSignal(0)
|
|
29
|
+
const [childIds, setChildIds] = createSignal<string[]>([])
|
|
30
|
+
|
|
31
|
+
/** Re-read cache-hit.config.json when parent session changes (picks up edits without full plugin reload). */
|
|
32
|
+
const runtimeConfig = createMemo(() => {
|
|
33
|
+
void props.sessionId
|
|
34
|
+
return loadPluginConfig()
|
|
35
|
+
})
|
|
36
|
+
const display = createMemo(() => runtimeConfig().display)
|
|
37
|
+
|
|
38
|
+
const bumpRefresh = () => setRefreshTick((v) => v + 1)
|
|
39
|
+
|
|
40
|
+
const timeline = createTimelineCollector({
|
|
41
|
+
config: props.timeline,
|
|
42
|
+
getRootSessionId: () => props.sessionId,
|
|
43
|
+
getChildIds: childIds,
|
|
44
|
+
getMessages: (id) =>
|
|
45
|
+
(props.api.state.session.messages(id) ?? []) as AssistantMessage[],
|
|
46
|
+
})
|
|
47
|
+
onCleanup(() => timeline.dispose())
|
|
48
|
+
|
|
49
|
+
const childSync = createChildSessionSync({
|
|
50
|
+
client: props.api.client.session,
|
|
51
|
+
getDirectory: () => props.api.state.path.directory,
|
|
52
|
+
getParentId: () => props.sessionId,
|
|
53
|
+
setChildIds,
|
|
54
|
+
onSynced: () => {
|
|
55
|
+
bumpRefresh()
|
|
56
|
+
timeline.schedule()
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
onCleanup(() => childSync.dispose())
|
|
60
|
+
|
|
61
|
+
const mainSnap = createMemo(() => {
|
|
62
|
+
void refreshTick()
|
|
63
|
+
const sid = props.sessionId
|
|
64
|
+
if (!sid) return emptySessionSnapshot()
|
|
65
|
+
const msgs = props.api.state.session.messages(sid)
|
|
66
|
+
return msgs?.length
|
|
67
|
+
? aggregateSessionFromMessages(msgs as AssistantMessage[])
|
|
68
|
+
: emptySessionSnapshot()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const mainMessages = createMemo(() => {
|
|
72
|
+
void refreshTick()
|
|
73
|
+
const sid = props.sessionId
|
|
74
|
+
if (!sid) return [] as AssistantMessage[]
|
|
75
|
+
return (props.api.state.session.messages(sid) ?? []) as AssistantMessage[]
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const subAgentList = createMemo(() =>
|
|
79
|
+
childIds()
|
|
80
|
+
.map((cid) => {
|
|
81
|
+
const msgs = props.api.state.session.messages(cid)
|
|
82
|
+
if (!msgs?.length) return null
|
|
83
|
+
const snap = aggregateSessionFromMessages(msgs as AssistantMessage[])
|
|
84
|
+
if (!subAgentHasStats(snap)) return null
|
|
85
|
+
return toSubAgentSummary(cid, snap)
|
|
86
|
+
})
|
|
87
|
+
.filter(Boolean) as SubAgentSummary[],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
createEffect(() => {
|
|
91
|
+
const sid = props.sessionId
|
|
92
|
+
void props.api.state.path.directory
|
|
93
|
+
childSync.resetForParentChange()
|
|
94
|
+
timeline.resetForRootChange()
|
|
95
|
+
if (sid) {
|
|
96
|
+
childSync.loadChildren()
|
|
97
|
+
timeline.schedule()
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
createEffect(() => {
|
|
102
|
+
const unsub = props.api.event.on("message.updated", (event) => {
|
|
103
|
+
bumpRefresh()
|
|
104
|
+
childSync.onForeignSessionActivity(event.properties?.info?.sessionID)
|
|
105
|
+
timeline.schedule()
|
|
106
|
+
})
|
|
107
|
+
onCleanup(() => unsub?.())
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<CacheHitSidebar
|
|
112
|
+
sessionId={() => props.sessionId}
|
|
113
|
+
theme={props.theme}
|
|
114
|
+
display={display()}
|
|
115
|
+
messages={mainMessages}
|
|
116
|
+
main={mainSnap}
|
|
117
|
+
subAgents={subAgentList}
|
|
118
|
+
formatCost={props.formatCost}
|
|
119
|
+
/>
|
|
120
|
+
)
|
|
121
|
+
}
|
package/src/stats.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { AssistantMessage, SessionSnapshot, SubAgentSummary } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
export function mainSessionHasStats(main: SessionSnapshot): boolean {
|
|
4
|
+
return (
|
|
5
|
+
main.cacheRead > 0 ||
|
|
6
|
+
main.cacheWrite > 0 ||
|
|
7
|
+
main.cost > 0 ||
|
|
8
|
+
main.input > 0 ||
|
|
9
|
+
main.output > 0
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function emptySessionSnapshot(): SessionSnapshot {
|
|
14
|
+
return { model: "", input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function aggregateSessionFromMessages(messages: readonly AssistantMessage[]): SessionSnapshot {
|
|
18
|
+
let model = "",
|
|
19
|
+
input = 0,
|
|
20
|
+
output = 0,
|
|
21
|
+
reasoning = 0,
|
|
22
|
+
cacheRead = 0,
|
|
23
|
+
cacheWrite = 0,
|
|
24
|
+
cost = 0
|
|
25
|
+
for (const msg of messages) {
|
|
26
|
+
if (msg.role !== "assistant") continue
|
|
27
|
+
const t = msg.tokens ?? {}
|
|
28
|
+
input += t.input ?? 0
|
|
29
|
+
output += t.output ?? 0
|
|
30
|
+
reasoning += t.reasoning ?? 0
|
|
31
|
+
cacheRead += t.cache?.read ?? 0
|
|
32
|
+
cacheWrite += t.cache?.write ?? 0
|
|
33
|
+
cost += msg.cost ?? 0
|
|
34
|
+
if (msg.modelID) model = msg.modelID
|
|
35
|
+
}
|
|
36
|
+
return { model, input, output, reasoning, cacheRead, cacheWrite, cost }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function toSubAgentSummary(id: string, snap: SessionSnapshot): SubAgentSummary {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
cost: snap.cost,
|
|
43
|
+
input: snap.input,
|
|
44
|
+
output: snap.output,
|
|
45
|
+
reasoning: snap.reasoning,
|
|
46
|
+
cacheRead: snap.cacheRead,
|
|
47
|
+
cacheWrite: snap.cacheWrite,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function aggregateSubAgents(subs: readonly SubAgentSummary[]): SessionSnapshot {
|
|
52
|
+
const total = emptySessionSnapshot()
|
|
53
|
+
for (const s of subs) {
|
|
54
|
+
total.input += s.input
|
|
55
|
+
total.output += s.output
|
|
56
|
+
total.reasoning += s.reasoning
|
|
57
|
+
total.cacheRead += s.cacheRead
|
|
58
|
+
total.cacheWrite += s.cacheWrite
|
|
59
|
+
total.cost += s.cost
|
|
60
|
+
}
|
|
61
|
+
return total
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function cacheHitRatio(cacheRead: number, input: number): number {
|
|
65
|
+
const denom = cacheRead + input
|
|
66
|
+
return denom > 0 ? cacheRead / denom : 0
|
|
67
|
+
}
|
|
68
|
+
|
|
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
|
+
export function subAgentHasStats(snap: SessionSnapshot): boolean {
|
|
83
|
+
return (
|
|
84
|
+
snap.cost > 0 ||
|
|
85
|
+
snap.cacheRead > 0 ||
|
|
86
|
+
snap.cacheWrite > 0 ||
|
|
87
|
+
snap.input > 0 ||
|
|
88
|
+
snap.output > 0 ||
|
|
89
|
+
snap.reasoning > 0
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function sidebarShouldShow(
|
|
94
|
+
main: SessionSnapshot,
|
|
95
|
+
subs: readonly SubAgentSummary[],
|
|
96
|
+
): boolean {
|
|
97
|
+
return subs.length > 0 || mainSessionHasStats(main)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type PerCallHitTrend = {
|
|
101
|
+
hitPercent: number
|
|
102
|
+
trendPercent: number
|
|
103
|
+
hasTrend: boolean
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Single assistant turn hit % (0–100), or null if skipped / no denominator. */
|
|
107
|
+
export function perMessageHitPercent(msg: AssistantMessage): number | null {
|
|
108
|
+
if (msg.role !== "assistant" || msg.summary === true) return null
|
|
109
|
+
const t = msg.tokens
|
|
110
|
+
if (!t) return null
|
|
111
|
+
const input = t.input ?? 0
|
|
112
|
+
const read = t.cache?.read ?? 0
|
|
113
|
+
const denom = read + input
|
|
114
|
+
if (denom <= 0) return null
|
|
115
|
+
return (read / denom) * 100
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Per-turn hit rates for the top Hit row (visual-cache).
|
|
120
|
+
* Skips `summary: true` assistant messages — not full LLM pricing turns.
|
|
121
|
+
*/
|
|
122
|
+
export function computePerCallHitTrend(messages: readonly AssistantMessage[]): PerCallHitTrend {
|
|
123
|
+
let prevHit = -1
|
|
124
|
+
let lastHit = -1
|
|
125
|
+
for (const msg of messages) {
|
|
126
|
+
const hit = perMessageHitPercent(msg)
|
|
127
|
+
if (hit === null) continue
|
|
128
|
+
prevHit = lastHit
|
|
129
|
+
lastHit = hit
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
hitPercent: lastHit >= 0 ? lastHit : 0,
|
|
133
|
+
trendPercent: prevHit >= 0 && lastHit >= 0 ? lastHit - prevHit : 0,
|
|
134
|
+
hasTrend: prevHit >= 0 && lastHit >= 0,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function shortModelName(modelId: string): string {
|
|
139
|
+
if (!modelId) return ""
|
|
140
|
+
return modelId.includes("/") ? (modelId.split("/").pop() ?? modelId) : modelId
|
|
141
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { TimelineConfig } from "../plugin-config.ts"
|
|
2
|
+
import type { AssistantMessage } from "../types.ts"
|
|
3
|
+
import { buildCallRecords, mergeAndSortRecords } from "./records.ts"
|
|
4
|
+
import {
|
|
5
|
+
appendTimelineRecord,
|
|
6
|
+
localDateKey,
|
|
7
|
+
purgeTimelineLogDir,
|
|
8
|
+
resolveTimelineDir,
|
|
9
|
+
timelineDailyLogPath,
|
|
10
|
+
} from "./writer.ts"
|
|
11
|
+
import type { LlmCallRecord } from "./types.ts"
|
|
12
|
+
|
|
13
|
+
export const TIMELINE_DEBOUNCE_MS = 500
|
|
14
|
+
|
|
15
|
+
export type TimelineCollector = {
|
|
16
|
+
schedule: () => void
|
|
17
|
+
resetForRootChange: () => void
|
|
18
|
+
dispose: () => void
|
|
19
|
+
memoryRecords: () => readonly LlmCallRecord[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createTimelineCollector(opts: {
|
|
23
|
+
config: TimelineConfig
|
|
24
|
+
getRootSessionId: () => string
|
|
25
|
+
getChildIds: () => readonly string[]
|
|
26
|
+
getMessages: (sessionId: string) => readonly AssistantMessage[]
|
|
27
|
+
/** Test hook: replace disk append */
|
|
28
|
+
append?: (logPath: string, record: LlmCallRecord) => Promise<void>
|
|
29
|
+
}): TimelineCollector {
|
|
30
|
+
if (!opts.config.enabled) {
|
|
31
|
+
return {
|
|
32
|
+
schedule: () => {},
|
|
33
|
+
resetForRootChange: () => {},
|
|
34
|
+
dispose: () => {},
|
|
35
|
+
memoryRecords: () => [],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const logsDir = resolveTimelineDir(opts.config)
|
|
40
|
+
const rotation = {
|
|
41
|
+
maxLinesPerFile: opts.config.maxLinesPerFile,
|
|
42
|
+
rotateMaxBytes: opts.config.rotateMaxBytes,
|
|
43
|
+
retainRotated: opts.config.retainRotated,
|
|
44
|
+
}
|
|
45
|
+
const append =
|
|
46
|
+
opts.append ??
|
|
47
|
+
((path, rec) => appendTimelineRecord(path, rec, rotation))
|
|
48
|
+
if (opts.config.maxAgeDays > 0 || opts.config.maxLogFiles > 0) {
|
|
49
|
+
void purgeTimelineLogDir(logsDir, {
|
|
50
|
+
maxAgeDays: opts.config.maxAgeDays,
|
|
51
|
+
maxLogFiles: opts.config.maxLogFiles,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
const flushedKeys = new Set<string>()
|
|
55
|
+
let activeDateKey = localDateKey()
|
|
56
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
57
|
+
let memory: LlmCallRecord[] = []
|
|
58
|
+
let collectGen = 0
|
|
59
|
+
|
|
60
|
+
const ensureDateKey = () => {
|
|
61
|
+
const today = localDateKey()
|
|
62
|
+
if (today !== activeDateKey) {
|
|
63
|
+
flushedKeys.clear()
|
|
64
|
+
activeDateKey = today
|
|
65
|
+
}
|
|
66
|
+
return today
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const clearDebounce = () => {
|
|
70
|
+
if (debounceTimer !== undefined) clearTimeout(debounceTimer)
|
|
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()
|
|
96
|
+
const rootId = opts.getRootSessionId()
|
|
97
|
+
if (!rootId) {
|
|
98
|
+
memory = []
|
|
99
|
+
return
|
|
100
|
+
}
|
|
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
|
+
|
|
129
|
+
const schedule = () => {
|
|
130
|
+
clearDebounce()
|
|
131
|
+
if (!opts.getRootSessionId()) return
|
|
132
|
+
debounceTimer = setTimeout(() => {
|
|
133
|
+
debounceTimer = undefined
|
|
134
|
+
collectNow()
|
|
135
|
+
}, TIMELINE_DEBOUNCE_MS)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const resetForRootChange = () => {
|
|
139
|
+
collectGen++
|
|
140
|
+
clearDebounce()
|
|
141
|
+
memory = []
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const dispose = () => {
|
|
145
|
+
collectGen++
|
|
146
|
+
clearDebounce()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const memoryRecords = () => {
|
|
150
|
+
const max = opts.config.maxMemoryRows
|
|
151
|
+
if (memory.length <= max) return memory
|
|
152
|
+
return memory.slice(-max)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { schedule, resetForRootChange, dispose, memoryRecords }
|
|
156
|
+
}
|