SLOPSHOPPER

keepwarm

提示缓存保温:输入框上方显示闲置计时与一键保温,/keepwarm N 接续保温 N 小时(上限 24),图表面板给出上下文、缓存剩余、额度与费用构成

newpanebandcommandtoaststatus
v0.8.4MITupdated 2026-10-09Ancean/claude-keepwarm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keepwarm
│ ┃ keepwarm-chart ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ [ 缓存时间线 ] [ 费用构成 ] [ 上下文长度 ] ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 时间范围 [ 本会话 ] [ 24 小时 ] [ 7 天 ] [ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 读取失败:JSON Parse error: Unexpected ⏺ Bash(bun test) │ ┃ identifier "dev" ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /keepwarm │ ⎿ keepwarm: 保温请求将在半秒后发出。 │ │ 缓存已闲置 0 分钟,约 60 分钟后过期 [ 保温一次 ][ 图表 ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
缓存已闲置 0 分钟,约 60 分钟后过期 [ 保温一次 ][ 图表 ]
Pane · keepwarm-chart
[ 缓存时间线 ] [ 费用构成 ] [ 上下文长度 ] [ 刷新 ] 时间范围 [ 本会话 ] [ 24 小时 ] [ 7 天 ] [ 30 天 ] [ 12 周 读取失败:JSON Parse error: Unexpected identifier "dev"
README

keepwarm:Claude Code 提示缓存保温插件

中文 | English

Claude Code 的提示缓存闲置约 1 小时后过期,下一次请求要把整段上下文重新写入缓存,长会话一次就可能多花几美元(或多耗一截订阅额度)。这个插件在输入框上方显示缓存闲置时间,提供一键保温和定时接续保温,并用图表面板展示上下文、缓存、额度与费用构成。

缓存写入(近 24 小时累计)费用构成(近 24 小时累计)上下文长度(近 24 小时,按小时)
缓存写入面板费用构成面板上下文长度面板

功能

  • 闲置提示:输入框上方显示“缓存已闲置 N 分钟,约 M 分钟后过期”,附“保温一次”“图表”按钮。
  • 保温命令
  • /keepwarm:立即发一次保温请求(一句极短的固定提示,让缓存续期)。
  • /keepwarm 8:接续保温 8 小时,闲置满 50 分钟自动发一次;你一发言,时限从那一刻重新起算。上限 24 小时。
  • /keepwarm off:停止接续保温。
  • /keepwarm chart:打开图表面板。
  • 图表面板
  • 顶部四格:当前上下文、缓存剩余时间、5 小时额度、本周额度。
  • 三张图:缓存时间线(长期范围下为缓存写入)、费用构成、上下文长度。
  • 时间范围:本会话、24 小时、7 天、30 天、12 周、12 个月;长期范围统计全部项目。
  • 费用与缓存写入可切换“每段 / 累计”:24 小时和 7 天默认累计曲线,更长范围默认柱状图。

安装

方式一:插件市场(推荐)

在终端执行下面两条命令,然后新开一个 Claude Code 会话:

claude plugin marketplace add https://github.com/Ancean/claude-keepwarm.git
claude plugin install keepwarm@ancean-plugins

在会话里也可以用 /plugin marketplace add Ancean/claude-keepwarm 和 /plugin install keepwarm@ancean-plugins;若提示读不到仓库(简写走 SSH),改用上面的 HTTPS 地址。更新:claude plugin marketplace update ancean-plugins,再 claude plugin update keepwarm@ancean-plugins。

方式二:手动放入技能目录

  1. 把本仓库放到 ~/.claude/skills/keepwarm/(Windows 为 C:\Users\<用户名>\.claude\skills\keepwarm\):
   git clone https://github.com/Ancean/claude-keepwarm.git ~/.claude/skills/keepwarm

确认路径是 ~/.claude/skills/keepwarm/.claude-plugin/plugin.json,不要多套一层文件夹。

  1. 重启 Claude 桌面应用或新开一个 Claude Code 会话,插件会以 keepwarm@skills-dir 自动加载。
  2. 更新:在该目录下 git pull,然后新开会话。

两种方式只能选一种,否则会出现重名,后装的那份不加载。

运行要求

  • 支持函数钩子插件(hooks.json 中的 modules)的 Claude Code 版本。
  • 命令行可直接运行 python(3.8 及以上,只用标准库)。没有 Python 时保温功能照常可用,图表面板会提示读取失败。

隐私与数据

  • 只读取本机会话记录(~/.claude/projects/)中的用量数字、时间戳、模型名和压缩元数据,不读取对话内容,也不联网上传任何数据。
  • 长期汇总会把上述用量数字逐文件缓存到 ~/.claude/keepwarm-cache/rows.json,删除后会自动重建。
  • 保温请求的时刻存在插件自己的存储里,只保留最近 30 个会话。

口径说明

  • 费用按 Anthropic 列表价估算(Opus、Sonnet、Haiku 三档写在代码 priceOf 中),不是账单;订阅用户可把金额理解为额度消耗的相对大小。
  • “冷读取”指闲置 1 小时以上后整段重写缓存的请求,多付金额按“缓存写入价 − 缓存读取价”估算;压缩后或换模型后的整段重写单列为“重建”,保温无法避免。
  • “保温净省”= 保温避免的冷读取费用 − 保温请求本身的花费,只在保温后确实接上了真实对话时计入。
  • 自动压缩阈值从 Claude Code 读取,读不到时使用后备值 295K。

已知限制

  • 电脑休眠或关闭 Claude 窗口期间不会发送保温请求。
  • Claude Code 每日是否另有清理缓存的时刻尚未核实,因此接续保温硬上限为 24 小时。
  • 更早会话中的保温请求若已超出最近 30 个会话的记录,会被计为普通请求。
Source 1 files
hooks/register.tsx 1373 lines
1import type { Register } from 'claude-code'
2
3// ---------- 参数 ----------
4// 缓存有效期 1 小时。闲置满 FIRE_MIN 分钟就补发一次保温,留出余量防止卡在 60 分钟边缘。
5const SHOW_MIN = 0
6const WARN_MIN = 45
7const FIRE_MIN = 50
8const MAX_HOURS = 24 // 硬上限:Claude Code 每日清理缓存的时刻未核实,任何设置都不得超过 24 小时
9const COMPACT_AT = 295000 // 读不到引擎阈值时的后备值:窗口 327680 时实测的自动压缩触发长度(中位约 299K)
10const PROMPT = '【保温】这是缓存保温请求,不要调用任何工具,不要继续之前的任务,只回复两个字:保温。'
11const PANE = 'keepwarm-chart'
12
13// 橙色主色调
14const C_MAIN = '#EA7A12'
15const C_DARK = '#9A3412'
16const C_LIGHT = '#FDBA74'
17const C_GOLD = '#D97706'
18const C_RED = '#DC2626'
19const C_TEXT = '#8a8a8a'
20const C_GRID = 'rgba(128,128,128,0.25)'
21
22// 只读本会话记录里的 usage、时间戳、模型和压缩元数据,不读对话内容
23const PY = [
24  'import sys,json,glob,os',
25  "fs=glob.glob(os.path.join(os.path.expanduser('~'),'.claude','projects','*',sys.argv[1]+'.jsonl'))",
26  'rows={};comp=[]',
27  'for f in fs:',
28  "  for line in open(f,encoding='utf-8',errors='replace'):",
29  "    if '\"usage\"' not in line and 'compact_boundary' not in line: continue",
30  '    try: d=json.loads(line)',
31  '    except Exception: continue',
32  "    if d.get('type')=='assistant':",
33  "      m=d.get('message') or {};u=m.get('usage')",
34  "      if not u or not m.get('id') or m.get('model')=='<synthetic>': continue",
35  "      rows.setdefault(m['id'],{'t':d.get('timestamp'),'m':m.get('model'),'i':u.get('input_tokens',0),'cw':u.get('cache_creation_input_tokens',0),'cr':u.get('cache_read_input_tokens',0),'o':u.get('output_tokens',0)})",
36  "    elif d.get('subtype')=='compact_boundary':",
37  "      cm=d.get('compactMetadata') or {}",
38  "      comp.append({'t':d.get('timestamp'),'trig':cm.get('trigger'),'pre':cm.get('preTokens'),'post':cm.get('postTokens')})",
39  "print(json.dumps({'r':list(rows.values()),'c':comp}))",
40].join('\n')
41
42// 长期汇总:扫描全部项目的会话记录,同样只取 usage、时间戳、模型和压缩元数据;逐文件缓存到 ~/.claude/keepwarm-cache
43// 输入(标准输入):{ edges: 分格边界毫秒数组, kw: 保温时刻 };输出每格的请求数、费用拆分、缓存写入拆分、上下文与压缩次数
44const AGG_PY = String.raw`import sys,json,glob,os,bisect
45from datetime import datetime
46P=json.loads(sys.stdin.read())
47E=P['edges'];KW=sorted(P.get('kw',[]));T0=E[0];T1=E[-1]
48H=os.path.expanduser('~');R=os.path.join(H,'.claude','projects')
49CD=os.path.join(H,'.claude','keepwarm-cache');CF=os.path.join(CD,'rows.json')
50try: C=json.load(open(CF,encoding='utf-8'))
51except Exception: C={}
52def ms(s):
53  try: return int(datetime.fromisoformat(s.replace('Z','+00:00')).timestamp()*1000)
54  except Exception: return None
55def parse(f):
56  rows={};comp=[]
57  for line in open(f,encoding='utf-8',errors='replace'):
58    if '"usage"' not in line and 'compact_boundary' not in line: continue
59    try: d=json.loads(line)
60    except Exception: continue
61    if d.get('type')=='assistant':
62      m=d.get('message') or {};u=m.get('usage')
63      if not u or not m.get('id') or m.get('model')=='<synthetic>': continue
64      t=ms(d.get('timestamp') or '')
65      if t is None: continue
66      rows.setdefault(m['id'],[t,m.get('model') or '',u.get('input_tokens',0) or 0,u.get('cache_creation_input_tokens',0) or 0,u.get('cache_read_input_tokens',0) or 0,u.get('output_tokens',0) or 0])
67    elif d.get('subtype')=='compact_boundary':
68      cm=d.get('compactMetadata') or {};t=ms(d.get('timestamp') or '')
69      if t is not None: comp.append([t,cm.get('trigger'),cm.get('preTokens') or 0])
70  return [[k]+v for k,v in rows.items()],comp
71fs=glob.glob(os.path.join(R,'*','*.jsonl'))+glob.glob(os.path.join(R,'*','*','subagents','*.jsonl'))
72st=[(f,os.stat(f)) for f in fs]
73st=[x for x in st if x[1].st_mtime*1000>=T0-86400000]
74st.sort(key=lambda x:x[1].st_mtime)
75NC={};seen=set();seqs=[]
76for f,s in st:
77  key=f'{s.st_mtime_ns}:{s.st_size}'
78  c=C.get(f)
79  if not c or c['k']!=key:
80    r,cp=parse(f);c={'k':key,'r':r,'c':cp}
81  NC[f]=c
82  rs=[]
83  for x in c['r']:
84    if x[0] in seen: continue
85    seen.add(x[0]);rs.append(x[1:])
86  rs.sort(key=lambda x:x[0])
87  seqs.append((rs,[x for x in c['c']],'subagents' in f))
88try:
89  os.makedirs(CD,exist_ok=True);json.dump(NC,open(CF+'.tmp','w',encoding='utf-8'));os.replace(CF+'.tmp',CF)
90except Exception: pass
91def price(m):
92  m=m.lower()
93  if 'sonnet' in m: return 2,10
94  if 'haiku' in m: return 1,5
95  return 4,20
96nb=len(E)-1
97B=[{'n':0,'base':0,'rebuild':0,'kw':0,'cold':0,'avoided':0,'coldN':0,'kwN':0,'i':0,'cw':0,'cr':0,'o':0,'ctx':[],'cwHit':0,'cwIdle':0,'cwRe':0,'cwKw':0,'compA':0,'compM':0,'pre':0,'ses':set()} for _ in range(nb)]
98def bin_of(t):
99  if t<T0 or t>=T1: return -1
100  return bisect.bisect_right(E,t)-1
101def is_kw(t):
102  j=bisect.bisect_left(KW,t-180000)
103  return j<len(KW) and KW[j]<=t+2000
104for si,(rs,comps,sub) in enumerate(seqs):
105  ct=sorted(c[0] for c in comps)
106  for c in comps:
107    b=bin_of(c[0])
108    if b>=0:
109      if c[1]=='auto': B[b]['compA']+=1
110      else: B[b]['compM']+=1
111      B[b]['pre']=max(B[b]['pre'],c[2])
112  prev=0;lastReal=None;chain=0;chainInp=4
113  for t,m,i,cw,cr,o in rs:
114    ctx=i+cw+cr
115    if ctx<=0: prev=t;continue
116    inp,out=price(m)
117    cost=(i*inp+cr*0.05*inp+cw*2*inp+o*out)/1e6
118    gap=(t-prev)/60000 if prev else 0
119    miss=cw>0.5*ctx and ctx>30000
120    extra=cw*1.95*inp/1e6 if miss else 0
121    j=bisect.bisect_right(ct,prev);after=j<len(ct) and ct[j]<=t
122    if is_kw(t): kind='kw'
123    elif not miss: kind='hit'
124    elif after: kind='compact'
125    elif gap>=60 and not sub: kind='idle'
126    else: kind='other'
127    b=bin_of(t);q=B[b] if b>=0 else None
128    if kind=='kw':
129      if q: q['kw']+=cost;q['kwN']+=1;q['cwKw']+=cw
130      chain=ctx;chainInp=inp
131    else:
132      if chain>0 and lastReal is not None and t-lastReal>=3600000 and gap<60 and q: q['avoided']+=chain*1.95*chainInp/1e6
133      chain=0;lastReal=t
134      if q:
135        if kind=='idle': q['cold']+=extra;q['coldN']+=1;q['cwIdle']+=cw
136        elif kind=='hit': q['cwHit']+=cw
137        else: q['rebuild']+=extra;q['cwRe']+=cw
138        q['base']+=cost-extra
139    if q:
140      q['n']+=1;q['i']+=i;q['cw']+=cw;q['cr']+=cr;q['o']+=o;q['ses'].add(si)
141      if not sub: q['ctx'].append(ctx)
142    prev=t
143out=[]
144for q in B:
145  c=sorted(q.pop('ctx'));q['ses']=len(q['ses'])
146  q['ctxMax']=c[-1] if c else 0;q['ctxMed']=c[len(c)//2] if c else 0
147  for k in ('base','rebuild','kw','cold','avoided'): q[k]=round(q[k],4)
148  out.append(q)
149print(json.dumps({'b':out,'files':len(st)}))`
150
151// ---------- 模块状态 ----------
152let lastAt: number | null = null // 最近一次任何请求(含保温)
153let lastReal: number | null = null // 最近一次你本人的请求,保温时长从这里起算
154let hours = 0 // 接续保温的小时数,0 为关闭
155let busy = false
156let sent = 0
157let kwTimes: number[] = [] // 本次加载以来发出的保温请求时刻
158let view = 'line' // line | cost | ctx
159let data: any = null // { reqs, comps }
160let loadError = ''
161let compactAt = COMPACT_AT // 打开图表时从引擎读取本会话的自动压缩阈值
162let paneOpen = false
163let span = 'ses' // ses 本会话 | hour | day | week | month
164let agg: any = null // { b: 每格汇总, edges, unit }
165let aggAt = 0
166let aggBusy = false
167let aggError = ''
168let cumBy: Record<string, boolean> = { hour: true, d7: true } // 每档默认:短范围看累计,长范围看每段
169
170// ---------- 纯函数 ----------
171const p2 = (n: number) => String(n).padStart(2, '0')
172const hm = (t: number) => {
173  const d = new Date(t)
174  return `${p2(d.getHours())}:${p2(d.getMinutes())}`
175}
176const md = (t: number) => {
177  const d = new Date(t)
178  return `${p2(d.getMonth() + 1)}/${p2(d.getDate())}`
179}
180const kfmt = (n: number) => (n === 0 ? '0' : `${Math.round(n / 1000)}K`)
181const usd = (v: number) => `${v < 0 ? '−' : ''}$${Math.abs(v) < 10 ? Math.abs(v).toFixed(2) : Math.abs(v).toFixed(1)}`
182const dur = (min: number) => (min >= 2880 ? `${Math.round(min / 1440)} 天` : min >= 120 ? `${Math.round(min / 60)} 小时` : `${Math.round(min)} 分`)
183const HOUR = 3600000
184const KIND_NAME: Record<string, string> = { hit: '命中', kw: '保温', idle: '冷读取', compact: '压缩后重建', other: '重写' }
185
186function priceOf(model: string) {
187  const m = (model || '').toLowerCase()
188  if (m.includes('sonnet')) return { inp: 2, out: 10 }
189  if (m.includes('haiku')) return { inp: 1, out: 5 }
190  return { inp: 4, out: 20 } // opus 及未识别:按 Opus 5.5 列表价
191}
192
193// 每次请求分类:hit 命中;kw 保温;idle 闲置超过 1 小时后的冷读取(保温可避免);
194// compact 压缩后重建、other 其他整段重写(如换模型),这两类保温无法避免
195function prep(raw: any) {
196  const comps = (raw.c || []).map((x: any) => ({ t: Date.parse(x.t), trig: x.trig, pre: x.pre || 0 })).filter((x: any) => Number.isFinite(x.t))
197  const reqs = (raw.r || [])
198    .map((x: any) => ({ t: Date.parse(x.t), m: x.m || '', i: x.i, cw: x.cw, cr: x.cr, o: x.o }))
199    .filter((x: any) => Number.isFinite(x.t) && x.i + x.cw + x.cr > 0)
200    .sort((a: any, b: any) => a.t - b.t)
201  let prev = 0
202  for (const q of reqs) {
203    q.ctx = q.i + q.cw + q.cr
204    q.inp = priceOf(q.m).inp
205    q.cost = (q.i * q.inp + q.cr * 0.05 * q.inp + q.cw * 2 * q.inp + q.o * priceOf(q.m).out) / 1e6
206    q.gap = prev ? (q.t - prev) / 60000 : 0
207    const isMiss = q.cw > 0.5 * q.ctx && q.ctx > 30000
208    q.extra = isMiss ? (q.cw * 1.95 * q.inp) / 1e6 : 0
209    const afterCompact = comps.some((c: any) => c.t > prev && c.t <= q.t)
210    if (kwTimes.some(k => q.t >= k - 2000 && q.t <= k + 180000)) q.kind = 'kw'
211    else if (!isMiss) q.kind = 'hit'
212    else if (afterCompact) q.kind = 'compact'
213    else if (q.gap >= 60) q.kind = 'idle'
214    else q.kind = 'other'
215    prev = q.t
216  }
217  return { reqs, comps }
218}
219
220// 费用账本:逐次累计正常费用、无法避免的重建、保温花费、冷读取多付,以及保温避免的冷读取
221function ledger(rs: any[]) {
222  let base = 0
223  let rebuild = 0
224  let kw = 0
225  let cold = 0
226  let avoided = 0
227  let coldN = 0
228  let kwN = 0
229  let lastRealT: number | null = null
230  let chainCtx = 0
231  let chainInp = 4
232  const rows = rs.map((q: any) => {
233    if (q.kind === 'kw') {
234      kw += q.cost
235      kwN += 1
236      chainCtx = q.ctx
237      chainInp = q.inp
238    } else {
239      // 一串保温之后的第一句真实请求:若离上一句真实请求已满 1 小时、而缓存仍热,记为避免了一次冷读取
240      if (chainCtx > 0 && lastRealT !== null && q.t - lastRealT >= HOUR && q.gap < 60) avoided += (chainCtx * 1.95 * chainInp) / 1e6
241      chainCtx = 0
242      lastRealT = q.t
243      if (q.kind === 'idle') {
244        cold += q.extra
245        coldN += 1
246      } else rebuild += q.extra
247      base += q.cost - q.extra
248    }
249    return { t: q.t, s1: base, s2: base + rebuild, s3: base + rebuild + kw, s4: base + rebuild + kw + cold, cf: base + rebuild + cold + avoided }
250  })
251  return { rows, base, rebuild, kw, cold, avoided, coldN, kwN }
252}
253
254function down(a: any[], n: number): any[] {
255  if (a.length <= n) return a
256  const step = a.length / n
257  const out: any[] = []
258  for (let i = 0; i < n; i++) out.push(a[Math.floor(i * step)])
259  out.push(a[a.length - 1])
260  return out
261}
262
263// 纵轴刻度取 1、2、2.5、5 乘 10 的整数次幂,至多 n 格
264function nice(v: number, n = 4) {
265  const raw = Math.max(v, 1e-9) / n
266  const mag = Math.pow(10, Math.floor(Math.log10(raw)))
267  let step = 10 * mag
268  for (const m of [1, 2, 2.5, 5]) {
269    if (m * mag >= raw) {
270      step = m * mag
271      break
272    }
273  }
274  return { max: Math.ceil(v / step - 1e-9) * step, step }
275}
276
277// 界面文字用中性色并随深浅主题切换;橙色只用于数据
278const FONT = "system-ui,-apple-system,'Segoe UI','Microsoft YaHei','PingFang SC',sans-serif"
279const STYLE =
280  '<style>.p{fill:#1f2328}.s{fill:#656d76}.g{stroke:rgba(128,128,128,.2)}.ax{stroke:rgba(128,128,128,.55)}' +
281  '.bg{fill:rgba(128,128,128,.07)}.tr{fill:rgba(128,128,128,.2)}.hl{fill:#9A3412}.ring{stroke:#f6f6f6}' +
282  '@media (prefers-color-scheme:dark){.p{fill:#e6edf3}.s{fill:#9da5ae}.hl{fill:#FDBA74}.ring{stroke:#2a2a2a}}</style>'
283const DEFS =
284  '<defs>' +
285  `<linearGradient id="warm" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${C_MAIN}" stop-opacity=".45"/><stop offset="1" stop-color="${C_MAIN}" stop-opacity=".05"/></linearGradient>` +
286  `<linearGradient id="soft" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${C_MAIN}" stop-opacity=".18"/><stop offset="1" stop-color="${C_MAIN}" stop-opacity=".03"/></linearGradient>` +
287  '<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="6" height="6" fill="rgba(128,128,128,.06)"/><line x1="0" y1="0" x2="0" y2="6" stroke="rgba(128,128,128,.32)" stroke-width="1.6"/></pattern>' +
288  '</defs>'
289
290type G = { w: number; h: number; x0: number; x1: number; y0: number; y1: number }
291
292// 标记尺寸取画布两倍:面板按标记宽度铺满所在栏,高度按比例,放大面板时图随之放大
293function doc(w: number, h: number, inner: string, card = true) {
294  return (
295    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w * 2}" height="${h * 2}" font-family="${FONT}" font-size="12">` +
296    STYLE +
297    DEFS +
298    (card ? `<rect x="0" y="0" width="${w}" height="${h}" rx="12" class="bg"/>` : '') +
299    inner +
300    '</svg>'
301  )
302}
303
304function head(title: string, sub: string) {
305  return `<text x="16" y="27" class="p" font-size="15" font-weight="600">${title}</text><text x="16" y="45" class="s">${sub}</text>`
306}
307
308function yAxis(g: G, max: number, step: number, fmt: (v: number) => string) {
309  let s = ''
310  for (let v = 0; v <= max + 1e-9; v += step) {
311    const y = g.y1 - (v / max) * (g.y1 - g.y0)
312    s += `<line x1="${g.x0}" y1="${y}" x2="${g.x1}" y2="${y}" class="${v === 0 ? 'ax' : 'g'}"/>`
313    s += `<text x="${g.x0 - 6}" y="${y + 4}" text-anchor="end" class="s" font-size="11">${fmt(v)}</text>`
314  }
315  return s
316}
317
318function hline(g: G, y: number, color: string, label: string, below = false) {
319  return (
320    `<line x1="${g.x0}" y1="${y}" x2="${g.x1}" y2="${y}" stroke="${color}" stroke-width="1.2" stroke-dasharray="5 4"/>` +
321    `<text x="${g.x0 + 4}" y="${below ? y + 14 : y - 5}" fill="${color}" font-size="11" font-weight="600">${label}</text>`
322  )
323}
324
325// 实时间轴:刻度按本地时间对齐
326function timeAxis(g: G, t0: number, t1: number) {
327  const span = t1 - t0
328  let step = 24 * HOUR
329  for (const c of [30 * 60000, HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR]) {
330    if (span / c <= 8) {
331      step = c
332      break
333    }
334  }
335  const o = new Date(t0).getTimezoneOffset() * 60000
336  let s = ''
337  for (let t = Math.ceil((t0 - o) / step) * step + o; t <= t1; t += step) {
338    const x = g.x0 + ((t - t0) / span) * (g.x1 - g.x0)
339    s += `<line x1="${x}" y1="${g.y1}" x2="${x}" y2="${g.y1 + 4}" class="ax"/>`
340    s += `<text x="${x}" y="${g.y1 + 17}" text-anchor="middle" class="s" font-size="11">${hm(t)}</text>`
341  }
342  return s
343}
344
345// 压缩空闲:超过 30 分钟的空闲一律按 30 分钟宽度画;超过 60 分钟的记为断点
346function pack(rs: any[], g: G) {
347  const CAP = 30 * 60000
348  const pos: number[] = []
349  const breaks: { i: number; gap: number }[] = []
350  let acc = 0
351  for (let i = 0; i < rs.length; i++) {
352    if (i > 0) {
353      const d = rs[i].t - rs[i - 1].t
354      acc += Math.min(d, CAP)
355      if (d > HOUR) breaks.push({ i, gap: d / 60000 })
356    }
357    pos.push(acc)
358  }
359  const end = Math.max(acc, 1)
360  const xs = pos.map(p => g.x0 + (p / end) * (g.x1 - g.x0))
361  return { xs, breaks }
362}
363
364// 压缩轴的刻度:每段活动的起点优先,其次每小时第一次请求;标签相距不足 46 就跳过
365function packAxis(g: G, rs: any[], xs: number[], breaks: { i: number }[]) {
366  const cand: { x: number; t: number; pri: number }[] = [{ x: xs[0], t: rs[0].t, pri: 0 }]
367  for (const b of breaks) cand.push({ x: xs[b.i], t: rs[b.i].t, pri: 1 })
368  cand.push({ x: xs[xs.length - 1], t: rs[rs.length - 1].t, pri: 1 })
369  let lastHour = Math.floor(rs[0].t / HOUR)
370  for (let i = 1; i < rs.length; i++) {
371    const h = Math.floor(rs[i].t / HOUR)
372    if (h !== lastHour) cand.push({ x: xs[i], t: rs[i].t, pri: 2 })
373    lastHour = h
374  }
375  cand.sort((a, b) => a.pri - b.pri || a.x - b.x)
376  const kept: { x: number; t: number }[] = []
377  for (const c of cand) if (kept.every(k => Math.abs(k.x - c.x) >= 46)) kept.push(c)
378  kept.sort((a, b) => a.x - b.x)
379  let s = ''
380  let day = ''
381  for (const k of kept) {
382    s += `<line x1="${k.x}" y1="${g.y1}" x2="${k.x}" y2="${g.y1 + 4}" class="ax"/>`
383    s += `<text x="${k.x}" y="${g.y1 + 17}" text-anchor="middle" class="s" font-size="11">${hm(k.t)}</text>`
384    if (md(k.t) !== day) {
385      day = md(k.t)
386      s += `<text x="${k.x}" y="${g.y1 + 31}" text-anchor="middle" class="s" font-size="10.5" font-weight="600">${day}</text>`
387    }
388  }
389  return s
390}
391
392function breakMarks(g: G, xs: number[], breaks: { i: number; gap: number }[]) {
393  let s = ''
394  let last = -100
395  for (const b of breaks) {
396    const x = (xs[b.i - 1] + xs[b.i]) / 2
397    s += `<line x1="${x}" y1="${g.y0}" x2="${x}" y2="${g.y1}" class="g" stroke-dasharray="2 3"/>`
398    if (x - last > 56) {
399      s += `<text x="${x}" y="${g.y0 - 6}" text-anchor="middle" class="s" font-size="10.5">闲 ${dur(b.gap)}</text>`
400      last = x
401    }
402  }
403  return s
404}
405
406// 文字宽度粗估:中日韩字符按字号计,其余按 0.56 倍
407function textW(t: string, size: number) {
408  let w = 0
409  for (const ch of t) w += ch.charCodeAt(0) > 255 ? size : size * 0.56
410  return w
411}
412
413const dia = (x: number, y: number, r: number, fill: string, stroke: string) =>
414  `<path d="M${x} ${y - r}L${x + r} ${y}L${x} ${y + r}L${x - r} ${y}Z" fill="${fill}" stroke="${stroke}" stroke-width="1.4"/>`
415
416// 图例符号:area 渐变面积、hatch 斜纹、band 实心色块、line 实线、dash 虚线、dot 圆点、dia 菱形、odia 空心菱形、tri 三角
417function swatch(kind: string, color: string, x: number, y: number) {
418  if (kind === 'area') return `<rect x="${x}" y="${y - 9}" width="12" height="10" rx="2" fill="url(#warm)"/><line x1="${x}" y1="${y - 9}" x2="${x + 12}" y2="${y - 9}" stroke="${color}" stroke-width="1.6"/>`
419  if (kind === 'hatch') return `<rect x="${x}" y="${y - 9}" width="12" height="10" rx="2" fill="url(#hatch)"/>`
420  if (kind === 'band') return `<rect x="${x}" y="${y - 9}" width="12" height="10" rx="2" fill="${color}"/>`
421  if (kind === 'dot') return `<circle cx="${x + 6}" cy="${y - 4}" r="3.6" fill="${color}"/>`
422  if (kind === 'dia') return dia(x + 6, y - 4, 4.6, color, 'none')
423  if (kind === 'odia') return dia(x + 6, y - 4, 4.2, 'none', color)
424  if (kind === 'tri') return `<path d="M${x + 1} ${y - 9}H${x + 11}L${x + 6} ${y}Z" fill="${color}"/>`
425  return `<line x1="${x}" y1="${y - 4}" x2="${x + 12}" y2="${y - 4}" stroke="${color}" stroke-width="2"${kind === 'dash' ? ' stroke-dasharray="3 2"' : ''}/>`
426}
427
428function legend(h: number, items: string[][]) {
429  let s = ''
430  let x = 16
431  const y = h - 14
432  for (const [kind, color, label] of items) {
433    s += swatch(kind, color, x, y)
434    s += `<text x="${x + 17}" y="${y}" class="s" font-size="11">${label}</text>`
435    x += 17 + textW(label, 11) + 16
436  }
437  return s
438}
439
440// 右侧直接标注:按 y 排序后错开,避免文字相叠
441function endLabels(g: G, items: { y: number; text: string; color: string }[]) {
442  const sorted = [...items].sort((a, b) => a.y - b.y)
443  let last = -100
444  let s = ''
445  for (const it of sorted) {
446    const y = Math.max(it.y, last + 15)
447    last = y
448    const paint = it.color === C_DARK ? 'class="hl"' : `fill="${it.color}"`
449    s += `<text x="${g.x1 + 8}" y="${y + 4}" ${paint} font-size="11.5" font-weight="600">${it.text}</text>`
450  }
451  return s
452}
453
454// 图一:缓存时间线(最近 3 小时到未来 1 小时)。面积是缓存里存着的上下文:缓存有效时为橙色渐变,过期后为斜纹
455function svgLine(d: any, now: number) {
456  const g: G = { w: 480, h: 340, x0: 46, x1: 464, y0: 70, y1: 262 }
457  const t0 = now - 3 * HOUR
458  const t1 = now + HOUR
459  const sx = (t: number) => g.x0 + ((Math.min(Math.max(t, t0), t1) - t0) / (t1 - t0)) * (g.x1 - g.x0)
460  const rs = d.reqs
461  let first = rs.findIndex((q: any) => q.t >= t0)
462  if (first < 0) first = rs.length
463  const from = Math.max(0, first - 1)
464  const vis = rs.slice(from)
465  const top = vis.reduce((m: number, q: any) => Math.max(m, q.ctx), 0)
466  const { max, step } = nice(Math.max(top, compactAt) * 1.06)
467  const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
468  let s = head('缓存时间线', '面积高度为缓存里的上下文;橙色为缓存有效,斜纹为已过期') + yAxis(g, max, step, kfmt) + timeAxis(g, t0, t1)
469
470  // 把每次请求到下一次请求之间切成“有效”和“过期”两段
471  const pieces: { a: number; b: number; ctx: number; warm: boolean }[] = []
472  for (let k = 0; k < vis.length; k++) {
473    const q = vis[k]
474    const end = k + 1 < vis.length ? vis[k + 1].t : now
475    const warmEnd = Math.min(end, q.t + HOUR)
476    if (warmEnd > t0 && warmEnd > q.t) pieces.push({ a: Math.max(q.t, t0), b: warmEnd, ctx: q.ctx, warm: true })
477    if (end > warmEnd && end > t0) pieces.push({ a: Math.max(warmEnd, t0), b: end, ctx: q.ctx, warm: false })
478  }
479  // 有效段连成整片面积,避免相邻矩形之间出现细缝
480  let run: typeof pieces = []
481  const flush = () => {
482    if (!run.length) return
483    let path = `M${sx(run[0].a)} ${g.y1}`
484    let edge = ''
485    for (const p of run) {
486      path += `L${sx(p.a)} ${sy(p.ctx)}L${sx(p.b)} ${sy(p.ctx)}`
487      edge += `${edge ? 'L' : 'M'}${sx(p.a)} ${sy(p.ctx)}L${sx(p.b)} ${sy(p.ctx)}`
488    }
489    path += `L${sx(run[run.length - 1].b)} ${g.y1}Z`
490    s += `<path d="${path}" fill="url(#warm)"/><path d="${edge}" fill="none" stroke="${C_MAIN}" stroke-width="1.8" stroke-linejoin="round"/>`
491    run = []
492  }
493  for (const p of pieces) {
494    const contiguous = run.length && Math.abs(run[run.length - 1].b - p.a) < 1
495    if (p.warm && (contiguous || !run.length)) run.push(p)
496    else {
497      flush()
498      if (p.warm) run.push(p)
499      else {
500        const xa = sx(p.a)
501        const xb = sx(p.b)
502        s += `<rect x="${xa}" y="${sy(p.ctx)}" width="${Math.max(0, xb - xa)}" height="${g.y1 - sy(p.ctx)}" fill="url(#hatch)"/>`
503        s += `<line x1="${xa}" y1="${sy(p.ctx)}" x2="${xb}" y2="${sy(p.ctx)}" stroke="rgba(128,128,128,.6)" stroke-width="1.2" stroke-dasharray="3 3"/>`
504        if (xb - xa > 76) s += `<text x="${(xa + xb) / 2}" y="${(sy(p.ctx) + g.y1) / 2 + 4}" text-anchor="middle" class="s" font-size="11">缓存已过期</text>`
505      }
506    }
507  }
508  flush()
509
510  // 未来:缓存还能保持到何时(接续保温开启时延伸到计划的最后一次保温之后 1 小时)
511  const last = rs.length ? rs[rs.length - 1] : null
512  const exp = last ? last.t + HOUR : now
513  if (last && exp > now) {
514    const planEnd = hours > 0 && lastReal !== null ? lastReal + hours * HOUR + HOUR : exp
515    const fEnd = Math.min(t1, Math.max(exp, planEnd))
516    s += `<rect x="${sx(now)}" y="${sy(last.ctx)}" width="${sx(fEnd) - sx(now)}" height="${g.y1 - sy(last.ctx)}" fill="url(#soft)"/>`
517    s += `<line x1="${sx(now)}" y1="${sy(last.ctx)}" x2="${sx(fEnd)}" y2="${sy(last.ctx)}" stroke="${C_MAIN}" stroke-width="1.4" stroke-dasharray="4 3"/>`
518  }
519  s += hline(g, sy(compactAt), C_GOLD, `自动压缩 ${kfmt(compactAt)}`)
520
521  // 请求刻痕与事件标记
522  for (const q of vis) if (q.t >= t0) s += `<line x1="${sx(q.t)}" y1="${g.y1}" x2="${sx(q.t)}" y2="${g.y1 - 4}" stroke="${q.kind === 'hit' ? C_MAIN : q.kind === 'kw' ? C_GOLD : C_DARK}" stroke-width="0.8" opacity=".55"/>`
523  let lastLabel = -100
524  for (const q of vis) {
525    if (q.t < t0) continue
526    const x = sx(q.t)
527    const y = sy(q.ctx)
528    if (q.kind === 'kw') s += dia(x, y, 5, C_GOLD, '#fff')
529    else if (q.kind !== 'hit') {
530      s += `<circle cx="${x}" cy="${y}" r="4" fill="${C_DARK}" class="ring" stroke-width="1.5"/>`
531      if (x - lastLabel > 74) {
532        const text = q.kind === 'idle' ? `冷读取 +${usd(q.extra)}` : q.kind === 'compact' ? '压缩后重建' : '整段重写'
533        s += `<text x="${x}" y="${y - 9}" text-anchor="middle" class="hl" font-size="10.5" font-weight="600">${text}</text>`
534        lastLabel = x
535      }
536    }
537  }
538  if (hours > 0 && lastReal !== null && last) {
539    const end = lastReal + hours * HOUR
540    for (let t = Math.max(now, last.t + FIRE_MIN * 60000); t < end && t <= t1; t += FIRE_MIN * 60000) s += dia(sx(t), sy(last.ctx), 5, 'none', C_GOLD)
541  }
542
543  // 现在与到期
544  s += `<line x1="${sx(now)}" y1="${g.y0 - 2}" x2="${sx(now)}" y2="${g.y1}" class="ax" stroke-width="1.2"/>`
545  const expired = exp < now
546  s += `<text x="${sx(now) + (expired ? 5 : -5)}" y="${g.y0 - 6}" text-anchor="${expired ? 'start' : 'end'}" class="p" font-size="11" font-weight="600">现在 ${hm(now)}</text>`
547  if (last && exp > t0) {
548    s += `<line x1="${sx(exp)}" y1="${g.y0 - 2}" x2="${sx(exp)}" y2="${g.y1}" stroke="${C_RED}" stroke-width="1.2" stroke-dasharray="4 3"/>`
549    s += `<text x="${sx(exp) + (expired ? 5 : -5)}" y="${g.y0 + 14}" text-anchor="${expired ? 'start' : 'end'}" fill="${C_RED}" font-size="11" font-weight="600">缓存到期 ${hm(exp)}</text>`
550  }
551  // 当前上下文标在“现在”线右侧的预计区里;缓存已过期时标在有效段末端内侧
552  if (last) {
553    const lx = exp > now ? sx(now) + 6 : sx(exp) - 6
554    s += `<text x="${lx}" y="${sy(last.ctx) + 17}" text-anchor="${exp > now ? 'start' : 'end'}" class="hl" font-size="12" font-weight="700">${kfmt(last.ctx)}</text>`
555  }
556  s += legend(g.h, [['area', C_MAIN, '缓存有效'], ['hatch', '', '已过期'], ['dot', C_DARK, '冷读取'], ['dia', C_GOLD, '保温'], ['odia', C_GOLD, '计划保温']])
557  return doc(g.w, g.h, s)
558}
559
560// 图二:费用构成(空闲时段压缩)。自下而上:正常费用、无法避免的重建、保温花费、冷读取多付
561function svgCost(d: any) {
562  const g: G = { w: 480, h: 362, x0: 46, x1: 396, y0: 124, y1: 276 }
563  const rs = d.reqs
564  if (!rs.length) return doc(g.w, g.h, head('费用构成', '没有读到请求记录'))
565  const L = ledger(rs)
566  const { xs, breaks } = pack(rs, g)
567  const pts = L.rows.map((r: any, i: number) => ({ ...r, x: xs[i] }))
568  const total = L.base + L.rebuild + L.kw + L.cold
569  const { max, step } = nice(Math.max(total, total - L.kw + L.avoided, 0.01) * 1.05)
570  const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
571  const sp = down(pts, 300)
572  const line = (key: string) => sp.map((p: any) => `${p.x.toFixed(1)},${sy(p[key]).toFixed(1)}`)
573  const band = (lo: string, hi: string, fill: string) => {
574    const upper = line(hi)
575    const lower = lo ? line(lo).reverse() : [`${sp[sp.length - 1].x.toFixed(1)},${g.y1}`, `${sp[0].x.toFixed(1)},${g.y1}`]
576    return `<polygon points="${upper.join(' ')} ${lower.join(' ')}" fill="${fill}"/>`
577  }
578
579  let s = head('费用构成', '按列表价估算,不是账单;空闲超过 30 分钟的时段已压缩')
580  // 顶部三项读数,颜色与图中色带一一对应
581  const net = L.avoided - L.kw
582  const kpi = [
583    { kind: 'band', color: C_DARK, label: '冷读取多付', value: usd(L.cold), sub: `${L.coldN} 次` },
584    { kind: 'band', color: C_GOLD, label: '保温花费', value: usd(L.kw), sub: `${L.kwN} 次` },
585    { kind: 'dash', color: C_DARK, label: net >= 0 ? '保温净省' : '保温净花', value: usd(Math.abs(net)), sub: `避免 ${usd(L.avoided)}` },
586  ]
587  kpi.forEach((k, j) => {
588    const x = 16 + j * 152
589    s += swatch(k.kind, k.color, x, 72) + `<text x="${x + 17}" y="72" class="s" font-size="11">${k.label}</text>`
590    s += `<text x="${x}" y="97" class="p" font-size="18" font-weight="700">${k.value}</text>`
591    s += `<text x="${x + textW(k.value, 18) + 6}" y="97" class="s" font-size="11">${k.sub}</text>`
592  })
593  s += yAxis(g, max, step, v => `$${Number.isInteger(Math.round(v * 100) / 100) ? v.toFixed(0) : v.toFixed(v < 1 ? 2 : 1)}`) + breakMarks(g, xs, breaks) + packAxis(g, rs, xs, breaks)
594  s += band('', 's1', 'url(#warm)')
595  if (L.rebuild > 0) s += band('s1', 's2', 'rgba(128,128,128,.45)')
596  if (L.kw > 0) s += band('s2', 's3', C_GOLD)
597  if (L.cold > 0) s += band('s3', 's4', C_DARK)
598  s += `<polyline fill="none" stroke="${C_MAIN}" stroke-width="2.2" stroke-linejoin="round" points="${line('s4').join(' ')}"/>`
599  const labels = [{ y: sy(total), text: `总 ${usd(total)}`, color: C_MAIN }]
600  if (L.kw > 0 || L.avoided > 0) {
601    const k = Math.max(0, pts.findIndex((p: any) => Math.abs(p.cf - p.s4) > 0.005) - 1)
602    const cf = pts.slice(k).map((p: any) => `${p.x.toFixed(1)},${sy(p.cf).toFixed(1)}`).join(' ')
603    s += `<polyline fill="none" stroke="${C_DARK}" stroke-width="1.6" stroke-dasharray="5 3" stroke-linejoin="round" points="${cf}"/>`
604    labels.push({ y: sy(total - L.kw + L.avoided), text: `不保温 ${usd(total - L.kw + L.avoided)}`, color: C_DARK })
605  }
606  // 每次冷读取在总费用线上标出多付的钱
607  let lastX = -100
608  pts.forEach((p: any, i: number) => {
609    if (rs[i].kind !== 'idle') return
610    s += `<circle cx="${p.x}" cy="${sy(p.s4)}" r="3.6" fill="${C_DARK}" class="ring" stroke-width="1.4"/>`
611    if (p.x - lastX > 44) {
612      const nearEdge = p.x > g.x1 - 30
613      s += `<text x="${nearEdge ? p.x - 7 : p.x}" y="${sy(p.s4) - (nearEdge ? 3 : 8)}" text-anchor="${nearEdge ? 'end' : 'middle'}" class="hl" font-size="10.5" font-weight="600">+${usd(rs[i].extra)}</text>`
614      lastX = p.x
615    }
616  })
617  s += endLabels(g, labels)
618  s += legend(g.h, [['area', C_MAIN, '正常对话'], ['band', 'rgba(128,128,128,.45)', '压缩或换模型后重建'], ['dash', C_DARK, '不保温时估算']])
619  return doc(g.w, g.h, s)
620}
621
622// 图三:上下文长度(空闲时段压缩,按活动段断开)
623function svgCtx(d: any) {
624  const g: G = { w: 480, h: 340, x0: 46, x1: 464, y0: 70, y1: 256 }
625  const rs = d.reqs
626  if (!rs.length) return doc(g.w, g.h, head('上下文长度', '没有读到请求记录'))
627  const { xs, breaks } = pack(rs, g)
628  const top = rs.reduce((m: number, q: any) => Math.max(m, q.ctx), 0)
629  const { max, step } = nice(Math.max(top, compactAt) * 1.06)
630  const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
631  let s = head('上下文长度', '三角为压缩发生处,尖端指向压缩前长度') + yAxis(g, max, step, kfmt) + breakMarks(g, xs, breaks) + packAxis(g, rs, xs, breaks)
632  const cuts = [0, ...breaks.map(b => b.i), rs.length]
633  for (let k = 0; k + 1 < cuts.length; k++) {
634    const idx: number[] = []
635    for (let i = cuts[k]; i < cuts[k + 1]; i++) idx.push(i)
636    const sp = down(idx, 200)
637    if (sp.length === 1) {
638      s += `<circle cx="${xs[sp[0]]}" cy="${sy(rs[sp[0]].ctx)}" r="2.5" fill="${C_MAIN}"/>`
639      continue
640    }
641    const line = sp.map((i: number) => `${xs[i].toFixed(1)},${sy(rs[i].ctx).toFixed(1)}`).join(' ')
642    s += `<polygon points="${xs[sp[0]].toFixed(1)},${g.y1} ${line} ${xs[sp[sp.length - 1]].toFixed(1)},${g.y1}" fill="url(#warm)"/>`
643    s += `<polyline fill="none" stroke="${C_MAIN}" stroke-width="2" stroke-linejoin="round" points="${line}"/>`
644  }
645  s += hline(g, sy(compactAt), C_GOLD, `自动压缩 ${kfmt(compactAt)}`)
646  for (const m of d.comps) {
647    let i = rs.findIndex((q: any) => q.t >= m.t)
648    if (i < 0) i = rs.length - 1
649    const x = xs[i]
650    const y = sy(m.pre)
651    const auto = m.trig === 'auto'
652    s += `<path d="M${x - 6} ${y - 10}H${x + 6}L${x} ${y}Z" fill="${auto ? C_DARK : C_TEXT}"/>`
653    s += `<text x="${x}" y="${y - 14}" text-anchor="middle" ${auto ? 'class="hl"' : 'class="s"'} font-size="10.5" font-weight="600">${kfmt(m.pre)}</text>`
654  }
655  const last = rs[rs.length - 1]
656  s += `<circle cx="${xs[rs.length - 1]}" cy="${sy(last.ctx)}" r="3.6" fill="${C_DARK}" class="ring" stroke-width="1.4"/>`
657  s += `<text x="${xs[rs.length - 1] - 7}" y="${sy(last.ctx) - 8}" text-anchor="end" class="hl" font-size="11.5" font-weight="700">${kfmt(last.ctx)}</text>`
658  s += legend(g.h, [['area', C_MAIN, '上下文长度'], ['tri', C_DARK, '自动压缩'], ['tri', C_TEXT, '手动压缩'], ['dash', C_GOLD, '压缩阈值']])
659  return doc(g.w, g.h, s)
660}
661
662// ---------- 长期汇总:按小时、天、周、月分格 ----------
663const SPANS: Record<string, { n: number; desc: string; name: string }> = {
664  hour: { n: 24, desc: '近 24 小时,按小时', name: '近 24 小时' },
665  d7: { n: 7, desc: '近 7 天,按天', name: '近 7 天' },
666  day: { n: 30, desc: '近 30 天,按天', name: '近 30 天' },
667  week: { n: 12, desc: '近 12 周,按周', name: '近 12 周' },
668  month: { n: 12, desc: '近 12 个月,按月', name: '近 12 个月' },
669}
670const C_BASE = '#F6B26B'
671const C_REB = 'rgba(128,128,128,.45)'
672
673// 分格边界按本地日历对齐(周从周一起),最后一格含现在
674function edgesOf(unit: string, now: number) {
675  const d = new Date(now)
676  if (unit === 'hour') d.setMinutes(0, 0, 0)
677  else {
678    d.setHours(0, 0, 0, 0)
679    if (unit === 'week') d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
680    if (unit === 'month') d.setDate(1)
681  }
682  const out: number[] = []
683  for (let k = 1 - SPANS[unit].n; k <= 1; k++) {
684    const x = new Date(d.getTime())
685    if (unit === 'hour') x.setHours(x.getHours() + k)
686    else if (unit === 'day' || unit === 'd7') x.setDate(x.getDate() + k)
687    else if (unit === 'week') x.setDate(x.getDate() + 7 * k)
688    else x.setMonth(x.getMonth() + k)
689    out.push(x.getTime())
690  }
691  return out
692}
693
694function binName(unit: string, t: number) {
695  const d = new Date(t)
696  if (unit === 'hour') return `${md(t)} ${p2(d.getHours())}时`
697  if (unit === 'week') return `${md(t)} 起一周`
698  if (unit === 'month') return `${d.getFullYear()}-${p2(d.getMonth() + 1)}`
699  return md(t)
700}
701
702// 柱状图横轴:标签从最后一格往前每隔若干格标一个,至多 8 个;按小时时换日处加日期,按月时换年处加年份
703function barAxis(g: G, unit: string, edges: number[]) {
704  const nb = edges.length - 1
705  const bw = (g.x1 - g.x0) / nb
706  const every = Math.ceil(nb / 8)
707  let s = ''
708  let sub = ''
709  for (let k = 0; k < nb; k++) {
710    if ((nb - 1 - k) % every !== 0) continue
711    const x = g.x0 + (k + 0.5) * bw
712    const d = new Date(edges[k])
713    const main = unit === 'hour' ? `${p2(d.getHours())}时` : unit === 'month' ? `${d.getMonth() + 1}月` : md(edges[k])
714    s += `<line x1="${x}" y1="${g.y1}" x2="${x}" y2="${g.y1 + 4}" class="ax"/>`
715    s += `<text x="${x}" y="${g.y1 + 17}" text-anchor="middle" class="s" font-size="11">${main}</text>`
716    const second = unit === 'hour' ? md(edges[k]) : unit === 'month' ? `${d.getFullYear()}` : ''
717    if (second && second !== sub) {
718      sub = second
719      s += `<text x="${x}" y="${g.y1 + 31}" text-anchor="middle" class="s" font-size="10.5" font-weight="600">${second}</text>`
720    }
721  }
722  return { s, bw, cx: (k: number) => g.x0 + (k + 0.5) * bw }
723}
724
725// 一根堆叠柱:自下而上逐段画,段间留 0.6 像素缝
726function stack(x: number, w: number, sy: (v: number) => number, parts: { v: number; fill: string }[]) {
727  let s = ''
728  let acc = 0
729  for (const p of parts) {
730    if (p.v <= 0) continue
731    const yb = sy(acc)
732    const yt = sy(acc + p.v)
733    s += `<rect x="${x - w / 2}" y="${yt}" width="${w}" height="${Math.max(0.6, yb - yt - (acc > 0 ? 0.6 : 0))}" fill="${p.fill}"/>`
734    acc += p.v
735  }
736  return s
737}
738
739// 柱顶小圆标:圆里写次数,柱太窄时只画圆
740function badge(x: number, y: number, n: number, bw: number) {
741  const r = bw >= 13 ? 6.5 : 4
742  return `<circle cx="${x}" cy="${y - r - 2}" r="${r}" fill="${C_DARK}" class="ring" stroke-width="1.2"/>` + (r >= 6 ? `<text x="${x}" y="${y - r + 1.5}" text-anchor="middle" fill="#fff" font-size="9" font-weight="700">${n}</text>` : '')
743}
744
745function kpiRow(cold: number, coldN: number, kw: number, kwN: number, avoided: number) {
746  const net = avoided - kw
747  const kpi = [
748    { kind: 'band', color: C_DARK, label: '冷读取多付', value: usd(cold), sub: `${coldN} 次` },
749    { kind: 'band', color: C_GOLD, label: '保温花费', value: usd(kw), sub: `${kwN} 次` },
750    { kind: 'dash', color: C_DARK, label: net >= 0 ? '保温净省' : '保温净花', value: usd(Math.abs(net)), sub: `避免 ${usd(avoided)}` },
751  ]
752  let s = ''
753  kpi.forEach((k, j) => {
754    const x = 16 + j * 152
755    s += swatch(k.kind, k.color, x, 72) + `<text x="${x + 17}" y="72" class="s" font-size="11">${k.label}</text>`
756    s += `<text x="${x}" y="97" class="p" font-size="18" font-weight="700">${k.value}</text>`
757    s += `<text x="${x + textW(k.value, 18) + 6}" y="97" class="s" font-size="11">${k.sub}</text>`
758  })
759  return s
760}
761
762const mfmt = (v: number) => (v === 0 ? '0' : v >= 1e6 ? `${+(v / 1e6).toFixed(2)}M` : kfmt(v))
763const tot = (b: any) => b.base + b.rebuild + b.kw + b.cold
764const sumOf = (bs: any[], k: string) => bs.reduce((m: number, b: any) => m + b[k], 0)
765
766// 汇总图一:缓存写入按原因拆分。写入单价是读取的 40 倍,冷读取和重建都表现为写入突增
767function aggWrite(a: any) {
768  const g: G = { w: 480, h: 340, x0: 46, x1: 464, y0: 70, y1: 262 }
769  const bs = a.b
770  const { s: ax, bw, cx } = barAxis(g, a.unit, a.edges)
771  const top = bs.reduce((m: number, b: any) => Math.max(m, b.cw), 0)
772  const { max, step } = nice(Math.max(top, 1000) * 1.12)
773  const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
774  const all = sumOf(bs, 'cw')
775  const share = all > 0 ? Math.round((100 * (sumOf(bs, 'cwIdle') + sumOf(bs, 'cwRe'))) / all) : 0
776  let s = head('缓存写入', `全部项目 · ${SPANS[a.unit].desc};整段重写占写入 ${share}%`) + yAxis(g, max, step, mfmt) + ax
777  bs.forEach((b: any, k: number) => {
778    if (b.cw <= 0) return
779    s += stack(cx(k), bw * 0.66, sy, [
780      { v: b.cwHit, fill: C_BASE },
781      { v: b.cwRe, fill: C_REB },
782      { v: b.cwKw, fill: C_GOLD },
783      { v: b.cwIdle, fill: C_DARK },
784    ])
785    if (b.coldN > 0) s += badge(cx(k), sy(b.cw), b.coldN, bw)
786  })
787  s += legend(g.h, [['band', C_BASE, '对话增长'], ['band', C_REB, '压缩或换模型重建'], ['band', C_GOLD, '保温'], ['dot', C_DARK, '冷读取(数字为次数)']])
788  return doc(g.w, g.h, s)
789}
790
791// 汇总图二:每格费用构成,虚线短横为不保温时的估算
792function aggCost(a: any) {
793  const g: G = { w: 480, h: 354, x0: 46, x1: 464, y0: 124, y1: 292 }
794  const bs = a.b
795  const { s: ax, bw, cx } = barAxis(g, a.unit, a.edges)
796  const all = bs.reduce((m: number, b: any) => m + tot(b), 0)
797  const top = bs.reduce((m: number, b: any) => Math.max(m, tot(b), tot(b) - b.kw + b.avoided), 0)
798  const { max, step } = nice(Math.max(top, 0.01) * 1.12)
799  const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
800  let s = head('费用构成', `全部项目 · ${SPANS[a.unit].desc};列表价估算,合计 ${usd(all)}`)
801  s += kpiRow(sumOf(bs, 'cold'), sumOf(bs, 'coldN'), sumOf(bs, 'kw'), sumOf(bs, 'kwN'), sumOf(bs, 'avoided'))
802  s += yAxis(g, max, step, v => `$${Number.isInteger(Math.round(v * 100) / 100) ? v.toFixed(0) : v.toFixed(v < 1 ? 2 : 1)}`) + ax
803  let peak = -1
804  bs.forEach((b: any, k: number) => {
805    const t = tot(b)
806    if (t <= 0) return
807    if (peak < 0 || t > tot(bs[peak])) peak = k
808    s += stack(cx(k), bw * 0.66, sy, [
809      { v: b.base, fill: C_BASE },
810      { v: b.rebuild, fill: C_REB },
811      { v: b.kw, fill: C_GOLD },
812      { v: b.cold, fill: C_DARK },
813    ])
814    if (b.kw > 0 || b.avoided > 0) {
815      const y = sy(t - b.kw + b.avoided)
816      s += `<line x1="${cx(k) - bw * 0.45}" y1="${y}" x2="${cx(k) + bw * 0.45}" y2="${y}" stroke="${C_DARK}" stroke-width="1.6" stroke-dasharray="3 2"/>`
817    }
818  })
819  if (peak >= 0) {
820    const x = cx(peak)
821    const anchor = x > g.x1 - 30 ? 'end' : x < g.x0 + 30 ? 'start' : 'middle'
822    s += `<text x="${x}" y="${sy(tot(bs[peak])) - 6}" text-anchor="${anchor}" class="hl" font-size="11" font-weight="600">${usd(tot(bs[peak]))}</text>`
823  }
824  s += legend(g.h, [['band', C_BASE, '正常对话'], ['band', C_REB, '压缩或换模型后重建'], ['dash', C_DARK, '不保温时估算']])
825  return doc(g.w, g.h, s)
826}
827
828// 汇总图三:每格最长上下文(柱)与中位数(圆点),柱顶三角为压缩次数
829function aggCtx(a: any) {
830  const g: G = { w: 480, h: 340, x0: 46, x1: 464, y0: 70, y1: 256 }
831  const bs = a.b
832  const { s: ax, bw, cx } = barAxis(g, a.unit, a.edges)
833  const top = bs.reduce((m: number, b: any) => Math.max(m, b.ctxMax), 0)
834  const { max, step } = nice(Math.max(top, compactAt) * 1.12)
835  const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
836  const comps = sumOf(bs, 'compA') + sumOf(bs, 'compM')
837  let s = head('上下文长度', `全部项目 · ${SPANS[a.unit].desc};共压缩 ${comps} 次`) + yAxis(g, max, step, mfmt) + ax
838  const w = bw * 0.66
839  bs.forEach((b: any, k: number) => {
840    if (b.ctxMax <= 0) return
841    const x = cx(k)
842    s += `<rect x="${x - w / 2}" y="${sy(b.ctxMax)}" width="${w}" height="${g.y1 - sy(b.ctxMax)}" fill="url(#warm)"/>`
843    s += `<line x1="${x - w / 2}" y1="${sy(b.ctxMax)}" x2="${x + w / 2}" y2="${sy(b.ctxMax)}" stroke="${C_MAIN}" stroke-width="2"/>`
844    s += `<circle cx="${x}" cy="${sy(b.ctxMed)}" r="${Math.min(3.4, bw * 0.22)}" fill="${C_DARK}" class="ring" stroke-width="1"/>`
845    const n = b.compA + b.compM
846    if (n > 0) {
847      const y = sy(b.ctxMax) - 3
848      s += `<path d="M${x - 4} ${y - 6}H${x + 4}L${x} ${y}Z" fill="${b.compA > 0 ? C_DARK : C_TEXT}"/>`
849      if (bw >= 12) s += `<text x="${x}" y="${y - 9}" text-anchor="middle" class="hl" font-size="9.5" font-weight="600">${n}</text>`
850    }
851  })
852  // 压缩标记都挤在阈值线上方,阈值文字改放线下
853  s += hline(g, sy(compactAt), C_GOLD, `自动压缩 ${kfmt(compactAt)}`, true)
854  s += legend(g.h, [['area', C_MAIN, '最长上下文'], ['dot', C_DARK, '中位数'], ['tri', C_DARK, '压缩次数'], ['dash', C_GOLD, '压缩阈值']])
855  return doc(g.w, g.h, s)
856}
857
858// 7 天档的累计图用逐小时细格,柱状图仍按天
859function hourlyEdges(t0: number, t1: number) {
860  const out: number[] = []
861  const x = new Date(t0)
862  while (x.getTime() <= t1) {
863    out.push(x.getTime())
864    x.setHours(x.getHours() + 1)
865  }
866  return out
867}
868
869// 累计图横轴:按小时的档每 3 小时一个刻度、换日处加日期;7 天档在每天零点标日期;其余档同柱状图
870function cumAxis(g: G, unit: string, edges: number[], sx: (t: number) => number) {
871  const nb = edges.length - 1
872  const idx: number[] = []
873  if (unit === 'd7') {
874    for (let k = 0; k <= nb; k++) if (new Date(edges[k]).getHours() === 0) idx.push(k)
875  } else {
876    const every = Math.ceil(nb / 8)
877    for (let k = 0; k <= nb; k += every) idx.push(k)
878  }
879  let s = ''
880  let sub = ''
881  for (const k of idx) {
882    const t = edges[k]
883    const x = sx(t)
884    const d = new Date(t)
885    const main = unit === 'hour' ? `${p2(d.getHours())}时` : unit === 'month' ? `${d.getMonth() + 1}月` : md(t)
886    s += `<line x1="${x}" y1="${g.y1}" x2="${x}" y2="${g.y1 + 4}" class="ax"/>`
887    s += `<text x="${x}" y="${g.y1 + 17}" text-anchor="middle" class="s" font-size="11">${main}</text>`
888    const second = unit === 'hour' ? md(t) : unit === 'month' ? `${d.getFullYear()}` : ''
889    if (second && second !== sub) {
890      sub = second
891      s += `<text x="${x}" y="${g.y1 + 31}" text-anchor="middle" class="s" font-size="10.5" font-weight="600">${second}</text>`
892    }
893  }
894  return s
895}
896
897// 累计图:费用(kind=cost)或缓存写入(kind=write),从范围起点累加到现在,自下而上分层与本会话费用图一致
898function aggCum(a: any, kind: string) {
899  const f = a.fine || a
900  const now = a.at
901  const isCost = kind === 'cost'
902  const g: G = isCost ? { w: 480, h: 354, x0: 46, x1: 396, y0: 124, y1: 276 } : { w: 480, h: 340, x0: 46, x1: 396, y0: 70, y1: 256 }
903  const e0 = f.edges[0]
904  const eN = f.edges[f.edges.length - 1]
905  const sx = (t: number) => g.x0 + ((t - e0) / (eN - e0)) * (g.x1 - g.x0)
906  const K = isCost ? ['base', 'rebuild', 'kw', 'cold'] : ['cwHit', 'cwRe', 'cwKw', 'cwIdle']
907  const acc = [0, 0, 0, 0]
908  let kwAcc = 0
909  let avAcc = 0
910  const pts: any[] = [{ t: e0, s1: 0, s2: 0, s3: 0, s4: 0, cf: 0, cold: 0 }]
911  f.b.forEach((b: any, k: number) => {
912    if (f.edges[k] >= now) return
913    K.forEach((key, j) => (acc[j] += b[key]))
914    kwAcc += b.kw
915    avAcc += b.avoided
916    const s4 = acc[0] + acc[1] + acc[2] + acc[3]
917    pts.push({ t: Math.min(f.edges[k + 1], now), s1: acc[0], s2: acc[0] + acc[1], s3: acc[0] + acc[1] + acc[2], s4, cf: s4 - kwAcc + avAcc, cold: b.coldN })
918  })
919  const last = pts[pts.length - 1]
920  const showCf = isCost && (kwAcc > 0 || avAcc > 0)
921  const { max, step } = nice(Math.max(last.s4, showCf ? last.cf : 0, isCost ? 0.01 : 1000) * 1.08)
922  const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
923  const line = (key: string) => pts.map((p: any) => `${sx(p.t).toFixed(1)},${sy(p[key]).toFixed(1)}`)
924  const band = (lo: string, hi: string, fill: string) => {
925    const lower = lo ? line(lo).reverse() : [`${sx(last.t).toFixed(1)},${g.y1}`, `${sx(e0).toFixed(1)},${g.y1}`]
926    return `<polygon points="${line(hi).join(' ')} ${lower.join(' ')}" fill="${fill}"/>`
927  }
928  const fmt = isCost ? usd : mfmt
929  let s = ''
930  if (isCost) {
931    s += head('费用构成', `全部项目 · ${SPANS[a.unit].name}累计;列表价估算,合计 ${usd(last.s4)}`)
932    s += kpiRow(acc[3], sumOf(f.b, 'coldN'), acc[2], sumOf(f.b, 'kwN'), avAcc)
933    s += yAxis(g, max, step, v => `$${Number.isInteger(Math.round(v * 100) / 100) ? v.toFixed(0) : v.toFixed(v < 1 ? 2 : 1)}`)
934  } else {
935    const share = last.s4 > 0 ? Math.round((100 * (acc[1] + acc[3])) / last.s4) : 0
936    s += head('缓存写入', `全部项目 · ${SPANS[a.unit].name}累计;整段重写占写入 ${share}%`) + yAxis(g, max, step, mfmt)
937  }
938  s += cumAxis(g, a.unit, f.edges, sx)
939  s += band('', 's1', 'url(#warm)')
940  if (acc[1] > 0) s += band('s1', 's2', C_REB)
941  if (acc[2] > 0) s += band('s2', 's3', C_GOLD)
942  if (acc[3] > 0) s += band('s3', 's4', C_DARK)
943  s += `<polyline fill="none" stroke="${C_MAIN}" stroke-width="2.2" stroke-linejoin="round" points="${line('s4').join(' ')}"/>`
944  const labels = [{ y: sy(last.s4), text: `${isCost ? '总' : '共'} ${fmt(last.s4)}`, color: C_MAIN }]
945  if (showCf) {
946    const k = Math.max(0, pts.findIndex((p: any) => Math.abs(p.cf - p.s4) > 0.005) - 1)
947    s += `<polyline fill="none" stroke="${C_DARK}" stroke-width="1.6" stroke-dasharray="5 3" stroke-linejoin="round" points="${line('cf').slice(k).join(' ')}"/>`
948    labels.push({ y: sy(last.cf), text: `不保温 ${usd(last.cf)}`, color: C_DARK })
949  }
950  // 有冷读取的时段在总量线上打点
951  for (const p of pts) if (p.cold > 0) s += `<circle cx="${sx(p.t)}" cy="${sy(p.s4)}" r="3.2" fill="${C_DARK}" class="ring" stroke-width="1.2"/>`
952  // 现在:累计线停在这里,右侧是范围内尚未到来的时间
953  if (now < eN) {
954    s += `<line x1="${sx(now)}" y1="${g.y0}" x2="${sx(now)}" y2="${g.y1}" class="ax" stroke-dasharray="2 3"/>`
955  }
956  s += endLabels(g, labels)
957  s += isCost
958    ? legend(g.h, [['area', C_MAIN, '正常对话'], ['band', C_REB, '压缩或换模型后重建'], ['dash', C_DARK, '不保温时估算']])
959    : legend(g.h, [['area', C_MAIN, '对话增长'], ['band', C_REB, '重建'], ['band', C_GOLD, '保温'], ['band', C_DARK, '冷读取重写'], ['dot', C_DARK, '有冷读取']])
960  return doc(g.w, g.h, s)
961}
962
963function aggTable(a: any) {
964  let t = '| 时段 | 请求 | 费用 | 冷读取 |\n|:--|--:|--:|--:|\n'
965  const rows = a.b.map((b: any, k: number) => ({ b, t: a.edges[k] })).filter((r: any) => r.b.n > 0).slice(-8).reverse()
966  for (const r of rows) t += `| ${binName(a.unit, r.t)} | ${r.b.n} | ${usd(tot(r.b))} | ${r.b.coldN} |\n`
967  return rows.length ? t : '这段时间没有请求记录。'
968}
969
970function resetLabel(iso: string | undefined, now: number) {
971  const t = iso ? Date.parse(iso) : NaN
972  if (!Number.isFinite(t)) return ''
973  return t - now < 20 * HOUR ? `${hm(t)} 重置` : `${md(t)} ${hm(t)} 重置`
974}
975
976// 顶部四格:上下文、缓存剩余、5 小时额度、本周额度
977function tiles(d: any, now: number, u: any) {
978  const rs = d.reqs
979  const last = rs.length ? rs[rs.length - 1] : null
980  const ctx = (u && u.context && u.context.tokens) || (last ? last.ctx : 0)
981  const left = last ? Math.ceil((last.t + HOUR - now) / 60000) : 0
982  const rl = (u && u.rateLimits) || []
983  const quota = (label: string, kind: string) => {
984    const r = rl.find((x: any) => x.kind === kind)
985    if (!r) return { label, value: '暂无读数', aside: '下次回复后更新', frac: 0, color: C_MAIN }
986    const rest = Math.max(0, 100 - r.percentUsed)
987    return { label, value: `剩 ${Math.round(rest)}%`, aside: resetLabel(r.resetsAt, now), frac: rest / 100, color: rest < 10 ? C_RED : C_MAIN }
988  }
989  return [
990    { label: '上下文', value: kfmt(ctx), aside: `压缩线 ${kfmt(compactAt)}`, frac: ctx / compactAt, color: ctx > 0.9 * compactAt ? C_RED : C_MAIN },
991    {
992      label: hours > 0 ? '缓存剩余 · 接续保温中' : '缓存剩余',
993      value: left > 0 ? `${left} 分钟` : '已过期',
994      aside: last ? (left > 0 ? `${hm(last.t + HOUR)} 到期` : '下次请求将冷读取') : '',
995      frac: Math.max(0, left) / 60,
996      color: left <= 10 ? C_RED : C_MAIN,
997    },
998    quota('5 小时额度', 'five_hour'),
999    quota('本周额度', 'seven_day'),
1000  ]
1001}
1002
1003function svgTiles(ts: any[]) {
1004  const w = 480
1005  const gap = 10
1006  const tw = (w - gap) / 2
1007  const th = 84
1008  const rows = Math.ceil(ts.length / 2)
1009  const h = rows * th + (rows - 1) * gap
1010  let s = ''
1011  ts.forEach((t: any, k: number) => {
1012    const x = (k % 2) * (tw + gap)
1013    const y = Math.floor(k / 2) * (th + gap)
1014    const bw = tw - 32
1015    const f = Math.max(0, Math.min(1, t.frac))
1016    s += `<rect x="${x}" y="${y}" width="${tw}" height="${th}" rx="12" class="bg"/>`
1017    s += `<text x="${x + 16}" y="${y + 25}" class="s">${t.label}</text>`
1018    s += `<text x="${x + 16}" y="${y + 54}" class="p" font-size="22" font-weight="600">${t.value}</text>`
1019    s += `<text x="${x + tw - 16}" y="${y + 54}" text-anchor="end" class="s" font-size="11.5">${t.aside}</text>`
1020    s += `<rect x="${x + 16}" y="${y + 66}" width="${bw}" height="6" rx="3" class="tr"/>`
1021    if (f > 0) s += `<rect x="${x + 16}" y="${y + 66}" width="${Math.max(6, bw * f)}" height="6" rx="3" fill="${t.color}"/>`
1022  })
1023  return doc(w, h, s, false)
1024}
1025
1026// 明细只留四列,窄面板也不出横向滚动
1027function recentTable(d: any) {
1028  let t = '| 时间 | 类型 | 上下文 | 费用 |\n|:--|:--|--:|--:|\n'
1029  for (const q of d.reqs.slice(-8).reverse()) t += `| ${hm(q.t)} | ${KIND_NAME[q.kind] || q.kind} | ${kfmt(q.ctx)} | ${usd(q.cost)} |\n`
1030  return t
1031}
1032
1033function summaryLines(d: any, now: number, u: any) {
1034  return tiles(d, now, u).map((t: any) => `${t.label}:${t.value}${t.aside ? `(${t.aside})` : ''}`)
1035}
1036
1037// ---------- 带 $ 的函数 ----------
1038async function loadData($: any) {
1039  loadError = ''
1040  try {
1041    const sid = await $.session.id()
1042    const r = await $.process.run(['python', '-c', PY, sid], { timeoutMs: 60000 })
1043    if (r.exitCode !== 0) throw new Error((r.stderr || '').slice(0, 200) || `退出码 ${r.exitCode}`)
1044    data = prep(JSON.parse(r.stdout))
1045  } catch (err: any) {
1046    data = null
1047    loadError = String(err && err.message ? err.message : err)
1048  }
1049  try {
1050    const u = await $.session.usage({ breakdown: 'summary' })
1051    const th = u && u.context && u.context.breakdown ? u.context.breakdown.autoCompactThreshold : undefined
1052    if (typeof th === 'number' && th > 0) compactAt = th
1053  } catch (err) {
1054    // 读不到阈值时沿用后备值
1055  }
1056}
1057
1058// 长期汇总:扫描全部项目的会话记录(只取用量与压缩元数据),按时段分格;五分钟内重复查看直接用上次结果
1059async function loadAgg($: any, force = false) {
1060  const want = span
1061  if (want === 'ses' || aggBusy) return
1062  const now = await $.clock.now()
1063  if (!force && agg && agg.unit === want && now - aggAt < 5 * 60000) return
1064  aggBusy = true
1065  aggError = ''
1066  $.ui.invalidate('ui.render')
1067  try {
1068    const kw: number[] = []
1069    for (const k of (await $.store.keys()).filter((k: string) => k.startsWith('kw:'))) {
1070      const v = await $.store.get(k)
1071      if (Array.isArray(v)) for (const x of v) if (typeof x === 'number') kw.push(x)
1072    }
1073    const edges = edgesOf(want, now)
1074    const main = await runAgg($, edges, kw)
1075    const fineEdges = want === 'd7' ? hourlyEdges(edges[0], edges[edges.length - 1]) : null
1076    const fine = fineEdges ? { ...(await runAgg($, fineEdges, kw)), edges: fineEdges } : null
1077    agg = { ...main, edges, unit: want, at: now, fine }
1078    aggAt = now
1079  } catch (err: any) {
1080    agg = null
1081    aggError = String(err && err.message ? err.message : err)
1082  }
1083  aggBusy = false
1084  $.ui.invalidate('ui.render')
1085  if (span !== want && span !== 'ses') void loadAgg($)
1086}
1087
1088async function runAgg($: any, edges: number[], kw: number[]) {
1089  const r = await $.process.run(['python', '-c', AGG_PY], { stdin: JSON.stringify({ edges, kw }), timeoutMs: 300000 })
1090  if (r.exitCode !== 0) throw new Error((r.stderr || '').slice(0, 200) || `退出码 ${r.exitCode}`)
1091  return JSON.parse(r.stdout)
1092}
1093
1094async function readUsage($: any) {
1095  try {
1096    return await $.session.usage()
1097  } catch (err) {
1098    return null
1099  }
1100}
1101
1102// 面板开着时每轮结束后重读记录,图与明细跟着更新
1103async function refreshIfOpen($: any) {
1104  if (!paneOpen) return
1105  await loadData($)
1106  $.ui.invalidate('ui.render')
1107  void loadAgg($)
1108}
1109
1110async function openChart($: any, force = false) {
1111  await loadData($)
1112  paneOpen = true
1113  await $.ui.open({ id: PANE, title: '保温图表', closeOnEscape: true })
1114  $.ui.invalidate('ui.render')
1115  void loadAgg($, force)
1116}
1117
1118// 保温时刻存进插件自己的持久存储,重载后仍能在图上认出保温请求;只记时刻,不读对话内容
1119async function loadKw($: any) {
1120  try {
1121    const v = await $.store.get(`kw:${await $.session.id()}`)
1122    if (Array.isArray(v)) kwTimes = v.filter((x: unknown) => typeof x === 'number')
1123  } catch (err) {
1124    // 读不到时只影响图上的保温标记
1125  }
1126}
1127
1128async function saveKw($: any) {
1129  try {
1130    await $.store.set(`kw:${await $.session.id()}`, kwTimes)
1131    const keys = (await $.store.keys()).filter((k: string) => k.startsWith('kw:'))
1132    for (const k of keys.slice(0, Math.max(0, keys.length - 30))) await $.store.delete(k)
1133  } catch (err) {
1134    // 存不下时只影响图上的保温标记
1135  }
1136}
1137
1138// 重载或撤回后模块变量会清空:用记录里最近一次请求的时刻接上,读不到就当作刚活动过
1139async function initLast($: any) {
1140  await loadKw($)
1141  await loadData($)
1142  const rs = data ? data.reqs : []
1143  lastAt = rs.length ? rs[rs.length - 1].t : await $.clock.now()
1144  $.ui.invalidate('ui.render')
1145}
1146
1147async function fireKeepWarm($: any) {
1148  // 发出前先记时刻:插件自己的 $.prompt.submit 不经过本插件的 prompt.submit 钩子(claude plugin test 实测)
1149  const t = await $.clock.now()
1150  kwTimes = [...kwTimes, t].slice(-200)
1151  void saveKw($)
1152  try {
1153    await $.prompt.submit({ text: PROMPT, asUser: true })
1154  } catch (err) {
1155    kwTimes = kwTimes.filter(k => k !== t)
1156    void saveKw($)
1157    $.ui.toast(`保温请求未发出:${String((err as any)?.message || err).slice(0, 80)}`)
1158  }
1159}
1160
1161async function everyMinute($: any) {
1162  if (lastAt === null) return
1163  const t = await $.clock.now()
1164  const idle = Math.floor((t - lastAt) / 60000)
1165  if (hours > 0 && lastReal !== null) {
1166    if (t >= lastReal + hours * 3600000) {
1167      hours = 0
1168      $.ui.toast(`接续保温已到时限并停止,共发送 ${sent} 次`)
1169    } else if (!busy && idle >= FIRE_MIN) {
1170      sent += 1
1171      void fireKeepWarm($)
1172    }
1173  }
1174  const left = lastReal === null ? 0 : Math.max(0, Math.ceil((lastReal + hours * 3600000 - t) / 60000))
1175  $.ui.status(hours > 0 ? `保温中,剩 ${Math.floor(left / 60)}h${left % 60}m,已发 ${sent} 次` : idle >= 15 ? `缓存闲置 ${idle} 分` : undefined)
1176  $.ui.invalidate('ui.render')
1177}
1178
1179export const register: Register = on => {
1180  const leftMin = (t: number) => (lastReal === null ? 0 : Math.max(0, Math.ceil((lastReal + hours * 3600000 - t) / 60000)))
1181
1182  on('session.start', async ($, e, next) => {
1183    await $.command.register({
1184      name: 'keepwarm',
1185      description: '/keepwarm 发一次保温;/keepwarm 8 接续保温 8 小时(上限 24);/keepwarm off 停止;/keepwarm chart 打开图表',
1186    })
1187    $.clock.every(60000, () => {
1188      void everyMinute($)
1189    })
1190    void initLast($)
1191    return next(e)
1192  })
1193
1194  on('command.run', { command: 'keepwarm' }, async ($, e) => {
1195    const arg = e.args.trim().toLowerCase()
1196    if (arg === '') {
1197      // 命令钩子里直接提交会被宿主拒绝(会等待本钩子占着的回合),改为命令返回后再发
1198      $.clock.after(500, () => {
1199        void fireKeepWarm($)
1200      })