token-stats-timer 1.0.14 → 1.0.16

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 (3) hide show
  1. package/README.md +7 -1
  2. package/package.json +1 -1
  3. package/token-stats.ts +188 -71
package/README.md CHANGED
@@ -16,7 +16,7 @@ Footer 上行(左对齐):
16
16
  - `↑ ↓ Σ CH` —— 累计输入 / 输出 / 总量 / 缓存命中率
17
17
  - `⚡` —— 实时速率(2s rolling window,无流时回落到平均速率)
18
18
  - 上下文占用(样式可配)
19
- - `5h: W: ⏱` —— 套餐剩余(MiniMax / GLM / Kimi / DeepSeek 内置套餐,需在 /stats 里为当前 provider 启用)
19
+ - `5h: W: ⏱` —— 套餐剩余(MiniMax / GLM / Kimi / DeepSeek / OpenCode Go 内置套餐,需在 /stats 里为当前 provider 启用)
20
20
 
21
21
  Footer 下行:cwd + git 分支 + 其他扩展状态。
22
22
 
@@ -101,6 +101,12 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
101
101
 
102
102
  > 组织 ID / 项目 ID 在 GLM Coding Plan 团队版后台「团队编程套餐」页面获取;如果你同时使用 Claude Code / Cursor 等工具并已在环境变量里配置,也可以在 config.json 里直接填上同名值。
103
103
 
104
+ ## OpenCode Go 余额
105
+
106
+ OpenCode Go 订阅(`opencode-go` provider,baseUrl `https://opencode.ai/zen/go/v1`)使用官方配额接口 `GET /zen/go/v1/usage`(`Authorization: Bearer <key>`,即 auth.json 里 `opencode-go` 的 `key` 或环境变量 `OPENCODE_API_KEY`),返回三个滚动窗口的已用百分比:5 小时 / 周 / 月。
107
+
108
+ footer 显示 `5h: X% W: Y% M: Z% ⏱ ...`(剩余比例 = 100 - 已用),后三个子项可分别用 `/stats config` → 显示内容 的「5h额度 / 周额度 / 月额度 / 刷新时间」开关控制。
109
+
104
110
  ## 与原包的兼容性
105
111
 
106
112
  - 显示/套餐配置沿用 `~/.pi/agent/extensions/token-stats/`(原 token-stats 的配置直接生效)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-stats-timer",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "pi extension to display token usage and time spend.",
