@keyou007/agent-bot 0.1.15-alpha.0 → 0.1.15-alpha.2

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +23 -20
  3. package/README.zh.md +23 -20
  4. package/config.example.yaml +1 -0
  5. package/dist/cli/FeishuAppConfiguration.js +42 -3
  6. package/dist/cli/FeishuAppConfiguration.js.map +1 -1
  7. package/dist/cli.js +4 -1
  8. package/dist/cli.js.map +1 -1
  9. package/dist/feishu/CardRenderer.js +114 -16
  10. package/dist/feishu/CardRenderer.js.map +1 -1
  11. package/dist/feishu/FeishuConnector.js +21 -6
  12. package/dist/feishu/FeishuConnector.js.map +1 -1
  13. package/dist/feishu/FeishuMarkdown.js +58 -6
  14. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  15. package/dist/feishu/FeishuMessageClient.js +33 -4
  16. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  17. package/dist/feishu/FeishuTurnPresenter.js +7 -6
  18. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  19. package/dist/feishu/FinalResponseBranding.js +15 -0
  20. package/dist/feishu/FinalResponseBranding.js.map +1 -0
  21. package/dist/logging/DailyLogStream.js +73 -0
  22. package/dist/logging/DailyLogStream.js.map +1 -0
  23. package/dist/logging/logger.js +2 -1
  24. package/dist/logging/logger.js.map +1 -1
  25. package/dist/proxy/ProxySessionController.js +270 -40
  26. package/dist/proxy/ProxySessionController.js.map +1 -1
  27. package/dist/state/StateStore.js +126 -0
  28. package/dist/state/StateStore.js.map +1 -1
  29. package/dist/state/migrations.js +11 -0
  30. package/dist/state/migrations.js.map +1 -1
  31. package/dist/supervision/SupervisorDiagnostics.js +37 -21
  32. package/dist/supervision/SupervisorDiagnostics.js.map +1 -1
  33. package/dist/supervisor.js +11 -4
  34. package/dist/supervisor.js.map +1 -1
  35. package/dist/utils/executeShellCommand.js +83 -16
  36. package/dist/utils/executeShellCommand.js.map +1 -1
  37. package/docs/technical-reference.md +9 -8
  38. package/docs/technical-reference.zh.md +9 -8
  39. package/npm-shrinkwrap.json +2 -2
  40. package/package.json +1 -1
  41. package/src/cli/FeishuAppConfiguration.ts +76 -3
  42. package/src/cli.ts +6 -1
  43. package/src/feishu/CardRenderer.ts +150 -17
  44. package/src/feishu/FeishuConnector.ts +32 -7
  45. package/src/feishu/FeishuMarkdown.ts +64 -5
  46. package/src/feishu/FeishuMessageClient.ts +41 -12
  47. package/src/feishu/FeishuTurnPresenter.ts +9 -6
  48. package/src/feishu/FinalResponseBranding.ts +18 -0
  49. package/src/logging/DailyLogStream.ts +88 -0
  50. package/src/logging/logger.ts +2 -1
  51. package/src/proxy/ProxySessionController.ts +292 -39
  52. package/src/state/StateStore.ts +180 -0
  53. package/src/state/migrations.ts +11 -0
  54. package/src/supervision/SupervisorDiagnostics.ts +41 -25
  55. package/src/supervisor.ts +10 -4
  56. package/src/utils/executeShellCommand.ts +103 -15
@@ -11,7 +11,7 @@ import type {
11
11
  ToolState,
12
12
  } from "../runtime/types.js";
