chatccc 0.2.179 → 0.2.180

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
@@ -144,7 +144,12 @@ npm install -g chatccc
144
144
  chatccc
145
145
  ```
146
146
 
147
- 要求 Node.js >= 20。安装完成后,在任意目录执行 `chatccc` 即可启动。配置、日志和状态文件会保存在包目录下的 `config.json`、`logs/`、`state/`。
147
+ 要求 Node.js >= 20。安装完成后,在任意目录执行 `chatccc` 即可启动。配置、日志和状态文件会保存在用户目录的 `.chatccc` 下:
148
+
149
+ - Windows:`C:\Users\<用户名>\.chatccc\`
150
+ - macOS / Linux:`~/.chatccc/`
151
+
152
+ 旧版本留在仓库或包目录下的 `config.json`、`logs/`、`state/` 会在首次启动时自动迁移到用户目录。
148
153
 
149
154
  首次启动时,如果还没有有效配置,ChatCCC 会自动打开本地 Web 配置向导(默认 `http://127.0.0.1:18080`)。
150
155
 
@@ -197,7 +202,7 @@ chatccc
197
202
  # 在微信里找到机器人,发送 /new 开始对话
198
203
  ```
199
204
 
200
- 微信登录信息会保存到 `state/ilink-auth.json`。token 过期后重新扫码即可。
205
+ 微信登录信息会保存到 `~/.chatccc/state/ilink-auth.json`。token 过期后重新扫码即可。
201
206
 
202
207
  ### 3. AI 工具配置
203
208
 
@@ -234,7 +239,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
234
239
 
235
240
  ### 4. `config.json`
236
241
 
237
- `config.json` 不存在时,ChatCCC 会从 `config.sample.json` 复制一份。常用结构如下:
242
+ `~/.chatccc/config.json` 不存在时,ChatCCC 会从 `config.sample.json` 复制一份。常用结构如下:
238
243
 
239
244
  ```json
240
245
  {
@@ -242,28 +247,32 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
242
247
  "appId": "cli_xxxxxxxxxxxx",
243
248
  "appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxx"
244
249
  },
