opencode-cache-hit 0.2.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.
@@ -5,15 +5,10 @@ import type { CacheHitMetrics } from "./use-cache-hit-metrics.ts"
5
5
  import { aggregateSubAgents } from "./stats.ts"
6
6
  import { computeSubsSaved } from "./pricing.ts"
7
7
  import { formatTokenCount } from "./format-tokens.ts"
8
- import { TuiMetricRow, truncateVisual, type PanelLayout } from "./tui-panel/index.ts"
8
+ import { formatSubAgentLabel, modelRowColor } from "./format-model.ts"
9
+ import { TuiMetricRow, type PanelLayout } from "./tui-panel/index.ts"
9
10
  import type { ProviderInfo, SubAgentSummary } from "./types.ts"
10
11
 
11
- function agentRowLabel(id: string, gauge: number): string {
12
- const tail = id.length > 10 ? id.slice(-8) : id
13
- const raw = id.length > 10 ? "\u2026" + tail : tail
14
- return truncateVisual(raw, Math.max(6, gauge - 14))
15
- }
16
-
17
12
  function subHasActivity(sub: SubAgentSummary): boolean {
18
13
  return sub.cost > 0 || sub.cacheRead > 0 || sub.cacheWrite > 0 || sub.input > 0
19
14
  }
@@ -57,9 +52,14 @@ export function AgentsView(props: {
57
52
  <TuiMetricRow
58
53
  pal={m.pal()}
59
54
  layout={layout}
60
- label={" " + agentRowLabel(sub.id, layout.gauge())}
55
+ label={
56
+ " " +
57
+ formatSubAgentLabel(sub, layout.gauge(), props.formatCost, m.t().tok)
58
+ }
61
59
  value={sub.cost > 0 ? props.formatCost(sub.cost) : formatTokenCount(sub.input)}
62
60
  unit={sub.cost > 0 ? "" : m.t().tok}
61
+ labelFg={modelRowColor(sub.model, sub.providerID, m.pal())}
62
+ valueFg={m.pal().muted}
63
63
  />
64
64
  </Show>
65
65
  )}
@@ -9,7 +9,6 @@ import type { AssistantMessage } from "./types.ts"
9
9
  import type { CacheTTLConfig } from "./plugin-config.ts"
10
10
  import { parseDuration } from "./plugin-config.ts"
11
11
  import type { PanelPalette, PanelLayout } from "./tui-panel/index.ts"
12
- import { TuiMetricRow } from "./tui-panel/index.ts"
13
12
 
14
13
  const SECOND = 1000
15
14
  const MINUTE = 60 * SECOND
