Forge / src /agent.js
ausername-12345
inject current file contents + write instruction when user asks to edit
67c2842
Raw
History Blame Contribute Delete
8.21 kB
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, '');
// Handle <css> as write to public/styles.css
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, '');
// Handle <javascript> as write to public/app.js
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 };