245
- "platforms": {
246
- "feishu": { "enabled": true },
247
- "ilink": { "enabled": true }
248
- },
249
- "port": 18080,
250
- "gitTimeoutSeconds": 180,
251
- "claude": {
252
- "enabled": false,
253
- "defaultAgent": true,
254
- "model": "claude-sonnet-4-6",
255
- "subagentModel": "",
256
- "effort": "",
257
- "apiKey": "",
258
- "baseUrl": ""
259
- },
260
- "cursor": {
261
- "enabled": false,
262
- "defaultAgent": false,
263
- "path": "",
264
- "model": ""
265
- },
266
- "codex": {
250
+ "platforms": {
251
+ "feishu": { "enabled": true, "platformType": "feishu" },
252
+ "ilink": { "enabled": true, "reuseTokenOnStart": true }
253
+ },
254
+ "port": 18080,
255
+ "gitTimeoutSeconds": 180,
256
+ "allowInterrupt": false,
257
+ "claude": {
258
+ "enabled": false,
259
+ "defaultAgent": true,
260
+ "model": "claude-sonnet-4-6",
261
+ "subagentModel": "",
262
+ "effort": "",
263
+ "apiKey": "",
264
+ "baseUrl": "",
265
+ "maxTurn": 0
266
+ },
267
+ "cursor": {
268
+ "enabled": false,
269
+ "defaultAgent": false,
270
+ "path": "",
271
+ "model": "",
272
+ "avatarBatteryMode": "apiPercent",
273
+ "onDemandMonthlyBudget": 1000
274
+ },
275
+ "codex": {
267
276
  "enabled": false,
268
277
  "defaultAgent": false,
269
278
  "path": "",
@@ -276,16 +285,21 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
276
285
  | 字段 | 说明 |
277
286
  | --- | --- |
278
287
  | `feishu.appId` / `feishu.appSecret` | 飞书应用凭证 |
279
- | `platforms.feishu.enabled` | 是否启用飞书 |
280
- | `platforms.ilink.enabled` | 是否启用微信 iLink |
281
- | `port` | 本地 Web 配置面板和中继服务端口 |
282
- | `gitTimeoutSeconds` | `/git` 命令超时时间,默认 180 秒 |
283
- | `*.enabled` | 是否启用对应 AI Agent |
284
- | `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具 |
285
- | `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
286
- | `claude.model` / `claude.subagentModel` / `claude.effort` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
287
- | `claude.apiKey` / `claude.baseUrl` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
288
- | `claude.maxTurn` | 选填;Claude 最大对话轮数,默认 0(无限制),可在 Web UI 编辑 |
288
+ | `platforms.feishu.enabled` | 是否启用飞书 |
289
+ | `platforms.feishu.platformType` | 飞书平台类型,默认 `feishu` |
290
+ | `platforms.ilink.enabled` | 是否启用微信 iLink |
291
+ | `platforms.ilink.reuseTokenOnStart` | 启动时是否复用已有微信登录 token |
292
+ | `port` | 本地 Web 配置面板和中继服务端口 |
293
+ | `gitTimeoutSeconds` | `/git` 命令超时时间,默认 180 |
294
+ | `allowInterrupt` | 是否允许新消息中断正在运行的任务;默认 false |
295
+ | `*.enabled` | 是否启用对应 AI Agent |
296
+ | `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具 |
297
+ | `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
298
+ | `cursor.avatarBatteryMode` | Cursor 头像电量显示来源:`apiPercent` 或 `onDemandUse` |
299
+ | `cursor.onDemandMonthlyBudget` | `avatarBatteryMode=onDemandUse` 时用于计算电量的月预算 |
300
+ | `claude.model` / `claude.subagentModel` / `claude.effort` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
301
+ | `claude.apiKey` / `claude.baseUrl` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
302
+ | `claude.maxTurn` | 选填;Claude 最大对话轮数,默认 0(无限制),可在 Web UI 编辑 |
289
303
 
290
304
  > **权限控制**:普通消息以 `bypassPermissions` 模式运行,跳过 Agent 操作确认。使用 `/plan` 或 `/ask` 前缀时,ChatCCC 自动切换为只读模式:Claude SDK 仅放行 Read + stop-stuck-loop 网络请求,Codex 使用 `--sandbox read-only`,Cursor 使用 `--mode plan/ask`。请只在可信环境中使用。
291
305
 
@@ -301,22 +315,26 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
301
315
  | --- | --- |
302
316
  | `/new` | 使用默认 Agent 创建新会话 |
303
317
  | `/new claude` | 创建 Claude Code 会话 |
304
- | `/new cursor` | 创建 Cursor 会话 |
305
- | `/new codex` | 创建 Codex 会话 |
306
- | `/newh` | 重置当前会话,保留工作目录 |
307
- | `/model` | 查看或切换当前会话的模型 |
308
- | `/stop` | 停止当前回复 |
309
- | `/state` | 查看当前会话状态 |
310
- | `/cd` | 查看或设置当前会话工作目录 |
311
- | `/sessions` | 查看所有会话状态 |
312
- | `/usage` | 查看 Codex 5h 用量和周用量 |
313
- | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
314
- | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
315
- | `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
316
- | `/restart` | 重启机器人进程 |
317
- | `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程) |
318
-
319
- > **模型切换**:`/model` 查看可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。当前仅 Claude Code 支持模型切换(需同时填写 `claude.model` 和 `claude.subagentModel`),Cursor / Codex 切换正在开发中。
318
+ | `/new cursor` | 创建 Cursor 会话 |
319
+ | `/new codex` | 创建 Codex 会话 |
320
+ | `/newh` | 重置当前会话,保留工作目录 |
321
+ | `/model` | 查看或切换当前会话的模型 |
322
+ | `/stop` | 停止当前回复 |
323
+ | `/cancel` | 取消当前会话里排队等待处理的消息 |
324
+ | `/state` | 查看当前会话状态 |
325
+ | `/cd` | 查看或设置当前会话工作目录 |
326
+ | `/sessions` | 查看所有会话状态 |
327
+ | `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话 |
328
+ | `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/周用量,Cursor 显示当前周期用量 |
329
+ | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
330
+ | `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
331
+ | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
332
+ | `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
333
+ | `/restart` | 重启机器人进程 |
334
+ | `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程) |
335
+ | `/deleteg` | 解散当前飞书会话群;Agent 会话记录保留 |
336
+
337
+ > **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`,Cursor 使用 `cursor.model`,Codex 使用 `codex.model`。
320
338
 
321
339
  ---
322
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.179",
3
+ "version": "0.2.180",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -16,10 +16,11 @@ describe("cardJsonToPlainText", () => {
16
16
  expect(text).toContain("/new");
17
17
  expect(text).toContain("/new cursor");
18
18
  expect(text).toContain("/new codex");
19
- expect(text).toContain("/restart");
20
- expect(text).toContain("/update");
21
- expect(text).toContain("/cd");
22
- });
19
+ expect(text).toContain("/restart");
20
+ expect(text).toContain("/update");
21
+ expect(text).toContain("/cd");
22
+ expect(text).toContain("/abd");
23
+ });
23
24
 
