kernelbot 1.0.6 → 1.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kernelbot",
3
- "version": "1.0.6",
3
+ "version": "1.0.9",
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
@@ -2,6 +2,7 @@ import Anthropic from '@anthropic-ai/sdk';
2
2
  import { toolDefinitions, executeTool, checkConfirmation } from './tools/index.js';
3
3
  import { getSystemPrompt } from './prompts/system.js';
4
4
  import { getLogger } from './utils/logger.js';
5
+ import { getMissingCredential, saveCredential } from './utils/config.js';
5
6
 
6
7
  export class Agent {
7
8
  constructor({ config, conversationManager }) {
@@ -9,69 +10,25 @@ export class Agent {
9
10
  this.conversationManager = conversationManager;
10
11
  this.client = new Anthropic({ apiKey: config.anthropic.api_key });
11
12
  this.systemPrompt = getSystemPrompt(config);
12
- this._pendingConfirmation = new Map(); // chatId -> { block, context }
13
+ this._pending = new Map(); // chatId -> pending state
13
14
  }
14
15
 
15
- async processMessage(chatId, userMessage, user) {
16
+ async processMessage(chatId, userMessage, user, onUpdate) {
16
17
  const logger = getLogger();
17
18
 
18
- // Handle pending confirmation responses
19
- const pending = this._pendingConfirmation.get(chatId);
19
+ this._onUpdate = onUpdate || null;
20
+
21
+ // Handle pending responses (confirmation or credential)
22
+ const pending = this._pending.get(chatId);
20
23
  if (pending) {
21
- this._pendingConfirmation.delete(chatId);
22
- const lower = userMessage.toLowerCase().trim();
23
-
24
- if (lower === 'yes' || lower === 'y' || lower === 'confirm') {
25
- // User approved — execute the blocked tool and resume
26
- logger.info(`User confirmed dangerous tool: ${pending.block.name}`);
27
- const result = await executeTool(pending.block.name, pending.block.input, pending.context);
28
-
29
- // Resume the agent loop with the tool result
30
- pending.toolResults.push({
31
- type: 'tool_result',
32
- tool_use_id: pending.block.id,
33
- content: JSON.stringify(result),
34
- });
35
-
36
- // Process remaining blocks if any
37
- for (const block of pending.remainingBlocks) {
38
- if (block.type !== 'tool_use') continue;
39
-
40
- const dangerLabel = checkConfirmation(block.name, block.input, this.config);
41
- if (dangerLabel) {
42
- // Another dangerous tool — ask again
43
- this._pendingConfirmation.set(chatId, {
44
- block,
45
- context: pending.context,
46
- toolResults: pending.toolResults,
47
- remainingBlocks: pending.remainingBlocks.filter((b) => b !== block),
48
- messages: pending.messages,
49
- });
50
- return `⚠️ Next action will **${dangerLabel}**.\n\n\`${block.name}\`: \`${JSON.stringify(block.input)}\`\n\nConfirm? (yes/no)`;
51
- }
52
-
53
- logger.info(`Tool call: ${block.name}`);
54
- const r = await executeTool(block.name, block.input, pending.context);
55
- pending.toolResults.push({
56
- type: 'tool_result',
57
- tool_use_id: block.id,
58
- content: JSON.stringify(r),
59
- });
60
- }
24
+ this._pending.delete(chatId);
25
+
26
+ if (pending.type === 'credential') {
27
+ return await this._handleCredentialResponse(chatId, userMessage, user, pending);
28
+ }
61
29
 
62
- // Continue the agent loop
63
- pending.messages.push({ role: 'user', content: pending.toolResults });
64
- return await this._continueLoop(chatId, pending.messages, user);
65
- } else {
66
- // User denied
67
- logger.info(`User denied dangerous tool: ${pending.block.name}`);
68
- pending.toolResults.push({
69
- type: 'tool_result',
70
- tool_use_id: pending.block.id,
71
- content: JSON.stringify({ error: 'User denied this operation.' }),
72
- });
73
- pending.messages.push({ role: 'user', content: pending.toolResults });
74
- return await this._continueLoop(chatId, pending.messages, user);
30
+ if (pending.type === 'confirmation') {
31
+ return await this._handleConfirmationResponse(chatId, userMessage, user, pending);
75
32
  }
76
33
  }
77
34
 
@@ -86,6 +43,156 @@ export class Agent {
86
43
  return await this._runLoop(chatId, messages, user, 0, max_tool_depth);
87
44
  }
88
45
 
46
+ _formatToolSummary(name, input) {
47
+ const key = {
48
+ execute_command: 'command',
49
+ read_file: 'path',
50
+ write_file: 'path',
51
+ list_directory: 'path',
52
+ git_clone: 'repo',
53
+ git_checkout: 'branch',
54
+ git_commit: 'message',
55
+ git_push: 'dir',
56
+ git_diff: 'dir',
57
+ github_create_pr: 'title',
58
+ github_create_repo: 'name',
59
+ github_list_prs: 'repo',
60
+ github_get_pr_diff: 'repo',
61
+ github_post_review: 'repo',
62
+ spawn_claude_code: 'prompt',
63
+ kill_process: 'pid',
64
+ docker_exec: 'container',
65
+ docker_logs: 'container',
66
+ docker_compose: 'action',
67
+ curl_url: 'url',
68
+ check_port: 'port',
69
+ }[name];
70
+ const val = key && input[key] ? String(input[key]).slice(0, 120) : JSON.stringify(input).slice(0, 120);
71
+ return `${name}: ${val}`;
72
+ }
73
+
74
+ async _sendUpdate(text) {
75
+ if (this._onUpdate) {
76
+ try { await this._onUpdate(text); } catch {}
77
+ }
78
+ }
79
+
80
+ async _handleCredentialResponse(chatId, userMessage, user, pending) {
81
+ const logger = getLogger();
82
+ const value = userMessage.trim();
83
+
84
+ if (value.toLowerCase() === 'skip' || value.toLowerCase() === 'cancel') {
85
+ logger.info(`User skipped credential: ${pending.credential.envKey}`);
86
+ pending.toolResults.push({
87
+ type: 'tool_result',
88
+ tool_use_id: pending.block.id,
89
+ content: JSON.stringify({ error: `${pending.credential.label} not provided. Operation skipped.` }),
90
+ });
91
+ return await this._resumeAfterPause(chatId, user, pending);
92
+ }
93
+
94
+ // Save the credential
95
+ saveCredential(this.config, pending.credential.envKey, value);
96
+ logger.info(`Saved credential: ${pending.credential.envKey}`);
97
+
98
+ // Now execute the original tool
99
+ const result = await executeTool(pending.block.name, pending.block.input, {
100
+ config: this.config,
101
+ user,
102
+ });
103
+
104
+ pending.toolResults.push({
105
+ type: 'tool_result',
106
+ tool_use_id: pending.block.id,
107
+ content: JSON.stringify(result),
108
+ });
109
+
110
+ return await this._resumeAfterPause(chatId, user, pending);
111
+ }
112
+
113
+ async _handleConfirmationResponse(chatId, userMessage, user, pending) {
114
+ const logger = getLogger();
115
+ const lower = userMessage.toLowerCase().trim();
116
+
117
+ if (lower === 'yes' || lower === 'y' || lower === 'confirm') {
118
+ logger.info(`User confirmed dangerous tool: ${pending.block.name}`);
119
+ const result = await executeTool(pending.block.name, pending.block.input, pending.context);
120
+
121
+ pending.toolResults.push({
122
+ type: 'tool_result',
123
+ tool_use_id: pending.block.id,
124
+ content: JSON.stringify(result),
125
+ });
126
+ } else {
127
+ logger.info(`User denied dangerous tool: ${pending.block.name}`);
128
+ pending.toolResults.push({
129
+ type: 'tool_result',
130
+ tool_use_id: pending.block.id,
131
+ content: JSON.stringify({ error: 'User denied this operation.' }),
132
+ });
133
+ }
134
+
135
+ return await this._resumeAfterPause(chatId, user, pending);
136
+ }
137
+
138
+ async _resumeAfterPause(chatId, user, pending) {
139
+ // Process remaining blocks
140
+ for (const block of pending.remainingBlocks) {
141
+ if (block.type !== 'tool_use') continue;
142
+
143
+ const pauseMsg = await this._checkPause(chatId, block, user, pending.toolResults, pending.remainingBlocks.filter((b) => b !== block), pending.messages);
144
+ if (pauseMsg) return pauseMsg;
145
+
146
+ const r = await executeTool(block.name, block.input, { config: this.config, user });
147
+ pending.toolResults.push({
148
+ type: 'tool_result',
149
+ tool_use_id: block.id,
150
+ content: JSON.stringify(r),
151
+ });
152
+ }
153
+
154
+ pending.messages.push({ role: 'user', content: pending.toolResults });
155
+ const { max_tool_depth } = this.config.anthropic;
156
+ return await this._runLoop(chatId, pending.messages, user, 0, max_tool_depth);
157
+ }
158
+
159
+ _checkPause(chatId, block, user, toolResults, remainingBlocks, messages) {
160
+ const logger = getLogger();
161
+
162
+ // Check missing credentials first
163
+ const missing = getMissingCredential(block.name, this.config);
164
+ if (missing) {
165
+ logger.warn(`Missing credential for ${block.name}: ${missing.envKey}`);
166
+ this._pending.set(chatId, {
167
+ type: 'credential',
168
+ block,
169
+ credential: missing,
170
+ context: { config: this.config, user },
171
+ toolResults,
172
+ remainingBlocks,
173
+ messages,
174
+ });
175
+ return `🔑 **${missing.label}** is required for this action.\n\nPlease send your token now (it will be saved to \`~/.kernelbot/.env\`).\n\nOr reply **skip** to cancel.`;
176
+ }
177
+
178
+ // Check dangerous operation confirmation
179
+ const dangerLabel = checkConfirmation(block.name, block.input, this.config);
180
+ if (dangerLabel) {
181
+ logger.warn(`Dangerous tool detected: ${block.name} — ${dangerLabel}`);
182
+ this._pending.set(chatId, {
183
+ type: 'confirmation',
184
+ block,
185
+ context: { config: this.config, user },
186
+ toolResults,
187
+ remainingBlocks,
188
+ messages,
189
+ });
190
+ return `⚠️ This action will **${dangerLabel}**.\n\n\`${block.name}\`: \`${JSON.stringify(block.input)}\`\n\nConfirm? (yes/no)`;
191
+ }
192
+
193
+ return null;
194
+ }
195
+
89
196
  async _runLoop(chatId, messages, user, startDepth, maxDepth) {
90
197
  const logger = getLogger();
91
198
  const { model, max_tokens, temperature } = this.config.anthropic;
@@ -115,30 +222,34 @@ export class Agent {
115
222
  if (response.stop_reason === 'tool_use') {
116
223
  messages.push({ role: 'assistant', content: response.content });
117
224
 
225
+ // Send Claude's thinking text to the user
226
+ const thinkingBlocks = response.content.filter((b) => b.type === 'text' && b.text.trim());
227
+ if (thinkingBlocks.length > 0) {
228
+ const thinking = thinkingBlocks.map((b) => b.text).join('\n');
229
+ logger.info(`Agent thinking: ${thinking.slice(0, 200)}`);
230
+ await this._sendUpdate(`💭 ${thinking}`);
231
+ }
232
+
118
233
  const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
119
234
  const toolResults = [];
120
235
 
121
236
  for (let i = 0; i < toolUseBlocks.length; i++) {
122
237
  const block = toolUseBlocks[i];
123
238
 
124
- // Check if this tool requires confirmation
125
- const dangerLabel = checkConfirmation(block.name, block.input, this.config);
126
- if (dangerLabel) {
127
- logger.warn(`Dangerous tool detected: ${block.name} — ${dangerLabel}`);
128
-
129
- // Store state and pause for user confirmation
130
- this._pendingConfirmation.set(chatId, {
131
- block,
132
- context: { config: this.config, user },
133
- toolResults,
134
- remainingBlocks: toolUseBlocks.slice(i + 1),
135
- messages,
136
- });
137
-
138
- return `⚠️ This action will **${dangerLabel}**.\n\n\`${block.name}\`: \`${JSON.stringify(block.input)}\`\n\nConfirm? (yes/no)`;
139
- }
239
+ // Check if we need to pause (missing cred or dangerous action)
240
+ const pauseMsg = this._checkPause(
241
+ chatId,
242
+ block,
243
+ user,
244
+ toolResults,
245
+ toolUseBlocks.slice(i + 1),
246
+ messages,
247
+ );
248
+ if (pauseMsg) return pauseMsg;
140
249
 
141
- logger.info(`Tool call: ${block.name}`);
250
+ const summary = this._formatToolSummary(block.name, block.input);
251
+ logger.info(`Tool call: ${summary}`);
252
+ await this._sendUpdate(`🔧 \`${summary}\``);
142
253
 
143
254
  const result = await executeTool(block.name, block.input, {
144
255
  config: this.config,
@@ -175,9 +286,4 @@ export class Agent {
175
286
  this.conversationManager.addMessage(chatId, 'assistant', depthWarning);
176
287
  return depthWarning;
177
288
  }
178
-
179
- async _continueLoop(chatId, messages, user) {
180
- const { max_tool_depth } = this.config.anthropic;
181
- return await this._runLoop(chatId, messages, user, 0, max_tool_depth);
182
- }
183
289
  }
package/src/bot.js CHANGED
@@ -50,10 +50,21 @@ export function startBot(config, agent) {
50
50
  bot.sendChatAction(chatId, 'typing').catch(() => {});
51
51
 
52
52
  try {
53
+ const onUpdate = async (text) => {
54
+ const parts = splitMessage(text);
55
+ for (const part of parts) {
56
+ try {
57
+ await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
58
+ } catch {
59
+ await bot.sendMessage(chatId, part);
60
+ }
61
+ }
62
+ };
63
+
53
64
  const reply = await agent.processMessage(chatId, msg.text, {
54
65
  id: userId,
55
66
  username,
56
- });
67
+ }, onUpdate);
57
68
 
58
69
  clearInterval(typingInterval);
59
70
 
package/src/tools/git.js CHANGED
@@ -9,6 +9,24 @@ function getWorkspaceDir(config) {
9
9
  return dir;
10
10
  }
11
11
 
12
+ function injectToken(url, config) {
13
+ const token = config.github?.token || process.env.GITHUB_TOKEN;
14
+ if (!token) return url;
15
+
16
+ // Inject token into HTTPS GitHub URLs for auth-free push/pull
17
+ try {
18
+ const parsed = new URL(url);
19
+ if (parsed.hostname === 'github.com' && parsed.protocol === 'https:') {
20
+ parsed.username = token;
21
+ parsed.password = 'x-oauth-basic';
22
+ return parsed.toString();
23
+ }
24
+ } catch {
25
+ // Not a parseable URL (e.g. org/repo shorthand before expansion)
26
+ }
27
+ return url;
28
+ }
29
+
12
30
  export const definitions = [
13
31
  {
14
32
  name: 'git_clone',
@@ -88,12 +106,15 @@ export const handlers = {
88
106
  url = `https://github.com/${repo}.git`;
89
107
  }
90
108
 
109
+ // Inject GitHub token for authenticated clone (enables push later)
110
+ const authUrl = injectToken(url, context.config);
111
+
91
112
  const repoName = dest || repo.split('/').pop().replace('.git', '');
92
113
  const targetDir = join(workspaceDir, repoName);
93
114
 
94
115
  try {
95
116
  const git = simpleGit();
96
- await git.clone(url, targetDir);
117
+ await git.clone(authUrl, targetDir);
97
118
  return { success: true, path: targetDir };
98
119
  } catch (err) {
99
120
  return { error: err.message };
@@ -127,10 +148,21 @@ export const handlers = {
127
148
  }
128
149
  },
129
150
 
130
- git_push: async (params) => {
151
+ git_push: async (params, context) => {
131
152
  const { dir, force = false } = params;
132
153
  try {
133
154
  const git = simpleGit(dir);
155
+
156
+ // Ensure remote URL has auth token for push
157
+ const remotes = await git.getRemotes(true);
158
+ const origin = remotes.find((r) => r.name === 'origin');
159
+ if (origin) {
160
+ const authUrl = injectToken(origin.refs.push || origin.refs.fetch, context.config);
161
+ if (authUrl !== (origin.refs.push || origin.refs.fetch)) {
162
+ await git.remote(['set-url', 'origin', authUrl]);
163
+ }
164
+ }
165
+
134
166
  const branch = (await git.branchLocal()).current;
135
167
  const options = force ? ['--force'] : [];
136
168
  await git.push('origin', branch, options);
@@ -184,3 +184,59 @@ export async function loadConfigInteractive() {
184
184
  const config = loadConfig();
185
185
  return await promptForMissing(config);
186
186
  }
187
+
188
+ /**
189
+ * Save a credential to ~/.kernelbot/.env and update the live config object.
190
+ * Called at runtime when a user provides a missing token via Telegram.
191
+ */
192
+ export function saveCredential(config, envKey, value) {
193
+ const configDir = getConfigDir();
194
+ mkdirSync(configDir, { recursive: true });
195
+ const envPath = join(configDir, '.env');
196
+
197
+ let content = '';
198
+ if (existsSync(envPath)) {
199
+ content = readFileSync(envPath, 'utf-8').trimEnd() + '\n';
200
+ }
201
+
202
+ const regex = new RegExp(`^${envKey}=.*$`, 'm');
203
+ if (regex.test(content)) {
204
+ content = content.replace(regex, `${envKey}=${value}`);
205
+ } else {
206
+ content += `${envKey}=${value}\n`;
207
+ }
208
+ writeFileSync(envPath, content);
209
+
210
+ // Update live config
211
+ switch (envKey) {
212
+ case 'GITHUB_TOKEN':
213
+ if (!config.github) config.github = {};
214
+ config.github.token = value;
215
+ break;
216
+ case 'ANTHROPIC_API_KEY':
217
+ config.anthropic.api_key = value;
218
+ break;
219
+ case 'TELEGRAM_BOT_TOKEN':
220
+ config.telegram.bot_token = value;
221
+ break;
222
+ }
223
+
224
+ // Also set in process.env so tools pick it up
225
+ process.env[envKey] = value;
226
+ }
227
+
228
+ /**
229
+ * Check which credentials a tool needs and return the missing one, if any.
230
+ */
231
+ export function getMissingCredential(toolName, config) {
232
+ const githubTools = ['github_create_pr', 'github_get_pr_diff', 'github_post_review', 'github_create_repo', 'github_list_prs', 'git_clone', 'git_push'];
233
+
234
+ if (githubTools.includes(toolName)) {
235
+ const token = config.github?.token || process.env.GITHUB_TOKEN;
236
+ if (!token) {
237
+ return { envKey: 'GITHUB_TOKEN', label: 'GitHub Personal Access Token' };
238
+ }
239
+ }
240
+
241
+ return null;
242
+ }