@zxh126/pi-qqbot 1.3.1 → 1.3.3

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
@@ -117,12 +117,13 @@ Some extension commands never print into the conversation — they call `ctx.ui.
117
117
 
118
118
  | Command | What happens |
119
119
  |---|---|
120
- | `/usage [range]`, `/cost [range]` | re-dispatched as `/qq usage <range>`: the bridge runs the original `/usage` command, then reads the fresh `customType: "usage-report"` entry from the session and renders it as text (requests, tokens, cache ratio, cost, per-model). **Usage unchanged** — keep sending `/usage today`. |
120
+ | `/usage [range]`, `/cost [range]` | re-dispatched as `/qq usage <range>`: the bridge runs the original `/usage` command, then **polls** for the fresh `customType: "usage-report"` entry for up to 2 s (the append lands ~100 ms *after* `sendUserMessage()` resolves, so a single read always misses it), falling back to the tail of the session file, and renders it as text (requests, tokens, cache ratio, cost, per-model). **Usage unchanged** — keep sending `/usage today`. |
121
+ | `/usage rate <value>`, `/usage rate auto` | really executed (they write `~/.pi/agent/pi-usage-report.json` / refresh the cached rate); the bridge then reads those files back and reports the resulting rate, so an invalid value shows up as "unchanged" |
121
122
  | `/mcp`, `/llama` | answered: they need host-TUI selection dialogs / browser login, so nothing is dispatched |
122
123
 
123
- `/usage rate ...` and `/usage help` change settings or print help on the TUI; the bridge tells you
124
- that instead of silently doing nothing. The `usage-report` entry type is the only coupling point with
125
- [`pi-usage-report`](https://gitee.com/zxh126/pi-usage-report).
124
+ A bare `/usage rate` and `/usage help` are answered locally, without running the command. Coupling with
125
+ [`pi-usage-report`](https://gitee.com/zxh126/pi-usage-report) is limited to the `usage-report` entry type
126
+ plus the two files it keeps in `~/.pi/agent/`.
126
127
 
127
128
  Guards:
128
129
 
@@ -115,11 +115,11 @@ TUI/RPC 前端。在 QQ 里的表现就是「已派发,然后没下文」。
115
115
 
116
116
  | 命令 | 现在发生什么 |
117
117
  |---|---|
118
- | `/usage [范围]`、`/cost [范围]` | 改派为 `/qq usage <范围>`:桥接先重跑原 `/usage` 命令,再从 session 里读出新的 `customType: "usage-report"` 条目,渲染成文本回 QQ(请求数/总 token/缓存比/费用/按模型)。**用法不变**,照旧发 `/usage today` |
118
+ | `/usage [范围]`、`/cost [范围]` | 改派为 `/qq usage <范围>`:桥接先重跑原 `/usage` 命令,再**轮询等待**本次新写入的 `customType: "usage-report"` 条目(`appendEntry` 比 `sendUserMessage()` 返回晚约 100 ms,只读一次必然落空),最多等 2 秒,仍未命中才退回读会话文件尾部;然后渲染成文本回 QQ(请求数/总 token/缓存比/费用/按模型)。**用法不变**,照旧发 `/usage today` |
119
119
  | `/mcp`、`/llama` | 直接回执:它们需要主机 TUI 的选择框 / 浏览器登录,不会派发 |
120
- | `/usage rate <值>`、`/usage rate auto` | 会真的执行(写 `~/.pi/agent/pi-usage-report.json` / 刷新汇率缓存),桥接再读回该文件确认新汇率;只发 `/usage rate` 与 `/usage help` 则本地直接回执 |
120
+ | `/usage rate <值>`、`/usage rate auto` | 会真的执行(写 `~/.pi/agent/pi-usage-report.json` / 刷新汇率缓存),桥接再读回文件确认新汇率,无效值会体现在“汇率未变”上;只发 `/usage rate` 与 `/usage help` 则本地直接回执,不执行命令 |
121
121
 
122
- 桥接与 [`pi-usage-report`](https://gitee.com/zxh126/pi-usage-report) 的耦合只有两处:`usage-report` 这个
122
+ 桥接与 [`pi-usage-report`](https://gitee.com/zxh126/pi-usage-report) 的耦合只有三处:`usage-report` 这个
123
123
  custom entry 类型名,以及它写在 `~/.pi/agent/` 下的两个配置文件。
124
124
 
125
125
  约束:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "type": "module",
5
5
  "description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
6
6
  "license": "MIT",
package/slash.ts CHANGED
@@ -382,6 +382,13 @@ export function pickerExpired(picker: PickerState | null): boolean {
382
382
  /** pi-usage-report 写入 session 的 custom entry 类型(耦合点,约定值) */
383
383
  const USAGE_ENTRY_TYPE = "usage-report";
384
384
 
385
+ /**
386
+ * appendEntry 的写入比 `sendUserMessage` 的 resolve 晚(实测 promise 183ms 返回,
387
+ * entry 在之后 ~100ms 才出现在 getEntries 里),所以必须轮询而不是只读一次。
388
+ */
389
+ const USAGE_WAIT_TIMEOUT_MS = 2000;
390
+ const USAGE_POLL_INTERVAL_MS = 100;
391
+
385
392
  /** pi-usage-report 的配置/汇率缓存(读回来确认:命令真的生效了,而不是口头应下) */
386
393
  const USAGE_SETTINGS_FILE = "pi-usage-report.json";
387
394
  const USAGE_RATE_FILE = "pi-usage-report-rate.json";
@@ -435,6 +442,64 @@ function sessionEntries(ctx: any): any[] {
435
442
  }
436
443
  }
437
444
 
445
+ function sessionFile(ctx: any): string | undefined {
446
+ try {
447
+ return ctx?.sessionManager?.getSessionFile?.() ?? undefined;
448
+ } catch {
449
+ return undefined;
450
+ }
451
+ }
452
+
453
+ /** 兼容三种时间形态:毫秒数字(pi.appendEntry 实际写的就是它)/ ISO 字符串 / Date */
454
+ function entryTime(value: unknown): number {
455
+ if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
456
+ if (value instanceof Date) return value.getTime();
457
+ if (typeof value === "string" && value) return Date.parse(value);
458
+ return NaN;
459
+ }
460
+
461
+ /**
462
+ * 从会话文件尾部找最近一条 usage-report(generatedAt 不早于 since)。
463
+ *
464
+ * 为什么需要这条路:appendEntry 是**另一个扩展**写的,命令上下文里的
465
+ * `sessionManager.getEntries()` 不一定即时反映(实测 `/usage today` 已经写入了
466
+ * 会话文件,但 getEntries 看不到新增项)。数据库不会骗人,文件会。
467
+ */
468
+ function readUsageEntryFromFile(file: string | undefined, since: number): any | undefined {
469
+ if (!file) return undefined;
470
+ try {
471
+ const size = fs.statSync(file).size;
472
+ const chunk = Math.min(size, 512 * 1024);
473
+ if (chunk <= 0) return undefined;
474
+ const fd = fs.openSync(file, "r");
475
+ let text = "";
476
+ try {
477
+ const buf = Buffer.alloc(chunk);
478
+ fs.readSync(fd, buf, 0, chunk, size - chunk);
479
+ text = buf.toString("utf8");
480
+ } finally {
481
+ fs.closeSync(fd);
482
+ }
483
+ const lines = text.split("\n");
484
+ for (let i = lines.length - 1; i >= 0; i--) {
485
+ const line = lines[i].trim();
486
+ if (!line.startsWith("{")) continue;
487
+ let entry: any;
488
+ try {
489
+ entry = JSON.parse(line);
490
+ } catch {
491
+ continue;
492
+ }
493
+ if (!isUsageEntry(entry)) continue;
494
+ const when = entryTime(entry?.data?.generatedAt ?? entry?.timestamp);
495
+ return Number.isFinite(when) && when >= since ? entry : undefined;
496
+ }
497
+ } catch {
498
+ return undefined;
499
+ }
500
+ return undefined;
501
+ }
502
+
438
503
  const intText = (value: unknown): string =>
439
504
  typeof value === "number" && Number.isFinite(value) ? Math.round(value).toLocaleString("en-US") : "0";
440
505
 
@@ -446,6 +511,28 @@ function localStamp(value: unknown): string {
446
511
  return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
447
512
  }
448
513
 
514
+ const isUsageEntry = (entry: any): boolean =>
515
+ entry?.type === "custom" && entry?.customType === USAGE_ENTRY_TYPE;
516
+
517
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
518
+
519
+ /**
520
+ * 等本次 /usage 写的报告出现:每 100ms 查一次 getEntries(最多 2s),
521
+ * 全程未命中再试一次会话文件(custom entry 可能很晚才落盘)。
522
+ */
523
+ async function waitForUsageEntry(
524
+ ctx: any,
525
+ opts: { before: Set<unknown>; since: number; file?: string },
526
+ ): Promise<any | undefined> {
527
+ const deadline = Date.now() + USAGE_WAIT_TIMEOUT_MS;
528
+ while (Date.now() < deadline) {
529
+ const fresh = [...sessionEntries(ctx)].reverse().find((entry: any) => isUsageEntry(entry) && !opts.before.has(entry?.id));
530
+ if (fresh) return fresh;
531
+ await sleep(USAGE_POLL_INTERVAL_MS);
532
+ }
533
+ return readUsageEntryFromFile(opts.file, opts.since);
534
+ }
535
+
449
536
  /** 把 pi-usage-report 的 ReportData 渲染成 QQ 可读的纯文本 */
450
537
  function formatUsage(data: any): string {
451
538
  if (!data?.totals) return "用量报告为空。";
@@ -619,7 +706,9 @@ export async function runBridge(
619
706
  }
620
707
 
621
708
  const command = `/usage${arg ? ` ${arg}` : ""}`;
709
+ const since = Date.now();
622
710
  const before = new Set(sessionEntries(ctx).map((entry: any) => entry?.id));
711
+ const file = sessionFile(ctx);
623
712
  reply(`正在统计用量(${command})…`);
624
713
  try {
625
714
  await pi.sendUserMessage(command, { expandPromptTemplates: true });
@@ -627,11 +716,10 @@ export async function runBridge(
627
716
  reply(`统计失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
628
717
  return;
629
718
  }
630
- const entry = [...sessionEntries(ctx)]
631
- .reverse()
632
- .find((e: any) => e?.type === "custom" && e?.customType === USAGE_ENTRY_TYPE && !before.has(e?.id));
719
+ // 先轮询 getEntries 的增量(appendEntry 晚于 await 返回),超时再退回读会话文件
720
+ const entry = await waitForUsageEntry(ctx, { before, since, file });
633
721
  if (!entry) {
634
- reply("没拿到用量报告(命令没有产出数据)。");
722
+ reply("没拿到用量报告:/usage 已执行但找不到报告数据(可重试一次;若反复出现请把 pi-qqbot.log 尾部发我)。");
635
723
  return;
636
724
  }
637
725
  reply(formatUsage(entry.data));