@zxh126/pi-qqbot 1.2.0 → 1.3.0

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
@@ -84,6 +84,7 @@ Any message that starts with `/` is routed before it can become a chat prompt:
84
84
 
85
85
  | # | Kind | Behaviour |
86
86
  |---|---|---|
87
+ | 0 | extension commands whose output only goes to `ctx.ui.*` (`/usage`, `/cost`, `/mcp`, `/llama`) | `/usage` and `/cost` are **bridged** (see below) so the report comes back to QQ; `/mcp` and `/llama` need host-TUI dialogs and are answered with an explanation instead of being silently swallowed |
87
88
  | 1 | pi **extension commands**, prompt templates, `/skill:name` | dispatched to pi with `expandPromptTemplates: true`, so things like `/usage`, `/cost` or your own commands work from QQ |
88
89
  | 2 | pi built-ins this bridge can take over | re-implemented through the extension API (table below) |
89
90
  | 3 | pi built-ins that only exist in the TUI | answered with "run it on the host", never sent to the model |
@@ -108,6 +109,21 @@ Host-only (answered, never forwarded to the model): `/settings`, `/hotkeys`, `/c
108
109
  `/trust`, `/login`, `/logout`, `/scoped-models`, `/llama`, `/import`, `/export`, `/share`, `/bug`,
109
110
  `/copy`, `/privacy`.
110
111
 
112
+ ### TUI-only commands come back to QQ
113
+
114
+ Some extension commands never print into the conversation — they call `ctx.ui.notify()` (and
115
+ `pi.appendEntry()`), which only renders on the host TUI/RPC front end. In QQ that showed up as
116
+ "dispatched, then silence". The bridge closes that hole:
117
+
118
+ | Command | What happens |
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`. |
121
+ | `/mcp`, `/llama` | answered: they need host-TUI selection dialogs / browser login, so nothing is dispatched |
122
+
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).
126
+
111
127
  Guards:
112
128
 
113
129
  - `/reload`, `/new`, `/compact`, `/quit`, `/tree`, `/fork`, `/clone` and `/resume` refuse to run while a task
@@ -221,7 +237,7 @@ The QQ gateway keeps only one active connection per bot. The extension uses a `p
221
237
 
222
238
  ## Logs
223
239
 
224
- Debug logs are written to `~/.pi/agent/pi-qqbot.log`.
240
+ Debug logs are written to `~/.pi/agent/pi-qqbot.log`. Each line starts with a **local** timestamp that carries the UTC offset, e.g. `[2026-10-24T07:39:09.762+08:00]`, so it lines up with file mtimes and process start times without mental arithmetic.
225
241
 
226
242
  ## License
227
243
 
@@ -84,6 +84,7 @@ pi install ./pi-qqbot
84
84
 
85
85
  | # | 类型 | 行为 |
86
86
  |---|---|---|
87
+ | 0 | 输出只走 `ctx.ui.*` 的扩展命令(`/usage`、`/cost`、`/mcp`、`/llama`) | `/usage`、`/cost` 会**桥接**(见下)把报告发回 QQ;`/mcp`、`/llama` 需要主机 TUI 的选择框/登录,直接回执说明,不再静默 |
87
88
  | 1 | pi 的**扩展命令**、prompt 模板、`/skill:name` | 以 `expandPromptTemplates: true` 派发给 pi,所以 `/usage`、`/cost` 或你自己注册的命令在 QQ 里也能用 |
88
89
  | 2 | 本桥接能接管的 pi 内置命令 | 用扩展 API 重新实现(见下表) |
89
90
  | 3 | 只能在 TUI 里跑的 pi 内置命令 | 回执「请在主机执行」,**不会**送进模型 |
@@ -107,6 +108,20 @@ pi install ./pi-qqbot
107
108
  只能在主机执行(回执,不转发给模型):`/settings`、`/hotkeys`、`/changelog`、`/trust`、`/login`、
108
109
  `/logout`、`/scoped-models`、`/llama`、`/import`、`/export`、`/share`、`/bug`、`/copy`、`/privacy`。
109
110
 
111
+ ### 只往 TUI 打字的命令,现在会回到 QQ
112
+
113
+ 有些扩展命令从不往对话里输出,而是调 `ctx.ui.notify()`(配合 `pi.appendEntry()`)——只渲染在主机
114
+ TUI/RPC 前端。在 QQ 里的表现就是「已派发,然后没下文」。桥接把这个洞堵上:
115
+
116
+ | 命令 | 现在发生什么 |
117
+ |---|---|
118
+ | `/usage [范围]`、`/cost [范围]` | 改派为 `/qq usage <范围>`:桥接先重跑原 `/usage` 命令,再从 session 里读出新的 `customType: "usage-report"` 条目,渲染成文本回 QQ(请求数/总 token/缓存比/费用/按模型)。**用法不变**,照旧发 `/usage today` |
119
+ | `/mcp`、`/llama` | 直接回执:它们需要主机 TUI 的选择框 / 浏览器登录,不会派发 |
120
+
121
+ `/usage rate ...` 与 `/usage help` 会改汇率或打印帮助(只在 TUI 可见),桥接会明确告知。
122
+ 桥接与 [`pi-usage-report`](https://gitee.com/zxh126/pi-usage-report) 的唯一耦合是 `usage-report` 这个
123
+ custom entry 类型名。
124
+
110
125
  约束:
111
126
 
112
127
  - 有任务在跑时,`/reload`、`/new`、`/compact`、`/quit`、`/tree`、`/fork`、`/clone`、`/resume` 会拒绝执行
@@ -211,7 +226,7 @@ QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile`
211
226
 