24
25
  it("converts status cards from v1 card format", () => {
25
26
  const text = cardJsonToPlainText(buildStatusCard("status body", "green"));
@@ -1,15 +1,16 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import {
3
- buildProgressCard,
4
- buildHelpCard,
2
+ import {
3
+ buildProgressCard,
4
+ buildHelpCard,
5
5
  buildCdContent,
6
6
  buildCdCard,
7
7
  buildSessionsCard,
8
8
  buildStatusCard,
9
9
  buildButtons,
10
10
  truncateContent,
11
- getToolEmoji,
12
- } from "../cards.ts";
11
+ getToolEmoji,
12
+ } from "../cards.ts";
13
+ import { ABD_HELP_LINE } from "../shared-prefix.ts";
13
14
 
14
15
  // ---------------------------------------------------------------------------
15
16
  // truncateContent
@@ -151,14 +152,22 @@ describe("buildHelpCard", () => {
151
152
  expect(parsed.elements[0].text.content).toContain("你好");
152
153
  });
153
154
 
154
- it("includes action buttons", () => {
155
- const card = buildHelpCard("test");
156
- const parsed = JSON.parse(card);
157
- const action = parsed.elements[2];
158
- expect(action.tag).toBe("action");
159
- expect(action.actions).toHaveLength(7);
160
- });
161
- });
155
+ it("includes action buttons", () => {
156
+ const card = buildHelpCard("test");
157
+ const parsed = JSON.parse(card);
158
+ const action = parsed.elements[2];
159
+ expect(action.tag).toBe("action");
160
+ expect(action.actions).toHaveLength(7);
161
+ });
162
+
163
+ it("adds ABD prefix help as the final help line", () => {
164
+ const card = buildHelpCard("test");
165
+ const parsed = JSON.parse(card);
166
+ const lines = parsed.elements[1].text.content.split("\n");
167
+
168
+ expect(lines.at(-1)).toBe(ABD_HELP_LINE);
169
+ });
170
+ });
162
171
 
163
172
  // ---------------------------------------------------------------------------
164
173
  // buildCdContent
@@ -65,8 +65,8 @@ vi.mock("../cursor-usage.ts", () => ({
65
65
  }));
66
66
 
67
67
  import { handleCommand } from "../orchestrator.ts";
