← 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); });