5
5
  "keywords": [
6
6
  "pi-package",
package/token-stats.ts CHANGED
@@ -13,8 +13,9 @@ import type {
13
13
  ExtensionContext,
14
14
  Theme,
15
15
  } from "@earendil-works/pi-coding-agent";
16
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
16
17
  import type { AssistantMessage } from "@earendil-works/pi-ai";
17
- import { Text } from "@earendil-works/pi-tui";
18
+ import { Markdown } from "@earendil-works/pi-tui";
18
19
  import {
19
20
  appendFile,
20
21
  mkdir,
@@ -150,6 +151,7 @@ export type DisplayKey =
150
151
  | "context" // 容量(ctx%)
151
152
  | "quota5h" // 5h 额度
152
153
  | "quotaWeek" // 周额度
154
+ | "quotaMonth" // 月额度(OpenCode Go 等)
153
155
  | "quotaClock" // 刷新时间(⏱)
154
156
 
155
157
  export interface DisplayConfig {
@@ -212,6 +214,41 @@ function formatTokenSpeed(tokensPerSecond: number): string {
212
214
  return `${Math.round(tokensPerSecond / 1000000)}M`;
213
215
  }
214
216
 
217
+ /**
218
+ * 生成 GitHub 风格 markdown 表格源码(由 Markdown 组件负责渲染对齐/换行)。
219
+ *
220
+ * @param headers 表头
221
+ * @param rows 数据行
222
+ * @param opts.aligns 每列对齐方式,缺省左对齐
223
+ * @param opts.totalRow 底部合计行
224
+ */
225
+ function renderTable(
226
+ headers: string[],
227
+ rows: string[][],
228
+ opts?: {
229
+ aligns?: Array<"left" | "right" | "center">;
230
+ totalRow?: string[];
231
+ },
232
+ ): string[] {
233
+ const aligns = opts?.aligns ?? [];
234
+ // 转义单元格内的 | 与换行,防止破坏表格结构
235
+ const esc = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
236
+ const alignMark = (i: number) => {
237
+ const a = aligns[i] ?? "left";
238
+ return a === "right" ? "---:" : a === "center" ? ":---:" : "---";
239
+ };
240
+
241
+ const lines = [
242
+ `| ${headers.map(esc).join(" | ")} |`,
243
+ `| ${headers.map((_, i) => alignMark(i)).join(" | ")} |`,
244
+ ...rows.map((r) => `| ${r.map(esc).join(" | ")} |`),
245
+ ];
246
+ if (opts?.totalRow) {
247
+ lines.push(`| ${opts.totalRow.map(esc).join(" | ")} |`);
248
+ }
249
+ return lines;
250
+ }
251
+
215
252
  function isReasonableTokenSpeed(tokensPerSecond: number): boolean {
216
253
  return Number.isFinite(tokensPerSecond) && tokensPerSecond > 0 && tokensPerSecond <= MAX_REASONABLE_TOKEN_SPEED;
217
254
  }
@@ -507,6 +544,73 @@ const BUILTIN_PLANS: TokenPlan[] = [
507
544
  };
508
545
  },
509
546
  },
547
+ {
548
+ id: "opencode-go",
549
+ name: "OpenCode Go",
550
+ matchProviders: ["opencode-go"],
551
+ apiKeyEnv: "OPENCODE_API_KEY",
552
+ baseUrl: "https://opencode.ai",
553
+ quotaPath: "/zen/go/v1/usage",
554
+ authHeader: (key) => ({ Authorization: "Bearer " + key }),
555
+ fetchQuota: async (plan: TokenPlan, key: string) => {
556
+ const r = await fetch(plan.baseUrl + plan.quotaPath, {
557
+ method: "GET",
558
+ headers: {
559
+ Authorization: "Bearer " + key,
560
+ "Content-Type": "application/json",
561
+ },
562
+ signal: AbortSignal.timeout(5000),
563
+ });
564
+ if (!r.ok) throw new Error("OpenCode Go 配额查询 HTTP " + r.status);
565
+ return await r.json();
566
+ },
567
+ format: (data: any) => {
568
+ // 官方 /v1/usage:percent 为已用比例,remaining = 100 - percent
569
+ const u = (data && typeof data === "object" ? (data.usage ?? data) : {}) as any;
570
+ const win = (k: string): any => {
571
+ const w = u?.[k];
572
+ if (w && w.status === "ok" && typeof w.percent === "number") return w;
573
+ return null;
574
+ };
575
+ const rolling = win("rolling");
576
+ const weekly = win("weekly");
577
+ const monthly = win("monthly");
578
+ if (!rolling && !weekly && !monthly) {
579
+ return { modelPrefix: "", display: "无数据", color: "err" as const };
580
+ }
581
+ const now = Date.now();
582
+ const resets = [rolling, weekly, monthly]
583
+ .filter((w): w is any => !!w)
584
+ .map((w) => {
585
+ const t = typeof w.resetsAt === "string" ? Date.parse(w.resetsAt) : NaN;
586
+ return Number.isFinite(t) && t > now ? t : null;
587
+ })
588
+ .filter((t): t is number => t !== null);
589
+ const nearestReset = resets.length > 0 ? Math.min(...resets) : null;
590
+
591
+ const rem = (w: any) => (w ? 100 - w.percent : null);
592
+ const r = rem(rolling);
593
+ const wk = rem(weekly);
594
+ const mo = rem(monthly);
595
+ const parts: string[] = [];
596
+ if (r !== null) parts.push(`5h: ${Math.round(r)}%`);
597
+ if (wk !== null) parts.push(`W: ${Math.round(wk)}%`);
598
+ if (mo !== null) parts.push(`M: ${Math.round(mo)}%`);
599
+ let display = parts.join(" ");
600
+ if (nearestReset) {
601
+ const diff = nearestReset - now;
602
+ if (diff > 0 && diff < 30 * 24 * 60 * 60 * 1000) {
603
+ display += ` ⏱ ${formatDuration(diff)}`;
604
+ }
605
+ }
606
+ const low = (v: number | null) => v !== null && v < 20;
607
+ const mid = (v: number | null) => v !== null && v < 50;
608
+ const color = low(r) || low(wk) || low(mo) ? "err" as const
609
+ : mid(r) || mid(wk) || mid(mo) ? "warn" as const
610
+ : "ok" as const;
611
+ return { modelPrefix: "", display, color };
612
+ },
613
+ },
510
614
  ];
