kernelbot 1.0.28 → 1.0.32

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.
@@ -0,0 +1,93 @@
1
+ import { spawn } from 'child_process';
2
+ import { getLogger } from './utils/logger.js';
3
+
4
+ /**
5
+ * Run `claude auth status` and return parsed output.
6
+ */
7
+ export function getClaudeAuthStatus() {
8
+ const logger = getLogger();
9
+ return new Promise((resolve) => {
10
+ const child = spawn('claude', ['auth', 'status'], {
11
+ stdio: ['ignore', 'pipe', 'pipe'],
12
+ });
13
+
14
+ let stdout = '';
15
+ let stderr = '';
16
+
17
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
18
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
19
+
20
+ child.on('close', (code) => {
21
+ const output = (stdout || stderr).trim();
22
+ logger.debug(`claude auth status (code ${code}): ${output.slice(0, 300)}`);
23
+ resolve({ code, output });
24
+ });
25
+
26
+ child.on('error', (err) => {
27
+ if (err.code === 'ENOENT') {
28
+ resolve({ code: -1, output: 'Claude Code CLI not found. Install with: npm i -g @anthropic-ai/claude-code' });
29
+ } else {
30
+ resolve({ code: -1, output: err.message });
31
+ }
32
+ });
33
+
34
+ // Timeout after 10s
35
+ setTimeout(() => {
36
+ child.kill('SIGTERM');
37
+ resolve({ code: -1, output: 'Timed out checking auth status' });
38
+ }, 10_000);
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Run `claude auth logout`.
44
+ */
45
+ export function claudeLogout() {
46
+ const logger = getLogger();
47
+ return new Promise((resolve) => {
48
+ const child = spawn('claude', ['auth', 'logout'], {
49
+ stdio: ['ignore', 'pipe', 'pipe'],
50
+ });
51
+
52
+ let stdout = '';
53
+ let stderr = '';
54
+
55
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
56
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
57
+
58
+ child.on('close', (code) => {
59
+ const output = (stdout || stderr).trim();
60
+ logger.info(`claude auth logout (code ${code}): ${output.slice(0, 300)}`);
61
+ resolve({ code, output });
62
+ });
63
+
64
+ child.on('error', (err) => {
65
+ resolve({ code: -1, output: err.message });
66
+ });
67
+
68
+ setTimeout(() => {
69
+ child.kill('SIGTERM');
70
+ resolve({ code: -1, output: 'Timed out during logout' });
71
+ }, 10_000);
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Return the current Claude Code auth mode from config.
77
+ */
78
+ export function getClaudeCodeAuthMode(config) {
79
+ const mode = config.claude_code?.auth_mode || 'system';
80
+ const info = { mode };
81
+
82
+ if (mode === 'api_key') {
83
+ const key = config.claude_code?.api_key || process.env.CLAUDE_CODE_API_KEY || '';
84
+ info.credential = key ? `${key.slice(0, 8)}...${key.slice(-4)}` : '(not set)';
85
+ } else if (mode === 'oauth_token') {
86
+ const token = config.claude_code?.oauth_token || process.env.CLAUDE_CODE_OAUTH_TOKEN || '';
87
+ info.credential = token ? `${token.slice(0, 8)}...${token.slice(-4)}` : '(not set)';
88
+ } else {
89
+ info.credential = 'Using host system login';
90
+ }
91
+
92
+ return info;
93
+ }
package/src/coder.js CHANGED
@@ -135,17 +135,43 @@ function processEvent(line, onOutput, logger) {
135
135
 
136
136
  export class ClaudeCodeSpawner {
137
137
  constructor(config) {
138
+ this.config = config;
138
139
  this.maxTurns = config.claude_code?.max_turns || 50;
139
- this.timeout = (config.claude_code?.timeout_seconds || 600) * 1000;
140
- this.model = config.claude_code?.model || null;
140
+ this.timeout = (config.claude_code?.timeout_seconds || 86400) * 1000;
141
141
  }
142
142
 
143
- async run({ workingDirectory, prompt, maxTurns, onOutput }) {
143
+ _buildSpawnEnv() {
144
+ const authMode = this.config.claude_code?.auth_mode || 'system';
145
+ const env = { ...process.env, IS_SANDBOX: '1' };
146
+
147
+ if (authMode === 'api_key') {
148
+ const key = this.config.claude_code?.api_key || process.env.CLAUDE_CODE_API_KEY;
149
+ if (key) {
150
+ env.ANTHROPIC_API_KEY = key;
151
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
152
+ }
153
+ } else if (authMode === 'oauth_token') {
154
+ const token = this.config.claude_code?.oauth_token || process.env.CLAUDE_CODE_OAUTH_TOKEN;
155
+ if (token) {
156
+ env.CLAUDE_CODE_OAUTH_TOKEN = token;
157
+ // Remove ANTHROPIC_API_KEY so it doesn't override subscription auth
158
+ delete env.ANTHROPIC_API_KEY;
159
+ }
160
+ }
161
+ // authMode === 'system' — pass env as-is
162
+
163
+ return env;
164
+ }
165
+
166
+ async run({ workingDirectory, prompt, maxTurns, onOutput, signal }) {
144
167
  const logger = getLogger();
145
168
  const turns = maxTurns || this.maxTurns;
146
169
 
147
170
  ensureClaudeCodeSetup();
148
171
 
172
+ // Read model dynamically from config (supports hot-reload via switchClaudeCodeModel)
173
+ const model = this.config.claude_code?.model || null;
174
+
149
175
  const args = [
150
176
  '-p', prompt,
151
177
  '--max-turns', String(turns),
@@ -153,8 +179,8 @@ export class ClaudeCodeSpawner {
153
179
  '--verbose',
154
180
  '--dangerously-skip-permissions',
155
181
  ];
156
- if (this.model) {
157
- args.push('--model', this.model);
182
+ if (model) {
183
+ args.push('--model', model);
158
184
  }
159
185
 
160
186
  const cmd = `claude ${args.map((a) => a.includes(' ') ? `"${a}"` : a).join(' ')}`;
@@ -219,10 +245,24 @@ export class ClaudeCodeSpawner {
219
245
  return new Promise((resolve, reject) => {
220
246
  const child = spawn('claude', args, {
221
247
  cwd: workingDirectory,
222
- env: { ...process.env, IS_SANDBOX: '1' },
248
+ env: this._buildSpawnEnv(),
223
249
  stdio: ['ignore', 'pipe', 'pipe'],
224
250
  });
225
251
 
252
+ // Wire abort signal to kill the child process
253
+ let abortHandler = null;
254
+ if (signal) {
255
+ if (signal.aborted) {
256
+ child.kill('SIGTERM');
257
+ } else {
258
+ abortHandler = () => {
259
+ logger.info('Claude Code: abort signal received — killing child process');
260
+ child.kill('SIGTERM');
261
+ };
262
+ signal.addEventListener('abort', abortHandler, { once: true });
263
+ }
264
+ }
265
+
226
266
  let fullOutput = '';
227
267
  let stderr = '';
228
268
  let buffer = '';
@@ -268,6 +308,7 @@ export class ClaudeCodeSpawner {
268
308
 
269
309
  child.on('close', async (code) => {
270
310
  clearTimeout(timer);
311
+ if (abortHandler && signal) signal.removeEventListener('abort', abortHandler);
271
312
 
272
313
  if (buffer.trim()) {
273
314
  fullOutput += buffer.trim();
@@ -312,6 +353,7 @@ export class ClaudeCodeSpawner {
312
353
 
313
354
  child.on('error', (err) => {
314
355
  clearTimeout(timer);
356
+ if (abortHandler && signal) signal.removeEventListener('abort', abortHandler);
315
357
  if (err.code === 'ENOENT') {
316
358
  reject(new Error('Claude Code CLI not found. Install with: npm i -g @anthropic-ai/claude-code'));
317
359
  } else {
@@ -0,0 +1,388 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { execSync } from 'child_process';
5
+ import { createHash } from 'crypto';
6
+ import { getLogger } from '../utils/logger.js';
7
+
8
+ const LIFE_DIR = join(homedir(), '.kernelbot', 'life');
9
+ const CODEBASE_DIR = join(LIFE_DIR, 'codebase');
10
+ const SUMMARIES_FILE = join(CODEBASE_DIR, 'file-summaries.json');
11
+ const ARCHITECTURE_FILE = join(CODEBASE_DIR, 'architecture.md');
12
+
13
+ // Files to always skip during scanning
14
+ const SKIP_PATTERNS = [
15
+ 'node_modules', '.git', 'package-lock.json', 'yarn.lock',
16
+ '.env', '.DS_Store', 'dist/', 'build/', 'coverage/',
17
+ ];
18
+
19
+ export class CodebaseKnowledge {
20
+ constructor({ config } = {}) {
21
+ this.config = config || {};
22
+ this._projectRoot = null;
23
+ this._summaries = {};
24
+ this._agent = null;
25
+
26
+ mkdirSync(CODEBASE_DIR, { recursive: true });
27
+ this._summaries = this._loadSummaries();
28
+ }
29
+
30
+ /** Set the agent reference (called after agent is created). */
31
+ setAgent(agent) {
32
+ this._agent = agent;
33
+ }
34
+
35
+ /** Set/detect project root. */
36
+ setProjectRoot(root) {
37
+ this._projectRoot = root;
38
+ }
39
+
40
+ getProjectRoot() {
41
+ if (this._projectRoot) return this._projectRoot;
42
+ // Try to detect from git
43
+ try {
44
+ this._projectRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
45
+ } catch {
46
+ this._projectRoot = process.cwd();
47
+ }
48
+ return this._projectRoot;
49
+ }
50
+
51
+ // ── Persistence ───────────────────────────────────────────────
52
+
53
+ _loadSummaries() {
54
+ if (existsSync(SUMMARIES_FILE)) {
55
+ try {
56
+ return JSON.parse(readFileSync(SUMMARIES_FILE, 'utf-8'));
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+ return {};
62
+ }
63
+
64
+ _saveSummaries() {
65
+ writeFileSync(SUMMARIES_FILE, JSON.stringify(this._summaries, null, 2), 'utf-8');
66
+ }
67
+
68
+ _saveArchitecture(content) {
69
+ writeFileSync(ARCHITECTURE_FILE, content, 'utf-8');
70
+ }
71
+
72
+ // ── File Hashing ──────────────────────────────────────────────
73
+
74
+ _hashFile(filePath) {
75
+ try {
76
+ const content = readFileSync(filePath, 'utf-8');
77
+ return createHash('md5').update(content).digest('hex').slice(0, 12);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ _lineCount(filePath) {
84
+ try {
85
+ const content = readFileSync(filePath, 'utf-8');
86
+ return content.split('\n').length;
87
+ } catch {
88
+ return 0;
89
+ }
90
+ }
91
+
92
+ // ── Scanning ──────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Scan a single file using the LLM to generate a summary.
96
+ * Requires this._agent to be set.
97
+ */
98
+ async scanFile(filePath) {
99
+ const logger = getLogger();
100
+ const root = this.getProjectRoot();
101
+ const fullPath = filePath.startsWith('/') ? filePath : join(root, filePath);
102
+ const relativePath = filePath.startsWith('/') ? filePath.replace(root + '/', '') : filePath;
103
+
104
+ // Check if file should be skipped
105
+ if (SKIP_PATTERNS.some(p => relativePath.includes(p))) return null;
106
+
107
+ const hash = this._hashFile(fullPath);
108
+ if (!hash) return null;
109
+
110
+ // Skip if already scanned and unchanged
111
+ const existing = this._summaries[relativePath];
112
+ if (existing && existing.lastHash === hash) return existing;
113
+
114
+ // Read file content
115
+ let content;
116
+ try {
117
+ content = readFileSync(fullPath, 'utf-8');
118
+ } catch {
119
+ return null;
120
+ }
121
+
122
+ // Truncate very large files
123
+ const maxChars = 8000;
124
+ const truncated = content.length > maxChars
125
+ ? content.slice(0, maxChars) + '\n... (truncated)'
126
+ : content;
127
+
128
+ // Use LLM to summarize if agent is available
129
+ let summary;
130
+ if (this._agent) {
131
+ try {
132
+ const prompt = `Analyze this source file and respond with ONLY a JSON object (no markdown, no code blocks):
133
+ {
134
+ "summary": "one-paragraph description of what this file does",
135
+ "exports": ["list", "of", "exported", "names"],
136
+ "dependencies": ["list", "of", "local", "imports"]
137
+ }
138
+
139
+ File: ${relativePath}
140
+ \`\`\`
141
+ ${truncated}
142
+ \`\`\``;
143
+
144
+ const response = await this._agent.orchestratorProvider.chat({
145
+ system: 'You are a code analysis assistant. Respond with only valid JSON, no markdown formatting.',
146
+ messages: [{ role: 'user', content: prompt }],
147
+ });
148
+
149
+ const text = (response.text || '').trim();
150
+ // Try to parse JSON from the response
151
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
152
+ if (jsonMatch) {
153
+ const parsed = JSON.parse(jsonMatch[0]);
154
+ summary = {
155
+ summary: parsed.summary || 'No summary generated',
156
+ exports: parsed.exports || [],
157
+ dependencies: parsed.dependencies || [],
158
+ };
159
+ }
160
+ } catch (err) {
161
+ logger.debug(`[Codebase] LLM scan failed for ${relativePath}: ${err.message}`);
162
+ }
163
+ }
164
+
165
+ // Fallback: basic static analysis
166
+ if (!summary) {
167
+ summary = this._staticAnalysis(content, relativePath);
168
+ }
169
+
170
+ const entry = {
171
+ ...summary,
172
+ lineCount: this._lineCount(fullPath),
173
+ lastHash: hash,
174
+ lastScanned: Date.now(),
175
+ };
176
+
177
+ this._summaries[relativePath] = entry;
178
+ this._saveSummaries();
179
+ logger.debug(`[Codebase] Scanned: ${relativePath}`);
180
+ return entry;
181
+ }
182
+
183
+ /**
184
+ * Scan only files that have changed since last scan (git-based).
185
+ */
186
+ async scanChanged() {
187
+ const logger = getLogger();
188
+ const root = this.getProjectRoot();
189
+
190
+ let changedFiles = [];
191
+ try {
192
+ // Get all tracked files that differ from what we've scanned
193
+ const allFiles = execSync('git ls-files --full-name', {
194
+ cwd: root,
195
+ encoding: 'utf-8',
196
+ }).trim().split('\n').filter(Boolean);
197
+
198
+ // Filter to source files
199
+ changedFiles = allFiles.filter(f =>
200
+ (f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.json') || f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.md')) &&
201
+ !SKIP_PATTERNS.some(p => f.includes(p))
202
+ );
203
+
204
+ // Only scan files whose hash has changed
205
+ changedFiles = changedFiles.filter(f => {
206
+ const fullPath = join(root, f);
207
+ const hash = this._hashFile(fullPath);
208
+ const existing = this._summaries[f];
209
+ return !existing || existing.lastHash !== hash;
210
+ });
211
+ } catch (err) {
212
+ logger.warn(`[Codebase] Git scan failed: ${err.message}`);
213
+ return 0;
214
+ }
215
+
216
+ logger.info(`[Codebase] Scanning ${changedFiles.length} changed files...`);
217
+ let scanned = 0;
218
+
219
+ for (const file of changedFiles) {
220
+ try {
221
+ await this.scanFile(file);
222
+ scanned++;
223
+ } catch (err) {
224
+ logger.debug(`[Codebase] Failed to scan ${file}: ${err.message}`);
225
+ }
226
+ }
227
+
228
+ logger.info(`[Codebase] Scan complete: ${scanned} files updated`);
229
+ return scanned;
230
+ }
231
+
232
+ /**
233
+ * Full scan of all source files. Heavy operation — use sparingly.
234
+ */
235
+ async scanAll() {
236
+ const logger = getLogger();
237
+ const root = this.getProjectRoot();
238
+
239
+ let allFiles = [];
240
+ try {
241
+ allFiles = execSync('git ls-files --full-name', {
242
+ cwd: root,
243
+ encoding: 'utf-8',
244
+ }).trim().split('\n').filter(Boolean);
245
+
246
+ allFiles = allFiles.filter(f =>
247
+ (f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.json') || f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.md')) &&
248
+ !SKIP_PATTERNS.some(p => f.includes(p))
249
+ );
250
+ } catch (err) {
251
+ logger.warn(`[Codebase] Git ls-files failed: ${err.message}`);
252
+ return 0;
253
+ }
254
+
255
+ logger.info(`[Codebase] Full scan: ${allFiles.length} files...`);
256
+ let scanned = 0;
257
+
258
+ for (const file of allFiles) {
259
+ try {
260
+ await this.scanFile(file);
261
+ scanned++;
262
+ } catch (err) {
263
+ logger.debug(`[Codebase] Failed to scan ${file}: ${err.message}`);
264
+ }
265
+ }
266
+
267
+ logger.info(`[Codebase] Full scan complete: ${scanned} files`);
268
+ return scanned;
269
+ }
270
+
271
+ // ── Queries ───────────────────────────────────────────────────
272
+
273
+ getFileSummary(path) {
274
+ return this._summaries[path] || null;
275
+ }
276
+
277
+ getAllSummaries() {
278
+ return { ...this._summaries };
279
+ }
280
+
281
+ getArchitecture() {
282
+ if (existsSync(ARCHITECTURE_FILE)) {
283
+ try {
284
+ return readFileSync(ARCHITECTURE_FILE, 'utf-8');
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+ return null;
290
+ }
291
+
292
+ /**
293
+ * Find files relevant to a proposed change description.
294
+ * Returns file paths sorted by relevance.
295
+ */
296
+ getRelevantFiles(description) {
297
+ const descLower = description.toLowerCase();
298
+ const keywords = descLower.split(/\W+/).filter(w => w.length > 2);
299
+
300
+ const scored = Object.entries(this._summaries).map(([path, info]) => {
301
+ let score = 0;
302
+ const text = `${path} ${info.summary || ''}`.toLowerCase();
303
+
304
+ for (const keyword of keywords) {
305
+ if (text.includes(keyword)) score++;
306
+ }
307
+
308
+ return { path, score, summary: info.summary };
309
+ });
310
+
311
+ return scored
312
+ .filter(s => s.score > 0)
313
+ .sort((a, b) => b.score - a.score)
314
+ .slice(0, 15);
315
+ }
316
+
317
+ /**
318
+ * Regenerate the architecture overview from all summaries.
319
+ */
320
+ async updateArchitecture() {
321
+ const logger = getLogger();
322
+ const entries = Object.entries(this._summaries);
323
+ if (entries.length === 0) return;
324
+
325
+ // Group by directory
326
+ const byDir = {};
327
+ for (const [path, info] of entries) {
328
+ const dir = path.includes('/') ? path.split('/').slice(0, -1).join('/') : '.';
329
+ if (!byDir[dir]) byDir[dir] = [];
330
+ byDir[dir].push({ path, ...info });
331
+ }
332
+
333
+ // Build a compact summary for LLM
334
+ const summaryText = Object.entries(byDir)
335
+ .map(([dir, files]) => {
336
+ const fileLines = files
337
+ .map(f => ` - ${f.path}: ${(f.summary || 'no summary').slice(0, 120)}`)
338
+ .join('\n');
339
+ return `### ${dir}/\n${fileLines}`;
340
+ })
341
+ .join('\n\n');
342
+
343
+ if (this._agent) {
344
+ try {
345
+ const prompt = `Based on these file summaries, write a concise architecture overview document in Markdown. Include: project structure, key components, data flow, and patterns used.
346
+
347
+ ${summaryText}`;
348
+
349
+ const response = await this._agent.orchestratorProvider.chat({
350
+ system: 'You are a software architect. Write clear, concise architecture documentation.',
351
+ messages: [{ role: 'user', content: prompt }],
352
+ });
353
+
354
+ if (response.text) {
355
+ this._saveArchitecture(response.text);
356
+ logger.info(`[Codebase] Architecture doc updated (${response.text.length} chars)`);
357
+ }
358
+ } catch (err) {
359
+ logger.warn(`[Codebase] Architecture update failed: ${err.message}`);
360
+ }
361
+ } else {
362
+ // Fallback: just dump the summaries
363
+ const doc = `# KERNEL Architecture\n\n_Auto-generated on ${new Date().toISOString()}_\n\n${summaryText}`;
364
+ this._saveArchitecture(doc);
365
+ }
366
+ }
367
+
368
+ // ── Static Analysis Fallback ──────────────────────────────────
369
+
370
+ _staticAnalysis(content, filePath) {
371
+ const exports = [];
372
+ const dependencies = [];
373
+
374
+ // Extract exports
375
+ const exportMatches = content.matchAll(/export\s+(?:default\s+)?(?:class|function|const|let|var)\s+(\w+)/g);
376
+ for (const m of exportMatches) exports.push(m[1]);
377
+
378
+ // Extract local imports
379
+ const importMatches = content.matchAll(/from\s+['"](\.[^'"]+)['"]/g);
380
+ for (const m of importMatches) dependencies.push(m[1]);
381
+
382
+ // Simple summary based on file path
383
+ let summary = `Source file at ${filePath}`;
384
+ if (exports.length > 0) summary += ` — exports: ${exports.join(', ')}`;
385
+
386
+ return { summary, exports, dependencies };
387
+ }
388
+ }