@hybridaione/hybridclaw 0.1.21 → 0.1.24

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 (113) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +50 -8
  3. package/config.example.json +3 -0
  4. package/container/package-lock.json +2 -2
  5. package/container/package.json +1 -1
  6. package/container/src/browser-tools.ts +53 -3
  7. package/container/src/hybridai-client.ts +270 -8
  8. package/container/src/index.ts +66 -3
  9. package/container/src/token-usage.ts +89 -0
  10. package/container/src/tools.ts +9 -2
  11. package/container/src/types.ts +19 -0
  12. package/container/src/web-fetch.ts +98 -7
  13. package/dist/agent.d.ts +1 -1
  14. package/dist/agent.d.ts.map +1 -1
  15. package/dist/agent.js +2 -2
  16. package/dist/agent.js.map +1 -1
  17. package/dist/chunk.d.ts +6 -0
  18. package/dist/chunk.d.ts.map +1 -0
  19. package/dist/chunk.js +129 -0
  20. package/dist/chunk.js.map +1 -0
  21. package/dist/container-runner.d.ts +1 -1
  22. package/dist/container-runner.d.ts.map +1 -1
  23. package/dist/container-runner.js +25 -1
  24. package/dist/container-runner.js.map +1 -1
  25. package/dist/conversation.d.ts +4 -0
  26. package/dist/conversation.d.ts.map +1 -1
  27. package/dist/conversation.js +13 -3
  28. package/dist/conversation.js.map +1 -1
  29. package/dist/discord-stream.d.ts +32 -0
  30. package/dist/discord-stream.d.ts.map +1 -0
  31. package/dist/discord-stream.js +196 -0
  32. package/dist/discord-stream.js.map +1 -0
  33. package/dist/discord.d.ts +9 -2
  34. package/dist/discord.d.ts.map +1 -1
  35. package/dist/discord.js +452 -23
  36. package/dist/discord.js.map +1 -1
  37. package/dist/gateway-client.d.ts.map +1 -1
  38. package/dist/gateway-client.js +5 -0
  39. package/dist/gateway-client.js.map +1 -1
  40. package/dist/gateway-service.d.ts +1 -0
  41. package/dist/gateway-service.d.ts.map +1 -1
  42. package/dist/gateway-service.js +60 -2
  43. package/dist/gateway-service.js.map +1 -1
  44. package/dist/gateway-types.d.ts +7 -1
  45. package/dist/gateway-types.d.ts.map +1 -1
  46. package/dist/gateway-types.js.map +1 -1
  47. package/dist/gateway.js +55 -4
  48. package/dist/gateway.js.map +1 -1
  49. package/dist/health.d.ts.map +1 -1
  50. package/dist/health.js +7 -0
  51. package/dist/health.js.map +1 -1
  52. package/dist/heartbeat.d.ts.map +1 -1
  53. package/dist/heartbeat.js +20 -0
  54. package/dist/heartbeat.js.map +1 -1
  55. package/dist/observability-ingest.d.ts.map +1 -1
  56. package/dist/observability-ingest.js +26 -0
  57. package/dist/observability-ingest.js.map +1 -1
  58. package/dist/prompt-hooks.d.ts +2 -0
  59. package/dist/prompt-hooks.d.ts.map +1 -1
  60. package/dist/prompt-hooks.js +29 -0
  61. package/dist/prompt-hooks.js.map +1 -1
  62. package/dist/runtime-config.d.ts +3 -0
  63. package/dist/runtime-config.d.ts.map +1 -1
  64. package/dist/runtime-config.js +17 -1
  65. package/dist/runtime-config.js.map +1 -1
  66. package/dist/scheduled-task-runner.d.ts.map +1 -1
  67. package/dist/scheduled-task-runner.js +20 -0
  68. package/dist/scheduled-task-runner.js.map +1 -1
  69. package/dist/session-maintenance.d.ts.map +1 -1
  70. package/dist/session-maintenance.js +1 -0
  71. package/dist/session-maintenance.js.map +1 -1
  72. package/dist/skills-guard.d.ts +36 -0
  73. package/dist/skills-guard.d.ts.map +1 -0
  74. package/dist/skills-guard.js +607 -0
  75. package/dist/skills-guard.js.map +1 -0
  76. package/dist/skills.d.ts +13 -2
  77. package/dist/skills.d.ts.map +1 -1
  78. package/dist/skills.js +494 -59
  79. package/dist/skills.js.map +1 -1
  80. package/dist/token-efficiency.d.ts +41 -0
  81. package/dist/token-efficiency.d.ts.map +1 -0
  82. package/dist/token-efficiency.js +164 -0
  83. package/dist/token-efficiency.js.map +1 -0
  84. package/dist/types.d.ts +11 -0
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/workspace.d.ts.map +1 -1
  87. package/dist/workspace.js +2 -1
  88. package/dist/workspace.js.map +1 -1
  89. package/docs/index.html +33 -7
  90. package/package.json +1 -1
  91. package/src/agent.ts +15 -1
  92. package/src/chunk.ts +153 -0
  93. package/src/container-runner.ts +24 -0
  94. package/src/conversation.ts +28 -4
  95. package/src/discord-stream.ts +240 -0
  96. package/src/discord.ts +517 -23
  97. package/src/gateway-client.ts +7 -0
  98. package/src/gateway-service.ts +72 -1
  99. package/src/gateway-types.ts +12 -1
  100. package/src/gateway.ts +65 -4
  101. package/src/health.ts +8 -0
  102. package/src/heartbeat.ts +20 -0
  103. package/src/observability-ingest.ts +24 -0
  104. package/src/prompt-hooks.ts +29 -0
  105. package/src/runtime-config.ts +18 -1
  106. package/src/scheduled-task-runner.ts +20 -0
  107. package/src/session-maintenance.ts +1 -0
  108. package/src/skills-guard.ts +736 -0
  109. package/src/skills.ts +570 -61
  110. package/src/token-efficiency.ts +228 -0
  111. package/src/types.ts +12 -0
  112. package/src/workspace.ts +2 -2
  113. package/.hybridclaw/container-image-state.json +0 -5
