@offerpilot/axiomruntime 0.0.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 (99) hide show
  1. package/README.md +185 -0
  2. package/dist/cli/commands/add.js +29 -0
  3. package/dist/cli/commands/context.js +29 -0
  4. package/dist/cli/commands/doctor.js +62 -0
  5. package/dist/cli/commands/edit.js +85 -0
  6. package/dist/cli/commands/help.js +16 -0
  7. package/dist/cli/commands/list.js +25 -0
  8. package/dist/cli/commands/log.js +63 -0
  9. package/dist/cli/commands/memory.js +241 -0
  10. package/dist/cli/commands/report.js +35 -0
  11. package/dist/cli/commands/session.js +74 -0
  12. package/dist/cli/commands/setup.js +86 -0
  13. package/dist/cli/commands/status.js +50 -0
  14. package/dist/cli/commands/telegram.js +701 -0
  15. package/dist/cli/commands/use.js +108 -0
  16. package/dist/cli/commands/version.js +12 -0
  17. package/dist/cli/index.js +276 -0
  18. package/dist/cli/output/table.js +22 -0
  19. package/dist/cli/prompts/prompt.js +37 -0
  20. package/dist/cli/registry.js +16 -0
  21. package/dist/core/config/cache-store.js +193 -0
  22. package/dist/core/config/json-store.js +114 -0
  23. package/dist/core/config/paths.js +85 -0
  24. package/dist/core/config/providers-store.js +89 -0
  25. package/dist/core/config/schema.js +60 -0
  26. package/dist/core/config/session-store.js +30 -0
  27. package/dist/core/config/usage-store.js +18 -0
  28. package/dist/core/context/context-service.js +186 -0
  29. package/dist/core/integrations/integration-state.js +105 -0
  30. package/dist/core/logs/log-service.js +56 -0
  31. package/dist/core/memory/embedding-check.js +121 -0
  32. package/dist/core/memory/memory-config.js +122 -0
  33. package/dist/core/models/model-discovery.js +430 -0
  34. package/dist/core/models/model-filter.js +13 -0
  35. package/dist/core/providers/provider-service.js +212 -0
  36. package/dist/core/reports/report-service.js +166 -0
  37. package/dist/core/runner/command-resolver.js +60 -0
  38. package/dist/core/runner/engine-registry.js +93 -0
  39. package/dist/core/runner/fallback.js +114 -0
  40. package/dist/core/runner/openai-usage-http.js +82 -0
  41. package/dist/core/runner/openai-usage-proxy.js +1 -0
  42. package/dist/core/runner/openai-usage-recording.js +172 -0
  43. package/dist/core/runner/openai-usage-responses.js +469 -0
  44. package/dist/core/runner/openai-usage-server.js +319 -0
  45. package/dist/core/runner/openai-usage-types.js +1 -0
  46. package/dist/core/runner/tool-runner.js +138 -0
  47. package/dist/core/sessions/session-service.js +47 -0
  48. package/dist/core/status/doctor-service.js +391 -0
  49. package/dist/core/status/status-service.js +60 -0
  50. package/dist/core/types.js +1 -0
  51. package/dist/core/usage/pricing.js +113 -0
  52. package/dist/core/usage/usage-service.js +30 -0
  53. package/dist/core/utils/is-record.js +3 -0
  54. package/dist/server/index.js +28 -0
  55. package/dist/server/runtime-server.js +430 -0
  56. package/dist/telegram/bot-registry.js +80 -0
  57. package/dist/telegram/bot.js +128 -0
  58. package/dist/telegram/config.js +235 -0
  59. package/dist/telegram/engine/claude-engine.js +240 -0
  60. package/dist/telegram/engine/codex-engine.js +437 -0
  61. package/dist/telegram/engine/engine-utils.js +67 -0
  62. package/dist/telegram/engine/process-utils.js +132 -0
  63. package/dist/telegram/engine/registry.js +31 -0
  64. package/dist/telegram/engine/types.js +1 -0
  65. package/dist/telegram/handler-registry.js +28 -0
  66. package/dist/telegram/handlers/callback.js +311 -0
  67. package/dist/telegram/handlers/command.js +272 -0
  68. package/dist/telegram/handlers/document.js +108 -0
  69. package/dist/telegram/handlers/memory.js +305 -0
  70. package/dist/telegram/handlers/message.js +701 -0
  71. package/dist/telegram/handlers/provider.js +332 -0
  72. package/dist/telegram/handlers/setup.js +527 -0
  73. package/dist/telegram/handlers/usage.js +124 -0
  74. package/dist/telegram/index.js +93 -0
  75. package/dist/telegram/interaction/approval.js +108 -0
  76. package/dist/telegram/interaction/command-menu.js +253 -0
  77. package/dist/telegram/interaction/formatter.js +487 -0
  78. package/dist/telegram/interaction/keyboards.js +145 -0
  79. package/dist/telegram/interaction/progress-reporter.js +160 -0
  80. package/dist/telegram/interaction/prompt-middleware.js +168 -0
  81. package/dist/telegram/interaction/result-store.js +41 -0
  82. package/dist/telegram/interaction/token-budget.js +21 -0
  83. package/dist/telegram/interaction/tool-name.js +41 -0
  84. package/dist/telegram/lifecycle-registry.js +47 -0
  85. package/dist/telegram/log.js +46 -0
  86. package/dist/telegram/memory/memory-inject.js +52 -0
  87. package/dist/telegram/memory/memory-service.js +413 -0
  88. package/dist/telegram/memory/memory-store.js +216 -0
  89. package/dist/telegram/memory/types.js +1 -0
  90. package/dist/telegram/network-retry.js +22 -0
  91. package/dist/telegram/network.js +53 -0
  92. package/dist/telegram/session/manager.js +229 -0
  93. package/dist/telegram/session/store.js +363 -0
  94. package/dist/telegram/session/types.js +1 -0
  95. package/dist/telegram/supervisor.js +57 -0
  96. package/dist/telegram/templates/messages.js +1 -0
  97. package/docs/README.md +98 -0
  98. package/docs/USAGE.html +853 -0
  99. package/package.json +57 -0
