@keyou007/agent-bot 0.1.15-alpha.5 → 0.1.15-alpha.6

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.
@@ -147,6 +147,10 @@ feishu:
147
147
  userOpenId: "${FEISHU_USER_OPEN_ID}"
148
148
  respondToOwnerOnly: true
149
149
  respondToAllGroupMessages: true
150
+ groupNameFormat:
151
+ project: "[{agent}] [{project}] {taskname}"
152
+ projectless: "[{agent}] {taskname}"
153
+ dateFormat: "MM-dd"
150
154
  thinkingCardLayout: "grouped"
151
155
 
152
156
  console:
@@ -185,6 +189,19 @@ logging:
185
189
 
186
190
  `/mute` and `/mute on` persist mention-only response mode for the current base group; `/mute off` disables it. The group timeline and every topic share this state. While muted, messages that do not mention the current bot are ignored before event claims, reactions, activity tracking, image downloads, command parsing, or Agent calls. The command that disables mute must itself mention the bot. Private chats do not support `/mute`.
187
191
 
192
+ `feishu.groupNameFormat.project` and `projectless` control names created by NewGroup or ForkGroup for Project and Projectless tasks. Each template must contain exactly one `{taskname}` and may use these variables:
193
+
194
+ | Variable | Value |
195
+ | --- | --- |
196
+ | `{os}` | Current system: `win` on Windows, `mac` on macOS, and `linux` on Linux |
197
+ | `{agent}` | Configured Agent standard name, such as `codex` or `traex` |
198
+ | `{project}` | At most the final two Project path levels, limited to 15 characters; normally omitted from the Projectless template |
199
+ | `{taskname}` | New-task or fork-task title |
200
+ | `{date}` | Local date and time rendered with `dateFormat` |
201
+ | `{date:yyyy-MM-dd}` | A format override for this placeholder only |
202
+
203
+ Date formats support `yyyy`/`yy` for year, `MM`/`M` for month, `dd`/`d` for day, `HH`/`H` for hour, `mm`/`m` for minute, and `ss`/`s` for second. Uppercase `YYYY`, `YY`, `DD`, and `D` are also accepted. Other characters are emitted literally. Feishu's 60-character group-name limit still applies; Agent Bot truncates `{taskname}` first so configured prefixes and suffixes remain visible. The defaults exactly preserve previous group names. Rerunning `agentbot init` adds this section to an existing config without replacing user settings.
204
+
188
205
  `feishu.thinkingCardLayout` defaults to `grouped`. The grouped renderer keeps Commentary and user steering messages visible, uses Commentary as the only execution-group boundary, uses only the latest native reasoning as each group title, and keeps every tool available inside the group. Command panels remove PowerShell launchers and POSIX command-string launchers such as `/bin/zsh -lc`, `/bin/bash -c`, and `/usr/bin/env sh -c` from both their titles and expanded command text without changing the command that is executed. Execution-group status is intentionally non-failing: while any child tool is running the group uses the running treatment, otherwise it settles to the neutral treatment; failed tools retain their own failure icon inside the group. Every execution panel is rendered with a collapsed default and a stable `element_id`; Agent Bot never changes the expanded flag based on tool status, user steering, or later Commentary, allowing the Feishu client to retain a user's manual expansion across full-card updates. Grouped pagination first renders complete tool panels, then fills pages from the newest end according to the rendered UTF-8 JSON size and component count. The activity area is limited to 24KB and 160 components, leaving headroom under Feishu's recommended 30KB card size and 200-component hard limit for the header, plan, files, and actions. Execution groups above eight tools are split into stable subpanels, but every tool keeps its expandable command, result, and image content. History pages are measured separately with complete native reasoning included. Set the option to `timeline` to use the unchanged original renderer and its 40-activity pages while the grouped layout is being refined.
189
206
 
190
207
  Feishu local commands beginning with `!` are persisted under `command-jobs/` beside the configured SQLite file and run in detached Runner processes. They have no execution timeout and release the conversation message queue immediately after submission, so multiple commands may run concurrently. Each Runner persists status and writes both stdout and stderr chunks to one `output.log` in observed arrival order; the log compacts its middle after reaching 8 MiB while retaining the beginning and end. The Worker only polls these files and updates the original card, then reattaches to unfinished jobs after restart. The running card's `Cancel` action persists a cancellation request that makes the Runner terminate the command process tree. This background mode applies only to Feishu `!` commands; the synchronous `agentbot task shell` CLI behavior is unchanged.