package/src/chunk.ts ADDED
@@ -0,0 +1,153 @@
1
+ export interface ChunkMessageOptions {
2
+ maxChars?: number;
3
+ maxLines?: number;
4
+ }
5
+
6
+ const DEFAULT_MAX_CHARS = 1_900;
7
+ const DEFAULT_MAX_LINES = 20;
8
+
9
+ function isFenceLine(line: string): boolean {
10
+ return line.trim().startsWith('```');
11
+ }
12
+
13
+ function parseFenceLanguage(line: string): string {
14
+ const trimmed = line.trim();
15
+ if (!trimmed.startsWith('```')) return '';
16
+ return trimmed.slice(3).trim();
17
+ }
18
+
19
+ function findSentenceBoundary(input: string): number {
20
+ let best = -1;
21
+ const re = /[.!?]\s+/g;
22
+ for (let match = re.exec(input); match; match = re.exec(input)) {
23
+ best = match.index + match[0].length;
24
+ }
25
+ return best;
26
+ }
27
+
28
+ function findPreferredSplit(input: string, hardLimit: number): number {
29
+ const limit = Math.max(1, Math.min(hardLimit, input.length));
30
+ const window = input.slice(0, limit);
31
+
32
+ const paragraph = window.lastIndexOf('\n\n');
33
+ if (paragraph >= Math.floor(limit * 0.45)) {
34
+ return paragraph + 2;
35
+ }
36
+
37
+ const line = window.lastIndexOf('\n');
38
+ if (line >= Math.floor(limit * 0.45)) {
39
+ return line + 1;
40
+ }
41
+
42
+ const sentence = findSentenceBoundary(window);
43
+ if (sentence >= Math.floor(limit * 0.45)) {
44
+ return sentence;
45
+ }
46
+
47
+ const word = window.lastIndexOf(' ');
48
+ if (word >= Math.floor(limit * 0.35)) {
49
+ return word + 1;
50
+ }
51
+
52
+ return limit;
53
+ }
54
+
55
+ function splitLongLine(line: string, maxChars: number): string[] {
56
+ if (line.length <= maxChars) return [line];
57
+
58
+ const pieces: string[] = [];
59
+ let remaining = line;
60
+ while (remaining.length > maxChars) {
61
+ let splitAt = findPreferredSplit(remaining, maxChars);
62
+ if (splitAt <= 0 || splitAt > remaining.length) {
63
+ splitAt = Math.min(maxChars, remaining.length);
64
+ }
65
+
66
+ const head = remaining.slice(0, splitAt).trimEnd();
67
+ if (!head) {
68
+ const fallback = remaining.slice(0, maxChars);
69
+ pieces.push(fallback);
70
+ remaining = remaining.slice(maxChars);
71
+ continue;
72
+ }
73
+
74
+ pieces.push(head);
75
+ remaining = remaining.slice(splitAt).trimStart();
76
+ }
77
+
78
+ if (remaining.length > 0) {
79
+ pieces.push(remaining);
80
+ }
81
+
82
+ return pieces;
83
+ }
84
+
85
+ export function chunkMessage(text: string, opts?: ChunkMessageOptions): string[] {
86
+ const maxChars = Math.max(200, opts?.maxChars ?? DEFAULT_MAX_CHARS);
87
+ const maxLines = Math.max(4, opts?.maxLines ?? DEFAULT_MAX_LINES);
88
+ const normalized = (text || '').replace(/\r\n?/g, '\n');
89
+ if (!normalized.trim()) return [];
90
+
91
+ const inputLines = normalized.split('\n');
92
+ const chunks: string[] = [];
93
+
94
+ let currentLines: string[] = [];
95
+ let currentChars = 0;
96
+ let openFence = false;
97
+ let fenceLanguage = '';
98
+
99
+ const flush = (isFinal: boolean): void => {
100
+ if (currentLines.length === 0) return;
101
+
102
+ let chunk = currentLines.join('\n');
103
+ if (openFence) {
104
+ chunk += '\n```';
105
+ }
106
+ chunks.push(chunk);
107
+
108
+ if (!isFinal && openFence) {
109
+ const reopenedFence = fenceLanguage ? `\`\`\`${fenceLanguage}` : '```';
110
+ currentLines = [reopenedFence];
111
+ currentChars = reopenedFence.length;
112
+ } else {
113
+ currentLines = [];
114
+ currentChars = 0;
115
+ if (isFinal && openFence) {
116
+ openFence = false;
117
+ fenceLanguage = '';
118
+ }
119
+ }
120
+ };
121
+
122
+ const appendLine = (line: string): void => {
123
+ const addedChars = currentLines.length === 0 ? line.length : line.length + 1;
124
+ const nextChars = currentChars + addedChars;
125
+ const nextLines = currentLines.length + 1;
126
+ if (currentLines.length > 0 && (nextChars > maxChars || nextLines > maxLines)) {
127
+ flush(false);
128
+ }
129
+
130
+ currentLines.push(line);
131
+ currentChars = currentLines.length === 1 ? line.length : currentChars + line.length + 1;
132
+
133
+ if (isFenceLine(line)) {
134
+ if (!openFence) {
135
+ openFence = true;
136
+ fenceLanguage = parseFenceLanguage(line);
137
+ } else {
138
+ openFence = false;
139
+ fenceLanguage = '';
140
+ }
141
+ }
142
+ };
143
+
144
+ for (const rawLine of inputLines) {
145
+ const splitLines = splitLongLine(rawLine, maxChars);
146
+ for (const part of splitLines) {
147
+ appendLine(part);
148
+ }
149
+ }
150
+
151
+ flush(true);
152
+ return chunks;
153
+ }
@@ -33,14 +33,31 @@ interface PoolEntry {
33
33
  sessionId: string;
34
34
  startedAt: number;
35
35
  stderrBuffer: string;
36
+ onTextDelta?: (delta: string) => void;
36
37
  onToolProgress?: (event: ToolProgressEvent) => void;
37
38
  }
