@amanm/openpaw 0.1.0
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/AGENTS.md +1 -0
- package/README.md +144 -0
- package/agent/agent.ts +217 -0
- package/agent/context-scan.ts +81 -0
- package/agent/file-editor-store.ts +27 -0
- package/agent/index.ts +31 -0
- package/agent/memory-store.ts +404 -0
- package/agent/model.ts +14 -0
- package/agent/prompt-builder.ts +139 -0
- package/agent/prompt-context-files.ts +151 -0
- package/agent/sandbox-paths.ts +52 -0
- package/agent/session-store.ts +80 -0
- package/agent/skill-catalog.ts +25 -0
- package/agent/skills/discover.ts +100 -0
- package/agent/tool-stream-format.ts +126 -0
- package/agent/tool-yaml-like.ts +96 -0
- package/agent/tools/bash.ts +100 -0
- package/agent/tools/file-editor.ts +293 -0
- package/agent/tools/list-dir.ts +58 -0
- package/agent/tools/load-skill.ts +40 -0
- package/agent/tools/memory.ts +84 -0
- package/agent/turn-context.ts +46 -0
- package/agent/types.ts +37 -0
- package/agent/workspace-bootstrap.ts +98 -0
- package/bin/openpaw.cjs +177 -0
- package/bundled-skills/find-skills/SKILL.md +163 -0
- package/cli/components/chat-app.tsx +759 -0
- package/cli/components/onboard-ui.tsx +325 -0
- package/cli/components/theme.ts +16 -0
- package/cli/configure.tsx +0 -0
- package/cli/lib/chat-transcript-types.ts +11 -0
- package/cli/lib/markdown-render-node.ts +523 -0
- package/cli/lib/onboard-markdown-syntax-style.ts +55 -0
- package/cli/lib/ui-messages-to-chat-transcript.ts +157 -0
- package/cli/lib/use-auto-copy-selection.ts +38 -0
- package/cli/onboard.tsx +248 -0
- package/cli/openpaw.tsx +144 -0
- package/cli/reset.ts +12 -0
- package/cli/tui.tsx +31 -0
- package/config/index.ts +3 -0
- package/config/paths.ts +71 -0
- package/config/personality-copy.ts +68 -0
- package/config/storage.ts +80 -0
- package/config/types.ts +37 -0
- package/gateway/bootstrap.ts +25 -0
- package/gateway/channel-adapter.ts +8 -0
- package/gateway/daemon-manager.ts +191 -0
- package/gateway/index.ts +18 -0
- package/gateway/session-key.ts +13 -0
- package/gateway/slash-command-tokens.ts +39 -0
- package/gateway/start-messaging.ts +40 -0
- package/gateway/telegram/active-thread-store.ts +89 -0
- package/gateway/telegram/adapter.ts +290 -0
- package/gateway/telegram/assistant-markdown.ts +48 -0
- package/gateway/telegram/bot-commands.ts +40 -0
- package/gateway/telegram/chat-preferences.ts +100 -0
- package/gateway/telegram/constants.ts +5 -0
- package/gateway/telegram/index.ts +4 -0
- package/gateway/telegram/message-html.ts +138 -0
- package/gateway/telegram/message-queue.ts +19 -0
- package/gateway/telegram/reserved-command-filter.ts +33 -0
- package/gateway/telegram/session-file-discovery.ts +62 -0
- package/gateway/telegram/session-key.ts +13 -0
- package/gateway/telegram/session-label.ts +14 -0
- package/gateway/telegram/sessions-list-reply.ts +39 -0
- package/gateway/telegram/stream-delivery.ts +618 -0
- package/gateway/tui/constants.ts +2 -0
- package/gateway/tui/tui-active-thread-store.ts +103 -0
- package/gateway/tui/tui-session-discovery.ts +94 -0
- package/gateway/tui/tui-session-label.ts +22 -0
- package/gateway/tui/tui-sessions-list-message.ts +37 -0
- package/package.json +52 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import type { Context } from "grammy";
|
|
2
|
+
import { GrammyError } from "grammy";
|
|
3
|
+
import type { ToolStreamEvent } from "../../agent/types";
|
|
4
|
+
import type { TelegramChatPreferences } from "./chat-preferences";
|
|
5
|
+
import {
|
|
6
|
+
formatAssistantMarkdownForTelegram,
|
|
7
|
+
type AssistantTelegramPayload,
|
|
8
|
+
} from "./assistant-markdown";
|
|
9
|
+
import {
|
|
10
|
+
formatReasoningPhaseHtml,
|
|
11
|
+
formatStandaloneToolResultHtml,
|
|
12
|
+
formatToolCallCompleteHtml,
|
|
13
|
+
formatToolInputOnlyHtml,
|
|
14
|
+
} from "./message-html";
|
|
15
|
+
|
|
16
|
+
const EDIT_INTERVAL_MS = 550;
|
|
17
|
+
const CURSOR = " ▉";
|
|
18
|
+
const CHUNK_SAFE = 3800;
|
|
19
|
+
const MAX_API_ATTEMPTS = 12;
|
|
20
|
+
|
|
21
|
+
/** Counters and timing for one assistant turn delivered to Telegram. */
|
|
22
|
+
export type TelegramDeliveryMetrics = {
|
|
23
|
+
editFailures: number;
|
|
24
|
+
retryAfter429: number;
|
|
25
|
+
fallbackReplies: number;
|
|
26
|
+
startedAt: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type Queued =
|
|
30
|
+
| { t: "d"; phase: "text" | "reasoning"; v: string }
|
|
31
|
+
| { t: "tool"; ev: ToolStreamEvent };
|
|
32
|
+
|
|
33
|
+
export type TelegramStreamHandlers = {
|
|
34
|
+
onTextDelta: (delta: string) => void;
|
|
35
|
+
onReasoningDelta: (delta: string) => void;
|
|
36
|
+
onToolStatus?: (event: ToolStreamEvent) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function isNotModifiedError(err: unknown): boolean {
|
|
40
|
+
return (
|
|
41
|
+
err instanceof GrammyError &&
|
|
42
|
+
typeof err.description === "string" &&
|
|
43
|
+
err.description.toLowerCase().includes("message is not modified")
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Telegram 400 errors when MarkdownV2/HTML entities are invalid. */
|
|
48
|
+
function isTelegramEntityParseError(err: unknown): boolean {
|
|
49
|
+
if (!(err instanceof GrammyError) || err.error_code !== 400) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const d = typeof err.description === "string" ? err.description.toLowerCase() : "";
|
|
53
|
+
return (
|
|
54
|
+
(d.includes("parse") && d.includes("entit")) ||
|
|
55
|
+
d.includes("can't parse entities") ||
|
|
56
|
+
d.includes("cannot parse entities") ||
|
|
57
|
+
d.includes("find end")
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Logs when Telegram rejects `parse_mode: MarkdownV2` (entity parse errors). */
|
|
62
|
+
function logMarkdownV2ApiParseFailed(where: string, err: unknown): void {
|
|
63
|
+
const detail =
|
|
64
|
+
err instanceof GrammyError && typeof err.description === "string"
|
|
65
|
+
? err.description
|
|
66
|
+
: err;
|
|
67
|
+
console.warn(`OpenPaw Telegram: MarkdownV2 parse failed (${where}), falling back to plain`, detail);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sleep(ms: number): Promise<void> {
|
|
71
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Optional plain-text recovery for MarkdownV2 entity errors on the assistant text phase only. */
|
|
75
|
+
type SendOrEditOptions = {
|
|
76
|
+
/** Pre-conversion assistant text; used when Telegram rejects MarkdownV2 entities. */
|
|
77
|
+
plainFallbackBody?: string;
|
|
78
|
+
/** Called when switching to plain after an entity parse error (rest of phase should skip V2). */
|
|
79
|
+
onEntityParseUsePlainForRestOfPhase?: () => void;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sends or edits a message with 429 retries, treats "not modified" as OK, and falls back to reply after edit failures.
|
|
84
|
+
* MarkdownV2 entity errors retry immediately as plain text (no parse_mode) using plainFallbackBody when provided.
|
|
85
|
+
*/
|
|
86
|
+
async function sendOrEditRobust(
|
|
87
|
+
ctx: Context,
|
|
88
|
+
chatId: number,
|
|
89
|
+
text: string,
|
|
90
|
+
messageId: number | undefined,
|
|
91
|
+
metrics: TelegramDeliveryMetrics,
|
|
92
|
+
parseMode?: "HTML" | "MarkdownV2",
|
|
93
|
+
options?: SendOrEditOptions,
|
|
94
|
+
): Promise<number | undefined> {
|
|
95
|
+
if (!text) {
|
|
96
|
+
return messageId;
|
|
97
|
+
}
|
|
98
|
+
const body = text.slice(0, 4096);
|
|
99
|
+
const plainFallbackBody = options?.plainFallbackBody;
|
|
100
|
+
const onEntityParseUsePlainForRestOfPhase = options?.onEntityParseUsePlainForRestOfPhase;
|
|
101
|
+
|
|
102
|
+
let activeBody = body;
|
|
103
|
+
let activeExtra = parseMode ? { parse_mode: parseMode } : {};
|
|
104
|
+
let switchedToPlainAfterEntityError = false;
|
|
105
|
+
|
|
106
|
+
for (let attempt = 0; attempt < MAX_API_ATTEMPTS; attempt++) {
|
|
107
|
+
try {
|
|
108
|
+
if (messageId !== undefined) {
|
|
109
|
+
await ctx.api.editMessageText(chatId, messageId, activeBody, activeExtra);
|
|
110
|
+
return messageId;
|
|
111
|
+
}
|
|
112
|
+
const sent = await ctx.reply(activeBody, activeExtra);
|
|
113
|
+
return sent.message_id;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (isNotModifiedError(e)) {
|
|
116
|
+
return messageId;
|
|
117
|
+
}
|
|
118
|
+
if (e instanceof GrammyError && e.error_code === 429) {
|
|
119
|
+
metrics.retryAfter429++;
|
|
120
|
+
const sec =
|
|
121
|
+
typeof e.parameters?.retry_after === "number" ? e.parameters.retry_after : 1;
|
|
122
|
+
await sleep(sec * 1000);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
isTelegramEntityParseError(e) &&
|
|
128
|
+
parseMode === "MarkdownV2" &&
|
|
129
|
+
plainFallbackBody !== undefined &&
|
|
130
|
+
!switchedToPlainAfterEntityError
|
|
131
|
+
) {
|
|
132
|
+
const plain = plainFallbackBody.slice(0, 4096);
|
|
133
|
+
if (plain.length > 0) {
|
|
134
|
+
logMarkdownV2ApiParseFailed(
|
|
135
|
+
messageId !== undefined ? "editMessageText" : "reply",
|
|
136
|
+
e,
|
|
137
|
+
);
|
|
138
|
+
activeBody = plain;
|
|
139
|
+
activeExtra = {};
|
|
140
|
+
switchedToPlainAfterEntityError = true;
|
|
141
|
+
onEntityParseUsePlainForRestOfPhase?.();
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.warn(
|
|
147
|
+
"OpenPaw Telegram: send/edit failed",
|
|
148
|
+
e instanceof GrammyError
|
|
149
|
+
? { code: e.error_code, description: e.description }
|
|
150
|
+
: e,
|
|
151
|
+
);
|
|
152
|
+
metrics.editFailures++;
|
|
153
|
+
|
|
154
|
+
if (messageId !== undefined) {
|
|
155
|
+
try {
|
|
156
|
+
metrics.fallbackReplies++;
|
|
157
|
+
const sent = await ctx.reply(activeBody, activeExtra);
|
|
158
|
+
return sent.message_id;
|
|
159
|
+
} catch (fallbackErr) {
|
|
160
|
+
if (
|
|
161
|
+
isTelegramEntityParseError(fallbackErr) &&
|
|
162
|
+
parseMode === "MarkdownV2" &&
|
|
163
|
+
plainFallbackBody !== undefined
|
|
164
|
+
) {
|
|
165
|
+
const plain = plainFallbackBody.slice(0, 4096);
|
|
166
|
+
if (plain.length > 0) {
|
|
167
|
+
try {
|
|
168
|
+
logMarkdownV2ApiParseFailed("fallback reply", fallbackErr);
|
|
169
|
+
const sent = await ctx.reply(plain, {});
|
|
170
|
+
onEntityParseUsePlainForRestOfPhase?.();
|
|
171
|
+
return sent.message_id;
|
|
172
|
+
} catch (plainReplyErr) {
|
|
173
|
+
console.warn("OpenPaw Telegram: fallback plain reply failed", plainReplyErr);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
console.warn("OpenPaw Telegram: fallback reply failed", fallbackErr);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
await sleep(Math.min(500 * (attempt + 1), 3000));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return messageId;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Sends a standalone chat message (tool lines, errors) with the same retry policy.
|
|
189
|
+
*/
|
|
190
|
+
async function replyRobust(
|
|
191
|
+
ctx: Context,
|
|
192
|
+
text: string,
|
|
193
|
+
metrics: TelegramDeliveryMetrics,
|
|
194
|
+
parseMode?: "HTML" | "MarkdownV2",
|
|
195
|
+
options?: Pick<SendOrEditOptions, "plainFallbackBody" | "onEntityParseUsePlainForRestOfPhase">,
|
|
196
|
+
): Promise<number | undefined> {
|
|
197
|
+
const body = text.slice(0, 4096);
|
|
198
|
+
if (!body) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
const extra = parseMode ? { parse_mode: parseMode } : {};
|
|
202
|
+
const plainFallbackBody = options?.plainFallbackBody;
|
|
203
|
+
const onEntityParseUsePlainForRestOfPhase = options?.onEntityParseUsePlainForRestOfPhase;
|
|
204
|
+
|
|
205
|
+
let activeBody = body;
|
|
206
|
+
let activeExtra = extra;
|
|
207
|
+
let switchedToPlainAfterEntityError = false;
|
|
208
|
+
|
|
209
|
+
for (let attempt = 0; attempt < MAX_API_ATTEMPTS; attempt++) {
|
|
210
|
+
try {
|
|
211
|
+
const sent = await ctx.reply(activeBody, activeExtra);
|
|
212
|
+
return sent.message_id;
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (e instanceof GrammyError && e.error_code === 429) {
|
|
215
|
+
metrics.retryAfter429++;
|
|
216
|
+
const sec =
|
|
217
|
+
typeof e.parameters?.retry_after === "number" ? e.parameters.retry_after : 1;
|
|
218
|
+
await sleep(sec * 1000);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
isTelegramEntityParseError(e) &&
|
|
224
|
+
parseMode === "MarkdownV2" &&
|
|
225
|
+
plainFallbackBody !== undefined &&
|
|
226
|
+
!switchedToPlainAfterEntityError
|
|
227
|
+
) {
|
|
228
|
+
const plain = plainFallbackBody.slice(0, 4096);
|
|
229
|
+
if (plain.length > 0) {
|
|
230
|
+
logMarkdownV2ApiParseFailed("replyRobust", e);
|
|
231
|
+
activeBody = plain;
|
|
232
|
+
activeExtra = {};
|
|
233
|
+
switchedToPlainAfterEntityError = true;
|
|
234
|
+
onEntityParseUsePlainForRestOfPhase?.();
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
metrics.editFailures++;
|
|
240
|
+
console.warn(
|
|
241
|
+
"OpenPaw Telegram: reply failed",
|
|
242
|
+
e instanceof GrammyError
|
|
243
|
+
? { code: e.error_code, description: e.description }
|
|
244
|
+
: e,
|
|
245
|
+
);
|
|
246
|
+
await sleep(Math.min(500 * (attempt + 1), 3000));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
type ActivePhase = {
|
|
253
|
+
kind: "text" | "reasoning";
|
|
254
|
+
/** Plain text: user-visible answer, or raw reasoning (HTML applied when sending). */
|
|
255
|
+
accumulated: string;
|
|
256
|
+
messageId: number | undefined;
|
|
257
|
+
lastEdit: number;
|
|
258
|
+
/** After Telegram rejects MarkdownV2, skip conversion for remaining edits in this text phase. */
|
|
259
|
+
forcePlainForRestOfPhase: boolean;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** Ref so TypeScript keeps correct typing across `await` inside the consumer loop. */
|
|
263
|
+
type PhaseRef = { current: ActivePhase | null };
|
|
264
|
+
|
|
265
|
+
function phaseHasDisplayableContent(p: ActivePhase): boolean {
|
|
266
|
+
return p.accumulated.trim().length > 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Builds payload for the main assistant text phase: MarkdownV2 when allowed, else plain.
|
|
271
|
+
*/
|
|
272
|
+
function textPhaseTelegramPayload(
|
|
273
|
+
plainMarkdownSource: string,
|
|
274
|
+
forcePlain: boolean,
|
|
275
|
+
): AssistantTelegramPayload {
|
|
276
|
+
if (forcePlain) {
|
|
277
|
+
return { body: plainMarkdownSource.slice(0, 4096), parseMode: undefined };
|
|
278
|
+
}
|
|
279
|
+
return formatAssistantMarkdownForTelegram(plainMarkdownSource);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Message body and optional `MarkdownV2` for assistant text (live streaming edits and finalize).
|
|
284
|
+
* Omits `parse_mode` when the phase is forced plain or the converter fell back to raw text.
|
|
285
|
+
*/
|
|
286
|
+
function textPhaseSendArgs(
|
|
287
|
+
plainMarkdownSource: string,
|
|
288
|
+
forcePlain: boolean,
|
|
289
|
+
): { body: string; parseMode?: "MarkdownV2" } {
|
|
290
|
+
const p = textPhaseTelegramPayload(plainMarkdownSource, forcePlain);
|
|
291
|
+
return p.parseMode === "MarkdownV2"
|
|
292
|
+
? { body: p.body, parseMode: "MarkdownV2" }
|
|
293
|
+
: { body: p.body };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Runs a task that streams assistant output to Telegram: separate messages per reasoning vs text phase,
|
|
298
|
+
* optional tool lines, debounced in-phase edits, and a trailing cursor until the turn ends.
|
|
299
|
+
*/
|
|
300
|
+
export async function deliverStreamingReply(
|
|
301
|
+
ctx: Context,
|
|
302
|
+
prefs: TelegramChatPreferences,
|
|
303
|
+
runWithHandlers: (handlers: TelegramStreamHandlers) => Promise<void>,
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
const chatId = ctx.chat?.id;
|
|
306
|
+
if (chatId === undefined) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const metrics: TelegramDeliveryMetrics = {
|
|
311
|
+
editFailures: 0,
|
|
312
|
+
retryAfter429: 0,
|
|
313
|
+
fallbackReplies: 0,
|
|
314
|
+
startedAt: Date.now(),
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const queue: Queued[] = [];
|
|
318
|
+
let producerDone = false;
|
|
319
|
+
let producerError: unknown;
|
|
320
|
+
|
|
321
|
+
const handlers: TelegramStreamHandlers = {
|
|
322
|
+
onTextDelta: (delta) => {
|
|
323
|
+
if (delta) {
|
|
324
|
+
queue.push({ t: "d", phase: "text", v: delta });
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
onReasoningDelta: (delta) => {
|
|
328
|
+
if (!delta || !prefs.showReasoning) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
queue.push({ t: "d", phase: "reasoning", v: delta });
|
|
332
|
+
},
|
|
333
|
+
onToolStatus: prefs.showToolCalls
|
|
334
|
+
? (ev) => {
|
|
335
|
+
queue.push({ t: "tool", ev });
|
|
336
|
+
}
|
|
337
|
+
: undefined,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const producer = (async () => {
|
|
341
|
+
try {
|
|
342
|
+
await runWithHandlers(handlers);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
producerError = e;
|
|
345
|
+
} finally {
|
|
346
|
+
producerDone = true;
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
|
|
350
|
+
const active: PhaseRef = { current: null };
|
|
351
|
+
let sentAny = false;
|
|
352
|
+
/** toolCallId → message to edit when output/error/denied arrives */
|
|
353
|
+
const pendingToolBubble = new Map<
|
|
354
|
+
string,
|
|
355
|
+
{ messageId: number; toolName: string; input: unknown }
|
|
356
|
+
>();
|
|
357
|
+
|
|
358
|
+
const finalizePhase = async (): Promise<void> => {
|
|
359
|
+
const cur = active.current;
|
|
360
|
+
if (!cur || !phaseHasDisplayableContent(cur)) {
|
|
361
|
+
active.current = null;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const html = cur.kind === "reasoning";
|
|
365
|
+
let acc = cur.accumulated;
|
|
366
|
+
let mid = cur.messageId;
|
|
367
|
+
const textOptions = (plainSlice: string): SendOrEditOptions | undefined =>
|
|
368
|
+
html
|
|
369
|
+
? undefined
|
|
370
|
+
: {
|
|
371
|
+
plainFallbackBody: plainSlice.slice(0, 4096),
|
|
372
|
+
onEntityParseUsePlainForRestOfPhase: () => {
|
|
373
|
+
cur.forcePlainForRestOfPhase = true;
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
while (acc.length > CHUNK_SAFE && mid !== undefined) {
|
|
378
|
+
const cut = acc.lastIndexOf("\n", CHUNK_SAFE);
|
|
379
|
+
const splitAt = cut > CHUNK_SAFE / 2 ? cut : CHUNK_SAFE;
|
|
380
|
+
const plainChunk = acc.slice(0, splitAt);
|
|
381
|
+
if (html) {
|
|
382
|
+
mid = await sendOrEditRobust(
|
|
383
|
+
ctx,
|
|
384
|
+
chatId,
|
|
385
|
+
formatReasoningPhaseHtml(plainChunk, false),
|
|
386
|
+
mid,
|
|
387
|
+
metrics,
|
|
388
|
+
"HTML",
|
|
389
|
+
);
|
|
390
|
+
} else {
|
|
391
|
+
const t = textPhaseSendArgs(plainChunk, cur.forcePlainForRestOfPhase);
|
|
392
|
+
mid = await sendOrEditRobust(
|
|
393
|
+
ctx,
|
|
394
|
+
chatId,
|
|
395
|
+
t.body,
|
|
396
|
+
mid,
|
|
397
|
+
metrics,
|
|
398
|
+
t.parseMode,
|
|
399
|
+
textOptions(plainChunk),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
acc = acc.slice(splitAt).trimStart();
|
|
403
|
+
}
|
|
404
|
+
if (acc.trim().length > 0) {
|
|
405
|
+
if (html) {
|
|
406
|
+
await sendOrEditRobust(
|
|
407
|
+
ctx,
|
|
408
|
+
chatId,
|
|
409
|
+
formatReasoningPhaseHtml(acc, false),
|
|
410
|
+
mid,
|
|
411
|
+
metrics,
|
|
412
|
+
"HTML",
|
|
413
|
+
);
|
|
414
|
+
} else {
|
|
415
|
+
const t = textPhaseSendArgs(acc, cur.forcePlainForRestOfPhase);
|
|
416
|
+
await sendOrEditRobust(
|
|
417
|
+
ctx,
|
|
418
|
+
chatId,
|
|
419
|
+
t.body,
|
|
420
|
+
mid,
|
|
421
|
+
metrics,
|
|
422
|
+
t.parseMode,
|
|
423
|
+
textOptions(acc),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
sentAny = true;
|
|
427
|
+
}
|
|
428
|
+
active.current = null;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const startPhase = (kind: "text" | "reasoning"): void => {
|
|
432
|
+
active.current = {
|
|
433
|
+
kind,
|
|
434
|
+
accumulated: "",
|
|
435
|
+
messageId: undefined,
|
|
436
|
+
lastEdit: 0,
|
|
437
|
+
forcePlainForRestOfPhase: false,
|
|
438
|
+
};
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const ensurePhase = async (kind: "text" | "reasoning"): Promise<ActivePhase> => {
|
|
442
|
+
if (active.current?.kind === kind) {
|
|
443
|
+
return active.current;
|
|
444
|
+
}
|
|
445
|
+
await finalizePhase();
|
|
446
|
+
startPhase(kind);
|
|
447
|
+
return active.current!;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const flushLiveEdit = async (showCursor: boolean): Promise<void> => {
|
|
451
|
+
const phase = active.current;
|
|
452
|
+
if (phase === null || !phaseHasDisplayableContent(phase)) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const html = phase.kind === "reasoning";
|
|
456
|
+
let acc = phase.accumulated;
|
|
457
|
+
let mid = phase.messageId;
|
|
458
|
+
const textOptions = (plainSlice: string): SendOrEditOptions | undefined =>
|
|
459
|
+
html
|
|
460
|
+
? undefined
|
|
461
|
+
: {
|
|
462
|
+
plainFallbackBody: plainSlice.slice(0, 4096),
|
|
463
|
+
onEntityParseUsePlainForRestOfPhase: () => {
|
|
464
|
+
phase.forcePlainForRestOfPhase = true;
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
while (acc.length > CHUNK_SAFE && mid !== undefined) {
|
|
469
|
+
const cut = acc.lastIndexOf("\n", CHUNK_SAFE);
|
|
470
|
+
const splitAt = cut > CHUNK_SAFE / 2 ? cut : CHUNK_SAFE;
|
|
471
|
+
const plainChunk = acc.slice(0, splitAt);
|
|
472
|
+
if (html) {
|
|
473
|
+
mid = await sendOrEditRobust(
|
|
474
|
+
ctx,
|
|
475
|
+
chatId,
|
|
476
|
+
formatReasoningPhaseHtml(plainChunk, false),
|
|
477
|
+
mid,
|
|
478
|
+
metrics,
|
|
479
|
+
"HTML",
|
|
480
|
+
);
|
|
481
|
+
} else {
|
|
482
|
+
const t = textPhaseSendArgs(plainChunk, phase.forcePlainForRestOfPhase);
|
|
483
|
+
mid = await sendOrEditRobust(
|
|
484
|
+
ctx,
|
|
485
|
+
chatId,
|
|
486
|
+
t.body,
|
|
487
|
+
mid,
|
|
488
|
+
metrics,
|
|
489
|
+
t.parseMode,
|
|
490
|
+
textOptions(plainChunk),
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
acc = acc.slice(splitAt).trimStart();
|
|
494
|
+
}
|
|
495
|
+
const tailPlain = showCursor && !html ? `${acc}${CURSOR}` : acc;
|
|
496
|
+
let newId: number | undefined;
|
|
497
|
+
if (html) {
|
|
498
|
+
newId = await sendOrEditRobust(
|
|
499
|
+
ctx,
|
|
500
|
+
chatId,
|
|
501
|
+
formatReasoningPhaseHtml(acc, showCursor),
|
|
502
|
+
mid,
|
|
503
|
+
metrics,
|
|
504
|
+
"HTML",
|
|
505
|
+
);
|
|
506
|
+
} else {
|
|
507
|
+
const t = textPhaseSendArgs(tailPlain, phase.forcePlainForRestOfPhase);
|
|
508
|
+
newId = await sendOrEditRobust(
|
|
509
|
+
ctx,
|
|
510
|
+
chatId,
|
|
511
|
+
t.body,
|
|
512
|
+
mid,
|
|
513
|
+
metrics,
|
|
514
|
+
t.parseMode,
|
|
515
|
+
textOptions(tailPlain),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
phase.messageId = newId;
|
|
519
|
+
phase.accumulated = acc;
|
|
520
|
+
phase.lastEdit = Date.now();
|
|
521
|
+
sentAny = true;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const consumer = (async () => {
|
|
525
|
+
while (!producerDone || queue.length > 0) {
|
|
526
|
+
while (queue.length > 0) {
|
|
527
|
+
const item = queue.shift()!;
|
|
528
|
+
if (item.t === "tool") {
|
|
529
|
+
await finalizePhase();
|
|
530
|
+
const ev = item.ev;
|
|
531
|
+
if (ev.type === "tool_input") {
|
|
532
|
+
const toolHtml = formatToolInputOnlyHtml(ev.toolName, ev.input);
|
|
533
|
+
const mid = await replyRobust(ctx, toolHtml, metrics, "HTML");
|
|
534
|
+
if (mid !== undefined) {
|
|
535
|
+
pendingToolBubble.set(ev.toolCallId, {
|
|
536
|
+
messageId: mid,
|
|
537
|
+
toolName: ev.toolName,
|
|
538
|
+
input: ev.input,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
sentAny = true;
|
|
542
|
+
} else {
|
|
543
|
+
const pending = pendingToolBubble.get(ev.toolCallId);
|
|
544
|
+
if (pending) {
|
|
545
|
+
const combined = formatToolCallCompleteHtml(
|
|
546
|
+
pending.toolName,
|
|
547
|
+
pending.input,
|
|
548
|
+
ev,
|
|
549
|
+
);
|
|
550
|
+
await sendOrEditRobust(
|
|
551
|
+
ctx,
|
|
552
|
+
chatId,
|
|
553
|
+
combined,
|
|
554
|
+
pending.messageId,
|
|
555
|
+
metrics,
|
|
556
|
+
"HTML",
|
|
557
|
+
);
|
|
558
|
+
pendingToolBubble.delete(ev.toolCallId);
|
|
559
|
+
} else {
|
|
560
|
+
const orphan = formatStandaloneToolResultHtml(ev);
|
|
561
|
+
if (orphan) {
|
|
562
|
+
await replyRobust(ctx, orphan, metrics, "HTML");
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
sentAny = true;
|
|
566
|
+
}
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const cur = await ensurePhase(item.phase);
|
|
571
|
+
cur.accumulated += item.v;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const curActive = active.current;
|
|
575
|
+
if (curActive !== null && phaseHasDisplayableContent(curActive)) {
|
|
576
|
+
const now = Date.now();
|
|
577
|
+
const shouldEdit =
|
|
578
|
+
producerDone ||
|
|
579
|
+
now - curActive.lastEdit >= EDIT_INTERVAL_MS ||
|
|
580
|
+
curActive.accumulated.length >= 40;
|
|
581
|
+
if (shouldEdit) {
|
|
582
|
+
await flushLiveEdit(!producerDone);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (!producerDone || queue.length > 0) {
|
|
587
|
+
await sleep(50);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
await finalizePhase();
|
|
592
|
+
|
|
593
|
+
if (!sentAny && producerDone) {
|
|
594
|
+
void (await replyRobust(
|
|
595
|
+
ctx,
|
|
596
|
+
"(No assistant text this turn — tools or empty reply.)",
|
|
597
|
+
metrics,
|
|
598
|
+
));
|
|
599
|
+
}
|
|
600
|
+
})();
|
|
601
|
+
|
|
602
|
+
await Promise.all([producer, consumer]);
|
|
603
|
+
|
|
604
|
+
const elapsedMs = Date.now() - metrics.startedAt;
|
|
605
|
+
console.log(
|
|
606
|
+
JSON.stringify({
|
|
607
|
+
openpaw: "telegram_turn",
|
|
608
|
+
editFailures: metrics.editFailures,
|
|
609
|
+
retryAfter429: metrics.retryAfter429,
|
|
610
|
+
fallbackReplies: metrics.fallbackReplies,
|
|
611
|
+
elapsedMs,
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
if (producerError) {
|
|
616
|
+
throw producerError;
|
|
617
|
+
}
|
|
618
|
+
}
|