token-stats-timer 1.0.12 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,8 +40,6 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
40
40
 
41
41
  > 为什么不用纯 osascript:macOS Sequoia 起通知按“调用进程”归属,pi 是 node 进程、无法在系统设置里授权,通知会被静默丢弃(脚本 exit 0 但没弹窗)。OSC 序列把归属改到终端 App,授权后即可正常弹。
42
42
 
43
- 每次投递结果追加到 `~/.pi/agent/extensions/token-stats-logs/notify.log`,没弹通知时先看它判断是触发问题还是投递问题。
44
-
45
43
  ### 配置
46
44
 
47
45
  配置文件:`~/.pi/agent/extensions/token-stats/notify-config.json`(不存在时用默认值)
@@ -63,7 +61,7 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
63
61
  - `sound` —— 通知声音(macOS 内置:Glass/Ping/Sosumi/Hero/Funk 等,`""` 静音;注意仅 osascript/terminal-notifier 通道有声,OSC 777 无声音参数)
64
62
  - `onSuccess/onFailure/onAbort/onSessionEnd` —— 各类通知开关
65
63
 
66
- `/notify` —— 无参查看当前状态;`on`/`off` 开关;`test` 立即发一条测试通知验证通道是否可达(先看系统通知栏有没有,没有再看 notify.log)。
64
+ `/notify` —— 无参查看当前状态;`on`/`off` 开关;`test` 立即发一条测试通知验证通道是否可达。
67
65
 
68
66
  ## 任务计时(step-timer)
69
67
 
@@ -78,16 +76,35 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
78
76
 
79
77
  ## 命令
80
78
 
81
- - `/stats` —— 无参进入套餐配置(为当前 provider 选择/关闭配额套餐)
79
+ - `/stats` —— 无参进入套餐配置(为当前 provider 选择/关闭配额套餐;选 GLM 后会继续询问是否配置团队套餐凭证)
82
80
  - `/stats day [YYYY-MM-DD]` / `hour` / `week` / `month [YYYY-MM]` —— 统计查询
83
- - `/stats config` —— 显示样式 / 显示内容 / 配额刷新时间
81
+ - `/stats config` —— 显示样式 / 显示内容 / 配额刷新时间 / GLM 团队凭证
84
82
  - `/notify [on|off|test]` —— 通知开关 / 测试(无参查看状态)
85
83
 
86
- ## 与两个原包的兼容性
84
+ ## GLM 团队套餐(Team Plan)
85
+
86
+ 个人版与团队版共用 `GET /api/monitor/usage/quota/limit`,区别在请求头:团队版需额外携带 `Bigmodel-Organization` / `Bigmodel-Project` 两个请求头并加 `?type=2`(api_key + 组织 ID + 项目 ID 三者缺一不可,仅国内站 `open.bigmodel.cn` 有团队档)。
87
+
88
+ 本插件在**组织 ID 与项目 ID 都配置**时才走团队查询,否则回退个人版查询:
89
+
90
+ - 启用 GLM 套餐后(`/stats` 选 GLM)会自动弹出团队凭证配置询问,可「✏️ 配置/修改」或「跳过」
91
+ - 随时可通过 `/stats config` → 「GLM 团队凭证」修改或清除
92
+ - 凭证保存在 `~/.pi/agent/extensions/token-stats/config.json` 的 `teamCredential` 字段:
93
+
94
+ ```json
95
+ {
96
+ "providerPlans": { "zai-coding-cn": "glm" },
97
+ "teamCredential": { "organization": "your-org-id", "project": "your-project-id" },
98
+ "ttl": 60
99
+ }
100
+ ```
101
+
102
+ > 组织 ID / 项目 ID 在 GLM Coding Plan 团队版后台「团队编程套餐」页面获取;如果你同时使用 Claude Code / Cursor 等工具并已在环境变量里配置,也可以在 config.json 里直接填上同名值。
103
+
104
+ ## 与原包的兼容性
87
105
 
88
106
  - 显示/套餐配置沿用 `~/.pi/agent/extensions/token-stats/`(原 token-stats 的配置直接生效)
