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.
Files changed (41) hide show
  1. package/AGENTS.md +4 -2
  2. package/CONTRIBUTING.md +24 -8
  3. package/README.md +55 -22
  4. package/README.zh-CN.md +155 -92
  5. package/cache-hit.config.example.json +7 -2
  6. package/docs/assets/cache-hit-panel.v3.png +0 -0
  7. package/docs/en/design.md +30 -8
  8. package/docs/en/timeline-duplicate-writes.md +125 -0
  9. package/docs/en/timeline.md +26 -21
  10. package/docs/zh-CN/design.md +31 -9
  11. package/docs/zh-CN/timeline.md +28 -24
  12. package/package.json +1 -2
  13. package/scripts/README.md +64 -1
  14. package/scripts/plot-hit-rate.ts +4 -3
  15. package/scripts/timeline-dashboard.ts +728 -0
  16. package/src/agents-view.tsx +24 -10
  17. package/src/cache-ttl-view.tsx +128 -0
  18. package/src/format-cost.ts +83 -1
  19. package/src/format-model.ts +227 -0
  20. package/src/i18n.ts +18 -3
  21. package/src/load-config.ts +24 -5
  22. package/src/main-session-view.tsx +43 -3
  23. package/src/plugin-config.ts +59 -1
  24. package/src/plugin.tsx +4 -1
  25. package/src/pricing.ts +57 -0
  26. package/src/sidebar-host.tsx +13 -7
  27. package/src/stats.ts +6 -15
  28. package/src/timeline/collector.ts +40 -87
  29. package/src/timeline/records.ts +18 -29
  30. package/src/timeline/types.ts +3 -3
  31. package/src/timeline/writer.ts +5 -4
  32. package/src/tui-panel/README.md +2 -2
  33. package/src/tui-panel/README.zh-CN.md +2 -2
  34. package/src/tui-panel/components.tsx +31 -4
  35. package/src/tui-panel/index.ts +6 -1
  36. package/src/tui-panel/palette.ts +5 -0
  37. package/src/types.ts +16 -0
  38. package/src/use-cache-hit-metrics.ts +8 -9
  39. package/src/version.ts +4 -1
  40. package/src/widget.tsx +11 -3
  41. 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.showCombinedHit()}>
59
+ <Show when={m.pricing().saved > 0}>
44
60
  <TuiMetricRow
45
61
  pal={m.pal()}
46
62
  layout={layout}
47
- label={m.t().withAgents}
48
- value={m.combinedPct()}
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
  )
@@ -19,7 +19,7 @@ export const DEFAULT_DISPLAY: DisplayConfig = {
19
19
 
20
20
  export type TimelineConfig = {
21
21
  enabled: boolean
22
- /** Empty → plugin `logs/` directory */
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
+ }
@@ -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
- childSync.onForeignSessionActivity(event.properties?.info?.sessionID)
105
- timeline.schedule()
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 { buildCallRecords, mergeAndSortRecords } from "./records.ts"
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
- schedule: () => void
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
- schedule: () => {},
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
- const flushedKeys = new Set<string>()
52
+
55
53
  let activeDateKey = localDateKey()
56
- let debounceTimer: ReturnType<typeof setTimeout> | undefined
57
54
  let memory: LlmCallRecord[] = []
58
- let collectGen = 0
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 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()
65
+ const handleMessage = (sessionID: string, msg: AssistantMessage) => {
66
+ if (disposed) return
96
67
  const rootId = opts.getRootSessionId()
97
- if (!rootId) {
98
- memory = []
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
- const schedule = () => {
130
- clearDebounce()
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
- const resetForRootChange = () => {
139
- collectGen++
140
- clearDebounce()
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
- const dispose = () => {
145
- collectGen++
146
- clearDebounce()
147
- }
86
+ const logPath = timelineDailyLogPath(logsDir, ensureDateKey())
87
+ void append(logPath, rec).catch(() => {})
148
88
 
149
- const memoryRecords = () => {
89
+ memory.push(rec)
150
90
  const max = opts.config.maxMemoryRows
151
- if (memory.length <= max) return memory
152
- return memory.slice(-max)
91
+ while (memory.length > max) memory.shift()
153
92
  }
154
93
 
155
- return { schedule, resetForRootChange, dispose, memoryRecords }
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
  }
@@ -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
- }
@@ -1,14 +1,14 @@
1
1
  /** Single LLM call row (one JSONL line). */
2
2
  export type LlmCallRecord = {
3
3
  schema: 1
4
- recordedAt: number
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: number
11
- completedAt?: number
10
+ created: string
11
+ completedAt?: string
12
12
  durationMs?: number
13
13
  isComplete: boolean
14
14
  input: number