opencode-cache-hit 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +4 -2
  2. package/CONTRIBUTING.md +24 -8
  3. package/README.md +55 -22
  4. package/README.zh-CN.md +155 -92
  5. package/cache-hit.config.example.json +7 -2
  6. package/docs/assets/cache-hit-panel.v3.png +0 -0
  7. package/docs/en/design.md +30 -8
  8. package/docs/en/timeline-duplicate-writes.md +125 -0
  9. package/docs/en/timeline.md +26 -21
  10. package/docs/zh-CN/design.md +31 -9
  11. package/docs/zh-CN/timeline.md +28 -24
  12. package/package.json +1 -2
  13. package/scripts/README.md +64 -1
  14. package/scripts/plot-hit-rate.ts +4 -3
  15. package/scripts/timeline-dashboard.ts +728 -0
  16. package/src/agents-view.tsx +24 -10
  17. package/src/cache-ttl-view.tsx +128 -0
  18. package/src/format-cost.ts +83 -1
  19. package/src/format-model.ts +227 -0
  20. package/src/i18n.ts +18 -3
  21. package/src/load-config.ts +24 -5
  22. package/src/main-session-view.tsx +43 -3
  23. package/src/plugin-config.ts +59 -1
  24. package/src/plugin.tsx +4 -1
  25. package/src/pricing.ts +57 -0
  26. package/src/sidebar-host.tsx +13 -7
  27. package/src/stats.ts +6 -15
  28. package/src/timeline/collector.ts +40 -87
  29. package/src/timeline/records.ts +18 -29
  30. package/src/timeline/types.ts +3 -3
  31. package/src/timeline/writer.ts +5 -4
  32. package/src/tui-panel/README.md +2 -2
  33. package/src/tui-panel/README.zh-CN.md +2 -2
  34. package/src/tui-panel/components.tsx +31 -4
  35. package/src/tui-panel/index.ts +6 -1
  36. package/src/tui-panel/palette.ts +5 -0
  37. package/src/types.ts +16 -0
  38. package/src/use-cache-hit-metrics.ts +8 -9
  39. package/src/version.ts +4 -1
  40. package/src/widget.tsx +11 -3
  41. package/docs/assets/cache-hit-panel.png +0 -0
@@ -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 { TuiMetricRow, truncateVisual, type PanelLayout } from "./tui-panel/index.ts"
8
- import type { SubAgentSummary } from "./types.ts"
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={" " + agentRowLabel(sub.id, layout.gauge())}
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
+ }
@@ -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 {
@@ -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
- 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
  }