diff --git a/app.py b/app.py index d869be3..916ccfe 100644 --- a/app.py +++ b/app.py @@ -274,6 +274,12 @@ class AssistantLearnPayload(BaseModel): tags: List[str] = Field(default_factory=list) release_name: Optional[str] = None metadata: Dict[str, Any] = Field(default_factory=dict) + write_code: bool = False + code_objective: Optional[str] = None + code_files: List[str] = Field(default_factory=list) + code_dry_run: bool = True + code_commit: bool = False + code_branch: Optional[str] = None class AssistantChatMessage(BaseModel): @@ -1325,6 +1331,134 @@ def _extract_json_object_from_text(text: str) -> Dict[str, Any]: return obj +def _extract_code_text(text: str) -> str: + raw = (text or "").strip() + if raw.startswith("```"): + lines = raw.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip().startswith("```"): + lines = lines[:-1] + return "\n".join(lines).rstrip() + "\n" + return raw + + +def build_learn_code_prompt( + objective: str, + note_title: str, + note_text: str, + file_path: str, + current_content: str, +) -> str: + return ( + "You are editing one source file in a real codebase.\n" + "Make minimal, correct changes to satisfy the objective.\n" + "Do not add explanations.\n" + "Return ONLY the full updated file content.\n\n" + f"Objective:\n{objective}\n\n" + f"Learn note title:\n{note_title}\n\n" + f"Learn note text:\n{note_text[:4000]}\n\n" + f"File path:\n{file_path}\n\n" + "Current file content:\n" + f"{current_content[:120000]}\n" + ) + + +async def _assistant_write_code_from_learn( + payload: AssistantLearnPayload, + note_title: str, + note_text: str, +) -> Dict[str, Any]: + repo_dir = Path(__file__).resolve().parent + objective = (payload.code_objective or "").strip() or f"Implement changes from learn note: {note_title}" + requested_files = [str(x).strip() for x in (payload.code_files or []) if str(x).strip()] + if not requested_files: + raise HTTPException(status_code=400, detail="code_files is required when write_code=true") + + branch_name = (payload.code_branch or "").strip() + if not branch_name: + branch_name = f"assistant/learn-{_slugify(note_title)}-{uuid.uuid4().hex[:6]}" + + chk = await _run_local_cmd(["git", "rev-parse", "--is-inside-work-tree"], repo_dir) + if chk["code"] != 0: + raise HTTPException(status_code=500, detail={"message": "Not a git repository", "stderr": chk["stderr"]}) + + if not payload.code_dry_run: + co = await _run_local_cmd(["git", "checkout", "-B", branch_name], repo_dir) + if co["code"] != 0: + raise HTTPException( + status_code=500, + detail={"message": "Failed to create/switch branch", "stderr": co["stderr"]}, + ) + + changed_files: List[str] = [] + skipped_files: List[Dict[str, str]] = [] + file_results: List[Dict[str, Any]] = [] + + for rel in requested_files[:20]: + rel_path = Path(rel) + if rel_path.is_absolute() or ".." in rel_path.parts: + skipped_files.append({"file": rel, "reason": "Only workspace-relative paths are allowed."}) + continue + abs_path = (repo_dir / rel_path).resolve() + try: + abs_path.relative_to(repo_dir) + except ValueError: + skipped_files.append({"file": rel, "reason": "Path escapes repository root."}) + continue + if not abs_path.exists() or not abs_path.is_file(): + skipped_files.append({"file": rel, "reason": "File does not exist."}) + continue + + current = abs_path.read_text(encoding="utf-8", errors="replace") + prompt = build_learn_code_prompt( + objective=objective, + note_title=note_title, + note_text=note_text, + file_path=rel, + current_content=current, + ) + raw = await ollama_generate(prompt) + updated = _extract_code_text(raw) + if not updated.strip(): + skipped_files.append({"file": rel, "reason": "Model returned empty content."}) + continue + changed = updated != current + file_results.append({"file": rel, "changed": changed}) + if not changed: + continue + changed_files.append(rel) + if not payload.code_dry_run: + abs_path.write_text(updated, encoding="utf-8") + + commit_hash: Optional[str] = None + commit_detail: Optional[str] = None + if changed_files and not payload.code_dry_run and payload.code_commit: + add = await _run_local_cmd(["git", "add", *changed_files], repo_dir) + if add["code"] != 0: + raise HTTPException(status_code=500, detail={"message": "Failed to add changed files", "stderr": add["stderr"]}) + msg = f"feat(assistant): apply learn code update {note_title[:50]}" + cm = await _run_local_cmd(["git", "commit", "-m", msg], repo_dir) + commit_detail = (cm["stdout"] or cm["stderr"] or "").strip() + if cm["code"] == 0: + head = await _run_local_cmd(["git", "rev-parse", "HEAD"], repo_dir) + if head["code"] == 0: + commit_hash = head["stdout"].strip() + + return { + "attempted": True, + "objective": objective, + "dry_run": payload.code_dry_run, + "branch_name": branch_name, + "requested_files": requested_files, + "changed_files": changed_files, + "skipped_files": skipped_files, + "file_results": file_results, + "commit": commit_hash, + "commit_detail": commit_detail, + } + + def build_assistant_plan_prompt(payload: AssistantPlanPayload, source_docs: List[Dict[str, Any]]) -> str: constraints = payload.constraints or [] constraint_lines = "\n".join(f"- {c}" for c in constraints) if constraints else "- None" @@ -2485,13 +2619,20 @@ async def assistant_learn(payload: AssistantLearnPayload, x_admin_api_key: Optio "fingerprint": make_fingerprint(title, "note", {}), } await es_index(doc) - return { + response: Dict[str, Any] = { "stored": True, "concept_id": doc["concept_id"], "release_name": payload.release_name, "title": title, "tags": payload.tags, } + if payload.write_code: + response["code_write"] = await _assistant_write_code_from_learn( + payload=payload, + note_title=title, + note_text=payload.text, + ) + return response @app.post("/assistant/chat", response_model=AssistantChatResponse) diff --git a/ui/assets/app.js b/ui/assets/app.js index 04fa883..964bf87 100644 --- a/ui/assets/app.js +++ b/ui/assets/app.js @@ -180,6 +180,57 @@ async function saveLearn() { } } +async function saveLearnAndWriteCode() { + const cfg = getConfig(); + const title = document.getElementById("learnTitle").value.trim(); + const tags = document.getElementById("learnTags").value + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + const text = document.getElementById("learnText").value.trim(); + const codeObjective = document.getElementById("learnCodeObjective").value.trim(); + const codeFiles = document.getElementById("learnCodeFiles").value + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + const codeDryRun = document.getElementById("learnCodeDryRun").checked; + const codeCommit = document.getElementById("learnCodeCommit").checked; + const out = document.getElementById("learnOutput"); + if (!text) { + out.textContent = "Provide note text first."; + return; + } + if (codeFiles.length === 0) { + out.textContent = "Provide at least one code file path."; + return; + } + out.textContent = "Saving note and writing code..."; + try { + const data = await apiPost("/assistant/learn", { + text, + title: title || null, + tags, + release_name: cfg.releaseName || null, + write_code: true, + code_objective: codeObjective || null, + code_files: codeFiles, + code_dry_run: codeDryRun, + code_commit: codeCommit, + }); + const cw = data.code_write || {}; + out.textContent = + `saved=${data.stored}\nconcept_id=${data.concept_id}\ntitle=${data.title}\n\n` + + `write_code.attempted=${cw.attempted}\n` + + `dry_run=${cw.dry_run}\n` + + `branch=${cw.branch_name || "-"}\n` + + `changed_files=${(cw.changed_files || []).join(", ") || "-"}\n` + + `skipped_files=${(cw.skipped_files || []).map((x) => `${x.file}:${x.reason}`).join(" | ") || "-"}\n` + + `commit=${cw.commit || "-"}`; + } catch (e) { + out.textContent = `Error: ${String(e)}`; + } +} + function appendChat(role, text, meta) { const target = document.getElementById("chatTranscript"); const el = document.createElement("div"); @@ -334,6 +385,7 @@ document.getElementById("loadInbox").addEventListener("click", loadInbox); document.getElementById("loadTasks").addEventListener("click", loadTasks); document.getElementById("makeDraft").addEventListener("click", makeDraft); document.getElementById("saveLearn").addEventListener("click", saveLearn); +document.getElementById("saveLearnWrite").addEventListener("click", saveLearnAndWriteCode); document.getElementById("sendChat").addEventListener("click", sendChat); document.getElementById("runImprove").addEventListener("click", runSelfImprove); document.getElementById("loadImproveHistory").addEventListener("click", loadImproveHistory); diff --git a/ui/index.html b/ui/index.html index d0cdd45..7fa0d46 100644 --- a/ui/index.html +++ b/ui/index.html @@ -60,8 +60,15 @@ + +
+ + + + +