@@ -121,13 +120,9 @@ export function CacheTTLView(props: {
121
120
 
122
121
  return (
123
122
  <Show when={elapsed() !== null}>
124
- <TuiMetricRow
125
- pal={props.pal}
126
- layout={props.layout}
127
- label={props.label}
128
- value={`${statusIcon()} ${formatElapsed(elapsed()!)}`}
129
- fg={statusColor()}
130
- />
123
+ <text fg={statusColor()}>
124
+ {props.layout.row(props.label, `${statusIcon()} ${formatElapsed(elapsed()!)}`, "")}
125
+ </text>
131
126
  </Show>
132
127
  )
133
128
  }
@@ -73,6 +73,76 @@ export function normalizeCostDisplay(raw: unknown): CostDisplayConfig {
73
73
  return cfg
74
74
  }
75
75
 
76
+ /** Resolved params for static HTML dashboards (timeline-dashboard.ts). */
77
+ export type CostDisplayEmbed = {
78
+ currency: CurrencyCode
79
+ costUnit: CurrencyCode
80
+ rate: number
81
+ symbol: string
82
+ decimals: number
83
+ minDisplay: number
84
+ /** Chart axis / table header, e.g. "Cost (¥)". */
85
+ chartLabel: string
86
+ /** Empty when display currency matches JSONL cost unit. */
87
+ costNote: string
88
+ }
89
+
90
+ function currencyOrDefault(code: unknown): CurrencyCode {
91
+ return typeof code === "string" && code in CURRENCY_PRESETS ? (code as CurrencyCode) : DEFAULT_COST_DISPLAY.currency
92
+ }
93
+
94
+ /** Guarantee finite rate/symbol/decimals for HTML embed + Chart.js. */
95
+ export function sanitizeCostDisplayEmbed(embed: CostDisplayEmbed): CostDisplayEmbed {
96
+ const currency = currencyOrDefault(embed.currency)
97
+ const costUnit = currencyOrDefault(embed.costUnit)
98
+ const preset = CURRENCY_PRESETS[currency]
99
+ let rate = embed.rate
100
+ if (!Number.isFinite(rate) || rate <= 0) {
101
+ rate = costUnit === currency ? 1 : (DEFAULT_COST_DISPLAY.rate ?? 1)
102
+ }
103
+ const symbol =
104
+ typeof embed.symbol === "string" && embed.symbol.length > 0 ? embed.symbol : preset.symbol
105
+ const decimals =
106
+ typeof embed.decimals === "number" && embed.decimals >= 0 && Number.isFinite(embed.decimals)
107
+ ? embed.decimals
108
+ : preset.decimals
109
+ const minDisplay =
110
+ typeof embed.minDisplay === "number" && embed.minDisplay > 0 && Number.isFinite(embed.minDisplay)
111
+ ? embed.minDisplay
112
+ : preset.minDisplay
113
+ const chartLabel = `Cost (${symbol})`
114
+ const costNote =
115
+ costUnit === currency ? "" : `JSONL cost is ${costUnit}; displayed as ${currency} @ ${rate}`
116
+ return { currency, costUnit, rate, symbol, decimals, minDisplay, chartLabel, costNote }
117
+ }
118
+
119
+ export function buildCostDisplayEmbed(config: CostDisplayConfig | unknown): CostDisplayEmbed {
120
+ const cfg = normalizeCostDisplay(config)
121
+ const currency = currencyOrDefault(cfg.currency)
122
+ const preset = CURRENCY_PRESETS[currency]
123
+ const symbol = cfg.symbol ?? preset.symbol
124
+ const decimals = cfg.decimals ?? preset.decimals
125
+ const minDisplay = cfg.minDisplay ?? preset.minDisplay
126
+ const costUnit = currencyOrDefault(cfg.costUnit ?? cfg.convert?.from ?? DEFAULT_COST_DISPLAY.costUnit)
127
+ const rate = costUnit === currency ? 1 : resolveExchangeRate({ ...cfg, currency, costUnit })
128
+ return sanitizeCostDisplayEmbed({
129
+ currency,
130
+ costUnit,
131
+ rate,
132
+ symbol,
133
+ decimals,
134
+ minDisplay,
135
+ chartLabel: `Cost (${symbol})`,
136
+ costNote:
137
+ costUnit === currency ? "" : `JSONL cost is ${costUnit}; displayed as ${currency} @ ${rate}`,
138
+ })
139
+ }
140
+
141
+ /** No config file / partial config / invalid fields → safe embed for dashboards. */
142
+ export function normalizeCostDisplayEmbed(raw: unknown): CostDisplayEmbed {
143
+ return buildCostDisplayEmbed(raw)
144
+ }
145
+
76
146
  export function createCostFormatter(config: CostDisplayConfig): (amountUsd: number) => string {
77
147
  const preset = CURRENCY_PRESETS[config.currency]
78
148
  const symbol = config.symbol ?? preset.symbol
@@ -0,0 +1,227 @@
1
+ import { formatTokenCount } from "./format-tokens.ts"
2
+ import { shortModelName } from "./stats.ts"
3
+ import type { PanelPalette } from "./tui-panel/palette.ts"
4
+ import { toneBrandHex } from "./tui-panel/palette.ts"
5
+ import { UNIT_GAP, truncateVisual, visualWidth } from "./tui-panel/layout.ts"
6
+ import type { SubAgentSummary } from "./types.ts"
7
+
8
+ const INDENT_COLS = 2
9
+ const MIN_ROW_GAP = 1
10
+ const MIN_LABEL_BUDGET = 6
11
+ const ID_TAIL_DEFAULT = 6
12
+ const ID_TAIL_MIN = 4
13
+
14
+ export type ModelFamilyId =
15
+ | "claude"
16
+ | "deepseek"
17
+ | "openai"
18
+ | "gemini"
19
+ | "qwen"
20
+ | "glm"
21
+ | "kimi"
22
+ | "minimax"
23
+ | "grok"
24
+ | "mimo"
25
+ | "meta"
26
+ | "mistral"
27
+
28
+ /**
29
+ * Approximate vendor brand colors (pre-toning). Tuned for recognition on dark terminals.
30
+ * Applied via `toneBrandHex` — not the panel semantic keys (`warning`, `primary`, …).
31
+ */
32
+ export const MODEL_BRAND_HEX: Record<ModelFamilyId, string> = {
33
+ claude: "#D4A574",
34
+ deepseek: "#4D6BFE",
35
+ openai: "#10A37F",
36
+ gemini: "#5B8DEF",
37
+ qwen: "#6157E5",
38
+ glm: "#2F67F6",
39
+ kimi: "#5B8FF9",
40
+ minimax: "#FF6B35",
41
+ grok: "#A8ADB8",
42
+ mimo: "#7C6FE8",
43
+ meta: "#0668E1",
44
+ mistral: "#FF8200",
45
+ }
46
+
47
+ /** Fallback hues for unknown providers (also toned; never panel `success` green). */
48
+ const UNKNOWN_BRAND_HEX = ["#8B9DAF", "#9CAF8B", "#A89BBF", "#B0A080"] as const
49
+
50
+ type ModelFamilyRule = {
51
+ id: ModelFamilyId
52
+ match: (name: string, providerID: string) => boolean
53
+ }
54
+
55
+ /**
56
+ * Built-in model/provider families (label stays full `displayModelName`).
57
+ * First match wins — order from more specific vendor slugs to broad prefixes.
58
+ */
59
+ export const MODEL_FAMILY_RULES: readonly ModelFamilyRule[] = [
60
+ {
61
+ id: "claude",
62
+ match: (n, p) =>
63
+ p === "anthropic" ||
64
+ n.startsWith("claude-") ||
65
+ /(^|-)(sonnet|opus|haiku)(-|$)/i.test(n),
66
+ },
67
+ {
68
+ id: "deepseek",
69
+ match: (n, p) => p === "deepseek" || n.startsWith("deepseek-"),
70
+ },
71
+ {
72
+ id: "openai",
73
+ match: (n, p) =>
74
+ p === "openai" ||
75
+ n.startsWith("gpt-") ||
76
+ /^o[13](-|$)/.test(n) ||
77
+ n.startsWith("chatgpt-"),
78
+ },
79
+ {
80
+ id: "gemini",
81
+ match: (n, p) => p === "google" || n.startsWith("gemini-"),
82
+ },
83
+ {
84
+ id: "qwen",
85
+ match: (n, p) => p === "qwen" || p === "alibaba" || n.startsWith("qwen"),
86
+ },
87
+ {
88
+ id: "glm",
89
+ match: (n, p) =>
90
+ p === "zhipu" ||
91
+ p === "zhipuai" ||
92
+ n.startsWith("glm-") ||
93
+ n.includes("chatglm"),
94
+ },
95
+ {
96
+ id: "kimi",
97
+ match: (n, p) => p === "moonshot" || n.startsWith("kimi-"),
98
+ },
99
+ {
100
+ id: "minimax",
101
+ match: (n, p) => p === "minimax" || n.startsWith("minimax"),
102
+ },
103
+ {
104
+ id: "grok",
105
+ match: (n, p) => p === "x-ai" || p === "xai" || n.startsWith("grok-"),
106
+ },
107
+ {
108
+ id: "mimo",
109
+ match: (n, p) => p === "mimo" || n.startsWith("mimo-"),
110
+ },
111
+ {
112
+ id: "meta",
113
+ match: (n, p) => p === "meta" || n.startsWith("llama-") || n.includes("meta-llama"),
114
+ },
115
+ {
116
+ id: "mistral",
117
+ match: (n, p) => p === "mistral" || n.startsWith("mistral-") || n.startsWith("codestral-"),
118
+ },
119
+ ]
120
+
121
+ /** Strip release-date tails only — same spirit as main-session `modelShort`. */
122
+ export function stripModelDateSuffix(name: string): string {
123
+ return name.replace(/-20\d{6,}$/, "").replace(/-\d{8}$/, "")
124
+ }
125
+
126
+ /** Sub-agent label text: `shortModelName` + date trim; row layout truncates visually. */
127
+ export function displayModelName(modelId: string): string {
128
+ const name = shortModelName(modelId)
129
+ if (!name) return ""
130
+ return stripModelDateSuffix(name)
131
+ }
132
+
133
+ /** @deprecated alias */
134
+ export function compactModelLabel(modelId: string, _providerID = ""): string {
135
+ return displayModelName(modelId)
136
+ }
137
+
138
+ function normalizeForFamilyMatch(s: string): string {
139
+ return s.toLowerCase()
140
+ }
141
+
142
+ function findFamilyRule(name: string, providerID: string): ModelFamilyRule | undefined {
143
+ const n = normalizeForFamilyMatch(name)
144
+ const p = normalizeForFamilyMatch(providerID)
145
+ return MODEL_FAMILY_RULES.find((r) => r.match(n, p))
146
+ }
147
+
148
+ export function modelFamilyId(modelId: string, providerID: string): ModelFamilyId | null {
149
+ const name = shortModelName(modelId)
150
+ if (!name) return null
151
+ return findFamilyRule(name, providerID)?.id ?? null
152
+ }
153
+
154
+ function stableHash(s: string): number {
155
+ let h = 5381
156
+ for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i)
157
+ return h >>> 0
158
+ }
159
+
160
+ /** Sub-agent label color from vendor brand hex (toned for TUI). */
161
+ export function modelRowColor(modelId: string, providerID: string, pal: PanelPalette): string {
162
+ const fallback = pal.muted
163
+ const family = modelFamilyId(modelId, providerID)
164
+ if (family) return toneBrandHex(MODEL_BRAND_HEX[family], fallback)
165
+ const name = shortModelName(modelId)
166
+ const key = providerID || name.split("-")[0] || name
167
+ const idx = stableHash(key) % UNKNOWN_BRAND_HEX.length
168
+ return toneBrandHex(UNKNOWN_BRAND_HEX[idx]!, fallback)
169
+ }
170
+
171
+ export function sessionIdTail(id: string, tailLen: number): string {
172
+ if (!id) return ""
173
+ if (id.length <= tailLen) return id
174
+ return "\u2026" + id.slice(-tailLen)
175
+ }
176
+
177
+ export function subAgentLabelBudget(gauge: number, value: string, unit: string): number {
178
+ const rightW = visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
179
+ return Math.max(MIN_LABEL_BUDGET, gauge - rightW - INDENT_COLS - MIN_ROW_GAP)
180
+ }
181
+
182
+ /** Sub-agent label: `{model} …{idTail}` — model first; truncation keeps model prefix. */
183
+ export function formatSubAgentLabel(
184
+ sub: SubAgentSummary,
185
+ gauge: number,
186
+ formatCost: (n: number) => string,
187
+ tokUnit: string,
188
+ ): string {
189
+ const value = sub.cost > 0 ? formatCost(sub.cost) : formatTokenCount(sub.input)
190
+ const unit = sub.cost > 0 ? "" : tokUnit
191
+ const budget = subAgentLabelBudget(gauge, value, unit)
192
+
193
+ if (!shortModelName(sub.model)) {
194
+ return truncateVisual(sessionIdTail(sub.id, ID_TAIL_DEFAULT), budget)
195
+ }
196
+
197
+ const model = displayModelName(sub.model)
198
+ return joinModelAndSessionId(model, sub.id, budget)
199
+ }
200
+
201
+ function joinModelAndSessionId(model: string, id: string, budget: number): string {
202
+ if (!model) {
203
+ return truncateVisual(sessionIdTail(id, ID_TAIL_DEFAULT), budget)
204
+ }
205
+
206
+ const tryPair = (tailLen: number, trimModel: boolean): string | null => {
207
+ const idPart = sessionIdTail(id, tailLen)
208
+ const idBlockW = visualWidth(idPart) + 1
209
+ if (budget <= idBlockW) return null
210
+ const modelPart = trimModel ? truncateVisual(model, budget - idBlockW) : model
211
+ if (!modelPart) return null
212
+ const combined = modelPart + " " + idPart
213
+ return visualWidth(combined) <= budget ? combined : null
214
+ }
215
+
216
+ for (const tailLen of [ID_TAIL_DEFAULT, ID_TAIL_MIN]) {
217
+ const full = tryPair(tailLen, false)
218
+ if (full) return full
219
+ }
220
+
221
+ for (const tailLen of [ID_TAIL_MIN, ID_TAIL_DEFAULT]) {
222
+ const trimmed = tryPair(tailLen, true)
223
+ if (trimmed) return trimmed
224
+ }
225
+
226
+ return truncateVisual(model, budget)
227
+ }
@@ -16,6 +16,7 @@ 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
@@ -44,8 +45,6 @@ export function CacheHitSidebarHost(props: {
44
45
  config: props.timeline,
45
46
  getRootSessionId: () => props.sessionId,
46
47
  getChildIds: childIds,
47
- getMessages: (id) =>
48
- (props.api.state.session.messages(id) ?? []) as AssistantMessage[],
49
48
  })
50
49
  onCleanup(() => timeline.dispose())
51
50
 
@@ -56,7 +55,6 @@ export function CacheHitSidebarHost(props: {
56
55
  setChildIds,
57
56
  onSynced: () => {
58
57
  bumpRefresh()
59
- timeline.schedule()
60
58
  },
61
59
  })
62
60
  onCleanup(() => childSync.dispose())
@@ -97,15 +95,17 @@ export function CacheHitSidebarHost(props: {
97
95
  timeline.resetForRootChange()
98
96
  if (sid) {
99
97
  childSync.loadChildren()
100
- timeline.schedule()
101
98
  }
102
99
  })
103
100
 
104
101
  createEffect(() => {
105
102
  const unsub = props.api.event.on("message.updated", (event) => {
106
103
  bumpRefresh()
107
- childSync.onForeignSessionActivity(event.properties?.info?.sessionID)
108
- 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
+ }
109
109
  })
110
110
  onCleanup(() => unsub?.())
111
111
  })
