@grinev/opencode-telegram-bot 0.18.0 → 0.19.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.
- package/.env.example +22 -1
- package/README.md +20 -5
- package/dist/app/start-bot-app.js +4 -0
- package/dist/attach/service.js +6 -1
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/mcps.js +394 -0
- package/dist/bot/commands/opencode-start.js +2 -10
- package/dist/bot/commands/projects.js +5 -4
- package/dist/bot/commands/tasklist.js +3 -1
- package/dist/bot/handlers/prompt.js +15 -14
- package/dist/bot/index.js +6 -2
- package/dist/config.js +25 -6
- package/dist/i18n/de.js +21 -0
- package/dist/i18n/en.js +21 -0
- package/dist/i18n/es.js +21 -0
- package/dist/i18n/fr.js +21 -0
- package/dist/i18n/ru.js +21 -0
- package/dist/i18n/zh.js +21 -0
- package/dist/opencode/auto-restart.js +92 -0
- package/dist/opencode/process.js +18 -1
- package/dist/pinned/manager.js +21 -0
- package/dist/scheduled-task/executor.js +155 -8
- package/dist/scheduled-task/runtime.js +30 -5
- package/dist/session/cache-manager.js +19 -6
- package/dist/summary/aggregator.js +35 -0
- package/dist/summary/formatter.js +2 -2
- package/dist/summary/markdown-to-telegram-v2.js +99 -0
- package/dist/summary/subagent-formatter.js +23 -2
- package/dist/telegram/render/inline-renderer.js +18 -0
- package/dist/tts/client.js +94 -19
- package/dist/utils/logger.js +47 -1
- package/package.json +2 -2
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
+
import { t } from "../i18n/index.js";
|
|
2
3
|
import { opencodeClient } from "../opencode/client.js";
|
|
3
4
|
import { logger } from "../utils/logger.js";
|
|
4
|
-
const SCHEDULED_TASK_AGENT = "build";
|
|
5
|
+
export const SCHEDULED_TASK_AGENT = "build";
|
|
5
6
|
const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
|
|
6
7
|
const EXECUTION_POLL_INTERVAL_MS = 2000;
|
|
7
8
|
const MAX_IDLE_POLLS_WITHOUT_RESULT = 3;
|
|
9
|
+
const COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS = 500;
|
|
10
|
+
const MAX_COMPLETED_EMPTY_RESULT_RECHECKS = 3;
|
|
8
11
|
const MODELS_DOCS_URL = "https://opencode.ai/docs/config/#models";
|
|
9
12
|
const EXECUTION_TIMEOUT_ERROR_PREFIX = "Scheduled task exceeded bot execution timeout";
|
|
13
|
+
const INTERACTIVE_PERMISSION_REJECT_MESSAGE = "Scheduled task cannot continue because it requires interactive permission.";
|
|
14
|
+
class ScheduledTaskEmptyAssistantResponseError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super("Scheduled task returned an empty assistant response");
|
|
17
|
+
this.name = "ScheduledTaskEmptyAssistantResponseError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
class ScheduledTaskInteractiveRequestError extends Error {
|
|
21
|
+
constructor(kind) {
|
|
22
|
+
super(t(kind === "question"
|
|
23
|
+
? "task.run.error.interactive_question"
|
|
24
|
+
: "task.run.error.interactive_permission"));
|
|
25
|
+
this.name = "ScheduledTaskInteractiveRequestError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
10
28
|
function collectResponseText(parts) {
|
|
11
29
|
return parts
|
|
12
30
|
.filter((part) => part.type === "text" && typeof part.text === "string" && !part.ignored)
|
|
@@ -69,7 +87,7 @@ function sleep(ms) {
|
|
|
69
87
|
function findLatestAssistantMessage(messages) {
|
|
70
88
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
71
89
|
const message = messages[index];
|
|
72
|
-
if (message?.info.role === "assistant") {
|
|
90
|
+
if (message?.info.role === "assistant" && !message.info.summary) {
|
|
73
91
|
return message;
|
|
74
92
|
}
|
|
75
93
|
}
|
|
@@ -77,7 +95,7 @@ function findLatestAssistantMessage(messages) {
|
|
|
77
95
|
}
|
|
78
96
|
function extractAssistantResult(message) {
|
|
79
97
|
if (!message) {
|
|
80
|
-
return { resultText: null, errorMessage: null, completed: false };
|
|
98
|
+
return { resultText: null, errorMessage: null, completed: false, message: null };
|
|
81
99
|
}
|
|
82
100
|
const errorMessage = extractErrorMessage(message.info.error);
|
|
83
101
|
if (errorMessage) {
|
|
@@ -85,6 +103,7 @@ function extractAssistantResult(message) {
|
|
|
85
103
|
resultText: null,
|
|
86
104
|
errorMessage: normalizeScheduledTaskErrorMessage(errorMessage),
|
|
87
105
|
completed: true,
|
|
106
|
+
message,
|
|
88
107
|
};
|
|
89
108
|
}
|
|
90
109
|
const resultText = collectResponseText(message.parts);
|
|
@@ -92,8 +111,116 @@ function extractAssistantResult(message) {
|
|
|
92
111
|
resultText,
|
|
93
112
|
errorMessage: null,
|
|
94
113
|
completed: Boolean(message.info.time?.completed),
|
|
114
|
+
message,
|
|
95
115
|
};
|
|
96
116
|
}
|
|
117
|
+
function summarizeAssistantParts(parts) {
|
|
118
|
+
return parts.map((part) => ({
|
|
119
|
+
id: part.id,
|
|
120
|
+
type: part.type,
|
|
121
|
+
ignored: part.ignored,
|
|
122
|
+
...(typeof part.text === "string" ? { textLength: part.text.length } : {}),
|
|
123
|
+
...(part.tool ? { tool: part.tool } : {}),
|
|
124
|
+
...(part.state?.status ? { status: part.state.status } : {}),
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
function logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, message, readCount) {
|
|
128
|
+
logger.warn("[ScheduledTaskExecutor] Empty completed assistant response diagnostics", {
|
|
129
|
+
taskId,
|
|
130
|
+
sessionId,
|
|
131
|
+
directory,
|
|
132
|
+
readCount,
|
|
133
|
+
assistantMessage: message
|
|
134
|
+
? {
|
|
135
|
+
id: message.info.id,
|
|
136
|
+
completed: Boolean(message.info.time?.completed),
|
|
137
|
+
summary: Boolean(message.info.summary),
|
|
138
|
+
errorMessage: extractErrorMessage(message.info.error),
|
|
139
|
+
parts: summarizeAssistantParts(message.parts),
|
|
140
|
+
}
|
|
141
|
+
: null,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async function loadPendingInteractiveRequest(sessionId, directory) {
|
|
145
|
+
const [questionsResult, permissionsResult] = await Promise.all([
|
|
146
|
+
opencodeClient.question.list({ directory }),
|
|
147
|
+
opencodeClient.permission.list({ directory }),
|
|
148
|
+
]);
|
|
149
|
+
if (questionsResult.error) {
|
|
150
|
+
logger.warn(`[ScheduledTaskExecutor] Failed to list pending questions: sessionId=${sessionId}`, questionsResult.error);
|
|
151
|
+
}
|
|
152
|
+
const question = questionsResult.data?.find((request) => request.sessionID === sessionId);
|
|
153
|
+
if (question) {
|
|
154
|
+
return { kind: "question", request: question };
|
|
155
|
+
}
|
|
156
|
+
if (permissionsResult.error) {
|
|
157
|
+
logger.warn(`[ScheduledTaskExecutor] Failed to list pending permissions: sessionId=${sessionId}`, permissionsResult.error);
|
|
158
|
+
}
|
|
159
|
+
const permission = permissionsResult.data?.find((request) => request.sessionID === sessionId);
|
|
160
|
+
if (permission) {
|
|
161
|
+
return { kind: "permission", request: permission };
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
async function rejectInteractiveRequest(request, directory) {
|
|
166
|
+
try {
|
|
167
|
+
if (request.kind === "question") {
|
|
168
|
+
const { error } = await opencodeClient.question.reject({
|
|
169
|
+
requestID: request.request.id,
|
|
170
|
+
directory,
|
|
171
|
+
});
|
|
172
|
+
if (error) {
|
|
173
|
+
logger.warn(`[ScheduledTaskExecutor] Failed to reject pending question: requestId=${request.request.id}`, error);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const { error } = await opencodeClient.permission.reply({
|
|
178
|
+
requestID: request.request.id,
|
|
179
|
+
directory,
|
|
180
|
+
reply: "reject",
|
|
181
|
+
message: INTERACTIVE_PERMISSION_REJECT_MESSAGE,
|
|
182
|
+
});
|
|
183
|
+
if (error) {
|
|
184
|
+
logger.warn(`[ScheduledTaskExecutor] Failed to reject pending permission: requestId=${request.request.id}`, error);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
logger.warn(`[ScheduledTaskExecutor] Failed to reject pending interactive request: requestId=${request.request.id}`, error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function abortScheduledTaskSession(sessionId, directory) {
|
|
192
|
+
try {
|
|
193
|
+
const { error } = await opencodeClient.session.abort({ sessionID: sessionId, directory });
|
|
194
|
+
if (error) {
|
|
195
|
+
logger.warn(`[ScheduledTaskExecutor] Failed to abort interactive scheduled task session: sessionId=${sessionId}`, error);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
logger.warn(`[ScheduledTaskExecutor] Failed to abort interactive scheduled task session: sessionId=${sessionId}`, error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function failIfInteractiveRequest(taskId, sessionId, directory) {
|
|
203
|
+
const interactiveRequest = await loadPendingInteractiveRequest(sessionId, directory);
|
|
204
|
+
if (!interactiveRequest) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
logger.warn("[ScheduledTaskExecutor] Scheduled task requested interactive action", {
|
|
208
|
+
taskId,
|
|
209
|
+
sessionId,
|
|
210
|
+
directory,
|
|
211
|
+
kind: interactiveRequest.kind,
|
|
212
|
+
requestId: interactiveRequest.request.id,
|
|
213
|
+
...(interactiveRequest.kind === "question"
|
|
214
|
+
? { questionCount: interactiveRequest.request.questions?.length ?? 0 }
|
|
215
|
+
: {
|
|
216
|
+
permission: interactiveRequest.request.permission,
|
|
217
|
+
patterns: interactiveRequest.request.patterns,
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
await rejectInteractiveRequest(interactiveRequest, directory);
|
|
221
|
+
await abortScheduledTaskSession(sessionId, directory);
|
|
222
|
+
throw new ScheduledTaskInteractiveRequestError(interactiveRequest.kind);
|
|
223
|
+
}
|
|
97
224
|
async function loadAssistantResult(sessionId, directory) {
|
|
98
225
|
const { data: messages, error: messagesError } = await opencodeClient.session.messages({
|
|
99
226
|
sessionID: sessionId,
|
|
@@ -104,14 +231,16 @@ async function loadAssistantResult(sessionId, directory) {
|
|
|
104
231
|
}
|
|
105
232
|
return extractAssistantResult(findLatestAssistantMessage(messages));
|
|
106
233
|
}
|
|
107
|
-
async function waitForScheduledTaskResult(sessionId, directory) {
|
|
234
|
+
async function waitForScheduledTaskResult(taskId, sessionId, directory) {
|
|
108
235
|
const startedAtMs = Date.now();
|
|
109
236
|
const executionTimeoutMs = getExecutionTimeoutMs();
|
|
110
237
|
let idlePollsWithoutResult = 0;
|
|
238
|
+
let completedEmptyResultReadCount = 0;
|
|
111
239
|
while (true) {
|
|
112
240
|
if (Date.now() - startedAtMs >= executionTimeoutMs) {
|
|
113
241
|
throw new Error(createExecutionTimeoutMessage());
|
|
114
242
|
}
|
|
243
|
+
await failIfInteractiveRequest(taskId, sessionId, directory);
|
|
115
244
|
const assistantResult = await loadAssistantResult(sessionId, directory);
|
|
116
245
|
if (assistantResult.errorMessage) {
|
|
117
246
|
throw new Error(assistantResult.errorMessage);
|
|
@@ -120,8 +249,15 @@ async function waitForScheduledTaskResult(sessionId, directory) {
|
|
|
120
249
|
if (assistantResult.resultText) {
|
|
121
250
|
return assistantResult.resultText;
|
|
122
251
|
}
|
|
123
|
-
|
|
252
|
+
completedEmptyResultReadCount += 1;
|
|
253
|
+
if (completedEmptyResultReadCount > MAX_COMPLETED_EMPTY_RESULT_RECHECKS) {
|
|
254
|
+
logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, assistantResult.message, completedEmptyResultReadCount);
|
|
255
|
+
throw new ScheduledTaskEmptyAssistantResponseError();
|
|
256
|
+
}
|
|
257
|
+
await sleep(COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS);
|
|
258
|
+
continue;
|
|
124
259
|
}
|
|
260
|
+
completedEmptyResultReadCount = 0;
|
|
125
261
|
const { data: statuses, error: statusError } = await opencodeClient.session.status({
|
|
126
262
|
directory,
|
|
127
263
|
});
|
|
@@ -138,7 +274,13 @@ async function waitForScheduledTaskResult(sessionId, directory) {
|
|
|
138
274
|
if (confirmedAssistantResult.resultText) {
|
|
139
275
|
return confirmedAssistantResult.resultText;
|
|
140
276
|
}
|
|
141
|
-
|
|
277
|
+
completedEmptyResultReadCount += 1;
|
|
278
|
+
if (completedEmptyResultReadCount > MAX_COMPLETED_EMPTY_RESULT_RECHECKS) {
|
|
279
|
+
logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, confirmedAssistantResult.message, completedEmptyResultReadCount);
|
|
280
|
+
throw new ScheduledTaskEmptyAssistantResponseError();
|
|
281
|
+
}
|
|
282
|
+
await sleep(COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS);
|
|
283
|
+
continue;
|
|
142
284
|
}
|
|
143
285
|
idlePollsWithoutResult += 1;
|
|
144
286
|
if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
|
|
@@ -154,6 +296,7 @@ async function waitForScheduledTaskResult(sessionId, directory) {
|
|
|
154
296
|
export async function executeScheduledTask(task) {
|
|
155
297
|
const startedAt = new Date().toISOString();
|
|
156
298
|
let sessionId = null;
|
|
299
|
+
let deleteTemporarySession = true;
|
|
157
300
|
try {
|
|
158
301
|
const { data: session, error: createError } = await opencodeClient.session.create({
|
|
159
302
|
directory: task.projectWorktree,
|
|
@@ -182,7 +325,7 @@ export async function executeScheduledTask(task) {
|
|
|
182
325
|
if (promptError) {
|
|
183
326
|
throw promptError || new Error("Scheduled task prompt execution failed");
|
|
184
327
|
}
|
|
185
|
-
const resultText = await waitForScheduledTaskResult(session.id, session.directory);
|
|
328
|
+
const resultText = await waitForScheduledTaskResult(task.id, session.id, session.directory);
|
|
186
329
|
return {
|
|
187
330
|
taskId: task.id,
|
|
188
331
|
status: "success",
|
|
@@ -194,6 +337,10 @@ export async function executeScheduledTask(task) {
|
|
|
194
337
|
}
|
|
195
338
|
catch (error) {
|
|
196
339
|
const errorMessage = toErrorMessage(error);
|
|
340
|
+
if (error instanceof ScheduledTaskEmptyAssistantResponseError && sessionId) {
|
|
341
|
+
deleteTemporarySession = false;
|
|
342
|
+
logger.warn(`[ScheduledTaskExecutor] Keeping temporary session for inspection: id=${task.id}, sessionId=${sessionId}`);
|
|
343
|
+
}
|
|
197
344
|
logger.warn(`[ScheduledTaskExecutor] Task execution failed: id=${task.id}, message=${errorMessage}`);
|
|
198
345
|
return {
|
|
199
346
|
taskId: task.id,
|
|
@@ -205,7 +352,7 @@ export async function executeScheduledTask(task) {
|
|
|
205
352
|
};
|
|
206
353
|
}
|
|
207
354
|
finally {
|
|
208
|
-
if (sessionId) {
|
|
355
|
+
if (sessionId && deleteTemporarySession) {
|
|
209
356
|
try {
|
|
210
357
|
await opencodeClient.session.delete({ sessionID: sessionId });
|
|
211
358
|
}
|
|
@@ -4,7 +4,8 @@ import { t } from "../i18n/index.js";
|
|
|
4
4
|
import { logger } from "../utils/logger.js";
|
|
5
5
|
import { safeBackgroundTask } from "../utils/safe-background-task.js";
|
|
6
6
|
import { sendBotText } from "../bot/utils/telegram-text.js";
|
|
7
|
-
import {
|
|
7
|
+
import { formatAssistantRunFooter } from "../bot/utils/assistant-run-footer.js";
|
|
8
|
+
import { executeScheduledTask, SCHEDULED_TASK_AGENT } from "./executor.js";
|
|
8
9
|
import { foregroundSessionState } from "./foreground-state.js";
|
|
9
10
|
import { computeNextRunAt, isTaskDue } from "./next-run.js";
|
|
10
11
|
import { getScheduledTask, listScheduledTasks, removeScheduledTask, replaceScheduledTasks, updateScheduledTask, } from "./store.js";
|
|
@@ -40,7 +41,15 @@ function normalizeTaskPrompt(prompt) {
|
|
|
40
41
|
}
|
|
41
42
|
return `${normalized.slice(0, TASK_DESCRIPTION_PREVIEW_LENGTH)}...`;
|
|
42
43
|
}
|
|
43
|
-
function
|
|
44
|
+
function calculateElapsedMs(startedAt, finishedAt) {
|
|
45
|
+
const startedAtMs = Date.parse(startedAt);
|
|
46
|
+
const finishedAtMs = Date.parse(finishedAt);
|
|
47
|
+
if (Number.isNaN(startedAtMs) || Number.isNaN(finishedAtMs)) {
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
return finishedAtMs - startedAtMs;
|
|
51
|
+
}
|
|
52
|
+
function buildSuccessDelivery(task, startedAt, runAt, resultText) {
|
|
44
53
|
return {
|
|
45
54
|
taskId: task.id,
|
|
46
55
|
scheduleSummary: task.scheduleSummary,
|
|
@@ -51,6 +60,12 @@ function buildSuccessDelivery(task, runAt, resultText) {
|
|
|
51
60
|
description: normalizeTaskPrompt(task.prompt),
|
|
52
61
|
}),
|
|
53
62
|
resultText,
|
|
63
|
+
footerText: formatAssistantRunFooter({
|
|
64
|
+
agent: SCHEDULED_TASK_AGENT,
|
|
65
|
+
providerID: task.model.providerID,
|
|
66
|
+
modelID: task.model.modelID,
|
|
67
|
+
elapsedMs: calculateElapsedMs(startedAt, runAt),
|
|
68
|
+
}),
|
|
54
69
|
};
|
|
55
70
|
}
|
|
56
71
|
function buildErrorDelivery(task, runAt, errorMessage) {
|
|
@@ -280,7 +295,7 @@ export class ScheduledTaskRuntime {
|
|
|
280
295
|
try {
|
|
281
296
|
const result = await executeScheduledTask(runningTask);
|
|
282
297
|
if (result.status === "success") {
|
|
283
|
-
await this.handleSuccessfulExecution(runningTask, result.finishedAt, result.resultText || "");
|
|
298
|
+
await this.handleSuccessfulExecution(runningTask, result.startedAt, result.finishedAt, result.resultText || "");
|
|
284
299
|
}
|
|
285
300
|
else {
|
|
286
301
|
await this.handleFailedExecution(runningTask, result.finishedAt, result.errorMessage || "Unknown error");
|
|
@@ -290,8 +305,8 @@ export class ScheduledTaskRuntime {
|
|
|
290
305
|
this.runningTaskIds.delete(taskId);
|
|
291
306
|
}
|
|
292
307
|
}
|
|
293
|
-
async handleSuccessfulExecution(task, finishedAt, resultText) {
|
|
294
|
-
const delivery = buildSuccessDelivery(task, finishedAt, resultText);
|
|
308
|
+
async handleSuccessfulExecution(task, startedAt, finishedAt, resultText) {
|
|
309
|
+
const delivery = buildSuccessDelivery(task, startedAt, finishedAt, resultText);
|
|
295
310
|
if (task.kind === "once") {
|
|
296
311
|
await removeScheduledTask(task.id);
|
|
297
312
|
this.removeTask(task.id);
|
|
@@ -357,12 +372,22 @@ export class ScheduledTaskRuntime {
|
|
|
357
372
|
? buildScheduledTaskSuccessMessageParts(delivery)
|
|
358
373
|
: [delivery.notificationText];
|
|
359
374
|
const format = delivery.status === "success" ? getScheduledTaskDeliveryFormat() : "raw";
|
|
375
|
+
const suppressResultNotification = delivery.status === "success" && Boolean(delivery.footerText);
|
|
360
376
|
for (const part of messageParts) {
|
|
361
377
|
await sendBotText({
|
|
362
378
|
api: this.botApi,
|
|
363
379
|
chatId: this.chatId,
|
|
364
380
|
text: part,
|
|
365
381
|
format,
|
|
382
|
+
...(suppressResultNotification ? { options: { disable_notification: true } } : {}),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
if (delivery.status === "success" && delivery.footerText) {
|
|
386
|
+
await sendBotText({
|
|
387
|
+
api: this.botApi,
|
|
388
|
+
chatId: this.chatId,
|
|
389
|
+
text: delivery.footerText,
|
|
390
|
+
format: "raw",
|
|
366
391
|
});
|
|
367
392
|
}
|
|
368
393
|
return true;
|
|
@@ -136,9 +136,8 @@ function upsertDirectory(worktree, lastUpdated) {
|
|
|
136
136
|
dedupeAndTrimDirectories(cacheData);
|
|
137
137
|
return true;
|
|
138
138
|
}
|
|
139
|
-
function buildListParams() {
|
|
140
|
-
|
|
141
|
-
if (!hasWatermark) {
|
|
139
|
+
function buildListParams(options) {
|
|
140
|
+
if (options?.force || cacheData.lastSyncedUpdatedAt === 0) {
|
|
142
141
|
return { limit: INITIAL_WARMUP_LIMIT };
|
|
143
142
|
}
|
|
144
143
|
return {
|
|
@@ -194,24 +193,38 @@ function isServerUnavailableError(error) {
|
|
|
194
193
|
}
|
|
195
194
|
return false;
|
|
196
195
|
}
|
|
197
|
-
async function runSync() {
|
|
196
|
+
async function runSync(options) {
|
|
198
197
|
await ensureCacheLoaded();
|
|
199
|
-
const
|
|
198
|
+
const shouldPrune = options?.force || cacheData.lastSyncedUpdatedAt === 0;
|
|
199
|
+
const params = buildListParams(options);
|
|
200
200
|
const { data: sessions, error } = await opencodeClient.session.list(params);
|
|
201
201
|
if (error || !sessions) {
|
|
202
202
|
throw error || new Error("No session list received from server");
|
|
203
203
|
}
|
|
204
204
|
let changed = false;
|
|
205
205
|
let maxUpdated = cacheData.lastSyncedUpdatedAt;
|
|
206
|
+
const seenDirectories = new Set();
|
|
206
207
|
for (const session of sessions) {
|
|
207
208
|
const updatedAt = session.time?.updated ?? Date.now();
|
|
208
209
|
if (upsertDirectory(session.directory, updatedAt)) {
|
|
209
210
|
changed = true;
|
|
210
211
|
}
|
|
212
|
+
if (session.directory && isValidWorktree(session.directory)) {
|
|
213
|
+
seenDirectories.add(worktreeKey(session.directory.trim()));
|
|
214
|
+
}
|
|
211
215
|
if (updatedAt > maxUpdated) {
|
|
212
216
|
maxUpdated = updatedAt;
|
|
213
217
|
}
|
|
214
218
|
}
|
|
219
|
+
const responseIsTruncated = sessions.length >= INITIAL_WARMUP_LIMIT;
|
|
220
|
+
if (shouldPrune && !responseIsTruncated) {
|
|
221
|
+
const before = cacheData.directories.length;
|
|
222
|
+
cacheData.directories = cacheData.directories.filter((d) => seenDirectories.has(worktreeKey(d.worktree)));
|
|
223
|
+
if (cacheData.directories.length !== before) {
|
|
224
|
+
changed = true;
|
|
225
|
+
logger.info(`[SessionCache] Pruned ${before - cacheData.directories.length} stale directories from cache`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
215
228
|
if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
|
|
216
229
|
cacheData.lastSyncedUpdatedAt = maxUpdated;
|
|
217
230
|
changed = true;
|
|
@@ -397,7 +410,7 @@ export async function syncSessionDirectoryCache(options) {
|
|
|
397
410
|
if (syncInFlight) {
|
|
398
411
|
return syncInFlight;
|
|
399
412
|
}
|
|
400
|
-
syncInFlight = runSync()
|
|
413
|
+
syncInFlight = runSync(options)
|
|
401
414
|
.then(() => {
|
|
402
415
|
lastSyncAttemptAt = Date.now();
|
|
403
416
|
})
|
|
@@ -24,6 +24,17 @@ function countDiffChangesFromText(text) {
|
|
|
24
24
|
}
|
|
25
25
|
return { additions, deletions };
|
|
26
26
|
}
|
|
27
|
+
function normalizeSnapshotValue(value) {
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
return value.map((item) => normalizeSnapshotValue(item));
|
|
30
|
+
}
|
|
31
|
+
if (value && typeof value === "object") {
|
|
32
|
+
return Object.fromEntries(Object.entries(value)
|
|
33
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
34
|
+
.map(([key, entryValue]) => [key, normalizeSnapshotValue(entryValue)]));
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
27
38
|
class SummaryAggregator {
|
|
28
39
|
currentSessionId = null;
|
|
29
40
|
textMessageStates = new Map();
|
|
@@ -65,6 +76,7 @@ class SummaryAggregator {
|
|
|
65
76
|
pendingSubagentCardIdsByParent = new Map();
|
|
66
77
|
pendingChildSessionIdsByParent = new Map();
|
|
67
78
|
fallbackSubagentCardIdsByParent = new Map();
|
|
79
|
+
lastSubagentSnapshot = "";
|
|
68
80
|
setBotAndChatId(bot, chatId) {
|
|
69
81
|
this.bot = bot;
|
|
70
82
|
this.chatId = chatId;
|
|
@@ -239,6 +251,7 @@ class SummaryAggregator {
|
|
|
239
251
|
this.pendingSubagentCardIdsByParent.clear();
|
|
240
252
|
this.pendingChildSessionIdsByParent.clear();
|
|
241
253
|
this.fallbackSubagentCardIdsByParent.clear();
|
|
254
|
+
this.lastSubagentSnapshot = "";
|
|
242
255
|
this.messageCount = 0;
|
|
243
256
|
this.lastUpdated = 0;
|
|
244
257
|
if (this.onClearedCallback) {
|
|
@@ -312,6 +325,28 @@ class SummaryAggregator {
|
|
|
312
325
|
terminalMessage: state.terminalMessage,
|
|
313
326
|
updatedAt: state.updatedAt,
|
|
314
327
|
}));
|
|
328
|
+
const snapshot = JSON.stringify(subagents.map((subagent) => ({
|
|
329
|
+
cardId: subagent.cardId,
|
|
330
|
+
sessionId: subagent.sessionId,
|
|
331
|
+
parentSessionId: subagent.parentSessionId,
|
|
332
|
+
agent: subagent.agent,
|
|
333
|
+
description: subagent.description,
|
|
334
|
+
prompt: subagent.prompt,
|
|
335
|
+
command: subagent.command,
|
|
336
|
+
status: subagent.status,
|
|
337
|
+
providerID: subagent.providerID,
|
|
338
|
+
modelID: subagent.modelID,
|
|
339
|
+
tokens: subagent.tokens,
|
|
340
|
+
cost: subagent.cost,
|
|
341
|
+
currentTool: subagent.currentTool,
|
|
342
|
+
currentToolInput: normalizeSnapshotValue(subagent.currentToolInput),
|
|
343
|
+
currentToolTitle: subagent.currentToolTitle,
|
|
344
|
+
terminalMessage: subagent.terminalMessage,
|
|
345
|
+
})));
|
|
346
|
+
if (snapshot === this.lastSubagentSnapshot) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
this.lastSubagentSnapshot = snapshot;
|
|
315
350
|
this.onSubagentCallback(this.currentSessionId, subagents);
|
|
316
351
|
}
|
|
317
352
|
createSubagentState(parentSessionId, sessionId, cardId = `subagent-${parentSessionId}-${Date.now()}-${this.subagentOrder.length}`) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as path from "path";
|
|
2
|
-
import { convert } from "telegram-markdown-v2";
|
|
3
2
|
import { config } from "../config.js";
|
|
4
3
|
import { logger } from "../utils/logger.js";
|
|
5
4
|
import { t } from "../i18n/index.js";
|
|
6
5
|
import { getCurrentProject } from "../settings/manager.js";
|
|
6
|
+
import { convertToTelegramMarkdownV2 } from "./markdown-to-telegram-v2.js";
|
|
7
7
|
import { normalizeMarkdownForTelegramRendering } from "../telegram/render/markdown-normalizer.js";
|
|
8
8
|
const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
9
9
|
const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
|
|
@@ -90,7 +90,7 @@ export function escapePlainTextForTelegramMarkdownV2(text) {
|
|
|
90
90
|
function formatMarkdownForTelegram(text) {
|
|
91
91
|
try {
|
|
92
92
|
const preprocessed = normalizeMarkdownForTelegramRendering(text);
|
|
93
|
-
return escapeMarkdownV2PipesOutsideCode(
|
|
93
|
+
return escapeMarkdownV2PipesOutsideCode(convertToTelegramMarkdownV2(preprocessed));
|
|
94
94
|
}
|
|
95
95
|
catch (error) {
|
|
96
96
|
logger.warn("[Formatter] Failed to convert markdown summary, falling back to raw text", error);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { parseTelegramBlocks } from "../telegram/render/block-parser.js";
|
|
2
|
+
import { normalizeMarkdownForTelegramRendering } from "../telegram/render/markdown-normalizer.js";
|
|
3
|
+
/**
|
|
4
|
+
* Escapes characters reserved in Telegram MarkdownV2.
|
|
5
|
+
* These must be escaped everywhere except when used as syntactic delimiters
|
|
6
|
+
* we intentionally emit (e.g. the surrounding * of bold).
|
|
7
|
+
*/
|
|
8
|
+
function escapeMarkdownV2(text) {
|
|
9
|
+
return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
|
|
10
|
+
}
|
|
11
|
+
function escapeMarkdownV2Code(text) {
|
|
12
|
+
return text.replace(/([`\\])/g, "\\$1");
|
|
13
|
+
}
|
|
14
|
+
function escapeMarkdownV2LinkUrl(text) {
|
|
15
|
+
return text.replace(/([)\\])/g, "\\$1");
|
|
16
|
+
}
|
|
17
|
+
function renderInlineNodes(nodes) {
|
|
18
|
+
return nodes
|
|
19
|
+
.map((node) => {
|
|
20
|
+
switch (node.type) {
|
|
21
|
+
case "text":
|
|
22
|
+
return escapeMarkdownV2(node.text);
|
|
23
|
+
case "bold":
|
|
24
|
+
return `*${renderInlineNodes(node.children)}*`;
|
|
25
|
+
case "italic":
|
|
26
|
+
return `_${renderInlineNodes(node.children)}_`;
|
|
27
|
+
case "strike":
|
|
28
|
+
return `~${renderInlineNodes(node.children)}~`;
|
|
29
|
+
case "code":
|
|
30
|
+
return `\`${escapeMarkdownV2Code(node.text)}\``;
|
|
31
|
+
case "link":
|
|
32
|
+
return `[${renderInlineNodes(node.text)}](${escapeMarkdownV2LinkUrl(node.url)})`;
|
|
33
|
+
case "underline":
|
|
34
|
+
return `__${renderInlineNodes(node.children)}__`;
|
|
35
|
+
case "spoiler":
|
|
36
|
+
return `||${renderInlineNodes(node.children)}||`;
|
|
37
|
+
default:
|
|
38
|
+
return escapeMarkdownV2(String(node));
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
.join("");
|
|
42
|
+
}
|
|
43
|
+
function renderBlock(block) {
|
|
44
|
+
switch (block.type) {
|
|
45
|
+
case "paragraph":
|
|
46
|
+
return renderInlineNodes(block.inlines);
|
|
47
|
+
case "heading": {
|
|
48
|
+
const content = renderInlineNodes(block.inlines);
|
|
49
|
+
return `*${content}*`;
|
|
50
|
+
}
|
|
51
|
+
case "blockquote":
|
|
52
|
+
return block.lines
|
|
53
|
+
.map((line) => renderInlineNodes(line)
|
|
54
|
+
.split("\n")
|
|
55
|
+
.map((part) => `> ${part}`)
|
|
56
|
+
.join("\n"))
|
|
57
|
+
.join("\n");
|
|
58
|
+
case "list":
|
|
59
|
+
return block.items
|
|
60
|
+
.map((item, index) => {
|
|
61
|
+
const prefix = block.ordered ? `${index + 1}\\. ` : "\\- ";
|
|
62
|
+
const body = renderInlineNodes(item);
|
|
63
|
+
return `${prefix}${body}`;
|
|
64
|
+
})
|
|
65
|
+
.join("\n");
|
|
66
|
+
case "code":
|
|
67
|
+
return `\`\`\`${escapeMarkdownV2Code(block.language ?? "")}\n${escapeMarkdownV2Code(block.text)}\n\`\`\``;
|
|
68
|
+
case "table": {
|
|
69
|
+
if (block.rows.length === 0) {
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
const header = `\\| ${block.rows[0].map(escapeMarkdownV2).join(" \\| ")} \\|`;
|
|
73
|
+
const separator = `\\| ${block.rows[0].map(() => "\\-\\-\\-").join(" \\| ")} \\|`;
|
|
74
|
+
const body = block.rows
|
|
75
|
+
.slice(1)
|
|
76
|
+
.map((row) => `\\| ${row.map(escapeMarkdownV2).join(" \\| ")} \\|`);
|
|
77
|
+
return [header, separator, ...body].join("\n");
|
|
78
|
+
}
|
|
79
|
+
case "rule":
|
|
80
|
+
return "---";
|
|
81
|
+
case "plain":
|
|
82
|
+
return escapeMarkdownV2(block.text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Converts standard Markdown into Telegram MarkdownV2.
|
|
87
|
+
*
|
|
88
|
+
* Uses the project's existing block parser (unified/remark) so that
|
|
89
|
+
* headings, lists, tables, code blocks, etc. are handled consistently
|
|
90
|
+
* with the entity-based Telegram renderer.
|
|
91
|
+
*/
|
|
92
|
+
export function convertToTelegramMarkdownV2(markdown) {
|
|
93
|
+
const normalized = normalizeMarkdownForTelegramRendering(markdown);
|
|
94
|
+
const blocks = parseTelegramBlocks(normalized);
|
|
95
|
+
if (blocks.length === 0) {
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
return blocks.map(renderBlock).join("\n\n");
|
|
99
|
+
}
|
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
import { formatModelDisplayName } from "../pinned/format.js";
|
|
2
2
|
import { t } from "../i18n/index.js";
|
|
3
3
|
import { formatCompactToolInfo } from "./formatter.js";
|
|
4
|
+
function shouldPreferInputDetails(tool, input) {
|
|
5
|
+
if (!input) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
switch (tool) {
|
|
9
|
+
case "read":
|
|
10
|
+
case "edit":
|
|
11
|
+
case "write":
|
|
12
|
+
case "apply_patch":
|
|
13
|
+
return typeof input.path === "string" || typeof input.filePath === "string";
|
|
14
|
+
case "bash":
|
|
15
|
+
return typeof input.command === "string";
|
|
16
|
+
case "grep":
|
|
17
|
+
case "glob":
|
|
18
|
+
return typeof input.pattern === "string";
|
|
19
|
+
}
|
|
20
|
+
return ["query", "url", "name", "prompt", "text"].some((field) => typeof input[field] === "string");
|
|
21
|
+
}
|
|
4
22
|
function formatToolStep(subagent) {
|
|
5
23
|
if (!subagent.currentTool) {
|
|
6
24
|
return "";
|
|
7
25
|
}
|
|
26
|
+
const toolTitle = shouldPreferInputDetails(subagent.currentTool, subagent.currentToolInput)
|
|
27
|
+
? undefined
|
|
28
|
+
: subagent.currentToolTitle;
|
|
8
29
|
const toolInfo = {
|
|
9
30
|
sessionId: subagent.sessionId ?? subagent.parentSessionId,
|
|
10
31
|
messageId: subagent.cardId,
|
|
@@ -13,12 +34,12 @@ function formatToolStep(subagent) {
|
|
|
13
34
|
state: {
|
|
14
35
|
status: "running",
|
|
15
36
|
input: subagent.currentToolInput ?? {},
|
|
16
|
-
title:
|
|
37
|
+
title: toolTitle,
|
|
17
38
|
metadata: {},
|
|
18
39
|
time: { start: subagent.updatedAt },
|
|
19
40
|
},
|
|
20
41
|
input: subagent.currentToolInput,
|
|
21
|
-
title:
|
|
42
|
+
title: toolTitle,
|
|
22
43
|
metadata: {},
|
|
23
44
|
hasFileAttachment: false,
|
|
24
45
|
};
|
|
@@ -32,6 +32,18 @@ function pushEntity(state, entity) {
|
|
|
32
32
|
}
|
|
33
33
|
state.entities.push(entity);
|
|
34
34
|
}
|
|
35
|
+
function isTelegramTextLinkUrl(url) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
return ["http:", "https:", "tg:", "mailto:"].includes(parsed.protocol);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function isLocalReferenceUrl(url) {
|
|
45
|
+
return url.startsWith("#") || url.startsWith("/") || url.startsWith("./") || url.startsWith("../");
|
|
46
|
+
}
|
|
35
47
|
function renderIntoState(state, nodes) {
|
|
36
48
|
for (const node of nodes) {
|
|
37
49
|
switch (node.type) {
|
|
@@ -81,6 +93,12 @@ function renderIntoState(state, nodes) {
|
|
|
81
93
|
case "link": {
|
|
82
94
|
const offset = state.text.length;
|
|
83
95
|
renderIntoState(state, node.text);
|
|
96
|
+
if (!isTelegramTextLinkUrl(node.url)) {
|
|
97
|
+
if (isLocalReferenceUrl(node.url)) {
|
|
98
|
+
appendText(state, ` (${node.url})`);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
84
102
|
pushEntity(state, {
|
|
85
103
|
type: "text_link",
|
|
86
104
|
offset,
|