511
615
 
512
616
  const DEFAULT_TOKEN_CONFIG: TokenConfig = { providerPlans: {}, ttl: 60 };
@@ -521,6 +625,7 @@ const DEFAULT_DISPLAY_CONFIG: DisplayConfig = {
521
625
  context: true,
522
626
  quota5h: true,
523
627
  quotaWeek: true,
628
+ quotaMonth: true,
524
629
  quotaClock: true,
525
630
  },
526
631
  contextStyle: "pct-window",
@@ -750,6 +855,10 @@ export function createTokenStats(
750
855
  const m = fullDisplay.match(/\bW:\s+\d+%/);
751
856
  if (m) filteredParts.push(m[0]);
752
857
  }
858
+ if (cfg.quotaMonth) {
859
+ const m = fullDisplay.match(/\bM:\s+\d+%/);
860
+ if (m) filteredParts.push(m[0]);
861
+ }
753
862
  if (cfg.quotaClock) {
754
863
  const m = fullDisplay.match(/⏱\s*\d+[hm]/);
755
864
  if (m) filteredParts.push(m[0]);
@@ -1317,23 +1426,25 @@ export function createTokenStats(
1317
1426
  return total > 0 ? (d.sumCacheRead / total) * 100 : 0;
1318
1427
  }
1319
1428
 
1320
- function renderDaySummary(daily: DailyRecord): string {
1429
+ function renderDaySummary(daily: DailyRecord): string[] {
1321
1430
  const d = daily;
1322
1431
  const avgInput = d.count > 0 ? d.sumInput / d.count : 0;
1323
1432
  const avgOutput = d.count > 0 ? d.sumOutput / d.count : 0;
1324
1433
  const totalPrompt = d.sumInput + d.sumCacheRead + d.sumCacheWrite;
1325
1434
  const cacheHitRate = weightedCacheHitRate(d);
1326
1435
 
1327
- const lines = [
1328
- `对话次数: ${d.count}`,
1329
- `新增输入: ${formatTokens(d.sumInput)} (平均 ${formatTokens(avgInput)}/次,未命中缓存)`,
1330
- `缓存输入: ${formatTokens(d.sumCacheRead)}`,
1331
- `总输出: ${formatTokens(d.sumOutput)} (平均 ${formatTokens(avgOutput)}/次)`,
1332
- `总token: ${formatTokens(totalPrompt)} (新增 + 缓存)`,
1333
- `缓存命中率: ${cacheHitRate.toFixed(1)}%`,
1334
- `平均速率: ${(d.sumTokensPerSec / d.count).toFixed(1)} t/s`,
1335
- ];
1336
- return lines.join("\n");
1436
+ return renderTable(
1437
+ ["指标", "数值"],
1438
+ [
1439
+ ["对话次数", String(d.count)],
1440
+ ["新增输入", `${formatTokens(d.sumInput)}(平均 ${formatTokens(avgInput)}/次,未命中缓存)`],
1441
+ ["缓存输入", formatTokens(d.sumCacheRead)],
1442
+ ["总输出", `${formatTokens(d.sumOutput)}(平均 ${formatTokens(avgOutput)}/次)`],
1443
+ ["总token", `${formatTokens(totalPrompt)}(新增 + 缓存)`],
1444
+ ["缓存命中率", `${cacheHitRate.toFixed(1)}%`],
1445
+ ["平均速率", `${(d.sumTokensPerSec / d.count).toFixed(1)} t/s`],
1446
+ ],
1447
+ );
1337
1448
  }
1338
1449
 
1339
1450
  async function showStats(
@@ -1341,9 +1452,7 @@ export function createTokenStats(
1341
1452
  title: string,
1342
1453
  ctx: ExtensionContext,
1343
1454
  ) {
1344
- const theme = ctx.ui.theme;
1345
- const text = `${theme.fg("accent", theme.bold(title))}\n${theme.fg("dim", "─".repeat(42))}\n` +
1346
- lines.map((l) => theme.fg("dim", l)).join("\n");
1455
+ const text = `## ${title}\n\n${lines.join("\n")}`;
1347
1456
  pi.sendMessage({
1348
1457
  customType: "token-stats",
1349
1458
  content: text,
@@ -1369,7 +1478,7 @@ export function createTokenStats(
1369
1478
  }
1370
1479
 
1371
1480
  await showStats(
1372
- renderDaySummary(daily).split("\n"),
1481
+ renderDaySummary(daily),
1373
1482
  `Token 统计 | ${date}`,
1374
1483
  ctx,
1375
1484
  );
@@ -1393,18 +1502,18 @@ export function createTokenStats(
1393
1502
 
1394
1503
  records.sort((a, b) => a.hour - b.hour);
1395
1504
 
1396
- const lines = [
1397
- "次数 输入 输出 命中率 速率",
1398
- "─".repeat(40),
1399
- ...records.map((r) =>
1400
- `${String(r.hour).padStart(2, "0")} ` +
1401
- `${String(r.count).padStart(3)} ` +
1402
- `${formatTokens(r.sumInput).padStart(7)} ` +
1403
- `${formatTokens(r.sumOutput).padStart(7)} ` +
1404
- `${weightedCacheHitRate(r).toFixed(1).padStart(5)}% ` +
1405
- `${(r.sumTokensPerSec / r.count).toFixed(1).padStart(5)}`,
1406
- ),
1407
- ];
1505
+ const lines = renderTable(
1506
+ ["时间", "次数", "输入", "输出", "命中率", "速率"],
1507
+ records.map((r) => [
1508
+ String(r.hour).padStart(2, "0"),
1509
+ String(r.count),
1510
+ formatTokens(r.sumInput),
1511
+ formatTokens(r.sumOutput),
1512
+ `${weightedCacheHitRate(r).toFixed(1)}%`,
1513
+ `${(r.sumTokensPerSec / r.count).toFixed(1)}`,
1514
+ ]),
1515
+ { aligns: ["left", "right", "right", "right", "right", "right"] },
1516
+ );
1408
1517
 
1409
1518
  await showStats(lines, `按小时分布 | ${date}`, ctx);
1410
1519
  }
@@ -1433,23 +1542,25 @@ export function createTokenStats(
1433
1542
  return;
1434
1543
  }
1435
1544
 
1436
- const lines = [
1437
- "日期 次数 新增输入 缓存输入 输出 总token 命中率 速率",
1438
- "─".repeat(70),
1439
- ...weekRecords.map((r) => {
1545
+ const lines = renderTable(
1546
+ ["日期", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
1547
+ weekRecords.map((r) => {
1440
1548
  const totalPrompt = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
1441
- return (
1442
- `${r.date} ` +
1443
- `${String(r.count).padStart(3)} ` +
1444
- `${formatTokens(r.sumInput).padStart(7)} ` +
1445
- `${formatTokens(r.sumCacheRead).padStart(7)} ` +
1446
- `${formatTokens(r.sumOutput).padStart(7)} ` +
1447
- `${formatTokens(totalPrompt).padStart(7)} ` +
1448
- `${weightedCacheHitRate(r).toFixed(1).padStart(5)}% ` +
1449
- `${(r.sumTokensPerSec / r.count).toFixed(1).padStart(5)}`
1450
- );
1549
+ return [
1550
+ r.date,
1551
+ String(r.count),
1552
+ formatTokens(r.sumInput),
1553
+ formatTokens(r.sumCacheRead),
1554
+ formatTokens(r.sumOutput),
1555
+ formatTokens(totalPrompt),
1556
+ `${weightedCacheHitRate(r).toFixed(1)}%`,
1557
+ `${(r.sumTokensPerSec / r.count).toFixed(1)}`,
1558
+ ];
1451
1559
  }),
1452
- ];
1560
+ {
1561
+ aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
1562
+ },
1563
+ );
1453
1564
 
1454
1565
  await showStats(lines, "本周每天汇总", ctx);
1455
1566
  }
@@ -1495,40 +1606,46 @@ export function createTokenStats(
1495
1606
  const totalPrompt = total.sumInput + total.sumCacheRead + total.sumCacheWrite;
1496
1607
  const cacheHitRate = weightedCacheHitRate(total);
1497
1608
 
1498
- const lines = [
1499
- "日期 次数 新增输入 缓存输入 输出 总token 命中率 速率",
1500
- "─".repeat(70),
1501
- ...monthRecords.map((r) => {
1609
+ const lines = renderTable(
1610
+ ["日期", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
1611
+ monthRecords.map((r) => {
1502
1612
  const tp = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
1503
- return (
1504
- `${r.date} ` +
1505
- `${String(r.count).padStart(3)} ` +
1506
- `${formatTokens(r.sumInput).padStart(7)} ` +
1507
- `${formatTokens(r.sumCacheRead).padStart(7)} ` +
1508
- `${formatTokens(r.sumOutput).padStart(7)} ` +
1509
- `${formatTokens(tp).padStart(7)} ` +
1510
- `${weightedCacheHitRate(r).toFixed(1).padStart(5)}% ` +
1511
- `${(r.sumTokensPerSec / r.count).toFixed(1).padStart(5)}`
1512
- );
1613
+ return [
1614
+ r.date,
1615
+ String(r.count),
1616
+ formatTokens(r.sumInput),
1617
+ formatTokens(r.sumCacheRead),
1618
+ formatTokens(r.sumOutput),
1619
+ formatTokens(tp),
1620
+ `${weightedCacheHitRate(r).toFixed(1)}%`,
1621
+ `${(r.sumTokensPerSec / r.count).toFixed(1)}`,
1622
+ ];
1513
1623
  }),
1514
- "",
1515
- `合计 ${String(total.count).padStart(3)} ` +
1516
- `${formatTokens(total.sumInput).padStart(7)} ` +
1517
- `${formatTokens(total.sumCacheRead).padStart(7)} ` +
1518
- `${formatTokens(total.sumOutput).padStart(7)} ` +
1519
- `${formatTokens(totalPrompt).padStart(7)} ` +
1520
- `${cacheHitRate.toFixed(1).padStart(5)}% ` +
1521
- `${(total.sumTokensPerSec / total.count).toFixed(1).padStart(5)}`,
1522
- ];
1624
+ {
1625
+ aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
1626
+ totalRow: [
1627
+ "合计",
1628
+ String(total.count),
1629
+ formatTokens(total.sumInput),
1630
+ formatTokens(total.sumCacheRead),
1631
+ formatTokens(total.sumOutput),
1632
+ formatTokens(totalPrompt),
1633
+ `${cacheHitRate.toFixed(1)}%`,
1634
+ `${(total.sumTokensPerSec / total.count).toFixed(1)}`,
1635
+ ],
1636
+ },
1637
+ );
1523
1638
 
1524
1639
  await showStats(lines, `${month} 月度汇总`, ctx);
1525
1640
  }
1526
1641
 
1527
1642
  // ── 事件注册 ─────────────────────────────────────────
1528
1643
 
1529
- // ── message renderer: 渲染 /stats 发出的消息 ─────────
1530
- pi.registerMessageRenderer("token-stats", (message, _options, _theme) => {
1531
- return new Text(message.content, 0, 0);
1644
+ // ── message renderer: 渲染 /stats 发出的消息(markdown 表格)──
1645
+ pi.registerMessageRenderer("token-stats", (message, _options, theme) => {
1646
+ return new Markdown(message.content, 0, 0, getMarkdownTheme(), {
1647
+ color: (text) => theme.fg("dim", text),
1648
+ });
1532
1649
  });
1533
1650
 
1534
1651
  // ── turn_start: 记录时间 + 检测供应商切换 ──────────
@@ -1925,12 +2042,12 @@ export function createTokenStats(
1925
2042
  } else if (subChoice === "显示内容") {
1926
2043
  const itemLabels: DisplayKey[] = [
1927
2044
  "input", "output", "totalTokens", "cacheHit", "speed", "context",
1928
- "quota5h", "quotaWeek", "quotaClock",
2045
+ "quota5h", "quotaWeek", "quotaMonth", "quotaClock",
1929
2046
  ];
1930
2047
  const itemNames: Record<DisplayKey, string> = {
1931
2048
  input: "输入", output: "输出", totalTokens: "总token",
1932
2049
  cacheHit: "缓存命中", speed: "速度", context: "容量",
1933
- quota5h: "5h额度", quotaWeek: "周额度", quotaClock: "刷新时间",
2050
+ quota5h: "5h额度", quotaWeek: "周额度", quotaMonth: "月额度", quotaClock: "刷新时间",
1934
2051
  };
1935
2052
  while (true) {
1936
2053
  const options = itemLabels.map(k =>