@zxh126/pi-qqbot 0.3.0 → 1.1.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
@@ -15,7 +15,8 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
15
15
  | **Session-scoped "always allow"** | Reply 「以后都同意」 and every approval request in the **current session** is auto-approved, including the one already waiting. |
16
16
  | **Single-instance lock** | Only one pi process hosts the QQ gateway; the others stand by and take over automatically when the host exits. |
17
17
  | **Cancel a running task** | Send `/cancel` (or 「取消」/「停止」) and the current run is aborted immediately — the QQ reply, the pending tool calls and the half-written answer are all cleaned up. |
18
- | **Built-in commands** | `/new`, `/status`, `/cancel`, `/help`. |
18
+ | **Slash-command routing** | QQ messages starting with `/` are routed: pi extension commands, prompt templates and `/skill:name` are dispatched to pi; a set of pi built-ins (`/reload`, `/new`, `/compact`, `/session`, `/name`, `/model`, `/thinking`, `/quit`) is taken over; host-only commands are answered instead of being fed to the model. |
19
+ | **Built-in commands** | `/status`, `/reset`, `/cancel`, `/help`. |
19
20
 
20
21
  ## Install
21
22
 
@@ -54,6 +55,7 @@ Then create `~/.pi/agent/pi-qqbot.json`:
54
55
  | `streaming` | `false` | Reserved, streaming is not enabled in v1 |
55
56
  | `approve.tools` | `["bash","powershell","write","edit"]` | Tools that require approval; tools annotated `destructiveHint` are always gated too |
56
57
  | `approve.remember` | `true` | Remember the decision per tool inside the session (no more prompts for that tool) |
58
+ | `commands` | see below | Slash-command routing gates: `passthrough`, `allowReload`, `allowQuit`, `allowSession` |
57
59
 
58
60
  Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables.
59
61
 
@@ -63,11 +65,56 @@ Start pi, then message the bot in QQ; pi handles it and replies.
63
65
 
64
66
  | Command | Description |
65
67
  |---|---|
66
- | `/new` | Reset: clear the reply buffer, approval memory and session-wide auto-approve |
68
+ | `/reset` | Clear the reply buffer, approval memory and session-wide auto-approve (**does not** switch sessions — send `/new` for that) |
67
69
  | `/status` | Show busy/idle state, auto-approve state and pending approval count |
68
70
  | `/cancel` | **Interrupt the task that is running right now** (aliases: `/stop`, `/abort`, 「取消」, 「停止」, 「中止」, 「终止」, 「算了」) |
69
71
  | `/help` | Show help |
70
72
 
73
+ ### Slash commands from QQ
74
+
75
+ Any message that starts with `/` is routed before it can become a chat prompt:
76
+
77
+ | # | Kind | Behaviour |
78
+ |---|---|---|
79
+ | 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 |
80
+ | 2 | pi built-ins this bridge can take over | re-implemented through the extension API (table below) |
81
+ | 3 | pi built-ins that only exist in the TUI | answered with "run it on the host", never sent to the model |
82
+ | 4 | everything else (`/tmp 里有什么`, `/etc 看看` …) | ordinary chat |
83
+
84
+ | QQ command | What happens | Gate |
85
+ |---|---|---|
86
+ | `/reload` | reloads extensions / skills / templates / themes — **the bot restarts too** and reconnects the gateway | `commands.allowReload` (default **false**) |
87
+ | `/new` | real new pi session (`ctx.newSession()`) | `commands.allowSession` |
88
+ | `/compact [instructions]` | compacts the context | `commands.allowSession` |
89
+ | `/session` | session id / cwd / model / thinking level / context usage | `commands.allowSession` |
90
+ | `/name [name]` | get or set the session name | `commands.allowSession` |
91
+ | `/model [provider/model]` | list available models or switch | `commands.allowSession` |
92
+ | `/thinking [level]` | get or set the thinking level | `commands.allowSession` |
93
+ | `/tree` | list branch points (numbered), answer with a number to jump there | `commands.allowSession` |
94
+ | `/fork` | list earlier user messages (numbered), answer with a number to fork from it | `commands.allowSession` |
95
+ | `/clone` | duplicate the current session at its current leaf | `commands.allowSession` |
96
+ | `/resume` | list the project's other sessions (numbered), answer with a number to switch | `commands.allowSession` |
97
+ | `/quit` | shuts the host pi down | `commands.allowQuit` (default **false**) |
98
+
99
+ Host-only (answered, never forwarded to the model): `/settings`, `/hotkeys`, `/changelog`,
100
+ `/trust`, `/login`, `/logout`, `/scoped-models`, `/llama`, `/import`, `/export`, `/share`, `/bug`,
101
+ `/copy`, `/privacy`.
102
+
103
+ Guards:
104
+
105
+ - `/reload`, `/new`, `/compact`, `/quit`, `/tree`, `/fork`, `/clone` and `/resume` refuse to run while a task
106
+ is in progress — send `/cancel` first.
107
+ - Selection commands (`/tree`, `/fork`, `/resume`) answer with a numbered list of at most 10 entries, scoped to
108
+ the user who asked and valid for 10 minutes. The next bare number from that user executes the choice; anything
109
+ else (or 「取消」) abandons it. A number without a pending list is ordinary chat.
110
+ - `/reload` is off by default: it restarts the bot, and if the new code fails to load the bot stays
111
+ down until someone fixes it on the host. Turn it on with `commands.allowReload: true`.
112
+ - Why the indirection: pi only dispatches **extension** commands from a message
113
+ (`prompt()` → `_tryExecuteExtensionCommand`), and `reload()` / `newSession()` are command-context
114
+ APIs. So this extension registers `/qq` and forwards `/reload` → `/qq reload`. Built-in names are
115
+ hard-coded here because pi does not export its built-in list; any unknown `/word` falls back to
116
+ plain text.
117
+
71
118
  ### Cancelling a run