@@ -147,6 +147,10 @@ feishu:
147
147
  userOpenId: "${FEISHU_USER_OPEN_ID}"
148
148
  respondToOwnerOnly: true
149
149
  respondToAllGroupMessages: true
150
+ groupNameFormat:
151
+ project: "[{agent}] [{project}] {taskname}"
152
+ projectless: "[{agent}] {taskname}"
153
+ dateFormat: "MM-dd"
150
154
  thinkingCardLayout: "grouped"
151
155
 
152
156
  console:
@@ -185,6 +189,19 @@ logging:
185
189
 
186
190
  `/mute` 或 `/mute on` 会为当前基础群聊持久化仅 @ 响应模式,`/mute off` 会关闭。群正文及其所有话题共享该状态;静音期间未 @ 当前机器人的消息会在事件去重、reaction、活跃时间、图片下载、命令解析和 Agent 调用之前被忽略。关闭静音的命令本身也必须 @ 机器人。私聊不支持 `/mute`。
187
191
 
192
+ `feishu.groupNameFormat.project` 和 `projectless` 分别控制 Project 与 Projectless 任务通过 NewGroup 或 ForkGroup 创建的群名。每个模板必须且只能包含一个 `{taskname}`,并可使用以下变量:
193
+
194
+ | 变量 | 内容 |
195
+ | --- | --- |
196
+ | `{os}` | 当前系统:Windows 为 `win`、macOS 为 `mac`、Linux 为 `linux` |
197
+ | `{agent}` | Agent 配置标准名,例如 `codex` 或 `traex` |
198
+ | `{project}` | Project 目录末尾最多两级,最长 15 个字符;Projectless 模板通常不使用 |
199
+ | `{taskname}` | 新任务或 Fork 任务的标题 |
200
+ | `{date}` | 按 `dateFormat` 格式化的本地日期时间 |
201
+ | `{date:yyyy-MM-dd}` | 只为当前位置指定日期格式,不修改全局 `dateFormat` |
202
+
203
+ 日期格式支持 `yyyy`/`yy` 年、`MM`/`M` 月、`dd`/`d` 日、`HH`/`H` 时、`mm`/`m` 分和 `ss`/`s` 秒,也兼容大写 `YYYY`、`YY`、`DD`、`D`。其他字符按原样输出。最终群名遵循飞书 60 字符限制;超长时优先截断 `{taskname}`,保留模板的前后固定部分。默认模板与旧版群名完全一致。重新运行 `agentbot init` 会为已有配置补齐这一配置段,不覆盖用户已有设置。
204
+
188
205
  `feishu.thinkingCardLayout` 默认为 `grouped`。分组渲染器保持 Commentary 和用户追加消息可见,只把 Commentary 作为执行组边界;每个执行组只用最新原生思考作为标题,展开后仍可查看组内全部工具。命令面板会从标题和展开后的命令正文中去掉 PowerShell 启动器,以及 `/bin/zsh -lc`、`/bin/bash -c`、`/usr/bin/env sh -c` 等 POSIX 命令字符串启动器,但不会修改实际执行的命令。执行组状态不会聚合为失败:存在运行中工具时使用运行中样式,否则回到中性样式;失败工具仍在组内保留自己的失败图标。所有执行面板都以折叠状态作为默认值,并使用稳定的 `element_id`;Agent Bot 不再根据工具状态、用户追加或后续 Commentary 改写展开属性,让飞书客户端能够在整卡更新时保留用户手动展开的状态。分组分页会先完整渲染工具面板,再根据生成内容的 UTF-8 JSON 大小和组件数量从最新端向前装页。活动区上限为 24KB 和 160 个组件,为飞书建议的 30KB 卡片大小及 200 个组件硬限制预留标题、计划、文件变更和操作按钮空间。超过 8 个工具的单个执行组会拆成标识稳定的子面板,但每个工具都继续保留可展开的命令、结果和图片内容。历史页会包含完整原生思考并独立测量。新版布局完善期间,可将该配置设为 `timeline` 使用保持不变的原版渲染器及其每页 40 条活动规则。
189
206
 