@@ -0,0 +1,701 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { InputFile } from "grammy";
4
+ import { exec } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { getTelegramEngine } from "../engine/registry.js";
7
+ import { recordProviderRuntimeFailure, recordProviderRuntimeSuccess } from "../../core/config/cache-store.js";
8
+ import { resolveProviderCandidatesForTool } from "../../core/runner/fallback.js";
9
+ import { parseTelegramCommand, resolveMappedCommand, safeSetChatCommandMenu } from "../interaction/command-menu.js";
10
+ import { Formatter, truncateText, splitPlainText, escapeHtml } from "../interaction/formatter.js";
11
+ import { applyPromptMiddlewares } from "../interaction/prompt-middleware.js";
12
+ import { ProgressReporter } from "../interaction/progress-reporter.js";
13
+ import { ResultStore } from "../interaction/result-store.js";
14
+ import { buildResultKeyboard } from "../interaction/keyboards.js";
15
+ import { emitTelegramTaskCompleted, emitTelegramTaskFailed } from "../lifecycle-registry.js";
16
+ import { writeTelegramLog } from "../log.js";
17
+ import { withTelegramNetworkRetry } from "../network-retry.js";
18
+ import { handleMemoryManageInput, handleRememberInput } from "./memory.js";
19
+ import { completeAgentProfile, remindSetup, setWorkdir, showReadyCard } from "./setup.js";
20
+ import { handleUsagePriceInput } from "./usage.js";
21
+ import { handleProviderAdminInput } from "./provider.js";
22
+ export const DEFAULT_PROMPT_TIMEOUT_MS = 15 * 60 * 1000;
23
+ export const CLI_INIT_TIMEOUT_MS = 3 * 60 * 1000;
24
+ const STALE_SESSION_THRESHOLD_MS = 20 * 60 * 1000;
25
+ export const resultStore = new ResultStore();
26
+ const MAX_PROMPT_HISTORY = 10;
27
+ const promptMessageMap = new Map();
28
+ function recordPromptMessage(chatId, entry) {
29
+ const list = promptMessageMap.get(chatId) ?? [];
30
+ const existing = list.findIndex((item) => item.messageId === entry.messageId);
31
+ if (existing >= 0)
32
+ list.splice(existing, 1);
33
+ list.push(entry);
34
+ if (list.length > MAX_PROMPT_HISTORY)
35
+ list.shift();
36
+ promptMessageMap.set(chatId, list);
37
+ }
38
+ function getLatestPromptMessage(chatId) {
39
+ const list = promptMessageMap.get(chatId);
40
+ return list?.length ? list[list.length - 1] : null;
41
+ }
42
+ export function registerMessageHandler(bot, deps) {
43
+ bot.on("edited_message:text", async (ctx) => {
44
+ const chatId = ctx.chat?.id;
45
+ if (!chatId)
46
+ return;
47
+ const editedId = ctx.editedMessage.message_id;
48
+ const previous = getLatestPromptMessage(chatId);
49
+ if (!previous || previous.messageId !== editedId)
50
+ return;
51
+ const newText = ctx.editedMessage.text?.trim();
52
+ if (!newText || newText.startsWith("/") || newText === previous.prompt)
53
+ return;
54
+ const session = deps.sessionManager.getOrCreate(chatId);
55
+ if (session.status === "running" || session.status === "waiting_approval") {
56
+ abortTelegramEngines(deps, chatId);
57
+ deps.approvalManager.resolveAll(chatId, false);
58
+ }
59
+ deps.sessionManager.prepareEditedPrompt(chatId, previous.prompt);
60
+ const replyToText = readReplyContext(ctx.editedMessage) ?? previous.replyToText;
61
+ recordPromptMessage(chatId, { messageId: editedId, prompt: newText, replyToText });
62
+ await executePrompt(ctx, deps, newText, undefined, { replyToText });
63
+ });
64
+ bot.on("message:text", async (ctx) => {
65
+ const text = ctx.message.text;
66
+ const chatId = ctx.chat?.id;
67
+ if (!chatId)
68
+ return;
69
+ const session = deps.sessionManager.getOrCreate(chatId);
70
+ if (await handleProviderAdminInput(ctx, text))
71
+ return;
72
+ if (text.startsWith("/")) {
73
+ void writeTelegramLog({ action: "slash_command_received", message: `cmd=${text.split(/\s/)[0]} setupStep=${session.setupStep} chatId=${chatId}` });
74
+ if (session.setupStep === "command_name") {
75
+ await handleCommandNameInput(ctx, deps, text, session);
76
+ return;
77
+ }
78
+ if (session.setupStep === "command_template") {
79
+ await handleCommandTemplateInput(ctx, deps, text, session, bot);
80
+ return;
81
+ }
82
+ await executeMappedSlashCommand(ctx, deps, text, session);
83
+ return;
84
+ }
85
+ if (await handleMemoryManageInput(ctx, deps, text, session)) {
86
+ return;
87
+ }
88
+ if (await handleRememberInput(ctx, deps, text, session)) {
89
+ return;
90
+ }
91
+ if (session.setupStep === "custom_workdir") {
92
+ await setWorkdir(ctx, deps, text, {
93
+ fromCustomInput: true,
94
+ afterSet: (next) => runCliInit(ctx, deps, next.engine)
95
+ });
96
+ return;
97
+ }
98
+ if (session.setupStep === "edit_custom_workdir") {
99
+ await setWorkdir(ctx, deps, text, {
100
+ fromCustomInput: true,
101
+ returnToReady: true,
102
+ afterSet: (next) => runCliInit(ctx, deps, next.engine)
103
+ });
104
+ return;
105
+ }
106
+ if (session.setupStep === "usage_price_input") {
107
+ await handleUsagePriceInput(ctx, deps, text);
108
+ return;
109
+ }
110
+ if (session.setupStep === "agent_profile") {
111
+ const next = await completeAgentProfile(ctx, deps, text, { showReady: false });
112
+ if (next) {
113
+ await showReadyCard(ctx, deps, next);
114
+ }
115
+ return;
116
+ }
117
+ if (session.setupStep === "command_name") {
118
+ await handleCommandNameInput(ctx, deps, text, session);
119
+ return;
120
+ }
121
+ if (session.setupStep === "command_template") {
122
+ await handleCommandTemplateInput(ctx, deps, text, session, bot);
123
+ return;
124
+ }
125
+ if (await remindSetup(ctx, deps, session))
126
+ return;
127
+ const replyToText = readReplyContext(ctx.message);
128
+ const messageId = ctx.message.message_id;
129
+ recordPromptMessage(chatId, { messageId, prompt: text, replyToText });
130
+ await executePrompt(ctx, deps, text, undefined, { replyToText });
131
+ });
132
+ }
133
+ function readReplyContext(message) {
134
+ const reply = message?.reply_to_message;
135
+ const text = reply?.text ?? reply?.caption;
136
+ return text?.trim() || undefined;
137
+ }
138
+ function abortTelegramEngines(deps, chatId) {
139
+ const engines = new Set();
140
+ if (deps.engines?.claude)
141
+ engines.add(deps.engines.claude);
142
+ if (deps.engines?.codex)
143
+ engines.add(deps.engines.codex);
144
+ if (deps.claudeEngine)
145
+ engines.add(deps.claudeEngine);
146
+ if (deps.codexEngine)
147
+ engines.add(deps.codexEngine);
148
+ for (const engine of engines)
149
+ engine.abort(chatId);
150
+ }
151
+ async function executeMappedSlashCommand(ctx, deps, text, session) {
152
+ const parsed = parseTelegramCommand(text);
153
+ if (!parsed)
154
+ return;
155
+ if (await remindSetup(ctx, deps, session))
156
+ return;
157
+ const customCommands = deps.sessionManager.listCustomCommands(session.chatId, session.cwd);
158
+ const mapped = resolveMappedCommand(session.engine, parsed.command, customCommands);
159
+ if (!mapped) {
160
+ await ctx.reply("未知命令,请通过 / 打开当前 CLI 可用命令。");
161
+ return;
162
+ }
163
+ if (mapped.prompt === "/__register_command") {
164
+ deps.sessionManager.update(session.id, { setupStep: "command_name" });
165
+ await ctx.reply(Formatter.commandNamePrompt(), {
166
+ reply_markup: { inline_keyboard: [[{ text: "关闭", callback_data: "command:cancel" }]] }
167
+ });
168
+ return;
169
+ }
170
+ if (mapped.prompt.startsWith("/__custom_")) {
171
+ await executeCustomCommand(ctx, deps, parsed.command, parsed.args, session);
172
+ return;
173
+ }
174
+ const prompt = [mapped.prompt, parsed.args].filter(Boolean).join(" ");
175
+ await executePrompt(ctx, deps, prompt, session.engine);
176
+ }
177
+ export async function runCliInit(ctx, deps, engine, options = {}) {
178
+ const chatId = ctx.chat?.id;
179
+ if (chatId) {
180
+ const session = deps.sessionManager.getOrCreate(chatId);
181
+ if (session.cwd && fs.existsSync(path.join(session.cwd, "CLAUDE.md"))) {
182
+ try {
183
+ await ctx.editMessageText("🤖 介绍一下我和你吧,可以帮你构建画像哦", { reply_markup: { inline_keyboard: [] } });
184
+ }
185
+ catch {
186
+ // Best-effort notification.
187
+ }
188
+ return;
189
+ }
190
+ }
191
+ try {
192
+ if (options.edit) {
193
+ await ctx.editMessageText("🤖 等我熟悉一下当前工作目录哦", { reply_markup: { inline_keyboard: [] } });
194
+ }
195
+ else {
196
+ await ctx.reply("🤖 等我熟悉一下当前工作目录哦");
197
+ }
198
+ }
199
+ catch {
200
+ // Best-effort notification.
201
+ }
202
+ await executePrompt(ctx, deps, "/init", engine, {
203
+ silentSuccess: true,
204
+ suppressFailureReply: true,
205
+ includeAgentContext: false,
206
+ skipSetupReminder: true,
207
+ timeoutMs: CLI_INIT_TIMEOUT_MS
208
+ });
209
+ }
210
+ export async function executePrompt(ctx, deps, prompt, forcedEngine, options = {}) {
211
+ const chatId = ctx.chat?.id;
212
+ if (!chatId)
213
+ return false;
214
+ const text = prompt.trim();
215
+ if (!text) {
216
+ await ctx.reply("Prompt is empty.");
217
+ return false;
218
+ }
219
+ return deps.sessionManager.runExclusive(chatId, () => executePromptLocked(ctx, deps, text, forcedEngine, options));
220
+ }
221
+ async function executePromptLocked(ctx, deps, text, forcedEngine, options = {}) {
222
+ const chatId = ctx.chat?.id;
223
+ if (!chatId)
224
+ return false;
225
+ let session = deps.sessionManager.getOrCreate(chatId);
226
+ if (!options.skipSetupReminder && await remindSetup(ctx, deps, session))
227
+ return false;
228
+ if (forcedEngine && session.engine !== forcedEngine) {
229
+ session = deps.sessionManager.update(session.id, {
230
+ engine: forcedEngine,
231
+ engineSessionId: null,
232
+ botContextFingerprint: null
233
+ });
234
+ }
235
+ if (session.status === "running" || session.status === "waiting_approval") {
236
+ const staleMs = Date.now() - new Date(session.lastActiveAt).getTime();
237
+ if (staleMs > STALE_SESSION_THRESHOLD_MS) {
238
+ session = deps.sessionManager.update(session.id, { status: "idle" });
239
+ }
240
+ else {
241
+ await ctx.reply("已有任务正在运行,请等待完成或使用 /stop 终止。");
242
+ return false;
243
+ }
244
+ }
245
+ session = deps.sessionManager.update(session.id, {
246
+ status: "running",
247
+ messageCount: session.messageCount + 1
248
+ });
249
+ const stopTypingStatus = startTypingStatus(ctx, chatId);
250
+ const reporter = new ProgressReporter(ctx.api, chatId, {
251
+ silent: options.silentSuccess ?? false,
252
+ throttleMs: options.progressThrottleMs
253
+ });
254
+ try {
255
+ const engine = resolveMessageEngine(deps, session.engine);
256
+ const attempts = await resolveTelegramProviderAttempts(session.engine, deps.config);
257
+ let result = null;
258
+ let successfulAttempt = null;
259
+ let lastError = null;
260
+ for (const [index, attempt] of attempts.entries()) {
261
+ const resume = attempt.preferred ? session.engineSessionId : null;
262
+ const promptWithContext = await applyPromptMiddlewares({
263
+ chatId,
264
+ prompt: text,
265
+ session,
266
+ engine: session.engine,
267
+ resume,
268
+ memoryService: deps.memoryService,
269
+ includeAgentContext: options.includeAgentContext !== false,
270
+ replyToText: options.replyToText
271
+ });
272
+ try {
273
+ result = await engine.execute({
274
+ chatId,
275
+ prompt: promptWithContext,
276
+ cwd: session.cwd,
277
+ resume,
278
+ permissionMode: session.permissionMode,
279
+ providerName: attempt.providerName,
280
+ modelName: attempt.modelName,
281
+ imagePaths: session.attachments
282
+ .filter((attachment) => attachment.kind === "image" || attachment.mimeType?.startsWith("image/"))
283
+ .map((attachment) => attachment.path),
284
+ timeoutMs: options.timeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS,
285
+ onProgress: (update) => reporter.update(update),
286
+ onPermission: async (req) => {
287
+ if (session.approveAll)
288
+ return true;
289
+ deps.sessionManager.update(session.id, { status: "waiting_approval" });
290
+ const approved = await deps.approvalManager.request(chatId, req);
291
+ deps.sessionManager.update(session.id, { status: "running" });
292
+ return approved;
293
+ }
294
+ });
295
+ successfulAttempt = attempt;
296
+ if (attempt.providerName) {
297
+ await recordProviderRuntimeSuccess(attempt.providerName);
298
+ }
299
+ break;
300
+ }
301
+ catch (error) {
302
+ lastError = error;
303
+ const next = attempts[index + 1];
304
+ if (attempt.providerName && !isNonFallbackTelegramError(error)) {
305
+ await recordProviderRuntimeFailure(attempt.providerName, error);
306
+ }
307
+ if (!next || isNonFallbackTelegramError(error)) {
308
+ throw error;
309
+ }
310
+ await writeTelegramFallbackLog({
311
+ chatId,
312
+ engine: session.engine,
313
+ from: attempt.providerLabel,
314
+ to: next.providerLabel,
315
+ model: attempt.modelLabel,
316
+ nextModel: next.modelLabel,
317
+ error
318
+ });
319
+ await reporter.update({
320
+ phase: "thinking",
321
+ message: `正在切换 provider:${attempt.providerLabel} → ${next.providerLabel}`
322
+ });
323
+ }
324
+ }
325
+ if (!result || !successfulAttempt) {
326
+ throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "Telegram task failed."));
327
+ }
328
+ const conversation = !options.silentSuccess && !text.trimStart().startsWith("/")
329
+ ? deps.sessionManager.appendConversationTurn(session.id, {
330
+ user: text,
331
+ assistant: result.response || "(empty)"
332
+ }).conversation
333
+ : session.conversation;
334
+ const completedSession = deps.sessionManager.update(session.id, {
335
+ engineSessionId: successfulAttempt.preferred ? result.sessionId ?? session.engineSessionId : null,
336
+ conversation,
337
+ status: "idle",
338
+ attachments: []
339
+ });
340
+ cleanupConsumedAttachments(session.attachments);
341
+ deps.sessionManager.recordProviderUsage(session.id, {
342
+ provider: result.provider ?? deps.config.provider ?? "unknown",
343
+ model: result.model ?? deps.config.model,
344
+ costUsd: result.cost,
345
+ usage: result.usage
346
+ });
347
+ if (!options.silentSuccess) {
348
+ await sendResult(ctx, session.engine, result, deps.config.maxOutputLength, reporter);
349
+ }
350
+ await writeTelegramLog({
351
+ action: "task_completed",
352
+ message: `Completed ${session.engine} task for Telegram chat ${chatId}`,
353
+ metadata: {
354
+ chatId,
355
+ engine: session.engine,
356
+ provider: result.provider,
357
+ model: result.model,
358
+ sessionId: result.sessionId,
359
+ duration: result.duration,
360
+ fallbackFrom: successfulAttempt.fallbackFrom ?? null,
361
+ preferredProvider: successfulAttempt.preferred
362
+ }
363
+ });
364
+ await emitTelegramTaskCompleted({
365
+ chatId,
366
+ engine: session.engine,
367
+ session: completedSession,
368
+ result,
369
+ fallbackFrom: successfulAttempt.fallbackFrom ?? null,
370
+ preferredProvider: successfulAttempt.preferred
371
+ });
372
+ return true;
373
+ }
374
+ catch (error) {
375
+ const failedSession = deps.sessionManager.update(session.id, { status: "idle" });
376
+ const message = error instanceof Error ? error.message : String(error);
377
+ await writeTelegramLog({
378
+ level: "error",
379
+ action: "task_failed",
380
+ message,
381
+ metadata: { chatId, engine: session.engine }
382
+ });
383
+ await emitTelegramTaskFailed({
384
+ chatId,
385
+ engine: session.engine,
386
+ session: failedSession,
387
+ error
388
+ });
389
+ if (!options.suppressFailureReply) {
390
+ await sendFailure(ctx, reporter, message);
391
+ }
392
+ return false;
393
+ }
394
+ finally {
395
+ await reporter.finalize();
396
+ stopTypingStatus();
397
+ }
398
+ }
399
+ function resolveMessageEngine(deps, name) {
400
+ if (deps.engines) {
401
+ return getTelegramEngine(deps.engines, name);
402
+ }
403
+ const legacyEngine = name === "codex" ? deps.codexEngine : deps.claudeEngine;
404
+ if (!legacyEngine) {
405
+ throw new Error(`Telegram engine is not registered: ${name}`);
406
+ }
407
+ return legacyEngine;
408
+ }
409
+ async function sendResult(ctx, engine, result, maxOutputLength, reporter) {
410
+ const chatId = ctx.chat?.id;
411
+ const hasFullOutput = (result.response || "").trim().length > 200;
412
+ const hasDiff = Boolean(result.diff);
413
+ if (chatId)
414
+ resultStore.set(chatId, result);
415
+ const cardHtml = Formatter.resultCard(result);
416
+ const keyboard = buildResultKeyboard(hasFullOutput, hasDiff);
417
+ if (cardHtml.length <= 4000) {
418
+ const replaced = await reporter?.replaceWithHtml(cardHtml, truncateText((result.response || "(empty)").trim(), maxOutputLength), { reply_markup: { inline_keyboard: keyboard.inline_keyboard } });
419
+ if (replaced)
420
+ return;
421
+ await replyHtmlWithPlainFallback(ctx, cardHtml, truncateText((result.response || "(empty)").trim(), maxOutputLength), keyboard);
422
+ return;
423
+ }
424
+ void engine;
425
+ const footer = formatResultFooter(result);
426
+ const fileSummary = Formatter.fileChangeSummary(result.filesChanged, result.diff);
427
+ const suffix = [fileSummary, footer].filter(Boolean).join("\n\n");
428
+ const formatted = Formatter.taskCompleted(engine, result, Math.min(maxOutputLength, 2500))
429
+ + (suffix ? `\n\n${suffix}` : "");
430
+ if (formatted.length <= 4000 && result.response.length <= maxOutputLength) {
431
+ if (await reporter?.replaceWithHtml(formatted, truncateText((result.response || "(empty)").trim(), maxOutputLength))) {
432
+ return;
433
+ }
434
+ await replyHtmlWithPlainFallback(ctx, formatted, truncateText((result.response || "(empty)").trim(), maxOutputLength));
435
+ return;
436
+ }
437
+ const responseText = (result.response || "(empty)").trim();
438
+ if (responseText.length <= 12000) {
439
+ const chunks = splitPlainText(responseText, 3800);
440
+ const firstChunk = chunks[0] + (chunks.length === 1 && suffix ? `\n\n${suffix}` : "");
441
+ if (!(await reporter?.replaceWithHtml(firstChunk, chunks[0]))) {
442
+ await withTelegramNetworkRetry(() => ctx.reply(firstChunk));
443
+ }
444
+ for (let i = 1; i < chunks.length; i++) {
445
+ const isLast = i === chunks.length - 1;
446
+ const text = isLast && suffix ? `${chunks[i]}\n\n${suffix}` : chunks[i];
447
+ await withTelegramNetworkRetry(() => ctx.reply(text));
448
+ }
449
+ return;
450
+ }
451
+ const attachmentNotice = "Output is attached as a file.";
452
+ const formattedNotice = Formatter.taskCompleted(engine, { ...result, response: attachmentNotice }, 800)
453
+ + (suffix ? `\n\n${suffix}` : "");
454
+ if (!(await reporter?.replaceWithHtml(formattedNotice, attachmentNotice))) {
455
+ await withTelegramNetworkRetry(() => ctx.reply(formattedNotice, { parse_mode: "HTML" }));
456
+ }
457
+ await withTelegramNetworkRetry(() => ctx.replyWithDocument(new InputFile(Buffer.from(result.response || "(empty)", "utf8"), "ai-gateway-output.md"), {
458
+ caption: "Full output"
459
+ }));
460
+ if (result.diff) {
461
+ await withTelegramNetworkRetry(() => ctx.replyWithDocument(new InputFile(Buffer.from(result.diff, "utf8"), "changes.diff"), {
462
+ caption: "Diff"
463
+ }));
464
+ }
465
+ }
466
+ async function sendFailure(ctx, reporter, message) {
467
+ const hint = classifyErrorHint(message);
468
+ const display = hint ? `${message}\n\n💡 ${hint}` : message;
469
+ const html = Formatter.taskFailed(display);
470
+ if (await reporter.replaceWithHtml(html, `Failed\n\n${truncateText(display, 1500)}`)) {
471
+ return;
472
+ }
473
+ await withTelegramNetworkRetry(() => ctx.reply(html, { parse_mode: "HTML" }));
474
+ }
475
+ function classifyErrorHint(message) {
476
+ if (/Failed to find .* CLI/i.test(message)) {
477
+ return "CLI 未安装,请确认 claude 或 codex 已正确安装并在 PATH 中。";
478
+ }
479
+ if (/timed?\s*out/i.test(message)) {
480
+ return "任务超时,可使用 /stop 提前终止长任务。";
481
+ }
482
+ if (/Dangerous command denied/i.test(message)) {
483
+ return null;
484
+ }
485
+ if (/任务已停止/i.test(message)) {
486
+ return null;
487
+ }
488
+ if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|502|503|429/i.test(message)) {
489
+ return "Provider 连接异常,系统已尝试 fallback。请稍后重试或检查网络。";
490
+ }
491
+ return null;
492
+ }
493
+ async function replyHtmlWithPlainFallback(ctx, html, fallbackText, keyboard) {
494
+ try {
495
+ const extra = { parse_mode: "HTML" };
496
+ if (keyboard)
497
+ extra.reply_markup = keyboard;
498
+ await withTelegramNetworkRetry(() => ctx.reply(html, extra));
499
+ }
500
+ catch {
501
+ await withTelegramNetworkRetry(() => ctx.reply(fallbackText || "(empty)"));
502
+ }
503
+ }
504
+ async function resolveTelegramProviderAttempts(engine, config) {
505
+ try {
506
+ const candidates = await resolveProviderCandidatesForTool(engine, config.provider, config.model);
507
+ if (candidates.length) {
508
+ return candidates.map((candidate, index) => ({
509
+ providerName: candidate.provider.name,
510
+ modelName: candidate.model,
511
+ providerLabel: candidate.provider.name,
512
+ modelLabel: candidate.model,
513
+ fallbackFrom: candidate.fallbackFrom,
514
+ preferred: isPreferredProviderAttempt(index, candidate.provider.name, candidate.model, candidate.fallbackFrom, config)
515
+ }));
516
+ }
517
+ }
518
+ catch (error) {
519
+ await writeTelegramLog({
520
+ level: "warn",
521
+ action: "provider_candidates_unavailable",
522
+ message: error instanceof Error ? error.message : String(error),
523
+ metadata: { engine, provider: config.provider ?? null, model: config.model ?? null }
524
+ });
525
+ }
526
+ return [{
527
+ providerName: config.provider,
528
+ modelName: config.model,
529
+ providerLabel: config.provider ?? "auto",
530
+ modelLabel: config.model,
531
+ preferred: true
532
+ }];
533
+ }
534
+ function isPreferredProviderAttempt(index, providerName, model, fallbackFrom, config) {
535
+ if (fallbackFrom)
536
+ return false;
537
+ if (index !== 0)
538
+ return false;
539
+ if (config.provider && providerName !== config.provider)
540
+ return false;
541
+ if (config.model && model !== config.model)
542
+ return false;
543
+ return true;
544
+ }
545
+ function isNonFallbackTelegramError(error) {
546
+ const message = error instanceof Error ? error.message : String(error);
547
+ return /Dangerous command denied|任务已停止|Failed to find .* CLI|Run `ai doctor`/.test(message);
548
+ }
549
+ async function writeTelegramFallbackLog(input) {
550
+ const error = input.error instanceof Error ? input.error.message : String(input.error);
551
+ await writeTelegramLog({
552
+ level: "warn",
553
+ action: "runtime_provider_fallback_retry",
554
+ message: `Provider fallback ${input.from} -> ${input.to} for Telegram ${input.engine}.`,
555
+ metadata: {
556
+ chatId: input.chatId,
557
+ engine: input.engine,
558
+ from: input.from,
559
+ to: input.to,
560
+ model: input.model ?? null,
561
+ nextModel: input.nextModel ?? null,
562
+ error: summarizeError(error)
563
+ }
564
+ });
565
+ }
566
+ function summarizeError(message) {
567
+ return message.replace(/\s+/g, " ").trim().slice(0, 240);
568
+ }
569
+ function cleanupConsumedAttachments(attachments) {
570
+ for (const attachment of attachments) {
571
+ try {
572
+ fs.unlinkSync(attachment.path);
573
+ }
574
+ catch {
575
+ // Temporary attachment cleanup is best-effort. Failed executions retain
576
+ // their attachments so the user can retry without uploading again.
577
+ }
578
+ }
579
+ }
580
+ function formatResultFooter(result) {
581
+ const parts = [];
582
+ if (result.duration && result.duration > 0) {
583
+ const seconds = Math.round(result.duration / 1000);
584
+ parts.push(seconds >= 60 ? `${Math.floor(seconds / 60)}m${seconds % 60}s` : `${seconds}s`);
585
+ }
586
+ if (result.provider)
587
+ parts.push(result.provider);
588
+ if (result.model)
589
+ parts.push(result.model);
590
+ if (!parts.length)
591
+ return "";
592
+ return `<i>⏱ ${parts.join(" · ")}</i>`;
593
+ }
594
+ export function startTypingStatus(ctx, chatId) {
595
+ let stopped = false;
596
+ const sendTyping = async () => {
597
+ if (stopped)
598
+ return;
599
+ try {
600
+ await ctx.api.sendChatAction(chatId, "typing");
601
+ }
602
+ catch {
603
+ // Chat actions are best-effort and should never affect task execution.
604
+ }
605
+ };
606
+ void sendTyping();
607
+ const interval = setInterval(() => void sendTyping(), 4000);
608
+ return () => {
609
+ stopped = true;
610
+ clearInterval(interval);
611
+ };
612
+ }
613
+ const execAsync = promisify(exec);
614
+ const pendingCommandRegistration = new Map();
615
+ async function handleCommandNameInput(ctx, deps, text, session) {
616
+ const input = text.replace(/^\//, "").trim();
617
+ if (!input) {
618
+ await ctx.reply(Formatter.commandNamePrompt(), {
619
+ reply_markup: { inline_keyboard: [[{ text: "关闭", callback_data: "command:cancel" }]] }
620
+ });
621
+ return;
622
+ }
623
+ const params = [];
624
+ input.replace(/\[(\w+)\]/g, (_, p) => {
625
+ params.push(p);
626
+ return "";
627
+ });
628
+ const name = input.split(/\s/)[0].replace(/\[.*$/, "").toLowerCase().replace(/[^a-z0-9_]/g, "_");
629
+ if (!name) {
630
+ await ctx.reply(Formatter.commandNamePrompt(), {
631
+ reply_markup: { inline_keyboard: [[{ text: "关闭", callback_data: "command:cancel" }]] }
632
+ });
633
+ return;
634
+ }
635
+ const description = `/${input}`;
636
+ pendingCommandRegistration.set(session.chatId, { name, params, description });
637
+ deps.sessionManager.update(session.id, { setupStep: "command_template" });
638
+ await ctx.reply(Formatter.commandTemplatePrompt(params), {
639
+ parse_mode: "HTML",
640
+ reply_markup: { inline_keyboard: [[{ text: "返回", callback_data: "command:back" }]] }
641
+ });
642
+ }
643
+ async function handleCommandTemplateInput(ctx, deps, text, session, bot) {
644
+ const pending = pendingCommandRegistration.get(session.chatId);
645
+ if (!pending) {
646
+ deps.sessionManager.update(session.id, { setupStep: "ready" });
647
+ await ctx.reply("注册流程已失效,请重新 /command");
648
+ return;
649
+ }
650
+ pendingCommandRegistration.delete(session.chatId);
651
+ const cmd = { ...pending, template: text.trim() };
652
+ deps.sessionManager.addCustomCommand(session.chatId, session.cwd, cmd);
653
+ deps.sessionManager.update(session.id, { setupStep: "ready" });
654
+ const customCommands = deps.sessionManager.listCustomCommands(session.chatId, session.cwd);
655
+ await safeSetChatCommandMenu(bot, session.chatId, session.engine, customCommands);
656
+ await ctx.reply(Formatter.commandRegistered(cmd.name, cmd.description), { parse_mode: "HTML" });
657
+ }
658
+ async function executeCustomCommand(ctx, deps, commandName, argsText, session) {
659
+ const commands = deps.sessionManager.listCustomCommands(session.chatId, session.cwd);
660
+ const cmd = commands.find((c) => c.name === commandName);
661
+ if (!cmd) {
662
+ await ctx.reply("自定义命令不存在。");
663
+ return;
664
+ }
665
+ const argValues = parseCustomCommandArgs(argsText, cmd.params.length);
666
+ let shell = cmd.template;
667
+ for (let i = 0; i < cmd.params.length; i++) {
668
+ const value = argValues[i] ?? "";
669
+ shell = shell.replaceAll(`[${cmd.params[i]}]`, value);
670
+ }
671
+ await ctx.reply(`⚙️ 执行中:<code>${escapeHtml(shell)}</code>`, { parse_mode: "HTML" });
672
+ try {
673
+ const { stdout, stderr } = await execAsync(shell, { cwd: session.cwd, timeout: 60_000, maxBuffer: 10 * 1024 * 1024 });
674
+ const output = (stdout || stderr || "(无输出)").trim();
675
+ if (output.length <= 3800) {
676
+ await ctx.reply(`<pre>${escapeHtml(output)}</pre>`, { parse_mode: "HTML" });
677
+ }
678
+ else {
679
+ await ctx.replyWithDocument(new InputFile(Buffer.from(output, "utf-8"), "output.txt"));
680
+ }
681
+ }
682
+ catch (error) {
683
+ const msg = error instanceof Error ? error.message : String(error);
684
+ if (msg.length <= 3800) {
685
+ await ctx.reply(`❌ 执行失败:\n<pre>${escapeHtml(msg)}</pre>`, { parse_mode: "HTML" });
686
+ }
687
+ else {
688
+ await ctx.replyWithDocument(new InputFile(Buffer.from(msg, "utf-8"), "error.txt"));
689
+ }
690
+ }
691
+ }
692
+ function parseCustomCommandArgs(argsText, paramCount) {
693
+ if (!argsText || paramCount === 0)
694
+ return [];
695
+ const parts = argsText.split(/\s+/);
696
+ if (parts.length <= paramCount)
697
+ return parts;
698
+ const result = parts.slice(0, paramCount - 1);
699
+ result.push(parts.slice(paramCount - 1).join(" "));
700
+ return result;
701
+ }