kernelbot 1.0.25 → 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/README.md CHANGED
@@ -219,11 +219,12 @@ conversation:
219
219
 
220
220
  ## Telegram Commands
221
221
 
222
- | Command | Description |
223
- | ---------- | ---------------------------------- |
224
- | `/clean` | Clear conversation and start fresh |
225
- | `/history` | Show message count in memory |
226
- | `/help` | Show help message |
222
+ | Command | Description |
223
+ | ---------- | ---------------------------------------------- |
224
+ | `/brain` | Show current AI model and switch provider/model |
225
+ | `/clean` | Clear conversation and start fresh |
226
+ | `/history` | Show message count in memory |
227
+ | `/help` | Show help message |
227
228
 
228
229
  ## Security
229
230
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kernelbot",
3
- "version": "1.0.25",
3
+ "version": "1.0.26",
4
4
  "description": "KernelBot — AI engineering agent with full OS control",
5
5
  "type": "module",
6
6
  "author": "Abdullah Al-Taheri <abdullah@altaheri.me>",
package/src/agent.js CHANGED
@@ -1,8 +1,12 @@
1
- import { createProvider } from './providers/index.js';
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 }) {
@@ -13,6 +17,89 @@ export class Agent {
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
 
@@ -38,10 +125,14 @@ export class Agent {
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.getHistory(chatId)];
128
+ // Build working messages from compressed history
129
+ const messages = [...this.conversationManager.getSummarizedHistory(chatId)];
43
130
 
44
- return await this._runLoop(chatId, messages, user, 0, max_tool_depth);
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: JSON.stringify({ error: `${pending.credential.label} not provided. Operation skipped.` }),
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: JSON.stringify(result),
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: JSON.stringify(result),
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: JSON.stringify({ error: 'User denied this operation.' }),
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: JSON.stringify(r),
249
+ content: this._truncateResult(block.name, r),
159
250
  });
160
251
  }
161
252
 
162
253
  pending.messages.push({ role: 'user', content: pending.toolResults });
163
254
  const { max_tool_depth } = this.config.brain;
164
- return await this._runLoop(chatId, pending.messages, user, 0, max_tool_depth);
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,8 +292,9 @@ 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();
297
+ let currentTools = tools || toolDefinitions;
206
298
 