72
119
 
73
120
  Long tasks started from QQ (installs, builds, a runaway agent loop) can be interrupted without going
package/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { CONFIG_PATH } from "./constants";
3
+ import { DEFAULT_COMMANDS, type CommandConfig } from "./slash";
3
4
 
4
5
  export interface ApproveConfig {
5
6
  /** 需要 QQ 审批的工具名(如 bash/powershell/write/edit) */
@@ -17,6 +18,8 @@ export interface QQBotConfig {
17
18
  streaming: boolean;
18
19
  toolProgress: boolean;
19
20
  approve: ApproveConfig;
21
+ /** QQ 侧斜杠命令的路由开关(/reload、/new… 以及扩展命令透传) */
22
+ commands: CommandConfig;
20
23
  }
21
24
 
22
25
  export function loadConfig(): QQBotConfig | null {
@@ -41,5 +44,11 @@ export function loadConfig(): QQBotConfig | null {
41
44
  tools: file.approve?.tools ?? ["bash", "powershell", "write", "edit"],
42
45
  remember: file.approve?.remember ?? true,
43
46
  },
47
+ commands: {
48
+ passthrough: file.commands?.passthrough ?? DEFAULT_COMMANDS.passthrough,
49
+ allowReload: file.commands?.allowReload ?? DEFAULT_COMMANDS.allowReload,
50
+ allowQuit: file.commands?.allowQuit ?? DEFAULT_COMMANDS.allowQuit,
51
+ allowSession: file.commands?.allowSession ?? DEFAULT_COMMANDS.allowSession,
52
+ },
44
53
  };
45
54
  }
@@ -15,7 +15,8 @@
15
15
  | **会话级「以后都同意」** | 回复「以后都同意」,**当前会话**内所有审批请求直接放行,连已经挂起的那条也一起放行。 |
16
16
  | **单实例锁** | 只有一个 pi 进程作为宿主连接 QQ,其余待命,宿主退出后自动接管。 |
17
17
  | **中断正在运行的任务** | 发 `/cancel`(或「取消」/「停止」),当前任务立刻中断 —— QQ 回复、待审批工具调用、写到一半的回答一并清理。 |
18
- | **内置指令** | `/new`、`/status`、`/cancel`、`/help`。 |
18
+ | **斜杠命令路由** | 以 `/` 开头的 QQ 消息会先被路由:pi 的扩展命令、prompt 模板、`/skill:name` 交给 pi 派发;一部分 pi 内置命令(`/reload`、`/new`、`/compact`、`/session`、`/name`、`/model`、`/thinking`、`/quit`)由本扩展接管;只能在主机执行的内置命令直接回执,不会送给模型。 |
19
+ | **内置指令** | `/status`、`/reset`、`/cancel`、`/help`。 |
19
20
 
20
21
  ## 安装
21
22
 
@@ -54,6 +55,7 @@ pi install ./pi-qqbot
54
55
  | `streaming` | `false` | 预留,v1 未启用流式 |
55
56
  | `approve.tools` | `["bash","powershell","write","edit"]` | 需要审批的工具名;带 `destructiveHint` 注解的工具同样会拦截 |
56
57
  | `approve.remember` | `true` | 会话内按工具记住决策(同类工具不再重复询问) |
58
+ | `commands` | 见下 | 斜杠命令路由开关:`passthrough`、`allowReload`、`allowQuit`、`allowSession` |
57
59
 
58
60
  也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据。
59
61
 
@@ -63,11 +65,53 @@ pi install ./pi-qqbot
63
65
 
64
66
  | 指令 | 说明 |
65
67
  |---|---|
66
- | `/new` | 重置:清空回复缓冲、审批记忆与自动同意开关 |
68
+ | `/reset` | 重置:清空回复缓冲、审批记忆与自动同意开关(**不换会话**,换会话发 `/new`) |
67
69
  | `/status` | 查看忙闲状态、自动同意状态、待审批数量 |
68
70
  | `/cancel` | **中断当前正在运行的任务**(别名:`/stop`、`/abort`、「取消」、「停止」、「中止」、「终止」、「算了」) |
69
71
  | `/help` | 帮助 |
70
72
 
