opencode-cache-hit 0.1.0 → 0.2.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.
@@ -26,7 +26,7 @@ export const CURRENCY_PRESETS: Record<CurrencyCode, { symbol: string; decimals:
26
26
  export const DEFAULT_COST_DISPLAY: CostDisplayConfig = {
27
27
  currency: "CNY",
28
28
  costUnit: "USD",
29
- rate: 7.2,
29
+ rate: 6.77,
30
30
  }
31
31
 
32
32
  function resolveExchangeRate(cfg: CostDisplayConfig): number {
@@ -88,3 +88,15 @@ export function createCostFormatter(config: CostDisplayConfig): (amountUsd: numb
88
88
  return "~" + symbol + v.toFixed(decimals)
89
89
  }
90
90
  }
91
+
92
+ export function createRateFormatter(config: CostDisplayConfig): (perMillion: number) => string {
93
+ const preset = CURRENCY_PRESETS[config.currency]
94
+ const symbol = config.symbol ?? preset.symbol
95
+ const unit = config.costUnit ?? config.convert?.from ?? "USD"
96
+ const rate = unit === config.currency ? 1 : resolveExchangeRate(config)
97
+
98
+ return (perMillion: number) => {
99
+ const v = perMillion * rate
100
+ return symbol + v.toFixed(2)
101
+ }
102
+ }
package/src/i18n.ts CHANGED
@@ -10,7 +10,11 @@ export type UiStrings = {
10
10
  out: string
11
11
  reasoning: string
12
12
  cost: string
13
- withAgents: string
13
+ saved: string
14
+ rate: string
15
+ rateIn: string
16
+ rateOut: string
17
+ rateCache: string
14
18
  hitFolded: string
15
19
  noData: string
16
20
  secDetail: string
@@ -19,6 +23,7 @@ export type UiStrings = {
19
23
  secAgents: string
20
24
  /** Shown in Agents section header: totals are child sessions only, not the parent session. */
21
25
  agentsScopeHint: string
26
+ secTTL: string
22
27
  tok: string
23
28
  }
24
29
 
@@ -32,7 +37,11 @@ const EN: UiStrings = {
32
37
  out: "Out:",
33
38
  reasoning: "Reason:",
34
39
  cost: "Cost:",
35
- withAgents: "w/ Agents:",
40
+ saved: "Saved:",
41
+ rate: "Rate:",
42
+ rateIn: "/M in",
43
+ rateOut: "/M out",
44
+ rateCache: "/M cache",
36
45
  hitFolded: "hit",
37
46
  noData: "Waiting for cache data...",
38
47
  secDetail: "Detail",
@@ -40,6 +49,7 @@ const EN: UiStrings = {
40
49
  model: "Model:",
41
50
  secAgents: "Agents",
42
51
  agentsScopeHint: " · sub-sessions",
52
+ secTTL: "TTL:",
43
53
  tok: "tok",
44
54
  }
45
55
 
@@ -53,7 +63,11 @@ const ZH: UiStrings = {
53
63
  out: "输出:",
54
64
  reasoning: "推理:",
55
65
  cost: "费用:",
56
- withAgents: "含 Agents:",
66
+ saved: "节省:",
67
+ rate: "单价:",
68
+ rateIn: "/M 输入",
69
+ rateOut: "/M 输出",
70
+ rateCache: "/M 缓存",
57
71
  hitFolded: "命中",
58
72
  noData: "等待缓存数据...",
59
73
  secDetail: "明细",
@@ -61,6 +75,7 @@ const ZH: UiStrings = {
61
75
  model: "模型:",
62
76
  secAgents: "子 Agent",
63
77
  agentsScopeHint: " · 仅子会话",
78
+ secTTL: "存活:",
64
79
  tok: "tok",
65
80
  }
66
81
 
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs"
2
- import { dirname, join } from "node:path"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
3
4
  import { fileURLToPath } from "node:url"
4
5
  import {
5
6
  type PluginConfig,
@@ -9,6 +10,11 @@ import {
9
10
 
10
11
  /** Parent of `src/` (plugin package root). Do not wrap in `dirname` — `..` already resolves there. */
11
12
  export const PLUGIN_ROOT = fileURLToPath(new URL("..", import.meta.url))
13
+
14
+ /** Preferred config: ~/.config/opencode/cache-hit.json (survives plugin updates). */
15
+ export const XDG_CONFIG_PATH = join(homedir(), ".config", "opencode", "cache-hit.json")
16
+
17
+ /** Legacy config: plugin-root `cache-hit.config.json` (for npm cache / local installs). */
12
18
  export const CONFIG_PATH = join(PLUGIN_ROOT, "cache-hit.config.json")
13
19
 
14
20
  function cloneDefault(): PluginConfig {
@@ -19,11 +25,24 @@ function cloneDefault(): PluginConfig {
19
25
  }
20
26
  }
21
27
 
22
- export function loadPluginConfig(): PluginConfig {
23
- if (!existsSync(CONFIG_PATH)) return cloneDefault()
28
+ function tryRead(path: string): PluginConfig | null {
24
29
  try {
25
- return normalizePluginConfig(JSON.parse(readFileSync(CONFIG_PATH, "utf8")))
30
+ return normalizePluginConfig(JSON.parse(readFileSync(path, "utf8")))
26
31
  } catch {
27
- return cloneDefault()
32
+ return null
33
+ }
34
+ }
35
+
36
+ export function loadPluginConfig(): PluginConfig {
37
+ // 1. XDG config dir (preferred — persists across updates)
38
+ if (existsSync(XDG_CONFIG_PATH)) {
39
+ const cfg = tryRead(XDG_CONFIG_PATH)
40
+ if (cfg) return cfg
41
+ }
42
+ // 2. Legacy plugin-root config (backward compatible)
43
+ if (existsSync(CONFIG_PATH)) {
44
+ const cfg = tryRead(CONFIG_PATH)
45
+ if (cfg) return cfg
28
46
  }
47
+ return cloneDefault()
29
48
  }
@@ -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 {
@@ -22,7 +22,9 @@ export function CacheHitSidebarHost(props: {
22
22
  theme: Record<string, unknown>
23
23
  display: DisplayConfig
24
24
  timeline: TimelineConfig
25
+ cacheTTL: CacheTTLConfig
25
26
  formatCost: (amount: number) => string
27
+ formatRate: (perMillion: number) => string
26
28
  api: OpenCodeTuiApi
27
29
  }) {
28
30
  const [refreshTick, setRefreshTick] = createSignal(0)
@@ -34,6 +36,7 @@ export function CacheHitSidebarHost(props: {
34
36
  return loadPluginConfig()
35
37
  })
36
38
  const display = createMemo(() => runtimeConfig().display)
39
+ const cacheTTL = createMemo(() => runtimeConfig().cacheTTL)
37
40
 
38
41
  const bumpRefresh = () => setRefreshTick((v) => v + 1)
39
42
 
@@ -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 ||
@@ -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}`
@@ -11,7 +26,15 @@ export function messageKeyFor(msg: AssistantMessage, sessionId: string): string
11
26
  }
12
27
 
13
28
  export function sortKeyForRecord(r: LlmCallRecord): number {
14
- return r.completedAt ?? r.created
29
+ const ts = r.completedAt ?? r.created
30
+ return new Date(ts).getTime()
31
+ }
32
+
33
+ export function mergeAndSortRecords(chunks: readonly LlmCallRecord[][]): LlmCallRecord[] {
34
+ const all = chunks.flat()
35
+ const keyed = all.map(r => ({ r, k: sortKeyForRecord(r) }))
36
+ keyed.sort((a, b) => a.k - b.k)
37
+ return keyed.map(x => x.r)
15
38
  }
16
39
 
17
40
  export function assistantMessageToRecord(
@@ -28,14 +51,14 @@ export function assistantMessageToRecord(
28
51
  const skippedForHit = msg.summary === true
29
52
  return {
30
53
  schema: 1,
31
- recordedAt,
54
+ recordedAt: msToISOString(recordedAt),
32
55
  sessionId,
33
56
  rootSessionId,
34
57
  scope,
35
58
  messageKey: messageKeyFor(msg, sessionId),
36
59
  modelId: msg.modelID ?? "",
37
- created: timing.created,
38
- completedAt: timing.completedAt,
60
+ created: msToISOString(timing.created),
61
+ completedAt: timing.completedAt !== undefined ? msToISOString(timing.completedAt) : undefined,
39
62
  durationMs: timing.durationMs,
40
63
  isComplete: timing.isComplete,
41
64
  input: t.input ?? 0,
@@ -67,7 +90,3 @@ export function buildCallRecords(
67
90
  return out
68
91
  }
69
92
 
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
@@ -1,11 +1,11 @@
1
1
  import { appendFile, mkdir, readdir, stat, unlink } from "node:fs/promises"
2
+ import { homedir } from "node:os"
2
3
  import { dirname, join } from "node:path"
3
4
  import type { TimelineConfig } from "../plugin-config.ts"
4
- import { PLUGIN_ROOT } from "../load-config.ts"
5
5
  import type { LlmCallRecord } from "./types.ts"
6
6
  import { rotateFileBySize, trimFileToMaxLines } from "./rotation.ts"
7
7
 
8
- export const DEFAULT_TIMELINE_DIR = join(PLUGIN_ROOT, "logs")
8
+ export const DEFAULT_TIMELINE_DIR = join(homedir(), ".local", "share", "opencode", "logs", "cache-hit")
9
9
  export const TIMELINE_FILE_PREFIX = "timeline"
10
10
 
11
11
  export type TimelineWriteOptions = Pick<
@@ -14,8 +14,9 @@ export type TimelineWriteOptions = Pick<
14
14
  >
15
15
 
16
16
  export function resolveTimelineDir(config: TimelineConfig): string {
17
- const raw = config.dir?.trim()
18
- return raw.length > 0 ? raw : DEFAULT_TIMELINE_DIR
17
+ const raw = (config.dir ?? "").trim()
18
+ if (!raw) return DEFAULT_TIMELINE_DIR
19
+ return raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw
19
20
  }
20
21
 
21
22
  /** Local calendar day `YYYY-MM-DD` for daily log files. */
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type SessionSnapshot = {
2
2
  model: string
3
+ providerID: string
3
4
  input: number
4
5
  output: number
5
6
  reasoning: number
@@ -10,6 +11,8 @@ export type SessionSnapshot = {
10
11
 
11
12
  export type SubAgentSummary = {
12
13
  id: string
14
+ model: string
15
+ providerID: string
13
16
  cost: number
14
17
  input: number
15
18
  output: number
@@ -23,6 +26,7 @@ export type AssistantMessage = {
23
26
  id?: string
24
27
  messageID?: string
25
28
  modelID?: string
29
+ providerID?: string
26
30
  cost?: number
27
31
  /** OpenCode SDK: true = summary/compaction message, not a full LLM pricing turn */
28
32
  summary?: boolean
@@ -38,9 +42,21 @@ export type AssistantMessage = {
38
42
  }
39
43
  }
40
44
 
45
+ export type ModelCost = {
46
+ input: number
47
+ output: number
48
+ cache: { read: number; write: number }
49
+ }
50
+
51
+ export type ProviderInfo = {
52
+ id: string
53
+ models: { [key: string]: { cost: ModelCost } }
54
+ }
55
+
41
56
  export type OpenCodeTuiApi = {
42
57
  state: {
43
58
  path: { directory: string }
59
+ provider: ReadonlyArray<ProviderInfo>
44
60
  session: {
45
61
  messages: (id: string) => unknown[] | undefined
46
62
  get: (id: string) => { parentID?: string } | undefined