190
207
  以 `!` 开头的飞书本地命令会写入 SQLite 文件同级的 `command-jobs/` 目录,并由脱离 Worker 的独立 Runner 进程执行。命令没有运行超时,提交成功后立即释放当前会话消息队列;不同命令可以并发运行。Runner 将状态持久化到任务目录,并把 stdout 和 stderr 数据块按照观察到的到达顺序写入同一个 `output.log`;日志达到 8 MiB 后保留头尾并压缩中间内容。Worker 只轮询这些文件并更新原卡片,重启后通过未完成状态重新接管监控。运行卡片的 `Cancel` 操作写入持久化取消请求,由 Runner 终止命令进程树。该后台模式仅用于飞书 `!` 命令;CLI 的同步 `agentbot task shell` 行为保持不变。
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.15-alpha.5",
3
+ "version": "0.1.15-alpha.6",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@keyou007/agent-bot",
9
- "version": "0.1.15-alpha.5",
9
+ "version": "0.1.15-alpha.6",
10
10
  "license": "GPL-3.0-only",
11
11
  "dependencies": {
12
12
  "@agentclientprotocol/sdk": "1.2.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.15-alpha.5",
3
+ "version": "0.1.15-alpha.6",
4
4
  "type": "module",
5
5
  "description": "Use Codex, TraeX, and compatible coding agents from Lark/Feishu.",
6
6
  "license": "GPL-3.0-only",
@@ -17,6 +17,10 @@ const DEFAULT_CONFIG = `feishu:
17
17
  userOpenId: "\${FEISHU_USER_OPEN_ID}"
18
18
  respondToOwnerOnly: true
19
19
  respondToAllGroupMessages: true
20
+ groupNameFormat:
21
+ project: "[{agent}] [{project}] {taskname}"
22
+ projectless: "[{agent}] {taskname}"
23
+ dateFormat: "MM-dd"
20
24
  thinkingCardLayout: "grouped"
21
25
 
22
26
  console:
@@ -3,6 +3,37 @@ import { DEFAULT_LOG_PATH, DEFAULT_SQLITE_PATH } from "./paths.js";
3
3
 
4
4
  const envRecordSchema = z.record(z.string(), z.string()).default({});
5
5
 
6
+ export const DEFAULT_GROUP_NAME_FORMAT = {
7
+ project: "[{agent}] [{project}] {taskname}",
8
+ projectless: "[{agent}] {taskname}",
9
+ dateFormat: "MM-dd",
10
+ } as const;
11
+
12
+ const groupNameTemplateSchema = z.string().min(1).max(240).superRefine((template, context) => {
13
+ const tokens = [...template.matchAll(/\{([^{}]+)\}/gu)].map((match) => match[1]!);
14
+ const taskNameCount = tokens.filter((token) => token === "taskname").length;
15
+ if (taskNameCount !== 1) {
16
+ context.addIssue({
17
+ code: "custom",
18
+ message: "A group name format must contain exactly one {taskname} placeholder.",
19
+ });
20
+ }
21
+ for (const token of tokens) {
22
+ if (["os", "agent", "project", "taskname", "date"].includes(token)) continue;
23
+ if (token.startsWith("date:") && token.slice("date:".length).trim()) continue;
24
+ context.addIssue({
25
+ code: "custom",
26
+ message: `Unsupported group name placeholder: {${token}}.`,
27
+ });
28
+ }
29
+ });
30
+
31
+ const groupNameFormatSchema = z.object({
32
+ project: groupNameTemplateSchema.default(DEFAULT_GROUP_NAME_FORMAT.project),
33
+ projectless: groupNameTemplateSchema.default(DEFAULT_GROUP_NAME_FORMAT.projectless),
34
+ dateFormat: z.string().min(1).max(80).default(DEFAULT_GROUP_NAME_FORMAT.dateFormat),
35
+ }).default(DEFAULT_GROUP_NAME_FORMAT);
36
+
6
37
  const agentKindSchema = z.union([
7
38
  z.literal("acp"),
8
39
  z.literal("app-server"),
@@ -30,6 +61,7 @@ export const appConfigSchema = z.object({
30
61
  userOpenId: z.string().optional(),
31
62
  respondToOwnerOnly: z.boolean().default(true),
32
63
  respondToAllGroupMessages: z.boolean().default(true),
64
+ groupNameFormat: groupNameFormatSchema,
33
65
  thinkingCardLayout: z.enum(["grouped", "timeline"]).default("grouped"),
34
66
  useConsoleWhenMissingCredentials: z.boolean().default(false),
35
67
  }),
@@ -53,4 +85,5 @@ export const appConfigSchema = z.object({
53
85
  });
54
86
 
55
87
  export type AgentConfig = z.infer<typeof agentConfigSchema>;
88
+ export type GroupNameFormatConfig = z.infer<typeof groupNameFormatSchema>;
56
89
  export type AppConfig = z.infer<typeof appConfigSchema>;
@@ -1,5 +1,5 @@
1
1
  export const FINAL_RESPONSE_BRANDING =
2
- "> Powered by [AgentBot](https://bytedance.larkoffice.com/docx/UTukdYBopojSivxRVeUcCXTtnid)";
2
+ "> Powered by [AgentBot](https://keyou.github.io/agent-bot/)";
3
3
  export const FINAL_RESPONSE_BRANDING_BLOCK = `----\n\n${FINAL_RESPONSE_BRANDING}`;
4
4
 
5
5
  export function splitFinalResponseBranding(markdown: string): {
@@ -0,0 +1,222 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import {
4
+ DEFAULT_GROUP_NAME_FORMAT,
5
+ type GroupNameFormatConfig,
6
+ } from "../config/schema.js";
7
+
8
+ export const FEISHU_GROUP_NAME_MAX_LENGTH = 60;
9
+ const GROUP_PROJECT_NAME_MAX_LENGTH = 15;
10
+ const PLACEHOLDER_PATTERN = /\{([^{}]+)\}/gu;
11
+ const TASK_NAME_PLACEHOLDER = "{taskname}";
12
+
13
+ export interface GroupNameFormatInput {
14
+ agentName: string;
15
+ projectCwd?: string;
16
+ taskName: string;
17
+ date?: Date;
18
+ platform?: NodeJS.Platform;
19
+ format?: GroupNameFormatConfig;
20
+ }
21
+
22
+ export interface GroupNameParseInput {
23
+ agentName: string;
24
+ groupName: string;
25
+ projectCwd?: string;
26
+ platform?: NodeJS.Platform;
27
+ format?: GroupNameFormatConfig;
28
+ }
29
+
30
+ export function formatNewGroupName(input: GroupNameFormatInput): string {
31
+ const format = input.format ?? DEFAULT_GROUP_NAME_FORMAT;
32
+ const template = input.projectCwd ? format.project : format.projectless;
33
+ const taskNameIndex = template.indexOf(TASK_NAME_PLACEHOLDER);
34
+ const values = templateValues(input, format);
35
+ const prefix = renderTemplate(template.slice(0, taskNameIndex), values);
36
+ const suffix = renderTemplate(template.slice(taskNameIndex + TASK_NAME_PLACEHOLDER.length), values);
37
+ const availableTaskNameLength = FEISHU_GROUP_NAME_MAX_LENGTH
38
+ - Array.from(prefix).length
39
+ - Array.from(suffix).length;
40
+ const taskName = truncateTail(input.taskName, availableTaskNameLength);
41
+ return truncateTail(`${prefix}${taskName}${suffix}`.trim(), FEISHU_GROUP_NAME_MAX_LENGTH);
42
+ }
43
+
44
+ export function parseTaskNameFromGroupName(input: GroupNameParseInput): string | undefined {
45
+ const format = input.format ?? DEFAULT_GROUP_NAME_FORMAT;
46
+ const template = input.projectCwd ? format.project : format.projectless;
47
+ const values = templateValues(input, format);
48
+ let pattern = "^";
49
+ let cursor = 0;
50
+ let match: RegExpExecArray | null;
51
+ PLACEHOLDER_PATTERN.lastIndex = 0;
52
+ while ((match = PLACEHOLDER_PATTERN.exec(template)) !== null) {
53
+ pattern += escapeRegExp(template.slice(cursor, match.index));
54
+ const token = match[1]!;
55
+ if (token === "taskname") pattern += "(?<taskname>.+?)";
56
+ else if (token === "date" || token.startsWith("date:")) {
57
+ const dateFormat = token === "date" ? format.dateFormat : token.slice("date:".length);
58
+ pattern += dateFormatPattern(dateFormat);
59
+ } else {
60
+ pattern += escapeRegExp(placeholderValue(token, values));
61
+ }
62
+ cursor = match.index + match[0].length;
63
+ }
64
+ pattern += `${escapeRegExp(template.slice(cursor))}$`;
65
+ const parsed = new RegExp(pattern, "iu").exec(input.groupName.trim())?.groups?.taskname?.trim();
66
+ return parsed || undefined;
67
+ }
68
+
69
+ export function formatGroupNameDate(date: Date, format: string): string {
70
+ const year = date.getFullYear();
71
+ const month = date.getMonth() + 1;
72
+ const day = date.getDate();
73
+ const hour = date.getHours();
74
+ const minute = date.getMinutes();
75
+ const second = date.getSeconds();
76
+ const values = new Map<string, string>([
77
+ ["yyyy", String(year).padStart(4, "0")],
78
+ ["YYYY", String(year).padStart(4, "0")],
79
+ ["yy", String(year % 100).padStart(2, "0")],
80
+ ["YY", String(year % 100).padStart(2, "0")],
81
+ ["MM", String(month).padStart(2, "0")],
82
+ ["M", String(month)],
83
+ ["dd", String(day).padStart(2, "0")],
84
+ ["DD", String(day).padStart(2, "0")],
85
+ ["d", String(day)],
86
+ ["D", String(day)],
87
+ ["HH", String(hour).padStart(2, "0")],
88
+ ["H", String(hour)],
89
+ ["mm", String(minute).padStart(2, "0")],
90
+ ["m", String(minute)],
91
+ ["ss", String(second).padStart(2, "0")],
92
+ ["s", String(second)],
93
+ ]);
94
+ return replaceDateTokens(format, (token) => values.get(token)!);
95
+ }
96
+
97
+ export function groupNameOs(platform: NodeJS.Platform = process.platform): string {
98
+ if (platform === "win32") return "win";
99
+ if (platform === "darwin") return "mac";
100
+ if (platform === "linux") return "linux";
101
+ return platform;
102
+ }
103
+
104
+ export function formatGroupProjectName(projectCwd: string): string {
105
+ const value = abbreviateHomeDirectory(projectCwd);
106
+ const levels = value
107
+ .replace(/[\\/]+$/, "")
108
+ .split(/[\\/]+/)
109
+ .filter(Boolean)
110
+ .slice(-2);
111
+ const separator = value.includes("\\") ? path.win32.sep : path.posix.sep;
112
+ return fitTrailingPathLevels(levels, GROUP_PROJECT_NAME_MAX_LENGTH, separator);
113
+ }
114
+
115
+ interface TemplateValues {
116
+ agent: string;
117
+ project: string;
118
+ os: string;
119
+ date: Date;
120
+ dateFormat: string;
121
+ }
122
+
123
+ function templateValues(
124
+ input: Pick<GroupNameFormatInput, "agentName" | "projectCwd" | "date" | "platform">,
125
+ format: GroupNameFormatConfig,
126
+ ): TemplateValues {
127
+ return {
128
+ agent: input.agentName,
129
+ project: input.projectCwd ? formatGroupProjectName(input.projectCwd) : "",
130
+ os: groupNameOs(input.platform),
131
+ date: input.date ?? new Date(),
132
+ dateFormat: format.dateFormat,
133
+ };
134
+ }
135
+
136
+ function renderTemplate(template: string, values: TemplateValues): string {
137
+ PLACEHOLDER_PATTERN.lastIndex = 0;
138
+ return template.replace(PLACEHOLDER_PATTERN, (_placeholder, token: string) => {
139
+ if (token === "date") return formatGroupNameDate(values.date, values.dateFormat);
140
+ if (token.startsWith("date:")) {
141
+ return formatGroupNameDate(values.date, token.slice("date:".length));
142
+ }
143
+ return placeholderValue(token, values);
144
+ });
145
+ }
146
+
147
+ function placeholderValue(token: string, values: TemplateValues): string {
148
+ if (token === "agent") return values.agent;
149
+ if (token === "project") return values.project;
150
+ if (token === "os") return values.os;
151
+ return "";
152
+ }
153
+
154
+ function dateFormatPattern(format: string): string {
155
+ return replaceDateTokens(format, (token) => {
156
+ if (["yyyy", "YYYY"].includes(token)) return "\\d{4}";
157
+ if (["yy", "YY", "MM", "dd", "DD", "HH", "mm", "ss"].includes(token)) return "\\d{2}";
158
+ return "\\d{1,2}";
159
+ }, escapeRegExp);
160
+ }
161
+
162
+ const DATE_TOKENS = [
163
+ "yyyy", "YYYY", "yy", "YY", "MM", "dd", "DD", "HH", "mm", "ss", "M", "d", "D", "H", "m", "s",
164
+ ];
165
+
166
+ function replaceDateTokens(
167
+ format: string,
168
+ replaceToken: (token: string) => string,
169
+ replaceLiteral: (literal: string) => string = (literal) => literal,
170
+ ): string {
171
+ let result = "";
172
+ for (let index = 0; index < format.length;) {
173
+ const token = DATE_TOKENS.find((candidate) => format.startsWith(candidate, index));
174
+ if (token) {
175
+ result += replaceToken(token);
176
+ index += token.length;
177
+ } else {
178
+ result += replaceLiteral(format[index]!);
179
+ index += 1;
180
+ }
181
+ }
182
+ return result;
183
+ }
184
+
185
+ function abbreviateHomeDirectory(value: string): string {
186
+ const homeDirectory = os.homedir();
187
+ const valueIsWindows = isWindowsAbsolutePath(value);
188
+ const homeIsWindows = isWindowsAbsolutePath(homeDirectory);
189
+ if (valueIsWindows !== homeIsWindows && (valueIsWindows || path.posix.isAbsolute(value))) return value;
190
+ const pathApi = valueIsWindows ? path.win32 : path;
191
+ const relative = pathApi.relative(homeDirectory, value);
192
+ if (relative === "") return "~";
193
+ if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`)) return value;
194
+ return `~${pathApi.sep}${relative}`;
195
+ }
196
+
197
+ function isWindowsAbsolutePath(value: string): boolean {
198
+ return /^[a-z]:[\\/]|^\\\\/iu.test(value);
199
+ }
200
+
201
+ function fitTrailingPathLevels(levels: string[], maxLength: number, separator: string): string {
202
+ if (levels.length === 0) return "";
203
+ if (levels.length === 1) return truncateTail(levels[0]!, maxLength);
204
+ const parent = levels[0]!;
205
+ const leaf = levels[1]!;
206
+ const joined = `${parent}${separator}${leaf}`;
207
+ if (Array.from(joined).length <= maxLength) return joined;
208
+ if (Array.from(leaf).length <= maxLength) return leaf;
209
+ return truncateTail(leaf, maxLength);
210
+ }
211
+
212
+ function truncateTail(value: string, maxLength: number): string {
213
+ const characters = Array.from(value);
214
+ if (characters.length <= maxLength) return value;
215
+ if (maxLength <= 0) return "";
216
+ if (maxLength <= 3) return ".".repeat(maxLength);
217
+ return `${characters.slice(0, maxLength - 3).join("")}...`;
218
+ }
219
+
220
+ function escapeRegExp(value: string): string {
221
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
222
+ }
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import type { Logger } from "pino";
6
6
  import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
