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 +6 -5
- package/package.json +1 -1
- package/src/agent.js +112 -14
- package/src/bot.js +258 -65
- package/src/conversation.js +36 -0
- package/src/prompts/system.js +28 -42
- package/src/tools/categories.js +101 -0
- package/src/utils/config.js +4 -2
package/README.md
CHANGED
|
@@ -219,11 +219,12 @@ conversation:
|
|
|
219
219
|
|
|
220
220
|
## Telegram Commands
|
|
221
221
|
|
|
222
|
-
| Command | Description
|
|
223
|
-
| ---------- |
|
|
224
|
-
| `/
|
|
225
|
-
| `/
|
|
226
|
-
| `/
|
|
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
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.
|
|
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
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:
|
|
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:
|
|
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
|
|
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) => {
|
package/src/conversation.js
CHANGED
|
@@ -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 });
|
package/src/prompts/system.js
CHANGED
|
@@ -1,46 +1,32 @@
|
|
|
1
|
-
import { toolDefinitions } from '../tools/index.js';
|
|
2
|
-
|
|
3
1
|
export function getSystemPrompt(config) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
##
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
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
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
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
|
+
}
|
package/src/utils/config.js
CHANGED
|
@@ -15,12 +15,13 @@ const DEFAULTS = {
|
|
|
15
15
|
brain: {
|
|
16
16
|
provider: 'anthropic',
|
|
17
17
|
model: 'claude-sonnet-4-20250514',
|
|
18
|
-
max_tokens:
|
|
18
|
+
max_tokens: 4096,
|
|
19
19
|
temperature: 0.3,
|
|
20
|
-
max_tool_depth:
|
|
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
|
|