@keyou007/agent-bot 0.1.15-alpha.0 → 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 (56) hide show
  1. package/CHANGELOG.md +10 -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 +51 -8
  10. package/dist/feishu/CardRenderer.js.map +1 -1
  11. package/dist/feishu/FeishuConnector.js +17 -5
  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 +192 -36
  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 +75 -9
  44. package/src/feishu/FeishuConnector.ts +28 -6
  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 +208 -35
  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
@@ -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
+ }
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import pino, { type Logger } from "pino";
4
4
  import type { AppConfig } from "../config/schema.js";
5
+ import { DailyLogStream } from "./DailyLogStream.js";
5
6
 
6
7
  export function createLogger(config: AppConfig): Logger {
7
8
  const streams: pino.StreamEntry[] = [
@@ -13,7 +14,7 @@ export function createLogger(config: AppConfig): Logger {
13
14
  if (config.logging.path) {
14
15
  fs.mkdirSync(path.dirname(config.logging.path), { recursive: true });
15
16
  streams.push({
16
- stream: fs.createWriteStream(config.logging.path, { flags: "a" }),
17
+ stream: new DailyLogStream(config.logging.path),
17
18
  });
18
19
  }
19
20