| 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: <write path>content</write>. To read: <read path>. 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 = /<read\s+([^>]+)>/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 = /<write\s+([^>]+)>([\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, ''); |
|
|
| |
| const cssRe = /<css>([\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, ''); |
|
|
| |
| const jsRe = /<javascript>([\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: <write path>content</write>` |
| }); |
| } |
|
|
| 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 }; |
|
|