@grinev/opencode-telegram-bot 0.13.1 → 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.
@@ -0,0 +1,285 @@
1
+ import { logger } from "../../utils/logger.js";
2
+ const TELEGRAM_MESSAGE_SAFE_LENGTH = 4000;
3
+ function getErrorMessage(error) {
4
+ if (error instanceof Error) {
5
+ return error.message;
6
+ }
7
+ return String(error);
8
+ }
9
+ function getRetryAfterMs(error) {
10
+ const message = getErrorMessage(error);
11
+ if (!/\b429\b/.test(message)) {
12
+ return null;
13
+ }
14
+ const retryMatch = message.match(/retry after\s+(\d+)/i);
15
+ if (!retryMatch) {
16
+ return null;
17
+ }
18
+ const seconds = Number.parseInt(retryMatch[1], 10);
19
+ if (!Number.isFinite(seconds) || seconds <= 0) {
20
+ return null;
21
+ }
22
+ return seconds * 1000;
23
+ }
24
+ function delay(ms) {
25
+ return new Promise((resolve) => {
26
+ setTimeout(resolve, ms);
27
+ });
28
+ }
29
+ function splitLongText(text, limit) {
30
+ if (text.length <= limit) {
31
+ return [text];
32
+ }
33
+ const chunks = [];
34
+ let remaining = text;
35
+ while (remaining.length > limit) {
36
+ let splitIndex = remaining.lastIndexOf("\n", limit);
37
+ if (splitIndex <= 0 || splitIndex < Math.floor(limit * 0.5)) {
38
+ splitIndex = limit;
39
+ }
40
+ chunks.push(remaining.slice(0, splitIndex));
41
+ remaining = remaining.slice(splitIndex).replace(/^\n+/, "");
42
+ }
43
+ if (remaining.length > 0) {
44
+ chunks.push(remaining);
45
+ }
46
+ return chunks;
47
+ }
48
+ function buildParts(entries) {
49
+ const text = entries
50
+ .map((entry) => entry.text.trim())
51
+ .filter(Boolean)
52
+ .join("\n\n");
53
+ if (!text) {
54
+ return [];
55
+ }
56
+ return splitLongText(text, TELEGRAM_MESSAGE_SAFE_LENGTH).filter(Boolean);
57
+ }
58
+ export class ToolCallStreamer {
59
+ throttleMs;
60
+ sendText;
61
+ editText;
62
+ deleteText;
63
+ states = new Map();
64
+ allStates = new Set();
65
+ constructor(options) {
66
+ this.throttleMs = Math.max(0, Math.floor(options.throttleMs));
67
+ this.sendText = options.sendText;
68
+ this.editText = options.editText;
69
+ this.deleteText = options.deleteText;
70
+ }
71
+ append(sessionId, text) {
72
+ const normalizedText = text.trim();
73
+ if (!sessionId || !normalizedText) {
74
+ return;
75
+ }
76
+ const state = this.getOrCreateState(sessionId);
77
+ state.entries.push({ text: normalizedText });
78
+ state.latestParts = buildParts(state.entries);
79
+ this.ensureTimer(state);
80
+ }
81
+ replaceByPrefix(sessionId, prefix, text) {
82
+ const normalizedPrefix = prefix.trim();
83
+ const normalizedText = text.trim();
84
+ if (!sessionId || !normalizedPrefix || !normalizedText) {
85
+ return;
86
+ }
87
+ const state = this.getOrCreateState(sessionId);
88
+ const existingEntry = state.entries.find((entry) => entry.prefix === normalizedPrefix);
89
+ if (existingEntry) {
90
+ existingEntry.text = normalizedText;
91
+ }
92
+ else {
93
+ state.entries.push({ prefix: normalizedPrefix, text: normalizedText });
94
+ }
95
+ state.latestParts = buildParts(state.entries);
96
+ this.ensureTimer(state);
97
+ }
98
+ async flushSession(sessionId, reason) {
99
+ const state = this.states.get(sessionId);
100
+ if (!state) {
101
+ return;
102
+ }
103
+ this.clearTimer(state);
104
+ await this.enqueueTask(state, () => this.syncState(state, reason));
105
+ }
106
+ async breakSession(sessionId, reason) {
107
+ const state = this.states.get(sessionId);
108
+ if (!state) {
109
+ return;
110
+ }
111
+ state.isBreaking = true;
112
+ this.getOrCreateState(sessionId);
113
+ this.clearTimer(state);
114
+ await this.enqueueTask(state, () => this.syncState(state, reason));
115
+ this.cancelState(state);
116
+ this.removeState(state);
117
+ logger.debug(`[ToolCallStreamer] Broke session stream: session=${sessionId}, reason=${reason}`);
118
+ }
119
+ clearSession(sessionId, reason) {
120
+ let clearedAny = false;
121
+ for (const state of Array.from(this.allStates)) {
122
+ if (state.sessionId !== sessionId) {
123
+ continue;
124
+ }
125
+ this.cancelState(state);
126
+ this.removeState(state);
127
+ clearedAny = true;
128
+ }
129
+ if (clearedAny) {
130
+ logger.debug(`[ToolCallStreamer] Cleared session stream: session=${sessionId}, reason=${reason}`);
131
+ }
132
+ }
133
+ clearAll(reason) {
134
+ for (const state of Array.from(this.allStates)) {
135
+ this.cancelState(state);
136
+ }
137
+ const count = this.allStates.size;
138
+ this.states.clear();
139
+ this.allStates.clear();
140
+ if (count > 0) {
141
+ logger.debug(`[ToolCallStreamer] Cleared all streams: count=${count}, reason=${reason}`);
142
+ }
143
+ }
144
+ getOrCreateState(sessionId) {
145
+ const existing = this.states.get(sessionId);
146
+ if (existing && !existing.isBroken && !existing.cancelled && !existing.isBreaking) {
147
+ return existing;
148
+ }
149
+ if (existing && (existing.isBroken || existing.cancelled)) {
150
+ this.clearTimer(existing);
151
+ this.removeState(existing);
152
+ }
153
+ const state = {
154
+ sessionId,
155
+ entries: [],
156
+ latestParts: [],
157
+ lastSentParts: [],
158
+ telegramMessageIds: [],
159
+ timer: null,
160
+ task: Promise.resolve(true),
161
+ cancelled: false,
162
+ isBroken: false,
163
+ isBreaking: false,
164
+ fatalErrorMessage: null,
165
+ fatalErrorLogged: false,
166
+ };
167
+ this.states.set(sessionId, state);
168
+ this.allStates.add(state);
169
+ return state;
170
+ }
171
+ clearTimer(state) {
172
+ if (!state.timer) {
173
+ return;
174
+ }
175
+ clearTimeout(state.timer);
176
+ state.timer = null;
177
+ }
178
+ ensureTimer(state) {
179
+ if (state.timer || state.isBroken || state.cancelled) {
180
+ return;
181
+ }
182
+ if (this.throttleMs === 0) {
183
+ void this.enqueueTask(state, () => this.syncState(state, "immediate")).catch((error) => {
184
+ logger.error(`[ToolCallStreamer] Immediate sync failed: session=${state.sessionId}`, error);
185
+ });
186
+ return;
187
+ }
188
+ state.timer = setTimeout(() => {
189
+ state.timer = null;
190
+ void this.enqueueTask(state, () => this.syncState(state, "throttle_elapsed")).catch((error) => {
191
+ logger.error(`[ToolCallStreamer] Throttled sync failed: session=${state.sessionId}`, error);
192
+ });
193
+ }, this.throttleMs);
194
+ }
195
+ enqueueTask(state, task) {
196
+ const nextTask = state.task.catch(() => false).then(task);
197
+ state.task = nextTask;
198
+ return nextTask;
199
+ }
200
+ cancelState(state) {
201
+ state.cancelled = true;
202
+ this.clearTimer(state);
203
+ }
204
+ removeState(state) {
205
+ if (this.states.get(state.sessionId) === state) {
206
+ this.states.delete(state.sessionId);
207
+ }
208
+ this.allStates.delete(state);
209
+ }
210
+ async syncState(state, reason) {
211
+ if (state.cancelled) {
212
+ return false;
213
+ }
214
+ if (state.isBroken) {
215
+ return false;
216
+ }
217
+ while (!state.isBroken && !state.cancelled) {
218
+ const parts = state.latestParts;
219
+ const unchanged = parts.length === state.lastSentParts.length &&
220
+ parts.every((part, index) => state.lastSentParts[index] === part);
221
+ if (unchanged) {
222
+ return state.telegramMessageIds.length > 0;
223
+ }
224
+ if (parts.length === 0) {
225
+ return state.telegramMessageIds.length > 0;
226
+ }
227
+ try {
228
+ await this.syncMessages(state, parts);
229
+ if (state.cancelled) {
230
+ return false;
231
+ }
232
+ logger.debug(`[ToolCallStreamer] Stream synced: session=${state.sessionId}, reason=${reason}, parts=${parts.length}`);
233
+ return true;
234
+ }
235
+ catch (error) {
236
+ const retryAfterMs = getRetryAfterMs(error);
237
+ if (retryAfterMs === null) {
238
+ this.markStreamBroken(state, error, reason);
239
+ return false;
240
+ }
241
+ const delayMs = Math.max(this.throttleMs, retryAfterMs);
242
+ logger.warn(`[ToolCallStreamer] Stream sync rate-limited, retrying in ${delayMs}ms: session=${state.sessionId}, reason=${reason}`, error);
243
+ await delay(delayMs);
244
+ }
245
+ }
246
+ return false;
247
+ }
248
+ markStreamBroken(state, error, reason) {
249
+ state.isBroken = true;
250
+ state.fatalErrorMessage = getErrorMessage(error);
251
+ if (state.fatalErrorLogged) {
252
+ return;
253
+ }
254
+ state.fatalErrorLogged = true;
255
+ logger.error(`[ToolCallStreamer] Stream marked as broken: session=${state.sessionId}, reason=${reason}, error=${state.fatalErrorMessage}`, error);
256
+ }
257
+ async syncMessages(state, parts) {
258
+ for (let index = 0; index < parts.length; index++) {
259
+ if (state.cancelled) {
260
+ return;
261
+ }
262
+ const text = parts[index];
263
+ const currentMessageId = state.telegramMessageIds[index];
264
+ if (currentMessageId) {
265
+ await this.editText(state.sessionId, currentMessageId, text);
266
+ state.lastSentParts[index] = text;
267
+ continue;
268
+ }
269
+ const messageId = await this.sendText(state.sessionId, text);
270
+ state.telegramMessageIds[index] = messageId;
271
+ state.lastSentParts[index] = text;
272
+ }
273
+ for (let index = state.telegramMessageIds.length - 1; index >= parts.length; index--) {
274
+ if (state.cancelled) {
275
+ return;
276
+ }
277
+ const messageId = state.telegramMessageIds[index];
278
+ if (messageId) {
279
+ await this.deleteText(state.sessionId, messageId);
280
+ }
281
+ state.telegramMessageIds.pop();
282
+ state.lastSentParts.pop();
283
+ }
284
+ }
285
+ }
@@ -1,16 +1,25 @@
1
- export async function finalizeAssistantResponse({ responseStreaming, sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, resolveFormat, getReplyKeyboard, sendText, }) {
2
- let streamedViaMessages = false;
3
- if (responseStreaming) {
4
- const preparedStreamPayload = prepareStreamingPayload(messageText);
5
- if (preparedStreamPayload) {
6
- preparedStreamPayload.sendOptions = undefined;
7
- preparedStreamPayload.editOptions = undefined;
8
- }
9
- streamedViaMessages = await responseStreamer.complete(sessionId, messageId, preparedStreamPayload ?? undefined);
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
- if (streamedViaMessages) {
13
- return true;
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
  }
@@ -4,9 +4,5 @@ export function deliverThinkingMessage(sessionId, batcher, options) {
4
4
  return;
5
5
  }
6
6
  const message = t("bot.thinking");
7
- if (options.responseStreaming) {
8
- batcher.sendTextNow(sessionId, message, "thinking_started_streaming");
9
- return;
10
- }
11
- batcher.enqueue(sessionId, message);
7
+ batcher.sendTextNow(sessionId, message, "thinking_started");
12
8
  }
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",
@@ -273,7 +285,7 @@ export const de = {
273
285
  "task.schedule_too_frequent": "Der wiederkehrende Zeitplan ist zu häufig. Das minimale erlaubte Intervall ist einmal alle 5 Minuten.",
274
286
  "task.kind.cron": "wiederkehrend",
275
287
  "task.kind.once": "einmalig",
276
- "task.run.success": "⏰ Geplante Aufgabe abgeschlossen: {description}\n\n{result}",
288
+ "task.run.success": "⏰ Geplante Aufgabe abgeschlossen: {description}",
277
289
  "task.run.error": "🔴 Geplante Aufgabe fehlgeschlagen: {description}\n\nFehler: {error}",
278
290
  "tasklist.empty": "📭 Noch keine geplanten Aufgaben.",
279
291
  "tasklist.select": "Wähle eine geplante Aufgabe:",
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",
@@ -273,7 +285,7 @@ export const en = {
273
285
  "task.schedule_too_frequent": "Recurring schedule is too frequent. The minimum allowed interval is once every 5 minutes.",
274
286
  "task.kind.cron": "recurring",
275
287
  "task.kind.once": "one-time",
276
- "task.run.success": "⏰ Scheduled task completed: {description}\n\n{result}",
288
+ "task.run.success": "⏰ Scheduled task completed: {description}",
277
289
  "task.run.error": "🔴 Scheduled task failed: {description}\n\nError: {error}",
278
290
  "tasklist.empty": "📭 No scheduled tasks yet.",
279
291
  "tasklist.select": "Select a scheduled task:",
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",
@@ -273,7 +285,7 @@ export const es = {
273
285
  "task.schedule_too_frequent": "El horario recurrente es demasiado frecuente. El intervalo mínimo permitido es una vez cada 5 minutos.",
274
286
  "task.kind.cron": "recurrente",
275
287
  "task.kind.once": "única",
276
- "task.run.success": "⏰ Tarea programada completada: {description}\n\n{result}",
288
+ "task.run.success": "⏰ Tarea programada completada: {description}",
277
289
  "task.run.error": "🔴 La tarea programada falló: {description}\n\nError: {error}",
278
290
  "tasklist.empty": "📭 Aún no hay tareas programadas.",
279
291
  "tasklist.select": "Elige una tarea programada:",
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}",
@@ -273,7 +285,7 @@ export const fr = {
273
285
  "task.schedule_too_frequent": "Le planning récurrent est trop fréquent. L'intervalle minimum autorisé est d'une fois toutes les 5 minutes.",
274
286
  "task.kind.cron": "récurrente",
275
287
  "task.kind.once": "ponctuelle",
276
- "task.run.success": "⏰ Tâche planifiée terminée : {description}\n\n{result}",
288
+ "task.run.success": "⏰ Tâche planifiée terminée : {description}",
277
289
  "task.run.error": "🔴 Échec de la tâche planifiée : {description}\n\nErreur : {error}",
278
290
  "tasklist.empty": "📭 Aucune tâche planifiée pour le moment.",
279
291
  "tasklist.select": "Sélectionnez une tâche planifiée :",
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}",
@@ -273,7 +285,7 @@ export const ru = {
273
285
  "task.schedule_too_frequent": "Повторяющееся расписание слишком частое. Минимально допустимый интервал - один запуск в 5 минут.",
274
286
  "task.kind.cron": "повторяющаяся",
275
287
  "task.kind.once": "однократная",
276
- "task.run.success": "⏰ Задача по расписанию выполнена: {description}\n\n{result}",
288
+ "task.run.success": "⏰ Задача по расписанию выполнена: {description}",
277
289
  "task.run.error": "🔴 Ошибка выполнения задачи по расписанию: {description}\n\nОшибка: {error}",
278
290
  "tasklist.empty": "📭 Задач по расписанию пока нет.",
279
291
  "tasklist.select": "Выберите задачу по расписанию:",
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} 个",
@@ -273,7 +285,7 @@ export const zh = {
273
285
  "task.schedule_too_frequent": "重复任务过于频繁。最小允许间隔为每 5 分钟一次。",
274
286
  "task.kind.cron": "重复",
275
287
  "task.kind.once": "一次性",
276
- "task.run.success": "⏰ 定时任务已完成: {description}\n\n{result}",
288
+ "task.run.success": "⏰ 定时任务已完成: {description}",
277
289
  "task.run.error": "🔴 定时任务执行失败: {description}\n\n错误: {error}",
278
290
  "tasklist.empty": "📭 还没有定时任务。",
279
291
  "tasklist.select": "请选择一个定时任务:",
@@ -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
+ }