@keyou007/agent-bot 0.1.14 → 0.1.15-alpha.1

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 (72) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +29 -24
  3. package/README.zh.md +29 -24
  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/help.js +6 -6
  8. package/dist/cli/initOptions.js +1 -4
  9. package/dist/cli/initOptions.js.map +1 -1
  10. package/dist/cli.js +8 -5
  11. package/dist/cli.js.map +1 -1
  12. package/dist/feishu/CardRenderer.js +91 -45
  13. package/dist/feishu/CardRenderer.js.map +1 -1
  14. package/dist/feishu/FeishuConnector.js +39 -12
  15. package/dist/feishu/FeishuConnector.js.map +1 -1
  16. package/dist/feishu/FeishuMarkdown.js +58 -6
  17. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  18. package/dist/feishu/FeishuMessageClient.js +108 -4
  19. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  20. package/dist/feishu/FeishuTurnPresenter.js +24 -1
  21. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  22. package/dist/feishu/FinalResponseBranding.js +15 -0
  23. package/dist/feishu/FinalResponseBranding.js.map +1 -0
  24. package/dist/feishu/InboundText.js +13 -0
  25. package/dist/feishu/InboundText.js.map +1 -0
  26. package/dist/feishu/MergedForwardMessage.js +317 -0
  27. package/dist/feishu/MergedForwardMessage.js.map +1 -0
  28. package/dist/logging/DailyLogStream.js +73 -0
  29. package/dist/logging/DailyLogStream.js.map +1 -0
  30. package/dist/logging/logger.js +2 -1
  31. package/dist/logging/logger.js.map +1 -1
  32. package/dist/presentation/OutboundRouter.js +21 -0
  33. package/dist/presentation/OutboundRouter.js.map +1 -1
  34. package/dist/proxy/ProxySessionController.js +536 -58
  35. package/dist/proxy/ProxySessionController.js.map +1 -1
  36. package/dist/state/StateStore.js +161 -3
  37. package/dist/state/StateStore.js.map +1 -1
  38. package/dist/state/migrations.js +12 -0
  39. package/dist/state/migrations.js.map +1 -1
  40. package/dist/supervision/SupervisorDiagnostics.js +37 -21
  41. package/dist/supervision/SupervisorDiagnostics.js.map +1 -1
  42. package/dist/supervisor.js +11 -4
  43. package/dist/supervisor.js.map +1 -1
  44. package/dist/utils/executeShellCommand.js +83 -16
  45. package/dist/utils/executeShellCommand.js.map +1 -1
  46. package/docs/technical-reference.md +9 -8
  47. package/docs/technical-reference.zh.md +9 -8
  48. package/npm-shrinkwrap.json +2 -2
  49. package/package.json +1 -1
  50. package/skills/agent-bot/SKILL.md +1 -1
  51. package/src/cli/FeishuAppConfiguration.ts +76 -3
  52. package/src/cli/help.ts +6 -6
  53. package/src/cli/initOptions.ts +1 -10
  54. package/src/cli.ts +10 -6
  55. package/src/feishu/CardRenderer.ts +116 -45
  56. package/src/feishu/FeishuConnector.ts +55 -13
  57. package/src/feishu/FeishuMarkdown.ts +64 -5
  58. package/src/feishu/FeishuMessageClient.ts +151 -12
  59. package/src/feishu/FeishuTurnPresenter.ts +28 -4
  60. package/src/feishu/FinalResponseBranding.ts +18 -0
  61. package/src/feishu/InboundText.ts +12 -0
  62. package/src/feishu/MergedForwardMessage.ts +372 -0
  63. package/src/feishu/types.ts +15 -0
  64. package/src/logging/DailyLogStream.ts +88 -0
  65. package/src/logging/logger.ts +2 -1
  66. package/src/presentation/OutboundRouter.ts +28 -0
  67. package/src/proxy/ProxySessionController.ts +642 -55
  68. package/src/state/StateStore.ts +230 -2
  69. package/src/state/migrations.ts +12 -0
  70. package/src/supervision/SupervisorDiagnostics.ts +41 -25
  71. package/src/supervisor.ts +10 -4
  72. 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 {
@@ -233,6 +232,17 @@ export interface DismissGroupCardView {
233
232
  requestedBy: string;
234
233
  }
235
234
 
235
+ export interface ShellCommandCardView {
236
+ command: string;
237
+ cwd: string;
238
+ stdout: string;
239
+ stderr: string;
240
+ status: "running" | "completed" | "failed" | "timed_out";
241
+ exitCode?: number | null;
242
+ elapsedMs: number;
243
+ outputTruncated: boolean;
244
+ }
245
+
236
246
  export class CardRenderer {
237
247
  private readonly thinkingCardLayout: ThinkingCardLayout;
238
248
 
@@ -240,6 +250,35 @@ export class CardRenderer {
240
250
  this.thinkingCardLayout = options.thinkingCardLayout ?? "grouped";
241
251
  }
242
252
 
253
+ renderShellCommandCard(view: ShellCommandCardView): Record<string, unknown> {
254
+ const stdout = normalizeTerminalOutput(view.stdout).trimEnd();
255
+ const stderr = normalizeTerminalOutput(view.stderr).trimEnd();
256
+ const rawOutput = [
257
+ stdout,
258
+ stderr ? `[stderr]\n${stderr}` : "",
259
+ ].filter(Boolean).join("\n");
260
+ const displayOutput = truncateMiddle(
261
+ rawOutput,
262
+ 6_000,
263
+ "\n\n... 中间输出已截断 ...\n\n",
264
+ );
265
+ const command = truncateMiddle(view.command.trim(), 600, " ... ");
266
+ const output = displayOutput || (view.status === "running" ? "等待输出..." : "(无输出)");
267
+ const status = shellCommandStatus(view);
268
+ const outputWasTruncated = view.outputTruncated || rawOutput.length > 6_000;
269
+ return sectionCard(
270
+ status.title,
271
+ [
272
+ markdown(codeBlock(`$ ${command}\n${output}`, 6_700)),
273
+ markdown([
274
+ `${inlineCode(view.cwd)} · ${status.label} · ${formatTurnDuration(view.elapsedMs)}`,
275
+ ...(outputWasTruncated ? ["输出过长,已保留开头和结尾并截断中间内容。"] : []),
276
+ ].join("\n")),
277
+ ],
278
+ status.template,
279
+ );
280
+ }
281
+
243
282
  renderExecutionSettings(view: ExecutionSettingsCardView): Record<string, unknown> {
244
283
  const baseAction: Record<string, string> = {
245
284
  contextKey: view.contextKey,
@@ -803,7 +842,6 @@ export class CardRenderer {
803
842
  schema: "2.0",
804
843
  config: {
805
844
  update_multi: true,
806
- width_mode: "fill",
807
845
  },
808
846
  header: {
809
847
  template: view.kind === "first" ? "turquoise" : view.kind === "upgrade" ? "blue" : "green",
@@ -869,7 +907,7 @@ export class CardRenderer {
869
907
  });
870
908
  elements.push(
871
909
  { tag: "hr" },
872
- taskActionRow(footerActions),
910
+ taskActionRow(footerActions, renderTurnDuration(state)),
873
911
  );
874
912
  } else if (state.status === "completed") {
875
913
  footerActions.push({
@@ -880,6 +918,11 @@ export class CardRenderer {
880
918
  { tag: "hr" },
881
919
  taskActionRow(footerActions),
882
920
  );
921
+ } else if (state.status === "starting") {
922
+ elements.push(
923
+ { tag: "hr" },
924
+ taskActionRow([], renderTurnDuration(state)),
925
+ );
883
926
  }
884
927
  if (state.status === "completed") {
885
928
  elements.push(
@@ -1724,37 +1767,27 @@ function groupedLiveActivityPage(
1724
1767
  const commentaryIndexes = groups
1725
1768
  .map((group, index) => isCommentaryGroup(group) ? index : -1)
1726
1769
  .filter((index) => index >= 0);
1727
- if (commentaryIndexes.length === 0) {
1728
- return { groups: latest, historyGroups: groups.slice(0, latestStart) };
1729
- }
1730
-
1731
- const selected = new Set<number>();
1732
- const pinnedCommentaries = commentaryIndexes.slice(0, PINNED_LIVE_COMMENTARIES);
1733
- for (const index of pinnedCommentaries) selected.add(index);
1734
- const latestCommentary = commentaryIndexes.at(-1)!;
1735
- for (let index = latestCommentary; index < groups.length; index += 1) selected.add(index);
1736
-
1737
- const renderSelection = (selection: ReadonlySet<number>): LiveGroupedActivityPage => ({
1738
- groups: groupsWithGaps(groups, selection),
1739
- historyGroups: groups.filter((_group, index) => index < latestCommentary || !selection.has(index)),
1740
- });
1741
- const fits = (selection: ReadonlySet<number>): boolean => {
1742
- const view = renderSelection(selection);
1743
- return groupedActivityPageFits(view.groups, projectCwd, {});
1770
+ const renderCutoff = (cutoff: number): LiveGroupedActivityPage => {
1771
+ const selected = new Set<number>(commentaryIndexes
1772
+ .filter((index) => index < cutoff)
1773
+ .slice(-PINNED_LIVE_COMMENTARIES));
1774
+ for (let index = cutoff; index < groups.length; index += 1) selected.add(index);
1775
+ return {
1776
+ groups: groupsWithGaps(groups, selected),
1777
+ historyGroups: groups.slice(0, cutoff),
1778
+ };
1744
1779
  };
1745
1780
 
1746
- if (!fits(selected)) {
1747
- const latestExecutions = [...selected]
1748
- .filter((index) => index > latestCommentary && groups[index]?.kind === "execution")
1749
- .sort((left, right) => left - right);
1750
- const newestExecution = latestExecutions.at(-1);
1751
- for (const index of latestExecutions) {
1752
- if (fits(selected) || index === newestExecution) break;
1753
- selected.delete(index);
1754
- }
1781
+ const executionCutoffs = groups
1782
+ .map((group, index) => group.kind === "execution" && group.tools.length > 0 ? index : -1)
1783
+ .filter((index) => index > 0);
1784
+ for (const cutoff of executionCutoffs) {
1785
+ const view = renderCutoff(cutoff);
1786
+ if (groupedActivityPageFits(view.groups, projectCwd, {})) return view;
1755
1787
  }
1756
1788
 
1757
- return renderSelection(selected);
1789
+ if (executionCutoffs.length > 0) return renderCutoff(executionCutoffs.at(-1)!);
1790
+ return { groups: latest, historyGroups: groups.slice(0, latestStart) };
1758
1791
  }
1759
1792
 
1760
1793
  function isCommentaryGroup(group: GroupedTurnActivity): boolean {
@@ -1964,10 +1997,26 @@ function renderToolDetails(tool: ToolState, projectCwd?: string): string {
1964
1997
  ? unwrapShellCommand(normalizedCommand) ?? normalizedCommand
1965
1998
  : normalizedCommand;
1966
1999
  const commandText = truncateText(displayCommand, 600);
1967
- const resultText = result ? truncateToolResult(stripAnsi(result).trim()) : undefined;
2000
+ const resultText = result ? truncateToolResult(normalizeTerminalOutput(result).trim()) : undefined;
1968
2001
  return codeBlock([`$ ${commandText}`, resultText].filter((part): part is string => part !== undefined).join("\n"), 2_003);
1969
2002
  }
1970
2003
 
2004
+ function normalizeTerminalOutput(value: string): string {
2005
+ const clean = stripAnsi(value).replace(/\r\n/g, "\n");
2006
+ return clean
2007
+ .split("\n")
2008
+ .map((line) => {
2009
+ if (!line.includes("\r")) return line;
2010
+ const updates = line.split("\r");
2011
+ for (let index = updates.length - 1; index >= 0; index -= 1) {
2012
+ const update = updates[index];
2013
+ if (update) return update;
2014
+ }
2015
+ return "";
2016
+ })
2017
+ .join("\n");
2018
+ }
2019
+
1971
2020
  function truncateToolResult(result: string): string {
1972
2021
  const maxLength = 900;
1973
2022
  const marker = "\n...\n";
@@ -2054,6 +2103,23 @@ function formatTurnDuration(durationMs: number): string {
2054
2103
  return `${seconds}s`;
2055
2104
  }
2056
2105
 
2106
+ function shellCommandStatus(view: ShellCommandCardView): {
2107
+ title: string;
2108
+ label: string;
2109
+ template: string;
2110
+ } {
2111
+ if (view.status === "running") {
2112
+ return { title: "正在执行命令", label: "运行中", template: "blue" };
2113
+ }
2114
+ if (view.status === "timed_out") {
2115
+ return { title: "命令执行超时", label: "已超时(120s)", template: "orange" };
2116
+ }
2117
+ if (view.status === "completed") {
2118
+ return { title: "命令执行完成", label: `退出码 ${view.exitCode ?? 0}`, template: "green" };
2119
+ }
2120
+ return { title: "命令执行失败", label: `退出码 ${view.exitCode ?? "未知"}`, template: "red" };
2121
+ }
2122
+
2057
2123
  function unwrapShellCommand(command: string): string | undefined {
2058
2124
  return unwrapPowerShellCommand(command) ?? unwrapPosixShellCommand(command);
2059
2125
  }
@@ -2415,18 +2481,29 @@ function resetHistoryEntryRow(
2415
2481
  };
2416
2482
  }
2417
2483
 
2418
- function taskActionRow(actions: TaskListCardAction[]): Record<string, unknown> {
2484
+ function taskActionRow(actions: TaskListCardAction[], trailingText?: string): Record<string, unknown> {
2485
+ const columns = actions.map((action) => ({
2486
+ tag: "column",
2487
+ width: "auto",
2488
+ vertical_align: "center",
2489
+ elements: [taskActionElement(action)],
2490
+ }));
2491
+ if (trailingText) {
2492
+ columns.push({
2493
+ tag: "column",
2494
+ width: "auto",
2495
+ vertical_align: "center",
2496
+ elements: [markdown(
2497
+ `<font color='grey'>${actions.length > 0 ? "· " : ""}${escapeCardHtml(trailingText)}</font>`,
2498
+ )],
2499
+ });
2500
+ }
2419
2501
  return {
2420
2502
  tag: "column_set",
2421
2503
  flex_mode: "flow",
2422
2504
  horizontal_spacing: "8px",
2423
2505
  margin: "2px 0 0 0",
2424
- columns: actions.map((action) => ({
2425
- tag: "column",
2426
- width: "auto",
2427
- vertical_align: "center",
2428
- elements: [taskActionElement(action)],
2429
- })),
2506
+ columns,
2430
2507
  };
2431
2508
  }
2432
2509
 
@@ -2448,12 +2525,6 @@ function directoryBrowserEntryRow(entry: DirectoryBrowserCardEntry): Record<stri
2448
2525
  ? [taskActionElement({ ...entry.openAction, text: label })]
2449
2526
  : [markdown(escapeCardHtml(label))],
2450
2527
  },
2451
- ...(entry.actions ?? []).map((action) => ({
2452
- tag: "column",
2453
- width: "auto",
2454
- vertical_align: "center",
2455
- elements: [taskActionElement(action)],
2456
- })),
2457
2528
  ],
2458
2529
  };
2459
2530
  }
@@ -2,8 +2,9 @@ import type { Logger } from "pino";
2
2
  import type { AppConfig } from "../config/schema.js";
3
3
  import { threadContextKey } from "./contextKey.js";
4
4
  import { resolveFeishuBotOpenId } from "./FeishuBotIdentity.js";
5
+ import { normalizeFeishuPostText } from "./InboundText.js";
5
6
  import { allowsFeishuUser } from "./ownerAccess.js";
6
- import type { ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
7
+ import type { CardAction, ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
7
8
 
8
9
  type BotOpenIdResolver = (appId: string, appSecret: string) => Promise<string>;
9
10
  type BotOpenIdListener = (botOpenId: string) => void;
@@ -88,6 +89,7 @@ export class FeishuConnector {
88
89
  });
89
90
  },
90
91
  "card.action.trigger": async (data: unknown) => {
92
+ const receivedAt = Date.now();
91
93
  const operatorOpenId = getFeishuEvent(data)?.operator?.open_id;
92
94
  if (!allowsFeishuUser(this.config, operatorOpenId)) {
93
95
  this.logger.debug(
@@ -102,11 +104,11 @@ export class FeishuConnector {
102
104
  return {};
103
105
  }
104
106
 
105
- void Promise.resolve()
106
- .then(() => this.handler.onCardAction(action))
107
- .catch((error: unknown) => {
108
- this.logger.error({ error, actionId: action.actionId }, "Failed to handle Feishu card action.");
109
- });
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
+ );
110
112
  return {
111
113
  toast: {
112
114
  type: "success",
@@ -122,6 +124,27 @@ export class FeishuConnector {
122
124
  await wsClient.start({ eventDispatcher });
123
125
  this.logger.info("Feishu WebSocket connector started.");
124
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
+ }
125
148
  }
126
149
 
127
150
  function toChatUpdatedEvent(data: unknown): ChatUpdatedEvent | undefined {
@@ -171,8 +194,9 @@ function toIncomingMessage(
171
194
  return undefined;
172
195
  }
173
196
 
197
+ const messageId = message.message_id ?? `${Date.now()}`;
174
198
  return {
175
- messageId: message.message_id ?? `${Date.now()}`,
199
+ messageId,
176
200
  contextKey: chatId
177
201
  ? threadContext
178
202
  ? threadContextKey(chatId, threadId!)
@@ -189,6 +213,8 @@ function toIncomingMessage(
189
213
  ...(mentionedBot ? { mentionedBot: true as const } : {}),
190
214
  text: parsed.text,
191
215
  ...(parsed.images.length > 0 ? { images: parsed.images.map((imageKey) => ({ imageKey })) } : {}),
216
+ ...(parsed.files.length > 0 ? { files: parsed.files } : {}),
217
+ ...(parsed.mergedForward ? { mergedForwardMessageId: messageId } : {}),
192
218
  };
193
219
  }
194
220
 
@@ -201,21 +227,36 @@ function parseMessageContent(
201
227
  messageType: unknown,
202
228
  content: Record<string, unknown>,
203
229
  mentions: unknown,
204
- ): { text: string; images: string[] } | undefined {
230
+ ): {
231
+ text: string;
232
+ images: string[];
233
+ files: Array<{ fileKey: string; fileName: string }>;
234
+ mergedForward?: true;
235
+ } | undefined {
205
236
  if (messageType === "text") {
206
237
  const rawText = typeof content.text === "string" ? content.text : "";
207
- return { text: stripLeadingMentions(rawText, mentions), images: [] };
238
+ return { text: normalizeFeishuPostText(stripLeadingMentions(rawText, mentions)), images: [], files: [] };
208
239
  }
209
240
  if (messageType === "image") {
210
241
  const imageKey = typeof content.image_key === "string" ? content.image_key : undefined;
211
- return imageKey ? { text: "", images: [imageKey] } : undefined;
242
+ return imageKey ? { text: "", images: [imageKey], files: [] } : undefined;
243
+ }
244
+ if (messageType === "file") {
245
+ const fileKey = typeof content.file_key === "string" ? content.file_key : undefined;
246
+ const fileName = typeof content.file_name === "string" && content.file_name.trim()
247
+ ? content.file_name.trim()
248
+ : "file";
249
+ return fileKey ? { text: "", images: [], files: [{ fileKey, fileName }] } : undefined;
250
+ }
251
+ if (messageType === "merge_forward") {
252
+ return { text: "", images: [], files: [], mergedForward: true };
212
253
  }
213
254
  if (messageType !== "post") return undefined;
214
255
 
215
256
  const locale = selectPostLocale(content);
216
257
  if (!locale) return undefined;
217
258
  const paragraphs: string[] = [];
218
- const title = typeof locale.title === "string" ? locale.title.trim() : "";
259
+ const title = typeof locale.title === "string" ? normalizeFeishuPostText(locale.title) : "";
219
260
  if (title) paragraphs.push(title);
220
261
  const images: string[] = [];
221
262
  if (Array.isArray(locale.content)) {
@@ -230,10 +271,11 @@ function parseMessageContent(
230
271
  images.push(element.image_key);
231
272
  }
232
273
  }
233
- if (rowText.trim()) paragraphs.push(rowText.trim());
274
+ const normalizedRow = normalizeFeishuPostText(rowText);
275
+ if (normalizedRow) paragraphs.push(normalizedRow);
234
276
  }
235
277
  }
236
- return { text: paragraphs.join("\n"), images: [...new Set(images)] };
278
+ return { text: paragraphs.join("\n"), images: [...new Set(images)], files: [] };
237
279
  }
238
280
 
239
281
  function selectPostLocale(content: Record<string, unknown>): Record<string, unknown> | 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
  }