13
13
  import type { TurnActivity, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
14
- import { truncateText } from "../utils/markdown.js";
14
+ import { truncateMiddle, truncateText } from "../utils/markdown.js";
15
15
  import { localCardImage } from "./LocalCardImage.js";
16
16
 
17
17
  export interface StartupStatusView {
@@ -195,7 +195,6 @@ export interface DirectoryBrowserCardEntry {
195
195
  name: string;
196
196
  kind: "directory" | "drive" | "file" | "image" | "binary";
197
197
  openAction?: TaskListCardAction;
198
- actions?: TaskListCardAction[];
199
198
  }
200
199
 
201
200
  export interface DirectoryBrowserCardView {
@@ -206,6 +205,13 @@ export interface DirectoryBrowserCardView {
206
205
  footerLines: string[];
207
206
  }
208
207
 
208
+ export interface DirectoryNewFolderCardView {
209
+ directory: string;
210
+ displayDirectory?: string;
211
+ contextKey: string;
212
+ page: number;
213
+ }
214
+
209
215
  const DIRECTORY_BROWSER_ROW_COUNT = 16;
210
216
 
211
217
  export type ThinkingCardLayout = "grouped" | "timeline";
@@ -233,6 +239,17 @@ export interface DismissGroupCardView {
233
239
  requestedBy: string;
234
240
  }
235
241
 
242
+ export interface ShellCommandCardView {
243
+ command: string;
244
+ cwd: string;
245
+ stdout: string;
246
+ stderr: string;
247
+ status: "running" | "completed" | "failed" | "timed_out";
248
+ exitCode?: number | null;
249
+ elapsedMs: number;
250
+ outputTruncated: boolean;
251
+ }
252
+
236
253
  export class CardRenderer {
237
254
  private readonly thinkingCardLayout: ThinkingCardLayout;
238
255
 
@@ -240,6 +257,35 @@ export class CardRenderer {
240
257
  this.thinkingCardLayout = options.thinkingCardLayout ?? "grouped";
241
258
  }
242
259
 
260
+ renderShellCommandCard(view: ShellCommandCardView): Record<string, unknown> {
261
+ const stdout = normalizeTerminalOutput(view.stdout).trimEnd();
262
+ const stderr = normalizeTerminalOutput(view.stderr).trimEnd();
263
+ const rawOutput = [
264
+ stdout,
265
+ stderr ? `[stderr]\n${stderr}` : "",
266
+ ].filter(Boolean).join("\n");
267
+ const displayOutput = truncateMiddle(
268
+ rawOutput,
269
+ 6_000,
270
+ "\n\n... 中间输出已截断 ...\n\n",
271
+ );
272
+ const command = truncateMiddle(view.command.trim(), 600, " ... ");
273
+ const output = displayOutput || (view.status === "running" ? "等待输出..." : "(无输出)");
274
+ const status = shellCommandStatus(view);
275
+ const outputWasTruncated = view.outputTruncated || rawOutput.length > 6_000;
276
+ return sectionCard(
277
+ status.title,
278
+ [
279
+ markdown(codeBlock(`$ ${command}\n${output}`, 6_700)),
280
+ markdown([
281
+ `${inlineCode(view.cwd)} · ${status.label} · ${formatTurnDuration(view.elapsedMs)}`,
282
+ ...(outputWasTruncated ? ["输出过长,已保留开头和结尾并截断中间内容。"] : []),
283
+ ].join("\n")),
284
+ ],
285
+ status.template,
286
+ );
287
+ }
288
+
243
289
  renderExecutionSettings(view: ExecutionSettingsCardView): Record<string, unknown> {
244
290
  const baseAction: Record<string, string> = {
245
291
  contextKey: view.contextKey,
@@ -1174,7 +1220,7 @@ export class CardRenderer {
1174
1220
  renderDirectoryBrowserCard(view: DirectoryBrowserCardView): Record<string, unknown> {
1175
1221
  const elements: Record<string, unknown>[] = [
1176
1222
  markdown(`**当前目录**:${inlineCode(view.directory)}`),
1177
- taskActionRow(view.currentActions),
1223
+ taskActionRow(view.currentActions, undefined, true),
1178
1224
  { tag: "hr" },
1179
1225
  ];
1180
1226
  const entryRows = view.entries.length === 0
@@ -1191,6 +1237,54 @@ export class CardRenderer {
1191
1237
  return sectionCard("文件浏览", elements, "blue", "2px");
1192
1238
  }
1193
1239
 
1240
+ renderDirectoryNewFolderCard(view: DirectoryNewFolderCardView): Record<string, unknown> {
1241
+ const actionValue = {
1242
+ action: "directory_new_folder_submit",
1243
+ directory: view.directory,
1244
+ contextKey: view.contextKey,
1245
+ page: String(view.page),
1246
+ };
1247
+ return compactCard("新建目录", "blue", [
1248
+ markdown(`将在当前目录下创建一个子目录:\n${inlineCode(view.displayDirectory ?? view.directory)}`),
1249
+ {
1250
+ tag: "form",
1251
+ name: "directory_new_folder_form",
1252
+ vertical_spacing: "8px",
1253
+ elements: [
1254
+ {
1255
+ tag: "input",
1256
+ element_id: "folder_name_input",
1257
+ name: "folderName",
1258
+ required: true,
1259
+ input_type: "text",
1260
+ width: "fill",
1261
+ max_length: 255,
1262
+ label: { tag: "plain_text", content: "目录名" },
1263
+ placeholder: { tag: "plain_text", content: "请输入目录名" },
1264
+ },
1265
+ {
1266
+ tag: "button",
1267
+ name: "directory_new_folder_create",
1268
+ text: { tag: "plain_text", content: "Create" },
1269
+ type: "primary",
1270
+ width: "fill",
1271
+ action_type: "form_submit",
1272
+ value: actionValue,
1273
+ },
1274
+ ],
1275
+ },
1276
+ taskActionRow([{
1277
+ text: "Back",
1278
+ value: {
1279
+ action: "directory_new_folder_cancel",
1280
+ directory: view.directory,
1281
+ contextKey: view.contextKey,
1282
+ page: String(view.page),
1283
+ },
1284
+ }]),
1285
+ ]);
1286
+ }
1287
+
1194
1288
  renderResetHistoryCard(view: ResetHistoryCardView): Record<string, unknown> {
1195
1289
  const elements: Record<string, unknown>[] = [
1196
1290
  {
@@ -1958,10 +2052,26 @@ function renderToolDetails(tool: ToolState, projectCwd?: string): string {
1958
2052
  ? unwrapShellCommand(normalizedCommand) ?? normalizedCommand
1959
2053
  : normalizedCommand;
1960
2054
  const commandText = truncateText(displayCommand, 600);
1961
- const resultText = result ? truncateToolResult(stripAnsi(result).trim()) : undefined;
2055
+ const resultText = result ? truncateToolResult(normalizeTerminalOutput(result).trim()) : undefined;
1962
2056
  return codeBlock([`$ ${commandText}`, resultText].filter((part): part is string => part !== undefined).join("\n"), 2_003);
1963
2057
  }
1964
2058
 
2059
+ function normalizeTerminalOutput(value: string): string {
2060
+ const clean = stripAnsi(value).replace(/\r\n/g, "\n");
2061
+ return clean
2062
+ .split("\n")
2063
+ .map((line) => {
2064
+ if (!line.includes("\r")) return line;
2065
+ const updates = line.split("\r");
2066
+ for (let index = updates.length - 1; index >= 0; index -= 1) {
2067
+ const update = updates[index];
2068
+ if (update) return update;
2069
+ }
2070
+ return "";
2071
+ })
2072
+ .join("\n");
2073
+ }
2074
+
1965
2075
  function truncateToolResult(result: string): string {
1966
2076
  const maxLength = 900;
1967
2077
  const marker = "\n...\n";
@@ -2048,6 +2158,23 @@ function formatTurnDuration(durationMs: number): string {
2048
2158
  return `${seconds}s`;
2049
2159
  }
2050
2160
 
2161
+ function shellCommandStatus(view: ShellCommandCardView): {
2162
+ title: string;
2163
+ label: string;
2164
+ template: string;
2165
+ } {
2166
+ if (view.status === "running") {
2167
+ return { title: "正在执行命令", label: "运行中", template: "blue" };
2168
+ }
2169
+ if (view.status === "timed_out") {
2170
+ return { title: "命令执行超时", label: "已超时(120s)", template: "orange" };
2171
+ }
2172
+ if (view.status === "completed") {
2173
+ return { title: "命令执行完成", label: `退出码 ${view.exitCode ?? 0}`, template: "green" };
2174
+ }
2175
+ return { title: "命令执行失败", label: `退出码 ${view.exitCode ?? "未知"}`, template: "red" };
2176
+ }
2177
+
2051
2178
  function unwrapShellCommand(command: string): string | undefined {
2052
2179
  return unwrapPowerShellCommand(command) ?? unwrapPosixShellCommand(command);
2053
2180
  }
@@ -2409,13 +2536,25 @@ function resetHistoryEntryRow(
2409
2536
  };
2410
2537
  }
2411
2538
 
2412
- function taskActionRow(actions: TaskListCardAction[], trailingText?: string): Record<string, unknown> {
2413
- const columns = actions.map((action) => ({
2414
- tag: "column",
2415
- width: "auto",
2416
- vertical_align: "center",
2417
- elements: [taskActionElement(action)],
2418
- }));
2539
+ function taskActionRow(
2540
+ actions: TaskListCardAction[],
2541
+ trailingText?: string,
2542
+ dotSeparated = false,
2543
+ ): Record<string, unknown> {
2544
+ const columns = actions.flatMap((action, index) => [
2545
+ ...(dotSeparated && index > 0 ? [{
2546
+ tag: "column",
2547
+ width: "auto",
2548
+ vertical_align: "center",
2549
+ elements: [markdown("<font color='grey'>·</font>")],
2550
+ }] : []),
2551
+ {
2552
+ tag: "column",
2553
+ width: "auto",
2554
+ vertical_align: "center",
2555
+ elements: [taskActionElement(action)],
2556
+ },
2557
+ ]);
2419
2558
  if (trailingText) {
2420
2559
  columns.push({
2421
2560
  tag: "column",
@@ -2453,12 +2592,6 @@ function directoryBrowserEntryRow(entry: DirectoryBrowserCardEntry): Record<stri
2453
2592
  ? [taskActionElement({ ...entry.openAction, text: label })]
2454
2593
  : [markdown(escapeCardHtml(label))],
2455
2594
  },
2456
- ...(entry.actions ?? []).map((action) => ({
2457
- tag: "column",
2458
- width: "auto",
2459
- vertical_align: "center",
2460
- elements: [taskActionElement(action)],
2461
- })),
2462
2595
  ],
2463
2596
  };
2464
2597
  }
@@ -4,7 +4,7 @@ import { threadContextKey } from "./contextKey.js";
4
4
  import { resolveFeishuBotOpenId } from "./FeishuBotIdentity.js";
5
5
  import { normalizeFeishuPostText } from "./InboundText.js";
6
6
  import { allowsFeishuUser } from "./ownerAccess.js";
7
- import type { ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
7
+ import type { CardAction, ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
8
8
 
9
9
  type BotOpenIdResolver = (appId: string, appSecret: string) => Promise<string>;
10
10
  type BotOpenIdListener = (botOpenId: string) => void;
@@ -89,6 +89,7 @@ export class FeishuConnector {
89
89
  });
90
90
  },
91
91
  "card.action.trigger": async (data: unknown) => {
92
+ const receivedAt = Date.now();
92
93
  const operatorOpenId = getFeishuEvent(data)?.operator?.open_id;
93
94
  if (!allowsFeishuUser(this.config, operatorOpenId)) {
94
95
  this.logger.debug(
@@ -103,11 +104,11 @@ export class FeishuConnector {
103
104
  return {};
104
105
  }
105
106
 
106
- void Promise.resolve()
107
- .then(() => this.handler.onCardAction(action))
108
- .catch((error: unknown) => {
109
- this.logger.error({ error, actionId: action.actionId }, "Failed to handle Feishu card action.");
110
- });
107
+ this.deferCardAction(action, receivedAt);
108
+ this.logger.debug(
109
+ { actionId: action.actionId, messageId: action.messageId, responsePreparedInMs: Date.now() - receivedAt },
110
+ "Prepared the Feishu card action acknowledgement.",
111
+ );
111
112
  return {
112
113
  toast: {
113
114
  type: "success",
@@ -123,6 +124,27 @@ export class FeishuConnector {
123
124
  await wsClient.start({ eventDispatcher });
124
125
  this.logger.info("Feishu WebSocket connector started.");
125
126
  }
127
+
128
+ private deferCardAction(action: CardAction, receivedAt: number): void {
129
+ setImmediate(() => {
130
+ const startedAt = Date.now();
131
+ this.logger.debug(
132
+ { actionId: action.actionId, messageId: action.messageId, dispatchDelayMs: startedAt - receivedAt },
133
+ "Started deferred Feishu card action handling.",
134
+ );
135
+ void Promise.resolve()
136
+ .then(() => this.handler.onCardAction(action))
137
+ .then(() => {
138
+ this.logger.debug(
139
+ { actionId: action.actionId, messageId: action.messageId, durationMs: Date.now() - startedAt },
140
+ "Completed Feishu card action handling.",
141
+ );
142
+ })
143
+ .catch((error: unknown) => {
144
+ this.logger.error({ error, actionId: action.actionId }, "Failed to handle Feishu card action.");
145
+ });
146
+ });
147
+ }
126
148
  }
127
149
 
128
150
  function toChatUpdatedEvent(data: unknown): ChatUpdatedEvent | undefined {
@@ -305,7 +327,10 @@ function getCardActionValue(action: Record<string, unknown>): Record<string, unk
305
327
  const option = parseJsonObject(action.option);
306
328
  if (Object.keys(option).length > 0) return option;
307
329
  }
308
- return isRecord(action.value) ? action.value : {};
330
+ const value = isRecord(action.value) ? action.value : {};
331
+ return isRecord(action.form_value)
332
+ ? { ...value, formValue: action.form_value }
333
+ : value;
309
334
  }
310
335
 
311
336
  function getEventId(data: unknown): string | undefined {
@@ -1,3 +1,5 @@
1
+ import path from "node:path";
2
+
1
3
  interface FenceState {
2
4
  indent: string;
3
5
  marker: "`" | "~";
@@ -9,7 +11,7 @@ const FENCE_OPENER = /^([ \t]*)((?:`{3,})|(?:~{3,}))(.*)$/;
9
11
  const MARKDOWN_LINK = /(!?)\[([^\]\r\n]*)\]\((<?[^)\r\n]+>?)\)/g;
10
12
  const LOCAL_FILE_LINE_REFERENCE = /(:\d+(?::\d+)?)$/;
11
13
 
12
- export function normalizeFeishuMarkdown(markdown: string): string {
14
+ export function normalizeFeishuMarkdown(markdown: string, projectCwd?: string): string {
13
15
  const lines = markdown.match(/[^\r\n]*(?:\r\n|\n)|[^\r\n]+$/g) ?? [];
14
16
  let fence: FenceState | undefined;
15
17
 
@@ -19,7 +21,7 @@ export function normalizeFeishuMarkdown(markdown: string): string {
19
21
 
20
22
  if (!fence) {
21
23
  const opener = FENCE_OPENER.exec(body);
22
- if (!opener?.[2]) return `${includeLocalFileLineReferences(body)}${ending}`;
24
+ if (!opener?.[2]) return `${includeLocalFileLineReferences(body, projectCwd)}${ending}`;
23
25
 
24
26
  const indent = opener[1] ?? "";
25
27
  const delimiter = opener[2];
@@ -40,7 +42,7 @@ export function normalizeFeishuMarkdown(markdown: string): string {
40
42
  }).join("");
41
43
  }
42
44
 
43
- function includeLocalFileLineReferences(markdown: string): string {
45
+ function includeLocalFileLineReferences(markdown: string, projectCwd?: string): string {
44
46
  return markdown.replace(MARKDOWN_LINK, (link, imageMarker: string, label: string, rawTarget: string) => {
45
47
  if (imageMarker) return link;
46
48
 
@@ -48,11 +50,68 @@ function includeLocalFileLineReferences(markdown: string): string {
48
50
  if (!isLocalFileTarget(target)) return link;
49
51
 
50
52
  const reference = LOCAL_FILE_LINE_REFERENCE.exec(target)?.[1];
51
- if (!reference || label.endsWith(reference)) return link;
52
- return `[${label}${reference}](${rawTarget})`;
53
+ const projectPathLabel = projectCwd ? projectFilePathLabel(target, projectCwd, reference) : undefined;
54
+ const normalizedLabel = projectPathLabel
55
+ ?? (reference && !label.endsWith(reference) ? `${label}${reference}` : label);
56
+ return normalizedLabel === label ? link : `[${normalizedLabel}](${rawTarget})`;
53
57
  });
54
58
  }
55
59
 
60
+ function projectFilePathLabel(
61
+ target: string,
62
+ projectCwd: string,
63
+ reference: string | undefined,
64
+ ): string | undefined {
65
+ const targetWithoutReference = reference ? target.slice(0, -reference.length) : target;
66
+ const filePath = localFilePath(targetWithoutReference);
67
+ if (!filePath) return undefined;
68
+ if (!isInsideProject(filePath, projectCwd)) {
69
+ return `${displayAbsolutePath(filePath)}${reference ?? ""}`;
70
+ }
71
+
72
+ const pathApi = usesWindowsPaths(filePath, projectCwd) ? path.win32 : path.posix;
73
+ const parsed = pathApi.parse(filePath);
74
+ if (Array.from(parsed.name).length > 5) return undefined;
75
+
76
+ const parentName = pathApi.basename(pathApi.dirname(filePath));
77
+ if (!parentName) return undefined;
78
+ return `${parentName}${pathApi.sep}${parsed.base}${reference ?? ""}`;
79
+ }
80
+
81
+ function localFilePath(target: string): string | undefined {
82
+ if (!/^file:\/\//i.test(target)) return normalizeWindowsDrivePrefix(target);
83
+
84
+ let value = target.slice("file://".length);
85
+ try {
86
+ value = decodeURIComponent(value);
87
+ } catch {
88
+ // Keep malformed percent escapes visible instead of dropping the link.
89
+ }
90
+ if (/^\/[a-z]:[\\/]/i.test(value)) return value.slice(1);
91
+ if (value.startsWith("/")) return value;
92
+ return `//${value}`;
93
+ }
94
+
95
+ function normalizeWindowsDrivePrefix(value: string): string {
96
+ return /^\/[a-z]:[\\/]/i.test(value) ? value.slice(1) : value;
97
+ }
98
+
99
+ function isInsideProject(filePath: string, projectCwd: string): boolean {
100
+ const pathApi = usesWindowsPaths(filePath, projectCwd) ? path.win32 : path.posix;
101
+ const relative = pathApi.relative(pathApi.resolve(projectCwd), pathApi.resolve(filePath));
102
+ return relative === ""
103
+ || (relative !== ".." && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative));
104
+ }
105
+
106
+ function displayAbsolutePath(filePath: string): string {
107
+ if (!usesWindowsPaths(filePath)) return path.posix.normalize(filePath);
108
+ return path.win32.normalize(filePath);
109
+ }
110
+
111
+ function usesWindowsPaths(...values: string[]): boolean {
112
+ return values.some((value) => /^\/?[a-z]:[\\/]/i.test(value) || /^\\\\/.test(value));
113
+ }
114
+
56
115
  function unwrapMarkdownTarget(target: string): string {
57
116
  return target.startsWith("<") && target.endsWith(">") ? target.slice(1, -1) : target;
58
117
  }
@@ -7,6 +7,7 @@ import type { AppConfig } from "../config/schema.js";
7
7
  import { normalizeFeishuMarkdown } from "./FeishuMarkdown.js";
8
8
  import { LOCAL_CARD_IMAGE_PATH } from "./LocalCardImage.js";
9
9
  import { renderMarkdownWithLocalImages } from "./LocalImageMarkdown.js";
10
+ import { splitFinalResponseBranding } from "./FinalResponseBranding.js";
10
11
  import {
11
12
  renderMergedForwardPrompt,
12
13
  renderReferencedMessage,
@@ -101,6 +102,7 @@ export class FeishuMessageClient implements FeishuOutbound {
101
102
  expiresAt: number;
102
103
  };
103
104
  private readonly sendQueues = new Map<string, Promise<void>>();
105
+ private readonly cardUpdateQueues = new Map<string, Promise<void>>();
104
106
  private readonly lastSendAt = new Map<string, number>();
105
107
  private readonly imageUploads = new Map<string, { mtimeMs: number; upload: Promise<string> }>();
106
108
  private readonly imageDownloads = new Map<string, Promise<string>>();
@@ -288,11 +290,7 @@ export class FeishuMessageClient implements FeishuOutbound {
288
290
 
289
291
  async sendMarkdown(contextKey: string, markdown: string, idempotencyKey?: string): Promise<string | undefined> {
290
292
  const normalizedMarkdown = normalizeFeishuMarkdown(markdown);
291
- const elements = await renderMarkdownWithLocalImages(
292
- normalizedMarkdown,
293
- (filePath) => this.uploadImageCached(filePath),
294
- (error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
295
- );
293
+ const elements = await this.renderFinalAnswerElements(normalizedMarkdown);
296
294
  return this.sendMessage(
297
295
  contextKey,
298
296
  "interactive",
@@ -334,11 +332,7 @@ export class FeishuMessageClient implements FeishuOutbound {
334
332
  idempotencyKey?: string,
335
333
  ): Promise<string | undefined> {
336
334
  const normalizedMarkdown = normalizeFeishuMarkdown(markdown);
337
- const elements = await renderMarkdownWithLocalImages(
338
- normalizedMarkdown,
339
- (filePath) => this.uploadImageCached(filePath),
340
- (error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
341
- );
335
+ const elements = await this.renderFinalAnswerElements(normalizedMarkdown);
342
336
  return this.replyMessage(
343
337
  contextKey,
344
338
  target,
@@ -364,9 +358,28 @@ export class FeishuMessageClient implements FeishuOutbound {
364
358
  );
365
359
  }
366
360
 
361
+ private async renderFinalAnswerElements(markdown: string): Promise<Array<Record<string, unknown>>> {
362
+ const { content, branding } = splitFinalResponseBranding(markdown);
363
+ const elements = content
364
+ ? await renderMarkdownWithLocalImages(
365
+ content,
366
+ (filePath) => this.uploadImageCached(filePath),
367
+ (error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
368
+ )
369
+ : [];
370
+ if (!branding) return elements;
371
+ return [
372
+ ...elements,
373
+ { tag: "hr" },
374
+ { tag: "markdown", content: branding, text_size: "notation" },
375
+ ];
376
+ }
377
+
367
378
  async updateInteractiveCard(messageId: string, card: Record<string, unknown>): Promise<void> {
368
- const preparedCard = await this.prepareInteractiveCard(card);
369
- await this.updateInteractiveCardNow(messageId, preparedCard);
379
+ await this.enqueueCardUpdate(messageId, async () => {
380
+ const preparedCard = await this.prepareInteractiveCard(card);
381
+ await this.updateInteractiveCardNow(messageId, preparedCard);
382
+ });
370
383
  }
371
384
 
372
385
  private async updateInteractiveCardNow(
@@ -626,6 +639,22 @@ export class FeishuMessageClient implements FeishuOutbound {
626
639
  return queued;
627
640
  }
628
641
 
642
+ private enqueueCardUpdate(messageId: string, task: () => Promise<void>): Promise<void> {
643
+ const previous = this.cardUpdateQueues.get(messageId) ?? Promise.resolve();
644
+ const queued = previous.catch(() => undefined).then(task);
645
+ const stored = queued.then(
646
+ () => undefined,
647
+ () => undefined,
648
+ );
649
+ this.cardUpdateQueues.set(messageId, stored);
650
+ stored.finally(() => {
651
+ if (this.cardUpdateQueues.get(messageId) === stored) {
652
+ this.cardUpdateQueues.delete(messageId);
653
+ }
654
+ });
655
+ return queued;
656
+ }
657
+
629
658
  private async getTenantAccessToken(): Promise<string> {
630
659
  const now = Date.now();
631
660
  if (this.token && this.token.expiresAt > now + 60_000) {
@@ -8,7 +8,11 @@ import { splitMarkdown } from "../presentation/splitMarkdown.js";
8
8
  import type { TurnViewState } from "../presentation/turnViewTypes.js";
9
9
  import { CardRenderer } from "./CardRenderer.js";
10
10
  import { CardUpdateScheduler } from "./CardUpdateScheduler.js";
11
+ import { normalizeFeishuMarkdown } from "./FeishuMarkdown.js";
11
12
  import type { FeishuOutbound, MessageReplyTarget } from "./types.js";
13
+ import {
14
+ FINAL_RESPONSE_BRANDING_BLOCK,
15
+ } from "./FinalResponseBranding.js";
12
16
  import { createId } from "../utils/id.js";
13
17
  import { createHash } from "node:crypto";
14
18
 
@@ -50,7 +54,6 @@ export interface FeishuTurnPresenterOptions {
50
54
 
51
55
  const MAX_FINAL_TABLES_PER_CARD = 5;
52
56
  const DEFAULT_ELAPSED_UPDATE_INTERVAL_MS = 3_000;
53
- const FINAL_RESPONSE_BRANDING = "> Powered by [AgentBot](https://github.com/keyou/agent-bot)";
54
57
  const FINAL_RESPONSE_BRANDING_MIN_LENGTH = 1_000;
55
58
 
56
59
  interface TurnEntry {
@@ -536,7 +539,7 @@ export class FeishuTurnPresenter {
536
539
  if (delivery?.finalDelivered || !state.finalResponse) return;
537
540
  const finalChunkLength = this.options.finalChunkLength ?? 4_000;
538
541
  const chunks = brandedFinalChunks(
539
- state.finalResponse,
542
+ normalizeFeishuMarkdown(state.finalResponse, state.projectCwd),
540
543
  finalChunkLength,
541
544
  );
542
545
  const messageIds = [...(delivery?.finalMessageIds ?? [])];
@@ -582,17 +585,17 @@ function brandedFinalChunks(response: string, maxLength: number): string[] {
582
585
 
583
586
  const last = chunks.pop();
584
587
  if (last === undefined) return chunks;
585
- const reservedLength = FINAL_RESPONSE_BRANDING.length + 2;
588
+ const reservedLength = FINAL_RESPONSE_BRANDING_BLOCK.length + 2;
586
589
  const brandedContentLimit = maxLength - reservedLength;
587
- if (brandedContentLimit < 32) return [...chunks, last, FINAL_RESPONSE_BRANDING];
590
+ if (brandedContentLimit < 32) return [...chunks, last, FINAL_RESPONSE_BRANDING_BLOCK];
588
591
 
589
592
  const lastParts = splitMarkdown(last, brandedContentLimit, MAX_FINAL_TABLES_PER_CARD);
590
593
  const lastContent = lastParts.pop();
591
594
  if (lastContent === undefined || lastContent.length > brandedContentLimit) {
592
- return [...chunks, ...lastParts, lastContent ?? last, FINAL_RESPONSE_BRANDING];
595
+ return [...chunks, ...lastParts, lastContent ?? last, FINAL_RESPONSE_BRANDING_BLOCK];
593
596
  }
594
597
  const separator = lastContent.endsWith("\n\n") ? "" : lastContent.endsWith("\n") ? "\n" : "\n\n";
595
- return [...chunks, ...lastParts, `${lastContent}${separator}${FINAL_RESPONSE_BRANDING}`];
598
+ return [...chunks, ...lastParts, `${lastContent}${separator}${FINAL_RESPONSE_BRANDING_BLOCK}`];
596
599
  }
597
600
 
598
601
  function eventPriority(event: AgentEvent): "normal" | "critical" {
@@ -0,0 +1,18 @@
1
+ export const FINAL_RESPONSE_BRANDING = "> Powered by [AgentBot](https://github.com/keyou/agent-bot)";
2
+ export const FINAL_RESPONSE_BRANDING_BLOCK = `----\n\n${FINAL_RESPONSE_BRANDING}`;
3
+
4
+ export function splitFinalResponseBranding(markdown: string): {
5
+ content: string;
6
+ branding?: string;
7
+ } {
8
+ if (markdown === FINAL_RESPONSE_BRANDING_BLOCK) {
9
+ return { content: "", branding: FINAL_RESPONSE_BRANDING };
10
+ }
11
+
12
+ const suffix = `\n\n${FINAL_RESPONSE_BRANDING_BLOCK}`;
13
+ if (!markdown.endsWith(suffix)) return { content: markdown };
14
+ return {
15
+ content: markdown.slice(0, -suffix.length),
16
+ branding: FINAL_RESPONSE_BRANDING,
17
+ };
18
+ }
@@ -0,0 +1,88 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Writable } from "node:stream";
4
+
5
+ interface DailyLogStreamOptions {
6
+ clock?: () => Date;
7
+ mode?: number;
8
+ beforeOpen?: (filePath: string) => void;
9
+ }
10
+
11
+ export class DailyLogStream extends Writable {
12
+ private currentPath?: string;
13
+ private lastPath?: string;
14
+ private currentStream?: fs.WriteStream;
15
+ private readonly clock: () => Date;
16
+
17
+ constructor(
18
+ private readonly basePath: string,
19
+ private readonly options: DailyLogStreamOptions = {},
20
+ ) {
21
+ super();
22
+ this.clock = options.clock ?? (() => new Date());
23
+ }
24
+
25
+ get filePath(): string | undefined {
26
+ return this.currentPath ?? this.lastPath;
27
+ }
28
+
29
+ override _write(
30
+ chunk: string | Buffer,
31
+ encoding: BufferEncoding,
32
+ callback: (error?: Error | null) => void,
33
+ ): void {
34
+ let stream: fs.WriteStream;
35
+ try {
36
+ stream = this.streamFor(this.clock());
37
+ } catch (error) {
38
+ callback(error instanceof Error ? error : new Error(String(error)));
39
+ return;
40
+ }
41
+ stream.write(chunk, encoding, callback);
42
+ }
43
+
44
+ override _final(callback: (error?: Error | null) => void): void {
45
+ const stream = this.currentStream;
46
+ this.currentStream = undefined;
47
+ this.currentPath = undefined;
48
+ if (!stream) {
49
+ callback();
50
+ return;
51
+ }
52
+ stream.end(callback);
53
+ }
54
+
55
+ override _destroy(error: Error | null, callback: (error?: Error | null) => void): void {
56
+ this.currentStream?.destroy();
57
+ this.currentStream = undefined;
58
+ this.currentPath = undefined;
59
+ callback(error);
60
+ }
61
+
62
+ private streamFor(date: Date): fs.WriteStream {
63
+ const targetPath = dailyLogPath(this.basePath, date);
64
+ if (this.currentStream && this.currentPath === targetPath) return this.currentStream;
65
+
66
+ const previous = this.currentStream;
67
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
68
+ this.options.beforeOpen?.(targetPath);
69
+ this.currentPath = targetPath;
70
+ this.lastPath = targetPath;
71
+ this.currentStream = fs.createWriteStream(targetPath, {
72
+ flags: "a",
73
+ ...(this.options.mode === undefined ? {} : { mode: this.options.mode }),
74
+ });
75
+ previous?.end();
76
+ return this.currentStream;
77
+ }
78
+ }
79
+
80
+ export function dailyLogPath(basePath: string, date: Date): string {
81
+ const parsed = path.parse(basePath);
82
+ const dateStamp = [
83
+ date.getFullYear(),
84
+ String(date.getMonth() + 1).padStart(2, "0"),
85
+ String(date.getDate()).padStart(2, "0"),
86
+ ].join("-");
87
+ return path.join(parsed.dir, `${parsed.name}.${dateStamp}${parsed.ext}`);
88
+ }