38
39
 
39
40
  const pool = new Map<string, PoolEntry>();
40
41
  const TOOL_RESULT_RE = /^\[tool\]\s+([a-zA-Z0-9_.-]+)\s+result\s+\((\d+)ms\):\s*(.*)$/;
41
42
  const TOOL_START_RE = /^\[tool\]\s+([a-zA-Z0-9_.-]+):\s*(.*)$/;
43
+ const STREAM_DELTA_RE = /^\[stream\]\s+([A-Za-z0-9+/=]+)$/;
42
44
  const CONTAINER_WORKSPACE_ROOT = '/workspace';
43
45
 
46
+ function emitTextDelta(entry: PoolEntry, line: string): void {
47
+ const callback = entry.onTextDelta;
48
+ if (!callback) return;
49
+ const match = line.match(STREAM_DELTA_RE);
50
+ if (!match) return;
51
+
52
+ try {
53
+ const delta = Buffer.from(match[1], 'base64').toString('utf-8');
54
+ if (!delta) return;
55
+ callback(delta);
56
+ } catch (err) {
57
+ logger.debug({ sessionId: entry.sessionId, err }, 'Text delta callback failed');
58
+ }
59
+ }
60
+
44
61
  function emitToolProgress(entry: PoolEntry, line: string): void {
45
62
  const callback = entry.onToolProgress;
46
63
  if (!callback) return;
@@ -218,6 +235,7 @@ function getOrSpawnContainer(sessionId: string, agentId: string): PoolEntry {
218
235
  for (const rawLine of lines) {
219
236
  const line = rawLine.trim();
220
237
  if (!line) continue;
238
+ emitTextDelta(entry, line);
221
239
  logger.debug({ container: containerName }, line);
222
240
  emitToolProgress(entry, line);
223
241
  }
@@ -226,6 +244,7 @@ function getOrSpawnContainer(sessionId: string, agentId: string): PoolEntry {
226
244
  proc.on('close', (code) => {
227
245
  const tail = entry.stderrBuffer.trim();
228
246
  if (tail) {
247
+ emitTextDelta(entry, tail);
229
248
  logger.debug({ container: containerName }, tail);
230
249
  emitToolProgress(entry, tail);
231
250
  entry.stderrBuffer = '';
@@ -256,6 +275,7 @@ export async function runContainer(
256
275
  channelId: string = '',
257
276
  scheduledTasks?: ScheduledTask[],
258
277
  allowedTools?: string[],
278
+ onTextDelta?: (delta: string) => void,
259
279
  onToolProgress?: (event: ToolProgressEvent) => void,
260
280
  abortSignal?: AbortSignal,
261
281
  ): Promise<ContainerOutput> {
@@ -312,6 +332,7 @@ export async function runContainer(
312
332
  allowedTools,
313
333
  };
314
334
 
335
+ entry.onTextDelta = onTextDelta;
315
336
  entry.onToolProgress = onToolProgress;
316
337
  const onAbort = () => {
317
338
  logger.info({ sessionId, containerName: entry.containerName }, 'Interrupt requested, stopping container');
@@ -346,6 +367,9 @@ export async function runContainer(
346
367
  return output;
347
368
  } finally {
348
369
  abortSignal?.removeEventListener('abort', onAbort);
370
+ if (entry.onTextDelta === onTextDelta) {
371
+ entry.onTextDelta = undefined;
372
+ }
349
373
  if (entry.onToolProgress === onToolProgress) {
350
374
  entry.onToolProgress = undefined;
351
375
  }
@@ -1,6 +1,10 @@
1
1
  import { expandSkillInvocation, loadSkills, type Skill } from './skills.js';
2
2
  import type { ChatMessage } from './types.js';
3
- import { buildSystemPromptFromHooks } from './prompt-hooks.js';
3
+ import { buildSystemPromptFromHooks, type PromptMode } from './prompt-hooks.js';
4
+ import {
5
+ optimizeHistoryMessagesForPrompt,
6
+ type HistoryOptimizationStats,
7
+ } from './token-efficiency.js';
4
8
 
5
9
  interface HistoryMessage {
6
10
  role: string;
@@ -10,6 +14,7 @@ interface HistoryMessage {
10
14
  export interface ConversationContext {
11
15
  messages: ChatMessage[];
12
16
  skills: Skill[];
17
+ historyStats: HistoryOptimizationStats;
13
18
  }
14
19
 
15
20
  export function buildConversationContext(params: {
@@ -17,14 +22,22 @@ export function buildConversationContext(params: {
17
22
  sessionSummary?: string | null;
18
23
  history: HistoryMessage[];
19
24
  expandLatestHistoryUser?: boolean;
25
+ promptMode?: PromptMode;
20
26
  }): ConversationContext {
21
- const { agentId, sessionSummary, history, expandLatestHistoryUser = false } = params;
27
+ const {
28
+ agentId,
29
+ sessionSummary,
30
+ history,
31
+ expandLatestHistoryUser = false,
32
+ promptMode = 'full',
33
+ } = params;
22
34
  const skills = loadSkills(agentId);
23
35
  const systemPrompt = buildSystemPromptFromHooks({
24
36
  agentId,
25
37
  sessionSummary,
26
38
  skills,
27
39
  purpose: 'conversation',
40
+ promptMode,
28
41
  });
29
42
 
30
43
  const messages: ChatMessage[] = [];
@@ -44,6 +57,17 @@ export function buildConversationContext(params: {
44
57
  }
45
58
  }
46
59
 
47
- messages.push(...historyMessages);
48
- return { messages, skills };
60
+ const optimizedHistory = optimizeHistoryMessagesForPrompt(
61
+ historyMessages.map((message) => ({
62
+ role: message.role,
63
+ content: typeof message.content === 'string' ? message.content : '',
64
+ })),
65
+ );
66
+
67
+ messages.push(...optimizedHistory.messages);
68
+ return {
69
+ messages,
70
+ skills,
71
+ historyStats: optimizedHistory.stats,
72
+ };
49
73
  }
@@ -0,0 +1,240 @@
1
+ import {
2
+ AttachmentBuilder,
3
+ type Message as DiscordMessage,
4
+ } from 'discord.js';
5
+
6
+ import { chunkMessage } from './chunk.js';
7
+ import { logger } from './logger.js';
8
+
9
+ interface DiscordSendChannel {
10
+ send: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<DiscordMessage>;
11
+ }
12
+
13
+ interface DiscordEditMessage {
14
+ edit: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<DiscordMessage>;
15
+ delete: () => Promise<unknown>;
16
+ }
17
+
18
+ interface DiscordErrorLike {
19
+ status?: number;
20
+ httpStatus?: number;
21
+ retryAfter?: number;
22
+ data?: {
23
+ retry_after?: number;
24
+ };
25
+ }
26
+
27
+ export interface DiscordStreamOptions {
28
+ maxChars?: number;
29
+ maxLines?: number;
30
+ editIntervalMs?: number;
31
+ onFirstMessage?: () => void;
32
+ }
33
+
34
+ const DEFAULT_MAX_CHARS = 1_800;
35
+ const DEFAULT_MAX_LINES = 20;
36
+ const DEFAULT_EDIT_INTERVAL_MS = 1_200;
37
+ const RETRY_MAX_ATTEMPTS = 3;
38
+ const RETRY_BASE_DELAY_MS = 500;
39
+
40
+ function isRetryableDiscordError(error: unknown): boolean {
41
+ const maybe = error as DiscordErrorLike;
42
+ const status = maybe.status ?? maybe.httpStatus;
43
+ return status === 429 || (typeof status === 'number' && status >= 500 && status <= 599);
44
+ }
45
+
46
+ function extractRetryDelayMs(error: unknown, fallbackMs: number): number {
47
+ const maybe = error as DiscordErrorLike;
48
+ const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
49
+ if (typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
50
+ return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
51
+ }
52
+ const jitter = Math.floor(Math.random() * 250);
53
+ return fallbackMs + jitter;
54
+ }
55
+
56
+ async function withDiscordRetry<T>(label: string, run: () => Promise<T>): Promise<T> {
57
+ let attempt = 0;
58
+ let delayMs = RETRY_BASE_DELAY_MS;
59
+ while (true) {
60
+ attempt += 1;
61
+ try {
62
+ return await run();
63
+ } catch (error) {
64
+ if (attempt >= RETRY_MAX_ATTEMPTS || !isRetryableDiscordError(error)) {
65
+ throw error;
66
+ }
67
+ const waitMs = extractRetryDelayMs(error, delayMs);
68
+ logger.warn({ label, attempt, waitMs, error }, 'Discord request failed; retrying');
69
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
70
+ delayMs = Math.min(delayMs * 2, 4_000);
71
+ }
72
+ }
73
+ }
74
+
75
+ export class DiscordStreamManager {
76
+ private readonly sourceMessage: DiscordMessage;
77
+ private readonly channel: DiscordSendChannel;
78
+ private readonly maxChars: number;
79
+ private readonly maxLines: number;
80
+ private readonly editIntervalMs: number;
81
+ private readonly onFirstMessage?: () => void;
82
+
83
+ private readonly messages: DiscordEditMessage[] = [];
84
+ private sentChunks: string[] = [];
85
+ private content = '';
86
+ private lastEditAt = 0;
87
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
88
+ private opQueue = Promise.resolve();
89
+ private closed = false;
90
+
91
+ constructor(sourceMessage: DiscordMessage, options?: DiscordStreamOptions) {
92
+ this.sourceMessage = sourceMessage;
93
+ this.channel = sourceMessage.channel as unknown as DiscordSendChannel;
94
+ this.maxChars = Math.max(200, options?.maxChars ?? DEFAULT_MAX_CHARS);
95
+ this.maxLines = Math.max(4, options?.maxLines ?? DEFAULT_MAX_LINES);
96
+ this.editIntervalMs = Math.max(250, options?.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS);
97
+ this.onFirstMessage = options?.onFirstMessage;
98
+ }
99
+
100
+ hasSentMessages(): boolean {
101
+ return this.messages.length > 0;
102
+ }
103
+
104
+ append(delta: string): Promise<void> {
105
+ if (this.closed) return Promise.resolve();
106
+ if (!delta) return Promise.resolve();
107
+ this.content += delta;
108
+ return this.enqueue(async () => {
109
+ await this.sync(false);
110
+ });
111
+ }
112
+
113
+ finalize(finalText: string, files?: AttachmentBuilder[]): Promise<void> {
114
+ if (this.closed) return Promise.resolve();
115
+ this.content = finalText;
116
+ if (this.flushTimer) {
117
+ clearTimeout(this.flushTimer);
118
+ this.flushTimer = null;
119
+ }
120
+ return this.enqueue(async () => {
121
+ await this.sync(true, files);
122
+ this.closed = true;
123
+ });
124
+ }
125
+
126
+ fail(errorText: string): Promise<void> {
127
+ if (this.closed) return Promise.resolve();
128
+ this.content = this.content
129
+ ? `${this.content}\n\n${errorText}`
130
+ : errorText;
131
+ if (this.flushTimer) {
132
+ clearTimeout(this.flushTimer);
133
+ this.flushTimer = null;
134
+ }
135
+ return this.enqueue(async () => {
136
+ await this.sync(true);
137
+ this.closed = true;
138
+ });
139
+ }
140
+
141
+ discard(): Promise<void> {
142
+ if (this.flushTimer) {
143
+ clearTimeout(this.flushTimer);
144
+ this.flushTimer = null;
145
+ }
146
+ this.closed = true;
147
+ return this.enqueue(async () => {
148
+ for (const message of this.messages) {
149
+ try {
150
+ await withDiscordRetry('delete', () => message.delete());
151
+ } catch (error) {
152
+ logger.debug({ error }, 'Failed to delete partial streamed message');
153
+ }
154
+ }
155
+ this.messages.length = 0;
156
+ this.sentChunks = [];
157
+ this.content = '';
158
+ });
159
+ }
160
+
161
+ private enqueue(task: () => Promise<void>): Promise<void> {
162
+ this.opQueue = this.opQueue
163
+ .then(task)
164
+ .catch((error) => {
165
+ logger.warn({ error }, 'Discord stream operation failed');
166
+ });
167
+ return this.opQueue;
168
+ }
169
+
170
+ private scheduleFlush(): void {
171
+ if (this.flushTimer || this.closed) return;
172
+ const waitMs = Math.max(0, this.editIntervalMs - (Date.now() - this.lastEditAt));
173
+ this.flushTimer = setTimeout(() => {
174
+ this.flushTimer = null;
175
+ void this.enqueue(async () => {
176
+ await this.sync(false);
177
+ });
178
+ }, waitMs);
179
+ }
180
+
181
+ private async sync(forceLastEdit: boolean, files?: AttachmentBuilder[]): Promise<void> {
182
+ const chunks = chunkMessage(this.content, {
183
+ maxChars: this.maxChars,
184
+ maxLines: this.maxLines,
185
+ });
186
+
187
+ if (chunks.length === 0) {
188
+ if (files && files.length > 0) {
189
+ const fallback = 'Attached files:';
190
+ const sent = await withDiscordRetry('reply', () => this.sourceMessage.reply({ content: fallback, files }));
191
+ this.messages.push(sent as unknown as DiscordEditMessage);
192
+ this.sentChunks.push(fallback);
193
+ this.onFirstMessage?.();
194
+ }
195
+ return;
196
+ }
197
+
198
+ for (let i = 0; i < chunks.length; i += 1) {
199
+ const chunk = chunks[i];
200
+ const isLast = i === chunks.length - 1;
201
+
202
+ if (i >= this.messages.length) {
203
+ const sent = i === 0
204
+ ? await withDiscordRetry('reply', () => this.sourceMessage.reply({ content: chunk }))
205
+ : await withDiscordRetry('send', () => this.channel.send({ content: chunk }));
206
+ this.messages.push(sent as unknown as DiscordEditMessage);
207
+ this.sentChunks.push(chunk);
208
+ this.onFirstMessage?.();
209
+ continue;
210
+ }
211
+
212
+ if (this.sentChunks[i] === chunk) continue;
213
+
214
+ const elapsed = Date.now() - this.lastEditAt;
215
+ if (isLast && !forceLastEdit && elapsed < this.editIntervalMs) {
216
+ this.scheduleFlush();
217
+ continue;
218
+ }
219
+
220
+ await withDiscordRetry('edit', () => this.messages[i].edit({ content: chunk }));
221
+ this.sentChunks[i] = chunk;
222
+ this.lastEditAt = Date.now();
223
+ }
224
+
225
+ if (this.messages.length > chunks.length) {
226
+ for (let i = this.messages.length - 1; i >= chunks.length; i -= 1) {
227
+ await withDiscordRetry('delete', () => this.messages[i].delete());
228
+ }
229
+ this.messages.splice(chunks.length);
230
+ this.sentChunks = this.sentChunks.slice(0, chunks.length);
231
+ }
232
+
233
+ if (files && files.length > 0) {
234
+ const lastIndex = chunks.length - 1;
235
+ await withDiscordRetry('edit', () => this.messages[lastIndex].edit({ content: chunks[lastIndex], files }));
236
+ this.sentChunks[lastIndex] = chunks[lastIndex];
237
+ this.lastEditAt = Date.now();
238
+ }
239
+ }
240
+ }