opencode-cache-hit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +67 -0
  2. package/CONTRIBUTING.md +73 -0
  3. package/LICENSE +21 -0
  4. package/README.md +216 -0
  5. package/README.zh-CN.md +186 -0
  6. package/cache-hit.config.example.json +22 -0
  7. package/docs/README.md +8 -0
  8. package/docs/assets/cache-hit-panel.png +0 -0
  9. package/docs/en/design.md +228 -0
  10. package/docs/en/timeline.md +253 -0
  11. package/docs/zh-CN/design.md +229 -0
  12. package/docs/zh-CN/timeline.md +301 -0
  13. package/index.tsx +1 -0
  14. package/package.json +71 -0
  15. package/scripts/README.md +39 -0
  16. package/scripts/plot-hit-rate.ts +222 -0
  17. package/src/agents-view.tsx +55 -0
  18. package/src/cache-hit-rows.tsx +68 -0
  19. package/src/child-session-sync.ts +93 -0
  20. package/src/format-cache-ui.ts +21 -0
  21. package/src/format-cost.ts +90 -0
  22. package/src/format-tokens.ts +5 -0
  23. package/src/i18n.ts +82 -0
  24. package/src/load-config.ts +29 -0
  25. package/src/main-session-view.tsx +76 -0
  26. package/src/message-timing.ts +35 -0
  27. package/src/plugin-config.ts +116 -0
  28. package/src/plugin.tsx +33 -0
  29. package/src/session-list.ts +11 -0
  30. package/src/sidebar-host.tsx +121 -0
  31. package/src/stats.ts +141 -0
  32. package/src/timeline/collector.ts +156 -0
  33. package/src/timeline/records.ts +73 -0
  34. package/src/timeline/rotation.ts +47 -0
  35. package/src/timeline/types.ts +22 -0
  36. package/src/timeline/writer.ts +134 -0
  37. package/src/tui-panel/README.md +78 -0
  38. package/src/tui-panel/README.zh-CN.md +76 -0
  39. package/src/tui-panel/components.tsx +163 -0
  40. package/src/tui-panel/index.ts +41 -0
  41. package/src/tui-panel/layout.ts +107 -0
  42. package/src/tui-panel/palette.ts +93 -0
  43. package/src/tui-panel/use-panel-layout.ts +69 -0
  44. package/src/types.ts +71 -0
  45. package/src/use-cache-hit-metrics.ts +103 -0
  46. package/src/version.ts +1 -0
  47. package/src/widget.tsx +117 -0
@@ -0,0 +1,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
+ }