diff --git a/README.md b/README.md index a66e8c0..dffd677 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,28 @@ # E2B Code Execution Agent -An advanced Mastra template that provides a coding agent capable of planning, writing, executing, and iterating on code in secure, isolated E2B sandboxes with comprehensive file management and development workflow capabilities. +This repository is a production-ready Mastra template for running your own fully personalized coding copilot. It packages the AI agent, long-term memory, custom tooling, and an Electron desktop shell so you can interact with the assistant on any machine without wiring the pieces together yourself. + +## What You Get + +- **AI Coding Agent** – A Mastra-powered assistant that can plan, write, run, and iterate on code in secure E2B sandboxes without touching your local filesystem. +- **Long-Term Memory** – Persistent summaries, semantic recall, and working-memory threads so the agent remembers what you are building. +- **Custom Workflow Commands** – A catalog of reusable shell commands you define (build, test, deploy) that the agent can execute with a single instruction. +- **Desktop App** – An Electron launcher that starts/stops the agent, streams logs, and stores your API keys so you can run the assistant without a terminal. +- **Environment Doctor** – A diagnostic script that verifies Node.js, installed dependencies, and required API keys before the agent launches. + +These pieces are wired together out of the box—you only need to add your API keys and optional personalization details. ## Overview This template demonstrates how to build an AI coding assistant that can work with real development environments. The agent can create sandboxes, manage files and directories, execute code in multiple languages, and monitor development workflows - all within secure, isolated E2B environments. +### Typical Use Cases + +- Spin up a fresh sandbox to prototype features without polluting local projects. +- Ask the agent to scaffold applications, write tests, or refactor code across files. +- Run your custom automation (e.g., `pnpm lint`, deployment scripts) from the agent via the `runCustomCommand` tool. +- Rely on the persistent memory so the agent tracks progress across sessions and remembers your preferences. + ## Features - **Secure Code Execution**: Run Python, JavaScript, and TypeScript code in isolated E2B sandboxes @@ -14,6 +31,8 @@ This template demonstrates how to build an AI coding assistant that can work wit - **Live Development Monitoring**: Watch directory changes and monitor development workflows - **Command Execution**: Run shell commands, install packages, and manage dependencies - **Memory System**: Persistent conversation memory with semantic recall and working memory +- **Personalization Layer**: Configure agent identity, preferences, and a catalog of repeatable workflows +- **Desktop Launcher**: Electron-based desktop application for starting/stopping the agent without the terminal - **Development Workflows**: Professional development patterns with build automation ## Prerequisites @@ -22,14 +41,17 @@ This template demonstrates how to build an AI coding assistant that can work wit - E2B API key (sign up at [e2b.dev](https://e2b.dev)) - OpenAI API key -## Setup +## Run It Locally + +Follow these steps the first time you set up the project on a new machine. 1. **Clone and install dependencies:** ```bash git clone https://github.com/mastra-ai/template-coding-agent.git cd template-coding-agent - pnpm install + npm install + # or: pnpm install ``` 2. **Set up environment variables:** @@ -44,12 +66,31 @@ This template demonstrates how to build an AI coding assistant that can work wit OPENAI_API_KEY="your-openai-api-key-here" ``` -3. **Start the development server:** +3. **Check your environment:** ```bash - pnpm run dev + npm run doctor ``` + The doctor script highlights missing dependencies or API keys before you launch the agent. + +4. **Personalize the agent (optional but recommended):** + + Open `src/mastra/agents/personal-config.ts` and update the identity, mission, preferences, memory strategy, and custom command catalog so the assistant behaves the way you want. + +5. **Start the agent (pick one):** + + - **CLI:** `npm run dev` for hot-reload development or `npm run start` for production mode. + - **Desktop App:** `npm run desktop` to open the Electron launcher. Provide your API keys once and start/stop the agent with buttons. Output streaming and status updates are surfaced in the UI. + +6. **Package a desktop build (optional):** + + ```bash + npm run desktop:package + ``` + + This produces a platform-specific build in the `dist/` directory using `electron-builder`. + ## Architecture ### Core Components @@ -63,6 +104,7 @@ The main agent with comprehensive development capabilities: - **File Operations**: Complete CRUD operations for files and directories - **Development Monitoring**: Watches for changes and monitors workflows - **Memory Integration**: Maintains conversation context and project history +- **Personal Profile**: Dynamically incorporates preferences defined in `personal-config.ts` #### **E2B Tools** (`src/mastra/tools/e2b.ts`) @@ -98,6 +140,7 @@ Complete toolkit for sandbox interaction: **Development Workflow:** - `runCommand` - Execute shell commands, build scripts, package management +- `runCustomCommand` - Trigger user-defined workflows stored in `personal-config.ts` ### Memory System @@ -117,22 +160,56 @@ E2B_API_KEY=your_e2b_api_key_here OPENAI_API_KEY=your_openai_api_key_here ``` -### Customization +### Personalization & Custom Commands -You can customize the agent behavior by modifying the instructions in `src/mastra/agents/coding-agent.ts`: +Fine-tune the assistant without editing core agent code by updating `src/mastra/agents/personal-config.ts`. ```typescript -export const codingAgent = new Agent({ - name: 'Coding Agent', - instructions: ` - // Customize agent instructions here - // Focus on specific languages, frameworks, or development patterns - `, - model: openai('gpt-4.1'), - // ... other configuration -}); +export const personalAgentConfig = { + name: 'My Personal Coding Companion', + tagline: 'A persistent AI partner tailored to your workflows.', + mission: 'Describe the long-term goals for the agent.', + preferences: { + languages: ['TypeScript', 'Python'], + technologies: ['Node.js', 'React'], + developmentStyle: ['Prefer TDD', 'Document architectural decisions'], + }, + memoryStrategy: { + longTermSummary: 'What should be remembered across sessions.', + workingMemoryGuidelines: ['Snapshot progress at the end of each session.'], + }, + customCommands: [ + { + id: 'start-dev', + label: 'Start Dev Server', + description: 'Launch the development server inside the sandbox.', + command: 'pnpm run dev', + }, + ], +}; ``` +Key capabilities provided by the configuration file: + +- **Agent identity** — control tone, mission, and project focus. +- **Preference sets** — inform the agent about favored languages, tools, and delivery style. +- **Memory strategy** — guide how long-term and short-term context should be stored. +- **Custom commands** — register reusable shell commands that can be triggered with the `runCustomCommand` tool using a `commandId`. + +Feel free to extend the configuration with additional custom commands or memory options; the agent automatically reads the updated details on startup. + +### Desktop Launcher + +The Electron desktop app wraps the same Mastra agent so you can run it without the terminal: + +1. Install dependencies and run `npm run doctor` to verify prerequisites. +2. Launch the desktop shell with `npm run desktop`. +3. Enter your API keys once—the app stores them locally using the operating system's application storage. +4. Start or stop the agent with a single click. Live logs and status updates stream into the UI so you can monitor the session in real time. +5. Package a distributable build with `npm run desktop:package` when you are ready to share it across machines. + +The launcher proxies your chosen mode (`mastra dev` for development or `mastra start` for production) and forwards environment variables so the agent runs exactly as it does via the CLI. + ## Common Issues ### "E2B_API_KEY is not set" @@ -171,8 +248,10 @@ export const codingAgent = new Agent({ src/mastra/ agents/ coding-agent.ts # Main coding agent with development capabilities + personal-config.ts # User-editable persona, memory, and command catalog tools/ e2b.ts # Complete E2B sandbox interaction toolkit + custom-commands.ts # Tool for invoking reusable workflows index.ts # Mastra configuration with storage and logging ``` diff --git a/desktop/electron-main.mjs b/desktop/electron-main.mjs new file mode 100644 index 0000000..1624584 --- /dev/null +++ b/desktop/electron-main.mjs @@ -0,0 +1,180 @@ +import { app, BrowserWindow, ipcMain } from 'electron'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import process from 'node:process'; +import { existsSync } from 'node:fs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); + +let mainWindow; +let agentProcess = null; +let currentMode = null; + +const sendStatus = status => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('agent:status', status); + } +}; + +const sendOutput = payload => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('agent:output', payload); + } +}; + +const resolveRendererPath = () => { + const rendererUrl = new URL('./renderer/index.html', import.meta.url); + return fileURLToPath(rendererUrl); +}; + +const resolvePreloadPath = () => { + const preloadUrl = new URL('./preload.mjs', import.meta.url); + return fileURLToPath(preloadUrl); +}; + +const createWindow = () => { + mainWindow = new BrowserWindow({ + width: 1100, + height: 720, + title: 'Coding Agent Desktop', + webPreferences: { + preload: resolvePreloadPath(), + }, + }); + + mainWindow.loadFile(resolveRendererPath()); + + mainWindow.on('closed', () => { + mainWindow = null; + }); +}; + +const startAgentProcess = (mode, extraEnv) => { + if (agentProcess) { + return { ok: false, message: 'The agent is already running.' }; + } + + const mastraBinary = path.join( + projectRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'mastra.cmd' : 'mastra', + ); + + if (!existsSync(mastraBinary)) { + return { + ok: false, + message: + 'Could not locate the Mastra CLI. Install dependencies first with "npm install" or "pnpm install".', + }; + } + + const commandArgs = mode === 'dev' ? ['dev'] : ['start']; + + const spawnOptions = { + cwd: projectRoot, + env: { ...process.env, ...extraEnv }, + shell: process.platform === 'win32', + }; + + try { + agentProcess = spawn(mastraBinary, commandArgs, spawnOptions); + } catch (error) { + return { ok: false, message: `Failed to launch Mastra: ${error.message}` }; + } + + currentMode = mode; + sendStatus({ state: 'running', mode }); + + const forward = (type, data) => { + sendOutput({ type, data: data.toString(), timestamp: Date.now() }); + }; + + agentProcess.stdout?.on('data', chunk => forward('stdout', chunk)); + agentProcess.stderr?.on('data', chunk => forward('stderr', chunk)); + + agentProcess.on('exit', code => { + sendOutput({ type: 'event', data: `Agent exited with code ${code ?? 'unknown'}`, timestamp: Date.now() }); + currentMode = null; + sendStatus({ state: 'stopped' }); + agentProcess = null; + }); + + agentProcess.on('error', error => { + sendOutput({ type: 'error', data: error.message, timestamp: Date.now() }); + currentMode = null; + sendStatus({ state: 'stopped' }); + agentProcess = null; + }); + + return { ok: true }; +}; + +const stopAgentProcess = () => { + if (!agentProcess) { + return { ok: false, message: 'The agent is not running.' }; + } + + sendStatus({ state: 'stopping', mode: currentMode }); + + if (process.platform === 'win32') { + agentProcess.kill(); + } else { + agentProcess.kill('SIGINT'); + setTimeout(() => agentProcess?.kill('SIGKILL'), 4000); + } + + return { ok: true }; +}; + +app.whenReady().then(() => { + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('before-quit', () => { + if (agentProcess) { + stopAgentProcess(); + } +}); + +ipcMain.handle('agent:start', async (_event, payload) => { + const { mode, env } = payload ?? {}; + if (!mode || (mode !== 'dev' && mode !== 'start')) { + return { ok: false, message: 'Invalid mode. Choose either "dev" or "start".' }; + } + + const result = startAgentProcess(mode, env ?? {}); + if (!result.ok) { + sendStatus({ state: 'stopped' }); + } + return result; +}); + +ipcMain.handle('agent:stop', async () => { + const result = stopAgentProcess(); + if (!result.ok) { + return result; + } + return { ok: true }; +}); + +ipcMain.handle('agent:status', async () => { + if (!agentProcess) { + return { state: 'stopped' }; + } + return { state: 'running', mode: currentMode }; +}); diff --git a/desktop/preload.mjs b/desktop/preload.mjs new file mode 100644 index 0000000..8dd274b --- /dev/null +++ b/desktop/preload.mjs @@ -0,0 +1,17 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('codingAgentDesktop', { + startAgent: options => ipcRenderer.invoke('agent:start', options), + stopAgent: () => ipcRenderer.invoke('agent:stop'), + getStatus: () => ipcRenderer.invoke('agent:status'), + onOutput: callback => { + const listener = (_event, payload) => callback(payload); + ipcRenderer.on('agent:output', listener); + return () => ipcRenderer.removeListener('agent:output', listener); + }, + onStatus: callback => { + const listener = (_event, payload) => callback(payload); + ipcRenderer.on('agent:status', listener); + return () => ipcRenderer.removeListener('agent:status', listener); + }, +}); diff --git a/desktop/renderer/index.html b/desktop/renderer/index.html new file mode 100644 index 0000000..c0277a0 --- /dev/null +++ b/desktop/renderer/index.html @@ -0,0 +1,68 @@ + + + + + + Coding Agent Desktop + + + +
+

