← back to Letsbegin
scripts/ralph-llm-runner.js
287 lines
#!/usr/bin/env node
// Gemini-driven Ralph story runner.
//
// Replaces the `claude --dangerously-skip-permissions -p ...` invocation
// in app/api/ralph/{run,continue}/route.ts with a Gemini 2.0 Flash call.
//
// Usage:
// node ralph-gemini-runner.js --prompt-file <story.txt> --project <dir>
//
// Behavior:
// 1. Read story prompt
// 2. Build context: project tree (top-level + src/app + recent edits) + tech stack hints
// 3. Call Gemini with strict JSON-output schema:
// { "files": [{"path":"<rel>", "content":"<full file>"}], "shell": ["..."], "done": true/false, "error": "..." }
// 4. Write each file, run optional shell cmds (read-only by default; --apply-shell to enable)
// 5. Run typecheck (npm run build OR tsc --noEmit)
// 6. Exit 0 on success, 1 on any failure
//
// No Claude API calls. No external Anthropic dependency.
// Honest limits: Gemini is a stateless single-shot — much weaker than Claude
// agentic loop. Stories that need iterative read→edit→test→edit will fail.
// Better suited for "implement this single file" stories.
const fs = require('fs');
const path = require('path');
const { execSync, spawnSync } = require('child_process');
// ─── args ────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const argMap = {};
for (let i = 0; i < args.length; i += 2) argMap[args[i]] = args[i + 1];
const PROMPT_FILE = argMap['--prompt-file'];
const PROJECT = argMap['--project'];
const APPLY_SHELL = args.includes('--apply-shell');
const MAX_FILES = parseInt(argMap['--max-files'] || '10', 10);
if (!PROMPT_FILE || !PROJECT) {
console.error('usage: ralph-gemini-runner.js --prompt-file <path> --project <path> [--apply-shell] [--max-files N]');
process.exit(2);
}
// Prefer a Ralph-dedicated key so this runner doesn't compete with Letsbegin's
// other AI routes (/api/prd/generate, /api/url-info, etc.) for free-tier quota.
const GEMINI_API_KEY = process.env.GEMINI_API_KEY_RALPH
|| process.env.GEMINI_API_KEY;
const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
// ─── context-building helpers ───────────────────────────────────────────
function safeRead(p, max = 4000) {
try {
const s = fs.readFileSync(p, 'utf8');
return s.length > max ? s.slice(0, max) + '\n…[truncated]' : s;
} catch { return ''; }
}
function listTree(dir, depth = 0, max = 80) {
const out = [];
function walk(d, prefix) {
if (out.length >= max) return;
let entries = [];
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (out.length >= max) return;
if (e.name.startsWith('.git') || e.name === 'node_modules' || e.name === '.next' || e.name === 'dist') continue;
const full = path.join(d, e.name);
if (e.isDirectory()) {
out.push(prefix + e.name + '/');
if (prefix.length < 12) walk(full, prefix + ' ');
} else {
out.push(prefix + e.name);
}
}
}
walk(dir, '');
return out.join('\n');
}
function buildContext(projectDir) {
const pkg = safeRead(path.join(projectDir, 'package.json'), 1500);
const tsconfig = safeRead(path.join(projectDir, 'tsconfig.json'), 800);
const tree = listTree(projectDir);
const claudeMd = safeRead(path.join(projectDir, 'CLAUDE.md'), 2000);
return [
'# Project tree',
'```',
tree,
'```',
'',
'# package.json',
'```json',
pkg,
'```',
'',
'# tsconfig.json',
'```json',
tsconfig,
'```',
claudeMd ? `# CLAUDE.md\n${claudeMd}` : '',
].filter(Boolean).join('\n');
}
// ─── LLM backend ────────────────────────────────────────────────────────
// Default: local Ollama (no API quota, free, private). Set RALPH_LLM=gemini
// to use Google Gemini Flash instead.
const LLM_BACKEND = (process.env.RALPH_LLM || 'ollama').toLowerCase();
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';
async function callGemini(prompt) {
if (!GEMINI_API_KEY) {
console.error('GEMINI_API_KEY_RALPH or GEMINI_API_KEY environment variable is required when RALPH_LLM=gemini');
process.exit(2);
}
const r = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.4,
maxOutputTokens: 8000,
responseMimeType: 'application/json',
},
}),
});
if (!r.ok) {
const err = await r.text();
throw new Error(`Gemini ${r.status}: ${err.slice(0, 200)}`);
}
const j = await r.json();
const text = j.candidates?.[0]?.content?.parts?.[0]?.text || '';
return text;
}
async function callOllama(prompt) {
// Note: don't use format:'json' — qwen3 is a reasoning model and the strict
// schema mode collides with its <think>...</think> phase, yielding {}.
// We strip the think block + extract JSON ourselves in the main flow.
const r = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: OLLAMA_MODEL,
prompt,
stream: false,
options: {
temperature: 0.3,
num_predict: 16000,
num_ctx: 16384,
},
}),
});
if (!r.ok) {
const err = await r.text();
throw new Error(`Ollama ${r.status}: ${err.slice(0, 200)}`);
}
const j = await r.json();
return j.response || '';
}
async function callLLM(prompt) {
if (LLM_BACKEND === 'gemini') return callGemini(prompt);
return callOllama(prompt);
}
// ─── main ────────────────────────────────────────────────────────────────
(async () => {
console.log(`[ralph-llm] backend: ${LLM_BACKEND}${LLM_BACKEND === 'ollama' ? ` (${OLLAMA_MODEL} @ ${OLLAMA_URL})` : ''}`);
console.log(`[ralph-llm] story: ${PROMPT_FILE}`);
console.log(`[ralph-llm] project: ${PROJECT}`);
const story = safeRead(PROMPT_FILE, 6000);
const ctx = buildContext(PROJECT);
const prompt = `${ctx}
---
# Your task (story to implement)
${story}
---
# Output schema (return STRICT JSON only — no prose, no markdown fence)
{
"files": [
{ "path": "<relative path from project root>", "content": "<COMPLETE file contents — full replacement, not a diff>" }
],
"shell": [ "<optional shell command to run, e.g. npm install foo>" ],
"done": true,
"summary": "<1-sentence what you did>"
}
Rules:
- Return COMPLETE files, not diffs. If you touch a 200-line file, return all 200 lines.
- Maximum ${MAX_FILES} files in this response.
- Don't touch lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml).
- Don't put binary content. Text/code only.
- If the story needs information you don't have (e.g. a file you weren't shown), set done=false and put the reason in "summary".
- Set done=true only when you believe the acceptance criteria are met.
- shell[] is optional — only use for npm install / database migrations / similar. Tests/build are run automatically afterward.
API ACCURACY (read carefully — most failures come from these):
- Only import symbols that actually exist in the module. If unsure, prefer Node built-ins you're confident about over guessing.
- node:test exports \`test\`, \`describe\`, \`it\`, \`before\`, \`after\`, \`beforeEach\`, \`afterEach\`, \`mock\`. It does NOT export \`expect\`. Use \`node:assert\` (or \`node:assert/strict\`) for assertions: \`import { strict as assert } from 'node:assert'; assert.equal(a, b);\`.
- Do not invent matchers like \`.toBe()\`, \`.toEqual()\` unless the project actually uses jest/vitest/chai (check package.json devDependencies first).
- Match the existing project's module system: if package.json has no \`"type": "module"\` and tsconfig targets CommonJS, use \`require()\` / CJS exports. Otherwise use ESM \`import\`/\`export\`.
- For test files in TypeScript, the project must already have @types/node and a test runner configured. Don't add new devDependencies unless the story explicitly says so.
Output the JSON now.`;
let result;
try {
const raw = await callLLM(prompt);
console.log(`[ralph-llm] returned ${raw.length} bytes`);
// qwen sometimes wraps JSON in <think>...</think> or ```json fences — strip those.
let cleaned = raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) cleaned = fence[1].trim();
const firstBrace = cleaned.indexOf('{');
if (firstBrace > 0) cleaned = cleaned.slice(firstBrace);
result = JSON.parse(cleaned);
} catch (e) {
console.error(`[ralph-llm] FAIL: ${e.message}`);
process.exit(1);
}
if (!result.done) {
console.error(`[ralph-llm] model set done=false: ${result.summary || 'no reason'}`);
process.exit(1);
}
// Apply file writes
const files = (result.files || []).slice(0, MAX_FILES);
for (const f of files) {
if (!f.path || typeof f.content !== 'string') continue;
if (path.isAbsolute(f.path) || f.path.includes('..')) {
console.error(`[ralph-llm] skip suspicious path: ${f.path}`);
continue;
}
if (/\b(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/.test(f.path)) continue;
const full = path.join(PROJECT, f.path);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, f.content);
console.log(`[ralph-llm] wrote ${f.path} (${f.content.length} bytes)`);
}
// Optional shell commands
if (APPLY_SHELL && Array.isArray(result.shell)) {
for (const cmd of result.shell.slice(0, 3)) {
console.log(`[ralph-llm] $ ${cmd}`);
const r = spawnSync(cmd, { cwd: PROJECT, shell: true, stdio: 'inherit', timeout: 120_000 });
if (r.status !== 0) {
console.error(`[ralph-llm] shell failed: ${cmd}`);
process.exit(1);
}
}
}
// Run typecheck. Use `set -o pipefail` so the head truncation doesn't mask
// tsc's non-zero exit. If tsc isn't on PATH (no local @typescript install),
// skip rather than fail — better than blocking on missing dev tooling.
console.log(`[ralph-llm] running typecheck…`);
const hasTsConfig = fs.existsSync(path.join(PROJECT, 'tsconfig.json'));
const hasLocalTsc = fs.existsSync(path.join(PROJECT, 'node_modules/.bin/tsc'));
let tcCmd;
if (!hasTsConfig) {
tcCmd = 'echo "no tsconfig — skipping typecheck"';
} else if (!hasLocalTsc) {
tcCmd = 'echo "no local tsc binary — skipping typecheck (run npm install to enable)"';
} else {
tcCmd = 'set -o pipefail; ./node_modules/.bin/tsc --noEmit 2>&1 | head -80';
}
const tc = spawnSync(tcCmd, { cwd: PROJECT, shell: true, stdio: 'inherit', timeout: 180_000 });
if (tc.status !== 0) {
console.error(`[ralph-llm] typecheck FAILED — story marked failed`);
process.exit(1);
}
console.log(`[ralph-llm] ✓ ${result.summary || 'story done'}`);
process.exit(0);
})().catch(e => { console.error(`[ralph-llm] uncaught: ${e.message}`); process.exit(1); });