kernelbot 1.0.24 → 1.0.26
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 +8 -0
- package/README.md +92 -71
- package/bin/kernel.js +30 -21
- package/config.example.yaml +2 -1
- package/package.json +5 -1
- package/src/agent.js +137 -55
- package/src/bot.js +258 -65
- package/src/conversation.js +36 -0
- package/src/prompts/system.js +28 -42
- package/src/providers/anthropic.js +44 -0
- package/src/providers/base.js +30 -0
- package/src/providers/index.js +36 -0
- package/src/providers/models.js +54 -0
- package/src/providers/openai-compat.js +163 -0
- package/src/tools/categories.js +101 -0
- package/src/utils/config.js +160 -12
package/src/agent.js
CHANGED
|
@@ -1,18 +1,105 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createProvider, PROVIDERS } from './providers/index.js';
|
|
2
2
|
import { toolDefinitions, executeTool, checkConfirmation } from './tools/index.js';
|
|
3
|
+
import { selectToolsForMessage, expandToolsForUsed } from './tools/categories.js';
|
|
3
4
|
import { getSystemPrompt } from './prompts/system.js';
|
|
4
5
|
import { getLogger } from './utils/logger.js';
|
|
5
|
-
import { getMissingCredential, saveCredential } from './utils/config.js';
|
|
6
|
+
import { getMissingCredential, saveCredential, saveProviderToYaml } from './utils/config.js';
|
|
7
|
+
|
|
8
|
+
const MAX_RESULT_LENGTH = 3000;
|
|
9
|
+
const LARGE_FIELDS = ['stdout', 'stderr', 'content', 'diff', 'output', 'body', 'html', 'text', 'log', 'logs'];
|
|
6
10
|
|
|
7
11
|
export class Agent {
|
|
8
12
|
constructor({ config, conversationManager }) {
|
|
9
13
|
this.config = config;
|
|
10
14
|
this.conversationManager = conversationManager;
|
|
11
|
-
this.
|
|
15
|
+
this.provider = createProvider(config);
|
|
12
16
|
this.systemPrompt = getSystemPrompt(config);
|
|
13
17
|
this._pending = new Map(); // chatId -> pending state
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
/** Return current brain info for display. */
|
|
21
|
+
getBrainInfo() {
|
|
22
|
+
const { provider, model } = this.config.brain;
|
|
23
|
+
const providerDef = PROVIDERS[provider];
|
|
24
|
+
const providerName = providerDef ? providerDef.name : provider;
|
|
25
|
+
const modelEntry = providerDef?.models.find((m) => m.id === model);
|
|
26
|
+
const modelLabel = modelEntry ? modelEntry.label : model;
|
|
27
|
+
return { provider, providerName, model, modelLabel };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Switch to a different provider/model at runtime.
|
|
32
|
+
* Resolves the API key from process.env automatically.
|
|
33
|
+
* Returns null on success, or an error string if the key is missing.
|
|
34
|
+
*/
|
|
35
|
+
switchBrain(providerKey, modelId) {
|
|
36
|
+
const logger = getLogger();
|
|
37
|
+
const providerDef = PROVIDERS[providerKey];
|
|
38
|
+
if (!providerDef) return `Unknown provider: ${providerKey}`;
|
|
39
|
+
|
|
40
|
+
const envKey = providerDef.envKey;
|
|
41
|
+
const apiKey = process.env[envKey];
|
|
42
|
+
if (!apiKey) {
|
|
43
|
+
return envKey; // caller handles prompting
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.config.brain.provider = providerKey;
|
|
47
|
+
this.config.brain.model = modelId;
|
|
48
|
+
this.config.brain.api_key = apiKey;
|
|
49
|
+
|
|
50
|
+
// Recreate the provider instance
|
|
51
|
+
this.provider = createProvider(this.config);
|
|
52
|
+
|
|
53
|
+
// Persist to config.yaml
|
|
54
|
+
saveProviderToYaml(providerKey, modelId);
|
|
55
|
+
|
|
56
|
+
logger.info(`Brain switched to ${providerDef.name} / ${modelId}`);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Finalize brain switch after API key was provided via chat.
|
|
62
|
+
*/
|
|
63
|
+
switchBrainWithKey(providerKey, modelId, apiKey) {
|
|
64
|
+
const logger = getLogger();
|
|
65
|
+
const providerDef = PROVIDERS[providerKey];
|
|
66
|
+
|
|
67
|
+
// Save the key
|
|
68
|
+
saveCredential(this.config, providerDef.envKey, apiKey);
|
|
69
|
+
|
|
70
|
+
this.config.brain.provider = providerKey;
|
|
71
|
+
this.config.brain.model = modelId;
|
|
72
|
+
this.config.brain.api_key = apiKey;
|
|
73
|
+
|
|
74
|
+
this.provider = createProvider(this.config);
|
|
75
|
+
saveProviderToYaml(providerKey, modelId);
|
|
76
|
+
|
|
77
|
+
logger.info(`Brain switched to ${providerDef.name} / ${modelId} (new key saved)`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Truncate a tool result to stay within token budget.
|
|
82
|
+
*/
|
|
83
|
+
_truncateResult(name, result) {
|
|
84
|
+
let str = JSON.stringify(result);
|
|
85
|
+
if (str.length <= MAX_RESULT_LENGTH) return str;
|
|
86
|
+
|
|
87
|
+
// Try truncating known large fields first
|
|
88
|
+
if (result && typeof result === 'object') {
|
|
89
|
+
const truncated = { ...result };
|
|
90
|
+
for (const field of LARGE_FIELDS) {
|
|
91
|
+
if (typeof truncated[field] === 'string' && truncated[field].length > 500) {
|
|
92
|
+
truncated[field] = truncated[field].slice(0, 500) + `\n... [truncated ${truncated[field].length - 500} chars]`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
str = JSON.stringify(truncated);
|
|
96
|
+
if (str.length <= MAX_RESULT_LENGTH) return str;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Hard truncate
|
|
100
|
+
return str.slice(0, MAX_RESULT_LENGTH) + `\n... [truncated, total ${str.length} chars]`;
|
|
101
|
+
}
|
|
102
|
+
|
|
16
103
|
async processMessage(chatId, userMessage, user, onUpdate, sendPhoto) {
|
|
17
104
|
const logger = getLogger();
|
|
18
105
|
|
|
@@ -33,15 +120,19 @@ export class Agent {
|
|
|
33
120
|
}
|
|
34
121
|
}
|
|
35
122
|
|
|
36
|
-
const { max_tool_depth } = this.config.
|
|
123
|
+
const { max_tool_depth } = this.config.brain;
|
|
37
124
|
|
|
38
125
|
// Add user message to persistent history
|
|
39
126
|
this.conversationManager.addMessage(chatId, 'user', userMessage);
|
|
40
127
|
|
|
41
|
-
// Build working messages from history
|
|
42
|
-
const messages = [...this.conversationManager.
|
|
128
|
+
// Build working messages from compressed history
|
|
129
|
+
const messages = [...this.conversationManager.getSummarizedHistory(chatId)];
|
|
43
130
|
|
|
44
|
-
|
|
131
|
+
// Select relevant tools based on user message
|
|
132
|
+
const tools = selectToolsForMessage(userMessage, toolDefinitions);
|
|
133
|
+
logger.debug(`Selected ${tools.length}/${toolDefinitions.length} tools for message`);
|
|
134
|
+
|
|
135
|
+
return await this._runLoop(chatId, messages, user, 0, max_tool_depth, tools);
|
|
45
136
|
}
|
|
46
137
|
|
|
47
138
|
_formatToolSummary(name, input) {
|
|
@@ -92,7 +183,7 @@ export class Agent {
|
|
|
92
183
|
pending.toolResults.push({
|
|
93
184
|
type: 'tool_result',
|
|
94
185
|
tool_use_id: pending.block.id,
|
|
95
|
-
content:
|
|
186
|
+
content: this._truncateResult(pending.block.name, { error: `${pending.credential.label} not provided. Operation skipped.` }),
|
|
96
187
|
});
|
|
97
188
|
return await this._resumeAfterPause(chatId, user, pending);
|
|
98
189
|
}
|
|
@@ -112,7 +203,7 @@ export class Agent {
|
|
|
112
203
|
pending.toolResults.push({
|
|
113
204
|
type: 'tool_result',
|
|
114
205
|
tool_use_id: pending.block.id,
|
|
115
|
-
content:
|
|
206
|
+
content: this._truncateResult(pending.block.name, result),
|
|
116
207
|
});
|
|
117
208
|
|
|
118
209
|
return await this._resumeAfterPause(chatId, user, pending);
|
|
@@ -129,14 +220,14 @@ export class Agent {
|
|
|
129
220
|
pending.toolResults.push({
|
|
130
221
|
type: 'tool_result',
|
|
131
222
|
tool_use_id: pending.block.id,
|
|
132
|
-
content:
|
|
223
|
+
content: this._truncateResult(pending.block.name, result),
|
|
133
224
|
});
|
|
134
225
|
} else {
|
|
135
226
|
logger.info(`User denied dangerous tool: ${pending.block.name}`);
|
|
136
227
|
pending.toolResults.push({
|
|
137
228
|
type: 'tool_result',
|
|
138
229
|
tool_use_id: pending.block.id,
|
|
139
|
-
content:
|
|
230
|
+
content: this._truncateResult(pending.block.name, { error: 'User denied this operation.' }),
|
|
140
231
|
});
|
|
141
232
|
}
|
|
142
233
|
|
|
@@ -155,13 +246,13 @@ export class Agent {
|
|
|
155
246
|
pending.toolResults.push({
|
|
156
247
|
type: 'tool_result',
|
|
157
248
|
tool_use_id: block.id,
|
|
158
|
-
content:
|
|
249
|
+
content: this._truncateResult(block.name, r),
|
|
159
250
|
});
|
|
160
251
|
}
|
|
161
252
|
|
|
162
253
|
pending.messages.push({ role: 'user', content: pending.toolResults });
|
|
163
|
-
const { max_tool_depth } = this.config.
|
|
164
|
-
return await this._runLoop(chatId, pending.messages, user, 0, max_tool_depth);
|
|
254
|
+
const { max_tool_depth } = this.config.brain;
|
|
255
|
+
return await this._runLoop(chatId, pending.messages, user, 0, max_tool_depth, pending.tools || toolDefinitions);
|
|
165
256
|
}
|
|
166
257
|
|
|
167
258
|
_checkPause(chatId, block, user, toolResults, remainingBlocks, messages) {
|
|
@@ -201,58 +292,48 @@ export class Agent {
|
|
|
201
292
|
return null;
|
|
202
293
|
}
|
|
203
294
|
|
|
204
|
-
async _runLoop(chatId, messages, user, startDepth, maxDepth) {
|
|
295
|
+
async _runLoop(chatId, messages, user, startDepth, maxDepth, tools) {
|
|
205
296
|
const logger = getLogger();
|
|
206
|
-
|
|
297
|
+
let currentTools = tools || toolDefinitions;
|
|
207
298
|
|
|
208
299
|
for (let depth = startDepth; depth < maxDepth; depth++) {
|
|
209
300
|
logger.debug(`Agent loop iteration ${depth + 1}/${maxDepth}`);
|
|
210
301
|
|
|
211
|
-
const response = await this.
|
|
212
|
-
model,
|
|
213
|
-
max_tokens,
|
|
214
|
-
temperature,
|
|
302
|
+
const response = await this.provider.chat({
|
|
215
303
|
system: this.systemPrompt,
|
|
216
|
-
tools: toolDefinitions,
|
|
217
304
|
messages,
|
|
305
|
+
tools: currentTools,
|
|
218
306
|
});
|
|
219
307
|
|
|
220
|
-
if (response.
|
|
221
|
-
const
|
|
222
|
-
.filter((b) => b.type === 'text')
|
|
223
|
-
.map((b) => b.text);
|
|
224
|
-
const reply = textBlocks.join('\n');
|
|
225
|
-
|
|
308
|
+
if (response.stopReason === 'end_turn') {
|
|
309
|
+
const reply = response.text || '';
|
|
226
310
|
this.conversationManager.addMessage(chatId, 'assistant', reply);
|
|
227
311
|
return reply;
|
|
228
312
|
}
|
|
229
313
|
|
|
230
|
-
if (response.
|
|
231
|
-
messages.push({ role: 'assistant', content: response.
|
|
314
|
+
if (response.stopReason === 'tool_use') {
|
|
315
|
+
messages.push({ role: 'assistant', content: response.rawContent });
|
|
232
316
|
|
|
233
|
-
// Send
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
logger.info(`Agent thinking: ${thinking.slice(0, 200)}`);
|
|
238
|
-
await this._sendUpdate(`💭 ${thinking}`);
|
|
317
|
+
// Send thinking text to the user
|
|
318
|
+
if (response.text && response.text.trim()) {
|
|
319
|
+
logger.info(`Agent thinking: ${response.text.slice(0, 200)}`);
|
|
320
|
+
await this._sendUpdate(`💭 ${response.text}`);
|
|
239
321
|
}
|
|
240
322
|
|
|
241
|
-
const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
|
|
242
323
|
const toolResults = [];
|
|
324
|
+
const usedToolNames = [];
|
|
243
325
|
|
|
244
|
-
for (let i = 0; i <
|
|
245
|
-
const block =
|
|
326
|
+
for (let i = 0; i < response.toolCalls.length; i++) {
|
|
327
|
+
const block = response.toolCalls[i];
|
|
328
|
+
|
|
329
|
+
// Build a block-like object for _checkPause (needs .type for remainingBlocks filter)
|
|
330
|
+
const blockObj = { type: 'tool_use', id: block.id, name: block.name, input: block.input };
|
|
246
331
|
|
|
247
332
|
// Check if we need to pause (missing cred or dangerous action)
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
toolResults,
|
|
253
|
-
toolUseBlocks.slice(i + 1),
|
|
254
|
-
messages,
|
|
255
|
-
);
|
|
333
|
+
const remaining = response.toolCalls.slice(i + 1).map((tc) => ({
|
|
334
|
+
type: 'tool_use', id: tc.id, name: tc.name, input: tc.input,
|
|
335
|
+
}));
|
|
336
|
+
const pauseMsg = this._checkPause(chatId, blockObj, user, toolResults, remaining, messages);
|
|
256
337
|
if (pauseMsg) return pauseMsg;
|
|
257
338
|
|
|
258
339
|
const summary = this._formatToolSummary(block.name, block.input);
|
|
@@ -266,26 +347,27 @@ export class Agent {
|
|
|
266
347
|
sendPhoto: this._sendPhoto,
|
|
267
348
|
});
|
|
268
349
|
|
|
350
|
+
usedToolNames.push(block.name);
|
|
351
|
+
|
|
269
352
|
toolResults.push({
|
|
270
353
|
type: 'tool_result',
|
|
271
354
|
tool_use_id: block.id,
|
|
272
|
-
content:
|
|
355
|
+
content: this._truncateResult(block.name, result),
|
|
273
356
|
});
|
|
274
357
|
}
|
|
275
358
|
|
|
359
|
+
// Expand tools based on what was actually used
|
|
360
|
+
currentTools = expandToolsForUsed(usedToolNames, currentTools, toolDefinitions);
|
|
361
|
+
|
|
276
362
|
messages.push({ role: 'user', content: toolResults });
|
|
277
363
|
continue;
|
|
278
364
|
}
|
|
279
365
|
|
|
280
366
|
// Unexpected stop reason
|
|
281
|
-
logger.warn(`Unexpected
|
|
282
|
-
|
|
283
|
-
.
|
|
284
|
-
|
|
285
|
-
.join('\n');
|
|
286
|
-
if (fallbackText) {
|
|
287
|
-
this.conversationManager.addMessage(chatId, 'assistant', fallbackText);
|
|
288
|
-
return fallbackText;
|
|
367
|
+
logger.warn(`Unexpected stopReason: ${response.stopReason}`);
|
|
368
|
+
if (response.text) {
|
|
369
|
+
this.conversationManager.addMessage(chatId, 'assistant', response.text);
|
|
370
|
+
return response.text;
|
|
289
371
|
}
|
|
290
372
|
return 'Something went wrong — unexpected response from the model.';
|
|
291
373
|
}
|
package/src/bot.js
CHANGED
|
@@ -2,6 +2,7 @@ import TelegramBot from 'node-telegram-bot-api';
|
|
|
2
2
|
import { createReadStream } from 'fs';
|
|
3
3
|
import { isAllowedUser, getUnauthorizedMessage } from './security/auth.js';
|
|
4
4
|
import { getLogger } from './utils/logger.js';
|
|
5
|
+
import { PROVIDERS } from './providers/models.js';
|
|
5
6
|
|
|
6
7
|
function splitMessage(text, maxLength = 4096) {
|
|
7
8
|
if (text.length <= maxLength) return [text];
|
|
@@ -22,9 +23,32 @@ function splitMessage(text, maxLength = 4096) {
|
|
|
22
23
|
return chunks;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Simple per-chat queue to serialize agent processing.
|
|
28
|
+
* Each chat gets its own promise chain so messages are processed in order.
|
|
29
|
+
*/
|
|
30
|
+
class ChatQueue {
|
|
31
|
+
constructor() {
|
|
32
|
+
this.queues = new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
enqueue(chatId, fn) {
|
|
36
|
+
const key = String(chatId);
|
|
37
|
+
const prev = this.queues.get(key) || Promise.resolve();
|
|
38
|
+
const next = prev.then(() => fn()).catch(() => {});
|
|
39
|
+
this.queues.set(key, next);
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
export function startBot(config, agent, conversationManager) {
|
|
26
45
|
const logger = getLogger();
|
|
27
46
|
const bot = new TelegramBot(config.telegram.bot_token, { polling: true });
|
|
47
|
+
const chatQueue = new ChatQueue();
|
|
48
|
+
const batchWindowMs = config.telegram.batch_window_ms || 3000;
|
|
49
|
+
|
|
50
|
+
// Per-chat message batching: chatId -> { messages[], timer, resolve }
|
|
51
|
+
const chatBatches = new Map();
|
|
28
52
|
|
|
29
53
|
// Load previous conversations from disk
|
|
30
54
|
const loaded = conversationManager.load();
|
|
@@ -34,6 +58,124 @@ export function startBot(config, agent, conversationManager) {
|
|
|
34
58
|
|
|
35
59
|
logger.info('Telegram bot started with polling');
|
|
36
60
|
|
|
61
|
+
// Track pending brain API key input: chatId -> { providerKey, modelId }
|
|
62
|
+
const pendingBrainKey = new Map();
|
|
63
|
+
|
|
64
|
+
// Handle inline keyboard callbacks for /brain
|
|
65
|
+
bot.on('callback_query', async (query) => {
|
|
66
|
+
const chatId = query.message.chat.id;
|
|
67
|
+
const data = query.data;
|
|
68
|
+
|
|
69
|
+
if (!isAllowedUser(query.from.id, config)) {
|
|
70
|
+
await bot.answerCallbackQuery(query.id, { text: 'Unauthorized' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
if (data.startsWith('brain_provider:')) {
|
|
76
|
+
// User picked a provider — show model list
|
|
77
|
+
const providerKey = data.split(':')[1];
|
|
78
|
+
const providerDef = PROVIDERS[providerKey];
|
|
79
|
+
if (!providerDef) {
|
|
80
|
+
await bot.answerCallbackQuery(query.id, { text: 'Unknown provider' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const modelButtons = providerDef.models.map((m) => ([{
|
|
85
|
+
text: m.label,
|
|
86
|
+
callback_data: `brain_model:${providerKey}:${m.id}`,
|
|
87
|
+
}]));
|
|
88
|
+
modelButtons.push([{ text: 'Cancel', callback_data: 'brain_cancel' }]);
|
|
89
|
+
|
|
90
|
+
await bot.editMessageText(`Select a *${providerDef.name}* model:`, {
|
|
91
|
+
chat_id: chatId,
|
|
92
|
+
message_id: query.message.message_id,
|
|
93
|
+
parse_mode: 'Markdown',
|
|
94
|
+
reply_markup: { inline_keyboard: modelButtons },
|
|
95
|
+
});
|
|
96
|
+
await bot.answerCallbackQuery(query.id);
|
|
97
|
+
|
|
98
|
+
} else if (data.startsWith('brain_model:')) {
|
|
99
|
+
// User picked a model — attempt switch
|
|
100
|
+
const [, providerKey, modelId] = data.split(':');
|
|
101
|
+
const providerDef = PROVIDERS[providerKey];
|
|
102
|
+
const modelEntry = providerDef?.models.find((m) => m.id === modelId);
|
|
103
|
+
const modelLabel = modelEntry ? modelEntry.label : modelId;
|
|
104
|
+
|
|
105
|
+
const missing = agent.switchBrain(providerKey, modelId);
|
|
106
|
+
if (missing) {
|
|
107
|
+
// API key missing — ask for it
|
|
108
|
+
pendingBrainKey.set(chatId, { providerKey, modelId });
|
|
109
|
+
await bot.editMessageText(
|
|
110
|
+
`🔑 *${providerDef.name}* API key is required.\n\nPlease send your \`${missing}\` now.\n\nOr send *cancel* to abort.`,
|
|
111
|
+
{
|
|
112
|
+
chat_id: chatId,
|
|
113
|
+
message_id: query.message.message_id,
|
|
114
|
+
parse_mode: 'Markdown',
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
const info = agent.getBrainInfo();
|
|
119
|
+
await bot.editMessageText(
|
|
120
|
+
`🧠 Brain switched to *${info.providerName}* / *${info.modelLabel}*`,
|
|
121
|
+
{
|
|
122
|
+
chat_id: chatId,
|
|
123
|
+
message_id: query.message.message_id,
|
|
124
|
+
parse_mode: 'Markdown',
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
await bot.answerCallbackQuery(query.id);
|
|
129
|
+
|
|
130
|
+
} else if (data === 'brain_cancel') {
|
|
131
|
+
pendingBrainKey.delete(chatId);
|
|
132
|
+
await bot.editMessageText('Brain change cancelled.', {
|
|
133
|
+
chat_id: chatId,
|
|
134
|
+
message_id: query.message.message_id,
|
|
135
|
+
});
|
|
136
|
+
await bot.answerCallbackQuery(query.id);
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logger.error(`Callback query error: ${err.message}`);
|
|
140
|
+
await bot.answerCallbackQuery(query.id, { text: 'Error' });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Batch messages for a chat. Returns the merged text for the first message,
|
|
146
|
+
* or null for subsequent messages (they get merged into the first).
|
|
147
|
+
*/
|
|
148
|
+
function batchMessage(chatId, text) {
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
const key = String(chatId);
|
|
151
|
+
let batch = chatBatches.get(key);
|
|
152
|
+
|
|
153
|
+
if (!batch) {
|
|
154
|
+
batch = { messages: [], timer: null, resolvers: [] };
|
|
155
|
+
chatBatches.set(key, batch);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
batch.messages.push(text);
|
|
159
|
+
batch.resolvers.push(resolve);
|
|
160
|
+
|
|
161
|
+
// Reset timer on each new message
|
|
162
|
+
if (batch.timer) clearTimeout(batch.timer);
|
|
163
|
+
|
|
164
|
+
batch.timer = setTimeout(() => {
|
|
165
|
+
chatBatches.delete(key);
|
|
166
|
+
const merged = batch.messages.length === 1
|
|
167
|
+
? batch.messages[0]
|
|
168
|
+
: batch.messages.map((m, i) => `[${i + 1}]: ${m}`).join('\n\n');
|
|
169
|
+
|
|
170
|
+
// First resolver gets the merged text, rest get null (skip)
|
|
171
|
+
batch.resolvers[0](merged);
|
|
172
|
+
for (let i = 1; i < batch.resolvers.length; i++) {
|
|
173
|
+
batch.resolvers[i](null);
|
|
174
|
+
}
|
|
175
|
+
}, batchWindowMs);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
37
179
|
bot.on('message', async (msg) => {
|
|
38
180
|
if (!msg.text) return; // ignore non-text
|
|
39
181
|
|
|
@@ -50,7 +192,47 @@ export function startBot(config, agent, conversationManager) {
|
|
|
50
192
|
|
|
51
193
|
let text = msg.text.trim();
|
|
52
194
|
|
|
53
|
-
// Handle
|
|
195
|
+
// Handle pending brain API key input
|
|
196
|
+
if (pendingBrainKey.has(chatId)) {
|
|
197
|
+
const pending = pendingBrainKey.get(chatId);
|
|
198
|
+
pendingBrainKey.delete(chatId);
|
|
199
|
+
|
|
200
|
+
if (text.toLowerCase() === 'cancel') {
|
|
201
|
+
await bot.sendMessage(chatId, 'Brain change cancelled.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
agent.switchBrainWithKey(pending.providerKey, pending.modelId, text);
|
|
206
|
+
const info = agent.getBrainInfo();
|
|
207
|
+
await bot.sendMessage(
|
|
208
|
+
chatId,
|
|
209
|
+
`🧠 Brain switched to *${info.providerName}* / *${info.modelLabel}*\n\nAPI key saved.`,
|
|
210
|
+
{ parse_mode: 'Markdown' },
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Handle commands — these bypass batching entirely
|
|
216
|
+
if (text === '/brain') {
|
|
217
|
+
const info = agent.getBrainInfo();
|
|
218
|
+
const providerKeys = Object.keys(PROVIDERS);
|
|
219
|
+
const buttons = providerKeys.map((key) => ([{
|
|
220
|
+
text: `${PROVIDERS[key].name}${key === info.provider ? ' ✓' : ''}`,
|
|
221
|
+
callback_data: `brain_provider:${key}`,
|
|
222
|
+
}]));
|
|
223
|
+
buttons.push([{ text: 'Cancel', callback_data: 'brain_cancel' }]);
|
|
224
|
+
|
|
225
|
+
await bot.sendMessage(
|
|
226
|
+
chatId,
|
|
227
|
+
`🧠 *Current brain:* ${info.providerName} / ${info.modelLabel}\n\nSelect a provider to switch:`,
|
|
228
|
+
{
|
|
229
|
+
parse_mode: 'Markdown',
|
|
230
|
+
reply_markup: { inline_keyboard: buttons },
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
54
236
|
if (text === '/clean' || text === '/clear' || text === '/reset') {
|
|
55
237
|
conversationManager.clear(chatId);
|
|
56
238
|
logger.info(`Conversation cleared for chat ${chatId} by ${username}`);
|
|
@@ -68,6 +250,7 @@ export function startBot(config, agent, conversationManager) {
|
|
|
68
250
|
await bot.sendMessage(chatId, [
|
|
69
251
|
'*KernelBot Commands*',
|
|
70
252
|
'',
|
|
253
|
+
'/brain — Show current AI model and switch provider/model',
|
|
71
254
|
'/clean — Clear conversation and start fresh',
|
|
72
255
|
'/history — Show message count in memory',
|
|
73
256
|
'/browse <url> — Browse a website and get a summary',
|
|
@@ -106,91 +289,101 @@ export function startBot(config, agent, conversationManager) {
|
|
|
106
289
|
text = `Extract content from ${extractUrl} using the CSS selector: ${extractSelector}`;
|
|
107
290
|
}
|
|
108
291
|
|
|
109
|
-
|
|
292
|
+
// Batch messages — wait for the batch window to close
|
|
293
|
+
const mergedText = await batchMessage(chatId, text);
|
|
294
|
+
if (mergedText === null) {
|
|
295
|
+
// This message was merged into another batch — skip
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
logger.info(`Message from ${username} (${userId}): ${mergedText.slice(0, 100)}`);
|
|
110
300
|
|
|
111
|
-
//
|
|
112
|
-
|
|
301
|
+
// Enqueue into per-chat queue for serialized processing
|
|
302
|
+
chatQueue.enqueue(chatId, async () => {
|
|
303
|
+
// Show typing and keep refreshing it
|
|
304
|
+
const typingInterval = setInterval(() => {
|
|
305
|
+
bot.sendChatAction(chatId, 'typing').catch(() => {});
|
|
306
|
+
}, 4000);
|
|
113
307
|
bot.sendChatAction(chatId, 'typing').catch(() => {});
|
|
114
|
-
}, 4000);
|
|
115
|
-
bot.sendChatAction(chatId, 'typing').catch(() => {});
|
|
116
308
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
const edited = await bot.editMessageText(update, {
|
|
123
|
-
chat_id: chatId,
|
|
124
|
-
message_id: opts.editMessageId,
|
|
125
|
-
parse_mode: 'Markdown',
|
|
126
|
-
});
|
|
127
|
-
return edited.message_id;
|
|
128
|
-
} catch {
|
|
309
|
+
try {
|
|
310
|
+
const onUpdate = async (update, opts = {}) => {
|
|
311
|
+
// Edit an existing message instead of sending a new one
|
|
312
|
+
if (opts.editMessageId) {
|
|
129
313
|
try {
|
|
130
314
|
const edited = await bot.editMessageText(update, {
|
|
131
315
|
chat_id: chatId,
|
|
132
316
|
message_id: opts.editMessageId,
|
|
317
|
+
parse_mode: 'Markdown',
|
|
133
318
|
});
|
|
134
319
|
return edited.message_id;
|
|
135
320
|
} catch {
|
|
136
|
-
|
|
321
|
+
try {
|
|
322
|
+
const edited = await bot.editMessageText(update, {
|
|
323
|
+
chat_id: chatId,
|
|
324
|
+
message_id: opts.editMessageId,
|
|
325
|
+
});
|
|
326
|
+
return edited.message_id;
|
|
327
|
+
} catch {
|
|
328
|
+
return opts.editMessageId;
|
|
329
|
+
}
|
|
137
330
|
}
|
|
138
331
|
}
|
|
139
|
-
}
|
|
140
332
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
333
|
+
// Send new message(s)
|
|
334
|
+
const parts = splitMessage(update);
|
|
335
|
+
let lastMsgId = null;
|
|
336
|
+
for (const part of parts) {
|
|
337
|
+
try {
|
|
338
|
+
const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
|
|
339
|
+
lastMsgId = sent.message_id;
|
|
340
|
+
} catch {
|
|
341
|
+
const sent = await bot.sendMessage(chatId, part);
|
|
342
|
+
lastMsgId = sent.message_id;
|
|
343
|
+
}
|
|
151
344
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const sendPhoto = async (filePath, caption) => {
|
|
157
|
-
try {
|
|
158
|
-
await bot.sendPhoto(chatId, createReadStream(filePath), {
|
|
159
|
-
caption: caption || '',
|
|
160
|
-
parse_mode: 'Markdown',
|
|
161
|
-
});
|
|
162
|
-
} catch {
|
|
345
|
+
return lastMsgId;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const sendPhoto = async (filePath, caption) => {
|
|
163
349
|
try {
|
|
164
350
|
await bot.sendPhoto(chatId, createReadStream(filePath), {
|
|
165
351
|
caption: caption || '',
|
|
352
|
+
parse_mode: 'Markdown',
|
|
166
353
|
});
|
|
167
|
-
} catch
|
|
168
|
-
|
|
354
|
+
} catch {
|
|
355
|
+
try {
|
|
356
|
+
await bot.sendPhoto(chatId, createReadStream(filePath), {
|
|
357
|
+
caption: caption || '',
|
|
358
|
+
});
|
|
359
|
+
} catch (err) {
|
|
360
|
+
logger.error(`Failed to send photo: ${err.message}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const reply = await agent.processMessage(chatId, mergedText, {
|
|
366
|
+
id: userId,
|
|
367
|
+
username,
|
|
368
|
+
}, onUpdate, sendPhoto);
|
|
369
|
+
|
|
370
|
+
clearInterval(typingInterval);
|
|
371
|
+
|
|
372
|
+
const chunks = splitMessage(reply || 'Done.');
|
|
373
|
+
for (const chunk of chunks) {
|
|
374
|
+
try {
|
|
375
|
+
await bot.sendMessage(chatId, chunk, { parse_mode: 'Markdown' });
|
|
376
|
+
} catch {
|
|
377
|
+
// Fallback to plain text if Markdown fails
|
|
378
|
+
await bot.sendMessage(chatId, chunk);
|
|
169
379
|
}
|
|
170
380
|
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
username,
|
|
176
|
-
}, onUpdate, sendPhoto);
|
|
177
|
-
|
|
178
|
-
clearInterval(typingInterval);
|
|
179
|
-
|
|
180
|
-
const chunks = splitMessage(reply || 'Done.');
|
|
181
|
-
for (const chunk of chunks) {
|
|
182
|
-
try {
|
|
183
|
-
await bot.sendMessage(chatId, chunk, { parse_mode: 'Markdown' });
|
|
184
|
-
} catch {
|
|
185
|
-
// Fallback to plain text if Markdown fails
|
|
186
|
-
await bot.sendMessage(chatId, chunk);
|
|
187
|
-
}
|
|
381
|
+
} catch (err) {
|
|
382
|
+
clearInterval(typingInterval);
|
|
383
|
+
logger.error(`Error processing message: ${err.message}`);
|
|
384
|
+
await bot.sendMessage(chatId, `Error: ${err.message}`);
|
|
188
385
|
}
|
|
189
|
-
}
|
|
190
|
-
clearInterval(typingInterval);
|
|
191
|
-
logger.error(`Error processing message: ${err.message}`);
|
|
192
|
-
await bot.sendMessage(chatId, `Error: ${err.message}`);
|
|
193
|
-
}
|
|
386
|
+
});
|
|
194
387
|
});
|
|
195
388
|
|
|
196
389
|
bot.on('polling_error', (err) => {
|