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.
- package/AGENTS.md +2 -1
- package/README.md +5 -18
- package/README.zh-CN.md +155 -96
- package/docs/assets/cache-hit-panel.v3.png +0 -0
- package/docs/en/design.md +22 -4
- package/docs/en/timeline-duplicate-writes.md +125 -0
- package/docs/en/timeline.md +17 -13
- package/docs/zh-CN/design.md +23 -5
- package/docs/zh-CN/timeline.md +18 -15
- package/package.json +1 -1
- package/scripts/README.md +63 -0
- package/scripts/timeline-dashboard.ts +728 -0
- package/src/agents-view.tsx +8 -8
- package/src/cache-ttl-view.tsx +3 -8
- package/src/format-cost.ts +70 -0
- package/src/format-model.ts +227 -0
- package/src/sidebar-host.tsx +6 -6
- package/src/timeline/collector.ts +40 -87
- package/src/timeline/records.ts +0 -30
- package/src/tui-panel/README.md +2 -2
- package/src/tui-panel/README.zh-CN.md +2 -2
- package/src/tui-panel/components.tsx +31 -4
- package/src/tui-panel/index.ts +6 -1
- package/src/tui-panel/palette.ts +5 -0
- package/src/version.ts +4 -1
- package/docs/assets/.gitkeep +0 -0
- package/docs/assets/cache-hit-panel.png +0 -0
package/src/agents-view.tsx
CHANGED
|
@@ -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 {
|
|
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={
|
|
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
|
)}
|
package/src/cache-ttl-view.tsx
CHANGED
|
@@ -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
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
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
|
}
|
package/src/format-cost.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/sidebar-host.tsx
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
|
|
55
53
|
let activeDateKey = localDateKey()
|
|
56
|
-
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
57
54
|
let memory: LlmCallRecord[] = []
|
|
58
|
-
let
|
|
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
|
|
70
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
clearDebounce()
|
|
147
|
-
}
|
|
86
|
+
const logPath = timelineDailyLogPath(logsDir, ensureDateKey())
|
|
87
|
+
void append(logPath, rec).catch(() => {})
|
|
148
88
|
|
|
149
|
-
|
|
89
|
+
memory.push(rec)
|
|
150
90
|
const max = opts.config.maxMemoryRows
|
|
151
|
-
|
|
152
|
-
return memory.slice(-max)
|
|
91
|
+
while (memory.length > max) memory.shift()
|
|
153
92
|
}
|
|
154
93
|
|
|
155
|
-
return {
|
|
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
|
}
|
package/src/timeline/records.ts
CHANGED
|
@@ -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
|
-
|
package/src/tui-panel/README.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|