7
7
  import { resolveUserPath } from "../config/paths.js";
8
- import type { AppConfig } from "../config/schema.js";
8
+ import { DEFAULT_GROUP_NAME_FORMAT, type AppConfig } from "../config/schema.js";
9
9
  import { CommandRouter } from "../commands/CommandRouter.js";
10
10
  import type { Command } from "../commands/commandTypes.js";
11
11
  import { baseChatContextKey, isThreadContextKey } from "../feishu/contextKey.js";
@@ -30,6 +30,11 @@ import {
30
30
  } from "../feishu/CardRenderer.js";
31
31
  import { CardUpdateScheduler } from "../feishu/CardUpdateScheduler.js";
32
32
  import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
33
+ import {
34
+ formatGroupNameDate,
35
+ formatNewGroupName,
36
+ parseTaskNameFromGroupName,
37
+ } from "../feishu/GroupNameFormatter.js";
33
38
  import { normalizeFeishuPostText } from "../feishu/InboundText.js";
34
39
  import { allowsFeishuUser } from "../feishu/ownerAccess.js";
35
40
  import type { OutboundRouter } from "../presentation/OutboundRouter.js";
@@ -3222,7 +3227,10 @@ export class ProxySessionController {
3222
3227
  executionSettings: SessionExecutionSettings,
3223
3228
  ): Promise<CreatedFeishuTaskGroup> {
3224
3229
  const explicitTitle = normalizeTaskTitle(requestedTitle);
3225
- const taskTitle = explicitTitle ?? `新任务 (${formatGroupDateSuffix(new Date())})`;
3230
+ const taskTitle = explicitTitle ?? `新任务 (${formatGroupNameDate(
3231
+ new Date(),
3232
+ this.config.feishu?.groupNameFormat?.dateFormat ?? DEFAULT_GROUP_NAME_FORMAT.dateFormat,
3233
+ )})`;
3226
3234
  const group = await this.createFeishuGroupContext(
3227
3235
  sourceContextKey,
3228
3236
  agentName,
@@ -3230,7 +3238,6 @@ export class ProxySessionController {
3230
3238
  userId,
3231
3239
  boundProjectCwd,
3232
3240
  "/newgroup",
3233
- false,
3234
3241
  );
3235
3242
 
3236
3243
  let task: SessionRecord;
@@ -3331,7 +3338,6 @@ export class ProxySessionController {
3331
3338
  userId,
3332
3339
  boundProjectCwd,
3333
3340
  "/forkgroup",
3334
- false,
3335
3341
  );
3336
3342
 
3337
3343
  let forked: ForkSessionResult;
@@ -3374,12 +3380,17 @@ export class ProxySessionController {
3374
3380
  userId: string | undefined,
3375
3381
  boundProjectCwd: string | undefined,
3376
3382
  commandName: "/newgroup" | "/forkgroup",
3377
- includeTimestamp: boolean,
3378
3383
  ): Promise<CreatedFeishuGroupContext> {
3379
3384
  if (!userId?.startsWith("ou_")) {
3380
3385
  throw new Error(`${commandName} 只能由具有 open_id 的飞书用户消息触发。`);
3381
3386
  }
3382
- const groupName = formatNewGroupName(agentName, boundProjectCwd, taskTitle, new Date(), includeTimestamp);
3387
+ const groupName = formatNewGroupName({
3388
+ agentName,
3389
+ projectCwd: boundProjectCwd,
3390
+ taskName: taskTitle,
3391
+ date: new Date(),
3392
+ format: this.config.feishu?.groupNameFormat,
3393
+ });
3383
3394
  const group = await this.outbound.createGroup(sourceContextKey, {
3384
3395
  name: groupName,
3385
3396
  userOpenId: userId,
@@ -4713,14 +4724,26 @@ export class ProxySessionController {
4713
4724
  contextKey: string,
4714
4725
  event: ChatUpdatedEvent,
4715
4726
  ): Promise<void> {
4716
- const parsed = parseAgentGroupName(event.afterName);
4717
- if (!parsed) return;
4718
4727
  const context = this.store.getUserContext(contextKey);
4719
4728
  if (!context?.currentSessionId) return;
4720
4729
  const record = this.store.getSessionForContext(context.currentSessionId, contextKey);
4721
4730
  if (!record || record.status === "closed") return;
4722
- if (record.agentName.toLowerCase() !== parsed.agentName.toLowerCase()) return;
4723
- const title = normalizeTaskTitle(parsed.title);
4731
+ const nameFormat = this.config.feishu?.groupNameFormat ?? DEFAULT_GROUP_NAME_FORMAT;
4732
+ const projectCwd = detectProjectlessWorkspace(record.cwd) ? undefined : record.cwd;
4733
+ const templateTitle = parseTaskNameFromGroupName({
4734
+ agentName: record.agentName,
4735
+ groupName: event.afterName,
4736
+ projectCwd,
4737
+ format: nameFormat,
4738
+ });
4739
+ const legacy = parseAgentGroupName(event.afterName);
4740
+ const legacyTitle = legacy && record.agentName.toLowerCase() === legacy.agentName.toLowerCase()
4741
+ ? legacy.title
4742
+ : undefined;
4743
+ if (isDefaultGroupNameFormat(nameFormat) && isLegacyGroupPrefixOnly(event.afterName)) return;
4744
+ const title = normalizeTaskTitle(isDefaultGroupNameFormat(nameFormat)
4745
+ ? legacyTitle ?? templateTitle
4746
+ : templateTitle ?? legacyTitle);
4724
4747
  if (!title || title === record.title) return;
4725
4748
 
4726
4749
  const loaded = await this.loadSession(record);
@@ -6978,44 +7001,6 @@ function isRecoveryAttemptRecent(attempt: TurnAttemptRecord, cutoff: number): bo
6978
7001
  return Number.isFinite(updatedAt) && updatedAt >= cutoff;
6979
7002
  }
6980
7003
 
6981
- function formatGroupDateSuffix(date: Date): string {
6982
- const month = String(date.getMonth() + 1).padStart(2, "0");
6983
- const day = String(date.getDate()).padStart(2, "0");
6984
- return `${month}-${day}`;
6985
- }
6986
-
6987
- function formatNewGroupName(
6988
- agentName: string,
6989
- projectCwd: string | undefined,
6990
- taskTitle: string,
6991
- date: Date,
6992
- includeTimestamp: boolean,
6993
- ): string {
6994
- const prefix = `[${agentName}] `;
6995
- const projectPrefix = projectCwd ? `${formatGroupProjectDirectory(projectCwd)} ` : "";
6996
- const titlePrefix = `${prefix}${projectPrefix}`;
6997
- const timestampSuffix = includeTimestamp ? ` (${formatGroupDateSuffix(date)})` : "";
6998
- const availableTitleLength = FEISHU_GROUP_NAME_MAX_LENGTH
6999
- - Array.from(titlePrefix).length
7000
- - Array.from(timestampSuffix).length;
7001
- const title = truncateTail(taskTitle, availableTitleLength);
7002
- return truncateTail(`${titlePrefix}${title}${timestampSuffix}`, FEISHU_GROUP_NAME_MAX_LENGTH);
7003
- }
7004
-
7005
- const FEISHU_GROUP_NAME_MAX_LENGTH = 60;
7006
- const GROUP_PROJECT_DIRECTORY_MAX_LENGTH = 15;
7007
-
7008
- function formatGroupProjectDirectory(projectCwd: string): string {
7009
- const value = abbreviateHomeDirectory(projectCwd);
7010
- const levels = value
7011
- .replace(/[\\/]+$/, "")
7012
- .split(/[\\/]+/)
7013
- .filter(Boolean)
7014
- .slice(-2);
7015
- const separator = value.includes("\\") ? path.win32.sep : path.posix.sep;
7016
- return `[${fitTrailingPathLevels(levels, GROUP_PROJECT_DIRECTORY_MAX_LENGTH, separator)}]`;
7017
- }
7018
-
7019
7004
  function abbreviateHomeDirectory(value: string): string {
7020
7005
  const homeDirectory = os.homedir();
7021
7006
  const valueIsWindows = isWindowsAbsolutePath(value);
@@ -7032,29 +7017,6 @@ function isWindowsAbsolutePath(value: string): boolean {
7032
7017
  return /^[a-z]:[\\/]|^\\\\/iu.test(value);
7033
7018
  }
7034
7019
 
7035
- function fitTrailingPathLevels(levels: string[], maxLength: number, separator: string): string {
7036
- if (levels.length === 0) return "";
7037
- if (levels.length === 1) return truncatePathLevel(levels[0]!, maxLength);
7038
- const parent = levels[0]!;
7039
- const leaf = levels[1]!;
7040
- const joined = `${parent}${separator}${leaf}`;
7041
- if (Array.from(joined).length <= maxLength) return joined;
7042
- if (Array.from(leaf).length <= maxLength) return leaf;
7043
- return truncatePathLevel(leaf, maxLength);
7044
- }
7045
-
7046
- function truncatePathLevel(value: string, maxLength: number): string {
7047
- return truncateTail(value, maxLength);
7048
- }
7049
-
7050
- function truncateTail(value: string, maxLength: number): string {
7051
- const characters = Array.from(value);
7052
- if (characters.length <= maxLength) return value;
7053
- if (maxLength <= 0) return "";
7054
- if (maxLength <= 3) return ".".repeat(maxLength);
7055
- return `${characters.slice(0, maxLength - 3).join("")}...`;
7056
- }
7057
-
7058
7020
  function parseAgentGroupName(value: string): { agentName: string; title: string } | undefined {
7059
7021
  const match = /^\[([^[\]]+)\]\s+(.+)$/.exec(value.trim());
7060
7022
  const agentName = match?.[1]?.trim();
@@ -7065,6 +7027,15 @@ function parseAgentGroupName(value: string): { agentName: string; title: string
7065
7027
  return agentName && title ? { agentName, title } : undefined;
7066
7028
  }
7067
7029
 
7030
+ function isDefaultGroupNameFormat(format: AppConfig["feishu"]["groupNameFormat"]): boolean {
7031
+ return format.project === DEFAULT_GROUP_NAME_FORMAT.project
7032
+ && format.projectless === DEFAULT_GROUP_NAME_FORMAT.projectless;
7033
+ }
7034
+
7035
+ function isLegacyGroupPrefixOnly(value: string): boolean {
7036
+ return /^\[[^[\]]+\]\s+\[[^[\]]+\]\s*$/u.test(value.trim());
7037
+ }
7038
+
7068
7039
  function validateGoalObjective(objective: string): void {
7069
7040
  const length = Array.from(objective.trim()).length;
7070
7041
  if (length === 0) throw new Error("Goal 不能为空。");