@grinev/opencode-telegram-bot 0.13.2 → 0.14.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/.env.example +0 -8
- package/README.md +25 -26
- package/dist/bot/commands/sessions.js +1 -0
- package/dist/bot/index.js +59 -23
- package/dist/bot/streaming/response-streamer.js +6 -4
- package/dist/bot/utils/finalize-assistant-response.js +20 -11
- package/dist/bot/utils/send-with-markdown-fallback.js +13 -2
- package/dist/bot/utils/thinking-message.js +0 -1
- package/dist/config.js +0 -16
- package/dist/i18n/de.js +12 -0
- package/dist/i18n/en.js +12 -0
- package/dist/i18n/es.js +12 -0
- package/dist/i18n/fr.js +12 -0
- package/dist/i18n/ru.js +12 -0
- package/dist/i18n/zh.js +12 -0
- package/dist/model/context-limit.js +57 -0
- package/dist/pinned/format.js +29 -0
- package/dist/pinned/manager.js +30 -55
- package/dist/summary/aggregator.js +435 -16
- package/dist/summary/formatter.js +50 -11
- package/dist/summary/subagent-formatter.js +63 -0
- package/dist/summary/tool-message-batcher.js +14 -209
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -50,20 +50,12 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
50
50
|
# Supported locales: en, de, es, fr, ru, zh
|
|
51
51
|
# BOT_LOCALE=en
|
|
52
52
|
|
|
53
|
-
# Service message batching interval in seconds (thinking + tool calls, default: 5)
|
|
54
|
-
# Recommended: keep >=2 to reduce risk of hitting Telegram rate limits (about 1 message/sec)
|
|
55
|
-
# 0 = send immediately (can hit rate limits if there are many tool calls)
|
|
56
|
-
# SERVICE_MESSAGES_INTERVAL_SEC=5
|
|
57
|
-
|
|
58
53
|
# Hide thinking indicator messages (default: false)
|
|
59
54
|
# HIDE_THINKING_MESSAGES=false
|
|
60
55
|
|
|
61
56
|
# Hide tool call service messages (default: false)
|
|
62
57
|
# HIDE_TOOL_CALL_MESSAGES=false
|
|
63
58
|
|
|
64
|
-
# Stream assistant responses while they are being generated (default: true)
|
|
65
|
-
# RESPONSE_STREAMING=true
|
|
66
|
-
|
|
67
59
|
# Assistant message formatting mode (default: markdown)
|
|
68
60
|
# markdown = convert assistant replies to Telegram MarkdownV2
|
|
69
61
|
# raw = show assistant replies as plain text
|
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
|
|
|
28
28
|
- **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time
|
|
29
29
|
- **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
|
|
30
30
|
- **Agent modes** — switch between Plan and Build modes on the fly
|
|
31
|
+
- **Subagent activity** — watch live subagent progress in chat, including the current task, agent, model, and active tool step
|
|
31
32
|
- **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
|
|
32
33
|
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
|
|
33
34
|
- **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
|
|
@@ -146,32 +147,30 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
146
147
|
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
|
|
147
148
|
- **Linux:** `~/.config/opencode-telegram-bot/.env`
|
|
148
149
|
|
|
149
|
-
| Variable
|
|
150
|
-
|
|
|
151
|
-
| `TELEGRAM_BOT_TOKEN`
|
|
152
|
-
| `TELEGRAM_ALLOWED_USER_ID`
|
|
153
|
-
| `TELEGRAM_PROXY_URL`
|
|
154
|
-
| `OPENCODE_API_URL`
|
|
155
|
-
| `OPENCODE_SERVER_USERNAME`
|
|
156
|
-
| `OPENCODE_SERVER_PASSWORD`
|
|
157
|
-
| `OPENCODE_MODEL_PROVIDER`
|
|
158
|
-
| `OPENCODE_MODEL_ID`
|
|
159
|
-
| `BOT_LOCALE`
|
|
160
|
-
| `SESSIONS_LIST_LIMIT`
|
|
161
|
-
| `PROJECTS_LIST_LIMIT`
|
|
162
|
-
| `COMMANDS_LIST_LIMIT`
|
|
163
|
-
| `TASK_LIMIT`
|
|
164
|
-
| `
|
|
165
|
-
| `
|
|
166
|
-
| `
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
|
|
174
|
-
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
150
|
+
| Variable | Description | Required | Default |
|
|
151
|
+
| -------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
|
|
152
|
+
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
|
|
153
|
+
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
|
|
154
|
+
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
|
|
155
|
+
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
|
|
156
|
+
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
|
|
157
|
+
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
|
|
158
|
+
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
|
|
159
|
+
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
|
|
160
|
+
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
|
|
161
|
+
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
|
|
162
|
+
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
|
|
163
|
+
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
|
|
164
|
+
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
|
|
165
|
+
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
166
|
+
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
167
|
+
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
|
|
168
|
+
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
|
|
169
|
+
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
|
|
170
|
+
| `STT_API_KEY` | API key for your STT provider | No | — |
|
|
171
|
+
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
|
|
172
|
+
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
|
|
173
|
+
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
175
174
|
|
|
176
175
|
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
|
|
177
176
|
|
|
@@ -51,6 +51,7 @@ async function loadSessionPage(directory, page, pageSize) {
|
|
|
51
51
|
const { data: sessions, error } = await opencodeClient.session.list({
|
|
52
52
|
directory,
|
|
53
53
|
limit: endExclusive + SESSION_FETCH_EXTRA_COUNT,
|
|
54
|
+
roots: true,
|
|
54
55
|
});
|
|
55
56
|
if (error || !sessions) {
|
|
56
57
|
throw error || new Error("No data received from server");
|
package/dist/bot/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import { keyboardManager } from "../keyboard/manager.js";
|
|
|
37
37
|
import { subscribeToEvents } from "../opencode/events.js";
|
|
38
38
|
import { summaryAggregator } from "../summary/aggregator.js";
|
|
39
39
|
import { formatSummary, formatSummaryWithMode, formatToolInfo, getAssistantParseMode, } from "../summary/formatter.js";
|
|
40
|
+
import { renderSubagentCards } from "../summary/subagent-formatter.js";
|
|
40
41
|
import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
|
|
41
42
|
import { getCurrentSession } from "../session/manager.js";
|
|
42
43
|
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
|
|
@@ -65,6 +66,7 @@ const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
|
|
|
65
66
|
const RESPONSE_STREAM_THROTTLE_MS = 200;
|
|
66
67
|
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
|
|
67
68
|
const SESSION_RETRY_PREFIX = "🔁";
|
|
69
|
+
const SUBAGENT_STREAM_PREFIX = "🧩";
|
|
68
70
|
const __filename = fileURLToPath(import.meta.url);
|
|
69
71
|
const __dirname = path.dirname(__filename);
|
|
70
72
|
const TEMP_DIR = path.join(__dirname, "..", ".tmp");
|
|
@@ -95,7 +97,6 @@ function prepareStreamingPayload(messageText) {
|
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
99
|
const toolMessageBatcher = new ToolMessageBatcher({
|
|
98
|
-
intervalSeconds: 5,
|
|
99
100
|
sendText: async (sessionId, text) => {
|
|
100
101
|
if (!botInstance || !chatIdInstance) {
|
|
101
102
|
return;
|
|
@@ -270,7 +271,6 @@ async function ensureEventSubscription(directory) {
|
|
|
270
271
|
logger.error("No directory found for event subscription");
|
|
271
272
|
return;
|
|
272
273
|
}
|
|
273
|
-
toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
|
|
274
274
|
summaryAggregator.setTypingIndicatorEnabled(true);
|
|
275
275
|
summaryAggregator.setOnCleared(() => {
|
|
276
276
|
toolMessageBatcher.clearAll("summary_aggregator_clear");
|
|
@@ -278,9 +278,6 @@ async function ensureEventSubscription(directory) {
|
|
|
278
278
|
responseStreamer.clearAll("summary_aggregator_clear");
|
|
279
279
|
});
|
|
280
280
|
summaryAggregator.setOnPartial((sessionId, messageId, messageText) => {
|
|
281
|
-
if (!config.bot.responseStreaming) {
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
281
|
if (!botInstance || !chatIdInstance) {
|
|
285
282
|
return;
|
|
286
283
|
}
|
|
@@ -292,7 +289,7 @@ async function ensureEventSubscription(directory) {
|
|
|
292
289
|
if (!preparedStreamPayload) {
|
|
293
290
|
return;
|
|
294
291
|
}
|
|
295
|
-
preparedStreamPayload.sendOptions =
|
|
292
|
+
preparedStreamPayload.sendOptions = { disable_notification: true };
|
|
296
293
|
preparedStreamPayload.editOptions = undefined;
|
|
297
294
|
responseStreamer.enqueue(sessionId, messageId, preparedStreamPayload);
|
|
298
295
|
});
|
|
@@ -315,8 +312,7 @@ async function ensureEventSubscription(directory) {
|
|
|
315
312
|
const botApi = botInstance.api;
|
|
316
313
|
const chatId = chatIdInstance;
|
|
317
314
|
try {
|
|
318
|
-
|
|
319
|
-
responseStreaming: config.bot.responseStreaming,
|
|
315
|
+
await finalizeAssistantResponse({
|
|
320
316
|
sessionId,
|
|
321
317
|
messageId,
|
|
322
318
|
messageText,
|
|
@@ -338,13 +334,17 @@ async function ensureEventSubscription(directory) {
|
|
|
338
334
|
format,
|
|
339
335
|
});
|
|
340
336
|
},
|
|
337
|
+
deleteMessages: async (messageIds) => {
|
|
338
|
+
for (const msgId of messageIds) {
|
|
339
|
+
try {
|
|
340
|
+
await botApi.deleteMessage(chatId, msgId);
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
logger.warn(`[Bot] Failed to delete streamed message ${msgId}:`, err);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
},
|
|
341
347
|
});
|
|
342
|
-
if (streamedViaMessages) {
|
|
343
|
-
logger.debug(`[Bot] Final assistant message already streamed (session=${sessionId}, message=${messageId})`);
|
|
344
|
-
foregroundSessionState.markIdle(sessionId);
|
|
345
|
-
await scheduledTaskRuntime.flushDeferredDeliveries();
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
348
|
}
|
|
349
349
|
catch (err) {
|
|
350
350
|
logger.error("Failed to send message to Telegram:", err);
|
|
@@ -368,7 +368,9 @@ async function ensureEventSubscription(directory) {
|
|
|
368
368
|
}
|
|
369
369
|
const shouldIncludeToolInfoInFileCaption = toolInfo.hasFileAttachment &&
|
|
370
370
|
(toolInfo.tool === "write" || toolInfo.tool === "edit" || toolInfo.tool === "apply_patch");
|
|
371
|
-
if (config.bot.hideToolCallMessages ||
|
|
371
|
+
if (config.bot.hideToolCallMessages ||
|
|
372
|
+
shouldIncludeToolInfoInFileCaption ||
|
|
373
|
+
toolInfo.tool === "task") {
|
|
372
374
|
return;
|
|
373
375
|
}
|
|
374
376
|
try {
|
|
@@ -381,6 +383,28 @@ async function ensureEventSubscription(directory) {
|
|
|
381
383
|
logger.error("Failed to send tool notification to Telegram:", err);
|
|
382
384
|
}
|
|
383
385
|
});
|
|
386
|
+
summaryAggregator.setOnSubagent(async (sessionId, subagents) => {
|
|
387
|
+
if (!botInstance || !chatIdInstance) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (config.bot.hideToolCallMessages) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const currentSession = getCurrentSession();
|
|
394
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const renderedCards = await renderSubagentCards(subagents);
|
|
399
|
+
if (!renderedCards) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, renderedCards);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
logger.error("Failed to render subagent activity for Telegram:", err);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
384
408
|
summaryAggregator.setOnToolFile(async (fileInfo) => {
|
|
385
409
|
if (!botInstance || !chatIdInstance) {
|
|
386
410
|
logger.error("Bot or chat ID not available for sending file");
|
|
@@ -463,24 +487,38 @@ async function ensureEventSubscription(directory) {
|
|
|
463
487
|
logger.debug("[Bot] Agent started thinking");
|
|
464
488
|
await toolCallStreamer.breakSession(sessionId, "thinking_started");
|
|
465
489
|
deliverThinkingMessage(sessionId, toolMessageBatcher, {
|
|
466
|
-
responseStreaming: config.bot.responseStreaming,
|
|
467
490
|
hideThinkingMessages: config.bot.hideThinkingMessages,
|
|
468
491
|
});
|
|
492
|
+
// Refresh pinned message so it shows the latest in-memory context
|
|
493
|
+
// (accumulated from silent token updates). 1 API call per thinking event.
|
|
494
|
+
if (pinnedMessageManager.isInitialized()) {
|
|
495
|
+
await pinnedMessageManager.refresh();
|
|
496
|
+
}
|
|
469
497
|
});
|
|
470
|
-
summaryAggregator.setOnTokens(async (tokens) => {
|
|
498
|
+
summaryAggregator.setOnTokens(async (tokens, isCompleted) => {
|
|
471
499
|
if (!pinnedMessageManager.isInitialized()) {
|
|
472
500
|
return;
|
|
473
501
|
}
|
|
474
502
|
try {
|
|
475
|
-
logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}`);
|
|
476
|
-
// Update keyboardManager SYNCHRONOUSLY before any await
|
|
477
|
-
// This ensures keyboard has correct context when onComplete sends the reply
|
|
503
|
+
logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}, completed=${isCompleted}`);
|
|
478
504
|
const contextSize = tokens.input + tokens.cacheRead;
|
|
479
505
|
const contextLimit = pinnedMessageManager.getContextLimit();
|
|
506
|
+
// Skip non-completed messages with zero context: a new assistant message
|
|
507
|
+
// starts with tokens={input:0, ...} which would overwrite valid context
|
|
508
|
+
// from the previous step. Only accept zeros from completed messages.
|
|
509
|
+
if (!isCompleted && contextSize === 0) {
|
|
510
|
+
logger.debug("[Bot] Skipping zero-token intermediate update");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// Update both keyboard and pinned state in memory (keeps them in sync)
|
|
480
514
|
if (contextLimit > 0) {
|
|
481
515
|
keyboardManager.updateContext(contextSize, contextLimit);
|
|
482
516
|
}
|
|
483
|
-
|
|
517
|
+
pinnedMessageManager.updateTokensSilent(tokens);
|
|
518
|
+
// Full pinned message update (API call) only on completed messages
|
|
519
|
+
if (isCompleted) {
|
|
520
|
+
await pinnedMessageManager.onMessageComplete(tokens);
|
|
521
|
+
}
|
|
484
522
|
}
|
|
485
523
|
catch (err) {
|
|
486
524
|
logger.error("[Bot] Error updating pinned message with tokens:", err);
|
|
@@ -600,8 +638,6 @@ async function ensureEventSubscription(directory) {
|
|
|
600
638
|
}
|
|
601
639
|
export function createBot() {
|
|
602
640
|
clearAllInteractionState("bot_startup");
|
|
603
|
-
toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
|
|
604
|
-
logger.debug(`[ToolBatcher] Service messages interval: ${config.bot.serviceMessagesIntervalSec}s`);
|
|
605
641
|
const botOptions = {};
|
|
606
642
|
if (config.telegram.proxyUrl) {
|
|
607
643
|
const proxyUrl = config.telegram.proxyUrl;
|
|
@@ -70,9 +70,10 @@ export class ResponseStreamer {
|
|
|
70
70
|
this.ensureTimer(state);
|
|
71
71
|
}
|
|
72
72
|
async complete(sessionId, messageId, payload, options) {
|
|
73
|
+
const notStreamed = { streamed: false, telegramMessageIds: [] };
|
|
73
74
|
const state = this.states.get(buildStateKey(sessionId, messageId));
|
|
74
75
|
if (!state) {
|
|
75
|
-
return
|
|
76
|
+
return notStreamed;
|
|
76
77
|
}
|
|
77
78
|
if (payload) {
|
|
78
79
|
const normalizedPayload = normalizePayload(payload);
|
|
@@ -86,12 +87,12 @@ export class ResponseStreamer {
|
|
|
86
87
|
await this.cleanupBrokenStream(state, "complete_broken_stream");
|
|
87
88
|
this.cancelState(state);
|
|
88
89
|
this.states.delete(state.key);
|
|
89
|
-
return
|
|
90
|
+
return notStreamed;
|
|
90
91
|
}
|
|
91
92
|
if (state.telegramMessageIds.length === 0) {
|
|
92
93
|
this.cancelState(state);
|
|
93
94
|
this.states.delete(state.key);
|
|
94
|
-
return
|
|
95
|
+
return notStreamed;
|
|
95
96
|
}
|
|
96
97
|
let synced = true;
|
|
97
98
|
if (options?.flushFinal !== false) {
|
|
@@ -100,9 +101,10 @@ export class ResponseStreamer {
|
|
|
100
101
|
await this.cleanupBrokenStream(state, "final_sync_failed_cleanup");
|
|
101
102
|
}
|
|
102
103
|
}
|
|
104
|
+
const messageIds = [...state.telegramMessageIds];
|
|
103
105
|
this.cancelState(state);
|
|
104
106
|
this.states.delete(state.key);
|
|
105
|
-
return synced;
|
|
107
|
+
return { streamed: synced, telegramMessageIds: messageIds };
|
|
106
108
|
}
|
|
107
109
|
clearMessage(sessionId, messageId, reason) {
|
|
108
110
|
const key = buildStateKey(sessionId, messageId);
|
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { logger } from "../../utils/logger.js";
|
|
2
|
+
export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, resolveFormat, getReplyKeyboard, sendText, deleteMessages, }) {
|
|
3
|
+
let streamedMessageIds = [];
|
|
4
|
+
const preparedStreamPayload = prepareStreamingPayload(messageText);
|
|
5
|
+
if (preparedStreamPayload) {
|
|
6
|
+
preparedStreamPayload.sendOptions = { disable_notification: true };
|
|
7
|
+
preparedStreamPayload.editOptions = undefined;
|
|
8
|
+
}
|
|
9
|
+
const result = await responseStreamer.complete(sessionId, messageId, preparedStreamPayload ?? undefined);
|
|
10
|
+
if (result.streamed) {
|
|
11
|
+
streamedMessageIds = result.telegramMessageIds;
|
|
10
12
|
}
|
|
11
13
|
await flushPendingServiceMessages();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
// When the response was streamed, delete the streamed messages and re-send
|
|
15
|
+
// via the non-streamed path so the reply keyboard carries the latest context.
|
|
16
|
+
if (streamedMessageIds.length > 0) {
|
|
17
|
+
try {
|
|
18
|
+
await deleteMessages(streamedMessageIds);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
logger.warn("[FinalizeResponse] Failed to delete streamed messages, sending with keyboard anyway:", err);
|
|
22
|
+
}
|
|
14
23
|
}
|
|
15
24
|
const parts = formatSummary(messageText);
|
|
16
25
|
const format = resolveFormat();
|
|
@@ -6,6 +6,17 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
|
|
|
6
6
|
"entity beginning",
|
|
7
7
|
"bad request: can't parse",
|
|
8
8
|
];
|
|
9
|
+
function stripMarkdownFormattingOptions(options) {
|
|
10
|
+
if (!options) {
|
|
11
|
+
return options;
|
|
12
|
+
}
|
|
13
|
+
const rawOptions = {
|
|
14
|
+
...options,
|
|
15
|
+
};
|
|
16
|
+
delete rawOptions.parse_mode;
|
|
17
|
+
delete rawOptions.entities;
|
|
18
|
+
return rawOptions;
|
|
19
|
+
}
|
|
9
20
|
function getErrorText(error) {
|
|
10
21
|
const parts = [];
|
|
11
22
|
if (error instanceof Error) {
|
|
@@ -52,7 +63,7 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, optio
|
|
|
52
63
|
throw error;
|
|
53
64
|
}
|
|
54
65
|
logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
|
|
55
|
-
return api.sendMessage(chatId, text, options);
|
|
66
|
+
return api.sendMessage(chatId, text, stripMarkdownFormattingOptions(options));
|
|
56
67
|
}
|
|
57
68
|
}
|
|
58
69
|
export async function editMessageWithMarkdownFallback({ api, chatId, messageId, text, options, parseMode, }) {
|
|
@@ -71,6 +82,6 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
|
|
|
71
82
|
throw error;
|
|
72
83
|
}
|
|
73
84
|
logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
|
|
74
|
-
return api.editMessageText(chatId, messageId, text, options);
|
|
85
|
+
return api.editMessageText(chatId, messageId, text, stripMarkdownFormattingOptions(options));
|
|
75
86
|
}
|
|
76
87
|
}
|
package/dist/config.js
CHANGED
|
@@ -21,20 +21,6 @@ function getOptionalPositiveIntEnvVar(key, defaultValue) {
|
|
|
21
21
|
}
|
|
22
22
|
return parsedValue;
|
|
23
23
|
}
|
|
24
|
-
function getOptionalNonNegativeIntEnvVarFromKeys(keys, defaultValue) {
|
|
25
|
-
for (const key of keys) {
|
|
26
|
-
const value = getEnvVar(key, false);
|
|
27
|
-
if (!value) {
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
const parsedValue = Number.parseInt(value, 10);
|
|
31
|
-
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
|
32
|
-
return defaultValue;
|
|
33
|
-
}
|
|
34
|
-
return parsedValue;
|
|
35
|
-
}
|
|
36
|
-
return defaultValue;
|
|
37
|
-
}
|
|
38
24
|
function getOptionalLocaleEnvVar(key, defaultValue) {
|
|
39
25
|
const value = getEnvVar(key, false);
|
|
40
26
|
return normalizeLocale(value, defaultValue);
|
|
@@ -88,10 +74,8 @@ export const config = {
|
|
|
88
74
|
commandsListLimit: getOptionalPositiveIntEnvVar("COMMANDS_LIST_LIMIT", 10),
|
|
89
75
|
taskLimit: getOptionalPositiveIntEnvVar("TASK_LIMIT", 10),
|
|
90
76
|
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
|
|
91
|
-
serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
|
|
92
77
|
hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
|
|
93
78
|
hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
|
|
94
|
-
responseStreaming: getOptionalBooleanEnvVar("RESPONSE_STREAMING", true),
|
|
95
79
|
messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"),
|
|
96
80
|
},
|
|
97
81
|
files: {
|
package/dist/i18n/de.js
CHANGED
|
@@ -217,6 +217,18 @@ export const de = {
|
|
|
217
217
|
"pinned.line.model": "Modell: {model}",
|
|
218
218
|
"pinned.line.context": "Kontext: {used} / {limit} ({percent}%)",
|
|
219
219
|
"pinned.line.cost": "Kosten: {cost} ausgegeben",
|
|
220
|
+
"subagent.header": "Subagent {agent}: {description}",
|
|
221
|
+
"subagent.line.status": "Status: {status}",
|
|
222
|
+
"subagent.line.task": "Aufgabe: {task}",
|
|
223
|
+
"subagent.line.agent": "Agent: {agent}",
|
|
224
|
+
"subagent.working": "Arbeitet...",
|
|
225
|
+
"subagent.working_with_details": "Arbeitet: {details}",
|
|
226
|
+
"subagent.completed": "Abgeschlossen",
|
|
227
|
+
"subagent.failed": "Aufgabe fehlgeschlagen",
|
|
228
|
+
"subagent.status.pending": "ausstehend",
|
|
229
|
+
"subagent.status.running": "laeuft",
|
|
230
|
+
"subagent.status.completed": "abgeschlossen",
|
|
231
|
+
"subagent.status.error": "Fehler",
|
|
220
232
|
"pinned.files.title": "Dateien ({count}):",
|
|
221
233
|
"pinned.files.item": " {path}{diff}",
|
|
222
234
|
"pinned.files.more": " ... und {count} mehr",
|
package/dist/i18n/en.js
CHANGED
|
@@ -217,6 +217,18 @@ export const en = {
|
|
|
217
217
|
"pinned.line.model": "Model: {model}",
|
|
218
218
|
"pinned.line.context": "Context: {used} / {limit} ({percent}%)",
|
|
219
219
|
"pinned.line.cost": "Cost: {cost} spent",
|
|
220
|
+
"subagent.header": "Subagent {agent}: {description}",
|
|
221
|
+
"subagent.line.status": "Status: {status}",
|
|
222
|
+
"subagent.line.task": "Task: {task}",
|
|
223
|
+
"subagent.line.agent": "Agent: {agent}",
|
|
224
|
+
"subagent.working": "Working...",
|
|
225
|
+
"subagent.working_with_details": "Working: {details}",
|
|
226
|
+
"subagent.completed": "Completed",
|
|
227
|
+
"subagent.failed": "Task failed",
|
|
228
|
+
"subagent.status.pending": "pending",
|
|
229
|
+
"subagent.status.running": "running",
|
|
230
|
+
"subagent.status.completed": "completed",
|
|
231
|
+
"subagent.status.error": "error",
|
|
220
232
|
"pinned.files.title": "Files ({count}):",
|
|
221
233
|
"pinned.files.item": " {path}{diff}",
|
|
222
234
|
"pinned.files.more": " ... and {count} more",
|
package/dist/i18n/es.js
CHANGED
|
@@ -217,6 +217,18 @@ export const es = {
|
|
|
217
217
|
"pinned.line.model": "Modelo: {model}",
|
|
218
218
|
"pinned.line.context": "Contexto: {used} / {limit} ({percent}%)",
|
|
219
219
|
"pinned.line.cost": "Costo: {cost} gastado",
|
|
220
|
+
"subagent.header": "Subagente {agent}: {description}",
|
|
221
|
+
"subagent.line.status": "Estado: {status}",
|
|
222
|
+
"subagent.line.task": "Tarea: {task}",
|
|
223
|
+
"subagent.line.agent": "Agente: {agent}",
|
|
224
|
+
"subagent.working": "Trabajando...",
|
|
225
|
+
"subagent.working_with_details": "Trabajando: {details}",
|
|
226
|
+
"subagent.completed": "Completada",
|
|
227
|
+
"subagent.failed": "Error de tarea",
|
|
228
|
+
"subagent.status.pending": "pendiente",
|
|
229
|
+
"subagent.status.running": "en ejecucion",
|
|
230
|
+
"subagent.status.completed": "completado",
|
|
231
|
+
"subagent.status.error": "error",
|
|
220
232
|
"pinned.files.title": "Archivos ({count}):",
|
|
221
233
|
"pinned.files.item": " {path}{diff}",
|
|
222
234
|
"pinned.files.more": " ... y {count} más",
|
package/dist/i18n/fr.js
CHANGED
|
@@ -217,6 +217,18 @@ export const fr = {
|
|
|
217
217
|
"pinned.line.model": "Modèle : {model}",
|
|
218
218
|
"pinned.line.context": "Contexte : {used} / {limit} ({percent}%)",
|
|
219
219
|
"pinned.line.cost": "Coût : {cost} dépensé",
|
|
220
|
+
"subagent.header": "Sous-agent {agent} : {description}",
|
|
221
|
+
"subagent.line.status": "Statut : {status}",
|
|
222
|
+
"subagent.line.task": "Tache : {task}",
|
|
223
|
+
"subagent.line.agent": "Agent : {agent}",
|
|
224
|
+
"subagent.working": "En cours...",
|
|
225
|
+
"subagent.working_with_details": "En cours : {details}",
|
|
226
|
+
"subagent.completed": "Terminee",
|
|
227
|
+
"subagent.failed": "Echec de la tache",
|
|
228
|
+
"subagent.status.pending": "en attente",
|
|
229
|
+
"subagent.status.running": "en cours",
|
|
230
|
+
"subagent.status.completed": "termine",
|
|
231
|
+
"subagent.status.error": "erreur",
|
|
220
232
|
"pinned.files.title": "Fichiers ({count}) :",
|
|
221
233
|
"pinned.files.item": " {path}{diff}",
|
|
222
234
|
"pinned.files.more": " ... et encore {count}",
|
package/dist/i18n/ru.js
CHANGED
|
@@ -217,6 +217,18 @@ export const ru = {
|
|
|
217
217
|
"pinned.line.model": "Модель: {model}",
|
|
218
218
|
"pinned.line.context": "Контекст: {used} / {limit} ({percent}%)",
|
|
219
219
|
"pinned.line.cost": "Стоимость: {cost} потрачено",
|
|
220
|
+
"subagent.header": "Сабагент {agent}: {description}",
|
|
221
|
+
"subagent.line.status": "Статус: {status}",
|
|
222
|
+
"subagent.line.task": "Задача: {task}",
|
|
223
|
+
"subagent.line.agent": "Агент: {agent}",
|
|
224
|
+
"subagent.working": "В работе...",
|
|
225
|
+
"subagent.working_with_details": "В работе: {details}",
|
|
226
|
+
"subagent.completed": "Завершена",
|
|
227
|
+
"subagent.failed": "Ошибка задачи",
|
|
228
|
+
"subagent.status.pending": "ожидание",
|
|
229
|
+
"subagent.status.running": "в работе",
|
|
230
|
+
"subagent.status.completed": "завершен",
|
|
231
|
+
"subagent.status.error": "ошибка",
|
|
220
232
|
"pinned.files.title": "Файлы ({count}):",
|
|
221
233
|
"pinned.files.item": " {path}{diff}",
|
|
222
234
|
"pinned.files.more": " ... и еще {count}",
|
package/dist/i18n/zh.js
CHANGED
|
@@ -217,6 +217,18 @@ export const zh = {
|
|
|
217
217
|
"pinned.line.model": "模型: {model}",
|
|
218
218
|
"pinned.line.context": "上下文: {used} / {limit} ({percent}%)",
|
|
219
219
|
"pinned.line.cost": "费用: {cost}",
|
|
220
|
+
"subagent.header": "子代理 {agent}: {description}",
|
|
221
|
+
"subagent.line.status": "状态: {status}",
|
|
222
|
+
"subagent.line.task": "任务: {task}",
|
|
223
|
+
"subagent.line.agent": "代理: {agent}",
|
|
224
|
+
"subagent.working": "执行中...",
|
|
225
|
+
"subagent.working_with_details": "执行中: {details}",
|
|
226
|
+
"subagent.completed": "已完成",
|
|
227
|
+
"subagent.failed": "任务失败",
|
|
228
|
+
"subagent.status.pending": "等待中",
|
|
229
|
+
"subagent.status.running": "运行中",
|
|
230
|
+
"subagent.status.completed": "已完成",
|
|
231
|
+
"subagent.status.error": "错误",
|
|
220
232
|
"pinned.files.title": "文件({count}):",
|
|
221
233
|
"pinned.files.item": " {path}{diff}",
|
|
222
234
|
"pinned.files.more": " ... 还有 {count} 个",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { opencodeClient } from "../opencode/client.js";
|
|
2
|
+
import { logger } from "../utils/logger.js";
|
|
3
|
+
import { DEFAULT_CONTEXT_LIMIT } from "../pinned/format.js";
|
|
4
|
+
const PROVIDER_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
5
|
+
const contextLimitCache = new Map();
|
|
6
|
+
let providersCacheExpiresAt = 0;
|
|
7
|
+
let providersFetchInFlight = null;
|
|
8
|
+
function getModelKey(providerID, modelID) {
|
|
9
|
+
return `${providerID}/${modelID}`;
|
|
10
|
+
}
|
|
11
|
+
async function refreshContextLimitCache() {
|
|
12
|
+
if (Date.now() < providersCacheExpiresAt) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (providersFetchInFlight) {
|
|
16
|
+
await providersFetchInFlight;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
providersFetchInFlight = (async () => {
|
|
20
|
+
try {
|
|
21
|
+
const { data, error } = await opencodeClient.config.providers();
|
|
22
|
+
if (error || !data) {
|
|
23
|
+
logger.warn("[ModelContextLimit] Failed to fetch providers:", error);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
contextLimitCache.clear();
|
|
27
|
+
for (const provider of data.providers) {
|
|
28
|
+
for (const [modelID, model] of Object.entries(provider.models)) {
|
|
29
|
+
if (model?.limit?.context) {
|
|
30
|
+
contextLimitCache.set(getModelKey(provider.id, modelID), model.limit.context);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
providersCacheExpiresAt = Date.now() + PROVIDER_CACHE_TTL_MS;
|
|
35
|
+
logger.debug(`[ModelContextLimit] Cached limits for ${contextLimitCache.size} provider/model pairs`);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
logger.warn("[ModelContextLimit] Error refreshing providers cache:", error);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
providersFetchInFlight = null;
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
await providersFetchInFlight;
|
|
45
|
+
}
|
|
46
|
+
export async function getModelContextLimit(providerID, modelID) {
|
|
47
|
+
if (!providerID || !modelID) {
|
|
48
|
+
return DEFAULT_CONTEXT_LIMIT;
|
|
49
|
+
}
|
|
50
|
+
const cacheKey = getModelKey(providerID, modelID);
|
|
51
|
+
const cachedLimit = contextLimitCache.get(cacheKey);
|
|
52
|
+
if (cachedLimit) {
|
|
53
|
+
return cachedLimit;
|
|
54
|
+
}
|
|
55
|
+
await refreshContextLimitCache();
|
|
56
|
+
return contextLimitCache.get(cacheKey) ?? DEFAULT_CONTEXT_LIMIT;
|
|
57
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { t } from "../i18n/index.js";
|
|
2
|
+
export const DEFAULT_CONTEXT_LIMIT = 200000;
|
|
3
|
+
export function formatTokenCount(count) {
|
|
4
|
+
if (count >= 1000000) {
|
|
5
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
6
|
+
}
|
|
7
|
+
if (count >= 1000) {
|
|
8
|
+
return `${Math.round(count / 1000)}K`;
|
|
9
|
+
}
|
|
10
|
+
return count.toString();
|
|
11
|
+
}
|
|
12
|
+
export function formatModelDisplayName(providerID, modelID) {
|
|
13
|
+
if (providerID && modelID) {
|
|
14
|
+
return `${providerID}/${modelID}`;
|
|
15
|
+
}
|
|
16
|
+
return t("pinned.unknown");
|
|
17
|
+
}
|
|
18
|
+
export function formatContextLine(tokensUsed, tokensLimit) {
|
|
19
|
+
const safeLimit = typeof tokensLimit === "number" && tokensLimit > 0 ? tokensLimit : null;
|
|
20
|
+
const percentage = safeLimit ? Math.round((tokensUsed / safeLimit) * 100) : 0;
|
|
21
|
+
return t("pinned.line.context", {
|
|
22
|
+
used: formatTokenCount(tokensUsed),
|
|
23
|
+
limit: safeLimit ? formatTokenCount(safeLimit) : t("pinned.unknown"),
|
|
24
|
+
percent: percentage,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function formatCostLine(cost) {
|
|
28
|
+
return t("pinned.line.cost", { cost: `$${cost.toFixed(2)}` });
|
|
29
|
+
}
|