shadowbrain / shadow_brain_core /brain /coding_tools.py
taemin1980's picture
πŸ”± Imperial Deployment: Shadow Brain Core ignition
d50a68d verified
Raw
History Blame Contribute Delete
18.4 kB
# -*- 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λ²ˆλΆ€ν„° λ‹€μ‹œ μˆ˜ν–‰ν•œλ‹€. 같은 μ‹€νŒ¨λ₯Ό λ°˜λ³΅ν•˜λ©΄ 접근을 바꿔라.
κ·œμΉ™:
- 도ꡬ κ²°κ³Ό(파일 λ‚΄μš©, 검색 κ²°κ³Ό)에 κ·Όκ±°ν•΄μ„œλ§Œ λ§ν•˜λΌ. 쑴재λ₯Ό ν™•μΈν•˜μ§€ μ•Šμ€ 파일/ν•¨μˆ˜λ₯Ό μ–ΈκΈ‰ν•˜μ§€ 마라.
- μˆ˜μ •μ€ μ΅œμ†Œ λ²”μœ„λ‘œ ν•˜κ³ , μ£Όλ³€ μ½”λ“œμ˜ μŠ€νƒ€μΌμ„ λ”°λ₯Έλ‹€.
- μ‹€νŒ¨ν•˜λ©΄ μ†”μ§ν•˜κ²Œ μ‹€νŒ¨ν–ˆλ‹€κ³  λ³΄κ³ ν•˜κ³  원인과 λ‹€μŒ μ‹œλ„λ₯Ό μ„€λͺ…ν•œλ‹€.
"""