const { nanoid } = require('nanoid'); const inferenceClient = require('./inferenceClient'); const toolManager = require('./toolManager'); const sandbox = require('./sandbox'); const errorHealer = require('./errorHealer'); const AGENT_NAME = process.env.AGENT_NAME || 'Forge'; const MAX_TOOL_ITERATIONS = 20; const path = require('path'); const fs = require('fs'); const projectRoot = path.resolve(__dirname, '..'); function systemPrompt() { return `${AGENT_NAME}. Project files: public/index.html, public/styles.css, public/app.js, src/*.js, server.js. To edit: content. To read: . Tactical and concise.`; } function makeLogEntry(type, fields = {}) { return { id: nanoid(10), type, ts: new Date().toISOString(), ...fields }; } const VALID_DIRS = ['src/', 'public/', 'scripts/', '']; const EXTENSIONS = ['.js', '.css', '.html', '.json', '.yml', '.yaml', '.sh', '.md']; function findFileName(line) { const parts = line.trim().split(/[ `'"]+/); const candidate = parts[parts.length - 1].replace(/[`'"]/g, '').trim(); if (!candidate || candidate === '') return null; const abs = path.resolve(projectRoot, candidate); if (abs.startsWith(projectRoot) && ( candidate.includes('.') || VALID_DIRS.some(d => candidate.startsWith(d)) )) return candidate; return null; } function processFileActions(content) { const actions = []; let text = content; const readRe = /]+)>/g; let m; while ((m = readRe.exec(content)) !== null) { const filePath = m[1].trim(); const absPath = path.resolve(projectRoot, filePath); if (filePath.includes('..') || !absPath.startsWith(projectRoot)) continue; try { actions.push({ type: 'read', path: filePath, result: fs.readFileSync(absPath, 'utf8') }); } catch { actions.push({ type: 'ignored', message: `could not read ${filePath}` }); } } text = text.replace(readRe, ''); const writeRe = /]+)>([\s\S]*?)<\/write>/g; while ((m = writeRe.exec(content)) !== null) { const filePath = m[1].trim(); const fileContent = m[2]; const absPath = path.resolve(projectRoot, filePath); if (filePath.includes('..') || !absPath.startsWith(projectRoot)) continue; try { fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(absPath, fileContent, 'utf8'); actions.push({ type: 'write', path: filePath }); } catch (err) { actions.push({ type: 'ignored', message: `could not write ${filePath}: ${err.message}` }); } } text = text.replace(writeRe, ''); // Handle as write to public/styles.css const cssRe = /([\s\S]*?)<\/css>/g; while ((m = cssRe.exec(content)) !== null) { const cssContent = m[1]; const absPath = path.resolve(projectRoot, 'public/styles.css'); try { const existing = fs.readFileSync(absPath, 'utf8'); fs.writeFileSync(absPath, existing + '\n' + cssContent, 'utf8'); } catch { fs.writeFileSync(absPath, cssContent, 'utf8'); } actions.push({ type: 'write', path: 'public/styles.css (appended)' }); } text = text.replace(cssRe, ''); // Handle as write to public/app.js const jsRe = /([\s\S]*?)<\/javascript>/g; while ((m = jsRe.exec(content)) !== null) { const jsContent = m[1]; const absPath = path.resolve(projectRoot, 'public/app.js'); try { const existing = fs.readFileSync(absPath, 'utf8'); fs.writeFileSync(absPath, existing + '\n' + jsContent, 'utf8'); } catch { fs.writeFileSync(absPath, jsContent, 'utf8'); } actions.push({ type: 'write', path: 'public/app.js (appended)' }); } text = text.replace(jsRe, '').trim(); return { cleanText: text, actions }; } function createAgent(io) { const llmHistory = []; const displayLog = []; function pushLog(entry) { displayLog.push(entry); io.emit('log:entry', entry); return entry; } function setStatus(state, detail) { io.emit('agent:status', { state, detail: detail || null }); } async function runLoop() { try { for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) { setStatus('thinking'); const apiMessages = [{ role: 'system', content: systemPrompt() }, ...llmHistory]; let message; try { message = await inferenceClient.chat(apiMessages); } catch (err) { setStatus('idle'); pushLog(makeLogEntry('assistant', { content: `I couldn't reach the Inference Port API: ${err.message}` })); return; } const rawContent = message.content || ''; const { cleanText, actions } = processFileActions(rawContent); if (actions.length > 0) { for (const action of actions) { if (action.type === 'read') { llmHistory.push({ role: 'system', content: `Contents of \`${action.path}\`:\n\`\`\`\n${action.result}\n\`\`\`` }); } else if (action.type === 'write') { llmHistory.push({ role: 'system', content: `Written \`${action.path}\`` }); } else if (action.type === 'error') { llmHistory.push({ role: 'system', content: `Error on \`${action.path}\`: ${action.message}` }); } } continue; } setStatus('idle'); const displayContent = rawContent || '...'; pushLog(makeLogEntry('assistant', { content: displayContent })); return; } } catch (err) { setStatus('idle'); pushLog(makeLogEntry('assistant', { content: `Something went wrong: ${err.message}` })); return; } setStatus('idle'); pushLog(makeLogEntry('assistant', { content: "I've gone through a few iterations without wrapping up - pausing here. What would you like next?" })); } const MAX_HISTORY = 20; function trimHistory() { while (llmHistory.length > MAX_HISTORY) { llmHistory.shift(); } } async function handleUserMessage(text) { const trimmed = String(text || '').trim(); if (!trimmed) return; trimHistory(); llmHistory.push({ role: 'user', content: trimmed }); const wantsEdit = /edit|change|add|update|toggle|mode|feature/i.test(trimmed); if (wantsEdit) { const files = ['public/index.html', 'public/styles.css', 'public/app.js']; const contents = files.map(f => { try { return `public/${f}:\n\`\`\`\n${fs.readFileSync(path.join(projectRoot, 'public', f), 'utf8')}\n\`\`\``; } catch { return null; } }).filter(Boolean).join('\n\n'); llmHistory.push({ role: 'system', content: `Here are the current file contents:\n\n${contents}\n\nTo make changes, use EXACTLY: content` }); } pushLog(makeLogEntry('user', { content: trimmed })); await runLoop(); } async function handleApprove(toolId) { const tool = toolManager.approveTool(toolId); pushLog(makeLogEntry('tool_update', { tool, action: 'approved', matchId: toolId })); llmHistory.push({ role: 'system', content: `The user approved the tool "${tool.name}" (id: ${tool.id}). It is now available to call. Continue what you were doing if it's still relevant.`, }); await runLoop(); } async function handleReject(toolId, reason) { const tool = toolManager.rejectTool(toolId, reason); pushLog(makeLogEntry('tool_update', { tool, action: 'rejected', reason, matchId: toolId })); llmHistory.push({ role: 'system', content: `The user rejected the tool "${tool.name}" (id: ${tool.id}).${reason ? ` Reason: ${reason}` : ''} Do not propose the same thing again unchanged - either adjust your approach or ask the user what they'd prefer.`, }); await runLoop(); } function setModel(name) { inferenceClient.setModel(name); io.emit('model:changed', { model: inferenceClient.getModel() }); } function snapshot() { return { log: displayLog, tools: toolManager.listTools(), model: inferenceClient.getModel(), availableModels: inferenceClient.AVAILABLE_MODELS, }; } return { handleUserMessage, handleApprove, handleReject, snapshot, setModel }; } module.exports = { createAgent, AGENT_NAME };