@@ -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
  }
@@ -25,18 +25,6 @@ export function messageKeyFor(msg: AssistantMessage, sessionId: string): string
25
25
  return `${sessionId}:${created}:${msg.modelID ?? ""}`
26
26
  }
27
27
 
28
- export function sortKeyForRecord(r: LlmCallRecord): number {
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)
38
- }
39
-
40
28
  export function assistantMessageToRecord(
41
29
  msg: AssistantMessage,
42
30
  sessionId: string,
@@ -72,21 +60,3 @@ export function assistantMessageToRecord(
72
60
  }
73
61
  }
74
62
 
75
- export function buildCallRecords(
76
- sessionId: string,
77
- rootSessionId: string,
78
- scope: "main" | "child",
79
- messages: readonly AssistantMessage[],
80
- opts?: { logSummaryMessages?: boolean; recordedAt?: number },
81
- ): LlmCallRecord[] {
82
- const now = opts?.recordedAt ?? Date.now()
83
- const logSummary = opts?.logSummaryMessages !== false
84
- const out: LlmCallRecord[] = []
85
- for (const msg of messages) {
86
- if (!logSummary && msg.summary === true) continue
87
- const rec = assistantMessageToRecord(msg, sessionId, rootSessionId, scope, now)
88
- if (rec) out.push(rec)
89
- }
90
- return out
91
- }
92
-
@@ -36,7 +36,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
36
36
  open={open()}
