alvin-bot 4.4.1
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/.env.example +43 -0
- package/BACKLOG.md +223 -0
- package/CHANGELOG.md +63 -0
- package/CLAUDE.example.md +152 -0
- package/CODE_OF_CONDUCT.md +52 -0
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/SECURITY.md +38 -0
- package/SOUL.example.md +60 -0
- package/TOOLS.example.md +42 -0
- package/alvin-bot.config.example.json +24 -0
- package/bin/cli.js +1088 -0
- package/dist/.metadata_never_index +0 -0
- package/dist/claude.js +102 -0
- package/dist/config.js +65 -0
- package/dist/engine.js +90 -0
- package/dist/find-claude-binary.js +98 -0
- package/dist/handlers/commands.js +1489 -0
- package/dist/handlers/document.js +187 -0
- package/dist/handlers/message.js +200 -0
- package/dist/handlers/photo.js +154 -0
- package/dist/handlers/platform-message.js +275 -0
- package/dist/handlers/video.js +237 -0
- package/dist/handlers/voice.js +148 -0
- package/dist/i18n.js +299 -0
- package/dist/index.js +442 -0
- package/dist/init-data-dir.js +81 -0
- package/dist/middleware/auth.js +215 -0
- package/dist/migrate.js +139 -0
- package/dist/paths.js +87 -0
- package/dist/platforms/discord.js +161 -0
- package/dist/platforms/index.js +130 -0
- package/dist/platforms/signal.js +205 -0
- package/dist/platforms/slack.js +318 -0
- package/dist/platforms/telegram.js +111 -0
- package/dist/platforms/types.js +8 -0
- package/dist/platforms/whatsapp.js +648 -0
- package/dist/providers/claude-sdk-provider.js +173 -0
- package/dist/providers/codex-cli-provider.js +121 -0
- package/dist/providers/index.js +7 -0
- package/dist/providers/openai-compatible.js +388 -0
- package/dist/providers/registry.js +209 -0
- package/dist/providers/tool-executor.js +450 -0
- package/dist/providers/types.js +205 -0
- package/dist/services/access.js +144 -0
- package/dist/services/asset-index.js +230 -0
- package/dist/services/browser-manager.js +161 -0
- package/dist/services/browser.js +121 -0
- package/dist/services/compaction.js +129 -0
- package/dist/services/cron.js +462 -0
- package/dist/services/custom-tools.js +317 -0
- package/dist/services/delivery-queue.js +154 -0
- package/dist/services/elevenlabs.js +58 -0
- package/dist/services/embeddings.js +386 -0
- package/dist/services/exec-guard.js +46 -0
- package/dist/services/fallback-order.js +151 -0
- package/dist/services/heartbeat.js +192 -0
- package/dist/services/hooks.js +44 -0
- package/dist/services/imagegen.js +72 -0
- package/dist/services/language-detect.js +144 -0
- package/dist/services/markdown.js +63 -0
- package/dist/services/mcp.js +252 -0
- package/dist/services/memory.js +133 -0
- package/dist/services/personality.js +227 -0
- package/dist/services/plugins.js +171 -0
- package/dist/services/reminders.js +97 -0
- package/dist/services/restart.js +48 -0
- package/dist/services/security-audit.js +66 -0
- package/dist/services/self-search.js +129 -0
- package/dist/services/session.js +93 -0
- package/dist/services/skills.js +287 -0
- package/dist/services/standing-orders.js +29 -0
- package/dist/services/subagents.js +142 -0
- package/dist/services/sudo.js +243 -0
- package/dist/services/telegram.js +113 -0
- package/dist/services/tool-discovery.js +214 -0
- package/dist/services/usage-tracker.js +137 -0
- package/dist/services/users.js +199 -0
- package/dist/services/voice.js +95 -0
- package/dist/tui/index.js +507 -0
- package/dist/web/canvas.js +30 -0
- package/dist/web/doctor-api.js +606 -0
- package/dist/web/openai-compat.js +252 -0
- package/dist/web/server.js +1351 -0
- package/dist/web/setup-api.js +1078 -0
- package/docs/mcp.example.json +16 -0
- package/docs/screenshots/00-Login.png +0 -0
- package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
- package/docs/screenshots/02-Chat.png +0 -0
- package/docs/screenshots/03-Dashboard-Overview.png +0 -0
- package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
- package/docs/screenshots/05-Personality-Editor.png +0 -0
- package/docs/screenshots/06-Memory-Manager.png +0 -0
- package/docs/screenshots/07-Active-Sessions.png +0 -0
- package/docs/screenshots/08-File-Browser.png +0 -0
- package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
- package/docs/screenshots/10-Custom-Tools.png +0 -0
- package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
- package/docs/screenshots/12-Messaging-Platforms.png +0 -0
- package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
- package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
- package/docs/screenshots/13-User-Management.png +0 -0
- package/docs/screenshots/14-Web-Terminal.png +0 -0
- package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
- package/docs/screenshots/16-Settings-and-Env.png +0 -0
- package/docs/screenshots/TG-commands.png +0 -0
- package/docs/screenshots/TG.png +0 -0
- package/docs/screenshots/_Mac-Installer.png +0 -0
- package/docs/tools.example.json +33 -0
- package/install.sh +165 -0
- package/package.json +190 -0
- package/plugins/calendar/index.js +270 -0
- package/plugins/email/index.js +231 -0
- package/plugins/finance/index.js +254 -0
- package/plugins/notes/index.js +227 -0
- package/plugins/smarthome/index.js +230 -0
- package/plugins/weather/index.js +122 -0
- package/skills/apple-notes/SKILL.md +31 -0
- package/skills/browse/SKILL.md +136 -0
- package/skills/code-project/SKILL.md +43 -0
- package/skills/data-analysis/SKILL.md +39 -0
- package/skills/document-creation/SKILL.md +48 -0
- package/skills/email-summary/SKILL.md +46 -0
- package/skills/github/SKILL.md +42 -0
- package/skills/summarize/SKILL.md +28 -0
- package/skills/system-admin/SKILL.md +39 -0
- package/skills/weather/SKILL.md +34 -0
- package/skills/web-research/SKILL.md +35 -0
- package/web/public/canvas.html +52 -0
- package/web/public/css/style.css +555 -0
- package/web/public/index.html +189 -0
- package/web/public/js/app.js +3102 -0
- package/web/public/js/i18n.js +1048 -0
- package/web/public/js/icons.js +104 -0
- package/web/public/login.html +48 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill System — Specialized knowledge for complex tasks.
|
|
3
|
+
*
|
|
4
|
+
* Skills are SKILL.md files in the skills/ directory that provide
|
|
5
|
+
* domain-specific instructions, workflows, and best practices.
|
|
6
|
+
*
|
|
7
|
+
* When a user message matches a skill's triggers, the skill's content
|
|
8
|
+
* is injected into the system prompt — giving the agent deep expertise
|
|
9
|
+
* for that specific task type.
|
|
10
|
+
*
|
|
11
|
+
* Philosophy: A generalist agent with specialist knowledge on demand.
|
|
12
|
+
*
|
|
13
|
+
* Features:
|
|
14
|
+
* - Bundled skills (skills/ in repo) + User skills (~/.alvin-bot/skills/)
|
|
15
|
+
* - User skills override bundled skills with the same ID
|
|
16
|
+
* - Hot-reload via fs.watch() on both directories
|
|
17
|
+
* - Self-modification via createSkill()
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, watch } from "fs";
|
|
20
|
+
import { resolve } from "path";
|
|
21
|
+
import { SKILLS_DIR } from "../paths.js";
|
|
22
|
+
import { USER_SKILLS_DIR } from "../paths.js";
|
|
23
|
+
import { loadAssetIndex } from "./asset-index.js";
|
|
24
|
+
// ── Skill Registry ──────────────────────────────────────
|
|
25
|
+
let cachedSkills = [];
|
|
26
|
+
let lastScanAt = 0;
|
|
27
|
+
/**
|
|
28
|
+
* Parse SKILL.md frontmatter (simple YAML-like header).
|
|
29
|
+
*
|
|
30
|
+
* Format:
|
|
31
|
+
* ---
|
|
32
|
+
* name: Video Creation
|
|
33
|
+
* description: Create videos with Remotion
|
|
34
|
+
* triggers: video, remotion, animation, render
|
|
35
|
+
* priority: 5
|
|
36
|
+
* category: media
|
|
37
|
+
* ---
|
|
38
|
+
* (rest is the skill content)
|
|
39
|
+
*/
|
|
40
|
+
function parseSkillFile(id, content, source) {
|
|
41
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
42
|
+
if (!fmMatch) {
|
|
43
|
+
// No frontmatter — treat entire file as content with defaults
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
name: id.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase()),
|
|
47
|
+
description: "",
|
|
48
|
+
triggers: [id.replace(/-/g, " ")],
|
|
49
|
+
content: content.trim(),
|
|
50
|
+
priority: 1,
|
|
51
|
+
category: "general",
|
|
52
|
+
source,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const frontmatter = fmMatch[1];
|
|
56
|
+
const body = fmMatch[2].trim();
|
|
57
|
+
function getField(key) {
|
|
58
|
+
const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
59
|
+
return match ? match[1].trim() : "";
|
|
60
|
+
}
|
|
61
|
+
const name = getField("name") || id;
|
|
62
|
+
const description = getField("description") || "";
|
|
63
|
+
const triggersRaw = getField("triggers") || id;
|
|
64
|
+
const priority = parseInt(getField("priority")) || 1;
|
|
65
|
+
const category = getField("category") || "general";
|
|
66
|
+
const assetCategoriesRaw = getField("assetCategories");
|
|
67
|
+
const assetCategories = assetCategoriesRaw
|
|
68
|
+
? assetCategoriesRaw.replace(/[\[\]]/g, "").split(",").map(s => s.trim()).filter(Boolean)
|
|
69
|
+
: undefined;
|
|
70
|
+
const triggers = triggersRaw
|
|
71
|
+
.split(",")
|
|
72
|
+
.map(t => t.trim().toLowerCase())
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
return { id, name, description, triggers, content: body, priority, category, source, assetCategories };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Scan a single skills directory and return all parsed skills.
|
|
78
|
+
*/
|
|
79
|
+
function scanDirectory(dir, source) {
|
|
80
|
+
if (!existsSync(dir))
|
|
81
|
+
return [];
|
|
82
|
+
const skills = [];
|
|
83
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
const skillFile = resolve(dir, entry.name, "SKILL.md");
|
|
87
|
+
if (existsSync(skillFile)) {
|
|
88
|
+
try {
|
|
89
|
+
const content = readFileSync(skillFile, "utf-8");
|
|
90
|
+
const skill = parseSkillFile(entry.name, content, source);
|
|
91
|
+
if (skill)
|
|
92
|
+
skills.push(skill);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
console.warn(`\u26a0\ufe0f Failed to load skill ${entry.name}:`, err);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Also support flat .md files in skills/
|
|
100
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
101
|
+
const id = entry.name.replace(/\.md$/, "");
|
|
102
|
+
try {
|
|
103
|
+
const content = readFileSync(resolve(dir, entry.name), "utf-8");
|
|
104
|
+
const skill = parseSkillFile(id, content, source);
|
|
105
|
+
if (skill)
|
|
106
|
+
skills.push(skill);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
console.warn(`\u26a0\ufe0f Failed to load skill ${id}:`, err);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return skills;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Reload all skills from both directories.
|
|
117
|
+
* User skills override bundled skills with the same ID.
|
|
118
|
+
*/
|
|
119
|
+
function reloadAllSkills() {
|
|
120
|
+
// Ensure bundled directory exists
|
|
121
|
+
if (!existsSync(SKILLS_DIR)) {
|
|
122
|
+
mkdirSync(SKILLS_DIR, { recursive: true });
|
|
123
|
+
}
|
|
124
|
+
const bundled = scanDirectory(SKILLS_DIR, "bundled");
|
|
125
|
+
const user = scanDirectory(USER_SKILLS_DIR, "user");
|
|
126
|
+
// Merge: user skills override bundled skills with same ID
|
|
127
|
+
const skillMap = new Map();
|
|
128
|
+
for (const s of bundled)
|
|
129
|
+
skillMap.set(s.id, s);
|
|
130
|
+
for (const s of user)
|
|
131
|
+
skillMap.set(s.id, s); // override
|
|
132
|
+
cachedSkills = [...skillMap.values()];
|
|
133
|
+
lastScanAt = Date.now();
|
|
134
|
+
if (cachedSkills.length > 0) {
|
|
135
|
+
const bundledCount = cachedSkills.filter(s => s.source === "bundled").length;
|
|
136
|
+
const userCount = cachedSkills.filter(s => s.source === "user").length;
|
|
137
|
+
console.log(`\ud83c\udfaf Skills loaded: ${cachedSkills.length} (${bundledCount} bundled, ${userCount} user) — ${cachedSkills.map(s => s.name).join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Scan both skills directories and load all SKILL.md files.
|
|
142
|
+
* Sets up fs.watch() for hot-reload on both directories.
|
|
143
|
+
*/
|
|
144
|
+
export function loadSkills() {
|
|
145
|
+
reloadAllSkills();
|
|
146
|
+
// Hot-reload watchers
|
|
147
|
+
try {
|
|
148
|
+
watch(SKILLS_DIR, { recursive: true }, () => {
|
|
149
|
+
console.log("Skills changed (bundled) \u2014 reloading");
|
|
150
|
+
reloadAllSkills();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch { }
|
|
154
|
+
try {
|
|
155
|
+
if (existsSync(USER_SKILLS_DIR)) {
|
|
156
|
+
watch(USER_SKILLS_DIR, { recursive: true }, () => {
|
|
157
|
+
console.log("Skills changed (user) \u2014 reloading");
|
|
158
|
+
reloadAllSkills();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch { }
|
|
163
|
+
return cachedSkills;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Get all loaded skills.
|
|
167
|
+
*/
|
|
168
|
+
export function getSkills() {
|
|
169
|
+
if (cachedSkills.length === 0 || Date.now() - lastScanAt > 300_000) {
|
|
170
|
+
reloadAllSkills();
|
|
171
|
+
}
|
|
172
|
+
return cachedSkills;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Find a skill by its ID.
|
|
176
|
+
*/
|
|
177
|
+
export function getSkillById(id) {
|
|
178
|
+
return cachedSkills.find(s => s.id === id);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Create or update a user skill (self-modification).
|
|
182
|
+
* Writes to USER_SKILLS_DIR and triggers reload.
|
|
183
|
+
*/
|
|
184
|
+
export function createSkill(id, content) {
|
|
185
|
+
const dir = resolve(USER_SKILLS_DIR, id);
|
|
186
|
+
if (!existsSync(dir))
|
|
187
|
+
mkdirSync(dir, { recursive: true });
|
|
188
|
+
writeFileSync(resolve(dir, "SKILL.md"), content);
|
|
189
|
+
// Trigger reload
|
|
190
|
+
reloadAllSkills();
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Find skills that match a user message.
|
|
195
|
+
* Returns matched skills sorted by priority (highest first).
|
|
196
|
+
*/
|
|
197
|
+
export function matchSkills(userMessage, maxResults = 2) {
|
|
198
|
+
const skills = getSkills();
|
|
199
|
+
if (skills.length === 0)
|
|
200
|
+
return [];
|
|
201
|
+
const msgLower = userMessage.toLowerCase();
|
|
202
|
+
const words = msgLower.split(/[\s,.!?;:()[\]{}'"]+/).filter(w => w.length >= 2);
|
|
203
|
+
const wordSet = new Set(words);
|
|
204
|
+
const scored = [];
|
|
205
|
+
for (const skill of skills) {
|
|
206
|
+
let score = 0;
|
|
207
|
+
for (const trigger of skill.triggers) {
|
|
208
|
+
// Exact phrase match (strongest signal)
|
|
209
|
+
if (msgLower.includes(trigger)) {
|
|
210
|
+
score += trigger.split(" ").length * 3; // multi-word triggers score higher
|
|
211
|
+
}
|
|
212
|
+
// Single-word trigger match
|
|
213
|
+
else if (trigger.split(" ").length === 1 && wordSet.has(trigger)) {
|
|
214
|
+
score += 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (score > 0) {
|
|
218
|
+
scored.push({ skill, score: score * skill.priority });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return scored
|
|
222
|
+
.sort((a, b) => b.score - a.score)
|
|
223
|
+
.slice(0, maxResults)
|
|
224
|
+
.map(s => s.skill);
|
|
225
|
+
}
|
|
226
|
+
// ── Skill-Asset Mapping ────────────────────────────────
|
|
227
|
+
/** Default mapping for skills that don't declare assetCategories in frontmatter. */
|
|
228
|
+
const SKILL_ASSET_MAP = {
|
|
229
|
+
"job-apply": ["cover-letters", "cv-templates", "photos"],
|
|
230
|
+
"cv-update": ["cv-templates", "photos"],
|
|
231
|
+
"cover-letter": ["cover-letters", "cv-templates"],
|
|
232
|
+
"formal-letter": ["legal", "cv-templates"],
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Find assets relevant to a skill.
|
|
236
|
+
* Uses frontmatter assetCategories if declared, otherwise falls back to static map.
|
|
237
|
+
*/
|
|
238
|
+
function findAssetsForSkill(skill) {
|
|
239
|
+
const categories = skill.assetCategories || SKILL_ASSET_MAP[skill.id];
|
|
240
|
+
if (!categories || categories.length === 0)
|
|
241
|
+
return [];
|
|
242
|
+
const index = loadAssetIndex();
|
|
243
|
+
return index.assets.filter(a => categories.includes(a.category));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Build a skill injection block for the system prompt.
|
|
247
|
+
* Includes matched skill content + relevant asset references.
|
|
248
|
+
*/
|
|
249
|
+
export function buildSkillContext(userMessage) {
|
|
250
|
+
const matched = matchSkills(userMessage, 1); // inject top 1 skill only
|
|
251
|
+
if (matched.length === 0)
|
|
252
|
+
return "";
|
|
253
|
+
const skill = matched[0];
|
|
254
|
+
let context = `\n\n## 🎯 Active Skill: ${skill.name}\n\n${skill.content}`;
|
|
255
|
+
// Inject relevant assets for this skill
|
|
256
|
+
const assets = findAssetsForSkill(skill);
|
|
257
|
+
if (assets.length > 0) {
|
|
258
|
+
context += `\n\n### 📂 Relevant Assets\n`;
|
|
259
|
+
for (const a of assets) {
|
|
260
|
+
context += `- ${a.category}/${a.filename} → \`${a.absolutePath}\`\n`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return context;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Get a summary of all available skills (for /skills command or status).
|
|
267
|
+
*/
|
|
268
|
+
export function getSkillsSummary() {
|
|
269
|
+
const skills = getSkills();
|
|
270
|
+
if (skills.length === 0)
|
|
271
|
+
return "No skills installed.";
|
|
272
|
+
const byCategory = new Map();
|
|
273
|
+
for (const s of skills) {
|
|
274
|
+
const list = byCategory.get(s.category) || [];
|
|
275
|
+
list.push(s);
|
|
276
|
+
byCategory.set(s.category, list);
|
|
277
|
+
}
|
|
278
|
+
const lines = [`\ud83c\udfaf **Skills (${skills.length}):**\n`];
|
|
279
|
+
for (const [cat, list] of byCategory) {
|
|
280
|
+
lines.push(`**${cat}:**`);
|
|
281
|
+
for (const s of list) {
|
|
282
|
+
const badge = s.source === "user" ? " \ud83d\udc64" : "";
|
|
283
|
+
lines.push(` \u2022 ${s.name}${badge} \u2014 ${s.description || "(no description)"}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return lines.join("\n");
|
|
287
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { AGENTS_FILE } from "../paths.js";
|
|
3
|
+
let cached = "";
|
|
4
|
+
/** Load standing orders from AGENTS.md. Called once at startup and on reload. */
|
|
5
|
+
export function loadStandingOrders() {
|
|
6
|
+
if (!existsSync(AGENTS_FILE))
|
|
7
|
+
return "";
|
|
8
|
+
try {
|
|
9
|
+
cached = readFileSync(AGENTS_FILE, "utf-8");
|
|
10
|
+
return cached;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Get cached standing orders (fast, no disk I/O) */
|
|
17
|
+
export function getStandingOrders() {
|
|
18
|
+
return cached;
|
|
19
|
+
}
|
|
20
|
+
/** Reload from disk (e.g., after editing via tools) */
|
|
21
|
+
export function reloadStandingOrders() {
|
|
22
|
+
try {
|
|
23
|
+
cached = readFileSync(AGENTS_FILE, "utf-8");
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-Agent System — Parallel Task Execution
|
|
3
|
+
*
|
|
4
|
+
* Spawns isolated AI workers that run in the background using the engine registry.
|
|
5
|
+
* Each sub-agent gets its own query call (not a persistent session).
|
|
6
|
+
* Results are stored and can be retrieved by the caller.
|
|
7
|
+
*/
|
|
8
|
+
import os from "os";
|
|
9
|
+
import crypto from "crypto";
|
|
10
|
+
import { config } from "../config.js";
|
|
11
|
+
// ── State ───────────────────────────────────────────────
|
|
12
|
+
const activeAgents = new Map();
|
|
13
|
+
// ── Core execution ──────────────────────────────────────
|
|
14
|
+
async function runSubAgent(id, agentConfig, abort) {
|
|
15
|
+
const startTime = Date.now();
|
|
16
|
+
const entry = activeAgents.get(id);
|
|
17
|
+
try {
|
|
18
|
+
const { getRegistry } = await import("../engine.js");
|
|
19
|
+
const registry = getRegistry();
|
|
20
|
+
const systemPrompt = `You are a sub-agent named "${agentConfig.name}". Complete the following task autonomously and report your results clearly when done. Working directory: ${agentConfig.workingDir || os.homedir()}`;
|
|
21
|
+
let finalText = "";
|
|
22
|
+
let inputTokens = 0;
|
|
23
|
+
let outputTokens = 0;
|
|
24
|
+
for await (const chunk of registry.queryWithFallback({
|
|
25
|
+
prompt: agentConfig.prompt,
|
|
26
|
+
systemPrompt,
|
|
27
|
+
workingDir: agentConfig.workingDir || os.homedir(),
|
|
28
|
+
effort: "high",
|
|
29
|
+
abortSignal: abort.signal,
|
|
30
|
+
})) {
|
|
31
|
+
if (chunk.type === "text")
|
|
32
|
+
finalText = chunk.text || "";
|
|
33
|
+
if (chunk.type === "done") {
|
|
34
|
+
inputTokens = chunk.inputTokens || 0;
|
|
35
|
+
outputTokens = chunk.outputTokens || 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
entry.result = {
|
|
39
|
+
id,
|
|
40
|
+
name: agentConfig.name,
|
|
41
|
+
status: "completed",
|
|
42
|
+
output: finalText,
|
|
43
|
+
tokensUsed: { input: inputTokens, output: outputTokens },
|
|
44
|
+
duration: Date.now() - startTime,
|
|
45
|
+
};
|
|
46
|
+
entry.info.status = "completed";
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const isAbort = err instanceof Error && err.message.includes("abort");
|
|
50
|
+
const isTimeout = abort.signal.aborted;
|
|
51
|
+
const status = isTimeout
|
|
52
|
+
? "timeout"
|
|
53
|
+
: isAbort
|
|
54
|
+
? "cancelled"
|
|
55
|
+
: "error";
|
|
56
|
+
entry.result = {
|
|
57
|
+
id,
|
|
58
|
+
name: agentConfig.name,
|
|
59
|
+
status,
|
|
60
|
+
output: "",
|
|
61
|
+
tokensUsed: { input: 0, output: 0 },
|
|
62
|
+
duration: Date.now() - startTime,
|
|
63
|
+
error: err instanceof Error ? err.message : String(err),
|
|
64
|
+
};
|
|
65
|
+
entry.info.status = status;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ── Public API ──────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* Spawn an isolated sub-agent that runs in the background.
|
|
71
|
+
* Returns the agent ID immediately (does NOT await completion).
|
|
72
|
+
*/
|
|
73
|
+
export function spawnSubAgent(agentConfig) {
|
|
74
|
+
// Check concurrency limit
|
|
75
|
+
const runningCount = [...activeAgents.values()].filter((a) => a.info.status === "running").length;
|
|
76
|
+
if (runningCount >= config.maxSubAgents) {
|
|
77
|
+
return Promise.reject(new Error(`Sub-agent limit reached (${config.maxSubAgents}). Wait for a running agent to finish or cancel one.`));
|
|
78
|
+
}
|
|
79
|
+
const id = crypto.randomUUID();
|
|
80
|
+
const timeout = agentConfig.timeout ?? config.subAgentTimeout;
|
|
81
|
+
const abort = new AbortController();
|
|
82
|
+
// Set up timeout
|
|
83
|
+
const timeoutId = setTimeout(() => abort.abort(), timeout);
|
|
84
|
+
const info = {
|
|
85
|
+
id,
|
|
86
|
+
name: agentConfig.name,
|
|
87
|
+
status: "running",
|
|
88
|
+
startedAt: Date.now(),
|
|
89
|
+
model: agentConfig.model,
|
|
90
|
+
};
|
|
91
|
+
activeAgents.set(id, { info, abort });
|
|
92
|
+
// Run in background — don't await
|
|
93
|
+
runSubAgent(id, agentConfig, abort)
|
|
94
|
+
.finally(() => {
|
|
95
|
+
clearTimeout(timeoutId);
|
|
96
|
+
// Auto-cleanup: remove completed agents after 30 minutes
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
const entry = activeAgents.get(id);
|
|
99
|
+
if (entry && entry.info.status !== "running") {
|
|
100
|
+
activeAgents.delete(id);
|
|
101
|
+
}
|
|
102
|
+
}, 30 * 60 * 1000);
|
|
103
|
+
});
|
|
104
|
+
return Promise.resolve(id);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* List all agents (active + recent completed).
|
|
108
|
+
*/
|
|
109
|
+
export function listSubAgents() {
|
|
110
|
+
return [...activeAgents.values()].map((a) => ({ ...a.info }));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Cancel a running sub-agent by ID.
|
|
114
|
+
* Returns true if the agent was found and aborted.
|
|
115
|
+
*/
|
|
116
|
+
export function cancelSubAgent(id) {
|
|
117
|
+
const entry = activeAgents.get(id);
|
|
118
|
+
if (!entry || entry.info.status !== "running")
|
|
119
|
+
return false;
|
|
120
|
+
entry.abort.abort();
|
|
121
|
+
entry.info.status = "cancelled";
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Get the result of a completed sub-agent.
|
|
126
|
+
* Returns null if not found or still running.
|
|
127
|
+
*/
|
|
128
|
+
export function getSubAgentResult(id) {
|
|
129
|
+
const entry = activeAgents.get(id);
|
|
130
|
+
return entry?.result ?? null;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Cancel all active sub-agents. Used during shutdown.
|
|
134
|
+
*/
|
|
135
|
+
export function cancelAllSubAgents() {
|
|
136
|
+
for (const [id, entry] of activeAgents) {
|
|
137
|
+
if (entry.info.status === "running") {
|
|
138
|
+
entry.abort.abort();
|
|
139
|
+
entry.info.status = "cancelled";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|