73
+ ### QQ 里的斜杠命令
74
+
75
+ 任何以 `/` 开头的消息都会先走路由,再决定是否当成对话:
76
+
77
+ | # | 类型 | 行为 |
78
+ |---|---|---|
79
+ | 1 | pi 的**扩展命令**、prompt 模板、`/skill:name` | 以 `expandPromptTemplates: true` 派发给 pi,所以 `/usage`、`/cost` 或你自己注册的命令在 QQ 里也能用 |
80
+ | 2 | 本桥接能接管的 pi 内置命令 | 用扩展 API 重新实现(见下表) |
81
+ | 3 | 只能在 TUI 里跑的 pi 内置命令 | 回执「请在主机执行」,**不会**送进模型 |
82
+ | 4 | 其余(`/tmp 里有什么`、`/etc 看看` …) | 当普通对话处理 |
83
+
84
+ | QQ 命令 | 实际动作 | 开关 |
85
+ |---|---|---|
86
+ | `/reload` | 重载扩展 / 技能 / 模板 / 主题 —— **bot 自己也会重启**并重连网关 | `commands.allowReload`(默认 **false**) |
87
+ | `/new` | 真正开新会话(`ctx.newSession()`) | `commands.allowSession` |
88
+ | `/compact [指令]` | 压缩上下文 | `commands.allowSession` |
89
+ | `/session` | 会话 id / cwd / 模型 / 思考等级 / 上下文用量 | `commands.allowSession` |
90
+ | `/name [名字]` | 查看或设置会话名 | `commands.allowSession` |
91
+ | `/model [provider/model]` | 列出可用模型或切换 | `commands.allowSession` |
92
+ | `/thinking [等级]` | 查看或设置思考等级 | `commands.allowSession` |
93
+ | `/tree` | 列出会话树上的分支点(编号),回编号即跳转 | `commands.allowSession` |
94
+ | `/fork` | 列出之前的用户消息(编号),回编号即从该处分叉 | `commands.allowSession` |
95
+ | `/clone` | 在当前叶子位置复制整个会话 | `commands.allowSession` |
96
+ | `/resume` | 列出本项目其他会话(编号),回编号即切换 | `commands.allowSession` |
97
+ | `/quit` | 关掉主机上的 pi | `commands.allowQuit`(默认 **false**) |
98
+
99
+ 只能在主机执行(回执,不转发给模型):`/settings`、`/hotkeys`、`/changelog`、`/trust`、`/login`、
100
+ `/logout`、`/scoped-models`、`/llama`、`/import`、`/export`、`/share`、`/bug`、`/copy`、`/privacy`。
101
+
102
+ 约束:
103
+
104
+ - 有任务在跑时,`/reload`、`/new`、`/compact`、`/quit`、`/tree`、`/fork`、`/clone`、`/resume` 会拒绝执行
105
+ —— 先发 `/cancel`。
106
+ - 选择型命令(`/tree`、`/fork`、`/resume`)会回一个最多 10 项的编号列表,只对该用户有效、10 分钟内有效;
107
+ 该用户下一条「纯数字」消息即执行选择,其它内容(或「取消」)则放弃。没有待选列表时,数字就是普通对话。
108
+ - `/reload` 默认关闭:它会重启 bot,如果新代码加载失败,bot 会一直哑着,只能在主机上救。
109
+ 置 `commands.allowReload: true` 才启用。
110
+ - 为什么要绕一层:pi 只对**扩展命令**执行派发(`prompt()` → `_tryExecuteExtensionCommand`),而
111
+ `reload()` / `newSession()` 又只能在命令上下文里调用。于是本扩展注册了 `/qq`,把 `/reload`
112
+ 改派成 `/qq reload`。内置命令名是硬编码的(pi 没有把内置清单导出给扩展);任何不认识的
113
+ `/xxx` 都会退回当普通文字。
114
+
71
115
  ### 取消正在运行的任务
72
116
 
73
117
  从 QQ 发起的耗时任务(装依赖、编译、agent 跑飞了)不必回到机器上处理:
package/index.ts CHANGED
@@ -14,6 +14,7 @@ import { AuthManager } from "./qq/auth";
14
14
  import { QQApi } from "./qq/api";
15
15
  import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
16
16
  import { Approver } from "./approver";
17
+ import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
17
18
  import { ReplyBuffer } from "./stream-buffer";
18
19
  import { splitText } from "./util/chunk";
19
20
 
@@ -30,6 +31,14 @@ interface State {
30
31
  busy: boolean;
31
32
  /** 当前 run 的上下文(用来 abort);run 结束或取消后清空 */
32
33
  ctx: ExtensionContext | null;
34
+ /** 最近一条 QQ 消息的发送者:桥接命令用它回执 */
35
+ lastOpenid: string | null;
36
+ /** 待选择列表(/tree、/fork、/resume 发出后的编号等待状态) */
37
+ picker: PickerState | null;
38
+ /** 发出列表的 QQ 用户:只有他能回编号 */
39
+ pickerOwner: string | null;
40
+ /** /qq 桥接命令的回执通道(每次收到 QQ 消息时刷新) */
41
+ bridgeReply: ((text: string) => void) | null;
33
42
  /** 本轮已被用户取消:settle 时丢弃半截回复 */
34
43
  cancelled: boolean;
35
44
  }
@@ -47,6 +56,10 @@ export default function (pi: ExtensionAPI) {
47
56
  passiveRefs: new Map(),
48
57
  busy: false,
49
58
  ctx: null,
59
+ lastOpenid: null,
60
+ picker: null,
61
+ pickerOwner: null,
62
+ bridgeReply: null,
50
63
  cancelled: false,
51
64
  };
52
65
 
@@ -204,6 +217,27 @@ export default function (pi: ExtensionAPI) {
204
217
  state.gateway = null;
205
218
  state.api = null;
206
219
  });
