Files

1 line
2.1 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "apgamerinfo/boyser-ai", "files": {"agent.py": {"4f70b277386b5bf84c74904d6cfba309c297450d": {"t": 1785747510.9731627, "n": 101603, "h": "efa259a1dfea31f0", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\nIS_WIN = os.name == \"nt\"\n\n\ndef _find_git_bash() -> str | None:\n \"\"\"หา bash.exe ของ Git for Windows — จงใจไม่ใช้ which(\"bash\") เพราะบน Windows\n มักได้ System32\\\\bash.exe (WSL) ซึ่งเป็นคนละ filesystem\"\"\"\n git = shutil.which(\"git\")\n candidates = []\n if git: # ปกติ git.exe อยู่ <root>\\cmd\\git.exe → bash อยู่ <root>\\bin\\bash.exe\n root = os.path.dirname(os.path.dirname(git))\n candidates.append(os.path.join(root, \"bin\", \"bash.exe\"))\n for env in (\"ProgramFiles\", \"ProgramFiles(x86)\", \"LocalAppData\"):\n base = os.environ.get(env)\n if base:\n candidates.append(os.path.join(base, \"Git\", \"bin\", \"bash.exe\"))\n candidates.append(os.path.join(base, \"Programs\", \"Git\", \"bin\", \"bash.exe\"))\n for c in candidates:\n if os.path.isfile(c):\n return c\n return None\n\n\nGIT_BASH = _find_git_bash() if IS_WIN else None\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n # รอบซ้ำอาจขอเรียก tool แทนการตอบ (history มี tool round) → ข้าม ไม่นับเป็นคำตอบ\n if ans.strip() and not looks_toolish(ans):\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nif IS_WIN:\n _SHELL_NOTE = (\n \"\\nThis machine runs Windows. Shell commands execute under \"\n + (\"Git Bash — bash syntax (ls, grep, pipes) works.\" if GIT_BASH\n else \"cmd.exe — write Windows commands (dir, type, set), NOT bash syntax.\")\n )\nelse:\n _SHELL_NOTE = \"\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time){_SHELL_NOTE}\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str, api_key: str = \"\") -> list[str]:\n try:\n hdr = {\"Authorization\": f\"Bearer {api_key}\"} if api_key else {}\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3, headers=hdr)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]], default: str | None = None) -> str | None:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ESC = ย้อนกลับ (คืน None) · ไม่มี TTY ใช้พิมพ์เลขแทน\n default = label ที่ให้ cursor ไปรออยู่ (ไม่อยู่ในตัวเลือกก็เริ่มบนสุดตามปกติ)\"\"\"\n if default is not None and not any(label == default for label, _ in choices):\n default = None\n if sys.stdin.isatty():\n import questionary\n from prompt_toolkit.keys import Keys\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n q = questionary.select(title, choices=q_choices, default=default)\n q.application.ttimeoutlen = 0.09 # ESC เดี่ยวตอบไว (default 0.5 วิ หน่วงเกิน)\n\n @q.application.key_bindings.add(Keys.Escape, eager=True)\n def _(event):\n event.app.exit(result=\"__ESC__\")\n\n ans = q.ask()\n if ans == \"__ESC__\":\n return None\n if ans is None: # Ctrl+C\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict | None:\n \"\"\"เมนูตั้งค่า — ESC ในเมนูลึก = ย้อนกลับไปเลือก backend, ESC ที่เมนูแรก = ยกเลิก (คืน None ถ้ามี config เดิม)\"\"\"\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model · ESC ย้อนกลับ)[/]\",\n border_style=\"bright_blue\",\n ))\n\n old = load_config() or {} # ค่าเดิมเป็น default ทุกช่อง — แก้เฉพาะที่อยากเปลี่ยน ไม่ต้องพิมพ์ใหม่หมด\n old_local = old if old.get(\"backend\") == \"local\" else {}\n while True:\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ], default=\"Claude API\" if old.get(\"backend\") == \"claude\" else None)\n if backend is None: # ESC ที่เมนูแรก = ยกเลิก wizard\n if load_config():\n console.print(\"[dim]ยกเลิก — ใช้ค่าเดิม[/]\")\n return None\n console.print(\"[yellow]ยังไม่เคยตั้งค่า — ต้องเลือก backend ก่อน (Ctrl+C = ออกโปรแกรม)[/]\")\n continue\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS,\n default=old.get(\"model\") if old.get(\"backend\") == \"claude\" else None)\n if model is None:\n continue\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth is None:\n continue\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n if prov is None:\n continue\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\",\n default=old_local.get(\"base_url\") or \"https://\")\n same_prov = base == old_local.get(\"base_url\") # provider เดิม → key/model เดิมเป็น default\n key = ask_text(f\"วาง API key ของ {prov}{' (Enter = ใช้ key เดิม)' if same_prov and old_local.get('api_key') else ''}:\",\n password=True)\n key = key or (old_local.get(\"api_key\", \"\") if same_prov else \"\")\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=(old_local.get(\"model\") if same_prov else \"\") or eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=old_local.get(\"base_url\") or OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n # เติม :11434 เฉพาะ http (https ผ่านโดเมน/Tunnel ใช้ 443 อยู่แล้ว ห้ามเติมทับ)\n if url.startswith(\"http://\") and \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n # ต่อผ่าน auth proxy/โดเมน (เช่น Cloudflare Tunnel หน้า Ollama) → ใส่ key; LAN เปล่าๆ Enter ข้าม\n key = ask_text(\"API key (Enter = ใช้ key เดิม):\" if old_local.get(\"api_key\")\n else \"API key (ถ้าต่อผ่าน proxy/โดเมนที่ล็อก key — Enter = ไม่มี):\", password=True)\n key = key or old_local.get(\"api_key\", \"\")\n models = list_ollama_models(url, key)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models],\n default=old_local.get(\"model\"))\n if model is None:\n continue\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=old_local.get(\"model\") or \"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model,\n \"num_ctx\": ask_ctx(old_local.get(\"num_ctx\") or 16384)}\n if key:\n cfg[\"api_key\"] = key\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=old_local.get(\"base_url\") or LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=old_local.get(\"model\") or \"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model,\n \"num_ctx\": ask_ctx(old_local.get(\"num_ctx\") or 16384)}\n\n theme = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES], default=old.get(\"theme\"))\n if theme is None:\n continue\n cfg[\"theme\"] = theme\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n if IS_WIN: # Windows: msvcrt อ่านปุ่มจาก console ได้เลย ไม่ต้องสลับโหมด terminal\n self._thread = threading.Thread(target=self._run_win, daemon=True)\n self._thread.start()\n else:\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def _run_win(self):\n import msvcrt\n\n while not self._stop.is_set():\n if self._paused.is_set() or not msvcrt.kbhit():\n time.sleep(0.05)\n continue\n if msvcrt.getwch() == \"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if not self._thread:\n return\n self._paused.set()\n if self._old is not None: # POSIX เท่านั้นที่ต้องคืนโหมด terminal\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select/kbhit รอบปัจจุบันก่อน input()\n\n def resume(self):\n if not self._thread:\n return\n if self._old is not None:\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef app_version() -> str:\n \"\"\"เวอร์ชันจาก git ของ repo เอง (ไม่ต้อง bump เลขมือ — /update แล้วเลขขยับเอง):\n v<จำนวน commit> (<hash> · <วันที่>) เช่น v42 (1542f99 · 2026-06-06); ไม่ใช่ git → ว่าง\"\"\"\n try:\n n = subprocess.run([\"git\", \"-C\", REPO_DIR, \"rev-list\", \"--count\", \"HEAD\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n info = subprocess.run([\"git\", \"-C\", REPO_DIR, \"log\", \"-1\", \"--format=%h %cs\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n if n and info and len(info.split()) == 2:\n h, d = info.split()\n return f\"v{n} ({h} · {d})\"\n except Exception:\n pass\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n common = dict(stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=WORKDIR)\n if IS_WIN:\n if GIT_BASH: # มี Git Bash → คำสั่ง bash ที่โมเดลเขียนใช้ได้เลย\n p = subprocess.Popen([GIT_BASH, \"-c\", command], **common)\n else: # ไม่มี → cmd.exe (system prompt บอกโมเดลให้เขียนคำสั่ง Windows แล้ว)\n p = subprocess.Popen(command, shell=True, **common)\n else:\n import signal\n\n p = subprocess.Popen(\n command, shell=True, start_new_session=True, **common, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n if IS_WIN: # taskkill /T ฆ่าทั้ง tree (เทียบเท่า killpg)\n subprocess.run([\"taskkill\", \"/F\", \"/T\", \"/PID\", str(p.pid)], capture_output=True)\n else:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches\n if \"/.venv/\" not in (n := m.replace(os.sep, \"/\")) and \"/.git/\" not in n]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef _grep_py(pattern: str, path: str) -> str:\n \"\"\"grep สำรองภาษา Python ล้วน — ใช้ตอนเครื่องไม่มี grep binary (Windows ที่ไม่มี Git Bash)\"\"\"\n try:\n rx = re.compile(pattern)\n except re.error as e:\n return f\"Error: invalid pattern: {e}\"\n skip = {\".git\", \".venv\", \"node_modules\", \"__pycache__\"}\n out: list[str] = []\n deadline = time.monotonic() + 30\n for root, dirs, files in os.walk(path):\n dirs[:] = [d for d in dirs if d not in skip]\n for fn in files:\n if time.monotonic() > deadline:\n return \"\\n\".join(out) if out else \"Error: grep timed out.\"\n fp = os.path.join(root, fn)\n try:\n with open(fp, \"rb\") as f:\n head = f.read(1024)\n if b\"\\0\" in head: # binary → ข้าม (เทียบเท่า grep -I)\n continue\n hits = 0\n with open(fp, encoding=\"utf-8\", errors=\"replace\") as f:\n for i, line in enumerate(f, 1):\n if rx.search(line):\n out.append(f\"{fp}:{i}:{line.rstrip()}\")\n hits += 1\n if hits >= 5 or len(out) >= 100:\n break\n except OSError:\n continue\n if len(out) >= 100:\n return \"\\n\".join(out)\n return \"\\n\".join(out) if out else \"(no matches)\"\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n if shutil.which(\"grep\") is None:\n return _grep_py(pattern, path)\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or (\"notepad\" if IS_WIN else \"nano\")\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n # Ollama หลัง auth proxy (เช่น Cloudflare Tunnel + Bearer key) → แนบ header ทุก call native\n self.headers = {\"Authorization\": f\"Bearer {cfg['api_key']}\"} if cfg.get(\"api_key\") else {}\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=5, headers=self.headers)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5,\n headers=self.headers,\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None,\n headers=self.headers) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # รอบที่ตอบจริง (ไม่มี tool call — จะรอบแรกหรือหลัง tool ก็ได้) บนเอกสารยาว\n # → vote หา consensus กันคำตอบไม่นิ่ง\n if (VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\n_FILE_SCAN = {\"t\": 0.0, \"files\": []}\n_SCAN_SKIP = {\".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\", \".cache\", \"dist\", \"build\"}\n\n\ndef scan_files(limit: int = 2000) -> list:\n \"\"\"รายชื่อไฟล์ใต้ cwd สำหรับ @ autocomplete (ข้าม dir ขยะ/ซ่อน) — cache 5 วิ\"\"\"\n if time.time() - _FILE_SCAN[\"t\"] < 5:\n return _FILE_SCAN[\"files\"]\n out = []\n for dirpath, dirnames, filenames in os.walk(\".\"):\n dirnames[:] = sorted(d for d in dirnames if d not in _SCAN_SKIP and not d.startswith(\".\"))\n for f in sorted(filenames):\n if f.startswith(\".\"):\n continue\n out.append(os.path.join(dirpath, f)[2:]) # ตัด ./ หน้า path\n if len(out) >= limit:\n break\n if len(out) >= limit:\n break\n _FILE_SCAN.update(t=time.time(), files=out)\n return out\n\n\n_MENTION_RE = re.compile(r\"@([\\w./+~-]+)\", re.UNICODE)\n\n\ndef expand_file_mentions(text: str):\n \"\"\"แปลง @path ที่เป็นไฟล์จริงเป็นเนื้อไฟล์แนบท้ายข้อความ → (ข้อความส่งโมเดล, [ไฟล์ที่แนบ])\"\"\"\n attached, parts = [], [text]\n for m in _MENTION_RE.finditer(text):\n p = m.group(1).rstrip(\".\") # กัน \"@a.py.\" ท้ายประโยค\n if p in attached or not os.path.isfile(p):\n continue\n try:\n data = open(p, encoding=\"utf-8\", errors=\"replace\").read()\n except OSError:\n continue\n if len(data) > 100_000:\n data = data[:100_000] + \"\\n... (ไฟล์ยาว ตัดที่ 100k ตัวอักษร — ใช้ tool อ่านส่วนที่เหลือ)\"\n attached.append(p)\n parts.append(f\"\\n--- เนื้อไฟล์ {p} (user แนบด้วย @) ---\\n{data}\\n--- จบไฟล์ {p} ---\")\n return \"\\n\".join(parts), attached\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n return\n # @ไฟล์ (แนบเนื้อไฟล์) หรือ ./path (เติม path เฉยๆ) → เด้งรายชื่อไฟล์ใน cwd\n m = re.search(r\"(@|\\./)([\\w./+~-]*)$\", text, re.UNICODE)\n if not m:\n return\n prefix = \"@\" if m.group(1) == \"@\" else \"\"\n q = m.group(2).lower()\n hits = [p for p in scan_files() if q in p.lower()]\n # ไฟล์ที่ชื่อ (basename) ขึ้นต้นด้วยคำค้นมาก่อน แล้วค่อยเรียงตาม path สั้น\n hits.sort(key=lambda p: (not os.path.basename(p).lower().startswith(q.rsplit(\"/\", 1)[-1]), len(p)))\n for p in hits[:50]:\n yield Completion(prefix + p, start_position=-len(m.group(0)), display=p)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · / คำสั่ง · @ แนบไฟล์ · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n ver = app_version()\n if ver:\n t.add_row(\"version\", ver)\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard() or cfg # None = ESC ยกเลิก (มีได้เฉพาะตอนมี config เดิม) → ใช้ค่าเดิม\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n ver = app_version()\n if ver:\n rows.insert(0, (\"version\", ver))\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<9}\", style=\"dim\") # 9 > \"version\" (7) — กัน label ติดค่า\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n if name is None: # ESC ยกเลิก\n continue\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n new_cfg = setup_wizard()\n if new_cfg is None: # ESC ยกเลิก — ไม่แตะ backend/ประวัติ\n continue\n cfg = new_cfg\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n expanded, attached = expand_file_mentions(user)\n if attached:\n console.print(f\"[dim]📎 แนบไฟล์: {', '.join(attached)}[/]\")\n messages.append({\"role\": \"user\", \"content\": expanded})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "3c2f85b8843a4f80ded0f1e68d5ae66827183bfc": {"t": 1785773681.8317947, "n": 101603, "h": "efa259a1dfea31f0", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\nIS_WIN = os.name == \"nt\"\n\n\ndef _find_git_bash() -> str | None:\n \"\"\"หา bash.exe ของ Git for Windows — จงใจไม่ใช้ which(\"bash\") เพราะบน Windows\n มักได้ System32\\\\bash.exe (WSL) ซึ่งเป็นคนละ filesystem\"\"\"\n git = shutil.which(\"git\")\n candidates = []\n if git: # ปกติ git.exe อยู่ <root>\\cmd\\git.exe → bash อยู่ <root>\\bin\\bash.exe\n root = os.path.dirname(os.path.dirname(git))\n candidates.append(os.path.join(root, \"bin\", \"bash.exe\"))\n for env in (\"ProgramFiles\", \"ProgramFiles(x86)\", \"LocalAppData\"):\n base = os.environ.get(env)\n if base:\n candidates.append(os.path.join(base, \"Git\", \"bin\", \"bash.exe\"))\n candidates.append(os.path.join(base, \"Programs\", \"Git\", \"bin\", \"bash.exe\"))\n for c in candidates:\n if os.path.isfile(c):\n return c\n return None\n\n\nGIT_BASH = _find_git_bash() if IS_WIN else None\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n # รอบซ้ำอาจขอเรียก tool แทนการตอบ (history มี tool round) → ข้าม ไม่นับเป็นคำตอบ\n if ans.strip() and not looks_toolish(ans):\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nif IS_WIN:\n _SHELL_NOTE = (\n \"\\nThis machine runs Windows. Shell commands execute under \"\n + (\"Git Bash — bash syntax (ls, grep, pipes) works.\" if GIT_BASH\n else \"cmd.exe — write Windows commands (dir, type, set), NOT bash syntax.\")\n )\nelse:\n _SHELL_NOTE = \"\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time){_SHELL_NOTE}\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str, api_key: str = \"\") -> list[str]:\n try:\n hdr = {\"Authorization\": f\"Bearer {api_key}\"} if api_key else {}\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3, headers=hdr)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]], default: str | None = None) -> str | None:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ESC = ย้อนกลับ (คืน None) · ไม่มี TTY ใช้พิมพ์เลขแทน\n default = label ที่ให้ cursor ไปรออยู่ (ไม่อยู่ในตัวเลือกก็เริ่มบนสุดตามปกติ)\"\"\"\n if default is not None and not any(label == default for label, _ in choices):\n default = None\n if sys.stdin.isatty():\n import questionary\n from prompt_toolkit.keys import Keys\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n q = questionary.select(title, choices=q_choices, default=default)\n q.application.ttimeoutlen = 0.09 # ESC เดี่ยวตอบไว (default 0.5 วิ หน่วงเกิน)\n\n @q.application.key_bindings.add(Keys.Escape, eager=True)\n def _(event):\n event.app.exit(result=\"__ESC__\")\n\n ans = q.ask()\n if ans == \"__ESC__\":\n return None\n if ans is None: # Ctrl+C\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict | None:\n \"\"\"เมนูตั้งค่า — ESC ในเมนูลึก = ย้อนกลับไปเลือก backend, ESC ที่เมนูแรก = ยกเลิก (คืน None ถ้ามี config เดิม)\"\"\"\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model · ESC ย้อนกลับ)[/]\",\n border_style=\"bright_blue\",\n ))\n\n old = load_config() or {} # ค่าเดิมเป็น default ทุกช่อง — แก้เฉพาะที่อยากเปลี่ยน ไม่ต้องพิมพ์ใหม่หมด\n old_local = old if old.get(\"backend\") == \"local\" else {}\n while True:\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ], default=\"Claude API\" if old.get(\"backend\") == \"claude\" else None)\n if backend is None: # ESC ที่เมนูแรก = ยกเลิก wizard\n if load_config():\n console.print(\"[dim]ยกเลิก — ใช้ค่าเดิม[/]\")\n return None\n console.print(\"[yellow]ยังไม่เคยตั้งค่า — ต้องเลือก backend ก่อน (Ctrl+C = ออกโปรแกรม)[/]\")\n continue\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS,\n default=old.get(\"model\") if old.get(\"backend\") == \"claude\" else None)\n if model is None:\n continue\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth is None:\n continue\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n if prov is None:\n continue\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\",\n default=old_local.get(\"base_url\") or \"https://\")\n same_prov = base == old_local.get(\"base_url\") # provider เดิม → key/model เดิมเป็น default\n key = ask_text(f\"วาง API key ของ {prov}{' (Enter = ใช้ key เดิม)' if same_prov and old_local.get('api_key') else ''}:\",\n password=True)\n key = key or (old_local.get(\"api_key\", \"\") if same_prov else \"\")\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=(old_local.get(\"model\") if same_prov else \"\") or eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=old_local.get(\"base_url\") or OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n # เติม :11434 เฉพาะ http (https ผ่านโดเมน/Tunnel ใช้ 443 อยู่แล้ว ห้ามเติมทับ)\n if url.startswith(\"http://\") and \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n # ต่อผ่าน auth proxy/โดเมน (เช่น Cloudflare Tunnel หน้า Ollama) → ใส่ key; LAN เปล่าๆ Enter ข้าม\n key = ask_text(\"API key (Enter = ใช้ key เดิม):\" if old_local.get(\"api_key\")\n else \"API key (ถ้าต่อผ่าน proxy/โดเมนที่ล็อก key — Enter = ไม่มี):\", password=True)\n key = key or old_local.get(\"api_key\", \"\")\n models = list_ollama_models(url, key)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models],\n default=old_local.get(\"model\"))\n if model is None:\n continue\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=old_local.get(\"model\") or \"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model,\n \"num_ctx\": ask_ctx(old_local.get(\"num_ctx\") or 16384)}\n if key:\n cfg[\"api_key\"] = key\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=old_local.get(\"base_url\") or LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=old_local.get(\"model\") or \"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model,\n \"num_ctx\": ask_ctx(old_local.get(\"num_ctx\") or 16384)}\n\n theme = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES], default=old.get(\"theme\"))\n if theme is None:\n continue\n cfg[\"theme\"] = theme\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n if IS_WIN: # Windows: msvcrt อ่านปุ่มจาก console ได้เลย ไม่ต้องสลับโหมด terminal\n self._thread = threading.Thread(target=self._run_win, daemon=True)\n self._thread.start()\n else:\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def _run_win(self):\n import msvcrt\n\n while not self._stop.is_set():\n if self._paused.is_set() or not msvcrt.kbhit():\n time.sleep(0.05)\n continue\n if msvcrt.getwch() == \"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if not self._thread:\n return\n self._paused.set()\n if self._old is not None: # POSIX เท่านั้นที่ต้องคืนโหมด terminal\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select/kbhit รอบปัจจุบันก่อน input()\n\n def resume(self):\n if not self._thread:\n return\n if self._old is not None:\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef app_version() -> str:\n \"\"\"เวอร์ชันจาก git ของ repo เอง (ไม่ต้อง bump เลขมือ — /update แล้วเลขขยับเอง):\n v<จำนวน commit> (<hash> · <วันที่>) เช่น v42 (1542f99 · 2026-06-06); ไม่ใช่ git → ว่าง\"\"\"\n try:\n n = subprocess.run([\"git\", \"-C\", REPO_DIR, \"rev-list\", \"--count\", \"HEAD\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n info = subprocess.run([\"git\", \"-C\", REPO_DIR, \"log\", \"-1\", \"--format=%h %cs\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n if n and info and len(info.split()) == 2:\n h, d = info.split()\n return f\"v{n} ({h} · {d})\"\n except Exception:\n pass\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n common = dict(stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=WORKDIR)\n if IS_WIN:\n if GIT_BASH: # มี Git Bash → คำสั่ง bash ที่โมเดลเขียนใช้ได้เลย\n p = subprocess.Popen([GIT_BASH, \"-c\", command], **common)\n else: # ไม่มี → cmd.exe (system prompt บอกโมเดลให้เขียนคำสั่ง Windows แล้ว)\n p = subprocess.Popen(command, shell=True, **common)\n else:\n import signal\n\n p = subprocess.Popen(\n command, shell=True, start_new_session=True, **common, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n if IS_WIN: # taskkill /T ฆ่าทั้ง tree (เทียบเท่า killpg)\n subprocess.run([\"taskkill\", \"/F\", \"/T\", \"/PID\", str(p.pid)], capture_output=True)\n else:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches\n if \"/.venv/\" not in (n := m.replace(os.sep, \"/\")) and \"/.git/\" not in n]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef _grep_py(pattern: str, path: str) -> str:\n \"\"\"grep สำรองภาษา Python ล้วน — ใช้ตอนเครื่องไม่มี grep binary (Windows ที่ไม่มี Git Bash)\"\"\"\n try:\n rx = re.compile(pattern)\n except re.error as e:\n return f\"Error: invalid pattern: {e}\"\n skip = {\".git\", \".venv\", \"node_modules\", \"__pycache__\"}\n out: list[str] = []\n deadline = time.monotonic() + 30\n for root, dirs, files in os.walk(path):\n dirs[:] = [d for d in dirs if d not in skip]\n for fn in files:\n if time.monotonic() > deadline:\n return \"\\n\".join(out) if out else \"Error: grep timed out.\"\n fp = os.path.join(root, fn)\n try:\n with open(fp, \"rb\") as f:\n head = f.read(1024)\n if b\"\\0\" in head: # binary → ข้าม (เทียบเท่า grep -I)\n continue\n hits = 0\n with open(fp, encoding=\"utf-8\", errors=\"replace\") as f:\n for i, line in enumerate(f, 1):\n if rx.search(line):\n out.append(f\"{fp}:{i}:{line.rstrip()}\")\n hits += 1\n if hits >= 5 or len(out) >= 100:\n break\n except OSError:\n continue\n if len(out) >= 100:\n return \"\\n\".join(out)\n return \"\\n\".join(out) if out else \"(no matches)\"\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n if shutil.which(\"grep\") is None:\n return _grep_py(pattern, path)\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or (\"notepad\" if IS_WIN else \"nano\")\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n # Ollama หลัง auth proxy (เช่น Cloudflare Tunnel + Bearer key) → แนบ header ทุก call native\n self.headers = {\"Authorization\": f\"Bearer {cfg['api_key']}\"} if cfg.get(\"api_key\") else {}\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=5, headers=self.headers)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5,\n headers=self.headers,\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None,\n headers=self.headers) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # รอบที่ตอบจริง (ไม่มี tool call — จะรอบแรกหรือหลัง tool ก็ได้) บนเอกสารยาว\n # → vote หา consensus กันคำตอบไม่นิ่ง\n if (VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\n_FILE_SCAN = {\"t\": 0.0, \"files\": []}\n_SCAN_SKIP = {\".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\", \".cache\", \"dist\", \"build\"}\n\n\ndef scan_files(limit: int = 2000) -> list:\n \"\"\"รายชื่อไฟล์ใต้ cwd สำหรับ @ autocomplete (ข้าม dir ขยะ/ซ่อน) — cache 5 วิ\"\"\"\n if time.time() - _FILE_SCAN[\"t\"] < 5:\n return _FILE_SCAN[\"files\"]\n out = []\n for dirpath, dirnames, filenames in os.walk(\".\"):\n dirnames[:] = sorted(d for d in dirnames if d not in _SCAN_SKIP and not d.startswith(\".\"))\n for f in sorted(filenames):\n if f.startswith(\".\"):\n continue\n out.append(os.path.join(dirpath, f)[2:]) # ตัด ./ หน้า path\n if len(out) >= limit:\n break\n if len(out) >= limit:\n break\n _FILE_SCAN.update(t=time.time(), files=out)\n return out\n\n\n_MENTION_RE = re.compile(r\"@([\\w./+~-]+)\", re.UNICODE)\n\n\ndef expand_file_mentions(text: str):\n \"\"\"แปลง @path ที่เป็นไฟล์จริงเป็นเนื้อไฟล์แนบท้ายข้อความ → (ข้อความส่งโมเดล, [ไฟล์ที่แนบ])\"\"\"\n attached, parts = [], [text]\n for m in _MENTION_RE.finditer(text):\n p = m.group(1).rstrip(\".\") # กัน \"@a.py.\" ท้ายประโยค\n if p in attached or not os.path.isfile(p):\n continue\n try:\n data = open(p, encoding=\"utf-8\", errors=\"replace\").read()\n except OSError:\n continue\n if len(data) > 100_000:\n data = data[:100_000] + \"\\n... (ไฟล์ยาว ตัดที่ 100k ตัวอักษร — ใช้ tool อ่านส่วนที่เหลือ)\"\n attached.append(p)\n parts.append(f\"\\n--- เนื้อไฟล์ {p} (user แนบด้วย @) ---\\n{data}\\n--- จบไฟล์ {p} ---\")\n return \"\\n\".join(parts), attached\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n return\n # @ไฟล์ (แนบเนื้อไฟล์) หรือ ./path (เติม path เฉยๆ) → เด้งรายชื่อไฟล์ใน cwd\n m = re.search(r\"(@|\\./)([\\w./+~-]*)$\", text, re.UNICODE)\n if not m:\n return\n prefix = \"@\" if m.group(1) == \"@\" else \"\"\n q = m.group(2).lower()\n hits = [p for p in scan_files() if q in p.lower()]\n # ไฟล์ที่ชื่อ (basename) ขึ้นต้นด้วยคำค้นมาก่อน แล้วค่อยเรียงตาม path สั้น\n hits.sort(key=lambda p: (not os.path.basename(p).lower().startswith(q.rsplit(\"/\", 1)[-1]), len(p)))\n for p in hits[:50]:\n yield Completion(prefix + p, start_position=-len(m.group(0)), display=p)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · / คำสั่ง · @ แนบไฟล์ · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n ver = app_version()\n if ver:\n t.add_row(\"version\", ver)\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard() or cfg # None = ESC ยกเลิก (มีได้เฉพาะตอนมี config เดิม) → ใช้ค่าเดิม\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n ver = app_version()\n if ver:\n rows.insert(0, (\"version\", ver))\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<9}\", style=\"dim\") # 9 > \"version\" (7) — กัน label ติดค่า\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n if name is None: # ESC ยกเลิก\n continue\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n new_cfg = setup_wizard()\n if new_cfg is None: # ESC ยกเลิก — ไม่แตะ backend/ประวัติ\n continue\n cfg = new_cfg\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n expanded, attached = expand_file_mentions(user)\n if attached:\n console.print(f\"[dim]📎 แนบไฟล์: {', '.join(attached)}[/]\")\n messages.append({\"role\": \"user\", \"content\": expanded})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "61d7fb8887f658b810fa33c07c4cef8675a14e7f": {"t": 1785773684.8779948, "n": 97602, "h": "78b3aed1be3dc0de", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n # รอบซ้ำอาจขอเรียก tool แทนการตอบ (history มี tool round) → ข้าม ไม่นับเป็นคำตอบ\n if ans.strip() and not looks_toolish(ans):\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str, api_key: str = \"\") -> list[str]:\n try:\n hdr = {\"Authorization\": f\"Bearer {api_key}\"} if api_key else {}\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3, headers=hdr)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]], default: str | None = None) -> str | None:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ESC = ย้อนกลับ (คืน None) · ไม่มี TTY ใช้พิมพ์เลขแทน\n default = label ที่ให้ cursor ไปรออยู่ (ไม่อยู่ในตัวเลือกก็เริ่มบนสุดตามปกติ)\"\"\"\n if default is not None and not any(label == default for label, _ in choices):\n default = None\n if sys.stdin.isatty():\n import questionary\n from prompt_toolkit.keys import Keys\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n q = questionary.select(title, choices=q_choices, default=default)\n q.application.ttimeoutlen = 0.09 # ESC เดี่ยวตอบไว (default 0.5 วิ หน่วงเกิน)\n\n @q.application.key_bindings.add(Keys.Escape, eager=True)\n def _(event):\n event.app.exit(result=\"__ESC__\")\n\n ans = q.ask()\n if ans == \"__ESC__\":\n return None\n if ans is None: # Ctrl+C\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict | None:\n \"\"\"เมนูตั้งค่า — ESC ในเมนูลึก = ย้อนกลับไปเลือก backend, ESC ที่เมนูแรก = ยกเลิก (คืน None ถ้ามี config เดิม)\"\"\"\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model · ESC ย้อนกลับ)[/]\",\n border_style=\"bright_blue\",\n ))\n\n old = load_config() or {} # ค่าเดิมเป็น default ทุกช่อง — แก้เฉพาะที่อยากเปลี่ยน ไม่ต้องพิมพ์ใหม่หมด\n old_local = old if old.get(\"backend\") == \"local\" else {}\n while True:\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ], default=\"Claude API\" if old.get(\"backend\") == \"claude\" else None)\n if backend is None: # ESC ที่เมนูแรก = ยกเลิก wizard\n if load_config():\n console.print(\"[dim]ยกเลิก — ใช้ค่าเดิม[/]\")\n return None\n console.print(\"[yellow]ยังไม่เคยตั้งค่า — ต้องเลือก backend ก่อน (Ctrl+C = ออกโปรแกรม)[/]\")\n continue\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS,\n default=old.get(\"model\") if old.get(\"backend\") == \"claude\" else None)\n if model is None:\n continue\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth is None:\n continue\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n if prov is None:\n continue\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\",\n default=old_local.get(\"base_url\") or \"https://\")\n same_prov = base == old_local.get(\"base_url\") # provider เดิม → key/model เดิมเป็น default\n key = ask_text(f\"วาง API key ของ {prov}{' (Enter = ใช้ key เดิม)' if same_prov and old_local.get('api_key') else ''}:\",\n password=True)\n key = key or (old_local.get(\"api_key\", \"\") if same_prov else \"\")\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=(old_local.get(\"model\") if same_prov else \"\") or eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=old_local.get(\"base_url\") or OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n # เติม :11434 เฉพาะ http (https ผ่านโดเมน/Tunnel ใช้ 443 อยู่แล้ว ห้ามเติมทับ)\n if url.startswith(\"http://\") and \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n # ต่อผ่าน auth proxy/โดเมน (เช่น Cloudflare Tunnel หน้า Ollama) → ใส่ key; LAN เปล่าๆ Enter ข้าม\n key = ask_text(\"API key (Enter = ใช้ key เดิม):\" if old_local.get(\"api_key\")\n else \"API key (ถ้าต่อผ่าน proxy/โดเมนที่ล็อก key — Enter = ไม่มี):\", password=True)\n key = key or old_local.get(\"api_key\", \"\")\n models = list_ollama_models(url, key)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models],\n default=old_local.get(\"model\"))\n if model is None:\n continue\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=old_local.get(\"model\") or \"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model,\n \"num_ctx\": ask_ctx(old_local.get(\"num_ctx\") or 16384)}\n if key:\n cfg[\"api_key\"] = key\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=old_local.get(\"base_url\") or LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=old_local.get(\"model\") or \"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model,\n \"num_ctx\": ask_ctx(old_local.get(\"num_ctx\") or 16384)}\n\n theme = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES], default=old.get(\"theme\"))\n if theme is None:\n continue\n cfg[\"theme\"] = theme\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef app_version() -> str:\n \"\"\"เวอร์ชันจาก git ของ repo เอง (ไม่ต้อง bump เลขมือ — /update แล้วเลขขยับเอง):\n v<จำนวน commit> (<hash> · <วันที่>) เช่น v42 (1542f99 · 2026-06-06); ไม่ใช่ git → ว่าง\"\"\"\n try:\n n = subprocess.run([\"git\", \"-C\", REPO_DIR, \"rev-list\", \"--count\", \"HEAD\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n info = subprocess.run([\"git\", \"-C\", REPO_DIR, \"log\", \"-1\", \"--format=%h %cs\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n if n and info and len(info.split()) == 2:\n h, d = info.split()\n return f\"v{n} ({h} · {d})\"\n except Exception:\n pass\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n # Ollama หลัง auth proxy (เช่น Cloudflare Tunnel + Bearer key) → แนบ header ทุก call native\n self.headers = {\"Authorization\": f\"Bearer {cfg['api_key']}\"} if cfg.get(\"api_key\") else {}\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=5, headers=self.headers)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5,\n headers=self.headers,\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None,\n headers=self.headers) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # รอบที่ตอบจริง (ไม่มี tool call — จะรอบแรกหรือหลัง tool ก็ได้) บนเอกสารยาว\n # → vote หา consensus กันคำตอบไม่นิ่ง\n if (VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\n_FILE_SCAN = {\"t\": 0.0, \"files\": []}\n_SCAN_SKIP = {\".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\", \".cache\", \"dist\", \"build\"}\n\n\ndef scan_files(limit: int = 2000) -> list:\n \"\"\"รายชื่อไฟล์ใต้ cwd สำหรับ @ autocomplete (ข้าม dir ขยะ/ซ่อน) — cache 5 วิ\"\"\"\n if time.time() - _FILE_SCAN[\"t\"] < 5:\n return _FILE_SCAN[\"files\"]\n out = []\n for dirpath, dirnames, filenames in os.walk(\".\"):\n dirnames[:] = sorted(d for d in dirnames if d not in _SCAN_SKIP and not d.startswith(\".\"))\n for f in sorted(filenames):\n if f.startswith(\".\"):\n continue\n out.append(os.path.join(dirpath, f)[2:]) # ตัด ./ หน้า path\n if len(out) >= limit:\n break\n if len(out) >= limit:\n break\n _FILE_SCAN.update(t=time.time(), files=out)\n return out\n\n\n_MENTION_RE = re.compile(r\"@([\\w./+~-]+)\", re.UNICODE)\n\n\ndef expand_file_mentions(text: str):\n \"\"\"แปลง @path ที่เป็นไฟล์จริงเป็นเนื้อไฟล์แนบท้ายข้อความ → (ข้อความส่งโมเดล, [ไฟล์ที่แนบ])\"\"\"\n attached, parts = [], [text]\n for m in _MENTION_RE.finditer(text):\n p = m.group(1).rstrip(\".\") # กัน \"@a.py.\" ท้ายประโยค\n if p in attached or not os.path.isfile(p):\n continue\n try:\n data = open(p, encoding=\"utf-8\", errors=\"replace\").read()\n except OSError:\n continue\n if len(data) > 100_000:\n data = data[:100_000] + \"\\n... (ไฟล์ยาว ตัดที่ 100k ตัวอักษร — ใช้ tool อ่านส่วนที่เหลือ)\"\n attached.append(p)\n parts.append(f\"\\n--- เนื้อไฟล์ {p} (user แนบด้วย @) ---\\n{data}\\n--- จบไฟล์ {p} ---\")\n return \"\\n\".join(parts), attached\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n return\n # @ไฟล์ (แนบเนื้อไฟล์) หรือ ./path (เติม path เฉยๆ) → เด้งรายชื่อไฟล์ใน cwd\n m = re.search(r\"(@|\\./)([\\w./+~-]*)$\", text, re.UNICODE)\n if not m:\n return\n prefix = \"@\" if m.group(1) == \"@\" else \"\"\n q = m.group(2).lower()\n hits = [p for p in scan_files() if q in p.lower()]\n # ไฟล์ที่ชื่อ (basename) ขึ้นต้นด้วยคำค้นมาก่อน แล้วค่อยเรียงตาม path สั้น\n hits.sort(key=lambda p: (not os.path.basename(p).lower().startswith(q.rsplit(\"/\", 1)[-1]), len(p)))\n for p in hits[:50]:\n yield Completion(prefix + p, start_position=-len(m.group(0)), display=p)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · / คำสั่ง · @ แนบไฟล์ · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n ver = app_version()\n if ver:\n t.add_row(\"version\", ver)\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard() or cfg # None = ESC ยกเลิก (มีได้เฉพาะตอนมี config เดิม) → ใช้ค่าเดิม\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n ver = app_version()\n if ver:\n rows.insert(0, (\"version\", ver))\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<9}\", style=\"dim\") # 9 > \"version\" (7) — กัน label ติดค่า\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n if name is None: # ESC ยกเลิก\n continue\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n new_cfg = setup_wizard()\n if new_cfg is None: # ESC ยกเลิก — ไม่แตะ backend/ประวัติ\n continue\n cfg = new_cfg\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n expanded, attached = expand_file_mentions(user)\n if attached:\n console.print(f\"[dim]📎 แนบไฟล์: {', '.join(attached)}[/]\")\n messages.append({\"role\": \"user\", \"content\": expanded})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "a818e56cb96f4e57b2a9f56172455066d2f48119": {"t": 1785773686.8087873, "n": 96193, "h": "eb2ea72badcee6be", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n # รอบซ้ำอาจขอเรียก tool แทนการตอบ (history มี tool round) → ข้าม ไม่นับเป็นคำตอบ\n if ans.strip() and not looks_toolish(ans):\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str, api_key: str = \"\") -> list[str]:\n try:\n hdr = {\"Authorization\": f\"Bearer {api_key}\"} if api_key else {}\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3, headers=hdr)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str | None:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ESC = ย้อนกลับ (คืน None) · ไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n from prompt_toolkit.keys import Keys\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n q = questionary.select(title, choices=q_choices)\n q.application.ttimeoutlen = 0.09 # ESC เดี่ยวตอบไว (default 0.5 วิ หน่วงเกิน)\n\n @q.application.key_bindings.add(Keys.Escape, eager=True)\n def _(event):\n event.app.exit(result=\"__ESC__\")\n\n ans = q.ask()\n if ans == \"__ESC__\":\n return None\n if ans is None: # Ctrl+C\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict | None:\n \"\"\"เมนูตั้งค่า — ESC ในเมนูลึก = ย้อนกลับไปเลือก backend, ESC ที่เมนูแรก = ยกเลิก (คืน None ถ้ามี config เดิม)\"\"\"\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model · ESC ย้อนกลับ)[/]\",\n border_style=\"bright_blue\",\n ))\n\n while True:\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n if backend is None: # ESC ที่เมนูแรก = ยกเลิก wizard\n if load_config():\n console.print(\"[dim]ยกเลิก — ใช้ค่าเดิม[/]\")\n return None\n console.print(\"[yellow]ยังไม่เคยตั้งค่า — ต้องเลือก backend ก่อน (Ctrl+C = ออกโปรแกรม)[/]\")\n continue\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n if model is None:\n continue\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth is None:\n continue\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n if prov is None:\n continue\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n # เติม :11434 เฉพาะ http (https ผ่านโดเมน/Tunnel ใช้ 443 อยู่แล้ว ห้ามเติมทับ)\n if url.startswith(\"http://\") and \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n # ต่อผ่าน auth proxy/โดเมน (เช่น Cloudflare Tunnel หน้า Ollama) → ใส่ key; LAN เปล่าๆ Enter ข้าม\n key = ask_text(\"API key (ถ้าต่อผ่าน proxy/โดเมนที่ล็อก key — Enter = ไม่มี):\", password=True)\n models = list_ollama_models(url, key)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n if model is None:\n continue\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n if key:\n cfg[\"api_key\"] = key\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n theme = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n if theme is None:\n continue\n cfg[\"theme\"] = theme\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef app_version() -> str:\n \"\"\"เวอร์ชันจาก git ของ repo เอง (ไม่ต้อง bump เลขมือ — /update แล้วเลขขยับเอง):\n v<จำนวน commit> (<hash> · <วันที่>) เช่น v42 (1542f99 · 2026-06-06); ไม่ใช่ git → ว่าง\"\"\"\n try:\n n = subprocess.run([\"git\", \"-C\", REPO_DIR, \"rev-list\", \"--count\", \"HEAD\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n info = subprocess.run([\"git\", \"-C\", REPO_DIR, \"log\", \"-1\", \"--format=%h %cs\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n if n and info and len(info.split()) == 2:\n h, d = info.split()\n return f\"v{n} ({h} · {d})\"\n except Exception:\n pass\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n # Ollama หลัง auth proxy (เช่น Cloudflare Tunnel + Bearer key) → แนบ header ทุก call native\n self.headers = {\"Authorization\": f\"Bearer {cfg['api_key']}\"} if cfg.get(\"api_key\") else {}\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=5, headers=self.headers)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5,\n headers=self.headers,\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None,\n headers=self.headers) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # รอบที่ตอบจริง (ไม่มี tool call — จะรอบแรกหรือหลัง tool ก็ได้) บนเอกสารยาว\n # → vote หา consensus กันคำตอบไม่นิ่ง\n if (VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\n_FILE_SCAN = {\"t\": 0.0, \"files\": []}\n_SCAN_SKIP = {\".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\", \".cache\", \"dist\", \"build\"}\n\n\ndef scan_files(limit: int = 2000) -> list:\n \"\"\"รายชื่อไฟล์ใต้ cwd สำหรับ @ autocomplete (ข้าม dir ขยะ/ซ่อน) — cache 5 วิ\"\"\"\n if time.time() - _FILE_SCAN[\"t\"] < 5:\n return _FILE_SCAN[\"files\"]\n out = []\n for dirpath, dirnames, filenames in os.walk(\".\"):\n dirnames[:] = sorted(d for d in dirnames if d not in _SCAN_SKIP and not d.startswith(\".\"))\n for f in sorted(filenames):\n if f.startswith(\".\"):\n continue\n out.append(os.path.join(dirpath, f)[2:]) # ตัด ./ หน้า path\n if len(out) >= limit:\n break\n if len(out) >= limit:\n break\n _FILE_SCAN.update(t=time.time(), files=out)\n return out\n\n\n_MENTION_RE = re.compile(r\"@([\\w./+~-]+)\", re.UNICODE)\n\n\ndef expand_file_mentions(text: str):\n \"\"\"แปลง @path ที่เป็นไฟล์จริงเป็นเนื้อไฟล์แนบท้ายข้อความ → (ข้อความส่งโมเดล, [ไฟล์ที่แนบ])\"\"\"\n attached, parts = [], [text]\n for m in _MENTION_RE.finditer(text):\n p = m.group(1).rstrip(\".\") # กัน \"@a.py.\" ท้ายประโยค\n if p in attached or not os.path.isfile(p):\n continue\n try:\n data = open(p, encoding=\"utf-8\", errors=\"replace\").read()\n except OSError:\n continue\n if len(data) > 100_000:\n data = data[:100_000] + \"\\n... (ไฟล์ยาว ตัดที่ 100k ตัวอักษร — ใช้ tool อ่านส่วนที่เหลือ)\"\n attached.append(p)\n parts.append(f\"\\n--- เนื้อไฟล์ {p} (user แนบด้วย @) ---\\n{data}\\n--- จบไฟล์ {p} ---\")\n return \"\\n\".join(parts), attached\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n return\n # @ไฟล์ (แนบเนื้อไฟล์) หรือ ./path (เติม path เฉยๆ) → เด้งรายชื่อไฟล์ใน cwd\n m = re.search(r\"(@|\\./)([\\w./+~-]*)$\", text, re.UNICODE)\n if not m:\n return\n prefix = \"@\" if m.group(1) == \"@\" else \"\"\n q = m.group(2).lower()\n hits = [p for p in scan_files() if q in p.lower()]\n # ไฟล์ที่ชื่อ (basename) ขึ้นต้นด้วยคำค้นมาก่อน แล้วค่อยเรียงตาม path สั้น\n hits.sort(key=lambda p: (not os.path.basename(p).lower().startswith(q.rsplit(\"/\", 1)[-1]), len(p)))\n for p in hits[:50]:\n yield Completion(prefix + p, start_position=-len(m.group(0)), display=p)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · / คำสั่ง · @ แนบไฟล์ · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n ver = app_version()\n if ver:\n t.add_row(\"version\", ver)\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard() or cfg # None = ESC ยกเลิก (มีได้เฉพาะตอนมี config เดิม) → ใช้ค่าเดิม\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n ver = app_version()\n if ver:\n rows.insert(0, (\"version\", ver))\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<9}\", style=\"dim\") # 9 > \"version\" (7) — กัน label ติดค่า\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n if name is None: # ESC ยกเลิก\n continue\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n new_cfg = setup_wizard()\n if new_cfg is None: # ESC ยกเลิก — ไม่แตะ backend/ประวัติ\n continue\n cfg = new_cfg\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n expanded, attached = expand_file_mentions(user)\n if attached:\n console.print(f\"[dim]📎 แนบไฟล์: {', '.join(attached)}[/]\")\n messages.append({\"role\": \"user\", \"content\": expanded})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "7e683f3c3544160717199571c6c17054a02c966b": {"t": 1785773691.5797608, "n": 94532, "h": "7a06156a5c837ba7", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n # รอบซ้ำอาจขอเรียก tool แทนการตอบ (history มี tool round) → ข้าม ไม่นับเป็นคำตอบ\n if ans.strip() and not looks_toolish(ans):\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str, api_key: str = \"\") -> list[str]:\n try:\n hdr = {\"Authorization\": f\"Bearer {api_key}\"} if api_key else {}\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3, headers=hdr)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n # เติม :11434 เฉพาะ http (https ผ่านโดเมน/Tunnel ใช้ 443 อยู่แล้ว ห้ามเติมทับ)\n if url.startswith(\"http://\") and \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n # ต่อผ่าน auth proxy/โดเมน (เช่น Cloudflare Tunnel หน้า Ollama) → ใส่ key; LAN เปล่าๆ Enter ข้าม\n key = ask_text(\"API key (ถ้าต่อผ่าน proxy/โดเมนที่ล็อก key — Enter = ไม่มี):\", password=True)\n models = list_ollama_models(url, key)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n if key:\n cfg[\"api_key\"] = key\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef app_version() -> str:\n \"\"\"เวอร์ชันจาก git ของ repo เอง (ไม่ต้อง bump เลขมือ — /update แล้วเลขขยับเอง):\n v<จำนวน commit> (<hash> · <วันที่>) เช่น v42 (1542f99 · 2026-06-06); ไม่ใช่ git → ว่าง\"\"\"\n try:\n n = subprocess.run([\"git\", \"-C\", REPO_DIR, \"rev-list\", \"--count\", \"HEAD\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n info = subprocess.run([\"git\", \"-C\", REPO_DIR, \"log\", \"-1\", \"--format=%h %cs\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n if n and info and len(info.split()) == 2:\n h, d = info.split()\n return f\"v{n} ({h} · {d})\"\n except Exception:\n pass\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n # Ollama หลัง auth proxy (เช่น Cloudflare Tunnel + Bearer key) → แนบ header ทุก call native\n self.headers = {\"Authorization\": f\"Bearer {cfg['api_key']}\"} if cfg.get(\"api_key\") else {}\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=5, headers=self.headers)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5,\n headers=self.headers,\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None,\n headers=self.headers) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # รอบที่ตอบจริง (ไม่มี tool call — จะรอบแรกหรือหลัง tool ก็ได้) บนเอกสารยาว\n # → vote หา consensus กันคำตอบไม่นิ่ง\n if (VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\n_FILE_SCAN = {\"t\": 0.0, \"files\": []}\n_SCAN_SKIP = {\".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\", \".cache\", \"dist\", \"build\"}\n\n\ndef scan_files(limit: int = 2000) -> list:\n \"\"\"รายชื่อไฟล์ใต้ cwd สำหรับ @ autocomplete (ข้าม dir ขยะ/ซ่อน) — cache 5 วิ\"\"\"\n if time.time() - _FILE_SCAN[\"t\"] < 5:\n return _FILE_SCAN[\"files\"]\n out = []\n for dirpath, dirnames, filenames in os.walk(\".\"):\n dirnames[:] = sorted(d for d in dirnames if d not in _SCAN_SKIP and not d.startswith(\".\"))\n for f in sorted(filenames):\n if f.startswith(\".\"):\n continue\n out.append(os.path.join(dirpath, f)[2:]) # ตัด ./ หน้า path\n if len(out) >= limit:\n break\n if len(out) >= limit:\n break\n _FILE_SCAN.update(t=time.time(), files=out)\n return out\n\n\n_MENTION_RE = re.compile(r\"@([\\w./+~-]+)\", re.UNICODE)\n\n\ndef expand_file_mentions(text: str):\n \"\"\"แปลง @path ที่เป็นไฟล์จริงเป็นเนื้อไฟล์แนบท้ายข้อความ → (ข้อความส่งโมเดล, [ไฟล์ที่แนบ])\"\"\"\n attached, parts = [], [text]\n for m in _MENTION_RE.finditer(text):\n p = m.group(1).rstrip(\".\") # กัน \"@a.py.\" ท้ายประโยค\n if p in attached or not os.path.isfile(p):\n continue\n try:\n data = open(p, encoding=\"utf-8\", errors=\"replace\").read()\n except OSError:\n continue\n if len(data) > 100_000:\n data = data[:100_000] + \"\\n... (ไฟล์ยาว ตัดที่ 100k ตัวอักษร — ใช้ tool อ่านส่วนที่เหลือ)\"\n attached.append(p)\n parts.append(f\"\\n--- เนื้อไฟล์ {p} (user แนบด้วย @) ---\\n{data}\\n--- จบไฟล์ {p} ---\")\n return \"\\n\".join(parts), attached\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n return\n # @ไฟล์ (แนบเนื้อไฟล์) หรือ ./path (เติม path เฉยๆ) → เด้งรายชื่อไฟล์ใน cwd\n m = re.search(r\"(@|\\./)([\\w./+~-]*)$\", text, re.UNICODE)\n if not m:\n return\n prefix = \"@\" if m.group(1) == \"@\" else \"\"\n q = m.group(2).lower()\n hits = [p for p in scan_files() if q in p.lower()]\n # ไฟล์ที่ชื่อ (basename) ขึ้นต้นด้วยคำค้นมาก่อน แล้วค่อยเรียงตาม path สั้น\n hits.sort(key=lambda p: (not os.path.basename(p).lower().startswith(q.rsplit(\"/\", 1)[-1]), len(p)))\n for p in hits[:50]:\n yield Completion(prefix + p, start_position=-len(m.group(0)), display=p)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · / คำสั่ง · @ แนบไฟล์ · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n ver = app_version()\n if ver:\n t.add_row(\"version\", ver)\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n ver = app_version()\n if ver:\n rows.insert(0, (\"version\", ver))\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<9}\", style=\"dim\") # 9 > \"version\" (7) — กัน label ติดค่า\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n expanded, attached = expand_file_mentions(user)\n if attached:\n console.print(f\"[dim]📎 แนบไฟล์: {', '.join(attached)}[/]\")\n messages.append({\"role\": \"user\", \"content\": expanded})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "91238d2a3ae8b4417ac82be3eaaf52c83faadf89": {"t": 1785773694.3369715, "n": 92012, "h": "43e448ea7b0213c7", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n # รอบซ้ำอาจขอเรียก tool แทนการตอบ (history มี tool round) → ข้าม ไม่นับเป็นคำตอบ\n if ans.strip() and not looks_toolish(ans):\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str, api_key: str = \"\") -> list[str]:\n try:\n hdr = {\"Authorization\": f\"Bearer {api_key}\"} if api_key else {}\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3, headers=hdr)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n # เติม :11434 เฉพาะ http (https ผ่านโดเมน/Tunnel ใช้ 443 อยู่แล้ว ห้ามเติมทับ)\n if url.startswith(\"http://\") and \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n # ต่อผ่าน auth proxy/โดเมน (เช่น Cloudflare Tunnel หน้า Ollama) → ใส่ key; LAN เปล่าๆ Enter ข้าม\n key = ask_text(\"API key (ถ้าต่อผ่าน proxy/โดเมนที่ล็อก key — Enter = ไม่มี):\", password=True)\n models = list_ollama_models(url, key)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n if key:\n cfg[\"api_key\"] = key\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef app_version() -> str:\n \"\"\"เวอร์ชันจาก git ของ repo เอง (ไม่ต้อง bump เลขมือ — /update แล้วเลขขยับเอง):\n v<จำนวน commit> (<hash> · <วันที่>) เช่น v42 (1542f99 · 2026-06-06); ไม่ใช่ git → ว่าง\"\"\"\n try:\n n = subprocess.run([\"git\", \"-C\", REPO_DIR, \"rev-list\", \"--count\", \"HEAD\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n info = subprocess.run([\"git\", \"-C\", REPO_DIR, \"log\", \"-1\", \"--format=%h %cs\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n if n and info and len(info.split()) == 2:\n h, d = info.split()\n return f\"v{n} ({h} · {d})\"\n except Exception:\n pass\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n # Ollama หลัง auth proxy (เช่น Cloudflare Tunnel + Bearer key) → แนบ header ทุก call native\n self.headers = {\"Authorization\": f\"Bearer {cfg['api_key']}\"} if cfg.get(\"api_key\") else {}\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=5, headers=self.headers)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5,\n headers=self.headers,\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None,\n headers=self.headers) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # รอบที่ตอบจริง (ไม่มี tool call — จะรอบแรกหรือหลัง tool ก็ได้) บนเอกสารยาว\n # → vote หา consensus กันคำตอบไม่นิ่ง\n if (VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n ver = app_version()\n if ver:\n t.add_row(\"version\", ver)\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n ver = app_version()\n if ver:\n rows.insert(0, (\"version\", ver))\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<9}\", style=\"dim\") # 9 > \"version\" (7) — กัน label ติดค่า\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "f12db662ee664851d5334eea9d0a6efd17bfec79": {"t": 1785773696.024791, "n": 91895, "h": "665eb75533223af8", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n # รอบซ้ำอาจขอเรียก tool แทนการตอบ (history มี tool round) → ข้าม ไม่นับเป็นคำตอบ\n if ans.strip() and not looks_toolish(ans):\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None,\n headers=getattr(backend, \"headers\", {}))\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str, api_key: str = \"\") -> list[str]:\n try:\n hdr = {\"Authorization\": f\"Bearer {api_key}\"} if api_key else {}\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3, headers=hdr)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n # ต่อผ่าน auth proxy/โดเมน (เช่น Cloudflare Tunnel หน้า Ollama) → ใส่ key; LAN เปล่าๆ Enter ข้าม\n key = ask_text(\"API key (ถ้าต่อผ่าน proxy/โดเมนที่ล็อก key — Enter = ไม่มี):\", password=True)\n models = list_ollama_models(url, key)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n if key:\n cfg[\"api_key\"] = key\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef app_version() -> str:\n \"\"\"เวอร์ชันจาก git ของ repo เอง (ไม่ต้อง bump เลขมือ — /update แล้วเลขขยับเอง):\n v<จำนวน commit> (<hash> · <วันที่>) เช่น v42 (1542f99 · 2026-06-06); ไม่ใช่ git → ว่าง\"\"\"\n try:\n n = subprocess.run([\"git\", \"-C\", REPO_DIR, \"rev-list\", \"--count\", \"HEAD\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n info = subprocess.run([\"git\", \"-C\", REPO_DIR, \"log\", \"-1\", \"--format=%h %cs\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n if n and info and len(info.split()) == 2:\n h, d = info.split()\n return f\"v{n} ({h} · {d})\"\n except Exception:\n pass\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n # Ollama หลัง auth proxy (เช่น Cloudflare Tunnel + Bearer key) → แนบ header ทุก call native\n self.headers = {\"Authorization\": f\"Bearer {cfg['api_key']}\"} if cfg.get(\"api_key\") else {}\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=5, headers=self.headers)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5,\n headers=self.headers,\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None,\n headers=self.headers) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # รอบที่ตอบจริง (ไม่มี tool call — จะรอบแรกหรือหลัง tool ก็ได้) บนเอกสารยาว\n # → vote หา consensus กันคำตอบไม่นิ่ง\n if (VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n ver = app_version()\n if ver:\n t.add_row(\"version\", ver)\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n ver = app_version()\n if ver:\n rows.insert(0, (\"version\", ver))\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<9}\", style=\"dim\") # 9 > \"version\" (7) — กัน label ติดค่า\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "8a7abd07c24881f8b201b3f8530a3c62e5d342dd": {"t": 1785773699.4659464, "n": 91078, "h": "7be6d43545a86ed0", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None)\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n # รอบซ้ำอาจขอเรียก tool แทนการตอบ (history มี tool round) → ข้าม ไม่นับเป็นคำตอบ\n if ans.strip() and not looks_toolish(ans):\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None)\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef app_version() -> str:\n \"\"\"เวอร์ชันจาก git ของ repo เอง (ไม่ต้อง bump เลขมือ — /update แล้วเลขขยับเอง):\n v<จำนวน commit> (<hash> · <วันที่>) เช่น v42 (1542f99 · 2026-06-06); ไม่ใช่ git → ว่าง\"\"\"\n try:\n n = subprocess.run([\"git\", \"-C\", REPO_DIR, \"rev-list\", \"--count\", \"HEAD\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n info = subprocess.run([\"git\", \"-C\", REPO_DIR, \"log\", \"-1\", \"--format=%h %cs\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n if n and info and len(info.split()) == 2:\n h, d = info.split()\n return f\"v{n} ({h} · {d})\"\n except Exception:\n pass\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # รอบที่ตอบจริง (ไม่มี tool call — จะรอบแรกหรือหลัง tool ก็ได้) บนเอกสารยาว\n # → vote หา consensus กันคำตอบไม่นิ่ง\n if (VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n ver = app_version()\n if ver:\n t.add_row(\"version\", ver)\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n ver = app_version()\n if ver:\n rows.insert(0, (\"version\", ver))\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<9}\", style=\"dim\") # 9 > \"version\" (7) — กัน label ติดค่า\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "b66eca9ca5b930d49f34c1ce389ad6ff00014ec8": {"t": 1785773701.3961332, "n": 91038, "h": "22f7d66adda00d9c", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None)\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n # รอบซ้ำอาจขอเรียก tool แทนการตอบ (history มี tool round) → ข้าม ไม่นับเป็นคำตอบ\n if ans.strip() and not looks_toolish(ans):\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None)\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef app_version() -> str:\n \"\"\"เวอร์ชันจาก git ของ repo เอง (ไม่ต้อง bump เลขมือ — /update แล้วเลขขยับเอง):\n v<จำนวน commit> (<hash> · <วันที่>) เช่น v42 (1542f99 · 2026-06-06); ไม่ใช่ git → ว่าง\"\"\"\n try:\n n = subprocess.run([\"git\", \"-C\", REPO_DIR, \"rev-list\", \"--count\", \"HEAD\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n info = subprocess.run([\"git\", \"-C\", REPO_DIR, \"log\", \"-1\", \"--format=%h %cs\"],\n capture_output=True, text=True, timeout=3).stdout.strip()\n if n and info and len(info.split()) == 2:\n h, d = info.split()\n return f\"v{n} ({h} · {d})\"\n except Exception:\n pass\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # รอบที่ตอบจริง (ไม่มี tool call — จะรอบแรกหรือหลัง tool ก็ได้) บนเอกสารยาว\n # → vote หา consensus กันคำตอบไม่นิ่ง\n if (VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n ver = app_version()\n if ver:\n t.add_row(\"version\", ver)\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n ver = app_version()\n if ver:\n rows.insert(0, (\"version\", ver))\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "1542f993201d9eca180ffd74115f5cdc80f6ed83": {"t": 1785773703.0197737, "n": 90151, "h": "1323a5c855b41745", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None)\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n # รอบซ้ำอาจขอเรียก tool แทนการตอบ (history มี tool round) → ข้าม ไม่นับเป็นคำตอบ\n if ans.strip() and not looks_toolish(ans):\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None)\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # รอบที่ตอบจริง (ไม่มี tool call — จะรอบแรกหรือหลัง tool ก็ได้) บนเอกสารยาว\n # → vote หา consensus กันคำตอบไม่นิ่ง\n if (VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "4c6be94e69aceeee44c9870b3c53ad50055be4a8": {"t": 1785773704.7721968, "n": 89997, "h": "3420fabcd5983321", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\nVOTE_ON = True # /vote: เอกสารยาวถามซ้ำ 3 รอบหา consensus (โมเดล local ตอบไม่นิ่งแม้ temp 0)\nVOTE_ROUNDS = 3\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef vote_consensus(backend, messages, first: str, intr=None) -> str | None:\n \"\"\"เอกสารยาว + โมเดล local = คำตอบไม่นิ่งแม้ temp 0 (เทสต์ 2026-06-06: เงื่อนไขเดิมเป๊ะ\n รันซ้ำได้คนละคำตอบ/บางทีติด loop) → ถามซ้ำให้ครบ VOTE_ROUNDS รอบ — รอบ 2+ แทบฟรี\n เพราะ Ollama reuse KV cache ของ prefix เดิม. ตรงกันหมด = ผ่าน; ไม่ตรง = ส่ง 3 คำตอบ\n (prompt สั้น ไม่แนบ doc) ให้โมเดลตัดสินเสียงข้างมาก. คืนคำตอบใหม่ หรือ None = คงคำตอบแรก\"\"\"\n base = messages[:-1] # ตัด assistant คำตอบแรกออก ให้ถามซ้ำจาก state เดิม\n opts = longdoc_options(base)\n if backend.num_ctx:\n opts[\"num_ctx\"] = backend.num_ctx # ต้องเท่าเดิม ไม่งั้น Ollama reload โมเดล\n answers = [first]\n for i in range(2, VOTE_ROUNDS + 1):\n if intr and intr.stopped():\n return None\n console.print(f\"[dim]🗳 เช็คความนิ่ง รอบ {i}/{VOTE_ROUNDS}...[/]\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model, \"messages\": base,\n \"stream\": False, \"options\": opts}, timeout=None)\n ans = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n if ans.strip():\n answers.append(ans)\n except Exception:\n return None # vote ล่ม → ใช้คำตอบแรกตามเดิม\n if len(answers) < 2:\n return None\n norm = {\" \".join(a.split()) for a in answers}\n if len(norm) == 1:\n console.print(f\"[dim]🗳 {len(answers)}/{VOTE_ROUNDS} รอบตอบตรงกัน[/]\")\n return None\n q = next((str(m.get(\"content\") or \"\") for m in reversed(base) if m.get(\"role\") == \"user\"), \"\")[-1500:]\n arb = (\"ส่วนท้ายของคำถาม:\\n\" + q + \"\\n\\nคำตอบจากการถามคำถามเดียวกัน \"\n + f\"{len(answers)} รอบ (โมเดลเดียวกัน คำตอบไม่ตรงกัน):\\n\"\n + \"\\n\\n\".join(f\"--- คำตอบที่ {i+1} ---\\n{a}\" for i, a in enumerate(answers))\n + \"\\n\\nจงเลือกคำตอบสุดท้ายที่น่าเชื่อถือที่สุด: ยึดเสียงข้างมาก \"\n \"ถ้าไม่มีเสียงข้างมากให้ยึดตัวที่เหตุผลภายในสอดคล้องที่สุด \"\n \"ตอบเป็นคำตอบสุดท้ายอย่างเดียว ไม่ต้องอธิบายการเลือก\")\n try:\n r = httpx.post(f\"{backend.api_base}/api/chat\",\n json={\"model\": backend.model,\n \"messages\": [{\"role\": \"user\", \"content\": arb}],\n \"stream\": False, \"options\": opts}, timeout=None)\n final = strip_special(r.json().get(\"message\", {}).get(\"content\", \"\"))\n except Exception:\n return None\n if not final.strip():\n return None\n console.print(Panel(Markdown(final), title=f\"🗳 consensus จาก {len(answers)} รอบ\",\n border_style=THEME[\"border\"], title_align=\"left\"))\n return final\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n # turn ถาม-ตอบล้วน (ไม่มี tool) บนเอกสารยาว → vote หา consensus กันคำตอบไม่นิ่ง\n if (rounds == 1 and VOTE_ON and content.strip()\n and longdoc_options(messages) and not (intr and intr.stopped())):\n final = vote_consensus(self, messages, strip_special(content), intr)\n if final is not None:\n messages[-1][\"content\"] = final # turn ต่อไปต่อยอดจาก consensus\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/vote\": \"เปิด/ปิด vote 3 รอบบนเอกสารยาว (กันคำตอบไม่นิ่งของโมเดล local)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON, VOTE_ON\n THINK_ON = cfg.get(\"think\", False)\n VOTE_ON = cfg.get(\"vote\", True)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/vote\":\n VOTE_ON = not VOTE_ON\n cfg[\"vote\"] = VOTE_ON\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if VOTE_ON else 'dim'}]vote mode: \"\n f\"{'เปิด — เอกสารยาวจะถามซ้ำ 3 รอบหา consensus' if VOTE_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "04db2c38b5ab91100e768d224b31ca1fa9b967f5": {"t": 1785773706.3170757, "n": 86262, "h": "b370fdbad3eab7e1", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate ได้ — num_predict cap กันเสียหายไม่จำกัด.\n (เคยใส่ repeat_penalty 1.3 ด้วย แต่เทสซ้ำพบผลไม่นิ่ง: บาง doc ช่วย multi-hop\n บาง doc ไม่ช่วยแถมทำ recall รายการพัง 9/10→0/10 — ถอนออกจนกว่าจะมี data แน่นกว่า)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"num_predict\": 4096}\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON\n THINK_ON = cfg.get(\"think\", False)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "eddacbdf4e632adfd4d214586069fd7df7117853": {"t": 1785773708.2956085, "n": 86333, "h": "762fdc8f95703537", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate และจับคู่ข้อมูลข้าม context ผิดตัว — repeat_penalty 1.3\n แก้ทั้งสองอาการ (multi-hop 0/2 → 2/2), num_predict กัน gen ไม่จบ.\n ใส่เฉพาะ request ยาวเท่านั้น: penalty สูงแบบ global จะทำ codegen ปกติเพี้ยน\n (ลงโทษ token ที่ต้องซ้ำ เช่นชื่อตัวแปร/indent)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"repeat_penalty\": 1.3, \"num_predict\": 4096}\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n # โมเดล local บางตัวส่ง todos เป็น list ของ string (ไม่ใช่ dict) → ห่อให้ก่อน กัน .get() crash ทั้ง turn\n todos = [t if isinstance(t, dict) else {\"task\": str(t), \"status\": \"pending\"} for t in (todos or [])]\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON\n THINK_ON = cfg.get(\"think\", False)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "6dcbbe85bbeea8646c609080b6b8ea98885b0ceb": {"t": 1785773710.1709955, "n": 86119, "h": "15491147f0c772fd", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\nLONGDOC_CHARS = 150_000 # ~40-50k token — เกินนี้ถือเป็น request วิเคราะห์เอกสารยาว\n\n\ndef longdoc_options(messages: list) -> dict:\n \"\"\"กัน failure mode ของ context ยาว (เทสต์จริง @190k, 2026-06-06): เอกสารยาว\n ทำโมเดลวน loop ตอน generate และจับคู่ข้อมูลข้าม context ผิดตัว — repeat_penalty 1.3\n แก้ทั้งสองอาการ (multi-hop 0/2 → 2/2), num_predict กัน gen ไม่จบ.\n ใส่เฉพาะ request ยาวเท่านั้น: penalty สูงแบบ global จะทำ codegen ปกติเพี้ยน\n (ลงโทษ token ที่ต้องซ้ำ เช่นชื่อตัวแปร/indent)\"\"\"\n chars = sum(len(str(m.get(\"content\") or \"\")) for m in messages)\n if chars < LONGDOC_CHARS:\n return {}\n return {\"repeat_penalty\": 1.3, \"num_predict\": 4096}\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n opts = longdoc_options(messages)\n if self.num_ctx:\n opts[\"num_ctx\"] = self.num_ctx\n if opts:\n payload[\"options\"] = opts\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON\n THINK_ON = cfg.get(\"think\", False)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "43ce5946dff075e37852603b6570bacc90698c18": {"t": 1785773713.1893818, "n": 85350, "h": "e4889c120e1b0dc5", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n import signal\n\n p = subprocess.Popen(\n command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=WORKDIR, start_new_session=True, # group ใหม่ → ฆ่าลูกหลานได้หมด\n )\n\n def kill():\n try:\n os.killpg(os.getpgid(p.pid), signal.SIGKILL)\n except Exception:\n pass\n try:\n p.communicate(timeout=1) # เก็บศพ กัน zombie\n except Exception:\n pass\n\n intr = _active_interrupt\n deadline = time.monotonic() + 120\n while True:\n try:\n out, _ = p.communicate(timeout=0.2) # docs: จับ TimeoutExpired แล้ว retry ได้\n out = (out or \"\").strip()\n return out[:20000] if out else f\"(no output, exit code {p.returncode})\"\n except subprocess.TimeoutExpired:\n pass\n if intr and intr.stopped(): # ESC → ฆ่าทั้ง process group ทันที\n kill()\n return \"Error: ถูกหยุดโดยผู้ใช้ (ESC)\"\n if time.monotonic() > deadline:\n kill()\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n if self.num_ctx:\n payload[\"options\"] = {\"num_ctx\": self.num_ctx}\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON\n THINK_ON = cfg.get(\"think\", False)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "d09449cdbf2d9cb520c3aaa89549b9b2b578877d": {"t": 1785773716.028512, "n": 84611, "h": "fad7263f191f6689", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\ndef hard_close(resp) -> None:\n \"\"\"ปิด httpx response แบบ shutdown socket — ปลุก recv ที่ block อยู่ได้จริง\n (resp.close() เฉยๆ ไม่ปลุก recv ที่ค้าง: ทดสอบแล้ว block ต่อจน server ส่งข้อมูล)\"\"\"\n import socket as _socket\n\n try:\n ns = resp.extensions.get(\"network_stream\")\n s = ns.get_extra_info(\"socket\") if ns else None\n if s:\n s.shutdown(_socket.SHUT_RDWR)\n except Exception:\n pass\n try:\n resp.close()\n except Exception:\n pass\n\n\ndef esc_watch(intr, close_fn) -> threading.Event:\n \"\"\"เฝ้า ESC ระหว่าง stream block รอ token แรก (โหลดโมเดล/prompt eval) — กดแล้วปิด stream\n ให้ iter หลุดจากการ block ทันที; คืน Event ให้ caller .set() ตอนจบ stream เพื่อเลิกเฝ้า\"\"\"\n done = threading.Event()\n if intr:\n def run():\n while not done.wait(0.1):\n if intr.stopped():\n try:\n close_fn()\n except Exception:\n pass\n return\n\n threading.Thread(target=run, daemon=True).start()\n return done\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n try:\n r = subprocess.run(\n command, shell=True, capture_output=True, text=True, timeout=120, cwd=WORKDIR\n )\n out = (r.stdout + r.stderr).strip()\n return out[:20000] if out else f\"(no output, exit code {r.returncode})\"\n except subprocess.TimeoutExpired:\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ tool_result ครบทุก id (กัน API 400 รอบถัดไป)\n if intr and intr.stopped():\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n if self.num_ctx:\n payload[\"options\"] = {\"num_ctx\": self.num_ctx}\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n watch = threading.Event()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n watch = esc_watch(intr, lambda: hard_close(r)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n watch = esc_watch(intr, lambda: hard_close(stream.response)) # ESC ตอนรอ token แรก → ปลุกให้หลุด block\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n except Exception:\n if not (intr and intr.stopped()):\n raise # error จริง — ไม่ใช่ esc_watch ปิด stream เพราะ ESC\n finally:\n watch.set()\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n if intr and intr.stopped(): # ESC ระหว่าง tool → ข้ามตัวที่เหลือ แต่ใส่ผลครบให้ประวัติ valid\n output = \"(หยุดโดยผู้ใช้ด้วย ESC)\"\n else:\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n return\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON\n THINK_ON = cfg.get(\"think\", False)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "4577e8e32fb76a27bbdbaef904c9aaa0b1f84f58": {"t": 1785773717.5872796, "n": 81851, "h": "5da6c7f6f939cfd0", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n old = load_config() or {}\n for k in (\"think\", \"statusline\"): # ค่าที่ wizard ไม่ได้ถาม — คงไว้จาก config เดิม\n if k in old:\n cfg[k] = old[k]\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n try:\n r = subprocess.run(\n command, shell=True, capture_output=True, text=True, timeout=120, cwd=WORKDIR\n )\n out = (r.stdout + r.stderr).strip()\n return out[:20000] if out else f\"(no output, exit code {r.returncode})\"\n except subprocess.TimeoutExpired:\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n if self.num_ctx:\n payload[\"options\"] = {\"num_ctx\": self.num_ctx}\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON\n THINK_ON = cfg.get(\"think\", False)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "76a2a0d0a6d84ade668bc2a1fd32ac82e96a052d": {"t": 1785773719.1853473, "n": 81684, "h": "1e101c6313832571", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n url = ask_text(\"Ollama อยู่ที่ไหน (Enter = เครื่องนี้ / ใส่ IP เครื่องอื่นในวง LAN เช่น 192.168.1.10):\",\n default=OLLAMA_URL)\n # ใส่แค่ IP/hostname ได้ — เติม scheme/port//v1 ให้เอง\n if \"://\" not in url:\n url = \"http://\" + url\n if \":\" not in url.split(\"://\", 1)[1]:\n url += \":11434\"\n if not url.rstrip(\"/\").endswith(\"/v1\"):\n url = url.rstrip(\"/\") + \"/v1\"\n models = list_ollama_models(url)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {url} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n try:\n r = subprocess.run(\n command, shell=True, capture_output=True, text=True, timeout=120, cwd=WORKDIR\n )\n out = (r.stdout + r.stderr).strip()\n return out[:20000] if out else f\"(no output, exit code {r.returncode})\"\n except subprocess.TimeoutExpired:\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n if self.num_ctx:\n payload[\"options\"] = {\"num_ctx\": self.num_ctx}\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON\n THINK_ON = cfg.get(\"think\", False)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "e12b60c19772bfdb2a41afbcf50ae1ae982d67df": {"t": 1785773722.7553265, "n": 81259, "h": "0d31313e412e7600", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\nREPO_DIR = os.path.dirname(os.path.abspath(__file__)) # โฟลเดอร์ที่ clone มา (ใช้เช็ค/ดึงอัปเดต)\nUPDATE_AVAILABLE = threading.Event() # ตั้งโดย background check → main loop โชว์เตือนครั้งเดียว\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n models = list_ollama_models(OLLAMA_URL)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {OLLAMA_URL} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": OLLAMA_URL, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n try:\n r = subprocess.run(\n command, shell=True, capture_output=True, text=True, timeout=120, cwd=WORKDIR\n )\n out = (r.stdout + r.stderr).strip()\n return out[:20000] if out else f\"(no output, exit code {r.returncode})\"\n except subprocess.TimeoutExpired:\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n if self.num_ctx:\n payload[\"options\"] = {\"num_ctx\": self.num_ctx}\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- เช็คอัปเดตจาก GitHub ----------\n\ndef check_update_bg() -> None:\n \"\"\"เช็คเวอร์ชันใหม่จาก git remote ใน daemon thread (เงียบ ไม่ถ่วง startup)\n เตือนเฉพาะตอน remote ใหม่กว่าจริง — เครื่อง dev ที่ commit ล้ำหน้า remote ไม่เตือน\"\"\"\n\n def run():\n try:\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n return # ติดตั้งแบบไม่มี git (เช่น โหลด zip) → เช็คไม่ได้\n local = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"rev-parse\", \"HEAD\"],\n capture_output=True, text=True, timeout=5,\n ).stdout.strip()\n remote_out = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"ls-remote\", \"--quiet\", \"origin\", \"HEAD\"],\n capture_output=True, text=True, timeout=15,\n ).stdout.split()\n remote = remote_out[0] if remote_out else \"\"\n if not local or not remote or local == remote:\n return\n # remote เป็นบรรพบุรุษของ HEAD = เราล้ำหน้าเอง ไม่ใช่ตกรุ่น; ถ้า object ไม่รู้จัก = remote มีของใหม่\n anc = subprocess.run(\n [\"git\", \"-C\", REPO_DIR, \"merge-base\", \"--is-ancestor\", remote, \"HEAD\"],\n capture_output=True, timeout=5,\n )\n if anc.returncode != 0:\n UPDATE_AVAILABLE.set()\n except Exception:\n pass # ออฟไลน์/ไม่มี git → เงียบไว้\n\n threading.Thread(target=run, daemon=True).start()\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/update\": \"อัปเดต BOYSER AI เป็นเวอร์ชันล่าสุดจาก GitHub\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON\n THINK_ON = cfg.get(\"think\", False)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n check_update_bg()\n\n while True:\n if UPDATE_AVAILABLE.is_set():\n UPDATE_AVAILABLE.clear() # เตือนครั้งเดียวพอ\n console.print(f\"[yellow]⬆ มีเวอร์ชันใหม่บน GitHub — พิมพ์ [bold]/update[/bold] เพื่ออัปเดต[/]\")\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/update\":\n import shutil\n\n if not os.path.isdir(os.path.join(REPO_DIR, \".git\")):\n console.print(\"[yellow]ติดตั้งแบบไม่มี git — อัปเดตด้วยการ clone repo ใหม่[/]\")\n continue\n console.print(\"[dim]กำลังดึงเวอร์ชันใหม่ (git pull)...[/]\")\n r = subprocess.run([\"git\", \"-C\", REPO_DIR, \"pull\", \"--ff-only\"], capture_output=True, text=True)\n out = (r.stdout + r.stderr).strip()\n if r.returncode != 0:\n console.print(f\"[red]pull ไม่สำเร็จ:[/] {out}\")\n continue\n if \"Already up to date\" in out:\n console.print(f\"[{THEME['accent']}]เป็นเวอร์ชันล่าสุดอยู่แล้ว[/]\")\n continue\n subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"-r\", os.path.join(REPO_DIR, \"requirements.txt\"), \"-q\"],\n capture_output=True,\n )\n # ติดตั้ง skills ใหม่ที่เพิ่มมากับเวอร์ชันนี้ (เฉพาะตัวที่ยังไม่มี — ไม่ทับของที่ user แก้เอง)\n src = os.path.join(REPO_DIR, \"skills\")\n added = 0\n if os.path.isdir(src):\n os.makedirs(SKILLS_DIRS[1], exist_ok=True)\n for name in os.listdir(src):\n s, d = os.path.join(src, name), os.path.join(SKILLS_DIRS[1], name)\n if os.path.isdir(s) and not os.path.exists(d):\n shutil.copytree(s, d)\n added += 1\n extra = f\" · skills ใหม่ {added} ตัว\" if added else \"\"\n console.print(f\"[green]✓ อัปเดตแล้ว{extra} — ปิดแล้วเปิด boyser-ai ใหม่เพื่อใช้เวอร์ชันล่าสุด[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}, "35c7bc9923164a28eeb3816330b4cd16478913fc": {"t": 1785773724.2546847, "n": 77622, "h": "eb47dd9bf3f21e82", "c": "#!/usr/bin/env python3\n\"\"\"BOYSER AI — agent ส่วนตัวแบบ Claude Code รันบนเครื่อง local.\n\nรันครั้งแรกจะมีเมนูตั้งค่า (เลือก Claude API / Ollama / llama.cpp)\nตั้งค่าเก็บที่ ~/.config/boyser-ai/config.json\n\nใน REPL: พิมพ์ / เพื่อดูเมนูคำสั่ง · exit ออก\nTools: bash, read_file, write_file, edit_file, glob, grep, web_search, web_fetch\n\"\"\"\n\nimport argparse\nimport difflib\nimport glob as globlib\nimport html as htmllib\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\n\nimport threading\n\nimport httpx\nfrom rich.align import Align\nfrom rich.console import Console, Group\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.live import Live\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom rich.text import Text\n\nconsole = Console()\n\n# ---------- shimmer \"กำลังคิด\" แบบสีวิ่งผ่านคำ (จับ combining marks ไทยไว้กับพยัญชนะ) ----------\n_GRAPHEME = re.compile(r\".[ัิ-ฺ็-๎]*\")\n_SHIMMER = [\"grey35\", \"grey50\", \"grey66\", \"grey82\", \"white\", \"grey82\", \"grey66\", \"grey50\", \"grey35\"]\n\n\ndef _shimmer_word(word: str, frame: int) -> Text:\n cells = _GRAPHEME.findall(word)\n center = frame % (len(cells) + 8)\n t = Text()\n for i, g in enumerate(cells):\n d = abs(i - center)\n t.append(g, style=_SHIMMER[d] if d < len(_SHIMMER) else \"grey35\")\n return t\n\n\nclass Thinking:\n \"\"\"spinner 'กำลังคิด' แบบ shimmer สีวิ่ง + เวลา ขณะรอโมเดล (ไม่โชว์เนื้อหา thinking)\"\"\"\n\n LABEL = \"กำลังคิด\"\n\n def __init__(self):\n self.start = time.monotonic()\n self._frame = 0\n self._stop = threading.Event()\n self._animated = console.is_terminal\n self._thread = None\n if self._animated:\n self._live = Live(console=console, auto_refresh=False, transient=True)\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n\n def _render(self) -> Text:\n elapsed = time.monotonic() - self.start\n head = Text(\"✻ \", style=THEME[\"accent\"])\n head.append_text(_shimmer_word(self.LABEL, self._frame))\n head.append(f\" {elapsed:.0f}s\", style=\"dim\")\n return head\n\n def _run(self) -> None:\n with self._live:\n while not self._stop.is_set():\n self._frame += 1\n self._live.update(self._render(), refresh=True)\n time.sleep(0.08)\n\n def stop(self) -> None:\n if self._animated:\n self._stop.set()\n self._thread.join()\n\nCLAUDE_MODELS = [\n (\"claude-opus-4-8\", \"ฉลาดสุด ($5/$25 ต่อ 1M token)\"),\n (\"claude-sonnet-4-6\", \"สมดุลความเร็ว/ราคา ($3/$15)\"),\n (\"claude-haiku-4-5\", \"เร็ว ถูกสุด ($1/$5)\"),\n]\nOLLAMA_URL = \"http://localhost:11434/v1\"\nLLAMACPP_URL = \"http://localhost:8080/v1\"\n# ผู้ให้บริการ cloud ที่ใช้ API แบบ OpenAI-compatible (ใส่ key แล้วใช้ผ่าน backend เดียวกับ local)\nCLOUD_PROVIDERS = {\n \"OpenRouter\": \"https://openrouter.ai/api/v1\",\n \"Groq\": \"https://api.groq.com/openai/v1\",\n \"DeepSeek\": \"https://api.deepseek.com/v1\",\n \"OpenAI\": \"https://api.openai.com/v1\",\n \"Together\": \"https://api.together.xyz/v1\",\n \"Gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\nWORKDIR = os.getcwd()\nCONFIG_DIR = os.path.expanduser(\"~/.config/boyser-ai\")\nCONFIG_PATH = os.path.join(CONFIG_DIR, \"config.json\")\nHISTORY_PATH = os.path.join(CONFIG_DIR, \"history\")\nMEMORY_FILE = os.path.join(CONFIG_DIR, \"memory.md\")\nPROJECT_FILES = [\"BOYSER.md\", \"AGENTS.md\", \"CLAUDE.md\"] # auto-load ไฟล์แรกที่เจอใน cwd\n# skill = โฟลเดอร์ที่มี SKILL.md (frontmatter: name/description) — project มาก่อน global\nSKILLS_DIRS = [os.path.join(WORKDIR, \".boyser\", \"skills\"), os.path.join(CONFIG_DIR, \"skills\")]\nSKILLS: dict = {} # name -> {path, dir, description}\n\n\ndef _load_logo() -> str:\n try:\n import pyfiglet\n\n return pyfiglet.figlet_format(\"BOYSER AI\", font=\"ansi_shadow\").rstrip(\"\\n\")\n except Exception:\n return \"✻ BOYSER AI\"\n\n\nLOGO = _load_logo()\n\n# ---------- ธีมสี (เปลี่ยนได้ด้วย /theme) ----------\nTHEMES = {\n \"ฟ้า\": {\"accent\": \"#22b8e6\", \"border\": \"#5fafff\"},\n \"เขียว\": {\"accent\": \"#56d364\", \"border\": \"#3fb950\"},\n \"ม่วง\": {\"accent\": \"#a371f7\", \"border\": \"#bc8cff\"},\n \"ส้ม\": {\"accent\": \"#ff9e3d\", \"border\": \"#ff8700\"},\n \"ชมพู\": {\"accent\": \"#ff79c6\", \"border\": \"#ff87d7\"},\n \"ขาวดำ\": {\"accent\": \"#dddddd\", \"border\": \"#888888\"},\n}\nTHEME = {\"name\": \"ฟ้า\", **THEMES[\"ฟ้า\"]}\nTHINK_ON = False # /think: ให้โมเดล local reason ก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\nMAX_TOOL_ROUNDS = 25 # กันลูป agentic ไม่จบ (โมเดลเรียก tool ซ้ำไม่หยุด)\n\n\ndef apply_theme(name: str) -> None:\n if name in THEMES:\n THEME.update(name=name, **THEMES[name])\n\nYOLO = False\nALWAYS_ALLOW: set = set()\n\nimport datetime\n\n_THAI_DAYS = [\"วันจันทร์\", \"วันอังคาร\", \"วันพุธ\", \"วันพฤหัสบดี\", \"วันศุกร์\", \"วันเสาร์\", \"วันอาทิตย์\"]\n_now = datetime.datetime.now()\nTODAY = f\"{_now.strftime('%A %Y-%m-%d')} ({_THAI_DAYS[_now.weekday()]})\"\n\nSYSTEM = f\"\"\"You are BOYSER AI, a helpful coding agent running on the user's local machine.\nWorking directory: {WORKDIR}\nCurrent date: {TODAY} (run `date` via bash if you need the exact time)\n\nUse your tools to complete tasks:\n- bash: run shell commands (git, install, run scripts)\n- read_file / write_file / edit_file: work with files; prefer edit_file for small changes\n- glob / grep: explore the codebase before editing — find files by pattern, search content\n- web_search / web_fetch: look up current information online. IMPORTANT: web_search returns only short snippets (often just page descriptions, not the actual data). If the snippets don't already contain the concrete answer (a price, a number, a date, a fact), you MUST web_fetch the most relevant result URL to read the real content BEFORE answering. Never answer a factual question with just a list of sources/links — fetch, then answer with the actual value and cite the source.\n\nLanguage rules (important):\n- When the user writes in Thai, ALWAYS answer in Thai. Every sentence of explanation must be Thai.\n- Keep technical terms in English as-is: code, shell commands, file paths, error messages, library/model names, programming keywords. Do not translate them.\n- When the user writes in another language, answer in that language.\n- NEVER output Chinese characters unless the user writes in Chinese. If you catch yourself writing Chinese, rewrite that sentence in Thai.\n\nWorking method (how to be effective):\n- For any task with more than ~2 steps, FIRST call todo_write to lay out a short plan, then work through it — mark each item in_progress before starting it and done when finished. Keep exactly one item in_progress at a time.\n- Explore before you edit: use glob / grep / read_file to understand the code first. Make surgical changes — touch only what the task needs, don't refactor unrelated code.\n- ALWAYS verify before saying a task is done — this is mandatory, not optional:\n 1. Run the code (and its tests) via bash. If there are no tests and the task is non-trivial, write a quick one.\n 2. Test edge cases and TRY TO BREAK IT — don't just run the happy path.\n 3. If it produces output, check the output is actually CORRECT, not just that it ran without error. When a ground truth exists (a known-good result, a reference implementation, a spec), compare against it.\n 4. Fix whatever breaks, then re-verify. Only say \"done\" after it passes.\n (See the self-check skill for the full procedure on substantial work.)\n- Use the remember tool to save durable facts the user will want next time (their preferences, project conventions, decisions made). Don't save trivia.\n- ask_user is your PREFERRED way to ask anything with a few likely answers. BEFORE you guess/assume a choice, or ask a question in prose, STOP and call ask_user with the options. Trigger it when: the request is ambiguous (e.g. \"ทำเว็บให้หน่อย\" → which kind/framework/style?), there are 2+ ways to do it, you need a preference (color/format/scope), or you're about to do something big or irreversible (confirm first). Give 2-5 concrete options (add multiple:true if several can apply). Example: ask_user(question=\"เว็บแบบไหน?\", options=[\"Landing page\",\"ร้านค้า\",\"Dashboard\",\"Blog\"]). Don't use it for trivial things you can just do.\n- For a MULTI-FILE project, output each file as a line `FILE: <relative/path>` immediately followed by a fenced code block containing the file's full content (do this for every file). The user can then save them all at once with /save — no need to call write_file per file.\n\nBe concise. Format answers in Markdown.\"\"\"\n\nBASE_SYSTEM = SYSTEM # เก็บ base ไว้ rebuild (เติม memory/skills/project) ได้ตอน /memory แก้\n\nTOOLS = [\n {\n \"name\": \"bash\",\n \"description\": \"Run a bash command on the user's machine and return stdout/stderr. Call this for anything that needs the shell: git, installing packages, running scripts, system info.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\", \"description\": \"The bash command to run\"},\n },\n \"required\": [\"command\"],\n },\n },\n {\n \"name\": \"read_file\",\n \"description\": \"Read a text file and return its contents with line numbers.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative file path\"},\n },\n \"required\": [\"path\"],\n },\n },\n {\n \"name\": \"write_file\",\n \"description\": \"Create or overwrite a file with the given content.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"content\"],\n },\n },\n {\n \"name\": \"edit_file\",\n \"description\": \"Replace an exact string in a file with a new string. old_string must appear exactly once in the file.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\"},\n \"old_string\": {\"type\": \"string\"},\n \"new_string\": {\"type\": \"string\"},\n },\n \"required\": [\"path\", \"old_string\", \"new_string\"],\n },\n },\n {\n \"name\": \"glob\",\n \"description\": \"Find files matching a glob pattern, sorted by modification time (newest first). Call this to locate files before reading or editing them. Example patterns: '**/*.py', 'src/**/*.ts'.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern, ** matches directories recursively\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"grep\",\n \"description\": \"Search file contents with a regex pattern (recursive, skips .git/.venv/node_modules). Returns matching lines as path:line:text. Call this to find where something is defined or used.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\"type\": \"string\", \"description\": \"Regex pattern to search for\"},\n \"path\": {\"type\": \"string\", \"description\": \"Directory or file to search in (default: current directory)\"},\n },\n \"required\": [\"pattern\"],\n },\n },\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web (DuckDuckGo) and return result titles, URLs and SHORT snippets. The snippets are usually page descriptions, NOT the actual data — they rarely contain the concrete answer (e.g. a live price). Use this to find which pages to read, then call web_fetch on the best URL to get the real content. Do not answer a factual question from snippets alone.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\"type\": \"string\"},\n },\n \"required\": [\"query\"],\n },\n },\n {\n \"name\": \"web_fetch\",\n \"description\": \"Fetch a URL and return its text content (HTML stripped). Call this to read a specific page, often after web_search.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\"type\": \"string\"},\n },\n \"required\": [\"url\"],\n },\n },\n {\n \"name\": \"todo_write\",\n \"description\": \"Create or update the task list (your plan). Pass the FULL list every time. Use it to plan multi-step work and track progress; keep exactly one task in_progress.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"done\"]},\n },\n \"required\": [\"task\", \"status\"],\n },\n },\n },\n \"required\": [\"todos\"],\n },\n },\n {\n \"name\": \"remember\",\n \"description\": \"Save one durable fact to persistent memory so it is available in future sessions (user preferences, project conventions, important decisions). One concise fact per call. Don't save trivia.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"fact\": {\"type\": \"string\"},\n },\n \"required\": [\"fact\"],\n },\n },\n {\n \"name\": \"use_skill\",\n \"description\": \"Load the full step-by-step instructions for a named skill (listed under 'Skills' in the system prompt). Call this BEFORE starting a task that matches a skill's description, then follow the loaded instructions.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\", \"description\": \"Skill name exactly as listed\"},\n },\n \"required\": [\"name\"],\n },\n },\n {\n \"name\": \"ask_user\",\n \"description\": \"Ask the user a question and let them PICK from options instead of plain text. CALL THIS whenever the request is ambiguous or has 2+ reasonable options/approaches, when you need a preference (which framework/color/format/scope), or to confirm before a big or irreversible action — INSTEAD of guessing or asking in prose. Example: ask_user(question='ใช้ framework ไหน?', options=['React','Vue','Vanilla JS']). Returns the user's choice.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"question\": {\"type\": \"string\"},\n \"options\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"2-5 ตัวเลือกให้ผู้ใช้กดเลือก\"},\n \"multiple\": {\"type\": \"boolean\", \"description\": \"true = เลือกได้หลายข้อ (checkbox); false = เลือกข้อเดียว\"},\n },\n \"required\": [\"question\", \"options\"],\n },\n },\n]\n\n\n# ---------- config ----------\n\ndef load_config() -> dict | None:\n try:\n with open(CONFIG_PATH, encoding=\"utf-8\") as f:\n return json.load(f)\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef save_config(cfg: dict) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n json.dump(cfg, f, indent=2, ensure_ascii=False)\n os.chmod(CONFIG_PATH, 0o600) # มี api key อยู่ข้างใน\n\n\ndef _parse_frontmatter(path: str) -> tuple[dict, str]:\n \"\"\"อ่าน frontmatter (--- key: value ---) → (meta, body)\"\"\"\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError:\n return {}, \"\"\n meta = {}\n body = text\n if text.startswith(\"---\"):\n end = text.find(\"\\n---\", 3)\n if end != -1:\n for line in text[3:end].splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip()\n body = text[end + 4:].lstrip()\n return meta, body\n\n\ndef discover_skills() -> dict:\n skills = {}\n for base in SKILLS_DIRS:\n if not os.path.isdir(base):\n continue\n for entry in sorted(os.listdir(base)):\n md = os.path.join(base, entry, \"SKILL.md\")\n if os.path.isfile(md):\n meta, _ = _parse_frontmatter(md)\n name = meta.get(\"name\", entry)\n if name not in skills: # project (ลิสต์ก่อน) ทับ global\n skills[name] = {\"path\": md, \"dir\": os.path.dirname(md), \"description\": meta.get(\"description\", \"\")}\n return skills\n\n\ndef use_skill(name: str) -> str:\n sk = SKILLS.get(name)\n if not sk:\n return f\"Error: ไม่พบ skill '{name}'. ที่มี: {', '.join(SKILLS) or '(ไม่มี)'}\"\n _, body = _parse_frontmatter(sk[\"path\"])\n files = [fn for fn in sorted(os.listdir(sk[\"dir\"])) if fn != \"SKILL.md\"]\n extra = f\"\\n\\n[ไฟล์ประกอบใน {sk['dir']}: {', '.join(files)}]\" if files else \"\"\n return body + extra\n\n\ndef build_system(base: str) -> tuple[str, list[str]]:\n \"\"\"ประกอบ system prompt + โหลด memory, project context, รายการ skills\"\"\"\n global SKILLS\n SKILLS = discover_skills()\n parts = [base]\n loaded = []\n if SKILLS:\n lines = \"\\n\".join(f\"- {n}: {s['description']}\" for n, s in SKILLS.items())\n parts.append(\n \"\\n# Skills (ความเชี่ยวชาญเฉพาะทาง)\\n\"\n \"เมื่อ task ตรงกับ skill ด้านล่าง เรียก tool `use_skill` ด้วยชื่อนั้นเพื่อโหลดวิธีทำแบบเต็มก่อนลงมือ:\\n\"\n f\"{lines}\"\n )\n loaded.append(f\"skills ({len(SKILLS)})\")\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n mem = f.read().strip()\n if mem:\n parts.append(f\"\\n# Persistent memory (จำไว้ข้าม session)\\n{mem}\")\n loaded.append(f\"memory ({mem.count(chr(10)) + 1} บรรทัด)\")\n except OSError:\n pass\n for name in PROJECT_FILES:\n p = os.path.join(WORKDIR, name)\n if os.path.isfile(p):\n try:\n with open(p, encoding=\"utf-8\") as f:\n parts.append(f\"\\n# Project context — {name}\\n{f.read().strip()[:8000]}\")\n loaded.append(name)\n except OSError:\n pass\n break\n return \"\\n\".join(parts), loaded\n\n\ndef list_ollama_models(base_url: str) -> list[str]:\n try:\n r = httpx.get(base_url.replace(\"/v1\", \"/api/tags\"), timeout=3)\n return [m[\"name\"] for m in r.json().get(\"models\", [])]\n except Exception:\n return []\n\n\ndef menu_select(title: str, choices: list[tuple[str, str]]) -> str:\n \"\"\"เมนูเลือกด้วยลูกศร (questionary) — ถ้าไม่มี TTY ใช้พิมพ์เลขแทน\"\"\"\n if sys.stdin.isatty():\n import questionary\n\n q_choices = [\n questionary.Choice(title=f\"{label} {desc}\" if desc else label, value=label)\n for label, desc in choices\n ]\n ans = questionary.select(title, choices=q_choices).ask()\n if ans is None:\n sys.exit(0)\n return ans\n console.print(f\"\\n[bold]{title}[/]\")\n for i, (label, desc) in enumerate(choices, 1):\n console.print(f\" {i}. {label} [dim]{desc}[/]\")\n while True:\n ans = console.input(\"เลือกหมายเลข: \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(choices):\n return choices[int(ans) - 1][0]\n\n\ndef ask_text(title: str, default: str = \"\", password: bool = False) -> str:\n if sys.stdin.isatty():\n import questionary\n\n fn = questionary.password if password else questionary.text\n kwargs = {} if password else {\"default\": default}\n ans = fn(title, **kwargs).ask()\n if ans is None:\n sys.exit(0)\n return ans.strip() or default\n return console.input(f\"{title} \").strip() or default\n\n\ndef ask_ctx(default: int = 16384) -> int:\n val = ask_text(\n \"context window num_ctx (token · มากขึ้น = จำได้เยอะแต่กิน VRAM/ช้าลง):\",\n default=str(default),\n )\n try:\n n = int(val.replace(\",\", \"\").strip())\n return n if n >= 512 else default\n except ValueError:\n return default\n\n\ndef ant_oauth_login() -> None:\n \"\"\"ตรวจ ant CLI + login ถ้ายังไม่ได้ login (ครั้งเดียว — ant จำ profile บนดิสก์ให้เอง)\"\"\"\n import shutil\n\n if not shutil.which(\"ant\"):\n console.print(\"[yellow]ยังไม่มี Anthropic CLI (`ant`) — ต้องติดตั้งก่อนใช้ OAuth:[/]\")\n console.print(\"[dim] mac: brew install anthropics/tap/ant[/]\")\n console.print(\"[dim] go: go install github.com/anthropics/anthropic-cli/cmd/ant@latest[/]\")\n console.print(\"[dim]ติดตั้งแล้วพิมพ์ ! ant auth login ในเซสชัน หรือเลือก OAuth ใหม่[/]\")\n return\n try: # logged in จริง = print-credentials ได้ token sk-ant-... (status คืน 0 เสมอ เชื่อไม่ได้)\n cred = subprocess.run(\n [\"ant\", \"auth\", \"print-credentials\", \"--access-token\"],\n capture_output=True, text=True, timeout=10,\n )\n if cred.stdout.strip().startswith(\"sk-ant\"):\n console.print(\"[green]✓ login อยู่แล้ว (ant จำไว้ให้) — ใช้ได้เลย[/]\")\n return\n except Exception:\n pass\n console.print(\"[cyan]จะเปิดเบราว์เซอร์ให้ login ครั้งเดียว (หลังจากนี้ ant จะจำไว้)…[/]\")\n try:\n subprocess.run([\"ant\", \"auth\", \"login\"], timeout=300)\n except Exception as e:\n console.print(f\"[red]login ไม่สำเร็จ: {e} — ลอง `! ant auth login` เองได้[/]\")\n\n\ndef setup_wizard() -> dict:\n console.print(Panel.fit(\n \"[bold bright_blue]✻ BOYSER AI[/] — ตั้งค่าครั้งแรก\\n\"\n \"[dim]เลือกว่าจะใช้สมองจากที่ไหน (เปลี่ยนทีหลังได้ด้วย /model)[/]\",\n border_style=\"bright_blue\",\n ))\n\n backend = menu_select(\"เลือก backend:\", [\n (\"Claude API\", \"Claude ของ Anthropic — API key หรือ login\"),\n (\"Cloud API\", \"OpenRouter / Groq / DeepSeek / OpenAI / Gemini ฯลฯ (ใส่ key)\"),\n (\"Ollama\", \"โมเดล local ฟรี — ใช้เครื่องตัวเอง\"),\n (\"llama.cpp\", \"llama-server แบบ OpenAI-compatible\"),\n ])\n\n if backend == \"Claude API\":\n model = menu_select(\"เลือกโมเดล:\", CLAUDE_MODELS)\n auth = menu_select(\"วิธียืนยันตัวตน:\", [\n (\"API key\", \"วาง sk-ant-... (จ่ายตามใช้)\"),\n (\"OAuth login\", \"ไม่ต้องวาง key — ต้อง `ant auth login` ก่อน (ยังเป็น API auth ไม่ใช่ subscription)\"),\n ])\n if auth == \"API key\":\n key = os.environ.get(\"ANTHROPIC_API_KEY\", \"\")\n if key:\n console.print(\"[green]เจอ ANTHROPIC_API_KEY ใน environment แล้ว จะใช้ตัวนั้น[/]\")\n else:\n key = ask_text(\"วาง API key (sk-ant-... จาก platform.claude.com):\", password=True)\n cfg = {\"backend\": \"claude\", \"model\": model, \"api_key\": key}\n else:\n ant_oauth_login() # เช็ค+login ให้ตรงนี้เลย (ครั้งแรก) — รอบหลัง ant จำไว้\n cfg = {\"backend\": \"claude\", \"model\": model} # ไม่มี key → SDK ใช้ profile/env\n\n elif backend == \"Cloud API\":\n choices = [(n, u) for n, u in CLOUD_PROVIDERS.items()] + [(\"กำหนดเอง\", \"ใส่ base_url เอง\")]\n prov = menu_select(\"เลือกผู้ให้บริการ:\", choices)\n base = CLOUD_PROVIDERS.get(prov) or ask_text(\"base_url (OpenAI-compatible):\", default=\"https://\")\n key = ask_text(f\"วาง API key ของ {prov}:\", password=True)\n eg = {\"OpenRouter\": \"anthropic/claude-sonnet-4.6\", \"Groq\": \"llama-3.3-70b-versatile\",\n \"DeepSeek\": \"deepseek-chat\", \"OpenAI\": \"gpt-4o\", \"Gemini\": \"gemini-2.0-flash\"}.get(prov, \"ชื่อโมเดล\")\n model = ask_text(f\"ชื่อโมเดล (เช่น {eg}):\", default=eg)\n cfg = {\"backend\": \"local\", \"base_url\": base, \"model\": model, \"api_key\": key}\n\n elif backend == \"Ollama\":\n models = list_ollama_models(OLLAMA_URL)\n if models:\n model = menu_select(\"เลือกโมเดล (จาก ollama list):\", [(m, \"\") for m in models])\n else:\n console.print(f\"[yellow]ต่อ Ollama ที่ {OLLAMA_URL} ไม่ได้ — พิมพ์ชื่อโมเดลเอง[/]\")\n model = ask_text(\"ชื่อโมเดล:\", default=\"qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": OLLAMA_URL, \"model\": model, \"num_ctx\": ask_ctx()}\n\n else: # llama.cpp\n url = ask_text(\"URL ของ llama-server:\", default=LLAMACPP_URL)\n model = ask_text(\"ชื่อโมเดล (llama.cpp ใส่อะไรก็ได้):\", default=\"default\")\n console.print(\"[dim]หมายเหตุ: llama.cpp กำหนด ctx ตอนรัน server (-c) เป็นหลัก ค่านี้ส่งไปเผื่อรองรับ[/]\")\n cfg = {\"backend\": \"local\", \"base_url\": url, \"model\": model, \"num_ctx\": ask_ctx()}\n\n cfg[\"theme\"] = menu_select(\"เลือกธีมสี:\", [(n, \"\") for n in THEMES])\n save_config(cfg)\n console.print(f\"[green]✓ บันทึกที่ {CONFIG_PATH}[/]\\n\")\n return cfg\n\n\n# ---------- ui helpers ----------\n\ndef confirm(key: str) -> bool:\n \"\"\"ถามยืนยันก่อนทำ action ที่แก้ไขเครื่อง (y / n / a = อนุญาต tool นี้ตลอด session)\"\"\"\n if YOLO or key in ALWAYS_ALLOW:\n return True\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # กันไม่ให้ตัวดัก ESC แย่งปุ่ม y/n/a\n ans = console.input(\" [yellow]ดำเนินการ? (y / n / a=อนุญาตตลอด)[/] \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n return False\n finally:\n if intr:\n intr.resume()\n if ans == \"a\":\n ALWAYS_ALLOW.add(key)\n return True\n return ans == \"y\"\n\n\n_active_interrupt = None # Interrupt ที่ทำงานอยู่ (ให้ confirm() หยุดมันชั่วคราวได้)\n\n\nclass Interrupt:\n \"\"\"ดักปุ่ม ESC ระหว่างโมเดลกำลังตอบ (อ่าน stdin ใน thread แยก) — กด ESC = หยุด turn นี้\n หยุดอ่านชั่วคราวได้ด้วย pause()/resume() เพื่อไม่แย่งปุ่มตอน input() ถาม y/n\"\"\"\n\n def __init__(self):\n self.event = threading.Event()\n self._stop = threading.Event()\n self._paused = threading.Event()\n self._thread = None\n self._fd = None\n self._old = None\n\n def __enter__(self):\n global _active_interrupt\n if sys.stdin.isatty():\n import termios\n import tty\n\n try:\n self._fd = sys.stdin.fileno()\n self._old = termios.tcgetattr(self._fd)\n tty.setcbreak(self._fd) # อ่านทีละปุ่มโดยไม่ต้องกด Enter\n self._thread = threading.Thread(target=self._run, daemon=True)\n self._thread.start()\n except Exception:\n self._old = None\n _active_interrupt = self\n return self\n\n def _run(self):\n import select\n\n while not self._stop.is_set():\n if self._paused.is_set():\n time.sleep(0.05)\n continue\n r, _, _ = select.select([sys.stdin], [], [], 0.1)\n if r and not self._paused.is_set():\n ch = os.read(self._fd, 1)\n if ch == b\"\\x1b\": # ESC\n self.event.set()\n return\n\n def stopped(self) -> bool:\n return self.event.is_set()\n\n def pause(self):\n \"\"\"หยุดอ่านปุ่ม + คืนโหมด terminal ปกติ เพื่อให้ input() (ถาม y/n) ใช้ได้\"\"\"\n if self._old is None or not self._thread:\n return\n self._paused.set()\n import termios\n\n try:\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n except Exception:\n pass\n time.sleep(0.06) # ให้ reader หลุดจาก select รอบปัจจุบันก่อน input()\n\n def resume(self):\n if self._old is None or not self._thread:\n return\n import tty\n\n try:\n tty.setcbreak(self._fd)\n except Exception:\n pass\n self._paused.clear()\n\n def __exit__(self, *a):\n global _active_interrupt\n self._stop.set()\n if self._thread:\n self._thread.join(timeout=0.3)\n if self._old is not None:\n import termios\n\n termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)\n _active_interrupt = None\n\n\nSESSION = {\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": 0.0, \"rem_tokens\": None, \"reset_tokens\": None}\n_status_ctx: dict = {\"backend\": None, \"on\": False}\n\n\ndef git_branch() -> str:\n try:\n r = subprocess.run(\n [\"git\", \"-C\", WORKDIR, \"branch\", \"--show-current\"],\n capture_output=True, text=True, timeout=2,\n )\n return r.stdout.strip()\n except Exception:\n return \"\"\n\n\ndef parse_reset_time(reset_str: str) -> str:\n if not reset_str:\n return \"\"\n if \"T\" in reset_str:\n try:\n s = reset_str.replace(\"Z\", \"+00:00\")\n dt = datetime.datetime.fromisoformat(s)\n now = datetime.datetime.now(datetime.timezone.utc)\n delta = dt - now\n seconds = int(delta.total_seconds())\n if seconds <= 0:\n return \"0s\"\n if seconds < 60:\n return f\"{seconds}s\"\n minutes = seconds // 60\n secs = seconds % 60\n if minutes < 60:\n return f\"{minutes}m{secs}s\"\n hours = minutes // 60\n mins = minutes % 60\n return f\"{hours}h{mins}m\"\n except Exception:\n if \"T\" in reset_str:\n parts = reset_str.split(\"T\")\n if len(parts) > 1:\n return parts[1].split(\".\")[0].rstrip(\"Z\")\n return reset_str\n return reset_str\n\n\ndef format_tokens(tok_str: str) -> str:\n if not tok_str:\n return \"\"\n try:\n val = int(tok_str)\n if val >= 1_000_000:\n return f\"{val / 1_000_000:.1f}M\"\n if val >= 1_000:\n return f\"{val / 1_000:.0f}k\"\n return str(val)\n except Exception:\n return tok_str\n\n\ndef extract_rate_limits(headers) -> tuple[str | None, str | None]:\n if not headers:\n return None, None\n h_lower = {k.lower(): v for k, v in headers.items()}\n \n rem_tokens = None\n for k in [\"anthropic-ratelimit-tokens-remaining\", \"x-ratelimit-remaining-tokens\", \"x-ratelimit-tokens-remaining\"]:\n if k in h_lower:\n rem_tokens = h_lower[k]\n break\n \n reset_tokens = None\n for k in [\"anthropic-ratelimit-tokens-reset\", \"x-ratelimit-reset-tokens\", \"x-ratelimit-tokens-reset\"]:\n if k in h_lower:\n reset_tokens = h_lower[k]\n break\n \n return rem_tokens, reset_tokens\n\n\ndef build_statusline() -> str:\n b = _status_ctx.get(\"backend\")\n parts = [b.name if b else \"?\"]\n br = git_branch()\n if br:\n parts.append(f\"⎇ {br}\")\n if SESSION[\"in\"] or SESSION[\"out\"]:\n parts.append(f\"↑{SESSION['in']} ↓{SESSION['out']} tok\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_parts = []\n if rem:\n lim_parts.append(f\"rem {format_tokens(rem)}\")\n if reset:\n lim_parts.append(f\"reset {parse_reset_time(reset)}\")\n parts.append(\" \".join(lim_parts))\n return \" · \".join(parts)\n\n\ndef usage_line(tokens_in: int, cached: int, tokens_out: int, elapsed: float) -> None:\n SESSION[\"in\"] += tokens_in\n SESSION[\"out\"] += tokens_out\n SESSION[\"cached\"] += cached\n tps = tokens_out / elapsed if elapsed > 0 else 0\n cache_part = f\" (+cache {cached})\" if cached else \"\"\n console.print(\n f\"[dim] ⤷ in {tokens_in}{cache_part} · out {tokens_out} · {elapsed:.1f}s · {tps:.0f} tok/s[/]\"\n )\n\n\ndef show_tool_call(name: str, tool_input: dict) -> None:\n arg = next(iter(tool_input.values()), \"\")\n if isinstance(arg, (list, dict)):\n arg = f\"{len(arg)} รายการ\"\n arg = str(arg).replace(\"\\n\", \" \")\n if len(arg) > 80:\n arg = arg[:77] + \"...\"\n console.print(f\"[bold {THEME['accent']}]⏺[/] [bold]{name}[/]([dim]{arg}[/])\")\n\n\ndef show_tool_result(output: str) -> None:\n lines = output.splitlines() or [\"\"]\n first = lines[0][:100]\n more = f\" … (+{len(lines) - 1} บรรทัด)\" if len(lines) > 1 else \"\"\n style = \"red\" if output.startswith(\"Error\") else \"dim\"\n console.print(f\" [dim]⎿[/] [{style}]{first}{more}[/]\")\n\n\ndef show_diff(path: str, old_text: str, new_text: str) -> None:\n diff = \"\\n\".join(\n difflib.unified_diff(\n old_text.splitlines(), new_text.splitlines(),\n fromfile=path, tofile=path, lineterm=\"\",\n )\n )\n if len(diff) > 4000:\n diff = diff[:4000] + \"\\n… (diff ยาว ตัดให้ดูบางส่วน)\"\n console.print(Syntax(diff, \"diff\", theme=\"ansi_dark\", background_color=\"default\"))\n\n\n# ---------- tool implementations ----------\n\ndef run_bash(command: str) -> str:\n if not confirm(\"bash\"):\n return \"Error: user denied this command.\"\n try:\n r = subprocess.run(\n command, shell=True, capture_output=True, text=True, timeout=120, cwd=WORKDIR\n )\n out = (r.stdout + r.stderr).strip()\n return out[:20000] if out else f\"(no output, exit code {r.returncode})\"\n except subprocess.TimeoutExpired:\n return \"Error: command timed out after 120s.\"\n\n\ndef read_file(path: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n lines = f.readlines()\n return \"\".join(f\"{i + 1}\\t{line}\" for i, line in enumerate(lines[:2000]))\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef write_file(path: str, content: str) -> str:\n old = \"\"\n if os.path.exists(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n old = f.read()\n except OSError:\n pass\n show_diff(path, old, content)\n if not confirm(\"write_file\"):\n return \"Error: user denied this write.\"\n try:\n os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(content)\n return f\"Wrote {path}\"\n except OSError as e:\n return f\"Error: {e}\"\n\n\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n try:\n with open(path, encoding=\"utf-8\") as f:\n text = f.read()\n except OSError as e:\n return f\"Error: {e}\"\n n = text.count(old_string)\n if n == 0:\n return \"Error: old_string not found in file.\"\n if n > 1:\n return f\"Error: old_string appears {n} times; it must be unique.\"\n new_text = text.replace(old_string, new_string)\n show_diff(path, text, new_text)\n if not confirm(\"edit_file\"):\n return \"Error: user denied this edit.\"\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(new_text)\n return f\"Edited {path}\"\n\n\ndef glob_tool(pattern: str) -> str:\n try:\n matches = globlib.glob(pattern, recursive=True)\n except Exception as e:\n return f\"Error: {e}\"\n matches = [m for m in matches if \"/.venv/\" not in m and \"/.git/\" not in m]\n matches.sort(key=lambda m: os.path.getmtime(m) if os.path.exists(m) else 0, reverse=True)\n if not matches:\n return \"(no files matched)\"\n return \"\\n\".join(matches[:100])\n\n\ndef grep_tool(pattern: str, path: str = \".\") -> str:\n cmd = [\n \"grep\", \"-rn\", \"-I\", \"-E\", \"-m\", \"5\",\n \"--exclude-dir=.git\", \"--exclude-dir=.venv\", \"--exclude-dir=node_modules\",\n \"--exclude-dir=__pycache__\", \"-e\", pattern, path,\n ]\n try:\n r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)\n except subprocess.TimeoutExpired:\n return \"Error: grep timed out.\"\n if r.returncode == 2:\n return f\"Error: {r.stderr.strip()}\"\n lines = r.stdout.splitlines()\n if not lines:\n return \"(no matches)\"\n return \"\\n\".join(lines[:100])\n\n\ndef web_search(query: str) -> str:\n try:\n from ddgs import DDGS\n\n results = DDGS().text(query, max_results=8)\n except Exception as e:\n return f\"Error: {e}\"\n if not results:\n return \"(no results)\"\n out = \"\\n\\n\".join(\n f\"{r.get('title', '')}\\n{r.get('href', '')}\\n{r.get('body', '')}\" for r in results\n )\n # snippet มักเป็นแค่คำโปรยไม่มีคำตอบจริง → ดึงเนื้อหาหน้าแรกที่ใช้ได้มาแนบเลย\n # (เผื่อโมเดลไม่ chain ไป web_fetch เอง) ลองสูงสุด 3 อันแรกจนกว่าจะได้เนื้อหาจริง\n for r in results[:3]:\n url = r.get(\"href\", \"\")\n if not url:\n continue\n page = web_fetch(url)\n if not page.startswith(\"Error\") and len(page) > 400:\n out += f\"\\n\\n=== เนื้อหาจาก {url} ===\\n{page[:3000]}\"\n break\n return out\n\n\ndef web_fetch(url: str) -> str:\n try:\n r = httpx.get(\n url, follow_redirects=True, timeout=20,\n headers={\"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64)\"},\n )\n text = r.text\n except Exception as e:\n return f\"Error: {e}\"\n text = re.sub(r\"<(script|style)[^>]*>.*?</\\1>\", \" \", text, flags=re.S | re.I)\n text = re.sub(r\"<[^>]+>\", \" \", text)\n text = htmllib.unescape(re.sub(r\"[ \\t]+\", \" \", text))\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text).strip()\n return text[:15000] if text else \"(empty page)\"\n\n\ndef parse_text_toolcalls(text: str) -> list:\n \"\"\"fallback: โมเดล local บางตัวพ่น tool call เป็นข้อความ (parser ของ engine จับไม่ได้)\n ดักจับ 3 ฟอร์แมตยอดฮิต → คืน [{name, args(dict)}]\"\"\"\n s = text.strip()\n calls: list = []\n\n # A) ทั้งก้อนเป็น JSON tool call ก้อนเดียว (qwen2.5-coder) หรือใน ```json ... ```\n fence = re.search(r\"```(?:json)?\\s*(\\{.*?\\})\\s*```\", s, re.S)\n candidate = fence.group(1) if fence else (s if s.startswith(\"{\") else \"\")\n if candidate and '\"name\"' in candidate:\n try:\n o = json.loads(candidate)\n if isinstance(o, dict) and \"name\" in o:\n return [{\"name\": o[\"name\"], \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}}]\n except json.JSONDecodeError:\n pass\n\n # B) <tool_call>{json}</tool_call> (Hermes/Qwen)\n for m in re.finditer(r\"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>\", s, re.S):\n try:\n o = json.loads(m.group(1))\n calls.append({\"name\": o.get(\"name\", \"\"), \"args\": o.get(\"arguments\") or o.get(\"parameters\") or {}})\n except json.JSONDecodeError:\n pass\n if calls:\n return calls\n\n # C) <function=NAME>...<parameter=KEY>VALUE...</...> (ปิดด้วย </function> หรือ </tool_call> ก็ได้)\n for fm in re.finditer(r\"<function=([^>\\s]+)>(.*?)(?:</function>|</tool_call>|\\Z)\", s, re.S):\n name = fm.group(1).strip()\n args = {}\n for pm in re.finditer(\n r\"<parameter=([^>\\s]+)>\\s*(.*?)\\s*(?=<parameter=|</parameter>|</function>|</tool_call>|\\Z)\",\n fm.group(2), re.S,\n ):\n args[pm.group(1).strip()] = pm.group(2).strip()\n if name:\n calls.append({\"name\": name, \"args\": args})\n return calls\n\n\n# ลบ special token ที่บางโมเดล (เช่น gemma4 quant q4) ทำหลุดมาใน content\n# จงใจเจาะจงเฉพาะ token พิเศษ ไม่แตะ <tag> ของ HTML/โค้ดที่โมเดลตั้งใจเขียน\n# จับ token ที่ขึ้นต้น <| หรือ ลงท้าย |> (channel, tool_call, tool_response, im_start ฯลฯ)\n# ปลอดภัยกับโค้ด: `a<b || c>d` ไม่โดน เพราะไม่ได้ขึ้นต้น <| หรือลงท้าย |>\n_SPECIAL_TOK = re.compile(\n r\"<\\|[^<>]*>|<[^<>]*\\|>|</?(?:start_of_turn|end_of_turn)>|<(?:eos|bos|pad|unused\\d+)>\"\n)\n\n\ndef strip_special(text: str) -> str:\n return _SPECIAL_TOK.sub(\"\", text)\n\n\n# แยกไฟล์จากคำตอบที่มี marker `FILE: path` ตามด้วย code block (สำหรับ /save multi-file)\n_FILE_BLOCK = re.compile(r\"FILE:\\s*([^\\s\\n]+)[^\\n]*\\n+```[a-zA-Z0-9]*\\n(.*?)```\", re.S)\n\n\ndef extract_files(text: str) -> list:\n return [(m.group(1).strip(), m.group(2)) for m in _FILE_BLOCK.finditer(text or \"\")]\n\n\ndef looks_toolish(text: str) -> bool:\n \"\"\"เดาว่า content ที่ stream มากำลังจะเป็น tool call (ไว้กันไม่ให้ render เป็นข้อความ)\"\"\"\n s = text.lstrip()\n return (\n s.startswith(\"<tool_call>\")\n or s.startswith(\"<function\")\n or s.startswith(\"```json\")\n or (s.startswith(\"{\") and '\"name\"' in s)\n )\n\n\nCURRENT_TODOS: list = []\n_TODO_MARK = {\"pending\": (\"○\", \"white\"), \"in_progress\": (\"◐\", \"yellow\"), \"done\": (\"●\", \"green\")}\n\n\ndef render_todos(todos: list) -> None:\n body = Text()\n for t in todos:\n mark, color = _TODO_MARK.get(t.get(\"status\", \"pending\"), (\"○\", \"white\"))\n line_style = \"green strike\" if t.get(\"status\") == \"done\" else color\n body.append(f\"{mark} \", style=color)\n body.append(t.get(\"task\", \"\") + \"\\n\", style=line_style)\n if body.plain.endswith(\"\\n\"):\n body.right_crop(1)\n console.print(Panel(body, title=\"แผนงาน\", border_style=\"dim\", title_align=\"left\"))\n\n\ndef todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n render_todos(todos)\n done = sum(1 for t in todos if t.get(\"status\") == \"done\")\n return f\"Plan updated ({done}/{len(todos)} done).\"\n\n\ndef remember(fact: str) -> str:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n stamp = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n with open(MEMORY_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"- [{stamp}] {fact.strip()}\\n\")\n return \"บันทึกลง memory แล้ว (จะจำได้ใน session ต่อไป)\"\n\n\ndef ask_user(question: str, options: list, multiple: bool = False) -> str:\n \"\"\"ถามผู้ใช้แล้วให้เลือกจากตัวเลือก (radio/checkbox) + 'อื่นๆ พิมพ์เอง' — คืนค่าที่เลือกเป็น tool result\"\"\"\n console.print(f\"\\n[bold {THEME['accent']}]? {question}[/]\")\n opts = [str(o) for o in (options or [])] + [\"อื่นๆ (พิมพ์เอง)\"]\n intr = _active_interrupt\n try:\n if intr:\n intr.pause() # หยุดตัวดัก ESC ไม่ให้แย่งปุ่มตอนเลือกเมนู\n if not sys.stdin.isatty():\n for i, o in enumerate(opts, 1):\n console.print(f\" {i}. {o}\")\n ans = console.input(\"เลือก (เลข/ข้อความ): \").strip()\n if ans.isdigit() and 1 <= int(ans) <= len(options or []):\n return opts[int(ans) - 1]\n return ans or \"(ไม่เลือก)\"\n import questionary\n\n if multiple:\n sel = questionary.checkbox(question, choices=opts).ask()\n if not sel:\n return \"(ผู้ใช้ไม่เลือกอะไร)\"\n out = []\n for s in sel:\n out.append((questionary.text(\"ระบุ:\").ask() or \"\") if s.startswith(\"อื่นๆ\") else s)\n return \", \".join(x for x in out if x) or \"(ไม่เลือก)\"\n sel = questionary.select(question, choices=opts).ask()\n if sel is None:\n return \"(ผู้ใช้ยกเลิก)\"\n if sel.startswith(\"อื่นๆ\"):\n return questionary.text(\"ระบุ:\").ask() or \"(ว่าง)\"\n return sel\n except (EOFError, KeyboardInterrupt):\n return \"(ผู้ใช้ยกเลิก)\"\n finally:\n if intr:\n intr.resume()\n\n\ndef read_memory_lines() -> list:\n try:\n with open(MEMORY_FILE, encoding=\"utf-8\") as f:\n return [ln.rstrip(\"\\n\") for ln in f if ln.strip()]\n except OSError:\n return []\n\n\ndef write_memory_lines(lines: list) -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n with open(MEMORY_FILE, \"w\", encoding=\"utf-8\") as f:\n f.write(\"\\n\".join(lines) + (\"\\n\" if lines else \"\"))\n\n\ndef print_memory() -> None:\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n return\n t = Table(show_header=False, box=None, padding=(0, 1))\n for i, ln in enumerate(lines, 1):\n t.add_row(f\"[dim]{i}[/]\", ln)\n console.print(Panel(t, title=f\"memory ({len(lines)})\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\ndef edit_memory() -> None:\n os.makedirs(CONFIG_DIR, exist_ok=True)\n if not os.path.exists(MEMORY_FILE):\n open(MEMORY_FILE, \"a\").close()\n editor = os.environ.get(\"EDITOR\") or os.environ.get(\"VISUAL\") or \"nano\"\n try:\n subprocess.call([editor, MEMORY_FILE])\n except Exception as e:\n console.print(f\"[red]เปิด editor ({editor}) ไม่ได้: {e}[/]\")\n\n\nTOOL_FUNCS = {\n \"bash\": lambda i: run_bash(i[\"command\"]),\n \"read_file\": lambda i: read_file(i[\"path\"]),\n \"write_file\": lambda i: write_file(i[\"path\"], i[\"content\"]),\n \"edit_file\": lambda i: edit_file(i[\"path\"], i[\"old_string\"], i[\"new_string\"]),\n \"glob\": lambda i: glob_tool(i[\"pattern\"]),\n \"grep\": lambda i: grep_tool(i[\"pattern\"], i.get(\"path\", \".\")),\n \"web_search\": lambda i: web_search(i[\"query\"]),\n \"web_fetch\": lambda i: web_fetch(i[\"url\"]),\n \"todo_write\": lambda i: todo_write(i[\"todos\"]),\n \"remember\": lambda i: remember(i[\"fact\"]),\n \"use_skill\": lambda i: use_skill(i[\"name\"]),\n \"ask_user\": lambda i: ask_user(i[\"question\"], i.get(\"options\", []), i.get(\"multiple\", False)),\n}\n\n# tool ที่ไม่ต้องโชว์บรรทัด ⏺/⎿ (มี UI ของตัวเอง)\n_QUIET_TOOLS = {\"todo_write\", \"ask_user\"}\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n quiet = name in _QUIET_TOOLS\n if not quiet:\n show_tool_call(name, tool_input)\n fn = TOOL_FUNCS.get(name)\n output = fn(tool_input) if fn else f\"Error: unknown tool {name}\"\n if not quiet:\n show_tool_result(output)\n return output\n\n\n# ---------- backends ----------\n\nclass ClaudeBackend:\n def __init__(self, cfg: dict):\n import anthropic\n\n self.anthropic = anthropic\n self.model = cfg[\"model\"]\n self.client = anthropic.Anthropic(api_key=cfg.get(\"api_key\") or None)\n self.api_error = anthropic.APIError\n self.name = self.model\n self.label = self.model\n\n def new_messages(self) -> list:\n return []\n\n def turn(self, messages: list, intr=None) -> None:\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n with self.client.messages.stream(\n model=self.model,\n max_tokens=32000,\n system=SYSTEM,\n tools=TOOLS,\n thinking={\"type\": \"adaptive\"}, # คิดเงียบๆ ข้างใน ไม่โชว์ (display omitted)\n cache_control={\"type\": \"ephemeral\"}, # cache ประวัติ ลดค่า token\n messages=messages,\n ) as stream:\n text = \"\"\n live = None\n think = Thinking()\n try:\n for event in stream:\n if intr and intr.stopped():\n break\n if event.type != \"content_block_delta\" or event.delta.type != \"text_delta\":\n continue\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n text += event.delta.text\n live.update(Markdown(text))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": text or \"(หยุดโดยผู้ใช้)\"})\n return\n response = stream.get_final_message()\n try:\n if hasattr(stream, \"response\") and hasattr(stream.response, \"headers\"):\n rem_tok, reset_tok = extract_rate_limits(stream.response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n\n u = response.usage\n usage_line(u.input_tokens, u.cache_read_input_tokens, u.output_tokens, time.monotonic() - start)\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = execute_tool(block.name, block.input)\n results.append(\n {\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output}\n )\n messages.append({\"role\": \"user\", \"content\": results})\n\n\nclass LocalBackend:\n OPENAI_TOOLS = [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t[\"name\"],\n \"description\": t[\"description\"],\n \"parameters\": t[\"input_schema\"],\n },\n }\n for t in TOOLS\n ]\n\n def __init__(self, cfg: dict):\n import openai\n\n self.model = cfg[\"model\"]\n self.num_ctx = cfg.get(\"num_ctx\")\n self.client = openai.OpenAI(base_url=cfg[\"base_url\"], api_key=cfg.get(\"api_key\") or \"local\")\n self.api_error = openai.APIError\n # Ollama /v1 ไม่รับ num_ctx (reload ที่ค่า Modelfile เสมอ) → ตรวจว่าเป็น Ollama\n # เพื่อยิงผ่าน native /api/chat ที่ตั้ง num_ctx ได้จริง\n self.api_base = cfg[\"base_url\"].rstrip(\"/\")\n if self.api_base.endswith(\"/v1\"):\n self.api_base = self.api_base[:-3].rstrip(\"/\")\n self.is_ollama = False\n self.supports_think = False\n try:\n r = httpx.get(self.api_base + \"/api/version\", timeout=2)\n # ต้องเป็น Ollama จริง (status 200 + มี field version) — กัน cloud API ที่ตอบ 404 เฉยๆ\n if r.status_code == 200 and \"version\" in r.json():\n self.is_ollama = True\n caps = httpx.post(\n self.api_base + \"/api/show\", json={\"model\": self.model}, timeout=5\n ).json().get(\"capabilities\", [])\n self.supports_think = \"thinking\" in caps # /think ส่ง think:true เฉพาะตัวที่รองรับ\n except Exception:\n pass\n ctx_tag = f\" · ctx {self.num_ctx}\" if self.num_ctx else \"\"\n self.name = self.model\n self.label = f\"{self.model} @ {cfg['base_url']}{ctx_tag}\"\n\n def new_messages(self) -> list:\n return [{\"role\": \"system\", \"content\": SYSTEM}]\n\n def _turn_ollama(self, messages: list, intr=None) -> None:\n \"\"\"native /api/chat — ตั้ง num_ctx ได้จริง (ใช้ native message shape: tool_calls.arguments เป็น dict)\"\"\"\n rounds = 0\n prev_sig = \"\"\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n payload = {\n \"model\": self.model,\n \"messages\": messages,\n \"tools\": self.OPENAI_TOOLS,\n \"stream\": True,\n }\n if THINK_ON and self.supports_think:\n payload[\"think\"] = True # /think เปิด → โมเดล reason ก่อน (spinner หมุนระหว่างคิด)\n if self.num_ctx:\n payload[\"options\"] = {\"num_ctx\": self.num_ctx}\n\n content = \"\"\n calls: list = []\n usage = None\n live = None\n think = Thinking()\n try:\n with httpx.stream(\"POST\", f\"{self.api_base}/api/chat\", json=payload, timeout=None) as r:\n try:\n rem_tok, reset_tok = extract_rate_limits(r.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n for line in r.iter_lines():\n if intr and intr.stopped():\n break\n if not line:\n continue\n o = json.loads(line)\n msg = o.get(\"message\", {})\n if msg.get(\"content\"):\n content += msg[\"content\"]\n # ถ้าเดาว่าเป็น tool call ที่พ่นเป็นข้อความ → ไม่ render รอ parse ตอนจบ\n if not looks_toolish(content):\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n for tc in msg.get(\"tool_calls\") or []:\n fn = tc.get(\"function\", {})\n calls.append({\"name\": fn.get(\"name\", \"\"), \"args\": fn.get(\"arguments\", {})})\n if o.get(\"done\"):\n usage = (o.get(\"prompt_eval_count\", 0), o.get(\"eval_count\", 0))\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage[0], 0, usage[1], time.monotonic() - start)\n\n # fallback: ถ้า engine ไม่ได้แยก tool call ออกมา แต่โมเดลพ่นเป็นข้อความ → parse เอง\n if not calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n calls = parsed\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content)))\n\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [{\"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]}} for c in calls]\n messages.append(assistant)\n\n if not calls:\n return\n\n sig = \"|\".join(f\"{c['name']}:{json.dumps(c['args'], sort_keys=True, default=str)}\" for c in calls)\n if sig and sig == prev_sig: # เรียก tool เดิมซ้ำเป๊ะ = ติด loop → หยุดทันที\n console.print(\"[red]⊘ หยุด: โมเดลเรียก tool เดิมซ้ำ (ติด loop)[/]\")\n return\n prev_sig = sig\n\n for c in calls:\n args = c[\"args\"] if isinstance(c[\"args\"], dict) else json.loads(c[\"args\"] or \"{}\")\n output = execute_tool(c[\"name\"], args)\n messages.append({\"role\": \"tool\", \"tool_name\": c[\"name\"], \"content\": output})\n\n def turn(self, messages: list, intr=None) -> None:\n if self.is_ollama:\n return self._turn_ollama(messages, intr) # native /api/chat → ตั้ง num_ctx ได้จริง\n rounds = 0\n while True:\n rounds += 1\n if rounds > MAX_TOOL_ROUNDS:\n console.print(f\"[red]⊘ หยุด: เรียก tool เกิน {MAX_TOOL_ROUNDS} รอบ (อาจติด loop)[/]\")\n return\n start = time.monotonic()\n raw_response = self.client.chat.completions.with_raw_response.create(\n model=self.model,\n messages=messages,\n tools=self.OPENAI_TOOLS,\n stream=True,\n stream_options={\"include_usage\": True},\n )\n try:\n rem_tok, reset_tok = extract_rate_limits(raw_response.headers)\n if rem_tok:\n SESSION[\"rem_tokens\"] = rem_tok\n if reset_tok:\n SESSION[\"reset_tokens\"] = reset_tok\n except Exception:\n pass\n stream = raw_response.parse()\n\n content = \"\"\n tool_calls: dict[int, dict] = {} # ประกอบ tool call จากชิ้นส่วนใน stream\n usage = None\n live = None\n think = Thinking()\n try:\n for chunk in stream:\n if intr and intr.stopped():\n break\n if getattr(chunk, \"usage\", None):\n usage = chunk.usage\n if not chunk.choices:\n continue\n d = chunk.choices[0].delta\n\n if d.content:\n content += d.content\n if not looks_toolish(content): # กัน tool call ที่พ่นเป็นข้อความ\n if think:\n think.stop()\n think = None\n if live is None:\n live = Live(console=console, vertical_overflow=\"visible\")\n live.start()\n live.update(Markdown(strip_special(content)))\n\n if d.tool_calls:\n for tc in d.tool_calls:\n e = tool_calls.setdefault(tc.index, {\"id\": \"\", \"name\": \"\", \"args\": \"\"})\n if tc.id:\n e[\"id\"] = tc.id\n if tc.function:\n e[\"name\"] += tc.function.name or \"\"\n e[\"args\"] += tc.function.arguments or \"\"\n finally:\n if think:\n think.stop()\n if live:\n live.stop()\n\n if intr and intr.stopped():\n console.print(\"[dim]⊘ หยุดแล้ว (ESC)[/]\")\n messages.append({\"role\": \"assistant\", \"content\": content or \"(หยุดโดยผู้ใช้)\"})\n return\n\n if usage:\n usage_line(usage.prompt_tokens, 0, usage.completion_tokens, time.monotonic() - start)\n\n # fallback: บาง local model พ่น tool call เป็นข้อความ → parse เอง\n if not tool_calls and content.strip():\n parsed = parse_text_toolcalls(content)\n if parsed:\n for i, p in enumerate(parsed):\n tool_calls[i] = {\"id\": f\"call_{i}\", \"name\": p[\"name\"], \"args\": json.dumps(p[\"args\"])}\n content = \"\"\n elif live is None:\n console.print(Markdown(strip_special(content))) # นึกว่า toolish แต่เป็นคำตอบ → โชว์\n\n calls = [tool_calls[i] for i in sorted(tool_calls)]\n assistant: dict = {\"role\": \"assistant\", \"content\": strip_special(content)}\n if calls:\n assistant[\"tool_calls\"] = [\n {\n \"id\": c[\"id\"] or f\"call_{i}\",\n \"type\": \"function\",\n \"function\": {\"name\": c[\"name\"], \"arguments\": c[\"args\"]},\n }\n for i, c in enumerate(calls)\n ]\n messages.append(assistant)\n\n if not calls:\n return\n\n for i, c in enumerate(calls):\n try:\n args = json.loads(c[\"args\"] or \"{}\")\n except json.JSONDecodeError:\n output = \"Error: invalid JSON in tool arguments.\"\n else:\n output = execute_tool(c[\"name\"], args)\n messages.append(\n {\"role\": \"tool\", \"tool_call_id\": c[\"id\"] or f\"call_{i}\", \"content\": output}\n )\n\n\ndef make_backend(cfg: dict):\n return ClaudeBackend(cfg) if cfg[\"backend\"] == \"claude\" else LocalBackend(cfg)\n\n\n# ---------- slash commands + input ----------\n\nSLASH_COMMANDS = {\n \"/help\": \"ดูวิธีใช้และรายการคำสั่ง\",\n \"/clear\": \"ล้างประวัติบทสนทนา\",\n \"/model\": \"เปลี่ยนโมเดล / backend (เมนูตั้งค่าใหม่)\",\n \"/ctx\": \"ตั้งขนาด context window — เช่น /ctx 32768 (เฉพาะโมเดล local)\",\n \"/theme\": \"เปลี่ยนธีมสี\",\n \"/skills\": \"ดู skills ที่มี\",\n \"/memory\": \"ดู/แก้/ลบ ความจำข้าม session\",\n \"/save\": \"บันทึกไฟล์หลายไฟล์จากคำตอบล่าสุด (ที่มี FILE: markers)\",\n \"/tools\": \"ดูรายการ tools ทั้งหมด\",\n \"/status\": \"สรุปสถานะ session (model, tokens, git, ฯลฯ)\",\n \"/statusline\": \"เปิด/ปิด status line ที่ก้นกล่องพิมพ์\",\n \"/think\": \"เปิด/ปิดให้โมเดล local คิดก่อนตอบ (ช้าลงแต่ฉลาดขึ้น)\",\n \"/yolo\": \"เปิด/ปิดโหมดไม่ถามยืนยัน\",\n \"/exit\": \"ออกจากโปรแกรม\",\n}\n\n\ndef make_prompt():\n \"\"\"ช่องพิมพ์แบบกล่องมีกรอบ (เหมือน Claude Code): ❯ ในกรอบ + เมนู / เด้ง + hint ล่าง\n ไม่มี TTY → ใช้ input() ธรรมดา\"\"\"\n if not sys.stdin.isatty():\n return lambda *a: input(\"\\n> \")\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.completion import Completer, Completion\n from prompt_toolkit.history import FileHistory\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.layout.menus import CompletionsMenu\n from prompt_toolkit.styles import DynamicStyle, Style\n from prompt_toolkit.widgets import Frame, TextArea\n\n class SlashCompleter(Completer):\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n if text.startswith(\"/\") and \" \" not in text:\n for cmd, desc in SLASH_COMMANDS.items():\n if cmd.startswith(text):\n yield Completion(cmd, start_position=-len(text), display=cmd, display_meta=desc)\n\n os.makedirs(CONFIG_DIR, exist_ok=True)\n state = {\"label\": \"\"}\n\n ta = TextArea(\n prompt=[(\"class:arrow\", \"❯ \")],\n multiline=False,\n wrap_lines=True,\n completer=SlashCompleter(),\n complete_while_typing=True,\n history=FileHistory(HISTORY_PATH),\n )\n\n kb = KeyBindings()\n\n @kb.add(\"enter\")\n def _(event):\n b = ta.buffer\n if b.complete_state and b.complete_state.current_completion:\n b.apply_completion(b.complete_state.current_completion) # เลือกเมนูก่อน ค่อย submit รอบหน้า\n else:\n event.app.exit(result=b.text)\n\n @kb.add(\"c-c\")\n def _(event):\n event.app.exit(exception=KeyboardInterrupt)\n\n @kb.add(\"c-d\")\n def _(event):\n if not ta.text:\n event.app.exit(exception=EOFError)\n\n def hint():\n if state.get(\"sl\"):\n return [(\"class:hint\", f\" {state['sl']} · / · ESC หยุด\")]\n return [(\"class:hint\", f\" {state['label']} · พิมพ์ / ดูคำสั่ง · ESC หยุด · Ctrl+C ออก\")]\n\n root = FloatContainer(\n content=HSplit([Frame(ta), Window(FormattedTextControl(hint), height=1)]),\n floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],\n )\n def build_style(): # อ่าน THEME สดทุกครั้ง → /theme เปลี่ยนสีกล่องได้ทันที\n a = THEME[\"accent\"]\n return Style.from_dict({\n \"arrow\": f\"{a} bold\",\n \"frame.border\": THEME[\"border\"],\n \"hint\": \"#666666\",\n \"completion-menu.completion\": \"bg:#222222 #bbbbbb\",\n \"completion-menu.completion.current\": f\"bg:{a} #000000\",\n \"completion-menu.meta.completion\": \"bg:#222222 #777777\",\n \"completion-menu.meta.completion.current\": f\"bg:{a} #000000\",\n })\n\n # erase_when_done = พอกด Enter กล่องหายไป (ไม่ค้างเป็นกล่องซ้ำ) แล้วเราพิมพ์ข้อความที่ส่งเองทีหลัง\n app = Application(\n layout=Layout(root, focused_element=ta), key_bindings=kb,\n style=DynamicStyle(build_style), erase_when_done=True,\n )\n\n def read(label=\"\"):\n state[\"label\"] = label\n state[\"sl\"] = build_statusline() if _status_ctx.get(\"on\") else \"\" # คำนวณครั้งเดียวต่อ prompt\n ta.text = \"\"\n return app.run() or \"\"\n\n return read\n\n\ndef print_help(backend) -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for cmd, desc in SLASH_COMMANDS.items():\n table.add_row(f\"[bold cyan]{cmd}[/]\", desc)\n console.print(Panel(table, title=\"คำสั่ง\", border_style=\"dim\"))\n console.print(f\"[dim]model: {backend.label} · config: {CONFIG_PATH}[/]\")\n\n\ndef print_tools() -> None:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for t in TOOLS:\n table.add_row(f\"[bold {THEME['accent']}]{t['name']}[/]\", t[\"description\"].split(\".\")[0])\n console.print(Panel(table, title=\"Tools\", border_style=\"dim\"))\n\n\ndef _fmt_dur(sec: float) -> str:\n sec = int(sec)\n h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60\n return (f\"{h}ชม \" if h else \"\") + (f\"{m}น \" if m or h else \"\") + f\"{s}วิ\"\n\n\ndef print_status(backend, cfg: dict, loaded: list) -> None:\n a = THEME[\"accent\"]\n t = Table(show_header=False, box=None, padding=(0, 2))\n t.add_row(\"model\", f\"[bold {a}]{backend.name}[/]\")\n ctx = f\" · ctx {backend.num_ctx}\" if getattr(backend, \"num_ctx\", None) else \"\"\n t.add_row(\"backend\", cfg[\"backend\"] + ctx)\n br = git_branch()\n t.add_row(\"cwd\", WORKDIR + (f\" ⎇ {br}\" if br else \"\"))\n t.add_row(\"theme\", THEME[\"name\"])\n t.add_row(\"think\", \"เปิด\" if THINK_ON else \"ปิด\")\n t.add_row(\"skills / tools\", f\"{len(SKILLS)} / {len(TOOL_FUNCS)}\")\n if loaded:\n t.add_row(\"loaded\", \", \".join(loaded))\n up = time.monotonic() - SESSION[\"started\"] if SESSION[\"started\"] else 0\n t.add_row(\"session\", f\"{SESSION['turns']} turns · {_fmt_dur(up)}\")\n t.add_row(\"tokens\", f\"↑ {SESSION['in']} ↓ {SESSION['out']} (cache {SESSION['cached']})\")\n rem = SESSION.get(\"rem_tokens\")\n reset = SESSION.get(\"reset_tokens\")\n if rem or reset:\n lim_str = \"\"\n if rem:\n lim_str += f\"rem {rem} ({format_tokens(rem)})\"\n if reset:\n if lim_str:\n lim_str += \" · \"\n lim_str += f\"reset {parse_reset_time(reset)} ({reset})\"\n t.add_row(\"rate limits\", lim_str)\n t.add_row(\"config\", CONFIG_PATH)\n console.print(Panel(t, title=\"status\", border_style=THEME[\"border\"], title_align=\"left\"))\n\n\n# ---------- main ----------\n\ndef main() -> None:\n global YOLO\n parser = argparse.ArgumentParser(description=\"BOYSER AI — mini Claude Code\")\n parser.add_argument(\"--local\", nargs=\"?\", const=OLLAMA_URL, default=None, metavar=\"BASE_URL\",\n help=\"ข้าม config ใช้โมเดล local ชั่วคราว\")\n parser.add_argument(\"--model\", help=\"ชื่อโมเดล (ใช้คู่ --local)\")\n parser.add_argument(\"--setup\", action=\"store_true\", help=\"เปิดเมนูตั้งค่าใหม่\")\n parser.add_argument(\"--yolo\", action=\"store_true\", help=\"ไม่ถามยืนยันก่อนรัน tool (ระวัง!)\")\n args = parser.parse_args()\n YOLO = args.yolo\n\n # ---- เลือก config: flag ชั่วคราว > ไฟล์ config > เมนูตั้งค่าครั้งแรก ----\n if args.local:\n if not args.model:\n sys.exit(\"ต้องระบุ --model ด้วย เช่น --model qwen3-coder-tools\")\n cfg = {\"backend\": \"local\", \"base_url\": args.local, \"model\": args.model}\n else:\n cfg = load_config()\n if cfg is None or args.setup:\n cfg = setup_wizard()\n\n apply_theme(cfg.get(\"theme\", \"ฟ้า\"))\n global THINK_ON\n THINK_ON = cfg.get(\"think\", False)\n\n global SYSTEM\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n\n backend = make_backend(cfg)\n messages = backend.new_messages()\n read_input = make_prompt()\n SESSION[\"started\"] = time.monotonic()\n _status_ctx[\"backend\"] = backend\n _status_ctx[\"on\"] = cfg.get(\"statusline\", False)\n\n def show_banner():\n accent = THEME[\"accent\"]\n logo_w = max((len(line) for line in LOGO.splitlines()), default=0)\n if console.size.width >= logo_w + 6: # จอกว้างพอ → โลโก้ใหญ่\n head = Align.center(Text(LOGO, style=f\"bold {accent}\", no_wrap=True))\n else: # จอแคบ → ชื่อเล็กกันล้น\n head = Align.center(Text(\"✻ BOYSER AI\", style=f\"bold {accent}\"))\n details = Text()\n rows = [(\"model\", backend.name), (\"cwd\", WORKDIR)]\n if loaded:\n rows.append((\"loaded\", \", \".join(loaded)))\n for k, v in rows:\n details.append(f\"{k:<7}\", style=\"dim\")\n details.append(v + \"\\n\")\n details.append(\"พิมพ์ \", style=\"dim\")\n details.append(\"/\", style=f\"bold {accent}\")\n details.append(\" ดูคำสั่ง · \", style=\"dim\")\n details.append(\"exit\", style=f\"bold {accent}\")\n details.append(\" ออก\", style=\"dim\")\n if YOLO:\n details.append(\" · YOLO MODE\", style=\"bold red\")\n body = Group(head, Text(), details)\n # Panel (ไม่ใช่ .fit) → ยืดเต็มความกว้างจอเหมือนกล่องแชต\n console.print(Panel(body, border_style=THEME[\"border\"], padding=(1, 2)))\n\n show_banner()\n\n while True:\n try:\n user = (read_input(backend.name) if sys.stdin.isatty() else read_input()).strip()\n except (EOFError, KeyboardInterrupt):\n break\n\n if not user:\n continue\n if sys.stdin.isatty(): # กล่องถูกลบไปแล้ว → โชว์ข้อความที่ส่งเป็นบรรทัดสะอาดๆ\n console.print(Text(\"❯ \", style=f\"bold {THEME['accent']}\") + Text(user))\n if user.lower() in (\"exit\", \"quit\", \"/exit\"):\n break\n if user == \"/help\":\n print_help(backend)\n continue\n if user == \"/theme\":\n name = menu_select(\"เลือกธีมสี:\", [(n, \"ปัจจุบัน\" if n == THEME[\"name\"] else \"\") for n in THEMES])\n apply_theme(name)\n cfg[\"theme\"] = name\n save_config(cfg)\n console.clear()\n show_banner()\n console.print(f\"[{THEME['accent']}]เปลี่ยนธีมเป็น {name} แล้ว[/]\")\n continue\n if user == \"/memory\":\n action = menu_select(\"จัดการ memory:\", [\n (\"ดู\", \"\"), (\"แก้ไข (editor)\", \"\"), (\"ลบทีละรายการ\", \"\"),\n (\"ล้างทั้งหมด\", \"\"), (\"ยกเลิก\", \"\"),\n ])\n changed = False\n if action == \"ดู\":\n print_memory()\n elif action == \"แก้ไข (editor)\":\n edit_memory()\n changed = True\n elif action == \"ลบทีละรายการ\":\n lines = read_memory_lines()\n if not lines:\n console.print(\"[dim]ยังไม่มี memory[/]\")\n else:\n pick = menu_select(\"ลบรายการไหน:\", [(ln, \"\") for ln in lines] + [(\"(ยกเลิก)\", \"\")])\n if pick != \"(ยกเลิก)\" and pick in lines:\n lines.remove(pick)\n write_memory_lines(lines)\n changed = True\n console.print(\"[dim]ลบแล้ว[/]\")\n elif action == \"ล้างทั้งหมด\":\n if console.input(\" [yellow]ล้าง memory ทั้งหมด? (y/N)[/] \").strip().lower() == \"y\":\n write_memory_lines([])\n changed = True\n console.print(\"[dim]ล้าง memory แล้ว[/]\")\n if changed: # โหลด memory ใหม่เข้า context (ล้างแชตให้ system prompt ใหม่มีผล)\n SYSTEM, loaded = build_system(BASE_SYSTEM)\n messages = backend.new_messages()\n console.print(\"[dim]โหลด memory ใหม่เข้า session แล้ว (ประวัติแชตถูกล้าง)[/]\")\n continue\n if user == \"/think\":\n THINK_ON = not THINK_ON\n cfg[\"think\"] = THINK_ON\n save_config(cfg)\n if THINK_ON and getattr(backend, \"is_ollama\", False) and not backend.supports_think:\n console.print(f\"[yellow]think: เปิดแล้ว แต่ {backend.name} ไม่รองรับ thinking — ลองโมเดลอื่น เช่น gemma/glm[/]\")\n else:\n console.print(f\"[{THEME['accent'] if THINK_ON else 'dim'}]think mode: {'เปิด — โมเดลจะคิดก่อนตอบ' if THINK_ON else 'ปิด'}[/]\")\n continue\n if user == \"/status\":\n print_status(backend, cfg, loaded)\n continue\n if user == \"/statusline\":\n on = not cfg.get(\"statusline\", False)\n cfg[\"statusline\"] = on\n _status_ctx[\"on\"] = on\n save_config(cfg)\n console.print(f\"[{THEME['accent'] if on else 'dim'}]status line: {'เปิด' if on else 'ปิด'}[/]\")\n continue\n if user == \"/skills\":\n if not SKILLS:\n console.print(f\"[dim]ยังไม่มี skill — สร้างที่ {SKILLS_DIRS[1]}/<ชื่อ>/SKILL.md[/]\")\n else:\n table = Table(show_header=False, box=None, padding=(0, 2))\n for n, s in SKILLS.items():\n table.add_row(f\"[bold {THEME['accent']}]{n}[/]\", s[\"description\"])\n console.print(Panel(table, title=\"Skills\", border_style=\"dim\"))\n continue\n if user == \"/save\":\n # รวม FILE: จากทุกข้อความ assistant ในบทสนทนา (โมเดลอาจทยอยออกหลาย turn) — path ซ้ำ เอาอันล่าสุด\n seen = {}\n for m in messages:\n if m.get(\"role\") == \"assistant\":\n c = m.get(\"content\", \"\")\n t = c if isinstance(c, str) else \"\".join(\n getattr(b, \"text\", \"\") for b in c if getattr(b, \"type\", None) == \"text\"\n )\n for path, content in extract_files(t):\n seen[path] = content\n files = list(seen.items())\n if not files:\n console.print(\"[yellow]ไม่เจอไฟล์ในคำตอบล่าสุด — ให้โมเดลออกแต่ละไฟล์เป็น `FILE: path` ตามด้วย code block[/]\")\n continue\n console.print(f\"[bold]เจอ {len(files)} ไฟล์:[/]\")\n for p, c in files:\n console.print(f\" [{THEME['accent']}]{p}[/] [dim]({c.count(chr(10)) + 1} บรรทัด)[/]\")\n if console.input(\" [yellow]บันทึกทั้งหมดลง cwd? (y/N)[/] \").strip().lower() == \"y\":\n for p, c in files:\n try:\n ap = p if os.path.isabs(p) else os.path.join(WORKDIR, p)\n os.makedirs(os.path.dirname(os.path.abspath(ap)), exist_ok=True)\n with open(ap, \"w\", encoding=\"utf-8\") as f:\n f.write(c)\n console.print(f\" [green]✓[/] {p}\")\n except OSError as e:\n console.print(f\" [red]✗ {p}: {e}[/]\")\n continue\n if user == \"/tools\":\n print_tools()\n continue\n if user == \"/clear\":\n messages = backend.new_messages()\n SESSION.update(**{\"in\": 0, \"out\": 0, \"cached\": 0, \"turns\": 0, \"started\": time.monotonic(), \"rem_tokens\": None, \"reset_tokens\": None})\n console.clear() # ล้างหน้าจอเหมือนพิมพ์ clear\n show_banner()\n console.print(\"[dim]ล้างหน้าจอ ประวัติ และตัวนับ token แล้ว[/]\")\n continue\n if user == \"/yolo\":\n YOLO = not YOLO\n console.print(f\"[{'red' if YOLO else 'green'}]YOLO mode: {'ON — ไม่ถามยืนยันแล้ว' if YOLO else 'OFF'}[/]\")\n continue\n if user == \"/model\":\n cfg = setup_wizard()\n apply_theme(cfg.get(\"theme\", THEME[\"name\"]))\n backend = make_backend(cfg)\n _status_ctx[\"backend\"] = backend\n messages = backend.new_messages()\n console.print(f\"[green]เปลี่ยนเป็น {backend.label} แล้ว (ประวัติถูกล้าง)[/]\")\n continue\n if user.startswith(\"/ctx\"):\n if cfg[\"backend\"] != \"local\":\n console.print(\"[yellow]/ctx ใช้ได้เฉพาะโมเดล local (Claude ctx 1M คงที่)[/]\")\n continue\n parts = user.split()\n if len(parts) < 2 or not parts[1].replace(\",\", \"\").isdigit():\n console.print(f\"[dim]ตอนนี้ num_ctx = {cfg.get('num_ctx', 'default')} · ใช้: /ctx 32768[/]\")\n continue\n cfg[\"num_ctx\"] = int(parts[1].replace(\",\", \"\"))\n save_config(cfg)\n backend = make_backend(cfg) # ประวัติคงไว้ ไม่ต้องล้าง\n console.print(f\"[green]ตั้ง num_ctx = {cfg['num_ctx']} แล้ว (ใช้ตั้งแต่ข้อความถัดไป)[/]\")\n continue\n if user.startswith(\"/\"):\n console.print(\"[yellow]ไม่รู้จักคำสั่งนี้ — พิมพ์ /help[/]\")\n continue\n\n messages.append({\"role\": \"user\", \"content\": user})\n SESSION[\"turns\"] += 1\n try:\n with Interrupt() as intr: # กด ESC ระหว่างตอบ = หยุด\n backend.turn(messages, intr)\n except KeyboardInterrupt:\n console.print(\"\\n[yellow]ยกเลิก turn นี้[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop()\n except backend.api_error as e:\n msg = str(getattr(e, \"message\", e))\n console.print(f\"\\n[red]API error: {msg}[/]\")\n low = msg.lower()\n if \"credit balance\" in low or \"billing\" in low:\n console.print(\"[yellow]→ บัญชี Anthropic เครดิต API หมด/ไม่พอ[/] [dim]เติมที่ console.anthropic.com → Plans & Billing[/]\")\n console.print(\"[dim] ทางเลือกฟรี: /model → Ollama (local) · หรือ Cloud → OpenRouter[/]\")\n elif \"authentication\" in low or \"x-api-key\" in low or \"credential\" in low:\n console.print(\"[yellow]→ ยืนยันตัวตนไม่ผ่าน[/] [dim]เช็ค API key หรือ `! ant auth login` ใหม่[/]\")\n while messages and messages[-1].get(\"role\") != \"user\":\n messages.pop()\n if messages and messages[-1].get(\"role\") == \"user\":\n messages.pop() # เอา turn ที่พังออก ให้ลองใหม่ได้\n\n\nif __name__ == \"__main__\":\n main()\n"}}}}