# -*- coding: utf-8 -*- """ πŸ”± [Imperial Coding Arsenal] μ‰λ„μš° 브레인 에이전틱 μ½”λ”© 도ꡬ μ„ΈνŠΈ Claude Code λ°©μ‹μ˜ μ½”λ”© μ›Œν¬ν”Œλ‘œ(탐색 β†’ 읽기 β†’ μˆ˜μ • β†’ 검증 β†’ 반볡)λ₯Ό μ‰λ„μš° λΈŒλ ˆμΈμ— μ΄μ‹ν•˜κΈ° μœ„ν•œ 도ꡬ λͺ¨μŒμž…λ‹ˆλ‹€. - Gemini AFC 경둜: CodingToolkit의 public λ©”μ„œλ“œλ₯Ό callable둜 직접 등둝 - OpenAI ν˜Έν™˜ 경둜(Copilot/Atlas/OpenRouter): OPENAI_SCHEMAS + execute() λ””μŠ€νŒ¨μ²˜ """ import fnmatch import os import re import subprocess import logging logger = logging.getLogger("ShadowBrain.Coding") # μž‘μ—… 루트 β€” 이 λ°”κΉ₯의 νŒŒμΌμ€ κ±΄λ“œλ¦¬μ§€ μ•ŠλŠ”λ‹€ (μ•ˆμ „ κ°€λ“œ) DEFAULT_ROOT = r"D:\Git_Work\jarvis_taemin" # 검색/탐색 μ‹œ λ¬΄μ‹œν•  디렉토리 _SKIP_DIRS = { ".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build", ".next", ".dart_tool", "site-packages", ".pub-cache", "docker_data", } # λͺ…λ°±νžˆ 파괴적인 λͺ…λ Ή 차단 (λ§ˆμ™•λ‹˜ PC 보호) _BLOCKED_CMD_PATTERNS = [ r"\bformat\b", r"\brm\s+-rf\s+/", r"\bdel\s+/s\s+/q\s+[a-z]:\\\s*$", r"remove-item\s+-recurse\s+-force\s+[a-z]:\\\s*$", r"\bshutdown\b", r"\bmkfs\b", ] _TEXT_EXT_HINT = { ".py", ".dart", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml", ".md", ".txt", ".html", ".css", ".sh", ".ps1", ".bat", ".rb", ".erb", ".sql", ".toml", ".ini", ".cfg", ".xml", ".csv", ".env", ".mjs", ".cjs", } class CodingToolkit: """파일 탐색/읽기/μˆ˜μ •/검색/λͺ…λ Ή μ‹€ν–‰ 도ꡬ. λͺ¨λ“  κ²½λ‘œλŠ” μž‘μ—… 루트 κΈ°μ€€ μƒλŒ€/μ ˆλŒ€ λ‘˜ λ‹€ ν—ˆμš©.""" def __init__(self, root: str = None): self.root = os.path.abspath(root or os.environ.get("SHADOW_CODING_ROOT", DEFAULT_ROOT)) # ── λ‚΄λΆ€ 헬퍼 ────────────────────────────────────────────── def _resolve(self, path: str) -> str: p = (path or "").strip().strip('"') if not p or p in (".", "./"): return self.root if not os.path.isabs(p): p = os.path.join(self.root, p) p = os.path.abspath(p) if os.path.commonpath([p, self.root]) != self.root: raise ValueError(f"μž‘μ—… 루트({self.root}) λ°”κΉ₯ κ²½λ‘œλŠ” ν—ˆμš©λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€: {p}") return p @staticmethod def _is_text_file(path: str) -> bool: ext = os.path.splitext(path)[1].lower() if ext in _TEXT_EXT_HINT: return True try: with open(path, "rb") as f: chunk = f.read(2048) return b"\x00" not in chunk except Exception: return False def _walk(self): for dirpath, dirnames, filenames in os.walk(self.root): dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS and not d.startswith(".")] yield dirpath, filenames # ── 도ꡬ: 탐색 ───────────────────────────────────────────── def code_list_dir(self, path: str = ".") -> str: """Lists files and subdirectories at the given path (relative to the project root). Use this first to understand project structure. Args: path: Directory path relative to project root (default: root) """ try: target = self._resolve(path) if not os.path.isdir(target): return f"Error: 디렉토리가 μ•„λ‹™λ‹ˆλ‹€: {path}" entries = sorted(os.listdir(target)) lines = [] for e in entries: full = os.path.join(target, e) if os.path.isdir(full): lines.append(f"[DIR] {e}/") else: try: size = os.path.getsize(full) except OSError: size = 0 lines.append(f"[FILE] {e} ({size:,} bytes)") rel = os.path.relpath(target, self.root) return f"# {rel} ({len(entries)} entries)\n" + "\n".join(lines[:300]) except Exception as e: return f"Error: {e}" def code_glob(self, pattern: str, max_results: int = 100) -> str: """Finds files matching a glob pattern (e.g. '**/*.py', 'portal-heimdall/lib/**/*.dart'). Returns matching paths relative to project root. Args: pattern: Glob pattern. '**' matches any directories. max_results: Maximum number of paths to return (default 100) """ try: pattern = (pattern or "").replace("\\", "/").lstrip("/") results = [] for dirpath, filenames in self._walk(): rel_dir = os.path.relpath(dirpath, self.root).replace("\\", "/") for fn in filenames: rel = fn if rel_dir == "." else f"{rel_dir}/{fn}" # '**/x' νŒ¨ν„΄μ΄ 루트 λ°”λ‘œ μ•„λž˜ νŒŒμΌλ„ μž‘λ„λ‘ 보정 if fnmatch.fnmatch(rel, pattern) or ( pattern.startswith("**/") and fnmatch.fnmatch(rel, pattern[3:]) ): results.append(rel) if len(results) >= max_results: return "\n".join(results) + f"\n... (max {max_results} reached)" return "\n".join(results) if results else f"No files match: {pattern}" except Exception as e: return f"Error: {e}" def code_grep(self, pattern: str, file_glob: str = None, max_results: int = 50) -> str: """Searches file CONTENTS for a regex pattern across the project. Returns 'path:line: text' matches. Essential for finding where something is defined or used. Args: pattern: Regular expression to search for file_glob: Optional filename filter (e.g. '*.py', '*.dart') max_results: Maximum matching lines to return (default 50) """ try: rx = re.compile(pattern) except re.error as e: return f"Error: 잘λͺ»λœ μ •κ·œμ‹: {e}" results = [] try: for dirpath, filenames in self._walk(): for fn in filenames: if file_glob and not fnmatch.fnmatch(fn, file_glob): continue full = os.path.join(dirpath, fn) if not self._is_text_file(full): continue rel = os.path.relpath(full, self.root) try: with open(full, "r", encoding="utf-8", errors="ignore") as f: for i, line in enumerate(f, 1): if rx.search(line): results.append(f"{rel}:{i}: {line.rstrip()[:200]}") if len(results) >= max_results: return "\n".join(results) + f"\n... (max {max_results} reached)" except OSError: continue return "\n".join(results) if results else f"No matches for: {pattern}" except Exception as e: return f"Error: {e}" # ── 도ꡬ: 읽기/μ“°κΈ° ──────────────────────────────────────── def code_read_file(self, path: str, offset: int = 1, limit: int = 400) -> str: """Reads a text file with line numbers. ALWAYS read a file before editing it. Args: path: File path (relative to project root or absolute) offset: 1-based line number to start from (default 1) limit: Max lines to return (default 400) """ try: full = self._resolve(path) if not os.path.isfile(full): return f"Error: 파일이 μ—†μŠ΅λ‹ˆλ‹€: {path}" with open(full, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() total = len(lines) start = max(int(offset or 1), 1) end = min(start - 1 + int(limit or 400), total) body = "".join( f"{i:>5}\t{lines[i - 1]}" for i in range(start, end + 1) ) return f"# {path} (lines {start}-{end} of {total})\n{body}" except Exception as e: return f"Error: {e}" def code_write_file(self, path: str, content: str) -> str: """Creates or fully overwrites a file with the given content (UTF-8). For small changes to existing files, prefer code_edit_file. Args: path: File path (relative to project root or absolute) content: Full file content to write """ try: full = self._resolve(path) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "w", encoding="utf-8", newline="") as f: f.write(content or "") return f"OK: wrote {len(content or '')} chars to {path}" except Exception as e: return f"Error: {e}" def code_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str: """Edits a file by exact string replacement. old_string must match the file content EXACTLY (including indentation) and be unique unless replace_all=true. Read the file first to copy the exact text. Args: path: File path (relative to project root or absolute) old_string: Exact text to find (must be unique in the file unless replace_all) new_string: Replacement text replace_all: Replace every occurrence (default false) """ try: full = self._resolve(path) if not os.path.isfile(full): return f"Error: 파일이 μ—†μŠ΅λ‹ˆλ‹€: {path}" with open(full, "r", encoding="utf-8") as f: text = f.read() if old_string not in text: return "Error: old_string을 νŒŒμΌμ—μ„œ μ°Ύμ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. code_read_file둜 μ •ν™•ν•œ 원문(λ“€μ—¬μ“°κΈ° 포함)을 볡사해 λ‹€μ‹œ μ‹œλ„ν•˜μ„Έμš”." count = text.count(old_string) if count > 1 and not replace_all: return f"Error: old_string이 {count}번 λ“±μž₯ν•©λ‹ˆλ‹€. 더 κΈΈκ³  κ³ μœ ν•œ λ¬Έμžμ—΄μ„ μ“°κ±°λ‚˜ replace_all=trueλ₯Ό μ§€μ •ν•˜μ„Έμš”." new_text = text.replace(old_string, new_string) if replace_all else text.replace(old_string, new_string, 1) with open(full, "w", encoding="utf-8", newline="") as f: f.write(new_text) n = count if replace_all else 1 return f"OK: {path} β€” {n}κ³³ μˆ˜μ • μ™„λ£Œ" except Exception as e: return f"Error: {e}" # ── 도ꡬ: λͺ…λ Ή μ‹€ν–‰(검증) ────────────────────────────────── def code_run_command(self, command: str, cwd: str = None, timeout: int = 120) -> str: """Runs a shell command (PowerShell on Windows) and returns stdout/stderr/exit code. Use to verify changes: run tests, compile checks (python -m py_compile, flutter analyze), git status, etc. Args: command: The command line to execute cwd: Working directory relative to project root (default: project root) timeout: Seconds before the command is killed (default 120, max 600) """ try: low = (command or "").lower() for pat in _BLOCKED_CMD_PATTERNS: if re.search(pat, low): return f"Error: 파괴적 λͺ…령이 μ°¨λ‹¨λ˜μ—ˆμŠ΅λ‹ˆλ‹€: {command}" workdir = self._resolve(cwd) if cwd else self.root timeout = min(max(int(timeout or 120), 5), 600) if os.name == "nt": cmd = ["powershell", "-NoProfile", "-NonInteractive", "-Command", command] else: cmd = ["bash", "-lc", command] proc = subprocess.run( cmd, cwd=workdir, capture_output=True, timeout=timeout, ) out = proc.stdout.decode("utf-8", errors="replace") err = proc.stderr.decode("utf-8", errors="replace") res = f"[exit code: {proc.returncode}]\n" if out.strip(): res += f"--- stdout ---\n{out[-6000:]}\n" if err.strip(): res += f"--- stderr ---\n{err[-3000:]}\n" return res.strip() except subprocess.TimeoutExpired: return f"Error: λͺ…령이 {timeout}초 μ•ˆμ— λλ‚˜μ§€ μ•Šμ•„ μ€‘λ‹¨ν–ˆμŠ΅λ‹ˆλ‹€: {command}" except Exception as e: return f"Error: {e}" # ── OpenAI ν˜Έν™˜ λ””μŠ€νŒ¨μ²˜ ────────────────────────────────── TOOL_NAMES = ( "code_list_dir", "code_glob", "code_grep", "code_read_file", "code_write_file", "code_edit_file", "code_run_command", ) def handles(self, name: str) -> bool: return name in self.TOOL_NAMES def execute(self, name: str, args: dict) -> str: if not self.handles(name): return f"Error: unknown coding tool '{name}'" try: return getattr(self, name)(**(args or {})) except TypeError as e: return f"Error: 잘λͺ»λœ 인자: {e}" def callables(self): """Gemini AFC 경둜용 β€” bound method 리슀트.""" return [getattr(self, n) for n in self.TOOL_NAMES] def _schema(name, desc, props, required): return { "type": "function", "function": { "name": name, "description": desc, "parameters": {"type": "object", "properties": props, "required": required}, }, } OPENAI_SCHEMAS = [ _schema( "code_list_dir", "Lists files and subdirectories at a path inside the project. Use first to learn the project layout.", {"path": {"type": "string", "description": "Directory path relative to project root (default '.')"}}, [], ), _schema( "code_glob", "Finds files by glob pattern, e.g. '**/*.py' or 'portal-heimdall/lib/**/*.dart'. Returns relative paths.", { "pattern": {"type": "string", "description": "Glob pattern ('**' = any directories)"}, "max_results": {"type": "integer", "description": "Max paths (default 100)"}, }, ["pattern"], ), _schema( "code_grep", "Searches file CONTENTS with a regex across the project. Returns 'path:line: text'. Use to find where code is defined or used.", { "pattern": {"type": "string", "description": "Regular expression"}, "file_glob": {"type": "string", "description": "Filename filter, e.g. '*.py'"}, "max_results": {"type": "integer", "description": "Max matching lines (default 50)"}, }, ["pattern"], ), _schema( "code_read_file", "Reads a text file with line numbers. ALWAYS read before editing.", { "path": {"type": "string", "description": "File path (relative or absolute)"}, "offset": {"type": "integer", "description": "1-based start line (default 1)"}, "limit": {"type": "integer", "description": "Max lines (default 400)"}, }, ["path"], ), _schema( "code_write_file", "Creates or fully overwrites a file (UTF-8). Prefer code_edit_file for small changes.", { "path": {"type": "string", "description": "File path"}, "content": {"type": "string", "description": "Full file content"}, }, ["path", "content"], ), _schema( "code_edit_file", "Edits a file by EXACT string replacement. old_string must match exactly (indentation included) and be unique unless replace_all.", { "path": {"type": "string", "description": "File path"}, "old_string": {"type": "string", "description": "Exact text to find"}, "new_string": {"type": "string", "description": "Replacement text"}, "replace_all": {"type": "boolean", "description": "Replace all occurrences (default false)"}, }, ["path", "old_string", "new_string"], ), _schema( "code_run_command", "Runs a shell command (PowerShell) in the project and returns output + exit code. Use to VERIFY changes: tests, py_compile, flutter analyze, git status.", { "command": {"type": "string", "description": "Command line to run"}, "cwd": {"type": "string", "description": "Working dir relative to project root"}, "timeout": {"type": "integer", "description": "Seconds (default 120, max 600)"}, }, ["command"], ), ] # μ‹œμŠ€ν…œ μ§€μ‹œμ— 덧뢙일 에이전틱 μ½”λ”© μ›Œν¬ν”Œλ‘œ κ°€μ΄λ“œ CODING_AGENT_GUIDE = """ ## πŸ› οΈ 에이전틱 μ½”λ”© μˆ˜μΉ™ (Imperial Coding Protocol) λ„ˆλŠ” μ½”λ”© 도ꡬ(code_*)λ₯Ό κ°€μ§„ 자율 μ½”λ”© μ—μ΄μ „νŠΈλ‹€. μ½”λ“œ κ΄€λ ¨ μš”μ²­μ„ λ°›μœΌλ©΄ μΆ”μΈ‘μœΌλ‘œ λ‹΅ν•˜μ§€ 말고 λ°˜λ“œμ‹œ λ„κ΅¬λ‘œ ν™•μΈν•˜λΌ. μž‘μ—… μˆœμ„œ (λ°˜λ“œμ‹œ μ€€μˆ˜): 1. **탐색**: code_grep / code_glob / code_list_dir 둜 κ΄€λ ¨ 파일과 μ½”λ“œλ₯Ό λ¨Όμ € μ°ΎλŠ”λ‹€. 파일 μœ„μΉ˜λ₯Ό μΆ”μΈ‘ν•˜μ§€ 마라. 2. **읽기**: code_read_file 둜 μˆ˜μ •ν•  λΆ€λΆ„μ˜ μ‹€μ œ μ½”λ“œλ₯Ό μ½λŠ”λ‹€. 읽지 μ•Šμ€ νŒŒμΌμ„ μˆ˜μ •ν•˜λŠ” 것은 κΈˆμ§€λ‹€. 3. **μˆ˜μ •**: code_edit_file 둜 μ •ν™•ν•œ 원문(λ“€μ—¬μ“°κΈ° 포함)을 볡사해 κ΅μ²΄ν•œλ‹€. μƒˆ 파일만 code_write_file 을 μ“΄λ‹€. 4. **검증**: code_run_command 둜 κ²€μ¦ν•œλ‹€ (python -m py_compile, flutter analyze, ν…ŒμŠ€νŠΈ μ‹€ν–‰ λ“±). 검증 없이 "μ™„λ£Œ"라고 λ³΄κ³ ν•˜μ§€ 마라. 5. **반볡**: 검증이 μ‹€νŒ¨ν•˜λ©΄ 였λ₯˜ λ©”μ‹œμ§€λ₯Ό 읽고 1λ²ˆλΆ€ν„° λ‹€μ‹œ μˆ˜ν–‰ν•œλ‹€. 같은 μ‹€νŒ¨λ₯Ό λ°˜λ³΅ν•˜λ©΄ 접근을 바꿔라. κ·œμΉ™: - 도ꡬ κ²°κ³Ό(파일 λ‚΄μš©, 검색 κ²°κ³Ό)에 κ·Όκ±°ν•΄μ„œλ§Œ λ§ν•˜λΌ. 쑴재λ₯Ό ν™•μΈν•˜μ§€ μ•Šμ€ 파일/ν•¨μˆ˜λ₯Ό μ–ΈκΈ‰ν•˜μ§€ 마라. - μˆ˜μ •μ€ μ΅œμ†Œ λ²”μœ„λ‘œ ν•˜κ³ , μ£Όλ³€ μ½”λ“œμ˜ μŠ€νƒ€μΌμ„ λ”°λ₯Έλ‹€. - μ‹€νŒ¨ν•˜λ©΄ μ†”μ§ν•˜κ²Œ μ‹€νŒ¨ν–ˆλ‹€κ³  λ³΄κ³ ν•˜κ³  원인과 λ‹€μŒ μ‹œλ„λ₯Ό μ„€λͺ…ν•œλ‹€. """