@zxh126/pi-qqbot 1.0.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
@@ -90,15 +90,23 @@ Any message that starts with `/` is routed before it can become a chat prompt:
90
90
  | `/name [name]` | get or set the session name | `commands.allowSession` |
91
91
  | `/model [provider/model]` | list available models or switch | `commands.allowSession` |
92
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` |
93
97
  | `/quit` | shuts the host pi down | `commands.allowQuit` (default **false**) |
94
98
 
95
99
  Host-only (answered, never forwarded to the model): `/settings`, `/hotkeys`, `/changelog`,
96
- `/trust`, `/login`, `/logout`, `/scoped-models`, `/llama`, `/tree`, `/fork`, `/clone`, `/resume`,
97
- `/import`, `/export`, `/share`, `/bug`, `/copy`, `/privacy`, `/handoff`.
100
+ `/trust`, `/login`, `/logout`, `/scoped-models`, `/llama`, `/import`, `/export`, `/share`, `/bug`,
101
+ `/copy`, `/privacy`.
98
102
 
99
103
  Guards:
100
104
 
101
- - `/reload`, `/new`, `/compact` and `/quit` refuse to run while a task is in progress — send `/cancel` first.
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.
102
110
  - `/reload` is off by default: it restarts the bot, and if the new code fails to load the bot stays
103
111
  down until someone fixes it on the host. Turn it on with `commands.allowReload: true`.
104
112
  - Why the indirection: pi only dispatches **extension** commands from a message
@@ -90,15 +90,21 @@ pi install ./pi-qqbot
90
90
  | `/name [名字]` | 查看或设置会话名 | `commands.allowSession` |
91
91
  | `/model [provider/model]` | 列出可用模型或切换 | `commands.allowSession` |
92
92
  | `/thinking [等级]` | 查看或设置思考等级 | `commands.allowSession` |
93
+ | `/tree` | 列出会话树上的分支点(编号),回编号即跳转 | `commands.allowSession` |
94
+ | `/fork` | 列出之前的用户消息(编号),回编号即从该处分叉 | `commands.allowSession` |
95
+ | `/clone` | 在当前叶子位置复制整个会话 | `commands.allowSession` |
96
+ | `/resume` | 列出本项目其他会话(编号),回编号即切换 | `commands.allowSession` |
93
97
  | `/quit` | 关掉主机上的 pi | `commands.allowQuit`(默认 **false**) |
94
98
 
95
99
  只能在主机执行(回执,不转发给模型):`/settings`、`/hotkeys`、`/changelog`、`/trust`、`/login`、
96
- `/logout`、`/scoped-models`、`/llama`、`/tree`、`/fork`、`/clone`、`/resume`、`/import`、`/export`、
97
- `/share`、`/bug`、`/copy`、`/privacy`、`/handoff`。
100
+ `/logout`、`/scoped-models`、`/llama`、`/import`、`/export`、`/share`、`/bug`、`/copy`、`/privacy`。
98
101
 
99
102
  约束:
100
103
 
101
- - 有任务在跑时,`/reload`、`/new`、`/compact`、`/quit` 会拒绝执行 —— 先发 `/cancel`。
104
+ - 有任务在跑时,`/reload`、`/new`、`/compact`、`/quit`、`/tree`、`/fork`、`/clone`、`/resume` 会拒绝执行
105
+ —— 先发 `/cancel`。
106
+ - 选择型命令(`/tree`、`/fork`、`/resume`)会回一个最多 10 项的编号列表,只对该用户有效、10 分钟内有效;
107
+ 该用户下一条「纯数字」消息即执行选择,其它内容(或「取消」)则放弃。没有待选列表时,数字就是普通对话。
102
108
  - `/reload` 默认关闭:它会重启 bot,如果新代码加载失败,bot 会一直哑着,只能在主机上救。
103
109
  置 `commands.allowReload: true` 才启用。
104
110
  - 为什么要绕一层:pi 只对**扩展命令**执行派发(`prompt()` → `_tryExecuteExtensionCommand`),而
package/index.ts CHANGED
@@ -14,7 +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 { routeSlash, runBridge } from "./slash";
17
+ import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
18
18
  import { ReplyBuffer } from "./stream-buffer";
19
19
  import { splitText } from "./util/chunk";
20
20
 
@@ -33,6 +33,10 @@ interface State {
33
33
  ctx: ExtensionContext | null;
34
34
  /** 最近一条 QQ 消息的发送者:桥接命令用它回执 */
35
35
  lastOpenid: string | null;
36
+ /** 待选择列表(/tree、/fork、/resume 发出后的编号等待状态) */
37
+ picker: PickerState | null;
38
+ /** 发出列表的 QQ 用户:只有他能回编号 */
39
+ pickerOwner: string | null;
36
40
  /** /qq 桥接命令的回执通道(每次收到 QQ 消息时刷新) */
37
41
  bridgeReply: ((text: string) => void) | null;
38
42
  /** 本轮已被用户取消:settle 时丢弃半截回复 */
@@ -53,6 +57,8 @@ export default function (pi: ExtensionAPI) {
53
57
  busy: false,
54
58
  ctx: null,
55
59
  lastOpenid: null,
60
+ picker: null,
61
+ pickerOwner: null,
56
62
  bridgeReply: null,
57
63
  cancelled: false,
58
64
  };
@@ -225,7 +231,7 @@ export default function (pi: ExtensionAPI) {
225
231
  const rest = (space === -1 ? "" : trimmed.slice(space + 1)).trim();
226
232
  const reply = (text: string) => state.bridgeReply?.(text);
227
233
  try {
228
- await runBridge(sub, rest, ctx, pi, reply);
234
+ await runBridge(sub, rest, ctx, pi, reply, state);
229
235
  } catch (e) {
230
236
  log("[/qq] 桥接失败:", String(e));
231
237
  reply(`/${sub} 执行失败:${String(e).slice(0, 200)}`);
@@ -304,6 +310,37 @@ export async function handleMessage(
304
310
  }
305
311
  state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
306
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
+
307
344
  // 取消指令优先于审批解析:「取消」不该被当成审批动作
308
345
  if (isCancelIntent(msg.content)) {
309
346
  await handleCancel(state, api, msg.openid);
@@ -312,6 +349,7 @@ export async function handleMessage(
312
349
 
313
350
  // 斜杠命令路由:pi 扩展命令 → 可接管的内置命令 → 仅主机可用的内置命令 → 普通文字
314
351
  state.lastOpenid = msg.openid;
352
+ state.pickerOwner = msg.openid;
315
353
  state.bridgeReply = (text: string) => {
316
354
  void replyTo(api, state, msg.openid, text);
317
355
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "1.0.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 CHANGED
@@ -11,14 +11,21 @@
11
11
  * 2. **pi built-in commands we can take over** (`TAKEOVER`) — pi never dispatches
12
12
  * built-ins from a message (only the TUI/RPC layer does), so these are
13
13
  * re-implemented through the extension API. The command-only APIs
14
- * (`reload`, `newSession`) need a fresh `ExtensionCommandContext`, so they run
15
- * inside the `/qq` bridge command registered by this extension.
14
+ * (`reload`, `newSession`, `fork`, `navigateTree`, `switchSession`) need a fresh
15
+ * `ExtensionCommandContext`, so they run inside the `/qq` bridge command
16
+ * registered by this extension.
16
17
  * 3. **built-ins we cannot reach** (`HOST_ONLY`) — reply "run it on the host".
17
18
  * 4. **anything else** — treated as ordinary chat text (the caller forwards it).
18
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
+ *
19
23
  * Everything is opted in conservatively through `commands` in the config file.
20
24
  */
21
25
 
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+
22
29
  export interface SlashCommandText {
23
30
  /** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
24
31
  name: string;
@@ -36,12 +43,12 @@ export function parseSlash(text: string): SlashCommandText | null {
36
43
  return { name: match[1]!.toLowerCase(), args: (match[2] ?? "").trim(), raw: text.trim() };
37
44
  }
38
45
 
39
- export type BridgeSub = "reload" | "new" | "compact" | "quit" | "session" | "name" | "model" | "thinking";
46
+ export type BridgeSub = "reload" | "new" | "compact" | "quit" | "session" | "name" | "model" | "thinking" | "tree" | "fork" | "clone" | "resume";
40
47
 
41
48
  interface Takeover {
42
- sub: BridgeSub;
49
+ sub: string;
43
50
  /** the command tears down the current runtime/session: reply before dispatching */
44
- selfDestroying?: boolean;
51
+ selfDestroying?: boolean;
45
52
  /** unsafe to run while a task is in progress */
46
53
  needsIdle?: boolean;
47
54
  needsArgs?: boolean;
@@ -62,6 +69,10 @@ export const TAKEOVER: Readonly<Record<string, Takeover>> = {
62
69
  name: { sub: "name", hint: "设置或查看会话名称" },
63
70
  model: { sub: "model", hint: "查看/切换模型(provider/model)" },
64
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: "在当前叶子位置复制会话" },
65
76
  };
66
77
 
67
78
  /** pi built-ins that the extension API cannot reach at all. */
@@ -74,17 +85,12 @@ export const HOST_ONLY: ReadonlySet<string> = new Set([
74
85
  "logout",
75
86
  "scoped-models",
76
87
  "llama",
77
- "tree",
78
- "fork",
79
- "clone",
80
- "resume",
81
88
  "import",
82
89
  "export",
83
90
  "share",
84
91
  "bug",
85
92
  "copy",
86
93
  "privacy",
87
- "handoff",
88
94
  ]);
89
95
 
90
96
  export interface CommandConfig {
@@ -94,7 +100,7 @@ export interface CommandConfig {
94
100
  allowReload: boolean;
95
101
  /** allow /quit (stops the host pi process) */
96
102
  allowQuit: boolean;
97
- /** allow session-level commands: /new, /compact, /session, /name, /model, /thinking */
103
+ /** allow session-level commands: /new, /compact, /session, /name, /model, /thinking, /tree, /fork, /clone, /resume */
98
104
  allowSession: boolean;
99
105
  }
100
106
 
@@ -146,16 +152,15 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
146
152
  const takeover = TAKEOVER[name];
147
153
  if (takeover) {
148
154
  if (!allowed(deps.cfg, name)) {
149
- await deps.reply(
150
- `/${name} 在 QQ 侧被禁用(配置 commands.allow${name === "reload" ? "Reload" : name === "quit" ? "Quit" : "Session"})。`,
151
- );
155
+ const gate = name === "reload" ? "allowReload" : name === "quit" ? "allowQuit" : "allowSession";
156
+ await deps.reply(`/${name} 在 QQ 侧被禁用(配置 commands.${gate})。`);
152
157
  return "handled";
153
158
  }
154
159
  if (takeover.needsIdle && deps.busy) {
155
160
  await deps.reply(`当前有任务在跑,先发 /cancel 取消,再执行 /${name}。`);
156
161
  return "handled";
157
162
  }
158
- const bridge = takeover.needsArgs && !parsed.args ? `/qq ${takeover.sub}` : `/qq ${takeover.sub}${parsed.args ? ` ${parsed.args}` : ""}`;
163
+ const bridge = `/qq ${takeover.sub}${parsed.args ? ` ${parsed.args}` : ""}`;
159
164
  deps.log("[cmd] 接管内置命令:", parsed.raw.slice(0, 80), "->", bridge.slice(0, 80));
160
165
  if (takeover.selfDestroying) {
161
166
  // the command tears the runtime down; answer before it lands
@@ -186,9 +191,151 @@ function formatTokens(value: number | null | undefined): string {
186
191
  return String(value);
187
192
  }
188
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
+
189
332
  /**
190
333
  * Body of the `/qq` bridge command: pi hands it a fresh `ExtensionCommandContext`,
191
- * which is the only place `reload()` / `newSession()` are legal.
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.
192
339
  */
193
340
  export async function runBridge(
194
341
  sub: string,
@@ -196,8 +343,11 @@ export async function runBridge(
196
343
  ctx: any,
197
344
  pi: any,
198
345
  reply: (text: string) => void,
346
+ store?: BridgeStore,
199
347
  ): Promise<void> {
200
348
  const level = String(sub ?? "").toLowerCase();
349
+ const first = arg.split(/\s+/)[0]?.toLowerCase() ?? "";
350
+
201
351
  switch (level) {
202
352
  case "reload": {
203
353
  await ctx.reload();
@@ -221,6 +371,64 @@ export async function runBridge(
221
371
  });
222
372
  return;
223
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
+ }
224
432
  case "session": {
225
433
  const sm = ctx.sessionManager;
226
434
  const usage = ctx.getContextUsage?.();
@@ -232,14 +440,19 @@ export async function runBridge(
232
440
  return "?";
233
441
  }
234
442
  })();
235
- const lines = [
236
- `会话:${sm.getSessionName?.() || "(未命名)"}`,
237
- `id:${sm.getSessionId?.() ?? "?"}`,
238
- `cwd:${sm.getCwd?.() ?? ctx.cwd ?? "?"}`,
239
- `模型:${model ? `${model.provider}/${model.id}` : "(未设置)"} 思考:${ctx.thinkingLevel ?? "?"}`,
240
- `条目:${entries} 上下文:${usage && usage.tokens !== undefined ? `${formatTokens(usage.tokens)}${usage.percent !== undefined ? ` (${Math.round(usage.percent)}%)` : ""}` : "未知"}`,
241
- ];
242
- reply(lines.join("\n"));
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
+ );
243
456
  return;
244
457
  }
245
458
  case "name": {
@@ -260,7 +473,9 @@ export async function runBridge(
260
473
  reply(
261
474
  [
262
475
  `当前模型:${current ? `${current.provider}/${current.id}` : "(未设置)"}`,
263
- available.length > 0 ? `可选(${available.length}):${names.join(", ")}${available.length > MAX_LIST ? " …" : ""}` : "没有可用模型",
476
+ available.length > 0
477
+ ? `可选(${available.length}):${names.join(", ")}${available.length > MAX_LIST ? " …" : ""}`
478
+ : "没有可用模型",
264
479
  ].join("\n"),
265
480
  );
266
481
  return;