212
227
  ## 日志
213
228
 
214
- 调试日志写入 `~/.pi/agent/pi-qqbot.log`。
229
+ 调试日志写入 `~/.pi/agent/pi-qqbot.log`。每行开头是**本地时间 + 时区偏移**(如 `[2026-10-24T07:39:09.762+08:00]`),和文件 mtime、进程启动时间直接对得上,不需要再拿 UTC 换算。
215
230
 
216
231
  ## License
217
232
 
package/logger.ts CHANGED
@@ -4,12 +4,28 @@ import path from "node:path";
4
4
 
5
5
  const LOG_PATH = path.join(os.homedir(), ".pi", "agent", "pi-qqbot.log");
6
6
 
7
- function ts() {
8
- return new Date().toISOString();
7
+ const pad = (value: number, width = 2): string =>
8
+ String(Math.trunc(Math.abs(value))).padStart(width, "0");
9
+
10
+ /**
11
+ * 本地时间戳 + 时区偏移,例如 `2026-10-24T07:39:09.762+08:00`。
12
+ *
13
+ * 以前用 `toISOString()`(UTC),看日志要和文件 mtime、进程启动时间(都是本地)
14
+ * 来回换算,容易对不上;北京时间直接读即可,同时又保留偏移,跨时区不会歧义。
15
+ */
16
+ export function timestamp(date = new Date()): string {
17
+ const offsetMinutes = -date.getTimezoneOffset(); // 东八区 = +480
18
+ const sign = offsetMinutes >= 0 ? "+" : "-";
19
+ const offset = `${sign}${pad(offsetMinutes / 60)}:${pad(offsetMinutes % 60)}`;
20
+ const local =
21
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
22
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
23
+ `.${pad(date.getMilliseconds(), 3)}`;
24
+ return `${local}${offset}`;
9
25
  }
10
26
 
11
27
  export function log(...args: unknown[]) {
12
- const line = `[${ts()}] ${args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}\n`;
28
+ const line = `[${timestamp()}] ${args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}\n`;
13
29
  try {
14
30
  fs.appendFileSync(LOG_PATH, line, "utf8");
15
31
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
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",
@@ -27,7 +27,7 @@
27
27
  ]
28
28
  },
29
29
  "scripts": {
30
- "test": "node test/queue.test.mjs"
30
+ "test": "node test/queue.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs"
31
31
  },
32
32
  "files": [
33
33
  "*.ts",
package/slash.ts CHANGED
@@ -130,6 +130,26 @@ export interface RouteDeps {
130
130
 
131
131
  export type DispatchResult = "dispatched" | "queued" | "rejected";
132
132
 
133
+ /**
134
+ * 输出只走 `ctx.ui.*`(TUI / RPC 通道)的扩展命令。
135
+ *
136
+ * 这类命令在 QQ 里发了不会有任何回复:它们的结果是通过 `ctx.ui.notify()` 渲染的,
137
+ * 而本桥接只把 assistant 文本(`message_update.text_delta`)转发到 QQ。
138
+ *
139
+ * - 带 `bridge`:改派 `/qq <bridge> <args>`,由桥接命令重新执行原命令并读 session 里
140
+ * 的 custom entry,自行渲染回 QQ(用户用法不变,仍发 `/usage today`)。
141
+ * - 不带 `bridge`:需要主机 TUI 的交互(选择框/浏览器登录),直接回执说清楚,不能沉默。
142
+ *
143
+ * 必须在 passthrough **之前**判断:这些命令确实在 `pi.getCommands()` 里,
144
+ * 一旦先走 passthrough 就会被派发到黑洞。
145
+ */
146
+ export const UI_ONLY: Readonly<Record<string, { bridge?: string; hint: string }>> = {
147
+ usage: { bridge: "usage", hint: "用量统计会改走 /qq usage 桥接" },
148
+ cost: { bridge: "usage", hint: "用量统计会改走 /qq usage 桥接" },
149
+ mcp: { hint: "MCP 管理需要主机 TUI 的选择框与浏览器登录" },
150
+ llama: { hint: "本地 llama 管理需要主机 TUI" },
151
+ };
152
+
133
153
  export type RouteResult = "dispatched" | "handled" | "text";
134
154
 
135
155
  const allowed = (cfg: CommandConfig, name: string): boolean => {
@@ -144,6 +164,19 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
144
164
  if (!parsed) return "text";
145
165
  const { name } = parsed;
146
166
 
167
+ // 0. 输出只走 TUI 通道的扩展命令:桥接或明确回执(必须早于 passthrough)
168
+ const uiOnly = UI_ONLY[name];
169
+ if (uiOnly && deps.listPiCommands().some((c) => c.name.toLowerCase() === name)) {
170
+ if (uiOnly.bridge) {
171
+ const bridge = `/qq ${uiOnly.bridge}${parsed.args ? ` ${parsed.args}` : ""}`;
172
+ deps.log("[cmd] 桥接 UI-only 命令:", parsed.raw.slice(0, 60), "->", bridge.slice(0, 60));
173
+ deps.dispatch(bridge);
174
+ return "dispatched";
175
+ }
176
+ await deps.reply(`/${name} ${uiOnly.hint},QQ 侧无法执行。`);
177
+ return "handled";
178
+ }
179
+
147
180
  // 1. pi extension commands, prompt templates and skill commands
148
181
  if (deps.cfg.passthrough) {
149
182
  const known = deps.listPiCommands().find((c) => c.name.toLowerCase() === name);
@@ -344,6 +377,63 @@ export function pickerExpired(picker: PickerState | null): boolean {
344
377
  return !picker || Date.now() - picker.createdAt > PICKER_TTL_MS;
345
378
  }
346
379
 
380
+ /** pi-usage-report 写入 session 的 custom entry 类型(耦合点,约定值) */
381
+ const USAGE_ENTRY_TYPE = "usage-report";
382
+
383
+ function sessionEntries(ctx: any): any[] {
384
+ try {
385
+ return ctx?.sessionManager?.getEntries?.() ?? [];
386
+ } catch {
387
+ return [];
388
+ }
389
+ }
390
+
391
+ const intText = (value: unknown): string =>
392
+ typeof value === "number" && Number.isFinite(value) ? Math.round(value).toLocaleString("en-US") : "0";
393
+
394
+ function localStamp(value: unknown): string {
395
+ if (typeof value !== "string") return "?";
396
+ const date = new Date(value);
397
+ if (Number.isNaN(date.getTime())) return "?";
398
+ const pad = (n: number) => String(n).padStart(2, "0");
399
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
400
+ }
401
+
402
+ /** 把 pi-usage-report 的 ReportData 渲染成 QQ 可读的纯文本 */
403
+ function formatUsage(data: any): string {
404
+ if (!data?.totals) return "用量报告为空。";
405
+ const t = data.totals;
406
+ const money = data.money ?? {};
407
+ const usd = Number(t.costUsd ?? 0);
408
+ const rate = typeof money.rate === "number" ? money.rate : null;
409
+ const symbol = money.symbol ?? "$";
410
+ const costText = rate !== null ? `${symbol}${(usd * rate).toFixed(2)}(USD ${usd.toFixed(4)},汇率 ${rate})` : `USD ${usd.toFixed(4)}(无汇率)`;
411
+ const totalTokens = Number(t.totalTokens ?? 0);
412
+ const cachePct = totalTokens > 0 ? ((Number(t.cacheRead ?? 0) / totalTokens) * 100).toFixed(1) : "0.0";
413
+ const lines = [
414
+ `用量统计:${data.rangeLabel ?? "?"} ${localStamp(data.from)} → ${localStamp(data.to)}`,
415
+ `请求 ${intText(t.requests)} 次 合计 ${formatTokens(totalTokens)} tokens 费用 ${costText}`,
416
+ `输入 ${intText(t.input)} 输出 ${intText(t.output)} 缓存读 ${intText(t.cacheRead)}(${cachePct}%)`,
417
+ `缓存写 ${intText(t.cacheWrite)} 推理 ${intText(t.reasoning)}`,
418
+ ];
419
+ const models: any[] = Array.isArray(data.byModel) ? data.byModel : [];
420
+ if (models.length > 0) {
421
+ lines.push("按模型:");
422
+ for (const row of models.slice(0, 5)) {
423
+ const bucket = row?.bucket ?? {};
424
+ const rowUsd = Number(bucket.costUsd ?? 0);
425
+ const rowCost = rate !== null ? `${symbol}${(rowUsd * rate).toFixed(2)}` : `USD ${rowUsd.toFixed(4)}`;
426
+ lines.push(
427
+ ` ${row?.model ?? "unknown"} ${intText(bucket.requests)} 次 ${formatTokens(Number(bucket.totalTokens ?? 0))} tokens ${rowCost}`,
428
+ );
429
+ }
430
+ }
431
+ const sessions: any[] = Array.isArray(data.bySession) ? data.bySession : [];
432
+ if (sessions.length > 1) lines.push(`会话数:${sessions.length} 个`);
433
+ if (t.unpriced) lines.push(`注:${intText(t.unpriced)} 次请求没有计价。`);
434
+ return lines.join("\n");
435
+ }
436
+
347
437
  /**
348
438
  * Body of the `/qq` bridge command: pi hands it a fresh `ExtensionCommandContext`,
349
439
  * which is the only place `reload()`, `newSession()`, `fork()`, `navigateTree()` and
@@ -444,6 +534,38 @@ export async function runBridge(
444
534
  await ctx.fork(leaf, { position: "at" });
445
535
  return;
446
536
  }
537
+ case "usage": {
538
+ // /qq usage [...]:重跑原 /usage 命令,再从 session 里读回它写的 custom entry
539
+ if (first === "rate" || first === "help" || first === "?") {
540
+ reply(
541
+ `/usage ${arg} 会修改汇率或打印帮助,那些输出只在主机 TUI 可见;\n发 /usage today|week|month|all|7d 可在 QQ 里看统计。`,
542
+ );
543
+ return;
544
+ }
545
+ const commands = (pi?.getCommands?.() ?? []) as { name: string }[];
546
+ if (!commands.some((c) => ["usage", "cost"].includes(String(c.name).toLowerCase()))) {
547
+ reply("主机上没有 /usage 命令(需要安装 pi-usage-report),无法统计。");
548
+ return;
549
+ }
550
+ const command = `/usage${arg ? ` ${arg}` : ""}`;
551
+ const before = new Set(sessionEntries(ctx).map((entry: any) => entry?.id));
552
+ reply(`正在统计用量(${command})…`);
553
+ try {
554
+ await pi.sendUserMessage(command, { expandPromptTemplates: true });
555
+ } catch (error) {
556
+ reply(`统计失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
557
+ return;
558
+ }
559
+ const entry = [...sessionEntries(ctx)]
560
+ .reverse()
561
+ .find((e: any) => e?.type === "custom" && e?.customType === USAGE_ENTRY_TYPE && !before.has(e?.id));
562
+ if (!entry) {
563
+ reply("没拿到用量报告(命令没有产出数据)。");
564
+ return;
565
+ }
566
+ reply(formatUsage(entry.data));
567
+ return;
568
+ }
447
569
  case "session": {
448
570
  const sm = ctx.sessionManager;
449
571
  const usage = ctx.getContextUsage?.();