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.
- package/AGENTS.md +4 -2
- package/CONTRIBUTING.md +24 -8
- package/README.md +55 -22
- package/README.zh-CN.md +155 -92
- package/cache-hit.config.example.json +7 -2
- package/docs/assets/cache-hit-panel.v3.png +0 -0
- package/docs/en/design.md +30 -8
- package/docs/en/timeline-duplicate-writes.md +125 -0
- package/docs/en/timeline.md +26 -21
- package/docs/zh-CN/design.md +31 -9
- package/docs/zh-CN/timeline.md +28 -24
- package/package.json +1 -2
- package/scripts/README.md +64 -1
- package/scripts/plot-hit-rate.ts +4 -3
- package/scripts/timeline-dashboard.ts +728 -0
- package/src/agents-view.tsx +24 -10
- package/src/cache-ttl-view.tsx +128 -0
- package/src/format-cost.ts +83 -1
- package/src/format-model.ts +227 -0
- package/src/i18n.ts +18 -3
- package/src/load-config.ts +24 -5
- package/src/main-session-view.tsx +43 -3
- package/src/plugin-config.ts +59 -1
- package/src/plugin.tsx +4 -1
- package/src/pricing.ts +57 -0
- package/src/sidebar-host.tsx +13 -7
- package/src/stats.ts +6 -15
- package/src/timeline/collector.ts +40 -87
- package/src/timeline/records.ts +18 -29
- package/src/timeline/types.ts +3 -3
- package/src/timeline/writer.ts +5 -4
- 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/types.ts +16 -0
- package/src/use-cache-hit-metrics.ts +8 -9
- package/src/version.ts +4 -1
- package/src/widget.tsx +11 -3
- package/docs/assets/cache-hit-panel.png +0 -0
package/src/agents-view.tsx
CHANGED
|
@@ -3,15 +3,11 @@ import { For, Show } from "solid-js"
|
|
|
3
3
|
import { TokenDetailRows } from "./cache-hit-rows.tsx"
|
|
4
4
|
import type { CacheHitMetrics } from "./use-cache-hit-metrics.ts"
|
|
5
5
|
import { aggregateSubAgents } from "./stats.ts"
|
|
6
|
+
import { computeSubsSaved } from "./pricing.ts"
|
|
6
7
|
import { formatTokenCount } from "./format-tokens.ts"
|
|
7
|
-
import {
|
|
8
|
-
import type
|
|
9
|
-
|
|
10
|
-
function agentRowLabel(id: string, gauge: number): string {
|
|
11
|
-
const tail = id.length > 10 ? id.slice(-8) : id
|
|
12
|
-
const raw = id.length > 10 ? "\u2026" + tail : tail
|
|
13
|
-
return truncateVisual(raw, Math.max(6, gauge - 14))
|
|
14
|
-
}
|
|
8
|
+
import { formatSubAgentLabel, modelRowColor } from "./format-model.ts"
|
|
9
|
+
import { TuiMetricRow, type PanelLayout } from "./tui-panel/index.ts"
|
|
10
|
+
import type { ProviderInfo, SubAgentSummary } from "./types.ts"
|
|
15
11
|
|
|
16
12
|
function subHasActivity(sub: SubAgentSummary): boolean {
|
|
17
13
|
return sub.cost > 0 || sub.cacheRead > 0 || sub.cacheWrite > 0 || sub.input > 0
|
|
@@ -20,14 +16,27 @@ function subHasActivity(sub: SubAgentSummary): boolean {
|
|
|
20
16
|
export function AgentsView(props: {
|
|
21
17
|
m: CacheHitMetrics
|
|
22
18
|
layout: PanelLayout
|
|
19
|
+
providers: ReadonlyArray<ProviderInfo>
|
|
23
20
|
formatCost: (n: number) => string
|
|
24
21
|
}) {
|
|
25
22
|
const { m, layout } = props
|
|
26
23
|
const total = () => aggregateSubAgents(m.subs())
|
|
27
24
|
|
|
25
|
+
const subsSaved = () => computeSubsSaved(m.subs(), props.providers)
|
|
26
|
+
|
|
28
27
|
return (
|
|
29
28
|
<>
|
|
30
|
-
<TokenDetailRows pal={m.pal()} layout={layout} t={m.t()} snap={total()}
|
|
29
|
+
<TokenDetailRows pal={m.pal()} layout={layout} t={m.t()} snap={total()}>
|
|
30
|
+
<Show when={subsSaved() > 0}>
|
|
31
|
+
<TuiMetricRow
|
|
32
|
+
pal={m.pal()}
|
|
33
|
+
layout={layout}
|
|
34
|
+
label={m.t().saved}
|
|
35
|
+
value={props.formatCost(subsSaved())}
|
|
36
|
+
fg={m.pal().success}
|
|
37
|
+
/>
|
|
38
|
+
</Show>
|
|
39
|
+
</TokenDetailRows>
|
|
31
40
|
<Show when={total().cost > 0}>
|
|
32
41
|
<TuiMetricRow
|
|
33
42
|
pal={m.pal()}
|
|
@@ -43,9 +52,14 @@ export function AgentsView(props: {
|
|
|
43
52
|
<TuiMetricRow
|
|
44
53
|
pal={m.pal()}
|
|
45
54
|
layout={layout}
|
|
46
|
-
label={
|
|
55
|
+
label={
|
|
56
|
+
" " +
|
|
57
|
+
formatSubAgentLabel(sub, layout.gauge(), props.formatCost, m.t().tok)
|
|
58
|
+
}
|
|
47
59
|
value={sub.cost > 0 ? props.formatCost(sub.cost) : formatTokenCount(sub.input)}
|
|
48
60
|
unit={sub.cost > 0 ? "" : m.t().tok}
|
|
61
|
+
labelFg={modelRowColor(sub.model, sub.providerID, m.pal())}
|
|
62
|
+
valueFg={m.pal().muted}
|
|
49
63
|
/>
|
|
50
64
|
</Show>
|
|
51
65
|
)}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache TTL elapsed time display.
|
|
3
|
+
* Inspired by opencode-cache-timer (https://github.com/nero-sensei/opencode-cache-timer)
|
|
4
|
+
* by nero-sensei.
|
|
5
|
+
*/
|
|
6
|
+
/** @jsxImportSource @opentui/solid */
|
|
7
|
+
import { createMemo, createSignal, onCleanup, Show, type Accessor } from "solid-js"
|
|
8
|
+
import type { AssistantMessage } from "./types.ts"
|
|
9
|
+
import type { CacheTTLConfig } from "./plugin-config.ts"
|
|
10
|
+
import { parseDuration } from "./plugin-config.ts"
|
|
11
|
+
import type { PanelPalette, PanelLayout } from "./tui-panel/index.ts"
|
|
12
|
+
|
|
13
|
+
const SECOND = 1000
|
|
14
|
+
const MINUTE = 60 * SECOND
|
|
15
|
+
const HOUR = 60 * MINUTE
|
|
16
|
+
|
|
17
|
+
const DEFAULT_TTL_MS = 5 * MINUTE
|
|
18
|
+
|
|
19
|
+
const BUILT_IN_TTL: Record<string, number> = {
|
|
20
|
+
anthropic: 5 * MINUTE,
|
|
21
|
+
openai: 5 * MINUTE,
|
|
22
|
+
deepseek: 2 * HOUR,
|
|
23
|
+
google: 1 * HOUR,
|
|
24
|
+
xai: 5 * MINUTE,
|
|
25
|
+
minimax: 5 * MINUTE,
|
|
26
|
+
xiaomi: 5 * MINUTE,
|
|
27
|
+
qwen: 5 * MINUTE,
|
|
28
|
+
moonshot: 5 * MINUTE,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findLastCacheActivity(messages: Accessor<AssistantMessage[]>): AssistantMessage | null {
|
|
32
|
+
const msgs = messages()
|
|
33
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
34
|
+
const m = msgs[i]
|
|
35
|
+
if (
|
|
36
|
+
m.role === "assistant" &&
|
|
37
|
+
m.time?.completed !== undefined &&
|
|
38
|
+
((m.tokens?.cache?.read ?? 0) > 0 || (m.tokens?.cache?.write ?? 0) > 0)
|
|
39
|
+
) {
|
|
40
|
+
return m
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getTTL(
|
|
47
|
+
providerID: string,
|
|
48
|
+
modelID: string,
|
|
49
|
+
config: CacheTTLConfig,
|
|
50
|
+
): number {
|
|
51
|
+
const userProviders = config.providers
|
|
52
|
+
const specific = userProviders[`${providerID}:${modelID}`]
|
|
53
|
+
if (specific !== undefined) {
|
|
54
|
+
const parsed = parseDuration(specific)
|
|
55
|
+
if (parsed !== null) return parsed
|
|
56
|
+
}
|
|
57
|
+
const userProvider = userProviders[providerID]
|
|
58
|
+
if (userProvider !== undefined) {
|
|
59
|
+
const parsed = parseDuration(userProvider)
|
|
60
|
+
if (parsed !== null) return parsed
|
|
61
|
+
}
|
|
62
|
+
const builtIn = BUILT_IN_TTL[providerID]
|
|
63
|
+
if (builtIn !== undefined) return builtIn
|
|
64
|
+
return DEFAULT_TTL_MS
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatElapsed(ms: number): string {
|
|
68
|
+
if (ms <= 0) return "0s"
|
|
69
|
+
const totalSeconds = Math.floor(ms / 1000)
|
|
70
|
+
const hours = Math.floor(totalSeconds / 3600)
|
|
71
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
72
|
+
const seconds = totalSeconds % 60
|
|
73
|
+
if (hours > 0) return `${hours}h ${minutes}m`
|
|
74
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`
|
|
75
|
+
return `${seconds}s`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function CacheTTLView(props: {
|
|
79
|
+
messages: Accessor<AssistantMessage[]>
|
|
80
|
+
config: CacheTTLConfig
|
|
81
|
+
pal: PanelPalette
|
|
82
|
+
layout: PanelLayout
|
|
83
|
+
label: string
|
|
84
|
+
}) {
|
|
85
|
+
const [now, setNow] = createSignal(Date.now())
|
|
86
|
+
const tick = setInterval(() => setNow(Date.now()), 1000)
|
|
87
|
+
onCleanup(() => clearInterval(tick))
|
|
88
|
+
|
|
89
|
+
const lastCache = createMemo(() => findLastCacheActivity(props.messages))
|
|
90
|
+
|
|
91
|
+
const ttlMs = createMemo(() => {
|
|
92
|
+
const m = lastCache()
|
|
93
|
+
if (!m || !m.providerID) return DEFAULT_TTL_MS
|
|
94
|
+
return getTTL(m.providerID, m.modelID ?? "", props.config)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const elapsed = createMemo(() => {
|
|
98
|
+
const m = lastCache()
|
|
99
|
+
if (!m || m.time.completed === undefined) return null
|
|
100
|
+
return now() - m.time.completed
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const statusIcon = createMemo(() => {
|
|
104
|
+
const e = elapsed()
|
|
105
|
+
const ttl = ttlMs()
|
|
106
|
+
if (e === null) return ""
|
|
107
|
+
if (e < ttl) return "●"
|
|
108
|
+
if (e < ttl * 2) return "◐"
|
|
109
|
+
return "○"
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const statusColor = createMemo(() => {
|
|
113
|
+
const e = elapsed()
|
|
114
|
+
const ttl = ttlMs()
|
|
115
|
+
if (e === null) return props.pal.textMuted
|
|
116
|
+
if (e < ttl) return props.pal.success
|
|
117
|
+
if (e < ttl * 2) return props.pal.warning
|
|
118
|
+
return props.pal.error
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Show when={elapsed() !== null}>
|
|
123
|
+
<text fg={statusColor()}>
|
|
124
|
+
{props.layout.row(props.label, `${statusIcon()} ${formatElapsed(elapsed()!)}`, "")}
|
|
125
|
+
</text>
|
|
126
|
+
</Show>
|
|
127
|
+
)
|
|
128
|
+
}
|
package/src/format-cost.ts
CHANGED
|
@@ -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:
|
|
29
|
+
rate: 6.77,
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function resolveExchangeRate(cfg: CostDisplayConfig): number {
|
|
@@ -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
|
|
@@ -88,3 +158,15 @@ export function createCostFormatter(config: CostDisplayConfig): (amountUsd: numb
|
|
|
88
158
|
return "~" + symbol + v.toFixed(decimals)
|
|
89
159
|
}
|
|
90
160
|
}
|
|
161
|
+
|
|
162
|
+
export function createRateFormatter(config: CostDisplayConfig): (perMillion: number) => string {
|
|
163
|
+
const preset = CURRENCY_PRESETS[config.currency]
|
|
164
|
+
const symbol = config.symbol ?? preset.symbol
|
|
165
|
+
const unit = config.costUnit ?? config.convert?.from ?? "USD"
|
|
166
|
+
const rate = unit === config.currency ? 1 : resolveExchangeRate(config)
|
|
167
|
+
|
|
168
|
+
return (perMillion: number) => {
|
|
169
|
+
const v = perMillion * rate
|
|
170
|
+
return symbol + v.toFixed(2)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -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/i18n.ts
CHANGED
|
@@ -10,7 +10,11 @@ export type UiStrings = {
|
|
|
10
10
|
out: string
|
|
11
11
|
reasoning: string
|
|
12
12
|
cost: string
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/load-config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
import {
|
|
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
|
-
|
|
23
|
-
if (!existsSync(CONFIG_PATH)) return cloneDefault()
|
|
28
|
+
function tryRead(path: string): PluginConfig | null {
|
|
24
29
|
try {
|
|
25
|
-
return normalizePluginConfig(JSON.parse(readFileSync(
|
|
30
|
+
return normalizePluginConfig(JSON.parse(readFileSync(path, "utf8")))
|
|
26
31
|
} catch {
|
|
27
|
-
return
|
|
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
|
}
|