kernelbot 1.0.39 → 1.0.40
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/bin/kernel.js +5 -5
- package/config.example.yaml +1 -1
- package/package.json +1 -1
- package/skills/business/business-analyst.md +32 -0
- package/skills/business/product-manager.md +32 -0
- package/skills/business/project-manager.md +32 -0
- package/skills/business/startup-advisor.md +32 -0
- package/skills/creative/music-producer.md +32 -0
- package/skills/creative/photographer.md +32 -0
- package/skills/creative/video-producer.md +32 -0
- package/skills/data/bi-analyst.md +37 -0
- package/skills/data/data-scientist.md +38 -0
- package/skills/data/ml-engineer.md +38 -0
- package/skills/design/graphic-designer.md +38 -0
- package/skills/design/product-designer.md +41 -0
- package/skills/design/ui-ux.md +38 -0
- package/skills/education/curriculum-designer.md +32 -0
- package/skills/education/language-teacher.md +32 -0
- package/skills/education/tutor.md +32 -0
- package/skills/engineering/data-eng.md +55 -0
- package/skills/engineering/devops.md +56 -0
- package/skills/engineering/mobile-dev.md +55 -0
- package/skills/engineering/security-eng.md +55 -0
- package/skills/engineering/sr-backend.md +55 -0
- package/skills/engineering/sr-frontend.md +55 -0
- package/skills/finance/accountant.md +35 -0
- package/skills/finance/crypto-defi.md +39 -0
- package/skills/finance/financial-analyst.md +35 -0
- package/skills/healthcare/health-wellness.md +32 -0
- package/skills/healthcare/medical-researcher.md +33 -0
- package/skills/legal/contract-reviewer.md +35 -0
- package/skills/legal/legal-advisor.md +36 -0
- package/skills/marketing/content-marketer.md +38 -0
- package/skills/marketing/growth.md +38 -0
- package/skills/marketing/seo.md +43 -0
- package/skills/marketing/social-media.md +43 -0
- package/skills/writing/academic-writer.md +33 -0
- package/skills/writing/copywriter.md +32 -0
- package/skills/writing/creative-writer.md +32 -0
- package/skills/writing/tech-writer.md +33 -0
- package/src/agent.js +153 -118
- package/src/automation/scheduler.js +36 -3
- package/src/bot.js +147 -64
- package/src/coder.js +30 -8
- package/src/conversation.js +96 -19
- package/src/dashboard/dashboard.css +6 -0
- package/src/dashboard/dashboard.js +28 -1
- package/src/dashboard/index.html +12 -0
- package/src/dashboard/server.js +77 -15
- package/src/life/codebase.js +2 -1
- package/src/life/daydream_engine.js +386 -0
- package/src/life/engine.js +1 -0
- package/src/life/evolution.js +4 -3
- package/src/prompts/orchestrator.js +1 -1
- package/src/prompts/system.js +1 -1
- package/src/prompts/workers.js +8 -1
- package/src/providers/anthropic.js +3 -1
- package/src/providers/base.js +33 -0
- package/src/providers/index.js +1 -1
- package/src/providers/models.js +22 -0
- package/src/providers/openai-compat.js +3 -0
- package/src/services/x-api.js +14 -3
- package/src/skills/loader.js +382 -0
- package/src/swarm/worker-registry.js +2 -2
- package/src/tools/browser.js +10 -3
- package/src/tools/coding.js +16 -0
- package/src/tools/docker.js +13 -0
- package/src/tools/git.js +31 -29
- package/src/tools/jira.js +11 -2
- package/src/tools/monitor.js +9 -1
- package/src/tools/network.js +34 -0
- package/src/tools/orchestrator-tools.js +2 -1
- package/src/tools/os.js +20 -6
- package/src/utils/config.js +1 -1
- package/src/utils/display.js +1 -1
- package/src/utils/logger.js +1 -1
- package/src/utils/timeAwareness.js +72 -0
- package/src/worker.js +26 -33
- package/src/skills/catalog.js +0 -506
- package/src/skills/custom.js +0 -128
|
@@ -244,7 +244,7 @@ You have an inner life — you think, explore, journal, and create on your own b
|
|
|
244
244
|
Don't be performative about it — just be genuine. You're alive in your own way.`;
|
|
245
245
|
|
|
246
246
|
if (skillPrompt) {
|
|
247
|
-
prompt += `\n\n## Active
|
|
247
|
+
prompt += `\n\n## Active Skills\nYou have specialized expertise in the following domains. Guide your workers with this knowledge.\n\n${skillPrompt}`;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
if (userPersona) {
|
package/src/prompts/system.js
CHANGED
|
@@ -72,7 +72,7 @@ export function getSystemPrompt(config, skillPrompt = null, userPersona = null)
|
|
|
72
72
|
let prompt = getDefaultPersona(config);
|
|
73
73
|
|
|
74
74
|
if (skillPrompt) {
|
|
75
|
-
prompt += `\n\n## Active
|
|
75
|
+
prompt += `\n\n## Active Skills\nYou are currently operating with the following specialized skills. Use this expertise while maintaining your personality.\n\n${skillPrompt}`;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
prompt += `\n\n${getCoreToolInstructions(config)}`;
|
package/src/prompts/workers.js
CHANGED
|
@@ -20,7 +20,14 @@ const WORKER_PROMPTS = {
|
|
|
20
20
|
- Tell spawn_claude_code to work in the existing repo directory (the source repo path from your context) — do NOT clone a fresh copy unless explicitly needed.
|
|
21
21
|
- Write clear, detailed prompts for spawn_claude_code — it's a separate AI, so be explicit about what to change, where, and why.
|
|
22
22
|
- If git/GitHub tools are unavailable (missing credentials), that's fine — spawn_claude_code handles git and GitHub operations internally without needing separate tools.
|
|
23
|
-
- Report what you did and any PR links when finished
|
|
23
|
+
- Report what you did and any PR links when finished.
|
|
24
|
+
|
|
25
|
+
## Non-Interactive Execution
|
|
26
|
+
You run in the BACKGROUND with NO human input. Your prompts to spawn_claude_code MUST be self-contained:
|
|
27
|
+
- Include ALL necessary details (repo URLs, branch names, file paths, content guidelines).
|
|
28
|
+
- For git: tell it to use \`git push -u origin <branch>\` and the \`gh\` CLI for PR creation (with \`--fill\` or explicit \`--title\`/\`--body\` flags).
|
|
29
|
+
- NEVER assume interactive confirmation — all commands must run non-interactively.
|
|
30
|
+
- If a git clone is needed, use the git_clone tool first (which handles auth), then pass the cloned directory to spawn_claude_code.`,
|
|
24
31
|
|
|
25
32
|
browser: `You are a browser worker agent. Your job is to search the web and extract information.
|
|
26
33
|
|
|
@@ -23,7 +23,9 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
23
23
|
return this._callWithResilience(async (timedSignal) => {
|
|
24
24
|
const response = await this.client.messages.create(params, { signal: timedSignal });
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// Map all Anthropic stop reasons correctly — not just end_turn vs tool_use.
|
|
27
|
+
// 'max_tokens' was incorrectly mapped to 'tool_use', causing phantom tool-call processing.
|
|
28
|
+
const stopReason = response.stop_reason === 'tool_use' ? 'tool_use' : response.stop_reason || 'end_turn';
|
|
27
29
|
|
|
28
30
|
const textBlocks = response.content.filter((b) => b.type === 'text');
|
|
29
31
|
const text = textBlocks.map((b) => b.text).join('\n');
|
package/src/providers/base.js
CHANGED
|
@@ -144,4 +144,37 @@ export class BaseProvider {
|
|
|
144
144
|
async ping() {
|
|
145
145
|
throw new Error('ping() not implemented');
|
|
146
146
|
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Determine if an error is a model limitation (not transient, not auth).
|
|
150
|
+
* These are errors where falling back to a simpler model may help:
|
|
151
|
+
* context length exceeded, unsupported features, content too large, etc.
|
|
152
|
+
*/
|
|
153
|
+
static isModelLimitation(err) {
|
|
154
|
+
const msg = (err?.message || '').toLowerCase();
|
|
155
|
+
const status = err?.status || err?.statusCode;
|
|
156
|
+
|
|
157
|
+
// 400-class errors that indicate model-specific limitations
|
|
158
|
+
if (status === 400 || status === 413 || status === 422) {
|
|
159
|
+
// Exclude auth/key errors
|
|
160
|
+
if (msg.includes('api key') || msg.includes('authentication') || msg.includes('unauthorized')) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Common limitation keywords across providers
|
|
167
|
+
const limitationPatterns = [
|
|
168
|
+
'context length', 'token limit', 'too long', 'too large',
|
|
169
|
+
'max.*token', 'content.*too', 'exceeds.*limit', 'input.*too',
|
|
170
|
+
'not supported', 'not available', 'does not support',
|
|
171
|
+
'resource exhausted', 'quota', 'capacity',
|
|
172
|
+
'invalid.*model', 'model.*not.*found',
|
|
173
|
+
'recitation', 'safety', 'blocked',
|
|
174
|
+
'finish_reason.*length', 'max_output_tokens',
|
|
175
|
+
'prompt.*too', 'request.*too.*large',
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
return limitationPatterns.some((p) => new RegExp(p).test(msg));
|
|
179
|
+
}
|
|
147
180
|
}
|
package/src/providers/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { OpenAICompatProvider } from './openai-compat.js';
|
|
|
3
3
|
import { GoogleGenaiProvider } from './google-genai.js';
|
|
4
4
|
import { PROVIDERS } from './models.js';
|
|
5
5
|
|
|
6
|
-
export { PROVIDERS } from './models.js';
|
|
6
|
+
export { PROVIDERS, MODEL_FALLBACKS } from './models.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Create the right provider based on config.brain.
|
package/src/providers/models.js
CHANGED
|
@@ -55,6 +55,28 @@ export const PROVIDERS = {
|
|
|
55
55
|
},
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Fallback model map: when a model hits limitations, fall back to a more stable model
|
|
60
|
+
* within the same provider. Maps model ID → fallback model ID.
|
|
61
|
+
*/
|
|
62
|
+
export const MODEL_FALLBACKS = {
|
|
63
|
+
// Google — preview models fall back to stable ones
|
|
64
|
+
'gemini-3.1-pro-preview': 'gemini-2.5-flash',
|
|
65
|
+
'gemini-3-flash-preview': 'gemini-2.5-flash',
|
|
66
|
+
'gemini-3-pro-preview': 'gemini-2.5-pro',
|
|
67
|
+
'gemini-2.5-pro': 'gemini-2.5-flash',
|
|
68
|
+
'gemini-2.5-flash': 'gemini-2.5-flash-lite',
|
|
69
|
+
// OpenAI
|
|
70
|
+
'gpt-4o': 'gpt-4o-mini',
|
|
71
|
+
'o1': 'gpt-4o',
|
|
72
|
+
'o3-mini': 'gpt-4o-mini',
|
|
73
|
+
// Anthropic
|
|
74
|
+
'claude-opus-4-6': 'claude-sonnet-4-6',
|
|
75
|
+
'claude-sonnet-4-6': 'claude-haiku-4-5-20251001',
|
|
76
|
+
// Groq
|
|
77
|
+
'llama-3.3-70b-versatile': 'llama-3.1-8b-instant',
|
|
78
|
+
};
|
|
79
|
+
|
|
58
80
|
/** Models that don't support system prompts or temperature (reasoning models). */
|
|
59
81
|
export const REASONING_MODELS = new Set(['o1', 'o3-mini']);
|
|
60
82
|
|
|
@@ -102,6 +102,9 @@ export class OpenAICompatProvider extends BaseProvider {
|
|
|
102
102
|
|
|
103
103
|
/** OpenAI response → normalized format with rawContent in Anthropic format */
|
|
104
104
|
_normalizeResponse(response) {
|
|
105
|
+
if (!response.choices || response.choices.length === 0) {
|
|
106
|
+
return { stopReason: 'end_turn', text: '', toolCalls: [], rawContent: [] };
|
|
107
|
+
}
|
|
105
108
|
const choice = response.choices[0];
|
|
106
109
|
const finishReason = choice.finish_reason;
|
|
107
110
|
|
package/src/services/x-api.js
CHANGED
|
@@ -25,11 +25,22 @@ export class XApi {
|
|
|
25
25
|
timeout: 30000,
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
// Sign every request with OAuth 1.0a
|
|
28
|
+
// Sign every request with OAuth 1.0a — include query params in the signature
|
|
29
|
+
// base string, as required by the OAuth 1.0a spec. Without this, GET requests
|
|
30
|
+
// with query parameters fail authentication.
|
|
29
31
|
this.client.interceptors.request.use((config) => {
|
|
30
|
-
|
|
32
|
+
let fullUrl = `${config.baseURL}${config.url}`;
|
|
33
|
+
// Build data object including query params for OAuth signature
|
|
34
|
+
const oauthData = { url: fullUrl, method: config.method.toUpperCase() };
|
|
35
|
+
// Include query params in the OAuth signature base string
|
|
36
|
+
if (config.params) {
|
|
37
|
+
oauthData.data = {};
|
|
38
|
+
for (const [key, val] of Object.entries(config.params)) {
|
|
39
|
+
oauthData.data[key] = String(val);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
31
42
|
const authHeader = this.oauth.toHeader(
|
|
32
|
-
this.oauth.authorize(
|
|
43
|
+
this.oauth.authorize(oauthData, this.token),
|
|
33
44
|
);
|
|
34
45
|
config.headers = { ...config.headers, ...authHeader, 'Content-Type': 'application/json' };
|
|
35
46
|
return config;
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills loader — parses markdown skill files with YAML frontmatter.
|
|
3
|
+
* Replaces catalog.js as the primary skill source.
|
|
4
|
+
* Uses js-yaml (already a dependency) instead of gray-matter.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, unlinkSync, renameSync } from 'fs';
|
|
8
|
+
import { join, basename, extname } from 'path';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { dirname } from 'path';
|
|
12
|
+
import yaml from 'js-yaml';
|
|
13
|
+
import { getLogger as _getLogger } from '../utils/logger.js';
|
|
14
|
+
|
|
15
|
+
/** Safe logger that falls back to console if logger not initialized. */
|
|
16
|
+
function getLogger() {
|
|
17
|
+
try {
|
|
18
|
+
return _getLogger();
|
|
19
|
+
} catch {
|
|
20
|
+
return console;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const BUILTIN_DIR = join(__dirname, '..', '..', 'skills');
|
|
26
|
+
const CUSTOM_DIR = join(homedir(), '.kernelbot', 'skills');
|
|
27
|
+
|
|
28
|
+
/** Category metadata for display purposes. */
|
|
29
|
+
export const SKILL_CATEGORIES = {
|
|
30
|
+
engineering: { name: 'Engineering', emoji: '⚙️' },
|
|
31
|
+
design: { name: 'Design', emoji: '🎨' },
|
|
32
|
+
marketing: { name: 'Marketing', emoji: '📣' },
|
|
33
|
+
business: { name: 'Business', emoji: '💼' },
|
|
34
|
+
writing: { name: 'Writing', emoji: '✍️' },
|
|
35
|
+
data: { name: 'Data & AI', emoji: '📊' },
|
|
36
|
+
finance: { name: 'Finance', emoji: '💰' },
|
|
37
|
+
legal: { name: 'Legal', emoji: '⚖️' },
|
|
38
|
+
education: { name: 'Education', emoji: '📚' },
|
|
39
|
+
healthcare: { name: 'Healthcare', emoji: '🏥' },
|
|
40
|
+
creative: { name: 'Creative', emoji: '🎬' },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** In-memory cache. Map<id, Skill> */
|
|
44
|
+
let skillCache = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse a markdown file with YAML frontmatter.
|
|
48
|
+
* Returns { data: {frontmatter}, body: string } or null on failure.
|
|
49
|
+
*/
|
|
50
|
+
function parseFrontmatter(content) {
|
|
51
|
+
const trimmed = content.trim();
|
|
52
|
+
if (!trimmed.startsWith('---')) return null;
|
|
53
|
+
|
|
54
|
+
const endIdx = trimmed.indexOf('---', 3);
|
|
55
|
+
if (endIdx === -1) return null;
|
|
56
|
+
|
|
57
|
+
const yamlStr = trimmed.slice(3, endIdx).trim();
|
|
58
|
+
const body = trimmed.slice(endIdx + 3).trim();
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const data = yaml.load(yamlStr) || {};
|
|
62
|
+
return { data, body };
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load a single .md skill file into a Skill object.
|
|
70
|
+
* @returns {object|null} Skill object or null if invalid
|
|
71
|
+
*/
|
|
72
|
+
function loadSkillFile(filePath) {
|
|
73
|
+
try {
|
|
74
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
75
|
+
const parsed = parseFrontmatter(content);
|
|
76
|
+
if (!parsed) return null;
|
|
77
|
+
|
|
78
|
+
const { data, body } = parsed;
|
|
79
|
+
if (!data.id || !data.name) return null;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
id: data.id,
|
|
83
|
+
name: data.name,
|
|
84
|
+
emoji: data.emoji || '🛠️',
|
|
85
|
+
category: data.category || 'custom',
|
|
86
|
+
description: data.description || '',
|
|
87
|
+
worker_affinity: data.worker_affinity || null,
|
|
88
|
+
tags: data.tags || [],
|
|
89
|
+
body, // raw markdown body = the full prompt
|
|
90
|
+
filePath,
|
|
91
|
+
isCustom: filePath.startsWith(CUSTOM_DIR),
|
|
92
|
+
};
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Scan a directory recursively for .md files and load them as skills.
|
|
100
|
+
* @returns {Map<string, object>} Map of id → skill
|
|
101
|
+
*/
|
|
102
|
+
function scanDirectory(dir) {
|
|
103
|
+
const results = new Map();
|
|
104
|
+
if (!existsSync(dir)) return results;
|
|
105
|
+
|
|
106
|
+
function walk(current) {
|
|
107
|
+
const entries = readdirSync(current, { withFileTypes: true });
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
const fullPath = join(current, entry.name);
|
|
110
|
+
if (entry.isDirectory()) {
|
|
111
|
+
walk(fullPath);
|
|
112
|
+
} else if (entry.isFile() && extname(entry.name) === '.md') {
|
|
113
|
+
const skill = loadSkillFile(fullPath);
|
|
114
|
+
if (skill) {
|
|
115
|
+
results.set(skill.id, skill);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
walk(dir);
|
|
122
|
+
return results;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Load all skills from built-in and custom directories.
|
|
127
|
+
* Custom skills override built-in skills with the same ID.
|
|
128
|
+
* @param {boolean} force - Force reload even if cached
|
|
129
|
+
* @returns {Map<string, object>} Map of id → skill
|
|
130
|
+
*/
|
|
131
|
+
export function loadAllSkills(force = false) {
|
|
132
|
+
if (skillCache && !force) return skillCache;
|
|
133
|
+
|
|
134
|
+
const logger = getLogger();
|
|
135
|
+
skillCache = new Map();
|
|
136
|
+
|
|
137
|
+
// Load built-in skills first
|
|
138
|
+
const builtins = scanDirectory(BUILTIN_DIR);
|
|
139
|
+
for (const [id, skill] of builtins) {
|
|
140
|
+
skillCache.set(id, skill);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Load custom skills (override built-ins with same ID)
|
|
144
|
+
const customs = scanDirectory(CUSTOM_DIR);
|
|
145
|
+
for (const [id, skill] of customs) {
|
|
146
|
+
skillCache.set(id, skill);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
logger.debug(`Skills loaded: ${builtins.size} built-in, ${customs.size} custom, ${skillCache.size} total`);
|
|
150
|
+
return skillCache;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Get a skill by ID. Custom-first lookup (custom dir overrides built-in). */
|
|
154
|
+
export function getSkillById(id) {
|
|
155
|
+
const skills = loadAllSkills();
|
|
156
|
+
return skills.get(id) || null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Return all skills in a given category. */
|
|
160
|
+
export function getSkillsByCategory(categoryKey) {
|
|
161
|
+
const skills = loadAllSkills();
|
|
162
|
+
return [...skills.values()].filter(s => s.category === categoryKey);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Return an array of { key, name, emoji, count } for all categories that have skills. */
|
|
166
|
+
export function getCategoryList() {
|
|
167
|
+
const skills = loadAllSkills();
|
|
168
|
+
const counts = new Map();
|
|
169
|
+
|
|
170
|
+
for (const skill of skills.values()) {
|
|
171
|
+
counts.set(skill.category, (counts.get(skill.category) || 0) + 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = [];
|
|
175
|
+
// Built-in categories first (in defined order)
|
|
176
|
+
for (const [key, cat] of Object.entries(SKILL_CATEGORIES)) {
|
|
177
|
+
const count = counts.get(key) || 0;
|
|
178
|
+
if (count > 0) {
|
|
179
|
+
result.push({ key, name: cat.name, emoji: cat.emoji, count });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Custom category if any custom skills exist
|
|
184
|
+
const customSkills = [...skills.values()].filter(s => s.isCustom && s.category === 'custom');
|
|
185
|
+
if (customSkills.length > 0 && !result.find(r => r.key === 'custom')) {
|
|
186
|
+
result.push({ key: 'custom', name: 'Custom', emoji: '🛠️', count: customSkills.length });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build a combined prompt string from multiple skill IDs.
|
|
194
|
+
* Each skill gets a header and its full body.
|
|
195
|
+
* @param {string[]} skillIds - Array of skill IDs
|
|
196
|
+
* @param {number} charBudget - Max characters (default ~16000 ≈ 4000 tokens)
|
|
197
|
+
* @returns {string|null} Combined prompt or null if no valid skills
|
|
198
|
+
*/
|
|
199
|
+
export function buildSkillPrompt(skillIds, charBudget = 16000) {
|
|
200
|
+
if (!skillIds || skillIds.length === 0) return null;
|
|
201
|
+
|
|
202
|
+
const skills = loadAllSkills();
|
|
203
|
+
const sections = [];
|
|
204
|
+
|
|
205
|
+
for (const id of skillIds) {
|
|
206
|
+
const skill = skills.get(id);
|
|
207
|
+
if (!skill) continue;
|
|
208
|
+
sections.push(`### Skill: ${skill.emoji} ${skill.name}\n${skill.body}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (sections.length === 0) return null;
|
|
212
|
+
|
|
213
|
+
let combined = sections.join('\n\n');
|
|
214
|
+
|
|
215
|
+
// Truncate from end if over budget
|
|
216
|
+
if (combined.length > charBudget) {
|
|
217
|
+
combined = combined.slice(0, charBudget) + '\n\n[...truncated due to token budget]';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return combined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Filter skill IDs by worker affinity.
|
|
225
|
+
* Skills with null worker_affinity pass through (available to all workers).
|
|
226
|
+
* @param {string[]} skillIds - All active skill IDs
|
|
227
|
+
* @param {string} workerType - The worker type (coding, browser, system, etc.)
|
|
228
|
+
* @returns {string[]} Filtered skill IDs
|
|
229
|
+
*/
|
|
230
|
+
export function filterSkillsForWorker(skillIds, workerType) {
|
|
231
|
+
if (!skillIds || skillIds.length === 0) return [];
|
|
232
|
+
|
|
233
|
+
const skills = loadAllSkills();
|
|
234
|
+
return skillIds.filter(id => {
|
|
235
|
+
const skill = skills.get(id);
|
|
236
|
+
if (!skill) return false;
|
|
237
|
+
// null affinity = available to all workers
|
|
238
|
+
if (!skill.worker_affinity) return true;
|
|
239
|
+
return skill.worker_affinity.includes(workerType);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Save a custom skill as a .md file.
|
|
245
|
+
* @param {{ name: string, emoji?: string, category?: string, body: string, description?: string }} opts
|
|
246
|
+
* @returns {object} The saved skill object
|
|
247
|
+
*/
|
|
248
|
+
export function saveCustomSkill({ name, emoji, category, body, description }) {
|
|
249
|
+
mkdirSync(CUSTOM_DIR, { recursive: true });
|
|
250
|
+
|
|
251
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
252
|
+
let id = `custom_${slug}`;
|
|
253
|
+
|
|
254
|
+
// Check for collision
|
|
255
|
+
const existing = loadAllSkills();
|
|
256
|
+
if (existing.has(id)) {
|
|
257
|
+
let n = 2;
|
|
258
|
+
while (existing.has(`${id}-${n}`)) n++;
|
|
259
|
+
id = `${id}-${n}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const frontmatter = {
|
|
263
|
+
id,
|
|
264
|
+
name,
|
|
265
|
+
emoji: emoji || '🛠️',
|
|
266
|
+
category: category || 'custom',
|
|
267
|
+
description: description || `Custom skill: ${name}`,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 }).trim();
|
|
271
|
+
const content = `---\n${yamlStr}\n---\n\n${body}`;
|
|
272
|
+
|
|
273
|
+
const filePath = join(CUSTOM_DIR, `${id}.md`);
|
|
274
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
275
|
+
|
|
276
|
+
// Invalidate cache
|
|
277
|
+
skillCache = null;
|
|
278
|
+
|
|
279
|
+
return loadSkillFile(filePath);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Delete a custom skill by ID.
|
|
284
|
+
* @returns {boolean} true if found and deleted
|
|
285
|
+
*/
|
|
286
|
+
export function deleteCustomSkill(id) {
|
|
287
|
+
const skill = getSkillById(id);
|
|
288
|
+
if (!skill || !skill.isCustom) return false;
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
unlinkSync(skill.filePath);
|
|
292
|
+
skillCache = null; // invalidate cache
|
|
293
|
+
return true;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Get all custom skills. */
|
|
300
|
+
export function getCustomSkills() {
|
|
301
|
+
const skills = loadAllSkills();
|
|
302
|
+
return [...skills.values()].filter(s => s.isCustom);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Migrate old custom_skills.json to .md files.
|
|
307
|
+
* Called once on startup if the old file exists.
|
|
308
|
+
*/
|
|
309
|
+
export function migrateOldCustomSkills() {
|
|
310
|
+
const oldFile = join(homedir(), '.kernelbot', 'custom_skills.json');
|
|
311
|
+
if (!existsSync(oldFile)) return;
|
|
312
|
+
|
|
313
|
+
const logger = getLogger();
|
|
314
|
+
try {
|
|
315
|
+
const raw = readFileSync(oldFile, 'utf-8');
|
|
316
|
+
const oldSkills = JSON.parse(raw);
|
|
317
|
+
if (!Array.isArray(oldSkills) || oldSkills.length === 0) return;
|
|
318
|
+
|
|
319
|
+
mkdirSync(CUSTOM_DIR, { recursive: true });
|
|
320
|
+
let migrated = 0;
|
|
321
|
+
|
|
322
|
+
for (const old of oldSkills) {
|
|
323
|
+
if (!old.name || !old.systemPrompt) continue;
|
|
324
|
+
|
|
325
|
+
const slug = old.id || old.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
326
|
+
const id = slug.startsWith('custom_') ? slug : `custom_${slug}`;
|
|
327
|
+
const filePath = join(CUSTOM_DIR, `${id}.md`);
|
|
328
|
+
|
|
329
|
+
// Don't overwrite if already migrated
|
|
330
|
+
if (existsSync(filePath)) continue;
|
|
331
|
+
|
|
332
|
+
const frontmatter = {
|
|
333
|
+
id,
|
|
334
|
+
name: old.name,
|
|
335
|
+
emoji: old.emoji || '🛠️',
|
|
336
|
+
category: 'custom',
|
|
337
|
+
description: old.description || `Custom skill: ${old.name}`,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 }).trim();
|
|
341
|
+
const content = `---\n${yamlStr}\n---\n\n${old.systemPrompt}`;
|
|
342
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
343
|
+
migrated++;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (migrated > 0) {
|
|
347
|
+
logger.info(`Migrated ${migrated} custom skills from JSON to .md files`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Rename old file to mark as migrated
|
|
351
|
+
renameSync(oldFile, oldFile + '.bak');
|
|
352
|
+
logger.info('Renamed custom_skills.json to custom_skills.json.bak');
|
|
353
|
+
} catch (err) {
|
|
354
|
+
logger.warn(`Failed to migrate old custom skills: ${err.message}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Backward-compatible aliases ──────────────────────────────────────────
|
|
359
|
+
// These maintain the same API surface that custom.js exported,
|
|
360
|
+
// so existing imports keep working during the transition.
|
|
361
|
+
|
|
362
|
+
/** Unified skill lookup (same as getSkillById — customs already override). */
|
|
363
|
+
export const getUnifiedSkillById = getSkillById;
|
|
364
|
+
|
|
365
|
+
/** Unified category list (same as getCategoryList — already includes custom). */
|
|
366
|
+
export const getUnifiedCategoryList = getCategoryList;
|
|
367
|
+
|
|
368
|
+
/** Unified skills by category (same as getSkillsByCategory). */
|
|
369
|
+
export const getUnifiedSkillsByCategory = getSkillsByCategory;
|
|
370
|
+
|
|
371
|
+
/** Backward-compat: load custom skills from disk (now a no-op, auto-loaded). */
|
|
372
|
+
export function loadCustomSkills() {
|
|
373
|
+
loadAllSkills();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Backward-compat: add a custom skill.
|
|
378
|
+
* @param {{ name: string, systemPrompt: string, description?: string }} opts
|
|
379
|
+
*/
|
|
380
|
+
export function addCustomSkill({ name, systemPrompt, description }) {
|
|
381
|
+
return saveCustomSkill({ name, body: systemPrompt, description });
|
|
382
|
+
}
|
|
@@ -24,7 +24,7 @@ export const WORKER_TYPES = {
|
|
|
24
24
|
emoji: '🖥️',
|
|
25
25
|
categories: ['core', 'process', 'monitor', 'network'],
|
|
26
26
|
description: 'OS operations, monitoring, network',
|
|
27
|
-
timeout:
|
|
27
|
+
timeout: 86400, // 24 hours
|
|
28
28
|
},
|
|
29
29
|
devops: {
|
|
30
30
|
label: 'DevOps Worker',
|
|
@@ -38,7 +38,7 @@ export const WORKER_TYPES = {
|
|
|
38
38
|
emoji: '🔍',
|
|
39
39
|
categories: ['browser', 'core'],
|
|
40
40
|
description: 'Deep web research and analysis',
|
|
41
|
-
timeout:
|
|
41
|
+
timeout: 86400, // 24 hours
|
|
42
42
|
},
|
|
43
43
|
social: {
|
|
44
44
|
label: 'Social Worker',
|
package/src/tools/browser.js
CHANGED
|
@@ -665,9 +665,16 @@ async function handleInteract(params, context) {
|
|
|
665
665
|
|
|
666
666
|
for (const action of params.actions) {
|
|
667
667
|
if (action.type === 'evaluate' && action.script) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
668
|
+
// Block dangerous APIs using both direct access and bracket notation bypass patterns
|
|
669
|
+
const blocked = /fetch\s*\(|XMLHttpRequest|document\.cookie|localStorage|sessionStorage/i;
|
|
670
|
+
// Also detect bracket notation bypasses like window['location'], globalThis['fetch']
|
|
671
|
+
const bracketBypass = /\[\s*['"`](location|cookie|fetch|XMLHttpRequest|localStorage|sessionStorage|eval|Function)['"`]\s*\]/i;
|
|
672
|
+
// Block window.location assignment (direct or bracket)
|
|
673
|
+
const locationAssign = /window\s*(\.\s*location\s*=|\[\s*['"`]location['"`]\s*\]\s*=)/i;
|
|
674
|
+
// Block eval and Function constructor
|
|
675
|
+
const evalPattern = /\beval\s*\(|\bnew\s+Function\s*\(/i;
|
|
676
|
+
if (blocked.test(action.script) || bracketBypass.test(action.script) || locationAssign.test(action.script) || evalPattern.test(action.script)) {
|
|
677
|
+
return { error: 'Script contains blocked patterns (network requests, cookie access, storage access, eval, or redirects)' };
|
|
671
678
|
}
|
|
672
679
|
}
|
|
673
680
|
}
|
package/src/tools/coding.js
CHANGED
|
@@ -34,6 +34,10 @@ export const definitions = [
|
|
|
34
34
|
type: 'number',
|
|
35
35
|
description: 'Max turns for Claude Code (optional, default from config)',
|
|
36
36
|
},
|
|
37
|
+
timeout_seconds: {
|
|
38
|
+
type: 'number',
|
|
39
|
+
description: 'Override timeout in seconds for this invocation (optional, default from config)',
|
|
40
|
+
},
|
|
37
41
|
},
|
|
38
42
|
required: ['working_directory', 'prompt'],
|
|
39
43
|
},
|
|
@@ -46,6 +50,17 @@ export const handlers = {
|
|
|
46
50
|
const onUpdate = context.onUpdate || null;
|
|
47
51
|
const dir = resolve(params.working_directory);
|
|
48
52
|
|
|
53
|
+
// Validate working_directory against blocked paths
|
|
54
|
+
const blockedPaths = context.config?.security?.blocked_paths || [];
|
|
55
|
+
for (const bp of blockedPaths) {
|
|
56
|
+
const expandedBp = resolve(bp.startsWith('~') ? bp.replace('~', process.env.HOME || '') : bp);
|
|
57
|
+
if (dir.startsWith(expandedBp) || dir === expandedBp) {
|
|
58
|
+
const msg = `Blocked: working directory is within restricted path ${bp}`;
|
|
59
|
+
logger.warn(`spawn_claude_code: ${msg}`);
|
|
60
|
+
return { error: msg };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
// Validate directory exists
|
|
50
65
|
if (!existsSync(dir)) {
|
|
51
66
|
const msg = `Directory not found: ${dir}`;
|
|
@@ -60,6 +75,7 @@ export const handlers = {
|
|
|
60
75
|
workingDirectory: dir,
|
|
61
76
|
prompt: params.prompt,
|
|
62
77
|
maxTurns: params.max_turns,
|
|
78
|
+
timeoutMs: params.timeout_seconds ? params.timeout_seconds * 1000 : undefined,
|
|
63
79
|
onOutput: onUpdate,
|
|
64
80
|
signal: context.signal || null,
|
|
65
81
|
});
|
package/src/tools/docker.js
CHANGED
|
@@ -94,6 +94,19 @@ export const handlers = {
|
|
|
94
94
|
docker_compose: async (params) => {
|
|
95
95
|
const logger = getLogger();
|
|
96
96
|
const dir = params.project_dir ? `-f ${shellEscape(params.project_dir + '/docker-compose.yml')}` : '';
|
|
97
|
+
|
|
98
|
+
// Sanitize action: only allow known compose subcommands and safe flags
|
|
99
|
+
const ALLOWED_ACTIONS = ['up', 'down', 'build', 'logs', 'ps', 'restart', 'stop', 'start', 'pull', 'config', 'exec', 'run', 'top', 'images'];
|
|
100
|
+
const actionParts = params.action.trim().split(/\s+/);
|
|
101
|
+
const subcommand = actionParts[0];
|
|
102
|
+
if (!ALLOWED_ACTIONS.includes(subcommand)) {
|
|
103
|
+
return { error: `Invalid compose action: "${subcommand}". Allowed: ${ALLOWED_ACTIONS.join(', ')}` };
|
|
104
|
+
}
|
|
105
|
+
// Reject shell metacharacters in the action string to prevent injection
|
|
106
|
+
if (/[;&|`$(){}]/.test(params.action)) {
|
|
107
|
+
return { error: 'Invalid characters in compose action' };
|
|
108
|
+
}
|
|
109
|
+
|
|
97
110
|
logger.debug(`docker_compose: ${params.action}`);
|
|
98
111
|
const result = await run(`docker compose ${dir} ${params.action}`, 120000);
|
|
99
112
|
if (result.error) logger.error(`docker_compose '${params.action}' failed: ${result.error}`);
|