@zxh126/pi-qqbot 2.3.0 → 2.4.1

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/slash.ts CHANGED
@@ -1,826 +1,922 @@
1
- /**
2
- * Slash-command routing for pi-qqbot.
3
- *
4
- * QQ messages that start with `/` are handled in this order:
5
- *
6
- * 1. **pi extension commands / prompt templates / skill commands** — anything the
7
- * running pi instance reports in `pi.getCommands()` (source: extension | prompt |
8
- * skill). These are dispatched verbatim with
9
- * `pi.sendUserMessage(text, { expandPromptTemplates: true })`, the only path pi
10
- * uses to execute extension commands from a message.
11
- * 2. **pi built-in commands we can take over** (`TAKEOVER`) — pi never dispatches
12
- * built-ins from a message (only the TUI/RPC layer does), so these are
13
- * re-implemented through the extension API. The command-only APIs
14
- * (`reload`, `newSession`, `fork`, `navigateTree`, `switchSession`) need a fresh
15
- * `ExtensionCommandContext`, so they run inside the `/qq` bridge command
16
- * registered by this extension.
17
- * 3. **built-ins we cannot reach** (`HOST_ONLY`) — reply "run it on the host".
18
- * 4. **anything else** — treated as ordinary chat text (the caller forwards it).
19
- *
20
- * Selection-style commands (`/tree`, `/fork`, `/resume`) answer with a numbered list
21
- * and accept the number as the next message; the pending list lives in `BridgeStore`.
22
- *
23
- * Everything is opted in conservatively through `commands` in the config file.
24
- */
25
-
26
- import fs from "node:fs";
27
- import path from "node:path";
28
-
29
- import { PI_AGENT_DIR } from "./constants";
30
-
31
- export interface SlashCommandText {
32
- /** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
33
- name: string;
34
- /** raw text after the command name */
35
- args: string;
36
- /** the original message */
37
- raw: string;
38
- }
39
-
40
- const SLASH = /^\/([A-Za-z0-9:_-]+)(?:\s+([\s\S]*))?$/;
41
-
42
- export function parseSlash(text: string): SlashCommandText | null {
43
- const match = SLASH.exec(text.trim());
44
- if (!match) return null;
45
- return { name: match[1]!.toLowerCase(), args: (match[2] ?? "").trim(), raw: text.trim() };
46
- }
47
-
48
- export type BridgeSub =
49
- | "reload"
50
- | "new"
51
- | "compact"
52
- | "quit"
53
- | "session"
54
- | "name"
55
- | "model"
56
- | "thinking"
57
- | "tree"
58
- | "fork"
59
- | "clone"
60
- | "resume"
61
- | "sendfile";
62
-
63
- interface Takeover {
64
- sub: string;
65
- /** the command tears down the current runtime/session: reply before dispatching */
66
- selfDestroying?: boolean;
67
- /** unsafe to run while a task is in progress */
68
- needsIdle?: boolean;
69
- needsArgs?: boolean;
70
- hint: string;
71
- }
72
-
73
- /**
74
- * pi built-ins that can be re-implemented with extension APIs.
75
- * `dist/core/slash-commands.d.ts` marks the full built-in list, but pi does not
76
- * export it, so this table is maintained here on purpose.
77
- */
78
- export const TAKEOVER: Readonly<Record<string, Takeover>> = {
79
- reload: { sub: "reload", selfDestroying: true, needsIdle: true, hint: "重新加载扩展/技能/模板/主题" },
80
- new: { sub: "new", selfDestroying: true, needsIdle: true, hint: "开启新的 pi 会话" },
81
- compact: { sub: "compact", needsIdle: true, hint: "压缩上下文(可带自定义指令)" },
82
- quit: { sub: "quit", selfDestroying: true, needsIdle: true, hint: "退出主机上的 pi" },
83
- session: { sub: "session", hint: "查看当前会话信息与上下文用量" },
84
- name: { sub: "name", hint: "设置或查看会话名称" },
85
- model: { sub: "model", hint: "查看/切换模型(provider/model)" },
86
- thinking: { sub: "thinking", hint: "查看/切换思考等级" },
87
- tree: { sub: "list tree", needsIdle: true, hint: "在会话树上跳转(回编号选择)" },
88
- fork: { sub: "list fork", needsIdle: true, hint: "从某条历史用户消息分叉(回编号选择)" },
89
- resume: { sub: "list resume", needsIdle: true, hint: "切换到本项目的另一个会话(回编号选择)" },
90
- clone: { sub: "clone", selfDestroying: true, needsIdle: true, hint: "在当前叶子位置复制会话" },
91
- sendfile: { sub: "sendfile", needsArgs: true, hint: "把本机文件发送到你的 QQ" },
92
- };
93
-
94
- /** pi built-ins that the extension API cannot reach at all. */
95
- export const HOST_ONLY: ReadonlySet<string> = new Set([
96
- "settings",
97
- "hotkeys",
98
- "changelog",
99
- "trust",
100
- "login",
101
- "logout",
102
- "scoped-models",
103
- "llama",
104
- "import",
105
- "export",
106
- "share",
107
- "bug",
108
- "copy",
109
- "privacy",
110
- ]);
111
-
112
- export interface CommandConfig {
113
- /** forward pi extension commands / prompt templates / skill commands */
114
- passthrough: boolean;
115
- /** allow /reload (restarts this bot too) */
116
- allowReload: boolean;
117
- /** allow /quit (stops the host pi process) */
118
- allowQuit: boolean;
119
- /** allow session-level commands: /new, /compact, /session, /name, /model, /thinking, /tree, /fork, /clone, /resume */
120
- allowSession: boolean;
121
- }
122
-
123
- export const DEFAULT_COMMANDS: CommandConfig = {
124
- passthrough: true,
125
- allowReload: false,
126
- allowQuit: false,
127
- allowSession: true,
128
- };
129
-
130
- export interface RouteDeps {
131
- /** commands pi currently knows about (extension/prompt/skill) */
132
- listPiCommands(): readonly { name: string; source?: string }[];
133
- /**
134
- * deliver a pi command through pi's own dispatcher.
135
- * `runsTurn` marks commands that start an agent run (prompt templates, `/skill:name`):
136
- * those are serialized through the bot's queue, everything else runs immediately.
137
- * Returns "queued" when the bot deferred it, "rejected" when the queue was full.
138
- */
139
- dispatch(text: string, options?: { runsTurn?: boolean }): DispatchResult;
140
- /** answer the QQ user */
141
- /** 回用户一条消息(返回值忽略,允许实现返回是否送达) */
142
- reply(text: string): Promise<unknown>;
143
- busy: boolean;
144
- cfg: CommandConfig;
145
- log(message: string, ...rest: unknown[]): void;
146
- }
147
-
148
- export type DispatchResult = "dispatched" | "queued" | "rejected";
149
-
150
- /**
151
- * 输出只走 `ctx.ui.*`(TUI / RPC 通道)的扩展命令。
152
- *
153
- * 这类命令在 QQ 里发了不会有任何回复:它们的结果是通过 `ctx.ui.notify()` 渲染的,
154
- * 而本桥接只把 assistant 文本(`message_update.text_delta`)转发到 QQ。
155
- *
156
- * - 带 `bridge`:改派 `/qq <bridge> <args>`,由桥接命令重新执行原命令并读 session 里
157
- * 的 custom entry,自行渲染回 QQ(用户用法不变,仍发 `/usage today`)。
158
- * - 不带 `bridge`:需要主机 TUI 的交互(选择框/浏览器登录),直接回执说清楚,不能沉默。
159
- *
160
- * 必须在 passthrough **之前**判断:这些命令确实在 `pi.getCommands()` 里,
161
- * 一旦先走 passthrough 就会被派发到黑洞。
162
- */
163
- export const UI_ONLY: Readonly<Record<string, { bridge?: string; hint: string }>> = {
164
- usage: { bridge: "usage", hint: "用量统计会改走 /qq usage 桥接" },
165
- cost: { bridge: "usage", hint: "用量统计会改走 /qq usage 桥接" },
166
- mcp: { hint: "MCP 管理需要主机 TUI 的选择框与浏览器登录" },
167
- llama: { hint: "本地 llama 管理需要主机 TUI" },
168
- };
169
-
170
- export type RouteResult = "dispatched" | "handled" | "text";
171
-
172
- const allowed = (cfg: CommandConfig, name: string): boolean => {
173
- if (name === "reload") return cfg.allowReload;
174
- if (name === "quit") return cfg.allowQuit;
175
- // 发文件不属于会话操作:由 files.enabled + QQ 审批把关
176
- if (name === "sendfile") return true;
177
- return cfg.allowSession;
178
- };
179
-
180
- /** Route one slash message. Returns "text" when it should be treated as chat. */
181
- export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteResult> {
182
- const parsed = parseSlash(text);
183
- if (!parsed) return "text";
184
- const { name } = parsed;
185
-
186
- // 0. 输出只走 TUI 通道的扩展命令:桥接或明确回执(必须早于 passthrough)
187
- const uiOnly = UI_ONLY[name];
188
- if (uiOnly && deps.listPiCommands().some((c) => c.name.toLowerCase() === name)) {
189
- if (uiOnly.bridge) {
190
- const bridge = `/qq ${uiOnly.bridge}${parsed.args ? ` ${parsed.args}` : ""}`;
191
- deps.log("[cmd] 桥接 UI-only 命令:", parsed.raw.slice(0, 60), "->", bridge.slice(0, 60));
192
- deps.dispatch(bridge);
193
- return "dispatched";
194
- }
195
- await deps.reply(`/${name} ${uiOnly.hint},QQ 侧无法执行。`);
196
- return "handled";
197
- }
198
-
199
- // 1. pi extension commands, prompt templates and skill commands
200
- if (deps.cfg.passthrough) {
201
- const known = deps.listPiCommands().find((c) => c.name.toLowerCase() === name);
202
- if (known) {
203
- // 提示模板与 /skill: 会开启一轮 agent run,交给队列串行;扩展命令自己管理 LLM 交互
204
- const runsTurn = known.source === "prompt" || known.source === "skill";
205
- deps.log("[cmd] 派发 pi 命令:", parsed.raw.slice(0, 80), "runsTurn=", runsTurn);
206
- const result = deps.dispatch(parsed.raw, { runsTurn });
207
- if (result === "queued") {
208
- await deps.reply(`已排队 /${name}:当前任务结束后执行(发 /queue 查看队列)。`);
209
- } else if (result === "rejected") {
210
- await deps.reply(`队列已满,/${name} 没有排上。发 /queue 查看队列,或 /cancel 清空后重发。`);
211
- } else {
212
- await deps.reply(`已派发 /${name} 给 pi。`);
213
- }
214
- return "dispatched";
215
- }
216
- }
217
-
218
- // 2. built-ins we can take over
219
- const takeover = TAKEOVER[name];
220
- if (takeover) {
221
- if (!allowed(deps.cfg, name)) {
222
- const gate = name === "reload" ? "allowReload" : name === "quit" ? "allowQuit" : "allowSession";
223
- await deps.reply(`/${name} 在 QQ 侧被禁用(配置 commands.${gate})。`);
224
- return "handled";
225
- }
226
- if (takeover.needsIdle && deps.busy) {
227
- await deps.reply(`当前有任务在跑,先发 /cancel 取消,再执行 /${name}。`);
228
- return "handled";
229
- }
230
- const bridge = `/qq ${takeover.sub}${parsed.args ? ` ${parsed.args}` : ""}`;
231
- deps.log("[cmd] 接管内置命令:", parsed.raw.slice(0, 80), "->", bridge.slice(0, 80));
232
- if (takeover.selfDestroying) {
233
- // the command tears the runtime down; answer before it lands
234
- await deps.reply(`已请求 /${name}(${takeover.hint})…`);
235
- deps.dispatch(bridge);
236
- return "dispatched";
237
- }
238
- deps.dispatch(bridge);
239
- return "dispatched";
240
- }
241
-
242
- // 3. built-ins only the host TUI can run
243
- if (HOST_ONLY.has(name)) {
244
- await deps.reply(`/${name} 只能在主机上的 pi 里执行(扩展 API 没有对应能力)。`);
245
- return "handled";
246
- }
247
-
248
- // 4. not a command we know: ordinary text
249
- return "text";
250
- }
251
-
252
- const MAX_LIST = 12;
253
-
254
- function formatTokens(value: number | null | undefined): string {
255
- if (typeof value !== "number" || !Number.isFinite(value)) return "?";
256
- if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
257
- if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
258
- return String(value);
259
- }
260
-
261
- // ---------------------------------------------------------------------------
262
- // pickers (tree / fork / resume)
263
- // ---------------------------------------------------------------------------
264
-
265
- export type PickerKind = "tree" | "fork" | "resume";
266
-
267
- export interface PickerItem {
268
- /** entry id (tree/fork) */
269
- id?: string;
270
- /** session file (resume) */
271
- path?: string;
272
- /** what the user sees next to the number */
273
- label: string;
274
- }
275
-
276
- export interface PickerState {
277
- kind: PickerKind;
278
- items: PickerItem[];
279
- /** only this QQ user may answer with a number */
280
- openid: string | null;
281
- createdAt: number;
282
- }
283
-
284
- /** Shared mutable state between the router (index.ts) and the bridge command. */
285
- export interface BridgeStore {
286
- picker: PickerState | null;
287
- /** set by the router right before dispatching a `list` command */
288
- pickerOwner: string | null;
289
- }
290
-
291
- export const PICKER_TTL_MS = 10 * 60_000;
292
- export const PICKER_MAX_ITEMS = 10;
293
-
294
- const KIND_LABEL: Record<PickerKind, string> = {
295
- tree: "会话树节点",
296
- fork: "可分叉的用户消息",
297
- resume: "本项目的其他会话",
298
- };
299
-
300
- function previewText(content: unknown): string {
301
- const parts = Array.isArray(content) ? content : [];
302
- const text = parts
303
- .map((part: any) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
304
- .join(" ")
305
- .replace(/\s+/g, " ")
306
- .trim();
307
- return text.length > 50 ? `${text.slice(0, 50)}…` : text || "(无文本)";
308
- }
309
-
310
- const stamp = (value: unknown): string => {
311
- const date = typeof value === "string" || typeof value === "number" ? new Date(value) : new Date();
312
- if (Number.isNaN(date.getTime())) return "?";
313
- const pad = (n: number) => String(n).padStart(2, "0");
314
- return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
315
- };
316
-
317
- function branchItems(ctx: any): PickerItem[] {
318
- let branch: any[] = [];
319
- try {
320
- branch = ctx.sessionManager.getBranch() ?? [];
321
- } catch {
322
- branch = [];
323
- }
324
- return branch
325
- .filter((entry) => entry?.type === "message" && entry?.message?.role === "user")
326
- .slice(-PICKER_MAX_ITEMS)
327
- .map((entry) => ({
328
- id: entry.id,
329
- label: `${stamp(entry.timestamp)} ${previewText(entry.message.content)}`,
330
- }));
331
- }
332
-
333
- function resumeItems(ctx: any): PickerItem[] {
334
- let dir: string | undefined;
335
- let current: string | undefined;
336
- try {
337
- dir = ctx.sessionManager.getSessionDir?.();
338
- current = ctx.sessionManager.getSessionFile?.();
339
- } catch {
340
- return [];
341
- }
342
- if (!dir) return [];
343
- let names: string[] = [];
344
- try {
345
- names = fs.readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
346
- } catch {
347
- return [];
348
- }
349
- return names
350
- .map((name) => {
351
- const full = path.join(dir!, name);
352
- let mtime = 0;
353
- let size = 0;
354
- try {
355
- const stat = fs.statSync(full);
356
- mtime = stat.mtimeMs;
357
- size = stat.size;
358
- } catch {
359
- // unreadable: keep it out of the ranking
360
- }
361
- return { full, name, mtime, size };
362
- })
363
- .filter((row) => row.mtime > 0 && row.full !== current)
364
- .sort((a, b) => b.mtime - a.mtime)
365
- .slice(0, PICKER_MAX_ITEMS)
366
- .map((row) => ({
367
- path: row.full,
368
- label: `${stamp(row.mtime)} ${Math.max(1, Math.round(row.size / 1024))}KB ${row.name.replace(/\.jsonl$/, "").slice(0, 44)}`,
369
- }));
370
- }
371
-
372
- function buildPickerItems(kind: PickerKind, ctx: any): PickerItem[] {
373
- if (kind === "resume") return resumeItems(ctx);
374
- return branchItems(ctx);
375
- }
376
-
377
- function formatPicker(picker: PickerState): string {
378
- const header = `选择${KIND_LABEL[picker.kind]}(${picker.items.length} 项):`;
379
- const rows = picker.items.map((item, index) => ` ${index + 1}. ${item.label}`);
380
- return [
381
- header,
382
- ...rows,
383
- `回复编号执行,或回复「取消」放弃(${Math.round(PICKER_TTL_MS / 60000)} 分钟内有效)。`,
384
- ].join("\n");
385
- }
386
-
387
- /** Parse a bare "3" reply that answers the pending picker. */
388
- export function parsePickIndex(text: string): number | null {
389
- const match = /^\s*(\d{1,3})\s*$/.exec(text);
390
- if (!match) return null;
391
- const value = Number(match[1]);
392
- return Number.isFinite(value) && value > 0 ? value : null;
393
- }
394
-
395
- export function pickerExpired(picker: PickerState | null): boolean {
396
- return !picker || Date.now() - picker.createdAt > PICKER_TTL_MS;
397
- }
398
-
399
- /** pi-usage-report 写入 session 的 custom entry 类型(耦合点,约定值) */
400
- const USAGE_ENTRY_TYPE = "usage-report";
401
-
402
- /**
403
- * appendEntry 的写入比 `sendUserMessage` 的 resolve 晚(实测 promise 183ms 返回,
404
- * entry 在之后 ~100ms 才出现在 getEntries 里),所以必须轮询而不是只读一次。
405
- */
406
- const USAGE_WAIT_TIMEOUT_MS = 2000;
407
- const USAGE_POLL_INTERVAL_MS = 100;
408
-
409
- /** pi-usage-report 的配置/汇率缓存(读回来确认:命令真的生效了,而不是口头应下) */
410
- const USAGE_SETTINGS_FILE = "pi-usage-report.json";
411
- const USAGE_RATE_FILE = "pi-usage-report-rate.json";
412
-
413
- const USAGE_HELP = [
414
- "/usage(默认 today)",
415
- "/usage today | yesterday | week | lastweek | month | lastmonth | all",
416
- "/usage 7d | 12h | 2w | 3m | 10天 滚动窗口",
417
- "/usage 2026-09-01..2026-09-30 本地日期区间(含首尾)",
418
- "/usage 今天 | 昨天 | 本周 | 上周 | 本月 | 上月 | 全部",
419
- "/usage rate <值> | rate auto 固定 USD→本币汇率,或恢复自动(会真的写配置)",
420
- ].join("\n");
421
-
422
- function readAgentJson(file: string): any {
423
- try {
424
- return JSON.parse(fs.readFileSync(path.join(PI_AGENT_DIR, file), "utf8"));
425
- } catch {
426
- return undefined;
427
- }
428
- }
429
-
430
- const CURRENCY_SYMBOLS: Record<string, string> = { CNY: "¥", USD: "$", EUR: "€", JPY: "¥", GBP: "£", HKD: "HK$" };
431
-
432
- /** 复刻 pi-usage-report 的 readSettings 优先级:fixed(usdRate) > 缓存的发布汇率 > 无 */
433
- function usageMoney(): { code: string; symbol: string; rate: number | null; source: string } {
434
- const raw = readAgentJson(USAGE_SETTINGS_FILE) ?? {};
435
- const code = String(raw.currency ?? "CNY").toUpperCase();
436
- const symbol = typeof raw.symbol === "string" ? raw.symbol : (CURRENCY_SYMBOLS[code] ?? code);
437
- const pinned = typeof raw.usdRate === "number" && raw.usdRate > 0 ? raw.usdRate : undefined;
438
- const fixed = raw.rateMode === "auto" ? undefined : pinned;
439
- if (fixed !== undefined) return { code, symbol, rate: fixed, source: "fixed,/usage rate 手动固定" };
440
- const cache = readAgentJson(USAGE_RATE_FILE);
441
- if (cache && cache.code === code && typeof cache.rate === "number" && cache.rate > 0) {
442
- const ageMinutes = Math.max(0, Math.round((Date.now() - Number(cache.fetchedAt ?? 0)) / 60000));
443
- return { code, symbol, rate: cache.rate, source: `cache,${ageMinutes} 分钟前抓取` };
444
- }
445
- return { code, symbol, rate: null, source: "无,可用 /usage rate <值>" };
446
- }
447
-
448
- function moneyText(money: { code: string; rate: number | null; source: string }): string {
449
- return money.rate === null
450
- ? `USD/${money.code}:无可用汇率(${money.source})`
451
- : `USD/${money.code} = ${money.rate}(${money.source})`;
452
- }
453
-
454
- function sessionEntries(ctx: any): any[] {
455
- try {
456
- return ctx?.sessionManager?.getEntries?.() ?? [];
457
- } catch {
458
- return [];
459
- }
460
- }
461
-
462
- function sessionFile(ctx: any): string | undefined {
463
- try {
464
- return ctx?.sessionManager?.getSessionFile?.() ?? undefined;
465
- } catch {
466
- return undefined;
467
- }
468
- }
469
-
470
- /** 兼容三种时间形态:毫秒数字(pi.appendEntry 实际写的就是它)/ ISO 字符串 / Date */
471
- function entryTime(value: unknown): number {
472
- if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
473
- if (value instanceof Date) return value.getTime();
474
- if (typeof value === "string" && value) return Date.parse(value);
475
- return NaN;
476
- }
477
-
478
- /**
479
- * 从会话文件尾部找最近一条 usage-report(generatedAt 不早于 since)。
480
- *
481
- * 为什么需要这条路:appendEntry 是**另一个扩展**写的,命令上下文里的
482
- * `sessionManager.getEntries()` 不一定即时反映(实测 `/usage today` 已经写入了
483
- * 会话文件,但 getEntries 看不到新增项)。数据库不会骗人,文件会。
484
- */
485
- function readUsageEntryFromFile(file: string | undefined, since: number): any | undefined {
486
- if (!file) return undefined;
487
- try {
488
- const size = fs.statSync(file).size;
489
- const chunk = Math.min(size, 512 * 1024);
490
- if (chunk <= 0) return undefined;
491
- const fd = fs.openSync(file, "r");
492
- let text = "";
493
- try {
494
- const buf = Buffer.alloc(chunk);
495
- fs.readSync(fd, buf, 0, chunk, size - chunk);
496
- text = buf.toString("utf8");
497
- } finally {
498
- fs.closeSync(fd);
499
- }
500
- const lines = text.split("\n");
501
- for (let i = lines.length - 1; i >= 0; i--) {
502
- const line = lines[i].trim();
503
- if (!line.startsWith("{")) continue;
504
- let entry: any;
505
- try {
506
- entry = JSON.parse(line);
507
- } catch {
508
- continue;
509
- }
510
- if (!isUsageEntry(entry)) continue;
511
- const when = entryTime(entry?.data?.generatedAt ?? entry?.timestamp);
512
- return Number.isFinite(when) && when >= since ? entry : undefined;
513
- }
514
- } catch {
515
- return undefined;
516
- }
517
- return undefined;
518
- }
519
-
520
- const intText = (value: unknown): string =>
521
- typeof value === "number" && Number.isFinite(value) ? Math.round(value).toLocaleString("en-US") : "0";
522
-
523
- function localStamp(value: unknown): string {
524
- if (typeof value !== "string") return "?";
525
- const date = new Date(value);
526
- if (Number.isNaN(date.getTime())) return "?";
527
- const pad = (n: number) => String(n).padStart(2, "0");
528
- return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
529
- }
530
-
531
- const isUsageEntry = (entry: any): boolean =>
532
- entry?.type === "custom" && entry?.customType === USAGE_ENTRY_TYPE;
533
-
534
- const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
535
-
536
- /**
537
- * 等本次 /usage 写的报告出现:每 100ms 查一次 getEntries(最多 2s),
538
- * 全程未命中再试一次会话文件(custom entry 可能很晚才落盘)。
539
- */
540
- async function waitForUsageEntry(
541
- ctx: any,
542
- opts: { before: Set<unknown>; since: number; file?: string },
543
- ): Promise<any | undefined> {
544
- const deadline = Date.now() + USAGE_WAIT_TIMEOUT_MS;
545
- while (Date.now() < deadline) {
546
- const fresh = [...sessionEntries(ctx)].reverse().find((entry: any) => isUsageEntry(entry) && !opts.before.has(entry?.id));
547
- if (fresh) return fresh;
548
- await sleep(USAGE_POLL_INTERVAL_MS);
549
- }
550
- return readUsageEntryFromFile(opts.file, opts.since);
551
- }
552
-
553
- /** 把 pi-usage-report 的 ReportData 渲染成 QQ 可读的纯文本 */
554
- function formatUsage(data: any): string {
555
- if (!data?.totals) return "用量报告为空。";
556
- const t = data.totals;
557
- const money = data.money ?? {};
558
- const usd = Number(t.costUsd ?? 0);
559
- const rate = typeof money.rate === "number" ? money.rate : null;
560
- const symbol = money.symbol ?? "$";
561
- const costText = rate !== null ? `${symbol}${(usd * rate).toFixed(2)}(USD ${usd.toFixed(4)},汇率 ${rate})` : `USD ${usd.toFixed(4)}(无汇率)`;
562
- const totalTokens = Number(t.totalTokens ?? 0);
563
- const cachePct = totalTokens > 0 ? ((Number(t.cacheRead ?? 0) / totalTokens) * 100).toFixed(1) : "0.0";
564
- const lines = [
565
- `用量统计:${data.rangeLabel ?? "?"} ${localStamp(data.from)} → ${localStamp(data.to)}`,
566
- `请求 ${intText(t.requests)} 次 合计 ${formatTokens(totalTokens)} tokens 费用 ${costText}`,
567
- `输入 ${intText(t.input)} 输出 ${intText(t.output)} 缓存读 ${intText(t.cacheRead)}(${cachePct}%)`,
568
- `缓存写 ${intText(t.cacheWrite)} 推理 ${intText(t.reasoning)}`,
569
- ];
570
- const models: any[] = Array.isArray(data.byModel) ? data.byModel : [];
571
- if (models.length > 0) {
572
- lines.push("按模型:");
573
- for (const row of models.slice(0, 5)) {
574
- const bucket = row?.bucket ?? {};
575
- const rowUsd = Number(bucket.costUsd ?? 0);
576
- const rowCost = rate !== null ? `${symbol}${(rowUsd * rate).toFixed(2)}` : `USD ${rowUsd.toFixed(4)}`;
577
- lines.push(
578
- ` ${row?.model ?? "unknown"} ${intText(bucket.requests)} 次 ${formatTokens(Number(bucket.totalTokens ?? 0))} tokens ${rowCost}`,
579
- );
580
- }
581
- }
582
- const sessions: any[] = Array.isArray(data.bySession) ? data.bySession : [];
583
- if (sessions.length > 1) lines.push(`会话数:${sessions.length} 个`);
584
- if (t.unpriced) lines.push(`注:${intText(t.unpriced)} 次请求没有计价。`);
585
- return lines.join("\n");
586
- }
587
-
588
- /**
589
- * Body of the `/qq` bridge command: pi hands it a fresh `ExtensionCommandContext`,
590
- * which is the only place `reload()`, `newSession()`, `fork()`, `navigateTree()` and
591
- * `switchSession()` are legal.
592
- *
593
- * Session-replacing actions (`reload`, `new`, `tree`/`fork`/`resume` picks, `clone`)
594
- * tear this runtime down; the caller answers the QQ user before dispatching them.
595
- */
596
- export async function runBridge(
597
- sub: string,
598
- arg: string,
599
- ctx: any,
600
- pi: any,
601
- reply: (text: string) => void,
602
- store?: BridgeStore,
603
- ): Promise<void> {
604
- const level = String(sub ?? "").toLowerCase();
605
- const first = arg.split(/\s+/)[0]?.toLowerCase() ?? "";
606
-
607
- switch (level) {
608
- case "reload": {
609
- await ctx.reload();
610
- return;
611
- }
612
- case "new": {
613
- await ctx.newSession();
614
- return;
615
- }
616
- case "quit": {
617
- reply("正在退出主机上的 pi…");
618
- ctx.shutdown();
619
- return;
620
- }
621
- case "compact": {
622
- reply(arg ? `开始压缩上下文(指令:${arg.slice(0, 80)})…` : "开始压缩上下文…");
623
- ctx.compact({
624
- customInstructions: arg || undefined,
625
- onComplete: () => reply("上下文压缩完成。"),
626
- onError: (error: Error) => reply(`上下文压缩失败:${String(error?.message ?? error).slice(0, 200)}`),
627
- });
628
- return;
629
- }
630
- case "list": {
631
- const kind = first as PickerKind;
632
- if (kind !== "tree" && kind !== "fork" && kind !== "resume") {
633
- reply(`未知列表类型:${first || "(空)"}`);
634
- return;
635
- }
636
- const items = buildPickerItems(kind, ctx);
637
- if (items.length === 0) {
638
- reply(`没有可用的${KIND_LABEL[kind]}。`);
639
- return;
640
- }
641
- const picker: PickerState = { kind, items, openid: store?.pickerOwner ?? null, createdAt: Date.now() };
642
- if (store) store.picker = picker;
643
- reply(formatPicker(picker));
644
- return;
645
- }
646
- case "pick": {
647
- const index = Number.parseInt(first, 10);
648
- const picker = store?.picker ?? null;
649
- if (!picker || !Number.isFinite(index)) {
650
- reply("选择列表已失效,请重新发送命令。");
651
- return;
652
- }
653
- const item = picker.items[index - 1];
654
- if (!item) {
655
- reply(`编号 ${first} 超出范围(1-${picker.items.length})。`);
656
- return;
657
- }
658
- if (store) store.picker = null;
659
- if (picker.kind === "tree") {
660
- reply(`正在跳转到第 ${index} 个节点…`);
661
- await ctx.navigateTree(item.id);
662
- return;
663
- }
664
- if (picker.kind === "fork") {
665
- reply(`正在从第 ${index} 条消息分叉…`);
666
- await ctx.fork(item.id);
667
- return;
668
- }
669
- reply(`正在切换会话:${item.label.slice(0, 44)}…`);
670
- await ctx.switchSession(item.path);
671
- return;
672
- }
673
- case "clone": {
674
- let leaf: string | null = null;
675
- try {
676
- leaf = ctx.sessionManager.getLeafId?.() ?? null;
677
- } catch {
678
- leaf = null;
679
- }
680
- if (!leaf) {
681
- reply("当前会话还没有可复制的位置(先发一条消息)。");
682
- return;
683
- }
684
- reply("正在当前叶子位置复制会话…");
685
- await ctx.fork(leaf, { position: "at" });
686
- return;
687
- }
688
- case "usage": {
689
- // /qq usage [...]:重跑原 /usage 命令,再从 session 里读回它写的 custom entry
690
- const rest = arg.slice(first.length).trim();
691
-
692
- // help:不执行命令(它在 TUI 打文本),直接回本地帮助
693
- if (first === "help" || first === "?") {
694
- reply(USAGE_HELP);
695
- return;
696
- }
697
-
698
- const commands = (pi?.getCommands?.() ?? []) as { name: string }[];
699
- const hasUsage = commands.some((c) => ["usage", "cost"].includes(String(c.name).toLowerCase()));
700
- if (!hasUsage) {
701
- reply("主机上没有 /usage 命令(需要安装 pi-usage-report),无法统计。");
702
- return;
703
- }
704
-
705
- // rate:会写配置/联网刷新汇率,必须真的执行,再读回状态确认(不能只回一句“TUI 可见”)
706
- if (first === "rate") {
707
- if (!rest) {
708
- reply(moneyText(usageMoney()));
709
- return;
710
- }
711
- const rateCommand = `/usage rate ${rest}`;
712
- try {
713
- await pi.sendUserMessage(rateCommand, { expandPromptTemplates: true });
714
- } catch (error) {
715
- reply(`执行 ${rateCommand} 失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
716
- return;
717
- }
718
- const after = usageMoney();
719
- reply(
720
- `已执行 ${rateCommand}\n${moneyText(after)}\n(下一步 /usage today 的 ¥/USD 换算就用它;无效值会被忽略)`,
721
- );
722
- return;
723
- }
724
-
725
- const command = `/usage${arg ? ` ${arg}` : ""}`;
726
- const since = Date.now();
727
- const before = new Set(sessionEntries(ctx).map((entry: any) => entry?.id));
728
- const file = sessionFile(ctx);
729
- reply(`正在统计用量(${command})…`);
730
- try {
731
- await pi.sendUserMessage(command, { expandPromptTemplates: true });
732
- } catch (error) {
733
- reply(`统计失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
734
- return;
735
- }
736
- // 先轮询 getEntries 的增量(appendEntry 晚于 await 返回),超时再退回读会话文件
737
- const entry = await waitForUsageEntry(ctx, { before, since, file });
738
- if (!entry) {
739
- reply("没拿到用量报告:/usage 已执行但找不到报告数据(可重试一次;若反复出现请把 pi-qqbot.log 尾部发我)。");
740
- return;
741
- }
742
- reply(formatUsage(entry.data));
743
- return;
744
- }
745
- case "session": {
746
- const sm = ctx.sessionManager;
747
- const usage = ctx.getContextUsage?.();
748
- const model = ctx.model;
749
- const entries = (() => {
750
- try {
751
- return sm.getEntries().length;
752
- } catch {
753
- return "?";
754
- }
755
- })();
756
- reply(
757
- [
758
- `会话:${sm.getSessionName?.() || "(未命名)"}`,
759
- `id:${sm.getSessionId?.() ?? "?"}`,
760
- `cwd:${sm.getCwd?.() ?? ctx.cwd ?? "?"}`,
761
- `模型:${model ? `${model.provider}/${model.id}` : "(未设置)"} 思考:${ctx.thinkingLevel ?? "?"}`,
762
- `条目:${entries} 上下文:${
763
- usage && usage.tokens !== undefined
764
- ? `${formatTokens(usage.tokens)}${usage.percent !== undefined ? ` (${Math.round(usage.percent)}%)` : ""}`
765
- : "未知"
766
- }`,
767
- ].join("\n"),
768
- );
769
- return;
770
- }
771
- case "name": {
772
- if (!arg) {
773
- reply(`当前会话名:${ctx.sessionManager.getSessionName?.() || "(未命名)"}`);
774
- return;
775
- }
776
- pi.setSessionName(arg);
777
- reply(`会话名已设为:${arg}`);
778
- return;
779
- }
780
- case "model": {
781
- const registry = ctx.modelRegistry;
782
- const available = registry?.getAvailable?.() ?? [];
783
- const current = ctx.model;
784
- if (!arg) {
785
- const names = available.slice(0, MAX_LIST).map((m: any) => `${m.provider}/${m.id}`);
786
- reply(
787
- [
788
- `当前模型:${current ? `${current.provider}/${current.id}` : "(未设置)"}`,
789
- available.length > 0
790
- ? `可选(${available.length}):${names.join(", ")}${available.length > MAX_LIST ? " …" : ""}`
791
- : "没有可用模型",
792
- ].join("\n"),
793
- );
794
- return;
795
- }
796
- const wanted = arg.toLowerCase();
797
- const found = available.find((m: any) => `${m.provider}/${m.id}`.toLowerCase() === wanted)
798
- ?? available.find((m: any) => String(m.id).toLowerCase() === wanted);
799
- if (!found) {
800
- reply(`找不到模型 ${arg}。发 /model 查看可用列表。`);
801
- return;
802
- }
803
- const ok = await pi.setModel(found);
804
- reply(ok ? `模型已切换为 ${found.provider}/${found.id}` : `切换到 ${found.provider}/${found.id} 失败(缺少凭据?)`);
805
- return;
806
- }
807
- case "thinking": {
808
- if (!arg) {
809
- reply(`当前思考等级:${pi.getThinkingLevel()}`);
810
- return;
811
- }
812
- const levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
813
- const wanted = arg.toLowerCase();
814
- if (!levels.includes(wanted)) {
815
- reply(`思考等级只能是:${levels.join(" / ")}`);
816
- return;
817
- }
818
- pi.setThinkingLevel(wanted);
819
- reply(`思考等级已设为 ${wanted}`);
820
- return;
821
- }
822
- default: {
823
- reply(`未知的桥接命令:/qq ${arg ? `${level} ${arg}` : level}`);
824
- }
825
- }
826
- }
1
+ /**
2
+ * Slash-command routing for pi-qqbot.
3
+ *
4
+ * QQ messages that start with `/` are handled in this order:
5
+ *
6
+ * 1. **pi extension commands / prompt templates / skill commands** — anything the
7
+ * running pi instance reports in `pi.getCommands()` (source: extension | prompt |
8
+ * skill). These are dispatched verbatim with
9
+ * `pi.sendUserMessage(text, { expandPromptTemplates: true })`, the only path pi
10
+ * uses to execute extension commands from a message.
11
+ * 2. **pi built-in commands we can take over** (`TAKEOVER`) — pi never dispatches
12
+ * built-ins from a message (only the TUI/RPC layer does), so these are
13
+ * re-implemented through the extension API. The command-only APIs
14
+ * (`reload`, `newSession`, `fork`, `navigateTree`, `switchSession`) need a fresh
15
+ * `ExtensionCommandContext`, so they run inside the `/qq` bridge command
16
+ * registered by this extension.
17
+ * 3. **built-ins we cannot reach** (`HOST_ONLY`) — reply "run it on the host".
18
+ * 4. **anything else** — treated as ordinary chat text (the caller forwards it).
19
+ *
20
+ * Messages starting with `!` / `!!` are not slash commands but they are handled here too,
21
+ * for the same reason: pi only turns them into shell commands in the **TUI / RPC front end**
22
+ * (`interactive-mode` checks `text.startsWith("!")`, RPC has a `bash` control command), and the
23
+ * extension API exposes no equivalent (`pi.sendUserMessage()` does not dispatch them,
24
+ * `pi.exec()` bypasses approval and session history). Forwarding `!ls` to the model would just
25
+ * confuse it, so the bridge answers with an explicit "not supported in QQ" receipt.
26
+ *
27
+ * Selection-style commands (`/tree`, `/fork`, `/resume`) answer with a numbered list
28
+ * and accept the number as the next message; the pending list lives in `BridgeStore`.
29
+ *
30
+ * Everything is opted in conservatively through `commands` in the config file.
31
+ */
32
+
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+
36
+ import { PI_AGENT_DIR } from "./constants";
37
+
38
+ export interface SlashCommandText {
39
+ /** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
40
+ name: string;
41
+ /** raw text after the command name */
42
+ args: string;
43
+ /** the original message */
44
+ raw: string;
45
+ }
46
+
47
+ const SLASH = /^\/([A-Za-z0-9:_-]+)(?:\s+([\s\S]*))?$/;
48
+
49
+ export function parseSlash(text: string): SlashCommandText | null {
50
+ const match = SLASH.exec(text.trim());
51
+ if (!match) return null;
52
+ return { name: match[1]!.toLowerCase(), args: (match[2] ?? "").trim(), raw: text.trim() };
53
+ }
54
+
55
+ export interface BangCommand {
56
+ /** `!` (run + show output to the model) or `!!` (run, keep output out of the context) */
57
+ prefix: "!" | "!!";
58
+ /** whatever followed the prefix */
59
+ command: string;
60
+ }
61
+
62
+ /** `!` / `!!`,全角 `!` / `!!` 也认(中文输入法很容易打出全角) */
63
+ const BANG = /^([!!]{1,2})\s*([\s\S]*\S)\s*$/;
64
+
65
+ /**
66
+ * 识别 pi 的 shell 快捷前缀。只有「前缀 + 非空命令」才算,单独一个 `!`、`!!!`、`你好!` 都退回普通文本,
67
+ * 免得把情绪化感叹号当成命令吞掉。
68
+ */
69
+ export function parseBangCommand(text: string): BangCommand | null {
70
+ const match = BANG.exec(text.trim());
71
+ if (!match) return null;
72
+ const rest = match[2]!.trim();
73
+ // `!!!ls` 这类:第三个别当成命令内容,整体退回普通文本
74
+ if (/^[!!]/.test(rest)) return null;
75
+ return { prefix: match[1]!.replace(/!/g, "!") as "!" | "!!", command: rest };
76
+ }
77
+
78
+ /** `!` / `!!` 的 QQ 侧回执:说清为什么不能执行、以及该怎么做 */
79
+ export function bangNotice(cmd: BangCommand): string {
80
+ return [
81
+ `QQ 侧不支持 ${cmd.prefix} 命令,这一条没有执行。`,
82
+ `${cmd.prefix} 是主机 pi 里的 shell 快捷方式${cmd.prefix === "!!" ? "(`!!` 还会把输出排除出模型上下文)" : ""},` +
83
+ "只有 TUI / RPC 前端认识;扩展 API 没有等价能力,桥接不会假装执行一遍(也不会把这条丢给模型去猜)。",
84
+ "想在本机跑命令:直接用中文说需求(例如「列一下当前目录里的仓库」),我会调 bash 工具执行,执行前在 QQ 里向你申请审批。",
85
+ ].join("\n");
86
+ }
87
+
88
+ export type BridgeSub =
89
+ | "reload"
90
+ | "new"
91
+ | "compact"
92
+ | "quit"
93
+ | "session"
94
+ | "name"
95
+ | "model"
96
+ | "thinking"
97
+ | "tree"
98
+ | "fork"
99
+ | "clone"
100
+ | "resume"
101
+ | "sendfile";
102
+
103
+ interface Takeover {
104
+ sub: string;
105
+ /** the command tears down the current runtime/session: reply before dispatching */
106
+ selfDestroying?: boolean;
107
+ /** unsafe to run while a task is in progress */
108
+ needsIdle?: boolean;
109
+ needsArgs?: boolean;
110
+ hint: string;
111
+ }
112
+
113
+ /**
114
+ * pi built-ins that can be re-implemented with extension APIs.
115
+ * `dist/core/slash-commands.d.ts` marks the full built-in list, but pi does not
116
+ * export it, so this table is maintained here on purpose.
117
+ */
118
+ export const TAKEOVER: Readonly<Record<string, Takeover>> = {
119
+ reload: { sub: "reload", selfDestroying: true, needsIdle: true, hint: "重新加载扩展/技能/模板/主题" },
120
+ update: { sub: "update", needsIdle: true, hint: "在线更新 pi-qqbot 并重载(npm + pi update)" },
121
+ new: { sub: "new", selfDestroying: true, needsIdle: true, hint: "开启新的 pi 会话" },
122
+ compact: { sub: "compact", needsIdle: true, hint: "压缩上下文(可带自定义指令)" },
123
+ quit: { sub: "quit", selfDestroying: true, needsIdle: true, hint: "退出主机上的 pi" },
124
+ session: { sub: "session", hint: "查看当前会话信息与上下文用量" },
125
+ name: { sub: "name", hint: "设置或查看会话名称" },
126
+ model: { sub: "model", hint: "查看/切换模型(provider/model)" },
127
+ thinking: { sub: "thinking", hint: "查看/切换思考等级" },
128
+ tree: { sub: "list tree", needsIdle: true, hint: "在会话树上跳转(回编号选择)" },
129
+ fork: { sub: "list fork", needsIdle: true, hint: "从某条历史用户消息分叉(回编号选择)" },
130
+ resume: { sub: "list resume", needsIdle: true, hint: "切换到本项目的另一个会话(回编号选择)" },
131
+ clone: { sub: "clone", selfDestroying: true, needsIdle: true, hint: "在当前叶子位置复制会话" },
132
+ sendfile: { sub: "sendfile", needsArgs: true, hint: "把本机文件发送到你的 QQ" },
133
+ };
134
+
135
+ /**
136
+ * `/qq <sub>` 里会**换掉运行时**的动作。
137
+ *
138
+ * pi 在它们返回后立刻把旧 `ExtensionCommandContext` 判为 stale:每个 guard getter /
139
+ * 方法都会抛 `This extension ctx is stale after session replacement or reload`。
140
+ * 所以调用方在这几个动作之后不能再碰 ctx(读 `ctx.model` 也算)。
141
+ *
142
+ * `/qq pick` 选 tree / fork / resume 时同样会换运行时,但运行时才知道选的是哪一类,
143
+ * 调用方对它靠 `syncModelFromCtx()` 内部的 try/catch 兜底。
144
+ */
145
+ export const REPLACES_RUNTIME: ReadonlySet<string> = new Set(["reload", "update", "new", "quit", "clone"]);
146
+
147
+ /** pi built-ins that the extension API cannot reach at all. */
148
+ export const HOST_ONLY: ReadonlySet<string> = new Set([
149
+ "settings",
150
+ "hotkeys",
151
+ "changelog",
152
+ "trust",
153
+ "login",
154
+ "logout",
155
+ "scoped-models",
156
+ "llama",
157
+ "import",
158
+ "export",
159
+ "share",
160
+ "bug",
161
+ "copy",
162
+ "privacy",
163
+ ]);
164
+
165
+ export interface CommandConfig {
166
+ /** forward pi extension commands / prompt templates / skill commands */
167
+ passthrough: boolean;
168
+ /** allow /reload (restarts this bot too) */
169
+ allowReload: boolean;
170
+ /** allow /quit (stops the host pi process) */
171
+ allowQuit: boolean;
172
+ /** allow /update (npm + pi update + reload:在线升级 pi-qqbot 自己) */
173
+ allowUpdate: boolean;
174
+ /** allow session-level commands: /new, /compact, /session, /name, /model, /thinking, /tree, /fork, /clone, /resume */
175
+ allowSession: boolean;
176
+ }
177
+
178
+ export const DEFAULT_COMMANDS: CommandConfig = {
179
+ passthrough: true,
180
+ allowReload: false,
181
+ allowQuit: false,
182
+ allowUpdate: false,
183
+ allowSession: true,
184
+ };
185
+
186
+ export interface RouteDeps {
187
+ /** commands pi currently knows about (extension/prompt/skill) */
188
+ listPiCommands(): readonly { name: string; source?: string }[];
189
+ /**
190
+ * deliver a pi command through pi's own dispatcher.
191
+ * `runsTurn` marks commands that start an agent run (prompt templates, `/skill:name`):
192
+ * those are serialized through the bot's queue, everything else runs immediately.
193
+ * Returns "queued" when the bot deferred it, "rejected" when the queue was full.
194
+ */
195
+ dispatch(text: string, options?: { runsTurn?: boolean }): DispatchResult;
196
+ /** answer the QQ user */
197
+ /** 回用户一条消息(返回值忽略,允许实现返回是否送达) */
198
+ reply(text: string): Promise<unknown>;
199
+ busy: boolean;
200
+ cfg: CommandConfig;
201
+ log(message: string, ...rest: unknown[]): void;
202
+ }
203
+
204
+ export type DispatchResult = "dispatched" | "queued" | "rejected";
205
+
206
+ /**
207
+ * 输出只走 `ctx.ui.*`(TUI / RPC 通道)的扩展命令。
208
+ *
209
+ * 这类命令在 QQ 里发了不会有任何回复:它们的结果是通过 `ctx.ui.notify()` 渲染的,
210
+ * 而本桥接只把 assistant 文本(`message_update.text_delta`)转发到 QQ。
211
+ *
212
+ * - 带 `bridge`:改派 `/qq <bridge> <args>`,由桥接命令重新执行原命令并读 session 里
213
+ * 的 custom entry,自行渲染回 QQ(用户用法不变,仍发 `/usage today`)。
214
+ * - 不带 `bridge`:需要主机 TUI 的交互(选择框/浏览器登录),直接回执说清楚,不能沉默。
215
+ *
216
+ * 必须在 passthrough **之前**判断:这些命令确实在 `pi.getCommands()` 里,
217
+ * 一旦先走 passthrough 就会被派发到黑洞。
218
+ */
219
+ export const UI_ONLY: Readonly<Record<string, { bridge?: string; hint: string }>> = {
220
+ usage: { bridge: "usage", hint: "用量统计会改走 /qq usage 桥接" },
221
+ cost: { bridge: "usage", hint: "用量统计会改走 /qq usage 桥接" },
222
+ mcp: { hint: "MCP 管理需要主机 TUI 的选择框与浏览器登录" },
223
+ llama: { hint: "本地 llama 管理需要主机 TUI" },
224
+ };
225
+
226
+ export type RouteResult = "dispatched" | "handled" | "text";
227
+
228
+ const allowed = (cfg: CommandConfig, name: string): boolean => {
229
+ if (name === "reload") return cfg.allowReload;
230
+ if (name === "quit") return cfg.allowQuit;
231
+ if (name === "update") return cfg.allowUpdate;
232
+ // 发文件不属于会话操作:由 files.enabled + QQ 审批把关
233
+ if (name === "sendfile") return true;
234
+ return cfg.allowSession;
235
+ };
236
+
237
+ /** Route one slash message. Returns "text" when it should be treated as chat. */
238
+ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteResult> {
239
+ // 0. `!` / `!!`:主机前端的 shell 快捷方式,扩展 API 够不到 → 明确回执,别当聊天丢给模型
240
+ const bang = parseBangCommand(text);
241
+ if (bang) {
242
+ deps.log("[cmd] 收到不支持的 shell 前缀:", bang.prefix, bang.command.slice(0, 60));
243
+ await deps.reply(bangNotice(bang));
244
+ return "handled";
245
+ }
246
+
247
+ const parsed = parseSlash(text);
248
+ if (!parsed) return "text";
249
+ const { name } = parsed;
250
+
251
+ // 0. 输出只走 TUI 通道的扩展命令:桥接或明确回执(必须早于 passthrough)
252
+ const uiOnly = UI_ONLY[name];
253
+ if (uiOnly && deps.listPiCommands().some((c) => c.name.toLowerCase() === name)) {
254
+ if (uiOnly.bridge) {
255
+ const bridge = `/qq ${uiOnly.bridge}${parsed.args ? ` ${parsed.args}` : ""}`;
256
+ deps.log("[cmd] 桥接 UI-only 命令:", parsed.raw.slice(0, 60), "->", bridge.slice(0, 60));
257
+ deps.dispatch(bridge);
258
+ return "dispatched";
259
+ }
260
+ await deps.reply(`/${name} ${uiOnly.hint},QQ 侧无法执行。`);
261
+ return "handled";
262
+ }
263
+
264
+ // 1. pi extension commands, prompt templates and skill commands
265
+ if (deps.cfg.passthrough) {
266
+ const known = deps.listPiCommands().find((c) => c.name.toLowerCase() === name);
267
+ if (known) {
268
+ // 提示模板与 /skill: 会开启一轮 agent run,交给队列串行;扩展命令自己管理 LLM 交互
269
+ const runsTurn = known.source === "prompt" || known.source === "skill";
270
+ deps.log("[cmd] 派发 pi 命令:", parsed.raw.slice(0, 80), "runsTurn=", runsTurn);
271
+ const result = deps.dispatch(parsed.raw, { runsTurn });
272
+ if (result === "queued") {
273
+ await deps.reply(`已排队 /${name}:当前任务结束后执行(发 /queue 查看队列)。`);
274
+ } else if (result === "rejected") {
275
+ await deps.reply(`队列已满,/${name} 没有排上。发 /queue 查看队列,或 /cancel 清空后重发。`);
276
+ } else {
277
+ await deps.reply(`已派发 /${name} 给 pi。`);
278
+ }
279
+ return "dispatched";
280
+ }
281
+ }
282
+
283
+ // 2. built-ins we can take over
284
+ const takeover = TAKEOVER[name];
285
+ if (takeover) {
286
+ if (!allowed(deps.cfg, name)) {
287
+ const gate = name === "reload" ? "allowReload" : name === "quit" ? "allowQuit" : "allowSession";
288
+ await deps.reply(`/${name} 在 QQ 侧被禁用(配置 commands.${gate})。`);
289
+ return "handled";
290
+ }
291
+ if (takeover.needsIdle && deps.busy) {
292
+ await deps.reply(`当前有任务在跑,先发 /cancel 取消,再执行 /${name}。`);
293
+ return "handled";
294
+ }
295
+ const bridge = `/qq ${takeover.sub}${parsed.args ? ` ${parsed.args}` : ""}`;
296
+ deps.log("[cmd] 接管内置命令:", parsed.raw.slice(0, 80), "->", bridge.slice(0, 80));
297
+ if (takeover.selfDestroying) {
298
+ // the command tears the runtime down; answer before it lands
299
+ await deps.reply(`已请求 /${name}(${takeover.hint})…`);
300
+ deps.dispatch(bridge);
301
+ return "dispatched";
302
+ }
303
+ deps.dispatch(bridge);
304
+ return "dispatched";
305
+ }
306
+
307
+ // 3. built-ins only the host TUI can run
308
+ if (HOST_ONLY.has(name)) {
309
+ await deps.reply(`/${name} 只能在主机上的 pi 里执行(扩展 API 没有对应能力)。`);
310
+ return "handled";
311
+ }
312
+
313
+ // 4. not a command we know: ordinary text
314
+ return "text";
315
+ }
316
+
317
+ const MAX_LIST = 12;
318
+
319
+ function formatTokens(value: number | null | undefined): string {
320
+ if (typeof value !== "number" || !Number.isFinite(value)) return "?";
321
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
322
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
323
+ return String(value);
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // pickers (tree / fork / resume / model)
328
+ // ---------------------------------------------------------------------------
329
+
330
+ export type PickerKind = "tree" | "fork" | "resume" | "model";
331
+
332
+ export interface PickerItem {
333
+ /** entry id (tree/fork) */
334
+ id?: string;
335
+ /** session file (resume) */
336
+ path?: string;
337
+ /** what the user sees next to the number */
338
+ label: string;
339
+ }
340
+
341
+ export interface PickerState {
342
+ kind: PickerKind;
343
+ items: PickerItem[];
344
+ /** only this QQ user may answer with a number */
345
+ openid: string | null;
346
+ createdAt: number;
347
+ }
348
+
349
+ /** Shared mutable state between the router (index.ts) and the bridge command. */
350
+ export interface BridgeStore {
351
+ picker: PickerState | null;
352
+ /** set by the router right before dispatching a `list` command */
353
+ pickerOwner: string | null;
354
+ }
355
+
356
+ export const PICKER_TTL_MS = 10 * 60_000;
357
+ export const PICKER_MAX_ITEMS = 10;
358
+
359
+ const KIND_LABEL: Record<PickerKind, string> = {
360
+ tree: "会话树节点",
361
+ fork: "可分叉的用户消息",
362
+ resume: "本项目的其他会话",
363
+ model: "新接入服务的模型",
364
+ };
365
+
366
+ function previewText(content: unknown): string {
367
+ const parts = Array.isArray(content) ? content : [];
368
+ const text = parts
369
+ .map((part: any) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
370
+ .join(" ")
371
+ .replace(/\s+/g, " ")
372
+ .trim();
373
+ return text.length > 50 ? `${text.slice(0, 50)}…` : text || "(无文本)";
374
+ }
375
+
376
+ const stamp = (value: unknown): string => {
377
+ const date = typeof value === "string" || typeof value === "number" ? new Date(value) : new Date();
378
+ if (Number.isNaN(date.getTime())) return "?";
379
+ const pad = (n: number) => String(n).padStart(2, "0");
380
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
381
+ };
382
+
383
+ function branchItems(ctx: any): PickerItem[] {
384
+ let branch: any[] = [];
385
+ try {
386
+ branch = ctx.sessionManager.getBranch() ?? [];
387
+ } catch {
388
+ branch = [];
389
+ }
390
+ return branch
391
+ .filter((entry) => entry?.type === "message" && entry?.message?.role === "user")
392
+ .slice(-PICKER_MAX_ITEMS)
393
+ .map((entry) => ({
394
+ id: entry.id,
395
+ label: `${stamp(entry.timestamp)} ${previewText(entry.message.content)}`,
396
+ }));
397
+ }
398
+
399
+ function resumeItems(ctx: any): PickerItem[] {
400
+ let dir: string | undefined;
401
+ let current: string | undefined;
402
+ try {
403
+ dir = ctx.sessionManager.getSessionDir?.();
404
+ current = ctx.sessionManager.getSessionFile?.();
405
+ } catch {
406
+ return [];
407
+ }
408
+ if (!dir) return [];
409
+ let names: string[] = [];
410
+ try {
411
+ names = fs.readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
412
+ } catch {
413
+ return [];
414
+ }
415
+ return names
416
+ .map((name) => {
417
+ const full = path.join(dir!, name);
418
+ let mtime = 0;
419
+ let size = 0;
420
+ try {
421
+ const stat = fs.statSync(full);
422
+ mtime = stat.mtimeMs;
423
+ size = stat.size;
424
+ } catch {
425
+ // unreadable: keep it out of the ranking
426
+ }
427
+ return { full, name, mtime, size };
428
+ })
429
+ .filter((row) => row.mtime > 0 && row.full !== current)
430
+ .sort((a, b) => b.mtime - a.mtime)
431
+ .slice(0, PICKER_MAX_ITEMS)
432
+ .map((row) => ({
433
+ path: row.full,
434
+ label: `${stamp(row.mtime)} ${Math.max(1, Math.round(row.size / 1024))}KB ${row.name.replace(/\.jsonl$/, "").slice(0, 44)}`,
435
+ }));
436
+ }
437
+
438
+ function buildPickerItems(kind: PickerKind, ctx: any): PickerItem[] {
439
+ if (kind === "resume") return resumeItems(ctx);
440
+ return branchItems(ctx);
441
+ }
442
+
443
+ function formatPicker(picker: PickerState): string {
444
+ const header = `选择${KIND_LABEL[picker.kind]}(${picker.items.length} 项):`;
445
+ const rows = picker.items.map((item, index) => ` ${index + 1}. ${item.label}`);
446
+ return [
447
+ header,
448
+ ...rows,
449
+ `回复编号执行,或回复「取消」放弃(${Math.round(PICKER_TTL_MS / 60000)} 分钟内有效)。`,
450
+ ].join("\n");
451
+ }
452
+
453
+ /** Parse a bare "3" reply that answers the pending picker. */
454
+ export function parsePickIndex(text: string): number | null {
455
+ const match = /^\s*(\d{1,3})\s*$/.exec(text);
456
+ if (!match) return null;
457
+ const value = Number(match[1]);
458
+ return Number.isFinite(value) && value > 0 ? value : null;
459
+ }
460
+
461
+ export function pickerExpired(picker: PickerState | null): boolean {
462
+ return !picker || Date.now() - picker.createdAt > PICKER_TTL_MS;
463
+ }
464
+
465
+ /** pi-usage-report 写入 session 的 custom entry 类型(耦合点,约定值) */
466
+ const USAGE_ENTRY_TYPE = "usage-report";
467
+
468
+ /**
469
+ * appendEntry 的写入比 `sendUserMessage` 的 resolve 晚(实测 promise 183ms 返回,
470
+ * entry 在之后 ~100ms 才出现在 getEntries 里),所以必须轮询而不是只读一次。
471
+ */
472
+ const USAGE_WAIT_TIMEOUT_MS = 2000;
473
+ const USAGE_POLL_INTERVAL_MS = 100;
474
+
475
+ /** pi-usage-report 的配置/汇率缓存(读回来确认:命令真的生效了,而不是口头应下) */
476
+ const USAGE_SETTINGS_FILE = "pi-usage-report.json";
477
+ const USAGE_RATE_FILE = "pi-usage-report-rate.json";
478
+
479
+ const USAGE_HELP = [
480
+ "/usage(默认 today)",
481
+ "/usage today | yesterday | week | lastweek | month | lastmonth | all",
482
+ "/usage 7d | 12h | 2w | 3m | 10天 滚动窗口",
483
+ "/usage 2026-09-01..2026-09-30 本地日期区间(含首尾)",
484
+ "/usage 今天 | 昨天 | 本周 | 上周 | 本月 | 上月 | 全部",
485
+ "/usage rate <值> | rate auto 固定 USD→本币汇率,或恢复自动(会真的写配置)",
486
+ ].join("\n");
487
+
488
+ function readAgentJson(file: string): any {
489
+ try {
490
+ return JSON.parse(fs.readFileSync(path.join(PI_AGENT_DIR, file), "utf8"));
491
+ } catch {
492
+ return undefined;
493
+ }
494
+ }
495
+
496
+ const CURRENCY_SYMBOLS: Record<string, string> = { CNY: "¥", USD: "$", EUR: "€", JPY: "¥", GBP: "£", HKD: "HK$" };
497
+
498
+ /** 复刻 pi-usage-report 的 readSettings 优先级:fixed(usdRate) > 缓存的发布汇率 > 无 */
499
+ function usageMoney(): { code: string; symbol: string; rate: number | null; source: string } {
500
+ const raw = readAgentJson(USAGE_SETTINGS_FILE) ?? {};
501
+ const code = String(raw.currency ?? "CNY").toUpperCase();
502
+ const symbol = typeof raw.symbol === "string" ? raw.symbol : (CURRENCY_SYMBOLS[code] ?? code);
503
+ const pinned = typeof raw.usdRate === "number" && raw.usdRate > 0 ? raw.usdRate : undefined;
504
+ const fixed = raw.rateMode === "auto" ? undefined : pinned;
505
+ if (fixed !== undefined) return { code, symbol, rate: fixed, source: "fixed,/usage rate 手动固定" };
506
+ const cache = readAgentJson(USAGE_RATE_FILE);
507
+ if (cache && cache.code === code && typeof cache.rate === "number" && cache.rate > 0) {
508
+ const ageMinutes = Math.max(0, Math.round((Date.now() - Number(cache.fetchedAt ?? 0)) / 60000));
509
+ return { code, symbol, rate: cache.rate, source: `cache,${ageMinutes} 分钟前抓取` };
510
+ }
511
+ return { code, symbol, rate: null, source: "无,可用 /usage rate <值>" };
512
+ }
513
+
514
+ function moneyText(money: { code: string; rate: number | null; source: string }): string {
515
+ return money.rate === null
516
+ ? `USD/${money.code}:无可用汇率(${money.source})`
517
+ : `USD/${money.code} = ${money.rate}(${money.source})`;
518
+ }
519
+
520
+ function sessionEntries(ctx: any): any[] {
521
+ try {
522
+ return ctx?.sessionManager?.getEntries?.() ?? [];
523
+ } catch {
524
+ return [];
525
+ }
526
+ }
527
+
528
+ function sessionFile(ctx: any): string | undefined {
529
+ try {
530
+ return ctx?.sessionManager?.getSessionFile?.() ?? undefined;
531
+ } catch {
532
+ return undefined;
533
+ }
534
+ }
535
+
536
+ /** 兼容三种时间形态:毫秒数字(pi.appendEntry 实际写的就是它)/ ISO 字符串 / Date */
537
+ function entryTime(value: unknown): number {
538
+ if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
539
+ if (value instanceof Date) return value.getTime();
540
+ if (typeof value === "string" && value) return Date.parse(value);
541
+ return NaN;
542
+ }
543
+
544
+ /**
545
+ * 从会话文件尾部找最近一条 usage-report(generatedAt 不早于 since)。
546
+ *
547
+ * 为什么需要这条路:appendEntry 是**另一个扩展**写的,命令上下文里的
548
+ * `sessionManager.getEntries()` 不一定即时反映(实测 `/usage today` 已经写入了
549
+ * 会话文件,但 getEntries 看不到新增项)。数据库不会骗人,文件会。
550
+ */
551
+ function readUsageEntryFromFile(file: string | undefined, since: number): any | undefined {
552
+ if (!file) return undefined;
553
+ try {
554
+ const size = fs.statSync(file).size;
555
+ const chunk = Math.min(size, 512 * 1024);
556
+ if (chunk <= 0) return undefined;
557
+ const fd = fs.openSync(file, "r");
558
+ let text = "";
559
+ try {
560
+ const buf = Buffer.alloc(chunk);
561
+ fs.readSync(fd, buf, 0, chunk, size - chunk);
562
+ text = buf.toString("utf8");
563
+ } finally {
564
+ fs.closeSync(fd);
565
+ }
566
+ const lines = text.split("\n");
567
+ for (let i = lines.length - 1; i >= 0; i--) {
568
+ const line = lines[i].trim();
569
+ if (!line.startsWith("{")) continue;
570
+ let entry: any;
571
+ try {
572
+ entry = JSON.parse(line);
573
+ } catch {
574
+ continue;
575
+ }
576
+ if (!isUsageEntry(entry)) continue;
577
+ const when = entryTime(entry?.data?.generatedAt ?? entry?.timestamp);
578
+ return Number.isFinite(when) && when >= since ? entry : undefined;
579
+ }
580
+ } catch {
581
+ return undefined;
582
+ }
583
+ return undefined;
584
+ }
585
+
586
+ const intText = (value: unknown): string =>
587
+ typeof value === "number" && Number.isFinite(value) ? Math.round(value).toLocaleString("en-US") : "0";
588
+
589
+ function localStamp(value: unknown): string {
590
+ if (typeof value !== "string") return "?";
591
+ const date = new Date(value);
592
+ if (Number.isNaN(date.getTime())) return "?";
593
+ const pad = (n: number) => String(n).padStart(2, "0");
594
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
595
+ }
596
+
597
+ const isUsageEntry = (entry: any): boolean =>
598
+ entry?.type === "custom" && entry?.customType === USAGE_ENTRY_TYPE;
599
+
600
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
601
+
602
+ /**
603
+ * 等本次 /usage 写的报告出现:每 100ms 查一次 getEntries(最多 2s),
604
+ * 全程未命中再试一次会话文件(custom entry 可能很晚才落盘)。
605
+ */
606
+ async function waitForUsageEntry(
607
+ ctx: any,
608
+ opts: { before: Set<unknown>; since: number; file?: string },
609
+ ): Promise<any | undefined> {
610
+ const deadline = Date.now() + USAGE_WAIT_TIMEOUT_MS;
611
+ while (Date.now() < deadline) {
612
+ const fresh = [...sessionEntries(ctx)].reverse().find((entry: any) => isUsageEntry(entry) && !opts.before.has(entry?.id));
613
+ if (fresh) return fresh;
614
+ await sleep(USAGE_POLL_INTERVAL_MS);
615
+ }
616
+ return readUsageEntryFromFile(opts.file, opts.since);
617
+ }
618
+
619
+ /** 把 pi-usage-report 的 ReportData 渲染成 QQ 可读的纯文本 */
620
+ function formatUsage(data: any): string {
621
+ if (!data?.totals) return "用量报告为空。";
622
+ const t = data.totals;
623
+ const money = data.money ?? {};
624
+ const usd = Number(t.costUsd ?? 0);
625
+ const rate = typeof money.rate === "number" ? money.rate : null;
626
+ const symbol = money.symbol ?? "$";
627
+ const costText = rate !== null ? `${symbol}${(usd * rate).toFixed(2)}(USD ${usd.toFixed(4)},汇率 ${rate})` : `USD ${usd.toFixed(4)}(无汇率)`;
628
+ const totalTokens = Number(t.totalTokens ?? 0);
629
+ const cachePct = totalTokens > 0 ? ((Number(t.cacheRead ?? 0) / totalTokens) * 100).toFixed(1) : "0.0";
630
+ const lines = [
631
+ `用量统计:${data.rangeLabel ?? "?"} ${localStamp(data.from)} → ${localStamp(data.to)}`,
632
+ `请求 ${intText(t.requests)} 次 合计 ${formatTokens(totalTokens)} tokens 费用 ${costText}`,
633
+ `输入 ${intText(t.input)} 输出 ${intText(t.output)} 缓存读 ${intText(t.cacheRead)}(${cachePct}%)`,
634
+ `缓存写 ${intText(t.cacheWrite)} 推理 ${intText(t.reasoning)}`,
635
+ ];
636
+ const models: any[] = Array.isArray(data.byModel) ? data.byModel : [];
637
+ if (models.length > 0) {
638
+ lines.push("按模型:");
639
+ for (const row of models.slice(0, 5)) {
640
+ const bucket = row?.bucket ?? {};
641
+ const rowUsd = Number(bucket.costUsd ?? 0);
642
+ const rowCost = rate !== null ? `${symbol}${(rowUsd * rate).toFixed(2)}` : `USD ${rowUsd.toFixed(4)}`;
643
+ lines.push(
644
+ ` ${row?.model ?? "unknown"} ${intText(bucket.requests)} 次 ${formatTokens(Number(bucket.totalTokens ?? 0))} tokens ${rowCost}`,
645
+ );
646
+ }
647
+ }
648
+ const sessions: any[] = Array.isArray(data.bySession) ? data.bySession : [];
649
+ if (sessions.length > 1) lines.push(`会话数:${sessions.length} 个`);
650
+ if (t.unpriced) lines.push(`注:${intText(t.unpriced)} 次请求没有计价。`);
651
+ return lines.join("\n");
652
+ }
653
+
654
+ /**
655
+ * Body of the `/qq` bridge command: pi hands it a fresh `ExtensionCommandContext`,
656
+ * which is the only place `reload()`, `newSession()`, `fork()`, `navigateTree()` and
657
+ * `switchSession()` are legal.
658
+ *
659
+ * Session-replacing actions (`reload`, `new`, `tree`/`fork`/`resume` picks, `clone`)
660
+ * tear this runtime down; the caller answers the QQ user before dispatching them.
661
+ */
662
+ export async function runBridge(
663
+ sub: string,
664
+ arg: string,
665
+ ctx: any,
666
+ pi: any,
667
+ reply: (text: string) => void,
668
+ store?: BridgeStore,
669
+ ): Promise<void> {
670
+ const level = String(sub ?? "").toLowerCase();
671
+ const first = arg.split(/\s+/)[0]?.toLowerCase() ?? "";
672
+
673
+ switch (level) {
674
+ case "reload": {
675
+ await ctx.reload();
676
+ return;
677
+ }
678
+ case "update": {
679
+ // 完整流程在 selfupdate.ts:检查 npm → pi update → 校验版本 → ctx.reload()
680
+ const { runSelfUpdate, installedVersion, checkLatestVersion, applyUpdate } = await import("./selfupdate");
681
+ await runSelfUpdate({
682
+ currentVersion: installedVersion(),
683
+ checkLatest: () => checkLatestVersion(),
684
+ applyUpdate: () => applyUpdate(),
685
+ versionAfterUpdate: () => installedVersion(),
686
+ reply: (text) => Promise.resolve(reply(text)),
687
+ reload: () => ctx.reload(),
688
+ });
689
+ return;
690
+ }
691
+ case "new": {
692
+ await ctx.newSession();
693
+ return;
694
+ }
695
+ case "quit": {
696
+ reply("正在退出主机上的 pi…");
697
+ ctx.shutdown();
698
+ return;
699
+ }
700
+ case "compact": {
701
+ reply(arg ? `开始压缩上下文(指令:${arg.slice(0, 80)})…` : "开始压缩上下文…");
702
+ ctx.compact({
703
+ customInstructions: arg || undefined,
704
+ onComplete: () => reply("上下文压缩完成。"),
705
+ onError: (error: Error) => reply(`上下文压缩失败:${String(error?.message ?? error).slice(0, 200)}`),
706
+ });
707
+ return;
708
+ }
709
+ case "list": {
710
+ const kind = first as PickerKind;
711
+ if (kind !== "tree" && kind !== "fork" && kind !== "resume") {
712
+ reply(`未知列表类型:${first || "(空)"}`);
713
+ return;
714
+ }
715
+ const items = buildPickerItems(kind, ctx);
716
+ if (items.length === 0) {
717
+ reply(`没有可用的${KIND_LABEL[kind]}。`);
718
+ return;
719
+ }
720
+ const picker: PickerState = { kind, items, openid: store?.pickerOwner ?? null, createdAt: Date.now() };
721
+ if (store) store.picker = picker;
722
+ reply(formatPicker(picker));
723
+ return;
724
+ }
725
+ case "pick": {
726
+ const index = Number.parseInt(first, 10);
727
+ const picker = store?.picker ?? null;
728
+ if (!picker || !Number.isFinite(index)) {
729
+ reply("选择列表已失效,请重新发送命令。");
730
+ return;
731
+ }
732
+ const item = picker.items[index - 1];
733
+ if (!item) {
734
+ reply(`编号 ${first} 超出范围(1-${picker.items.length})。`);
735
+ return;
736
+ }
737
+ if (store) store.picker = null;
738
+ // 欠费接手:清单是「provider/model」,选了就切过去
739
+ if (picker.kind === "model") {
740
+ const [provider, ...rest] = String(item.id ?? "").split("/");
741
+ const modelId = rest.join("/");
742
+ const found = provider && modelId ? ctx.modelRegistry?.find?.(provider, modelId) : undefined;
743
+ if (!found) {
744
+ reply(`模型 ${item.id ?? "?"} 已不在目录里(models.json 可能改过),发 /model 重新看看。`);
745
+ return;
746
+ }
747
+ const ok = await pi.setModel(found);
748
+ reply(
749
+ ok
750
+ ? `已切换到 ${provider}/${modelId},直接发问题就行。`
751
+ : `切换到 ${provider}/${modelId} 失败(这个服务没有凭据?)`,
752
+ );
753
+ return;
754
+ }
755
+ if (picker.kind === "tree") {
756
+ reply(`正在跳转到第 ${index} 个节点…`);
757
+ await ctx.navigateTree(item.id);
758
+ return;
759
+ }
760
+ if (picker.kind === "fork") {
761
+ reply(`正在从第 ${index} 条消息分叉…`);
762
+ await ctx.fork(item.id);
763
+ return;
764
+ }
765
+ reply(`正在切换会话:${item.label.slice(0, 44)}…`);
766
+ await ctx.switchSession(item.path);
767
+ return;
768
+ }
769
+ case "clone": {
770
+ let leaf: string | null = null;
771
+ try {
772
+ leaf = ctx.sessionManager.getLeafId?.() ?? null;
773
+ } catch {
774
+ leaf = null;
775
+ }
776
+ if (!leaf) {
777
+ reply("当前会话还没有可复制的位置(先发一条消息)。");
778
+ return;
779
+ }
780
+ reply("正在当前叶子位置复制会话…");
781
+ await ctx.fork(leaf, { position: "at" });
782
+ return;
783
+ }
784
+ case "usage": {
785
+ // /qq usage [...]:重跑原 /usage 命令,再从 session 里读回它写的 custom entry
786
+ const rest = arg.slice(first.length).trim();
787
+
788
+ // help:不执行命令(它在 TUI 打文本),直接回本地帮助
789
+ if (first === "help" || first === "?") {
790
+ reply(USAGE_HELP);
791
+ return;
792
+ }
793
+
794
+ const commands = (pi?.getCommands?.() ?? []) as { name: string }[];
795
+ const hasUsage = commands.some((c) => ["usage", "cost"].includes(String(c.name).toLowerCase()));
796
+ if (!hasUsage) {
797
+ reply("主机上没有 /usage 命令(需要安装 pi-usage-report),无法统计。");
798
+ return;
799
+ }
800
+
801
+ // rate:会写配置/联网刷新汇率,必须真的执行,再读回状态确认(不能只回一句“TUI 可见”)
802
+ if (first === "rate") {
803
+ if (!rest) {
804
+ reply(moneyText(usageMoney()));
805
+ return;
806
+ }
807
+ const rateCommand = `/usage rate ${rest}`;
808
+ try {
809
+ await pi.sendUserMessage(rateCommand, { expandPromptTemplates: true });
810
+ } catch (error) {
811
+ reply(`执行 ${rateCommand} 失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
812
+ return;
813
+ }
814
+ const after = usageMoney();
815
+ reply(
816
+ `已执行 ${rateCommand}\n${moneyText(after)}\n(下一步 /usage today 的 ¥/USD 换算就用它;无效值会被忽略)`,
817
+ );
818
+ return;
819
+ }
820
+
821
+ const command = `/usage${arg ? ` ${arg}` : ""}`;
822
+ const since = Date.now();
823
+ const before = new Set(sessionEntries(ctx).map((entry: any) => entry?.id));
824
+ const file = sessionFile(ctx);
825
+ reply(`正在统计用量(${command})…`);
826
+ try {
827
+ await pi.sendUserMessage(command, { expandPromptTemplates: true });
828
+ } catch (error) {
829
+ reply(`统计失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
830
+ return;
831
+ }
832
+ // 先轮询 getEntries 的增量(appendEntry 晚于 await 返回),超时再退回读会话文件
833
+ const entry = await waitForUsageEntry(ctx, { before, since, file });
834
+ if (!entry) {
835
+ reply("没拿到用量报告:/usage 已执行但找不到报告数据(可重试一次;若反复出现请把 pi-qqbot.log 尾部发我)。");
836
+ return;
837
+ }
838
+ reply(formatUsage(entry.data));
839
+ return;
840
+ }
841
+ case "session": {
842
+ const sm = ctx.sessionManager;
843
+ const usage = ctx.getContextUsage?.();
844
+ const model = ctx.model;
845
+ const entries = (() => {
846
+ try {
847
+ return sm.getEntries().length;
848
+ } catch {
849
+ return "?";
850
+ }
851
+ })();
852
+ reply(
853
+ [
854
+ `会话:${sm.getSessionName?.() || "(未命名)"}`,
855
+ `id:${sm.getSessionId?.() ?? "?"}`,
856
+ `cwd:${sm.getCwd?.() ?? ctx.cwd ?? "?"}`,
857
+ `模型:${model ? `${model.provider}/${model.id}` : "(未设置)"} 思考:${ctx.thinkingLevel ?? "?"}`,
858
+ `条目:${entries} 上下文:${
859
+ usage && usage.tokens !== undefined
860
+ ? `${formatTokens(usage.tokens)}${usage.percent !== undefined ? ` (${Math.round(usage.percent)}%)` : ""}`
861
+ : "未知"
862
+ }`,
863
+ ].join("\n"),
864
+ );
865
+ return;
866
+ }
867
+ case "name": {
868
+ if (!arg) {
869
+ reply(`当前会话名:${ctx.sessionManager.getSessionName?.() || "(未命名)"}`);
870
+ return;
871
+ }
872
+ pi.setSessionName(arg);
873
+ reply(`会话名已设为:${arg}`);
874
+ return;
875
+ }
876
+ case "model": {
877
+ const registry = ctx.modelRegistry;
878
+ const available = registry?.getAvailable?.() ?? [];
879
+ const current = ctx.model;
880
+ if (!arg) {
881
+ const names = available.slice(0, MAX_LIST).map((m: any) => `${m.provider}/${m.id}`);
882
+ reply(
883
+ [
884
+ `当前模型:${current ? `${current.provider}/${current.id}` : "(未设置)"}`,
885
+ available.length > 0
886
+ ? `可选(${available.length}):${names.join(", ")}${available.length > MAX_LIST ? " …" : ""}`
887
+ : "没有可用模型",
888
+ ].join("\n"),
889
+ );
890
+ return;
891
+ }
892
+ const wanted = arg.toLowerCase();
893
+ const found = available.find((m: any) => `${m.provider}/${m.id}`.toLowerCase() === wanted)
894
+ ?? available.find((m: any) => String(m.id).toLowerCase() === wanted);
895
+ if (!found) {
896
+ reply(`找不到模型 ${arg}。发 /model 查看可用列表。`);
897
+ return;
898
+ }
899
+ const ok = await pi.setModel(found);
900
+ reply(ok ? `模型已切换为 ${found.provider}/${found.id}` : `切换到 ${found.provider}/${found.id} 失败(缺少凭据?)`);
901
+ return;
902
+ }
903
+ case "thinking": {
904
+ if (!arg) {
905
+ reply(`当前思考等级:${pi.getThinkingLevel()}`);
906
+ return;
907
+ }
908
+ const levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
909
+ const wanted = arg.toLowerCase();
910
+ if (!levels.includes(wanted)) {
911
+ reply(`思考等级只能是:${levels.join(" / ")}`);
912
+ return;
913
+ }
914
+ pi.setThinkingLevel(wanted);
915
+ reply(`思考等级已设为 ${wanted}`);
916
+ return;
917
+ }
918
+ default: {
919
+ reply(`未知的桥接命令:/qq ${arg ? `${level} ${arg}` : level}`);
920
+ }
921
+ }
922
+ }