@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.
- package/CHANGELOG.md +59 -0
- package/README.md +50 -8
- package/config.example.json +3 -0
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/browser-tools.ts +53 -3
- package/container/src/hybridai-client.ts +270 -8
- package/container/src/index.ts +66 -3
- package/container/src/token-usage.ts +89 -0
- package/container/src/tools.ts +9 -2
- package/container/src/types.ts +19 -0
- package/container/src/web-fetch.ts +98 -7
- package/dist/agent.d.ts +1 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +2 -2
- package/dist/agent.js.map +1 -1
- package/dist/chunk.d.ts +6 -0
- package/dist/chunk.d.ts.map +1 -0
- package/dist/chunk.js +129 -0
- package/dist/chunk.js.map +1 -0
- package/dist/container-runner.d.ts +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +25 -1
- package/dist/container-runner.js.map +1 -1
- package/dist/conversation.d.ts +4 -0
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +13 -3
- package/dist/conversation.js.map +1 -1
- package/dist/discord-stream.d.ts +32 -0
- package/dist/discord-stream.d.ts.map +1 -0
- package/dist/discord-stream.js +196 -0
- package/dist/discord-stream.js.map +1 -0
- package/dist/discord.d.ts +9 -2
- package/dist/discord.d.ts.map +1 -1
- package/dist/discord.js +452 -23
- package/dist/discord.js.map +1 -1
- package/dist/gateway-client.d.ts.map +1 -1
- package/dist/gateway-client.js +5 -0
- package/dist/gateway-client.js.map +1 -1
- package/dist/gateway-service.d.ts +1 -0
- package/dist/gateway-service.d.ts.map +1 -1
- package/dist/gateway-service.js +60 -2
- package/dist/gateway-service.js.map +1 -1
- package/dist/gateway-types.d.ts +7 -1
- package/dist/gateway-types.d.ts.map +1 -1
- package/dist/gateway-types.js.map +1 -1
- package/dist/gateway.js +55 -4
- package/dist/gateway.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +7 -0
- package/dist/health.js.map +1 -1
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +20 -0
- package/dist/heartbeat.js.map +1 -1
- package/dist/observability-ingest.d.ts.map +1 -1
- package/dist/observability-ingest.js +26 -0
- package/dist/observability-ingest.js.map +1 -1
- package/dist/prompt-hooks.d.ts +2 -0
- package/dist/prompt-hooks.d.ts.map +1 -1
- package/dist/prompt-hooks.js +29 -0
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +3 -0
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +17 -1
- package/dist/runtime-config.js.map +1 -1
- package/dist/scheduled-task-runner.d.ts.map +1 -1
- package/dist/scheduled-task-runner.js +20 -0
- package/dist/scheduled-task-runner.js.map +1 -1
- package/dist/session-maintenance.d.ts.map +1 -1
- package/dist/session-maintenance.js +1 -0
- package/dist/session-maintenance.js.map +1 -1
- package/dist/skills-guard.d.ts +36 -0
- package/dist/skills-guard.d.ts.map +1 -0
- package/dist/skills-guard.js +607 -0
- package/dist/skills-guard.js.map +1 -0
- package/dist/skills.d.ts +13 -2
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +494 -59
- package/dist/skills.js.map +1 -1
- package/dist/token-efficiency.d.ts +41 -0
- package/dist/token-efficiency.d.ts.map +1 -0
- package/dist/token-efficiency.js +164 -0
- package/dist/token-efficiency.js.map +1 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +2 -1
- package/dist/workspace.js.map +1 -1
- package/docs/index.html +33 -7
- package/package.json +1 -1
- package/src/agent.ts +15 -1
- package/src/chunk.ts +153 -0
- package/src/container-runner.ts +24 -0
- package/src/conversation.ts +28 -4
- package/src/discord-stream.ts +240 -0
- package/src/discord.ts +517 -23
- package/src/gateway-client.ts +7 -0
- package/src/gateway-service.ts +72 -1
- package/src/gateway-types.ts +12 -1
- package/src/gateway.ts +65 -4
- package/src/health.ts +8 -0
- package/src/heartbeat.ts +20 -0
- package/src/observability-ingest.ts +24 -0
- package/src/prompt-hooks.ts +29 -0
- package/src/runtime-config.ts +18 -1
- package/src/scheduled-task-runner.ts +20 -0
- package/src/session-maintenance.ts +1 -0
- package/src/skills-guard.ts +736 -0
- package/src/skills.ts +570 -61
- package/src/token-efficiency.ts +228 -0
- package/src/types.ts +12 -0
- package/src/workspace.ts +2 -2
- 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
|
+
}
|
package/src/container-runner.ts
CHANGED
|
@@ -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
|
}
|
package/src/conversation.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
48
|
-
|
|
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
|
+
}
|