37
37
  onToggle={() => setOpen((o) => !o)}
38
38
  title="My Panel"
39
- version="0.1.0"
39
+ version="0.2.0"
40
40
  />
41
41
  <Show when={open()}>
42
42
  <Show
@@ -69,7 +69,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
69
69
  | `TuiPanel` | Outer border + padding |
70
70
  | `TuiPanelTitle` | Foldable title; optional `collapsed` summary |
71
71
  | `TuiSection` | `▼` section header + separator fill |
72
- | `TuiMetricRow` | Label left, value (+ unit) right |
72
+ | `TuiMetricRow` | Label left, value (+ unit) right; optional `labelFg` / `valueFg` for split colors |
73
73
  | `TuiHitRow` | Hit bar + % + trend |
74
74
  | `computeHitBarWidth` | Dynamic bar width |
75
75
 
@@ -36,7 +36,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
36
36
  open={open()}
37
37
  onToggle={() => setOpen((o) => !o)}
38
38
  title="My Panel"
39
- version="0.1.0"
39
+ version="0.2.0"
40
40
  />
41
41
  <Show when={open()}>
42
42
  <Show
@@ -69,7 +69,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
69
69
  | `TuiPanel` | 外框 + padding |
70
70
  | `TuiPanelTitle` | 可折叠标题;可选 `collapsed` 摘要 |
71
71
  | `TuiSection` | `▼` 区块标题 + 分隔线填充 |
72
- | `TuiMetricRow` | 左标签右数值(可选 unit |
72
+ | `TuiMetricRow` | 左标签右数值(可选 unit);可选 `labelFg` / `valueFg` 分段上色 |
73
73
  | `TuiHitRow` | Hit 条 + % + 趋势 |
74
74
  | `computeHitBarWidth` | 动态进度条宽度 |
75
75