207
299
  for (let depth = startDepth; depth < maxDepth; depth++) {
208
300
  logger.debug(`Agent loop iteration ${depth + 1}/${maxDepth}`);
@@ -210,7 +302,7 @@ export class Agent {
210
302
  const response = await this.provider.chat({
211
303
  system: this.systemPrompt,
212
304
  messages,
213
- tools: toolDefinitions,
305
+ tools: currentTools,
214
306
  });
215
307
 
216
308
  if (response.stopReason === 'end_turn') {
@@ -229,6 +321,7 @@ export class Agent {
229
321
  }
230
322
 
231
323
  const toolResults = [];
324
+ const usedToolNames = [];
232
325
 
233
326
  for (let i = 0; i < response.toolCalls.length; i++) {
234
327
  const block = response.toolCalls[i];
@@ -254,13 +347,18 @@ export class Agent {
254
347
  sendPhoto: this._sendPhoto,
255
348
  });
256
349
 
350
+ usedToolNames.push(block.name);
351
+
257
352
  toolResults.push({
258
353
  type: 'tool_result',
259
354
  tool_use_id: block.id,
260
- content: JSON.stringify(result),
355
+ content: this._truncateResult(block.name, result),
261
356
  });
262
357
  }
263
358
 
359
+ // Expand tools based on what was actually used
360
+ currentTools = expandToolsForUsed(usedToolNames, currentTools, toolDefinitions);
361
+
264
362
  messages.push({ role: 'user', content: toolResults });
265
363
  continue;
266
364
  }
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 commands
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
- logger.info(`Message from ${username} (${userId}): ${text.slice(0, 100)}`);
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
- // Show typing and keep refreshing it
112
- const typingInterval = setInterval(() => {
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
- try {
118
- const onUpdate = async (update, opts = {}) => {
119
- // Edit an existing message instead of sending a new one
120
- if (opts.editMessageId) {
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
- return opts.editMessageId;
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
- // Send new message(s)
142
- const parts = splitMessage(update);
143
- let lastMsgId = null;
144
- for (const part of parts) {
145
- try {
146
- const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
147
- lastMsgId = sent.message_id;
148
- } catch {
149
- const sent = await bot.sendMessage(chatId, part);
150
- lastMsgId = sent.message_id;
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
- return lastMsgId;
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 (err) {
168
- logger.error(`Failed to send photo: ${err.message}`);
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
- const reply = await agent.processMessage(chatId, text, {
174
- id: userId,
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
- } catch (err) {
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) => {
@@ -11,6 +11,7 @@ function getConversationsPath() {
11
11
  export class ConversationManager {
12
12
  constructor(config) {
13
13
  this.maxHistory = config.conversation.max_history;
14
+ this.recentWindow = config.conversation.recent_window || 10;
14
15
  this.conversations = new Map();
15
16
  this.filePath = getConversationsPath();
16
17
  }
@@ -49,6 +50,41 @@ export class ConversationManager {
49
50
  return this.conversations.get(key);
50
51
  }
51
52
 
53
+ /**
54
+ * Get history with older messages compressed into a summary.
55
+ * Keeps the last `recentWindow` messages verbatim and summarizes older ones.
56
+ */
57
+ getSummarizedHistory(chatId) {
58
+ const history = this.getHistory(chatId);
59
+
60
+ if (history.length <= this.recentWindow) {
61
+ return [...history];
62
+ }
63
+
64
+ const olderMessages = history.slice(0, history.length - this.recentWindow);
65
+ const recentMessages = history.slice(history.length - this.recentWindow);
66
+
67
+ // Compress older messages into a single summary
68
+ const summaryLines = olderMessages.map((msg) => {
69
+ const content = typeof msg.content === 'string'
70
+ ? msg.content.slice(0, 200)
71
+ : JSON.stringify(msg.content).slice(0, 200);
72
+ return `[${msg.role}]: ${content}`;
73
+ });
74
+
75
+ const summaryMessage = {
76
+ role: 'user',
77
+ content: `[CONVERSATION SUMMARY - ${olderMessages.length} earlier messages]\n${summaryLines.join('\n')}`,
78
+ };
79
+
80
+ // Ensure result starts with user role
81
+ const result = [summaryMessage, ...recentMessages];
82
+
83
+ // If the first real message after summary is assistant, that's fine since
84
+ // our summary is role:user. But ensure recent starts correctly.
85
+ return result;
86
+ }
87
+
52
88
  addMessage(chatId, role, content) {
53
89
  const history = this.getHistory(chatId);
54
90
  history.push({ role, content });
@@ -1,46 +1,32 @@
1
- import { toolDefinitions } from '../tools/index.js';
2
-
3
1
  export function getSystemPrompt(config) {
4
- const toolList = toolDefinitions.map((t) => `- ${t.name}: ${t.description}`).join('\n');
5
-
6
- return `You are ${config.bot.name}, a senior software engineer and sysadmin AI agent.
7
- You talk to the user via Telegram. You are confident, concise, and effective.
8
-
9
- You have full access to the operating system through your tools:
10
- ${toolList}
11
-
12
- ## Coding Tasks (writing code, fixing bugs, reviewing code, scaffolding projects)
13
- IMPORTANT: You MUST NOT write code yourself using read_file/write_file. ALWAYS delegate coding to Claude Code.
14
- 1. Use git tools to clone the repo and create a branch
15
- 2. Use spawn_claude_code to do the actual coding work inside the repo — give it a clear, detailed prompt describing exactly what to build or fix
16
- 3. After Claude Code finishes, use git tools to commit and push
17
- 4. Use GitHub tools to create the PR
18
- 5. Report back with the PR link
19
-
20
- ## Web Browsing Tasks (researching, scraping, reading documentation, taking screenshots)
21
- - Use browse_website to read and summarize web pages
22
- - Use screenshot_website to capture visual snapshots of pages — the screenshot is automatically sent to the chat
23
- - Use extract_content to pull specific data from pages using CSS selectors
24
- - Use interact_with_page for pages that need clicking, typing, or scrolling to reveal content
25
- - Use send_image to send any image file directly to the Telegram chat (screenshots, generated images, etc.)
26
- - When a user sends /browse <url>, use browse_website on that URL
27
- - When a user sends /screenshot <url>, use screenshot_website on that URL
28
- - When a user sends /extract <url> <selector>, use extract_content with that URL and selector
29
-
30
- You are the orchestrator. Claude Code is the coder. Never use read_file + write_file to modify source code — that's Claude Code's job. You handle git, GitHub, and infrastructure. Claude Code handles all code changes.
31
-
32
-
33
- ## Non-Coding Tasks (monitoring, deploying, restarting services, checking status)
34
- - Use OS, Docker, process, network, and monitoring tools directly
35
- - No need to spawn Claude Code for these
2
+ return `You are ${config.bot.name}, a senior software engineer and sysadmin AI agent on Telegram. Be concise — this is chat, not documentation.
3
+
4
+ ## Coding Tasks
5
+ NEVER write code yourself with read_file/write_file. ALWAYS use spawn_claude_code.
6
+ 1. Clone repo + create branch (git tools)
7
+ 2. spawn_claude_code with a clear, detailed prompt
8
+ 3. Commit + push (git tools)
9
+ 4. Create PR (GitHub tools) and report the link
10
+
11
+ ## Web Browsing
12
+ - browse_website: read/summarize pages
13
+ - screenshot_website: visual snapshots (auto-sent to chat)
14
+ - extract_content: pull data via CSS selectors
15
+ - interact_with_page: click/type/scroll on pages
16
+ - send_image: send any image file to chat
17
+
18
+ ## Non-Coding Tasks
19
+ Use OS, Docker, process, network, and monitoring tools directly. No need for Claude Code.
20
+
21
+ ## Efficiency Rules
22
+ - Chain shell commands with && in execute_command instead of multiple calls
23
+ - Read multiple files with one execute_command("cat file1 file2") instead of multiple read_file calls
24
+ - Plan first, gather info in one step, then act
25
+ - Keep responses under 500 words unless asked for details
36
26
 
37
27
  ## Guidelines
38
- - Use tools proactively to complete tasks. Don't just describe what you would do do it.
39
- - When a task requires multiple steps, execute them in sequence using tools.
40
- - If a command fails, analyze the error and try an alternative approach.
41
- - Be concise you're talking on Telegram, not writing essays.
42
- - For destructive operations (rm, kill, service stop, force push), confirm with the user first.
43
- - Never expose API keys, tokens, or secrets in your responses.
44
- - If a task will take a while, tell the user upfront.
45
- - If something fails, explain what went wrong and suggest a fix.`;
28
+ - Use tools proactively don't describe what you'd do, just do it.
29
+ - If a command fails, analyze and try an alternative.
30
+ - For destructive ops (rm, kill, force push), confirm with the user first.
31
+ - Never expose secrets in responses.`;
46
32
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Smart tool filtering — send only relevant tools per request to save tokens.
3
+ */
4
+
5
+ export const TOOL_CATEGORIES = {
6
+ core: ['execute_command', 'read_file', 'write_file', 'list_directory'],
7
+ git: ['git_clone', 'git_checkout', 'git_commit', 'git_push', 'git_diff'],
8
+ github: ['github_create_pr', 'github_get_pr_diff', 'github_post_review', 'github_create_repo', 'github_list_prs'],
9
+ coding: ['spawn_claude_code'],
10
+ docker: ['docker_ps', 'docker_logs', 'docker_exec', 'docker_compose'],
11
+ process: ['process_list', 'kill_process', 'service_control'],
12
+ monitor: ['disk_usage', 'memory_usage', 'cpu_usage', 'system_logs'],
13
+ network: ['check_port', 'curl_url', 'nginx_reload'],
14
+ browser: ['browse_website', 'screenshot_website', 'extract_content', 'send_image', 'interact_with_page'],
15
+ jira: ['jira_get_ticket', 'jira_search_tickets', 'jira_list_my_tickets', 'jira_get_project_tickets'],
16
+ };
17
+
18
+ const CATEGORY_KEYWORDS = {
19
+ coding: ['code', 'fix', 'bug', 'implement', 'refactor', 'build', 'feature', 'develop', 'program', 'write code', 'add feature', 'change', 'update', 'modify', 'create app', 'scaffold', 'debug', 'patch', 'review'],
20
+ git: ['git', 'commit', 'branch', 'merge', 'clone', 'pull', 'push', 'diff', 'stash', 'rebase', 'checkout', 'repo'],
21
+ github: ['pr', 'pull request', 'github', 'review', 'merge request'],
22
+ docker: ['docker', 'container', 'compose', 'image', 'kubernetes', 'k8s'],
23
+ process: ['process', 'kill', 'restart', 'service', 'daemon', 'systemctl', 'pid'],
24
+ monitor: ['disk', 'memory', 'cpu', 'usage', 'monitor', 'logs', 'status', 'health', 'space'],
25
+ network: ['port', 'curl', 'http', 'nginx', 'network', 'api', 'endpoint', 'request', 'url', 'fetch'],
26
+ browser: ['browse', 'screenshot', 'scrape', 'website', 'web page', 'webpage', 'extract content', 'html', 'css selector'],
27
+ jira: ['jira', 'ticket', 'issue', 'sprint', 'backlog', 'story', 'epic'],
28
+ };
29
+
30
+ // Categories that imply other categories
31
+ const CATEGORY_DEPS = {
32
+ coding: ['git', 'github'],
33
+ github: ['git'],
34
+ };
35
+
36
+ /**
37
+ * Select relevant tools for a user message based on keyword matching.
38
+ * Always includes 'core' tools. Falls back to ALL tools if nothing specific matched.
39
+ */
40
+ export function selectToolsForMessage(userMessage, allTools) {
41
+ const lower = userMessage.toLowerCase();
42
+ const matched = new Set(['core']);
43
+
44
+ for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
45
+ for (const kw of keywords) {
46
+ if (lower.includes(kw)) {
47
+ matched.add(category);
48
+ // Add dependencies
49
+ const deps = CATEGORY_DEPS[category];
50
+ if (deps) deps.forEach((d) => matched.add(d));
51
+ break;
52
+ }
53
+ }
54
+ }
55
+
56
+ // Fallback: if only core matched, the request is ambiguous — send all tools
57
+ if (matched.size === 1) {
58
+ return allTools;
59
+ }
60
+
61
+ // Build the filtered tool name set
62
+ const toolNames = new Set();
63
+ for (const cat of matched) {
64
+ const names = TOOL_CATEGORIES[cat];
65
+ if (names) names.forEach((n) => toolNames.add(n));
66
+ }
67
+
68
+ return allTools.filter((t) => toolNames.has(t.name));
69
+ }
70
+
71
+ /**
72
+ * After a tool is used, expand the tool set to include related categories
73
+ * so the model can use follow-up tools it might need.
74
+ */
75
+ export function expandToolsForUsed(usedToolNames, currentTools, allTools) {
76
+ const currentNames = new Set(currentTools.map((t) => t.name));
77
+ const needed = new Set();
78
+
79
+ for (const name of usedToolNames) {
80
+ // Find which category this tool belongs to
81
+ for (const [cat, tools] of Object.entries(TOOL_CATEGORIES)) {
82
+ if (tools.includes(name)) {
83
+ // Add deps for that category
84
+ const deps = CATEGORY_DEPS[cat];
85
+ if (deps) {
86
+ for (const dep of deps) {
87
+ for (const t of TOOL_CATEGORIES[dep]) {
88
+ if (!currentNames.has(t)) needed.add(t);
89
+ }
90
+ }
91
+ }
92
+ break;
93
+ }
94
+ }
95
+ }
96
+
97
+ if (needed.size === 0) return currentTools;
98
+
99
+ const extra = allTools.filter((t) => needed.has(t.name));
100
+ return [...currentTools, ...extra];
101
+ }
@@ -15,12 +15,13 @@ const DEFAULTS = {
15
15
  brain: {
16
16
  provider: 'anthropic',
17
17
  model: 'claude-sonnet-4-20250514',
18
- max_tokens: 8192,
18
+ max_tokens: 4096,
19
19
  temperature: 0.3,
20
- max_tool_depth: 25,
20
+ max_tool_depth: 12,
21
21
  },
22
22
  telegram: {
23
23
  allowed_users: [],
24
+ batch_window_ms: 3000,
24
25
  },
25
26
  claude_code: {
26
27
  model: 'claude-opus-4-6',
@@ -46,6 +47,7 @@ const DEFAULTS = {
46
47
  },
47
48
  conversation: {
48
49
  max_history: 50,
50
+ recent_window: 10,
49
51
  },
50
52
  };
51
53