@grinev/opencode-telegram-bot 0.5.0 → 0.6.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 +11 -0
- package/README.md +17 -14
- package/dist/bot/commands/new.js +2 -0
- package/dist/bot/commands/sessions.js +2 -0
- package/dist/bot/handlers/permission.js +54 -30
- package/dist/bot/index.js +43 -7
- package/dist/config.js +31 -0
- package/dist/permission/manager.js +58 -42
- package/dist/pinned/manager.js +7 -5
- package/dist/settings/manager.js +6 -1
- package/dist/summary/aggregator.js +87 -6
- package/dist/summary/formatter.js +91 -15
- package/dist/summary/tool-message-batcher.js +182 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -39,6 +39,17 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
39
39
|
# Bot locale: en or ru (default: en)
|
|
40
40
|
# BOT_LOCALE=en
|
|
41
41
|
|
|
42
|
+
# Service message batching interval in seconds (thinking + tool calls, default: 5)
|
|
43
|
+
# Recommended: keep >=2 to reduce risk of hitting Telegram rate limits (about 1 message/sec)
|
|
44
|
+
# 0 = send immediately (can hit rate limits if there are many tool calls)
|
|
45
|
+
# SERVICE_MESSAGES_INTERVAL_SEC=5
|
|
46
|
+
|
|
47
|
+
# Hide thinking indicator messages (default: false)
|
|
48
|
+
# HIDE_THINKING_MESSAGES=false
|
|
49
|
+
|
|
50
|
+
# Hide tool call service messages (default: false)
|
|
51
|
+
# HIDE_TOOL_CALL_MESSAGES=false
|
|
52
|
+
|
|
42
53
|
# Code File Settings (optional)
|
|
43
54
|
# Maximum file size in KB to send as document (default: 100)
|
|
44
55
|
# CODE_FILE_MAX_SIZE_KB=100
|
package/README.md
CHANGED
|
@@ -122,20 +122,23 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
122
122
|
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
|
|
123
123
|
- **Linux:** `~/.config/opencode-telegram-bot/.env`
|
|
124
124
|
|
|
125
|
-
| Variable
|
|
126
|
-
|
|
|
127
|
-
| `TELEGRAM_BOT_TOKEN`
|
|
128
|
-
| `TELEGRAM_ALLOWED_USER_ID`
|
|
129
|
-
| `TELEGRAM_PROXY_URL`
|
|
130
|
-
| `OPENCODE_API_URL`
|
|
131
|
-
| `OPENCODE_SERVER_USERNAME`
|
|
132
|
-
| `OPENCODE_SERVER_PASSWORD`
|
|
133
|
-
| `OPENCODE_MODEL_PROVIDER`
|
|
134
|
-
| `OPENCODE_MODEL_ID`
|
|
135
|
-
| `BOT_LOCALE`
|
|
136
|
-
| `SESSIONS_LIST_LIMIT`
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
125
|
+
| Variable | Description | Required | Default |
|
|
126
|
+
| ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ----------------------- |
|
|
127
|
+
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
|
|
128
|
+
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
|
|
129
|
+
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
|
|
130
|
+
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
|
|
131
|
+
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
|
|
132
|
+
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
|
|
133
|
+
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
|
|
134
|
+
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
|
|
135
|
+
| `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
|
|
136
|
+
| `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
|
|
137
|
+
| `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
|
|
138
|
+
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
139
|
+
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
140
|
+
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
|
|
141
|
+
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
139
142
|
|
|
140
143
|
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
|
|
141
144
|
|
package/dist/bot/commands/new.js
CHANGED
|
@@ -3,6 +3,7 @@ import { setCurrentSession } from "../../session/manager.js";
|
|
|
3
3
|
import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
|
|
4
4
|
import { getCurrentProject } from "../../settings/manager.js";
|
|
5
5
|
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
6
|
+
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
6
7
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
7
8
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
8
9
|
import { getStoredAgent } from "../../agent/manager.js";
|
|
@@ -32,6 +33,7 @@ export async function newCommand(ctx) {
|
|
|
32
33
|
directory: currentProject.worktree,
|
|
33
34
|
};
|
|
34
35
|
setCurrentSession(sessionInfo);
|
|
36
|
+
summaryAggregator.clear();
|
|
35
37
|
clearAllInteractionState("session_created");
|
|
36
38
|
await ingestSessionInfoForCache(session);
|
|
37
39
|
// Initialize pinned message manager and create pinned message
|
|
@@ -3,6 +3,7 @@ import { opencodeClient } from "../../opencode/client.js";
|
|
|
3
3
|
import { setCurrentSession } from "../../session/manager.js";
|
|
4
4
|
import { getCurrentProject } from "../../settings/manager.js";
|
|
5
5
|
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
6
|
+
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
6
7
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
7
8
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
8
9
|
import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
|
|
@@ -84,6 +85,7 @@ export async function handleSessionSelect(ctx) {
|
|
|
84
85
|
directory: currentProject.worktree,
|
|
85
86
|
};
|
|
86
87
|
setCurrentSession(sessionInfo);
|
|
88
|
+
summaryAggregator.clear();
|
|
87
89
|
clearAllInteractionState("session_switched");
|
|
88
90
|
await ctx.answerCallbackQuery();
|
|
89
91
|
let loadingMessageId = null;
|
|
@@ -50,6 +50,33 @@ function clearPermissionInteraction(reason) {
|
|
|
50
50
|
interactionManager.clear(reason);
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
function syncPermissionInteractionState(metadata = {}) {
|
|
54
|
+
const pendingCount = permissionManager.getPendingCount();
|
|
55
|
+
if (pendingCount === 0) {
|
|
56
|
+
clearPermissionInteraction("permission_no_pending_requests");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const nextMetadata = {
|
|
60
|
+
pendingCount,
|
|
61
|
+
...metadata,
|
|
62
|
+
};
|
|
63
|
+
const state = interactionManager.getSnapshot();
|
|
64
|
+
if (state?.kind === "permission") {
|
|
65
|
+
interactionManager.transition({
|
|
66
|
+
expectedInput: "callback",
|
|
67
|
+
metadata: nextMetadata,
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
interactionManager.start({
|
|
72
|
+
kind: "permission",
|
|
73
|
+
expectedInput: "callback",
|
|
74
|
+
metadata: nextMetadata,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function isPermissionReply(value) {
|
|
78
|
+
return value === "once" || value === "always" || value === "reject";
|
|
79
|
+
}
|
|
53
80
|
/**
|
|
54
81
|
* Handle permission callback from inline buttons
|
|
55
82
|
*/
|
|
@@ -71,10 +98,22 @@ export async function handlePermissionCallback(ctx) {
|
|
|
71
98
|
await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
|
|
72
99
|
return true;
|
|
73
100
|
}
|
|
101
|
+
const requestID = permissionManager.getRequestID(callbackMessageId);
|
|
102
|
+
if (!requestID) {
|
|
103
|
+
await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
74
106
|
const parts = data.split(":");
|
|
75
107
|
const action = parts[1];
|
|
108
|
+
if (!isPermissionReply(action)) {
|
|
109
|
+
await ctx.answerCallbackQuery({
|
|
110
|
+
text: t("permission.processing_error_callback"),
|
|
111
|
+
show_alert: true,
|
|
112
|
+
});
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
76
115
|
try {
|
|
77
|
-
await handlePermissionReply(ctx, action);
|
|
116
|
+
await handlePermissionReply(ctx, action, requestID, callbackMessageId);
|
|
78
117
|
}
|
|
79
118
|
catch (err) {
|
|
80
119
|
logger.error("[PermissionHandler] Error handling callback:", err);
|
|
@@ -88,13 +127,12 @@ export async function handlePermissionCallback(ctx) {
|
|
|
88
127
|
/**
|
|
89
128
|
* Handle permission reply (once/always/reject)
|
|
90
129
|
*/
|
|
91
|
-
async function handlePermissionReply(ctx, reply) {
|
|
92
|
-
const requestID = permissionManager.getRequestID();
|
|
130
|
+
async function handlePermissionReply(ctx, reply, requestID, callbackMessageId) {
|
|
93
131
|
const currentProject = getCurrentProject();
|
|
94
132
|
const currentSession = getCurrentSession();
|
|
95
133
|
const chatId = ctx.chat?.id;
|
|
96
134
|
const directory = currentSession?.directory ?? currentProject?.worktree;
|
|
97
|
-
if (!
|
|
135
|
+
if (!directory || !chatId) {
|
|
98
136
|
permissionManager.clear();
|
|
99
137
|
clearPermissionInteraction("permission_invalid_runtime_context");
|
|
100
138
|
await ctx.answerCallbackQuery({
|
|
@@ -134,30 +172,20 @@ async function handlePermissionReply(ctx, reply) {
|
|
|
134
172
|
logger.info("[PermissionHandler] Permission reply sent successfully");
|
|
135
173
|
},
|
|
136
174
|
});
|
|
137
|
-
permissionManager.
|
|
138
|
-
|
|
175
|
+
permissionManager.removeByMessageId(callbackMessageId);
|
|
176
|
+
if (!permissionManager.isActive()) {
|
|
177
|
+
clearPermissionInteraction("permission_replied");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
syncPermissionInteractionState({
|
|
181
|
+
lastRepliedRequestID: requestID,
|
|
182
|
+
});
|
|
139
183
|
}
|
|
140
184
|
/**
|
|
141
185
|
* Show permission request message with inline buttons
|
|
142
186
|
*/
|
|
143
187
|
export async function showPermissionRequest(bot, chatId, request) {
|
|
144
188
|
logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);
|
|
145
|
-
const previousMessageId = permissionManager.getMessageId();
|
|
146
|
-
const hadActivePermission = permissionManager.isActive();
|
|
147
|
-
if (hadActivePermission && previousMessageId !== null) {
|
|
148
|
-
logger.debug(`[PermissionHandler] Replacing active permission request, deleting previous message: ${previousMessageId}`);
|
|
149
|
-
await bot.deleteMessage(chatId, previousMessageId).catch((err) => {
|
|
150
|
-
logger.debug("[PermissionHandler] Failed to delete previous permission message:", err);
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
permissionManager.startPermission(request);
|
|
154
|
-
interactionManager.start({
|
|
155
|
-
kind: "permission",
|
|
156
|
-
expectedInput: "callback",
|
|
157
|
-
metadata: {
|
|
158
|
-
requestID: request.id,
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
189
|
const text = formatPermissionText(request);
|
|
162
190
|
const keyboard = buildPermissionKeyboard();
|
|
163
191
|
try {
|
|
@@ -166,18 +194,14 @@ export async function showPermissionRequest(bot, chatId, request) {
|
|
|
166
194
|
parse_mode: "Markdown",
|
|
167
195
|
});
|
|
168
196
|
logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
|
|
169
|
-
permissionManager.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
messageId: message.message_id,
|
|
174
|
-
},
|
|
197
|
+
permissionManager.startPermission(request, message.message_id);
|
|
198
|
+
syncPermissionInteractionState({
|
|
199
|
+
requestID: request.id,
|
|
200
|
+
messageId: message.message_id,
|
|
175
201
|
});
|
|
176
202
|
summaryAggregator.stopTypingIndicator();
|
|
177
203
|
}
|
|
178
204
|
catch (err) {
|
|
179
|
-
permissionManager.clear();
|
|
180
|
-
clearPermissionInteraction("permission_message_send_failed");
|
|
181
205
|
logger.error("[PermissionHandler] Failed to send permission message:", err);
|
|
182
206
|
throw err;
|
|
183
207
|
}
|
package/dist/bot/index.js
CHANGED
|
@@ -36,6 +36,7 @@ import { keyboardManager } from "../keyboard/manager.js";
|
|
|
36
36
|
import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
|
|
37
37
|
import { summaryAggregator } from "../summary/aggregator.js";
|
|
38
38
|
import { formatSummary, formatToolInfo } from "../summary/formatter.js";
|
|
39
|
+
import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
|
|
39
40
|
import { opencodeClient } from "../opencode/client.js";
|
|
40
41
|
import { clearSession, getCurrentSession, setCurrentSession } from "../session/manager.js";
|
|
41
42
|
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
|
|
@@ -52,6 +53,21 @@ import { t } from "../i18n/index.js";
|
|
|
52
53
|
let botInstance = null;
|
|
53
54
|
let chatIdInstance = null;
|
|
54
55
|
let commandsInitialized = false;
|
|
56
|
+
const toolMessageBatcher = new ToolMessageBatcher({
|
|
57
|
+
intervalSeconds: 5,
|
|
58
|
+
sendMessage: async (sessionId, text) => {
|
|
59
|
+
if (!botInstance || !chatIdInstance) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const currentSession = getCurrentSession();
|
|
63
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await botInstance.api.sendMessage(chatIdInstance, text, {
|
|
67
|
+
disable_notification: true,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
});
|
|
55
71
|
async function ensureCommandsInitialized(ctx, next) {
|
|
56
72
|
if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
|
|
57
73
|
await next();
|
|
@@ -82,6 +98,10 @@ async function ensureEventSubscription(directory) {
|
|
|
82
98
|
logger.error("No directory found for event subscription");
|
|
83
99
|
return;
|
|
84
100
|
}
|
|
101
|
+
toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
|
|
102
|
+
summaryAggregator.setOnCleared(() => {
|
|
103
|
+
toolMessageBatcher.clearAll("summary_aggregator_clear");
|
|
104
|
+
});
|
|
85
105
|
summaryAggregator.setOnComplete(async (sessionId, messageText) => {
|
|
86
106
|
if (!botInstance || !chatIdInstance) {
|
|
87
107
|
logger.error("Bot or chat ID not available for sending message");
|
|
@@ -91,6 +111,7 @@ async function ensureEventSubscription(directory) {
|
|
|
91
111
|
if (currentSession?.id !== sessionId) {
|
|
92
112
|
return;
|
|
93
113
|
}
|
|
114
|
+
await toolMessageBatcher.flushSession(sessionId, "assistant_message_completed");
|
|
94
115
|
try {
|
|
95
116
|
const parts = formatSummary(messageText);
|
|
96
117
|
logger.debug(`[Bot] Sending completed message to Telegram (chatId=${chatIdInstance}, parts=${parts.length})`);
|
|
@@ -121,19 +142,21 @@ async function ensureEventSubscription(directory) {
|
|
|
121
142
|
}
|
|
122
143
|
});
|
|
123
144
|
summaryAggregator.setOnTool(async (toolInfo) => {
|
|
145
|
+
if (config.bot.hideToolCallMessages) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
124
148
|
if (!botInstance || !chatIdInstance) {
|
|
125
149
|
logger.error("Bot or chat ID not available for sending tool notification");
|
|
126
150
|
return;
|
|
127
151
|
}
|
|
128
152
|
const currentSession = getCurrentSession();
|
|
129
|
-
|
|
130
|
-
if (!currentSession || currentSession.id !== sessionId) {
|
|
153
|
+
if (!currentSession || currentSession.id !== toolInfo.sessionId) {
|
|
131
154
|
return;
|
|
132
155
|
}
|
|
133
156
|
try {
|
|
134
157
|
const message = formatToolInfo(toolInfo);
|
|
135
158
|
if (message) {
|
|
136
|
-
|
|
159
|
+
toolMessageBatcher.enqueue(toolInfo.sessionId, message);
|
|
137
160
|
}
|
|
138
161
|
}
|
|
139
162
|
catch (err) {
|
|
@@ -159,6 +182,7 @@ async function ensureEventSubscription(directory) {
|
|
|
159
182
|
await fs.writeFile(tempFilePath, fileData.buffer);
|
|
160
183
|
await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
|
|
161
184
|
caption: fileData.caption,
|
|
185
|
+
disable_notification: true,
|
|
162
186
|
});
|
|
163
187
|
await fs.unlink(tempFilePath);
|
|
164
188
|
logger.debug(`[Bot] Temporary file deleted: ${fileData.filename}`);
|
|
@@ -172,6 +196,10 @@ async function ensureEventSubscription(directory) {
|
|
|
172
196
|
logger.error("Bot or chat ID not available for showing questions");
|
|
173
197
|
return;
|
|
174
198
|
}
|
|
199
|
+
const currentSession = getCurrentSession();
|
|
200
|
+
if (currentSession) {
|
|
201
|
+
await toolMessageBatcher.flushSession(currentSession.id, "question_asked");
|
|
202
|
+
}
|
|
175
203
|
if (questionManager.isActive()) {
|
|
176
204
|
logger.warn("[Bot] Replacing active poll with a new one");
|
|
177
205
|
const previousMessageIds = questionManager.getMessageIds();
|
|
@@ -202,17 +230,23 @@ async function ensureEventSubscription(directory) {
|
|
|
202
230
|
logger.error("Bot or chat ID not available for showing permission request");
|
|
203
231
|
return;
|
|
204
232
|
}
|
|
233
|
+
await toolMessageBatcher.flushSession(request.sessionID, "permission_asked");
|
|
205
234
|
logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}`);
|
|
206
235
|
await showPermissionRequest(botInstance.api, chatIdInstance, request);
|
|
207
236
|
});
|
|
208
|
-
summaryAggregator.setOnThinking(async () => {
|
|
237
|
+
summaryAggregator.setOnThinking(async (sessionId) => {
|
|
238
|
+
if (config.bot.hideThinkingMessages) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
209
241
|
if (!botInstance || !chatIdInstance) {
|
|
210
242
|
return;
|
|
211
243
|
}
|
|
244
|
+
const currentSession = getCurrentSession();
|
|
245
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
212
248
|
logger.debug("[Bot] Agent started thinking");
|
|
213
|
-
|
|
214
|
-
logger.error("[Bot] Failed to send thinking message:", err);
|
|
215
|
-
});
|
|
249
|
+
toolMessageBatcher.enqueue(sessionId, t("bot.thinking"));
|
|
216
250
|
});
|
|
217
251
|
summaryAggregator.setOnTokens(async (tokens) => {
|
|
218
252
|
if (!pinnedMessageManager.isInitialized()) {
|
|
@@ -325,6 +359,8 @@ async function resetMismatchedSessionContext() {
|
|
|
325
359
|
}
|
|
326
360
|
export function createBot() {
|
|
327
361
|
clearAllInteractionState("bot_startup");
|
|
362
|
+
toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
|
|
363
|
+
logger.info(`[ToolBatcher] Service messages interval: ${config.bot.serviceMessagesIntervalSec}s`);
|
|
328
364
|
const botOptions = {};
|
|
329
365
|
if (config.telegram.proxyUrl) {
|
|
330
366
|
const proxyUrl = config.telegram.proxyUrl;
|
package/dist/config.js
CHANGED
|
@@ -20,6 +20,20 @@ function getOptionalPositiveIntEnvVar(key, defaultValue) {
|
|
|
20
20
|
}
|
|
21
21
|
return parsedValue;
|
|
22
22
|
}
|
|
23
|
+
function getOptionalNonNegativeIntEnvVarFromKeys(keys, defaultValue) {
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
const value = getEnvVar(key, false);
|
|
26
|
+
if (!value) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const parsedValue = Number.parseInt(value, 10);
|
|
30
|
+
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
|
31
|
+
return defaultValue;
|
|
32
|
+
}
|
|
33
|
+
return parsedValue;
|
|
34
|
+
}
|
|
35
|
+
return defaultValue;
|
|
36
|
+
}
|
|
23
37
|
function getOptionalLocaleEnvVar(key, defaultValue) {
|
|
24
38
|
const value = getEnvVar(key, false);
|
|
25
39
|
if (!value) {
|
|
@@ -34,6 +48,20 @@ function getOptionalLocaleEnvVar(key, defaultValue) {
|
|
|
34
48
|
}
|
|
35
49
|
return defaultValue;
|
|
36
50
|
}
|
|
51
|
+
function getOptionalBooleanEnvVar(key, defaultValue) {
|
|
52
|
+
const value = getEnvVar(key, false);
|
|
53
|
+
if (!value) {
|
|
54
|
+
return defaultValue;
|
|
55
|
+
}
|
|
56
|
+
const normalized = value.trim().toLowerCase();
|
|
57
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return defaultValue;
|
|
64
|
+
}
|
|
37
65
|
export const config = {
|
|
38
66
|
telegram: {
|
|
39
67
|
token: getEnvVar("TELEGRAM_BOT_TOKEN"),
|
|
@@ -55,6 +83,9 @@ export const config = {
|
|
|
55
83
|
bot: {
|
|
56
84
|
sessionsListLimit: getOptionalPositiveIntEnvVar("SESSIONS_LIST_LIMIT", 10),
|
|
57
85
|
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
|
|
86
|
+
serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
|
|
87
|
+
hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
|
|
88
|
+
hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
|
|
58
89
|
},
|
|
59
90
|
files: {
|
|
60
91
|
maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),
|
|
@@ -1,83 +1,99 @@
|
|
|
1
1
|
import { logger } from "../utils/logger.js";
|
|
2
2
|
class PermissionManager {
|
|
3
3
|
state = {
|
|
4
|
-
|
|
5
|
-
messageId: null,
|
|
6
|
-
isActive: false,
|
|
4
|
+
requestsByMessageId: new Map(),
|
|
7
5
|
};
|
|
8
6
|
/**
|
|
9
|
-
*
|
|
7
|
+
* Register a new permission request message
|
|
10
8
|
*/
|
|
11
|
-
startPermission(request) {
|
|
12
|
-
logger.debug(`[PermissionManager] startPermission: id=${request.id}, permission=${request.permission}`);
|
|
13
|
-
if (this.state.
|
|
14
|
-
logger.warn(
|
|
15
|
-
this.clear();
|
|
9
|
+
startPermission(request, messageId) {
|
|
10
|
+
logger.debug(`[PermissionManager] startPermission: id=${request.id}, permission=${request.permission}, messageId=${messageId}`);
|
|
11
|
+
if (this.state.requestsByMessageId.has(messageId)) {
|
|
12
|
+
logger.warn(`[PermissionManager] Message ID already tracked, replacing: ${messageId}`);
|
|
16
13
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
request,
|
|
20
|
-
messageId: null,
|
|
21
|
-
isActive: true,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Get current permission request
|
|
26
|
-
*/
|
|
27
|
-
getRequest() {
|
|
28
|
-
return this.state.request;
|
|
14
|
+
this.state.requestsByMessageId.set(messageId, request);
|
|
15
|
+
logger.info(`[PermissionManager] New permission request: type=${request.permission}, patterns=${request.patterns.join(", ")}, pending=${this.state.requestsByMessageId.size}`);
|
|
29
16
|
}
|
|
30
17
|
/**
|
|
31
|
-
* Get request
|
|
18
|
+
* Get permission request by Telegram message ID
|
|
32
19
|
*/
|
|
33
|
-
|
|
34
|
-
|
|
20
|
+
getRequest(messageId) {
|
|
21
|
+
if (messageId === null) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return this.state.requestsByMessageId.get(messageId) ?? null;
|
|
35
25
|
}
|
|
36
26
|
/**
|
|
37
|
-
* Get
|
|
27
|
+
* Get request ID for API reply by Telegram message ID
|
|
38
28
|
*/
|
|
39
|
-
|
|
40
|
-
return this.
|
|
29
|
+
getRequestID(messageId) {
|
|
30
|
+
return this.getRequest(messageId)?.id ?? null;
|
|
41
31
|
}
|
|
42
32
|
/**
|
|
43
|
-
* Get
|
|
33
|
+
* Get permission type (bash, edit, etc.) by message ID
|
|
44
34
|
*/
|
|
45
|
-
|
|
46
|
-
return this.
|
|
35
|
+
getPermissionType(messageId) {
|
|
36
|
+
return this.getRequest(messageId)?.permission ?? null;
|
|
47
37
|
}
|
|
48
38
|
/**
|
|
49
|
-
*
|
|
39
|
+
* Get patterns (commands/files) by message ID
|
|
50
40
|
*/
|
|
51
|
-
|
|
52
|
-
this.
|
|
41
|
+
getPatterns(messageId) {
|
|
42
|
+
return this.getRequest(messageId)?.patterns ?? [];
|
|
53
43
|
}
|
|
54
44
|
/**
|
|
55
45
|
* Check if callback message ID belongs to active permission request
|
|
56
46
|
*/
|
|
57
47
|
isActiveMessage(messageId) {
|
|
58
|
-
return
|
|
48
|
+
return messageId !== null && this.state.requestsByMessageId.has(messageId);
|
|
59
49
|
}
|
|
60
50
|
/**
|
|
61
|
-
* Get Telegram message ID
|
|
51
|
+
* Get latest Telegram message ID
|
|
62
52
|
*/
|
|
63
53
|
getMessageId() {
|
|
64
|
-
|
|
54
|
+
const messageIds = this.getMessageIds();
|
|
55
|
+
if (messageIds.length === 0) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return messageIds[messageIds.length - 1];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get Telegram message IDs for all active requests
|
|
62
|
+
*/
|
|
63
|
+
getMessageIds() {
|
|
64
|
+
return Array.from(this.state.requestsByMessageId.keys());
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Remove permission request by Telegram message ID
|
|
68
|
+
*/
|
|
69
|
+
removeByMessageId(messageId) {
|
|
70
|
+
const request = this.getRequest(messageId);
|
|
71
|
+
if (!request || messageId === null) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
this.state.requestsByMessageId.delete(messageId);
|
|
75
|
+
logger.debug(`[PermissionManager] Removed permission request: id=${request.id}, messageId=${messageId}, pending=${this.state.requestsByMessageId.size}`);
|
|
76
|
+
return request;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Get number of active permission requests
|
|
80
|
+
*/
|
|
81
|
+
getPendingCount() {
|
|
82
|
+
return this.state.requestsByMessageId.size;
|
|
65
83
|
}
|
|
66
84
|
/**
|
|
67
|
-
* Check if
|
|
85
|
+
* Check if there are active permission requests
|
|
68
86
|
*/
|
|
69
87
|
isActive() {
|
|
70
|
-
return this.state.
|
|
88
|
+
return this.state.requestsByMessageId.size > 0;
|
|
71
89
|
}
|
|
72
90
|
/**
|
|
73
91
|
* Clear state after reply
|
|
74
92
|
*/
|
|
75
93
|
clear() {
|
|
76
|
-
logger.debug(
|
|
94
|
+
logger.debug(`[PermissionManager] Clearing permission state: pending=${this.state.requestsByMessageId.size}`);
|
|
77
95
|
this.state = {
|
|
78
|
-
|
|
79
|
-
messageId: null,
|
|
80
|
-
isActive: false,
|
|
96
|
+
requestsByMessageId: new Map(),
|
|
81
97
|
};
|
|
82
98
|
}
|
|
83
99
|
}
|
package/dist/pinned/manager.js
CHANGED
|
@@ -266,7 +266,7 @@ class PinnedMessageManager {
|
|
|
266
266
|
logger.debug(`[PinnedManager] loadDiffsFromMessages: ${messagesData.length} messages`);
|
|
267
267
|
const filesMap = new Map();
|
|
268
268
|
let toolCount = 0;
|
|
269
|
-
let
|
|
269
|
+
let fileToolCount = 0;
|
|
270
270
|
for (const { parts } of messagesData) {
|
|
271
271
|
for (const part of parts) {
|
|
272
272
|
if (part.type !== "tool")
|
|
@@ -275,10 +275,12 @@ class PinnedMessageManager {
|
|
|
275
275
|
const toolPart = part;
|
|
276
276
|
if (toolPart.state.status !== "completed")
|
|
277
277
|
continue;
|
|
278
|
-
if (toolPart.tool === "edit" ||
|
|
279
|
-
|
|
278
|
+
if (toolPart.tool === "edit" ||
|
|
279
|
+
toolPart.tool === "write" ||
|
|
280
|
+
toolPart.tool === "apply_patch") {
|
|
281
|
+
fileToolCount++;
|
|
280
282
|
}
|
|
281
|
-
if (toolPart.tool === "edit" &&
|
|
283
|
+
if ((toolPart.tool === "edit" || toolPart.tool === "apply_patch") &&
|
|
282
284
|
toolPart.state.metadata &&
|
|
283
285
|
"filediff" in toolPart.state.metadata) {
|
|
284
286
|
const filediff = toolPart.state.metadata.filediff;
|
|
@@ -318,7 +320,7 @@ class PinnedMessageManager {
|
|
|
318
320
|
}
|
|
319
321
|
}
|
|
320
322
|
}
|
|
321
|
-
logger.debug(`[PinnedManager] loadDiffsFromMessages: found ${toolCount} tool parts, ${
|
|
323
|
+
logger.debug(`[PinnedManager] loadDiffsFromMessages: found ${toolCount} tool parts, ${fileToolCount} file tools`);
|
|
322
324
|
if (filesMap.size > 0) {
|
|
323
325
|
this.state.changedFiles = Array.from(filesMap.values());
|
|
324
326
|
logger.info(`[PinnedManager] Loaded ${this.state.changedFiles.length} file diffs from messages`);
|
package/dist/settings/manager.js
CHANGED
|
@@ -119,5 +119,10 @@ export function __resetSettingsForTests() {
|
|
|
119
119
|
settingsWriteQueue = Promise.resolve();
|
|
120
120
|
}
|
|
121
121
|
export async function loadSettings() {
|
|
122
|
-
|
|
122
|
+
const loadedSettings = (await readSettingsFile());
|
|
123
|
+
if ("toolMessagesIntervalSec" in loadedSettings) {
|
|
124
|
+
delete loadedSettings.toolMessagesIntervalSec;
|
|
125
|
+
void writeSettingsFile(loadedSettings);
|
|
126
|
+
}
|
|
127
|
+
currentSettings = loadedSettings;
|
|
123
128
|
}
|
|
@@ -1,6 +1,29 @@
|
|
|
1
|
-
import { prepareCodeFile } from "./formatter.js";
|
|
1
|
+
import { normalizePathForDisplay, prepareCodeFile } from "./formatter.js";
|
|
2
2
|
import { logger } from "../utils/logger.js";
|
|
3
3
|
import { getCurrentProject } from "../settings/manager.js";
|
|
4
|
+
function extractFirstUpdatedFileFromTitle(title) {
|
|
5
|
+
for (const rawLine of title.split("\n")) {
|
|
6
|
+
const line = rawLine.trim();
|
|
7
|
+
if (line.length >= 3 && line[1] === " " && /[AMDURC]/.test(line[0])) {
|
|
8
|
+
return line.slice(2).trim();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
function countDiffChangesFromText(text) {
|
|
14
|
+
let additions = 0;
|
|
15
|
+
let deletions = 0;
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
18
|
+
additions++;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
22
|
+
deletions++;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { additions, deletions };
|
|
26
|
+
}
|
|
4
27
|
class SummaryAggregator {
|
|
5
28
|
currentSessionId = null;
|
|
6
29
|
currentMessageParts = new Map();
|
|
@@ -19,6 +42,7 @@ class SummaryAggregator {
|
|
|
19
42
|
onPermissionCallback = null;
|
|
20
43
|
onSessionDiffCallback = null;
|
|
21
44
|
onFileChangeCallback = null;
|
|
45
|
+
onClearedCallback = null;
|
|
22
46
|
processedToolStates = new Set();
|
|
23
47
|
bot = null;
|
|
24
48
|
chatId = null;
|
|
@@ -61,6 +85,9 @@ class SummaryAggregator {
|
|
|
61
85
|
setOnFileChange(callback) {
|
|
62
86
|
this.onFileChangeCallback = callback;
|
|
63
87
|
}
|
|
88
|
+
setOnCleared(callback) {
|
|
89
|
+
this.onClearedCallback = callback;
|
|
90
|
+
}
|
|
64
91
|
startTypingIndicator() {
|
|
65
92
|
if (this.typingTimer) {
|
|
66
93
|
return;
|
|
@@ -145,6 +172,14 @@ class SummaryAggregator {
|
|
|
145
172
|
this.processedToolStates.clear();
|
|
146
173
|
this.messageCount = 0;
|
|
147
174
|
this.lastUpdated = 0;
|
|
175
|
+
if (this.onClearedCallback) {
|
|
176
|
+
try {
|
|
177
|
+
this.onClearedCallback();
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
logger.error("[Aggregator] Error in clear callback:", err);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
148
183
|
}
|
|
149
184
|
handleMessageUpdated(event) {
|
|
150
185
|
const { info } = event.properties;
|
|
@@ -160,8 +195,11 @@ class SummaryAggregator {
|
|
|
160
195
|
this.startTypingIndicator();
|
|
161
196
|
// Notify that agent started thinking
|
|
162
197
|
if (this.onThinkingCallback) {
|
|
198
|
+
const callback = this.onThinkingCallback;
|
|
163
199
|
setImmediate(() => {
|
|
164
|
-
|
|
200
|
+
if (typeof callback === "function") {
|
|
201
|
+
callback(info.sessionID);
|
|
202
|
+
}
|
|
165
203
|
});
|
|
166
204
|
}
|
|
167
205
|
}
|
|
@@ -264,6 +302,7 @@ class SummaryAggregator {
|
|
|
264
302
|
if (!this.processedToolStates.has(notifiedKey)) {
|
|
265
303
|
this.processedToolStates.add(notifiedKey);
|
|
266
304
|
const toolData = {
|
|
305
|
+
sessionId: part.sessionID,
|
|
267
306
|
messageId: messageID,
|
|
268
307
|
callId: part.callID,
|
|
269
308
|
tool: part.tool,
|
|
@@ -283,7 +322,8 @@ class SummaryAggregator {
|
|
|
283
322
|
if (!this.processedToolStates.has(fileKey)) {
|
|
284
323
|
this.processedToolStates.add(fileKey);
|
|
285
324
|
if (part.tool === "write" && input && "content" in input && "filePath" in input) {
|
|
286
|
-
const
|
|
325
|
+
const filePath = normalizePathForDisplay(input.filePath);
|
|
326
|
+
const fileData = prepareCodeFile(input.content, filePath, "write");
|
|
287
327
|
if (fileData && this.onToolFileCallback) {
|
|
288
328
|
logger.debug(`[Aggregator] Sending write file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
|
|
289
329
|
this.onToolFileCallback(fileData);
|
|
@@ -292,7 +332,7 @@ class SummaryAggregator {
|
|
|
292
332
|
if (this.onFileChangeCallback) {
|
|
293
333
|
const lines = input.content.split("\n").length;
|
|
294
334
|
this.onFileChangeCallback({
|
|
295
|
-
file:
|
|
335
|
+
file: filePath,
|
|
296
336
|
additions: lines,
|
|
297
337
|
deletions: 0,
|
|
298
338
|
});
|
|
@@ -303,12 +343,12 @@ class SummaryAggregator {
|
|
|
303
343
|
"diff" in state.metadata &&
|
|
304
344
|
"filediff" in state.metadata) {
|
|
305
345
|
const filediff = state.metadata.filediff;
|
|
306
|
-
const filePath = filediff.file;
|
|
346
|
+
const filePath = filediff.file ? normalizePathForDisplay(filediff.file) : undefined;
|
|
307
347
|
const diff = state.metadata.diff;
|
|
308
348
|
if (filePath && diff) {
|
|
309
349
|
const fileData = prepareCodeFile(diff, filePath, "edit");
|
|
310
350
|
if (fileData && this.onToolFileCallback) {
|
|
311
|
-
logger.debug(`[Aggregator] Sending
|
|
351
|
+
logger.debug(`[Aggregator] Sending ${part.tool} file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
|
|
312
352
|
this.onToolFileCallback(fileData);
|
|
313
353
|
}
|
|
314
354
|
// Notify about file change for pinned message
|
|
@@ -321,6 +361,47 @@ class SummaryAggregator {
|
|
|
321
361
|
}
|
|
322
362
|
}
|
|
323
363
|
}
|
|
364
|
+
else if (part.tool === "apply_patch") {
|
|
365
|
+
const metadata = state.metadata;
|
|
366
|
+
const filePathFromInput = input && typeof input.filePath === "string"
|
|
367
|
+
? normalizePathForDisplay(input.filePath)
|
|
368
|
+
: input && typeof input.path === "string"
|
|
369
|
+
? normalizePathForDisplay(input.path)
|
|
370
|
+
: "";
|
|
371
|
+
const filePathFromTitle = title ? extractFirstUpdatedFileFromTitle(title) : "";
|
|
372
|
+
const filePath = (metadata?.filediff?.file && normalizePathForDisplay(metadata.filediff.file)) ||
|
|
373
|
+
filePathFromInput ||
|
|
374
|
+
normalizePathForDisplay(filePathFromTitle);
|
|
375
|
+
const diffText = typeof metadata?.diff === "string"
|
|
376
|
+
? metadata.diff
|
|
377
|
+
: input && typeof input.patchText === "string"
|
|
378
|
+
? input.patchText
|
|
379
|
+
: "";
|
|
380
|
+
if (filePath && diffText) {
|
|
381
|
+
const fileData = prepareCodeFile(diffText, filePath, "edit");
|
|
382
|
+
if (fileData && this.onToolFileCallback) {
|
|
383
|
+
logger.debug(`[Aggregator] Sending apply_patch file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
|
|
384
|
+
this.onToolFileCallback(fileData);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (filePath && this.onFileChangeCallback) {
|
|
388
|
+
if (metadata?.filediff) {
|
|
389
|
+
this.onFileChangeCallback({
|
|
390
|
+
file: filePath,
|
|
391
|
+
additions: metadata.filediff.additions || 0,
|
|
392
|
+
deletions: metadata.filediff.deletions || 0,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
else if (diffText) {
|
|
396
|
+
const changes = countDiffChangesFromText(diffText);
|
|
397
|
+
this.onFileChangeCallback({
|
|
398
|
+
file: filePath,
|
|
399
|
+
additions: changes.additions,
|
|
400
|
+
deletions: changes.deletions,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
324
405
|
}
|
|
325
406
|
}
|
|
326
407
|
}
|
|
@@ -2,6 +2,7 @@ import * as path from "path";
|
|
|
2
2
|
import { config } from "../config.js";
|
|
3
3
|
import { logger } from "../utils/logger.js";
|
|
4
4
|
import { t } from "../i18n/index.js";
|
|
5
|
+
import { getCurrentProject } from "../settings/manager.js";
|
|
5
6
|
const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
6
7
|
function splitText(text, maxLength) {
|
|
7
8
|
const parts = [];
|
|
@@ -21,6 +22,27 @@ function splitText(text, maxLength) {
|
|
|
21
22
|
}
|
|
22
23
|
return parts;
|
|
23
24
|
}
|
|
25
|
+
export function normalizePathForDisplay(filePath) {
|
|
26
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
27
|
+
const project = getCurrentProject();
|
|
28
|
+
if (!project?.worktree) {
|
|
29
|
+
return normalizedPath;
|
|
30
|
+
}
|
|
31
|
+
const normalizedWorktree = project.worktree.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
32
|
+
if (!normalizedWorktree) {
|
|
33
|
+
return normalizedPath;
|
|
34
|
+
}
|
|
35
|
+
const pathForCompare = process.platform === "win32" ? normalizedPath.toLowerCase() : normalizedPath;
|
|
36
|
+
const worktreeForCompare = process.platform === "win32" ? normalizedWorktree.toLowerCase() : normalizedWorktree;
|
|
37
|
+
if (pathForCompare === worktreeForCompare) {
|
|
38
|
+
return ".";
|
|
39
|
+
}
|
|
40
|
+
const worktreePrefix = `${worktreeForCompare}/`;
|
|
41
|
+
if (pathForCompare.startsWith(worktreePrefix)) {
|
|
42
|
+
return normalizedPath.slice(normalizedWorktree.length + 1);
|
|
43
|
+
}
|
|
44
|
+
return normalizedPath;
|
|
45
|
+
}
|
|
24
46
|
export function formatSummary(text) {
|
|
25
47
|
if (!text || text.trim().length === 0) {
|
|
26
48
|
return [];
|
|
@@ -50,9 +72,10 @@ function getToolDetails(tool, input) {
|
|
|
50
72
|
case "read":
|
|
51
73
|
case "edit":
|
|
52
74
|
case "write":
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
case "apply_patch":
|
|
76
|
+
const filePath = input.path || input.filePath;
|
|
77
|
+
if (typeof filePath === "string")
|
|
78
|
+
return normalizePathForDisplay(filePath);
|
|
56
79
|
break;
|
|
57
80
|
case "bash":
|
|
58
81
|
if (typeof input.command === "string")
|
|
@@ -88,6 +111,8 @@ function getToolIcon(tool) {
|
|
|
88
111
|
return "✍️";
|
|
89
112
|
case "edit":
|
|
90
113
|
return "✏️";
|
|
114
|
+
case "apply_patch":
|
|
115
|
+
return "🩹";
|
|
91
116
|
case "bash":
|
|
92
117
|
return "💻";
|
|
93
118
|
case "glob":
|
|
@@ -133,6 +158,37 @@ function formatTodos(todos) {
|
|
|
133
158
|
}
|
|
134
159
|
return result;
|
|
135
160
|
}
|
|
161
|
+
function formatDiffLineInfo(filediff) {
|
|
162
|
+
const parts = [];
|
|
163
|
+
if (filediff.additions && filediff.additions > 0)
|
|
164
|
+
parts.push(`+${filediff.additions}`);
|
|
165
|
+
if (filediff.deletions && filediff.deletions > 0)
|
|
166
|
+
parts.push(`-${filediff.deletions}`);
|
|
167
|
+
return parts.length > 0 ? ` (${parts.join(" ")})` : "";
|
|
168
|
+
}
|
|
169
|
+
function countDiffChangesFromText(text) {
|
|
170
|
+
let additions = 0;
|
|
171
|
+
let deletions = 0;
|
|
172
|
+
for (const line of text.split("\n")) {
|
|
173
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
174
|
+
additions++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
178
|
+
deletions++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { additions, deletions };
|
|
182
|
+
}
|
|
183
|
+
function extractFirstUpdatedFileFromTitle(title) {
|
|
184
|
+
for (const rawLine of title.split("\n")) {
|
|
185
|
+
const line = rawLine.trim();
|
|
186
|
+
if (line.length >= 3 && line[1] === " " && /[AMDURC]/.test(line[0])) {
|
|
187
|
+
return line.slice(2).trim();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return "";
|
|
191
|
+
}
|
|
136
192
|
export function formatToolInfo(toolInfo) {
|
|
137
193
|
const { tool, input, title } = toolInfo;
|
|
138
194
|
logger.debug(`[Formatter] formatToolInfo: tool=${tool}, hasMetadata=${!!toolInfo.metadata}, hasFilediff=${!!toolInfo.metadata?.filediff}`);
|
|
@@ -151,22 +207,41 @@ export function formatToolInfo(toolInfo) {
|
|
|
151
207
|
if (tool === "bash" && input && typeof input.command === "string") {
|
|
152
208
|
details = input.command;
|
|
153
209
|
}
|
|
210
|
+
if (tool === "apply_patch") {
|
|
211
|
+
const filediff = toolInfo.metadata && "filediff" in toolInfo.metadata
|
|
212
|
+
? toolInfo.metadata.filediff
|
|
213
|
+
: undefined;
|
|
214
|
+
if (filediff?.file) {
|
|
215
|
+
details = normalizePathForDisplay(filediff.file);
|
|
216
|
+
}
|
|
217
|
+
else if (title) {
|
|
218
|
+
const fileFromTitle = extractFirstUpdatedFileFromTitle(title);
|
|
219
|
+
if (fileFromTitle) {
|
|
220
|
+
details = normalizePathForDisplay(fileFromTitle);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
154
224
|
const detailsStr = details ? ` ${details}` : "";
|
|
155
225
|
let lineInfo = "";
|
|
156
226
|
if (tool === "write" && input && "content" in input && typeof input.content === "string") {
|
|
157
227
|
const lines = countLines(input.content);
|
|
158
228
|
lineInfo = ` (+${lines})`;
|
|
159
229
|
}
|
|
160
|
-
if (tool === "edit"
|
|
230
|
+
if ((tool === "edit" || tool === "apply_patch") &&
|
|
231
|
+
toolInfo.metadata &&
|
|
232
|
+
"filediff" in toolInfo.metadata) {
|
|
161
233
|
const filediff = toolInfo.metadata.filediff;
|
|
162
|
-
logger.debug("[Formatter]
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
234
|
+
logger.debug("[Formatter] Diff metadata:", JSON.stringify(toolInfo.metadata, null, 2));
|
|
235
|
+
lineInfo = formatDiffLineInfo(filediff);
|
|
236
|
+
}
|
|
237
|
+
if (tool === "apply_patch" && !lineInfo) {
|
|
238
|
+
const diffText = toolInfo.metadata && typeof toolInfo.metadata.diff === "string"
|
|
239
|
+
? toolInfo.metadata.diff
|
|
240
|
+
: input && typeof input.patchText === "string"
|
|
241
|
+
? input.patchText
|
|
242
|
+
: "";
|
|
243
|
+
if (diffText) {
|
|
244
|
+
lineInfo = formatDiffLineInfo(countDiffChangesFromText(diffText));
|
|
170
245
|
}
|
|
171
246
|
}
|
|
172
247
|
return `${toolIcon} ${description}${tool}${detailsStr}${lineInfo}`;
|
|
@@ -209,18 +284,19 @@ function formatDiff(diff) {
|
|
|
209
284
|
return formattedLines.join("\n");
|
|
210
285
|
}
|
|
211
286
|
export function prepareCodeFile(content, filePath, operation) {
|
|
287
|
+
const displayPath = normalizePathForDisplay(filePath);
|
|
212
288
|
let processedContent = content;
|
|
213
289
|
if (operation === "edit") {
|
|
214
290
|
processedContent = formatDiff(content);
|
|
215
291
|
}
|
|
216
292
|
const sizeKb = Buffer.byteLength(processedContent, "utf8") / 1024;
|
|
217
293
|
if (sizeKb > config.files.maxFileSizeKb) {
|
|
218
|
-
logger.debug(`[Formatter] File too large: ${
|
|
294
|
+
logger.debug(`[Formatter] File too large: ${displayPath} (${sizeKb.toFixed(2)} KB > ${config.files.maxFileSizeKb} KB)`);
|
|
219
295
|
return null;
|
|
220
296
|
}
|
|
221
297
|
const header = operation === "write"
|
|
222
|
-
? t("tool.file_header.write", { path:
|
|
223
|
-
: t("tool.file_header.edit", { path:
|
|
298
|
+
? t("tool.file_header.write", { path: displayPath })
|
|
299
|
+
: t("tool.file_header.edit", { path: displayPath });
|
|
224
300
|
const fullContent = header + processedContent;
|
|
225
301
|
const buffer = Buffer.from(fullContent, "utf8");
|
|
226
302
|
const basename = path.basename(filePath);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.js";
|
|
2
|
+
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
3
|
+
const TELEGRAM_MESSAGE_MAX_LENGTH = 4096;
|
|
4
|
+
function normalizeIntervalSeconds(value) {
|
|
5
|
+
if (!Number.isFinite(value)) {
|
|
6
|
+
return DEFAULT_INTERVAL_SECONDS;
|
|
7
|
+
}
|
|
8
|
+
const normalized = Math.floor(value);
|
|
9
|
+
if (normalized < 0) {
|
|
10
|
+
return DEFAULT_INTERVAL_SECONDS;
|
|
11
|
+
}
|
|
12
|
+
return normalized;
|
|
13
|
+
}
|
|
14
|
+
export class ToolMessageBatcher {
|
|
15
|
+
intervalSeconds;
|
|
16
|
+
sendMessage;
|
|
17
|
+
queues = new Map();
|
|
18
|
+
timers = new Map();
|
|
19
|
+
generation = 0;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.intervalSeconds = normalizeIntervalSeconds(options.intervalSeconds);
|
|
22
|
+
this.sendMessage = options.sendMessage;
|
|
23
|
+
}
|
|
24
|
+
setIntervalSeconds(nextIntervalSeconds) {
|
|
25
|
+
const normalized = normalizeIntervalSeconds(nextIntervalSeconds);
|
|
26
|
+
if (this.intervalSeconds === normalized) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
this.intervalSeconds = normalized;
|
|
30
|
+
logger.info(`[ToolBatcher] Interval updated: ${normalized}s`);
|
|
31
|
+
if (normalized === 0) {
|
|
32
|
+
void this.flushAll("interval_updated");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const sessionIds = Array.from(this.queues.keys());
|
|
36
|
+
for (const sessionId of sessionIds) {
|
|
37
|
+
this.restartTimer(sessionId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
getIntervalSeconds() {
|
|
41
|
+
return this.intervalSeconds;
|
|
42
|
+
}
|
|
43
|
+
enqueue(sessionId, message) {
|
|
44
|
+
const normalizedMessage = message.trim();
|
|
45
|
+
if (!sessionId || normalizedMessage.length === 0) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (this.intervalSeconds === 0) {
|
|
49
|
+
const expectedGeneration = this.generation;
|
|
50
|
+
logger.debug(`[ToolBatcher] Sending immediate message: session=${sessionId}`);
|
|
51
|
+
void this.sendMessageSafe(sessionId, normalizedMessage, "immediate", expectedGeneration);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const queue = this.queues.get(sessionId) ?? [];
|
|
55
|
+
queue.push(normalizedMessage);
|
|
56
|
+
this.queues.set(sessionId, queue);
|
|
57
|
+
logger.debug(`[ToolBatcher] Queued message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
|
|
58
|
+
this.ensureTimer(sessionId);
|
|
59
|
+
}
|
|
60
|
+
async flushSession(sessionId, reason) {
|
|
61
|
+
const expectedGeneration = this.generation;
|
|
62
|
+
this.clearTimer(sessionId);
|
|
63
|
+
const queuedMessages = this.queues.get(sessionId);
|
|
64
|
+
if (!queuedMessages || queuedMessages.length === 0) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.queues.delete(sessionId);
|
|
68
|
+
const batches = this.packMessages(queuedMessages);
|
|
69
|
+
logger.debug(`[ToolBatcher] Flushing ${queuedMessages.length} tool messages as ${batches.length} Telegram messages (session=${sessionId}, reason=${reason})`);
|
|
70
|
+
for (const batchMessage of batches) {
|
|
71
|
+
await this.sendMessageSafe(sessionId, batchMessage, reason, expectedGeneration);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async flushAll(reason) {
|
|
75
|
+
for (const sessionId of Array.from(this.timers.keys())) {
|
|
76
|
+
this.clearTimer(sessionId);
|
|
77
|
+
}
|
|
78
|
+
const sessionIds = Array.from(this.queues.keys());
|
|
79
|
+
for (const sessionId of sessionIds) {
|
|
80
|
+
await this.flushSession(sessionId, reason);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
clearSession(sessionId, reason) {
|
|
84
|
+
this.generation++;
|
|
85
|
+
this.clearTimer(sessionId);
|
|
86
|
+
if (this.queues.delete(sessionId)) {
|
|
87
|
+
logger.debug(`[ToolBatcher] Cleared session queue: session=${sessionId}, reason=${reason}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
clearAll(reason) {
|
|
91
|
+
this.generation++;
|
|
92
|
+
for (const timer of this.timers.values()) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
const queuedSessions = this.queues.size;
|
|
96
|
+
this.timers.clear();
|
|
97
|
+
this.queues.clear();
|
|
98
|
+
if (queuedSessions > 0) {
|
|
99
|
+
logger.debug(`[ToolBatcher] Cleared all queued tool messages: sessions=${queuedSessions}, reason=${reason}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
clearTimer(sessionId) {
|
|
103
|
+
const timer = this.timers.get(sessionId);
|
|
104
|
+
if (!timer) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
this.timers.delete(sessionId);
|
|
109
|
+
}
|
|
110
|
+
ensureTimer(sessionId) {
|
|
111
|
+
if (this.timers.has(sessionId)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.restartTimer(sessionId);
|
|
115
|
+
}
|
|
116
|
+
restartTimer(sessionId) {
|
|
117
|
+
this.clearTimer(sessionId);
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
this.timers.delete(sessionId);
|
|
120
|
+
void this.flushSession(sessionId, "interval_elapsed");
|
|
121
|
+
}, this.intervalSeconds * 1000);
|
|
122
|
+
this.timers.set(sessionId, timer);
|
|
123
|
+
}
|
|
124
|
+
async sendMessageSafe(sessionId, text, reason, expectedGeneration) {
|
|
125
|
+
if (this.generation !== expectedGeneration) {
|
|
126
|
+
logger.debug(`[ToolBatcher] Dropping stale tool batch message: session=${sessionId}, reason=${reason}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await this.sendMessage(sessionId, text);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
logger.error(`[ToolBatcher] Failed to send tool batch message: session=${sessionId}, reason=${reason}`, err);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
packMessages(messages) {
|
|
137
|
+
const normalizedEntries = messages
|
|
138
|
+
.flatMap((message) => this.splitLongText(message, TELEGRAM_MESSAGE_MAX_LENGTH))
|
|
139
|
+
.filter((entry) => entry.length > 0);
|
|
140
|
+
if (normalizedEntries.length === 0) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const result = [];
|
|
144
|
+
let current = "";
|
|
145
|
+
for (const entry of normalizedEntries) {
|
|
146
|
+
if (!current) {
|
|
147
|
+
current = entry;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const candidate = `${current}\n\n${entry}`;
|
|
151
|
+
if (candidate.length <= TELEGRAM_MESSAGE_MAX_LENGTH) {
|
|
152
|
+
current = candidate;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
result.push(current);
|
|
156
|
+
current = entry;
|
|
157
|
+
}
|
|
158
|
+
if (current) {
|
|
159
|
+
result.push(current);
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
splitLongText(text, limit) {
|
|
164
|
+
if (text.length <= limit) {
|
|
165
|
+
return [text];
|
|
166
|
+
}
|
|
167
|
+
const chunks = [];
|
|
168
|
+
let remaining = text;
|
|
169
|
+
while (remaining.length > limit) {
|
|
170
|
+
let splitIndex = remaining.lastIndexOf("\n", limit);
|
|
171
|
+
if (splitIndex <= 0 || splitIndex < Math.floor(limit * 0.5)) {
|
|
172
|
+
splitIndex = limit;
|
|
173
|
+
}
|
|
174
|
+
chunks.push(remaining.slice(0, splitIndex));
|
|
175
|
+
remaining = remaining.slice(splitIndex).replace(/^\n+/, "");
|
|
176
|
+
}
|
|
177
|
+
if (remaining.length > 0) {
|
|
178
|
+
chunks.push(remaining);
|
|
179
|
+
}
|
|
180
|
+
return chunks;
|
|
181
|
+
}
|
|
182
|
+
}
|