Personal Coding Agent

+

Launch, monitor, and manage your personalized Mastra coding agent without the terminal.

+
+ +
+
+

Environment

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+

Status: stopped

+
+ +
+

Agent Output

+
+
+
+ + + + + + diff --git a/desktop/renderer/renderer.js b/desktop/renderer/renderer.js new file mode 100644 index 0000000..42110aa --- /dev/null +++ b/desktop/renderer/renderer.js @@ -0,0 +1,122 @@ +const outputLog = document.getElementById('output-log'); +const statusText = document.getElementById('status-text'); +const startButton = document.getElementById('start-button'); +const stopButton = document.getElementById('stop-button'); +const form = document.getElementById('config-form'); +const modeSelect = document.getElementById('mode'); +const e2bInput = document.getElementById('e2b-key'); +const openaiInput = document.getElementById('openai-key'); + +const STORAGE_KEY = 'coding-agent-desktop-config'; + +const persistConfig = config => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); +}; + +const loadConfig = () => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch (error) { + console.warn('Failed to load stored config', error); + return null; + } +}; + +const appendLogEntry = ({ type, data, timestamp }) => { + const entry = document.createElement('pre'); + entry.className = `log-entry ${type}`; + const time = new Date(timestamp); + const prefix = `[${time.toLocaleTimeString()}]`; + entry.textContent = `${prefix} ${data}`; + outputLog.append(entry); + outputLog.scrollTop = outputLog.scrollHeight; +}; + +const setStatus = status => { + statusText.textContent = `Status: ${status}`; +}; + +const disableFormWhileRunning = isRunning => { + startButton.disabled = isRunning; + modeSelect.disabled = isRunning; + e2bInput.disabled = isRunning; + openaiInput.disabled = isRunning; + stopButton.disabled = !isRunning; +}; + +const hydrateForm = () => { + const config = loadConfig(); + if (!config) return; + if (config.mode) { + modeSelect.value = config.mode; + } + if (config.e2bKey) { + e2bInput.value = config.e2bKey; + } + if (config.openaiKey) { + openaiInput.value = config.openaiKey; + } +}; + +hydrateForm(); + +disableFormWhileRunning(false); + +window.codingAgentDesktop.onOutput(payload => { + appendLogEntry(payload); +}); + +window.codingAgentDesktop.onStatus(payload => { + const { state, mode } = payload ?? {}; + if (state === 'running') { + setStatus(`running (${mode ?? 'unknown'})`); + disableFormWhileRunning(true); + } else if (state === 'stopping') { + setStatus('stopping…'); + } else { + setStatus('stopped'); + disableFormWhileRunning(false); + } +}); + +form.addEventListener('submit', async event => { + event.preventDefault(); + + const mode = modeSelect.value; + const e2bKey = e2bInput.value.trim(); + const openaiKey = openaiInput.value.trim(); + + persistConfig({ mode, e2bKey, openaiKey }); + + disableFormWhileRunning(true); + setStatus('starting…'); + + const env = {}; + if (e2bKey) env.E2B_API_KEY = e2bKey; + if (openaiKey) env.OPENAI_API_KEY = openaiKey; + + const result = await window.codingAgentDesktop.startAgent({ mode, env }); + if (!result?.ok) { + disableFormWhileRunning(false); + setStatus('stopped'); + appendLogEntry({ type: 'error', data: result?.message ?? 'Unable to start agent', timestamp: Date.now() }); + } +}); + +stopButton.addEventListener('click', async () => { + stopButton.disabled = true; + const result = await window.codingAgentDesktop.stopAgent(); + if (!result?.ok) { + appendLogEntry({ type: 'error', data: result?.message ?? 'Agent was not running', timestamp: Date.now() }); + } +}); + +window.codingAgentDesktop.getStatus().then(status => { + const { state, mode } = status ?? {}; + if (state === 'running') { + setStatus(`running (${mode ?? 'unknown'})`); + disableFormWhileRunning(true); + } +}); diff --git a/desktop/renderer/styles.css b/desktop/renderer/styles.css new file mode 100644 index 0000000..5efa5f7 --- /dev/null +++ b/desktop/renderer/styles.css @@ -0,0 +1,195 @@ +:root { + color-scheme: dark; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background-color: #111827; + color: #f9fafb; +} + +body { + margin: 0; + display: grid; + grid-template-columns: 2fr 1fr; + grid-template-areas: + 'header header' + 'main aside'; + min-height: 100vh; +} + +header { + grid-area: header; + padding: 24px 32px 16px; + background: linear-gradient(135deg, #1f2937 0%, #111827 100%); + border-bottom: 1px solid #1f2937; +} + +header h1 { + margin: 0 0 8px; + font-size: 28px; +} + +header .tagline { + margin: 0; + color: #9ca3af; +} + +main { + grid-area: main; + padding: 24px 32px; + display: grid; + gap: 24px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +aside { + grid-area: aside; + padding: 24px 32px; + background: #0f172a; + border-left: 1px solid #1e293b; +} + +h2 { + margin-top: 0; + font-size: 20px; +} + +.configuration, +.output { + background: #1f2937; + border: 1px solid #374151; + border-radius: 16px; + padding: 24px; + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.45); +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +label { + font-weight: 600; + color: #d1d5db; +} + +input, +select { + background: #111827; + border: 1px solid #374151; + border-radius: 10px; + padding: 10px 12px; + color: #f9fafb; +} + +input:focus, +select:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +.actions { + display: flex; + gap: 12px; + margin-top: 8px; +} + +button { + appearance: none; + border: none; + border-radius: 10px; + padding: 10px 18px; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +button#start-button { + background: linear-gradient(135deg, #2563eb, #3b82f6); + color: #f9fafb; + box-shadow: 0 10px 30px rgba(59, 130, 246, 0.35); +} + +button#start-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 14px 40px rgba(59, 130, 246, 0.45); +} + +button.secondary { + background: #374151; + color: #e5e7eb; +} + +.status { + margin-top: 12px; + color: #9ca3af; +} + +.log { + background: #0f172a; + border-radius: 12px; + padding: 16px; + height: 360px; + overflow-y: auto; + font-family: 'Fira Code', 'SFMono-Regular', ui-monospace, monospace; + font-size: 13px; + border: 1px solid #1e293b; +} + +.log-entry { + margin: 0 0 8px; + white-space: pre-wrap; + word-break: break-word; +} + +.log-entry.stdout { + color: #a5b4fc; +} + +.log-entry.stderr { + color: #fca5a5; +} + +.log-entry.event { + color: #34d399; +} + +.log-entry.error { + color: #f87171; +} + +.tips { + color: #e5e7eb; +} + +.tips ul { + padding-left: 18px; + margin: 8px 0 0; + color: #cbd5f5; +} + +code { + background: rgba(148, 163, 184, 0.1); + padding: 2px 6px; + border-radius: 6px; +} + +@media (max-width: 1080px) { + body { + grid-template-columns: 1fr; + grid-template-areas: + 'header' + 'main' + 'aside'; + } + + aside { + border-left: none; + border-top: 1px solid #1e293b; + } +} diff --git a/package.json b/package.json index 542f309..c1309e4 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,16 @@ "name": "coding-agent", "version": "0.2.0", "description": "Advanced Mastra AI coding agent with secure E2B sandbox execution, comprehensive file management, and multi-language support for Python, JavaScript, and TypeScript development workflows", - "main": "index.js", + "main": "desktop/electron-main.mjs", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "dev": "mastra dev", "build": "mastra build", - "start": "mastra start" + "start": "mastra start", + "lint": "tsc --noEmit", + "doctor": "node scripts/doctor.mjs", + "desktop": "electron .", + "desktop:package": "electron-builder --dir" }, "keywords": [], "author": "", @@ -28,8 +32,25 @@ "zod": "^3.25.76" }, "devDependencies": { + "@types/electron": "^1.6.0", "@types/node": "^24.1.0", + "electron": "^31.3.1", + "electron-builder": "^24.13.3", "mastra": "latest", "typescript": "^5.8.3" + }, + "build": { + "appId": "com.mastra.codingagent.desktop", + "productName": "Coding Agent Desktop", + "files": [ + "dist/**/*", + "desktop/**/*", + "src/**/*", + "package.json", + "tsconfig.json" + ], + "extraMetadata": { + "main": "desktop/electron-main.mjs" + } } } diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100644 index 0000000..0711c3f --- /dev/null +++ b/scripts/doctor.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const projectRoot = path.resolve(process.cwd()); +const issues = []; +const warnings = []; + +const requiredNodeMajor = 20; +const currentNodeMajor = Number.parseInt(process.versions.node.split('.')[0] ?? '0', 10); +if (Number.isFinite(currentNodeMajor) && currentNodeMajor < requiredNodeMajor) { + issues.push( + `Node.js ${requiredNodeMajor}.x or newer is required. Detected ${process.versions.node}. Please upgrade Node.js.`, + ); +} + +const envPath = path.join(projectRoot, '.env'); +if (!existsSync(envPath)) { + warnings.push('No .env file found. Copy .env.example to .env and supply your API keys.'); +} else { + const envContents = readFileSync(envPath, 'utf8'); + if (!envContents.includes('E2B_API_KEY')) { + warnings.push('E2B_API_KEY is missing from .env. The sandbox integration will not work without it.'); + } + if (!envContents.includes('OPENAI_API_KEY')) { + warnings.push('OPENAI_API_KEY is missing from .env. The agent will be unable to call the OpenAI API.'); + } +} + +const nodeModulesPath = path.join(projectRoot, 'node_modules'); +if (!existsSync(nodeModulesPath)) { + issues.push('Dependencies are not installed. Run "npm install" or "pnpm install" first.'); +} + +const checkBinary = binary => { + try { + execSync(`${binary} --version`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +if (!checkBinary('mastra')) { + const mastraBin = path.join(projectRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'mastra.cmd' : 'mastra'); + if (!existsSync(mastraBin)) { + warnings.push('The Mastra CLI is unavailable on PATH. Use npm scripts (e.g., "npm run dev") to start the agent.'); + } +} + +const summary = []; +if (issues.length === 0) { + summary.push('Environment check passed with no blocking issues.'); +} else { + summary.push('Environment check detected blocking issues:'); + for (const issue of issues) { + summary.push(` • ${issue}`); + } +} + +if (warnings.length) { + summary.push('Warnings:'); + for (const warning of warnings) { + summary.push(` • ${warning}`); + } +} + +console.log(summary.join('\n')); + +if (issues.length > 0) { + process.exitCode = 1; +} diff --git a/src/mastra/agents/coding-agent.ts b/src/mastra/agents/coding-agent.ts index e5a32bf..bda2064 100644 --- a/src/mastra/agents/coding-agent.ts +++ b/src/mastra/agents/coding-agent.ts @@ -18,10 +18,10 @@ import { writeFiles, } from '../tools/e2b'; import { fastembed } from '@mastra/fastembed'; +import { runCustomCommand } from '../tools/custom-commands'; +import { personalAgentConfig } from './personal-config'; -export const codingAgent = new Agent({ - name: 'Coding Agent', - instructions: ` +const baseInstructions = ` # Mastra Coding Agent for E2B Sandboxes You are an advanced coding agent that plans, writes, executes, and iterates on code in secure, isolated E2B sandboxes with comprehensive file management, live monitoring, and development workflow capabilities. @@ -186,7 +186,130 @@ For sophisticated projects, leverage: - **Deployment preparation** and distribution packaging Remember: You are not just a code executor, but a complete development environment that can handle sophisticated, multi-file projects with professional development workflows and comprehensive monitoring capabilities. -`, +`; + +const buildPersonalizedInstructions = (): string => { + const lines: string[] = []; + + lines.push('## Personalized Configuration'); + if (personalAgentConfig.name || personalAgentConfig.tagline) { + const identityParts = [personalAgentConfig.name, personalAgentConfig.tagline].filter(Boolean).join(' — '); + if (identityParts) { + lines.push(`- **Identity**: ${identityParts}`); + } + } + if (personalAgentConfig.mission) { + lines.push(`- **Mission**: ${personalAgentConfig.mission}`); + } + + const { preferences } = personalAgentConfig; + if ( + preferences?.languages?.length || + preferences?.technologies?.length || + preferences?.developmentStyle?.length || + preferences?.goals?.length + ) { + lines.push(''); + lines.push('### Preference Highlights'); + if (preferences.languages?.length) { + lines.push(`- Preferred languages: ${preferences.languages.join(', ')}`); + } + if (preferences.technologies?.length) { + lines.push(`- Preferred technologies: ${preferences.technologies.join(', ')}`); + } + if (preferences.developmentStyle?.length) { + lines.push('- Development style guidelines:'); + lines.push(...preferences.developmentStyle.map(style => ` - ${style}`)); + } + if (preferences.goals?.length) { + lines.push('- Long-term goals:'); + lines.push(...preferences.goals.map(goal => ` - ${goal}`)); + } + } + + const { memoryStrategy } = personalAgentConfig; + if ( + memoryStrategy?.longTermSummary || + memoryStrategy?.longTermFocusAreas?.length || + memoryStrategy?.shortTermSummary || + memoryStrategy?.workingMemoryGuidelines?.length + ) { + lines.push(''); + lines.push('### Memory Strategy'); + if (memoryStrategy.longTermSummary) { + lines.push(`- Long-term memory: ${memoryStrategy.longTermSummary}`); + } + if (memoryStrategy.longTermFocusAreas?.length) { + lines.push('- Focus areas for long-term storage:'); + lines.push(...memoryStrategy.longTermFocusAreas.map(area => ` - ${area}`)); + } + if (memoryStrategy.shortTermSummary) { + lines.push(`- Short-term memory: ${memoryStrategy.shortTermSummary}`); + } + if (memoryStrategy.workingMemoryGuidelines?.length) { + lines.push('- Working memory best practices:'); + lines.push(...memoryStrategy.workingMemoryGuidelines.map(guideline => ` - ${guideline}`)); + } + } + + if (personalAgentConfig.additionalInstructions?.length) { + lines.push(''); + lines.push('### Additional Personal Instructions'); + lines.push(...personalAgentConfig.additionalInstructions.map(instruction => `- ${instruction}`)); + } + + if (personalAgentConfig.customCommands?.length) { + lines.push(''); + lines.push('### Custom Command Catalog'); + lines.push('- Use `runCustomCommand` with the commandId to invoke these workflows.'); + for (const command of personalAgentConfig.customCommands) { + const labelSuffix = command.label ? ` (${command.label})` : ''; + lines.push(`- **${command.id}**${labelSuffix}: ${command.description}`); + } + } + + return lines.join('\n'); +}; + +const personalizedInstructions = buildPersonalizedInstructions(); +const finalInstructions = `${baseInstructions}${personalizedInstructions ? `\n\n${personalizedInstructions}` : ''}`; + +const mergeMemoryOptions = ( + defaults: Record, + overrides?: Record, +): Record => { + if (!overrides) { + return { ...defaults }; + } + + const merged: Record = { ...defaults, ...overrides }; + + const defaultThreads = (defaults.threads ?? {}) as Record; + const overrideThreads = (overrides.threads ?? {}) as Record; + if (Object.keys(defaultThreads).length || Object.keys(overrideThreads).length) { + merged.threads = { ...defaultThreads, ...overrideThreads }; + } + + const defaultWorkingMemory = (defaults.workingMemory ?? {}) as Record; + const overrideWorkingMemory = (overrides.workingMemory ?? {}) as Record; + if (Object.keys(defaultWorkingMemory).length || Object.keys(overrideWorkingMemory).length) { + merged.workingMemory = { ...defaultWorkingMemory, ...overrideWorkingMemory }; + } + + return merged; +}; + +const defaultMemoryOptions: Record = { + threads: { generateTitle: true }, + semanticRecall: true, + workingMemory: { enabled: true }, +}; + +const memoryOptions = mergeMemoryOptions(defaultMemoryOptions, personalAgentConfig.memoryOptions); + +export const codingAgent = new Agent({ + name: personalAgentConfig.name || 'Coding Agent', + instructions: finalInstructions, model: openai('gpt-4.1'), tools: { createSandbox, @@ -202,14 +325,11 @@ Remember: You are not just a code executor, but a complete development environme getFileSize, watchDirectory, runCommand, + runCustomCommand, }, memory: new Memory({ storage: new LibSQLStore({ url: 'file:../../mastra.db' }), - options: { - threads: { generateTitle: true }, - semanticRecall: true, - workingMemory: { enabled: true }, - }, + options: memoryOptions, embedder: fastembed, vector: new LibSQLVector({ connectionUrl: 'file:../../mastra.db' }), }), diff --git a/src/mastra/agents/personal-config.ts b/src/mastra/agents/personal-config.ts new file mode 100644 index 0000000..fcaf785 --- /dev/null +++ b/src/mastra/agents/personal-config.ts @@ -0,0 +1,144 @@ +export interface CustomCommandConfig { + /** Unique identifier used when calling the command via the runCustomCommand tool. */ + id: string; + /** + * Human-readable label for the command. + * This is used for documentation and may be surfaced in the agent instructions. + */ + label: string; + /** Description that explains what the command does. */ + description: string; + /** The shell command that will be executed in the sandbox. */ + command: string; + /** Optional working directory used when executing the command. */ + workingDirectory?: string; + /** + * Timeout in milliseconds. When omitted the tool defaults to the sandbox + * standard timeout (30 seconds). + */ + timeoutMs?: number; +} + +export interface PersonalAgentPreferences { + /** Preferred programming languages. */ + languages?: string[]; + /** Frameworks, libraries, or tools the agent should prioritize. */ + technologies?: string[]; + /** General development style tips. */ + developmentStyle?: string[]; + /** Additional high level instructions. */ + goals?: string[]; +} + +export interface MemoryStrategyConfig { + /** Description of how long-term memory should be utilized. */ + longTermSummary?: string; + /** Topics or categories that should be prioritized when storing long-term memory. */ + longTermFocusAreas?: string[]; + /** Description for how the agent should handle short-term working memory. */ + shortTermSummary?: string; + /** Guidance for when to snapshot or summarize context. */ + workingMemoryGuidelines?: string[]; +} + +export interface PersonalAgentConfig { + /** Friendly name for the agent. */ + name: string; + /** Short tagline or mission statement. */ + tagline: string; + /** High-level description of the agent's purpose for the user. */ + mission: string; + /** Collection of preference values to customize the agent behaviour. */ + preferences?: PersonalAgentPreferences; + /** Memory instructions used to guide how state should be handled. */ + memoryStrategy?: MemoryStrategyConfig; + /** + * Extra instructions appended to the agent prompt. + * Useful for keeping a personal tone of voice or project-specific rules. + */ + additionalInstructions?: string[]; + /** + * Optional overrides for the Mastra Memory options. + * This object is shallowly merged with the defaults in coding-agent.ts so that + * advanced users can tweak the behaviour without modifying agent code. + */ + memoryOptions?: Record; + /** A catalogue of custom commands available via the runCustomCommand tool. */ + customCommands?: CustomCommandConfig[]; +} + +export const personalAgentConfig: PersonalAgentConfig = { + name: 'My Personal Coding Companion', + tagline: 'A persistent AI partner tailored to your workflows.', + mission: + 'Deliver thoughtful coding assistance, maintain long-term knowledge about ongoing projects, and execute repeatable workflows efficiently.', + preferences: { + languages: ['TypeScript', 'Python'], + technologies: ['Node.js', 'React', 'Docker'], + developmentStyle: [ + 'Prioritize clean, well-documented code with strong typing.', + 'Value incremental delivery with frequent validation.', + 'Prefer automating repetitive tasks when possible.', + ], + goals: [ + 'Continuously surface context from prior sessions when relevant.', + 'Document significant project decisions for future reference.', + 'Seek clarification when requirements feel ambiguous.', + ], + }, + memoryStrategy: { + longTermSummary: + 'Leverage the LibSQL-backed semantic memory to retain durable knowledge: project architecture, key decisions, recurring preferences, and workflows.', + longTermFocusAreas: [ + 'Project-specific coding conventions and style guides.', + 'Preferred tooling, scripts, and infrastructure setups.', + 'Historical troubleshooting steps and successful resolutions.', + ], + shortTermSummary: + 'Maintain a detailed working memory to handle active tasks, summarizing new findings before they exit the short-term context window.', + workingMemoryGuidelines: [ + 'Summarize or snapshot progress after completing major milestones.', + 'Capture rationale for architectural or tooling choices.', + 'Highlight outstanding questions or follow-up actions.', + ], + }, + additionalInstructions: [ + 'Always provide concise reasoning before running destructive commands.', + 'When appropriate, create lightweight documentation updates alongside code changes.', + 'Encourage a teaching tone when explaining complex concepts.', + ], + memoryOptions: { + workingMemory: { + enabled: true, + maxTokens: 2048, + }, + semanticRecall: true, + threads: { + generateTitle: true, + tagWithDate: true, + }, + }, + customCommands: [ + { + id: 'start-dev', + label: 'Start Development Server', + description: 'Launch the dev server using pnpm.', + command: 'pnpm run dev', + timeoutMs: 120000, + }, + { + id: 'lint-project', + label: 'Run Project Linting', + description: 'Execute the linting workflow for the current repository.', + command: 'pnpm run lint', + }, + { + id: 'project-status', + label: 'Git Project Status', + description: 'Check repository status and show a short summary of recent activity.', + command: 'git status --short && git log -3 --oneline', + workingDirectory: '.', + timeoutMs: 30000, + }, + ], +}; diff --git a/src/mastra/tools/custom-commands.ts b/src/mastra/tools/custom-commands.ts new file mode 100644 index 0000000..559c609 --- /dev/null +++ b/src/mastra/tools/custom-commands.ts @@ -0,0 +1,77 @@ +import { createTool } from '@mastra/core/tools'; +import z from 'zod'; +import { Sandbox } from '@e2b/code-interpreter'; +import { personalAgentConfig } from '../agents/personal-config'; + +type RunCustomCommandContext = { + sandboxId: string; + commandId: string; + args?: string[]; + workingDirectory?: string; + timeoutMs?: number; +}; + +const customCommands = personalAgentConfig.customCommands ?? []; +const commandsById = new Map(customCommands.map(command => [command.id, command])); + +export const runCustomCommand = createTool({ + id: 'runCustomCommand', + description: + 'Execute a user-defined custom command in the connected E2B sandbox. Commands are configured in src/mastra/agents/personal-config.ts.', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to run the custom command in'), + commandId: z.string().describe('Unique identifier for the command defined in personal-config.ts'), + args: z.array(z.string()).optional().describe('Additional arguments appended to the command string'), + workingDirectory: z.string().optional().describe('Override the configured working directory'), + timeoutMs: z.number().optional().describe('Override the configured timeout in milliseconds'), + }), + outputSchema: z + .object({ + success: z.boolean().describe('Whether the command executed successfully'), + exitCode: z.number().describe('The exit code of the command'), + stdout: z.string().describe('The standard output from the command'), + stderr: z.string().describe('The standard error from the command'), + command: z.string().describe('The command string that was executed'), + executionTime: z.number().describe('How long the command took to execute in milliseconds'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed command execution'), + }), + ), + execute: async ({ context }) => { + const commandConfig = commandsById.get(context.commandId); + + if (!commandConfig) { + return { + error: `Custom command "${context.commandId}" is not defined. Update personal-config.ts to add it.`, + }; + } + + try { + const sandbox = await Sandbox.connect(context.sandboxId); + const commandArgs = context.args?.join(' ') ?? ''; + const commandToRun = [commandConfig.command, commandArgs].filter(Boolean).join(' ').trim(); + + const startTime = Date.now(); + const result = await sandbox.commands.run(commandToRun, { + cwd: context.workingDirectory ?? commandConfig.workingDirectory, + timeoutMs: context.timeoutMs ?? commandConfig.timeoutMs ?? 30000, + }); + const executionTime = Date.now() - startTime; + + return { + success: result.exitCode === 0, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + command: commandToRun, + executionTime, + }; + } catch (error) { + return { + error: JSON.stringify(error, Object.getOwnPropertyNames(error)), + }; + } + }, +}); diff --git a/src/mastra/tools/e2b.ts b/src/mastra/tools/e2b.ts index 29c2f29..8addb06 100644 --- a/src/mastra/tools/e2b.ts +++ b/src/mastra/tools/e2b.ts @@ -2,7 +2,69 @@ import { createTool } from '@mastra/core/tools'; import z from 'zod'; import { FilesystemEventType, FileType, Sandbox } from '@e2b/code-interpreter'; -export const createSandbox = createTool({ +type SandboxIdentifier = { + sandboxId: string; +}; + +type CreateSandboxContext = { + metadata?: Record; + envs?: Record; + timeoutMS?: number; +}; + +type RunCodeContext = SandboxIdentifier & { + code: string; + runCodeOpts?: { + language?: 'ts' | 'js' | 'python'; + envs?: Record; + timeoutMS?: number; + requestTimeoutMs?: number; + }; +}; + +type ReadFileContext = SandboxIdentifier & { + path: string; +}; + +type WriteFileContext = SandboxIdentifier & { + path: string; + content: string; +}; + +type WriteFilesContext = SandboxIdentifier & { + files: Array<{ path: string; data: string }>; +}; + +type ListFilesContext = SandboxIdentifier & { + path: string; +}; + +type DeleteFileContext = ReadFileContext; + +type CreateDirectoryContext = ReadFileContext; + +type GetFileInfoContext = ReadFileContext; + +type CheckFileExistsContext = ReadFileContext; + +type GetFileSizeContext = ReadFileContext & { + humanReadable: boolean; +}; + +type WatchDirectoryContext = SandboxIdentifier & { + path: string; + recursive: boolean; + watchDuration: number; +}; + +type RunCommandContext = SandboxIdentifier & { + command: string; + workingDirectory?: string; + timeoutMs: number; + captureOutput: boolean; +}; + +export const createSandbox = createTool({ id: 'createSandbox', description: 'Create an e2b sandbox', inputSchema: z.object({ @@ -42,7 +104,7 @@ export const createSandbox = createTool({ }, }); -export const runCode = createTool({ +export const runCode = createTool({ id: 'runCode', description: 'Run code in an e2b sandbox', inputSchema: z.object({ @@ -93,7 +155,7 @@ export const runCode = createTool({ }, }); -export const readFile = createTool({ +export const readFile = createTool({ id: 'readFile', description: 'Read a file from the e2b sandbox', inputSchema: z.object({ @@ -127,7 +189,7 @@ export const readFile = createTool({ }, }); -export const writeFile = createTool({ +export const writeFile = createTool({ id: 'writeFile', description: 'Write a single file to the e2b sandbox', inputSchema: z.object({ @@ -162,7 +224,7 @@ export const writeFile = createTool({ }, }); -export const writeFiles = createTool({ +export const writeFiles = createTool({ id: 'writeFiles', description: 'Write multiple files to the e2b sandbox', inputSchema: z.object({ @@ -203,7 +265,7 @@ export const writeFiles = createTool({ }, }); -export const listFiles = createTool({ +export const listFiles = createTool({ id: 'listFiles', description: 'List files and directories in a path within the e2b sandbox', inputSchema: z.object({ @@ -233,8 +295,6 @@ export const listFiles = createTool({ const sandbox = await Sandbox.connect(context.sandboxId); const fileList = await sandbox.files.list(context.path); - fileList.map(f => f.type); - return { files: fileList.map(file => ({ name: file.name, @@ -251,7 +311,7 @@ export const listFiles = createTool({ }, }); -export const deleteFile = createTool({ +export const deleteFile = createTool({ id: 'deleteFile', description: 'Delete a file or directory from the e2b sandbox', inputSchema: z.object({ @@ -285,7 +345,7 @@ export const deleteFile = createTool({ }, }); -export const createDirectory = createTool({ +export const createDirectory = createTool({ id: 'createDirectory', description: 'Create a directory in the e2b sandbox', inputSchema: z.object({ @@ -319,7 +379,7 @@ export const createDirectory = createTool({ }, }); -export const getFileInfo = createTool({ +export const getFileInfo = createTool({ id: 'getFileInfo', description: 'Get detailed information about a file or directory in the e2b sandbox', inputSchema: z.object({ @@ -369,7 +429,7 @@ export const getFileInfo = createTool({ }, }); -export const checkFileExists = createTool({ +export const checkFileExists = createTool({ id: 'checkFileExists', description: 'Check if a file or directory exists in the e2b sandbox', inputSchema: z.object({ @@ -413,7 +473,7 @@ export const checkFileExists = createTool({ }, }); -export const getFileSize = createTool({ +export const getFileSize = createTool({ id: 'getFileSize', description: 'Get the size of a file or directory in the e2b sandbox', inputSchema: z.object({ @@ -469,7 +529,7 @@ export const getFileSize = createTool({ }, }); -export const watchDirectory = createTool({ +export const watchDirectory = createTool({ id: 'watchDirectory', description: 'Start watching a directory for file system changes in the e2b sandbox', inputSchema: z.object({ @@ -541,7 +601,7 @@ export const watchDirectory = createTool({ }, }); -export const runCommand = createTool({ +export const runCommand = createTool({ id: 'runCommand', description: 'Run a shell command in the e2b sandbox', inputSchema: z.object({ diff --git a/src/types/external.d.ts b/src/types/external.d.ts new file mode 100644 index 0000000..0abcbf9 --- /dev/null +++ b/src/types/external.d.ts @@ -0,0 +1,198 @@ +declare module '@mastra/core/agent' { + export class Agent { + constructor(config: TConfig); + } +} + +declare module '@mastra/libsql' { + export class LibSQLStore { + constructor(options: TOptions); + } + export class LibSQLVector { + constructor(options: TOptions); + } +} + +declare module '@mastra/memory' { + export class Memory { + constructor(config: TConfig); + } +} + +declare module '@ai-sdk/openai' { + export function openai(config?: unknown): unknown; +} + +declare module '@mastra/fastembed' { + export function fastembed(config?: Record): unknown; +} + +declare module '@mastra/core/mastra' { + export class Mastra { + constructor(config: TConfig); + agent?: unknown; + } +} + +declare module '@mastra/loggers' { + export class PinoLogger { + constructor(options?: TOptions); + } +} + +declare module '@mastra/core/tools' { + export interface ToolExecutionArgs { + context: TContext; + } + + export interface ToolConfig { + id: string; + description: string; + inputSchema?: unknown; + outputSchema?: unknown; + execute: (args: ToolExecutionArgs) => Promise | TResult; + } + + export function createTool( + config: ToolConfig, + ): ToolConfig; +} + +declare module '@e2b/code-interpreter' { + export enum FilesystemEventType { + CREATE = 'CREATE', + DELETE = 'DELETE', + WRITE = 'WRITE', + } + + export enum FileType { + FILE = 'file', + DIR = 'dir', + SYMLINK = 'symlink', + } + + export interface SandboxFileInfo { + name: string; + path: string; + type: FileType; + size: number; + mode: number; + permissions: string; + owner: string; + group: string; + modifiedTime?: Date; + symlinkTarget?: string | null; + } + + export interface SandboxCommandResult { + exitCode: number; + stdout: string; + stderr: string; + } + + export interface SandboxCommandOptions { + cwd?: string; + timeoutMs?: number; + } + + export interface SandboxWatchHandle { + stop(): Promise; + } + + export interface SandboxRunCodeOptions { + language?: 'ts' | 'js' | 'python'; + envs?: Record; + timeoutMS?: number; + requestTimeoutMs?: number; + } + + export interface SandboxCreateOptions { + metadata?: Record; + envs?: Record; + timeoutMS?: number; + } + + export interface SandboxWriteFileDescriptor { + path: string; + data: string; + } + + export interface SandboxFilesAPI { + read(path: string): Promise; + write(path: string, data: string): Promise; + write(files: SandboxWriteFileDescriptor[]): Promise; + list(path: string): Promise; + remove(path: string): Promise; + makeDir(path: string): Promise; + getInfo(path: string): Promise; + watchDir( + path: string, + listener: (event: { type: FilesystemEventType; name: string }) => Promise | void, + options?: { recursive?: boolean }, + ): Promise; + } + + export interface SandboxCommandsAPI { + run(command: string, options?: SandboxCommandOptions): Promise; + } + + export class Sandbox { + static create(options?: SandboxCreateOptions): Promise; + static connect(sandboxId: string): Promise; + readonly sandboxId: string; + readonly files: SandboxFilesAPI; + readonly commands: SandboxCommandsAPI; + runCode(code: string, options?: SandboxRunCodeOptions): Promise<{ + toJSON(): unknown; + }>; + } +} + +declare module 'zod' { + interface ZodType { + describe(description: string): this; + optional(): this; + default(value: TOutput): this; + or(schema: T): this; + } + + interface ZodEnum extends ZodType {} + + interface ZodObject> extends ZodType { + shape: TShape; + } + + interface ZodArray extends ZodType { + element: TType; + } + + interface ZodNativeEnum extends ZodType {} + + interface ZodRecord extends ZodType> {} + + interface ZodString extends ZodType {} + + interface ZodNumber extends ZodType {} + + interface ZodBoolean extends ZodType {} + + interface ZodDate extends ZodType {} + + const z: { + object>(shape: T): ZodObject; + record(valueType: T): ZodRecord; + string(): ZodString; + number(): ZodNumber; + boolean(): ZodBoolean; + array(schema: T): ZodArray; + enum(values: T): ZodEnum; + nativeEnum(enumObject: T): ZodNativeEnum; + date(): ZodDate; + }; + + export default z; +} + +declare const process: { + env: Record; +};