"""Repo read intern — pack repo with repomix, answer question with LLM.""" from __future__ import annotations import os import shutil import subprocess import tempfile from pathlib import Path from typing import Any from .base import BudgetExceededError, Intern, InternResponse from ..safety import always_ask_globs # Conservative token estimate: chars/4 overestimates for repomix XML (tag overhead), # so BudgetExceededError fires early rather than late. Real enforcement is on the # endpoint side — this is a client-side safety net, not a precision counter. CHARS_PER_TOKEN = 4 def _estimate_tokens(text: str) -> int: return len(text) // CHARS_PER_TOKEN def _top_files_by_size(packed: str, n: int = 5) -> list[str]: """Return the n largest files in repomix XML output, by block content size. Parses each ``...`` block and measures the byte length of its content, so BudgetExceededError.top_files points the user at the real overflow culprits — not the first files in document order. """ sizes: list[tuple[int, str]] = [] pos = 0 while True: open_idx = packed.find('', open_idx) if close_idx == -1: break path_start = packed.find('path="', open_idx) + 6 path_end = packed.find('"', path_start) path = packed[path_start:path_end] # Content sits between the opening tag's '>' and the closing ''. content_start = packed.find('>', open_idx) + 1 content_size = close_idx - content_start sizes.append((content_size, path)) pos = close_idx + len('') sizes.sort(key=lambda x: x[0], reverse=True) return [path for _, path in sizes[:n]] class RepoRead(Intern): id = "repo_read" description = "Pack directory/repo with repomix and answer a question. Cheap repo comprehension." def run( self, paths: list[str], question: str, compress: bool = False, **kwargs: Any, ) -> InternResponse | BudgetExceededError: if not paths: return InternResponse(text="No paths provided", usage={}) # Build repomix args: pass all paths, not just first. ignore_globs = always_ask_globs() # Resolve npx via PATH. On Windows, subprocess.run(shell=False) does not # expand PATHEXT, so passing bare "npx" fails to find npx.cmd; shutil.which # finds the .cmd (or .exe / shell wrapper on POSIX) and returns a full path # that CreateProcess / execve can dispatch directly. npx = shutil.which("npx") or shutil.which("npx.cmd") if npx is None: return InternResponse(text="npx not found - install Node.js", usage={}) # Use mkstemp to avoid Windows file-sharing issues with NamedTemporaryFile. # os.close(fd) releases the handle before repomix writes to the path. fd, tmp_path = tempfile.mkstemp(suffix=".xml") os.close(fd) err_fd, err_path = tempfile.mkstemp(suffix=".stderr") os.close(err_fd) try: args = [ npx, "-y", "repomix@latest", *paths, "--output", tmp_path, "--style", "xml", ] if compress: args.append("--compress") for glob in ignore_globs: args += ["--ignore", glob] try: # Redirect repomix's stdio to FILES/DEVNULL, never PIPES. capture_output # spawns reader threads that communicate() joins; if any process in the # child tree (an orphaned `node` grandchild, or — under concurrent launches # on Windows — a sibling subprocess that inherited the pipe handle) keeps a # pipe write-end open, that join blocks FOREVER, past the timeout, wedging # the worker thread. repomix's real output already goes to --output, so its # stdout is unused; stderr is captured to a file for error reporting only. with open(err_path, "w", encoding="utf-8", errors="replace") as err_f: subprocess.run( args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=err_f, timeout=120, check=True, ) packed_text = Path(tmp_path).read_text() except subprocess.TimeoutExpired: return InternResponse(text="Repomix timed out (120s)", usage={}) except subprocess.CalledProcessError: err = Path(err_path).read_text(encoding="utf-8", errors="replace") return InternResponse(text=f"Repomix failed: {err[:500]}", usage={}) except FileNotFoundError: return InternResponse(text="npx not found - install Node.js", usage={}) finally: # Always clean up the temp files, even on error. for _p in (tmp_path, err_path): try: os.unlink(_p) except OSError: pass # Budget check before sending to LLM. tokens = _estimate_tokens(packed_text) if tokens > self.context_budget: return BudgetExceededError( tokens=tokens, limit=self.context_budget, top_files=_top_files_by_size(packed_text), ) prompt = f"# Packed repo\n\n{packed_text}\n\n# Question\n\n{question}" if self.client is None: return InternResponse(text="No client configured", usage={}) extra_body = kwargs.get("extra_body", {}) resp = self.client.chat.completions.create( model=self.model or "deepseek-v4-flash", messages=[ {"role": "system", "content": self.system_prompt}, {"role": "user", "content": prompt}, ], max_tokens=kwargs.get("max_tokens", self.max_tokens), temperature=self.temperature, extra_body=extra_body if extra_body else None, ) choice = resp.choices[0] return InternResponse( text=choice.message.content or "", usage={ "tokens_in": resp.usage.prompt_tokens if resp.usage else 0, "tokens_out": resp.usage.completion_tokens if resp.usage else 0, }, )