220
+
221
+ // ---- /qq:QQ 侧命令的桥接入口 ----
222
+ // pi 只对「扩展命令」执行派发,内置命令(/reload、/new…)只能在 TUI/RPC 层跑;
223
+ // 而 reload()/newSession() 又必须拿到 ExtensionCommandContext。于是注册一个扩展
224
+ // 命令,QQ 侧收到 /reload 时改派 /qq reload,就能在合法的命令上下文里执行。
225
+ pi.registerCommand("qq", {
226
+ description: "pi-qqbot 内部桥接:/qq <reload|new|compact|quit|session|name|model|thinking> [args]",
227
+ handler: async (args, ctx) => {
228
+ const trimmed = args.trim();
229
+ const space = trimmed.indexOf(" ");
230
+ const sub = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
231
+ const rest = (space === -1 ? "" : trimmed.slice(space + 1)).trim();
232
+ const reply = (text: string) => state.bridgeReply?.(text);
233
+ try {
234
+ await runBridge(sub, rest, ctx, pi, reply, state);
235
+ } catch (e) {
236
+ log("[/qq] 桥接失败:", String(e));
237
+ reply(`/${sub} 执行失败:${String(e).slice(0, 200)}`);
238
+ }
239
+ },
240
+ });
207
241
  }
208
242
 
209
243
  /** 取消/停止这类「打断当前任务」的表达 */
@@ -276,12 +310,62 @@ export async function handleMessage(
276
310
  }
277
311
  state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
278
312
 
313
+ // 待选择的编号回复(/tree、/fork、/resume 之后)
314
+ if (state.picker) {
315
+ if (pickerExpired(state.picker)) {
316
+ state.picker = null;
317
+ } else if (state.picker.openid === msg.openid) {
318
+ const index = parsePickIndex(msg.content);
319
+ if (index !== null) {
320
+ if (state.busy) {
321
+ await replyTo(api, state, msg.openid, "当前有任务在跑,先发 /cancel 取消,再回编号。");
322
+ return;
323
+ }
324
+ if (index > state.picker.items.length) {
325
+ await replyTo(api, state, msg.openid, `编号 ${index} 超出范围(1-${state.picker.items.length})。`);
326
+ return;
327
+ }
328
+ state.pickerOwner = msg.openid;
329
+ state.bridgeReply = (text: string) => {
330
+ void replyTo(api, state, msg.openid, text);
331
+ };
332
+ log("[cmd] 选择编号", index, "kind=", state.picker.kind);
333
+ pi.sendUserMessage(`/qq pick ${index}`, { expandPromptTemplates: true });
334
+ return;
335
+ }
336
+ if (/^(取消|算了|cancel)$/i.test(msg.content.trim())) {
337
+ state.picker = null;
338
+ await replyTo(api, state, msg.openid, "已放弃选择。");
339
+ return;
340
+ }
341
+ }
342
+ }
343
+
279
344
  // 取消指令优先于审批解析:「取消」不该被当成审批动作
280
345
  if (isCancelIntent(msg.content)) {
281
346
  await handleCancel(state, api, msg.openid);
282
347
  return;
283
348
  }
284
349
 
350
+ // 斜杠命令路由:pi 扩展命令 → 可接管的内置命令 → 仅主机可用的内置命令 → 普通文字
351
+ state.lastOpenid = msg.openid;
352
+ state.pickerOwner = msg.openid;
353
+ state.bridgeReply = (text: string) => {
354
+ void replyTo(api, state, msg.openid, text);
355
+ };
356
+ const routed = await routeSlash(
357
+ {
358
+ listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string }[],
359
+ dispatch: (text: string) => pi.sendUserMessage(text, { expandPromptTemplates: true }),
360
+ reply: (text: string) => replyTo(api, state, msg.openid, text),
361
+ busy: state.busy,
362
+ cfg: cfg.commands,
363
+ log,
364
+ },
365
+ msg.content,
366
+ );
367
+ if (routed !== "text") return;
368
+
285
369
  // 远程审批回复优先
286
370
  const parsed = state.approver.parseReply(msg.content.trim());
287
371
  if (parsed) {
@@ -344,11 +428,12 @@ export async function handleMessage(
344
428
  msg.openid,
345
429
  [
346
430
  "pi-qqbot 指令:",
347
- "/new — 开新会话(清空审批记忆/自动同意)",
348
- "/status — 查看状态",
431
+ "/status — 查看状态(忙闲 / 自动同意 / 待审批)",
432
+ "/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
349
433
  "/cancel(或「取消」「停止」)— 中断正在运行的任务",
350
434
  "/help — 帮助",
351
- "其余文本交给 pi 处理。",
435
+ "pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
436
+ "其余文本交给 pi 当作对话处理。",
352
437
  ].join("\n"),
353
438
  );
354
439
  return;
@@ -366,10 +451,10 @@ export async function handleMessage(
366
451
  );
367
452
  return;
368
453
  }
369
- if (cmd === "new") {
454
+ if (cmd === "reset") {
370
455
  state.replyBuffer.reset();
371
456
  state.approver.reset();
372
- await replyTo(api, state, msg.openid, "已重置(含审批记忆/自动同意),下一条消息开启新对话。");
457
+ await replyTo(api, state, msg.openid, "已重置(回复缓冲/审批记忆/自动同意)。要开新会话请发 /new。");
373
458
  return;
374
459
  }
375
460
 
@@ -415,9 +500,11 @@ async function replyTo(
415
500
  }
416
501
  }
