From 0c3e88ac2f99f9fc5a1e327186240027fe1bd9a8 Mon Sep 17 00:00:00 2001 From: Jaco Bezuidenhout Date: Wed, 20 May 2026 12:39:24 +0000 Subject: [PATCH] feat: add create mode for new files Auto-detects create vs edit based on whether target_path exists. Edit jobs use unified diff/patch (existing behaviour). Create jobs ask the model to output the complete new file, validated by file type (_validate_output handles .py compile, .html tag check, generic non-empty check). - _resolve_target: handles /opt/sites/ and /opt/stacks/ paths - _CREATOR_SYSTEM + _build_create_prompt + _ask_creator: full-file output - _validate_output: replaces direct _syntax_check calls in the loop - run_coding_loop: branches on mode, stores mode on job dict - approve: mkdir parents for new paths, skip backup if no original - submit_task: initialise mode field as None Co-Authored-By: Claude Sonnet 4.6 --- main.py | 124 +++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 28 deletions(-) diff --git a/main.py b/main.py index 1c561d5..62ea844 100644 --- a/main.py +++ b/main.py @@ -28,6 +28,12 @@ JOBS_FILE = STACKS_DIR / "qyburn-coder" / "jobs.json" MAX_ATTEMPTS = 3 +def _resolve_target(target_path: str) -> Path: + if target_path.startswith("/opt/"): + return Path(target_path) + return STACKS_DIR / target_path + + def _save_jobs(): try: JOBS_FILE.write_text(json.dumps(jobs, indent=2)) @@ -110,12 +116,43 @@ def _syntax_check(code: str, filename: str) -> str | None: Path(tmp).unlink(missing_ok=True) +def _validate_output(content: str, filename: str) -> str | None: + """Returns error string or None if valid. Handles .py, .html, and generic files.""" + if not content.strip(): + return "model returned empty output" + ext = Path(filename).suffix.lower() + if ext == ".py": + return _syntax_check(content, filename) + if ext in (".html", ".htm"): + low = content.lower() + if " or )" + return None + return None + + def _make_diff(original: str, modified: str, filename: str) -> str: a = original.splitlines(keepends=True) b = modified.splitlines(keepends=True) return "".join(difflib.unified_diff(a, b, fromfile=f"a/{filename}", tofile=f"b/{filename}")) or "(no changes)" +def _build_create_prompt(job: dict, attempt: int, last_error: str | None) -> str: + lines = [f"Task: {job['description']}"] + if job.get("constraints"): + lines.append(f"Constraints: {job['constraints']}") + if attempt > 1 and last_error: + lines.append(f"\nPrevious attempt failed:\n{last_error}") + lines.append("Fix the issue in the output.") + lines += [ + "", + f"Create the file: {job['target_path']}", + "", + "Output the complete file content:", + ] + return "\n".join(lines) + + def _build_prompt(job: dict, original: str, attempt: int, last_error: str | None) -> str: lines = [f"Task: {job['description']}"] if job.get("constraints"): @@ -167,6 +204,28 @@ _CODER_SYSTEM = ( "+ added lines, - removed lines." ) +_CREATOR_SYSTEM = ( + "You are a code and file generator. " + "When given a task, output ONLY the complete new file as raw text. " + "No markdown fences, no explanations, no preamble. " + "Output only the file content itself, starting with the very first character of the file." +) + + +async def _ask_creator(task_prompt: str) -> str: + async with httpx.AsyncClient(timeout=300.0) as client: + r = await client.post(f"{OLLAMA_URL}/api/chat", json={ + "model": CODER_MODEL, + "messages": [ + {"role": "system", "content": _CREATOR_SYSTEM}, + {"role": "user", "content": task_prompt}, + ], + "stream": False, + "options": {"temperature": 0.1, "num_predict": 8192}, + }) + r.raise_for_status() + return r.json()["message"]["content"] + async def _ask_coder(task_prompt: str) -> str: async with httpx.AsyncClient(timeout=300.0) as client: @@ -227,17 +286,18 @@ async def run_coding_loop(job_id: str): job["status"] = "running" job["updated_at"] = datetime.now(timezone.utc).isoformat() - target = STACKS_DIR / job["target_path"] - if not target.exists(): - job["status"] = "failed" - job["error"] = f"target file not found: {job['target_path']}" - job["updated_at"] = datetime.now(timezone.utc).isoformat() - logger.error(f"[{job_id[:8]}] {job['error']}") - return + target = _resolve_target(job["target_path"]) + mode = "edit" if target.exists() else "create" + job["mode"] = mode + job["updated_at"] = datetime.now(timezone.utc).isoformat() + + if mode == "edit": + original = target.read_text() + else: + original = "" + logger.info(f"[{job_id[:8]}] target does not exist — create mode") await _warm_coder_model() - - original = target.read_text() sandbox_dir = SANDBOX_DIR / job_id sandbox_dir.mkdir(parents=True, exist_ok=True) sandbox_file = sandbox_dir / Path(job["target_path"]).name @@ -258,37 +318,43 @@ async def run_coding_loop(job_id: str): logger.info(f"[{job_id[:8]}] attempt {attempt}/{MAX_ATTEMPTS}") try: - response = await _ask_coder(_build_prompt(job, original, attempt, last_error)) + if mode == "edit": + response = await _ask_coder(_build_prompt(job, original, attempt, last_error)) + else: + response = await _ask_creator(_build_create_prompt(job, attempt, last_error)) except Exception as e: last_error = f"Ollama error: {e}" logger.warning(f"[{job_id[:8]}] attempt {attempt} — LLM error: {e}") continue job["tokens_estimated"] = job.get("tokens_estimated", 0) + len(response) // 4 - diff_text = _extract_diff(response) - last_diff = diff_text + raw = _extract_diff(response) + last_diff = raw - patched, patch_error = _apply_diff(original, diff_text) - if patch_error: - last_error = patch_error - logger.warning(f"[{job_id[:8]}] attempt {attempt} patch error: {patch_error[:120]}") - continue + if mode == "edit": + content, patch_error = _apply_diff(original, raw) + if patch_error: + last_error = patch_error + logger.warning(f"[{job_id[:8]}] attempt {attempt} patch error: {patch_error[:120]}") + continue + else: + content = raw - syntax_error = _syntax_check(patched, Path(job["target_path"]).name) - if syntax_error is None: - sandbox_file.write_text(patched) + error = _validate_output(content, Path(job["target_path"]).name) + if error is None: + sandbox_file.write_text(content) job["status"] = "pending_review" - job["diff"] = diff_text + job["diff"] = raw if mode == "edit" else f"[NEW FILE — {Path(job['target_path']).name}]\n\n{content}" job["sandbox_file"] = str(sandbox_file) job["original"] = original job["error"] = None job["updated_at"] = datetime.now(timezone.utc).isoformat() - logger.info(f"[{job_id[:8]}] patch OK on attempt {attempt} — pending review") + logger.info(f"[{job_id[:8]}] {mode} OK on attempt {attempt} — pending review") _write_last_run() return - last_error = syntax_error - logger.warning(f"[{job_id[:8]}] attempt {attempt} syntax error: {syntax_error[:120]}") + last_error = error + logger.warning(f"[{job_id[:8]}] attempt {attempt} error: {error[:120]}") _write_handoff(job, original, last_diff) job["status"] = "escalated" @@ -463,7 +529,7 @@ async def submit_task(req: TaskRequest, background_tasks: BackgroundTasks): "constraints": req.constraints, "attempts": 0, "created_at": now, "updated_at": now, "cancelled": False, "error": None, "diff": None, - "sandbox_file": None, "original": None, + "mode": None, "sandbox_file": None, "original": None, "tokens_estimated": 0, } background_tasks.add_task(run_coding_loop, job_id) @@ -521,9 +587,11 @@ async def approve_job(job_id: str, request: Request): sandbox_file = Path(job["sandbox_file"]) if not sandbox_file.exists(): raise HTTPException(status_code=500, detail="sandbox file missing") - target = STACKS_DIR / job["target_path"] - backup = target.with_suffix(target.suffix + ".qyburn-bak") - backup.write_text(job["original"]) + target = _resolve_target(job["target_path"]) + target.parent.mkdir(parents=True, exist_ok=True) + if job.get("original"): + backup = target.with_suffix(target.suffix + ".qyburn-bak") + backup.write_text(job["original"]) target.write_text(sandbox_file.read_text()) job["status"] = "applied" job["updated_at"] = datetime.now(timezone.utc).isoformat()