89
107
  - 统计日志沿用 `~/.pi/agent/extensions/token-stats-logs/`(历史数据 `/stats` 可直接查询)
90
- - run 计时状态沿用 session 内 `run-timer-state` 自定义条目(`/reload` 后自动恢复,兼容旧 `turn-timer-state`)
91
108
 
92
109
  ## 安装(替换原包)
93
110
 
package/notify.ts CHANGED
@@ -41,8 +41,6 @@ import { join } from "node:path";
41
41
 
42
42
  const CONFIG_DIR = join(homedir(), ".pi/agent/extensions/token-stats");
43
43
  const CONFIG_FILE = join(CONFIG_DIR, "notify-config.json");
44
- const LOG_DIR = join(homedir(), ".pi/agent/extensions/token-stats-logs");
45
- const NOTIFY_LOG = join(LOG_DIR, "notify.log");
46
44
 
47
45
  /** 支持 OSC 777 终端协议的应用(按 TERM_PROGRAM 匹配) */
48
46
  const OSC777_TERMINALS = new Set([
@@ -91,19 +89,6 @@ function saveConfig(cfg: NotifyConfig): void {
91
89
  /** 连续两次通知的最短间隔(毫秒),防止并行 agent 结束时刷屏 */
92
90
  const DEBOUNCE_MS = 3000;
93
91
 
94
- function logLine(channel: string, title: string, ok: boolean, detail = ""): void {
95
- try {
96
- mkdirSync(LOG_DIR, { recursive: true });
97
- appendFileSync(
98
- NOTIFY_LOG,
99
- `${new Date().toISOString()} [${channel}] ${ok ? "OK" : "FAIL"} ${JSON.stringify(title)}${detail ? ` ${detail}` : ""}\n`,
100
- "utf-8",
101
- );
102
- } catch {
103
- // 日志失败不影响通知本身
104
- }
105
- }
106
-
107
92
  function formatDuration(ms: number): string {
108
93
  const totalSeconds = Math.max(0, Math.floor(ms / 1000));
109
94
  const hours = Math.floor(totalSeconds / 3600);
@@ -173,7 +158,7 @@ function notifyTerminalNotifier(title: string, message: string, sound: string):
173
158
  }
174
159
  }
175
160
 
176
- /** 通道3:osascript 保底(不支持 OSC 777 的终端);失败信息写日志供排查 */
161
+ /** 通道3:osascript 保底(不支持 OSC 777 的终端) */
177
162
  function notifyOSAScript(title: string, message: string, sound: string): void {
178
163
  const soundPart = sound ? ` sound name ${JSON.stringify(sound)}` : "";
179
164
  const script = `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)}${soundPart}`;
@@ -181,14 +166,6 @@ function notifyOSAScript(title: string, message: string, sound: string): void {
181
166
  detached: true,
182
167
  stdio: ["ignore", "ignore", "pipe"],
183
168
  });
184
- let err = "";
185
- child.stderr?.on("data", (d) => {
186
- err += d.toString();
187
- });
188
- child.on("error", (e) => logLine("osascript", title, false, e.message));
189
- child.on("exit", (code) => {
190
- if (code !== 0) logLine("osascript", title, false, err.trim() || `exit ${code}`);
191
- });
192
169
  child.unref();
193
170
  }
194
171
 
@@ -213,19 +190,16 @@ export function createNotifier(pi: ExtensionAPI): void {
213
190
  if (isITerm2()) {
214
191
  // iTerm2:官方文档通知序列 OSC 9(实测有效)
215
192
  notifyOSC9(title, message);
216
- logLine("osc9", title, true);
217
193
  return;
218
194
  }
219
195
 
220
196
  if (supportsOSC777()) {
221
197
  // Ghostty / WezTerm / Hyper / rxvt:OSC 777
222
198
  notifyOSC777(title, message);
223
- logLine("osc777", title, true);
224
199
  return;
225
200
  }
226
201
 
227
202
  if (notifyTerminalNotifier(title, message, sound)) {
228
- logLine("terminal-notifier", title, true);
229
203
  return;
230
204
  }
231
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-stats-timer",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "pi extension to display token usage and time spend.",
5
5
  "keywords": [
6
6
  "pi-package",
package/token-stats.ts CHANGED
@@ -100,6 +100,17 @@ interface DailyRecord {
100
100
 
101
101
  // ── 套餐用量类型 ──────────────────────────────────────────
102
102
 
103
+ /** GLM / 智谱团队套餐凭证:组织 ID + 项目 ID(二者齐全才走团队查询) */
104
+ interface TeamCredential {
105
+ organization: string;
106
+ project: string;
107
+ }
108
+
109
+ /** fetchQuota 额外参数(目前仅 GLM 团队版使用) */
110
+ interface QuotaFetchExtra {
111
+ team?: TeamCredential | null;
112
+ }
113
+
103
114
  interface TokenPlan {
104
115
  id: string;
105
116
  name: string;
@@ -108,12 +119,14 @@ interface TokenPlan {
108
119
  baseUrl: string;
109
120
  quotaPath: string;
110
121
  authHeader: (key: string) => Record<string, string>;
111
- fetchQuota: (plan: TokenPlan, key: string) => Promise<any>;
122
+ fetchQuota: (plan: TokenPlan, key: string, extra?: QuotaFetchExtra) => Promise<any>;
112
123
  format: (data: any) => { modelPrefix: string; display: string; color: 'ok' | 'warn' | 'err' };
113
124
  }
114
125
 
115
126
  interface TokenConfig {
116
127
  providerPlans: Record<string, string | null>;
128
+ /** GLM 团队套餐凭证(可选),经 /stats 菜单配置并持久化 */
129
+ teamCredential?: { organization: string; project: string };
117
130
  ttl: number;
118
131
  }
119
132
 
@@ -334,15 +347,27 @@ const BUILTIN_PLANS: TokenPlan[] = [
334
347
  {
335
348
  id: "glm",
336
349
  name: "GLM (智谱)",
337
- matchProviders: ["zhipu-cn", "zhipu", "glm", "bigmodel"],
350
+ matchProviders: ["zhipu-cn", "zhipu", "glm", "bigmodel", "zai-coding-cn"],
338
351
  apiKeyEnv: "GLM_API_KEY",
339
352
  baseUrl: "https://open.bigmodel.cn",
340
353
  quotaPath: "/api/monitor/usage/quota/limit",
341
354
  authHeader: (key) => ({ Authorization: key }),
342
- fetchQuota: async (plan: TokenPlan, key: string) => {
343
- const r = await fetch(plan.baseUrl + plan.quotaPath, {
355
+ fetchQuota: async (plan: TokenPlan, key: string, extra?: QuotaFetchExtra) => {
356
+ const team = extra?.team;
357
+ const headers: Record<string, string> = {
358
+ ...plan.authHeader(key),
359
+ "Content-Type": "application/json",
360
+ };
361
+ // 团队套餐:同一 quota 端点加 ?type=2,并携带组织/项目 ID 请求头
362
+ // (api_key + 组织 ID + 项目 ID 三者缺一不可,仅国内站 open.bigmodel.cn 有团队版)
363
+ if (team) {
364
+ headers["Bigmodel-Organization"] = team.organization;
365
+ headers["Bigmodel-Project"] = team.project;
366
+ }
367
+ const url = plan.baseUrl + plan.quotaPath + (team ? "?type=2" : "");
368
+ const r = await fetch(url, {
344
369
  method: "GET",
345
- headers: { ...plan.authHeader(key), "Content-Type": "application/json" },
370
+ headers,
346
371
  signal: AbortSignal.timeout(5000),
347
372
  });
348
373
  if (!r.ok) throw new Error("GLM 配额查询 HTTP " + r.status);
@@ -350,23 +375,56 @@ const BUILTIN_PLANS: TokenPlan[] = [
350
375
  },
351
376
  format: (data: any) => {
352
377
  const limits = data?.data?.limits || [];
353
- const tokenLimits = limits.filter((x: any) => (x.type || "").toLowerCase() === "tokens_limit");
354
- if (tokenLimits.length === 0) return { modelPrefix: "", display: "无数据", color: "err" as const };
355
- let fiveHour = tokenLimits[0];
356
- let weekly = tokenLimits[1];
357
- if (fiveHour?.unit === 6) [fiveHour, weekly] = [weekly, fiveHour];
358
- const intervalRemaining = 100 - (fiveHour?.percentage ?? 0);
359
- const weeklyRemaining = 100 - (weekly?.percentage ?? 0);
378
+ // 个人版返回 TOKENS_LIMIT,团队版返回 CREDIT_LIMIT(大小写不敏感)必识
379
+ const isQuota = (t: any) => {
380
+ const s = String(t ?? "").toLowerCase();
381
+ return s === "tokens_limit" || s === "credit_limit";
382
+ };
383
+ const entries = limits.filter((x: any) => isQuota(x?.type));
384
+ if (entries.length === 0) return { modelPrefix: "", display: "无数据", color: "err" as const };
385
+
386
+ // 窗口分类锚定 unit(不依赖数组顺序,与 cc-switch parse_zhipu_token_tiers 一致):
387
+ // unit: 3 → 5h 滚动窗口;unit: 6 → 每周窗口(老/新套餐 number 7 / 1 均可能)
388
+ const byUnit = (u: number) => entries.find((x: any) => x?.unit === u);
389
+ let fiveHour: any = byUnit(3) ?? null;
390
+ let weekly: any = byUnit(6) ?? null;
391
+ // 兜底:unit 缺失/不识别 → 无 nextResetTime 优先归 5h,其余按重置时间升序补槽
392
+ if (!fiveHour || !weekly) {
393
+ const unclassified = entries
394
+ .filter((x: any) => x !== fiveHour && x !== weekly)
395
+ .sort((a: any, b: any) =>
396
+ (typeof a?.nextResetTime === "number" ? a.nextResetTime : Number.MIN_SAFE_INTEGER) -
397
+ (typeof b?.nextResetTime === "number" ? b.nextResetTime : Number.MIN_SAFE_INTEGER));
398
+ for (const e of unclassified) {
399
+ if (!fiveHour) fiveHour = e;
400
+ else if (!weekly) weekly = e;
401
+ }
402
+ }
403
+
404
+ // percentage 为已用比例 → 剩余 = 100 - 已用
405
+ const intervalRemaining = fiveHour ? 100 - (fiveHour.percentage ?? 0) : null;
406
+ const weeklyRemaining = weekly ? 100 - (weekly.percentage ?? 0) : null;
360
407
  const now = Date.now();
361
- const resets = tokenLimits
362
- .map((x: any) => x.nextResetTime)
408
+ const resets = entries
409
+ .map((x: any) => x?.nextResetTime)
363
410
  .filter((t: any) => typeof t === "number" && t > now);
364
411
  const nearestReset = resets.length > 0 ? Math.min(...resets) : null;
365
- return {
366
- modelPrefix: "",
367
- display: formatTokenPlanDisplay(intervalRemaining, weeklyRemaining, nearestReset),
368
- color: intervalRemaining < 20 || weeklyRemaining < 20 ? "err" as const : intervalRemaining < 50 || weeklyRemaining < 50 ? "warn" as const : "ok" as const,
369
- };
412
+
413
+ const both = intervalRemaining !== null && weeklyRemaining !== null;
414
+ const display =
415
+ intervalRemaining !== null && weeklyRemaining !== null
416
+ ? formatTokenPlanDisplay(intervalRemaining, weeklyRemaining, nearestReset)
417
+ : intervalRemaining !== null
418
+ ? `5h: ${Math.round(intervalRemaining)}%`
419
+ : weeklyRemaining !== null
420
+ ? `W: ${Math.round(weeklyRemaining)}%`
421
+ : "无数据";
422
+ const low = (v: number | null) => v !== null && v < 20;
423
+ const mid = (v: number | null) => v !== null && v < 50;
424
+ const color = low(intervalRemaining) || low(weeklyRemaining) ? "err" as const
425
+ : mid(intervalRemaining) || mid(weeklyRemaining) ? "warn" as const
426
+ : "ok" as const;
427
+ return { modelPrefix: "", display, color };
370
428
  },
371
429
  },
372
430
  {
@@ -1017,6 +1075,20 @@ export function createTokenStats(
1017
1075
  return null;
1018
1076
  }
1019
1077
 
1078
+ /**
1079
+ * 解析套餐的团队凭证(当前仅 GLM/智谱团队版)。
1080
+ * 组织 ID + 项目 ID 二者齐全才返回,否则返回 null(回退个人版查询)。
1081
+ * 来源:token-stats config.json 的 teamCredential(/stats 菜单配置)。
1082
+ */
1083
+ function resolveTeamCredential(plan: TokenPlan): TeamCredential | null {
1084
+ if (plan.id !== "glm") return null;
1085
+ const tc = tokenConfig?.teamCredential;
1086
+ const organization = tc?.organization?.trim() ?? "";
1087
+ const project = tc?.project?.trim() ?? "";
1088
+ if (organization && project) return { organization, project };
1089
+ return null;
1090
+ }
1091
+
1020
1092
  /**
1021
1093
  * 检测并处理 provider 变化。
1022
1094
  * 返回 true 表示发生了切换(供调用者决定是否要 force refresh)。
@@ -1138,7 +1210,11 @@ export function createTokenStats(
1138
1210
 
1139
1211
  // 5. 调接口
1140
1212
  try {
1141
- const data = await plan.fetchQuota(plan, key);
1213
+ const data = await plan.fetchQuota(
1214
+ plan,
1215
+ key,
1216
+ { team: resolveTeamCredential(plan) },
1217
+ );
1142
1218
  cache[plan.id] = { fetchedAt: Date.now(), ttl: ttlMs, data };
1143
1219
  await writeQuotaCache(cache);
1144
1220
  const fmt = plan.format(data);
@@ -1173,6 +1249,58 @@ export function createTokenStats(
1173
1249
  shared.requestRender?.();
1174
1250
  }
1175
1251
 
1252
+ /** config.json 无参流里的基础结构(避免 null 展开报错) */
1253
+ function baseTokenConfig(): TokenConfig {
1254
+ return { ...(tokenConfig ?? { providerPlans: {}, ttl: 60 }) };
1255
+ }
1256
+
1257
+ /**
1258
+ * GLM 团队套餐交互配置:提示用户填写组织 ID / 项目 ID 并持久化到 config.json,
1259
+ * 保存后 force 刷新使团队查询(?type=2)立即生效,并反馈查询结果。
1260
+ */
1261
+ async function promptGlmTeamConfig(ctx: ExtensionContext): Promise<void> {
1262
+ const cur = tokenConfig?.teamCredential;
1263
+ const curLabel =
1264
+ cur?.organization && cur?.project
1265
+ ? `${cur.organization} / ${cur.project}`
1266
+ : "未配置(按个人版查询)";
1267
+ const choice = await ctx.ui.select(
1268
+ `GLM 团队套餐凭证?当前:${curLabel}\n填写组织 ID + 项目 ID(二者齐全才走团队查询 ?type=2),跳过则维持个人版查询`,
1269
+ ["✏️ 配置/修改", "跳过"],
1270
+ );
1271
+ if (!choice || choice === "跳过") {
1272
+ await forceRefreshQuota(ctx);
1273
+ const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1274
+ ctx.ui.notify(
1275
+ quotaState?.error ? `GLM 配额查询失败:${errMsg}` : "GLM 配额已启用(个人版查询)",
1276
+ "info",
1277
+ );
1278
+ return;
1279
+ }
1280
+
1281
+ const organization = await ctx.ui.input("组织 ID (Organization)", cur?.organization ?? "");
1282
+ const project = await ctx.ui.input("项目 ID (Project)", cur?.project ?? "");
1283
+ const org = organization?.trim() ?? "";
1284
+ const proj = project?.trim() ?? "";
1285
+ if (!org || !proj) {
1286
+ ctx.ui.notify("组织/项目 ID 不能为空,团队凭证未保存", "warning");
1287
+ return;
1288
+ }
1289
+
1290
+ tokenConfig = {
1291
+ ...baseTokenConfig(),
1292
+ teamCredential: { organization: org, project: proj },
1293
+ };
1294
+ await saveTokenConfig(tokenConfig);
1295
+
1296
+ await forceRefreshQuota(ctx);
1297
+ const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1298
+ ctx.ui.notify(
1299
+ quotaState?.error ? `GLM 团队配额查询失败:${errMsg}` : "GLM 团队套餐配额已启用",
1300
+ "info",
1301
+ );
1302
+ }
1303
+
1176
1304
  /** 清空所有套餐缓存(session_start 调,避免跨 session 复用旧数据) */
1177
1305
  async function invalidateAllQuotaCache() {
1178
1306
  try {
@@ -1668,6 +1796,11 @@ export function createTokenStats(
1668
1796
  } catch { /* ctx 已失效(session 被替换),忽略 */ }
1669
1797
  shared.requestRender?.();
1670
1798
  }, (tokenConfig?.ttl || 60) * 1000);
1799
+ if (plan.id === "glm") {
1800
+ // GLM:继续询问团队套餐凭证(个人版/团队版二选一,内部会 force 查询并反馈)
1801
+ await promptGlmTeamConfig(ctx);
1802
+ return;
1803
+ }
1671
1804
  if (quotaState?.error) {
1672
1805
  // 仅当 quotaState 带有 error 字段时(key 缺失 / API 错误 / 网络错误 / 无数据)才提示"查询失败"
1673
1806
  const errMsg = formatQuotaError(quotaState);
@@ -1684,9 +1817,55 @@ export function createTokenStats(
1684
1817
  "显示样式",
1685
1818
  "显示内容",
1686
1819
  "刷新时间 (当前 " + (tokenConfig?.ttl || 60) + "s)",
1820
+ "GLM 团队凭证",
1687
1821
  ]);
1688
1822
  if (!subChoice) return;
1689
1823
 
1824
+ if (subChoice === "GLM 团队凭证") {
1825
+ const cur = tokenConfig?.teamCredential;
1826
+ const label =
1827
+ cur?.organization && cur?.project
1828
+ ? `${cur.organization} / ${cur.project}`
1829
+ : "未配置";
1830
+ const action = await ctx.ui.select("GLM 团队凭证(当前: " + label + ")", [
1831
+ "✏️ 配置/修改",
1832
+ "清除",
1833
+ "返回",
1834
+ ]);
1835
+ if (!action || action === "返回") return;
1836
+ if (action === "清除") {
1837
+ tokenConfig = {
1838
+ ...baseTokenConfig(),
1839
+ teamCredential: { organization: "", project: "" },
1840
+ };
1841
+ await saveTokenConfig(tokenConfig);
1842
+ await forceRefreshQuota(ctx);
1843
+ ctx.ui.notify("GLM 团队凭证已清除(恢复个人版查询)", "info");
1844
+ } else {
1845
+ const organization = await ctx.ui.input("组织 ID (Organization)", cur?.organization ?? "");
1846
+ const project = await ctx.ui.input("项目 ID (Project)", cur?.project ?? "");
1847
+ const org = organization?.trim() ?? "";
1848
+ const proj = project?.trim() ?? "";
1849
+ if (!org || !proj) {
1850
+ ctx.ui.notify("组织/项目 ID 不能为空,未保存", "warning");
1851
+ return;
1852
+ }
1853
+ tokenConfig = {
1854
+ ...baseTokenConfig(),
1855
+ teamCredential: { organization: org, project: proj },
1856
+ };
1857
+ await saveTokenConfig(tokenConfig);
1858
+ await forceRefreshQuota(ctx);
1859
+ const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1860
+ if (quotaState?.error) {
1861
+ ctx.ui.notify(`GLM 团队配额查询失败:${errMsg}`, "info");
1862
+ } else {
1863
+ ctx.ui.notify("GLM 团队凭证已保存(团队查询已生效)", "info");
1864
+ }
1865
+ }
1866
+ return;
1867
+ }
1868
+
1690
1869
  if (subChoice === "显示样式") {
1691
1870
  const catChoice = await ctx.ui.select("选择要配置的样式类别", [
1692
1871
  "上下文样式",