417
502
 
418
- function parseCommand(text: string): "new" | "status" | "help" | null {
419
- const m = /^\/(new|status|help)\s*$/i.exec(text.trim());
420
- return m ? (m[1].toLowerCase() as "new" | "status" | "help") : null;
503
+ function parseCommand(text: string): "reset" | "status" | "help" | null {
504
+ // 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
505
+ // 本地的「清缓冲/清审批记忆」改名为 /reset。
506
+ const m = /^\/(reset|status|help)\s*$/i.exec(text.trim());
507
+ return m ? (m[1].toLowerCase() as "reset" | "status" | "help") : null;
421
508
  }
422
509
 
423
510
  /** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "0.3.0",
3
+ "version": "1.1.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",
package/slash.ts ADDED
@@ -0,0 +1,513 @@
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
+ export interface SlashCommandText {
30
+ /** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
31
+ name: string;
32
+ /** raw text after the command name */
33
+ args: string;
34
+ /** the original message */
35
+ raw: string;
36
+ }
37
+
38
+ const SLASH = /^\/([A-Za-z0-9:_-]+)(?:\s+([\s\S]*))?$/;
39
+
40
+ export function parseSlash(text: string): SlashCommandText | null {
41
+ const match = SLASH.exec(text.trim());
42
+ if (!match) return null;
43
+ return { name: match[1]!.toLowerCase(), args: (match[2] ?? "").trim(), raw: text.trim() };
44
+ }
45
+
46
+ export type BridgeSub = "reload" | "new" | "compact" | "quit" | "session" | "name" | "model" | "thinking" | "tree" | "fork" | "clone" | "resume";
47
+
48
+ interface Takeover {
49
+ sub: string;
50
+ /** the command tears down the current runtime/session: reply before dispatching */
51
+ selfDestroying?: boolean;
52
+ /** unsafe to run while a task is in progress */
53
+ needsIdle?: boolean;
54
+ needsArgs?: boolean;
55
+ hint: string;
56
+ }
57
+
58
+ /**
59
+ * pi built-ins that can be re-implemented with extension APIs.
60
+ * `dist/core/slash-commands.d.ts` marks the full built-in list, but pi does not
61
+ * export it, so this table is maintained here on purpose.
62
+ */
63
+ export const TAKEOVER: Readonly<Record<string, Takeover>> = {
64
+ reload: { sub: "reload", selfDestroying: true, needsIdle: true, hint: "重新加载扩展/技能/模板/主题" },
65
+ new: { sub: "new", selfDestroying: true, needsIdle: true, hint: "开启新的 pi 会话" },
66
+ compact: { sub: "compact", needsIdle: true, hint: "压缩上下文(可带自定义指令)" },
67
+ quit: { sub: "quit", selfDestroying: true, needsIdle: true, hint: "退出主机上的 pi" },
68
+ session: { sub: "session", hint: "查看当前会话信息与上下文用量" },
69
+ name: { sub: "name", hint: "设置或查看会话名称" },
70
+ model: { sub: "model", hint: "查看/切换模型(provider/model)" },
71
+ thinking: { sub: "thinking", hint: "查看/切换思考等级" },
72
+ tree: { sub: "list tree", needsIdle: true, hint: "在会话树上跳转(回编号选择)" },
73
+ fork: { sub: "list fork", needsIdle: true, hint: "从某条历史用户消息分叉(回编号选择)" },
74
+ resume: { sub: "list resume", needsIdle: true, hint: "切换到本项目的另一个会话(回编号选择)" },
75
+ clone: { sub: "clone", selfDestroying: true, needsIdle: true, hint: "在当前叶子位置复制会话" },
76
+ };
77
+
78
+ /** pi built-ins that the extension API cannot reach at all. */
79
+ export const HOST_ONLY: ReadonlySet<string> = new Set([
80
+ "settings",
81
+ "hotkeys",
82
+ "changelog",
83
+ "trust",
84
+ "login",
85
+ "logout",
86
+ "scoped-models",
87
+ "llama",
88
+ "import",
89
+ "export",
90
+ "share",
91
+ "bug",
92
+ "copy",
93
+ "privacy",
94
+ ]);
95
+
96
+ export interface CommandConfig {
97
+ /** forward pi extension commands / prompt templates / skill commands */
98
+ passthrough: boolean;
99
+ /** allow /reload (restarts this bot too) */
100
+ allowReload: boolean;
101
+ /** allow /quit (stops the host pi process) */
102
+ allowQuit: boolean;
103
+ /** allow session-level commands: /new, /compact, /session, /name, /model, /thinking, /tree, /fork, /clone, /resume */
104
+ allowSession: boolean;
105
+ }
106
+
107
+ export const DEFAULT_COMMANDS: CommandConfig = {
108
+ passthrough: true,
109
+ allowReload: false,
110
+ allowQuit: false,
111
+ allowSession: true,
112
+ };
113
+
114
+ export interface RouteDeps {
115
+ /** commands pi currently knows about (extension/prompt/skill) */
116
+ listPiCommands(): readonly { name: string; source?: string }[];
117
+ /** deliver a pi command through pi's own dispatcher */
118
+ dispatch(text: string): void;
119
+ /** answer the QQ user */
120
+ reply(text: string): Promise<void>;
121
+ busy: boolean;
122
+ cfg: CommandConfig;
123
+ log(message: string, ...rest: unknown[]): void;
124
+ }
125
+
126
+ export type RouteResult = "dispatched" | "handled" | "text";
127
+
128
+ const allowed = (cfg: CommandConfig, name: string): boolean => {
129
+ if (name === "reload") return cfg.allowReload;
130
+ if (name === "quit") return cfg.allowQuit;
131
+ return cfg.allowSession;
132
+ };
133
+
134
+ /** Route one slash message. Returns "text" when it should be treated as chat. */
135
+ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteResult> {
136
+ const parsed = parseSlash(text);
137
+ if (!parsed) return "text";
138
+ const { name } = parsed;
139
+
140
+ // 1. pi extension commands, prompt templates and skill commands
141
+ if (deps.cfg.passthrough) {
142
+ const known = deps.listPiCommands().some((c) => c.name.toLowerCase() === name);
143
+ if (known) {
144
+ deps.log("[cmd] 派发 pi 扩展命令:", parsed.raw.slice(0, 80));
145
+ deps.dispatch(parsed.raw);
146
+ await deps.reply(`已派发 /${name} 给 pi。`);
147
+ return "dispatched";
148
+ }
149
+ }
150
+
151
+ // 2. built-ins we can take over
152
+ const takeover = TAKEOVER[name];
153
+ if (takeover) {
154
+ if (!allowed(deps.cfg, name)) {
155
+ const gate = name === "reload" ? "allowReload" : name === "quit" ? "allowQuit" : "allowSession";
156
+ await deps.reply(`/${name} 在 QQ 侧被禁用(配置 commands.${gate})。`);
157
+ return "handled";
158
+ }
159
+ if (takeover.needsIdle && deps.busy) {
160
+ await deps.reply(`当前有任务在跑,先发 /cancel 取消,再执行 /${name}。`);
161
+ return "handled";
162
+ }
163
+ const bridge = `/qq ${takeover.sub}${parsed.args ? ` ${parsed.args}` : ""}`;
164
+ deps.log("[cmd] 接管内置命令:", parsed.raw.slice(0, 80), "->", bridge.slice(0, 80));
165
+ if (takeover.selfDestroying) {
166
+ // the command tears the runtime down; answer before it lands
167
+ await deps.reply(`已请求 /${name}(${takeover.hint})…`);
168
+ deps.dispatch(bridge);
169
+ return "dispatched";
170
+ }
171
+ deps.dispatch(bridge);
172
+ return "dispatched";
173
+ }
174
+
175
+ // 3. built-ins only the host TUI can run
176
+ if (HOST_ONLY.has(name)) {
177
+ await deps.reply(`/${name} 只能在主机上的 pi 里执行(扩展 API 没有对应能力)。`);
178
+ return "handled";
179
+ }
180
+
181
+ // 4. not a command we know: ordinary text
182
+ return "text";
183
+ }
184
+
185
+ const MAX_LIST = 12;
186
+
187
+ function formatTokens(value: number | null | undefined): string {
188
+ if (typeof value !== "number" || !Number.isFinite(value)) return "?";
189
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
190
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
191
+ return String(value);
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // pickers (tree / fork / resume)
196
+ // ---------------------------------------------------------------------------
197
+
198
+ export type PickerKind = "tree" | "fork" | "resume";
199
+
200
+ export interface PickerItem {
201
+ /** entry id (tree/fork) */
202
+ id?: string;
203
+ /** session file (resume) */
204
+ path?: string;
205
+ /** what the user sees next to the number */
206
+ label: string;
207
+ }
208
+
209
+ export interface PickerState {
210
+ kind: PickerKind;
211
+ items: PickerItem[];
212
+ /** only this QQ user may answer with a number */
213
+ openid: string | null;
214
+ createdAt: number;
215
+ }
216
+
217
+ /** Shared mutable state between the router (index.ts) and the bridge command. */
218
+ export interface BridgeStore {
219
+ picker: PickerState | null;
220
+ /** set by the router right before dispatching a `list` command */
221
+ pickerOwner: string | null;
222
+ }
223
+
224
+ export const PICKER_TTL_MS = 10 * 60_000;
225
+ export const PICKER_MAX_ITEMS = 10;
226
+
227
+ const KIND_LABEL: Record<PickerKind, string> = {
228
+ tree: "会话树节点",
229
+ fork: "可分叉的用户消息",
230
+ resume: "本项目的其他会话",
231
+ };
232
+
233
+ function previewText(content: unknown): string {
234
+ const parts = Array.isArray(content) ? content : [];
235
+ const text = parts
236
+ .map((part: any) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
237
+ .join(" ")
238
+ .replace(/\s+/g, " ")
239
+ .trim();
240
+ return text.length > 50 ? `${text.slice(0, 50)}…` : text || "(无文本)";
241
+ }
242
+
243
+ const stamp = (value: unknown): string => {
244
+ const date = typeof value === "string" || typeof value === "number" ? new Date(value) : new Date();
245
+ if (Number.isNaN(date.getTime())) return "?";
246
+ const pad = (n: number) => String(n).padStart(2, "0");
247
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
248
+ };
249
+
250
+ function branchItems(ctx: any): PickerItem[] {
251
+ let branch: any[] = [];
252
+ try {
253
+ branch = ctx.sessionManager.getBranch() ?? [];
254
+ } catch {
255
+ branch = [];
256
+ }
257
+ return branch
258
+ .filter((entry) => entry?.type === "message" && entry?.message?.role === "user")
259
+ .slice(-PICKER_MAX_ITEMS)
260
+ .map((entry) => ({
261
+ id: entry.id,
262
+ label: `${stamp(entry.timestamp)} ${previewText(entry.message.content)}`,
263
+ }));
264
+ }
265
+
266
+ function resumeItems(ctx: any): PickerItem[] {
267
+ let dir: string | undefined;
268
+ let current: string | undefined;
269
+ try {
270
+ dir = ctx.sessionManager.getSessionDir?.();
271
+ current = ctx.sessionManager.getSessionFile?.();
272
+ } catch {
273
+ return [];
274
+ }
275
+ if (!dir) return [];
276
+ let names: string[] = [];
277
+ try {
278
+ names = fs.readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
279
+ } catch {
280
+ return [];
281
+ }
282
+ return names
283
+ .map((name) => {
284
+ const full = path.join(dir!, name);
285
+ let mtime = 0;
286
+ let size = 0;
287
+ try {
288
+ const stat = fs.statSync(full);
289
+ mtime = stat.mtimeMs;
290
+ size = stat.size;
291
+ } catch {
292
+ // unreadable: keep it out of the ranking
293
+ }
294
+ return { full, name, mtime, size };
295
+ })
296
+ .filter((row) => row.mtime > 0 && row.full !== current)
297
+ .sort((a, b) => b.mtime - a.mtime)
298
+ .slice(0, PICKER_MAX_ITEMS)
299
+ .map((row) => ({
300
+ path: row.full,
301
+ label: `${stamp(row.mtime)} ${Math.max(1, Math.round(row.size / 1024))}KB ${row.name.replace(/\.jsonl$/, "").slice(0, 44)}`,
302
+ }));
303
+ }
304
+
305
+ function buildPickerItems(kind: PickerKind, ctx: any): PickerItem[] {
306
+ if (kind === "resume") return resumeItems(ctx);
307
+ return branchItems(ctx);
308
+ }
309
+
310
+ function formatPicker(picker: PickerState): string {
311
+ const header = `选择${KIND_LABEL[picker.kind]}(${picker.items.length} 项):`;
312
+ const rows = picker.items.map((item, index) => ` ${index + 1}. ${item.label}`);
313
+ return [
314
+ header,
315
+ ...rows,
316
+ `回复编号执行,或回复「取消」放弃(${Math.round(PICKER_TTL_MS / 60000)} 分钟内有效)。`,
317
+ ].join("\n");
318
+ }
319
+
320
+ /** Parse a bare "3" reply that answers the pending picker. */
321
+ export function parsePickIndex(text: string): number | null {
322
+ const match = /^\s*(\d{1,3})\s*$/.exec(text);
323
+ if (!match) return null;
324
+ const value = Number(match[1]);
325
+ return Number.isFinite(value) && value > 0 ? value : null;
326
+ }
327
+
328
+ export function pickerExpired(picker: PickerState | null): boolean {
329
+ return !picker || Date.now() - picker.createdAt > PICKER_TTL_MS;
330
+ }
331
+
332
+ /**
333
+ * Body of the `/qq` bridge command: pi hands it a fresh `ExtensionCommandContext`,
334
+ * which is the only place `reload()`, `newSession()`, `fork()`, `navigateTree()` and
335
+ * `switchSession()` are legal.
336
+ *
337
+ * Session-replacing actions (`reload`, `new`, `tree`/`fork`/`resume` picks, `clone`)
338
+ * tear this runtime down; the caller answers the QQ user before dispatching them.
339
+ */
340
+ export async function runBridge(
341
+ sub: string,
342
+ arg: string,
343
+ ctx: any,
344
+ pi: any,
345
+ reply: (text: string) => void,
346
+ store?: BridgeStore,
347
+ ): Promise<void> {
348
+ const level = String(sub ?? "").toLowerCase();
349
+ const first = arg.split(/\s+/)[0]?.toLowerCase() ?? "";
350
+
351
+ switch (level) {
352
+ case "reload": {
353
+ await ctx.reload();
354
+ return;
355
+ }
356
+ case "new": {
357
+ await ctx.newSession();
358
+ return;
359
+ }
360
+ case "quit": {
361
+ reply("正在退出主机上的 pi…");
362
+ ctx.shutdown();
363
+ return;
364
+ }
365
+ case "compact": {
366
+ reply(arg ? `开始压缩上下文(指令:${arg.slice(0, 80)})…` : "开始压缩上下文…");
367
+ ctx.compact({
368
+ customInstructions: arg || undefined,
369
+ onComplete: () => reply("上下文压缩完成。"),
370
+ onError: (error: Error) => reply(`上下文压缩失败:${String(error?.message ?? error).slice(0, 200)}`),
371
+ });
372
+ return;
373
+ }
374
+ case "list": {
375
+ const kind = first as PickerKind;
376
+ if (kind !== "tree" && kind !== "fork" && kind !== "resume") {
377
+ reply(`未知列表类型:${first || "(空)"}`);
378
+ return;
379
+ }
380
+ const items = buildPickerItems(kind, ctx);
381
+ if (items.length === 0) {
382
+ reply(`没有可用的${KIND_LABEL[kind]}。`);
383
+ return;
384
+ }
385
+ const picker: PickerState = { kind, items, openid: store?.pickerOwner ?? null, createdAt: Date.now() };
386
+ if (store) store.picker = picker;
387
+ reply(formatPicker(picker));
388
+ return;
389
+ }
390
+ case "pick": {
391
+ const index = Number.parseInt(first, 10);
392
+ const picker = store?.picker ?? null;
393
+ if (!picker || !Number.isFinite(index)) {
394
+ reply("选择列表已失效,请重新发送命令。");
395
+ return;
396
+ }
397
+ const item = picker.items[index - 1];
398
+ if (!item) {
399
+ reply(`编号 ${first} 超出范围(1-${picker.items.length})。`);
400
+ return;
401
+ }
402
+ if (store) store.picker = null;
403
+ if (picker.kind === "tree") {
404
+ reply(`正在跳转到第 ${index} 个节点…`);
405
+ await ctx.navigateTree(item.id);
406
+ return;
407
+ }
408
+ if (picker.kind === "fork") {
409
+ reply(`正在从第 ${index} 条消息分叉…`);
410
+ await ctx.fork(item.id);
411
+ return;
412
+ }
413
+ reply(`正在切换会话:${item.label.slice(0, 44)}…`);
414
+ await ctx.switchSession(item.path);
415
+ return;
416
+ }
417
+ case "clone": {
418
+ let leaf: string | null = null;
419
+ try {
420
+ leaf = ctx.sessionManager.getLeafId?.() ?? null;
421
+ } catch {
422
+ leaf = null;
423
+ }
424
+ if (!leaf) {
425
+ reply("当前会话还没有可复制的位置(先发一条消息)。");
426
+ return;
427
+ }
428
+ reply("正在当前叶子位置复制会话…");
429
+ await ctx.fork(leaf, { position: "at" });
430
+ return;
431
+ }
432
+ case "session": {
433
+ const sm = ctx.sessionManager;
434
+ const usage = ctx.getContextUsage?.();
435
+ const model = ctx.model;
436
+ const entries = (() => {
437
+ try {
438
+ return sm.getEntries().length;
439
+ } catch {
440
+ return "?";
441
+ }
442
+ })();
443
+ reply(
444
+ [
445
+ `会话:${sm.getSessionName?.() || "(未命名)"}`,
446
+ `id:${sm.getSessionId?.() ?? "?"}`,
447
+ `cwd:${sm.getCwd?.() ?? ctx.cwd ?? "?"}`,
448
+ `模型:${model ? `${model.provider}/${model.id}` : "(未设置)"} 思考:${ctx.thinkingLevel ?? "?"}`,
449
+ `条目:${entries} 上下文:${
450
+ usage && usage.tokens !== undefined
451
+ ? `${formatTokens(usage.tokens)}${usage.percent !== undefined ? ` (${Math.round(usage.percent)}%)` : ""}`
452
+ : "未知"
453
+ }`,
454
+ ].join("\n"),
455
+ );
456
+ return;
457
+ }
458
+ case "name": {
459
+ if (!arg) {
460
+ reply(`当前会话名:${ctx.sessionManager.getSessionName?.() || "(未命名)"}`);
461
+ return;
462
+ }
463
+ pi.setSessionName(arg);
464
+ reply(`会话名已设为:${arg}`);
465
+ return;
466
+ }
467
+ case "model": {
468
+ const registry = ctx.modelRegistry;
469
+ const available = registry?.getAvailable?.() ?? [];
470
+ const current = ctx.model;
471
+ if (!arg) {
472
+ const names = available.slice(0, MAX_LIST).map((m: any) => `${m.provider}/${m.id}`);
473
+ reply(
474
+ [
475
+ `当前模型:${current ? `${current.provider}/${current.id}` : "(未设置)"}`,
476
+ available.length > 0
477
+ ? `可选(${available.length}):${names.join(", ")}${available.length > MAX_LIST ? " …" : ""}`
478
+ : "没有可用模型",
479
+ ].join("\n"),
480
+ );
481
+ return;
482
+ }
483
+ const wanted = arg.toLowerCase();
484
+ const found = available.find((m: any) => `${m.provider}/${m.id}`.toLowerCase() === wanted)
485
+ ?? available.find((m: any) => String(m.id).toLowerCase() === wanted);
486
+ if (!found) {
487
+ reply(`找不到模型 ${arg}。发 /model 查看可用列表。`);
488
+ return;
489
+ }
490
+ const ok = await pi.setModel(found);
491
+ reply(ok ? `模型已切换为 ${found.provider}/${found.id}` : `切换到 ${found.provider}/${found.id} 失败(缺少凭据?)`);
492
+ return;
493
+ }
494
+ case "thinking": {
495
+ if (!arg) {
496
+ reply(`当前思考等级:${pi.getThinkingLevel()}`);
497
+ return;
498
+ }
499
+ const levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
500
+ const wanted = arg.toLowerCase();
501
+ if (!levels.includes(wanted)) {
502
+ reply(`思考等级只能是:${levels.join(" / ")}`);
503
+ return;
504
+ }
505
+ pi.setThinkingLevel(wanted);
506
+ reply(`思考等级已设为 ${wanted}`);
507
+ return;
508
+ }
509
+ default: {
510
+ reply(`未知的桥接命令:/qq ${arg ? `${level} ${arg}` : level}`);
511
+ }
512
+ }
513
+ }