Spaces:
Running
Running
Download shadow_brain_core/brain/coding_tools.py from taemin1980/shadowbrain: direct link, hf CLI and curl.
- Browser
- Download file 62.4 kB
-
https://huggingface.co/spaces/taemin1980/shadowbrain/resolve/main/shadow_brain_core/brain/coding_tools.py
- Command line
-
hf download hf://spaces/taemin1980/shadowbrain/shadow_brain_core/brain/coding_tools.py
-
curl -L -o coding_tools.py https://huggingface.co/spaces/taemin1980/shadowbrain/resolve/main/shadow_brain_core/brain/coding_tools.py
62.4 kB
| # -*- coding: utf-8 -*- | |
| """ | |
| ๐ฑ [Imperial Coding Arsenal] ์๋์ฐ ๋ธ๋ ์ธ ์์ด์ ํฑ ์ฝ๋ฉ ๋๊ตฌ ์ธํธ | |
| Claude Code ๋ฐฉ์์ ์ฝ๋ฉ ์ํฌํ๋ก(ํ์ โ ์ฝ๊ธฐ โ ์์ โ ๊ฒ์ฆ โ ๋ฐ๋ณต)๋ฅผ | |
| ์๋์ฐ ๋ธ๋ ์ธ์ ์ด์ํ๊ธฐ ์ํ ๋๊ตฌ ๋ชจ์์ ๋๋ค. | |
| - Gemini AFC ๊ฒฝ๋ก: CodingToolkit์ public ๋ฉ์๋๋ฅผ callable๋ก ์ง์ ๋ฑ๋ก | |
| - OpenAI ํธํ ๊ฒฝ๋ก(Atlas/OpenRouter): OPENAI_SCHEMAS + execute() ๋์คํจ์ฒ | |
| """ | |
| import fnmatch | |
| import hashlib | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import logging | |
| import threading | |
| import time | |
| logger = logging.getLogger("ShadowBrain.Coding") | |
| # ์์ ๋ฃจํธ โ ์ด ๋ฐ๊นฅ์ ํ์ผ์ ๊ฑด๋๋ฆฌ์ง ์๋๋ค (์์ ๊ฐ๋) | |
| DEFAULT_ROOT = r"D:\Git_Work\jarvis_taemin" | |
| # ๐ฑ [Per-Request Root Override] ํค์๋ฌ 'ํ๋ฏธ๋ ์ฝ๋'๊ฐ ๊ณ ๋ฅธ ํ๋ก์ ํธ ํด๋(cwd)๋ฅผ | |
| # ํด๋น ์์ฒญ ์ค๋ ๋์์๋ง ์์ ๋ฃจํธ๋ก ์ฐ๊ธฐ ์ํ ์ค๋ ๋ ๋ก์ปฌ ์ค๋ฒ๋ผ์ด๋. | |
| # think() ๋๊ตฌ ๋ฃจํ๋ ์์ฒญ ์ค๋ ๋ ์์์ ๋๊ธฐ์ ์ผ๋ก ๋๋ฏ๋ก thread-local์ด ์ ํํ ๊ฒฉ๋ฆฌํ๋ค. | |
| _ROOT_OVERRIDE = threading.local() | |
| def set_coding_root_override(path: str): | |
| """ํ์ฌ ์์ฒญ ์ค๋ ๋์ ์ฝ๋ฉ ์์ ๋ฃจํธ๋ฅผ ์ง์ ํ๋ค (์์ฒญ ์ข ๋ฃ ์ clear ํ์).""" | |
| _ROOT_OVERRIDE.value = os.path.abspath(path) if path else None | |
| def clear_coding_root_override(): | |
| _ROOT_OVERRIDE.value = None | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ๐งญ [Per-Turn Agent State] ํ๋ฏธ๋ ์ฝ๋ฉ ํด ์ํ (์ค๋ ๋ ๋ก์ปฌ, ๋ฃจํธ ์ค๋ฒ๋ผ์ด๋์ ๋์ผ ์๋ช ) | |
| # - read_files : ์ด๋ฒ ํด์ code_read_file ๋ก ์ฝ์ ํ์ผ ์งํฉ โ read-before-edit ๊ฐ์ | |
| # - plan : ๐ ํ๋ ๋ชจ๋ โ ํ์ผ ์์ /์์ฑยท์ํ ๋ณ๊ฒฝ ๋ช ๋ น์ '๋๊ตฌ ๋ ๋ฒจ'์์ ์ฐจ๋จ | |
| # - changes : ์ด๋ฒ ํด์ ์์ /์์ฑํ ํ์ผ ๋ชฉ๋ก [{path, action}] โ CLI ๋ณ๊ฒฝ ์์ฝ | |
| # - session : ์ธ์ ํค โ ์ฒดํฌํฌ์ธํธ(undo) ๋๋ ํ ๋ฆฌ ๊ท์ | |
| # ๋ฆด๋ ์ด(relays/taemini.py)๊ฐ ํด ์์์ begin_coding_turn, ์ข ๋ฃ์ end_coding_turn์ | |
| # ํธ์ถํ๋ค. ์ด ์ํ๊ฐ ์์ผ๋ฉด(ํ ์ฑ๋์ ์ฝ๋ฉ ๋ชจ๋) ๊ฐ๋๋ ์ ๋ถ ๋นํ์ฑ โ ๊ธฐ์กด ๋์ ์ ์ง. | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _TURN_STATE = threading.local() | |
| CHECKPOINT_BASE = os.path.join(os.path.expanduser("~"), ".shadow_brain", "checkpoints") | |
| _CHECKPOINT_KEEP = 20 # ์ธ์ ๋น ๋ณด๊ดํ๋ ํด ์ฒดํฌํฌ์ธํธ ์ | |
| def begin_coding_turn(session_key: str = "", plan_mode: bool = False, user_id: str = "anonymous"): | |
| _TURN_STATE.value = { | |
| "session": (session_key or "").strip(), | |
| "plan": bool(plan_mode), | |
| "uid": (user_id or "anonymous").strip() or "anonymous", | |
| "read_files": set(), | |
| "changes": [], | |
| "ckpt_dir": None, | |
| "backed_up": set(), | |
| } | |
| def end_coding_turn(): | |
| """ํด ์ํ๋ฅผ ๊ฑท๊ณ ์ด๋ฒ ํด ๋ณ๊ฒฝ ํ์ผ ๋ชฉ๋ก [{path, action}] ์ ๋ฐํํ๋ค. | |
| ๊ฐ์ ํ์ผ ๋คํ ์์ ์ 1๊ฑด์ผ๋ก ํฉ์น๋ 'create'๊ฐ ์์ผ๋ฉด ๊ทธ๊ฒ์ ์ฐ์ ํ๊ธฐ.""" | |
| st = getattr(_TURN_STATE, "value", None) | |
| _TURN_STATE.value = None | |
| seen = {} | |
| for ch in (st or {}).get("changes") or []: | |
| prev = seen.get(ch["path"]) | |
| if prev is None or ch["action"] == "create": | |
| seen[ch["path"]] = ch["action"] | |
| return [{"path": p, "action": a} for p, a in seen.items()] | |
| def _turn(): | |
| return getattr(_TURN_STATE, "value", None) | |
| def _session_ckpt_root(session_key: str) -> str: | |
| h = hashlib.sha1((session_key or "default").encode("utf-8")).hexdigest()[:16] | |
| return os.path.join(CHECKPOINT_BASE, h) | |
| def _checkpoint_before_change(full_path: str, root: str): | |
| """์์ /์์ฑ ์ง์ ์๋ณธ ๋ฐฑ์ โ /undo(ํด ๋จ์ ๋กค๋ฐฑ)์ ์ฌ๋ฃ. | |
| ํด๋น ํ์ผ๋ณ ์ต์ด 1ํ๋ง ๋ฐฑ์ (์๋ณธ ๋ณด์กด). ์คํจ๋ ์กฐ์ฉํ ๋ฌด์(ํธ์ง์ ๋ง์ง ์๋๋ค).""" | |
| st = _turn() | |
| if st is None or not st.get("session"): | |
| return | |
| try: | |
| if full_path in st["backed_up"]: | |
| return | |
| st["backed_up"].add(full_path) | |
| if st["ckpt_dir"] is None: | |
| sess_root = _session_ckpt_root(st["session"]) | |
| os.makedirs(sess_root, exist_ok=True) | |
| # ์ค๋๋ ํด ์ฒดํฌํฌ์ธํธ ์ ๋ฆฌ (์ธ์ ๋น ์ต์ _CHECKPOINT_KEEP๊ฐ ์ ์ง) | |
| turns = sorted(d for d in os.listdir(sess_root) | |
| if os.path.isdir(os.path.join(sess_root, d))) | |
| if len(turns) >= _CHECKPOINT_KEEP: | |
| for old in turns[:len(turns) - (_CHECKPOINT_KEEP - 1)]: | |
| shutil.rmtree(os.path.join(sess_root, old), ignore_errors=True) | |
| st["ckpt_dir"] = os.path.join(sess_root, str(int(time.time() * 1000))) | |
| os.makedirs(st["ckpt_dir"], exist_ok=True) | |
| manifest_path = os.path.join(st["ckpt_dir"], "manifest.json") | |
| try: | |
| with open(manifest_path, "r", encoding="utf-8") as f: | |
| manifest = json.load(f) or [] | |
| except Exception: | |
| manifest = [] | |
| entry = {"path": full_path, "backup": None} | |
| if os.path.isfile(full_path): | |
| if os.path.getsize(full_path) > 5 * 1024 * 1024: | |
| return # 5MB ์ด๊ณผ ํ์ผ์ ๋ฐฑ์ ์๋ต (undo ๋์์์ ์ ์ธ) | |
| bak = f"{len(manifest):04d}.bak" | |
| shutil.copy2(full_path, os.path.join(st["ckpt_dir"], bak)) | |
| entry["backup"] = bak | |
| manifest.append(entry) | |
| with open(manifest_path, "w", encoding="utf-8") as f: | |
| json.dump(manifest, f, ensure_ascii=False) | |
| except Exception as ck_err: | |
| logger.warning(f"[Coding Checkpoint] ๋ฐฑ์ ์คํจ(ํธ์ง์ ๊ณ์): {ck_err}") | |
| def _record_change(full_path: str, root: str, action: str): | |
| st = _turn() | |
| if st is None: | |
| return | |
| try: | |
| rel = os.path.relpath(full_path, root) | |
| except Exception: | |
| rel = full_path | |
| st["changes"].append({"path": rel, "action": action}) | |
| def undo_last_coding_turn(session_key: str): | |
| """์ธ์ ์ ๊ฐ์ฅ ์ต๊ทผ ํด ์ฒดํฌํฌ์ธํธ๋ฅผ ๋ณต์ํ๋ค (Claude Code /rewind ๋ฑ๊ฐ). | |
| ๋ฐํ: ๋ณต์๋ ํญ๋ชฉ ๋ฆฌ์คํธ [{path, action(restored|deleted)}]. ์์ผ๋ฉด ๋น ๋ฆฌ์คํธ.""" | |
| sess_root = _session_ckpt_root(session_key) | |
| if not os.path.isdir(sess_root): | |
| return [] | |
| turns = sorted((d for d in os.listdir(sess_root) | |
| if os.path.isdir(os.path.join(sess_root, d))), reverse=True) | |
| for turn in turns: | |
| turn_dir = os.path.join(sess_root, turn) | |
| manifest_path = os.path.join(turn_dir, "manifest.json") | |
| try: | |
| with open(manifest_path, "r", encoding="utf-8") as f: | |
| manifest = json.load(f) or [] | |
| except Exception: | |
| shutil.rmtree(turn_dir, ignore_errors=True) | |
| continue | |
| restored = [] | |
| for entry in reversed(manifest): | |
| path = entry.get("path") or "" | |
| bak = entry.get("backup") | |
| try: | |
| if bak: | |
| bak_full = os.path.join(turn_dir, os.path.basename(bak)) | |
| if os.path.isfile(bak_full): | |
| shutil.copy2(bak_full, path) | |
| restored.append({"path": path, "action": "restored"}) | |
| elif os.path.isfile(path): | |
| os.remove(path) # ์ด๋ฒ ํด์ ์๋ก ๋ง๋ ํ์ผ โ ์ญ์ ๋ก ๋ณต์ | |
| restored.append({"path": path, "action": "deleted"}) | |
| except Exception as undo_err: | |
| logger.warning(f"[Coding Undo] ๋ณต์ ์คํจ {path}: {undo_err}") | |
| shutil.rmtree(turn_dir, ignore_errors=True) | |
| if restored: | |
| return restored | |
| return [] | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ๐ [์๊ฐ ๋ฌด์ฅ] ์คํฌ ์๊ฐ ์์ฑ/์ฅ์ฐฉ โ ์์ด์ ํธ๊ฐ ํ์ํ ์ ๋ฌธ ์ง์์ ์ค์ค๋ก ๋ฒผ๋ ค ์ ๋๋ค. | |
| # ์คํฌ ๋ณผํธ: <repo_root>/.agent/skills/<name>/SKILL.md (์ง์ ์ฅ์ฐฉ ์คํฌ๊ณผ ๋์ผ ๋ณด๊ด์). | |
| # ์ธ์ ์ฅ์ฐฉ: local_guardian_store 'session_skills:<sessionKey>' โ ๋ฆด๋ ์ด๊ฐ ํด๋ง๋ค ์ฝ์ด | |
| # ์์คํ ์ง์์ ์ฃผ์ ํ๋ค. ์ง์ (job)์ ์ฅ์ฐฉ ๋ชฉ๋ก์ ์ ๋ ๊ฑด๋๋ฆฌ์ง ์๋๋ค(์ง์ =๋ง์๋ | |
| # ์๊ด, ํด๋ฐฑ ๊ธ์ง ํ์ฅ ๋ณด์กด) โ ์ธ์ ์ฅ์ฐฉ์ ๊ทธ ์์ ์นํ๋ ์์จ ๊ณ์ธต์ด๋ค. | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _SKILL_NAME_RE = re.compile(r"^[\w๊ฐ-ํฃ][\w๊ฐ-ํฃ .-]{0,60}$") | |
| _SESSION_SKILLS_CAP = 6 # ์ธ์ ๋น ์ฅ์ฐฉ ์ํ (์ด๊ณผ ์ ์ค๋๋ ๊ฒ๋ถํฐ ๋ฐ๋ ค๋จ) | |
| def _skills_dir(): | |
| override = os.environ.get("SHADOW_SKILLS_DIR") | |
| if override: | |
| return override | |
| repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| return os.path.join(repo_root, ".agent", "skills") | |
| def list_vault_skills(limit: int = 40): | |
| """๋ณผํธ์ ์กด์ฌํ๋ ์คํฌ ์ด๋ฆ ๋ชฉ๋ก (SKILL.md๊ฐ ์ค์ฌํ๋ ํด๋๋ง).""" | |
| try: | |
| d = _skills_dir() | |
| return sorted(n for n in os.listdir(d) | |
| if os.path.isfile(os.path.join(d, n, "SKILL.md")))[:limit] | |
| except Exception: | |
| return [] | |
| def session_skill_names(user_id: str, session_key: str): | |
| """์ธ์ ์ ์ฅ์ฐฉ๋ ์คํฌ ์ด๋ฆ ๋ชฉ๋ก (์คํ ์ด ๊ธฐ๋ก ์์ ์ ์ง).""" | |
| if not session_key: | |
| return [] | |
| try: | |
| from local_guardian_store import get_setting | |
| names = json.loads(get_setting(user_id or "anonymous", "local_taemini", | |
| f"session_skills:{session_key}", "[]") or "[]") | |
| return [str(n) for n in names] if isinstance(names, list) else [] | |
| except Exception: | |
| return [] | |
| def _equip_to_session(st, safe_name: str): | |
| """์ธ์ ์ฅ์ฐฉ ๊ธฐ๋ก ๊ณต์ฉ โ ์ฑ๊ณต ์ None, ์คํจ ์ ์ค๋ฅ ๋ฌธ์์ด (forge/equip ๊ณต์ ).""" | |
| try: | |
| from local_guardian_store import get_setting, set_setting | |
| key = f"session_skills:{st['session']}" | |
| uid = st.get("uid") or "anonymous" | |
| names = json.loads(get_setting(uid, "local_taemini", key, "[]") or "[]") | |
| if not isinstance(names, list): | |
| names = [] | |
| if safe_name in names: | |
| names.remove(safe_name) | |
| names.append(safe_name) | |
| names = names[-_SESSION_SKILLS_CAP:] | |
| set_setting(uid, "local_taemini", key, json.dumps(names, ensure_ascii=False)) | |
| return None | |
| except Exception as e: | |
| return str(e) | |
| def load_session_skills(user_id: str, session_key: str, max_chars_each: int = 1500): | |
| """์ธ์ ์ฅ์ฐฉ ์คํฌ [(name, content), ...] โ ๋ฆด๋ ์ด๊ฐ ํด๋ง๋ค ํธ์ถํด ํ๋กฌํํธ์ ์ฃผ์ . | |
| ํ์ผ์ ๋งค๋ฒ ์๋ก ์ฝ๋๋ค(์๊ฐ ๊ฐฑ์ ์ฆ์ ๋ฐ์ โ ์ง์ ์คํฌ ์บ์์ ๋ฌ๋ฆฌ ๋ฌด์บ์).""" | |
| out = [] | |
| for name in session_skill_names(user_id, session_key): | |
| safe = os.path.basename(name.strip()) | |
| path = os.path.join(_skills_dir(), safe, "SKILL.md") | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| text = f.read().strip() | |
| if len(text) > max_chars_each: | |
| text = text[:max_chars_each] + "\nโฆ(์ดํ ์๋ต)" | |
| out.append((safe, text)) | |
| except Exception: | |
| continue | |
| return out | |
| # ๐ ํ๋ ๋ชจ๋์์ code_run_command ๋ก ํ์ฉํ๋ '์ฝ๊ธฐ์ฑ' ๋ช ๋ น ์ ๋ (์ํ ๋ถ๋ณ๊ฒฝ) | |
| _PLAN_SAFE_CMD_PREFIXES = ( | |
| "git status", "git diff", "git log", "git show", "git branch", "git blame", | |
| "ls", "dir", "cat ", "head ", "tail ", "grep ", "rg ", "find ", "wc ", | |
| "tree", "pwd", "file ", "stat ", "du ", "type ", "where ", "which ", | |
| "python -m py_compile", "python3 -m py_compile", | |
| ) | |
| _PLAN_BLOCK_MSG = ("๐ ํ๋ ๋ชจ๋: ์ง๊ธ์ ๊ณํ ์๋ฆฝ ๋จ๊ณ๋ผ {what}์ด(๊ฐ) ๋๊ตฌ ๋ ๋ฒจ์์ " | |
| "์ฐจ๋จ๋์ด ์์ต๋๋ค. ์ด ์๋๋ ์คํ๋์ง ์์์ต๋๋ค โ ์ ๋ '์์ /์คํ ์๋ฃ'๋ผ๊ณ " | |
| "๋ณด๊ณ ํ์ง ๋ง์ญ์์ค(๊ฑฐ์ง ๋ณด๊ณ ๊ธ์ง). ๊ณํ(๋จ๊ณ๋ณ todos)๋ง ์ ๋ฆฌํด ๋ณด๊ณ ํ๊ณ , " | |
| "์คํ์ ๋ง์๋์ด ์ผ๋ฐ ๋ชจ๋๋ก ์ ํํ ๋ค์ ์งํํฉ๋๋ค.") | |
| def _loopback_headers(): | |
| """์๊ธฐ ์์ (127.0.0.1 ๋ฐ๋ชฌ)์ผ๋ก์ HTTP ๋ฃจํ๋ฐฑ์ฉ ์ธ์ฆ ํค๋ โ ์ค์น๋ณ ๋ก์ปฌ ํค. | |
| (auth_gate๊ฐ ๋ก์ปฌ ์์ฒญ์๋ X-Taemini-Key๋ฅผ ์๊ตฌํ๋ ๊ฐํ ํ๊ฒฝ ๋์ โ | |
| 2026-07-14 ์ ๋ฌธ๊ฐ ์ํ 401 ์ค์ฌ๊ณ , delegate_to_guardian๋ ๋์ผ ๊ฒฝ๋ก)""" | |
| try: | |
| with open(os.path.join(os.path.expanduser("~"), ".taemini", "shadow_local_token"), | |
| "r", encoding="utf-8") as f: | |
| tok = f.read().strip() | |
| return {"X-Taemini-Key": tok} if tok else {} | |
| except Exception: | |
| return {} | |
| # ๊ฒ์/ํ์ ์ ๋ฌด์ํ ๋๋ ํ ๋ฆฌ | |
| _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 root(self) -> str: | |
| """์์ฒญ ์ค๋ ๋์ ์ค๋ฒ๋ผ์ด๋(cwd)๊ฐ ์์ผ๋ฉด ๊ทธ ํด๋, ์์ผ๋ฉด ๊ธฐ๋ณธ ๋ฃจํธ.""" | |
| override = getattr(_ROOT_OVERRIDE, "value", None) | |
| return override if override else self._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 | |
| 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() | |
| st = _turn() | |
| if st is not None: | |
| st["read_files"].add(full) # read-before-edit ๊ฐ๋ ํด์ | |
| 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) | |
| st = _turn() | |
| if st is not None and st.get("plan"): | |
| return _PLAN_BLOCK_MSG.format(what="ํ์ผ ์์ฑ/๋ฎ์ด์ฐ๊ธฐ(code_write_file)") | |
| existed = os.path.isfile(full) | |
| if (existed and st is not None and full not in st["read_files"] | |
| and os.path.basename(full) not in _READ_EXEMPT_FILES): | |
| return ("Error: ๊ธฐ์กด ํ์ผ์ ์ฝ์ง ์๊ณ ํต์งธ๋ก ๋ฎ์ด์ฐ๋ ๊ฒ์ ๊ธ์ง์ ๋๋ค. " | |
| f"๋จผ์ code_read_file('{path}') ๋ก ํ์ฌ ๋ด์ฉ์ ํ์ธํ ๋ค, " | |
| "๋ถ๋ถ ์์ ์ด๋ฉด code_edit_file ์ ์ฌ์ฉํ์ญ์์ค.") | |
| _checkpoint_before_change(full, self.root) | |
| os.makedirs(os.path.dirname(full), exist_ok=True) | |
| with open(full, "w", encoding="utf-8", newline="") as f: | |
| f.write(content or "") | |
| if st is not None: | |
| st["read_files"].add(full) | |
| _record_change(full, self.root, "write" if existed else "create") | |
| verb = "๋ฎ์ด์" if existed else "์ ํ์ผ ์์ฑ" | |
| return f"OK: wrote {len(content or '')} chars to {path} ({verb})" | |
| except Exception as e: | |
| return f"Error: {e}" | |
| def _edit_failure_hint(lines, old_string) -> str: | |
| """old_string ๋ถ์ผ์น ์ ์๊ฐ๊ต์ ํํธ โ ์ฒซ ์ ํจ ์ค๊ณผ ๊ฐ์ฅ ๋น์ทํ ์์น์ ์ค์ | |
| ์๋ฌธ(์ค๋ฒํธ ํฌํจ)์ ๋ณด์ฌ์ค ๋ชจ๋ธ์ด ์ ํํ ์๋ฌธ์ ๋ค์ ๋ณต์ฌํ๊ฒ ํ๋ค. | |
| ์ ํ/ํฌํจ ์ผ์น โ difflib ์ ์ฌ๋(โฅ0.55) ์์ผ๋ก ํ์ (์คํยท๊ณต๋ฐฑ ์ฐจ์ด ๋ฐฉ์ด).""" | |
| import difflib | |
| first = next((l.strip() for l in (old_string or "").splitlines() if l.strip()), "") | |
| if not first: | |
| return "" | |
| best_i, best_r = -1, 0.55 | |
| for i, line in enumerate(lines[:8000]): | |
| ls = line.strip() | |
| if not ls: | |
| continue | |
| if ls == first or (len(first) > 12 and first in line): | |
| best_i, best_r = i, 1.0 | |
| break | |
| r = difflib.SequenceMatcher(None, first, ls).ratio() | |
| if r > best_r: | |
| best_i, best_r = i, r | |
| if best_i >= 0: | |
| lo, hi = max(0, best_i - 2), min(len(lines), best_i + 7) | |
| snippet = "".join(f"{n + 1:>5}\t{lines[n]}" for n in range(lo, hi)) | |
| return ("\n๋น์ทํ ์์น์ ์ค์ ์๋ฌธ(์ฐธ๊ณ ):\n" + snippet | |
| + "์ ์๋ฌธ์ ๋ค์ฌ์ฐ๊ธฐยท๊ณต๋ฐฑ๊น์ง ๊ทธ๋๋ก old_string์ ๋ณต์ฌํด ๋ค์ ์๋ํ์ญ์์ค.") | |
| return "\n(ํ์ผ์์ ๋น์ทํ ์ค๋ ์ฐพ์ง ๋ชปํ์ต๋๋ค โ code_grep ์ผ๋ก ์์น๋ถํฐ ๋ค์ ํ์ธํ์ญ์์ค.)" | |
| def _snippet_around(new_text, pos, added_lines, path) -> str: | |
| """ํธ์ง ์ง์ ์ฃผ๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ โ ๋ชจ๋ธ์ด ์ฌ์ฝ๊ธฐ ์์ด ๊ฒฐ๊ณผ๋ฅผ ํ์ธํ๊ฒ ํ๋ค.""" | |
| lines = new_text.splitlines(keepends=True) | |
| line_no = new_text[:pos].count("\n") # 0-based | |
| lo = max(0, line_no - 2) | |
| hi = min(len(lines), line_no + added_lines + 3) | |
| if hi - lo > 14: | |
| hi = lo + 14 | |
| return "".join(f"{n + 1:>5}\t{lines[n]}" for n in range(lo, hi)) | |
| 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}" | |
| st = _turn() | |
| if st is not None and st.get("plan"): | |
| return _PLAN_BLOCK_MSG.format(what="ํ์ผ ์์ (code_edit_file)") | |
| if (st is not None and full not in st["read_files"] | |
| and os.path.basename(full) not in _READ_EXEMPT_FILES): | |
| return (f"Error: ์ฝ์ง ์์ ํ์ผ์ ์์ ํ ์ ์์ต๋๋ค. ๋จผ์ code_read_file('{path}') ๋ก " | |
| "์์ ํ ๋ถ๋ถ์ ์ค์ ์๋ฌธ์ ํ์ธํ์ญ์์ค (์ด๋ฒ ํด ๊ธฐ์ค).") | |
| with open(full, "r", encoding="utf-8") as f: | |
| text = f.read() | |
| if old_string not in text: | |
| return ("Error: old_string์ ํ์ผ์์ ์ฐพ์ง ๋ชปํ์ต๋๋ค." | |
| + self._edit_failure_hint(text.splitlines(keepends=True), old_string)) | |
| count = text.count(old_string) | |
| if count > 1 and not replace_all: | |
| return f"Error: old_string์ด {count}๋ฒ ๋ฑ์ฅํฉ๋๋ค. ๋ ๊ธธ๊ณ ๊ณ ์ ํ ๋ฌธ์์ด์ ์ฐ๊ฑฐ๋ replace_all=true๋ฅผ ์ง์ ํ์ธ์." | |
| _checkpoint_before_change(full, self.root) | |
| pos = text.index(old_string) | |
| 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) | |
| _record_change(full, self.root, "edit") | |
| n = count if replace_all else 1 | |
| preview = self._snippet_around(new_text, pos, (new_string or "").count("\n"), path) | |
| return (f"OK: {path} โ {n}๊ณณ ์์ ์๋ฃ\n์์ ๊ฒฐ๊ณผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ(์ฌ์ฝ๊ธฐ ๋ถํ์):\n{preview}") | |
| 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}" | |
| st = _turn() | |
| if st is not None and st.get("plan"): | |
| stripped = low.strip() | |
| if not any(stripped.startswith(p) for p in _PLAN_SAFE_CMD_PREFIXES): | |
| return _PLAN_BLOCK_MSG.format(what=f"์ํ ๋ณ๊ฒฝ ๊ฐ๋ฅ ๋ช ๋ น ์คํ({command[:60]})") | |
| 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}" | |
| # โโ ๋๊ตฌ: ๐ ์๊ฐ ๋ฌด์ฅ (์คํฌ ์๊ฐ ์์ฑ/์ฅ์ฐฉ) โโโโโโโโโโโโโโโโ | |
| def code_forge_skill(self, name: str, content: str) -> str: | |
| """Creates or updates a reusable SKILL document in the shared skill vault. Use when the task needs domain knowledge/procedures no equipped skill covers โ distill the know-how (commands, pitfalls, steps), then equip it with code_equip_skill. | |
| Args: | |
| name: Skill name (Korean/English letters, digits, space, ._- up to 60 chars) | |
| content: The skill document body (markdown). Min 40 chars. | |
| """ | |
| st = _turn() | |
| if st is not None and st.get("plan"): | |
| return _PLAN_BLOCK_MSG.format(what="์คํฌ ์์ฑ(code_forge_skill)") | |
| safe = (name or "").strip() | |
| # basename์ผ๋ก ์กฐ์ฉํ ์๋ผ๋ด์ง ์๋๋ค โ ๊ฒฝ๋ก ๋ฌธ์๊ฐ ์์ธ ์ด๋ฆ์ ๋ช ์์ ๊ฑฐ๋ถ | |
| if not safe or safe != os.path.basename(safe) or not _SKILL_NAME_RE.match(safe): | |
| return "Error: ์คํฌ ์ด๋ฆ์ ํ๊ธ/์๋ฌธ/์ซ์/๊ณต๋ฐฑ/._- 60์ ์ด๋ด์ฌ์ผ ํฉ๋๋ค (๊ฒฝ๋ก ๋ฌธ์ ๊ธ์ง)." | |
| body = (content or "").strip() | |
| if len(body) < 40: | |
| return "Error: ์คํฌ ๋ด์ฉ์ด ๋๋ฌด ์งง์ต๋๋ค(40์ ๋ฏธ๋ง) โ ์ฌ์ฌ์ฉ ๊ฐ์น๊ฐ ์๋ ์ ์ฐจ/์ง์์ ๋ด์ผ์ญ์์ค." | |
| body = body[:20000] | |
| try: | |
| d = os.path.join(_skills_dir(), safe) | |
| os.makedirs(d, exist_ok=True) | |
| with open(os.path.join(d, "SKILL.md"), "w", encoding="utf-8") as f: | |
| f.write(body + "\n") | |
| except Exception as e: | |
| return f"Error: ์คํฌ ์ ์ฅ ์คํจ: {e}" | |
| try: # ์ง์ ์ฅ์ฐฉ ์บ์ ๋ฌดํจํ โ ๊ฐ์ ์ด๋ฆ ์คํฌ ๊ฐฑ์ ์ด ์ฆ์ ๋ฐ์๋๊ฒ | |
| from services.brain_service import LOGIC_STATE | |
| b = LOGIC_STATE.get("jarvis_brain") | |
| if b is not None and hasattr(b, "_skill_cache"): | |
| b._skill_cache.pop(safe, None) | |
| except Exception: | |
| pass | |
| # โ๏ธ ์ ์ฅ ์ฆ์ ์ด ์ธ์ ์ ์๋ ์ฅ์ฐฉ โ "๋ง๋ค๊ณ ์ฅ์ฐฉํ๊ฒ ๋ค"๊ณ ๋ง๋ง ํ๊ณ equip์ | |
| # ์ ๋ถ๋ฅด๋ ์ฝํ ๋ชจ๋ธ ์ค๊ด์ธก(2026-07-13) ๋๋ฌธ์ ๋๊ตฌ ์๋ฏธ๋ก ์ forge=์์ฑ+์ฅ์ฐฉ์ผ๋ก. | |
| equip_err = _equip_to_session(st, safe) if (st and st.get("session")) else "์ธ์ ์์" | |
| if equip_err: | |
| return (f"OK: ๐ ์คํฌ '{safe}' ์ ์ฅ({len(body)}์). " | |
| f"(์ธ์ ์ฅ์ฐฉ์ ์ ๋จ: {equip_err} โ code_equip_skill ๋ก ์ฅ์ฐฉ ๊ฐ๋ฅ)") | |
| return (f"OK: ๐ ์คํฌ '{safe}' ์ ์ฅ({len(body)}์) + ์ด ์ธ์ ์ ์ฅ์ฐฉ ์๋ฃ โ " | |
| "๋ค์ ํด๋ถํฐ ์๋ ์ฃผ์ ๋ฉ๋๋ค.") | |
| def code_equip_skill(self, name: str) -> str: | |
| """Equips a vault skill onto THIS session โ its content is injected into your system context every following turn, and returned right now for immediate use. If the name doesn't match, available skills are listed. | |
| Args: | |
| name: Skill name to equip (as stored in the vault) | |
| """ | |
| st = _turn() | |
| if st is None or not st.get("session"): | |
| return "Error: ์ธ์ ์ ๋ณด๊ฐ ์์ด ์ฅ์ฐฉํ ์ ์์ต๋๋ค (ํ๋ฏธ๋ ์ฝ๋ฉ ์ฝ์ ํด์์๋ง ์ง์)." | |
| safe = os.path.basename((name or "").strip()) | |
| path = os.path.join(_skills_dir(), safe, "SKILL.md") | |
| if not os.path.isfile(path): | |
| avail = list_vault_skills(20) | |
| return ("Error: ๊ทธ๋ฐ ์คํฌ์ด ์์ต๋๋ค: " + safe | |
| + (f"\n์ฌ์ฉ ๊ฐ๋ฅ: {', '.join(avail)}" if avail | |
| else "\n(๋ณผํธ๊ฐ ๋น์ด ์์ต๋๋ค โ code_forge_skill ๋ก ๋ง๋์ญ์์ค.)")) | |
| equip_err = _equip_to_session(st, safe) | |
| if equip_err: | |
| return f"Error: ์ฅ์ฐฉ ๊ธฐ๋ก ์คํจ: {equip_err}" | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| text = f.read().strip()[:1500] | |
| except Exception: | |
| text = "(๋ด์ฉ ์ฝ๊ธฐ ์คํจ)" | |
| return (f"OK: ๐ '{safe}' ์ฅ์ฐฉ ์๋ฃ โ ๋ค์ ํด๋ถํฐ ์๋ ์ฃผ์ ๋ฉ๋๋ค. " | |
| f"์ง๊ธ ๋ฐ๋ก ํ์ฉํ ๋ด์ฉ:\n{text}") | |
| # โโ ๋๊ตฌ: ๐ง ์์ ๋๊ตฌ (์คํฌ๋ฆฝํธ ์ ์ โ ์คํฌํ) โโโโโโโโโโโโโ | |
| def code_forge_tool(self, name: str, script: str, usage: str) -> str: | |
| """Creates a self-made TOOL: a Python script saved in the skill vault, plus a SKILL doc describing exactly how to run it. Auto-equipped. Re-forge the same name to improve it (evolution). Run it later with code_run_command. | |
| Args: | |
| name: Tool name (letters/digits/space/._- up to 60 chars) | |
| script: Python source code (syntax-checked before saving) | |
| usage: How/when to use it โ arguments, examples, caveats | |
| """ | |
| st = _turn() | |
| if st is not None and st.get("plan"): | |
| return _PLAN_BLOCK_MSG.format(what="๋๊ตฌ ์์ฑ(code_forge_tool)") | |
| safe = (name or "").strip() | |
| if not safe or safe != os.path.basename(safe) or not _SKILL_NAME_RE.match(safe): | |
| return "Error: ๋๊ตฌ ์ด๋ฆ์ ํ๊ธ/์๋ฌธ/์ซ์/๊ณต๋ฐฑ/._- 60์ ์ด๋ด์ฌ์ผ ํฉ๋๋ค (๊ฒฝ๋ก ๋ฌธ์ ๊ธ์ง)." | |
| src = (script or "").strip() | |
| if len(src) < 40: | |
| return "Error: ์คํฌ๋ฆฝํธ๊ฐ ๋๋ฌด ์งง์ต๋๋ค(40์ ๋ฏธ๋ง)." | |
| try: | |
| compile(src, f"{safe}.py", "exec") # ๋ฌธ๋ฒ ๊ฒ์ฌ โ ๊นจ์ง ๋๊ตฌ๋ฅผ ๋ณผํธ์ ๋ฃ์ง ์๋๋ค | |
| except SyntaxError as se: | |
| return (f"Error: ํ์ด์ฌ ๋ฌธ๋ฒ ์ค๋ฅ(line {se.lineno}): {se.msg}\n" | |
| "์คํฌ๋ฆฝํธ๋ฅผ ๊ณ ์ณ ๊ฐ์ ์ด๋ฆ์ผ๋ก ๋ค์ forge ํ์ญ์์ค.") | |
| d = os.path.join(_skills_dir(), safe) | |
| tool_path = os.path.join(d, "tool.py") | |
| try: | |
| os.makedirs(d, exist_ok=True) | |
| with open(tool_path, "w", encoding="utf-8") as f: | |
| f.write(src[:30000] + "\n") | |
| doc = (f"# ๐ง ์์ ๋๊ตฌ: {safe}\n\n" | |
| f"์คํ: `python3 \"{tool_path}\" [์ธ์...]` (code_run_command ๋ก ํธ์ถ)\n\n" | |
| f"## ์ฌ์ฉ๋ฒ\n{(usage or '').strip()[:3000]}\n\n" | |
| f"## ๊ฐ์ \n๋ถ์ ํํ๋ฉด code_forge_tool('{safe}', ์์ ์คํฌ๋ฆฝํธ, ์ฌ์ฉ๋ฒ)์ผ๋ก ๋ฎ์ด์จ ์งํ์ํฌ ๊ฒ.\n") | |
| with open(os.path.join(d, "SKILL.md"), "w", encoding="utf-8") as f: | |
| f.write(doc) | |
| except Exception as e: | |
| return f"Error: ๋๊ตฌ ์ ์ฅ ์คํจ: {e}" | |
| equip_err = _equip_to_session(st, safe) if (st and st.get("session")) else "์ธ์ ์์" | |
| tail = "" if equip_err else " + ์ฌ์ฉ๋ฒ ์คํฌ ์ฅ์ฐฉ ์๋ฃ(๋ค์ ํด๋ถํฐ ์๋ ์ฃผ์ )" | |
| return (f"OK: ๐ง ์์ ๋๊ตฌ '{safe}' ์ ์ฅ(๋ฌธ๋ฒ ๊ฒ์ฌ ํต๊ณผ){tail}.\n" | |
| f"์คํ: code_run_command(\"python3 \\\"{tool_path}\\\" ...\")") | |
| # โโ ๋๊ตฌ: ๐ง ์ฅ๊ธฐ๊ธฐ์ต (SQLite ๋ฒกํฐ + ์ต์๋์ธ ๋ณผํธ ๋์ ๊ธฐ๋ก) โโ | |
| MEMORY_CONTEXT = "taemini-lessons" | |
| MEMORY_NOTE = "Knowledge/ํ๋ฏธ๋ ์ฅ๊ธฐ๊ธฐ์ต.md" | |
| def code_remember(self, lesson: str, topic: str = "") -> str: | |
| """Engraves a reusable lesson/fact into LONG-TERM memory so future sessions recall it: SQLite vector memory (semantic recall, auto-injected as [์ฅ๊ธฐ๊ธฐ์ต ํ์]) + the local Obsidian vault note. Use for cross-project lessons; workspace facts go to TAEMINI.md instead. | |
| Args: | |
| lesson: The lesson/fact worth remembering (min 20 chars) | |
| topic: Short topic tag (optional) | |
| """ | |
| st = _turn() | |
| body = (lesson or "").strip() | |
| if len(body) < 20: | |
| return "Error: ๊ตํ์ด ๋๋ฌด ์งง์ต๋๋ค(20์ ๋ฏธ๋ง) โ ๋ค์์๋ ์ธ ๊ตฌ์ฒด์ ๋ด์ฉ์ ๋ด์ผ์ญ์์ค." | |
| body = body[:2000] | |
| tag = (topic or "").strip()[:40] | |
| text = (f"[๊ตํ/{tag}] " if tag else "[๊ตํ] ") + body | |
| uid = (st or {}).get("uid") or "anonymous" | |
| stores = [] | |
| try: # โ SQLite ์ฅ๊ธฐ๊ธฐ์ต (chat_history + vec_memory ์๋ฒ ๋ฉ โ recall์ ์ฌ๋ฃ) | |
| import local_memory | |
| if local_memory.remember(uid, self.MEMORY_CONTEXT, "local_taemini", "assistant", text): | |
| stores.append("SQLite ๋ฒกํฐ๊ธฐ์ต") | |
| except Exception as mem_err: | |
| logger.warning(f"[Coding Memory] SQLite ๊ธฐ๋ก ์คํจ: {mem_err}") | |
| if os.environ.get("SHADOW_MEMORY_NO_OBSIDIAN") == "1": | |
| return (f"OK: ๐ง ์ฅ๊ธฐ๊ธฐ์ต์ ์๊น({' + '.join(stores)}) โ ์ต์๋์ธ ๊ธฐ๋ก์ " | |
| "ํ๊ฒฝ๋ณ์๋ก ๋นํ์ฑ(๊ฒ์ฆ ๋ชจ๋).") if stores else "Error: ์ ์ฅ ์คํจ." | |
| try: # โก ์ต์๋์ธ ๋ณผํธ ์ง์ ๋ ธํธ (์ฌ๋์ด ์ฝ๋ ์ถ์ ์ฅ๋ถ) โ ๋์ผ ๊ตํ ์ค๋ณต ์๊น ๋ฐฉ์ง | |
| from obsidian_jobs import _vault | |
| v = _vault("local") | |
| if v is not None: | |
| try: | |
| _, _existing = v.read_note(self.MEMORY_NOTE) | |
| except Exception: | |
| _existing = "" | |
| if text in _existing: | |
| stores.append("์ต์๋์ธ ๋ณผํธ(์ด๋ฏธ ์์)") | |
| else: | |
| import datetime as _dt | |
| entry = f"- {_dt.date.today().isoformat()} {text}\n" | |
| v.write_note(self.MEMORY_NOTE, entry, mode="append") | |
| stores.append("์ต์๋์ธ ๋ณผํธ") | |
| except Exception as obs_err: | |
| logger.warning(f"[Coding Memory] ์ต์๋์ธ ๊ธฐ๋ก ์คํจ: {obs_err}") | |
| if not stores: | |
| return "Error: ์ฅ๊ธฐ๊ธฐ์ต ์ ์ฅ์ ์คํจํ์ต๋๋ค (SQLite/์ต์๋์ธ ๋ชจ๋ ๋ถ๊ฐ)." | |
| return (f"OK: ๐ง ์ฅ๊ธฐ๊ธฐ์ต์ ์๊น({' + '.join(stores)}) โ ๋ค์ ์ธ์ ๋ถํฐ ๊ด๋ จ ์์ ์์ " | |
| "์๋ ํ์๋ฉ๋๋ค.") | |
| # โโ ๋๊ตฌ: ๐งฌ ์๊ฐ ์งํ ์ง์ (์์ด์ ํธ ์ ์ฉ ์ง์ ์ ์ง์นจ/์ฅ์ฐฉ ์๊ฐ ๊ฐ์ ) โโ | |
| SELF_JOB_ID = "taemini_self" | |
| SELF_JOB_LABEL = "์๊ฐ์งํ ํ๋ฏธ๋" | |
| SELF_JOB_LEARNED_HEADER = "## ์ค์ค๋ก ๋ฐฐ์ด ์ง์นจ" | |
| SELF_JOB_RULE_CAP = 4000 # ์ง์นจ ์ด๋ ์ํ โ ์ฝํ ๋ชจ๋ธ ์ปจํ ์คํธ ๋ณดํธ | |
| def code_evolve_job(self, rule: str = "", add_skills: str = "") -> str: | |
| """Evolves YOUR OWN job 'taemini_self': APPENDS a learned directive (base rules are preserved, never replaced) and/or equips skills, then activates the job. Call with NO args first to see the current state. Other jobs are untouchable (owner-only). | |
| Args: | |
| rule: ONE new lesson-directive to append to your learned rules (min 20 chars). Distill what you learned about how to work well. | |
| add_skills: Comma-separated vault skill names to permanently equip on this job. | |
| """ | |
| st = _turn() | |
| try: | |
| from obsidian_jobs import get_vault_job, save_vault_job | |
| from local_guardian_store import get_local_guardian_job, set_local_guardian_job | |
| except Exception as e: | |
| return f"Error: ์ง์ ์์คํ ๋ชจ๋ ๋ก๋ ์คํจ: {e}" | |
| uid = (st or {}).get("uid") or "anonymous" | |
| try: | |
| cur = get_vault_job(self.SELF_JOB_ID, "local") or {} | |
| except Exception as e: | |
| return f"Error: ์ง์ ๋ณผํธ ์ ๊ทผ ์คํจ(์ต์๋์ธ Vault ๋นํ์ฑ?): {e}" | |
| cur_rule = str(cur.get("rule") or "").strip() | |
| cur_skills = list(cur.get("equipped_skills") or []) | |
| rule = (rule or "").strip() | |
| adds = [s.strip() for s in (add_skills or "").split(",") if s.strip()] | |
| if not rule and not adds: # ์กฐํ ๋ชจ๋ โ ๊ฐ์ ์ ํ์ฌ ์ํ ํ์ธ(read-before-write) | |
| active = get_local_guardian_job(uid, "local_taemini") == self.SELF_JOB_ID | |
| return (f"[{self.SELF_JOB_LABEL} ํ์ฌ ์ํ] ํ์ฑ: {'์' if active else '์๋์ค'}\n" | |
| f"์ฅ์ฐฉ ์คํฌ: {cur_skills or '(์์)'}\n์ง์นจ:\n{cur_rule or '(์์ง ์์)'}") | |
| if st is not None and st.get("plan"): | |
| return _PLAN_BLOCK_MSG.format(what="์ง์ ๊ฐ์ (code_evolve_job)") | |
| if rule and len(rule) < 20: | |
| return "Error: ์ง์นจ์ด ๋๋ฌด ์งง์ต๋๋ค(20์ ๋ฏธ๋ง) โ ์ผํ๋ฉฐ ๋ฐฐ์ด ์์น์ ๊ตฌ์ฒด์ ์ผ๋ก ์ ์ผ์ญ์์ค." | |
| # ๐งฌ ์ง์นจ์ '๋์ ' โ ๊ธฐ๋ฐ ์์น(์๋น์ค๊ฐ ์ฌ์ ์ฌ๊ณ ๋ฐฉ์)์ ์ฝํ ๋ชจ๋ธ์ ์๊ฐ ๊ฐ์ ์ด | |
| # ํต์งธ๋ก ๋์ฒดํด ์ง์๋ฒ๋ฆฌ๋ ์ฌ๊ณ ๋ฐฉ์ง. ์ ์ง์นจ์ '์ค์ค๋ก ๋ฐฐ์ด ์ง์นจ' ์น์ ์ append. | |
| new_rule = cur_rule | |
| if rule: | |
| if rule in cur_rule: | |
| return f"OK: ์ด๋ฏธ ์ง์นจ์ ์๋ ๋ด์ฉ์ ๋๋ค โ ์ค๋ณต ์ถ๊ฐ ์๋ต.\nํ์ฌ ์ง์นจ:\n{cur_rule[-600:]}" | |
| entry = "- " + rule | |
| if self.SELF_JOB_LEARNED_HEADER in cur_rule: | |
| new_rule = cur_rule.rstrip() + "\n" + entry | |
| else: | |
| new_rule = ((cur_rule.rstrip() + "\n\n") if cur_rule else "") \ | |
| + self.SELF_JOB_LEARNED_HEADER + " (code_evolve_job์ด ๋์ )\n" + entry | |
| if len(new_rule) > self.SELF_JOB_RULE_CAP: | |
| return ("Error: ์ง์นจ์ด ๊ฐ๋ ์ฐผ์ต๋๋ค(4000์) โ ๋ง์๋๊ป ์ง์ ๊ด๋ฆฌ์์์ " | |
| "์ ๋ฆฌ๋ฅผ ์์ฒญํ๊ฑฐ๋, ์ธ๋ถ ๋ด์ฉ์ code_remember/์คํฌ๋ก ์ฎ๊ธฐ์ญ์์ค.") | |
| missing = [s for s in adds if not os.path.isfile(os.path.join(_skills_dir(), s, "SKILL.md"))] | |
| if missing: | |
| return (f"Error: ๋ณผํธ์ ์๋ ์คํฌ: {missing} โ code_forge_skill/tool ๋ก ๋จผ์ ๋ง๋์ญ์์ค. " | |
| f"์ฌ์ฉ ๊ฐ๋ฅ: {', '.join(list_vault_skills(20))}") | |
| for s in adds: | |
| if s in cur_skills: | |
| cur_skills.remove(s) | |
| cur_skills.append(s) | |
| cur_skills = cur_skills[-12:] | |
| job = { | |
| "job_id": self.SELF_JOB_ID, "label": self.SELF_JOB_LABEL, | |
| "description": "ํ๋ฏธ๋๊ฐ ์ค์ค๋ก ๊ฐ์ ํ๋ ์ ์ฉ ์ง์ (์ง์นจยท์ฅ์ฐฉ ์คํฌ ์๊ฐ ์งํ)", | |
| "tool_mode": "all", "equipped_skills": cur_skills, | |
| "rule": new_rule, | |
| } | |
| try: | |
| save_vault_job(job, "local") | |
| set_local_guardian_job(uid, "local_taemini", self.SELF_JOB_ID) | |
| except Exception as e: | |
| return f"Error: ์ง์ ์ ์ฅ/์ฅ์ฐฉ ์คํจ: {e}" | |
| return (f"OK: ๐งฌ '{self.SELF_JOB_LABEL}' ๊ฐ์ + ํ์ฑํ โ ์ง์นจ {len(new_rule)}์(๋์ ), " | |
| f"์ฅ์ฐฉ ์คํฌ {cur_skills or '(์์)'}. ๋ค์ ํด๋ถํฐ ์ง์นจ์ด ์ ์ฉ๋ฉ๋๋ค.") | |
| # โโ ๋๊ตฌ: ๐งโ๐ฌ ์ ๋ฌธ๊ฐ ์ํ (๋งํ ๋ํ โ ์ค์ค๋ก ์ ๋ฌธ๊ฐ๋ฅผ ์ ์ํด 2์ฐจ ์๊ฒฌ) โโ | |
| def code_consult_expert(self, specialty: str, question: str, context: str = "") -> str: | |
| """Summons an on-demand EXPERT persona (a specialist YOU define) and asks it your question โ a second brain configuration for when you're stuck. Pass the specialty, a self-contained question, and the relevant context you gathered. Verify its claims before adopting them. | |
| Args: | |
| specialty: Expert field, e.g. 'ํ์ด์ฌ ์ฑ๋ฅ ์ต์ ํ', '๋คํธ์ํฌ ๋ณด์' | |
| question: Self-contained question (min 10 chars) | |
| context: Relevant findings/code excerpts you already gathered | |
| """ | |
| spec = " ".join((specialty or "").split())[:60] | |
| q = (question or "").strip() | |
| if not spec or len(q) < 10: | |
| return "Error: specialty์ question(10์ ์ด์)์ด ํ์ํฉ๋๋ค." | |
| sys_i = (f"๋๋ '{spec}' ๋ถ์ผ ์ต๊ณ ์ ๋ฌธ๊ฐ๋ก ์ํ๋์๋ค. ์๋ ์ง๋ฌธ์ ๊ทธ ์ ๋ฌธ๊ฐ๋ก์ ๋ตํ๋ผ.\n" | |
| "- ๊ฒฐ๋ก ๋ถํฐ, ๊ทผ๊ฑฐ์ ํจ๊ป 5๋ฌธ์ฅ ์ด๋ด๋ก. ํ์คํ์ง ์์ ๋ถ๋ถ์ '๋ถํ์ค'์ด๋ผ๊ณ ํ๊ธฐํ๋ผ.\n" | |
| "- ๋ชจ๋ฅด๋ฉด ๋ชจ๋ฅธ๋ค๊ณ ๋ตํ๋ผ. ์ง์ด๋ด๋ ๊ฒ์ ๊ธ์ง๋ค. ๋๊ตฌ๋ฅผ ํธ์ถํ์ง ๋ง๊ณ ์ง์์ผ๋ก ๋ตํ๋ผ.") | |
| body = { | |
| "message": ((f"[์๋ขฐ ์ปจํ ์คํธ]\n{(context or '').strip()[:4000]}\n\n" if (context or "").strip() else "") | |
| + f"[์ ๋ฌธ๊ฐ ์๋ฌธ ์ง๋ฌธ]\n{q[:2000]}"), | |
| "sessionKey": f"expert-{hashlib.sha1((spec + str(time.time())).encode()).hexdigest()[:8]}", | |
| "skipGrounding": True, | |
| "extraSystem": sys_i, | |
| } | |
| try: # ์์(delegate)๊ณผ ๋์ผํ ์๊ธฐ HTTP ๋ฃจํ๋ฐฑ โ ์ค์ฒฉ think() ์ํ ์ค์ผ ๋ฐฉ์ง | |
| import requests as _rq | |
| port = (os.environ.get("SHADOW_PORT") or "18700").strip() | |
| r = _rq.post(f"http://127.0.0.1:{port}/api/shadow/shadow/send", json=body, | |
| headers=_loopback_headers(), timeout=300) | |
| data = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} | |
| except Exception as e: | |
| return f"Error: ์ ๋ฌธ๊ฐ ์ํ ์คํจ: {e}" | |
| if not data or data.get("status") == "ERROR": | |
| return f"Error: ์ ๋ฌธ๊ฐ ์๋ต ์คํจ: {(data or {}).get('message') or f'HTTP {r.status_code}'}" | |
| ans = (data.get("content") or "").strip()[:4000] | |
| if not ans: | |
| return "Error: ์ ๋ฌธ๊ฐ ์๋ต์ด ๋น์์ต๋๋ค โ ์ง๋ฌธ์ ๋ ๊ตฌ์ฒด์ ์ผ๋ก ๋ฐ๊ฟ ์ฌ์๋ํ์ญ์์ค." | |
| return (f"[๐งโ๐ฌ '{spec}' ์ ๋ฌธ๊ฐ ๋ต๋ณ]\n{ans}\n" | |
| "(์ ๋ฌธ๊ฐ ์๊ฒฌ๋ ๊ฒ์ฆ ๋์์ด๋ค โ ์ฌ์ค ์ฃผ์ฅ์ ๋๊ตฌ๋ก ํ์ธํ ๋ค ์ฑํํ๋ผ.)") | |
| # โโ OpenAI ํธํ ๋์คํจ์ฒ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| TOOL_NAMES = ( | |
| "code_list_dir", "code_glob", "code_grep", | |
| "code_read_file", "code_write_file", "code_edit_file", "code_run_command", | |
| "code_forge_skill", "code_equip_skill", "code_forge_tool", "code_evolve_job", | |
| "code_remember", "code_consult_expert", | |
| ) | |
| 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"], | |
| ), | |
| _schema( | |
| "code_forge_skill", | |
| "Creates/updates a reusable SKILL document in the shared vault. Use when the task needs know-how no equipped skill covers โ distill it (steps, commands, pitfalls), then equip with code_equip_skill.", | |
| { | |
| "name": {"type": "string", "description": "Skill name (letters/digits/space/._- up to 60 chars)"}, | |
| "content": {"type": "string", "description": "Skill body markdown (min 40 chars)"}, | |
| }, | |
| ["name", "content"], | |
| ), | |
| _schema( | |
| "code_equip_skill", | |
| "Equips a vault skill onto this session โ injected into your context every following turn, and returned now for immediate use. Wrong name โ lists available skills.", | |
| {"name": {"type": "string", "description": "Skill name to equip"}}, | |
| ["name"], | |
| ), | |
| _schema( | |
| "code_forge_tool", | |
| "Creates a self-made TOOL: a Python script (syntax-checked) saved in the vault + a usage SKILL auto-equipped. Re-forge the same name to improve it. Run it via code_run_command.", | |
| { | |
| "name": {"type": "string", "description": "Tool name"}, | |
| "script": {"type": "string", "description": "Python source code"}, | |
| "usage": {"type": "string", "description": "How/when to use โ args, examples, caveats"}, | |
| }, | |
| ["name", "script", "usage"], | |
| ), | |
| _schema( | |
| "code_evolve_job", | |
| "Evolves YOUR OWN job 'taemini_self' (self-authored directive + equipped skills) and activates it. Call with no args first to see current state. Other jobs are untouchable.", | |
| { | |
| "rule": {"type": "string", "description": "New standing directive (replaces old; min 20 chars)"}, | |
| "add_skills": {"type": "string", "description": "Comma-separated vault skill names to equip on the job"}, | |
| }, | |
| [], | |
| ), | |
| _schema( | |
| "code_consult_expert", | |
| "Summons an on-demand EXPERT persona you define and asks it a self-contained question โ a second opinion when you're stuck. Verify its claims with tools before adopting.", | |
| { | |
| "specialty": {"type": "string", "description": "Expert field, e.g. 'ํ์ด์ฌ ์ฑ๋ฅ ์ต์ ํ'"}, | |
| "question": {"type": "string", "description": "Self-contained question (min 10 chars)"}, | |
| "context": {"type": "string", "description": "Relevant findings you already gathered"}, | |
| }, | |
| ["specialty", "question"], | |
| ), | |
| _schema( | |
| "code_remember", | |
| "Engraves a reusable lesson/fact into LONG-TERM memory (SQLite vector recall + Obsidian vault note) so future sessions remember it. Cross-project lessons only; workspace facts go to TAEMINI.md.", | |
| { | |
| "lesson": {"type": "string", "description": "The lesson/fact worth remembering (min 20 chars)"}, | |
| "topic": {"type": "string", "description": "Short topic tag"}, | |
| }, | |
| ["lesson"], | |
| ), | |
| ] | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ๐บ๏ธ [ํ๋ก์ ํธ ์ปจํ ์คํธ ์๋ ์์ง] โ Claude Code์ env+CLAUDE.md ์ฃผ์ ๋ฑ๊ฐ. | |
| # ์ฝ๋ฉ ํด ํ๋กฌํํธ ์์ ์๋ ์ฝ์ ๋์ด ๋ชจ๋ธ์ด ๋๊ตฌ๋ก ๋๋ฌ๊ธฐ ์ ์ ๋ฐฉํฅ์ ์ก๋๋ค. | |
| # ์ ๋ถ ๋ก์ปฌ ํ์ผ/git ๋ก์ปฌ ๋ช ๋ น๋ง ์ฌ์ฉ(๋คํธ์ํฌ 0) โ TTFT ๋ถ๋ด ์ต์. | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _PROJECT_TYPE_MARKERS = ( | |
| "package.json", "pubspec.yaml", "requirements.txt", "pyproject.toml", | |
| "Cargo.toml", "go.mod", "Gemfile", "CMakeLists.txt", "build.gradle", | |
| "settings.gradle", "Makefile", "pom.xml", "composer.json", "mix.exs", | |
| ) | |
| _AGENT_NOTE_FILES = ( # (ํ์ผ๋ช , ์ต๋ ๋ฌธ์์) โ ์์ = ์ฐ์ ์์ | |
| ("TAEMINI.md", 3500), | |
| ("TAEMINI_NOTES.md", 3500), | |
| ("CLAUDE.md", 2500), | |
| ("AGENTS.md", 2500), | |
| ) | |
| # ๐งฌ [์ํฌ์คํ์ด์ค ์ง์นจ์] TAEMINI.md โ ์ด ํด๋์์ ํ๋ฏธ๋๊ฐ ์ผํ๋ ๋ฐฉ์์ ๋จ์ผ ์ง์ค. | |
| # ์์ผ๋ฉด ์ฝ๋ฉ ํด ์์ ์ ์๋๋ฅผ ์๋ ์์ฑํ๋ค(๋ง์๋ ์๊ตฌ: "์์ผ๋ฉด ๋ง๋ค๊ณ ์ค์ค๋ก ๊ฐ์ "). | |
| # ๋งค ํด [ํ๋ก์ ํธ ์ปจํ ์คํธ]๋ก ์ฃผ์ ๋๊ณ , ๋ชจ๋ธ์ด code_edit_file๋ก ๊ฐ์ ์ ๋์ ํ๋ค. | |
| WORKSPACE_CHARTER = "TAEMINI.md" | |
| _CHARTER_SEED = """# ๐งฌ TAEMINI.md โ ์ด ์ํฌ์คํ์ด์ค์ ํ๋ฏธ๋ ์ง์นจ์ (์๊ฐ ๊ฐ์ ) | |
| ์ด ํ์ผ์ ํ๋ฏธ๋(๋ก์ปฌ ์ฝ๋ฉ ์์ด์ ํธ)๊ฐ ์ด ํ๋ก์ ํธ์์ ์ผํ๋ ๋ฐฉ์์ ๋จ์ผ ์ง์ค์ด๋ค. | |
| ํ๋ฏธ๋๋ ๋งค ์์ ์์ ์ด ์ง์นจ์ ๋ฐ๋ฅด๊ณ , ์์ ์ด ๋๋๋ฉด ๋ฐฐ์ด ๊ฒ์ ์ฌ๊ธฐ ๋ฐ์ํด ๊ฐ์ ํ๋ค. | |
| ## ์ง์นจ (์ค์ค๋ก ๊ฐ์ ํ ๊ฒ) | |
| - ๊ฒ์ฆ ์์ด ์๋ฃ ๋ณด๊ณ ๊ธ์ง. ๊ฐ์ ์คํจ 2ํ๋ฉด ์ ๊ทผ์ ๋ฐ๊พผ๋ค. | |
| ## ํ๋ก์ ํธ ์ฌ์ค (๊ตฌ์กฐยท๋น๋ยท๊ฒ์ฆ ๋ช ๋ น โ ํ์ธ๋๋ฉด ๊ธฐ๋ก) | |
| - (์์ง ์์) | |
| ## ํจ์ (๊ฒช์ ์ค์ โ ๋ฐ๋ณต ๊ธ์ง) | |
| - (์์ง ์์) | |
| ## ์์ ๋๊ตฌ/์คํฌ (code_forge_tool/skill ๋ก ๋ง๋ ๊ฒ โ ์ฉ๋ ๋ฉ๋ชจ) | |
| - (์์ง ์์) | |
| """ | |
| # ์ง์นจ์๋ฅ๋ ๋งค ํด [ํ๋ก์ ํธ ์ปจํ ์คํธ]๋ก ์ ์ฒด ์ฃผ์ ๋๋ฏ๋ก read-before-edit ๋ฉด์ | |
| # (์ฌ์ฝ๊ธฐ ๊ฐ์๋ ์์ ๋ง์ฐฐ โ ๋ชจ๋ธ์ด ๊ฐ์ ์ ํฌ๊ธฐํ๊ณ ๊ฑฐ์ง ๋ณด๊ณ ํ๋ ์ค๊ด์ธก์ ์์ธ) | |
| _READ_EXEMPT_FILES = (WORKSPACE_CHARTER, "TAEMINI_NOTES.md") | |
| def ensure_workspace_charter(cwd: str) -> bool: | |
| """TAEMINI.md๊ฐ ์์ผ๋ฉด ์๋ ์์ฑ(์์ฑ ์ True). ๊ตฌ๊ด๋ก TAEMINI_NOTES.md๊ฐ | |
| ์ด๋ฏธ ์์ผ๋ฉด ๊ทธ๊ฒ์ ์ง์นจ์๋ก ์ธ์ ํ๊ณ ์๋ก ๋ง๋ค์ง ์๋๋ค(์ด์ค ์ฅ๋ถ ๋ฐฉ์ง).""" | |
| try: | |
| cwd = os.path.abspath(cwd or ".") | |
| path = os.path.join(cwd, WORKSPACE_CHARTER) | |
| if os.path.isfile(path) or os.path.isfile(os.path.join(cwd, "TAEMINI_NOTES.md")): | |
| return False | |
| with open(path, "w", encoding="utf-8") as f: | |
| f.write(_CHARTER_SEED) | |
| with _PROJECT_CTX_LOCK: # ์ด๋ฒ ํด ์ฃผ์ ์ ๋ฐ๋ก ๋ฐ์๋๊ฒ ์บ์ ๋ฌดํจํ | |
| _PROJECT_CTX_CACHE.pop(cwd, None) | |
| return True | |
| except Exception: | |
| return False | |
| _PROJECT_CTX_CACHE = {} # cwd โ (ts, text) | |
| _PROJECT_CTX_LOCK = threading.Lock() | |
| _PROJECT_CTX_TTL = 15 # ์ด โ ์ฐ์ ํด์์ ์ฌ์์ง ์๋ต | |
| def _git_lines(cwd, args, timeout=2.0): | |
| try: | |
| p = subprocess.run(["git", "-C", cwd] + args, capture_output=True, | |
| timeout=timeout, text=True, errors="replace") | |
| if p.returncode != 0: | |
| return [] | |
| return [l for l in p.stdout.splitlines() if l.strip()] | |
| except Exception: | |
| return [] | |
| def build_project_context(cwd: str, max_chars: int = 7000) -> str: | |
| """์์ ๋ฃจํธ์ ์ค๋ฆฌ์ํ ์ด์ ๋ธ๋ก โ ์ต์์ ๊ตฌ์ฑยทํ๋ก์ ํธ ํ์ ยทgit ์ํยท์์ด์ ํธ ๋ ธํธ.""" | |
| cwd = os.path.abspath(cwd or ".") | |
| now = time.time() | |
| with _PROJECT_CTX_LOCK: | |
| cached = _PROJECT_CTX_CACHE.get(cwd) | |
| if cached and now - cached[0] < _PROJECT_CTX_TTL: | |
| return cached[1] | |
| parts = [] | |
| try: | |
| entries = sorted(os.listdir(cwd)) | |
| dirs = [e + "/" for e in entries | |
| if os.path.isdir(os.path.join(cwd, e)) and e not in _SKIP_DIRS and not e.startswith(".")] | |
| files = [e for e in entries | |
| if os.path.isfile(os.path.join(cwd, e)) and not e.startswith(".")] | |
| top = (dirs + files)[:40] | |
| if top: | |
| parts.append("์ต์์ ๊ตฌ์ฑ: " + " ".join(top)) | |
| markers = [m for m in _PROJECT_TYPE_MARKERS if m in files] | |
| if markers: | |
| parts.append("ํ๋ก์ ํธ ๋ง์ปค: " + ", ".join(markers)) | |
| except Exception: | |
| pass | |
| branch = _git_lines(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) | |
| if branch: | |
| git_bits = [f"git ๋ธ๋์น: {branch[0]}"] | |
| status = _git_lines(cwd, ["status", "--porcelain"]) | |
| if status: | |
| shown = status[:10] | |
| more = f" (+{len(status) - 10}๊ฑด ๋)" if len(status) > 10 else "" | |
| git_bits.append("๋ณ๊ฒฝ ํ์ผ:\n " + "\n ".join(shown) + more) | |
| else: | |
| git_bits.append("์์ ํธ๋ฆฌ ๊นจ๋ํจ") | |
| log = _git_lines(cwd, ["log", "-5", "--format=%h %s (%cr)"]) | |
| if log: | |
| git_bits.append("์ต๊ทผ ์ปค๋ฐ:\n " + "\n ".join(log)) | |
| parts.append("\n".join(git_bits)) | |
| for fname, cap in _AGENT_NOTE_FILES: | |
| fp = os.path.join(cwd, fname) | |
| try: | |
| if os.path.isfile(fp): | |
| with open(fp, "r", encoding="utf-8", errors="replace") as f: | |
| body = f.read(cap + 1) | |
| if len(body) > cap: | |
| body = body[:cap] + "\nโฆ(์ดํ ์๋ต โ ํ์ ์ code_read_file๋ก ์ ์ฒด ์ด๋)" | |
| parts.append(f"### {fname} (ํ๋ก์ ํธ ๋ ธํธ/์ง์นจ โ ์๋ ํฌํจ)\n{body.strip()}") | |
| except Exception: | |
| continue | |
| text = "\n\n".join(p for p in parts if p).strip()[:max_chars] | |
| with _PROJECT_CTX_LOCK: | |
| _PROJECT_CTX_CACHE[cwd] = (now, text) | |
| if len(_PROJECT_CTX_CACHE) > 32: # ๋ฌดํ ์ฑ์ฅ ๋ฐฉ์ง | |
| oldest = min(_PROJECT_CTX_CACHE, key=lambda k: _PROJECT_CTX_CACHE[k][0]) | |
| _PROJECT_CTX_CACHE.pop(oldest, None) | |
| return text | |
| # ์์คํ ์ง์์ ๋ง๋ถ์ผ ์์ด์ ํฑ ์ฝ๋ฉ ์ํฌํ๋ก ๊ฐ์ด๋ | |
| 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๋ฒ๋ถํฐ ๋ค์ ์ํํ๋ค. ๊ฐ์ ์คํจ๋ฅผ ๋ฐ๋ณตํ๋ฉด ์ ๊ทผ์ ๋ฐ๊ฟ๋ผ. | |
| ๊ท์น: | |
| - ๋๊ตฌ ๊ฒฐ๊ณผ(ํ์ผ ๋ด์ฉ, ๊ฒ์ ๊ฒฐ๊ณผ)์ ๊ทผ๊ฑฐํด์๋ง ๋งํ๋ผ. ์กด์ฌ๋ฅผ ํ์ธํ์ง ์์ ํ์ผ/ํจ์๋ฅผ ์ธ๊ธํ์ง ๋ง๋ผ. | |
| - ์์ ์ ์ต์ ๋ฒ์๋ก ํ๊ณ , ์ฃผ๋ณ ์ฝ๋์ ์คํ์ผ์ ๋ฐ๋ฅธ๋ค. | |
| - ์คํจํ๋ฉด ์์งํ๊ฒ ์คํจํ๋ค๊ณ ๋ณด๊ณ ํ๊ณ ์์ธ๊ณผ ๋ค์ ์๋๋ฅผ ์ค๋ช ํ๋ค. | |
| """ | |