automagik-genie
Version:
Self-evolving AI agent orchestration framework with Model Context Protocol support
321 lines (320 loc) β’ 13.5 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ForgeExecutor = void 0;
exports.createForgeExecutor = createForgeExecutor;
// @ts-ignore - forge-client.js is compiled JS without type declarations
const service_config_js_1 = require("./service-config.js");
const forge_client_js_1 = require("../../../src/lib/forge-client.js");
const child_process_1 = require("child_process");
const path_1 = __importDefault(require("path"));
// NOTE: AgentSyncCache interface removed - Forge discovers .genie folders natively
class ForgeExecutor {
constructor(config) {
this.config = config;
this.forge = new forge_client_js_1.ForgeClient(config.forgeBaseUrl, config.forgeToken);
}
/**
* NOTE: syncProfiles and helper methods removed - Forge discovers .genie folders natively
* Removed: syncProfiles, loadSyncCache, saveSyncCache, hashContent, cleanNullValues, mergeProfiles
*/
async createTask(params) {
const { agentName, prompt, executorKey, executorVariant, executionMode, model, name } = params;
const projectId = await this.getOrCreateGenieProject();
// Detect current git branch and use it as base_branch
let baseBranch = 'main'; // Default fallback
try {
baseBranch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', cwd: process.cwd() }).trim();
await this.forge.updateProject(projectId, { default_base_branch: baseBranch });
}
catch (error) {
// If git detection fails, try to get default_base_branch from project
try {
const project = await this.forge.getProject(projectId);
if (project.default_base_branch) {
baseBranch = project.default_base_branch;
}
}
catch {
// Use fallback 'main'
}
}
// Generate task title: use provided name or extract from prompt
const emojiPrefix = this.getAgentEmoji(agentName);
let taskTitle;
if (name) {
// User-provided name
taskTitle = `[${emojiPrefix}] ${name}`;
}
else {
// Smart default: extract keywords from prompt
const smartName = this.generateSmartTaskName(prompt);
taskTitle = `[${emojiPrefix}] ${smartName}`;
}
const requestBody = {
task: {
project_id: projectId,
title: taskTitle,
description: prompt
},
executor_profile_id: this.mapExecutorToProfile(executorKey, executorVariant, model),
base_branch: baseBranch
};
const response = await this.forge.createAndStartTask(requestBody);
// Response contains: { id: taskId, project_id: projectId, attempts: [{ id: attemptId, ... }] }
const taskId = response.id;
// WORKAROUND: Forge API returns empty attempts array, need to fetch separately
let attemptId;
if (response.attempts && response.attempts.length > 0 && response.attempts[0].id) {
attemptId = response.attempts[0].id;
}
else {
// Fetch the task attempt that was just created
const attempts = await this.forge.listTaskAttempts();
const taskAttempt = attempts.find((a) => a.task_id === taskId);
if (taskAttempt) {
attemptId = taskAttempt.id;
}
else {
// Last resort fallback
attemptId = taskId;
}
}
// Build Forge URL
const forgeUrl = `${this.config.forgeBaseUrl}/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}?view=diffs`;
return {
attemptId,
taskId,
projectId,
forgeUrl
};
}
async resumeTask(taskId, followUpPrompt) {
await this.forge.followUpTaskAttempt(taskId, followUpPrompt);
}
async stopTask(taskId) {
await this.forge.stopTaskAttemptExecution(taskId);
}
async getTaskStatus(taskId) {
const attempt = await this.forge.getTaskAttempt(taskId);
return { status: attempt.status || 'unknown' };
}
async fetchTaskLogs(taskId) {
try {
const processes = await this.forge.listExecutionProcesses(taskId);
if (!Array.isArray(processes) || !processes.length)
return null;
const latest = processes[processes.length - 1];
return latest?.output || null;
}
catch {
return null;
}
}
async listTasks() {
const projectId = await this.getOrCreateGenieProject();
// Query tasks and attempts separately
const [tasks, allAttempts] = await Promise.all([
this.forge.listTasks(projectId),
this.forge.listTaskAttempts()
]);
// Filter attempts to current project
const projectAttempts = allAttempts.filter((attempt) => attempt.project_id === projectId);
// Build task lookup map (task_id -> task)
const taskMap = new Map();
for (const task of tasks) {
taskMap.set(task.id, task);
}
// Group attempts by task_id and get latest attempt for each task
const latestAttemptsByTask = new Map();
for (const attempt of projectAttempts) {
const taskId = attempt.task_id;
const existing = latestAttemptsByTask.get(taskId);
// Keep the most recently created attempt for each task
if (!existing || new Date(attempt.created_at) > new Date(existing.created_at)) {
latestAttemptsByTask.set(taskId, attempt);
}
}
// Return attempt summaries (using attempt ID, not task ID)
return Array.from(latestAttemptsByTask.values()).map((attempt) => {
const task = taskMap.get(attempt.task_id);
const displayName = task?.title || attempt.description || attempt.id;
return {
id: attempt.id, // β
Now returns attempt ID (UUID) instead of task ID
name: displayName,
agent: this.extractAgentNameFromTitle(task?.title || ''),
status: attempt.status || 'unknown',
executor: (attempt.executor_profile_id?.executor || '').toLowerCase() || null,
variant: attempt.executor_profile_id?.variant || null,
model: attempt.executor_profile_id?.model || null,
created: attempt.created_at,
updated: attempt.updated_at
};
});
}
/**
* Get or create the Genie project for the current workspace
* @returns Project ID
* @public Exposed for install helpers and other orchestration needs
*/
async getOrCreateGenieProject() {
if (this.config.genieProjectId) {
return this.config.genieProjectId;
}
try {
// Use git root directory (works for both main workspace and worktrees)
// This prevents worktrees from creating duplicate projects
let repoRoot;
try {
repoRoot = (0, child_process_1.execSync)('git rev-parse --show-toplevel', {
encoding: 'utf8',
cwd: process.cwd(),
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
}
catch {
// Fallback to process.cwd() if not in git repo
repoRoot = process.cwd();
}
const projects = await this.forge.listProjects();
const existingProject = projects.find((p) => p.git_repo_path === repoRoot);
if (existingProject) {
this.config.genieProjectId = existingProject.id;
return existingProject.id;
}
// Auto-detect project name from git repo or directory name
let projectName = 'Genie Project';
try {
// Try git remote first
const remoteUrl = (0, child_process_1.execSync)('git config --get remote.origin.url', {
encoding: 'utf8',
cwd: repoRoot,
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
// Extract repo name from URL (e.g., "automagik-genie.git" β "automagik-genie")
const match = remoteUrl.match(/\/([^\/]+?)(\.git)?$/);
if (match && match[1]) {
projectName = match[1].replace(/\.git$/, '');
}
}
catch {
// Fallback to directory name if git fails
try {
const dirName = path_1.default.basename(repoRoot);
if (dirName) {
projectName = dirName;
}
}
catch {
// Keep default "Genie Project"
}
}
const newProject = await this.forge.createProject({
name: projectName,
git_repo_path: repoRoot,
use_existing_repo: true
});
this.config.genieProjectId = newProject.id;
return newProject.id;
}
catch (error) {
// Provide context for downstream error handlers
const message = error.message || String(error);
throw new Error(`Failed to create Forge project: ${message}`);
}
}
mapExecutorToProfile(executorKey, variant, model) {
// Frontmatter now uses Forge format directly (CLAUDE_CODE, OPENCODE, etc.)
// No mapping needed - use executor as-is
const executor = executorKey.trim().toUpperCase();
const resolvedVariant = (variant || 'DEFAULT').toUpperCase();
const profile = {
executor,
variant: resolvedVariant
};
if (model && model.trim().length) {
profile.model = model.trim();
}
return profile;
}
extractAgentNameFromTitle(title) {
// Handle old format "Genie: agent (mode)" and new emoji format "[π§] agent: mode"
const oldMatch = title.match(/^Genie: ([^\(]+)/);
if (oldMatch)
return oldMatch[1].trim();
const emojiMatch = title.match(/^\[[\p{Emoji}]\]\s+([^:]+)/u);
return emojiMatch ? emojiMatch[1].trim() : title;
}
/**
* Generate a smart task name from the prompt by extracting keywords
* Examples:
* "Fix authentication bug in login" β "fix-authentication-bug"
* "Daily health check" β "daily-health-check"
* "Analyze performance metrics" β "analyze-performance-metrics"
*/
generateSmartTaskName(prompt) {
// Remove common filler words
const stopWords = new Set([
'a', 'an', 'the', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by',
'from', 'as', 'is', 'was', 'are', 'were', 'been', 'be', 'have', 'has',
'had', 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'may',
'might', 'can', 'this', 'that', 'these', 'those', 'i', 'you', 'we', 'they'
]);
// Extract first ~6 meaningful words
const words = prompt
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, ' ') // Remove special chars
.split(/\s+/)
.filter(w => w.length > 0 && !stopWords.has(w))
.slice(0, 6); // Take first 6 keywords
if (words.length === 0) {
// Fallback if no keywords found
return 'task-' + Date.now().toString().slice(-6);
}
return words.join('-');
}
getAgentEmoji(agentName) {
// Map agent names to emojis per @.genie/code/skills/emoji-naming-convention.md
const normalized = agentName.toLowerCase().trim();
// Agent emojis
const agentEmojis = {
// Orchestrators & Planning
'genie': 'π§',
'wish': 'π',
'plan': 'π',
'forge': 'βοΈ',
// Execution agents (robots do the work)
'implementor': 'π€',
'tests': 'π€',
'polish': 'π€',
'refactor': 'π€',
// Validation & Review
'review': 'β
',
// Tools & Utilities
'git': 'π§',
'release': 'π',
'commit': 'π¦',
// Analysis & Learning
'learn': 'π',
'debug': 'π',
'analyze': 'π',
'thinkdeep': 'π§ ',
// Communication & Consensus
'consensus': 'π€',
'prompt': 'π',
'roadmap': 'πΊοΈ'
};
return agentEmojis[normalized] || 'π§'; // Default to genie emoji
}
}
exports.ForgeExecutor = ForgeExecutor;
function createForgeExecutor(config = {}) {
const defaultConfig = {
forgeBaseUrl: process.env.FORGE_BASE_URL || (0, service_config_js_1.getForgeConfig)().baseUrl,
forgeToken: process.env.FORGE_TOKEN,
genieProjectId: process.env.GENIE_PROJECT_ID
};
return new ForgeExecutor({ ...defaultConfig, ...config });
}