68
- import {
69
- _clearAdapterCacheForTest,
68
+ import {
69
+ _clearAdapterCacheForTest,
70
70
  _resetSessionRegistryFileForTest,
71
71
  _resetSessionToolsFileForTest,
72
72
  _setAdapterForToolForTest,
@@ -74,9 +74,10 @@ import {
74
74
  _setSessionToolsFileForTest,
75
75
  loadSessionRegistryForBinding,
76
76
  recordSessionRegistry,
77
- resetState,
78
- } from "../session.ts";
79
- import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
77
+ resetState,
78
+ } from "../session.ts";
79
+ import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
80
+ import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
80
81
 
81
82
  function mockPlatform(kind: "wechat" | "feishu" = "wechat"): PlatformAdapter {
82
83
  return {
@@ -197,8 +198,8 @@ describe("handleCommand WeChat processing ack", () => {
197
198
  expect(platform.sendCard).not.toHaveBeenCalled();
198
199
  });
199
200
 
200
- it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
201
- const platform = mockPlatform();
201
+ it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
202
+ const platform = mockPlatform();
202
203
  await recordSessionRegistry({
203
204
  chatId: "wx-chat",
204
205
  sessionId: "sid-wechat",
@@ -208,9 +209,44 @@ describe("handleCommand WeChat processing ack", () => {
208
209
  });
209
210
 
210
211
  await handleCommand(platform, "继续说明", "wx-chat", "wx-user", Date.now(), "p2p");
211
-
212
- expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
213
- });
212
+
213
+ expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
214
+ });
215
+
216
+ it("treats /abd as a shared prompt prefix in an existing session", async () => {
217
+ const platform = mockPlatform();
218
+ const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
219
+ yield {
220
+ type: "assistant" as const,
221
+ blocks: [{ type: "text" as const, text: "done" }],
222
+ };
223
+ });
224
+ _setAdapterForToolForTest("claude", {
225
+ displayName: "Claude",
226
+ sessionDescPrefix: "Claude Session:",
227
+ createSession: vi.fn(async () => ({ sessionId: "sid-wechat" })),
228
+ prompt,
229
+ getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
230
+ sessionId,
231
+ cwd: "F:\\repo",
232
+ }),
233
+ closeSession: async () => {},
234
+ });
235
+ await recordSessionRegistry({
236
+ chatId: "wx-chat",
237
+ sessionId: "sid-wechat",
238
+ tool: "claude",
239
+ chatName: "ready-session",
240
+ running: false,
241
+ });
242
+
243
+ await handleCommand(platform, "/abd帮我分析", "wx-chat", "wx-user", Date.now(), "p2p");
244
+
245
+ expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
246
+ const userText = prompt.mock.calls[0][1];
247
+ expect(userText).toContain(`[User message]\n帮我分析\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
248
+ expect(userText).not.toContain("/abd");
249
+ });
214
250
 
215
251
  it("does not send the stopped success text until the running prompt really exits", async () => {
216
252
  const platform = mockPlatform();
@@ -232,7 +268,7 @@ describe("handleCommand WeChat processing ack", () => {
232
268
  expect(platform.sendText).not.toHaveBeenCalledWith("wx-chat", "会话已停止。");
233
269
  });
234
270
 
235
- it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
271
+ it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
236
272
  const platform = mockPlatform("feishu");
237
273
  const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
238
274
  yield {
@@ -277,8 +313,40 @@ describe("handleCommand WeChat processing ack", () => {
277
313
 
278
314
  const registry = await loadSessionRegistryForBinding();
279
315
  expect(registry["feishu-p2p"]).toBeUndefined();
280
- expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
281
- });
316
+ expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
317
+ });
318
+
319
+ it("auto-creates a Feishu group for /abd private messages and sends the transformed prompt", async () => {
320
+ const platform = mockPlatform("feishu");
321
+ const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
322
+ yield {
323
+ type: "assistant" as const,
324
+ blocks: [{ type: "text" as const, text: "done" }],
325
+ };
326
+ });
327
+ _setAdapterForToolForTest("claude", {
328
+ displayName: "Claude",
329
+ sessionDescPrefix: "Claude Session:",
330
+ createSession: vi.fn(async () => ({ sessionId: "sid-feishu-abd" })),
331
+ prompt,
332
+ getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
333
+ sessionId,
334
+ cwd: "F:\\repo",
335
+ }),
336
+ closeSession: async () => {},
337
+ });
338
+
339
+ await handleCommand(platform, "/abd帮我看一下日志", "feishu-p2p", "ou-user", Date.now(), "p2p");
340
+
341
+ expect(platform.createGroup).toHaveBeenCalledWith(expect.stringContaining("帮我看一下日志"), ["ou-user"]);
342
+ expect(platform.createGroup).not.toHaveBeenCalledWith(expect.stringContaining("---"), expect.anything());
343
+ const updateCall = vi.mocked(platform.updateChatInfo).mock.calls[0];
344
+ expect(updateCall[1]).not.toContain("---");
345
+ expect(updateCall[1]).not.toContain(ABD_APPEND_PROMPT);
346
+ const userText = prompt.mock.calls[0][1];
347
+ expect(userText).toContain(`[User message]\n帮我看一下日志\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
348
+ expect(userText).not.toContain("/abd");
349
+ });
282
350
 
283
351
  it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
284
352
  const platform = mockPlatform("feishu");
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ ABD_APPEND_PROMPT,
5
+ applySharedPrefix,
6
+ } from "../shared-prefix.ts";
7
+
8
+ describe("applySharedPrefix", () => {
9
+ it("leaves normal messages unchanged", () => {
10
+ expect(applySharedPrefix("帮我分析")).toEqual({
11
+ matched: false,
12
+ text: "帮我分析",
13
+ body: "帮我分析",
14
+ });
15
+ });
16
+
17
+ it("removes /abd without requiring a space", () => {
18
+ const result = applySharedPrefix("/abd帮我分析");
19
+
20
+ expect(result.matched).toBe(true);
21
+ expect(result.body).toBe("帮我分析");
22
+ expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
23
+ });
24
+
25
+ it("removes whitespace after /abd", () => {
26
+ const result = applySharedPrefix("/abd 帮我分析");
27
+
28
+ expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
29
+ });
30
+
31
+ it("keeps the appendix when the user body is empty", () => {
32
+ const result = applySharedPrefix("/abd ");
33
+
34
+ expect(result.text).toBe(`---\n${ABD_APPEND_PROMPT}`);
35
+ });
36
+ });
package/src/cards.ts CHANGED
@@ -1,8 +1,10 @@
1
- // ---------------------------------------------------------------------------
2
- // Button helpers
3
- // ---------------------------------------------------------------------------
4
-
5
- export interface ButtonDef {
1
+ // ---------------------------------------------------------------------------
2
+ // Button helpers
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { ABD_HELP_LINE } from "./shared-prefix.ts";
6
+
7
+ export interface ButtonDef {
6
8
  text: string;
7
9
  value: string;
8
10
  type?: "primary" | "default" | "danger";
@@ -135,10 +137,11 @@ export function buildHelpCard(
135
137
  "发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
136
138
  "发送 **/plan** 以规划模式提问(只读,不执行写操作)",
137
139
  "发送 **/ask** 以问答模式提问(只读,不执行写操作)",
138
- "发送 **/usage** 查看 Codex 5h 和周用量",
139
- "发送 **/restart** 重启 ChatCCC 进程",
140
- "发送 **/update** 更新并重启(仅 npm 全局安装可用)",
141
- ].join("\n");
140
+ "发送 **/usage** 查看 Codex 5h 和周用量",
141
+ "发送 **/restart** 重启 ChatCCC 进程",
142
+ "发送 **/update** 更新并重启(仅 npm 全局安装可用)",
143
+ ABD_HELP_LINE,
144
+ ].join("\n");
142
145
  return JSON.stringify({
143
146
  config: { wide_screen_mode: true },
144
147
  header: { template: "blue", title: { content: "ChatCCC", tag: "plain_text" } },
@@ -78,9 +78,10 @@ import {
78
78
  enqueueMessage,
79
79
  cancelQueuedMessage,
80
80
  } from "./session-chat-binding.ts";
81
- import { getCodexUsageSummary, getTenantAccessToken, sendPostMessage } from "./feishu-platform.ts";
82
- import { getCursorUsageSummary, type CursorUsageSummary } from "./cursor-usage.ts";
83
- export { type PlatformAdapter } from "./platform-adapter.ts";
81
+ import { getCodexUsageSummary, getTenantAccessToken, sendPostMessage } from "./feishu-platform.ts";
82
+ import { getCursorUsageSummary, type CursorUsageSummary } from "./cursor-usage.ts";
83
+ import { applySharedPrefix } from "./shared-prefix.ts";
84
+ export { type PlatformAdapter } from "./platform-adapter.ts";
84
85
  import type { PlatformAdapter } from "./platform-adapter.ts";
85
86
  import type { CodexUsageSummary } from "./feishu-api.ts";
86
87
 
@@ -271,14 +272,13 @@ function isUntitledSessionChatName(name: string): boolean {
271
272
  return name === "新会话" || name.startsWith("新会话-");
272
273
  }
273
274
 
274
- function shouldSendWechatProcessingAck(
275
- platform: PlatformAdapter,
276
- textLower: string,
277
- chatType: string,
278
- text: string,
279
- ): boolean {
280
- return platform.kind === "wechat" && chatType === "p2p" && !textLower.startsWith("/");
281
- }
275
+ function shouldSendWechatProcessingAck(
276
+ platform: PlatformAdapter,
277
+ isCommandText: boolean,
278
+ chatType: string,
279
+ ): boolean {
280
+ return platform.kind === "wechat" && chatType === "p2p" && !isCommandText;
281
+ }
282
282
 
283
283
  function isNonWechatP2p(platform: PlatformAdapter, chatType: string): boolean {
284
284
  return chatType === "p2p" && platform.kind !== "wechat";
@@ -408,13 +408,17 @@ export async function handleCommand(
408
408
  msgTimestamp: number,
409
409
  chatType = "group",
410
410
  traceId?: string,
411
- ): Promise<void> {
412
- const tid = traceId ?? makeTraceId();
413
- const textLower = text.toLowerCase();
414
- recordChatPlatform(chatId, platform);
411
+ ): Promise<void> {
412
+ const tid = traceId ?? makeTraceId();
413
+ const sharedPrefix = applySharedPrefix(text);
414
+ const promptText = sharedPrefix.text;
415
+ text = sharedPrefix.body;
416
+ const textLower = text.toLowerCase();
417
+ const isCommandText = !sharedPrefix.matched && textLower.startsWith("/");
418
+ recordChatPlatform(chatId, platform);
415
419
  await cleanupNonWechatP2pBinding(platform, chatId, chatType, tid);
416
420
 
417
- if (textLower === "/restart") {
421
+ if (isCommandText && textLower === "/restart") {
418
422
  logTrace(tid, "BRANCH", { cmd: "/restart" });
419
423
  await platform.sendText(chatId, "重启中...请几秒后发消息唤醒我").catch(() => {});
420
424
  logTrace(tid, "DONE", { outcome: "restart" });
@@ -449,7 +453,7 @@ export async function handleCommand(
449
453
  return;
450
454
  }
451
455
 
452
- if (textLower === "/update") {
456
+ if (isCommandText && textLower === "/update") {
453
457
  logTrace(tid, "BRANCH", { cmd: "/update" });
454
458
  const isGlobal = isRunningFromGlobalNpm();
455
459
  appendStartupTrace("update: command received", { isGlobal, chatId });
@@ -466,7 +470,7 @@ export async function handleCommand(
466
470
  return;
467
471
  }
468
472
 
469
- if (textLower === "/usage") {
473
+ if (isCommandText && textLower === "/usage") {
470
474
  const usageTool = await resolveUsageTool(chatId);
471
475
  logTrace(tid, "BRANCH", { cmd: "/usage", tool: usageTool });
472
476
  try {
@@ -479,7 +483,7 @@ export async function handleCommand(
479
483
  return;
480
484
  }
481
485
 
482
- if (textLower === "/cd" || textLower.startsWith("/cd ")) {
486
+ if (isCommandText && (textLower === "/cd" || textLower.startsWith("/cd "))) {
483
487
  logTrace(tid, "BRANCH", {
484
488
  cmd: "/cd",
485
489
  arg: text.slice(3).trim() || "(none)",
@@ -602,7 +606,7 @@ export async function handleCommand(
602
606
  return;
603
607
  }
604
608
 
605
- if (textLower === "/new" || textLower.startsWith("/new ")) {
609
+ if (isCommandText && (textLower === "/new" || textLower.startsWith("/new "))) {
606
610
  const toolArg = text.slice(5).trim().toLowerCase();
607
611
  const tool = toolArg || resolveDefaultAgentTool();
608
612
  logTrace(tid, "BRANCH", { cmd: "/new", tool });
@@ -863,7 +867,7 @@ export async function handleCommand(
863
867
  if (sessionId && descriptionTool && toolLabel) {
864
868
  // 有会话上下文 — 路由到命令处理或 prompt
865
869
  logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
866
- const routeKind = textLower.startsWith("/") ? "command" : "prompt";
870
+ const routeKind = isCommandText ? "command" : "prompt";
867
871
  const chatKind = chatType === "p2p" ? "p2p chat" : "session group";
868
872
  console.log(
869
873
  `[${ts()}] [ROUTE] ${toolLabel} ${chatKind} ${routeKind} detected, session=${sessionId} tool=${descriptionTool}`,
@@ -872,7 +876,7 @@ export async function handleCommand(
872
876
  if (
873
877
  chatType !== "p2p" &&
874
878
  isUntitledSessionChatName(chatInfo!.name) &&
875
- !textLower.startsWith("/")
879
+ !isCommandText
876
880
  ) {
877
881
  const MAX_PREFIX = 10;
878
882
  const prefix = text.slice(0, MAX_PREFIX);
@@ -907,7 +911,7 @@ export async function handleCommand(
907
911
  if (
908
912
  chatType === "p2p" &&
909
913
  platform.kind === "wechat" &&
910
- !textLower.startsWith("/")
914
+ !isCommandText
911
915
  ) {
912
916
  try {
913
917
  const reg = await loadSessionRegistryForBinding();
@@ -946,7 +950,7 @@ export async function handleCommand(
946
950
  }
947
951
  }
948
952
 
949
- if (textLower === "/stop") {
953
+ if (isCommandText && textLower === "/stop") {
950
954
  logTrace(tid, "BRANCH", { cmd: "/stop" });
951
955
  if (stopSession(sessionId)) {
952
956
  console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
@@ -960,7 +964,7 @@ export async function handleCommand(
960
964
  return;
961
965
  }
962
966
 
963
- if (textLower === "/cancel") {
967
+ if (isCommandText && textLower === "/cancel") {
964
968
  logTrace(tid, "BRANCH", { cmd: "/cancel" });
965
969
  if (cancelQueuedMessage(sessionId)) {
966
970
  console.log(`[${ts()}] [CANCEL] Queue cancelled for session=${sessionId}`);
@@ -973,7 +977,7 @@ export async function handleCommand(
973
977
  return;
974
978
  }
975
979
 
976
- if (textLower === "/test") {
980
+ if (isCommandText && textLower === "/test") {
977
981
  logTrace(tid, "BRANCH", { cmd: "/test" });
978
982
  const tableHeaders = ["名称", "版本", "状态"];
979
983
  const tableRows = [
@@ -1010,7 +1014,7 @@ export async function handleCommand(
1010
1014
  return;
1011
1015
  }
1012
1016
 
1013
- if (textLower === "/state") {
1017
+ if (isCommandText && textLower === "/state") {
1014
1018
  logTrace(tid, "BRANCH", { cmd: "/state" });
1015
1019
  const status = await getSessionStatus(chatId);
1016
1020
  const isActive = isSessionRunning(sessionId);
@@ -1049,7 +1053,7 @@ export async function handleCommand(
1049
1053
  return;
1050
1054
  }
1051
1055
 
1052
- if (textLower === "/sessions") {
1056
+ if (isCommandText && textLower === "/sessions") {
1053
1057
  logTrace(tid, "BRANCH", { cmd: "/sessions" });
1054
1058
  const allSessions = await getAllSessionsStatus();
1055
1059
  const now = Date.now();
@@ -1074,7 +1078,7 @@ export async function handleCommand(
1074
1078
  return;
1075
1079
  }
1076
1080
 
1077
- if (textLower === "/newh") {
1081
+ if (isCommandText && textLower === "/newh") {
1078
1082
  logTrace(tid, "BRANCH", { cmd: "/newh" });
1079
1083
  const adapter = getAdapterForTool(descriptionTool, sessionId);
1080
1084
  let cwd: string;
@@ -1161,7 +1165,7 @@ export async function handleCommand(
1161
1165
  return;
1162
1166
  }
1163
1167
 
1164
- if (textLower === "/deleteg") {
1168
+ if (isCommandText && textLower === "/deleteg") {
1165
1169
  logTrace(tid, "BRANCH", { cmd: "/deleteg" });
1166
1170
  if (chatType === "p2p") {
1167
1171
  await platform
@@ -1199,7 +1203,7 @@ export async function handleCommand(
1199
1203
  }
1200
1204
 
1201
1205
  // /session <number>:切换到 /sessions 列表中的指定会话
1202
- const sessionMatch = textLower.match(/^\/session\s+(\d+)$/);
1206
+ const sessionMatch = isCommandText ? textLower.match(/^\/session\s+(\d+)$/) : null;
1203
1207
  if (sessionMatch) {
1204
1208
  const index = parseInt(sessionMatch[1], 10) - 1;
1205
1209
  logTrace(tid, "BRANCH", { cmd: "/session", index: index + 1 });
@@ -1325,7 +1329,7 @@ export async function handleCommand(
1325
1329
  }
1326
1330
 
1327
1331
  // /model clear — 清除当前 session 的模型覆盖
1328
- if (textLower === "/model clear") {
1332
+ if (isCommandText && textLower === "/model clear") {
1329
1333
  logTrace(tid, "BRANCH", { cmd: "/model clear", sessionId });
1330
1334
  clearSessionModelOverride(sessionId);
1331
1335
  const defaultModel = getEffectiveModelForTool(descriptionTool);
@@ -1340,7 +1344,7 @@ export async function handleCommand(
1340
1344
  }
1341
1345
 
1342
1346
  // /model <name> — 切换当前 session 的模型(支持所有 agent,模糊匹配)
1343
- if (textLower.startsWith("/model ")) {
1347
+ if (isCommandText && textLower.startsWith("/model ")) {
1344
1348
  const modelArg = text.slice(7).trim();
1345
1349
  if (!modelArg) return; // 纯 "/model " 不处理,交给上面的 /model 分支
1346
1350
  logTrace(tid, "BRANCH", { cmd: "/model", arg: modelArg, sessionId, tool: descriptionTool });
@@ -1374,7 +1378,7 @@ export async function handleCommand(
1374
1378
  }
1375
1379
 
1376
1380
  // /model — 查看当前会话的可用模型(根据会话 Agent 类型)
1377
- if (textLower === "/model") {
1381
+ if (isCommandText && textLower === "/model") {
1378
1382
  logTrace(tid, "BRANCH", { cmd: "/model", sessionId, tool: descriptionTool });
1379
1383
  const models = getAllModelsForTool(descriptionTool as AgentTool);
1380
1384
  const currentModel = getEffectiveModelForTool(descriptionTool, sessionId);
@@ -1398,7 +1402,7 @@ export async function handleCommand(
1398
1402
  }
1399
1403
 
1400
1404
  // /git <args>:在「当前会话工作目录」执行 git 命令
1401
- if (textLower.startsWith("/git ") || textLower === "/git") {
1405
+ if (isCommandText && (textLower.startsWith("/git ") || textLower === "/git")) {
1402
1406
  const args = text === "/git" ? "" : text.slice(5).trim();
1403
1407
  logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
1404
1408
  if (!args) {
@@ -1467,9 +1471,9 @@ export async function handleCommand(
1467
1471
 
1468
1472
  // 并发检查:同一 session 只能有一个活跃 prompt,多余消息进入队列
1469
1473
  if (isSessionRunning(sessionId)) {
1470
- const queued = enqueueMessage(sessionId, {
1471
- text, chatId, openId, msgTimestamp, chatType, traceId: tid,
1472
- });
1474
+ const queued = enqueueMessage(sessionId, {
1475
+ text: promptText, chatId, openId, msgTimestamp, chatType, traceId: tid,
1476
+ });
1473
1477
  if (queued) {
1474
1478
  logTrace(tid, "QUEUED", { sessionId });
1475
1479
  console.log(
@@ -1494,16 +1498,16 @@ export async function handleCommand(
1494
1498
  return;
1495
1499
  }
1496
1500
 
1497
- if (shouldSendWechatProcessingAck(platform, textLower, chatType, text)) {
1501
+ if (shouldSendWechatProcessingAck(platform, isCommandText, chatType)) {
1498
1502
  await platform.sendText(chatId, "生成中...").catch(() => {});
1499
1503
  }
1500
1504
 
1501
1505
  try {
1502
1506
  logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
1503
1507
  await resumeAndPrompt(
1504
- sessionId,
1505
- text,
1506
- platform,
1508
+ sessionId,
1509
+ promptText,
1510
+ platform,
1507
1511
  chatId,
1508
1512
  msgTimestamp,
1509
1513
  descriptionTool,
@@ -1529,7 +1533,7 @@ export async function handleCommand(
1529
1533
  }
1530
1534
 
1531
1535
  // 无会话上下文 → 检查是否是 /model 查询
1532
- if (textLower === "/model") {
1536
+ if (isCommandText && textLower === "/model") {
1533
1537
  const defaultTool = resolveDefaultAgentTool();
1534
1538
  const models = getAllModelsForTool(defaultTool);
1535
1539
  let currentModel = "";
@@ -1556,7 +1560,7 @@ export async function handleCommand(
1556
1560
  }
1557
1561
 
1558
1562
  // 无会话上下文 → /sessions 仍是有效指令,不触发飞书私聊自动建群。
1559
- if (textLower === "/sessions") {
1563
+ if (isCommandText && textLower === "/sessions") {
1560
1564
  logTrace(tid, "BRANCH", { cmd: "/sessions", scope: "global" });
1561
1565
  const allSessions = await getAllSessionsStatus();
1562
1566
  const now = Date.now();
@@ -1582,7 +1586,7 @@ export async function handleCommand(
1582
1586
  }
1583
1587
 
1584
1588
  // 飞书私聊普通消息:不再绑定私聊本身,而是自动创建会话群并把私聊内容作为首轮 prompt。
1585
- if (isNonWechatP2p(platform, chatType) && !textLower.startsWith("/")) {
1589
+ if (isNonWechatP2p(platform, chatType) && !isCommandText) {
1586
1590
  const tool = resolveDefaultAgentTool();
1587
1591
  const toolLabel = toolDisplayName(tool);
1588
1592
  logTrace(tid, "BRANCH", { cmd: "auto_new_from_p2p", tool });
@@ -1700,9 +1704,9 @@ export async function handleCommand(
1700
1704
  try {
1701
1705
  logTrace(tid, "RESUME", { sessionId, tool, trigger: "auto_new_from_p2p" });
1702
1706
  await resumeAndPrompt(
1703
- sessionId,
1704
- text,
1705
- platform,
1707
+ sessionId,
1708
+ promptText,
1709
+ platform,
1706
1710
  newChatId,
1707
1711
  msgTimestamp,
1708
1712
  tool,
@@ -0,0 +1,29 @@
1
+ export const ABD_PREFIX = "/abd";
2
+
3
+ export const ABD_APPEND_PROMPT =
4
+ "请从第一性原理出发挖掘我的真实需求。你觉得我的需求合理吗?开始实现之前有什么问题要问我的?";
5
+
6
+ export const ABD_HELP_LINE =
7
+ `发送 **${ABD_PREFIX}** 前缀消息,将以第一性原理追问真实需求、合理性和实现前问题`;
8
+
9
+ export interface SharedPrefixResult {
10
+ matched: boolean;
11
+ text: string;
12
+ body: string;
13
+ prefix?: typeof ABD_PREFIX;
14
+ }
15
+
16
+ export function applySharedPrefix(text: string): SharedPrefixResult {
17
+ if (!text.toLowerCase().startsWith(ABD_PREFIX)) {
18
+ return { matched: false, text, body: text };
19
+ }
20
+
21
+ const body = text.slice(ABD_PREFIX.length).replace(/^\s+/, "");
22
+ const appendix = ["---", ABD_APPEND_PROMPT].join("\n");
23
+ return {
24
+ matched: true,
25
+ prefix: ABD_PREFIX,
26
+ body,
27
+ text: body ? [body, appendix].join("\n\n") : appendix,
28
+ };
29
+ }