[
  {
    "path": ".gitignore",
    "content": "temp/\ntmp/\n\n__pycache__/\n*.py[cod]\n*$py.class\n.venv/\nvenv/\nenv/\nbuild/\ndist/\n*.egg-info/\n\n.streamlit/\n\n.vscode/\n.idea/\n*.swp\n*.swo\n\n.DS_Store\nThumbs.db\n\n*.log\n.env\nauth.json\nmodel_responses.txt\n\n# Sensitive files (API keys, credentials)\nmykey.py\n\ntasks/\n\n*.zip\n\nmemory/*\n!memory/memory_management_sop.md\n\n# Allow tracking of specific SOPs\n!memory/web_setup_sop.md\n!memory/autonomous_operation_sop.md\n!memory/autonomous_operation_sop/\n!memory/autonomous_operation_sop/**\n!memory/scheduled_task_sop.md\n\n# L4 session archiver (only the script, not archives)\n!memory/L4_raw_sessions/\nmemory/L4_raw_sessions/*\n!memory/L4_raw_sessions/compress_session.py\n\n# ljqCtrl related tools\n!memory/ljqCtrl.py\n!memory/ljqCtrl_sop.md\n\n# procmem_scanner related tools\n!memory/procmem_scanner.py\n!memory/procmem_scanner_sop.md\n\n# TMWebDriver SOP\n!memory/tmwebdriver_sop.md\n\n# Vue3 Component SOP\n!memory/vue3_component_sop.md\n\n# Subagent SOP\n!memory/subagent_sop.md\n\n# Supervisor SOP\n!memory/supervisor_sop.md\n\n# Plan SOP\n!memory/plan_sop.md\n\n# Goal Mode SOP\n!memory/goal_mode_sop.md\n\n# Skill Search SOP\n!memory/skill_search/\n!memory/skill_search/**\n\n\n# ADB UI tool\n!memory/adb_ui.py\n\n# Keychain\n!memory/keychain.py\n\n# Vision / OCR / UI detection tools\n!memory/ocr_utils.py\n!memory/vision_sop.md\n!memory/ui_detect.py\n!memory/vision_api.template.py\n\n# Memory management\n!memory/memory_cleanup_sop.md\n\n# Visual Studio\n.vs/\nrestore_commit.txt\n\nsche_tasks/\n# CDP Bridge 密钥配置（首次运行自动生成）\nassets/tmwd_cdp_bridge/config.js\nassets/copilot_proxy.pyw\n**log.*\n\n# Reflect (ignore new files, whitelist existing)\nreflect/*\n!reflect/autonomous.py\n!reflect/scheduler.py\n!reflect/agent_team_worker.py\n!reflect/goal_mode.py\n\n# Universal: never track __pycache__ anywhere\n**/__pycache__/\n\n.claude/\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to GenericAgent\n\n## Why This File Is Short\n\nGenericAgent's core is ~3K lines. Every file in this repo will be read by AI agents — potentially thousands of times. Extra words cost real tokens and push useful context out of the window, increasing hallucinations. This document practices what it preaches: **say only what matters.**\n\n## Before You Contribute\n\n1. **Read the codebase first.** It's small enough to read in one sitting. Understand the philosophy before proposing changes.\n2. **Open an Issue first** for anything non-trivial. Discuss before coding.\n\n## Code Standards\n\nAll PRs go through a strict automated code review skill. Key expectations:\n\n- **Self-documenting code, minimal comments.** If code needs a paragraph to explain, rewrite it.\n- **Compact and visually uniform.** Fewer lines, consistent line lengths, no fluff.\n- **Small change radius.** Changing A shouldn't ripple through B, C, D.\n- **More features → less code.** Good abstractions make the codebase shrink, not grow.\n- **Let it crash by failure radius.** Critical errors fail loud; trivial ones pass silently. No blanket try-catch.\n\n> ⚠️ This review is deliberately strict — most AI-generated code (e.g. Claude Code output) will not pass as-is. Read the full principles before submitting.\n\n## Skill Contributions\n\nGenericAgent evolves through skills. Not all skills belong in the core repo:\n\n| Type | Where it goes | Example |\n|---|---|---|\n| **Fundamental / universal** | Core repo (`memory/`) | File search, clipboard, basic web ops |\n| **Domain-specific / niche** | Skill Marketplace *(coming soon)* | Stock screening, food delivery, specific API integrations |\n\nIf your skill only makes sense for a specific workflow, it's a marketplace candidate, not a core PR.\n\n## PR Checklist\n\n- [ ] Issue linked or context explained in ≤3 sentences\n- [ ] Code passes the [review principles] self-check:\n  1. Can I safely modify this locally without reading the whole codebase?\n  2. Is there a clear core abstraction — new features add implementations, not modify old logic?\n  3. Are change points converging at boundaries, not scattered everywhere?\n  4. On failure, can I quickly locate the responsible module?\n- [ ] Net line count: ideally negative or zero for refactors\n- [ ] No unnecessary dependencies added\n"
  },
  {
    "path": "GETTING_STARTED.md",
    "content": "# 🚀 新手上手指南\n\n> 完全没接触过编程也没关系，跟着做就行。Mac / Windows 都适用。\n>\n> 如果你已经有 Python 环境，直接跳到[第 2 步](#2-配置-api-key)。\n\n---\n\n## 1. 安装 Python\n\n### Mac\n\n打开「终端」（启动台搜索 \"终端\" 或 \"Terminal\"），粘贴这行命令然后回车：\n\n```bash\nbrew install python\n```\n\n如果提示 `brew: command not found`，说明还没装 Homebrew，先粘贴这行：\n\n```bash\n/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n```\n\n装完后再执行 `brew install python`。\n\n### Windows\n\n1. 打开 [python.org/downloads](https://www.python.org/downloads/)，点黄色大按钮下载\n2. 运行安装包，**底部的 \"Add Python to PATH\" 一定要勾上**\n3. 点 \"Install Now\"\n\n### 验证\n\n终端 / 命令提示符里输入：\n\n```bash\npython3 --version\n```\n\n看到 `Python 3.x.x` 就 OK。Windows 上也可以试 `python --version`。\n\n> ⚠️ **版本提示**：推荐 **Python 3.11 或 3.12**。不要使用 3.14（与 pywebview 等依赖不兼容）。\n\n---\n\n## 2. 配置 API Key\n\n### 下载项目\n\n1. 打开 [GitHub 仓库页面](https://github.com/lsdefine/GenericAgent)\n2. 点绿色 **Code** 按钮 → **Download ZIP**\n3. 解压到你喜欢的位置\n\n### 创建配置文件\n\n进入项目文件夹，把 `mykey_template.py` 复制一份，重命名为 `mykey.py`。\n\n用任意文本编辑器打开 `mykey.py`，填入你的 API 信息。**选一种填就行**，不用的配置删掉或留着不管都行。\n\n> 💡 也可以运行交互式向导 `python assets/configure_mykey.py`，按提示选择厂商、填入 Key 即可自动生成 `mykey.py`。\n\n### 配置示例\n\n**最常见的用法：**\n\n```python\n# 变量名含 'oai' → 走 OpenAI 兼容格式 (/chat/completions)\noai_config = {\n    'apikey': 'sk-你的密钥',\n    'apibase': 'http://你的API地址:端口',\n    'model': '模型名称',\n}\n```\n\n```python\n# 变量名含 'claude'（不含 'native'）→ 走 Claude 兼容格式 (/messages)\nclaude_config = {\n    'apikey': 'sk-你的密钥',\n    'apibase': 'http://你的API地址:端口',\n    'model': 'claude-sonnet-4-20250514',\n}\n```\n\n```python\n# MiniMax 使用 OpenAI 兼容格式，变量名含 'oai' 即可\n# 温度自动修正为 (0, 1]，支持 M2.7 / M2.5 全系列，204K 上下文\noai_minimax_config = {\n    'apikey': 'eyJh...',\n    'apibase': 'https://api.minimax.io/v1',\n    'model': 'MiniMax-M2.7',\n}\n```\n\n**使用标准工具调用格式（适合较弱模型）：**\n\n```python\n# 变量名同时含 'native' 和 'claude' → Claude 标准工具调用格式\nnative_claude_config = {\n    'apikey': 'sk-ant-你的密钥',\n    'apibase': 'https://api.anthropic.com',\n    'model': 'claude-sonnet-4-20250514',\n}\n```\n\n> 💡 还支持 `native_oai_config`（OpenAI 标准工具调用）、`sider_cookie`（Sider）等，详见 `mykey_template.py` 中的注释。\n\n### 关键规则\n\n**变量命名决定接口格式**（不是模型名决定的）：\n\n| 变量名包含 | 触发的 Session | 适用场景 |\n|-----------|---------------|---------|\n| `oai` | OpenAI 兼容 | 大多数 API 服务、OpenAI 官方 |\n| `claude`（不含 `native`） | Claude 兼容 | Claude API 服务 |\n| `native` + `claude` | Claude 标准工具调用 | 较弱模型推荐，工具调用更规范 |\n| `native` + `oai` | OpenAI 标准工具调用 | 较弱模型推荐，工具调用更规范 |\n\n> 例：用 Claude 模型，但 API 服务提供的是 OpenAI 兼容接口 → 变量名用 `oai_xxx`。\n> 例：用 MiniMax 模型 → 变量名用 `oai_minimax_config`，MiniMax 走 OpenAI 兼容接口。\n\n**`apibase` 填写规则**（会自动拼接端点路径）：\n\n| 你填的内容 | 系统行为 |\n|-----------|---------|\n| `http://host:2001` | 自动补 `/v1/chat/completions` |\n| `http://host:2001/v1` | 自动补 `/chat/completions` |\n| `http://host:2001/v1/chat/completions` | 直接使用，不拼接 |\n\n---\n\n## 3. 初次启动\n\n终端里进入项目文件夹，运行：\n\n```bash\ncd 你的解压路径\npython3 agentmain.py\n```\n\n这就是**命令行模式**，已经可以用了。你会看到一个输入提示符，直接打字发送任务即可。\n\n试试你的第一个任务：\n\n```\n帮我在桌面创建一个 hello.txt，内容是 Hello World\n```\n\n> 💡 Windows 上如果 `python3` 不识别，换成 `python agentmain.py`。\n\n---\n\n## 4. 让 Agent 自己装依赖\n\nAgent 启动后，只需要一句话，它就会自己搞定所有依赖：\n\n```\n请查看你的代码，安装所有用得上的 python 依赖\n```\n\nAgent 会自己读代码、找出需要的包、全部装好。\n\n> ⚠️ 如果遇到网络问题导致 Agent 无法调用 API，可能需要先手动装一个包：\n> ```bash\n> pip install requests\n> ```\n\n### 升级到图形界面\n\n依赖装完后，就可以用 GUI 模式了：\n\n```bash\npython3 launch.pyw\n```\n\n启动后会出现一个桌面悬浮窗，直接在里面输入任务指令。\n\n### 可选：让 Agent 帮你做的事\n\n```\n请帮我建立 git 连接，方便以后更新代码\n```\n\nAgent 会自动配好。如果你电脑上没有 Git，它也会帮你下载 portable 版。\n\n```\n请帮我在桌面创建一个 launch.pyw 的快捷方式\n```\n\n这样以后双击桌面图标就能启动，不用再开终端了。\n\n---\n\n## 5. 能力解锁\n\n环境跑起来之后，你可以逐步解锁更多能力。每一项都只需要**对 Agent 说一句话**：\n\n### 基础能力\n\n| 能力 | 对 Agent 说 | 说明 |\n|------|-----------|------|\n| **PowerShell 脚本执行** | `帮我解锁当前用户的 PowerShell ps1 执行权限` | Windows 默认禁止运行 .ps1 脚本 |\n| **全局文件搜索** | `安装并配置 Everything 命令行工具进 PATH` | 毫秒级全盘文件搜索 |\n\n### 浏览器自动化\n\n| 能力 | 对 Agent 说 | 说明 |\n|------|-----------|------|\n| **Web 工具解锁** | `执行 web setup sop，解锁 web 工具` | 注入浏览器插件，使 Agent 能直接操控网页 |\n\n解锁后，Agent 可以在**保留你登录态**的真实浏览器中操作：\n\n```\n打开淘宝，搜索 iPhone 16，按价格排序\n去 B 站，查看我最近看过的历史视频\n```\n\n### 进阶能力\n\n| 能力 | 对 Agent 说 | 说明 |\n|------|-----------|------|\n| **OCR** | `用rapidocr配置你的ocr能力并存入记忆` | 让 Agent 能\"看到\"屏幕文字 |\n| **屏幕视觉** | `仿造你的llmcore，写个调用vision的能力并存入记忆` | 让 Agent 能\"看到\"屏幕内容 |\n| **移动端控制** | `配置 ADB 环境，准备连接安卓设备` | 通过 USB/WiFi 控制 Android 手机 |\n\n### 聊天平台接入（可选）\n\n接入后可以随时随地通过手机给电脑上的 Agent 发指令。\n\n对 Agent 说：`看你的代码，帮我配置 XX 平台的机器人接入`\n\n支持的平台：**微信个人Bot** / QQ / 飞书 / 企业微信 / 钉钉 / Telegram\n\n> Agent 会自动读取代码、引导你完成配置。\n\n### 高级模式\n\n以下模式全部**自文档化**——不用查手册，直接问 Agent 即可：\n\n| 模式 | 对 Agent 说 |\n|------|------------|\n| **Reflect（反射）** | `查看你的代码，告诉我你的 reflect 模式怎么启用` |\n| **计划任务** | `查看你的代码，告诉我你的计划任务模式怎么启用` |\n| **Plan（规划）** | `查看你的代码，告诉我你的 plan 模式怎么启用` |\n| **SubAgent（子代理）** | `查看你的代码，告诉我你的 subagent 模式怎么启用` |\n| **自主探索** | `查看你的代码，告诉我你的自主探索模式怎么启用` |\n\n> 💡 这就是 GenericAgent 的核心设计理念：**代码即文档**。Agent 能读懂自己的源码，所以任何功能你都可以直接问它。\n\n---\n\n## 💡 使用越久越强\n\nGenericAgent 不预设技能，而是**靠使用进化**。每完成一个新任务，它会自动将执行路径固化为 Skill，下次遇到类似任务直接调用。\n\n你不需要管理这些 Skill，Agent 会自动处理。使用时间越长，积累的技能越多，最终形成一棵完全属于你的专属技能树。\n\n> 💡 如果你觉得某些重要信息 Agent 没有记住，可以直接告诉它：`把这个记到你的记忆里`，它会主动记忆。\n\n**其他 Claw 的 Skill 也可以直接复用：**\n\n- 让 Agent 搜索：`帮我找个做 XXX 的 skill` → 完成后 → `加入你的记忆中`\n- 直接指定来源：`访问 XXX 文件夹/URL，按照这个 skill 做 XXX`\n\n**保持更新：**\n\n对 Agent 说：`git 更新你的代码，然后看看 commit 有什么新功能`\n\n> Agent 会自动 pull 最新代码并解读 commit log，告诉你新增了什么能力。\n\n> 更多细节请参阅 [README.md](README.md) 或 [详细版图文教程](https://my.feishu.cn/wiki/CGrDw0T76iNFuskmwxdcWrpinPb)。"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 lsdefine\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n<img src=\"assets/images/bar.jpg\" width=\"880\"/>\n\n<a href=\"https://trendshift.io/repositories/25944\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/25944\" alt=\"lsdefine%2FGenericAgent | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n</div>\n\n<p align=\"center\">\n  <a href=\"#english\">English</a> | <a href=\"#chinese\">中文</a> | 📄 Technical Report:&nbsp;<a href=\"https://arxiv.org/abs/2604.17091\"><img src=\"https://img.shields.io/badge/arXiv-2604.17091-b31b1b?logo=arxiv&logoColor=white\" alt=\"arXiv\" height=\"18\"/></a>&nbsp;<a href=\"assets/GenericAgent_Technical_Report.pdf\"><img src=\"https://img.shields.io/badge/-PDF-EA4335?logo=adobeacrobatreader&logoColor=white\" alt=\"Technical Report PDF\" height=\"18\"/></a>&nbsp;<a href=\"https://github.com/JinyiHan99/GA-Technical-Report\"><img src=\"https://img.shields.io/badge/-Code%20%26%20Data-181717?logo=github&logoColor=white\" alt=\"Experiments & Reproduction Repo\" height=\"18\"/></a> | 📘 <a href=\"https://datawhalechina.github.io/hello-generic-agent/\">教程</a> | <a href=\"https://fudankw.cn/sophub\">Sophub</a>\n</p>\n\n> 📌 **Official channel**: This GitHub repository is the sole official source for GenericAgent. We have no affiliation with any third-party website using the GenericAgent name.\n\n---\n<a name=\"english\"></a>\n## 🌟 Overview\n\n**GenericAgent** is a minimal, self-evolving autonomous agent framework. Its core is just **~3K lines of code**. Through **9 atomic tools + a ~100-line Agent Loop**, it grants any LLM system-level control over a local computer — covering browser, terminal, filesystem, keyboard/mouse input, screen vision, and mobile devices (ADB).\n\nIts design philosophy: **don't preload skills — evolve them.**\n\nEvery time GenericAgent solves a new task, it automatically crystallizes the execution path into an skill for direct reuse later. The longer you use it, the more skills accumulate — forming a skill tree that belongs entirely to you, grown from 3K lines of seed code.\n\n> **🤖 Self-Bootstrap Proof** — Everything in this repository, from installing Git and running `git init` to every commit message, was completed autonomously by GenericAgent. The author never opened a terminal once.\n\n## 📋 Core Features\n- **Self-Evolving**: Automatically crystallizes each task into an skill. Capabilities grow with every use, forming your personal skill tree.\n- **Minimal Architecture**: ~3K lines of core code. Agent Loop is ~100 lines. No complex dependencies, zero deployment overhead.\n- **Strong Execution**: Injects into a real browser (preserving login sessions). 9 atomic tools take direct control of the system.\n- **High Compatibility**: Supports Claude / Gemini / Kimi / MiniMax and other major models. Cross-platform.\n- **Token Efficient**: <30K context window — a fraction of the 200K–1M other agents consume. Layered memory ensures the right knowledge is always in scope. Less noise, fewer hallucinations, higher success rate — at a fraction of the cost.\n\n\n## 🧬 Self-Evolution Mechanism\n\nThis is what fundamentally distinguishes GenericAgent from every other agent framework.\n\n```\n[New Task] --> [Autonomous Exploration] (install deps, write scripts, debug & verify) -->\n[Crystallize Execution Path into skill] --> [Write to Memory Layer] --> [Direct Recall on Next Similar Task]\n```\n\n| What you say | What the agent does the first time | Every time after |\n|---|---|---|\n| *\"Read my WeChat messages\"* | Install deps → reverse DB → write read script → save skill | **one-line invoke** |\n| *\"Monitor stocks and alert me\"* | Install mootdx → build selection flow → configure cron → save skill | **one-line start** |\n| *\"Send this file via Gmail\"* | Configure OAuth → write send script → save skill | **ready to use** |\n\nAfter a few weeks, your agent instance will have a skill tree no one else in the world has — all grown from 3K lines of seed code.\n\n\n##### 🎯 Demo Showcase\n\n| 🧋 Food Delivery Order | 📈 Quantitative Stock Screening |\n|:---:|:---:|\n| <img src=\"assets/demo/order_tea.gif\" width=\"100%\" alt=\"Order Tea\"> | <img src=\"assets/demo/selectstock.gif\" width=\"100%\" alt=\"Stock Selection\"> |\n| *\"Order me a milk tea\"* — Navigates the delivery app, selects items, and completes checkout automatically. | *\"Find GEM stocks with EXPMA golden cross, turnover > 5%\"* — Screens stocks with quantitative conditions. |\n| 🌐 Autonomous Web Exploration | 💰 Expense Tracking | 💬 Batch Messaging |\n| <img src=\"assets/demo/autonomous_explore.png\" width=\"100%\" alt=\"Web Exploration\"> | <img src=\"assets/demo/alipay_expense.png\" width=\"100%\" alt=\"Alipay Expense\"> | <img src=\"assets/demo/wechat_batch.png\" width=\"100%\" alt=\"WeChat Batch\"> |\n| Autonomously browses and periodically summarizes web content. | *\"Find expenses over ¥2K in the last 3 months\"* — Drives Alipay via ADB. | Sends bulk WeChat messages, fully driving the WeChat client. |\n\n## 📅 Latest News\n\n- **2026-04-21:** 📄 [Technical Report released on arXiv](https://arxiv.org/abs/2604.17091) — *GenericAgent: A Token-Efficient Self-Evolving LLM Agent via Contextual Information Density Maximization*\n- **2026-04-11:** Introduced **L4 session archive memory** and scheduler cron integration\n- **2026-03-23:** Support personal WeChat as a bot frontend\n- **2026-03-10:** [Released million-scale Skill Library](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7)\n- **2026-03-08:** [Released \"Dintal Claw\" — a GenericAgent-powered government affairs bot](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg)\n- **2026-03-01:** [GenericAgent featured by Jiqizhixin (机器之心)](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg)\n- **2026-01-16:** GenericAgent V1.0 public release\n\n---\n\n## 🚀 Quick Start\n\n#### Method 1: Standard Installation\n\n```bash\n# 1. Clone the repo\ngit clone https://github.com/lsdefine/GenericAgent.git\ncd GenericAgent\n\n# 2. Install dependencies\npip install requests streamlit pywebview   # Desktop GUI (launch.pyw)\npip install requests textual               # Terminal UI (tuiapp.py)\n\n# 3. Configure API Key\ncp mykey_template.py mykey.py\n# Edit mykey.py and fill in your LLM API Key\n\n# 4. Launch\npython launch.pyw\n```\n\n#### Method 2: uv (for experienced Python users)\n\nIf you prefer a modern Python workflow, GenericAgent also provides a minimal `pyproject.toml`:\n\n```bash\ngit clone https://github.com/lsdefine/GenericAgent.git\ncd GenericAgent\nuv venv\nuv pip install -e \".[ui]\"        # Core + GUI dependencies\ncp mykey_template.py mykey.py\npython launch.pyw\n```\n\n> GenericAgent is meant to grow its environment through the Agent itself, not by pre-installing every possible package.\n\nFull guide: [GETTING_STARTED.md](GETTING_STARTED.md)\n\n---\n\n## 🖥️ Desktop Frontends\n\n### Terminal UI\n\nA lightweight, keyboard-driven interface built on [Textual](https://github.com/Textualize/textual). Supports multiple concurrent sessions, real-time streaming, and runs anywhere a terminal does — no browser needed.\n\n```bash\npython frontends/tuiapp.py\n```\n\n### Other Desktop Frontends\n\n```bash\npython frontends/qtapp.py                # Qt-based desktop app\nstreamlit run frontends/stapp2.py        # Alternative Streamlit UI\n```\n\n### Codeg\n\n<table><tr>\n<td width=\"70%\">\n\n[Codeg](https://github.com/yiqi-017/codeg) (`feat/genericagent-integration` branch) is a desktop/web UI that connects GenericAgent alongside other agents (Claude Code, Gemini, Codex, etc.) in a unified interface with a polished, modern UI.\n\n> This integration is usable now. Some features are still being refined — feedback welcome.\n\nPlace your GenericAgent directory alongside the codeg project. Codeg will auto-detect `frontends/genericagent_acp_bridge.py` and launch GenericAgent as a local ACP agent.\n\n</td>\n<td width=\"30%\">\n<img src=\"assets/demo/codeg-demo.gif\" width=\"90%\" alt=\"Codeg Demo\">\n</td>\n</tr></table>\n\n---\n\n## 💬 Bot Interface (IM)\n\n### Telegram Bot\n\n```python\n# mykey.py\ntg_bot_token = 'YOUR_BOT_TOKEN'\ntg_allowed_users = [YOUR_USER_ID]\n```\n\n```bash\npython frontends/tgapp.py\n```\n\n### Common Chat Commands\n\nThe default Streamlit desktop UI started by `python launch.pyw`, plus the QQ / Telegram / Feishu / WeCom / DingTalk frontends, support these chat commands:\n\n- `/new` - start a fresh conversation and clear the current context\n- `/continue` - list recoverable conversation snapshots\n- `/continue N` - restore the `N`th recoverable conversation\n\n\n## 📊 Comparison with Similar Tools\n\n| Feature | GenericAgent | OpenClaw | Claude Code |\n|------|:---:|:---:|:---:|\n| **Codebase** | ~3K lines | ~530,000 lines | Open-sourced (large) |\n| **Deployment** | `pip install` + API Key | Multi-service orchestration | CLI + subscription |\n| **Browser Control** | Real browser (session preserved) | Sandbox / headless browser | Via MCP plugin |\n| **OS Control** | Mouse/kbd, vision, ADB | Multi-agent delegation | File + terminal |\n| **Self-Evolution** | Autonomous skill growth | Plugin ecosystem | Stateless between sessions |\n| **Out of the Box** | A few core files + starter skills | Hundreds of modules | Rich CLI toolset |\n\n\n## 🧠 How It Works\n\nGenericAgent accomplishes complex tasks through **Layered Memory × Minimal Toolset × Autonomous Execution Loop**, continuously accumulating experience during execution.\n\n1️⃣ **Layered Memory System**\n> _Memory crystallizes throughout task execution, letting the agent build stable, efficient working patterns over time._\n\n- **L0 — Meta Rules**: Core behavioral rules and system constraints of the agent\n- **L1 — Insight Index**: Minimal memory index for fast routing and recall\n- **L2 — Global Facts**: Stable knowledge accumulated over long-term operation\n- **L3 — Task Skills / SOPs**: Reusable workflows for completing specific task types\n- **L4 — Session Archive**: Archived task records distilled from finished sessions for long-horizon recall\n\n2️⃣ **Autonomous Execution Loop**\n\n> _Perceive environment state → Task reasoning → Execute tools → Write experience to memory → Loop_\n\nThe entire core loop is just **~100 lines of code** (`agent_loop.py`).\n\n3️⃣ **Minimal Toolset**\n> _GenericAgent provides only **9 atomic tools**, forming the foundational capabilities for interacting with the outside world._\n\n| Tool | Function |\n|------|------|\n| `code_run` | Execute arbitrary code |\n| `file_read` | Read files |\n| `file_write` | Write files |\n| `file_patch` | Patch / modify files |\n| `web_scan` | Perceive web content |\n| `web_execute_js` | Control browser behavior |\n| `ask_user` | Human-in-the-loop confirmation |\n\n> Additionally, 2 **memory management tools** (`update_working_checkpoint`, `start_long_term_update`) allow the agent to persist context and accumulate experience across sessions.\n\n4️⃣ **Capability Extension Mechanism**\n> _Capable of dynamically creating new tools._\n\nVia `code_run`, GenericAgent can dynamically install Python packages, write new scripts, call external APIs, or control hardware at runtime — crystallizing temporary abilities into permanent tools.\n\n<div align=\"center\">\n  <img src=\"assets/images/workflow.jpg\" alt=\"GenericAgent Workflow\" width=\"400\"/>\n  <br><em>GenericAgent Workflow Diagram</em>\n</div>\n\n\n## ⭐ Support\n\nIf this project helped you, please consider leaving a **Star!** 🙏\n\nYou're also welcome to join our **GenericAgent Community Group** for discussion, feedback, and co-building 👏\n\n<div align=\"center\">\n  <table>\n    <tr>\n      <td align=\"center\"><strong>WeChat Group 15</strong><br><img src=\"assets/images/wechat_group15.jpg\" alt=\"WeChat Group 15 QR Code\" width=\"250\"/></td>\n    </tr>\n  </table>\n</div>\n\n## 🚩 Friendly Links\n\nThanks for the support from the LinuxDo community!\n\n[![LinuxDo](https://img.shields.io/badge/社区-LinuxDo-blue?style=for-the-badge)](https://linux.do/)\n\n## 📄 License\n\nMIT License — see [LICENSE](LICENSE)\n\n*Disclaimer: This project does not build or operate any commercial website. Apart from DintalClaw, no institution, organization, or individual is currently officially authorized to conduct commercial activities under the GenericAgent name.*\n\n\n---\n<a name=\"chinese\"></a>\n## 🌟 项目简介\n\n**GenericAgent** 是一个极简、可自我进化的自主 Agent 框架。核心仅 **~3K 行代码**，通过 **9 个原子工具 + ~100 行 Agent Loop**，赋予任意 LLM 对本地计算机的系统级控制能力，覆盖浏览器、终端、文件系统、键鼠输入、屏幕视觉及移动设备。\n\n它的设计哲学是：**不预设技能，靠进化获得能力。**\n\n每解决一个新任务，GenericAgent 就将执行路径自动固化为 Skill，供后续直接调用。使用时间越长，沉淀的技能越多，形成一棵完全属于你、从 3K 行种子代码生长出来的专属技能树。\n\n> **🤖 自举实证** — 本仓库的一切，从安装 Git、`git init` 到每一条 commit message，均由 GenericAgent 自主完成。作者全程未打开过一次终端。\n\n## 📋 核心特性\n- **自我进化**: 每次任务自动沉淀 Skill，能力随使用持续增长，形成专属技能树\n- **极简架构**: ~3K 行核心代码，Agent Loop 约百行，无复杂依赖，部署零负担\n- **强执行力**: 注入真实浏览器（保留登录态），9 个原子工具直接接管系统\n- **高兼容性**: 支持 Claude / Gemini / Kimi / MiniMax 等主流模型，跨平台运行\n- **极致省 Token**: 上下文窗口不到 30K，是其他 Agent（200K–1M）的零头。分层记忆让关键信息始终在场——噪声更少，幻觉更低，成功率反而更高，而成本低一个数量级。\n\n## 🧬 自我进化机制\n\n这是 GenericAgent 区别于其他 Agent 框架的根本所在。\n\n```\n[遇到新任务]-->[自主摸索](安装依赖、编写脚本、调试验证)-->\n[将执行路径固化为 Skill]-->[写入记忆层]-->[下次同类任务直接调用]\n```\n\n| 你说的一句话 | Agent 第一次做了什么 | 之后每次 |\n|---|---|---|\n| *\"监控股票并提醒我\"* | 安装 mootdx → 构建选股流程 → 配置定时任务 → 保存 Skill | **一句话启动** |\n| *\"用 Gmail 发这个文件\"* | 配置 OAuth → 编写发送脚本 → 保存 Skill | **直接可用** |\n\n用几周后，你的 Agent 实例将拥有一套任何人都没有的专属技能树，全部从 3K 行种子代码中生长而来。\n\n<!-- | *\"帮我读取微信消息\"* | 安装依赖 → 逆向数据库 → 写读取脚本 → 保存 Skill | **一句话调用** | -->\n\n#### 🎯 实例展示\n\n| 🧋 外卖下单 | 📈 量化选股 |\n|:---:|:---:|\n| <img src=\"assets/demo/order_tea.gif\" width=\"100%\" alt=\"Order Tea\"> | <img src=\"assets/demo/selectstock.gif\" width=\"100%\" alt=\"Stock Selection\"> |\n| *\"Order me a milk tea\"* — 自动导航外卖 App，选品并完成结账 | *\"Find GEM stocks with EXPMA golden cross, turnover > 5%\"* — 量化条件筛股 |\n| 🌐 自主网页探索 | 💰 支出追踪 | 💬 批量消息 |\n| <img src=\"assets/demo/autonomous_explore.png\" width=\"100%\" alt=\"Web Exploration\"> | <img src=\"assets/demo/alipay_expense.png\" width=\"100%\" alt=\"Alipay Expense\"> | <img src=\"assets/demo/wechat_batch.png\" width=\"100%\" alt=\"WeChat Batch\"> |\n| 自主浏览并定时汇总网页信息 | *\"查找近 3 个月超 ¥2K 的支出\"* — 通过 ADB 驱动支付宝 | 批量发送微信消息，完整驱动微信客户端 |\n\n\n\n## 📅 最新动态\n\n- **2026-04-21:** 📄 [技术报告已发布至 arXiv](https://arxiv.org/abs/2604.17091) — *GenericAgent: A Token-Efficient Self-Evolving LLM Agent via Contextual Information Density Maximization*\n- **2026-04-11:** 引入 **L4 会话归档记忆**，并接入 scheduler cron 调度\n- **2026-03-23:** 支持个人微信接入作为 Bot 前端\n- **2026-03-10:** [发布百万级 Skill 库](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7)\n- **2026-03-08:** [发布以 GenericAgent 为核心的\"政务龙虾\" Dintal Claw](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg)\n- **2026-03-01:** [GenericAgent 被机器之心报道](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg)\n- **2026-01-16:** GenericAgent V1.0 公开版本发布\n\n---\n\n## 🚀 快速开始\n\n#### 方法一：标准安装\n\n```bash\n# 1. 克隆仓库\ngit clone https://github.com/lsdefine/GenericAgent.git\ncd GenericAgent\n\n# 2. 安装依赖\npip install requests streamlit pywebview   # 桌面 GUI (launch.pyw)\npip install requests textual               # 终端 UI (tuiapp.py)\n\n# 3. 配置 API Key\ncp mykey_template.py mykey.py\n# 编辑 mykey.py，填入你的 LLM API Key\n# 或使用交互式向导：python assets/configure_mykey.py\n\n# 4. 启动\npython launch.pyw\n```\n\n#### 方法二：uv 快速安装（熟悉 Python 的用户）\n\n如果你习惯现代 Python 工作流，GenericAgent 也提供了一个最小化的 `pyproject.toml`：\n\n```bash\ngit clone https://github.com/lsdefine/GenericAgent.git\ncd GenericAgent\nuv pip install -e \".[ui]\"        # 核心 + GUI 依赖\ncp mykey_template.py mykey.py\npython launch.pyw\n```\n\n> GenericAgent 更推荐由 Agent 在使用中自举环境，而不是预先手动装完整依赖。\n\n完整引导流程见 [GETTING_STARTED.md](GETTING_STARTED.md)。\n\n📖 新手使用指南（图文版）：[飞书文档](https://my.feishu.cn/wiki/CGrDw0T76iNFuskmwxdcWrpinPb)\n\n📘 完整入门教程（Datawhale 出品）：[Hello GenericAgent](https://datawhalechina.github.io/hello-generic-agent/) · [GitHub](https://github.com/datawhalechina/hello-generic-agent)\n\n---\n\n## 🖥️ 桌面前端\n\n### 终端 UI\n\n基于 [Textual](https://github.com/Textualize/textual) 的轻量键盘驱动界面。支持多会话并发、实时流式输出，有终端就能跑，无需浏览器。\n\n```bash\npython frontends/tuiapp.py\n```\n\n### 其他桌面前端\n\n```bash\npython frontends/qtapp.py                # 基于 Qt 的桌面应用\nstreamlit run frontends/stapp2.py        # 另一种 Streamlit 风格 UI\n```\n\n### Codeg前端\n\n<table><tr>\n<td width=\"70%\">\n\n[Codeg](https://github.com/yiqi-017/codeg)（`feat/genericagent-integration` 分支）是一个桌面/Web UI，可以将 GenericAgent 与其他代理（Claude Code、Gemini、Codex 等）在统一界面中并行使用，UI 更加精美。\n\n> 此集成已可使用，部分功能仍在完善中，欢迎体验反馈。\n\n将 GenericAgent 目录放在 codeg 项目同级目录下，Codeg 会自动检测 `frontends/genericagent_acp_bridge.py` 并将 GenericAgent 作为本地 ACP 代理启动。\n\n</td>\n<td width=\"30%\">\n<img src=\"assets/demo/codeg-demo.gif\" width=\"90%\" alt=\"Codeg Demo\">\n</td>\n</tr></table>\n\n---\n\n## 💬 Bot 接口（IM）\n\n### 微信 Bot（个人微信）\n\n无需额外配置，扫码登录即可：\n\n```bash\npip install pycryptodome qrcode requests\npython frontends/wechatapp.py\n```\n\n> 首次启动会弹出二维码，用微信扫码完成绑定。之后通过微信消息与 Agent 交互。\n\n### QQ Bot\n\n使用 `qq-botpy` WebSocket 长连接，**无需公网 webhook**：\n\n```bash\npip install qq-botpy\n```\n\n在 `mykey.py` 中补充：\n\n```python\nqq_app_id = \"YOUR_APP_ID\"\nqq_app_secret = \"YOUR_APP_SECRET\"\nqq_allowed_users = [\"YOUR_USER_OPENID\"]  # 或 ['*'] 公开访问\n```\n\n```bash\npython frontends/qqapp.py\n```\n\n> 在 [QQ 开放平台](https://q.qq.com) 创建机器人获取 AppID / AppSecret。首次消息后，用户 openid 记录于 `temp/qqapp.log`。\n\n### 飞书（Lark）\n\n```bash\npip install lark-oapi\npython frontends/fsapp.py\n```\n\n```python\nfs_app_id = \"cli_xxx\"\nfs_app_secret = \"xxx\"\nfs_allowed_users = [\"ou_xxx\"]  # 或 ['*']\n```\n\n**入站支持**：文本、富文本 post、图片、文件、音频、media、交互卡片 / 分享卡片  \n**出站支持**：流式进度卡片、图片回传、文件 / media 回传  \n**视觉模型**：图片首轮以真正的多模态输入发送给兼容 OpenAI Vision 的后端\n\n详细配置见 [assets/SETUP_FEISHU.md](assets/SETUP_FEISHU.md)\n\n\n### 企业微信（WeCom）\n\n```bash\npip install wecom_aibot_sdk\npython frontends/wecomapp.py\n```\n\n```python\nwecom_bot_id = \"your_bot_id\"\nwecom_secret = \"your_bot_secret\"\nwecom_allowed_users = [\"your_user_id\"]\nwecom_welcome_message = \"你好，我在线上。\"\n```\n\n### 钉钉（DingTalk）\n\n```bash\npip install dingtalk-stream\npython frontends/dingtalkapp.py\n```\n\n```python\ndingtalk_client_id = \"your_app_key\"\ndingtalk_client_secret = \"your_app_secret\"\ndingtalk_allowed_users = [\"your_staff_id\"]  # 或 ['*']\n```\n\n### 通用聊天命令\n\n默认通过 `python launch.pyw` 启动的 Streamlit 桌面 UI，以及 QQ / Telegram / 飞书 / 企业微信 / 钉钉前端，都支持以下命令：\n\n- `/new` - 开启新对话并清空当前上下文\n- `/continue` - 列出可恢复会话快照\n- `/continue N` - 恢复第 `N` 个可恢复会话\n\n\n## 📊 与同类产品对比\n\n| 特性 | GenericAgent | OpenClaw | Claude Code |\n|------|:---:|:---:|:---:|\n| **代码量** | ~3K 行 | ~530,000 行 | 已开源（体量大） |\n| **部署方式** | `pip install` + API Key | 多服务编排 | CLI + 订阅 |\n| **浏览器控制** | 注入真实浏览器（保留登录态） | 沙箱 / 无头浏览器 | 通过 MCP 插件 |\n| **OS 控制** | 键鼠、视觉、ADB | 多 Agent 委派 | 文件 + 终端 |\n| **自我进化** | 自主生长 Skill 和工具 | 插件生态 | 会话间无状态 |\n| **出厂配置** | 几个核心文件 + 少量初始 Skills | 数百模块 | 丰富 CLI 工具集 |\n\n\n## 🧠 工作机制\n\nGenericAgent 通过**分层记忆 × 最小工具集 × 自主执行循环**完成复杂任务，并在执行过程中持续积累经验。\n\n1️⃣ **分层记忆系统**\n> 记忆在任务执行过程中持续沉淀，使 Agent 逐步形成稳定且高效的工作方式\n\n\n- **L0 — 元规则（Meta Rules）**：Agent 的基础行为规则和系统约束\n- **L1 — 记忆索引（Insight Index）**：极简索引层，用于快速路由与召回\n- **L2 — 全局事实（Global Facts）**：在长期运行过程中积累的稳定知识\n- **L3 — 任务 Skills / SOPs**：完成特定任务类型的可复用流程\n- **L4 — 会话归档（Session Archive）**：从已完成任务中提炼出的归档记录，用于长程召回\n\n2️⃣ **自主执行循环**\n\n> 感知环境状态  →  任务推理  →  调用工具执行  →  经验写入记忆  →  循环\n\n整个核心循环仅 **约百行代码**（`agent_loop.py`）。\n\n3️⃣ **最小工具集**\n>GenericAgent 仅提供 **9 个原子工具**，构成与外部世界交互的基础能力\n\n| 工具 | 功能 |\n|------|------|\n| `code_run` | 执行任意代码 |\n| `file_read` | 读取文件 |\n| `file_write` | 写入文件 |\n| `file_patch` | 修改文件 |\n| `web_scan` | 感知网页内容 |\n| `web_execute_js` | 控制浏览器行为 |\n| `ask_user` | 人机协作确认 |\n\n> 此外，还有 2 个**记忆管理工具**（`update_working_checkpoint`、`start_long_term_update`），使 Agent 能够跨会话积累经验、维持持久上下文。\n\n4️⃣ **能力扩展机制**\n> 具备动态创建新的工具能力\n>\n通过 `code_run`，GenericAgent 可在运行时动态安装 Python 包、编写新脚本、调用外部 API 或控制硬件，将临时能力固化为永久工具。\n\n<div align=\"center\">\n  <img src=\"assets/images/workflow.jpg\" alt=\"GenericAgent 工作流程\" width=\"400\"/>\n  <br><em>GenericAgent 工作流程图</em>\n</div>\n\n## ⭐ 支持\n如果这个项目对您有帮助，欢迎点一个 **Star!** 🙏\n\n同时也欢迎加入我们的**GenericAgent体验交流群**，一起交流、反馈和共建 👏\n<div align=\"center\">\n  <table>\n    <tr>\n      <td align=\"center\"><strong>微信群 15</strong><br><img src=\"assets/images/wechat_group15.jpg\" alt=\"微信群 15 二维码\" width=\"250\"/></td>\n    </tr>\n  </table>\n</div>\n\n## 🚩 友情链接\n\n感谢 **LinuxDo** 社区的支持！\n\n[![LinuxDo](https://img.shields.io/badge/社区-LinuxDo-blue?style=for-the-badge)](https://linux.do/)\n\n\n## 📄 许可\nMIT License — 详见 [LICENSE](LICENSE)\n\n*声明：本项目未构建任何商业站点；除 DintalClaw 外，目前未官方授权任何机构、组织或个人以 GenericAgent 名义从事商业活动。*\n\n## 📈 Star History\n\n<a href=\"https://star-history.com/#lsdefine/GenericAgent&Date\">\n <picture>\n   <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=lsdefine/GenericAgent&type=Date&theme=dark\" />\n   <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=lsdefine/GenericAgent&type=Date\" />\n   <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=lsdefine/GenericAgent&type=Date\" />\n </picture>\n</a>\n"
  },
  {
    "path": "TMWebDriver.py",
    "content": "import json, threading, time, uuid, queue, socket, requests, traceback\nfrom typing import Any\nfrom simple_websocket_server import WebSocketServer, WebSocket\nimport bottle\nfrom bottle import request\n\nclass Session:\n    def __init__(self, session_id, info, client=None):\n        self.id = session_id\n        self.info = info\n        self.connect_at = time.time()\n        self.disconnect_at = None\n        self.type = info.get('type', 'ws')\n        self.ws_client = client if self.type in ('ws', 'ext_ws') else None\n        self.http_queue = client if self.type == 'http' else None\n    @property\n    def url(self): return self.info.get('url', '')\n    def is_active(self):\n        if self.type == 'http' and time.time() - self.connect_at > 60: self.mark_disconnected()\n        return self.disconnect_at is None\n    def reconnect(self, client, info):\n        self.info = info\n        self.type = info.get('type', 'ws')\n        if self.type in ('ws', 'ext_ws'):\n            self.ws_client = client\n            self.http_queue = None\n        elif self.type == 'http':\n            self.http_queue = client\n        self.connect_at = time.time()\n        self.disconnect_at = None\n    def mark_disconnected(self):\n        if self.is_active(): print(f\"Tab disconnected: {self.url} (Session: {self.id})\")\n        self.disconnect_at = time.time()\n\n\nclass TMWebDriver:  \n    def __init__(self, host: str = '127.0.0.1', port: int = 18765):  \n        self.host, self.port = host, port\n        self.sessions, self.results, self.acks = {}, {}, {}\n        self.default_session_id = None  \n        self.latest_session_id = None  \n        self.is_remote = socket.socket().connect_ex((host, port+1)) == 0\n        if not self.is_remote:  \n            self.start_ws_server()  \n            self.start_http_server()\n        else:\n            self.remote = f'http://{self.host}:{self.port+1}/link'\n\n    def start_http_server(self):\n        self.app = app = bottle.Bottle()\n\n        @app.route('/api/longpoll', method=['GET', 'POST'])\n        def long_poll():\n            data = request.json\n            session_id = data.get('sessionId')  \n            session_info = {'url': data.get('url'), 'title': data.get('title', ''), 'type': 'http'}  \n            if session_id not in self.sessions: \n                session = Session(session_id, session_info, queue.Queue())\n                print(f\"Browser http connected: {session.url} (Session: {session_id})\")  \n                self.sessions[session_id] = session\n            session = self.sessions[session_id]\n            if session.disconnect_at is not None and session.type != 'http': session.reconnect(queue.Queue(), session_info)\n            session.disconnect_at = None\n            if session.type == 'http': msgQ = session.http_queue\n            else: return json.dumps({\"id\": \"\", \"ret\": \"use ws\"})\n            session.connect_at = start_time = time.time()\n            while time.time() - start_time < 5:\n                try:\n                    msg = msgQ.get(timeout=0.2)\n                    try: self.acks[json.loads(msg).get('id','')] = True\n                    except Exception: traceback.print_exc()\n                    return msg\n                except queue.Empty: continue\n            return json.dumps({\"id\": \"\", \"ret\": \"next long-poll\"})\n\n        @app.route('/api/result', method=['GET','POST'])\n        def result():\n            data = request.json\n            if data.get('type') == 'result':  \n                self.results[data.get('id')] = {'success': True, 'data': data.get('result'), 'newTabs': data.get('newTabs', [])}  \n            elif data.get('type') == 'error':  \n                self.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])}  \n            return 'ok'\n\n        @app.route('/link', method=['GET','POST'])\n        def link():\n            data = request.json\n            if data.get('cmd') == 'get_all_sessions': return json.dumps({'r': self.get_all_sessions()}, ensure_ascii=False)  \n            if data.get('cmd') == 'find_session': \n                url_pattern = data.get('url_pattern', '')\n                return json.dumps({'r': self.find_session(url_pattern)}, ensure_ascii=False)\n            if data.get('cmd') == 'execute_js':\n                session_id = data.get('sessionId')\n                code = data.get('code')\n                timeout = float(data.get('timeout', 10.0))\n                try:\n                    result = self.execute_js(code, timeout=timeout, session_id=session_id)\n                    print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\\n', ' '))\n                    return json.dumps({'r': result}, ensure_ascii=False)\n                except Exception as e:\n                    return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False)\n            return 'ok'\n        def run():\n            from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler\n            from socketserver import ThreadingMixIn\n            class _T(ThreadingMixIn, WSGIServer): pass\n            class _H(WSGIRequestHandler):\n                def log_request(self, *a): pass\n            make_server(self.host, self.port+1, app, server_class=_T, handler_class=_H).serve_forever()\n        http_thread = threading.Thread(target=run, daemon=True)\n        http_thread.start()  \n\n    def clean_sessions(self):\n        sids = list(self.sessions.keys())\n        for sid in sids:\n            session = self.sessions[sid]\n            if not session.is_active() and time.time() - session.disconnect_at > 600:\n                del self.sessions[sid]\n    \n    def start_ws_server(self) -> None:  \n        driver = self  \n        class JSExecutor(WebSocket):  \n            def handle(self) -> None:  \n                try:  \n                    data = json.loads(self.data)  \n                    if data.get('type') == 'ready':  \n                        session_id = data.get('sessionId')  \n                        session_info = {'url': data.get('url'), 'title': data.get('title', ''),\n                            'connected_at': time.time(), 'type': 'ws'}  \n                        driver._register_client(session_id, self, session_info)  \n                    elif data.get('type') in ['ext_ready', 'tabs_update']:\n                        tabs = data.get('tabs', [])\n                        current_tab_ids = {str(tab['id']) for tab in tabs}\n                        print(f\"Received tabs update: {current_tab_ids}\")\n                        for sid in list(driver.sessions.keys()):\n                            sess = driver.sessions[sid]\n                            if sess.type == 'ext_ws' and sid not in current_tab_ids:\n                                sess.mark_disconnected()\n                        for tab in tabs:\n                            session_id = str(tab['id'])\n                            session_info = {'url': tab.get('url'), 'title': tab.get('title', ''), 'connected_at': time.time(), 'type': 'ext_ws'}\n                            sess = driver.sessions.get(session_id)\n                            if sess and sess.is_active(): sess.info = session_info\n                            else: driver._register_client(session_id, self, session_info)\n                    elif data.get('type') == 'ack': driver.acks[data.get('id','')] = True\n                    elif data.get('type') == 'result':  \n                        driver.results[data.get('id')] = {'success': True, 'data': data.get('result'), 'newTabs': data.get('newTabs', [])}  \n                    elif data.get('type') == 'error':  \n                        driver.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])}  \n                except Exception as e:  \n                    print(f\"Error handling message: {e}\")  \n                    if hasattr(self, 'data'): print(self.data)  \n            def connected(self): (f\"New connection from {self.address}\")  \n            def handle_close(self): \n                print(f\"WS Connection closed: {self.address}\")\n                driver._unregister_client(self)  \n        \n        self.server = WebSocketServer(self.host, self.port, JSExecutor)  \n        server_thread = threading.Thread(target=self.server.serve_forever)  \n        server_thread.daemon = True  \n        server_thread.start()  \n        print(f\"WebSocket server running on ws://{self.host}:{self.port}\")  \n    \n    def _register_client(self, session_id: str, client: WebSocket, session_info) -> None:  \n        is_new_session = session_id not in self.sessions\n\n        if is_new_session:\n            session = Session(session_id, session_info, client)\n            self.sessions[session_id] = session            \n            print(f\"New tab connected: {session.url} (Session: {session_id})\")  \n        else:\n            session = self.sessions[session_id]\n            session.reconnect(client, session_info)\n            print(f\"Tab reconnected: {session.url} (Session: {session_id})\")  \n\n        self.latest_session_id = session_id\n        if self.default_session_id is None: self.default_session_id = session_id \n    \n    def _unregister_client(self, client: WebSocket) -> None:  \n        for session in self.sessions.values():\n            if session.ws_client == client: session.mark_disconnected()\n    \n    def execute_js(self, code, timeout=15, session_id=None) -> Any:  \n        if session_id is None: session_id = self.default_session_id  \n        if self.is_remote:\n            print('remote_execute_js')\n            response = self._remote_cmd({\"cmd\": \"execute_js\", \"sessionId\": session_id, \n                                         \"code\": code, \"timeout\": str(timeout)}).get('r', {})\n            if response.get('error'): raise Exception(response['error'])\n            return response\n \n        session = self.sessions.get(session_id)\n        if not session or not session.is_active(): \n            time.sleep(3)\n            session = self.sessions.get(session_id)\n            if not session or not session.is_active(): \n                alive_sessions = [s for s in self.sessions.values() if s.is_active()]\n                if alive_sessions:\n                    session = alive_sessions[0]  \n                    print(f\"会话 {session_id} 未连接，自动切换到最新活动会话: {session.id}\")\n                    session_id = self.default_session_id = session.id\n                if not session or not session.is_active(): \n                    raise ValueError(f\"会话ID {session_id} 未连接\")  \n\n        tp = session.type\n        if tp not in ('ws', 'http', 'ext_ws'):\n            raise ValueError(f\"Unsupported session type: {tp}\")\n        exec_id = str(uuid.uuid4())  \n        payload_dict = {'id': exec_id, 'code': code}\n        if tp == 'ext_ws': payload_dict['tabId'] = int(session.id)\n        payload = json.dumps(payload_dict)\n\n        if tp in ['ws', 'ext_ws']: session.ws_client.send_message(payload)  \n        elif tp == 'http': session.http_queue.put(payload)\n\n        start_time = time.time()  \n        self.clean_sessions() \n        hasjump = acked = False\n\n        while exec_id not in self.results:  \n            time.sleep(0.2)  \n            if not acked and exec_id in self.acks:\n                acked = True; start_time = time.time()\n            if tp in ['ws', 'ext_ws']:\n                if not session.is_active(): hasjump = True\n                if hasjump and session.is_active():\n                    return {'result': f\"Session {session_id} reloaded.\", \"closed\":1}\n            if time.time() - start_time > timeout:  \n                if tp in ['ws', 'ext_ws']:\n                    if hasjump: return {'result': f\"Session {session_id} reloaded and new page is loading...\", 'closed':1}\n                    if acked: return {\"result\": f\"No response data in {timeout}s (ACK received, script may still be running)\"}\n                    return {\"result\": f\"No response data in {timeout}s (no ACK, script may not have been delivered)\"}\n                elif tp == 'http':\n                    if acked: return {\"result\": f\"Session {session_id} no response in {timeout}s (delivered but no result)\"}\n                    return {\"result\": f\"Session {session_id} no response in {timeout}s (script not polled)\"}\n        \n        result = self.results.pop(exec_id)  \n        if exec_id in self.acks: self.acks.pop(exec_id)\n        if not result['success']: raise Exception(result['data'])  \n        rr = {'data': result['data']}\n        newtabs = result.get('newTabs', []); [x.pop('ts', None) for x in newtabs]\n        if newtabs: rr['newTabs'] = newtabs\n        return rr\n    \n    def _remote_cmd(self, cmd):\n        try: return requests.post(self.remote, headers={\"Content-Type\": \"application/json\"}, json=cmd, timeout=30).json()\n        except (ConnectionError, requests.exceptions.ConnectionError):\n            raise ConnectionError(\"TMWebDriver master未运行，看tmwebdriver_sop启动master\")\n\n    def get_all_sessions(self):  \n        if self.is_remote:\n            return self._remote_cmd({\"cmd\": \"get_all_sessions\"}).get('r', [])\n        return [{'id': session.id, **session.info} for session in self.sessions.values()\n                if session.is_active()]  \n\n    def get_session_dict(self):\n        return {session['id']: session['url'] for session in self.get_all_sessions()}\n        \n    def find_session(self, url_pattern: str):\n        if url_pattern == '': \n            session = self.sessions.get(self.latest_session_id)\n            return [(session.id, session.info)] if session else []\n        matching_sessions = []  \n        for session in self.sessions.values():\n            if not session.is_active(): continue\n            if 'url' in session.info and url_pattern in session.info['url']:  \n                matching_sessions.append((session.id, session.info))  \n        return matching_sessions\n\n    def set_session(self, url_pattern: str) -> bool:  \n        if self.is_remote:\n            matched = self._remote_cmd({\"cmd\": \"find_session\", \"url_pattern\": url_pattern}).get('r', [])\n        else:\n            matched = self.find_session(url_pattern)\n        if not matched: return print(f\"警告: 未找到URL包含 '{url_pattern}' 的会话\")  \n        if len(matched) > 1: print(f\"警告: 找到多个URL包含 '{url_pattern}' 的会话，选择第一个\")  \n        self.default_session_id, info = matched[0]\n        print(f\"成功设置默认会话: {self.default_session_id}: {info['url']}\")  \n        return self.default_session_id  \n    \n    def jump(self, url, timeout=10): self.execute_js(f\"window.location.href='{url}'\", timeout=timeout)\n    \nif __name__ == \"__main__\":\n    driver = TMWebDriver(host='127.0.0.1', port=18765)"
  },
  {
    "path": "agent_loop.py",
    "content": "import json, re, os\nfrom dataclasses import dataclass\nfrom typing import Any, Optional\n@dataclass\nclass StepOutcome:\n    data: Any\n    next_prompt: Optional[str] = None\n    should_exit: bool = False\ndef try_call_generator(func, *args, **kwargs):\n    ret = func(*args, **kwargs)\n    if hasattr(ret, '__iter__') and not isinstance(ret, (str, bytes, dict, list)): ret = yield from ret\n    return ret\n\nclass BaseHandler:\n    def tool_before_callback(self, tool_name, args, response): pass\n    def tool_after_callback(self, tool_name, args, response, ret): pass\n    def turn_end_callback(self, response, tool_calls, tool_results, turn, next_prompt, exit_reason): return next_prompt\n    def dispatch(self, tool_name, args, response, index=0):\n        method_name = f\"do_{tool_name}\"\n        if hasattr(self, method_name):\n            args['_index'] = index\n            prer = yield from try_call_generator(self.tool_before_callback, tool_name, args, response)\n            ret = yield from try_call_generator(getattr(self, method_name), args, response)\n            _ = yield from try_call_generator(self.tool_after_callback, tool_name, args, response, ret)\n            return ret\n        elif tool_name == 'bad_json': return StepOutcome(None, next_prompt=args.get('msg', 'bad_json'), should_exit=False)\n        else:\n            yield f\"未知工具: {tool_name}\\n\"\n            return StepOutcome(None, next_prompt=f\"未知工具 {tool_name}\", should_exit=False)\n\ndef json_default(o): return list(o) if isinstance(o, set) else str(o)\ndef exhaust(g):\n    try: \n        while True: next(g)\n    except StopIteration as e: return e.value\n\ndef get_pretty_json(data):\n    if isinstance(data, dict) and \"script\" in data:\n        data = data.copy(); data[\"script\"] = data[\"script\"].replace(\"; \", \";\\n  \")\n    return json.dumps(data, indent=2, ensure_ascii=False).replace('\\\\n', '\\n')\n\ndef agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, max_turns=40, verbose=True, initial_user_content=None):\n    messages = [\n        {\"role\": \"system\", \"content\": system_prompt},\n        {\"role\": \"user\", \"content\": initial_user_content if initial_user_content is not None else user_input}\n    ]\n    turn = 0;  handler.max_turns = max_turns\n    while turn < handler.max_turns:\n        turn += 1; turnstr = f'LLM Running (Turn {turn}) ...'\n        if handler.parent.task_dir: turnstr = f'Turn {turn} ...'\n        if verbose: turnstr = f'**{turnstr}**'\n        yield f\"\\n\\n{turnstr}\\n\\n\"\n        if turn%10 == 0: client.last_tools = ''  # 每10轮重置一次工具描述，避免上下文过大导致的模型性能下降\n        response_gen = client.chat(messages=messages, tools=tools_schema)\n        if verbose:\n            response = yield from response_gen\n            yield '\\n\\n'\n        else:\n            response = exhaust(response_gen)\n            cleaned = _clean_content(response.content)\n            if cleaned: yield cleaned + '\\n'\n\n        if not response.tool_calls: tool_calls = [{'tool_name': 'no_tool', 'args': {}}]\n        else: tool_calls = [{'tool_name': tc.function.name, 'args': json.loads(tc.function.arguments), 'id': tc.id}\n                          for tc in response.tool_calls]\n       \n        tool_results = []; next_prompts = set(); exit_reason = {}\n        for ii, tc in enumerate(tool_calls):\n            tool_name, args, tid = tc['tool_name'], tc['args'], tc.get('id', '')\n            if tool_name == 'no_tool': pass\n            else: \n                if verbose: yield f\"🛠️ Tool: `{tool_name}`  📥 args:\\n````text\\n{get_pretty_json(args)}\\n````\\n\"\n                else: yield f\"🛠️ {tool_name}({_compact_tool_args(tool_name, args)})\\n\\n\\n\"\n            handler.current_turn = turn\n            gen = handler.dispatch(tool_name, args, response, index=ii)\n            try:\n                v = next(gen)\n                def proxy(): yield v; return (yield from gen)\n                if verbose: yield '`````\\n'\n                outcome = (yield from proxy()) if verbose else exhaust(proxy())\n                if verbose: yield '`````\\n'\n            except StopIteration as e: outcome = e.value\n            \n            if outcome.should_exit: \n                exit_reason = {'result': 'EXITED', 'data': outcome.data}; break\n            if not outcome.next_prompt: \n                exit_reason = {'result': 'CURRENT_TASK_DONE', 'data': outcome.data}; break\n            if outcome.next_prompt.startswith('未知工具'): client.last_tools = ''\n            if outcome.data is not None and tool_name != 'no_tool': \n                datastr = json.dumps(outcome.data, ensure_ascii=False, default=json_default) if type(outcome.data) in [dict, list] else str(outcome.data) \n                tool_results.append({'tool_use_id': tid, 'content': datastr})\n            next_prompts.add(outcome.next_prompt)\n        if len(next_prompts) == 0 or exit_reason:\n            if len(handler._done_hooks) == 0 or exit_reason.get('result', '') == 'EXITED': break\n            next_prompts.add(handler._done_hooks.pop(0))\n        next_prompt = handler.turn_end_callback(response, tool_calls, tool_results, turn, '\\n'.join(next_prompts), exit_reason)\n        messages = [{\"role\": \"user\", \"content\": next_prompt, \"tool_results\": tool_results}]   # just new message, history is kept in *Session\n    if exit_reason: handler.turn_end_callback(response, tool_calls, tool_results, turn, '', exit_reason)\n    return exit_reason or {'result': 'MAX_TURNS_EXCEEDED'}\n\ndef _clean_content(text):\n    if not text: return ''\n    def _shrink_code(m):\n        lines = m.group(0).split('\\n')\n        lang = lines[0].replace('```','').strip()\n        body = [l for l in lines[1:-1] if l.strip()]\n        if len(body) <= 6: return m.group(0)\n        preview = '\\n'.join(body[:5])\n        return f'```{lang}\\n{preview}\\n  ... ({len(body)} lines)\\n```'\n    text = re.sub(r'```[\\s\\S]*?```', _shrink_code, text)\n    for p in [r'<file_content>[\\s\\S]*?</file_content>', r'<tool_(?:use|call)>[\\s\\S]*?</tool_(?:use|call)>', r'(\\r?\\n){3,}']:\n        text = re.sub(p, '\\n\\n' if '\\\\n' in p else '', text)\n    return text.strip()\n\ndef _compact_tool_args(name, args):\n    a = {k: v for k, v in args.items() if k != '_index'}\n    for k in ('path',): \n        if k in a: a[k] = os.path.basename(a[k])\n    if name == 'update_working_checkpoint': s = a.get('key_info', ''); return (s[:60]+'...') if len(s)>60 else s\n    if name == 'ask_user':\n        q = str(a.get('question', ''))\n        cs = a.get('candidates') or []\n        if cs: q += '\\ncandidates:\\n' + '\\n'.join(f'- {c}' for c in cs)\n        return q\n    s = json.dumps(a, ensure_ascii=False); return (s[:120]+'...') if len(s)>120 else s\n"
  },
  {
    "path": "agentmain.py",
    "content": "import os, sys, threading, queue, time, json, re, random, locale\nos.environ.setdefault('GA_LANG', 'zh' if any(k in (locale.getlocale()[0] or '').lower() for k in ('zh', 'chinese')) else 'en')\nif sys.stdout is None: sys.stdout = open(os.devnull, \"w\")\nelif hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(errors='replace')\nif sys.stderr is None: sys.stderr = open(os.devnull, \"w\")\nelif hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(errors='replace')\nsys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))\n\nfrom llmcore import reload_mykeys, LLMSession, ToolClient, ClaudeSession, MixinSession, NativeToolClient, NativeClaudeSession, NativeOAISession, resolve_client\nfrom agent_loop import agent_runner_loop\nfrom ga import GenericAgentHandler, smart_format, get_global_memory, format_error, consume_file\n\nscript_dir = os.path.dirname(os.path.abspath(__file__))\ndef load_tool_schema(suffix=''):\n    global TOOLS_SCHEMA\n    TS = open(os.path.join(script_dir, f'assets/tools_schema{suffix}.json'), 'r', encoding='utf-8').read()\n    TOOLS_SCHEMA = json.loads(TS if os.name == 'nt' else TS.replace('powershell', 'bash'))\nload_tool_schema()\n\nlang_suffix = '_en' if os.environ.get('GA_LANG', '') == 'en' else ''\nmem_dir = os.path.join(script_dir, 'memory')\nif not os.path.exists(mem_dir): os.makedirs(mem_dir)\nmem_txt = os.path.join(mem_dir, 'global_mem.txt')\nif not os.path.exists(mem_txt): open(mem_txt, 'w', encoding='utf-8').write('# [Global Memory - L2]\\n')\nmem_insight = os.path.join(mem_dir, 'global_mem_insight.txt')\nif not os.path.exists(mem_insight):\n    t = os.path.join(script_dir, f'assets/global_mem_insight_template{lang_suffix}.txt')\n    open(mem_insight, 'w', encoding='utf-8').write(open(t, encoding='utf-8').read() if os.path.exists(t) else '')\ncdp_cfg = os.path.join(script_dir, 'assets/tmwd_cdp_bridge/config.js')\nif not os.path.exists(cdp_cfg):\n    try:\n        os.makedirs(os.path.dirname(cdp_cfg), exist_ok=True)\n        open(cdp_cfg, 'w', encoding='utf-8').write(f\"const TID = '__ljq_{hex(random.randint(0, 99999999))[2:8]}';\")\n    except Exception as e: print(f'[WARN] CDP config init failed: {e} — advanced web features (tmwebdriver) will be unavailable.')\n\ndef get_system_prompt():\n    with open(os.path.join(script_dir, f'assets/sys_prompt{lang_suffix}.txt'), 'r', encoding='utf-8') as f: prompt = f.read()\n    prompt += f\"\\nToday: {time.strftime('%Y-%m-%d %a')}\\n\"\n    prompt += get_global_memory()\n    return prompt\n\nclass GenericAgent:\n    def __init__(self):\n        os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True)\n        self.lock = threading.Lock()\n        self.task_dir = None\n        self.history = []; self.handler = None; \n        self.task_queue = queue.Queue() \n        self.is_running = False; self.stop_sig = False\n        self.llm_no = 0;  self.inc_out = False; self.verbose = True\n        self.peer_hint = True\n        self.log_path = os.path.join(script_dir, f'temp/model_responses/model_responses_{int(time.time()*1e6)%1000000:06d}.txt')\n        self.load_llm_sessions()\n\n    def load_llm_sessions(self):\n        mykeys, changed = reload_mykeys()\n        if not changed and hasattr(self, 'llmclients'): return\n        try: oldhistory = self.llmclient.backend.history\n        except: oldhistory = None\n        llm_sessions = []\n        for k, cfg in mykeys.items():\n            if not any(x in k for x in ['api', 'config', 'cookie']): continue\n            try:\n                if 'mixin' in k: llm_sessions += [{'mixin_cfg': cfg}]\n                elif c := resolve_client(k): llm_sessions += [c]\n            except: pass\n        for i, s in enumerate(llm_sessions):\n            if isinstance(s, dict) and 'mixin_cfg' in s:\n                try:\n                    mixin = MixinSession(llm_sessions, s['mixin_cfg'])\n                    if isinstance(mixin._sessions[0], (NativeClaudeSession, NativeOAISession)): llm_sessions[i] = NativeToolClient(mixin)\n                    else: llm_sessions[i] = ToolClient(mixin)\n                except Exception as e: print(f'\\n\\n\\n[ERROR] Failed to init MixinSession with cfg {s[\"mixin_cfg\"]}: {e}!!!\\n\\n')\n        self.llmclients = llm_sessions\n        self.llmclient = self.llmclients[self.llm_no%len(self.llmclients)]\n        if oldhistory: self.llmclient.backend.history = oldhistory\n    \n    def next_llm(self, n=-1):\n        self.load_llm_sessions()\n        self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclients)\n        lastc = self.llmclient\n        self.llmclient = self.llmclients[self.llm_no]\n        try: self.llmclient.backend.history = lastc.backend.history\n        except: raise Exception('[ERROR] BAD Mixin config: Check your mykey.py')\n        self.llmclient.last_tools = ''\n        name = self.get_llm_name(model=True)\n        if 'glm' in name or 'minimax' in name or 'kimi' in name: load_tool_schema('_cn')\n        else: load_tool_schema()\n    def list_llms(self): \n        self.load_llm_sessions()\n        return [(i, self.get_llm_name(b), i == self.llm_no) for i, b in enumerate(self.llmclients)]\n    def get_llm_name(self, b=None, model=False):\n        b = self.llmclient if b is None else b\n        if isinstance(b, dict): return 'BADCONFIG_MIXIN'\n        if model: return b.backend.model.lower()\n        return f\"{type(b.backend).__name__}/{b.backend.name}\"\n\n    def abort(self):\n        if not self.is_running: return\n        print('Abort current task...')\n        self.stop_sig = True\n        if self.handler is not None: self.handler.code_stop_signal.append(1)\n            \n    def put_task(self, query, source=\"user\", images=None):\n        display_queue = queue.Queue()\n        self.task_queue.put({\"query\": query, \"source\": source, \"images\": images or [], \"output\": display_queue})\n        return display_queue\n\n    # i know it is dangerous, but raw_query is dangerous enough it doesn't enlarge\n    def _handle_slash_cmd(self, raw_query, display_queue):\n        if not raw_query.startswith('/'): return raw_query\n        if _sm := re.match(r'/session\\.(\\w+)=(.*)', raw_query.strip()):\n            k, v = _sm.group(1), _sm.group(2)\n            vfile = os.path.join(script_dir, 'temp', v)\n            if os.path.isfile(vfile): v = open(vfile, encoding='utf-8').read().strip()\n            try: v = json.loads(v)  # cover number parsing\n            except (json.JSONDecodeError, ValueError): pass\n            setattr(self.llmclient.backend, k, v)\n            display_queue.put({'done': smart_format(f\"✅ session.{k} = {repr(v)}\", max_str_len=500), 'source': 'system'})\n            return None\n        if raw_query.strip() == '/resume':\n            return r'帮我看看最近有哪些会话可以恢复。读model_responses/目录，按修改时间取最近10个文件，从每个文件里找最后一个<history>...</history>块，用一句话总结每个会话在聊什么，列表给我选。注意读文件后要把字面的\\n替换成真换行才能正确匹配。'\n        return raw_query\n\n    def run(self):\n        while True:\n            task = self.task_queue.get()\n            raw_query, source, display_queue = task[\"query\"], task[\"source\"], task[\"output\"]\n            raw_query = self._handle_slash_cmd(raw_query, display_queue)\n            if raw_query is None:\n                self.task_queue.task_done(); continue\n            self.is_running = True\n            rquery = smart_format(raw_query.replace('\\n', ' '), max_str_len=200)\n            self.history.append(f\"[USER]: {rquery}\")\n            \n            sys_prompt = get_system_prompt() + getattr(self.llmclient.backend, 'extra_sys_prompt', '')\n            if self.peer_hint: sys_prompt += f\"\\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\\n\"\n            handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp'))\n            if self.handler and 'key_info' in self.handler.working: \n                ki = re.sub(r'\\n\\[SYSTEM\\] 此为.*?工作记忆[。\\n]*', '', self.handler.working['key_info'])  # 去旧\n                handler.working['key_info'] = ki\n                handler.working['passed_sessions'] = ps = self.handler.working.get('passed_sessions', 0) + 1\n                if ps > 0: handler.working['key_info'] += f'\\n[SYSTEM] 此为 {ps} 个对话前设置的key_info，若已在新任务，先更新或清除工作记忆。\\n'\n            self.handler = handler  # although new handler, the **full** history is in llmclient, so it is full history!\n            self.llmclient.log_path = self.log_path\n            gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query, \n                                handler, TOOLS_SCHEMA, max_turns=70, verbose=self.verbose)\n            try:\n                full_resp = \"\"; last_pos = 0\n                for chunk in gen:\n                    if consume_file(self.task_dir, '_stop'): self.abort() \n                    if self.stop_sig: break\n                    full_resp += chunk\n                    if len(full_resp) - last_pos > 50 or 'LLM Running' in chunk:\n                        display_queue.put({'next': full_resp[last_pos:] if self.inc_out else full_resp, 'source': source})\n                        last_pos = len(full_resp)\n                if self.inc_out and last_pos < len(full_resp): display_queue.put({'next': full_resp[last_pos:], 'source': source})\n                if '</summary>' in full_resp: full_resp = full_resp.replace('</summary>', '</summary>\\n\\n')\n                if '</file_content>' in full_resp: full_resp = re.sub(r'<file_content>\\s*(.*?)\\s*</file_content>', r'\\n````\\n<file_content>\\n\\1\\n</file_content>\\n````', full_resp, flags=re.DOTALL)                \n                display_queue.put({'done': full_resp, 'source': source})\n                self.history = handler.history_info\n            except Exception as e:\n                print(f\"Backend Error: {format_error(e)}\")\n                display_queue.put({'done': full_resp + f'\\n```\\n{format_error(e)}\\n```', 'source': source})\n            finally:\n                if self.stop_sig: print('User aborted the task.')\n                self.is_running = self.stop_sig = False\n                self.task_queue.task_done()\n                if self.handler is not None: self.handler.code_stop_signal.append(1)\n\nGeneraticAgent = GenericAgent    \n\nif __name__ == '__main__':\n    import argparse\n    from datetime import datetime\n    parser = argparse.ArgumentParser()\n    parser.add_argument('--task', metavar='IODIR', help='一次性任务模式(文件IO)')\n    parser.add_argument('--reflect', metavar='SCRIPT', help='反射模式：加载监控脚本，check()触发时发任务')\n    parser.add_argument('--input', help='prompt')\n    parser.add_argument('--llm_no', type=int, default=0)\n    parser.add_argument('--verbose', action='store_true')\n    parser.add_argument('--nobg', action='store_true')\n    args = parser.parse_args()\n\n    if args.task and not args.nobg:\n        import subprocess, platform\n        cmd = [sys.executable, os.path.abspath(__file__)] + [a for a in sys.argv[1:]] + ['--nobg']\n        d = os.path.join(script_dir, f'temp/{args.task}'); os.makedirs(d, exist_ok=True)\n        p = subprocess.Popen(cmd, cwd=script_dir,\n            creationflags=0x08000000 if platform.system() == 'Windows' else 0,\n            stdout=open(os.path.join(d, 'stdout.log'), 'w', encoding='utf-8'),\n            stderr=open(os.path.join(d, 'stderr.log'), 'w', encoding='utf-8'))\n        print(p.pid); sys.exit(0)\n\n    agent = GeneraticAgent()\n    agent.next_llm(args.llm_no)\n    agent.verbose = args.verbose\n    threading.Thread(target=agent.run, daemon=True).start()\n\n    if args.task:\n        agent.peer_hint = False\n        agent.task_dir = d = os.path.join(script_dir, f'temp/{args.task}'); nround = ''\n        infile = os.path.join(d, 'input.txt')\n        if args.input:\n            os.makedirs(d, exist_ok=True)\n            import glob; [os.remove(f) for f in glob.glob(os.path.join(d, 'output*.txt'))]\n            with open(infile, 'w', encoding='utf-8') as f: f.write(args.input)\n        if (fh := consume_file(d, '_history.json')): agent.llmclient.backend.history = json.loads(fh)\n        with open(infile, encoding='utf-8') as f: raw = f.read()\n        while True:\n            dq = agent.put_task(raw, source='task')\n            while 'done' not in (item := dq.get(timeout=300)): \n                if 'next' in item and random.random() < 0.95:  # 概率写一次中间结果\n                    with open(f'{d}/output{nround}.txt', 'w', encoding='utf-8') as f: f.write(item.get('next', ''))\n            with open(f'{d}/output{nround}.txt', 'w', encoding='utf-8') as f: f.write(item['done'] + '\\n\\n[ROUND END]\\n')\n            consume_file(d, '_stop')  # 已经成功停下来了，避免打断下次reply\n            for _ in range(300):  # 等reply.txt，10分钟超时\n                time.sleep(2)\n                if (raw := consume_file(d, 'reply.txt')): break\n            else: break\n            nround = nround + 1 if isinstance(nround, int) else 1\n    elif args.reflect:\n        agent.peer_hint = False\n        import importlib.util\n        spec = importlib.util.spec_from_file_location('reflect_script', args.reflect)\n        mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)\n        _mt = os.path.getmtime(args.reflect)\n        print(f'[Reflect] loaded {args.reflect}')\n        while True:\n            if os.path.getmtime(args.reflect) != _mt:\n                try: spec.loader.exec_module(mod); _mt = os.path.getmtime(args.reflect); print('[Reflect] reloaded')\n                except Exception as e: print(f'[Reflect] reload error: {e}')\n            time.sleep(getattr(mod, 'INTERVAL', 5))\n            try: task = mod.check()\n            except Exception as e: \n                print(f'[Reflect] check() error: {e}'); continue\n            if task and task == '/exit': break\n            if task is None: continue\n            print(f'[Reflect] triggered: {task[:80]}')\n            dq = agent.put_task(task, source='reflect')\n            try:\n                while 'done' not in (item := dq.get(timeout=180)): pass\n                result = item['done']\n                print(result)\n            except Exception as e:\n                if getattr(mod, 'ONCE', False): raise\n                print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}'\n            log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True)\n            script_name = os.path.splitext(os.path.basename(args.reflect))[0]\n            open(os.path.join(log_dir, f'{script_name}_{datetime.now():%Y-%m-%d}.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\\n{result}\\n\\n')\n            if (on_done := getattr(mod, 'on_done', None)):\n                try: on_done(result)\n                except Exception as e: print(f'[Reflect] on_done error: {e}')\n            if getattr(mod, 'ONCE', False): print('[Reflect] ONCE=True, exiting.'); break\n    else:\n        try: import readline\n        except Exception: pass\n        agent.inc_out = True\n        while True:\n            q = input('> ').strip()\n            if not q: continue\n            try:\n                dq = agent.put_task(q, source='user')\n                while True:\n                    item = dq.get()\n                    if 'next' in item: print(item['next'], end='', flush=True)\n                    if 'done' in item: print(); break\n            except KeyboardInterrupt:\n                agent.abort()\n                print('\\n[Interrupted]')\n"
  },
  {
    "path": "assets/SETUP_FEISHU.md",
    "content": "# 飞书 Agent 配置指南\n\n> 让你的个人电脑变成飞书机器人的大脑，随时随地通过飞书对话控制你的电脑。\n\n---\n\n## 📋 目录\n\n1. [前置条件](#前置条件)\n2. [方案选择](#方案选择)\n3. [企业用户配置](#企业用户配置)\n4. [个人用户配置](#个人用户配置)\n5. [项目配置](#项目配置)\n6. [运行与测试](#运行与测试)\n7. [常见问题](#常见问题)\n\n---\n\n## 前置条件\n\n### 必需环境\n\n- Python 3.8+\n- 本项目完整代码\n- LLM API 密钥（Claude/OpenAI 等，已在 `llmcore/mykeys` 中配置）\n\n### 安装依赖\n\n```bash\npip install lark-oapi\n```\n\n---\n\n## 方案选择\n\n| 你的情况           | 推荐方案                   | 预计耗时  |\n| ------------------ | -------------------------- | --------- |\n| 公司已有飞书企业版 | [企业用户配置](#企业用户配置) | 5-10分钟  |\n| 个人用户/学习测试  | [个人用户配置](#个人用户配置) | 10-15分钟 |\n\n---\n\n## 企业用户配置\n\n> 适用于：你的公司使用飞书，你有权限创建应用或联系管理员审批\n\n### 步骤 1：创建应用\n\n1. 访问 [飞书开放平台](https://open.feishu.cn/)\n2. 登录你的企业飞书账号\n3. 点击右上角「创建应用」→「企业自建应用」\n4. 填写应用信息：\n   - 应用名称：`我的Agent助手`（可自定义）\n   - 应用描述：`个人AI助手`\n   - 应用图标：可选\n\n### 步骤 2：添加机器人能力\n\n1. 进入应用详情页\n2. 左侧菜单选择「添加应用能力」\n3. 找到「机器人」，点击「添加」\n4. 配置机器人信息（可保持默认）\n\n### 步骤 3：配置权限\n\n1. 左侧菜单「权限管理」→「API 权限」\n2. 搜索并开通以下权限：\n   - `im:message` - 获取与发送单聊、群组消息\n   - `im:message:send_as_bot` - 以应用身份发送消息\n   - `contact:user.id:readonly` - 获取用户 ID\n\n### 步骤 4：获取凭证\n\n1. 左侧菜单「凭证与基础信息」\n2. 记录以下信息：\n   - **App ID**：`cli_xxxxxxxx`\n   - **App Secret**：`xxxxxxxxxxxxxxxx`\n\n### 步骤 5：发布应用\n\n1. 左侧菜单「版本管理与发布」\n2. 点击「创建版本」\n3. 填写版本信息，提交审核\n4. **联系企业管理员审批**（或自己是管理员直接审批）\n\n### 步骤 6：获取你的 Open ID\n\n1. 应用审批通过后，在飞书中搜索你的机器人\n2. 给机器人发送任意消息\n3. 运行以下代码获取你的 Open ID：\n\n```python\n# 临时运行一次，获取 open_id\nimport lark_oapi as lark\nfrom lark_oapi.api.im.v1 import *\n\nclient = lark.Client.builder().app_id(\"你的APP_ID\").app_secret(\"你的APP_SECRET\").build()\n\n# 监听消息，打印发送者的 open_id\ndef handle(data):\n    print(f\"你的 Open ID: {data.event.sender.sender_id.open_id}\")\n\n# ... 或者查看 frontends/fsapp.py 运行时的日志输出\n```\n\n---\n\n## 个人用户配置\n\n> 适用于：没有企业飞书账号，想个人测试使用\n\n### 步骤 1：创建测试企业\n\n1. 访问 [飞书开放平台](https://open.feishu.cn/)\n2. 使用个人手机号注册/登录\n3. 点击右上角头像 →「创建测试企业」\n4. 填写企业名称（如：`我的测试工作区`）\n5. 创建完成后，你就是这个测试企业的**管理员**\n\n### 步骤 2：创建应用\n\n> 与企业用户步骤相同\n\n1. 点击「创建应用」→「企业自建应用」\n2. 填写应用信息\n\n### 步骤 3：添加机器人能力\n\n1. 进入应用详情页\n2. 「添加应用能力」→「机器人」→「添加」\n\n### 步骤 4：配置权限\n\n1. 「权限管理」→「API 权限」\n2. 开通权限：\n   - `im:message`\n   - `im:message:send_as_bot`\n   - `contact:user.id:readonly`\n\n### 步骤 5：获取凭证\n\n1. 「凭证与基础信息」\n2. 复制 **App ID** 和 **App Secret**\n\n### 步骤 6：发布应用（测试企业可自审批）\n\n1. 「版本管理与发布」→「创建版本」\n2. 提交后，进入 [飞书管理后台](https://feishu.cn/admin)\n3. 「工作台」→「应用审核」→ 通过你的应用\n\n### 步骤 7：在飞书客户端使用\n\n1. 下载 [飞书客户端](https://www.feishu.cn/download)\n2. 登录你的测试企业账号\n3. 搜索你创建的机器人名称\n4. 开始对话！\n\n---\n\n## 项目配置\n\n### 配置飞书凭证\n\n编辑项目根目录的 `mykey.py`，添加：\n\n```python\n# 飞书应用凭证\nfs_app_id = \"cli_xxxxxxxxxxxxxxxx\"      # 替换为你的 App ID\nfs_app_secret = \"xxxxxxxxxxxxxxxx\"       # 替换为你的 App Secret\n\n# 允许使用的用户 Open ID 列表（可选，留空则允许所有人）\nfs_allowed_users = [\n    \"ou_xxxxxxxxxxxxxxxxxxxxxxxx\",       # 你的 Open ID\n]\n```\n\n### 确认 LLM 配置\n\n确保 `llmcore/mykeys` 中已配置 LLM API 密钥：\n\n```python\n# 示例：Claude API\nclaude_config = {\n    'apikey': 'sk-ant-xxxxx',\n    'apibase': 'https://api.anthropic.com',\n    'model': 'claude-sonnet-4-20250514'\n}\n```\n\n---\n\n## 运行与测试\n\n### 启动服务\n\n```bash\ncd /path/to/pc-agent-loop\npython frontends/fsapp.py\n```\n\n### 预期输出\n\n```\n==================================================\n飞书 Agent 已启动（长连接模式）\nApp ID: cli_xxxxxxxxxxxxxxxx\n等待消息...\n==================================================\n```\n\n### 测试对话\n\n1. 打开飞书客户端\n2. 找到你的机器人\n3. 发送：`你好`\n4. 等待回复（首次可能需要几秒）\n\n---\n\n## 可用命令\n\n在与机器人对话时，可以使用以下特殊命令：\n\n| 命令 | 说明 |\n| ---- | ---- |\n| `/new` | 开始新对话，清除当前上下文 |\n| `/stop` | 中止当前正在执行的任务 |\n| `/restore <关键词>` | 恢复之前的对话上下文（根据关键词搜索历史记录） |\n\n### 命令示例\n\n```\n/new                    # 清空对话，重新开始\n/stop                   # 停止正在运行的任务\n/restore 昨天的任务      # 恢复包含\"昨天的任务\"关键词的历史对话\n```\n\n### 消息显示说明\n\n- ⏳ 表示任务正在执行中\n- 消息会实时更新，无需等待完成\n- 超长回复会自动分段发送\n\n---\n\n## 常见问题\n\n### Q: 提示「应用未发布」或「无权限」\n\n**A:** 确保应用已发布且管理员已审批。测试企业用户需要在管理后台手动审批。\n\n### Q: 发送消息后没有回复\n\n**A:** 检查：\n\n1. `frontends/fsapp.py` 是否在运行\n2. 终端是否有错误日志\n3. LLM API 密钥是否配置正确\n\n### Q: 提示「invalid app_id」\n\n**A:** 检查 `mykey.py` 中的 `fs_app_id` 是否正确复制（包含 `cli_` 前缀）\n\n### Q: 如何获取自己的 Open ID？\n\n**A:** 运行 `frontends/fsapp.py` 后给机器人发消息，查看终端日志中的 `open_id`\n\n### Q: 能否多人同时使用？\n\n**A:** 不能。一个应用只能有一个长连接，连接到一台电脑。每个人需要创建自己的应用。\n\n---\n\n## 架构说明\n\n```\n你的飞书 ←→ 飞书云 ←→ 长连接 ←→ frontends/fsapp.py ←→ Agent ←→ 你的电脑\n                              ↑\n                         运行在你电脑上\n```\n\n- 消息通过飞书云转发到你电脑上运行的 `frontends/fsapp.py`\n- Agent 处理请求后，通过飞书 API 回复消息\n- **你的电脑必须保持运行** `frontends/fsapp.py` 才能响应消息\n\n---\n\n## 下一步\n\n- 自定义 Agent 行为：编辑 `assets/sys_prompt.txt`\n- 添加新工具：编辑 `assets/tools_schema.json`\n- 查看日志：运行时观察终端输出\n\n---\n\n*文档版本：v1.1 | 更新日期：2026-03-07*\n\n**v1.1 更新内容：**\n- 新增「可用命令」章节（/new, /stop, /restore）\n- 新增消息显示说明（⏳ 进行中标记、实时更新等）\n"
  },
  {
    "path": "assets/agent_bbs.py",
    "content": "# agent_bbs.py — 极简Agent公告板（多板块版）\n# 启动: uvicorn agent_bbs:app --host 0.0.0.0 --port 58800\n# 或: python agent_bbs.py\n\nimport sqlite3, uuid, time, json, os\nfrom threading import Lock\nfrom fastapi import FastAPI, HTTPException, Query, Body, UploadFile, File\nfrom fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse, FileResponse\nfrom contextlib import contextmanager\nfrom starlette.requests import Request\nfrom starlette.responses import Response\nfrom starlette.middleware.base import BaseHTTPMiddleware\n\n# key → board config; 修改 boards.json 可热重载新增板块\nBOARDS_FILE = \"boards.json\"\nDEFAULT_BOARDS = {\"agent-bbs-test\": {\"name\": \"default\", \"db\": \"agent_bbs.db\"}}\nBOARDS, BOARDS_MTIME_NS, BOARDS_LOCK = DEFAULT_BOARDS, None, Lock()\n\ndef load_boards_if_changed():\n    global BOARDS, BOARDS_MTIME_NS\n    with BOARDS_LOCK:\n        if not os.path.exists(BOARDS_FILE):\n            json.dump(DEFAULT_BOARDS, open(BOARDS_FILE, \"w\", encoding=\"utf-8\"), ensure_ascii=False, indent=2)\n        mtime = os.stat(BOARDS_FILE).st_mtime_ns\n        if mtime == BOARDS_MTIME_NS: return BOARDS\n        try:\n            new = json.load(open(BOARDS_FILE, \"r\", encoding=\"utf-8\"))\n            assert isinstance(new, dict) and all(isinstance(v, dict) and \"db\" in v and \"name\" in v for v in new.values())\n            BOARDS, BOARDS_MTIME_NS = new, mtime; init_db()\n            print(f\"[boards] reloaded {len(BOARDS)} boards\")\n        except Exception as e: print(f\"[boards] reload failed, keep old config: {e}\")\n        return BOARDS\n\nUPLOAD_DIR = \"bbs_files\"\nos.makedirs(UPLOAD_DIR, exist_ok=True)\n\napp = FastAPI(title=\"Agent BBS\", docs_url=None, redoc_url=None, openapi_url=None)\n\nclass ApiKeyMiddleware(BaseHTTPMiddleware):\n    async def dispatch(self, request: Request, call_next):\n        key = request.headers.get(\"x-api-key\") or request.query_params.get(\"key\")\n        board = load_boards_if_changed().get(key)\n        if not board: return Response(\"Not Found\", status_code=404)\n        request.state.board = board\n        return await call_next(request)\n\napp.add_middleware(ApiKeyMiddleware)\n\nHTML_PAGE = \"\"\"<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"><title>Agent BBS</title>\n<style>\n*{margin:0;padding:0;box-sizing:border-box}\nbody{font-family:Consolas,'Microsoft YaHei',monospace;background:#1a1a2e;color:#e0e0e0;padding:20px}\nh1{color:#e94560;font-size:22px;margin-bottom:15px}\n.post{background:#16213e;border-left:3px solid #0f3460;padding:10px 14px;margin:8px 0;border-radius:0 6px 6px 0}\n.post .meta{font-size:12px;color:#888;margin-bottom:4px}\n.post .author{color:#e94560;font-weight:bold}\n.post .content{white-space:pre-wrap;word-break:break-all}\n.bar{display:flex;gap:10px;margin-bottom:15px;align-items:center}\n.bar select,.bar button{background:#16213e;color:#e0e0e0;border:1px solid #0f3460;padding:4px 10px;border-radius:4px;cursor:pointer}\n.bar button:hover{background:#0f3460}\n#status{font-size:12px;color:#666}\n</style></head><body>\n<h1>Agent BBS</h1>\n<div class=\"bar\">\n  <select id=\"filter\"><option value=\"\">All Agents</option></select>\n  <button onclick=\"refresh()\">Refresh</button>\n  <button onclick=\"pg(-1)\">◀ Prev</button><button onclick=\"pg(1)\">Next ▶</button>\n  <span id=\"status\"></span>\n</div>\n<div id=\"posts\"></div>\n<script>\nconst _key=new URLSearchParams(location.search).get('key')||'';\nconst _hdr=_key?{'X-API-Key':_key}:{};\nlet page=0,PP=300,total=0;\nasync function loadAuthors(){\n  const r=await fetch('/authors',{headers:_hdr});\n  const authors=await r.json();\n  const sel=document.getElementById('filter'),cur=sel.value;\n  sel.innerHTML='<option value=\"\">All Agents</option>';\n  authors.forEach(a=>{const o=document.createElement('option');o.value=a;o.textContent=a;sel.appendChild(o)});\n  sel.value=cur;\n}\nasync function loadPosts(){\n  const f=document.getElementById('filter').value;\n  const aq=f?'author='+encodeURIComponent(f)+'&':'';\n  const [pr,cr]=await Promise.all([\n    fetch(`/posts?${aq}limit=${PP}&offset=${page*PP}`,{headers:_hdr}),\n    fetch(`/count?${aq.slice(0,-1)}`,{headers:_hdr})\n  ]);\n  const posts=await pr.json(),pages=Math.ceil((total=(await cr.json()).total)/PP)||1;\n  page=Math.max(0,Math.min(page,pages-1));\n  document.getElementById('posts').innerHTML=posts.map(p=>\n    `<div class=\"post\"><div class=\"meta\"><span class=\"author\">${esc(p.author)}</span> · #${p.id} · ${new Date(p.created_at*1000).toLocaleString()}</div><div class=\"content\">${esc(p.content)}</div></div>`\n  ).join('');\n  document.getElementById('status').textContent=`Page ${page+1}/${pages} · ${total} posts`;\n}\nfunction refresh(){loadAuthors();loadPosts()}\nfunction pg(d){page+=Math.sign(d);loadPosts();window.scrollTo(0,0)}\nfunction esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}\ndocument.getElementById('filter').onchange=()=>{page=0;loadPosts()};\nrefresh();\nsetInterval(loadPosts,8000);\n</script></body></html>\"\"\"\n\nREADME_TEXT = \"Agent BBS API\\tAuth: ALL requests require header X-API-Key: <key> or pass ?key=<key> as query parameter.\\t1. Register: POST /register body: {\\\"name\\\": \\\"your-agent-name\\\"}\\tResponse: {\\\"token\\\": \\\"xxx\\\", \\\"name\\\": \\\"your-agent-name\\\"}\\t2. Post: POST /post body: {\\\"token\\\": \\\"xxx\\\", \\\"content\\\": \\\"your message\\\"}\\tResponse: {\\\"id\\\": 1, \\\"author\\\": \\\"your-agent-name\\\"}\\t3. Poll new: GET /poll?since_id=0&limit=50\\tReturns posts with id > since_id, ordered by id asc. Keep track of the last id you received, use it as since_id next time.\\t4. Query: GET /posts?author=xxx&limit=50\\tauthor is optional. Returns posts ordered by id desc.\t5. Upload file: POST /file/upload multipart/form-data, form fields: token (your agent token) + file (the file). Requires X-API-Key. Response: {\\\"ref\\\": \\\"a1b2c3/filename.ext\\\"}. Paste ref into post content to reference the file.\t6. Download file: GET /file/{rand_id}/{filename} Requires X-API-Key. e.g. /file/a1b2c3/filename.ext\"\n\n@app.get(\"/readme\")\ndef readme(): return PlainTextResponse(README_TEXT)\n\n@app.get(\"/\", response_class=HTMLResponse)\ndef index(): return HTML_PAGE\n\n@contextmanager\ndef get_db(db_path):\n    conn = sqlite3.connect(db_path)\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n        conn.commit()\n    finally: conn.close()\n\ndef _db(request): return request.state.board[\"db\"]\n\ndef init_db():\n    for board in BOARDS.values():\n        with get_db(board[\"db\"]) as db:\n            db.execute(\"\"\"CREATE TABLE IF NOT EXISTS users (\n                token TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, created_at REAL)\"\"\")\n            db.execute(\"\"\"CREATE TABLE IF NOT EXISTS posts (\n                id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL,\n                content TEXT NOT NULL, created_at REAL,\n                FOREIGN KEY(author) REFERENCES users(name))\"\"\")\n            db.execute(\"CREATE INDEX IF NOT EXISTS idx_posts_id ON posts(id)\")\n\ndef verify_token(token, db_path):\n    with get_db(db_path) as db:\n        row = db.execute(\"SELECT name FROM users WHERE token=?\", (token,)).fetchone()\n    if not row: raise HTTPException(401, \"invalid token\")\n    return row[\"name\"]\n\n@app.on_event(\"startup\")\ndef startup(): load_boards_if_changed()\n\n@app.post(\"/register\")\ndef register(request: Request, name=Body(..., embed=True)):\n    token = uuid.uuid4().hex[:16]\n    try:\n        with get_db(_db(request)) as db:\n            db.execute(\"INSERT INTO users VALUES(?,?,?)\", (token, name, time.time()))\n    except sqlite3.IntegrityError:\n        with get_db(_db(request)) as db:\n            row = db.execute(\"SELECT token FROM users WHERE name=?\", (name,)).fetchone()\n        return {\"token\": row[\"token\"], \"name\": name}\n    return {\"token\": token, \"name\": name}\n\n@app.post(\"/post\")\ndef create_post(request: Request, token=Body(...), content=Body(...)):\n    author = verify_token(token, _db(request))\n    with get_db(_db(request)) as db:\n        cur = db.execute(\"INSERT INTO posts(author,content,created_at) VALUES(?,?,?)\",\n                         (author, content, time.time()))\n        post_id = cur.lastrowid\n    return {\"id\": post_id, \"author\": author}\n\n@app.get(\"/poll\")\ndef poll(request: Request, since_id=Query(0), limit=Query(50)):\n    with get_db(_db(request)) as db:\n        rows = db.execute(\"SELECT id,author,content,created_at FROM posts WHERE id>? ORDER BY id LIMIT ?\",\n                          (since_id, limit)).fetchall()\n    return [dict(r) for r in rows]\n\n@app.get(\"/count\")\ndef count_posts(request: Request, author=Query(None)):\n    with get_db(_db(request)) as db:\n        q, p = (\"SELECT COUNT(*) c FROM posts WHERE author=?\", (author,)) if author else (\"SELECT COUNT(*) c FROM posts\", ())\n        return {\"total\": db.execute(q, p).fetchone()[\"c\"]}\n\n@app.get(\"/authors\")\ndef get_authors(request: Request):\n    with get_db(_db(request)) as db:\n        return [r[\"author\"] for r in db.execute(\"SELECT DISTINCT author FROM posts ORDER BY author\").fetchall()]\n\n@app.get(\"/posts\")\ndef get_posts(request: Request, author=Query(None), limit=Query(50), offset=Query(0)):\n    with get_db(_db(request)) as db:\n        if author:\n            rows = db.execute(\"SELECT id,author,content,created_at FROM posts WHERE author=? ORDER BY id DESC LIMIT ? OFFSET ?\",\n                              (author, limit, offset)).fetchall()\n        else:\n            rows = db.execute(\"SELECT id,author,content,created_at FROM posts ORDER BY id DESC LIMIT ? OFFSET ?\",\n                              (limit, offset)).fetchall()\n    return [dict(r) for r in rows]\n\n@app.post(\"/file/upload\")\ndef upload_file(request: Request, token=Body(...), file: UploadFile = File(...)):\n    verify_token(token, _db(request))\n    rand_id = uuid.uuid4().hex[:6]\n    safe_name = os.path.basename(file.filename)\n    dest = os.path.join(UPLOAD_DIR, rand_id)\n    os.makedirs(dest, exist_ok=True)\n    with open(os.path.join(dest, safe_name), \"wb\") as f:\n        f.write(file.file.read())\n    return {\"ref\": f\"{rand_id}/{safe_name}\"}\n\n@app.get(\"/file/{rand_id}/{filename}\")\ndef download_file(rand_id: str, filename: str):\n    path = os.path.join(UPLOAD_DIR, rand_id, os.path.basename(filename))\n    if not os.path.exists(path):\n        raise HTTPException(404, \"not found\")\n    return FileResponse(path, filename=filename)\n\nif __name__ == \"__main__\":\n    import uvicorn\n    uvicorn.run(app, host=\"0.0.0.0\", port=58800)"
  },
  {
    "path": "assets/code_run_header.py",
    "content": "import sys, os, json, re, time, subprocess\nsys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'memory'))\n_r = subprocess.run\ndef _d(b):\n    if not b: return ''\n    if isinstance(b, str): return b\n    try: return b.decode()\n    except: return b.decode('gbk', 'replace')\ndef _run(*a, **k):\n    t = k.pop('text', 0) | k.pop('universal_newlines', 0)\n    enc = k.pop('encoding', None)\n    k.pop('errors', None)\n    if enc: t = 1\n    if t and isinstance(k.get('input'), str):\n        k['input'] = k['input'].encode()\n    r = _r(*a, **k)\n    if t:\n        if r.stdout is not None: r.stdout = _d(r.stdout)\n        if r.stderr is not None: r.stderr = _d(r.stderr)\n    return r\nsubprocess.run = _run\n_Pi = subprocess.Popen.__init__\ndef _pinit(self, *a, **k):\n    if os.name == 'nt': k['creationflags'] = (k.get('creationflags') or 0) | 0x08000000\n    _Pi(self, *a, **k)\nsubprocess.Popen.__init__ = _pinit\nsys.excepthook = lambda t, v, tb: (sys.__excepthook__(t, v, tb), print(f\"\\n[Agent Hint]: NO GUESSING! You MUST probe first. If missing common package, pip.\")) if issubclass(t, (ImportError, AttributeError)) else sys.__excepthook__(t, v, tb)\n"
  },
  {
    "path": "assets/configure_mykey.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nGenericAgent — 交互式初始化向导 (configure.py)\n一键配置 LLM 模型 + 消息平台，自动生成 mykey.py\n\n用法:\n    python configure.py\n\"\"\"\n\nimport os\nimport sys\nimport shutil\nimport json\nimport urllib.request\nimport time\nfrom datetime import datetime\n\n# ── ANSI 颜色 ──────────────────────────────────────────────────────────────\nC = {\n    'reset': '\\033[0m', 'bold': '\\033[1m', 'dim': '\\033[2m',\n    'red': '\\033[91m', 'green': '\\033[92m', 'yellow': '\\033[93m',\n    'blue': '\\033[94m', 'magenta': '\\033[95m', 'cyan': '\\033[96m', 'white': '\\033[97m',\n}\n\nPROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\nMYKPY_PATH = os.path.join(PROJECT_ROOT, 'mykey.py')\n\n# ── 模型厂商定义 ───────────────────────────────────────────────────────────\n\nLLM_PROVIDERS = [\n    {\n        'id': 'deepseek',\n        'name': 'DeepSeek V4 Flash (推荐首选)',\n        'desc': '国产开源模型，速度快、性价比高，原生 OAI 协议',\n        'type': 'native_oai',\n        'template': {\n            'name': 'deepseek-flash', 'apikey': 'sk-<your-deepseek-key>',\n            'apibase': 'https://api.deepseek.com', 'model': 'deepseek-v4-flash',\n            'api_mode': 'chat_completions', 'reasoning_effort': 'high',\n        },\n        'key_hint': '在 https://platform.deepseek.com/api_keys 获取',\n        'model_choices': ['deepseek-v4-flash', 'deepseek-v3-premium'],\n    },\n    {\n        'id': 'openai',\n        'name': 'OpenAI GPT-5 / o 系列',\n        'desc': 'OpenAI 官方，支持 GPT-5、o 系列推理模型',\n        'type': 'native_oai',\n        'template': {\n            'name': 'gpt-native', 'apikey': 'sk-<your-openai-key>',\n            'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.4',\n            'api_mode': 'chat_completions', 'reasoning_effort': 'high',\n            'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120,\n        },\n        'key_hint': '在 https://platform.openai.com/api-keys 获取',\n        'model_choices': ['gpt-5.4', 'o4-mini-high', 'o4-mini'],\n    },\n    {\n        'id': 'anthropic',\n        'name': 'Anthropic Claude 官方直连',\n        'desc': 'Claude 官方 API，sk-ant- 开头，原生 tool 协议',\n        'type': 'native_claude',\n        'template': {\n            'name': 'anthropic-direct', 'apikey': 'sk-ant-<your-anthropic-key>',\n            'apibase': 'https://api.anthropic.com', 'model': 'claude-opus-4-7',\n            'thinking_type': 'adaptive', 'max_tokens': 32768, 'temperature': 1,\n        },\n        'key_hint': '在 https://console.anthropic.com/ 获取',\n        'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'],\n    },\n    {\n        'id': 'cc_relay',\n        'name': 'CC Switch 透传 (社区常用)',\n        'desc': '社区 Claude Code 透传渠道，需要 fake_cc_system_prompt=True',\n        'type': 'native_claude',\n        'template': {\n            'name': 'cc-relay', 'apikey': 'sk-user-<your-relay-key>',\n            'apibase': 'https://<your-cc-switch-host>/claude/office',\n            'model': 'claude-opus-4-7', 'fake_cc_system_prompt': True,\n            'thinking_type': 'adaptive',\n        },\n        'key_hint': '从你的 CC Switch 服务商获取 apikey 和 apibase',\n        'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'],\n        'extra_fields': [\n            {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://your-host/claude/office'},\n            {'key': 'fake_cc_system_prompt', 'label': 'fake_cc_system_prompt', 'type': 'bool', 'default': True},\n        ],\n    },\n    {\n        'id': 'zhipu',\n        'name': '智谱 GLM (Anthropic 兼容)',\n        'desc': '智谱 GLM-5.1，走 Anthropic 兼容协议',\n        'type': 'native_claude',\n        'template': {\n            'name': 'zhipu-glm', 'apikey': 'sk-<your-zhipu-key>',\n            'apibase': 'https://open.bigmodel.cn/api/anthropic',\n            'model': 'GLM-5.1-Cloud', 'fake_cc_system_prompt': False,\n            'thinking_type': 'adaptive', 'max_retries': 3,\n            'connect_timeout': 10, 'read_timeout': 180,\n        },\n        'key_hint': '在 https://open.bigmodel.cn/usercenter/apikeys 获取',\n        'model_choices': ['GLM-5.1-Cloud', 'GLM-5.1-Edge'],\n    },\n    {\n        'id': 'minimax',\n        'name': 'MiniMax (推荐 Anthropic 路径)',\n        'desc': 'MiniMax M2.7，Anthropic 路径无 <think> 标签',\n        'type': 'native_claude',\n        'template': {\n            'name': 'minimax-anthropic', 'apikey': 'eyJh...<your-minimax-key>',\n            'apibase': 'https://api.minimaxi.com/anthropic',\n            'model': 'MiniMax-M2.7', 'max_retries': 3,\n        },\n        'key_hint': '在 https://platform.minimaxi.com/user-center/basic-information 获取',\n        'model_choices': ['MiniMax-M2.7', 'MiniMax-M2.5'],\n    },\n    {\n        'id': 'minimax_oai',\n        'name': 'MiniMax (OpenAI 兼容路径)',\n        'desc': 'MiniMax M2.7，走 /v1/chat/completions',\n        'type': 'native_oai',\n        'template': {\n            'name': 'minimax-oai', 'apikey': 'eyJh...<your-minimax-key>',\n            'apibase': 'https://api.minimaxi.com/v1', 'model': 'MiniMax-M2.7',\n            'context_win': 50000,\n        },\n        'key_hint': '在 https://platform.minimaxi.com/user-center/basic-information 获取',\n        'model_choices': ['MiniMax-M2.7', 'MiniMax-M2.5'],\n    },\n    {\n        'id': 'kimi',\n        'name': 'Kimi for Coding (Anthropic 兼容)',\n        'desc': 'Kimi 官方 CC 兼容端点，kimi-for-coding 模型',\n        'type': 'native_claude',\n        'template': {\n            'name': 'kimi-coding', 'apikey': 'sk-kimi-<your-key>',\n            'apibase': 'https://api.kimi.com/coding',\n            'model': 'kimi-for-coding', 'fake_cc_system_prompt': True,\n            'thinking_type': 'adaptive',\n        },\n        'key_hint': '在 https://kimi.com/code 获取 API Key',\n        'model_choices': ['kimi-for-coding', 'kimi-thinking-plus'],\n    },\n    {\n        'id': 'moonshot_oai',\n        'name': 'Kimi / Moonshot (OAI 兼容)',\n        'desc': 'Moonshot OAI 端点，kimi-k2 系列，温度强制 1.0',\n        'type': 'native_oai',\n        'template': {\n            'name': 'kimi-k2', 'apikey': 'sk-<your-moonshot-key>',\n            'apibase': 'https://api.moonshot.cn/v1', 'model': 'kimi-k2-turbo-preview',\n        },\n        'key_hint': '在 https://platform.moonshot.cn/ 获取',\n        'model_choices': ['kimi-k2-turbo-preview', 'kimi-k2'],\n    },\n    {\n        'id': 'openrouter',\n        'name': 'OpenRouter (多模型中继)',\n        'desc': '一个 Key 用所有模型，支持 Claude/GPT/Gemini 等',\n        'type': 'native_oai',\n        'template': {\n            'name': 'openrouter', 'apikey': 'sk-or-<your-openrouter-key>',\n            'apibase': 'https://openrouter.ai/api/v1',\n            'model': 'anthropic/claude-opus-4-7',\n            'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120,\n        },\n        'key_hint': '在 https://openrouter.ai/keys 获取',\n        'model_choices': ['anthropic/claude-opus-4-7', 'openai/gpt-5.4'],\n    },\n    {\n        'id': 'crs',\n        'name': 'CRS 反代 Claude Max',\n        'desc': 'CRS 协议的反代 Claude，需要 fake_cc_system_prompt=True',\n        'type': 'native_claude',\n        'template': {\n            'name': 'crs-claude-max', 'apikey': 'cr_<your-crs-key>',\n            'apibase': 'https://<your-crs-host>/api',\n            'model': 'claude-opus-4-7[1m]', 'fake_cc_system_prompt': True,\n            'thinking_type': 'adaptive', 'max_tokens': 32768,\n            'max_retries': 3, 'read_timeout': 180,\n        },\n        'key_hint': '从你的 CRS 服务商获取 key 和 host',\n        'model_choices': ['claude-opus-4-7[1m]', 'claude-sonnet-4-6'],\n        'extra_fields': [\n            {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://your-crs-host/api'},\n        ],\n    },\n    {\n        'id': 'crs_gemini',\n        'name': 'CRS Gemini Ultra (Antigravity 通道)',\n        'desc': 'CRS 包装的 Google Antigravity，不支持 SSE 流式，必须 stream=False',\n        'type': 'native_claude',\n        'template': {\n            'name': 'crs-gemini-ultra', 'apikey': 'cr_<your-crs-gemini-key>',\n            'apibase': 'https://<your-crs-gemini-host>/antigravity/api',\n            'model': 'claude-opus-4-7-thinking', 'stream': False,\n            'max_tokens': 32768, 'max_retries': 3, 'read_timeout': 180,\n        },\n        'key_hint': '从你的 CRS 服务商获取 Gemini Ultra key 和 host',\n        'model_choices': ['claude-opus-4-7-thinking', 'claude-opus-4-7[1m]', 'claude-opus-4-7'],\n        'extra_fields': [\n            {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://your-crs-gemini-host/antigravity/api'},\n        ],\n    },\n]\n\n# ── 消息平台定义 ────────────────────────────────────────────────────────────\nPLATFORMS = [\n    {\n        'id': 'none',\n        'name': '不使用消息平台（纯终端 REPL）',\n        'desc': '直接用 python agentmain.py 在终端交互',\n        'deps': [],\n    },\n    {\n        'id': 'telegram',\n        'name': 'Telegram 机器人',\n        'desc': '通过 Telegram Bot 与 Agent 对话',\n        'file': 'frontends/tgapp.py',\n        'deps': ['python-telegram-bot'],\n        'env_vars': [\n            {'key': 'tg_bot_token', 'label': 'Bot Token', 'hint': '从 @BotFather 获取'},\n            {'key': 'tg_allowed_users', 'label': '允许的用户 ID（逗号分隔, 留空=所有人）', 'default': '[]', 'is_list': True},\n        ],\n    },\n    {\n        'id': 'qq',\n        'name': 'QQ 机器人',\n        'desc': '通过 QQ 官方机器人 API 接入',\n        'file': 'frontends/qqapp.py',\n        'deps': ['qq-botpy'],\n        'env_vars': [\n            {'key': 'qq_app_id', 'label': 'App ID', 'hint': 'QQ 开放平台获取'},\n            {'key': 'qq_app_secret', 'label': 'App Secret'},\n            {'key': 'qq_allowed_users', 'label': '允许的用户 OpenID（逗号分隔, 留空=所有人）', 'default': '[]', 'is_list': True},\n        ],\n    },\n    {\n        'id': 'feishu',\n        'name': '飞书机器人',\n        'desc': '通过飞书应用与 Agent 对话',\n        'file': 'frontends/fsapp.py',\n        'deps': ['lark-oapi'],\n        'env_vars': [\n            {'key': 'fs_app_id', 'label': 'App ID', 'hint': '飞书开放平台获取'},\n            {'key': 'fs_app_secret', 'label': 'App Secret'},\n            {'key': 'fs_allowed_users', 'label': '允许的用户（逗号分隔, 留空=所有人）', 'default': '[]', 'is_list': True},\n        ],\n    },\n    {\n        'id': 'wecom',\n        'name': '企业微信机器人',\n        'desc': '通过企业微信 Bot 接入',\n        'file': 'frontends/wecomapp.py',\n        'deps': ['wecombot'],\n        'env_vars': [\n            {'key': 'wecom_bot_id', 'label': 'Bot ID'},\n            {'key': 'wecom_secret', 'label': 'Bot Secret'},\n            {'key': 'wecom_allowed_users', 'label': '允许的用户（逗号分隔, 留空=所有人）', 'default': '[]', 'is_list': True},\n        ],\n    },\n    {\n        'id': 'dingtalk',\n        'name': '钉钉机器人',\n        'desc': '通过钉钉应用接入',\n        'file': 'frontends/dingtalkapp.py',\n        'deps': ['dingtalk-sdk'],\n        'env_vars': [\n            {'key': 'dingtalk_client_id', 'label': 'Client ID (App Key)'},\n            {'key': 'dingtalk_client_secret', 'label': 'Client Secret (App Secret)'},\n            {'key': 'dingtalk_allowed_users', 'label': '允许的用户 StaffID（逗号分隔, 留空=所有人）', 'default': '[]', 'is_list': True},\n        ],\n    },\n    {\n        'id': 'discord',\n        'name': 'Discord 机器人',\n        'desc': '通过 Discord Bot 接入',\n        'file': 'frontends/dcapp.py',\n        'deps': ['discord.py'],\n        'env_vars': [\n            {'key': 'dc_bot_token', 'label': 'Bot Token', 'hint': 'Discord Developer Portal 获取'},\n            {'key': 'dc_allowed_users', 'label': '允许的用户 ID（逗号分隔, 留空=所有人）', 'default': '[]', 'is_list': True},\n        ],\n    },\n]\n\n\ndef _read_char():\n    \"\"\"跨平台读取单个字符（Windows 用 getwch 避免 CRLF 拆字节问题）。\"\"\"\n    if os.name == 'nt':\n        import msvcrt\n        return msvcrt.getwch()\n    else:\n        import tty\n        import termios\n        fd = sys.stdin.fileno()\n        old = termios.tcgetattr(fd)\n        try:\n            tty.setraw(fd)\n            return sys.stdin.read(1)\n        finally:\n            termios.tcsetattr(fd, termios.TCSADRAIN, old)\n\ndef _masked(v, reveal, tail):\n    \"\"\"生成脱敏字符串：前 reveal 位明文 + * + 后 tail 位明文\"\"\"\n    if len(v) > reveal + tail:\n        return v[:reveal] + '*' * min(len(v) - reveal - tail, 8) + v[-tail:]\n    elif len(v) > reveal:\n        return v[:reveal] + '*' * (len(v) - reveal)\n    return v\n\ndef masked_input(prompt, reveal=6, tail=4):\n    \"\"\"密文输入：每输入一个字符实时显示脱敏版本，支持逐字输入和粘贴。\n\n    prompt 必须为单行（不含 \\\\n）。\n    \"\"\"\n    sys.stdout.write(prompt)\n    sys.stdout.flush()\n    chars = []\n\n    def _repaint():\n        m = _masked(''.join(chars), reveal, tail)\n        # \\r → 行首；写 prompt+m；多余空格覆盖前次更长渲染的残留字符\n        sys.stdout.write(f'\\r{prompt}{m}     \\r{prompt}{m}')\n        sys.stdout.flush()\n\n    while True:\n        c = _read_char()\n        if c in ('\\r', '\\n'):\n            break\n        if c in ('\\x03', '\\x04'):\n            raise KeyboardInterrupt\n        if c in ('\\x08', '\\x7f'):\n            if chars:\n                chars.pop()\n                _repaint()\n        elif c.isprintable() or c == ' ':\n            chars.append(c)\n            _repaint()\n\n    value = ''.join(chars)\n    _repaint()\n    sys.stdout.write('\\n')\n    sys.stdout.flush()\n    return value\n\n\n# ═══════════════════════════════════════════════════════════════════════════\n#  UI Helpers\n# ═══════════════════════════════════════════════════════════════════════════\n\ndef cprint(text, color=None, bold=False, end='\\n'):\n    parts = []\n    if color: parts.append(C.get(color, ''))\n    if bold: parts.append(C['bold'])\n    parts.append(text)\n    parts.append(C['reset'])\n    print(''.join(parts), end=end)\n\ndef banner():\n    print('\\033[2J\\033[H', end='')  # ANSI 清屏，跨平台\n    print(f\"{C['cyan']}{C['bold']}\")\n    print(\"  ╔═══════════════════════════════════════════════════════════╗\")\n    print(\"  ║        GenericAgent — 交互式初始化向导 v1.1              ║\")\n    print(\"  ║   一键配置 LLM 模型 + 消息平台，自动生成 mykey.py        ║\")\n    print(\"  ╚═══════════════════════════════════════════════════════════╝\")\n    print(f\"{C['reset']}\")\n    print(f\"{C['dim']}  项目目录: {PROJECT_ROOT}{C['reset']}\")\n    print()\n\ndef _check_python():\n    \"\"\"检查 Python 版本，返回 (ok, msg)\"\"\"\n    vi = sys.version_info\n    if vi < (3, 10):\n        return False, f\"Python {vi.major}.{vi.minor} 不满足最低要求 (≥ 3.10)\"\n    if vi >= (3, 14):\n        return True, f\"⚠ Python {vi.major}.{vi.minor} 可能与 pywebview 等依赖不兼容，推荐 3.11/3.12\"\n    return True, f\"✓ Python {vi.major}.{vi.minor}.{vi.micro}\"\n\ndef ask_choice(prompt, choices, allow_multi=False, default=None):\n    \"\"\"交互式选择，返回 selected_id 或 [selected_ids]\"\"\"\n    print(f\"\\n{C['bold']}{prompt}{C['reset']}\")\n    if allow_multi:\n        print(f\"{C['dim']}  (可多选，输入序号用逗号分隔，如: 1,3,5；输入 a 全选；回车跳过){C['reset']}\")\n    else:\n        print(f\"{C['dim']}  (输入序号，如: 1){C['reset']}\")\n    for i, c in enumerate(choices, 1):\n        desc = c.get('desc', '')\n        print(f\"  {C['green']}{i}.{C['reset']} {C['bold']}{c['name']}{C['reset']}  {C['dim']}{desc}{C['reset']}\")\n    while True:\n        raw = input(f\"\\n  {C['yellow']}►{C['reset']} \").strip()\n        if not raw and default is not None:\n            return default\n        if allow_multi:\n            if raw.lower() == 'a':\n                return [c['id'] for c in choices]\n            parts = [p.strip() for p in raw.split(',') if p.strip()]\n            selected = []\n            for p in parts:\n                try:\n                    idx = int(p) - 1\n                    if 0 <= idx < len(choices):\n                        selected.append(choices[idx]['id'])\n                except ValueError:\n                    pass\n            if selected:\n                return selected\n        else:\n            try:\n                idx = int(raw) - 1\n                if 0 <= idx < len(choices):\n                    return choices[idx]['id']\n            except ValueError:\n                pass\n        print(f\"  {C['red']}✗ 请输入有效序号{C['reset']}\")\n\ndef ask_input(prompt, default=None, secret=False, hint=None):\n    \"\"\"交互式输入。secret=True 时使用脱敏输入。\"\"\"\n    # 提示信息先打印（不放进 prompt，保证 prompt 单行）\n    if hint:\n        cprint(f\"  {hint}\", 'dim')\n    if default is not None:\n        cprint(f\"  [默认: {default}]\", 'dim')\n    # 单行 prompt，\\r 能正确回行首\n    prompt_line = f\"  {C['yellow']}►{C['reset']} {prompt}: \"\n    while True:\n        if secret:\n            val = masked_input(prompt_line).strip()\n        else:\n            val = input(prompt_line).strip()\n        if not val and default is not None:\n            return default\n        if val:\n            return val\n        cprint(\"✗ 此项不能为空\", 'red')\n\ndef ask_yesno(prompt, default=True):\n    hint = \"Y/N\"\n    raw = input(f\"\\n  {C['yellow']}►{C['reset']} {prompt} ({hint}): \").strip().lower()\n    if not raw:\n        return default\n    return raw.startswith('y')\n\n\n# ═══════════════════════════════════════════════════════════════════════════\n#  LLM 配置逻辑\n# ═══════════════════════════════════════════════════════════════════════════\n\ndef _get_proxy_handler():\n    \"\"\"从环境变量读取代理配置，返回 ProxyHandler 或 None\"\"\"\n    for var in ('HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'):\n        url = os.environ.get(var)\n        if url:\n            return urllib.request.ProxyHandler({'https': url, 'http': url})\n    return None\n\ndef probe_models(provider, apikey, apibase=None):\n    \"\"\"调用 API 探测可用模型列表，返回模型 ID 列表或 None\"\"\"\n    ptype = provider.get('type', 'native_oai')\n    base = (apibase or provider['template'].get('apibase', '')).rstrip('/')\n\n    if ptype == 'native_claude':\n        # Anthropic 协议: 尝试 /v1/models (多数中继兼容此路径)\n        url = f\"{base}/v1/models\"\n        headers = {'x-api-key': apikey, 'anthropic-version': '2023-06-01'}\n    else:\n        url = f\"{base}/models\"\n        headers = {'Authorization': f'Bearer {apikey}'}\n\n    print(f\"\\n  {C['dim']}🔍 正在探测可用模型 ({url})...{C['reset']}\", end='', flush=True)\n    time.sleep(0.3)\n\n    opener = urllib.request.build_opener()\n    ph = _get_proxy_handler()\n    if ph:\n        opener = urllib.request.build_opener(ph)\n        print(f\" {C['dim']}(via proxy){C['reset']}\", end='', flush=True)\n\n    try:\n        req = urllib.request.Request(url, headers=headers, method='GET')\n        with opener.open(req, timeout=8) as resp:\n            data = json.loads(resp.read().decode())\n            # 兼容两种响应格式: {data: [{id: ...}]} 与 {object: 'list', data: [...]}\n            models = data.get('data', [])\n            ids = sorted(set(m['id'] for m in models if isinstance(m, dict) and m.get('id')))\n            if ids:\n                print(f\" {C['green']}✓ 发现 {len(ids)} 个模型{C['reset']}\")\n                return ids\n            print(f\" {C['yellow']}⚠ 返回为空{C['reset']}\")\n            return None\n    except Exception as e:\n        print(f\" {C['yellow']}⚠ 探测失败: {type(e).__name__}（将使用预设列表）{C['reset']}\")\n        return None\n\ndef _normalize_model_choices(choices):\n    \"\"\"统一 model_choices 格式为 [{'id': str, 'name': str}]\"\"\"\n    if not choices:\n        return []\n    result = []\n    for item in choices:\n        if isinstance(item, str):\n            result.append({'id': item, 'name': item})\n        elif isinstance(item, dict):\n            result.append(item)\n        elif isinstance(item, (tuple, list)) and len(item) >= 1:\n            result.append({'id': item[0], 'name': item[1] if len(item) > 1 else item[0]})\n    return result\n\ndef _configure_advanced(provider, cfg):\n    \"\"\"配置高级可选字段: proxy, context_win, stream, user_agent, thinking_budget_tokens\"\"\"\n    print(f\"\\n  {C['dim']}── 高级选项（回车跳过，使用默认值）{C['reset']}\")\n    proxy = ask_input(\"HTTP 代理地址 (proxy)\", default='', hint='如 http://127.0.0.1:2082，留空跳过')\n    if proxy:\n        cfg['proxy'] = proxy\n    cw = ask_input(\"上下文窗口阈值 (context_win)\", default='', hint='NativeClaude 默认 28000，其他默认 24000')\n    if cw:\n        cfg['context_win'] = int(cw)\n    if cfg.get('thinking_type') == 'enabled':\n        tbt = ask_input(\"thinking_budget_tokens\", default='', hint='low≈4096, medium≈10240, high≈32768')\n        if tbt:\n            cfg['thinking_budget_tokens'] = int(tbt)\n    if provider['type'] == 'native_claude':\n        ua = ask_input(\"User-Agent 版本号\", default='', hint='某些中转按 UA 白名单校验，pin 老版本用')\n        if ua:\n            cfg['user_agent'] = ua\n    stream_default = cfg.get('stream', True)\n    if ask_yesno(\"启用 SSE 流式 (stream)\", default=stream_default):\n        cfg['stream'] = True\n    else:\n        cfg['stream'] = False\n\ndef configure_llm(provider):\n    \"\"\"引导用户配置单个模型\"\"\"\n    print(f\"\\n{C['cyan']}{'─'*60}{C['reset']}\")\n    print(f\"{C['bold']}  配置: {provider['name']}{C['reset']}\")\n    print(f\"  {C['dim']}{provider['desc']}{C['reset']}\")\n    print(f\"{C['cyan']}{'─'*60}{C['reset']}\")\n\n    cfg = dict(provider['template'])\n\n    # API Key（密文输入）\n    cfg['apikey'] = ask_input(\n        f\"API Key\",\n        hint=provider.get('key_hint', ''),\n        secret=True,\n    )\n\n    # 额外字段\n    for field in provider.get('extra_fields', []):\n        if field['key'] == 'apibase':\n            cfg['apibase'] = ask_input(\n                field['label'],\n                default=field.get('default', cfg.get('apibase', '')),\n            )\n        elif field.get('type') == 'bool':\n            cfg[field['key']] = ask_yesno(\n                field['label'],\n                default=field.get('default', True)\n            )\n\n    # 模型选择\n    model_list = probe_models(provider, cfg['apikey'], cfg.get('apibase'))\n    if model_list:\n        refresh_choice = {'id': '__refresh__', 'name': '🔄 重新探测模型列表'}\n        choices = [refresh_choice] + [{'id': m, 'name': m} for m in model_list]\n        while True:\n            picked = ask_choice(\"API 探测到以下可用模型，请选择:\", choices)\n            if picked == '__refresh__':\n                print(f\"  {C['dim']}再次探测...{C['reset']}\")\n                model_list = probe_models(provider, cfg['apikey'], cfg.get('apibase'))\n                if not model_list:\n                    print(f\"  {C['yellow']}⚠ 再次探测失败，回退到预设列表{C['reset']}\")\n                    picked = _fallback_model(provider)\n                    break\n                choices = [refresh_choice] + [{'id': m, 'name': m} for m in model_list]\n            else:\n                break\n        cfg['model'] = picked\n    else:\n        cfg['model'] = _fallback_model(provider)\n\n    # 别名\n    default_name = cfg.get('name', provider['id'])\n    name = ask_input(\"此配置的别名 (name，Mixin 引用用)\", default=default_name)\n    if name:\n        cfg['name'] = name\n\n    # 高级选项\n    if ask_yesno(\"配置高级选项（proxy / context_win / stream 等）？\", default=False):\n        _configure_advanced(provider, cfg)\n\n    return cfg\n\ndef _fallback_model(provider):\n    \"\"\"使用预设模型列表让用户选择\"\"\"\n    normalized = _normalize_model_choices(provider.get('model_choices', []))\n    if normalized:\n        return ask_choice(\"选择模型:\", normalized)\n    return ask_input(\"请输入模型名称\", default=provider['template'].get('model', ''))\n\ndef configure_llms():\n    \"\"\"配置 LLM 模型\"\"\"\n    print(f\"\\n{C['bold']}{C['magenta']}╔══════════════════════════════════════╗\")\n    print(f\"║     第一步: 配置 LLM 模型           ║\")\n    print(f\"╚══════════════════════════════════════╝{C['reset']}\")\n    print(f\"\\n{C['dim']}  你可以配置最多 2 个模型组成故障转移 (Mixin) 列表。{C['reset']}\")\n\n    all_cfgs = []\n    provider_id = ask_choice(\"选择模型厂商 (配置第 1 个模型):\", LLM_PROVIDERS)\n    provider = next(p for p in LLM_PROVIDERS if p['id'] == provider_id)\n    cfg = configure_llm(provider)\n    all_cfgs.append(cfg)\n\n    if ask_yesno(\"再添加一个模型做故障转移？\", default=False):\n        providers_ext = [{'id': '__stop__', 'name': '✓ 不需要备选了', 'desc': ''}] + LLM_PROVIDERS\n        provider_id = ask_choice(\n            \"选择模型厂商 (配置第 2 个模型 — 或选「不需要备选了」跳过):\",\n            providers_ext\n        )\n        if provider_id != '__stop__':\n            provider = next(p for p in LLM_PROVIDERS if p['id'] == provider_id)\n            cfg = configure_llm(provider)\n            all_cfgs.append(cfg)\n\n    return all_cfgs\n\n\n# ═══════════════════════════════════════════════════════════════════════════\n#  消息平台配置逻辑\n# ═══════════════════════════════════════════════════════════════════════════\n\ndef configure_platforms():\n    \"\"\"配置消息平台，返回 (platform_configs, pip_hints)\"\"\"\n    print(f\"\\n{C['bold']}{C['magenta']}╔══════════════════════════════════════╗\")\n    print(f\"║     第二步: 配置消息平台             ║\")\n    print(f\"╚══════════════════════════════════════╝{C['reset']}\")\n    print(f\"\\n{C['dim']}  消息平台用于从聊天软件与 Agent 交互。{C['reset']}\")\n    print(f\"{C['dim']}  你也可以跳过此步，直接用终端 REPL。{C['reset']}\")\n\n    platform_ids = ask_choice(\n        \"选择消息平台 (可多选，选 '不使用' 则跳过):\",\n        PLATFORMS,\n        allow_multi=True,\n        default=['none']\n    )\n\n    if 'none' in platform_ids:\n        return [], set()\n\n    selected_platforms = []\n    pip_hints = set()\n\n    for pid in platform_ids:\n        platform = next(p for p in PLATFORMS if p['id'] == pid)\n        pip_hints.update(platform.get('deps', []))\n\n        print(f\"\\n{C['cyan']}{'─'*60}{C['reset']}\")\n        print(f\"{C['bold']}  配置: {platform['name']}{C['reset']}\")\n        print(f\"{C['cyan']}{'─'*60}{C['reset']}\")\n\n        env_vals = {}\n\n        # 飞书扫码创建\n        if pid == 'feishu' and ask_yesno(\"使用一键扫码创建应用？（推荐）\", default=True):\n            env_vals = _feishu_scan(platform)\n\n        # 补充扫码未获取的字段（或扫码失败时全手动填写）\n        for var in platform['env_vars']:\n            if var['key'] not in env_vals:\n                env_vals.update(_manual_platform_var(var))\n\n        # 企业微信专属：欢迎消息\n        if pid == 'wecom' and ask_yesno(\"设置欢迎消息？\", default=False):\n            env_vals['wecom_welcome_message'] = ask_input(\"欢迎消息内容\", default='你好，我在线上。')\n\n        selected_platforms.append({'platform': platform, 'config': env_vals})\n\n    return selected_platforms, pip_hints\n\ndef _manual_platform_var(var):\n    \"\"\"手动填写单个平台变量\"\"\"\n    val = ask_input(var['label'], hint=var.get('hint', ''), default=var.get('default'))\n    if var.get('is_list'):\n        if val == '[]' or not val:\n            return {var['key']: []}\n        return {var['key']: [x.strip() for x in val.split(',') if x.strip()]}\n    return {var['key']: val}\n\ndef _feishu_scan(platform):\n    \"\"\"飞书一键扫码创建应用，返回 env_vals 或空 dict\"\"\"\n    try:\n        import lark_oapi as lark\n        import qrcode, threading\n        from io import StringIO\n    except ImportError:\n        print(f\"\\n  {C['yellow']}⚠ lark-oapi 未安装，降级为手动配置{C['reset']}\")\n        return {}\n\n    print(f\"\\n  {C['cyan']}📱 正在启动一键创建...{C['reset']}\")\n    print(f\"  {C['dim']}  请用飞书 App 扫描终端二维码，完成授权后自动获取凭据。{C['reset']}\\n\")\n\n    qr_printed = threading.Event()\n    result_holder = {'data': None}\n\n    def handle_qr(info):\n        url = info['url']\n        expire = info['expire_in']\n        qr = qrcode.QRCode(border=1, box_size=1)\n        qr.add_data(url)\n        buf = StringIO()\n        qr.print_ascii(out=buf)\n        qr_art = buf.getvalue()\n        print(f\"\\n  {C['bold']}请用飞书扫描下方二维码，或复制链接在浏览器打开:{C['reset']}\")\n        print(f\"  {C['green']}{qr_art.replace(chr(27), '')}{C['reset']}\")\n        print(f\"  {C['dim']}  链接: {url}{C['reset']}\")\n        print(f\"  {C['dim']}  有效期 {expire} 秒{C['reset']}\")\n        qr_printed.set()\n\n    def handle_status(info):\n        status = info['status']\n        if status == 'polling':\n            print(f\"  {C['yellow']}⏳ 等待扫码...{C['reset']}\")\n        elif status == 'slow_down':\n            print(f\"  {C['yellow']}⏳ 等待中... (间隔 {info.get('interval', '?')}s){C['reset']}\")\n        elif status == 'domain_switched':\n            print(f\"  {C['cyan']}🌐 已切换认证域名{C['reset']}\")\n\n    def run_register():\n        try:\n            result = lark.register_app(\n                on_qr_code=handle_qr,\n                on_status_change=handle_status,\n            )\n            result_holder['data'] = result\n        except Exception as e:\n            print(f\"\\n  {C['red']}✗ 创建失败: {e}{C['reset']}\")\n\n    thread = threading.Thread(target=run_register, daemon=True)\n    thread.start()\n    qr_printed.wait(timeout=15)\n    thread.join(timeout=300)\n\n    if result_holder['data']:\n        result = result_holder['data']\n        print(f\"\\n  {C['green']}✅ 应用创建成功！{C['reset']}\")\n        print(f\"  App ID:     {C['bold']}{result['client_id']}{C['reset']}\")\n        print(f\"  App Secret: {C['bold']}{result['client_secret']}{C['reset']}\")\n        return {\n            'fs_app_id': result['client_id'],\n            'fs_app_secret': result['client_secret'],\n        }\n    else:\n        print(f\"\\n  {C['yellow']}⚠ 扫码创建未完成，降级为手动填写...{C['reset']}\")\n        return {}\n\n\n\n# ═══════════════════════════════════════════════════════════════════════════\n#  生成 mykey.py\n# ═══════════════════════════════════════════════════════════════════════════\n\ndef _var_type_info(cfg):\n    \"\"\"根据配置类型返回 (var_prefix, session_type)\"\"\"\n    cfg_type = cfg.get('type', 'native_oai')\n    if cfg_type == 'native_claude':\n        return 'native_claude_config', 'NativeClaudeSession'\n    elif cfg_type == 'claude':\n        return 'claude_config', 'ClaudeSession'\n    elif cfg_type == 'oai':\n        return 'oai_config', 'LLMSession'\n    else:\n        return 'native_oai_config', 'NativeOAISession'\n\n\ndef generate_mykey(llm_cfgs, platform_configs):\n    \"\"\"生成 mykey.py 内容\"\"\"\n    lines = []\n    lines.append(\"# ══════════════════════════════════════════════════════════════════════════════\")\n    lines.append(f\"#  GenericAgent — mykey.py (由 configure.py 自动生成 @ {datetime.now().strftime('%Y-%m-%d %H:%M')})\")\n    lines.append(\"# ══════════════════════════════════════════════════════════════════════════════\")\n    lines.append(\"\")\n    lines.append(\"# ── 停止符 ──────────────────────────────────────────────────────────────────\")\n    lines.append(\"_SETUP_DONE = 'configure.py'  # 删除此行可重新触发配置向导\")\n    lines.append(\"\")\n\n    # Mixin 配置\n    names = [c['name'] for c in llm_cfgs]\n    lines.append(\"# ── Mixin 故障转移 ──────────────────────────────────────────────────────────\")\n    lines.append(\"mixin_config = {\")\n    lines.append(f\"    'llm_nos': {names},\")\n    lines.append(\"    'max_retries': 10,\")\n    lines.append(\"    'base_delay': 0.5,\")\n    lines.append(\"}\")\n    lines.append(\"\")\n\n    # 各模型配置\n    # 同类型多实例时加上数字后缀\n    type_counts = {}\n    for cfg in llm_cfgs:\n        cfg_type = cfg.get('type', 'native_oai')\n        type_counts[cfg_type] = type_counts.get(cfg_type, 0) + 1\n\n    type_indices = {}\n    for i, cfg in enumerate(llm_cfgs):\n        cfg_type = cfg.get('type', 'native_oai')\n        var_prefix, session_type = _var_type_info(cfg)\n        idx = type_indices.get(cfg_type, 0)\n        type_indices[cfg_type] = idx + 1\n\n        # 同类型只有一个时不加后缀；多个时加数字后缀\n        if type_counts[cfg_type] > 1:\n            var_name = f\"{var_prefix}_{idx}\"\n        else:\n            var_name = var_prefix\n\n        lines.append(f\"# ── {cfg['name']} ({session_type}) ─────────────────────────────────────────────\")\n        lines.append(f\"{var_name} = {{\")\n        _write_config_fields(lines, cfg)\n        lines.append(\"}\")\n        lines.append(\"\")\n\n    # 平台配置\n    if platform_configs:\n        lines.append(\"# ══════════════════════════════════════════════════════════════════════════════\")\n        lines.append(\"#  聊天平台集成\")\n        lines.append(\"# ══════════════════════════════════════════════════════════════════════════════\")\n        lines.append(\"\")\n        for pc in platform_configs:\n            for key, val in pc['config'].items():\n                _write_platform_value(lines, key, val)\n            lines.append(\"\")\n\n    # 尾部\n    lines.append(\"# ══════════════════════════════════════════════════════════════════════════════\")\n    lines.append(\"#  配置完毕！运行: python agentmain.py  (终端 REPL)\")\n    if platform_configs:\n        for pc in platform_configs:\n            p = pc['platform']\n            lines.append(f\"#  或: python {p['file']}  ({p['name']})\")\n    lines.append(\"# ══════════════════════════════════════════════════════════════════════════════\")\n\n    return '\\n'.join(lines)\n\ndef _write_config_fields(lines, cfg):\n    \"\"\"写入配置字典的键值对（缩进的 'key': value, 格式）\"\"\"\n    for key in ['name', 'apikey', 'apibase', 'model', 'api_mode',\n                'fake_cc_system_prompt', 'thinking_type', 'thinking_budget_tokens',\n                'reasoning_effort', 'max_tokens', 'max_retries', 'connect_timeout',\n                'read_timeout', 'temperature', 'context_win',\n                'proxy', 'user_agent', 'stream']:\n        if key not in cfg:\n            continue\n        val = cfg[key]\n        if isinstance(val, bool):\n            lines.append(f\"    '{key}': {str(val)},\")\n        elif isinstance(val, (int, float)):\n            lines.append(f\"    '{key}': {val},\")\n        elif isinstance(val, str):\n            lines.append(f\"    '{key}': '{val}',\")\n        else:\n            lines.append(f\"    '{key}': {repr(val)},\")\n\ndef _write_platform_value(lines, key, val):\n    \"\"\"写入顶级变量（平台配置等）\"\"\"\n    if isinstance(val, list):\n        if val:\n            lines.append(f\"{key} = {repr(val)}\")\n        else:\n            lines.append(f\"{key} = []  # 允许所有用户\")\n    elif isinstance(val, str):\n        lines.append(f\"{key} = '{val}'\")\n    else:\n        lines.append(f\"{key} = {repr(val)}\")\n\n\n# ═══════════════════════════════════════════════════════════════════════════\n#  Main\n# ═══════════════════════════════════════════════════════════════════════════\n\ndef main():\n    banner()\n\n    # Python 版本检查\n    ok, msg = _check_python()\n    if not ok:\n        print(f\"  {C['red']}✗ {msg}{C['reset']}\")\n        sys.exit(1)\n    color = 'yellow' if '⚠' in msg else 'green'\n    print(f\"  {C[color]}{msg}{C['reset']}\\n\")\n\n    # 检测已有配置\n    if os.path.exists(MYKPY_PATH):\n        print(f\"  {C['yellow']}⚠ 检测到已有 mykey.py{C['reset']}\")\n        if not ask_yesno(\"是否重新配置？\", default=False):\n            print(f\"\\n  {C['dim']}  退出。如需重新配置请删除 mykey.py 后重试。{C['reset']}\\n\")\n            sys.exit(0)\n\n    # ── 顶层菜单 ──\n    scope = ask_choice(\n        \"你想配置什么？\",\n        [\n            {'id': 'llm', 'name': 'LLM 模型', 'desc': '选择厂商、填写 API Key、探测模型列表'},\n            {'id': 'platform', 'name': '消息平台 (Telegram/QQ/飞书等)', 'desc': '配置聊天机器人接入'},\n            {'id': 'both', 'name': '两项都配置 (推荐)', 'desc': 'LLM + 平台，完整初始化'},\n        ],\n        default='both',\n    )\n\n    llm_cfgs = []\n    platform_configs = []\n    platform_deps = set()\n\n    # ── 执行 ──\n\n    if scope in ('llm', 'both'):\n        llm_cfgs = _do_llm()\n        if scope == 'llm':\n            if ask_yesno(\"是否继续配置消息平台？\", default=True):\n                platform_configs, platform_deps = configure_platforms()\n\n    if scope == 'both':\n        platform_configs, platform_deps = configure_platforms()\n\n    if scope == 'platform':\n        platform_configs, platform_deps = configure_platforms()\n        if ask_yesno(\"是否继续配置 LLM 模型？\", default=True):\n            llm_cfgs = _do_llm()\n\n    # ── 生成 mykey.py ──\n    if not llm_cfgs and not platform_configs:\n        print(f\"\\n  {C['yellow']}⚠ 没有配置任何内容，退出。{C['reset']}\")\n        sys.exit(0)\n\n    content = generate_mykey(llm_cfgs, platform_configs)\n\n    # 备份旧文件\n    if os.path.exists(MYKPY_PATH):\n        backup = os.path.join(PROJECT_ROOT, f'mykey.py.bak.{datetime.now().strftime(\"%Y%m%d_%H%M%S\")}')\n        shutil.copy2(MYKPY_PATH, backup)\n        print(f\"\\n  {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup}{C['reset']}\")\n\n    # 写入\n    with open(MYKPY_PATH, 'w', encoding='utf-8') as f:\n        f.write(content)\n    print(f\"\\n  {C['green']}✓ mykey.py 已生成!{C['reset']}\")\n\n    # ── 完成提示 ──\n    print(f\"\\n{C['bold']}{C['green']}╔══════════════════════════════════════╗\")\n    print(f\"║      配置完成!                      ║\")\n    print(f\"╚══════════════════════════════════════╝{C['reset']}\")\n    print()\n    if llm_cfgs:\n        print(f\"  {C['cyan']}  终端 REPL:{C['reset']}  python agentmain.py\")\n    if platform_configs:\n        for i, pc in enumerate(platform_configs, 1):\n            p = pc['platform']\n            print(f\"  {C['cyan']}  平台 {i} ({p['name']}):{C['reset']}  python {p['file']}\")\n    print()\n\n    # pip 依赖提示\n    all_deps = sorted(platform_deps)\n    if all_deps:\n        print(f\"  {C['yellow']}💡 提示：你需要安装以下依赖以使消息平台正常工作:{C['reset']}\")\n        print(f\"     {C['cyan']}pip install {' '.join(all_deps)}{C['reset']}\")\n        print()\n\n    # ── 入门示例 ──\n    print(f\"  {C['bold']}试试这些命令:{C['reset']}\")\n    examples = [\n        \"帮我在桌面创建一个 hello.txt，内容是 Hello World\",\n        \"请查看你的代码，安装所有用得上的 python 依赖\",\n        \"执行 web setup sop，解锁 web 工具\",\n        \"打开淘宝，搜索 iPhone 16，按价格排序\",\n        \"用rapidocr配置你的ocr能力并存入记忆\",\n        \"git 更新你的代码，然后看看 commit 有什么新功能\",\n        \"把这个记到你的记忆里\",\n    ]\n    for ex in examples:\n        print(f\"    {C['dim']}{ex}{C['reset']}\")\n    print()\n\n    print(f\"  {C['green']}{C['bold']}合抱之木，生于毫末{C['reset']}\\n\")\n\n\ndef _do_llm():\n    \"\"\"配置 LLM 模型，失败则 exit。\"\"\"\n    cfgs = configure_llms()\n    if not cfgs:\n        print(f\"\\n  {C['red']}✗ 至少需要配置一个模型才能使用。退出。{C['reset']}\")\n        sys.exit(1)\n    return cfgs\n\n\nif __name__ == '__main__':\n    try:\n        main()\n    except KeyboardInterrupt:\n        print(f\"\\n\\n  {C['yellow']}⚠ 用户中断{C['reset']}\")\n        sys.exit(0)\n"
  },
  {
    "path": "assets/global_mem_insight_template.txt",
    "content": "# [Global Memory Insight]\n需要时read L2 或 ls ../memory/ 查L3\nL0(META-SOP): memory_management_sop\nL2: 现空\nL3: memory_cleanup_sop(记忆整理) | skill_search | ui_detect.py | ocr_utils.py | subagent | web_setup_sop | plan_sop \n| procmem_scanner | keychain | ljqCtrl_sop+.py | tmwebdriver_sop | autonomous_operation_sop | scheduled_task_sop | vision_sop | adb_ui.py\nL4: L4_raw_sessions/ 历史会话\n\n浏览器特殊操作: tmwebdriver_sop(文件上传/图搜/PDF blob/物理坐标/HttpOnly Cookie/autofill突破/跨域iframe/CDP/跨tab)\n键鼠: ljqCtrl_sop(禁pyautogui/先activate) 截图/视觉: ocr/vision_sop | 禁全屏截图，优先窗口\n定时:scheduled_task_sop | 自主:autonomous_operation_sop | watchdog/反射:agentmain --reflect\n手机:adb_ui.py\n\n[RULES]\n1. 搜索先行: 搜文件名严禁不用es(禁PS递归/禁dir遍历), 搜索一定优先使用web工具的google(严禁duckduckgo等), 优先看cwd，禁猜路径\n2. 交叉验证: 禁信摘要, 数值进详情页核实\n3. 编码安全: 禁PS cat/type用file_read; 改前必读; memory模块直接import(已在PATH,禁加虚假前缀)\n4. 闭环: 物理模拟后确认; 3次失败请求干预; Git完整闭环\n5. 进程: 禁无条件杀python(杀自己), 精确PID, 禁os.kill判活\n6. 窗口: GUI状态优先win32gui枚举标题\n7. web JS: 输入用原生setter+事件链, 点击前检disabled, 注意引号转义; scan空/不全先稍等再scan, 禁首扫定论\n8. SOP: 读SOP禁凭印象,有utils必用 | 复杂超长程任务/用户明确提及规划模式→读plan_sop\n"
  },
  {
    "path": "assets/global_mem_insight_template_en.txt",
    "content": "# [Global Memory Insight]\nRead L2 or ls ../memory/ for L3 when needed\nL0(META-SOP): memory_management_sop\nL2: currently empty\nL3: memory_cleanup_sop(memory cleanup) | skill_search | ui_detect.py | ocr_utils.py | subagent | web_setup_sop | plan_sop \n| procmem_scanner | keychain | ljqCtrl_sop+.py | tmwebdriver_sop | autonomous_operation_sop | scheduled_task_sop | vision_sop | adb_ui.py\nL4: L4_raw_sessions/ historical sessions\n\nBrowser special ops: tmwebdriver_sop(file upload/image search/PDF blob/physical coords/HttpOnly Cookie/autofill bypass/cross-origin iframe/CDP/cross-tab)\nKeyboard & Mouse: ljqCtrl_sop(no pyautogui/activate first) Screenshot/Vision: ocr/vision_sop | No fullscreen capture, prefer window\nScheduling: scheduled_task_sop | Autonomous: autonomous_operation_sop | watchdog/reflect: agentmain --reflect\nMobile: adb_ui.py\n\n[RULES]\n1. Search first: must use es for filename search (no PS recursion/no dir traversal), always prefer Google for web search (no duckduckgo etc), check cwd first, no guessing paths\n2. Cross-verify: never trust summaries, verify numbers on detail pages\n3. Encoding safety: use file_read not PS cat/type; read before modify; import memory modules directly (already in PATH, no fake prefixes)\n4. Close the loop: confirm after physical simulation; request intervention after 3 failures; complete Git workflow\n5. Processes: never kill python unconditionally (kills self), use exact PID, no os.kill for liveness check\n6. Windows: prefer win32gui title enumeration for GUI state\n7. Web JS: use native setter + event chain for input, check disabled before click, mind quote escaping; if scan empty/incomplete wait then rescan, no conclusions from first scan\n8. SOP: read SOPs not from memory, must use utils if available | complex long-running/user mentions planning -> read plan_sop\n"
  },
  {
    "path": "assets/insight_fixed_structure.txt",
    "content": "Facts(L2): ../memory/global_mem.txt | GA CodeRoot: ../ | SOPs(L3): ../memory/*.md or *.py | META-SOP(L0): ../memory/memory_management_sop.md\nL1 Insight是极简索引，L2/L3变更时同步L1，索引必须极简。写记忆前先读META-SOP(L0)。\n\n[CONSTITUTION]\n1. 改自身源码先请示；./内可自主实验，允许装包和portable工具\n2. 决策前查记忆，有SOP/utils必用；多次失败回看SOP；未查证不断言\n3. 分步执行，控制粒度，限制失败半径；3次失败请求干预\n4. 密钥文件仅引用，不读取/移动\n5. 写任何记忆前读META-SOP核验，memory下文件只能patch修改（除非新建）\n"
  },
  {
    "path": "assets/insight_fixed_structure_en.txt",
    "content": "Facts(L2): ../memory/global_mem.txt | CodeRoot: ../ | SOPs(L3): ../memory/*.md or *.py | META-SOP(L0): ../memory/memory_management_sop.md\nL1 Insight is a minimal index; sync L1 when L2/L3 changes; keep index minimal. Read META-SOP(L0) before writing any memory.\n\n[CONSTITUTION]\n1. Ask before modifying own source code; free to experiment within ./; installing packages and portable tools allowed\n2. Check memory before decisions; always use existing SOPs/utils; revisit SOPs on repeated failures; never assert without evidence\n3. Execute step by step, control granularity, limit blast radius; request intervention after 3 failures\n4. Key/secret files: reference only, never read or move\n5. Read META-SOP to verify before writing any memory; files under memory/ must be patched only (unless creating new)"
  },
  {
    "path": "assets/install-macos-app.sh",
    "content": "#!/bin/bash\n\n# GenericAgent macOS Desktop App Installation Script\n#\n# Usage:\n#   bash assets/install-macos-app.sh [--auto]\n#\n# This installer creates a small .app bundle that opens Terminal and runs\n# `python3 launch.pyw` from the current GenericAgent checkout.\n\nif [ -z \"${BASH_VERSION}\" ]; then\n    if command -v bash >/dev/null 2>&1; then\n        exec bash -- \"${0}\" \"$@\"\n    else\n        echo \"Error: This script requires bash.\"\n        exit 1\n    fi\nfi\n\nset -euo pipefail\n\nRED='\\033[0;31m'; GREEN='\\033[0;32m'; YELLOW='\\033[1;33m'; BLUE='\\033[0;34m'; CYAN='\\033[0;36m'; NC='\\033[0m'\nlog_info()    { echo -e \"${BLUE}ℹ️  $1${NC}\"; }\nlog_success() { echo -e \"${GREEN}✅ $1${NC}\"; }\nlog_warning() { echo -e \"${YELLOW}⚠️  $1${NC}\"; }\nlog_error()   { echo -e \"${RED}❌ $1${NC}\"; }\n\nAUTO_MODE=false\nfor arg in \"$@\"; do\n    case \"$arg\" in\n        --auto) AUTO_MODE=true ;;\n    esac\ndone\n\nAPP_NAME=\"GenericAgent\"\nPRIMARY_INSTALL_DIR=\"/Applications\"\nFALLBACK_INSTALL_DIR=\"${HOME}/Applications\"\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROJECT_ROOT=\"$(cd \"${SCRIPT_DIR}/..\" && pwd)\"\nICON_PATH=\"${PROJECT_ROOT}/assets/images/logo.jpg\"\nLAUNCH_SCRIPT=\"${PROJECT_ROOT}/launch.pyw\"\n\necho -e \"${CYAN}\"\necho \"╔═══════════════════════════════════════════════════════════╗\"\necho \"║   GenericAgent — macOS Desktop App Installer             ║\"\necho \"╚═══════════════════════════════════════════════════════════╝\"\necho -e \"${NC}\"\n\nif [[ \"$(uname)\" != \"Darwin\" ]]; then\n    log_error \"This script only supports macOS.\"\n    exit 1\nfi\n\nif ! command -v python3 >/dev/null 2>&1; then\n    log_error \"python3 is not installed.\"\n    exit 1\nfi\n\nif [ ! -f \"${LAUNCH_SCRIPT}\" ]; then\n    log_error \"launch.pyw not found at ${LAUNCH_SCRIPT}\"\n    exit 1\nfi\n\nproject_path_for_applescript=\"${PROJECT_ROOT}/\"\nproject_path_for_applescript=\"${project_path_for_applescript//\\\\/\\\\\\\\}\"\nproject_path_for_applescript=\"${project_path_for_applescript//\\\"/\\\\\\\"}\"\n\ndetect_existing_app() {\n    if [ -d \"${PRIMARY_INSTALL_DIR}/${APP_NAME}.app\" ]; then\n        echo \"${PRIMARY_INSTALL_DIR}/${APP_NAME}.app\"\n        return\n    fi\n    if [ -d \"${FALLBACK_INSTALL_DIR}/${APP_NAME}.app\" ]; then\n        echo \"${FALLBACK_INSTALL_DIR}/${APP_NAME}.app\"\n        return\n    fi\n}\n\nexisting_app_path=\"$(detect_existing_app || true)\"\nif [ -n \"${existing_app_path}\" ]; then\n    log_warning \"${APP_NAME}.app already exists at ${existing_app_path}\"\nfi\n\nif [ \"${AUTO_MODE}\" = false ]; then\n    echo \"\"\n    echo \"This will install a desktop app that launches GenericAgent\"\n    echo \"from Spotlight, Launchpad, or the Applications folder.\"\n    echo \"\"\n    if [ -n \"${existing_app_path}\" ]; then\n        read -p \"Reinstall ${APP_NAME}.app? (y/N) \" -n 1 -r\n    else\n        read -p \"Continue? (Y/n) \" -n 1 -r\n    fi\n    echo\n    if [ -n \"${existing_app_path}\" ]; then\n        [[ ! ${REPLY:-} =~ ^[Yy]$ ]] && { echo \"Aborted.\"; exit 0; }\n    else\n        [[ ${REPLY:-} =~ ^[Nn]$ ]] && { echo \"Aborted.\"; exit 0; }\n    fi\nfi\n\nTMP_DIR=\"$(mktemp -d)\"\ntrap 'rm -rf \"${TMP_DIR}\"' EXIT\n\nlog_info \"Building ${APP_NAME}.app...\"\n\ncat > \"${TMP_DIR}/${APP_NAME}.applescript\" <<APPLESCRIPT\non run\n    set projectPathStr to \"${project_path_for_applescript}\"\n    tell application \"Terminal\"\n        activate\n        do script \"cd \" & quoted form of projectPathStr & \" && python3 launch.pyw\"\n    end tell\nend run\nAPPLESCRIPT\n\nosacompile -o \"${TMP_DIR}/${APP_NAME}.app\" \"${TMP_DIR}/${APP_NAME}.applescript\"\n\nlog_info \"Applying GenericAgent icon...\"\nif [ -f \"${ICON_PATH}\" ]; then\n    ICONSET_DIR=\"${TMP_DIR}/ga-icon.iconset\"\n    mkdir -p \"${ICONSET_DIR}\"\n\n    sips -z 16 16   \"${ICON_PATH}\" --out \"${ICONSET_DIR}/icon_16x16.png\"       >/dev/null 2>&1\n    sips -z 32 32   \"${ICON_PATH}\" --out \"${ICONSET_DIR}/icon_16x16@2x.png\"    >/dev/null 2>&1\n    sips -z 32 32   \"${ICON_PATH}\" --out \"${ICONSET_DIR}/icon_32x32.png\"       >/dev/null 2>&1\n    sips -z 64 64   \"${ICON_PATH}\" --out \"${ICONSET_DIR}/icon_32x32@2x.png\"    >/dev/null 2>&1\n    sips -z 128 128 \"${ICON_PATH}\" --out \"${ICONSET_DIR}/icon_128x128.png\"     >/dev/null 2>&1\n    sips -z 256 256 \"${ICON_PATH}\" --out \"${ICONSET_DIR}/icon_128x128@2x.png\"  >/dev/null 2>&1\n    sips -z 256 256 \"${ICON_PATH}\" --out \"${ICONSET_DIR}/icon_256x256.png\"     >/dev/null 2>&1\n    sips -z 512 512 \"${ICON_PATH}\" --out \"${ICONSET_DIR}/icon_256x256@2x.png\"  >/dev/null 2>&1\n    sips -z 512 512 \"${ICON_PATH}\" --out \"${ICONSET_DIR}/icon_512x512.png\"     >/dev/null 2>&1\n    cp \"${ICON_PATH}\" \"${ICONSET_DIR}/icon_512x512@2x.png\"\n\n    iconutil -c icns \"${ICONSET_DIR}\" -o \"${TMP_DIR}/ga-icon.icns\"\n    cp \"${TMP_DIR}/ga-icon.icns\" \"${TMP_DIR}/${APP_NAME}.app/Contents/Resources/applet.icns\"\n    log_success \"Icon applied from assets/images/logo.jpg\"\nelse\n    log_warning \"Logo not found at ${ICON_PATH}, using default icon.\"\nfi\n\ninstall_bundle() {\n    local install_dir=\"$1\"\n    local destination=\"${install_dir}/${APP_NAME}.app\"\n    mkdir -p \"${install_dir}\"\n    rm -rf \"${destination}\"\n    cp -R \"${TMP_DIR}/${APP_NAME}.app\" \"${destination}\"\n}\n\ninstall_path=\"\"\nif install_bundle \"${PRIMARY_INSTALL_DIR}\" 2>/dev/null; then\n    install_path=\"${PRIMARY_INSTALL_DIR}/${APP_NAME}.app\"\nelse\n    log_warning \"Could not write to ${PRIMARY_INSTALL_DIR}; falling back to ${FALLBACK_INSTALL_DIR}\"\n    install_bundle \"${FALLBACK_INSTALL_DIR}\"\n    install_path=\"${FALLBACK_INSTALL_DIR}/${APP_NAME}.app\"\nfi\n\nlog_success \"Installed to: ${install_path}\"\n\necho \"\"\necho -e \"${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}\"\necho -e \"${CYAN}║${NC}  ✨  ${APP_NAME} Desktop App installed successfully!          ${CYAN}║${NC}\"\necho -e \"${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}\"\necho \"\"\necho -e \"${BLUE}Launch methods:${NC}\"\necho \"  • Spotlight:  Cmd + Space → type '${APP_NAME}' → Enter\"\necho \"  • Launchpad:  Find the '${APP_NAME}' icon\"\necho \"  • Finder:     Open ${install_path}\"\necho \"\"\necho -e \"${BLUE}Runtime behavior:${NC}\"\necho \"  The app uses the current checkout path embedded at install time:\"\necho \"  ${PROJECT_ROOT}\"\necho \"  If you move the repo later, re-run this installer.\"\necho \"\"\necho -e \"${BLUE}Uninstall:${NC}\"\necho \"  rm -rf '${install_path}'\"\necho \"\"\n"
  },
  {
    "path": "assets/install_python_windows.bat",
    "content": "@echo off\nsetlocal enabledelayedexpansion\ntitle Python One-Click Installer\ncolor 0A\n\necho.\necho ========================================\necho    Python One-Click Installer (Windows)\necho ========================================\necho.\n\nnet session >nul 2>&1\nif %errorlevel% neq 0 (\n    echo [!] Administrator privileges required. Restarting with elevation...\n    powershell -Command \"Start-Process '%~f0' -Verb RunAs\"\n    exit /b\n)\n\necho [OK] Administrator privileges confirmed\necho.\n\npython --version >nul 2>&1\nif %errorlevel% equ 0 (\n    echo [OK] Python already installed:\n    python --version\n    echo.\n    choice /C YN /M \"Install latest version anyway? (Y=Yes / N=Exit)\"\n    if errorlevel 2 goto :end\n)\n\nset PYTHON_VERSION=3.12.9\nset MIRROR_URL=https://npmmirror.com/mirrors/python/3.12.9/python-3.12.9-amd64.exe\nset OFFICIAL_URL=https://www.python.org/ftp/python/3.12.9/python-3.12.9-amd64.exe\nset INSTALLER=%TEMP%\\python_installer.exe\n\necho [*] Preparing to download Python %PYTHON_VERSION%\necho [*] Trying mirror source first...\necho.\n\npowershell -NoProfile -Command \"[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; $ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '%MIRROR_URL%' -OutFile '%INSTALLER%' -UseBasicParsing\"\n\nif not exist \"%INSTALLER%\" goto :official\nfor %%A in (\"%INSTALLER%\") do if %%~zA lss 1000000 goto :official\necho [OK] Mirror download complete\ngoto :install\n\n:official\necho [!] Mirror failed, switching to official source...\npowershell -NoProfile -Command \"[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; $ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '%OFFICIAL_URL%' -OutFile '%INSTALLER%' -UseBasicParsing\"\n\nif not exist \"%INSTALLER%\" (\n    echo [x] Download failed. Please check your network connection and retry.\n    pause\n    goto :end\n)\nfor %%A in (\"%INSTALLER%\") do if %%~zA lss 1000000 (\n    echo [x] Downloaded file is incomplete. Please check your network and retry.\n    pause\n    goto :end\n)\necho [OK] Official source download complete\n\n:install\necho.\necho [*] Installing Python %PYTHON_VERSION% (this may take 2-5 minutes^)...\necho.\n\nstart /wait \"\" \"%INSTALLER%\" /passive InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1\n\nset INSTALL_CODE=%errorlevel%\ndel /f /q \"%INSTALLER%\" >nul 2>&1\n\nif %INSTALL_CODE% neq 0 (\n    echo [x] Installation failed with error code: %INSTALL_CODE%\n    pause\n    goto :end\n)\n\necho [+] Installation complete!\necho.\n\ntimeout /t 3 /nobreak >nul\n\nset \"PATH=C:\\Program Files\\Python312;C:\\Program Files\\Python312\\Scripts;%PATH%\"\n\npython --version >nul 2>&1\nif %errorlevel% equ 0 (\n    echo [OK] Python installed successfully:\n    python --version\n    echo.\n    echo [OK] pip version:\n    pip --version\n    echo.\n    echo [*] Configuring pip mirror (Tsinghua^)...\n    pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple\n    pip config set global.trusted-host pypi.tuna.tsinghua.edu.cn\n    echo.\n    echo [*] Installing requests...\n    pip install requests\n    echo.\n    echo ========================================\n    echo    All done! Open a new terminal to use\n    echo    python and pip commands.\n    echo ========================================\n) else (\n    echo [!] PATH not yet refreshed. Please close this window and open a new terminal.\n)\n\n:end\necho.\npause\n"
  },
  {
    "path": "assets/sys_prompt.txt",
    "content": "# Role: 物理级全能执行者\n你拥有文件读写、脚本执行、用户浏览器JS注入、系统级干预的物理操作权限。禁止推诿\"无法操作\"——不空想，用工具探测。\n## 行动原则\n调用工具前先推演：当前阶段、上步结果是否符合预期、下步策略，必须在回复文本中用<summary>输出极简总结。\n- 探测优先：失败时先充分获取信息（日志/状态/上下文），关键信息存入工作记忆，再决定重试或换方案。不可逆操作先询问用户。\n- 失败升级：1次→读错误理解原因，2次→探测环境状态，3次→深度分析后换方案或问用户。禁止无新信息的重复操作。\n"
  },
  {
    "path": "assets/sys_prompt_en.txt",
    "content": "# Role: Physical-Level Omnipotent Executor\nYou have full physical access: file I/O, script execution, browser JS injection, and system-level intervention. Never deflect with \"can't do it\" — don't speculate, use tools to probe.\nSummarize and reply in user's language or follow user's prompt.\n## Action Principles\nBefore each tool call, reason: current phase, whether the last result met expectations, and next strategy and <summary> in reply text of each turn.\n- Probe first: on failure, gather sufficient info (logs/status/context), store key findings in working memory, then decide to retry or pivot. Ask the user before irreversible operations.\n- Failure escalation: 1st fail → read error and understand cause; 2nd → probe environment state; 3rd → deep analysis then switch approach or ask user. Never repeat an action without new information."
  },
  {
    "path": "assets/tmwd_cdp_bridge/background.js",
    "content": "// background.js - Cookie + CDP Bridge\nchrome.runtime.onInstalled.addListener(() => {\n  console.log('CDP Bridge installed');\n  // Strip CSP headers to allow eval/inline scripts\n  chrome.declarativeNetRequest.updateDynamicRules({\n    removeRuleIds: [9999],\n    addRules: [{\n      id: 9999, priority: 1,\n      action: { type: 'modifyHeaders', responseHeaders: [\n        { header: 'content-security-policy', operation: 'remove' },\n        { header: 'content-security-policy-report-only', operation: 'remove' }\n      ]},\n      condition: { urlFilter: '*', resourceTypes: ['main_frame', 'sub_frame'] }\n    }]\n  });\n});\n\nasync function handleExtMessage(msg, sender) {\n  if (msg.cmd === 'cookies') return await handleCookies(msg, sender);\n  if (msg.cmd === 'cdp') return await handleCDP(msg, sender);\n  if (msg.cmd === 'batch') return await handleBatch(msg, sender);\n  if (msg.cmd === 'tabs') {\n    try {\n      if (msg.method === 'switch') {\n        const tab = await chrome.tabs.update(msg.tabId, { active: true });\n        await chrome.windows.update(tab.windowId, { focused: true });\n        return { ok: true };\n      } else {\n        const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));\n        const data = tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId }));\n        return { ok: true, data };\n      }\n    } catch (e) { return { ok: false, error: e.message }; }\n  }\n  if (msg.cmd === 'management') {\n    try {\n      if (msg.method === 'list') {\n        const all = await chrome.management.getAll();\n        return { ok: true, data: all.map(e => ({ id: e.id, name: e.name, enabled: e.enabled, type: e.type, version: e.version })) };\n      }\n      if (msg.method === 'reload') {\n        chrome.alarms.create('tmwd-self-reload', { when: Date.now() + 200 });\n        return { ok: true };\n      }\n      if (msg.method === 'disable') {\n        await chrome.management.setEnabled(msg.extId, false);\n        return { ok: true };\n      }\n      if (msg.method === 'enable') {\n        await chrome.management.setEnabled(msg.extId, true);\n        return { ok: true };\n      }\n      return { ok: false, error: 'Unknown method: ' + msg.method };\n    } catch (e) { return { ok: false, error: e.message }; }\n  }\n  if (msg.cmd === 'contentSettings') {\n    try {\n      const type = msg.type || 'automaticDownloads';\n      const setting = msg.setting || 'allow';\n      const pattern = msg.pattern || '<all_urls>';\n      await chrome.contentSettings[type].set({\n        primaryPattern: pattern,\n        setting: setting\n      });\n      return { ok: true };\n    } catch (e) { return { ok: false, error: e.message }; }\n  }\n  return { ok: false, error: 'Unknown cmd: ' + msg.cmd };\n}\n\nchrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {\n  handleExtMessage(msg, sender).then(sendResponse);\n  return true;\n});\n\nasync function handleCookies(msg, sender) {\n  try {\n    let url = msg.url || sender.tab?.url;\n    if (!url && msg.tabId) {\n      const tab = await chrome.tabs.get(msg.tabId);\n      url = tab.url;\n    }\n    const origin = url.match(/^https?:\\/\\/[^\\/]+/)[0];\n    const all = await chrome.cookies.getAll({ url });\n    const part = await chrome.cookies.getAll({ url, partitionKey: { topLevelSite: origin } }).catch(() => []);\n    const merged = [...all];\n    for (const c of part) {\n      if (!merged.some(x => x.name === c.name && x.domain === c.domain)) merged.push(c);\n    }\n    return { ok: true, data: merged };\n  } catch (e) {\n    return { ok: false, error: e.message };\n  }\n}\n\nasync function handleBatch(msg, sender) {\n  const R = [];\n  let attached = null;\n  const resolve$N = (params) => JSON.parse(JSON.stringify(params || {}).replace(/\"\\$(\\d+)\\.([^\"]+)\"/g,\n    (_, i, path) => { let v = R[+i]; for (const k of path.split('.')) v = v[k]; return JSON.stringify(v); }));\n  try {\n    for (const c of msg.commands) {\n      if (c.tabId === undefined && msg.tabId !== undefined) c.tabId = msg.tabId;\n      if (c.cmd === 'cookies') {\n        R.push(await handleCookies(c, sender));\n      } else if (c.cmd === 'tabs') {\n        const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));\n        R.push({ ok: true, data: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId })) });\n      } else if (c.cmd === 'cdp') {\n        const tabId = c.tabId || msg.tabId || sender.tab?.id;\n        if (attached !== tabId) {\n          if (attached) { await chrome.debugger.detach({ tabId: attached }); attached = null; }\n          await chrome.debugger.attach({ tabId }, '1.3');\n          attached = tabId;\n        }\n        R.push(await chrome.debugger.sendCommand({ tabId }, c.method, resolve$N(c.params)));\n      } else {\n        R.push({ ok: false, error: 'unknown cmd: ' + c.cmd });\n      }\n    }\n    if (attached) await chrome.debugger.detach({ tabId: attached });\n    return { ok: true, results: R };\n  } catch (e) {\n    if (attached) try { await chrome.debugger.detach({ tabId: attached }); } catch (_) {}\n    return { ok: false, error: e.message, results: R };\n  }\n}\n\nasync function handleCDP(msg, sender) {\n  const tabId = msg.tabId || sender.tab?.id;\n  if (!tabId) return { ok: false, error: 'no tabId' };\n  try {\n    await chrome.debugger.attach({ tabId }, '1.3');\n    const result = await chrome.debugger.sendCommand({ tabId }, msg.method, msg.params || {});\n    await chrome.debugger.detach({ tabId });\n    return { ok: true, data: result };\n  } catch (e) {\n    try { await chrome.debugger.detach({ tabId }); } catch (_) {}\n    return { ok: false, error: e.message };\n  }\n}\n// Filter out chrome:// and other internal tabs that can't be scripted\nconst isScriptable = url => url && /^https?:/.test(url);\n\n// --- Shared page/CDP script builder core ---\nfunction buildExecScript(code, errorHandler) {\n  return `(async () => {\n    function smartProcessResult(result) {\n      if (result === null || result === undefined || typeof result !== 'object') return result;\n      try { if (result.window === result && result.document) return '[Window: ' + (result.location?.href || 'about:blank') + ']'; } catch(_){}\n      if (typeof jQuery !== 'undefined' && result instanceof jQuery) {\n        const elements = []; for (let i = 0; i < result.length; i++) { if (result[i] && result[i].nodeType === 1) elements.push(result[i].outerHTML); } return elements;\n      }\n      if (result instanceof NodeList || result instanceof HTMLCollection) {\n        const elements = []; for (let i = 0; i < result.length; i++) { if (result[i] && result[i].nodeType === 1) elements.push(result[i].outerHTML); } return elements;\n      }\n      if (result.nodeType === 1) return result.outerHTML;\n      if (!Array.isArray(result) && typeof result === 'object' && 'length' in result && typeof result.length === 'number') {\n        const firstElement = result[0];\n        if (firstElement && firstElement.nodeType === 1) {\n          const elements = []; const length = Math.min(result.length, 100);\n          for (let i = 0; i < length; i++) { const elem = result[i]; if (elem && elem.nodeType === 1) elements.push(elem.outerHTML); } return elements;\n        }\n      }\n      try { return JSON.parse(JSON.stringify(result, function(key, value) { if (typeof value === 'object' && value !== null) { if (value.nodeType === 1) return value.outerHTML; if (value === window || value === document) return '[Object]'; try { if (value.window === value && value.document) return '[Window]'; } catch(_){} } return value; })); } catch (e) { return '[无法序列化: ' + e.message + ']'; }\n    }\n    try {\n      const jsCode = ${JSON.stringify(code)}.trim();\n      const lines = jsCode.split(/\\\\r?\\\\n/).filter(l => l.trim());\n      const lastLine = lines.length > 0 ? lines[lines.length - 1].trim() : '';\n      const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;\n      let r;\n      function _air(c) { const ls = c.split(/\\\\r?\\\\n/); let i = ls.length - 1; while (i >= 0 && !ls[i].trim()) i--; if (i < 0) return c; const t = ls[i].trim(); if (/^(return |return;|return$|let |const |var |if |if\\\\(|for |for\\\\(|while |while\\\\(|switch|try |throw |class |function |async |import |export |\\\\/\\\\/|})/.test(t)) return c; ls[i] = ls[i].match(/^(\\\\s*)/)[1] + 'return ' + t; return ls.join('\\\\n'); }\n      if (lastLine.startsWith('return')) {\n        r = await (new AsyncFunction(jsCode))();\n      } else {\n        try { r = eval(jsCode); if (r instanceof Promise) r = await r; } catch (e) {\n          if (e instanceof SyntaxError && (/return/i.test(e.message) || /await/i.test(e.message))) { r = await (new AsyncFunction(_air(jsCode)))(); } else throw e;\n        }\n      }\n      return { ok: true, data: smartProcessResult(r) };\n    } catch (e) {\n      ${errorHandler}\n    }\n  })()`;\n}\n\nfunction buildPageScript(code) {\n  return buildExecScript(code, `\n      const errMsg = e.message || String(e);\n      return { ok: false, error: { name: e.name || 'Error', message: errMsg, stack: e.stack || '' },\n        csp: errMsg.includes('Refused to evaluate') || errMsg.includes('unsafe-eval') || errMsg.includes('Content Security Policy') };\n  `);\n}\n\nfunction buildCdpScript(code) {\n  return buildExecScript(code, `\n      return { ok: false, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' } };\n  `);\n}\n\n// --- WebSocket Client for TMWebDriver ---\nlet ws = null;\nconst WS_URL = 'ws://127.0.0.1:18765';\n\nfunction scheduleProbe() {\n  // Use chrome.alarms to survive MV3 service worker suspension\n  chrome.alarms.create('tmwd-ws-probe', { delayInMinutes: 0.083 }); // ~5s\n}\n\nfunction scheduleKeepalive() {\n  // Keep SW alive while WS is connected (~25s, under 30s SW timeout)\n  chrome.alarms.create('tmwd-ws-keepalive', { delayInMinutes: 0.4 }); // ~24s\n}\n\nasync function isServerAlive() {\n  try {\n    const ctrl = new AbortController();\n    setTimeout(() => ctrl.abort(), 2000);\n    await fetch('http://127.0.0.1:18765', { signal: ctrl.signal });\n    return true; // Got HTTP response → port is listening\n  } catch (e) {\n    return false; // Network error (connection refused) or timeout → server not alive\n  }\n}\n\nchrome.alarms.onAlarm.addListener(async (alarm) => {\n  if (alarm.name === 'tmwd-self-reload') {\n    chrome.runtime.reload();\n    return;\n  }\n  if (alarm.name === 'tmwd-ws-keepalive') {\n    // Keepalive: ping to keep SW alive + detect dead connections\n    if (ws && ws.readyState === WebSocket.OPEN) {\n      try { ws.send('{\"type\":\"ping\"}'); } catch (_) {}\n      scheduleKeepalive();\n    } else {\n      // Connection lost, switch to probe mode\n      ws = null;\n      scheduleProbe();\n    }\n  }\n  if (alarm.name === 'tmwd-ws-probe') {\n    if (ws && ws.readyState <= 1) return; // Already connected/connecting\n    if (await isServerAlive()) {\n      console.log('[TMWD-WS] Server detected, connecting...');\n      connectWS();\n    } else {\n      scheduleProbe(); // Server not up, keep probing\n    }\n  }\n});\n\nasync function handleWsExec(data) {\n  const tabId = data.tabId;\n  console.log('[TMWD-WS] Exec request', data.id, 'on tab', tabId);\n  ws.send(JSON.stringify({ type: 'ack', id: data.id }));\n  if (!tabId) {\n    ws.send(JSON.stringify({ type: 'error', id: data.id, error: 'No tabId provided' }));\n    return;\n  }\n  // Use onCreated listener to reliably capture new tabs (avoids race condition with query-diff)\n  const newTabIds = new Set();\n  const onCreated = (tab) => { newTabIds.add(tab.id); };\n  chrome.tabs.onCreated.addListener(onCreated);\n  try {\n    let res;\n    try {\n      const result = await chrome.scripting.executeScript({\n        target: { tabId },\n        world: 'MAIN',\n        func: async (s) => await eval(s),\n        args: [buildPageScript(data.code)]\n      });\n      res = result[0]?.result;\n      if (res === null || res === undefined) {\n        console.log('[TMWD-WS] executeScript returned null/undefined, treating as CSP issue');\n        res = { ok: false, error: { name: 'Error', message: 'executeScript returned null (possible CSP or context issue)', stack: '' }, csp: true };\n      }\n    } catch (e) {\n      console.log('[TMWD-WS] scripting.executeScript failed:', e.message);\n      res = { ok: false, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' }, csp: true };\n    }\n    // CDP fallback for CSP-restricted pages\n    if (res && !res.ok && res.csp) {\n      console.log('[TMWD-WS] CDP fallback for tab', tabId);\n      const wrappedCode = buildCdpScript(data.code);\n      try {\n        await chrome.debugger.attach({ tabId }, '1.3');\n        const cdpRes = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {\n          expression: wrappedCode, awaitPromise: true, returnByValue: true\n        });\n        await chrome.debugger.detach({ tabId });\n        if (cdpRes.exceptionDetails) {\n          const desc = cdpRes.exceptionDetails.exception?.description || 'CDP Error';\n          res = { ok: false, error: { name: 'Error', message: desc, stack: desc } };\n        } else {\n          res = cdpRes.result.value;\n        }\n      } catch (cdpErr) {\n        try { await chrome.debugger.detach({ tabId }); } catch (_) {}\n        res = { ok: false, error: { name: 'Error', message: 'CDP fallback failed: ' + cdpErr.message, stack: '' } };\n      }\n    }\n    // Grace period for async tab creation (e.g. link click with target=_blank)\n    if (newTabIds.size === 0) await new Promise(r => setTimeout(r, 200));\n    chrome.tabs.onCreated.removeListener(onCreated);\n    // Get full info for captured new tabs\n    const newTabs = [];\n    for (const id of newTabIds) {\n      try { const t = await chrome.tabs.get(id); newTabs.push({id: t.id, url: t.url, title: t.title}); } catch (_) {}\n    }\n    if (res?.ok) {\n      ws.send(JSON.stringify({ type: 'result', id: data.id, result: res.data, newTabs }));\n    } else {\n      console.log(res);\n      ws.send(JSON.stringify({ type: 'error', id: data.id, error: res?.error || 'Unknown error', newTabs }));\n    }\n  } catch (e) {\n    ws.send(JSON.stringify({ type: 'error', id: data.id, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' } }));\n  } finally {\n    chrome.tabs.onCreated.removeListener(onCreated);\n  }\n}\n\nfunction connectWS() {\n  if (ws && ws.readyState <= 1) return; // CONNECTING or OPEN\n  ws = null;\n  console.log('[TMWD-WS] Connecting to', WS_URL);\n  try {\n    ws = new WebSocket(WS_URL);\n  } catch (e) {\n    console.error('[TMWD-WS] Constructor error:', e);\n    ws = null;\n    scheduleProbe();\n    return;\n  }\n  ws.onopen = async () => {\n    console.log('[TMWD-WS] Connected!');\n    scheduleKeepalive(); // Keep SW alive while connected\n    const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));\n    ws.send(JSON.stringify({\n      type: 'ext_ready',\n      tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title }))\n    }));\n    console.log('[TMWD-WS] Sent ext_ready with', tabs.length, 'tabs');\n  };\n  ws.onmessage = async (event) => {\n    try {\n      const data = JSON.parse(event.data);\n      if (data.id && data.code) {\n        let code = data.code;\n        // If code is a JSON string representing an object, parse it\n        if (typeof code === 'string') {\n          try { const p = JSON.parse(code); if (p && typeof p === 'object') code = p; } catch (_) {}\n        }\n        if (typeof code === 'object' && code !== null && code.cmd) {\n          // Custom protocol message → route to handleExtMessage\n          if (code.tabId === undefined && data.tabId !== undefined) code.tabId = data.tabId;\n          const res = await handleExtMessage(code, {});\n          ws.send(JSON.stringify({ type: res.ok ? 'result' : 'error', id: data.id, result: res.data ?? res.results ?? res, error: res.error }));\n        } else if (typeof code === 'string') {\n          // Plain JS code\n          await handleWsExec(data);\n        } else if (typeof code === 'object' && code !== null) {\n          // Object without cmd → legacy extension message\n          const msg = code.tabId === undefined && data.tabId !== undefined ? { ...code, tabId: data.tabId } : code;\n          const res = await handleExtMessage(msg, {});\n          ws.send(JSON.stringify({ type: res.ok ? 'result' : 'error', id: data.id, result: res.data ?? res.results ?? res, error: res.error }));\n        }\n      }\n    } catch (e) {\n      console.error('[TMWD-WS] message parse error', e);\n    }\n  };\n  ws.onclose = () => {\n    console.log('[TMWD-WS] Disconnected');\n    ws = null;\n    scheduleProbe();\n  };\n  ws.onerror = (e) => {\n    console.error('[TMWD-WS] Error:', e);\n    // onclose will fire after this, which triggers reconnect\n  };\n}\n\n// Initial connect + wake-up hooks\nconnectWS();\nchrome.runtime.onStartup.addListener(() => connectWS());\nchrome.runtime.onInstalled.addListener(() => connectWS());\n\n// Sync tab list on changes\nasync function sendTabsUpdate() {\n  if (!ws || ws.readyState !== WebSocket.OPEN) return;\n  const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url) && !/streamlit/i.test(t.title));\n  ws.send(JSON.stringify({\n    type: 'tabs_update',\n    tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title }))\n  }));\n}\nchrome.tabs.onUpdated.addListener((_, changeInfo) => {\n  if (changeInfo.status === 'complete') sendTabsUpdate();\n});\nchrome.tabs.onRemoved.addListener(() => sendTabsUpdate());\nchrome.tabs.onCreated.addListener(() => sendTabsUpdate());\n"
  },
  {
    "path": "assets/tmwd_cdp_bridge/content.js",
    "content": ";(function(){ if (/streamlit/i.test(document.title)) return;\n\n// Remove meta CSP tags\ndocument.querySelectorAll('meta[http-equiv=\"Content-Security-Policy\"]').forEach(e => e.remove());\n\n// Indicator badge at bottom-right (userscript style)\n(function(){\n  if(window.self!==window.top)return;\n  const d=document.createElement('div');\n  d.id='ljq-ind';\n  d.innerText='ljq_driver: 已连接';\n  d.style.cssText='position:fixed;bottom:8px;right:8px;background:#4CAF50;color:white;padding:4px 7px;border-radius:4px;font-size:11px;font-weight:bold;z-index:99999;cursor:pointer;box-shadow:0 2px 4px rgba(0,0,0,0.2);opacity:0.5;';\n  d.addEventListener('click',()=>alert('会话活跃\\nURL: '+location.href));\n  (document.body||document.documentElement).appendChild(d);\n})();\n\nnew MutationObserver(muts => {\n  for (const m of muts) for (const n of m.addedNodes) {\n    if (n.id === TID || (n.querySelector && n.querySelector('#' + TID))) {\n      const el = n.id === TID ? n : n.querySelector('#' + TID);\n      handle(el);\n    }\n  }\n}).observe(document.documentElement, { childList: true, subtree: true });\n\nasync function handle(el) {\n  try {\n    const req = el.textContent.trim() ? JSON.parse(el.textContent) : { cmd: 'cookies' };\n    const cmd = req.cmd || 'cookies';\n    let resp;\n    if (cmd === 'cookies') {\n      resp = await chrome.runtime.sendMessage({ cmd: 'cookies', url: req.url || location.href });\n    } else if (cmd === 'cdp') {\n      resp = await chrome.runtime.sendMessage({ cmd: 'cdp', method: req.method, params: req.params || {}, tabId: req.tabId });\n    } else if (cmd === 'batch') {\n      resp = await chrome.runtime.sendMessage({ cmd: 'batch', commands: req.commands, tabId: req.tabId });\n    } else if (cmd === 'tabs') {\n      resp = await chrome.runtime.sendMessage({ cmd: 'tabs', method: req.method, tabId: req.tabId });\n    } else {\n      resp = { ok: false, error: 'unknown cmd: ' + cmd };\n    }\n    el.textContent = JSON.stringify(resp);\n  } catch (e) {\n    el.textContent = JSON.stringify({ ok: false, error: e.message });\n  }\n}\n})();"
  },
  {
    "path": "assets/tmwd_cdp_bridge/disable_dialogs.js",
    "content": "// Disable alert/confirm/prompt to prevent page JS from blocking extension\n(function() {\n  const _log = console.log.bind(console);\n  function toast(type, msg) {\n    _log('[TMWD] ' + type + ' suppressed:', msg);\n    try {\n      const d = document.createElement('div');\n      d.textContent = '[' + type + '] ' + msg;\n      Object.assign(d.style, {\n        position:'fixed', top:'12px', right:'12px', zIndex:'2147483647',\n        background:'#222', color:'#fff', padding:'10px 18px', borderRadius:'8px',\n        fontSize:'14px', maxWidth:'420px', wordBreak:'break-all',\n        boxShadow:'0 4px 16px rgba(0,0,0,.3)', opacity:'1',\n        transition:'opacity .5s', pointerEvents:'none'\n      });\n      (document.body || document.documentElement).appendChild(d);\n      setTimeout(() => { d.style.opacity = '0'; }, 3000);\n      setTimeout(() => { d.remove(); }, 3600);\n    } catch(e) {}\n  }\n  window.alert = function(msg) { toast('alert', msg); };\n  window.confirm = function(msg) { toast('confirm', msg); return true; };\n  window.prompt = function(msg, def) { toast('prompt', msg); return def || null; };\n})();"
  },
  {
    "path": "assets/tmwd_cdp_bridge/manifest.json",
    "content": "{\n  \"manifest_version\": 3,\n  \"name\": \"TMWD CDP Bridge\",\n  \"version\": \"2.0\",\n  \"description\": \"Cookie viewer + CDP bridge\",\n  \"permissions\": [\n    \"cookies\",\n    \"tabs\",\n    \"activeTab\",\n    \"debugger\",\n    \"scripting\",\n    \"alarms\",\n    \"declarativeNetRequest\",\n    \"management\",\n    \"contentSettings\"\n  ],\n  \"host_permissions\": [\"<all_urls>\"],\n  \"background\": {\n    \"service_worker\": \"background.js\"\n  },\n  \"content_scripts\": [\n    {\n      \"matches\": [\"<all_urls>\"],\n      \"js\": [\"disable_dialogs.js\"],\n      \"run_at\": \"document_start\",\n      \"all_frames\": true,\n      \"world\": \"MAIN\"\n    },\n    {\n      \"matches\": [\"<all_urls>\"],\n      \"js\": [\"config.js\", \"content.js\"],\n      \"run_at\": \"document_idle\",\n      \"all_frames\": true\n    }\n  ],\n  \"action\": {\n    \"default_popup\": \"popup.html\",\n    \"default_title\": \"TMWD CDP Bridge\"\n  }\n}"
  },
  {
    "path": "assets/tmwd_cdp_bridge/popup.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<style>\nbody{width:420px;max-height:500px;margin:0;padding:8px;font:12px monospace;background:#1e1e1e;color:#d4d4d4;overflow-y:auto}\nh3{margin:4px 0;color:#569cd6}\nbutton{background:#264f78;color:#fff;border:none;padding:4px 12px;cursor:pointer;border-radius:3px;margin-bottom:6px}\nbutton:hover{background:#37699e}\npre{white-space:pre-wrap;word-break:break-all;margin:0;padding:6px;background:#252526;border-radius:3px;max-height:420px;overflow-y:auto}\n</style>\n</head>\n<body>\n<h3>🍪 Cookies</h3>\n<button id=\"refresh\">刷新</button>\n<pre id=\"out\">点击刷新获取 cookies...</pre>\n<script src=\"popup.js\"></script>\n</body>\n</html>"
  },
  {
    "path": "assets/tmwd_cdp_bridge/popup.js",
    "content": "document.addEventListener('DOMContentLoaded', () => {\n  const out = document.getElementById('out');\n  const btn = document.getElementById('refresh');\n  btn.addEventListener('click', fetchCookies);\n  fetchCookies();\n});\n\nasync function fetchCookies() {\n  const out = document.getElementById('out');\n  try {\n    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n    if (!tab?.url) { out.textContent = 'No active tab'; return; }\n    const resp = await chrome.runtime.sendMessage({ cmd: 'cookies', url: tab.url });\n    if (!resp?.ok) { out.textContent = 'Error: ' + (resp?.error || 'unknown'); return; }\n    if (!resp.data.length) { out.textContent = '(no cookies)'; return; }\n    // 展示带标记\n    out.textContent = resp.data.map(c =>\n      `${c.name}=${c.value}` + (c.httpOnly ? ' [H]' : '') + (c.secure ? ' [S]' : '') + (c.partitionKey ? ' [P]' : '')\n    ).join('\\n');\n    // 自动复制 name=value; 格式到剪贴板\n    const str = resp.data.map(c => `${c.name}=${c.value}`).join('; ');\n    await navigator.clipboard.writeText(str);\n  } catch (e) { out.textContent = 'Error: ' + e.message; }\n}"
  },
  {
    "path": "assets/tool_usable_history.json",
    "content": "[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"code_run个helloworld，根据结果简单评价你的工具配置\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"\\n\\n<summary>用户要求code_run helloworld并评价工具配置</summary>\\n\\n```python\\nprint(\\\"Hello, World!\\\")\\n```\"},{\"type\":\"tool_use\",\"id\":\"tooluse_GlmTNw190WGgK4sBBOiDHK\",\"name\":\"code_run\",\"input\":{\"script\":\"print(\\\"Hello, World!\\\")\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"tooluse_GlmTNw190WGgK4sBBOiDHK\",\"content\":\"{\\\"status\\\": \\\"success\\\", \\\"stdout\\\": \\\"Hello, World!\\\\r\\\\n\\\", \\\"exit_code\\\": 0}\"},{\"type\":\"text\",\"text\":\"\\n### [WORKING MEMORY]\\n<history>\\n\\n[USER]: code_run个helloworld，根据结果简单评价你的工具配置\\n</history>\\nCurrent turn: 1\\n\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"\\n\\n<summary>code_run helloworld成功，评价工具配置</summary>\\n\\n工具配置没问题。`code_run` 返回了结构化的 JSON（`status`、`stdout`、`exit_code`），执行链路通畅，cwd 默认落在 temp 目录。\\n\\n刚才还验证了 `inline_eval` 能正确访问 agent 内部状态并导出 history，`file_patch` 也能精准改文件。整套工具链从代码执行、文件读写、到 agent 内省都是通的。\"}]}]"
  },
  {
    "path": "assets/tools_schema.json",
    "content": "[\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"code_run\",\n    \"description\": \"Code executor. Prefer python. Multi-call OK, use script param. Reply code block is executed if no script arg; prefer for single call to avoid escaping. No hardcoding bulk data\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"script\": {\"type\": \"string\", \"description\": \"[Mutually exclusive] NEVER use this param when use reply code block.\"},\n      \"type\": {\"type\": \"string\", \"enum\": [\"python\", \"powershell\"], \"description\": \"Code type\", \"default\": \"python\"},\n      \"timeout\": {\"type\": \"integer\", \"description\": \"in seconds\", \"default\": 60},\n      \"cwd\": {\"type\": \"string\", \"description\": \"Working directory, defaults to cwd\"},\n      \"inline_eval\": {\"type\": \"boolean\", \"description\": \"DO NOT USE except explicitly specified.\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"file_read\",\n    \"description\": \"Read file. Read before modify for latest context and line numbers\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"path\": {\"type\": \"string\", \"description\": \"Relative or absolute\"},\n      \"start\": {\"type\": \"integer\", \"description\": \"Start line number (1-based)\"},\n      \"count\": {\"type\": \"integer\", \"description\": \"Number of lines to read\", \"default\": 200},\n      \"keyword\": {\"type\": \"string\", \"description\": \"[Optional] If provided, returns first match (case-insensitive) with context\"},\n      \"show_linenos\": {\"type\": \"boolean\", \"description\": \"Show line numbers\", \"default\": true}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"file_patch\",\n    \"description\": \"Replace unique old_content with new_content. Exact match required (whitespace/indentation). On failure, file_read to recheck\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"path\": {\"type\": \"string\", \"description\": \"File path\"},\n      \"old_content\": {\"type\": \"string\", \"description\": \"Original text block to replace (must be unique)\"},\n      \"new_content\": {\"type\": \"string\", \"description\": \"New content. Supports {{file:path:startLine:endLine}} to ref file lines, auto-expanded\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"file_write\",\n    \"description\": \"Create/overwrite/append files. HUGE edits ONLY. Supports {{file:path:startLine:endLine}}, auto-expanded\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"path\": {\"type\": \"string\", \"description\": \"File path\"},\n      \"content\": {\"type\": \"string\"},\t\n      \"mode\": {\"type\": \"string\", \"enum\": [\"overwrite\", \"append\", \"prepend\"], \"description\": \"Write mode\", \"default\": \"overwrite\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"web_scan\",\n    \"description\": \"Get simplified HTML and tab list. Removes hidden/floating/covered elements. Call after switching pages\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"tabs_only\": {\"type\": \"boolean\", \"description\": \"Show tab list only, no HTML\"},\n      \"switch_tab_id\": {\"type\": \"string\", \"description\": \"[Optional] Tab ID to switch to\"},\n      \"text_only\": {\"type\": \"boolean\", \"description\": \"Plain text only, no HTML\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"web_execute_js\",\n    \"description\": \"Execute JS. Multi-call OK with different switch_tab_id. No guessing. Act accurately to reduce web_scan calls. Execute JS in ```javascript blocks if no script arg, prefer to avoid escaping\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"script\": {\"type\": \"string\", \"description\": \"[Mutually exclusive] JS code or script path. NEVER use this param when use reply code block\"},\n      \"save_to_file\": {\"type\": \"string\", \"description\": \"file path; **only** for long result\"},\n      \"no_monitor\": {\"type\": \"boolean\", \"description\": \"Skip page change monitoring, saves 2-3s. Only for reads, not for page actions\"},\n      \"switch_tab_id\": {\"type\": \"string\", \"description\": \"[Optional] Tab ID to switch to before executing\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"update_working_checkpoint\",\n    \"description\": \"Short-term working notepad, auto-injected each turn to prevent info loss in long tasks. Call during early/mid stages, not at end. When: (1) after reading SOP, store user needs & key constraints (skip for simple 1-2 step tasks); (2) before subtask switch or context flush; (3) after repeated failures, re-read SOP and must store new findings; (4) on new task, update content, clear old progress but keep valid constraints.\\n\\nDon't call: simple tasks (1-2 steps), task completed (use long-term memory tool)\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"key_info\": {\"type\": \"string\", \"description\": \"Replaces current notepad (<200 tokens). Incremental update: review existing, keep valid, add/remove/modify. Store: pitfalls, user requirements, key params/findings, file paths, progress, next steps. Don't store: ephemeral info, obvious context, old task info when user switched tasks. Prefer over-updating over losing key info\"},\n      \"related_sop\": {\"type\": \"string\", \"description\": \"Related SOP names, tips for further re-read\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"ask_user\",\n    \"description\": \"Interrupt task to ask user when needing decisions, extra info, or facing unresolvable blockers\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"question\": {\"type\": \"string\", \"description\": \"Question for the user\"},\n      \"candidates\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"Optional quick-select choices for the user\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"start_long_term_update\",\n    \"description\": \"Start distilling long-term memory. Call when discovering info worth remembering (env facts/user prefs/lessons learned). Skip if memory already updated or in autonomous flow. Must call when a task that took 15+ turns is completed\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {}}}\n  }\n]"
  },
  {
    "path": "assets/tools_schema_cn.json",
    "content": "[\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"code_run\",\n    \"description\": \"代码执行器。优先使用python。支持Multi-call，并行时用script参数。无script参数时正文代码块会被执行，单次调用优先使用以免转义。禁硬编码大量数据\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"script\": {\"type\": \"string\", \"description\": \"[Optional] 要执行的代码。为免转义建议留空，改用正文代码块（与此参数互斥）\"},\n      \"type\": {\"type\": \"string\", \"enum\": [\"python\", \"powershell\"], \"description\": \"代码类型\", \"default\": \"python\"},\n      \"timeout\": {\"type\": \"integer\", \"description\": \"执行超时时间（秒）\", \"default\": 60},\n      \"cwd\": {\"type\": \"string\", \"description\": \"工作目录，默认为当前工作目录\"},\n      \"inline_eval\": {\"type\": \"boolean\", \"description\": \"不允许使用除非明确要求\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"file_read\",\n    \"description\": \"读取文件内容。建议在修改文件前先读取，以确保获取最新的上下文和行号。支持分页读取或关键字搜索\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"path\": {\"type\": \"string\", \"description\": \"文件相对或绝对路径\"},\n      \"start\": {\"type\": \"integer\", \"description\": \"起始行号（从 1 开始）\"},\n      \"count\": {\"type\": \"integer\", \"description\": \"读取的行数\", \"default\": 200},\n      \"keyword\": {\"type\": \"string\", \"description\": \"可选搜索关键字。如果提供，将返回第一个匹配项（忽略大小写）及其周边的内容\"},\n      \"show_linenos\": {\"type\": \"boolean\", \"description\": \"是否显示行号，建议开启以辅助 file_patch 定位\", \"default\": true}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"file_patch\",\n    \"description\": \"精细化局部文件修改。在文件中寻找唯一的 old_content 块并替换为 new_content。要求 old_content 必须在文件中唯一存在，且空格、缩进、换行必须与原文件完全一致。如果匹配失败，请使用 file_read 重新确认文件内容\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"path\": {\"type\": \"string\", \"description\": \"文件路径\"},\n      \"old_content\": {\"type\": \"string\", \"description\": \"文件中需要被替换的原始文本块（需确保唯一性）\"},\n      \"new_content\": {\"type\": \"string\", \"description\": \"替换后的新文本内容。支持 {{file:路径:起始行:结束行}} 语法引用文件内容，写入前自动展开\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"file_write\",\n    \"description\": \"用于文件的新建、全量覆盖或追加写入。对于精细的代码修改，应优先使用 file_patch。写入内容支持 {{file:路径:起始行:结束行}} 语法引用文件片段，写入前自动展开\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"path\": {\"type\": \"string\", \"description\": \"文件路径\"},\n      \"content\": {\"type\": \"string\"},\n      \"mode\": {\"type\": \"string\", \"enum\": [\"overwrite\", \"append\", \"prepend\"], \"description\": \"写入模式覆盖、追加或在开头追加\", \"default\": \"overwrite\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"web_scan\",\n    \"description\": \"获取当前页面的简化HTML内容和标签页列表。会移除隐藏/浮动/被遮盖的元素。切换页面后一般应先调用查看\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"tabs_only\": {\"type\": \"boolean\", \"description\": \"仅返回标签页列表和当前标签信息，不获取HTML内容\"},\n      \"switch_tab_id\": {\"type\": \"string\", \"description\": \"可选的标签页 ID。如果提供，系统将在扫描前切换到该标签页\"},\n      \"text_only\": {\"type\": \"boolean\", \"description\": \"只要纯文本不要HTML\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"web_execute_js\",\n    \"description\": \"执行JS。支持Multi-call，用不同switch_tab_id并行操作多标签页。禁止猜测，准确操作以减少 web_scan 调用。无script参数时执行正文 ```javascript 块，以免转义\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"script\": {\"type\": \"string\", \"description\": \"[Optional] JS代码或路径。为免转义建议留空，改用正文代码块（与此参数互斥）\"},\n      \"save_to_file\": {\"type\": \"string\", \"description\": \"结果存文件，适合返回值较长时\"},\n      \"no_monitor\": {\"type\": \"boolean\", \"description\": \"跳过页面变更监控，省2-3秒。仅在纯读取信息时设置，页面操作时不要设置\"},\n      \"switch_tab_id\": {\"type\": \"string\", \"description\": \"可选的标签页 ID，切换到该标签页执行\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"update_working_checkpoint\",\n    \"description\": \"短期工作便签，每轮自动注入上下文，防长任务信息丢失。前中期调用，非结束时。何时调用：(1)任务开始读SOP后，存用户需求和关键约束/参数（简单1-2步任务除外）；(2)子任务切换或上下文即将被冲刷前；(3)多次重试失败后，重读SOP并必须调用存储新发现；(4)切换新任务时更新内容，清旧进度但保留仍有效的约束。\\n\\n何时不调用：简单任务（1-2步且无严重约束）、任务已完成时（应当用长期结算工具）\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"key_info\": {\"type\": \"string\", \"description\": \"替换当前便签（<200 tokens）。增量更新：先回顾现有内容，保留仍有效的，再增删改。存：要避的坑、用户原始需求、关键参数/发现、文件路径、当前进度、下一步计划。不存：马上要用用完即丢的、上下文中显而易见的、用户已换全新任务时的旧任务信息。宁多更新不丢关键\"},\n      \"related_sop\": {\"type\": \"string\", \"description\": \"相关sop名称，可以多个，必要时需要再读\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"ask_user\",\n    \"description\": \"当需要用户决策、提供额外信息或遇到无法自动解决的阻碍时，调用此工具中断任务并提问\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {\n      \"question\": {\"type\": \"string\", \"description\": \"向用户提出的明确问题\"},\n      \"candidates\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"description\": \"提供给用户的可选快捷选项列表\"}}}\n  }},\n  {\"type\": \"function\", \"function\": {\n    \"name\": \"start_long_term_update\",\n    \"description\": \"准备开始提炼记忆。发现值得长期记忆的信息（环境事实/用户偏好/避坑经验）时调用此工具。已记忆更新或在自主流程内时无需调用。超15轮完成的任务必须调用以沉淀经验\",\n    \"parameters\": {\"type\": \"object\", \"properties\": {}}}\n  }\n]"
  },
  {
    "path": "frontends/DESKTOP_PET_README.md",
    "content": "# Desktop Pet Skin System\n\n## 快速开始\n\n运行桌面宠物：\n```bash\npython3 desktop_pet_v2.pyw\n```\n\n## 功能特性\n\n### 1. 多皮肤支持\n- 自动发现 `skins/` 目录下的所有皮肤\n- 右键菜单切换皮肤\n- 支持 sprite sheet 和 GIF 两种格式\n\n### 2. 多动画状态\n- **idle** - 待机动画\n- **walk** - 行走动画\n- **run** - 跑步动画\n- **sprint** - 冲刺动画\n\n右键菜单可切换动画状态\n\n### 3. 交互功能\n- **单击** - 拖动宠物\n- **双击** - 关闭程序\n- **右键** - 打开菜单（切换皮肤/动画）\n\n### 4. HTTP 远程控制\n```bash\n# 显示消息\ncurl \"http://127.0.0.1:51983/?msg=Hello\"\n\n# 切换动画状态\ncurl \"http://127.0.0.1:51983/?state=run\"\n\n# POST 消息\ncurl -X POST -d \"任务完成\" http://127.0.0.1:51983/\n```\n\n## 添加新皮肤\n\n### 目录结构\n```\nskins/\n└── your-skin-name/\n    ├── skin.json       # 配置文件（必需）\n    ├── idle.png        # 动画资源\n    ├── walk.png\n    ├── run.png\n    └── sprint.png\n```\n\n### skin.json 配置示例\n\n#### Sprite Sheet 格式（推荐）\n```json\n{\n  \"name\": \"My Pet\",\n  \"version\": \"1.0.0\",\n  \"author\": \"Your Name\",\n  \"description\": \"描述\",\n  \"format\": \"sprite\",\n  \"animations\": {\n    \"idle\": {\n      \"file\": \"idle.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 44,\n        \"frameHeight\": 31,\n        \"frameCount\": 6,\n        \"columns\": 6,\n        \"fps\": 6,\n        \"startFrame\": 0\n      }\n    },\n    \"walk\": {\n      \"file\": \"walk.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 65,\n        \"frameHeight\": 32,\n        \"frameCount\": 8,\n        \"columns\": 8,\n        \"fps\": 8,\n        \"startFrame\": 0\n      }\n    }\n  }\n}\n```\n\n#### GIF 格式\n```json\n{\n  \"name\": \"My Pet\",\n  \"format\": \"gif\",\n  \"animations\": {\n    \"idle\": {\n      \"file\": \"idle.gif\",\n      \"loop\": true\n    },\n    \"walk\": {\n      \"file\": \"walk.gif\",\n      \"loop\": true\n    }\n  }\n}\n```\n\n### 配置说明\n\n- **frameWidth/frameHeight**: 单帧尺寸（像素）\n- **frameCount**: 帧数\n- **columns**: sprite sheet 的列数\n- **fps**: 播放帧率\n- **startFrame**: 起始帧索引（从 0 开始）\n\n### Sprite Sheet 布局\n\n```\n+-------+-------+-------+-------+\n| 帧0   | 帧1   | 帧2   | 帧3   |  ← 第一行\n+-------+-------+-------+-------+\n| 帧4   | 帧5   | 帧6   | 帧7   |  ← 第二行\n+-------+-------+-------+-------+\n```\n\n如果 `columns=4, startFrame=2, frameCount=3`，则读取：帧2, 帧3, 帧4\n\n## 已包含的皮肤\n\n1. **Glube** - 像素风小怪兽（多文件 sprite）\n2. **Vita** - 像素风小恐龙（单文件 sprite）\n3. **Doux** - 像素风小恐龙（单文件 sprite）\n\n## 从 ai-bubu 导入更多皮肤\n\nai-bubu 项目包含更多皮肤资源，可以直接复制：\n\n```bash\n# 复制皮肤\ncp -r ai-bubu-main/packages/app/public/skins/boy frontends/skins/\ncp -r ai-bubu-main/packages/app/public/skins/dinosaur frontends/skins/\ncp -r ai-bubu-main/packages/app/public/skins/line frontends/skins/\ncp -r ai-bubu-main/packages/app/public/skins/mort frontends/skins/\ncp -r ai-bubu-main/packages/app/public/skins/tard frontends/skins/\n```\n\n## 与 stapp.py 集成\n\n在 `stapp.py` 中点击\"🐱 桌面宠物\"按钮会自动启动桌面宠物，并在每个 turn 结束时发送通知。\n\n## 故障排查\n\n### 皮肤不显示\n1. 检查 `skin.json` 格式是否正确\n2. 确认图片文件存在\n3. 检查 sprite 配置参数是否匹配图片尺寸\n\n### 动画不流畅\n- 调整 `fps` 参数\n- 检查帧数是否正确\n\n### 透明背景问题\n- 确保 PNG 文件包含 alpha 通道\n- 使用 RGBA 模式的图片\n\n## 技术细节\n\n- 基于 Tkinter + PIL/Pillow\n- 支持透明背景（#01FF01 色键）\n- 窗口置顶、无边框\n- HTTP 服务器端口：51983\n"
  },
  {
    "path": "frontends/btw_cmd.py",
    "content": "\"\"\"`/btw` 命令：side question — 不打断主 Agent 的临时 subagent 问答。\n\n- 持锁 deepcopy backend.history → 后台线程 backend.raw_ask 单次拉答\n- 主 agent backend.history 零写入；不入 task_queue\n- 答案 → display_queue 'done'（install 路径）或同步 return（frontend 路径）\n\n复用 backend.raw_ask + make_messages，不新建 LLM 实例。\n\"\"\"\nfrom __future__ import annotations\nimport copy, os, threading, time\nfrom typing import Optional\n\n\n_WRAPPER_ZH = \"\"\"<system-reminder>\n这是用户的临时插问 (side question)。主 agent 仍在后台运行，**不会被打断**。\n\n身份与边界：\n- 你是一个独立的轻量 sub-agent\n- 上下文里能看到主 agent 与用户的完整对话、最近的工具调用与结果\n- 用户在问当前进展或顺便确认某事——基于已有信息**一次性**作答\n- 没有任何工具可用：不要\"让我查一下\" / \"我去试试\" / 任何承诺动作\n- 信息不足就坦白说\"基于目前对话我不知道\"\n\n侧问内容如下：\n</system-reminder>\n\n{question}\"\"\"\n\n_WRAPPER_EN = \"\"\"<system-reminder>\nThis is a side question from the user. The main agent is NOT interrupted — it continues in the background.\n\nIdentity & boundaries:\n- You are an independent lightweight sub-agent\n- You can see the full conversation between the main agent and the user, plus recent tool calls/results\n- The user is asking about current progress or a quick aside — answer in **one shot** from existing info\n- You have NO tools — never say \"let me check\" / \"I'll try\" / any action promise\n- If info is missing, just say \"based on the conversation I don't know\"\n\nQuestion:\n</system-reminder>\n\n{question}\"\"\"\n\n_TIMEOUT_SEC = 120\n\n\ndef _wrapper(): return _WRAPPER_EN if os.environ.get('GA_LANG') == 'en' else _WRAPPER_ZH\n\n\ndef _strip_cmd(query):\n    s = (query or '').strip()\n    return s[len('/btw'):].strip() if s.startswith('/btw') else s\n\n\ndef _help_text():\n    return ('**/btw 用法**：side question — 临时问主 agent 当前进展，不打断主线\\n\\n'\n            '`/btw <你的问题>`\\n\\n'\n            '行为：抓取当前对话上下文 → 单轮纯文本作答（无工具）→ 主 agent 历史不变。')\n\n\ndef _snapshot_history(backend):\n    \"\"\"Lock + deepcopy: defends against concurrent compress_history_tags mutating inner blocks.\"\"\"\n    with backend.lock:\n        return copy.deepcopy(list(backend.history))\n\n\ndef _build_wire(backend, history, sidequest_msg):\n    \"\"\"history + sidequest → wire-format. Dispatches: BaseSession subclasses → make_messages,\n    Native* → raw pairs (raw_ask runs _fix/_drop/_ensure transforms itself).\"\"\"\n    msgs = history + [sidequest_msg]\n    if hasattr(backend, 'make_messages'):\n        return backend.make_messages(msgs)\n    return [{\"role\": m[\"role\"], \"content\": list(m.get(\"content\", []))} for m in msgs]\n\n\ndef _ask(agent, question, deadline):\n    \"\"\"One-shot raw_ask against current backend; never mutates backend.history.\"\"\"\n    backend = agent.llmclient.backend\n    user_msg = {\"role\": \"user\",\n                \"content\": [{\"type\": \"text\", \"text\": _wrapper().format(question=question)}]}\n    wire = _build_wire(backend, _snapshot_history(backend), user_msg)\n    text = ''\n    for chunk in backend.raw_ask(wire):\n        text += chunk\n        if time.time() > deadline:\n            return text + '\\n\\n⚠️ /btw 超时，仅返回部分回复。'\n    return text\n\n\ndef _format(question, body, took):\n    head = f'> 🟡 /btw {question}\\n\\n'\n    return head + (body.strip() or '*(空回复)*') + f'\\n\\n*({took:.1f}s)*'\n\n\ndef _run(agent, question, deadline):\n    \"\"\"Catches errors at the boundary so neither caller path needs its own try/except.\"\"\"\n    try: return _ask(agent, question, deadline)\n    except Exception as e: return f'❌ /btw 失败: {type(e).__name__}: {e}'\n\n\ndef handle(agent, query, display_queue) -> Optional[str]:\n    \"\"\"Slash-cmd entry (server-side, install path). Spawn worker; return None to consume.\"\"\"\n    question = _strip_cmd(query)\n    if not question or question in ('help', '?', '-h', '--help'):\n        display_queue.put({'done': _help_text(), 'source': 'system'})\n        return None\n    started = time.time()\n    deadline = started + _TIMEOUT_SEC\n\n    def worker():\n        body = _run(agent, question, deadline)\n        display_queue.put({'done': _format(question, body, time.time() - started), 'source': 'system'})\n\n    threading.Thread(target=worker, daemon=True, name='btw-sidequest').start()\n    return None\n\n\ndef handle_frontend_command(agent, query) -> str:\n    \"\"\"Sync entry for frontends wanting a string back (tg/wx/stapp/...).\"\"\"\n    question = _strip_cmd(query)\n    if not question or question in ('help', '?', '-h', '--help'):\n        return _help_text()\n    started = time.time()\n    body = _run(agent, question, started + _TIMEOUT_SEC)\n    return _format(question, body, time.time() - started)\n\n\ndef install(cls):\n    \"\"\"Idempotent monkey-patch: intercept /btw before original dispatch.\"\"\"\n    orig = cls._handle_slash_cmd\n    if getattr(orig, '_btw_patched', False): return\n\n    def patched(self, raw_query, display_queue):\n        s = (raw_query or '').strip()\n        if s == '/btw' or s.startswith('/btw ') or s.startswith('/btw\\t'):\n            r = handle(self, raw_query, display_queue)\n            if r is None: return None\n            return r\n        return orig(self, raw_query, display_queue)\n\n    patched._btw_patched = True\n    cls._handle_slash_cmd = patched\n"
  },
  {
    "path": "frontends/chatapp_common.py",
    "content": "import ast, asyncio, glob, json, os, queue as Q, re, socket, sys, time\n\nHELP_COMMANDS = (\n    (\"/help\", \"显示帮助\"),\n    (\"/status\", \"查看状态\"),\n    (\"/stop\", \"停止当前任务\"),\n    (\"/new\", \"开启新对话并清空当前上下文\"),\n    (\"/restore\", \"恢复上次对话历史\"),\n    (\"/continue\", \"列出可恢复会话\"),\n    (\"/continue [n]\", \"恢复第 n 个会话\"),\n    (\"/btw <q>\", \"side question — 临时插问主 agent 进展，不打断主线\"),\n    (\"/llm\", \"查看当前模型列表\"),\n    (\"/llm [n]\", \"切换到第 n 个模型\"),\n)\nTELEGRAM_MENU_COMMANDS = (\n    (\"help\", \"显示帮助\"),\n    (\"status\", \"查看状态\"),\n    (\"stop\", \"停止当前任务\"),\n    (\"new\", \"开启新对话并清空当前上下文\"),\n    (\"restore\", \"恢复上次对话历史\"),\n    (\"continue\", \"列出可恢复会话；/continue n 恢复第 n 个\"),\n    (\"llm\", \"查看模型列表；/llm n 切换到指定模型\"),\n)\n\n\ndef build_help_text(commands=HELP_COMMANDS):\n    return \"📖 命令列表:\\n\" + \"\\n\".join(f\"{cmd} - {desc}\" for cmd, desc in commands)\n\n\nHELP_TEXT = build_help_text()\nFILE_HINT = \"If you need to show files to user, use [FILE:filepath] in your response.\"\nTAG_PATS = [r\"<\" + t + r\">.*?</\" + t + r\">\" for t in (\"thinking\", \"summary\", \"tool_use\", \"file_content\")]\nPROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\nRESTORE_GLOBS = (\n    os.path.join(PROJECT_ROOT, \"temp\", \"model_responses\", \"model_responses_*.txt\"),\n    os.path.join(PROJECT_ROOT, \"temp\", \"model_responses_*.txt\"),\n)\nRESTORE_BLOCK_RE = re.compile(\n    r\"^=== (Prompt|Response) ===.*?\\n(.*?)(?=^=== (?:Prompt|Response) ===|\\Z)\",\n    re.DOTALL | re.MULTILINE,\n)\nHISTORY_RE = re.compile(r\"<history>\\s*(.*?)\\s*</history>\", re.DOTALL)\nSUMMARY_RE = re.compile(r\"<summary>\\s*(.*?)\\s*</summary>\", re.DOTALL)\n\n\ndef clean_reply(text):\n    for pat in TAG_PATS:\n        text = re.sub(pat, \"\", text or \"\", flags=re.DOTALL)\n    return re.sub(r\"\\n{3,}\", \"\\n\\n\", text).strip() or \"...\"\n\n\ndef extract_files(text):\n    return re.findall(r\"\\[FILE:([^\\]]+)\\]\", text or \"\")\n\n\ndef strip_files(text):\n    return re.sub(r\"\\[FILE:[^\\]]+\\]\", \"\", text or \"\").strip()\n\n\ndef split_text(text, limit):\n    text, parts = (text or \"\").strip() or \"...\", []\n    while len(text) > limit:\n        cut = text.rfind(\"\\n\", 0, limit)\n        if cut < limit * 0.6:\n            cut = limit\n        parts.append(text[:cut].rstrip())\n        text = text[cut:].lstrip()\n    return parts + ([text] if text else []) or [\"...\"]\n\n\ndef _restore_log_files():\n    files = []\n    for pattern in RESTORE_GLOBS:\n        files.extend(glob.glob(pattern))\n    return sorted(set(files))\n\n\ndef _restore_text_pairs(content):\n    users = re.findall(r\"=== USER ===\\n(.+?)(?==== |$)\", content, re.DOTALL)\n    resps = re.findall(r\"=== Response ===.*?\\n(.+?)(?==== Prompt|$)\", content, re.DOTALL)\n    restored = []\n    for u, r in zip(users, resps):\n        u, r = u.strip(), r.strip()[:500]\n        if u and r:\n            restored.extend([f\"[USER]: {u}\", f\"[Agent] {r}\"])\n    return restored\n\n\ndef _native_prompt_obj(prompt_body):\n    try:\n        prompt = json.loads(prompt_body)\n    except Exception:\n        return None\n    if not isinstance(prompt, dict) or prompt.get(\"role\") != \"user\":\n        return None\n    if not isinstance(prompt.get(\"content\"), list):\n        return None\n    return prompt\n\n\ndef _native_prompt_text(prompt):\n    texts = []\n    for block in prompt.get(\"content\", []):\n        if isinstance(block, dict) and block.get(\"type\") == \"text\":\n            text = block.get(\"text\", \"\")\n            if isinstance(text, str) and text.strip():\n                texts.append(text)\n    return \"\\n\".join(texts).strip()\n\n\ndef _native_history_lines(prompt_text):\n    match = HISTORY_RE.search(prompt_text or \"\")\n    if not match:\n        return []\n    restored = []\n    for line in match.group(1).splitlines():\n        line = line.strip()\n        if line.startswith(\"[USER]: \") or line.startswith(\"[Agent] \"):\n            restored.append(line)\n    return restored\n\n\ndef _native_first_user_line(prompt_text):\n    text = (prompt_text or \"\").strip()\n    if not text or \"<history>\" in text or text.startswith(\"### [WORKING MEMORY]\"):\n        return \"\"\n    if text.startswith(FILE_HINT):\n        text = text[len(FILE_HINT):].lstrip()\n    if \"### 用户当前消息\" in text:\n        text = text.split(\"### 用户当前消息\", 1)[-1].strip()\n    return text\n\n\ndef _native_response_summary(response_body):\n    try:\n        blocks = ast.literal_eval((response_body or \"\").strip())\n    except Exception:\n        return \"\"\n    if not isinstance(blocks, list):\n        return \"\"\n    text_parts = []\n    for block in blocks:\n        if isinstance(block, dict) and block.get(\"type\") == \"text\":\n            text = block.get(\"text\", \"\")\n            if isinstance(text, str) and text:\n                text_parts.append(text)\n    match = SUMMARY_RE.search(\"\\n\".join(text_parts))\n    return (match.group(1).strip() if match else \"\")[:500]\n\n\ndef _restore_native_history(content):\n    blocks = RESTORE_BLOCK_RE.findall(content or \"\")\n    if not blocks:\n        return []\n    pairs = []\n    pending_prompt = None\n    for label, body in blocks:\n        if label == \"Prompt\":\n            pending_prompt = body\n        elif pending_prompt is not None:\n            pairs.append((pending_prompt, body))\n            pending_prompt = None\n    for prompt_body, response_body in reversed(pairs):\n        prompt = _native_prompt_obj(prompt_body)\n        if prompt is None:\n            continue\n        prompt_text = _native_prompt_text(prompt)\n        restored = list(_native_history_lines(prompt_text))\n        if restored:\n            summary = _native_response_summary(response_body)\n            summary_line = f\"[Agent] {summary}\" if summary else \"\"\n            if summary_line and (not restored or restored[-1] != summary_line):\n                restored.append(summary_line)\n            return restored\n        user_text = _native_first_user_line(prompt_text)\n        summary = _native_response_summary(response_body)\n        if user_text and summary:\n            return [f\"[USER]: {user_text}\", f\"[Agent] {summary}\"]\n    return []\n\n\ndef format_restore():\n    files = _restore_log_files()\n    if not files:\n        return None, \"❌ 没有找到历史记录\"\n    latest = max(files, key=os.path.getmtime)\n    with open(latest, \"r\", encoding=\"utf-8\") as f:\n        content = f.read()\n    restored = _restore_text_pairs(content) or _restore_native_history(content)\n    if not restored:\n        return None, \"❌ 历史记录里没有可恢复内容\"\n    count = sum(1 for line in restored if line.startswith(\"[USER]: \"))\n    return (restored, os.path.basename(latest), count), None\n\n\ndef build_done_text(raw_text):\n    files = [p for p in extract_files(raw_text) if os.path.exists(p)]\n    body = strip_files(clean_reply(raw_text))\n    if files:\n        body = (body + \"\\n\\n\" if body else \"\") + \"\\n\".join(f\"生成文件: {p}\" for p in files)\n    return body or \"...\"\n\n\ndef public_access(allowed):\n    return not allowed or \"*\" in allowed\n\n\ndef to_allowed_set(value):\n    if value is None:\n        return set()\n    if isinstance(value, str):\n        value = [value]\n    return {str(x).strip() for x in value if str(x).strip()}\n\n\ndef allowed_label(allowed):\n    return \"public\" if public_access(allowed) else sorted(allowed)\n\n\ndef ensure_single_instance(port, label):\n    try:\n        lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n        lock_sock.bind((\"127.0.0.1\", port))\n        return lock_sock\n    except OSError:\n        print(f\"[{label}] Another instance is already running, skipping...\")\n        sys.exit(1)\n\n\ndef require_runtime(agent, label, **required):\n    missing = [k for k, v in required.items() if not v]\n    if missing:\n        print(f\"[{label}] ERROR: please set {', '.join(missing)} in mykey.py or mykey.json\")\n        sys.exit(1)\n    if agent.llmclient is None:\n        print(f\"[{label}] ERROR: no usable LLM backend found in mykey.py or mykey.json\")\n        sys.exit(1)\n\n\ndef redirect_log(script_file, log_name, label, allowed):\n    log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(script_file))), \"temp\")\n    os.makedirs(log_dir, exist_ok=True)\n    logf = open(os.path.join(log_dir, log_name), \"a\", encoding=\"utf-8\", buffering=1)\n    sys.stdout = sys.stderr = logf\n    print(f\"[NEW] {label} process starting, the above are history infos ...\")\n    print(f\"[{label}] allow list: {allowed_label(allowed)}\")\n\n\nclass AgentChatMixin:\n    label = \"Chat\"\n    source = \"chat\"\n    split_limit = 1500\n    ping_interval = 20\n\n    def __init__(self, agent, user_tasks):\n        self.agent, self.user_tasks = agent, user_tasks\n\n    async def send_text(self, chat_id, content, **ctx):\n        raise NotImplementedError\n\n    async def send_done(self, chat_id, raw_text, **ctx):\n        await self.send_text(chat_id, build_done_text(raw_text), **ctx)\n\n    async def handle_command(self, chat_id, cmd, **ctx):\n        parts = (cmd or \"\").split()\n        op = (parts[0] if parts else \"\").lower()\n        if op == \"/help\":\n            return await self.send_text(chat_id, HELP_TEXT, **ctx)\n        if op == \"/stop\":\n            state = self.user_tasks.get(chat_id)\n            if state:\n                state[\"running\"] = False\n            self.agent.abort()\n            return await self.send_text(chat_id, \"⏹️ 正在停止...\", **ctx)\n        if op == \"/status\":\n            llm = self.agent.get_llm_name() if self.agent.llmclient else \"未配置\"\n            return await self.send_text(chat_id, f\"状态: {'🔴 运行中' if self.agent.is_running else '🟢 空闲'}\\nLLM: [{self.agent.llm_no}] {llm}\", **ctx)\n        if op == \"/llm\":\n            if not self.agent.llmclient:\n                return await self.send_text(chat_id, \"❌ 当前没有可用的 LLM 配置\", **ctx)\n            if len(parts) > 1:\n                try:\n                    self.agent.next_llm(int(parts[1]))\n                    return await self.send_text(chat_id, f\"✅ 已切换到 [{self.agent.llm_no}] {self.agent.get_llm_name()}\", **ctx)\n                except Exception:\n                    return await self.send_text(chat_id, f\"用法: /llm <0-{len(self.agent.list_llms()) - 1}>\", **ctx)\n            lines = [f\"{'→' if cur else '  '} [{i}] {name}\" for i, name, cur in self.agent.list_llms()]\n            return await self.send_text(chat_id, \"LLMs:\\n\" + \"\\n\".join(lines), **ctx)\n        if op == \"/restore\":\n            try:\n                restored_info, err = format_restore()\n                if err:\n                    return await self.send_text(chat_id, err, **ctx)\n                restored, fname, count = restored_info\n                self.agent.abort()\n                self.agent.history.extend(restored)\n                return await self.send_text(chat_id, f\"✅ 已恢复 {count} 轮对话\\n来源: {fname}\\n(仅恢复上下文，请输入新问题继续)\", **ctx)\n            except Exception as e:\n                return await self.send_text(chat_id, f\"❌ 恢复失败: {e}\", **ctx)\n        if op == \"/continue\":\n            return await self.send_text(chat_id, _handle_continue_frontend(self.agent, cmd), **ctx)\n        if op == \"/new\":\n            return await self.send_text(chat_id, _reset_conversation(self.agent), **ctx)\n        if op == \"/btw\":\n            answer = await asyncio.to_thread(_handle_btw_frontend, self.agent, cmd)\n            return await self.send_text(chat_id, answer, **ctx)\n        return await self.send_text(chat_id, HELP_TEXT, **ctx)\n\n    async def run_agent(self, chat_id, text, **ctx):\n        state = {\"running\": True}\n        self.user_tasks[chat_id] = state\n        try:\n            await self.send_text(chat_id, \"思考中...\", **ctx)\n            dq = self.agent.put_task(f\"{FILE_HINT}\\n\\n{text}\", source=self.source)\n            last_ping = time.time()\n            while state[\"running\"]:\n                try:\n                    item = await asyncio.to_thread(dq.get, True, 3)\n                except Q.Empty:\n                    if self.agent.is_running and time.time() - last_ping > self.ping_interval:\n                        await self.send_text(chat_id, \"⏳ 还在处理中，请稍等...\", **ctx)\n                        last_ping = time.time()\n                    continue\n                if \"done\" in item:\n                    await self.send_done(chat_id, item.get(\"done\", \"\"), **ctx)\n                    break\n            if not state[\"running\"]:\n                await self.send_text(chat_id, \"⏹️ 已停止\", **ctx)\n        except Exception as e:\n            import traceback\n            print(f\"[{self.label}] run_agent error: {e}\")\n            traceback.print_exc()\n            await self.send_text(chat_id, f\"❌ 错误: {e}\", **ctx)\n        finally:\n            self.user_tasks.pop(chat_id, None)\n\n\nfrom agentmain import GeneraticAgent as _GA\nfrom continue_cmd import handle_frontend_command as _handle_continue_frontend, install as _install_continue, reset_conversation as _reset_conversation\n_install_continue(_GA)\nfrom btw_cmd import handle_frontend_command as _handle_btw_frontend, install as _install_btw; _install_btw(_GA)\n"
  },
  {
    "path": "frontends/continue_cmd.py",
    "content": "\"\"\"`/continue` command: list & restore past model_responses sessions.\nPure functions + one `install(cls)` monkey-patch entry. No side effects at import.\n\"\"\"\nimport ast, glob, json, os, re, time\n_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),\n                        'temp', 'model_responses')\n_LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt')\n_BLOCK_RE = re.compile(r'^=== (Prompt|Response) ===.*?\\n(.*?)(?=^=== (?:Prompt|Response) ===|\\Z)',\n                       re.DOTALL | re.MULTILINE)\n_SUMMARY_RE = re.compile(r'<summary>\\s*(.*?)\\s*</summary>', re.DOTALL)\n\ndef _rel_time(mtime):\n    d = int(time.time() - mtime)\n    if d < 60: return f'{d}秒前'\n    if d < 3600: return f'{d // 60}分前'\n    if d < 86400: return f'{d // 3600}小时前'\n    return f'{d // 86400}天前'\n\ndef _pairs(content):\n    blocks, pairs, pending = _BLOCK_RE.findall(content or ''), [], None\n    for label, body in blocks:\n        if label == 'Prompt': pending = body.strip()\n        elif pending is not None:\n            pairs.append((pending, body.strip())); pending = None\n    return pairs\n\ndef _first_user(pairs):\n    for p, _ in pairs:\n        try: msg = json.loads(p)\n        except Exception: continue\n        if not isinstance(msg, dict): continue\n        for blk in msg.get('content', []) or []:\n            if isinstance(blk, dict) and blk.get('type') == 'text':\n                t = (blk.get('text') or '').strip()\n                if t and '<history>' not in t and not t.startswith('### [WORKING MEMORY]'):\n                    return t\n    for p, _ in pairs[:1]:\n        for line in p.splitlines():\n            s = line.strip()\n            if s and not s.startswith('###'): return s\n    return ''\n\n\ndef _last_summary(pairs):\n    for _, response_body in reversed(pairs):\n        try:\n            blocks = ast.literal_eval(response_body)\n        except Exception:\n            continue\n        if not isinstance(blocks, list):\n            continue\n        text_parts = []\n        for block in blocks:\n            if isinstance(block, dict) and block.get('type') == 'text':\n                text = block.get('text', '')\n                if isinstance(text, str) and text:\n                    text_parts.append(text)\n        match = _SUMMARY_RE.search('\\n'.join(text_parts))\n        if match:\n            summary = match.group(1).strip()\n            if summary:\n                return summary\n    return ''\n\n\ndef _preview_text(pairs):\n    return _last_summary(pairs) or _first_user(pairs)\n\ndef _recent_context(my_pid, n=5):\n    \"\"\"扫描最近 n 个 model_response 文件（排除自身），提取 lastQ / lastA。\"\"\"\n    out = []\n    for f in sorted(glob.glob(_LOG_GLOB), key=os.path.getmtime, reverse=True):\n        m = re.search(r'model_responses_(\\d+)', os.path.basename(f))\n        if not m or m.group(1) == str(my_pid): continue\n        try: c = open(f, encoding='utf-8', errors='ignore').read()\n        except Exception: continue\n        q = s = \"\"\n        for hm in re.finditer(r'<history>(.*?)</history>', c, re.DOTALL):\n            u = re.search(r'\\[USER\\]:\\s*(.+?)(?:\\\\n|<)', hm.group(1))\n            if u: q = u.group(1)\n        sm = _SUMMARY_RE.search(c)\n        if sm: s = sm.group(1).strip()\n        q, s = q[:60].strip(), s[:60].replace('\\n', ' ').strip()\n        out.append(f'· {m.group(1)} | lastQ: {q or \"-\"} | lastA: {s or \"-\"}')\n        if len(out) >= n: break\n    return ('[RecentContext] 近期并行会话（非当前）:\\n' + '\\n'.join(out) + '\\n[/RecentContext]') if out else \"\"\n\ndef _parse_native_history(pairs):\n    history = []\n    for p, r in pairs:\n        try: user_msg = json.loads(p)\n        except Exception: return None\n        try: blocks = ast.literal_eval(r)\n        except Exception: return None\n        if not (isinstance(user_msg, dict) and user_msg.get('role') == 'user'): return None\n        if not isinstance(blocks, list): return None\n        history.append(user_msg)\n        history.append({'role': 'assistant', 'content': blocks})\n    return history\n\ndef list_sessions(exclude_pid=None):\n    \"\"\"Newest-first list of (path, mtime, first_user_text, n_rounds).\"\"\"\n    files = glob.glob(_LOG_GLOB)\n    if exclude_pid is not None:\n        tag = f'model_responses_{exclude_pid}.txt'\n        files = [f for f in files if not f.endswith(tag)]\n    out = []\n    for f in files:\n        try:\n            with open(f, encoding='utf-8', errors='replace') as fh:\n                content = fh.read()\n        except Exception: continue\n        pairs = _pairs(content)\n        if not pairs: continue\n        out.append((f, os.path.getmtime(f), _preview_text(pairs), len(pairs)))\n    out.sort(key=lambda x: x[1], reverse=True)\n    return out\n_MD_ESCAPE_RE = re.compile(r'([\\\\`*_\\[\\]])')\ndef _escape_md(s): return _MD_ESCAPE_RE.sub(r'\\\\\\1', s)\n\n\ndef _agent_clients(agent):\n    clients = []\n    for client in getattr(agent, 'llmclients', []) or []:\n        if client not in clients:\n            clients.append(client)\n    current = getattr(agent, 'llmclient', None)\n    if current is not None and current not in clients:\n        clients.insert(0, current)\n    return clients\n\n\ndef _replace_backend_history(agent, history):\n    backend = getattr(getattr(agent, 'llmclient', None), 'backend', None)\n    if backend is not None and hasattr(backend, 'history'):\n        backend.history = list(history or [])\n\n\ndef _current_log_path(pid=None):\n    pid = os.getpid() if pid is None else pid\n    return os.path.join(_LOG_DIR, f'model_responses_{pid}.txt')\n\n\ndef _snapshot_current_log(pid=None):\n    \"\"\"Persist current PID log as a standalone recoverable snapshot, then clear it.\"\"\"\n    path = _current_log_path(pid)\n    if not os.path.isfile(path):\n        return None\n    try:\n        with open(path, encoding='utf-8', errors='replace') as fh:\n            content = fh.read()\n    except Exception:\n        return None\n    if not _pairs(content):\n        return None\n    os.makedirs(_LOG_DIR, exist_ok=True)\n    pid = os.getpid() if pid is None else pid\n    stamp = time.strftime('%Y%m%d_%H%M%S')\n    snapshot = os.path.join(_LOG_DIR, f'model_responses_snapshot_{pid}_{stamp}_{time.time_ns() % 1_000_000_000:09d}.txt')\n    with open(snapshot, 'w', encoding='utf-8', errors='replace') as fh:\n        fh.write(content)\n    with open(path, 'w', encoding='utf-8', errors='replace'):\n        pass\n    return snapshot\n\n\ndef reset_conversation(agent, message='🆕 已开启新对话，当前上下文已清空'):\n    \"\"\"Abort current work and clear all known frontend-visible conversation state.\"\"\"\n    try:\n        agent.abort()\n    except Exception:\n        pass\n    _snapshot_current_log()\n    if hasattr(agent, 'history'):\n        agent.history = []\n    for client in _agent_clients(agent):\n        backend = getattr(client, 'backend', None)\n        if backend is not None and hasattr(backend, 'history'):\n            backend.history = []\n        if hasattr(client, 'last_tools'):\n            client.last_tools = ''\n    if hasattr(agent, 'handler'):\n        agent.handler = None\n    return message\n\ndef format_list(sessions, limit=20):\n    if not sessions: return '❌ 没有可恢复的历史会话'\n    lines = ['**可恢复会话**（输入 `/continue N` 恢复第 N 个）：', '']\n    for i, (_, mtime, first, n) in enumerate(sessions[:limit], 1):\n        preview = _escape_md((first or '（无法预览）').replace('\\n', ' ')[:60])\n        lines.append(f'{i}. `{_rel_time(mtime)}` · **{n} 轮** · {preview}')\n    return '\\n'.join(lines)\n\ndef restore(agent, path):\n    \"\"\"Restore session at path. Returns (msg, is_full).\"\"\"\n    try:\n        with open(path, encoding='utf-8', errors='replace') as fh:\n            content = fh.read()\n    except Exception as e: return f'❌ 读取失败: {e}', False\n    pairs = _pairs(content)\n    if not pairs: return f'❌ {os.path.basename(path)} 为空或格式不符', False\n    history = _parse_native_history(pairs)\n    name = os.path.basename(path)\n    if history is not None:\n        agent.abort()\n        _replace_backend_history(agent, history)\n        return f'✅ 已恢复 {len(pairs)} 轮完整对话（{name}）\\n(已写入 backend.history，可直接继续)', True\n    from chatapp_common import _restore_native_history, _restore_text_pairs\n    summary = _restore_text_pairs(content) or _restore_native_history(content)\n    if not summary: return f'❌ {name} 无法解析（非 native 且无摘要可提取）', False\n    agent.abort()\n    agent.history.extend(summary)\n    n = sum(1 for l in summary if l.startswith('[USER]: '))\n    return f'⚠️ 非 native 格式，已降级恢复 {n} 轮摘要（{name}）\\n(请输入新问题继续)', False\n\ndef handle(agent, query, display_queue):\n    \"\"\"Dispatch /continue or /continue N. Returns None if consumed else original query.\"\"\"\n    s = (query or '').strip()\n    if s == '/continue':\n        display_queue.put({'done': format_list(list_sessions(exclude_pid=os.getpid())), 'source': 'system'})\n        return None\n    m = re.match(r'/continue\\s+(\\d+)\\s*$', s)\n    if m:\n        sessions = list_sessions(exclude_pid=os.getpid())\n        idx = int(m.group(1)) - 1\n        if not (0 <= idx < len(sessions)):\n            display_queue.put({'done': f'❌ 索引越界（有效范围 1-{len(sessions)}）', 'source': 'system'})\n            return None\n        reset_conversation(agent, message=None)\n        msg, _ = restore(agent, sessions[idx][0])\n        display_queue.put({'done': msg, 'source': 'system'})\n        return None\n    return query\n\n\ndef _user_text(prompt_body):\n    \"\"\"User-typed text from a prompt JSON; '' if this is an agent auto-continuation.\"\"\"\n    try: msg = json.loads(prompt_body)\n    except Exception: return ''\n    if not isinstance(msg, dict): return ''\n    for blk in msg.get('content', []) or []:\n        if isinstance(blk, dict) and blk.get('type') == 'text':\n            t = (blk.get('text') or '').strip()\n            if t and not t.startswith('### [WORKING MEMORY]'): return t\n    return ''\n\n\ndef _assistant_text(response_body):\n    \"\"\"Joined text from a response blocks repr; '' on parse failure.\"\"\"\n    try: blocks = ast.literal_eval(response_body)\n    except Exception: return ''\n    if not isinstance(blocks, list): return ''\n    return '\\n'.join(b['text'] for b in blocks\n                     if isinstance(b, dict) and b.get('type') == 'text'\n                     and isinstance(b.get('text'), str) and b['text'].strip())\n\n\n_TURN_MARK = '**LLM Running (Turn {}) ...**\\n\\n'\n\n\ndef extract_ui_messages(path):\n    \"\"\"Parse a model_responses log into [{role, content}, ...] for UI replay.\n\n    Auto-continuation turns are folded into one assistant bubble with Turn markers,\n    matching live chat rendering via fold_turns().\n    \"\"\"\n    try:\n        with open(path, encoding='utf-8', errors='replace') as f: content = f.read()\n    except Exception: return []\n\n    rounds = []  # [(user_text, [turn_text, ...]), ...]\n    for prompt, response in _pairs(content):\n        user = _user_text(prompt)\n        if user or not rounds: rounds.append((user, []))\n        rounds[-1][1].append(_assistant_text(response))\n\n    out = []\n    for user, turns in rounds:\n        if not user or not any(turns): continue\n        body = '\\n\\n'.join(t if i == 0 else _TURN_MARK.format(i + 1) + t\n                           for i, t in enumerate(turns))\n        out += [{'role': 'user', 'content': user},\n                {'role': 'assistant', 'content': body}]\n    return out\n\n\ndef handle_frontend_command(agent, query, exclude_pid=None):\n    \"\"\"Frontend-friendly /continue entry that returns text directly.\"\"\"\n    s = (query or '').strip()\n    exclude_pid = os.getpid() if exclude_pid is None else exclude_pid\n    if s == '/continue':\n        return format_list(list_sessions(exclude_pid=exclude_pid))\n    m = re.match(r'/continue\\s+(\\d+)\\s*$', s)\n    if not m:\n        return '用法: /continue 或 /continue N'\n    sessions = list_sessions(exclude_pid=exclude_pid)\n    idx = int(m.group(1)) - 1\n    if not (0 <= idx < len(sessions)):\n        return f'❌ 索引越界（有效范围 1-{len(sessions)}）'\n    reset_conversation(agent, message=None)\n    msg, _ = restore(agent, sessions[idx][0])\n    return msg\n\n\ndef install(cls):\n    \"\"\"Wrap cls._handle_slash_cmd so /continue is handled before original dispatch.\"\"\"\n    orig = cls._handle_slash_cmd\n    if getattr(orig, '_continue_patched', False): return\n    def patched(self, raw_query, display_queue):\n        if (raw_query or '').startswith('/continue'):\n            r = handle(self, raw_query, display_queue)\n            if r is None: return None\n        return orig(self, raw_query, display_queue)\n    patched._continue_patched = True\n    cls._handle_slash_cmd = patched\n"
  },
  {
    "path": "frontends/dcapp.py",
    "content": "# Discord Bot Frontend for GenericAgent\n# ⚠️ 需要在 Discord Developer Portal 开启 \"Message Content Intent\"\n#   Bot → Privileged Gateway Intents → MESSAGE CONTENT INTENT → 打开\n# pip install discord.py\n\nimport asyncio, json, os, queue as Q, re, sys, threading, time\nfrom collections import OrderedDict\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom agentmain import GeneraticAgent\nfrom chatapp_common import (\n    AgentChatMixin, build_done_text, ensure_single_instance, extract_files,\n    public_access, redirect_log, require_runtime, split_text, strip_files, clean_reply,\n    HELP_TEXT, FILE_HINT, format_restore,\n    _handle_continue_frontend, _reset_conversation,\n)\nfrom llmcore import mykeys\n\ntry:\n    import discord\nexcept Exception:\n    print(\"Please install discord.py to use Discord: pip install discord.py\")\n    sys.exit(1)\n\nagent = GeneraticAgent(); agent.verbose = False\nBOT_TOKEN = str(mykeys.get(\"discord_bot_token\", \"\") or \"\").strip()\nALLOWED = {str(x).strip() for x in mykeys.get(\"discord_allowed_users\", []) if str(x).strip()}\nUSER_TASKS = {}\nPROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\nTEMP_DIR = os.path.join(PROJECT_ROOT, \"temp\")\nMEDIA_DIR = os.path.join(TEMP_DIR, \"discord_media\")\nACTIVE_FILE = os.path.join(TEMP_DIR, \"discord_active_channels.json\")\nACTIVE_TTL_SECONDS = 30 * 24 * 3600\nEXIT_CHANNEL_TEXTS = {\"退出该频道\", \"退出此频道\", \"退出频道\"}\nEXIT_THREAD_TEXTS = {\"退出该子区\", \"退出此子区\", \"退出子区\"}\nos.makedirs(MEDIA_DIR, exist_ok=True)\n\n\ndef _extract_discord_progress(text):\n    \"\"\"Return the newest concise <summary> from a streaming transcript.\"\"\"\n    matches = re.findall(r\"<summary>\\s*(.*?)\\s*</summary>\", text or \"\", flags=re.DOTALL)\n    if not matches:\n        return \"\"\n    summary = re.sub(r\"\\s+\", \" \", matches[-1]).strip()\n    return summary[:120]\n\n\ndef _strip_discord_transcript(text):\n    \"\"\"Hide LLM/tool transcript noise while preserving the final natural reply.\"\"\"\n    text = text or \"\"\n    text = re.sub(r\"^\\s*\\*?\\*?LLM Running \\(Turn \\d+\\) \\.\\.\\.\\*?\\*?\\s*$\", \"\", text, flags=re.M)\n    text = re.sub(r\"^\\s*🛠️\\s+.*?(?=^\\s*(?:\\*?\\*?LLM Running|<summary>|$))\", \"\", text, flags=re.M | re.DOTALL)\n    text = re.sub(r\"^\\s*(?:✅|❌|ERR|STDOUT|PAT\\b|RC\\b).*?$\", \"\", text, flags=re.M)\n    text = re.sub(r\"<tool_use>.*?</tool_use>\", \"\", text, flags=re.DOTALL)\n    text = clean_reply(text)\n    return strip_files(text).strip()\n\n\ndef _display_done_text(text):\n    body = _strip_discord_transcript(text)\n    if body and body != \"...\":\n        return body\n    summaries = re.findall(r\"<summary>\\s*(.*?)\\s*</summary>\", text or \"\", flags=re.DOTALL)\n    if summaries:\n        return re.sub(r\"\\s+\", \" \", summaries[-1]).strip() or \"...\"\n    return \"...\"\n\n\nclass DiscordApp(AgentChatMixin):\n    label, source, split_limit = \"Discord\", \"discord\", 1900\n\n    def __init__(self):\n        super().__init__(agent, USER_TASKS)\n        intents = discord.Intents.default()\n        intents.message_content = True\n        intents.guilds = True\n        intents.dm_messages = True\n        proxy = str(mykeys.get(\"proxy\", \"\") or \"\").strip() or None\n        self.client = discord.Client(intents=intents, proxy=proxy)\n        self.background_tasks = set()\n        self._channel_cache = OrderedDict()  # chat_id -> channel/user object (LRU, max 500)\n        self._active_channels = self._load_active_channels()  # guild chat_id -> {last_seen: float}\n        self._active_lock = threading.Lock()\n        self._agents = OrderedDict()  # chat_id -> GeneraticAgent, each chat has isolated history\n        self._agent_lock = threading.Lock()\n\n        @self.client.event\n        async def on_ready():\n            print(f\"[Discord] bot ready: {self.client.user} ({self.client.user.id})\")\n\n        @self.client.event\n        async def on_message(message):\n            await self._handle_message(message)\n\n    def _chat_id(self, message):\n        \"\"\"Return a string chat_id: 'dm:<user_id>' or 'ch:<channel_id>'.\"\"\"\n        if isinstance(message.channel, discord.DMChannel):\n            return f\"dm:{message.author.id}\"\n        return f\"ch:{message.channel.id}\"\n\n    def _load_active_channels(self):\n        try:\n            with open(ACTIVE_FILE, \"r\", encoding=\"utf-8\") as f:\n                data = json.load(f)\n            if not isinstance(data, dict):\n                return {}\n            now = time.time()\n            active = {}\n            for chat_id, item in data.items():\n                if not str(chat_id).startswith(\"ch:\") or not isinstance(item, dict):\n                    continue\n                last_seen = float(item.get(\"last_seen\") or 0)\n                if now - last_seen <= ACTIVE_TTL_SECONDS:\n                    active[str(chat_id)] = {\"last_seen\": last_seen}\n            return active\n        except FileNotFoundError:\n            return {}\n        except Exception as e:\n            print(f\"[Discord] failed to load active channels: {e}\")\n            return {}\n\n    def _save_active_channels(self):\n        try:\n            os.makedirs(os.path.dirname(ACTIVE_FILE), exist_ok=True)\n            tmp = ACTIVE_FILE + \".tmp\"\n            with open(tmp, \"w\", encoding=\"utf-8\") as f:\n                json.dump(self._active_channels, f, ensure_ascii=False, indent=2, sort_keys=True)\n            os.replace(tmp, ACTIVE_FILE)\n        except Exception as e:\n            print(f\"[Discord] failed to save active channels: {e}\")\n\n    def _is_active_channel(self, chat_id, now=None):\n        now = now or time.time()\n        with self._active_lock:\n            item = self._active_channels.get(chat_id)\n            if not item:\n                return False\n            if now - float(item.get(\"last_seen\") or 0) > ACTIVE_TTL_SECONDS:\n                self._active_channels.pop(chat_id, None)\n                self._save_active_channels()\n                print(f\"[Discord] channel expired: {chat_id}\")\n                return False\n            return True\n\n    def _touch_active_channel(self, chat_id, now=None):\n        if not chat_id.startswith(\"ch:\"):\n            return\n        with self._active_lock:\n            self._active_channels[chat_id] = {\"last_seen\": float(now or time.time())}\n            self._save_active_channels()\n\n    def _deactivate_channel(self, chat_id):\n        with self._active_lock:\n            changed = self._active_channels.pop(chat_id, None) is not None\n            self._save_active_channels()\n        state = self.user_tasks.get(chat_id)\n        if state:\n            state[\"running\"] = False\n        try:\n            self._get_agent(chat_id).abort()\n        except Exception as e:\n            print(f\"[Discord] deactivate abort failed for {chat_id}: {e}\")\n        return changed\n\n    def _get_agent(self, chat_id):\n        with self._agent_lock:\n            ga = self._agents.get(chat_id)\n            if ga is None:\n                ga = GeneraticAgent()\n                ga.verbose = False\n                self._agents[chat_id] = ga\n                threading.Thread(target=ga.run, daemon=True, name=f\"discord-agent-{chat_id}\").start()\n                if len(self._agents) > 200:\n                    old_chat_id, _old_agent = self._agents.popitem(last=False)\n                    print(f\"[Discord] dropped agent cache entry: {old_chat_id}\")\n            else:\n                self._agents.move_to_end(chat_id)\n            return ga\n\n    async def _download_attachments(self, message):\n        \"\"\"Download attachments/images to MEDIA_DIR, return list of local paths.\"\"\"\n        paths = []\n        for att in message.attachments:\n            safe_name = re.sub(r'[<>:\"/\\\\|?*]', '_', att.filename or f\"file_{att.id}\")\n            local_path = os.path.join(MEDIA_DIR, f\"{att.id}_{safe_name}\")\n            try:\n                await att.save(local_path)\n                paths.append(local_path)\n                print(f\"[Discord] saved attachment: {local_path}\")\n            except Exception as e:\n                print(f\"[Discord] failed to save attachment {att.filename}: {e}\")\n        return paths\n\n    async def send_text(self, chat_id, content, **ctx):\n        \"\"\"Send text (and optionally files) to a chat_id.\"\"\"\n        channel = self._channel_cache.get(chat_id)\n        if channel is None:\n            try:\n                if chat_id.startswith(\"dm:\"):\n                    user = await self.client.fetch_user(int(chat_id[3:]))\n                    channel = await user.create_dm()\n                else:\n                    channel = await self.client.fetch_channel(int(chat_id[3:]))\n                self._channel_cache[chat_id] = channel\n                if len(self._channel_cache) > 500:\n                    self._channel_cache.popitem(last=False)\n            except Exception as e:\n                print(f\"[Discord] cannot resolve channel for {chat_id}: {e}\")\n                return\n        for part in split_text(content, self.split_limit):\n            try:\n                await channel.send(part)\n            except Exception as e:\n                print(f\"[Discord] send error: {e}\")\n\n    async def send_done(self, chat_id, raw_text, **ctx):\n        \"\"\"Send final reply: text parts + file attachments.\"\"\"\n        files = [p for p in extract_files(raw_text) if os.path.exists(p)]\n        body = _display_done_text(raw_text)\n\n        # Send text (send_text handles splitting internally)\n        if body and body != \"...\":\n            await self.send_text(chat_id, body, **ctx)\n\n        # Send files as Discord attachments\n        if files:\n            channel = self._channel_cache.get(chat_id)\n            if channel:\n                for fpath in files:\n                    try:\n                        await channel.send(file=discord.File(fpath))\n                    except Exception as e:\n                        print(f\"[Discord] failed to send file {fpath}: {e}\")\n                        await self.send_text(chat_id, f\"⚠️ 文件发送失败: {os.path.basename(fpath)}\", **ctx)\n\n        if not body and not files:\n            await self.send_text(chat_id, \"...\", **ctx)\n\n    async def handle_command(self, chat_id, cmd, **ctx):\n        \"\"\"Handle slash commands against the per-chat agent, keeping Discord chats isolated.\"\"\"\n        ga = self._get_agent(chat_id)\n        parts = (cmd or \"\").split()\n        op = (parts[0] if parts else \"\").lower()\n        if op == \"/help\":\n            return await self.send_text(chat_id, HELP_TEXT, **ctx)\n        if op == \"/stop\":\n            state = self.user_tasks.get(chat_id)\n            if state:\n                state[\"running\"] = False\n            ga.abort()\n            return await self.send_text(chat_id, \"⏹️ 正在停止...\", **ctx)\n        if op == \"/status\":\n            llm = ga.get_llm_name() if ga.llmclient else \"未配置\"\n            return await self.send_text(chat_id, f\"状态: {'🔴 运行中' if ga.is_running else '🟢 空闲'}\\nLLM: [{ga.llm_no}] {llm}\", **ctx)\n        if op == \"/llm\":\n            if not ga.llmclient:\n                return await self.send_text(chat_id, \"❌ 当前没有可用的 LLM 配置\", **ctx)\n            if len(parts) > 1:\n                try:\n                    ga.next_llm(int(parts[1]))\n                    return await self.send_text(chat_id, f\"✅ 已切换到 [{ga.llm_no}] {ga.get_llm_name()}\", **ctx)\n                except Exception:\n                    return await self.send_text(chat_id, f\"用法: /llm <0-{len(ga.list_llms()) - 1}>\", **ctx)\n            lines = [f\"{'→' if cur else '  '} [{i}] {name}\" for i, name, cur in ga.list_llms()]\n            return await self.send_text(chat_id, \"LLMs:\\n\" + \"\\n\".join(lines), **ctx)\n        if op == \"/restore\":\n            try:\n                restored_info, err = format_restore()\n                if err:\n                    return await self.send_text(chat_id, err, **ctx)\n                restored, fname, count = restored_info\n                ga.abort()\n                ga.history.extend(restored)\n                return await self.send_text(chat_id, f\"✅ 已恢复 {count} 轮对话\\n来源: {fname}\\n(仅恢复上下文，请输入新问题继续)\", **ctx)\n            except Exception as e:\n                return await self.send_text(chat_id, f\"❌ 恢复失败: {e}\", **ctx)\n        if op == \"/continue\":\n            return await self.send_text(chat_id, _handle_continue_frontend(ga, cmd), **ctx)\n        if op == \"/new\":\n            return await self.send_text(chat_id, _reset_conversation(ga), **ctx)\n        return await self.send_text(chat_id, HELP_TEXT, **ctx)\n\n    async def run_agent(self, chat_id, text, **ctx):\n        \"\"\"Run the isolated per-chat Discord agent.\"\"\"\n        ga = self._get_agent(chat_id)\n        state = {\"running\": True}\n        self.user_tasks[chat_id] = state\n        try:\n            await self.send_text(chat_id, \"思考中...\", **ctx)\n            dq = ga.put_task(f\"{FILE_HINT}\\n\\n{text}\", source=self.source)\n            last_ping = time.time()\n            last_step = \"\"\n            step_no = 0\n            while state[\"running\"]:\n                try:\n                    item = await asyncio.to_thread(dq.get, True, 3)\n                except Q.Empty:\n                    if ga.is_running and time.time() - last_ping > self.ping_interval:\n                        await self.send_text(chat_id, \"⏳ 还在处理中，请稍等...\", **ctx)\n                        last_ping = time.time()\n                    continue\n                if \"next\" in item:\n                    step = _extract_discord_progress(item.get(\"next\", \"\"))\n                    if step and step != last_step:\n                        step_no += 1\n                        await self.send_text(chat_id, f\"步骤{step_no}：{step}\", **ctx)\n                        last_step = step\n                        last_ping = time.time()\n                    continue\n                if \"done\" in item:\n                    await self.send_done(chat_id, item.get(\"done\", \"\"), **ctx)\n                    break\n            if not state[\"running\"]:\n                await self.send_text(chat_id, \"⏹️ 已停止\", **ctx)\n        except Exception as e:\n            import traceback\n            print(f\"[{self.label}] run_agent error: {e}\")\n            traceback.print_exc()\n            await self.send_text(chat_id, f\"❌ 错误: {e}\", **ctx)\n        finally:\n            self.user_tasks.pop(chat_id, None)\n\n    async def _handle_message(self, message):\n        # Ignore self\n        if message.author == self.client.user or message.author.bot:\n            return\n\n        is_dm = isinstance(message.channel, discord.DMChannel)\n        is_guild = message.guild is not None\n        chat_id = self._chat_id(message)\n        now = time.time()\n        mentioned = bool(is_guild and self.client.user and self.client.user.mentioned_in(message))\n\n        self._channel_cache[chat_id] = message.channel\n        if len(self._channel_cache) > 500:\n            self._channel_cache.popitem(last=False)\n\n        user_id = str(message.author.id)\n        user_name = str(message.author)\n\n        if not public_access(ALLOWED) and user_id not in ALLOWED:\n            print(f\"[Discord] unauthorized user: {user_name} ({user_id})\")\n            return\n\n        if is_guild:\n            active = self._is_active_channel(chat_id, now)\n            if not mentioned and not active:\n                return\n            if mentioned or active:\n                self._touch_active_channel(chat_id, now)\n\n        # Strip bot mention from content\n        content = message.content or \"\"\n        if is_guild and self.client.user:\n            content = re.sub(rf\"<@!?{self.client.user.id}>\", \"\", content).strip()\n        else:\n            content = content.strip()\n\n        normalized = re.sub(r\"\\s+\", \"\", content)\n        if is_guild and normalized in EXIT_CHANNEL_TEXTS | EXIT_THREAD_TEXTS:\n            self._deactivate_channel(chat_id)\n            label = \"子区\" if normalized in EXIT_THREAD_TEXTS else \"频道\"\n            await self.send_text(chat_id, f\"✅ 已退出该{label}，之后除非重新 @ 我，否则不会主动响应。\")\n            print(f\"[Discord] manually deactivated {chat_id} by {user_name} ({user_id})\")\n            return\n\n        # Download attachments\n        attachment_paths = await self._download_attachments(message)\n\n        # Build message text with attachment paths\n        if attachment_paths:\n            paths_text = \"\\n\".join(f\"[附件: {p}]\" for p in attachment_paths)\n            content = f\"{content}\\n{paths_text}\" if content else paths_text\n\n        if not content:\n            return\n\n        print(f\"[Discord] message from {user_name} ({user_id}, {'dm' if is_dm else 'guild'}): {content[:200]}\")\n\n        if content.startswith(\"/\"):\n            return await self.handle_command(chat_id, content)\n\n        task = asyncio.create_task(self.run_agent(chat_id, content))\n        self.background_tasks.add(task)\n        task.add_done_callback(self.background_tasks.discard)\n\n    async def start(self):\n        print(\"[Discord] bot starting...\")\n        delay, max_delay = 5, 300\n        while True:\n            started_at = time.monotonic()\n            try:\n                await self.client.start(BOT_TOKEN)\n            except Exception as e:\n                print(f\"[Discord] error: {e}\")\n            if time.monotonic() - started_at >= 60:\n                delay = 5\n            print(f\"[Discord] reconnect in {delay}s...\")\n            await asyncio.sleep(delay)\n            delay = min(delay * 2, max_delay)\n\n\nif __name__ == \"__main__\":\n    _LOCK_SOCK = ensure_single_instance(19532, \"Discord\")\n    require_runtime(agent, \"Discord\", discord_bot_token=BOT_TOKEN)\n    redirect_log(__file__, \"dcapp.log\", \"Discord\", ALLOWED)\n    asyncio.run(DiscordApp().start())\n"
  },
  {
    "path": "frontends/desktop_pet.pyw",
    "content": "\"\"\"Desktop Pet with HTTP Toast — ~90 lines\"\"\"\nimport tkinter as tk, threading, random, os, sys\nfrom http.server import HTTPServer, BaseHTTPRequestHandler\nfrom urllib.parse import urlparse, parse_qs\n\nPORT = 41983\nGIF = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), 'pet.gif')\n\nclass Pet:\n    def __init__(self):\n        self.root = tk.Tk()\n        self.root.overrideredirect(True)\n        self.root.wm_attributes('-topmost', True)\n        self.root.wm_attributes('-transparentcolor', '#01FF01')\n        self.root.config(bg='#01FF01')\n        self.root.after(50, lambda: self.root.geometry('+300+500'))\n        # load GIF frames\n        self.frames, i = [], 0\n        while True:\n            try: self.frames.append(tk.PhotoImage(file=GIF, format=f'gif -index {i}')); i += 1\n            except: break\n        if not self.frames: raise FileNotFoundError(f'No GIF: {GIF}')\n        self.idx = 0\n        self.label = tk.Label(self.root, image=self.frames[0], bg='#01FF01', bd=0)\n        self.label.pack()\n        # drag\n        self.label.bind('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y)))\n        self.label.bind('<B1-Motion>', self._drag)\n        self.label.bind('<Double-1>', lambda e: (self.root.destroy(), os._exit(0)))\n        # start loops\n        self._animate()\n        self._wander()\n        self._start_server()\n        self.root.mainloop()\n\n    def _drag(self, e):\n        x, y = self.root.winfo_x() + e.x - self._d[0], self.root.winfo_y() + e.y - self._d[1]\n        self.root.geometry(f'+{x}+{y}')\n\n    def _animate(self):\n        self.idx = (self.idx + 1) % len(self.frames)\n        self.label.config(image=self.frames[self.idx])\n        self.root.after(150, self._animate)\n\n    def _wander(self):\n        if random.random() < 0.25:\n            x = self.root.winfo_x() + random.randint(-15, 15)\n            y = self.root.winfo_y() + random.randint(-5, 5)\n            self.root.geometry(f'+{x}+{y}')\n        self.root.after(4000, self._wander)\n\n    def show_toast(self, msg):\n        \"\"\"Show a speech bubble near the pet that auto-dismisses.\"\"\"\n        tw = tk.Toplevel(self.root)\n        tw.overrideredirect(True)\n        tw.wm_attributes('-topmost', True)\n        tw.config(bg='#FFFDE7')\n        px, py = self.root.winfo_x(), self.root.winfo_y()\n        tw.geometry(f'+{px + 30}+{py - 50}')\n        # bubble content\n        f = tk.Frame(tw, bg='#FFFDE7', highlightbackground='#888', highlightthickness=1, padx=8, pady=4)\n        f.pack()\n        tk.Label(f, text=msg, bg='#FFFDE7', fg='#333', font=('Segoe UI', 10), wraplength=220, justify='left').pack()\n        # auto dismiss\n        tw.after(3000, tw.destroy)\n\n    def _start_server(self):\n        pet = self\n        class H(BaseHTTPRequestHandler):\n            def do_GET(self):\n                qs = parse_qs(urlparse(self.path).query)\n                msg = qs.get('msg', [''])[0]\n                if msg:\n                    pet.root.after(0, pet.show_toast, msg)\n                    self.send_response(200); self.end_headers(); self.wfile.write(b'ok')\n                else:\n                    self.send_response(400); self.end_headers(); self.wfile.write(b'?msg=xxx')\n            def do_POST(self):\n                body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode()\n                if body:\n                    pet.root.after(0, pet.show_toast, body)\n                    self.send_response(200); self.end_headers(); self.wfile.write(b'ok')\n                else:\n                    self.send_response(400); self.end_headers(); self.wfile.write(b'empty body')\n            def log_message(self, *a): pass\n        HTTPServer.allow_reuse_address = False\n        srv = HTTPServer(('127.0.0.1', PORT), H)\n        t = threading.Thread(target=srv.serve_forever, daemon=True)\n        t.start()\n        print(f'Toast server: http://127.0.0.1:{PORT}/?msg=hello')\n\nif __name__ == '__main__':\n    Pet()\n"
  },
  {
    "path": "frontends/desktop_pet_v2.pyw",
    "content": "\"\"\"Desktop Pet with Skin System — Cross-platform with True Transparency\"\"\"\nimport os, re, sys, json, threading, io\nfrom http.server import HTTPServer, BaseHTTPRequestHandler\nfrom urllib.parse import urlparse, parse_qs\nfrom PIL import Image, ImageDraw, ImageFont, ImageOps\n\nPORT = 41983\nSCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))\nPROJECT_DIR = os.path.dirname(SCRIPT_DIR)\nSKINS_DIR = os.path.join(SCRIPT_DIR, 'skins')\n\nclass SkinLoader:\n    \"\"\"Load and parse skin configuration\"\"\"\n    @staticmethod\n    def load_skin(skin_path):\n        \"\"\"Load skin.json and return skin config\"\"\"\n        config_file = os.path.join(skin_path, 'skin.json')\n        if not os.path.exists(config_file):\n            raise FileNotFoundError(f\"skin.json not found in {skin_path}\")\n\n        with open(config_file, 'r', encoding='utf-8') as f:\n            config = json.load(f)\n\n        if 'animations' not in config:\n            raise ValueError(\"skin.json must contain 'animations' field\")\n\n        config['path'] = skin_path\n        return config\n\n    @staticmethod\n    def list_skins():\n        \"\"\"List all available skins\"\"\"\n        if not os.path.exists(SKINS_DIR):\n            return []\n\n        skins = []\n        for item in os.listdir(SKINS_DIR):\n            skin_path = os.path.join(SKINS_DIR, item)\n            if os.path.isdir(skin_path):\n                config_file = os.path.join(skin_path, 'skin.json')\n                if os.path.exists(config_file):\n                    skins.append(item)\n        return skins\n\nclass AnimationLoader:\n    \"\"\"Load animation frames from sprite sheet\"\"\"\n    @staticmethod\n    def load_sprite_frames(skin_path, anim_config):\n        \"\"\"Load frames from sprite sheet\"\"\"\n        file_path = os.path.join(skin_path, anim_config['file'])\n        sprite_config = anim_config['sprite']\n\n        img = Image.open(file_path)\n        frames = []\n\n        frame_width = sprite_config['frameWidth']\n        frame_height = sprite_config['frameHeight']\n        frame_count = sprite_config['frameCount']\n        columns = sprite_config['columns']\n        start_frame = sprite_config.get('startFrame', 0)\n\n        for i in range(frame_count):\n            frame_idx = start_frame + i\n            row = frame_idx // columns\n            col = frame_idx % columns\n\n            x = col * frame_width\n            y = row * frame_height\n\n            frame = img.crop((x, y, x + frame_width, y + frame_height))\n            frames.append(frame)\n\n        return frames\n\n\ndef _load_default_font(size):\n    \"\"\"Load a usable font for bubble text.\"\"\"\n    font_candidates = [\n        '/System/Library/Fonts/Supplemental/Arial Unicode.ttf',\n        '/System/Library/Fonts/PingFang.ttc',\n        '/System/Library/Fonts/STHeiti Light.ttc',\n        'C:/Windows/Fonts/msyh.ttc',\n        'C:/Windows/Fonts/simhei.ttf',\n        'C:/Windows/Fonts/arial.ttf',\n        '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',\n        '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',\n        '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',\n        '/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf',\n        '/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc',\n    ]\n    for font_path in font_candidates:\n        if os.path.exists(font_path):\n            try:\n                return ImageFont.truetype(font_path, size=size)\n            except Exception:\n                pass\n    return ImageFont.load_default()\n\n\ndef _normalize_bubble_text(text):\n    \"\"\"Normalize text for fonts that cannot render some symbols.\"\"\"\n    text = (text or '').strip()\n    lines = text.replace('\\r\\n', '\\n').replace('\\r', '\\n').split('\\n')\n    if lines:\n        turn_match = re.match(r'^\\s*🔄?\\s*Turn\\s+(\\d+)\\s*$', lines[0], flags=re.IGNORECASE)\n        if turn_match:\n            rest = '\\n'.join(line.strip() for line in lines[1:] if line.strip())\n            return f\"Turn {turn_match.group(1)}: {rest}\" if rest else f\"Turn {turn_match.group(1)}:\"\n    return text.replace('🔄 Turn', 'Turn').replace('🔄', '').strip()\n\n\ndef _wrap_text_for_width(draw, text, font, max_width):\n    \"\"\"Wrap text to fit inside max_width.\"\"\"\n    text = _normalize_bubble_text(text)\n    if not text:\n        return ['']\n\n    paragraphs = text.replace('\\r\\n', '\\n').replace('\\r', '\\n').split('\\n')\n    lines = []\n\n    for paragraph in paragraphs:\n        if not paragraph:\n            lines.append('')\n            continue\n\n        current = ''\n        for ch in paragraph:\n            candidate = current + ch\n            bbox = draw.textbbox((0, 0), candidate, font=font)\n            width = bbox[2] - bbox[0]\n            if current and width > max_width:\n                lines.append(current)\n                current = ch\n            else:\n                current = candidate\n        if current:\n            lines.append(current)\n\n    return lines or ['']\n\n\ndef build_bubble_image(message, max_width=220):\n    \"\"\"Build a PIL image for the toast bubble using the user asset when available.\"\"\"\n    message = (message or '').strip()\n    bubble_path = next((p for p in [os.path.join(SCRIPT_DIR, 'chat_bubble.png'),\n                                     os.path.join(SCRIPT_DIR, 'bubble.png')]\n                        if os.path.exists(p)), None)\n\n    if bubble_path:\n        bubble = Image.open(bubble_path).convert('RGBA')\n    else:\n        bubble = Image.new('RGBA', (256, 128), (255, 255, 255, 0))\n        draw = ImageDraw.Draw(bubble)\n        draw.rounded_rectangle((8, 8, 247, 87), radius=12, fill=(255, 255, 255, 255), outline=(0, 0, 0, 255), width=3)\n        draw.polygon([(48, 87), (72, 87), (56, 112)], fill=(255, 255, 255, 255), outline=(0, 0, 0, 255))\n\n    bubble = ImageOps.contain(bubble, (max_width, max(64, int(max_width * bubble.height / bubble.width))), Image.NEAREST)\n\n    # Detect the actual opaque bubble region to position text correctly\n    alpha = bubble.getchannel('A')\n    content_box = alpha.getbbox()  # (left, top, right, bottom) of opaque area\n    if content_box:\n        cb_left, cb_top, cb_right, cb_bottom = content_box\n    else:\n        cb_left, cb_top, cb_right, cb_bottom = 0, 0, bubble.width, bubble.height\n    content_w = cb_right - cb_left\n    content_h = cb_bottom - cb_top\n\n    font_size = max(12, content_h // 6)\n    font = _load_default_font(font_size)\n    draw = ImageDraw.Draw(bubble)\n\n    # Padding relative to the opaque bubble region, not the full image\n    inner_pad_x = max(6, content_w // 14)\n    inner_pad_top = max(4, content_h // 12)\n    inner_pad_bottom = max(12, content_h // 4)\n    text_area_width = max(36, content_w - inner_pad_x * 2)\n\n    lines = _wrap_text_for_width(draw, message, font, text_area_width)\n    ascent, descent = font.getmetrics() if hasattr(font, 'getmetrics') else (font_size, font_size // 4)\n    line_height = max(font_size, ascent + descent)\n    usable_h = content_h - inner_pad_top - inner_pad_bottom\n    max_lines = max(1, usable_h // line_height)\n    if len(lines) > max_lines:\n        lines = lines[:max_lines]\n        if lines:\n            last = lines[-1]\n            while last and draw.textbbox((0, 0), last + '…', font=font)[2] > text_area_width:\n                last = last[:-1]\n            lines[-1] = (last + '…') if last else '…'\n\n    total_text_height = len(lines) * line_height\n    y = cb_top + inner_pad_top + max(0, (usable_h - total_text_height) // 2) - 3\n\n    for line in lines:\n        bbox = draw.textbbox((0, 0), line, font=font)\n        text_width = bbox[2] - bbox[0]\n        x = cb_left + inner_pad_x + (text_area_width - text_width) / 2\n        draw.text((x, y), line, font=font, fill=(32, 32, 32, 255))\n        y += line_height\n\n    alpha = bubble.getchannel('A')\n    bbox = alpha.getbbox()\n    if bbox:\n        bubble = bubble.crop(bbox)\n\n    width, height = bubble.size\n    alpha = bubble.getchannel('A')\n    bottom_y = height - 1\n    tail_x = width // 2\n    for y in range(height - 1, -1, -1):\n        xs = [x for x in range(width) if alpha.getpixel((x, y)) > 0]\n        if xs:\n            bottom_y = y\n            tail_x = xs[len(xs) // 2]\n            break\n\n    return {\n        'image': bubble,\n        'size': bubble.size,\n        'tail_tip': (tail_x, bottom_y),\n    }\n\n# ============================================================================\n# Shared Base Class\n# ============================================================================\nclass PetBase:\n    \"\"\"Shared logic for Mac and Windows pet implementations.\"\"\"\n\n    def _schedule_main(self, fn):\n        \"\"\"Schedule fn on the GUI main thread. Subclasses must override.\"\"\"\n        raise NotImplementedError\n\n    def set_state_safe(self, state):\n        \"\"\"Thread-safe wrapper for set_state.\"\"\"\n        self._schedule_main(lambda: self.set_state(state))\n\n    def show_toast_safe(self, message):\n        \"\"\"Thread-safe wrapper for show_toast.\"\"\"\n        self._schedule_main(lambda m=message: self.show_toast(m))\n\n    def _start_server(self):\n        \"\"\"Start HTTP control server.\"\"\"\n        pet = self\n\n        class Handler(BaseHTTPRequestHandler):\n            def do_GET(self):\n                parsed = urlparse(self.path)\n                params = parse_qs(parsed.query)\n\n                if 'state' in params:\n                    state = params['state'][0]\n                    pet.set_state_safe(state)\n                    self.send_response(200)\n                    self.end_headers()\n                    self.wfile.write(b'ok')\n                elif 'msg' in params:\n                    msg = params['msg'][0]\n                    pet.show_toast_safe(msg)\n                    self.send_response(200)\n                    self.end_headers()\n                    self.wfile.write(b'ok')\n                else:\n                    self.send_response(400)\n                    self.end_headers()\n                    self.wfile.write(b'?state=idle/walk/run/sprint or ?msg=hello')\n\n            def do_POST(self):\n                body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode()\n                if body:\n                    pet.show_toast_safe(body)\n                    self.send_response(200)\n                    self.end_headers()\n                    self.wfile.write(b'ok')\n                else:\n                    self.send_response(400)\n                    self.end_headers()\n                    self.wfile.write(b'empty body')\n\n            def log_message(self, *a):\n                pass\n\n        try:\n            HTTPServer.allow_reuse_address = True\n            srv = HTTPServer(('127.0.0.1', PORT), Handler)\n            threading.Thread(target=srv.serve_forever, daemon=True).start()\n            print(f'✓ Server: http://127.0.0.1:{PORT}/?state=walk')\n        except OSError as e:\n            if e.errno == 48:\n                print(f'⚠ Port {PORT} already in use')\n            else:\n                raise\n\n\n# ============================================================================\n# macOS Implementation - Pure Cocoa with True Transparency\n# ============================================================================\nif sys.platform == 'darwin':\n    from Cocoa import (\n        NSApplication, NSWindow, NSImageView, NSImage, NSData, NSTimer,\n        NSMenu, NSMenuItem, NSApp, NSFloatingWindowLevel, NSColor,\n        NSBackingStoreBuffered, NSWindowStyleMaskBorderless,\n        NSApplicationActivationPolicyAccessory\n    )\n    from Foundation import NSMakeRect, NSMakePoint, NSMakeSize\n    from PyObjCTools import AppHelper\n    import objc\n\n    class MacPet(PetBase):\n        def __init__(self, skin_name=None):\n            self.app = NSApplication.sharedApplication()\n            self.app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)\n\n            # Load skin\n            self.load_skin(skin_name)\n            self.available_skins = SkinLoader.list_skins()\n\n            # Get screen size\n            from AppKit import NSScreen, NSWindowCollectionBehaviorCanJoinAllSpaces, NSWindowCollectionBehaviorStationary\n            screen = NSScreen.mainScreen()\n            screen_frame = screen.frame()\n            screen_width = screen_frame.size.width\n            screen_height = screen_frame.size.height\n\n            # Position at right side\n            x_pos = screen_width - 200\n            y_pos = 300\n\n            # Create transparent window\n            self.window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(\n                NSMakeRect(x_pos, y_pos, self.display_width, self.display_height),\n                NSWindowStyleMaskBorderless,\n                NSBackingStoreBuffered,\n                False\n            )\n\n            self.window.setOpaque_(False)\n            self.window.setBackgroundColor_(NSColor.clearColor())\n            self.window.setLevel_(NSFloatingWindowLevel)\n            self.window.setMovableByWindowBackground_(True)\n            self.window.setAcceptsMouseMovedEvents_(True)\n\n            # Make window sticky across spaces (stays in fixed screen position)\n            self.window.setCollectionBehavior_(\n                NSWindowCollectionBehaviorCanJoinAllSpaces |\n                NSWindowCollectionBehaviorStationary\n            )\n\n            # Create custom view for handling mouse events\n            from AppKit import NSView\n            from objc import super as objc_super\n\n            class DraggableImageView(NSView):\n                \"\"\"Custom view that handles dragging and double-click\"\"\"\n                def initWithFrame_(self, frame):\n                    self = objc_super(DraggableImageView, self).initWithFrame_(frame)\n                    if self is None:\n                        return None\n                    self.image_view = NSImageView.alloc().initWithFrame_(self.bounds())\n                    self.image_view.setImageScaling_(1)  # NSImageScaleProportionallyUpOrDown\n                    self.addSubview_(self.image_view)\n\n                    # Create overlay view for toast (always on top)\n                    # Make it non-opaque so it doesn't block the image\n                    self.overlay_view = NSView.alloc().initWithFrame_(self.bounds())\n                    self.overlay_view.setWantsLayer_(True)\n                    self.addSubview_(self.overlay_view)\n\n                    self.drag_start = None\n                    return self\n\n                def mouseDown_(self, event):\n                    \"\"\"Handle mouse down for dragging\"\"\"\n                    if event.clickCount() == 2:\n                        # Double-click to quit\n                        from AppKit import NSApp\n                        NSApp.terminate_(None)\n                    else:\n                        # Start dragging\n                        self.drag_start = event.locationInWindow()\n\n                def mouseDragged_(self, event):\n                    \"\"\"Handle mouse drag\"\"\"\n                    if self.drag_start:\n                        current_location = event.locationInWindow()\n                        window_frame = self.window().frame()\n\n                        dx = current_location.x - self.drag_start.x\n                        dy = current_location.y - self.drag_start.y\n\n                        new_origin = NSMakePoint(\n                            window_frame.origin.x + dx,\n                            window_frame.origin.y + dy\n                        )\n\n                        self.window().setFrameOrigin_(new_origin)\n\n                def acceptsFirstMouse_(self, event):\n                    \"\"\"Accept first mouse click\"\"\"\n                    return True\n\n                def rightMouseDown_(self, event):\n                    from AppKit import NSMenu, NSMenuItem, NSApp\n\n                    menu = NSMenu.alloc().init()\n                    pet = getattr(self, 'mac_pet', None) or self.window().delegate()\n                    if not pet:\n                        return\n\n                    for skin_name in pet.available_skins:  # preload this in MacPet.__init__\n                        item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(\n                            skin_name,\n                            'changeSkin:',\n                            ''\n                        )\n                        item.setTarget_(pet)\n                        item.setRepresentedObject_(skin_name)\n                        menu.addItem_(item)\n\n                    menu.addItem_(NSMenuItem.separatorItem())\n                    quit_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_('Quit', 'terminate:', '')\n                    menu.addItem_(quit_item)\n\n                    NSApp.activateIgnoringOtherApps_(True)\n                    NSMenu.popUpContextMenu_withEvent_forView_(menu, event, self)\n\n            # Create draggable view\n            self.content_view = DraggableImageView.alloc().initWithFrame_(\n                NSMakeRect(0, 0, self.display_width, self.display_height)\n            )\n            self.content_view.mac_pet = self\n            self.image_view = self.content_view.image_view\n            self.overlay_view = self.content_view.overlay_view\n            self.window.setContentView_(self.content_view)\n\n            # Animation state\n            self.current_state = 'idle'\n            self.frame_idx = 0\n\n            # Toast state\n            self.toast_label = None\n            self.toast_timer = None\n            self.toast_image = None\n            self.toast_window = None\n\n            # Start animation timer\n            self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(\n                1.0 / self.animations[self.current_state]['fps'],\n                self,\n                'animate:',\n                None,\n                True\n            )\n\n            # Show window\n            self.window.makeKeyAndOrderFront_(None)\n\n            # Start HTTP server\n            self._start_server()\n\n            print(f\"✓ macOS Pet started at ({x_pos}, {y_pos})\")\n            print(f\"  Animations: {', '.join(self.animations.keys())}\")\n\n        def load_skin(self, skin_name=None):\n            \"\"\"Load skin configuration and animations\"\"\"\n            available_skins = SkinLoader.list_skins()\n            if not available_skins:\n                raise FileNotFoundError(f\"No skins found in {SKINS_DIR}\")\n\n            if skin_name is None or skin_name not in available_skins:\n                skin_name = available_skins[0]\n\n            skin_path = os.path.join(SKINS_DIR, skin_name)\n            self.skin_config = SkinLoader.load_skin(skin_path)\n\n            # Get display size\n            display_size = self.skin_config.get('size', {})\n            self.display_width = display_size.get('width', 128)\n            self.display_height = display_size.get('height', 128)\n\n            # Load animations\n            self.animations = {}\n            for anim_name, anim_config in self.skin_config['animations'].items():\n                pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)\n\n                # Scale frames\n                scaled_frames = []\n                for frame in pil_frames:\n                    if frame.mode != 'RGBA':\n                        frame = frame.convert('RGBA')\n                    scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)\n                    scaled_frames.append(scaled)\n\n                # Convert to NSImage with proper alpha handling\n                ns_images = []\n                for pil_img in scaled_frames:\n                    # Convert PIL to PNG bytes (PNG preserves alpha channel)\n                    png_buffer = io.BytesIO()\n                    pil_img.save(png_buffer, format='PNG')\n                    png_data = png_buffer.getvalue()\n\n                    # Create NSImage from PNG data\n                    ns_data = NSData.dataWithBytes_length_(png_data, len(png_data))\n                    ns_image = NSImage.alloc().initWithData_(ns_data)\n                    ns_images.append(ns_image)\n\n                self.animations[anim_name] = {\n                    'frames': ns_images,\n                    'fps': anim_config.get('sprite', {}).get('fps', 6)\n                }\n\n        def animate_(self, timer):\n            \"\"\"Animation callback\"\"\"\n            anim = self.animations[self.current_state]\n            frames = anim['frames']\n\n            if frames:\n                self.image_view.setImage_(frames[self.frame_idx])\n                self.frame_idx = (self.frame_idx + 1) % len(frames)\n\n        def set_state(self, state):\n            \"\"\"Change animation state (must be called on main thread)\"\"\"\n            if state in self.animations and state != self.current_state:\n                self.current_state = state\n                self.frame_idx = 0\n\n                # Update timer interval\n                self.timer.invalidate()\n                self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(\n                    1.0 / self.animations[self.current_state]['fps'],\n                    self,\n                    'animate:',\n                    None,\n                    True\n                )\n                print(f\"→ State: {state}\")\n\n        def _schedule_main(self, fn):\n            AppHelper.callAfter(fn)\n\n        def show_toast(self, message):\n            \"\"\"Show toast message above pet\"\"\"\n            from AppKit import NSImageView\n\n            if self.toast_window:\n                self.toast_window.orderOut_(None)\n                self.toast_window = None\n                self.toast_label = None\n            if self.toast_timer:\n                self.toast_timer.invalidate()\n                self.toast_timer = None\n\n            bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))\n            bubble_pil = bubble_info['image']\n            bubble_width, bubble_height = bubble_info['size']\n            tail_x, tail_y = bubble_info['tail_tip']\n\n            png_buffer = io.BytesIO()\n            bubble_pil.save(png_buffer, format='PNG')\n            png_data = png_buffer.getvalue()\n            ns_data = NSData.dataWithBytes_length_(png_data, len(png_data))\n            self.toast_image = NSImage.alloc().initWithData_(ns_data)\n\n            pet_frame = self.window.frame()\n            anchor_x = pet_frame.origin.x + self.display_width * 0.75\n            anchor_y = pet_frame.origin.y + self.display_height * 1.65\n            toast_x = anchor_x - tail_x\n            toast_y = anchor_y - tail_y\n\n            self.toast_window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(\n                NSMakeRect(toast_x, toast_y, bubble_width, bubble_height),\n                NSWindowStyleMaskBorderless,\n                NSBackingStoreBuffered,\n                False\n            )\n            self.toast_window.setOpaque_(False)\n            self.toast_window.setBackgroundColor_(NSColor.clearColor())\n            self.toast_window.setLevel_(NSFloatingWindowLevel)\n            self.toast_window.setIgnoresMouseEvents_(True)\n            self.toast_window.setHasShadow_(False)\n\n            self.toast_label = NSImageView.alloc().initWithFrame_(\n                NSMakeRect(0, 0, bubble_width, bubble_height)\n            )\n            self.toast_label.setImage_(self.toast_image)\n            self.toast_label.setImageScaling_(0)\n            self.toast_window.setContentView_(self.toast_label)\n            self.toast_window.orderFrontRegardless()\n\n            self.toast_timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(\n                3.0,\n                self,\n                'hideToast:',\n                None,\n                False\n            )\n            print(f\"Toast: {message}\")\n\n        def hideToast_(self, timer):\n            \"\"\"Hide toast message\"\"\"\n            if self.toast_window:\n                self.toast_window.orderOut_(None)\n                self.toast_window = None\n            self.toast_label = None\n            self.toast_image = None\n            self.toast_timer = None\n\n        def run(self):\n            \"\"\"Run the application\"\"\"\n            AppHelper.runEventLoop()\n        \n        def changeSkin_(self, sender):\n            skin_name = sender.representedObject()\n            print(f\"Changing skin to: {skin_name}\")\n            self.load_skin(skin_name)\n            self.current_state = 'idle'\n            self.frame_idx = 0\n\n# ============================================================================\n# Windows/Linux Implementations\n# ============================================================================\nelse:\n    if sys.platform.startswith('win'):\n        import tkinter as tk\n        from PIL import ImageTk\n\n        class WinPet(PetBase):\n            def __init__(self, skin_name=None):\n                self.root = tk.Tk()\n                self.root.wm_attributes('-topmost', True)\n                self.is_windows = sys.platform.startswith('win')\n                self.platform_name = 'Windows' if self.is_windows else 'Linux'\n                self.pet_bg_color = '#F0F0F0' if self.is_windows else 'black'\n                self.toast_bg_color = '#00ff01' if self.is_windows else 'black'\n\n                # Load skin\n                self.load_skin(skin_name)\n\n                # Setup window\n                screen_width = self.root.winfo_screenwidth()\n                screen_height = self.root.winfo_screenheight()\n\n                x_pos = screen_width - 200\n                y_pos = screen_height - 300\n\n                self.root.geometry(f'{self.display_width}x{self.display_height}+{x_pos}+{y_pos}')\n                self.root.overrideredirect(True)\n                self.root.wm_attributes('-topmost', True)\n\n                # Transparent background\n                if self.is_windows:\n                    self.root.wm_attributes('-transparentcolor', self.pet_bg_color)\n                self.root.config(bg=self.pet_bg_color)\n\n                # Create label\n                self.label = tk.Label(self.root, bg=self.pet_bg_color, bd=0)\n                self.label.pack()\n\n                # Bind events\n                self.label.bind('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y)))\n                self.label.bind('<B1-Motion>', self._drag)\n                self.label.bind('<Double-1>', lambda e: (self.root.destroy(), os._exit(0)))\n                self.label.bind('<Button-3>', self._on_right_click)\n\n                # Animation state\n                self.current_state = 'idle'\n                self.frame_idx = 0\n\n                # Toast state\n                self.toast_window = None\n                self.toast_photo = None\n\n                # Start animation\n                self._animate()\n                self._start_server()\n\n                print(f\"✓ {self.platform_name} Pet started at ({x_pos}, {y_pos})\")\n                print(f\"  Animations: {', '.join(self.animations.keys())}\")\n\n                self.root.mainloop()\n\n            def load_skin(self, skin_name=None):\n                \"\"\"Load skin configuration and animations\"\"\"\n                available_skins = SkinLoader.list_skins()\n                if not available_skins:\n                    raise FileNotFoundError(f\"No skins found in {SKINS_DIR}\")\n\n                if skin_name is None or skin_name not in available_skins:\n                    skin_name = available_skins[0]\n\n                skin_path = os.path.join(SKINS_DIR, skin_name)\n                self.skin_config = SkinLoader.load_skin(skin_path)\n\n                # Get display size\n                display_size = self.skin_config.get('size', {})\n                self.display_width = display_size.get('width', 128)\n                self.display_height = display_size.get('height', 128)\n\n                # Load animations\n                self.animations = {}\n                for anim_name, anim_config in self.skin_config['animations'].items():\n                    pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)\n\n                    # Scale and convert frames\n                    tk_frames = []\n                    for frame in pil_frames:\n                        if frame.mode != 'RGBA':\n                            frame = frame.convert('RGBA')\n                        scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)\n                        tk_frames.append(ImageTk.PhotoImage(scaled))\n\n                    self.animations[anim_name] = {\n                        'frames': tk_frames,\n                        'fps': anim_config.get('sprite', {}).get('fps', 6)\n                    }\n\n            def set_state(self, state):\n                \"\"\"Change animation state\"\"\"\n                if state in self.animations and state != self.current_state:\n                    self.current_state = state\n                    self.frame_idx = 0\n                    print(f\"→ State: {state}\")\n\n            def _drag(self, e):\n                x = self.root.winfo_x() + e.x - self._d[0]\n                y = self.root.winfo_y() + e.y - self._d[1]\n                self.root.geometry(f'+{x}+{y}')\n\n            def _animate(self):\n                \"\"\"Animate current state\"\"\"\n                if self.current_state not in self.animations:\n                    self.root.after(100, self._animate)\n                    return\n\n                anim = self.animations[self.current_state]\n                frames = anim['frames']\n\n                if frames:\n                    self.label.config(image=frames[self.frame_idx])\n                    self.frame_idx = (self.frame_idx + 1) % len(frames)\n\n                delay = int(1000 / anim['fps'])\n                self.root.after(delay, self._animate)\n\n            def show_toast(self, message):\n                \"\"\"Show toast message above pet\"\"\"\n                if self.toast_window:\n                    try:\n                        self.toast_window.destroy()\n                    except:\n                        pass\n                    self.toast_window = None\n\n                bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))\n                bubble_pil = bubble_info['image']\n                bubble_width, bubble_height = bubble_info['size']\n                tail_x, tail_y = bubble_info['tail_tip']\n\n                self.toast_photo = ImageTk.PhotoImage(bubble_pil)\n\n                self.toast_window = tk.Toplevel(self.root)\n                self.toast_window.overrideredirect(True)\n                self.toast_window.wm_attributes('-topmost', True)\n                if self.is_windows:\n                    self.toast_window.wm_attributes('-transparentcolor', self.toast_bg_color)\n                self.toast_window.config(bg=self.toast_bg_color)\n\n                toast_label = tk.Label(\n                    self.toast_window,\n                    image=self.toast_photo,\n                    bg=self.toast_bg_color,\n                    bd=0,\n                    highlightthickness=0\n                )\n                toast_label.pack()\n\n                pet_x = self.root.winfo_x()\n                pet_y = self.root.winfo_y()\n                anchor_x = pet_x + int(self.display_width * 0.75)\n                anchor_y = pet_y\n                toast_x = anchor_x - tail_x\n                toast_y = anchor_y - bubble_height\n\n                self.toast_window.geometry(f'{bubble_width}x{bubble_height}+{toast_x}+{toast_y}')\n\n                self.root.after(3000, self._hide_toast)\n                print(f\"Toast: {message}\")\n\n            def _hide_toast(self):\n                \"\"\"Hide toast message\"\"\"\n                if self.toast_window:\n                    try:\n                        self.toast_window.destroy()\n                        self.toast_window = None\n                    except:\n                        pass\n\n            def _schedule_main(self, fn):\n                self.root.after(0, fn)\n\n            def run(self):\n                \"\"\"Run the application (already in mainloop)\"\"\"\n                pass\n            \n            def _on_right_click(self, event):\n                # Build a dynamic menu of all available skins\n                menu = tk.Menu(self.root, tearoff=0)\n                for skin_name in SkinLoader.list_skins():\n                    menu.add_command(\n                        label=skin_name,\n                        command=lambda name=skin_name: self._change_skin(name)\n                    )\n                menu.add_separator()\n                menu.add_command(label=\"Quit\", command=lambda: (self.root.destroy(), os._exit(0)))\n                menu.tk_popup(event.x_root, event.y_root)\n\n            def _change_skin(self, skin_name):\n                print(f\"Changing skin to: {skin_name}\")\n                self.load_skin(skin_name)\n                self.current_state = 'idle'\n                self.frame_idx = 0\n    else:\n        from PySide6.QtCore import Qt, QTimer, QPoint\n        from PySide6.QtGui import QAction, QCursor, QImage, QPixmap\n        from PySide6.QtWidgets import QApplication, QLabel, QMenu, QWidget\n\n        class _LinuxPetLabel(QLabel):\n            def __init__(self, pet):\n                super().__init__()\n                self.pet = pet\n                self.drag_offset = None\n\n            def mousePressEvent(self, event):\n                if event.button() == Qt.LeftButton:\n                    self.drag_offset = event.globalPosition().toPoint() - self.pet.window.frameGeometry().topLeft()\n                    event.accept()\n                    return\n                if event.button() == Qt.RightButton:\n                    self.pet._show_context_menu(event.globalPosition().toPoint())\n                    event.accept()\n                    return\n                super().mousePressEvent(event)\n\n            def mouseMoveEvent(self, event):\n                if self.drag_offset is not None and (event.buttons() & Qt.LeftButton):\n                    self.pet.window.move(event.globalPosition().toPoint() - self.drag_offset)\n                    self.pet._reposition_toast()\n                    event.accept()\n                    return\n                super().mouseMoveEvent(event)\n\n            def mouseReleaseEvent(self, event):\n                if event.button() == Qt.LeftButton:\n                    self.drag_offset = None\n                super().mouseReleaseEvent(event)\n\n            def mouseDoubleClickEvent(self, event):\n                if event.button() == Qt.LeftButton:\n                    QApplication.instance().quit()\n                    event.accept()\n                    return\n                super().mouseDoubleClickEvent(event)\n\n\n        class LinuxPet(PetBase):\n            def __init__(self, skin_name=None):\n                self.app = QApplication.instance() or QApplication(sys.argv)\n                self.available_skins = SkinLoader.list_skins()\n                self.load_skin(skin_name)\n\n                screen = self.app.primaryScreen()\n                screen_geo = screen.availableGeometry() if screen else None\n                if screen_geo:\n                    x_pos = screen_geo.right() - self.display_width - 72\n                    y_pos = screen_geo.bottom() - self.display_height - 120\n                else:\n                    x_pos, y_pos = 1200, 700\n\n                self.window = QWidget()\n                self.window.setWindowFlags(\n                    Qt.FramelessWindowHint |\n                    Qt.WindowStaysOnTopHint |\n                    Qt.Tool\n                )\n                self.window.setAttribute(Qt.WA_TranslucentBackground, True)\n                self.window.setAttribute(Qt.WA_ShowWithoutActivating, True)\n                self.window.resize(self.display_width, self.display_height)\n                self.window.move(x_pos, y_pos)\n\n                self.label = _LinuxPetLabel(self)\n                self.label.setParent(self.window)\n                self.label.setGeometry(0, 0, self.display_width, self.display_height)\n                self.label.setAttribute(Qt.WA_TranslucentBackground, True)\n                self.label.setStyleSheet('background: transparent;')\n                self.label.setScaledContents(True)\n\n                self.current_state = 'idle'\n                self.frame_idx = 0\n                self.toast_window = None\n                self.toast_label = None\n                self.toast_pixmap = None\n\n                self.anim_timer = QTimer()\n                self.anim_timer.timeout.connect(self._animate)\n                self._restart_animation_timer()\n\n                self.window.show()\n                self._start_server()\n\n                print(f\"✓ Linux PySide6 Pet started at ({x_pos}, {y_pos})\")\n                print(f\"  Animations: {', '.join(self.animations.keys())}\")\n\n            def _pil_to_qpixmap(self, pil_img):\n                buffer = io.BytesIO()\n                pil_img.save(buffer, format='PNG')\n                qimage = QImage.fromData(buffer.getvalue(), 'PNG')\n                return QPixmap.fromImage(qimage)\n\n            def load_skin(self, skin_name=None):\n                available_skins = SkinLoader.list_skins()\n                if not available_skins:\n                    raise FileNotFoundError(f\"No skins found in {SKINS_DIR}\")\n\n                if skin_name is None or skin_name not in available_skins:\n                    skin_name = available_skins[0]\n\n                skin_path = os.path.join(SKINS_DIR, skin_name)\n                self.skin_config = SkinLoader.load_skin(skin_path)\n\n                display_size = self.skin_config.get('size', {})\n                self.display_width = display_size.get('width', 128)\n                self.display_height = display_size.get('height', 128)\n\n                self.animations = {}\n                for anim_name, anim_config in self.skin_config['animations'].items():\n                    pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)\n                    qt_frames = []\n                    for frame in pil_frames:\n                        if frame.mode != 'RGBA':\n                            frame = frame.convert('RGBA')\n                        scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)\n                        qt_frames.append(self._pil_to_qpixmap(scaled))\n\n                    self.animations[anim_name] = {\n                        'frames': qt_frames,\n                        'fps': anim_config.get('sprite', {}).get('fps', 6)\n                    }\n\n                if hasattr(self, 'window'):\n                    self.window.resize(self.display_width, self.display_height)\n                    self.label.setGeometry(0, 0, self.display_width, self.display_height)\n                    self._animate(force=True)\n                    self._reposition_toast()\n\n            def _restart_animation_timer(self):\n                anim = self.animations.get(self.current_state) or next(iter(self.animations.values()))\n                fps = max(1, anim.get('fps', 6))\n                self.anim_timer.start(int(1000 / fps))\n\n            def _animate(self, force=False):\n                if self.current_state not in self.animations:\n                    return\n                anim = self.animations[self.current_state]\n                frames = anim['frames']\n                if not frames:\n                    return\n                if force:\n                    self.frame_idx = 0\n                self.label.setPixmap(frames[self.frame_idx])\n                self.frame_idx = (self.frame_idx + 1) % len(frames)\n\n            def set_state(self, state):\n                if state in self.animations and state != self.current_state:\n                    self.current_state = state\n                    self.frame_idx = 0\n                    self._restart_animation_timer()\n                    print(f\"→ State: {state}\")\n\n            def _show_context_menu(self, global_pos):\n                menu = QMenu(self.window)\n                for skin_name in SkinLoader.list_skins():\n                    action = QAction(skin_name, menu)\n                    action.triggered.connect(lambda checked=False, name=skin_name: self._change_skin(name))\n                    menu.addAction(action)\n                menu.addSeparator()\n                quit_action = QAction('Quit', menu)\n                quit_action.triggered.connect(QApplication.instance().quit)\n                menu.addAction(quit_action)\n                menu.popup(global_pos)\n\n            def _compute_toast_geometry(self, bubble_width, bubble_height, tail_x, tail_y):\n                pet_pos = self.window.frameGeometry().topLeft()\n                anchor_x = pet_pos.x() + int(self.display_width * 0.75)\n                anchor_y = pet_pos.y() + int(self.display_height * 0.15)\n                return anchor_x - tail_x, anchor_y - tail_y - bubble_height // 2\n\n            def show_toast(self, message):\n                if self.toast_window:\n                    self.toast_window.close()\n                    self.toast_window = None\n                    self.toast_label = None\n                    self.toast_pixmap = None\n\n                bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))\n                bubble_pil = bubble_info['image']\n                bubble_width, bubble_height = bubble_info['size']\n                tail_x, tail_y = bubble_info['tail_tip']\n                self.toast_pixmap = self._pil_to_qpixmap(bubble_pil)\n\n                self.toast_window = QWidget()\n                self.toast_window.setWindowFlags(\n                    Qt.FramelessWindowHint |\n                    Qt.WindowStaysOnTopHint |\n                    Qt.Tool |\n                    Qt.WindowTransparentForInput\n                )\n                self.toast_window.setAttribute(Qt.WA_TranslucentBackground, True)\n                self.toast_window.setAttribute(Qt.WA_ShowWithoutActivating, True)\n                self.toast_window.resize(bubble_width, bubble_height)\n\n                self.toast_label = QLabel(self.toast_window)\n                self.toast_label.setGeometry(0, 0, bubble_width, bubble_height)\n                self.toast_label.setPixmap(self.toast_pixmap)\n                self.toast_label.setAttribute(Qt.WA_TranslucentBackground, True)\n                self.toast_label.setStyleSheet('background: transparent;')\n\n                toast_x, toast_y = self._compute_toast_geometry(bubble_width, bubble_height, tail_x, tail_y)\n                self.toast_window.move(toast_x, toast_y)\n                self.toast_window.show()\n\n                QTimer.singleShot(3000, self._hide_toast)\n                print(f\"Toast: {message}\")\n\n            def _reposition_toast(self):\n                if not self.toast_window:\n                    return\n                label_pixmap = self.toast_label.pixmap() if self.toast_label else None\n                if label_pixmap is None:\n                    return\n                bubble_width = label_pixmap.width()\n                bubble_height = label_pixmap.height()\n                toast_x, toast_y = self._compute_toast_geometry(\n                    bubble_width,\n                    bubble_height,\n                    bubble_width // 2,\n                    bubble_height\n                )\n                self.toast_window.move(toast_x, toast_y)\n\n            def _hide_toast(self):\n                if self.toast_window:\n                    self.toast_window.close()\n                    self.toast_window = None\n                    self.toast_label = None\n                    self.toast_pixmap = None\n\n            def _schedule_main(self, fn):\n                QTimer.singleShot(0, fn)\n\n            def _change_skin(self, skin_name):\n                print(f\"Changing skin to: {skin_name}\")\n                self.load_skin(skin_name)\n                self.current_state = 'idle'\n                self.frame_idx = 0\n                self._restart_animation_timer()\n\n            def run(self):\n                self.app.exec()\n\nif __name__ == '__main__':\n    # Singleton: if port already in use, another instance is running\n    import socket\n    _s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n    try:\n        _s.connect(('127.0.0.1', PORT))\n        _s.close()\n        print(f'⚠ Pet already running on port {PORT}, exiting.')\n        sys.exit(0)\n    except ConnectionRefusedError:\n        pass\n\n    if sys.platform == 'darwin':\n        pet = MacPet('vita')\n        pet.run()\n    elif sys.platform.startswith('win'):\n        pet = WinPet('vita')\n    else:\n        pet = LinuxPet('vita')\n        pet.run()\n\n"
  },
  {
    "path": "frontends/dingtalkapp.py",
    "content": "import asyncio, json, os, sys, threading, time\nimport requests\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom agentmain import GeneraticAgent\nfrom chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text\nfrom llmcore import mykeys\n\ntry:\n    from dingtalk_stream import AckMessage, CallbackHandler, Credential, DingTalkStreamClient\n    from dingtalk_stream.chatbot import ChatbotMessage\nexcept Exception:\n    print(\"Please install dingtalk-stream to use DingTalk: pip install dingtalk-stream\")\n    sys.exit(1)\n\nagent = GeneraticAgent(); agent.verbose = False\nCLIENT_ID = str(mykeys.get(\"dingtalk_client_id\", \"\") or \"\").strip()\nCLIENT_SECRET = str(mykeys.get(\"dingtalk_client_secret\", \"\") or \"\").strip()\nALLOWED = {str(x).strip() for x in mykeys.get(\"dingtalk_allowed_users\", []) if str(x).strip()}\nUSER_TASKS = {}\n\n\nclass DingTalkApp(AgentChatMixin):\n    label, source, split_limit = \"DingTalk\", \"dingtalk\", 1800\n\n    def __init__(self):\n        super().__init__(agent, USER_TASKS)\n        self.client, self.access_token, self.token_expiry, self.background_tasks = None, None, 0, set()\n\n    async def _get_access_token(self):\n        if self.access_token and time.time() < self.token_expiry:\n            return self.access_token\n\n        def _fetch():\n            resp = requests.post(\"https://api.dingtalk.com/v1.0/oauth2/accessToken\", json={\"appKey\": CLIENT_ID, \"appSecret\": CLIENT_SECRET}, timeout=20)\n            resp.raise_for_status()\n            return resp.json()\n\n        last_err = None\n        for attempt in range(2):\n            try:\n                data = await asyncio.to_thread(_fetch)\n                self.access_token = data.get(\"accessToken\")\n                self.token_expiry = time.time() + int(data.get(\"expireIn\", 7200)) - 60\n                return self.access_token\n            except Exception as e:\n                last_err = e\n                if attempt == 0:\n                    await asyncio.sleep(1)\n        print(f\"[DingTalk] token error after retry: {last_err}\")\n        return None\n\n    async def _send_batch_message(self, chat_id, msg_key, msg_param):\n        token = await self._get_access_token()\n        if not token:\n            return False\n        headers = {\"x-acs-dingtalk-access-token\": token}\n        if chat_id.startswith(\"group:\"):\n            url = \"https://api.dingtalk.com/v1.0/robot/groupMessages/send\"\n            payload = {\"robotCode\": CLIENT_ID, \"openConversationId\": chat_id[6:], \"msgKey\": msg_key, \"msgParam\": json.dumps(msg_param, ensure_ascii=False)}\n        else:\n            url = \"https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend\"\n            payload = {\"robotCode\": CLIENT_ID, \"userIds\": [chat_id], \"msgKey\": msg_key, \"msgParam\": json.dumps(msg_param, ensure_ascii=False)}\n\n        def _post():\n            resp = requests.post(url, json=payload, headers=headers, timeout=20)\n            body = resp.text\n            if resp.status_code != 200:\n                raise RuntimeError(f\"HTTP {resp.status_code}: {body[:300]}\")\n            result = resp.json() if \"json\" in resp.headers.get(\"content-type\", \"\") else {}\n            errcode = result.get(\"errcode\")\n            if errcode not in (None, 0):\n                raise RuntimeError(f\"API errcode={errcode}: {body[:300]}\")\n            return True\n\n        try:\n            return await asyncio.to_thread(_post)\n        except Exception as e:\n            print(f\"[DingTalk] send error: {e}\")\n            return False\n\n    async def send_text(self, chat_id, content):\n        for part in split_text(content, self.split_limit):\n            await self._send_batch_message(chat_id, \"sampleMarkdown\", {\"text\": part, \"title\": \"Agent Reply\"})\n\n    async def on_message(self, content, sender_id, sender_name, conversation_type=None, conversation_id=None):\n        try:\n            if not content:\n                return\n            if not public_access(ALLOWED) and sender_id not in ALLOWED:\n                print(f\"[DingTalk] unauthorized user: {sender_id}\")\n                return\n            is_group = conversation_type == \"2\" and conversation_id\n            chat_id = f\"group:{conversation_id}\" if is_group else sender_id\n            print(f\"[DingTalk] message from {sender_name} ({sender_id}): {content}\")\n            if content.startswith(\"/\"):\n                return await self.handle_command(chat_id, content)\n            task = asyncio.create_task(self.run_agent(chat_id, content))\n            self.background_tasks.add(task)\n            task.add_done_callback(self.background_tasks.discard)\n        except Exception:\n            import traceback\n            print(\"[DingTalk] handle_message error\")\n            traceback.print_exc()\n\n    async def start(self):\n        self.client = DingTalkStreamClient(Credential(CLIENT_ID, CLIENT_SECRET))\n        self.client.register_callback_handler(ChatbotMessage.TOPIC, _DingTalkHandler(self))\n        print(\"[DingTalk] bot starting...\")\n        delay, max_delay = 5, 300\n        while True:\n            started_at = time.monotonic()\n            try:\n                await self.client.start()\n            except Exception as e:\n                print(f\"[DingTalk] stream error: {e}\")\n            # any session that lived >=60s is treated as healthy -> reset backoff\n            if time.monotonic() - started_at >= 60:\n                delay = 5\n            print(f\"[DingTalk] reconnect in {delay}s...\")\n            await asyncio.sleep(delay)\n            delay = min(delay * 2, max_delay)\n\n\nclass _DingTalkHandler(CallbackHandler):\n    def __init__(self, app):\n        super().__init__()\n        self.app = app\n\n    async def process(self, message):\n        try:\n            chatbot_msg = ChatbotMessage.from_dict(message.data)\n            text = getattr(getattr(chatbot_msg, \"text\", None), \"content\", \"\") or \"\"\n            extensions = getattr(chatbot_msg, \"extensions\", None) or {}\n            recognition = ((extensions.get(\"content\") or {}).get(\"recognition\") or \"\").strip() if isinstance(extensions, dict) else \"\"\n            if not (text := text.strip()):\n                text = recognition or str((message.data.get(\"text\", {}) or {}).get(\"content\", \"\") or \"\").strip()\n            sender_id = str(getattr(chatbot_msg, \"sender_staff_id\", None) or getattr(chatbot_msg, \"sender_id\", None) or \"unknown\")\n            sender_name = getattr(chatbot_msg, \"sender_nick\", None) or \"Unknown\"\n            await self.app.on_message(text, sender_id, sender_name, message.data.get(\"conversationType\"), message.data.get(\"conversationId\") or message.data.get(\"openConversationId\"))\n        except Exception as e:\n            print(f\"[DingTalk] callback error: {e}\")\n        return AckMessage.STATUS_OK, \"OK\"\n\n\nif __name__ == \"__main__\":\n    _LOCK_SOCK = ensure_single_instance(19530, \"DingTalk\")\n    require_runtime(agent, \"DingTalk\", dingtalk_client_id=CLIENT_ID, dingtalk_client_secret=CLIENT_SECRET)\n    redirect_log(__file__, \"dingtalkapp.log\", \"DingTalk\", ALLOWED)\n    threading.Thread(target=agent.run, daemon=True).start()\n    asyncio.run(DingTalkApp().start())\n"
  },
  {
    "path": "frontends/fsapp.py",
    "content": "import glob, json, os, queue as Q, re, sys, threading, time\n\nPROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\nsys.path.insert(0, PROJECT_ROOT)\nos.chdir(PROJECT_ROOT)\nfrom agentmain import GeneraticAgent\nfrom frontends.chatapp_common import format_restore\nfrom frontends.continue_cmd import handle_frontend_command as handle_continue_frontend, reset_conversation\nfrom llmcore import mykeys\n\nimport traceback\nimport lark_oapi as lark\nfrom lark_oapi.api.im.v1 import *\n\n_TAG_PATS = [r\"<\" + t + r\">.*?</\" + t + r\">\" for t in (\"thinking\", \"summary\", \"tool_use\", \"file_content\")]\n_IMAGE_EXTS = {\".png\", \".jpg\", \".jpeg\", \".gif\", \".bmp\", \".webp\", \".ico\", \".tiff\", \".tif\"}\n_AUDIO_EXTS = {\".opus\", \".mp3\", \".wav\", \".m4a\", \".aac\"}\n_VIDEO_EXTS = {\".mp4\", \".mov\", \".avi\", \".mkv\", \".webm\"}\n_FILE_TYPE_MAP = {\n    \".opus\": \"opus\",\n    \".mp4\": \"mp4\",\n    \".pdf\": \"pdf\",\n    \".doc\": \"doc\",\n    \".docx\": \"doc\",\n    \".xls\": \"xls\",\n    \".xlsx\": \"xls\",\n    \".ppt\": \"ppt\",\n    \".pptx\": \"ppt\",\n}\n_MSG_TYPE_MAP = {\"image\": \"[image]\", \"audio\": \"[audio]\", \"file\": \"[file]\", \"media\": \"[media]\", \"sticker\": \"[sticker]\"}\n\nTEMP_DIR = os.path.join(PROJECT_ROOT, \"temp\")\nMEDIA_DIR = os.path.join(TEMP_DIR, \"feishu_media\")\nos.makedirs(MEDIA_DIR, exist_ok=True)\n\n\n_TRUNC_TAIL = 300  # 截断兜底时保留原文尾部字符数\n\n\ndef _clean(text):\n    for pat in _TAG_PATS:\n        text = re.sub(pat, \"\", text or \"\", flags=re.DOTALL)\n    return re.sub(r\"\\n{3,}\", \"\\n\\n\", text).strip()\n\n\ndef _extract_files(text):\n    return re.findall(r\"\\[FILE:([^\\]]+)\\]\", text or \"\")\n\n\ndef _strip_files(text):\n    return re.sub(r\"\\[FILE:[^\\]]+\\]\", \"\", text or \"\").strip()\n\n\ndef _display_text(text):\n    cleaned = _strip_files(_clean(text))\n    if cleaned:\n        return cleaned\n    tail = (text or \"\").strip()[-_TRUNC_TAIL:]\n    return \"⚠️ 模型输出被截断或为空\" + (f\"\\n…{tail}\" if tail else \"\")\n\n\ndef _to_allowed_set(value):\n    if value is None:\n        return set()\n    if isinstance(value, str):\n        value = [value]\n    return {str(x).strip() for x in value if str(x).strip()}\n\n\ndef _parse_json(raw):\n    if not raw:\n        return {}\n    try:\n        return json.loads(raw)\n    except Exception:\n        return {}\n\n\ndef _extract_share_card_content(content_json, msg_type):\n    parts = []\n    if msg_type == \"share_chat\":\n        parts.append(f\"[shared chat: {content_json.get('chat_id', '')}]\")\n    elif msg_type == \"share_user\":\n        parts.append(f\"[shared user: {content_json.get('user_id', '')}]\")\n    elif msg_type == \"interactive\":\n        parts.extend(_extract_interactive_content(content_json))\n    elif msg_type == \"share_calendar_event\":\n        parts.append(f\"[shared calendar event: {content_json.get('event_key', '')}]\")\n    elif msg_type == \"system\":\n        parts.append(\"[system message]\")\n    elif msg_type == \"merge_forward\":\n        parts.append(\"[merged forward messages]\")\n    return \"\\n\".join([p for p in parts if p]).strip() or f\"[{msg_type}]\"\n\n\ndef _extract_interactive_content(content):\n    parts = []\n    if isinstance(content, str):\n        try:\n            content = json.loads(content)\n        except Exception:\n            return [content] if content.strip() else []\n    if not isinstance(content, dict):\n        return parts\n    title = content.get(\"title\")\n    if isinstance(title, dict):\n        title_text = title.get(\"content\", \"\") or title.get(\"text\", \"\")\n        if title_text:\n            parts.append(f\"title: {title_text}\")\n    elif isinstance(title, str) and title:\n        parts.append(f\"title: {title}\")\n    elements = content.get(\"elements\", [])\n    if isinstance(elements, list):\n        for row in elements:\n            if isinstance(row, dict):\n                parts.extend(_extract_element_content(row))\n            elif isinstance(row, list):\n                for el in row:\n                    parts.extend(_extract_element_content(el))\n    card = content.get(\"card\", {})\n    if card:\n        parts.extend(_extract_interactive_content(card))\n    header = content.get(\"header\", {})\n    if isinstance(header, dict):\n        header_title = header.get(\"title\", {})\n        if isinstance(header_title, dict):\n            header_text = header_title.get(\"content\", \"\") or header_title.get(\"text\", \"\")\n            if header_text:\n                parts.append(f\"title: {header_text}\")\n    return [p for p in parts if p]\n\n\ndef _extract_element_content(element):\n    parts = []\n    if not isinstance(element, dict):\n        return parts\n    tag = element.get(\"tag\", \"\")\n    if tag in (\"markdown\", \"lark_md\"):\n        content = element.get(\"content\", \"\")\n        if content:\n            parts.append(content)\n    elif tag == \"div\":\n        text = element.get(\"text\", {})\n        if isinstance(text, dict):\n            text_content = text.get(\"content\", \"\") or text.get(\"text\", \"\")\n            if text_content:\n                parts.append(text_content)\n        elif isinstance(text, str) and text:\n            parts.append(text)\n        for field in element.get(\"fields\", []) or []:\n            if isinstance(field, dict):\n                field_text = field.get(\"text\", {})\n                if isinstance(field_text, dict):\n                    content = field_text.get(\"content\", \"\") or field_text.get(\"text\", \"\")\n                    if content:\n                        parts.append(content)\n    elif tag == \"a\":\n        href = element.get(\"href\", \"\")\n        text = element.get(\"text\", \"\")\n        if href:\n            parts.append(f\"link: {href}\")\n        if text:\n            parts.append(text)\n    elif tag == \"button\":\n        text = element.get(\"text\", {})\n        if isinstance(text, dict):\n            content = text.get(\"content\", \"\") or text.get(\"text\", \"\")\n            if content:\n                parts.append(content)\n        url = element.get(\"url\", \"\") or (element.get(\"multi_url\", {}) or {}).get(\"url\", \"\")\n        if url:\n            parts.append(f\"link: {url}\")\n    elif tag == \"img\":\n        alt = element.get(\"alt\", {})\n        if isinstance(alt, dict):\n            parts.append(alt.get(\"content\", \"[image]\") or \"[image]\")\n        else:\n            parts.append(\"[image]\")\n    for child in element.get(\"elements\", []) or []:\n        parts.extend(_extract_element_content(child))\n    for col in element.get(\"columns\", []) or []:\n        for child in (col.get(\"elements\", []) if isinstance(col, dict) else []):\n            parts.extend(_extract_element_content(child))\n    return parts\n\n\ndef _extract_post_content(content_json):\n    def _parse_block(block):\n        if not isinstance(block, dict) or not isinstance(block.get(\"content\"), list):\n            return None, []\n        texts, images = [], []\n        if block.get(\"title\"):\n            texts.append(block.get(\"title\"))\n        for row in block[\"content\"]:\n            if not isinstance(row, list):\n                continue\n            for el in row:\n                if not isinstance(el, dict):\n                    continue\n                tag = el.get(\"tag\")\n                if tag in (\"text\", \"a\"):\n                    texts.append(el.get(\"text\", \"\"))\n                elif tag == \"at\":\n                    texts.append(f\"@{el.get('user_name', 'user')}\")\n                elif tag == \"img\" and el.get(\"image_key\"):\n                    images.append(el[\"image_key\"])\n        text = \" \".join([t for t in texts if t]).strip()\n        return text or None, images\n\n    root = content_json\n    if isinstance(root, dict) and isinstance(root.get(\"post\"), dict):\n        root = root[\"post\"]\n    if not isinstance(root, dict):\n        return \"\", []\n    if \"content\" in root:\n        text, imgs = _parse_block(root)\n        if text or imgs:\n            return text or \"\", imgs\n    for key in (\"zh_cn\", \"en_us\", \"ja_jp\"):\n        if key in root:\n            text, imgs = _parse_block(root[key])\n            if text or imgs:\n                return text or \"\", imgs\n    for val in root.values():\n        if isinstance(val, dict):\n            text, imgs = _parse_block(val)\n            if text or imgs:\n                return text or \"\", imgs\n    return \"\", []\n\n\nAPP_ID = str(mykeys.get(\"fs_app_id\", \"\") or \"\").strip()\nAPP_SECRET = str(mykeys.get(\"fs_app_secret\", \"\") or \"\").strip()\nALLOWED_USERS = _to_allowed_set(mykeys.get(\"fs_allowed_users\", []))\nPUBLIC_ACCESS = not ALLOWED_USERS or \"*\" in ALLOWED_USERS\nAGENT_TIMEOUT_SEC = 900\n\nagent = GeneraticAgent()\nthreading.Thread(target=agent.run, daemon=True).start()\nclient, user_tasks = None, {}\n\n\ndef create_client():\n    return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build()\n\n\ndef _card_raw(elements):\n    return json.dumps({\n        \"schema\": \"2.0\",\n        \"config\": {\"streaming_mode\": False, \"width_mode\": \"fill\"},\n        \"body\": {\"elements\": elements},\n    }, ensure_ascii=False)\n\n\ndef _card(text):\n    return _card_raw([{\"tag\": \"markdown\", \"content\": text}])\n\n\ndef _send_raw(receive_id, payload, msg_type, rtype):\n    try:\n        body = CreateMessageRequest.builder().receive_id_type(rtype).request_body(\n            CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build()\n        ).build()\n        r = client.im.v1.message.create(body)\n        if r.success():\n            return r.data.message_id if r.data else None\n        print(f\"发送失败: {r.code}, {r.msg}\")\n    except Exception as e:\n        print(f\"[ERROR] _send_raw 网络异常: {e}\")\n    return None\n\n\ndef _patch_card(message_id, card_json):\n    return _patch_card_result(message_id, card_json)[0]\n\n\ndef _patch_card_result(message_id, card_json):\n    try:\n        body = PatchMessageRequest.builder().message_id(message_id).request_body(\n            PatchMessageRequestBody.builder().content(card_json).build()\n        ).build()\n        r = client.im.v1.message.patch(body)\n        if not r.success():\n            print(f\"[ERROR] patch_card 失败: {r.code}, {r.msg}\")\n        msg = f\"{getattr(r, 'code', '')} {getattr(r, 'msg', '')}\".lower()\n        return r.success(), (\"230099\" in msg or \"11310\" in msg or \"element exceeds the limit\" in msg)\n    except Exception as e:\n        print(f\"[ERROR] _patch_card 网络异常: {e}\")\n        return False, False\n\n\ndef send_message(receive_id, content, msg_type=\"text\", use_card=False, receive_id_type=\"open_id\"):\n    if use_card:\n        return _send_raw(receive_id, _card(content), \"interactive\", receive_id_type)\n    if msg_type == \"text\":\n        return _send_raw(receive_id, json.dumps({\"text\": content}, ensure_ascii=False), \"text\", receive_id_type)\n    return _send_raw(receive_id, content, msg_type, receive_id_type)\n\n\ndef update_message(message_id, content):\n    return _patch_card(message_id, _card(content))\n\n\ndef _upload_image_sync(file_path):\n    try:\n        with open(file_path, \"rb\") as f:\n            request = CreateImageRequest.builder().request_body(\n                CreateImageRequestBody.builder().image_type(\"message\").image(f).build()\n            ).build()\n            response = client.im.v1.image.create(request)\n            if response.success():\n                return response.data.image_key\n            print(f\"[ERROR] upload image failed: {response.code}, {response.msg}\")\n    except Exception as e:\n        print(f\"[ERROR] upload image failed {file_path}: {e}\")\n    return None\n\n\ndef _upload_file_sync(file_path):\n    ext = os.path.splitext(file_path)[1].lower()\n    file_type = _FILE_TYPE_MAP.get(ext, \"stream\")\n    file_name = os.path.basename(file_path)\n    try:\n        with open(file_path, \"rb\") as f:\n            request = CreateFileRequest.builder().request_body(\n                CreateFileRequestBody.builder().file_type(file_type).file_name(file_name).file(f).build()\n            ).build()\n            response = client.im.v1.file.create(request)\n            if response.success():\n                return response.data.file_key\n            print(f\"[ERROR] upload file failed: {response.code}, {response.msg}\")\n    except Exception as e:\n        print(f\"[ERROR] upload file failed {file_path}: {e}\")\n    return None\n\n\ndef _download_image_sync(message_id, image_key):\n    try:\n        request = GetMessageResourceRequest.builder().message_id(message_id).file_key(image_key).type(\"image\").build()\n        response = client.im.v1.message_resource.get(request)\n        if response.success():\n            data = response.file.read() if hasattr(response.file, \"read\") else response.file\n            return data, response.file_name\n        print(f\"[ERROR] download image failed: {response.code}, {response.msg}\")\n    except Exception as e:\n        print(f\"[ERROR] download image failed {image_key}: {e}\")\n    return None, None\n\n\ndef _download_file_sync(message_id, file_key, resource_type=\"file\"):\n    if resource_type == \"audio\":\n        resource_type = \"file\"\n    try:\n        request = GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(resource_type).build()\n        response = client.im.v1.message_resource.get(request)\n        if response.success():\n            data = response.file.read() if hasattr(response.file, \"read\") else response.file\n            return data, response.file_name\n        print(f\"[ERROR] download {resource_type} failed: {response.code}, {response.msg}\")\n    except Exception as e:\n        print(f\"[ERROR] download {resource_type} failed {file_key}: {e}\")\n    return None, None\n\n\ndef _download_and_save_media(msg_type, content_json, message_id):\n    data, filename = None, None\n    if msg_type == \"image\":\n        image_key = content_json.get(\"image_key\")\n        if image_key and message_id:\n            data, filename = _download_image_sync(message_id, image_key)\n            if not filename:\n                filename = f\"{image_key[:16]}.jpg\"\n    elif msg_type in (\"audio\", \"file\", \"media\"):\n        file_key = content_json.get(\"file_key\")\n        if file_key and message_id:\n            data, filename = _download_file_sync(message_id, file_key, msg_type)\n            if not filename:\n                filename = file_key[:16]\n            if msg_type == \"audio\" and filename and not filename.endswith(\".opus\"):\n                filename = f\"{filename}.opus\"\n    if data and filename:\n        file_path = os.path.join(MEDIA_DIR, os.path.basename(filename))\n        with open(file_path, \"wb\") as f:\n            f.write(data)\n        return file_path, filename\n    return None, None\n\n\ndef _describe_media(msg_type, file_path, filename):\n    if msg_type == \"image\":\n        return f\"[image: {filename}]\\n[Image: source: {file_path}]\"\n    if msg_type == \"audio\":\n        return f\"[audio: {filename}]\\n[File: source: {file_path}]\"\n    if msg_type in (\"file\", \"media\"):\n        return f\"[{msg_type}: {filename}]\\n[File: source: {file_path}]\"\n    return f\"[{msg_type}]\\n[File: source: {file_path}]\"\n\n\ndef _send_local_file(receive_id, file_path, receive_id_type=\"open_id\"):\n    if not os.path.isfile(file_path):\n        send_message(receive_id, f\"⚠️ 文件不存在: {file_path}\", receive_id_type=receive_id_type)\n        return False\n    ext = os.path.splitext(file_path)[1].lower()\n    if ext in _IMAGE_EXTS:\n        image_key = _upload_image_sync(file_path)\n        if image_key:\n            send_message(receive_id, json.dumps({\"image_key\": image_key}, ensure_ascii=False), msg_type=\"image\", receive_id_type=receive_id_type)\n            return True\n    else:\n        file_key = _upload_file_sync(file_path)\n        if file_key:\n            msg_type = \"media\" if ext in _AUDIO_EXTS or ext in _VIDEO_EXTS else \"file\"\n            send_message(receive_id, json.dumps({\"file_key\": file_key}, ensure_ascii=False), msg_type=msg_type, receive_id_type=receive_id_type)\n            return True\n    send_message(receive_id, f\"⚠️ 文件发送失败: {os.path.basename(file_path)}\", receive_id_type=receive_id_type)\n    return False\n\n\ndef _send_generated_files(receive_id, raw_text, receive_id_type=\"open_id\"):\n    for file_path in _extract_files(raw_text):\n        _send_local_file(receive_id, file_path, receive_id_type)\n\n\ndef _build_user_message(message):\n    msg_type = message.message_type\n    message_id = message.message_id\n    content_json = _parse_json(message.content)\n    parts, image_paths = [], []\n    if msg_type == \"text\":\n        text = str(content_json.get(\"text\", \"\") or \"\").strip()\n        if text:\n            parts.append(text)\n    elif msg_type == \"post\":\n        text, image_keys = _extract_post_content(content_json)\n        if text:\n            parts.append(text)\n        for image_key in image_keys:\n            file_path, filename = _download_and_save_media(\"image\", {\"image_key\": image_key}, message_id)\n            if file_path and filename:\n                parts.append(_describe_media(\"image\", file_path, filename))\n                image_paths.append(file_path)\n            else:\n                parts.append(\"[image: download failed]\")\n    elif msg_type in (\"image\", \"audio\", \"file\", \"media\"):\n        file_path, filename = _download_and_save_media(msg_type, content_json, message_id)\n        if file_path and filename:\n            parts.append(_describe_media(msg_type, file_path, filename))\n            if msg_type == \"image\":\n                image_paths.append(file_path)\n        else:\n            parts.append(f\"[{msg_type}: download failed]\")\n    elif msg_type in (\"share_chat\", \"share_user\", \"interactive\", \"share_calendar_event\", \"system\", \"merge_forward\"):\n        parts.append(_extract_share_card_content(content_json, msg_type))\n    else:\n        parts.append(_MSG_TYPE_MAP.get(msg_type, f\"[{msg_type}]\"))\n    return \"\\n\".join([p for p in parts if p]).strip(), image_paths\n\n\ndef _fmt_tool_call(tc):\n    name = tc.get('tool_name', '?')\n    args = {k: v for k, v in (tc.get('args') or {}).items() if not k.startswith('_')}\n    return f\"- `{name}`({json.dumps(args, ensure_ascii=False)[:200]})\"\n\n\ndef _build_step_detail(resp, tool_calls):\n    \"\"\"从 LLM response + tool_calls 组装单步展开详情（纯函数）。\"\"\"\n    parts = []\n    thinking = (getattr(resp, 'thinking', '') or '').strip() if resp else ''\n    if thinking:\n        parts.append(f\"### 💭 Thinking\\n{thinking}\")\n    if tool_calls:\n        parts.append(\"### 🛠 Tool Calls\\n\" + \"\\n\".join(_fmt_tool_call(tc) for tc in tool_calls))\n    content = _display_text((getattr(resp, 'content', '') or '')).strip() if resp else ''\n    if content and content != '...':\n        parts.append(f\"### 📝 Output\\n{content}\")\n    return \"\\n\\n\".join(parts)\n\n\nclass _TaskCard:\n    \"\"\"飞书任务卡片：单卡片持续 patch；每步一个独立折叠面板（header 显示 summary，展开看详情）。\"\"\"\n    _DETAIL_LIMIT = 8000\n    _FINAL_LIMIT = 6000\n\n    def __init__(self, receive_id, rid_type):\n        self.rid, self.rtype = receive_id, rid_type\n        self.steps = []          # [(summary, detail), ...]\n        self.status = \"🤔 思考中...\"\n        self.final = None\n        self.msg_id = None\n        self.page_no = 1\n        self.turn_no = 0\n        self.turn_base = 1\n        self.note = None\n\n    def _step_panel(self, idx, summary, detail):\n        detail = detail or \"_(无输出)_\"\n        if len(detail) > self._DETAIL_LIMIT:\n            detail = detail[:self._DETAIL_LIMIT] + f\"\\n\\n…(已截断,共 {len(detail)} 字符)\"\n        return {\n            \"tag\": \"collapsible_panel\", \"expanded\": False,\n            \"header\": {\"title\": {\"tag\": \"plain_text\", \"content\": f\"Turn {idx} · {summary}\"}},\n            \"elements\": [{\"tag\": \"markdown\", \"content\": detail}],\n        }\n\n    def _build(self):\n        header = f\"**{self.status}**\"\n        if self.page_no > 1:\n            header += f\"\\n\\n📄 工作卡片 {self.page_no}\"\n        els = [{\"tag\": \"markdown\", \"content\": header}]\n        if self.note:\n            els.append({\"tag\": \"markdown\", \"content\": self.note})\n        for i, (s, d) in enumerate(self.steps, self.turn_base):\n            els.append(self._step_panel(i, s, d))\n        if self.final:\n            els += [{\"tag\": \"hr\"}, {\"tag\": \"markdown\", \"content\": self.final}]\n        return _card_raw(els)\n\n    def _push(self):\n        card = self._build()\n        if self.msg_id:\n            return _patch_card_result(self.msg_id, card)\n        else:\n            self.msg_id = _send_raw(self.rid, card, \"interactive\", self.rtype)\n            return bool(self.msg_id), False\n\n    def _rollover(self):\n        self.page_no += 1\n        self.msg_id = None\n        self.final = None\n        self.note = \"⚠️ 上一张工作卡片达到飞书限制，本页继续展示后续进展。\"\n\n    # ── 公开接口 ──\n\n    def start(self):\n        self._push()\n\n    def step(self, summary, detail=\"\"):\n        self.turn_no += 1\n        step = (summary, detail)\n        self.steps.append(step)\n        self.status = f\"⏳ 工作中 · Turn {self.turn_no}\"\n        ok, limit = self._push()\n        if limit:\n            self.steps.pop()\n            self._rollover()\n            self.turn_base = self.turn_no\n            self.steps = [step]\n            self._push()\n\n    def done(self, text):\n        self.status = \"✅ 已完成\"\n        self.final = (text or \"_(无文本输出)_\")[:self._FINAL_LIMIT]\n        ok, limit = self._push()\n        if limit:\n            self._rollover()\n            self.steps = []\n            self.turn_base = self.turn_no + 1\n            self.final = (text or \"_(无文本输出)_\")[:self._FINAL_LIMIT]\n            self._push()\n\n    def fail(self, msg):\n        self.status = f\"❌ {msg}\"\n        self._push()\n\n\ndef _make_task_hook(card, done_event, on_final):\n    \"\"\"飞书任务 hook：每轮 patch 卡片状态；结束触发 on_final(raw) 处理附件。\"\"\"\n    def hook(ctx):\n        try:\n            if ctx.get('exit_reason'):\n                resp = ctx.get('response')\n                raw = resp.content if hasattr(resp, 'content') else str(resp)\n                card.done(_display_text(raw))\n                on_final(raw)\n                done_event.set()\n            elif ctx.get('summary'):\n                detail = _build_step_detail(ctx.get('response'), ctx.get('tool_calls') or [])\n                card.step(ctx['summary'], detail)\n        except Exception as e:\n            print(f\"[fs hook] error: {e}\")\n    return hook\n\n\ndef handle_message(data):\n    event, message, sender = data.event, data.event.message, data.event.sender\n    open_id = sender.sender_id.open_id\n    chat_id = message.chat_id\n    if not PUBLIC_ACCESS and open_id not in ALLOWED_USERS:\n        print(f\"未授权用户: {open_id}\")\n        return\n    user_input, image_paths = _build_user_message(message)\n    if not user_input:\n        if chat_id:\n            send_message(chat_id, f\"⚠️ 暂不支持处理此类飞书消息：{message.message_type}\", receive_id_type=\"chat_id\")\n        else:\n            send_message(open_id, f\"⚠️ 暂不支持处理此类飞书消息：{message.message_type}\")\n        return\n    print(f\"收到消息 [{open_id}] ({message.message_type}, {len(image_paths)} images): {user_input[:200]}\")\n    if message.message_type == \"text\" and user_input.startswith(\"/\"):\n        return handle_command(open_id, user_input, chat_id)\n\n    def run_agent():\n        user_tasks[open_id] = {\"running\": True}\n        receive_id = chat_id or open_id\n        rid_type = \"chat_id\" if chat_id else \"open_id\"\n        done_event = threading.Event()\n        hook_key = f\"fs_{open_id}\"\n        card = _TaskCard(receive_id, rid_type)\n        card.start()\n        on_final = lambda raw: _send_generated_files(receive_id, raw, receive_id_type=rid_type)\n        if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {}\n        agent._turn_end_hooks[hook_key] = _make_task_hook(card, done_event, on_final)\n        try:\n            agent.put_task(user_input, source=\"feishu\", images=image_paths)\n            start = time.time()\n            while not done_event.wait(timeout=3):\n                if not user_tasks.get(open_id, {}).get(\"running\", True):\n                    agent.abort()\n                    card.fail(\"已停止\")\n                    break\n                if time.time() - start > AGENT_TIMEOUT_SEC:\n                    agent.abort()\n                    card.fail(\"任务超时\")\n                    break\n        except Exception as e:\n            traceback.print_exc()\n            card.fail(f\"错误: {e}\")\n        finally:\n            agent._turn_end_hooks.pop(hook_key, None)\n            user_tasks.pop(open_id, None)\n\n    threading.Thread(target=run_agent, daemon=True).start()\n\n\ndef handle_command(open_id, cmd, chat_id=None):\n    def _send_cmd_response(content):\n        if chat_id:\n            send_message(chat_id, content, receive_id_type=\"chat_id\")\n        else:\n            send_message(open_id, content)\n    parts = (cmd or \"\").split()\n    op = (parts[0] if parts else \"\").lower()\n    if op == \"/stop\":\n        if open_id in user_tasks:\n            user_tasks[open_id][\"running\"] = False\n        agent.abort()\n        _send_cmd_response(\"正在停止...\")\n    elif op == \"/new\":\n        _send_cmd_response(reset_conversation(agent))\n    elif op == \"/help\":\n        _send_cmd_response(\"命令列表:\\n/stop - 停止当前任务\\n/status - 查看状态\\n/llm - 查看当前模型列表\\n/llm [n] - 切换到第 n 个模型\\n/restore - 恢复上次对话历史\\n/continue - 列出可恢复会话\\n/continue [n] - 恢复第 n 个会话\\n/new - 开启新对话并清空当前上下文\\n/help - 显示帮助\")\n    elif op == \"/status\":\n        llm = agent.get_llm_name() if agent.llmclient else \"未配置\"\n        _send_cmd_response(f\"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\\nLLM: [{agent.llm_no}] {llm}\")\n    elif op == \"/llm\":\n        if not agent.llmclient:\n            return _send_cmd_response(\"❌ 当前没有可用的 LLM 配置\")\n        if len(parts) > 1:\n            try:\n                agent.next_llm(int(parts[1]))\n                return _send_cmd_response(f\"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}\")\n            except Exception:\n                return _send_cmd_response(f\"用法: /llm <0-{len(agent.list_llms()) - 1}>\")\n        lines = [f\"{'→' if cur else '  '} [{i}] {name}\" for i, name, cur in agent.list_llms()]\n        _send_cmd_response(\"LLMs:\\n\" + \"\\n\".join(lines))\n    elif op == \"/restore\":\n        try:\n            restored_info, err = format_restore()\n            if err:\n                return _send_cmd_response(err.replace(\"❌ \", \"\"))\n            restored, fname, count = restored_info\n            agent.history.extend(restored)\n            agent.abort()\n            _send_cmd_response(f\"已恢复 {count} 轮对话\\n来源: {fname}\\n(仅恢复上下文，请输入新问题继续)\")\n        except Exception as e:\n            _send_cmd_response(f\"恢复失败: {e}\")\n    elif op == \"/continue\" or cmd.startswith(\"/continue\"):\n        _send_cmd_response(handle_continue_frontend(agent, cmd))\n    else:\n        _send_cmd_response(f\"未知命令: {cmd}\")\n\n\ndef main():\n    global client\n    if not APP_ID or not APP_SECRET:\n        print(\"错误: 请在 mykey.py 或 mykey.json 中配置 fs_app_id 和 fs_app_secret\")\n        sys.exit(1)\n    client = create_client()\n    handler = lark.EventDispatcherHandler.builder(\"\", \"\").register_p2_im_message_receive_v1(handle_message).build()\n    print(\"=\" * 50 + \"\\n飞书 Agent 已启动（长连接模式）\\n\" + f\"App ID: {APP_ID}\\n等待消息...\\n\" + \"=\" * 50)\n    retry_delay = 5\n    while True:\n        try:\n            cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO)\n            cli.start()\n        except Exception as e:\n            print(f\"[WARN] 飞书长连接断开或启动失败: {e}\")\n        print(f\"[INFO] {retry_delay}s 后重连...\")\n        time.sleep(retry_delay)\n        retry_delay = min(retry_delay * 2, 120)\n        # 重连时刷新 client\n        try:\n            client = create_client()\n        except Exception:\n            pass\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "frontends/genericagent_acp_bridge.py",
    "content": "import io\nimport json\nimport os\nimport sys\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n\n# Must run BEFORE importing agentmain — it reconfigures stdout at import time,\n# and its submodules may print() during init.  We capture the raw binary stdout\n# for ACP JSON-RPC, then redirect the text-mode stdout to stderr so any stray\n# prints from agentmain/llmcore don't pollute the ACP channel.\nif sys.platform == \"win32\":\n    import msvcrt\n    _stdout_fd = os.dup(sys.__stdout__.fileno())\n    msvcrt.setmode(_stdout_fd, os.O_BINARY)\n    _acp_stdout = os.fdopen(_stdout_fd, \"wb\", buffering=0)\n    msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)\n    # Mark the ACP fd as non-inheritable so child processes can't write to it.\n    os.set_inheritable(_stdout_fd, False)\n    # Redirect the original stdout fd to stderr so child processes\n    # (tool calls) don't write into the ACP JSON-RPC channel.\n    os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())\nelse:\n    _stdout_fd = os.dup(sys.__stdout__.fileno())\n    os.set_inheritable(_stdout_fd, False)\n    _acp_stdout = os.fdopen(_stdout_fd, \"wb\", buffering=0)\n    os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())\n\n\nclass _StdoutToStderrRouter(io.TextIOBase):\n    \"\"\"Redirect text-mode stdout to stderr so agentmain prints don't leak.\"\"\"\n    def writable(self): return True\n    def write(self, s):\n        if s:\n            sys.stderr.write(s)\n            sys.stderr.flush()\n        return len(s) if s else 0\n    def flush(self): sys.stderr.flush()\n\nsys.stdout = _StdoutToStderrRouter()\n\nimport argparse\nimport queue\nimport threading\nimport traceback\nimport uuid\nfrom dataclasses import dataclass, field\nfrom typing import Any, Dict, List, Optional\n\nfrom agentmain import GeneraticAgent\n\n\nJSONRPC_VERSION = \"2.0\"\nACP_PROTOCOL_VERSION = 1\n\n\ndef eprint(*args: Any) -> None:\n    print(*args, file=sys.stderr, flush=True)\n\n\ndef make_text_block(text: str) -> Dict[str, Any]:\n    return {\"type\": \"text\", \"text\": text}\n\n\ndef make_session_update(session_id: str, update: Dict[str, Any]) -> Dict[str, Any]:\n    return {\n        \"jsonrpc\": JSONRPC_VERSION,\n        \"method\": \"session/update\",\n        \"params\": {\"sessionId\": session_id, \"update\": update},\n    }\n\n\ndef compact_json(obj: Dict[str, Any]) -> str:\n    return json.dumps(obj, ensure_ascii=False, separators=(\",\", \":\"))\n\n\ndef parse_jsonrpc_line(line: str) -> Optional[Dict[str, Any]]:\n    stripped = line.strip()\n    if not stripped:\n        return None\n    try:\n        obj = json.loads(stripped)\n    except json.JSONDecodeError:\n        return None\n    return obj if isinstance(obj, dict) else None\n\n\ndef content_blocks_to_text(blocks: List[Dict[str, Any]]) -> str:\n    parts: List[str] = []\n    for block in blocks:\n        if not isinstance(block, dict):\n            continue\n        block_type = block.get(\"type\")\n        if block_type == \"text\":\n            text = block.get(\"text\")\n            if isinstance(text, str) and text:\n                parts.append(text)\n        elif block_type == \"resource_link\":\n            name = block.get(\"name\") or \"resource\"\n            uri = block.get(\"uri\") or \"\"\n            desc = block.get(\"description\") or \"\"\n            parts.append(f\"[ResourceLink] {name}: {uri}\\n{desc}\".strip())\n        elif block_type == \"resource\":\n            uri = block.get(\"uri\") or \"resource\"\n            text = block.get(\"text\")\n            if isinstance(text, str) and text:\n                parts.append(f\"[Resource] {uri}\\n{text}\")\n            else:\n                parts.append(f\"[Resource] {uri}\")\n        elif block_type == \"image\":\n            uri = block.get(\"uri\") or \"inline-image\"\n            parts.append(f\"[Image omitted] {uri}\")\n        else:\n            parts.append(f\"[Unsupported content block: {block_type}]\")\n    return \"\\n\\n\".join(p for p in parts if p).strip()\n\n\ndef jsonrpc_error(code: int, message: str, req_id: Any = None, data: Any = None) -> Dict[str, Any]:\n    err: Dict[str, Any] = {\"code\": code, \"message\": message}\n    if data is not None:\n        err[\"data\"] = data\n    return {\"jsonrpc\": JSONRPC_VERSION, \"id\": req_id, \"error\": err}\n\n\ndef jsonrpc_result(req_id: Any, result: Any) -> Dict[str, Any]:\n    return {\"jsonrpc\": JSONRPC_VERSION, \"id\": req_id, \"result\": result}\n\n\n@dataclass\nclass SessionState:\n    session_id: str\n    cwd: str\n    agent: GeneraticAgent\n    current_prompt_id: Any = None\n    prompt_lock: threading.Lock = field(default_factory=threading.Lock)\n\n\nclass GenericAgentAcpBridge:\n    def __init__(self, llm_no: int = 0):\n        self.llm_no = llm_no\n        self._json_out = _acp_stdout\n        self._write_lock = threading.Lock()\n        self._sessions: Dict[str, SessionState] = {}\n        self._shutdown = False\n\n    def write_message(self, msg: Dict[str, Any]) -> None:\n        payload = compact_json(msg)\n        raw = (payload + \"\\n\").encode(\"utf-8\")\n        method = msg.get(\"method\", msg.get(\"id\", \"?\"))\n        eprint(f\"[ACP-BRIDGE] >>> {payload[:500]}\")\n        try:\n            with self._write_lock:\n                self._json_out.write(raw)\n                self._json_out.flush()\n        except Exception as e:\n            eprint(f\"[ACP-BRIDGE] WRITE FAILED: {type(e).__name__}: {e}\")\n\n    def new_agent(self) -> GeneraticAgent:\n        agent = GeneraticAgent()\n        agent.next_llm(self.llm_no)\n        agent.verbose = True\n        agent.inc_out = True\n        threading.Thread(target=agent.run, daemon=True).start()\n        return agent\n\n    def handle_initialize(self, req_id: Any, params: Dict[str, Any]) -> None:\n        requested_version = params.get(\"protocolVersion\", ACP_PROTOCOL_VERSION)\n        version = ACP_PROTOCOL_VERSION if requested_version == ACP_PROTOCOL_VERSION else ACP_PROTOCOL_VERSION\n        result = {\n            \"protocolVersion\": version,\n            \"agentCapabilities\": {\n                \"loadSession\": False,\n                \"mcpCapabilities\": {\"http\": False, \"sse\": False},\n                \"promptCapabilities\": {\n                    \"image\": False,\n                    \"audio\": False,\n                    \"embeddedContext\": False,\n                },\n                \"sessionCapabilities\": {},\n            },\n            \"agentInfo\": {\n                \"name\": \"genericagent-acp\",\n                \"title\": \"GenericAgent\",\n                \"version\": \"0.1.0\",\n            },\n            \"authMethods\": [],\n        }\n        self.write_message(jsonrpc_result(req_id, result))\n\n    def handle_session_new(self, req_id: Any, params: Dict[str, Any]) -> None:\n        cwd = params.get(\"cwd\")\n        if not isinstance(cwd, str) or not cwd:\n            self.write_message(jsonrpc_error(-32602, \"cwd is required\", req_id))\n            return\n        if not os.path.isabs(cwd):\n            cwd = os.path.abspath(cwd)\n        session_id = f\"ga_{uuid.uuid4().hex}\"\n        agent = self.new_agent()\n        session = SessionState(session_id=session_id, cwd=cwd, agent=agent)\n        self._sessions[session_id] = session\n        self.write_message(\n            jsonrpc_result(\n                req_id,\n                {\n                    \"sessionId\": session_id,\n                    \"modes\": None,\n                    \"configOptions\": None,\n                },\n            )\n        )\n\n    def handle_session_prompt(self, req_id: Any, params: Dict[str, Any]) -> None:\n        session_id = params.get(\"sessionId\")\n        prompt_blocks = params.get(\"prompt\")\n        session = self._sessions.get(session_id)\n        if session is None:\n            self.write_message(jsonrpc_error(-32602, \"unknown sessionId\", req_id))\n            return\n        if not isinstance(prompt_blocks, list):\n            self.write_message(jsonrpc_error(-32602, \"prompt must be an array\", req_id))\n            return\n        prompt_text = content_blocks_to_text(prompt_blocks)\n        if not prompt_text:\n            self.write_message(jsonrpc_error(-32602, \"prompt must contain text or supported content\", req_id))\n            return\n\n        with session.prompt_lock:\n            if session.current_prompt_id is not None:\n                self.write_message(\n                    jsonrpc_error(-32603, \"session already has an active prompt\", req_id)\n                )\n                return\n            session.current_prompt_id = req_id\n\n        def run_prompt() -> None:\n            stop_reason = \"end_turn\"\n            try:\n                dq = session.agent.put_task(prompt_text, source=\"acp\")\n                self._drain_agent_queue(session, dq)\n            except Exception as exc:\n                stop_reason = \"end_turn\"\n                self.write_message(\n                    make_session_update(\n                        session.session_id,\n                        {\n                            \"sessionUpdate\": \"agent_message_chunk\",\n                            \"content\": make_text_block(\n                                f\"[Bridge error] {type(exc).__name__}: {exc}\"\n                            ),\n                        },\n                    )\n                )\n                eprint(\"[GenericAgent ACP] prompt thread failed:\", traceback.format_exc())\n            finally:\n                with session.prompt_lock:\n                    finished_req_id = session.current_prompt_id\n                    session.current_prompt_id = None\n                if finished_req_id is not None:\n                    import time\n                    time.sleep(0.1)\n                    self.write_message(\n                        jsonrpc_result(finished_req_id, {\"stopReason\": stop_reason})\n                    )\n\n        threading.Thread(target=run_prompt, daemon=True).start()\n\n    def _drain_agent_queue(self, session: SessionState, dq: \"queue.Queue[Dict[str, Any]]\") -> None:\n        sent_any = False\n        while True:\n            item = dq.get()\n            if not isinstance(item, dict):\n                continue\n            # With inc_out=True, \"next\" items are already incremental deltas.\n            if \"next\" in item and \"done\" not in item:\n                delta = item[\"next\"]\n                if isinstance(delta, str) and delta:\n                    sent_any = True\n                    try:\n                        self.write_message(\n                            make_session_update(\n                                session.session_id,\n                                {\n                                    \"sessionUpdate\": \"agent_message_chunk\",\n                                    \"content\": make_text_block(delta),\n                                },\n                            )\n                        )\n                    except Exception as e:\n                        eprint(f\"[ACP-BRIDGE] ERROR writing update: {e}\")\n            if \"done\" in item:\n                # \"done\" text has post-processing (</summary>\\n\\n insertion)\n                # that shifts offsets — cannot safely compute a tail delta.\n                # Only use \"done\" content if nothing was streamed (error case).\n                if not sent_any:\n                    done_text = item[\"done\"]\n                    if isinstance(done_text, str) and done_text:\n                        try:\n                            self.write_message(\n                                make_session_update(\n                                    session.session_id,\n                                    {\n                                        \"sessionUpdate\": \"agent_message_chunk\",\n                                        \"content\": make_text_block(done_text),\n                                    },\n                                )\n                            )\n                        except Exception as e:\n                            eprint(f\"[ACP-BRIDGE] ERROR writing done: {e}\")\n                break\n\n    def handle_session_cancel(self, params: Dict[str, Any]) -> None:\n        session_id = params.get(\"sessionId\")\n        session = self._sessions.get(session_id)\n        if session is None:\n            return\n        if session.current_prompt_id is not None:\n            session.agent.abort()\n\n    def handle_message(self, msg: Dict[str, Any]) -> None:\n        method = msg.get(\"method\")\n        req_id = msg.get(\"id\")\n        params = msg.get(\"params\") or {}\n\n        try:\n            if method == \"initialize\":\n                self.handle_initialize(req_id, params)\n            elif method == \"session/new\":\n                self.handle_session_new(req_id, params)\n            elif method == \"session/prompt\":\n                self.handle_session_prompt(req_id, params)\n            elif method == \"session/cancel\":\n                self.handle_session_cancel(params)\n            elif method == \"session/load\":\n                self.write_message(jsonrpc_error(-32601, \"session/load not supported\", req_id))\n            elif method == \"session/list\":\n                self.write_message(jsonrpc_error(-32601, \"session/list not supported\", req_id))\n            elif method == \"session/close\":\n                self.write_message(jsonrpc_result(req_id, {}))\n            elif method is None:\n                if req_id is not None:\n                    self.write_message(jsonrpc_error(-32600, \"invalid request\", req_id))\n            else:\n                if req_id is not None:\n                    self.write_message(jsonrpc_error(-32601, f\"method not found: {method}\", req_id))\n        except Exception as exc:\n            eprint(\"[GenericAgent ACP] request handler failed:\", traceback.format_exc())\n            if req_id is not None:\n                self.write_message(\n                    jsonrpc_error(-32603, f\"internal error: {type(exc).__name__}: {exc}\", req_id)\n                )\n\n    def serve(self) -> None:\n        eprint(\"[GenericAgent ACP] bridge started\")\n        stdin = io.TextIOWrapper(sys.stdin.buffer, encoding=\"utf-8\", errors=\"replace\") if hasattr(sys.stdin, 'buffer') else sys.stdin\n        for raw_line in stdin:\n            msg = parse_jsonrpc_line(raw_line)\n            if msg is None:\n                continue\n            self.handle_message(msg)\n            if self._shutdown:\n                break\n        eprint(\"[GenericAgent ACP] bridge stopped\")\n\n\ndef main() -> int:\n    parser = argparse.ArgumentParser(description=\"GenericAgent ACP bridge over stdio\")\n    parser.add_argument(\"--llm-no\", type=int, default=0, help=\"LLM index for GenericAgent\")\n    args = parser.parse_args()\n    bridge = GenericAgentAcpBridge(llm_no=args.llm_no)\n    bridge.serve()\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": "frontends/qqapp.py",
    "content": "import asyncio, os, sys, threading, time\nfrom collections import deque\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom agentmain import GeneraticAgent\nfrom chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text\nfrom llmcore import mykeys\n\ntry:\n    import botpy\n    from botpy.message import C2CMessage, GroupMessage\nexcept Exception:\n    print(\"Please install qq-botpy to use QQ module: pip install qq-botpy\")\n    sys.exit(1)\n\nagent = GeneraticAgent(); agent.verbose = False\nAPP_ID = str(mykeys.get(\"qq_app_id\", \"\") or \"\").strip()\nAPP_SECRET = str(mykeys.get(\"qq_app_secret\", \"\") or \"\").strip()\nALLOWED = {str(x).strip() for x in mykeys.get(\"qq_allowed_users\", []) if str(x).strip()}\nPROCESSED_IDS, USER_TASKS = deque(maxlen=1000), {}\nSEQ_LOCK, MSG_SEQ = threading.Lock(), 1\n\n\ndef _next_msg_seq():\n    global MSG_SEQ\n    with SEQ_LOCK:\n        MSG_SEQ += 1\n        return MSG_SEQ\n\n\ndef _build_intents():\n    try:\n        return botpy.Intents(public_messages=True, direct_message=True)\n    except Exception:\n        intents = botpy.Intents.none() if hasattr(botpy.Intents, \"none\") else botpy.Intents()\n        for attr in (\"public_messages\", \"public_guild_messages\", \"direct_message\", \"direct_messages\", \"c2c_message\", \"c2c_messages\", \"group_at_message\", \"group_at_messages\"):\n            if hasattr(intents, attr):\n                try:\n                    setattr(intents, attr, True)\n                except Exception:\n                    pass\n        return intents\n\n\ndef _make_bot_class(app):\n    class QQBot(botpy.Client):\n        def __init__(self):\n            super().__init__(intents=_build_intents(), ext_handlers=False)\n\n        async def on_ready(self):\n            print(f\"[QQ] bot ready: {getattr(getattr(self, 'robot', None), 'name', 'QQBot')}\")\n\n        async def on_c2c_message_create(self, message: C2CMessage):\n            await app.on_message(message, is_group=False)\n\n        async def on_group_at_message_create(self, message: GroupMessage):\n            await app.on_message(message, is_group=True)\n\n        async def on_direct_message_create(self, message):\n            await app.on_message(message, is_group=False)\n\n    return QQBot\n\n\nclass QQApp(AgentChatMixin):\n    label, source, split_limit = \"QQ\", \"qq\", 1500\n\n    def __init__(self):\n        super().__init__(agent, USER_TASKS)\n        self.client = None\n\n    async def send_text(self, chat_id, content, *, msg_id=None, is_group=False):\n        if not self.client:\n            return\n        api = self.client.api.post_group_message if is_group else self.client.api.post_c2c_message\n        key = \"group_openid\" if is_group else \"openid\"\n        for part in split_text(content, self.split_limit):\n            await api(**{key: chat_id, \"msg_type\": 0, \"content\": part, \"msg_id\": msg_id, \"msg_seq\": _next_msg_seq()})\n\n    async def on_message(self, data, is_group=False):\n        try:\n            msg_id = getattr(data, \"id\", None)\n            if msg_id in PROCESSED_IDS:\n                return\n            PROCESSED_IDS.append(msg_id)\n            content = (getattr(data, \"content\", \"\") or \"\").strip()\n            if not content:\n                return\n            author = getattr(data, \"author\", None)\n            user_id = str(getattr(author, \"member_openid\" if is_group else \"user_openid\", \"\") or getattr(author, \"id\", \"\") or \"unknown\")\n            chat_id = str(getattr(data, \"group_openid\", \"\") or user_id) if is_group else user_id\n            if not public_access(ALLOWED) and user_id not in ALLOWED:\n                print(f\"[QQ] unauthorized user: {user_id}\")\n                return\n            print(f\"[QQ] message from {user_id} ({'group' if is_group else 'c2c'}): {content}\")\n            if content.startswith(\"/\"):\n                return await self.handle_command(chat_id, content, msg_id=msg_id, is_group=is_group)\n            asyncio.create_task(self.run_agent(chat_id, content, msg_id=msg_id, is_group=is_group))\n        except Exception:\n            import traceback\n            print(\"[QQ] handle_message error\")\n            traceback.print_exc()\n\n    async def start(self):\n        self.client = _make_bot_class(self)()\n        delay, max_delay = 5, 300\n        while True:\n            started_at = time.monotonic()\n            try:\n                print(f\"[QQ] bot starting... {time.strftime('%m-%d %H:%M')}\")\n                await self.client.start(appid=APP_ID, secret=APP_SECRET)\n            except Exception as e:\n                print(f\"[QQ] bot error: {e}\")\n            if time.monotonic() - started_at >= 60:\n                delay = 5\n            print(f\"[QQ] reconnect in {delay}s...\")\n            await asyncio.sleep(delay)\n            delay = min(delay * 2, max_delay)\n\n\nif __name__ == \"__main__\":\n    _LOCK_SOCK = ensure_single_instance(19528, \"QQ\")\n    require_runtime(agent, \"QQ\", qq_app_id=APP_ID, qq_app_secret=APP_SECRET)\n    redirect_log(__file__, \"qqapp.log\", \"QQ\", ALLOWED)\n    threading.Thread(target=agent.run, daemon=True).start()\n    asyncio.run(QQApp().start())\n"
  },
  {
    "path": "frontends/qtapp.py",
    "content": "\"\"\"\n桌面前端单文件版 – PySide6 聊天面板 + 悬浮按钮   thanks to GaoZhiCheng\n依赖: pip install PySide6\n可选: pip install markdown  (Markdown 渲染)\n用法: python frontends/qtapp.py \n\"\"\"\nfrom __future__ import annotations\n\nimport math, os, sys, json, glob, re, base64, time, threading\nimport queue as _queue\nfrom datetime import datetime\nfrom typing import Optional\n\nfrom PySide6.QtWidgets import (\n    QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,\n    QScrollArea, QFrame, QTextEdit, QStackedWidget,\n    QListWidget, QListWidgetItem, QSizePolicy, QFileDialog,\n    QSplitter, QTextBrowser, QApplication, QMessageBox,\n    QMenu, QLineEdit,\n)\nfrom PySide6.QtCore import (\n    Qt, QTimer, QPoint, QPointF, QByteArray, QSize,\n    Signal, QMetaObject, Q_ARG, QObject, QDateTime, QEvent,\n)\nfrom PySide6.QtGui import (\n    QPainter, QColor, QLinearGradient, QRadialGradient,\n    QPen, QPainterPath, QCursor, QFont, QIcon, QPixmap, QRegion,\n)\n\nsys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))\nfrom agentmain import GeneraticAgent\nfrom chatapp_common import FILE_HINT, HELP_TEXT, clean_reply, build_done_text, format_restore\n\n\n# ══════════════════════════════════════════════════════════════════════\n# FloatingButton\n# ══════════════════════════════════════════════════════════════════════\n\nclass FloatingButton(QWidget):\n    SIZE = 60       # circle diameter\n    MARGIN = 14     # extra space for glow\n    TOTAL = SIZE + MARGIN * 2\n\n    def __init__(self, chat_panel: QWidget):\n        super().__init__()\n        self.chat_panel = chat_panel\n        self._drag_origin_global: QPoint | None = None\n        self._drag_origin_win: QPoint | None = None\n        self._dragged = False\n        self._glow = 0.5\n        self._glow_dir = 1\n        self._hovering = False\n        self._hover_clock = 0.0\n        self._hover_strength = 0.0\n        self._flow_phase = 0.0\n        self._running = False\n        self._last_toggle_ms = 0  # debounce timestamp\n\n        # Window flags: frameless, always on top, no taskbar entry\n        self.setWindowFlags(\n            Qt.FramelessWindowHint\n            | Qt.WindowStaysOnTopHint\n            | Qt.Tool\n        )\n        self.setAttribute(Qt.WA_TranslucentBackground)\n        self.setFixedSize(self.TOTAL, self.TOTAL)\n        self.setCursor(QCursor(Qt.PointingHandCursor))\n\n        # Smooth animation (~30 fps)\n        self._timer = QTimer(self)\n        self._timer.timeout.connect(self._tick)\n        self._timer.start(33)\n\n        # Default position: bottom-right of the work area\n        scr = QApplication.primaryScreen().availableGeometry()\n        self.move(scr.right() - self.TOTAL - 20, scr.bottom() - self.TOTAL - 20)\n\n    # ── Animation ────────────────────────────────────────\n    def _tick(self):\n        # running status: green when model is actively responding\n        self._running = bool(\n            getattr(self.chat_panel, \"_is_streaming\", False)\n            or getattr(getattr(self.chat_panel, \"agent\", None), \"is_running\", False)\n        )\n\n        self._glow += self._glow_dir * 0.04\n        if self._glow >= 1.0:\n            self._glow, self._glow_dir = 1.0, -1\n        elif self._glow <= 0.0:\n            self._glow, self._glow_dir = 0.0, 1\n\n        target = 1.0 if self._hovering else 0.0\n        self._hover_strength += (target - self._hover_strength) * 0.20\n        self._hover_clock += 0.033\n        self._flow_phase += 0.16 + (0.06 if self._running else 0.0) + (0.05 if self._hovering else 0.0)\n        self.update()\n\n    # ── Painting ──────────────────────────────────────────\n    def paintEvent(self, _event):\n        p = QPainter(self)\n        p.setRenderHint(QPainter.Antialiasing)\n\n        m = self.MARGIN\n        r = self.SIZE // 2\n        cx = m + r\n        # Rhythmic spring bounce: one main hop + one lighter rebound per beat.\n        beat_t = self._hover_clock % 1.18\n        spring = 0.0\n        if beat_t < 0.70:\n            spring += max(0.0, math.exp(-5.2 * beat_t) * math.sin(15.5 * beat_t))\n        if beat_t > 0.20:\n            rt = beat_t - 0.20\n            spring += 0.52 * max(0.0, math.exp(-7.0 * rt) * math.sin(21.0 * rt))\n        idle_sway = 0.20 * math.sin(self._hover_clock * 2.1)\n        bounce = int(round((spring * 7.2 + idle_sway) * self._hover_strength))\n        cy = m + r - bounce\n\n        if self._running:\n            # running: #2DFFF5 -> #FFF878\n            g0 = QColor(45, 255, 245, 195)\n            g1 = QColor(255, 248, 120, 195)\n            glow_rgb = (96, 255, 216)\n        else:\n            # idle: #103CE7 -> #64E9FF\n            g0 = QColor(16, 60, 231, 195)\n            g1 = QColor(100, 233, 255, 195)\n            glow_rgb = (74, 170, 255)\n\n        # --- Outer glow rings (3 layers) ---\n        base_alpha = int(45 + 25 * self._glow)\n        for i, gr in enumerate([r + 10, r + 6, r + 2]):\n            g = QRadialGradient(QPointF(cx, cy), gr)\n            g.setColorAt(0.0, QColor(glow_rgb[0], glow_rgb[1], glow_rgb[2], max(0, base_alpha - i * 14)))\n            g.setColorAt(1.0, QColor(glow_rgb[0], glow_rgb[1], glow_rgb[2], 0))\n            p.setBrush(g)\n            p.setPen(Qt.NoPen)\n            p.drawEllipse(int(cx - gr), int(cy - gr), int(gr * 2), int(gr * 2))\n\n        # --- Frosted glass disc behind main circle ---\n        frost = QRadialGradient(QPointF(cx, cy), r)\n        frost.setColorAt(0.0, QColor(30, 30, 45, 140))\n        frost.setColorAt(0.85, QColor(20, 20, 32, 160))\n        frost.setColorAt(1.0, QColor(14, 14, 20, 100))\n        p.setBrush(frost)\n        p.setPen(Qt.NoPen)\n        p.drawEllipse(cx - r, cy - r, r * 2, r * 2)\n\n        # --- Main circle (flowing state gradient) ---\n        spin = self._flow_phase\n        dx = math.cos(spin) * r\n        dy = math.sin(spin) * r\n        grad = QLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy)\n        grad.setColorAt(0.0, g0)\n        grad.setColorAt(1.0, g1)\n        p.setBrush(grad)\n        p.setPen(QPen(QColor(255, 255, 255, 50), 1.5))\n        p.drawEllipse(cx - r, cy - r, r * 2, r * 2)\n\n        # --- Flowing glass streaks ---\n        clip = QPainterPath()\n        clip.addEllipse(float(cx - r), float(cy - r), float(r * 2), float(r * 2))\n        p.setClipPath(clip)\n\n        flow_shift = math.sin(self._flow_phase * 0.85) * (r * 0.7)\n        streak1 = QLinearGradient(cx - r + flow_shift, cy - r, cx + r + flow_shift, cy + r)\n        streak1.setColorAt(0.00, QColor(255, 255, 255, 0))\n        streak1.setColorAt(0.45, QColor(255, 255, 255, 42))\n        streak1.setColorAt(0.52, QColor(255, 255, 255, 78))\n        streak1.setColorAt(0.60, QColor(255, 255, 255, 24))\n        streak1.setColorAt(1.00, QColor(255, 255, 255, 0))\n        p.setBrush(streak1)\n        p.setPen(Qt.NoPen)\n        p.drawEllipse(cx - r, cy - r, r * 2, r * 2)\n\n        flow_shift_2 = math.cos(self._flow_phase * 1.2) * (r * 0.5)\n        streak2 = QLinearGradient(cx - r, cy + flow_shift_2, cx + r, cy - flow_shift_2)\n        streak2.setColorAt(0.00, QColor(255, 255, 255, 0))\n        streak2.setColorAt(0.35, QColor(255, 255, 255, 16))\n        streak2.setColorAt(0.50, QColor(255, 255, 255, 46))\n        streak2.setColorAt(0.65, QColor(255, 255, 255, 16))\n        streak2.setColorAt(1.00, QColor(255, 255, 255, 0))\n        p.setBrush(streak2)\n        p.drawEllipse(cx - r, cy - r, r * 2, r * 2)\n\n        # --- Top highlight ---\n        hl = QLinearGradient(cx, cy - r, cx, cy)\n        hl.setColorAt(0.0, QColor(255, 255, 255, 72))\n        hl.setColorAt(1.0, QColor(255, 255, 255, 0))\n        p.setBrush(hl)\n        p.drawRect(cx - r, cy - r, r * 2, r)\n        p.setClipping(False)\n\n        # --- Bot icon ---\n        p.setPen(QPen(QColor(255, 255, 255, 220), 1.8))\n        p.setBrush(Qt.NoBrush)\n        # Head\n        p.drawRoundedRect(cx - 9, cy - 6, 18, 12, 2, 2)\n        # Eyes\n        p.setBrush(QColor(255, 255, 255, 220))\n        p.setPen(Qt.NoPen)\n        p.drawEllipse(cx - 6, cy - 3, 4, 4)\n        p.drawEllipse(cx + 2, cy - 3, 4, 4)\n        # Antenna stem\n        p.setPen(QPen(QColor(255, 255, 255, 220), 1.8))\n        p.drawLine(cx, cy - 6, cx, cy - 10)\n        # Antenna tip\n        p.setBrush(QColor(255, 255, 255, 190))\n        p.setPen(Qt.NoPen)\n        p.drawEllipse(cx - 2, cy - 13, 4, 4)\n\n    def enterEvent(self, event):\n        self._hovering = True\n        self.update()\n        super().enterEvent(event)\n\n    def leaveEvent(self, event):\n        self._hovering = False\n        self.update()\n        super().leaveEvent(event)\n\n    # ── Mouse events (drag + click) ───────────────────────\n    def mousePressEvent(self, event):\n        if event.button() == Qt.LeftButton:\n            self._drag_origin_global = event.globalPosition().toPoint()\n            self._drag_origin_win = self.pos()\n            self._dragged = False\n\n    def mouseMoveEvent(self, event):\n        if event.buttons() == Qt.LeftButton and self._drag_origin_global:\n            delta = event.globalPosition().toPoint() - self._drag_origin_global\n            if abs(delta.x()) > 5 or abs(delta.y()) > 5:\n                self._dragged = True\n            if self._dragged:\n                new = self._drag_origin_win + delta\n                scr = QApplication.primaryScreen().availableGeometry()\n                new.setX(max(scr.left(), min(new.x(), scr.right() - self.width())))\n                new.setY(max(scr.top(), min(new.y(), scr.bottom() - self.height())))\n                self.move(new)\n\n    def mouseDoubleClickEvent(self, event):\n        # Qt sends Press→Release→DoubleClick→Release on double-click.\n        # The first Release already toggled the panel; swallow the DoubleClick\n        # so the second Release does NOT trigger a second toggle.\n        self._dragged = True   # mark as \"dragged\" → Release will be ignored\n        event.accept()\n\n    def mouseReleaseEvent(self, event):\n        if event.button() == Qt.LeftButton:\n            if not self._dragged:\n                self._toggle()\n            self._dragged = False\n        self._drag_origin_global = None\n\n    # ── Toggle panel ──────────────────────────────────────\n    def _toggle(self):\n        now = QDateTime.currentMSecsSinceEpoch()\n        if now - self._last_toggle_ms < 500:   # 500 ms debounce\n            return\n        self._last_toggle_ms = now\n\n        if self.chat_panel.isVisible():\n            self.chat_panel.hide()\n        else:\n            self._position_panel()\n            self.chat_panel.show()\n            self.chat_panel.raise_()\n            self.chat_panel.activateWindow()\n\n    def _position_panel(self):\n        scr = QApplication.primaryScreen().availableGeometry()\n        btn = self.geometry()\n        pw = self.chat_panel.width()\n        ph = self.chat_panel.height()\n        # Prefer left of button, bottom-aligned\n        x = btn.left() - pw - 12\n        y = btn.bottom() - ph\n        x = max(scr.left() + 10, min(x, scr.right() - pw - 10))\n        y = max(scr.top() + 10, min(y, scr.bottom() - ph - 10))\n        self.chat_panel.move(x, y)\n\n\n# ══════════════════════════════════════════════════════════════════════\n# ChatPanel\n# ══════════════════════════════════════════════════════════════════════\n\n# ── constants ─────────────────────────────────────────────────────────────────\nHISTORY_FILE = \"memory/chat_history.json\"\nTEXT_FILE_EXTS = {\n    \".txt\", \".md\", \".py\", \".json\", \".csv\", \".yaml\", \".yml\",\n    \".log\", \".ini\", \".toml\", \".xml\", \".html\", \".js\", \".ts\", \".sql\",\n}\nMAX_INLINE_CHARS = 6000\nMAX_UPLOAD_BYTES = 10 * 1024 * 1024  # 10 MB\nAUTO_IDLE_THRESHOLD = 1800  # seconds before autonomous trigger\nAUTO_COOLDOWN = 120         # seconds between triggers\n\nC = {\n    \"bg\":       QColor(14, 14, 18),\n    \"panel\":    QColor(20, 20, 24, 248),\n    \"border\":   QColor(45, 45, 50),\n    \"accent\":   \"#7c3aed\",\n    \"text\":     \"#e4e4e7\",\n    \"muted\":    \"#71717a\",\n    \"user_g0\":  QColor(79, 70, 229),\n    \"user_g1\":  QColor(124, 58, 237),\n    \"asst_bg\":  QColor(39, 39, 42, 210),\n    \"asst_bdr\": QColor(63, 63, 70),\n    \"send_g0\":  QColor(220, 38, 38),\n    \"send_g1\":  QColor(239, 68, 68),\n    \"green\":    \"#22c55e\",\n    \"hover_bg\": \"rgba(63,63,70,0.6)\",\n    \"accent_bg\":\"rgba(124,58,237,0.25)\",\n    \"accent_bdr\":\"rgba(124,58,237,0.5)\",\n}\n\nSCROLLBAR_STYLE = \"\"\"\nQScrollBar:vertical { width: 5px; background: transparent; border: none; }\nQScrollBar::handle:vertical {\n    background: rgba(255,255,255,0.12); border-radius: 2px; min-height: 20px;\n}\nQScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }\nQScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; }\n\"\"\"\n\n_SVG_COPY = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg>'\n_SVG_REGEN = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"/><path d=\"M3 3v5h5\"/></svg>'\n_SVG_CHAT = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/></svg>'\n_SVG_CLOCK = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><polyline points=\"12 6 12 12 16 14\"/></svg>'\n_SVG_SEARCH = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>'\n_SVG_BOOK = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20\"/></svg>'\n_SVG_GEAR = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>'\n_SVG_PLUS = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>'\n_SVG_CLIP = _SVG_PLUS\n_SVG_STOP = '<svg viewBox=\"0 0 24 24\" fill=\"{c}\" stroke=\"none\"><rect width=\"10\" height=\"10\" x=\"7\" y=\"7\" rx=\"1.5\" ry=\"1.5\"/></svg>'\n_SVG_RESET = _SVG_REGEN\n_SVG_SAVE = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z\"/><polyline points=\"17 21 17 13 7 13 7 21\"/><polyline points=\"7 3 7 8 15 8\"/></svg>'\n_SVG_TRASH = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"/><path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"/><line x1=\"10\" x2=\"10\" y1=\"11\" y2=\"17\"/><line x1=\"14\" x2=\"14\" y1=\"11\" y2=\"17\"/></svg>'\n_SVG_BOLT = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg>'\n_SVG_PLAY = '<svg viewBox=\"0 0 24 24\" fill=\"{c}\" stroke=\"none\"><polygon points=\"6 3 20 12 6 21 6 3\"/></svg>'\n_SVG_FILE = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z\"/><polyline points=\"14 2 14 8 20 8\"/><line x1=\"16\" x2=\"8\" y1=\"13\" y2=\"13\"/><line x1=\"16\" x2=\"8\" y1=\"17\" y2=\"17\"/><line x1=\"10\" x2=\"8\" y1=\"9\" y2=\"9\"/></svg>'\n_SVG_USER = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2\"/><circle cx=\"12\" cy=\"7\" r=\"4\"/></svg>'\n_SVG_BOT = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z\"/><path d=\"M5 3v4\"/><path d=\"M7 5H3\"/></svg>'\n_SVG_SEND = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"{c}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 2 11 13\"/><path d=\"m22 2-7 20-4-9-9-4Z\"/></svg>'\n\n_MD_CSS = \"\"\"\nbody { color: #e4e4e7; font-family: \"Arial\", \"Microsoft YaHei\", sans-serif; font-size: 13px; line-height: 1.6; font-weight: 400; }\nh1 { color: #f4f4f5; font-size: 20px; font-weight: 700; border-bottom: 1px solid #3f3f46; padding-bottom: 4px; margin-top: 16px; }\nh2 { color: #f4f4f5; font-size: 17px; font-weight: 700; border-bottom: 1px solid #3f3f46; padding-bottom: 3px; margin-top: 14px; }\nh3 { color: #f4f4f5; font-size: 15px; font-weight: 600; margin-top: 12px; }\nh4,h5,h6 { color: #d4d4d8; font-size: 13px; font-weight: 600; margin-top: 10px; }\ncode { background: rgba(63,63,70,0.6); color: #c4b5fd; padding: 1px 4px; border-radius: 3px;\n       font-family: Consolas, \"Courier New\", monospace; font-size: 12px; }\npre  { background: rgba(24,24,30,0.95); border: 1px solid #3f3f46; border-radius: 6px;\n       padding: 10px 12px; margin: 8px 0; }\npre code { background: transparent; padding: 0; color: #d4d4d8; }\na { color: #818cf8; text-decoration: none; }\na:hover { text-decoration: underline; }\nblockquote { border-left: 3px solid #7c3aed; margin: 8px 0 8px 0; padding: 4px 0 4px 12px; color: #a1a1aa; }\ntable { border-collapse: collapse; margin: 8px 0; }\nth, td { border: 1px solid #3f3f46; padding: 5px 10px; }\nth { background: rgba(63,63,70,0.35); color: #d4d4d8; font-weight: 700; }\nhr { border: none; border-top: 1px solid #3f3f46; margin: 12px 0; }\nul, ol { padding-left: 22px; margin: 4px 0; }\nli { margin: 2px 0; }\np { margin: 6px 0; }\n\"\"\"\n\n\ndef _md_to_html(text: str) -> str:\n    try:\n        import markdown\n        return markdown.markdown(\n            text, extensions=[\"fenced_code\", \"tables\", \"nl2br\", \"sane_lists\"]\n        )\n    except ImportError:\n        pass\n    html, in_code, in_ul = [], False, False\n    for raw in text.split(\"\\n\"):\n        if raw.strip().startswith(\"```\"):\n            if in_code:\n                html.append(\"</code></pre>\")\n            else:\n                html.append(\"<pre><code>\")\n            in_code = not in_code\n            continue\n        if in_code:\n            html.append(raw.replace(\"&\", \"&amp;\").replace(\"<\", \"&lt;\").replace(\">\", \"&gt;\"))\n            continue\n        line = raw\n        line = re.sub(r\"`([^`]+)`\", r\"<code>\\1</code>\", line)\n        line = re.sub(r\"\\*\\*(.+?)\\*\\*\", r\"<b>\\1</b>\", line)\n        line = re.sub(r\"\\*(.+?)\\*\", r\"<i>\\1</i>\", line)\n        line = re.sub(r\"\\[([^\\]]+)\\]\\(([^)]+)\\)\", r'<a href=\"\\2\">\\1</a>', line)\n        if re.match(r\"^#{1,6}\\s\", line):\n            lvl = len(line.split()[0])\n            line = f\"<h{lvl}>{line[lvl:].strip()}</h{lvl}>\"\n        elif re.match(r\"^-{3,}$|^_{3,}$|^\\*{3,}$\", line.strip()):\n            line = \"<hr>\"\n        elif re.match(r\"^\\s*[-*+]\\s\", line):\n            content = re.sub(r\"^\\s*[-*+]\\s\", \"\", line)\n            if not in_ul:\n                html.append(\"<ul>\")\n                in_ul = True\n            line = f\"<li>{content}</li>\"\n        else:\n            if in_ul:\n                html.append(\"</ul>\")\n                in_ul = False\n            line = f\"<p>{line}</p>\" if line.strip() else \"\"\n        html.append(line)\n    if in_code:\n        html.append(\"</code></pre>\")\n    if in_ul:\n        html.append(\"</ul>\")\n    return \"\\n\".join(html)\n\n\n_icon_cache: dict[str, QIcon] = {}\n\ndef _svg_icon(key: str, svg_template: str, color: str = \"#a1a1aa\",\n              size: int = 16) -> QIcon:\n    cache_key = f\"{key}_{color}_{size}\"\n    if cache_key not in _icon_cache:\n        try:\n            from PySide6.QtSvg import QSvgRenderer\n        except ImportError:\n            return QIcon()\n        data = QByteArray(svg_template.format(c=color).encode(\"utf-8\"))\n        renderer = QSvgRenderer(data)\n        pixmap = QPixmap(size, size)\n        pixmap.fill(Qt.transparent)\n        painter = QPainter(pixmap)\n        renderer.render(painter)\n        painter.end()\n        _icon_cache[cache_key] = QIcon(pixmap)\n    return _icon_cache[cache_key]\n\n\n# ── utilities ─────────────────────────────────────────────────────────────────\ndef _make_session_id() -> str:\n    return datetime.now().strftime(\"%Y%m%d_%H%M%S_%f\")\n\n\ndef _load_history() -> list:\n    if os.path.exists(HISTORY_FILE):\n        try:\n            with open(HISTORY_FILE, \"r\", encoding=\"utf-8\") as f:\n                return json.load(f)\n        except Exception:\n            pass\n    return []\n\n\ndef _save_history(history: list):\n    os.makedirs(os.path.dirname(HISTORY_FILE), exist_ok=True)\n    with open(HISTORY_FILE, \"w\", encoding=\"utf-8\") as f:\n        json.dump(history, f, ensure_ascii=False, indent=2)\n\n\ndef _build_prompt_with_uploads(prompt: str, files: list) -> tuple:\n    \"\"\"\n    files: list of {'name': str, 'type': str, 'raw': bytes}\n    returns (full_prompt, display_prompt, display_attachments)\n    \"\"\"\n    if not files:\n        return prompt, prompt, []\n\n    os.makedirs(\"temp/uploaded\", exist_ok=True)\n    attachment_chunks = [\"\\n\\n[用户上传附件 — 文件已保存到本地磁盘，可用 file_read 工具读取]\"]\n    display_attachments = []\n    img_count, file_names = 0, []\n\n    for f in files:\n        raw, name, mime = f[\"raw\"], f[\"name\"], f.get(\"type\", \"\")\n        size = len(raw)\n        ext = os.path.splitext(name)[1].lower()\n        safe = re.sub(r\"[^A-Za-z0-9._\\-]\", \"_\", name)\n        saved = os.path.join(\n            \"temp\", \"uploaded\",\n            f\"{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{safe}\",\n        )\n        try:\n            with open(saved, \"wb\") as out:\n                out.write(raw)\n        except Exception:\n            saved = \"(保存失败)\"\n\n        if mime.startswith(\"image/\"):\n            b64 = base64.b64encode(raw).decode()\n            attachment_chunks.append(\n                f\"\\n- [图片附件] {name} ({size} bytes)\\n  磁盘路径: {saved}\"\n                f\"\\n  data:{mime};base64,{b64}\"\n            )\n            display_attachments.append({\"type\": \"image\", \"name\": name})\n            img_count += 1\n        elif ext in TEXT_FILE_EXTS:\n            text = raw.decode(\"utf-8\", errors=\"replace\")\n            attachment_chunks.append(\n                f\"\\n--- 文本文件: {name} ({size} bytes) ---\\n磁盘路径: {saved}\\n{text[:MAX_INLINE_CHARS]}\"\n                + (\"\\n[内容已截断，请用 file_read 读取完整内容]\" if len(text) > MAX_INLINE_CHARS else \"\")\n            )\n            display_attachments.append({\"type\": \"file\", \"name\": name})\n            file_names.append(name)\n        else:\n            attachment_chunks.append(\n                f\"\\n- 文件: {name} ({size} bytes)\\n  磁盘路径: {saved}\"\n            )\n            display_attachments.append({\"type\": \"file\", \"name\": name})\n            file_names.append(name)\n\n    parts = []\n    if img_count:\n        parts.append(f\"{img_count} 张图片\")\n    if file_names:\n        parts.append(f\"{len(file_names)} 个文件（{'、'.join(file_names)}）\")\n    display_prompt = f\"{prompt}\\n\\n📎 已附带：{'，'.join(parts)}\" if parts else prompt\n    return prompt + \"\\n\".join(attachment_chunks), display_prompt, display_attachments\n\n\n# ── small reusable widgets ────────────────────────────────────────────────────\nclass _Separator(QFrame):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setFixedHeight(1)\n        self.setStyleSheet(f\"background: {C['border'].name()};\")\n\n\nclass _Badge(QLabel):\n    def __init__(self, text: str, parent=None):\n        super().__init__(text, parent)\n        self.setStyleSheet(\n            \"QLabel { background: rgba(63,63,70,0.9); color: #a1a1aa;\"\n            \" border: 1px solid #3f3f46; border-radius: 9px;\"\n            \" padding: 1px 8px; font-size: 11px; }\"\n        )\n\n\nclass _StreamingBadge(QLabel):\n    def __init__(self, parent=None):\n        super().__init__(\"处理中…\", parent)\n        self.setStyleSheet(\n            \"QLabel { background: rgba(124,58,237,0.18); color: #c4b5fd;\"\n            \" border: 1px solid rgba(124,58,237,0.35); border-radius: 9px;\"\n            \" padding: 1px 8px; font-size: 11px; }\"\n        )\n        self.hide()\n\n\nclass _FoldableTextBrowser(QTextBrowser):\n    \"\"\"QTextBrowser subclass that reliably detects clicks on fold anchors.\"\"\"\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.viewport().installEventFilter(self)\n\n    def eventFilter(self, obj, event):\n        from PySide6.QtCore import QEvent\n        if obj is self.viewport() and event.type() == QEvent.MouseButtonRelease:\n            href = self.anchorAt(event.pos())\n            if href and href.startswith(\"#fold_\"):\n                from urllib.parse import unquote\n                title = unquote(href[6:])\n                p = self.parent()\n                while p and not isinstance(p, _MsgRow):\n                    p = p.parent()\n                if p and hasattr(p, '_toggle_fold'):\n                    p._toggle_fold(title)\n                    return True\n        return super().eventFilter(obj, event)\n\n\nclass _MsgRow(QWidget):\n    \"\"\"A single message row – flat layout with avatar, inspired by ChatGPT / Qwen.\"\"\"\n\n    _ACTION_BTN = \"\"\"\n        QPushButton {\n            background: transparent; border: none; border-radius: 4px; padding: 3px;\n        }\n        QPushButton:hover { background: %s; }\n    \"\"\" % C[\"hover_bg\"]\n    def __init__(self, text: str, role: str, parent=None, on_resend=None, on_delete=None, on_rewrite=None, created_at: str = None):\n        super().__init__(parent)\n        self._text = text\n        self._role = role\n        self._on_resend = on_resend\n        self._on_delete = on_delete\n        self._on_rewrite = on_rewrite\n        self._created_at = created_at\n        self._action_row = None\n        self._finished = True\n\n        is_user = role == \"user\"\n        self.setStyleSheet(\"background: transparent;\")\n\n        outer = QHBoxLayout(self)\n        outer.setContentsMargins(12, 10, 12, 10)\n        outer.setSpacing(10)\n        outer.setAlignment(Qt.AlignTop)\n\n        # ── avatar ──\n        avatar = QLabel()\n        avatar.setFixedSize(30, 30)\n        avatar.setAlignment(Qt.AlignCenter)\n        svg_data = _SVG_USER if is_user else _SVG_BOT\n        avatar_color = \"#c8c8d0\" if is_user else \"#9eb4d0\"\n        pm = QPixmap(30, 30)\n        pm.fill(QColor(0, 0, 0, 0))\n        from PySide6.QtSvg import QSvgRenderer\n        renderer = QSvgRenderer(QByteArray(svg_data.replace(\"{c}\", avatar_color).encode()))\n        p = QPainter(pm)\n        renderer.render(p)\n        p.end()\n        avatar.setPixmap(pm)\n        avatar.setStyleSheet(\n            \"QLabel { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.10);\"\n            \" border-radius: 15px; }\"\n        )\n\n        # ── content column ──\n        content_col = QVBoxLayout()\n        content_col.setContentsMargins(0, 0, 0, 0)\n        content_col.setSpacing(2)\n\n        role_lbl = QLabel(\"你\" if is_user else \"助手\")\n        role_lbl.setStyleSheet(\n            \"color: #d4d4d8; font-size: 12px; font-weight: 700; background: transparent;\"\n        )\n        if is_user:\n            role_lbl.setAlignment(Qt.AlignRight)\n        content_col.addWidget(role_lbl)\n\n        if is_user:\n            # ── user: right-aligned bubble ──\n            bubble = QWidget()\n            bubble.setStyleSheet(\n                \"background: rgba(63,63,70,0.4); border-radius: 12px;\"\n            )\n            bubble_ly = QVBoxLayout(bubble)\n            bubble_ly.setContentsMargins(12, 8, 12, 8)\n            bubble_ly.setSpacing(0)\n\n            label = QLabel(text)\n            label.setWordWrap(True)\n            label.setTextInteractionFlags(Qt.TextSelectableByMouse)\n            label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)\n            label.setStyleSheet(\n                \"QLabel { background: transparent; color: #e4e4e7;\"\n                \" padding: 0; font-size: 14px; line-height: 1.6; }\"\n            )\n            bubble_ly.addWidget(label)\n            self._label = label\n\n            # Size bubble to text: measure longest line, cap at 420\n            fm = label.fontMetrics()\n            text_w = max((fm.horizontalAdvance(ln) for ln in text.split('\\n')), default=0)\n            bubble.setMinimumWidth(min(text_w + 24, 420))\n            bubble.setMaximumWidth(420)\n            content_col.addWidget(bubble, 0, Qt.AlignRight)\n\n            # ── user message action row ──\n            self._action_row = QWidget()\n            self._action_row.setStyleSheet(\"background: transparent;\")\n            alayout = QHBoxLayout(self._action_row)\n            alayout.setContentsMargins(0, 4, 0, 0)\n            alayout.setSpacing(4)\n            alayout.setAlignment(Qt.AlignRight)\n\n            icon_sz = QSize(15, 15)\n\n            copy_btn = QPushButton()\n            copy_btn.setIcon(_svg_icon(\"copy\", _SVG_COPY))\n            copy_btn.setIconSize(icon_sz)\n            copy_btn.setFixedSize(26, 24)\n            copy_btn.setStyleSheet(self._ACTION_BTN)\n            copy_btn.setToolTip(\"复制\")\n            copy_btn.setCursor(QCursor(Qt.PointingHandCursor))\n            copy_btn.clicked.connect(self._copy_text)\n            alayout.addWidget(copy_btn)\n\n            if on_delete:\n                delete_btn = QPushButton()\n                delete_btn.setIcon(_svg_icon(\"delete\", _SVG_TRASH))\n                delete_btn.setIconSize(icon_sz)\n                delete_btn.setFixedSize(26, 24)\n                delete_btn.setStyleSheet(self._ACTION_BTN)\n                delete_btn.setToolTip(\"删除\")\n                delete_btn.setCursor(QCursor(Qt.PointingHandCursor))\n                delete_btn.clicked.connect(self._do_delete)\n                alayout.addWidget(delete_btn)\n\n            if on_rewrite:\n                rewrite_btn = QPushButton()\n                rewrite_btn.setIcon(_svg_icon(\"rewrite\", _SVG_RESET))\n                rewrite_btn.setIconSize(icon_sz)\n                rewrite_btn.setFixedSize(26, 24)\n                rewrite_btn.setStyleSheet(self._ACTION_BTN)\n                rewrite_btn.setToolTip(\"重写\")\n                rewrite_btn.setCursor(QCursor(Qt.PointingHandCursor))\n                rewrite_btn.clicked.connect(self._do_rewrite)\n                alayout.addWidget(rewrite_btn)\n\n            alayout.addStretch()\n\n            if created_at:\n                from datetime import datetime\n                try:\n                    dt = datetime.fromisoformat(created_at)\n                    time_lbl = QLabel(dt.strftime(\"%Y-%m-%d %H:%M\"))\n                    time_lbl.setStyleSheet(\"color: #a1a1aa; font-size: 11px; background: transparent;\")\n                    alayout.addWidget(time_lbl)\n                except:\n                    pass\n\n            self._action_row.hide()\n            content_col.addWidget(self._action_row, 0, Qt.AlignRight)\n        else:\n            # ── assistant: left-aligned, no bubble ──\n            browser = _FoldableTextBrowser()\n            browser.setReadOnly(True)\n            browser.setOpenExternalLinks(True)\n            browser.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n            browser.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n            browser.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)\n            browser.document().setDefaultStyleSheet(_MD_CSS)\n            browser.setStyleSheet(\n                \"QTextBrowser { background: transparent; color: #e4e4e7;\"\n                \" border: none; padding: 0; font-size: 14px; }\"\n            )\n            self._folded_ids = set()  # 记录被折叠的块\n            self._auto_fold_new_blocks(text)\n            browser.setHtml(self._render_with_folds(text))\n            self._label = browser\n            content_col.addWidget(browser)\n            self._adjust_browser_height()\n\n            self._action_row = QWidget()\n            self._action_row.setStyleSheet(\"background: transparent;\")\n            alayout = QHBoxLayout(self._action_row)\n            alayout.setContentsMargins(0, 4, 0, 0)\n            alayout.setSpacing(4)\n\n            icon_sz = QSize(15, 15)\n\n            copy_btn = QPushButton()\n            copy_btn.setIcon(_svg_icon(\"copy\", _SVG_COPY))\n            copy_btn.setIconSize(icon_sz)\n            copy_btn.setFixedSize(26, 24)\n            copy_btn.setStyleSheet(self._ACTION_BTN)\n            copy_btn.setToolTip(\"复制\")\n            copy_btn.setCursor(QCursor(Qt.PointingHandCursor))\n            copy_btn.clicked.connect(self._copy_text)\n            alayout.addWidget(copy_btn)\n\n            if on_delete:\n                delete_btn = QPushButton()\n                delete_btn.setIcon(_svg_icon(\"delete\", _SVG_TRASH))\n                delete_btn.setIconSize(icon_sz)\n                delete_btn.setFixedSize(26, 24)\n                delete_btn.setStyleSheet(self._ACTION_BTN)\n                delete_btn.setToolTip(\"删除\")\n                delete_btn.setCursor(QCursor(Qt.PointingHandCursor))\n                delete_btn.clicked.connect(self._do_delete)\n                alayout.addWidget(delete_btn)\n\n            if on_resend:\n                regen_btn = QPushButton()\n                regen_btn.setIcon(_svg_icon(\"regen\", _SVG_REGEN))\n                regen_btn.setIconSize(icon_sz)\n                regen_btn.setFixedSize(26, 24)\n                regen_btn.setStyleSheet(self._ACTION_BTN)\n                regen_btn.setToolTip(\"重新生成\")\n                regen_btn.setCursor(QCursor(Qt.PointingHandCursor))\n                regen_btn.clicked.connect(self._do_resend)\n                alayout.addWidget(regen_btn)\n\n            export_btn = QPushButton()\n            export_btn.setIcon(_svg_icon(\"save\", _SVG_SAVE))\n            export_btn.setIconSize(icon_sz)\n            export_btn.setFixedSize(26, 24)\n            export_btn.setStyleSheet(self._ACTION_BTN)\n            export_btn.setToolTip(\"导出为md\")\n            export_btn.setCursor(QCursor(Qt.PointingHandCursor))\n            export_btn.clicked.connect(self._export_as_md)\n            alayout.addWidget(export_btn)\n\n            alayout.addStretch()\n\n            if created_at:\n                from datetime import datetime\n                try:\n                    dt = datetime.fromisoformat(created_at)\n                    time_lbl = QLabel(dt.strftime(\"%Y-%m-%d %H:%M\"))\n                    time_lbl.setStyleSheet(\"color: #a1a1aa; font-size: 11px; background: transparent;\")\n                    alayout.addWidget(time_lbl)\n                except:\n                    pass\n\n            self._action_row.hide()\n            content_col.addWidget(self._action_row)\n\n        # ── assemble: assistant left, user right ──\n        if is_user:\n            outer.addStretch(1)\n            outer.addLayout(content_col, 0)\n            outer.addWidget(avatar, 0, Qt.AlignTop)\n        else:\n            outer.addWidget(avatar, 0, Qt.AlignTop)\n            outer.addLayout(content_col, 1)\n\n    def _copy_text(self):\n        QApplication.clipboard().setText(self._text)\n\n    def _do_resend(self):\n        if self._on_resend:\n            self._on_resend()\n\n    def _do_delete(self):\n        if self._on_delete:\n            self._on_delete()\n\n    def _do_rewrite(self):\n        if self._on_rewrite:\n            self._on_rewrite()\n\n    def _export_as_md(self):\n        from PySide6.QtWidgets import QFileDialog\n        import os\n        from datetime import datetime\n        default_name = f\"msg_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md\"\n        file_path, _ = QFileDialog.getSaveFileName(\n            self, \"导出为 Markdown\", default_name, \"Markdown 文件 (*.md);;所有文件 (*)\"\n        )\n        if file_path:\n            try:\n                with open(file_path, \"w\", encoding=\"utf-8\") as f:\n                    f.write(self._text)\n            except Exception as e:\n                import traceback\n                traceback.print_exc()\n\n    def enterEvent(self, event):\n        if self._action_row and self._finished:\n            self._action_row.show()\n        super().enterEvent(event)\n\n    def leaveEvent(self, event):\n        if self._action_row:\n            self._action_row.hide()\n        super().leaveEvent(event)\n\n    def resizeEvent(self, event):\n        super().resizeEvent(event)\n        if self._role != \"user\" and hasattr(self, '_label'):\n            self._adjust_browser_height()\n\n    def set_finished(self, done: bool):\n        self._finished = done\n        if not done and self._action_row:\n            self._action_row.hide()\n\n    def _adjust_browser_height(self):\n        doc = self._label.document()\n        w = self._label.width()\n        if w < 50:\n            w = 460\n        doc.setTextWidth(w - 6)\n        self._label.setFixedHeight(int(doc.size().height() + 8))\n\n    def set_text(self, text: str):\n        self._text = text\n        if self._role == \"user\":\n            self._label.setText(text)\n            self._label.adjustSize()\n        else:\n            self._auto_fold_new_blocks(text)\n            self._label.setHtml(self._render_with_folds(text))\n            self._adjust_browser_height()\n\n    def highlight(self, keyword: str):\n        \"\"\"Apply highlight and return keyword's y position in document, or None.\"\"\"\n        if not keyword or not self._text:\n            return None\n        kw_lower = keyword.lower()\n        text_lower = self._text.lower()\n        if kw_lower not in text_lower:\n            return None\n        if self._role == \"user\":\n            escaped = self._text.replace(\"&\", \"&amp;\").replace(\"<\", \"&lt;\").replace(\">\", \"&gt;\")\n            kw_esc = keyword.replace(\"&\", \"&amp;\").replace(\"<\", \"&lt;\").replace(\">\", \"&gt;\")\n            highlighted = escaped.replace(kw_esc, f'<span style=\"background: rgba(251,191,36,0.35); color: #fbbf24;\">{kw_esc}</span>')\n            self._label.setText(highlighted)\n            self._label.adjustSize()\n            return 0  # plain text, keyword at top\n        else:\n            from PySide6.QtGui import QTextDocument, QTextCursor, QTextCharFormat\n            doc = self._label.document()\n            cursor = QTextCursor(doc)\n            flags = QTextDocument.FindFlags(0)\n            fmt = QTextCharFormat()\n            fmt.setBackground(QColor(251, 191, 36, 90))\n            fmt.setForeground(QColor(251, 191, 36))\n            keyword_y = None\n            while True:\n                cursor = doc.find(keyword, cursor, flags)\n                if cursor.isNull():\n                    break\n                cursor.mergeCharFormat(fmt)\n                if keyword_y is None:\n                    keyword_y = self._label.cursorRect(cursor).y()\n            self._adjust_browser_height()\n            return keyword_y\n\n    def clear_highlight(self):\n        if self._role == \"user\":\n            self._label.setText(self._text)\n            self._label.adjustSize()\n        else:\n            self._label.setHtml(self._render_with_folds(self._text))\n            self._adjust_browser_height()\n\n\n    def _parse_foldable_blocks(self, text: str):\n        \"\"\"解析文本为可折叠块，返回 [(type, title_or_None, content), ...]\"\"\"\n        import re\n        lines = text.split('\\n')\n        blocks = []\n        current_type = \"normal\"\n        current_title = None\n        current_lines = []\n\n        for line in lines:\n            # 检查是否是折叠块开始\n            llm_match = re.match(r'^\\s*\\*\\*LLM Running \\(Turn \\d+\\) \\.\\.\\.\\*\\*\\s*$', line)\n            tool_match = re.match(r'^\\s*🛠️\\s*Tool:', line)\n            tool_compact_match = re.match(r'^\\s*🛠️\\s+\\w+\\(', line)\n\n            is_foldable_start = llm_match or tool_match or tool_compact_match\n\n            if is_foldable_start:\n                if current_lines:\n                    blocks.append((current_type, current_title, '\\n'.join(current_lines)))\n\n                title = line.strip()\n                if llm_match:\n                    title = line.strip().replace('**', '')\n                current_type = \"foldable\"\n                current_title = title\n                current_lines = [line]\n            else:\n                current_lines.append(line)\n\n        if current_lines:\n            blocks.append((current_type, current_title, '\\n'.join(current_lines)))\n\n        return blocks\n\n    def _auto_fold_new_blocks(self, text: str):\n        \"\"\"将新出现的折叠块加入 _folded_ids（仅在此处修改集合）\"\"\"\n        for _, title, _ in self._parse_foldable_blocks(text):\n            if title is not None and title not in self._folded_ids:\n                self._folded_ids.add(title)\n\n    def _render_with_folds(self, text: str) -> str:\n        \"\"\"渲染文本为带折叠的 HTML（纯渲染，不修改 _folded_ids）\"\"\"\n        from urllib.parse import quote\n        blocks = self._parse_foldable_blocks(text)\n        html_parts = []\n\n        for i, (block_type, title, content) in enumerate(blocks):\n            if block_type == \"normal\":\n                html_parts.append(f'<div>{_md_to_html(content)}</div>')\n            else:\n                safe_title = quote(title, safe='')\n                display_title = title.replace('**', '')\n                if title in self._folded_ids:\n                    # 折叠状态：只显示标题 + 展开链接\n                    html_parts.append(\n                        f'<div><p><a href=\"#fold_{safe_title}\" style=\"color: #a1a1aa; text-decoration: none;\">▶ {display_title} (点击展开)</a></p></div>'\n                    )\n                else:\n                    # 展开状态：显示标题 + 折叠链接 + 内容\n                    html_parts.append(\n                        f'<div><p><a href=\"#fold_{safe_title}\" style=\"color: #a1a1aa; text-decoration: none;\">▼ {display_title} (点击折叠)</a></p>{_md_to_html(content)}</div>'\n                    )\n        return '\\n'.join(html_parts)\n\n    def _toggle_fold(self, title):\n        \"\"\"折叠/展开切换\"\"\"\n        if title in self._folded_ids:\n            self._folded_ids.remove(title)\n        else:\n            self._folded_ids.add(title)\n        self._label.setHtml(self._render_with_folds(self._text))\n        self._adjust_browser_height()\n\n\nclass _TabButton(QPushButton):\n    _STYLE = \"\"\"\n    QPushButton {{\n        background: transparent; color: {muted};\n        border: none; border-radius: 8px;\n        padding: 0 14px; font-size: 12px; font-weight: 700;\n    }}\n    QPushButton:hover {{\n        background: {hover_bg}; color: {text};\n    }}\n    QPushButton:checked {{\n        background: {accent}; color: white;\n    }}\n    \"\"\".format(muted=C[\"muted\"], text=C[\"text\"], hover_bg=C[\"hover_bg\"], accent=C[\"accent\"])\n\n    def __init__(self, text: str, parent=None):\n        super().__init__(text, parent)\n        self.setCheckable(True)\n        self.setFixedHeight(30)\n        self.setStyleSheet(self._STYLE)\n\n\ndef _action_btn(label: str, color: str, icon: QIcon | None = None) -> QPushButton:\n    btn = QPushButton(label)\n    if icon and not icon.isNull():\n        btn.setIcon(icon)\n        btn.setIconSize(QSize(16, 16))\n    btn.setFixedHeight(36)\n    btn.setStyleSheet(f\"\"\"\n        QPushButton {{\n            background: rgba(35,35,40,0.8); color: {C['text']};\n            border: 1px solid {C['border'].name()};\n            border-left: 3px solid {color};\n            border-radius: 8px; padding: 0 14px;\n            font-size: 13px; font-weight: 700; text-align: left;\n        }}\n        QPushButton:hover {{ background: rgba(55,55,62,0.9); }}\n        QPushButton:checked {{ color: {color}; background: rgba(35,35,40,0.95); }}\n    \"\"\")\n    return btn\n\n\n# ── Main panel ────────────────────────────────────────────────────────────────\nclass ChatPanel(QWidget):\n    \"\"\"Frameless always-on-top chat window.\"\"\"\n\n    def __init__(self, agent):\n        super().__init__()\n        self.agent = agent\n\n        # session state\n        self._messages: list[dict] = []\n        self._session = {\"id\": _make_session_id(), \"title\": \"新对话\", \"messages\": []}\n        self._history: list[dict] = _load_history()\n        self._pending_files: list[dict] = []  # {'name','type','raw'}\n        self._settings_health_checked = False\n\n        # streaming state\n        self._display_queue: Optional[_queue.Queue] = None\n        self._streaming_row: Optional[_MsgRow] = None\n        self._streaming_text = \"\"\n        self._user_scrolled_up = False\n        self._poll_timer = QTimer(self)\n        self._poll_timer.timeout.connect(self._poll_queue)\n\n        # autonomous mode\n        self.autonomous_enabled = False\n        self.last_reply_time = time.time()\n\n        self.setWindowFlags(\n            Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool\n        )\n        self.setAttribute(Qt.WA_TranslucentBackground)\n        self.resize(530, 700)\n\n        # drag state (title bar)\n        self._drag_pos: Optional[QPoint] = None\n\n        self._build_ui()\n\n    def paintEvent(self, _event):\n        p = QPainter(self)\n        p.setRenderHint(QPainter.Antialiasing)\n        path = QPainterPath()\n        path.addRect(0.5, 0.5, self.width() - 1.0, self.height() - 1.0)\n        grad = QLinearGradient(0, 0, 0, self.height())\n        grad.setColorAt(0.0, QColor(20, 20, 28, 255))\n        grad.setColorAt(1.0, QColor(10, 10, 14, 255))\n        p.fillPath(path, grad)\n\n    def resizeEvent(self, event):\n        path = QPainterPath()\n        path.addRect(0, 0, float(self.width()), float(self.height()))\n        self.setMask(QRegion(path.toFillPolygon().toPolygon()))\n        super().resizeEvent(event)\n\n    # ── UI construction ───────────────────────────────────────────────────────\n    def _build_ui(self):\n        root = QVBoxLayout(self)\n        root.setContentsMargins(0, 0, 0, 0)\n        root.setSpacing(0)\n\n        root.addWidget(self._build_titlebar())\n        root.addWidget(_Separator())\n        root.addWidget(self._build_tabbar())\n        root.addWidget(_Separator())\n\n        self._stack = QStackedWidget()\n        self._stack.setStyleSheet(\"background: transparent;\")\n        self._stack.addWidget(self._build_chat_page())    # 0\n        self._stack.addWidget(self._build_history_page()) # 1\n        self._stack.addWidget(self._build_sop_page())     # 2\n        self._stack.addWidget(self._build_settings_page())# 3\n        root.addWidget(self._stack)\n        root.addWidget(self._build_statusbar())\n\n        # Now that _stack exists, activate the first tab\n        self._switch_tab(0)\n\n    # ── title bar ─────────────────────────────────────────────────────────────\n    def _build_titlebar(self) -> QWidget:\n        bar = QWidget()\n        bar.setFixedHeight(48)\n        bar.setStyleSheet(\"background: transparent;\")\n        bar.setCursor(QCursor(Qt.SizeAllCursor))\n\n        ly = QHBoxLayout(bar)\n        ly.setContentsMargins(16, 0, 10, 0)\n        ly.setSpacing(8)\n\n        # Search button\n        search_btn = QPushButton()\n        search_btn.setIcon(_svg_icon(\"search\", _SVG_SEARCH, \"#a1a1aa\"))\n        search_btn.setIconSize(QSize(16, 16))\n        search_btn.setFixedSize(26, 26)\n        search_btn.setCursor(QCursor(Qt.PointingHandCursor))\n        search_btn.setStyleSheet(\"\"\"\n            QPushButton { background: transparent; border: none; border-radius: 13px; }\n            QPushButton:hover { background: rgba(63,63,70,0.6); }\n        \"\"\")\n        search_btn.clicked.connect(self._toggle_search)\n        self._search_btn = search_btn\n        ly.addWidget(search_btn)\n\n        # Search widget (hidden by default)\n        self._search_widget = QWidget()\n        self._search_widget.hide()\n        sw_ly = QHBoxLayout(self._search_widget)\n        sw_ly.setContentsMargins(0, 0, 0, 0)\n        sw_ly.setSpacing(6)\n\n        self._search_input = QLineEdit()\n        self._search_input.setPlaceholderText(\"搜索当前对话和历史...\")\n        self._search_input.setFixedHeight(26)\n        self._search_input.setStyleSheet(f\"\"\"\n            QLineEdit {{\n                background: rgba(32,32,38,0.9);\n                border: 1px solid {C['border'].name()};\n                border-radius: 13px;\n                color: {C['text']};\n                font-size: 13px;\n                padding: 0 10px;\n            }}\n            QLineEdit::placeholder {{ color: {C['muted']}; }}\n        \"\"\")\n        self._search_input.setFixedWidth(200)\n        self._search_input.textChanged.connect(self._on_search_changed)\n        self._search_input.installEventFilter(self)\n        sw_ly.addWidget(self._search_input)\n\n        close_search = QPushButton(\"×\")\n        close_search.setFixedSize(26, 26)\n        close_search.setCursor(QCursor(Qt.PointingHandCursor))\n        close_search.setStyleSheet(\"\"\"\n            QPushButton { background: transparent; color: #71717a; border: none; font-size: 16px; }\n            QPushButton:hover { color: #a1a1aa; }\n        \"\"\")\n        close_search.clicked.connect(self._hide_search)\n        sw_ly.addWidget(close_search)\n        ly.addWidget(self._search_widget)\n\n        ly.addStretch()\n\n        # Minimize button\n        mini = QPushButton(\"\\uE949\")\n        mini.setFixedSize(26, 26)\n        mini.setCursor(QCursor(Qt.PointingHandCursor))\n        mini.setStyleSheet(\"\"\"\n            QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa;\n                border: none; border-radius: 13px; font-family: \"Segoe MDL2 Assets\"; font-size: 9px; }\n            QPushButton:hover { background: rgba(63,63,70,0.9); color: white; }\n        \"\"\")\n        mini.clicked.connect(self.hide)\n        ly.addWidget(mini)\n\n        # Maximize button\n        maxi = QPushButton(\"\\uE739\")\n        maxi.setFixedSize(26, 26)\n        maxi.setCursor(QCursor(Qt.PointingHandCursor))\n        maxi.setStyleSheet(\"\"\"\n            QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa;\n                border: none; border-radius: 13px; font-family: \"Segoe MDL2 Assets\"; font-size: 9px; }\n            QPushButton:hover { background: rgba(63,63,70,0.9); color: white; }\n        \"\"\")\n        maxi.clicked.connect(self._toggle_maximize)\n        self._maxi_btn = maxi\n        ly.addWidget(maxi)\n\n        # Close button\n        close = QPushButton(\"\\uE8BB\")\n        close.setFixedSize(26, 26)\n        close.setCursor(QCursor(Qt.PointingHandCursor))\n        close.setStyleSheet(\"\"\"\n            QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa;\n                border: none; border-radius: 13px; font-family: \"Segoe MDL2 Assets\"; font-size: 9px; }\n            QPushButton:hover { background: rgba(220,38,38,0.85); color: white; }\n        \"\"\")\n        close.clicked.connect(lambda: (self.close(), QApplication.instance().quit()))\n        ly.addWidget(close)\n\n        # Drag\n        bar.mousePressEvent   = self._tb_press\n        bar.mouseMoveEvent    = self._tb_move\n        bar.mouseReleaseEvent = self._tb_release\n        return bar\n\n    def _toggle_search(self):\n        if hasattr(self, \"_search_visible\") and self._search_visible:\n            self._hide_search()\n        else:\n            self._show_search()\n\n    def _show_search(self):\n        self._search_visible = True\n        self._search_btn.setFixedSize(0, 0)\n        self._search_widget.show()\n        self._search_input.setFocus()\n        self._search_input.selectAll()\n\n    def _hide_search(self):\n        self._search_visible = False\n        self._search_btn.setFixedSize(26, 26)\n        self._search_widget.hide()\n        self._search_input.clear()\n        self._clear_all_highlights()\n        if self._stack.currentIndex() == 1:\n            self._reset_history_items_style()\n\n    def _hide_search_if_no_focus(self):\n        if not self._search_input.hasFocus():\n            self._hide_search()\n\n    def _on_search_changed(self, text):\n        if not text.strip():\n            self._clear_all_highlights()\n            return\n        keyword = text.strip()\n        current_tab = self._stack.currentIndex()\n\n        if current_tab == 0:\n            self._search_current_chat(keyword)\n        elif current_tab == 1:\n            self._search_history(keyword)\n\n    def _clear_all_highlights(self):\n        for i in range(self._msg_layout.count() - 1):\n            w = self._msg_layout.itemAt(i).widget()\n            if isinstance(w, _MsgRow):\n                w.clear_highlight()\n\n    def _search_current_chat(self, keyword: str):\n        first_found = None\n        first_keyword_y = None\n        for i in range(self._msg_layout.count() - 1):\n            w = self._msg_layout.itemAt(i).widget()\n            if isinstance(w, _MsgRow):\n                if keyword.lower() in w._text.lower():\n                    kw_y = w.highlight(keyword)\n                    if first_found is None:\n                        first_found = w\n                        first_keyword_y = kw_y\n                else:\n                    w.clear_highlight()\n        # 滚动到第一个匹配项（使用关键词在文档内的实际位置）\n        if first_found:\n            self._scroll_to_widget(first_found, first_keyword_y or 0)\n\n    def _scroll_to_widget(self, w, keyword_y=0):\n        self._user_scrolled_up = True\n        self._msg_container.layout().activate()\n        QApplication.processEvents()\n\n        sb = self._scroll.verticalScrollBar()\n        vp_h = self._scroll.viewport().height()\n        keyword_screen_y = w.y() + keyword_y\n        target = keyword_screen_y - vp_h // 3\n        target = max(0, min(target, sb.maximum()))\n        sb.setValue(target)\n        QApplication.processEvents()\n        self._scroll.viewport().repaint()\n\n    def _search_history(self, keyword: str):\n        kw_lower = keyword.lower()\n        for i in range(self._hist_list.count()):\n            item = self._hist_list.item(i)\n            session = item.data(Qt.UserRole)\n            messages = session.get(\"messages\", []) if session else []\n            content_text = \" \".join([m.get(\"content\", \"\") for m in messages if isinstance(m.get(\"content\"), str)])\n            match = kw_lower in content_text.lower()\n            item.setHidden(not match)\n            if match:\n                item.setBackground(QColor(251, 191, 36, 50))\n                item.setForeground(QColor(251, 191, 36))\n            else:\n                item.setBackground(QColor(0, 0, 0, 0))\n                item.setForeground(QColor(255, 255, 255))\n\n    def _reset_history_items_style(self):\n        for i in range(self._hist_list.count()):\n            item = self._hist_list.item(i)\n            item.setHidden(False)\n            item.setBackground(QColor(0, 0, 0, 0))\n            item.setForeground(QColor(255, 255, 255))\n            w = self._hist_list.itemWidget(item)\n            if w:\n                w.setStyleSheet(\n                    f\"background: rgba(35,35,42,0.6); color: {C['text']};\"\n                    \" border: 1px solid #3f3f46; border-radius: 8px;\"\n                    \" padding: 8px 12px; margin: 2px 0;\"\n                )\n\n    def _tb_press(self, e):\n        if e.button() == Qt.LeftButton:\n            self._drag_pos = e.globalPosition().toPoint() - self.pos()\n\n    def _tb_move(self, e):\n        if e.buttons() == Qt.LeftButton and self._drag_pos is not None:\n            self.move(e.globalPosition().toPoint() - self._drag_pos)\n\n    def _tb_release(self, _e):\n        self._drag_pos = None\n\n    def _toggle_maximize(self):\n        if self.isMaximized():\n            self.showNormal()\n            self._maxi_btn.setText(\"☐\")\n        else:\n            self.showMaximized()\n            self._maxi_btn.setText(\"❐\")\n\n    # ── status bar ─────────────────────────────────────────────────────────────\n    def _build_statusbar(self) -> QWidget:\n        bar = QWidget()\n        bar.setFixedHeight(24)\n        bar.setStyleSheet(\"background: transparent;\")\n        ly = QHBoxLayout(bar)\n        ly.setContentsMargins(16, 0, 10, 0)\n        ly.setSpacing(8)\n\n        # Status dot\n        dot = QLabel(\"●\")\n        dot.setStyleSheet(f\"color: {C['green']}; font-size: 9px;\")\n        dot.setFixedWidth(12)\n        ly.addWidget(dot)\n\n        # Model name (clickable to show model list)\n        self._model_badge = QLabel(self._model_name())\n        self._model_badge.setStyleSheet(\"color: #a1a1aa; font-size: 11px;\")\n        self._model_badge.setCursor(QCursor(Qt.PointingHandCursor))\n        self._model_badge.mousePressEvent = lambda e: self._show_model_menu(e)\n        ly.addWidget(self._model_badge)\n\n        self._streaming_badge = _StreamingBadge()\n        ly.addWidget(self._streaming_badge)\n\n        ly.addStretch()\n        return bar\n\n    def _show_model_menu(self, _e):\n        menu = QMenu(self._model_badge)\n        menu.setStyleSheet(f\"\"\"\n            QMenu {{\n                background: {C['panel'].name()};\n                border: 1px solid {C['border'].name()};\n                padding: 4px 0;\n            }}\n            QMenu::item {{\n                color: {C['text']};\n                padding: 6px 20px 6px 12px;\n                font-size: 12px;\n            }}\n            QMenu::item:selected {{\n                background: {C['hover_bg']};\n            }}\n        \"\"\")\n        for i, client in enumerate(self.agent.llmclients):\n            name = getattr(client, 'name', None) or \"未知\"\n            act = menu.addAction(f\"{name}  #{i + 1}\")\n            act.triggered.connect(lambda _, idx=i: self._do_switch_to(idx))\n        menu.exec(QCursor.pos())\n\n    # ── tab bar ───────────────────────────────────────────────────────────────\n    def _build_tabbar(self) -> QWidget:\n        bar = QWidget()\n        bar.setFixedHeight(40)\n        bar.setStyleSheet(\"background: rgba(10,10,14,0.6);\")\n\n        ly = QHBoxLayout(bar)\n        ly.setContentsMargins(12, 5, 12, 5)\n        ly.setSpacing(4)\n\n        self._tabs: list[_TabButton] = []\n        tab_defs = [\n            (_SVG_CHAT,  \"对话\"),\n            (_SVG_CLOCK, \"历史\"),\n            (_SVG_BOOK,  \"SOP\"),\n            (_SVG_GEAR,  \"设置\"),\n        ]\n        for i, (svg, text) in enumerate(tab_defs):\n            btn = _TabButton(text)\n            btn.setIcon(_svg_icon(text, svg, \"#b0b0b8\"))\n            btn.setIconSize(QSize(14, 14))\n            btn.clicked.connect(lambda _checked, idx=i: self._switch_tab(idx))\n            ly.addWidget(btn)\n            self._tabs.append(btn)\n\n        ly.addStretch()\n\n        new_btn = QPushButton(\"新对话\")\n        new_btn.setIcon(_svg_icon(\"plus\", _SVG_PLUS, \"#a78bfa\"))\n        new_btn.setIconSize(QSize(12, 12))\n        new_btn.setFixedHeight(27)\n        new_btn.setStyleSheet(f\"\"\"\n            QPushButton {{ background: rgba(124,58,237,0.18); color: #a78bfa;\n                border: 1px solid rgba(124,58,237,0.3); border-radius: 7px;\n                padding: 0 10px; font-size: 12px; font-weight: 700; }}\n            QPushButton:hover {{ background: rgba(124,58,237,0.35); color: white; }}\n        \"\"\")\n        new_btn.clicked.connect(self._new_session)\n        ly.addWidget(new_btn)\n\n        # NOTE: _switch_tab(0) is called in _build_ui() after _stack is created\n        return bar\n\n    def _switch_tab(self, idx: int):\n        self._stack.setCurrentIndex(idx)\n        for i, btn in enumerate(self._tabs):\n            btn.setChecked(i == idx)\n        # 切换标签时关闭搜索框\n        if hasattr(self, '_search_visible') and self._search_visible:\n            self._hide_search()\n        if idx == 1:\n            self._refresh_history()\n        if idx == 2:\n            self._refresh_sop()\n        if idx == 3:\n            self._refresh_model_rows_style()\n            if not self._settings_health_checked:\n                self._start_health_checks()\n                self._settings_health_checked = True\n\n    # ── chat page ─────────────────────────────────────────────────────────────\n    def _build_chat_page(self) -> QWidget:\n        page = QWidget()\n        page.setStyleSheet(\"background: transparent;\")\n        ly = QVBoxLayout(page)\n        ly.setContentsMargins(0, 0, 0, 0)\n        ly.setSpacing(0)\n\n        # ── message scroll area ──\n        self._scroll = QScrollArea()\n        self._scroll.setWidgetResizable(True)\n        self._scroll.setFrameShape(QFrame.NoFrame)\n        self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n        self._scroll.setStyleSheet(f\"QScrollArea {{ background: transparent; border: none; }} {SCROLLBAR_STYLE}\")\n\n        self._msg_container = QWidget()\n        self._msg_container.setStyleSheet(\"background: transparent;\")\n        self._msg_layout = QVBoxLayout(self._msg_container)\n        self._msg_layout.setContentsMargins(0, 12, 0, 12)\n        self._msg_layout.setSpacing(4)\n        self._msg_layout.addStretch()\n\n        self._scroll.setWidget(self._msg_container)\n        self._scroll.verticalScrollBar().valueChanged.connect(self._on_scroll)\n\n        # ── scroll navigation buttons (centered at bottom of message area) ──\n        scroll_wrapper = QWidget()\n        scroll_wrapper.setStyleSheet(\"background: transparent;\")\n        wrap_ly = QVBoxLayout(scroll_wrapper)\n        wrap_ly.setContentsMargins(0, 0, 0, 0)\n        wrap_ly.setSpacing(0)\n        wrap_ly.addWidget(self._scroll)\n\n        self._nav_widget = QWidget()\n        self._nav_widget.setFixedSize(68, 28)\n        self._nav_widget.setStyleSheet(\"background: transparent; border: none;\")\n        nav_ly = QHBoxLayout(self._nav_widget)\n        nav_ly.setContentsMargins(6, 2, 6, 2)\n        nav_ly.setSpacing(8)\n\n        self._nav_up = QPushButton(\"∧\")\n        self._nav_up.setFixedWidth(26)\n        self._nav_up.setCursor(QCursor(Qt.PointingHandCursor))\n        self._nav_up.setStyleSheet(\"\"\"\n            QPushButton { background: transparent; color: #71717a; border: none; font-size: 14px; }\n            QPushButton:hover { color: #a1a1aa; }\n            QPushButton:disabled { color: #27272a; }\n        \"\"\")\n        self._nav_up.clicked.connect(self._scroll_to_top)\n\n        self._nav_down = QPushButton(\"∨\")\n        self._nav_down.setFixedWidth(26)\n        self._nav_down.setCursor(QCursor(Qt.PointingHandCursor))\n        self._nav_down.setStyleSheet(\"\"\"\n            QPushButton { background: transparent; color: #71717a; border: none; font-size: 14px; }\n            QPushButton:hover { color: #a1a1aa; }\n            QPushButton:disabled { color: #27272a; }\n        \"\"\")\n        self._nav_down.clicked.connect(self._scroll_to_bottom)\n\n        nav_ly.addWidget(self._nav_up)\n        nav_ly.addWidget(self._nav_down)\n\n        wrap_ly.addWidget(self._nav_widget, 0, Qt.AlignHCenter | Qt.AlignBottom)\n        self._nav_widget.setContentsMargins(0, 0, 0, 8)\n        self._nav_widget.hide()\n\n        ly.addWidget(scroll_wrapper, 1)\n\n        ly.addWidget(_Separator())\n\n        # ── input area ──\n        ly.addWidget(self._build_input_area())\n\n        QTimer.singleShot(200, self._update_nav_visibility)\n        return page\n\n    def _build_input_area(self) -> QWidget:\n        wrap = QWidget()\n        wrap.setStyleSheet(\"background: transparent;\")\n        ly = QVBoxLayout(wrap)\n        ly.setContentsMargins(20, 6, 20, 0)\n        ly.setSpacing(0)\n\n        self._chips_row = QWidget()\n        self._chips_row.setStyleSheet(\"background: transparent;\")\n        self._chips_ly = QHBoxLayout(self._chips_row)\n        self._chips_ly.setContentsMargins(0, 0, 0, 6)\n        self._chips_ly.setSpacing(6)\n        self._chips_row.hide()\n        ly.addWidget(self._chips_row)\n\n        card = QWidget()\n        card.setStyleSheet(f\"\"\"\n            QWidget#inputCard {{\n                background: rgba(32,32,38,0.85);\n                border: 1px solid {C['border'].name()};\n                border-radius: 16px;\n            }}\n            QWidget#inputCard:focus-within {{\n                border-color: rgba(124,58,237,0.55);\n            }}\n        \"\"\")\n        card.setObjectName(\"inputCard\")\n        card_ly = QVBoxLayout(card)\n        card_ly.setContentsMargins(14, 10, 10, 10)\n        card_ly.setSpacing(6)\n\n        class _PlainTextEdit(QTextEdit):\n            def insertFromMimeData(self, source):\n                text = source.text() or source.data(\"text/plain\")\n                if text:\n                    self.insertPlainText(text)\n\n        self._input = _PlainTextEdit()\n        self._input.setAutoFormatting(QTextEdit.AutoNone)\n        self._input.setFixedHeight(64)\n        self._input.setPlaceholderText(\"给助手发送消息... Enter发送，Shift+Enter换行\")\n        self._input.setStyleSheet(f\"\"\"\n            QTextEdit {{\n                background: transparent; color: {C['text']};\n                border: none; padding: 0; font-size: 14px;\n                selection-background-color: rgba(124,58,237,0.4);\n            }}\n        \"\"\")\n        self._input.installEventFilter(self)\n        self._input.textChanged.connect(self._on_text_changed)\n        card_ly.addWidget(self._input)\n\n        bottom = QHBoxLayout()\n        bottom.setSpacing(6)\n\n        attach = QPushButton()\n        attach.setIcon(_svg_icon(\"clip\", _SVG_CLIP, \"#a1a1aa\"))\n        attach.setIconSize(QSize(17, 17))\n        attach.setFixedSize(30, 30)\n        attach.setToolTip(\"上传附件\")\n        attach.setCursor(QCursor(Qt.PointingHandCursor))\n        attach.setStyleSheet(\"\"\"\n            QPushButton { background: transparent; border: none; border-radius: 15px; }\n            QPushButton:hover { background: rgba(63,63,70,0.6); }\n        \"\"\")\n        attach.clicked.connect(self._attach_files)\n        bottom.addWidget(attach)\n\n        self._char_lbl = QLabel(\"0 / 2000\")\n        self._char_lbl.setStyleSheet(f\"color: {C['muted']}; font-size: 11px;\")\n        bottom.addWidget(self._char_lbl)\n\n        self._token_lbl = QLabel(\"\")\n        self._token_lbl.setStyleSheet(f\"color: {C['muted']}; font-size: 11px; margin-left: 10px;\")\n        bottom.addWidget(self._token_lbl)\n\n        bottom.addStretch()\n\n        self._is_streaming = False\n        self._send_btn = QPushButton()\n        self._send_btn.setFixedSize(34, 34)\n        self._send_btn.setCursor(QCursor(Qt.PointingHandCursor))\n        self._send_btn.clicked.connect(self._on_send_btn_click)\n        self._set_send_mode()\n        bottom.addWidget(self._send_btn)\n\n        card_ly.addLayout(bottom)\n        ly.addWidget(card)\n        return wrap\n\n    # ── history page ──────────────────────────────────────────────────────────\n    def _build_history_page(self) -> QWidget:\n        page = QWidget()\n        page.setStyleSheet(\"background: transparent;\")\n        ly = QVBoxLayout(page)\n        ly.setContentsMargins(12, 12, 12, 12)\n        ly.setSpacing(8)\n\n        header = QHBoxLayout()\n        lbl = QLabel(\"历史记录\")\n        lbl.setStyleSheet(\"color: #f4f4f5; font-weight: 600; font-size: 14px;\")\n        header.addWidget(lbl)\n        header.addStretch()\n\n        restore_btn = QPushButton(\"恢复会话\")\n        restore_btn.setStyleSheet(self._small_btn_style(C[\"accent\"]))\n        restore_btn.clicked.connect(self._restore_selected)\n        header.addWidget(restore_btn)\n\n        del_btn = QPushButton(\"删除\")\n        del_btn.setStyleSheet(self._small_btn_style(\"#dc2626\"))\n        del_btn.clicked.connect(self._delete_selected)\n        header.addWidget(del_btn)\n        ly.addLayout(header)\n\n        self._hist_list = QListWidget()\n        self._hist_list.setStyleSheet(f\"\"\"\n            QListWidget {{ background: transparent; border: none; outline: none; }}\n            QListWidget::item {{\n                background: rgba(35,35,42,0.6); color: {C['text']};\n                border: 1px solid {C['border'].name()}; border-radius: 8px;\n                padding: 8px 12px; margin: 2px 0;\n            }}\n            QListWidget::item:hover {{ background: rgba(55,55,65,0.8);\n                border-color: rgba(124,58,237,0.4); }}\n            QListWidget::item:selected {{ background: {C[\"accent_bg\"]};\n                border-color: rgba(124,58,237,0.6); }}\n            {SCROLLBAR_STYLE}\n        \"\"\")\n        self._hist_list.itemDoubleClicked.connect(self._restore_selected)\n        ly.addWidget(self._hist_list)\n        return page\n\n    # ── SOP page ──────────────────────────────────────────────────────────────\n    def _build_sop_page(self) -> QWidget:\n        page = QWidget()\n        page.setStyleSheet(\"background: transparent;\")\n        ly = QVBoxLayout(page)\n        ly.setContentsMargins(0, 0, 0, 0)\n\n        splitter = QSplitter(Qt.Horizontal)\n\n        self._sop_list = QListWidget()\n        self._sop_list.setMaximumWidth(175)\n        self._sop_list.setStyleSheet(f\"\"\"\n            QListWidget {{ background: rgba(10,10,14,0.7); border: none;\n                border-right: 1px solid {C['border'].name()}; outline: none; }}\n            QListWidget::item {{ color: {C['muted']}; padding: 7px 10px;\n                border-radius: 4px; margin: 1px 4px; }}\n            QListWidget::item:hover {{ background: rgba(55,55,65,0.7); color: {C['text']}; }}\n            QListWidget::item:selected {{ background: rgba(124,58,237,0.28); color: white; }}\n            {SCROLLBAR_STYLE}\n        \"\"\")\n        self._sop_list.currentItemChanged.connect(self._load_sop)\n        splitter.addWidget(self._sop_list)\n\n        self._sop_viewer = QTextBrowser()\n        self._sop_viewer.setOpenExternalLinks(True)\n        self._sop_viewer.document().setDefaultStyleSheet(_MD_CSS)\n        self._sop_viewer.setStyleSheet(f\"\"\"\n            QTextBrowser {{ background: transparent; color: {C['text']};\n                border: none; padding: 10px 14px;\n                font-family: \"Arial\", \"Microsoft YaHei\", sans-serif;\n                font-size: 13px; }}\n            {SCROLLBAR_STYLE}\n        \"\"\")\n        splitter.addWidget(self._sop_viewer)\n        splitter.setSizes([165, 340])\n        ly.addWidget(splitter)\n        return page\n\n    # ── settings page ─────────────────────────────────────────────────────────\n    def _build_settings_page(self) -> QWidget:\n        page = QWidget()\n        page.setStyleSheet(\"background: transparent;\")\n        ly = QVBoxLayout(page)\n        ly.setContentsMargins(16, 16, 16, 16)\n        ly.setSpacing(8)\n\n        lbl = QLabel(\"控制面板\")\n        lbl.setStyleSheet(\"color: #f4f4f5; font-weight: 600; font-size: 14px;\")\n        ly.addWidget(lbl)\n\n        self._model_info = QLabel(f\"当前模型：{self._model_name()} (#{self.agent.llm_no})\")\n        self._model_info.setStyleSheet(f\"color: {C['muted']}; font-size: 12px;\")\n        ly.addWidget(self._model_info)\n        ly.addSpacing(4)\n\n        model_hdr = QLabel(\"模型列表\")\n        model_hdr.setStyleSheet(\"color: #d4d4d8; font-weight: 600; font-size: 13px;\")\n        ly.addWidget(model_hdr)\n\n        self._model_rows_container = QWidget()\n        self._model_rows_container.setStyleSheet(\"background: transparent;\")\n        self._model_rows_layout = QVBoxLayout(self._model_rows_container)\n        self._model_rows_layout.setContentsMargins(0, 0, 0, 0)\n        self._model_rows_layout.setSpacing(3)\n        ly.addWidget(self._model_rows_container)\n\n        self._model_row_widgets: list[dict] = []\n        self._health_results: dict[int, bool | None] = {}\n        self._build_model_rows()\n\n        ly.addSpacing(6)\n\n        for (lbl_text, color, handler, svg) in [\n            (\"重置提示词\", \"#059669\", self._do_reset_prompt,  _SVG_RESET),\n            (\"保存当前会话\",\"#0ea5e9\", self._do_save,         _SVG_SAVE),\n            (\"清空对话\",   \"#78716c\", self._do_clear,         _SVG_TRASH),\n        ]:\n            b = _action_btn(lbl_text, color, _svg_icon(lbl_text, svg))\n            b.clicked.connect(handler)\n            ly.addWidget(b)\n\n        ly.addSpacing(10)\n        sep = QLabel(\"自主行动\")\n        sep.setStyleSheet(\"color: #f4f4f5; font-weight: 600; font-size: 13px;\")\n        ly.addWidget(sep)\n\n        self._auto_btn = _action_btn(f\"开启自主行动 (idle > {AUTO_IDLE_THRESHOLD // 60} min 自动触发)\", \"#f59e0b\",\n                                      _svg_icon(\"bolt\", _SVG_BOLT))\n        self._auto_btn.setCheckable(True)\n        self._auto_btn.clicked.connect(self._do_toggle_auto)\n        ly.addWidget(self._auto_btn)\n\n        trigger_btn = _action_btn(\"立即触发一次\", \"#f59e0b\",\n                                  _svg_icon(\"play\", _SVG_PLAY))\n        trigger_btn.clicked.connect(self._do_trigger_auto)\n        ly.addWidget(trigger_btn)\n\n        ly.addStretch()\n        return page\n\n    # ── model list ────────────────────────────────────────────────────────────\n    _MODEL_ROW_STYLE = (\n        \"QPushButton { background: rgba(39,39,42,0.7); color: #e4e4e7;\"\n        \" border: 1px solid #3f3f46; border-radius: 8px;\"\n        \" padding: 6px 10px; font-size: 12px; font-weight: 700; text-align: left; }\"\n        \" QPushButton:hover { background: rgba(63,63,70,0.8); }\"\n    )\n    _MODEL_ROW_ACTIVE = (\n        \"QPushButton { background: rgba(124,58,237,0.25); color: #c4b5fd;\"\n        \" border: 1px solid rgba(124,58,237,0.5); border-radius: 8px;\"\n        \" padding: 6px 10px; font-size: 12px; font-weight: 700; text-align: left; }\"\n        \" QPushButton:hover { background: rgba(124,58,237,0.35); }\"\n    )\n\n    def _build_model_rows(self):\n        while self._model_rows_layout.count():\n            w = self._model_rows_layout.takeAt(0).widget()\n            if w:\n                w.deleteLater()\n        self._model_row_widgets.clear()\n\n        for idx, tc in enumerate(self.agent.llmclients):\n            b = tc.backend\n            name = f\"{type(b).__name__}/{b.model}\"\n            is_current = idx == self.agent.llm_no\n\n            row = QWidget()\n            row.setStyleSheet(\"background: transparent;\")\n            rlay = QHBoxLayout(row)\n            rlay.setContentsMargins(0, 0, 0, 0)\n            rlay.setSpacing(6)\n\n            dot = QLabel(\"●\")\n            dot.setFixedWidth(14)\n            dot.setAlignment(Qt.AlignCenter)\n            dot.setStyleSheet(\"color: #71717a; font-size: 11px;\")\n            rlay.addWidget(dot)\n\n            btn = QPushButton(f\"  #{idx}  {name}\")\n            btn.setCursor(QCursor(Qt.PointingHandCursor))\n            btn.setStyleSheet(self._MODEL_ROW_ACTIVE if is_current else self._MODEL_ROW_STYLE)\n            btn.clicked.connect(lambda checked, i=idx: self._do_switch_to(i))\n            rlay.addWidget(btn, 1)\n\n            self._model_rows_layout.addWidget(row)\n            self._model_row_widgets.append({\"dot\": dot, \"btn\": btn, \"idx\": idx})\n\n    def _refresh_model_rows_style(self):\n        for entry in self._model_row_widgets:\n            is_current = entry[\"idx\"] == self.agent.llm_no\n            entry[\"btn\"].setStyleSheet(\n                self._MODEL_ROW_ACTIVE if is_current else self._MODEL_ROW_STYLE\n            )\n            status = self._health_results.get(entry[\"idx\"])\n            if status is True:\n                entry[\"dot\"].setStyleSheet(\"color: #22c55e; font-size: 11px;\")\n            elif status is False:\n                entry[\"dot\"].setStyleSheet(\"color: #ef4444; font-size: 11px;\")\n            else:\n                entry[\"dot\"].setStyleSheet(\"color: #71717a; font-size: 11px;\")\n\n    def _do_switch_to(self, idx: int):\n        if idx == self.agent.llm_no:\n            return\n        self.agent.next_llm(n=idx)\n        name = self._model_name()\n        self._model_badge.setText(name)\n        self._model_info.setText(f\"当前模型：{name} (#{self.agent.llm_no})\")\n        self._add_system_notice(f\"已切换至 {name}，对话上下文已保留\")\n        self._refresh_model_rows_style()\n\n    def _start_health_checks(self):\n        self._health_results.clear()\n        self._health_pending = 0\n        self._health_result_queue = _queue.Queue()\n        for entry in self._model_row_widgets:\n            entry[\"dot\"].setStyleSheet(\"color: #71717a; font-size: 11px;\")\n            entry[\"dot\"].setText(\"◌\")\n        for idx, tc in enumerate(self.agent.llmclients):\n            self._health_pending += 1\n            t = threading.Thread(target=self._check_backend, args=(idx, tc.backend), daemon=True)\n            t.start()\n        if not hasattr(self, '_health_poll_timer'):\n            self._health_poll_timer = QTimer(self)\n            self._health_poll_timer.timeout.connect(self._poll_health_results)\n        self._health_poll_timer.start(500)\n\n    def _poll_health_results(self):\n        while True:\n            try:\n                idx, ok = self._health_result_queue.get_nowait()\n                self._health_results[idx] = ok\n            except _queue.Empty:\n                break\n        self._refresh_model_rows_style()\n        if len(self._health_results) >= self._health_pending:\n            self._health_poll_timer.stop()\n\n    def _check_backend(self, idx: int, backend):\n        ok = False\n        try:\n            reply = backend.ask(\"你好\")\n            # 兼容生成器函数（NativeClaudeSession.ask是生成器）\n            if hasattr(reply, '__iter__') and not isinstance(reply, str):\n                reply = ''.join(str(b) for b in reply if isinstance(b, str))\n            text = str(reply).strip() if reply else \"\"\n            ok = len(text) > 0 and not text.startswith(\"Error\") and not text.startswith(\"[\")\n            print(f\"[HealthCheck] Backend #{idx} {type(backend).__name__}/{backend.model}: {'OK' if ok else 'FAIL'} -> {text[:60]}\")\n        except Exception as e:\n            print(f\"[HealthCheck] Backend #{idx} {type(backend).__name__}/{backend.model}: ERROR -> {e}\")\n            ok = False\n        if hasattr(backend, 'raw_msgs') and backend.raw_msgs:\n            backend.raw_msgs = [m for m in backend.raw_msgs if m.get(\"prompt\") != \"你好\"]\n        self._health_result_queue.put((idx, ok))\n\n    # ── event filter (Enter key in text edit, Escape to close search) ──────────\n    def eventFilter(self, obj, event):\n        if event.type() == QEvent.KeyPress:\n            if obj is self._search_input and event.key() == Qt.Key_Escape:\n                self._hide_search()\n                return True\n            if obj is self._input and event.key() in (Qt.Key_Return, Qt.Key_Enter):\n                if not (event.modifiers() & Qt.ShiftModifier):\n                    self._handle_send()\n                    return True\n        # 搜索框失焦时关闭搜索\n        if event.type() == QEvent.FocusOut and obj is self._search_input:\n            # 延迟关闭，等待点击事件处理完毕\n            QTimer.singleShot(50, self._hide_search_if_no_focus)\n        return super().eventFilter(obj, event)\n\n    def _on_text_changed(self):\n        n = len(self._input.toPlainText())\n        self._char_lbl.setText(f\"{n} / 2000\")\n\n    # ── file attachment ────────────────────────────────────────────────────────\n    def _attach_files(self):\n        paths, _ = QFileDialog.getOpenFileNames(\n            self, \"选择附件\", \"\",\n            \"All Files (*);;\"\n            \"Images (*.png *.jpg *.jpeg *.gif *.webp *.bmp);;\"\n            \"Text (*.txt *.md *.py *.json *.csv *.yaml *.yml *.log *.js *.ts *.sql)\",\n        )\n        for path in paths:\n            name = os.path.basename(path)\n            if any(f[\"name\"] == name for f in self._pending_files):\n                continue\n            ext = os.path.splitext(path)[1].lower()\n            img_exts = {\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\", \".bmp\"}\n            mime = (f\"image/{ext[1:]}\" if ext in img_exts else\n                    \"text/plain\" if ext in TEXT_FILE_EXTS else\n                    \"application/octet-stream\")\n            try:\n                with open(path, \"rb\") as fh:\n                    raw = fh.read()\n                if len(raw) > MAX_UPLOAD_BYTES:\n                    print(f\"[Attach] 文件过大，已跳过: {name} ({len(raw)} bytes)\")\n                    continue\n                self._pending_files.append({\"name\": name, \"type\": mime, \"raw\": raw})\n            except Exception as e:\n                print(f\"[Attach] Failed to read {path}: {e}\")\n        self._refresh_chips()\n\n    def _refresh_chips(self):\n        while self._chips_ly.count():\n            item = self._chips_ly.takeAt(0)\n            if item.widget():\n                item.widget().deleteLater()\n        if not self._pending_files:\n            self._chips_row.hide()\n            return\n        for f in self._pending_files:\n            chip = QLabel(f['name'])\n            chip.setStyleSheet(f\"\"\"\n                QLabel {{ background: rgba(55,55,65,0.7); color: {C['text']};\n                    border: 1px solid {C['border'].name()}; border-radius: 6px;\n                    padding: 3px 8px; font-size: 11px; }}\n            \"\"\")\n            self._chips_ly.addWidget(chip)\n        self._chips_ly.addStretch()\n        self._chips_row.show()\n\n    # ── send / streaming ───────────────────────────────────────────────────────\n    _SEND_BTN_STYLE = \"\"\"\n        QPushButton { background: #e4e4e7; border: none; border-radius: 17px; }\n        QPushButton:hover { background: #f4f4f5; }\n        QPushButton:pressed { background: #d4d4d8; }\n    \"\"\"\n    _STOP_BTN_STYLE = \"\"\"\n        QPushButton { background: rgba(239,68,68,0.85); border: none; border-radius: 17px; }\n        QPushButton:hover { background: rgba(248,113,113,0.9); }\n        QPushButton:pressed { background: rgba(220,38,38,0.9); }\n    \"\"\"\n\n    def _set_send_mode(self):\n        self._is_streaming = False\n        self._send_btn.setText(\"\")\n        self._send_btn.setIcon(_svg_icon(\"send_arrow\", _SVG_SEND, \"#18181b\"))\n        self._send_btn.setIconSize(QSize(18, 18))\n        self._send_btn.setStyleSheet(self._SEND_BTN_STYLE)\n\n    def _set_stop_mode(self):\n        self._is_streaming = True\n        self._send_btn.setText(\"\")\n        self._send_btn.setIcon(_svg_icon(\"stop_circle\", _SVG_STOP, \"#ffffff\"))\n        self._send_btn.setIconSize(QSize(16, 16))\n        self._send_btn.setStyleSheet(self._STOP_BTN_STYLE)\n\n    def _on_send_btn_click(self):\n        if self._is_streaming:\n            self._do_stop()\n        else:\n            self._handle_send()\n\n    def _handle_send(self):\n        text = self._input.toPlainText().strip()\n        files = self._pending_files.copy()\n        if not text and not files:\n            return\n\n        if text.startswith(\"/\"):\n            self._input.clear()\n            self._pending_files.clear()\n            self._refresh_chips()\n            self._handle_command(text)\n            return\n\n        prompt = text or \"请分析我上传的附件。\"\n        full_prompt, display_prompt, _ = _build_prompt_with_uploads(prompt, files)\n\n        # Clear input state\n        self._input.clear()\n        self._pending_files.clear()\n        self._refresh_chips()\n\n        # Update session title\n        if self._session[\"title\"] == \"新对话\" and prompt:\n            self._session[\"title\"] = prompt[:20] + (\"...\" if len(prompt) > 20 else \"\")\n\n        from datetime import datetime\n        now_iso = datetime.now().isoformat()\n        user_idx = len(self._messages)\n        self._messages.append({\"role\": \"user\", \"content\": display_prompt, \"created_at\": now_iso})\n        self._add_msg_row(\n            \"user\",\n            display_prompt,\n            created_at=now_iso,\n            on_delete=lambda idx=user_idx: self._delete_message(idx),\n            on_rewrite=lambda idx=user_idx: self._rewrite_message(idx)\n        )\n        self._update_token_usage()\n\n        # Start streaming — reset scroll lock so new output auto-scrolls\n        self._user_scrolled_up = False\n        self._streaming_text = \"\"\n        # The streaming row will be replaced when done, it doesn't need deletion/export\n        self._streaming_row = self._add_msg_row(\"assistant\", \"▌\")\n        self._streaming_row.set_finished(False)\n        self._set_stop_mode()\n        self._streaming_badge.show()\n\n        self._display_queue = self.agent.put_task(f\"{FILE_HINT}\\n\\n{full_prompt}\", source=\"user\")\n        self._poll_timer.start(40)\n\n    def _handle_command(self, cmd: str):\n        parts = cmd.split()\n        op = parts[0].lower() if parts else \"\"\n        if op == \"/help\":\n            self._add_system_notice(HELP_TEXT)\n        elif op == \"/stop\":\n            self._do_stop()\n            self._add_system_notice(\"⏹️ 已停止\")\n        elif op == \"/status\":\n            llm = self._model_name()\n            state = \"🔴 运行中\" if self.agent.is_running else \"🟢 空闲\"\n            self._add_system_notice(f\"状态: {state}\\nLLM: [{self.agent.llm_no}] {llm}\")\n        elif op == \"/llm\":\n            if not self.agent.llmclient:\n                self._add_system_notice(\"❌ 当前没有可用的 LLM 配置\")\n            elif len(parts) > 1:\n                try:\n                    idx = int(parts[1])\n                    self._do_switch_to(idx)\n                except Exception:\n                    self._add_system_notice(f\"用法: /llm <0-{len(self.agent.llmclients) - 1}>\")\n            else:\n                lines = [f\"{'→' if i == self.agent.llm_no else '  '} [{i}] {getattr(c, 'name', type(c.backend).__name__ + '/' + c.backend.model)}\"\n                         for i, c in enumerate(self.agent.llmclients)]\n                self._add_system_notice(\"LLMs:\\n\" + \"\\n\".join(lines))\n        elif op == \"/restore\":\n            restored_info, err = format_restore()\n            if err:\n                self._add_system_notice(err)\n            else:\n                restored, fname, count = restored_info\n                self.agent.abort()\n                self.agent.history.extend(restored)\n                self._add_system_notice(f\"✅ 已恢复 {count} 轮对话\\n来源: {fname}\")\n        elif op == \"/new\":\n            self._do_clear()\n            self._add_system_notice(\"✅ 已开启新对话\")\n        else:\n            self._add_system_notice(f\"未知命令: {cmd}\\n{HELP_TEXT}\")\n\n    def _poll_queue(self):\n        if not self._display_queue:\n            return\n        try:\n            while True:\n                item = self._display_queue.get_nowait()\n                if not isinstance(item, dict) or (\"next\" not in item and \"done\" not in item):\n                    print(f\"[Queue] 跳过异常项: {item}\")\n                    continue\n                if \"next\" in item:\n                    self._streaming_text = item[\"next\"]\n                    if self._streaming_row:\n                        self._streaming_row.set_text(self._streaming_text + \" ▌\")\n                    self._update_token_usage()\n                    self._scroll_bottom()\n                if \"done\" in item:\n                    final = item[\"done\"]\n                    from datetime import datetime\n                    now_iso = datetime.now().isoformat()\n                    # Remove the temporary streaming row\n                    if self._streaming_row:\n                        # Find its position in the layout to replace it\n                        idx = self._msg_layout.indexOf(self._streaming_row)\n                        self._streaming_row.deleteLater()\n                        self._streaming_row = None\n                    # Add the final message with proper buttons\n                    assist_idx = len(self._messages)\n                    self._messages.append({\"role\": \"assistant\", \"content\": final, \"created_at\": now_iso})\n                    # Insert at the same position where the streaming row was, or before the stretch\n                    insert_pos = idx if idx >= 0 else self._msg_layout.count() - 1\n                    row = _MsgRow(\n                        final,\n                        \"assistant\",\n                        on_resend=self._regenerate_response,\n                        on_delete=lambda idx=assist_idx: self._delete_message(idx),\n                        on_rewrite=None,\n                        created_at=now_iso\n                    )\n                    # 自动展开最后一个 LLM Running 块，方便用户直接看到结果\n                    for _, title, _ in reversed(row._parse_foldable_blocks(final)):\n                        if title is not None and title in row._folded_ids and 'LLM Running' in title:\n                            row._folded_ids.remove(title)\n                            row._label.setHtml(row._render_with_folds(final))\n                            row._adjust_browser_height()\n                            break\n                    self._msg_layout.insertWidget(insert_pos, row)\n                    self._poll_timer.stop()\n                    self._set_send_mode()\n                    self._streaming_badge.hide()\n                    self.last_reply_time = time.time()\n                    self._update_token_usage()\n                    self._scroll_bottom()\n                    self._auto_save()\n                    break\n        except _queue.Empty:\n            pass\n\n    def _add_msg_row(self, role: str, text: str, created_at: str = None, on_delete=None, on_rewrite=None) -> _MsgRow:\n        row = _MsgRow(\n            text,\n            role,\n            on_resend=self._regenerate_response if role != \"user\" else None,\n            on_delete=on_delete,\n            on_rewrite=on_rewrite,\n            created_at=created_at\n        )\n        self._msg_layout.insertWidget(self._msg_layout.count() - 1, row)\n        self._scroll_bottom()\n        return row\n\n    def _regenerate_response(self):\n        \"\"\"Resend the last user message to regenerate the assistant response.\"\"\"\n        if self._is_streaming:\n            return\n        for msg in reversed(self._messages):\n            if msg[\"role\"] == \"user\":\n                self._input.setPlainText(msg[\"content\"])\n                self._handle_send()\n                break\n\n    def _delete_message(self, index: int):\n        \"\"\"Delete the message at the given index.\"\"\"\n        if index < 0 or index >= len(self._messages):\n            return\n        # Remove from data\n        self._messages.pop(index)\n        # Rebuild all rows to ensure on_delete indices are correct\n        self._rebuild_messages()\n        # Update\n        self._update_token_usage()\n        self._auto_save()\n\n    def _rewrite_message(self, index: int):\n        \"\"\"Rewrite the user message at the given index.\"\"\"\n        if index < 0 or index >= len(self._messages):\n            return\n        if self._messages[index][\"role\"] != \"user\":\n            return\n        # Get the content and fill it into the input\n        content = self._messages[index][\"content\"]\n        self._input.setPlainText(content)\n        # Remove this message and everything after it\n        self._messages = self._messages[:index]\n        # Rebuild UI\n        self._rebuild_messages()\n        self._update_token_usage()\n        self._auto_save()\n\n    def _on_scroll(self, value):\n        sb = self._scroll.verticalScrollBar()\n        self._user_scrolled_up = value < sb.maximum() - 30\n        self._update_nav_visibility()\n\n    def _update_nav_visibility(self):\n        sb = self._scroll.verticalScrollBar()\n        max_val = sb.maximum()\n        vp_h = self._scroll.viewport().height()\n        total_h = max_val + vp_h\n        show_nav = max_val > 0 and total_h >= vp_h * 1.5\n\n        if show_nav:\n            self._nav_widget.show()\n            self._nav_up.setEnabled(sb.value() > 2)\n            self._nav_down.setEnabled(max_val > 0 and sb.value() < max_val - 2)\n        else:\n            self._nav_widget.hide()\n\n    def _scroll_to_top(self):\n        self._user_scrolled_up = True\n        self._scroll.verticalScrollBar().setValue(0)\n\n    def _scroll_to_bottom(self):\n        self._user_scrolled_up = False\n        QTimer.singleShot(60, lambda: self._scroll.verticalScrollBar().setValue(\n            self._scroll.verticalScrollBar().maximum()\n        ))\n\n    def _scroll_bottom(self):\n        if self._user_scrolled_up:\n            return\n        QTimer.singleShot(60, lambda: self._scroll.verticalScrollBar().setValue(\n            self._scroll.verticalScrollBar().maximum()\n        ))\n\n    # ── inject (autonomous mode) ───────────────────────────────────────────────\n    def inject_message(self, text: str):\n        \"\"\"Programmatically send a message (called by idle monitor).\"\"\"\n        self._input.setPlainText(text)\n        self._handle_send()\n\n    # ── history ────────────────────────────────────────────────────────────────\n    def _refresh_history(self):\n        self._history = _load_history()\n        self._hist_list.clear()\n        for s in reversed(self._history[-20:]):\n            n = len(s.get(\"messages\", []))\n            item = QListWidgetItem(f\"  {s.get('title','未命名')}   ({n} 条)\")\n            item.setData(Qt.UserRole, s)\n            self._hist_list.addItem(item)\n\n    def _restore_selected(self, item=None):\n        item = item or self._hist_list.currentItem()\n        if not item:\n            return\n        s = item.data(Qt.UserRole)\n        if s:\n            self._session = s.copy()\n            self._messages = s.get(\"messages\", []).copy()\n            self._rebuild_messages()\n            self._switch_tab(0)\n            self._update_token_usage()\n            search_text = self._search_input.text().strip()\n            if search_text:\n                QTimer.singleShot(50, lambda: self._search_current_chat(search_text))\n\n    def _delete_selected(self):\n        item = self._hist_list.currentItem()\n        if not item:\n            return\n        s = item.data(Qt.UserRole)\n        if s:\n            self._history = [h for h in self._history if h.get(\"id\") != s.get(\"id\")]\n            _save_history(self._history)\n            self._refresh_history()\n\n    def _rebuild_messages(self):\n        while self._msg_layout.count() > 1:\n            it = self._msg_layout.takeAt(0)\n            if it.widget():\n                it.widget().deleteLater()\n        for i, m in enumerate(self._messages):\n            rewrite_cb = (lambda idx=i: self._rewrite_message(idx)) if m[\"role\"] == \"user\" else None\n            self._add_msg_row(\n                m[\"role\"],\n                m[\"content\"],\n                created_at=m.get(\"created_at\"),\n                on_delete=lambda idx=i: self._delete_message(idx),\n                on_rewrite=rewrite_cb\n            )\n        self._update_token_usage()\n\n    def _update_token_usage(self):\n        in_chars = sum(len(m.get(\"content\", \"\")) for m in self._messages if m.get(\"role\") == \"user\")\n        out_chars = sum(len(m.get(\"content\", \"\")) for m in self._messages if m.get(\"role\") == \"assistant\")\n        if getattr(self, \"_is_streaming\", False) and getattr(self, \"_streaming_text\", \"\"):\n            out_chars += len(self._streaming_text)\n        \n        in_tokens = int(in_chars / 2.5)\n        out_tokens = int(out_chars / 2.5)\n        \n        if in_tokens == 0 and out_tokens == 0:\n            self._token_lbl.setText(\"\")\n        else:\n            self._token_lbl.setText(f\"|   会话上下文消耗: 入 {in_tokens}  出 {out_tokens} tokens\")\n\n    # ── SOP ────────────────────────────────────────────────────────────────────\n    def _refresh_sop(self):\n        self._sop_list.clear()\n        file_icon = _svg_icon(\"sop_file_item\", _SVG_FILE, C[\"muted\"])\n        for path in sorted(glob.glob(os.path.join(os.path.dirname(os.path.dirname(__file__)), \"memory\", \"*.md\"))):\n            name = os.path.basename(path)\n            size = os.path.getsize(path)\n            it = QListWidgetItem(name)\n            it.setIcon(file_icon)\n            it.setData(Qt.UserRole, path)\n            it.setToolTip(f\"{size:,} 字节\")\n            self._sop_list.addItem(it)\n\n    def _load_sop(self, item):\n        if not item:\n            return\n        path = item.data(Qt.UserRole)\n        try:\n            with open(path, \"r\", encoding=\"utf-8\") as f:\n                self._sop_viewer.setHtml(_md_to_html(f.read()))\n        except Exception as e:\n            self._sop_viewer.setPlainText(f\"读取失败: {e}\")\n\n    # ── settings actions ───────────────────────────────────────────────────────\n    def _model_name(self) -> str:\n        if self.agent.llmclient is None:\n            return \"未配置\"\n        try:\n            return self.agent.get_llm_name()\n        except Exception:\n            return \"未知\"\n\n    def _add_system_notice(self, text: str):\n        \"\"\"Insert a small centered notice label (not tracked as a message).\"\"\"\n        lbl = QLabel(text)\n        lbl.setWordWrap(True)\n        lbl.setAlignment(Qt.AlignCenter)\n        lbl.setStyleSheet(\n            \"QLabel { background: transparent; color: #71717a;\"\n            \" border: none; padding: 6px 20px; font-size: 12px; }\"\n        )\n        self._msg_layout.insertWidget(self._msg_layout.count() - 1, lbl)\n        self._scroll_bottom()\n\n    def _do_stop(self):\n        self.agent.abort()\n        self._poll_timer.stop()\n        self._set_send_mode()\n        self._streaming_badge.hide()\n        if self._streaming_row:\n            self._streaming_row.set_text(self._streaming_text or \"（已停止）\")\n            self._streaming_row.set_finished(True)\n            self._streaming_row = None\n        self._update_token_usage()\n\n    def _do_reset_prompt(self):\n        if self.agent.llmclient and hasattr(self.agent.llmclient, 'last_tools'):\n            self.agent.llmclient.last_tools = \"\"\n\n    def _auto_save(self):\n        if not self._messages:\n            return\n        if self._session.get(\"title\") == \"新对话\":\n            first_user = next(\n                (m[\"content\"] for m in self._messages if m[\"role\"] == \"user\"), \"\"\n            )\n            if first_user:\n                self._session[\"title\"] = first_user[:30].replace(\"\\n\", \" \")\n        self._do_save()\n\n    def _do_save(self):\n        if not self._messages:\n            return\n        self._session[\"messages\"] = self._messages.copy()\n        self._session[\"updatedAt\"] = datetime.now().isoformat()\n        self._history = _load_history()\n        for i, s in enumerate(self._history):\n            if s.get(\"id\") == self._session[\"id\"]:\n                self._history[i] = self._session.copy()\n                break\n        else:\n            self._history.append(self._session.copy())\n        _save_history(self._history)\n\n    def _do_clear(self):\n        self._messages.clear()\n        self._session = {\"id\": _make_session_id(), \"title\": \"新对话\", \"messages\": []}\n        self._rebuild_messages()\n        self._switch_tab(0)\n        self._update_token_usage()\n\n    def _new_session(self):\n        if self._messages:\n            self._do_save()\n        self._do_clear()\n\n    def _do_toggle_auto(self):\n        self.autonomous_enabled = not self.autonomous_enabled\n        self._auto_btn.setChecked(self.autonomous_enabled)\n        lbl = \"暂停自主行动\" if self.autonomous_enabled else \"开启自主行动 (idle > 30 min 自动触发)\"\n        self._auto_btn.setText(lbl)\n\n    def _do_trigger_auto(self):\n        self.inject_message(\n            \"[AUTO]🤖 用户触发了自主行动，请阅读自动化sop，选择并执行一项有价值的任务。\"\n        )\n\n    # ── helpers ────────────────────────────────────────────────────────────────\n    @staticmethod\n    def _small_btn_style(color: str) -> str:\n        return (\n            f\"QPushButton {{ background: {color}; color: white; border: none;\"\n            f\" border-radius: 7px; padding: 4px 12px; font-size: 12px; font-weight: 600; }}\"\n            f\"QPushButton:hover {{ opacity: 0.85; }}\"\n        )\n\n\n# ══════════════════════════════════════════════════════════════════════\n# Entry Point\n# ══════════════════════════════════════════════════════════════════════\n\ndef main():\n    # High-DPI support\n    QApplication.setHighDpiScaleFactorRoundingPolicy(\n        Qt.HighDpiScaleFactorRoundingPolicy.PassThrough\n    )\n    app = QApplication(sys.argv)\n    app.setQuitOnLastWindowClosed(False)\n    app.setApplicationName(\"GenericAgent\")\n\n    # Font\n    font = QFont()\n    # Keep English glyphs in Arial; Chinese falls back to Microsoft YaHei.\n    try:\n        font.setFamilies([\"Arial\", \"Microsoft YaHei\"])\n    except Exception:\n        font.setFamily(\"Microsoft YaHei\")\n    font.setPointSize(10)\n    app.setFont(font)\n\n    # ── Agent initialisation ──────────────────────────────\n    agent = GeneraticAgent()\n    if agent.llmclient is None:\n        QMessageBox.critical(\n            None,\n            \"未配置 LLM\",\n            \"未在 mykey.py 中发现任何可用的 LLM 接口配置，\\n程序将在无 LLM 模式下运行。\",\n        )\n    else:\n        threading.Thread(target=agent.run, daemon=True).start()\n\n    # ── Windows ───────────────────────────────────────────\n    panel = ChatPanel(agent)\n    button = FloatingButton(panel)\n    button.show()\n\n    # Position panel next to button and show it on first launch\n    button._position_panel()\n    panel.show()\n\n    scr = QApplication.primaryScreen().availableGeometry()\n    print(f\"[GenericAgent] 启动成功\")\n    print(f\"  屏幕分辨率: {scr.width()}x{scr.height()}\")\n    print(f\"  悬浮按钮: ({button.x()}, {button.y()})\")\n    print(f\"  聊天面板: ({panel.x()}, {panel.y()})\")\n    print(f\"  关闭面板后可点击右下角发光按钮重新打开\")\n\n    # ── Idle monitor (autonomous mode) ────────────────────\n    _last_trigger = 0.0\n\n    def idle_check():\n        nonlocal _last_trigger\n        if not panel.autonomous_enabled:\n            return\n        now = time.time()\n        if now - _last_trigger < AUTO_COOLDOWN:\n            return\n        idle = now - panel.last_reply_time\n        if idle > AUTO_IDLE_THRESHOLD:\n            _last_trigger = now\n            panel.inject_message(\n                \"[AUTO]🤖 用户已经离开超过30分钟，作为自主智能体，请阅读自动化sop，执行自动任务。\"\n            )\n\n    idle_timer = QTimer()\n    idle_timer.timeout.connect(idle_check)\n    idle_timer.start(5000)  # check every 5 s\n\n    sys.exit(app.exec())\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "frontends/skins/boy/skin.json",
    "content": "{\n  \"name\": \"Boy\",\n  \"version\": \"1.0.0\",\n  \"author\": \"pzuh\",\n  \"source\": \"https://pzuh.itch.io/temple-run-game-sprites\",\n  \"description\": \"Boy 角色皮肤\",\n  \"style\": \"pixel\",\n  \"format\": \"sprite\",\n  \"size\": {\n    \"width\": 80,\n    \"height\": 122\n  },\n  \"animations\": {\n    \"idle\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 64,\n        \"frameHeight\": 98,\n        \"frameCount\": 10,\n        \"columns\": 40,\n        \"fps\": 6,\n        \"startFrame\": 0\n      }\n    },\n    \"walk\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 64,\n        \"frameHeight\": 98,\n        \"frameCount\": 10,\n        \"columns\": 40,\n        \"fps\": 3,\n        \"startFrame\": 20\n      }\n    },\n    \"run\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 64,\n        \"frameHeight\": 98,\n        \"frameCount\": 10,\n        \"columns\": 40,\n        \"fps\": 10,\n        \"startFrame\": 20\n      }\n    },\n    \"sprint\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 64,\n        \"frameHeight\": 98,\n        \"frameCount\": 10,\n        \"columns\": 40,\n        \"fps\": 24,\n        \"startFrame\": 20\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "frontends/skins/dinosaur/skin.json",
    "content": "{\n  \"name\": \"Dinosaur\",\n  \"version\": \"1.0.0\",\n  \"author\": \"voidcord54\",\n  \"source\": \"https://voidcord54.itch.io/\",\n  \"description\": \"像素风小恐龙 Dinosaur\",\n  \"style\": \"pixel\",\n  \"format\": \"sprite\",\n  \"size\": { \"width\": 128, \"height\": 128 },\n  \"animations\": {\n    \"idle\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 128,\n        \"frameHeight\": 128,\n        \"frameCount\": 2,\n        \"columns\": 5,\n        \"fps\": 6,\n        \"startFrame\": 0\n      }\n    },\n    \"walk\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 128,\n        \"frameHeight\": 128,\n        \"frameCount\": 2,\n        \"columns\": 5,\n        \"fps\": 4,\n        \"startFrame\": 2\n      }\n    },\n    \"run\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 128,\n        \"frameHeight\": 128,\n        \"frameCount\": 2,\n        \"columns\": 5,\n        \"fps\": 8,\n        \"startFrame\": 2\n      }\n    },\n    \"sprint\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 128,\n        \"frameHeight\": 128,\n        \"frameCount\": 2,\n        \"columns\": 5,\n        \"fps\": 16,\n        \"startFrame\": 2\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "frontends/skins/doux/skin.json",
    "content": "{\n  \"name\": \"Doux\",\n  \"version\": \"1.0.0\",\n  \"author\": \"arks\",\n  \"source\": \"https://arks.itch.io/dino-characters\",\n  \"license\": \"CC0\",\n  \"description\": \"像素风小恐龙 Doux\",\n  \"style\": \"pixel\",\n  \"format\": \"sprite\",\n  \"size\": { \"width\": 128, \"height\": 128 },\n  \"animations\": {\n    \"idle\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 4,\n        \"columns\": 24,\n        \"fps\": 6,\n        \"startFrame\": 0\n      }\n    },\n    \"walk\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 6,\n        \"columns\": 24,\n        \"fps\": 6,\n        \"startFrame\": 5\n      }\n    },\n    \"run\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 8,\n        \"columns\": 24,\n        \"fps\": 16,\n        \"startFrame\": 6\n      }\n    },\n    \"sprint\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 6,\n        \"columns\": 24,\n        \"fps\": 16,\n        \"startFrame\": 17\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "frontends/skins/glube/skin.json",
    "content": "{\n  \"name\": \"Glube\",\n  \"version\": \"1.0.0\",\n  \"author\": \"SketchesWithKevin\",\n  \"source\": \"https://sketcheswithkevin.itch.io/glube-platformer\",\n  \"description\": \"像素风小怪兽 Glube\",\n  \"style\": \"pixel\",\n  \"format\": \"sprite\",\n  \"size\": { \"width\": 65, \"height\": 38 },\n  \"animations\": {\n    \"idle\": {\n      \"file\": \"idle.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 44,\n        \"frameHeight\": 31,\n        \"frameCount\": 6,\n        \"columns\": 6,\n        \"fps\": 6,\n        \"startFrame\": 0\n      }\n    },\n    \"walk\": {\n      \"file\": \"walk.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 65,\n        \"frameHeight\": 32,\n        \"frameCount\": 8,\n        \"columns\": 8,\n        \"fps\": 6,\n        \"startFrame\": 0\n      }\n    },\n    \"run\": {\n      \"file\": \"run.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 65,\n        \"frameHeight\": 32,\n        \"frameCount\": 8,\n        \"columns\": 8,\n        \"fps\": 12,\n        \"startFrame\": 0\n      }\n    },\n    \"sprint\": {\n      \"file\": \"run.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 65,\n        \"frameHeight\": 32,\n        \"frameCount\": 8,\n        \"columns\": 8,\n        \"fps\": 24,\n        \"startFrame\": 0\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "frontends/skins/line/License.txt",
    "content": "License is CC0 - https://creativecommons.org/public-domain/cc0/\n\nYOU CAN:\n\n-> You can do whatever you want with this asset, including modifying it for commercial use.\n-> Credit is not required, but is greatly appreciated!"
  },
  {
    "path": "frontends/skins/line/skin.json",
    "content": "{\n  \"name\": \"Line\",\n  \"version\": \"1.0.0\",\n  \"author\": \"itch.io\",\n  \"source\": \"https://itch.io\",\n  \"description\": \"Line 角色皮肤\",\n  \"style\": \"pixel\",\n  \"format\": \"sprite\",\n  \"size\": { \"width\": 128, \"height\": 128 },\n  \"animations\": {\n    \"idle\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 156,\n        \"frameHeight\": 185,\n        \"frameCount\": 4,\n        \"columns\": 28,\n        \"fps\": 6,\n        \"startFrame\": 0\n      }\n    },\n    \"walk\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 156,\n        \"frameHeight\": 185,\n        \"frameCount\": 8,\n        \"columns\": 28,\n        \"fps\": 6,\n        \"startFrame\": 4\n      }\n    },\n    \"run\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 156,\n        \"frameHeight\": 185,\n        \"frameCount\": 8,\n        \"columns\": 28,\n        \"fps\": 10,\n        \"startFrame\": 12\n      }\n    },\n    \"sprint\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 156,\n        \"frameHeight\": 185,\n        \"frameCount\": 8,\n        \"columns\": 28,\n        \"fps\": 24,\n        \"startFrame\": 12\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "frontends/skins/mort/skin.json",
    "content": "{\n  \"name\": \"Mort\",\n  \"version\": \"1.0.0\",\n  \"author\": \"arks\",\n  \"source\": \"https://arks.itch.io/dino-characters\",\n  \"license\": \"CC0\",\n  \"description\": \"像素风小恐龙 Mort\",\n  \"style\": \"pixel\",\n  \"format\": \"sprite\",\n  \"size\": { \"width\": 128, \"height\": 128 },\n  \"animations\": {\n    \"idle\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 4,\n        \"columns\": 24,\n        \"fps\": 6,\n        \"startFrame\": 0\n      }\n    },\n    \"walk\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 6,\n        \"columns\": 24,\n        \"fps\": 6,\n        \"startFrame\": 5\n      }\n    },\n    \"run\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 8,\n        \"columns\": 24,\n        \"fps\": 16,\n        \"startFrame\": 6\n      }\n    },\n    \"sprint\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 6,\n        \"columns\": 24,\n        \"fps\": 16,\n        \"startFrame\": 17\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "frontends/skins/tard/skin.json",
    "content": "{\n  \"name\": \"Tard\",\n  \"version\": \"1.0.0\",\n  \"author\": \"arks\",\n  \"source\": \"https://arks.itch.io/dino-characters\",\n  \"license\": \"CC0\",\n  \"description\": \"像素风小恐龙 Tard\",\n  \"style\": \"pixel\",\n  \"format\": \"sprite\",\n  \"size\": { \"width\": 128, \"height\": 128 },\n  \"animations\": {\n    \"idle\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 4,\n        \"columns\": 24,\n        \"fps\": 6,\n        \"startFrame\": 0\n      }\n    },\n    \"walk\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 6,\n        \"columns\": 24,\n        \"fps\": 6,\n        \"startFrame\": 5\n      }\n    },\n    \"run\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 8,\n        \"columns\": 24,\n        \"fps\": 16,\n        \"startFrame\": 6\n      }\n    },\n    \"sprint\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 6,\n        \"columns\": 24,\n        \"fps\": 16,\n        \"startFrame\": 17\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "frontends/skins/vita/skin.json",
    "content": "{\n  \"name\": \"Vita\",\n  \"version\": \"1.0.0\",\n  \"author\": \"arks\",\n  \"source\": \"https://arks.itch.io/dino-characters\",\n  \"license\": \"CC0\",\n  \"description\": \"像素风小恐龙 Vita\",\n  \"style\": \"pixel\",\n  \"format\": \"sprite\",\n  \"size\": { \"width\": 128, \"height\": 128 },\n  \"animations\": {\n    \"idle\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 4,\n        \"columns\": 24,\n        \"fps\": 6,\n        \"startFrame\": 0\n      }\n    },\n    \"walk\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 6,\n        \"columns\": 24,\n        \"fps\": 6,\n        \"startFrame\": 5\n      }\n    },\n    \"run\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 8,\n        \"columns\": 24,\n        \"fps\": 16,\n        \"startFrame\": 6\n      }\n    },\n    \"sprint\": {\n      \"file\": \"skin.png\",\n      \"loop\": true,\n      \"sprite\": {\n        \"frameWidth\": 24,\n        \"frameHeight\": 24,\n        \"frameCount\": 6,\n        \"columns\": 24,\n        \"fps\": 16,\n        \"startFrame\": 17\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "frontends/stapp.py",
    "content": "import os, sys, subprocess\nfrom urllib.request import urlopen\nfrom urllib.parse import quote\nif sys.stdout is None: sys.stdout = open(os.devnull, \"w\")\nif sys.stderr is None: sys.stderr = open(os.devnull, \"w\")\ntry: sys.stdout.reconfigure(errors='replace')\nexcept: pass\ntry: sys.stderr.reconfigure(errors='replace')\nexcept: pass\nscript_dir = os.path.dirname(__file__)\nsys.path.append(os.path.abspath(os.path.join(script_dir, '..')))\nsys.path.append(os.path.abspath(script_dir))\n\nimport streamlit as st\nimport time, json, re, threading, queue\nfrom agentmain import GeneraticAgent\nimport chatapp_common  # activate /continue command (monkey patches GeneraticAgent)\nfrom continue_cmd import handle_frontend_command, reset_conversation, list_sessions, extract_ui_messages\nfrom btw_cmd import handle_frontend_command as btw_handle_frontend\n\nst.set_page_config(page_title=\"Cowork\", layout=\"wide\")\n\nLANG = os.environ.get('GA_LANG', 'zh')\nif LANG not in ('zh', 'en'): LANG = 'zh'\nI18N = {\n    'zh': {\n        'force_stop': '强行停止任务',\n        'reinject_tools': '重新注入工具',\n        'desktop_pet': '🐱 桌面宠物',\n    },\n    'en': {\n        'force_stop': 'Force Stop',\n        'reinject_tools': 'Reinject Tools',\n        'desktop_pet': '🐱 Desktop Pet',\n    },\n}\ndef T(key): return I18N.get(LANG, I18N['zh']).get(key, key)\n\n@st.cache_resource\ndef init():\n    agent = GeneraticAgent()\n    if agent.llmclient is None:\n        st.error(\"⚠️ Please set mykey.py!\")\n        st.stop()\n    else: threading.Thread(target=agent.run, daemon=True).start()\n    return agent\n\nagent = init()\n\nst.title(\"🖥️ Cowork\")\n\nst.session_state.setdefault('autonomous_enabled', False)\n\n@st.fragment\ndef render_sidebar():\n    st.session_state.setdefault('autonomous_enabled', False)\n    llm_options = agent.list_llms()\n    current_idx = agent.llm_no\n    llm_labels = {idx: f\"{idx}: {(name or '').strip()}\" for idx, name, _ in llm_options}\n    st.caption(f\"LLM Core: {llm_labels.get(current_idx, str(current_idx))}\")\n    selected_idx = st.selectbox(\"LLM\", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, label_visibility=\"collapsed\", key=\"sidebar_llm_select\")\n    if selected_idx != current_idx:\n        agent.next_llm(selected_idx); st.rerun(scope=\"fragment\")\n    if st.button(T('force_stop')):\n        agent.abort(); st.toast(\"Stop signal sended\"); st.rerun()\n    if st.button(T('reinject_tools')):\n        agent.llmclient.last_tools = ''\n        try:\n            hist_path = os.path.join(script_dir, '..', 'assets', 'tool_usable_history.json')\n            with open(hist_path, 'r', encoding='utf-8') as f: tool_hist = json.load(f)\n            agent.llmclient.backend.history.extend(tool_hist)\n            st.toast(f\"Tools injected\")\n        except Exception as e: st.toast(f\"Injected tools failed: {e}\")\n    if st.button(T('desktop_pet')):\n        kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {}\n        pet_script = os.path.join(script_dir, 'desktop_pet_v2.pyw')\n        if not os.path.exists(pet_script): pet_script = os.path.join(script_dir, 'desktop_pet.pyw')\n        subprocess.Popen([sys.executable, pet_script], **kwargs)\n        def _pet_req(q):\n            def _do():\n                try: urlopen(f'http://127.0.0.1:41983/?{q}', timeout=2)\n                except Exception: pass\n            threading.Thread(target=_do, daemon=True).start()\n        agent._pet_req = _pet_req\n        if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {}\n        def _pet_hook(ctx):\n            parts = [f\"Turn {ctx.get('turn','?')}\"]\n            if ctx.get('summary'): parts.append(ctx['summary'])\n            if ctx.get('exit_reason'): parts.append('DONE')\n            _pet_req(f'msg={quote(chr(10).join(parts))}')\n            if ctx.get('exit_reason'): _pet_req('state=idle')\n        agent._turn_end_hooks['pet'] = _pet_hook\n        st.toast(\"Desktop pet started\")\n    \n    if LANG == 'zh':\n        st.divider()\n        if st.button(\"开始空闲自主行动\"):\n            st.session_state.last_reply_time = int(time.time()) - 1800\n            st.toast(\"已将上次回复时间设为1800秒前\"); st.rerun()\n        if st.session_state.autonomous_enabled:\n            if st.button(\"⏸️ 禁止自主行动\"):\n                st.session_state.autonomous_enabled = False\n                st.toast(\"⏸️ 已禁止自主行动\"); st.rerun()\n            st.caption(\"🟢 自主行动运行中，会在你离开它30分钟后自动进行\")\n        else:\n            if st.button(\"▶️ 允许自主行动\", type=\"primary\"):\n                st.session_state.autonomous_enabled = True\n                st.toast(\"✅ 已允许自主行动\"); st.rerun()\n            st.caption(\"🔴 自主行动已停止\")\nwith st.sidebar: render_sidebar()\n\ndef fold_turns(text):\n    \"\"\"Return list of segments: [{'type':'text','content':...}, {'type':'fold','title':...,'content':...}]\"\"\"\n    # 先把4+反引号块替换为占位符，避免误切子agent嵌套的 LLM Running\n    _ph = []\n    safe = re.sub(r'`{4,}.*?`{4,}', lambda m: (_ph.append(m.group(0)), f'\\x00PH{len(_ph)-1}\\x00')[1], text, flags=re.DOTALL)\n    # 流式中间态：末尾可能有未闭合的4+反引号块，也需保护\n    safe = re.sub(r'`{4,}[^`].*$', lambda m: (_ph.append(m.group(0)), f'\\x00PH{len(_ph)-1}\\x00')[1], safe, flags=re.DOTALL)\n    parts = re.split(r'(\\**LLM Running \\(Turn \\d+\\) \\.\\.\\.\\*\\**)', safe)\n    parts = [re.sub(r'\\x00PH(\\d+)\\x00', lambda m: _ph[int(m.group(1))], p) for p in parts]\n    if len(parts) < 4: return [{'type': 'text', 'content': text}]\n    segments = []\n    if parts[0].strip(): segments.append({'type': 'text', 'content': parts[0]})\n    turns = []\n    for i in range(1, len(parts), 2):\n        marker = parts[i]\n        content = parts[i+1] if i+1 < len(parts) else ''\n        turns.append((marker, content))\n    for idx, (marker, content) in enumerate(turns):\n        if idx < len(turns) - 1:\n            _c = re.sub(r'`{3,}.*?`{3,}|<thinking>.*?</thinking>', '', content, flags=re.DOTALL)\n            matches = re.findall(r'<summary>\\s*((?:(?!<summary>).)*?)\\s*</summary>', _c, re.DOTALL)\n            if matches:\n                title = matches[0].strip()\n                title = title.split('\\n')[0]\n                if len(title) > 50: title = title[:50] + '...'\n            else:\n                _plain = _c.strip().split('\\n', 1)[0]\n                title = (_plain[:50] + '...') if len(_plain) > 50 else (_plain or marker.strip('*'))\n            segments.append({'type': 'fold', 'title': title, 'content': content})\n        else: segments.append({'type': 'text', 'content': marker + content})\n    return segments\n_SUMMARY_TAG_RE = re.compile(r'<summary>.*?</summary>\\s*', re.DOTALL)\n\ndef render_segments(segments, suffix=''):\n    # 整块重画：调用方用 slot.container() 包裹，保证 DOM 路径稳定、跨 rerun 对齐（消除\"灰色重影\"）。\n    # heartbeat 空转时 segments 不变 → Streamlit 后端 diff 无变化 → 前端零闪烁；\n    # 但 container/markdown 本身是 API 调用，StopException 仍会被抛出（abort 照常起作用）。\n    for seg in segments:\n        if seg['type'] == 'fold':\n            with st.expander(seg['title'], expanded=False): st.markdown(seg['content'])\n        else:\n            # Strip <summary> meta tags from text segments — folded turns already\n            # promote them to expander titles; for the first/last segments\n            # they'd otherwise leak into the chat as raw text (esp. after /continue\n            # restores a multi-turn body).\n            st.markdown(_SUMMARY_TAG_RE.sub('', seg['content']) + suffix)\n\ndef agent_backend_stream(prompt=None):\n    \"\"\"Drain main task display_queue.\n    - prompt given:  start a fresh task; new dq is kept in session_state.\n    - prompt is None: resume a dq left in session_state by a prior run (e.g. after /btw).\n    Per-chunk progress is mirrored to session_state.partial_response so the rendered\n    bubble survives reruns. No implicit agent.abort() — explicit stop is on the Stop button.\"\"\"\n    if prompt is not None:\n        st.session_state.display_queue = agent.put_task(prompt, source=\"user\")\n        st.session_state.partial_response = ''\n    dq = st.session_state.get('display_queue')\n    if dq is None: return\n    # Drop a dangling 'LLM Running (Turn N) ...' marker if the captured partial\n    # ended right at a turn boundary with no content yet — otherwise the resume\n    # bubble flashes as a marker-only gray line. The marker reappears with\n    # content on the next chunk (raw_resp is cumulative).\n    response = re.sub(r'\\**LLM Running \\(Turn \\d+\\) \\.\\.\\.\\**\\s*$',\n                      '', st.session_state.get('partial_response', '')).rstrip()\n    while True:\n        try: item = dq.get(timeout=1)\n        except queue.Empty:\n            yield response   # heartbeat: let outer st.markdown() run → Streamlit checks StopException\n            continue\n        if 'next' in item:\n            response = item['next']\n            st.session_state.partial_response = response\n            yield response\n        if 'done' in item:\n            st.session_state.display_queue = None\n            st.session_state.partial_response = ''\n            yield item['done']; break\n\n\ndef render_main_stream(prompt=None):\n    \"\"\"Render the assistant bubble for the main task (new or resumed). Saves final to messages.\"\"\"\n    with st.chat_message(\"assistant\"):\n        frozen = 0; live = st.empty(); response = ''\n        CURSOR = ' ▌'\n        for response in agent_backend_stream(prompt):\n            segs = fold_turns(response)\n            n_done = max(0, len(segs) - 1)\n            while frozen < n_done:\n                with live.container(): render_segments([segs[frozen]])\n                live = st.empty(); frozen += 1\n            with live.container(): render_segments([segs[-1]], suffix=CURSOR)   # live 区域\n        segs = fold_turns(response)\n        for i in range(frozen, len(segs)):\n            with live.container(): render_segments([segs[i]])\n            if i < len(segs) - 1: live = st.empty()\n    if response:\n        st.session_state.messages.append({\"role\": \"assistant\", \"content\": response})\n        st.session_state.last_reply_time = int(time.time())\n\nif \"messages\" not in st.session_state: st.session_state.messages = []\nfor msg in st.session_state.messages:\n    with st.chat_message(msg[\"role\"]):\n        # 用 slot=st.empty() + with slot.container(): ... 的外壳，DOM 路径和流式渲染完全一致，跨 rerun 对齐\n        slot = st.empty()\n        with slot.container():\n            if msg[\"role\"] == \"assistant\": render_segments(fold_turns(msg[\"content\"]))\n            else: st.markdown(msg[\"content\"])\n\n# Scroll-height ghost fix: during streaming, expander open/close mid-animation can leave\n# phantom height → scrollbar long but can't scroll to bottom. Periodically detect & reflow.\ntry:\n    from streamlit import iframe as _st_iframe  # 1.56+\n    _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()})\nexcept (ImportError, AttributeError):\n    from streamlit.components.v1 import html as _embed_html  # ≤1.55\n_js_scroll_fix = (\n    \"!function(){var p=window.parent;if(p.__sfx2)return;p.__sfx2=1;var d=p.document;\"\n    \"function f(){var m=d.querySelector('section.main');if(!m)return;\"\n    \"var s=m.scrollTop;m.style.minHeight=m.scrollHeight+1+'px';void m.offsetHeight;\"\n    \"m.style.minHeight='';void m.offsetHeight;m.scrollTop=s}\"\n    \"d.addEventListener('transitionend',function(e){\"\n    \"e.target.closest&&e.target.closest('details')&&setTimeout(f,60)},!0);\"\n    \"new MutationObserver(function(){setTimeout(f,80)})\"\n    \".observe(d.body,{subtree:1,attributes:1,attributeFilter:['open']});\"\n    \"setInterval(f,5000)}()\"\n)\n# IME composition fix (macOS only) - prevents Enter from submitting during CJK input\n_js_ime_fix = (\"\" if os.name == 'nt' else\n    \"!function(){if(window.parent.__imeFix)return;window.parent.__imeFix=1;\"\n    \"var d=window.parent.document,c=0;\"\n    \"d.addEventListener('compositionstart',()=>c=1,!0);\"\n    \"d.addEventListener('compositionend',()=>c=0,!0);\"\n    \"function f(){d.querySelectorAll('textarea[data-testid=stChatInputTextArea]')\"\n    \".forEach(t=>{t.__imeFix||(t.__imeFix=1,t.addEventListener('keydown',e=>{\"\n    \"e.key==='Enter'&&!e.shiftKey&&(e.isComposing||c||e.keyCode===229)&&\"\n    \"(e.stopImmediatePropagation(),e.preventDefault())},!0))})}\"\n    \"f();new MutationObserver(f).observe(d.body,{childList:1,subtree:1})}()\")\n_embed_html(f'<script>{_js_scroll_fix};{_js_ime_fix}</script>', height=0)\n\nif prompt := st.chat_input(\"any task?\"):\n    ts = time.strftime(\"%Y-%m-%d %H:%M:%S\")\n    cmd = (prompt or \"\").strip()\n    def _reset_and_rerun():\n        st.session_state.streaming = False\n        st.session_state.stopping = False\n        st.session_state.display_queue = None\n        st.session_state.partial_response = \"\"\n        st.session_state.reply_ts = \"\"\n        st.session_state.current_prompt = \"\"\n        st.session_state.last_reply_time = int(time.time())\n        st.rerun()\n    if cmd == \"/new\":\n        st.session_state.messages = [{\"role\": \"assistant\", \"content\": reset_conversation(agent), \"time\": ts}]\n        _reset_and_rerun()\n    if cmd.startswith(\"/continue\"):\n        m = re.match(r'/continue\\s+(\\d+)\\s*$', cmd.strip())\n        sessions = list_sessions(exclude_pid=os.getpid()) if m else []\n        idx = int(m.group(1)) - 1 if m else -1\n        # Resolve target path BEFORE handle (which snapshots current log, shifting indices).\n        target = sessions[idx][0] if 0 <= idx < len(sessions) else None\n        result = handle_frontend_command(agent, cmd)\n        history = extract_ui_messages(target) if target and result.startswith('✅') else None\n        tail = [{\"role\": \"assistant\", \"content\": result, \"time\": ts}]\n        if history:\n            st.session_state.messages = history + tail\n        else:\n            st.session_state.messages = list(st.session_state.messages) + \\\n                [{\"role\": \"user\", \"content\": cmd, \"time\": ts}] + tail\n        _reset_and_rerun()\n    if cmd.startswith(\"/btw\"):\n        answer = btw_handle_frontend(agent, cmd)  # sync; bypasses put_task → main agent.run() untouched\n        st.session_state.messages = list(st.session_state.messages) + [\n            {\"role\": \"user\", \"content\": prompt, \"time\": ts},\n            {\"role\": \"assistant\", \"content\": answer, \"time\": ts},\n        ]\n        st.rerun()  # preserve display_queue/partial_response so resume path drains the running main task\n    # Regular prompt: cancel any in-flight task to match original \"submit cancels\" UX.\n    # (/btw branch above is the only path that intentionally lets the prior task keep streaming.)\n    if st.session_state.get('display_queue') is not None:\n        agent.abort()\n        st.session_state.display_queue = None\n        st.session_state.partial_response = ''\n    st.session_state.messages.append({\"role\": \"user\", \"content\": prompt})\n    if hasattr(agent, '_pet_req') and not prompt.startswith('/'): agent._pet_req('state=walk')\n    with st.chat_message(\"user\"): st.markdown(prompt)\n    render_main_stream(prompt)\nelif st.session_state.get('display_queue') is not None:\n    # No new prompt but a task is mid-flight (typically a /btw rerun) — resume drain.\n    render_main_stream()\n\nif st.session_state.autonomous_enabled:\n    st.markdown(f\"\"\"<div id=\"last-reply-time\" style=\"display:none\">{st.session_state.get('last_reply_time', int(time.time()))}</div>\"\"\", unsafe_allow_html=True)\n"
  },
  {
    "path": "frontends/stapp2.py",
    "content": "import os, sys\nimport html\nif sys.stdout is None: sys.stdout = open(os.devnull, \"w\")\nif sys.stderr is None: sys.stderr = open(os.devnull, \"w\")\ntry: sys.stdout.reconfigure(errors='replace')\nexcept: pass\ntry: sys.stderr.reconfigure(errors='replace')\nexcept: pass\nsys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))\n\nimport streamlit as st\ntry:\n    from streamlit import iframe as _st_iframe  # 1.56+\n    _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()})\nexcept (ImportError, AttributeError):\n    from streamlit.components.v1 import html as _embed_html  # ≤1.55\nimport time, json, re, threading, queue\nfrom datetime import datetime\nfrom agentmain import GeneraticAgent\n\nst.set_page_config(page_title=\"Cowork\", layout=\"wide\")\n\n# ─── Anthropic Light Theme CSS ───\nANTHROPIC_CSS = \"\"\"\n<style>\n/* ===== Root variables ===== */\n:root {\n    --anthropic-primary: #D4A27F;\n    --anthropic-primary-hover: #C4895F;\n    --anthropic-bg: #FAF9F6;\n    --anthropic-bg-secondary: #EEECE2;\n    --anthropic-code-bg: #F4F1EB;\n    --anthropic-text: #1A1714;\n    --anthropic-text-secondary: #6B6560;\n    --anthropic-border: #D5CEC5;\n    --anthropic-sidebar-bg: #F0EDE4;\n    --anthropic-accent: #CC785C;\n    --anthropic-success: #5A8A5E;\n    --anthropic-warning: #C4885A;\n    --anthropic-error: #C45A5A;\n    --anthropic-info: #5A7A8A;\n    --anthropic-font: 'Source Sans Pro', sans-serif;\n    --anthropic-mono: 'Source Code Pro', monospace;\n}\n\n/* ===== Global ===== */\nbody, [data-testid=\"stAppViewContainer\"] {\n    background-color: var(--anthropic-bg) !important;\n    color: var(--anthropic-text) !important;\n}\n\n.stApp {\n    background-color: var(--anthropic-bg) !important;\n}\n\n/* ===== Header / Top bar ===== */\n[data-testid=\"stHeader\"], header[data-testid=\"stHeader\"] {\n    background-color: var(--anthropic-bg) !important;\n    border-bottom: 1px solid var(--anthropic-border) !important;\n}\n/* Hide default Streamlit toolbar buttons (deploy, hamburger, etc.) */\n[data-testid=\"stToolbar\"] {\n    visibility: hidden !important;\n}\n[data-testid=\"stDecoration\"],\n#MainMenu {\n    display: none !important;\n    visibility: hidden !important;\n}\n/* Restore sidebar expand button (lives inside stToolbar) */\n[data-testid=\"stExpandSidebarButton\"],\n[data-testid=\"stExpandSidebarButton\"] * {\n    visibility: visible !important;\n}\n/* Only restore ancestor divs that contain the sidebar button */\n[data-testid=\"stToolbar\"] div:has([data-testid=\"stExpandSidebarButton\"]) {\n    visibility: visible !important;\n}\n/* Make top-left settings/sidebar toggle darker and easier to see */\nbutton[data-testid=\"stExpandSidebarButton\"] {\n    visibility: visible !important;\n    background: #F4F1EA !important;\n    background-color: #F4F1EA !important;\n    border: none !important;\n    color: #3B2F2A !important;\n    border-radius: 10px !important;\n    box-shadow: none !important;\n}\nbutton[data-testid=\"stExpandSidebarButton\"]:hover {\n    background: #EAE4D9 !important;\n    background-color: #EAE4D9 !important;\n    border-color: transparent !important;\n}\nbutton[data-testid=\"stExpandSidebarButton\"],\nbutton[data-testid=\"stExpandSidebarButton\"] *,\nbutton[data-testid=\"stExpandSidebarButton\"] [data-testid=\"stIconMaterial\"] {\n    color: #3B2F2A !important;\n    fill: #3B2F2A !important;\n    stroke: #3B2F2A !important;\n}\n/* Hide other toolbar buttons (deploy, etc.) */\nbutton[kind=\"header\"] {\n    visibility: hidden !important;\n}\n\n/* ===== Sidebar ===== */\n[data-testid=\"stSidebar\"], section[data-testid=\"stSidebar\"] {\n    background-color: var(--anthropic-sidebar-bg) !important;\n    border-right: 1px solid var(--anthropic-border) !important;\n}\n\n[data-testid=\"stSidebar\"] .stMarkdown,\n[data-testid=\"stSidebar\"] p,\n[data-testid=\"stSidebar\"] span,\n[data-testid=\"stSidebar\"] label {\n    color: var(--anthropic-text) !important;\n}\n\n[data-testid=\"stSidebar\"] hr {\n    border-color: var(--anthropic-border) !important;\n}\n\n/* ===== Sidebar Selectbox ===== */\n[data-testid=\"stSidebar\"] [data-testid=\"stSelectbox\"] {\n    width: fit-content !important;\n    max-width: 100% !important;\n}\n\n[data-testid=\"stSidebar\"] [data-testid=\"stSelectbox\"] > div {\n    width: fit-content !important;\n    max-width: 100% !important;\n}\n\n[data-testid=\"stSidebar\"] [data-testid=\"stSelectbox\"] label,\n[data-testid=\"stSidebar\"] .stSelectbox label {\n    color: var(--anthropic-text-secondary) !important;\n    font-size: 0.9rem !important;\n    font-weight: 500 !important;\n}\n\n[data-testid=\"stSidebar\"] [data-baseweb=\"select\"] {\n    width: fit-content !important;\n    max-width: 100% !important;\n    display: inline-block !important;\n}\n\n[data-testid=\"stSidebar\"] [data-baseweb=\"select\"] > div {\n    width: fit-content !important;\n    max-width: 100% !important;\n    background: #F7F3EC !important;\n    border: none !important;\n    box-shadow: none !important;\n    border-radius: 12px !important;\n    min-height: 42px !important;\n    padding-right: 1.6rem !important;\n    position: relative !important;\n}\n\n[data-testid=\"stSidebar\"] [data-baseweb=\"select\"] > div:hover,\n[data-testid=\"stSidebar\"] [data-baseweb=\"select\"] > div:focus-within {\n    background: #EFE9DE !important;\n    border: none !important;\n    box-shadow: none !important;\n}\n\n[data-testid=\"stSidebar\"] [data-baseweb=\"select\"] input,\n[data-testid=\"stSidebar\"] [data-baseweb=\"select\"] span,\n[data-testid=\"stSidebar\"] [data-baseweb=\"select\"] div {\n    color: var(--anthropic-text) !important;\n}\n\n[data-testid=\"stSidebar\"] [data-baseweb=\"select\"] span {\n    white-space: nowrap !important;\n}\n\n[data-baseweb=\"popover\"],\n[data-baseweb=\"menu\"],\n[data-baseweb=\"popover\"] > div,\n[data-baseweb=\"popover\"] [role=\"presentation\"],\n[data-baseweb=\"popover\"] ul,\n[data-baseweb=\"popover\"] li,\n[data-baseweb=\"popover\"] [role=\"listbox\"],\n[data-baseweb=\"popover\"] [role=\"option\"] {\n    background: #F7F3EC !important;\n    color: var(--anthropic-text) !important;\n}\n\n[role=\"listbox\"] {\n    background: #F7F3EC !important;\n    border: 1px solid var(--anthropic-border) !important;\n    border-radius: 14px !important;\n    box-shadow: 0 10px 30px rgba(58, 47, 42, 0.12) !important;\n    padding: 0.35rem !important;\n    color: var(--anthropic-text) !important;\n}\n\n[role=\"option\"] {\n    color: var(--anthropic-text) !important;\n    background: transparent !important;\n    border-radius: 10px !important;\n}\n\n[role=\"option\"]:hover,\n[role=\"option\"][aria-selected=\"true\"] {\n    background: #EFE9DE !important;\n    color: var(--anthropic-text) !important;\n}\n\n/* ===== Title ===== */\nh1, .stTitle, [data-testid=\"stHeading\"] h1 {\n    color: var(--anthropic-text) !important;\n    font-weight: 600 !important;\n    letter-spacing: -0.02em !important;\n}\n\n/* ===== Agent name input fixed in header bar ===== */\n[data-testid=\"stTextInput\"] {\n    position: fixed !important;\n    top: 0 !important;\n    left: 50% !important;\n    transform: translateX(-50%) !important;\n    z-index: 999999 !important;\n    height: 60px !important;\n    display: flex !important;\n    align-items: center !important;\n    margin: 0 !important;\n    padding: 0 !important;\n}\n/* Hide the empty container left behind */\n[data-testid=\"stMainBlockContainer\"] > [data-testid=\"stVerticalBlock\"] > [data-testid=\"stElementContainer\"]:first-child {\n    height: 0 !important;\n    overflow: hidden !important;\n    margin: 0 !important;\n    padding: 0 !important;\n}\n[data-testid=\"stTextInput\"] > div {\n    background-color: transparent !important;\n    border: none !important;\n    box-shadow: none !important;\n    padding: 0 !important;\n    margin: 0 !important;\n    position: relative !important;\n}\n[data-testid=\"stTextInput\"] > label {\n    display: none !important;\n}\n[data-testid=\"stTextInput\"] input[type=\"text\"] {\n    font-size: 1.6rem !important;\n    font-weight: 600 !important;\n    letter-spacing: -0.02em !important;\n    color: var(--anthropic-text) !important;\n    background-color: var(--anthropic-bg) !important;\n    border: none !important;\n    border-radius: 0 !important;\n    padding: 0.3rem 1.8rem 0.3rem 0.5rem !important;\n    box-shadow: none !important;\n    width: 320px !important;\n    text-align: center !important;\n    transition: all 0.2s ease !important;\n    cursor: default !important;\n    caret-color: #1a1714 !important;\n}\n[data-testid=\"stTextInput\"] input[type=\"text\"]:hover {\n    background-color: var(--anthropic-bg-secondary) !important;\n    border-radius: 6px !important;\n}\n[data-testid=\"stTextInput\"] input[type=\"text\"]:focus {\n    background-color: var(--anthropic-bg-secondary) !important;\n    border-radius: 6px !important;\n    box-shadow: none !important;\n    cursor: text !important;\n    caret-color: #1a1714 !important;\n}\n/* Edit pencil icon - visible by default, semi-transparent on focus */\n[data-testid=\"stTextInput\"] > div::after {\n    content: '✎' !important;\n    position: absolute !important;\n    right: 8px !important;\n    top: 50% !important;\n    transform: translateY(-50%) !important;\n    font-size: 0.9rem !important;\n    color: var(--anthropic-text-secondary) !important;\n    pointer-events: none !important;\n    opacity: 0.6 !important;\n    transition: opacity 0.2s ease !important;\n}\n[data-testid=\"stTextInput\"] > div:hover::after {\n    opacity: 0.85 !important;\n}\n[data-testid=\"stTextInput\"] > div:focus-within::after {\n    opacity: 0 !important;\n}\n\nh2, h3, h4, h5, h6 {\n    color: var(--anthropic-text) !important;\n    font-weight: 500 !important;\n}\n\n/* ===== Buttons ===== */\n.stButton > button {\n    background-color: var(--anthropic-bg-secondary) !important;\n    color: var(--anthropic-text) !important;\n    border: 1px solid var(--anthropic-border) !important;\n    border-radius: 8px !important;\n    padding: 0.4rem 1rem !important;\n    font-weight: 500 !important;\n    transition: all 0.2s ease !important;\n}\n\n.stButton > button:hover {\n    background-color: var(--anthropic-primary) !important;\n    color: white !important;\n    border-color: var(--anthropic-primary) !important;\n}\n\n.stButton > button[kind=\"primary\"],\n.stButton > button[data-testid=\"stBaseButton-primary\"] {\n    background-color: var(--anthropic-primary) !important;\n    color: white !important;\n    border-color: var(--anthropic-primary) !important;\n}\n\n.stButton > button[kind=\"primary\"]:hover,\n.stButton > button[data-testid=\"stBaseButton-primary\"]:hover {\n    background-color: var(--anthropic-primary-hover) !important;\n    border-color: var(--anthropic-primary-hover) !important;\n}\n\n/* ===== Chat input ===== */\n[data-testid=\"stChatInput\"],\n[data-testid=\"stChatInput\"] > div {\n    background-color: var(--anthropic-bg) !important;\n    border-color: var(--anthropic-border) !important;\n}\n\n[data-testid=\"stChatInput\"] {\n    margin-bottom: 12px !important;\n}\n\n[data-testid=\"stChatInput\"] textarea,\n[data-testid=\"stChatInputTextArea\"] {\n    color: var(--anthropic-text) !important;\n    background-color: var(--anthropic-bg) !important;\n    caret-color: #1A1714 !important;\n}\n\n[data-testid=\"stChatInput\"] textarea::placeholder {\n    color: var(--anthropic-text-secondary) !important;\n    opacity: 0.7 !important;\n}\n\n/* Chat input container border */\n[data-testid=\"stChatInput\"] > div {\n    border: 1px solid var(--anthropic-border) !important;\n    border-radius: 12px !important;\n    min-height: 60px !important;\n    padding: 0.35rem 0.45rem 0.35rem 0.8rem !important;\n    align-items: center !important;\n    gap: 0.5rem !important;\n    transition: none !important;\n    animation: none !important;\n}\n\n[data-testid=\"stChatInput\"] > div:focus-within {\n    border-color: var(--anthropic-primary) !important;\n    box-shadow: 0 0 0 2px rgba(212, 162, 127, 0.2) !important;\n}\n\n[data-testid=\"stChatInput\"] textarea,\n[data-testid=\"stChatInputTextArea\"] {\n    min-height: 1.5rem !important;\n    padding: 0.35rem 0 !important;\n    line-height: 1.5 !important;\n    transition: none !important;\n    animation: none !important;\n}\n\n/* Chat send button */\n[data-testid=\"stChatInput\"] button,\n[data-testid=\"stChatInputSubmitButton\"] {\n    background-color: var(--anthropic-primary) !important;\n    color: white !important;\n    border-radius: 12px !important;\n    width: 60px !important;\n    height: 60px !important;\n    min-width: 60px !important;\n    min-height: 60px !important;\n    padding: 0 !important;\n    display: inline-flex !important;\n    align-items: center !important;\n    justify-content: center !important;\n    flex-shrink: 0 !important;\n    transition: none !important;\n    animation: none !important;\n}\n\n[data-testid=\"stChatInput\"] button svg,\n[data-testid=\"stChatInputSubmitButton\"] svg,\n[data-testid=\"stChatInput\"] button [data-testid=\"stIconMaterial\"],\n[data-testid=\"stChatInputSubmitButton\"] [data-testid=\"stIconMaterial\"] {\n    width: 1.25rem !important;\n    height: 1.25rem !important;\n    font-size: 1.25rem !important;\n}\n\n[data-testid=\"stChatInput\"] button:hover {\n    background-color: var(--anthropic-primary-hover) !important;\n}\n\n/* Stop streaming button - fixed at bottom center, above chat input */\n.stop-btn-anchor {\n    display: none !important;\n}\n\n/* Collapse the wrapper so it doesn't push chat bubbles */\n[data-testid=\"stElementContainer\"]:has(.stop-btn-anchor) {\n    height: 0 !important;\n    min-height: 0 !important;\n    margin: 0 !important;\n    padding: 0 !important;\n    overflow: visible !important;\n}\n\n[data-testid=\"stVerticalBlock\"]:has(.stop-btn-anchor):not(:has([data-testid=\"stChatMessage\"])) {\n    position: fixed !important;\n    bottom: 5.75rem !important;\n    left: 50% !important;\n    transform: translateX(-50%) !important;\n    z-index: 1000 !important;\n    width: auto !important;\n    background: transparent !important;\n    pointer-events: none !important;\n    gap: 0 !important;\n}\n\n[data-testid=\"stVerticalBlock\"]:has(.stop-btn-anchor):not(:has([data-testid=\"stChatMessage\"])) > * {\n    pointer-events: auto !important;\n}\n\n[data-testid=\"stVerticalBlock\"]:has(.stop-btn-anchor):not(:has([data-testid=\"stChatMessage\"])) [data-testid=\"stButton\"] {\n    margin: 0 !important;\n}\n\n[data-testid=\"stVerticalBlock\"]:has(.stop-btn-anchor):not(:has([data-testid=\"stChatMessage\"])) [data-testid=\"stButton\"] > button {\n    border-radius: 999px !important;\n    padding: 0.35rem 1.1rem !important;\n    min-height: 2rem !important;\n    font-size: 0.84rem !important;\n    font-weight: 500 !important;\n    line-height: 1 !important;\n    box-shadow: 0 2px 8px rgba(0,0,0,0.12) !important;\n    white-space: nowrap !important;\n}\n\n[data-testid=\"stVerticalBlock\"]:has(.stop-btn-anchor):not(:has([data-testid=\"stChatMessage\"])) [data-testid=\"stButton\"] > button[kind=\"primary\"],\n[data-testid=\"stVerticalBlock\"]:has(.stop-btn-anchor):not(:has([data-testid=\"stChatMessage\"])) [data-testid=\"stButton\"] > button[data-testid=\"stBaseButton-primary\"] {\n    background-color: rgba(212, 162, 127, 0.95) !important;\n    border-color: rgba(212, 162, 127, 0.95) !important;\n}\n\n[data-testid=\"stVerticalBlock\"]:has(.stop-btn-anchor):not(:has([data-testid=\"stChatMessage\"])) [data-testid=\"stButton\"] > button:hover {\n    transform: translateY(-1px) !important;\n    box-shadow: 0 3px 12px rgba(0,0,0,0.15) !important;\n}\n\n/* ===== Chat messages ===== */\n[data-testid=\"stChatMessage\"] {\n    background-color: var(--anthropic-bg) !important;\n    border: none !important;\n    border-radius: 12px !important;\n    padding: 1rem 1.2rem !important;\n    margin-bottom: 0.5rem !important;\n}\n\n/* Assistant messages - clean white like Anthropic */\n[data-testid=\"stChatMessage\"]:has([data-testid=\"stChatMessageAvatarAssistant\"]) {\n    background-color: var(--anthropic-bg) !important;\n}\n\n/* User messages - subtle bordered box */\n[data-testid=\"stChatMessage\"]:has([data-testid=\"stChatMessageAvatarUser\"]) {\n    background-color: var(--anthropic-bg) !important;\n    border: 1px solid var(--anthropic-border) !important;\n    border-radius: 12px !important;\n    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04) !important;\n}\n\n/* Chat message text */\n[data-testid=\"stChatMessage\"] p,\n[data-testid=\"stChatMessage\"] .stMarkdown {\n    color: var(--anthropic-text) !important;\n    line-height: 1.6 !important;\n}\n\n/* Message timestamp */\n.msg-timestamp {\n    text-align: left;\n    font-size: 0.73rem;\n    color: var(--anthropic-text-secondary);\n    margin-top: -0.3rem;\n    margin-bottom: 0.2rem;\n    opacity: 0.55;\n    font-family: var(--anthropic-mono);\n    letter-spacing: 0.02em;\n}\n\n/* ===== Chat avatars ===== */\n[data-testid=\"stChatMessageAvatarContainer\"] {\n    width: 36px !important;\n    height: 36px !important;\n}\n[data-testid=\"stChatMessageAvatarContainer\"] > div,\n[data-testid*=\"stChatMessageAvatar\"],\n[data-testid*=\"chatAvatar\"] {\n    width: 36px !important;\n    height: 36px !important;\n    border-radius: 50% !important;\n    display: flex !important;\n    align-items: center !important;\n    justify-content: center !important;\n    overflow: hidden !important;\n}\n\n/* User avatar - warm brown gradient */\n[data-testid*=\"stChatMessageAvatar\"]:has(svg),\n[data-testid*=\"chatAvatar\"][data-testid*=\"user\"],\n[data-testid*=\"stChatMessageAvatar\"][data-testid*=\"User\"],\n[data-testid*=\"stChatMessageAvatar\"][data-testid*=\"user\"] {\n    background: linear-gradient(145deg, #D8B08A 0%, #B98259 100%) !important;\n    border: 1px solid rgba(150, 102, 67, 0.22) !important;\n    box-shadow: inset 0 1px 0 rgba(255,255,255,0.34), 0 2px 6px rgba(104, 76, 54, 0.10) !important;\n}\n/* Assistant avatar - cream gradient */\n[data-testid*=\"chatAvatar\"][data-testid*=\"assistant\"],\n[data-testid*=\"stChatMessageAvatar\"][data-testid*=\"Assistant\"],\n[data-testid*=\"stChatMessageAvatar\"][data-testid*=\"assistant\"],\n[data-testid=\"stChatMessageAvatarContainer\"] > div {\n    background: linear-gradient(145deg, #F6F1E9 0%, #E5D7C7 100%) !important;\n    border: 1px solid rgba(187, 165, 141, 0.50) !important;\n    box-shadow: inset 0 1px 0 rgba(255,255,255,0.72), 0 2px 6px rgba(104, 76, 54, 0.08) !important;\n}\n\n/* ===== Inline code (not inside pre/code blocks) ===== */\n:not(pre) > code {\n    background-color: var(--anthropic-code-bg) !important;\n    border: 1px solid var(--anthropic-border) !important;\n    border-radius: 4px !important;\n    padding: 0.15em 0.4em !important;\n    font-size: 0.9em !important;\n    color: var(--anthropic-text) !important;\n}\n\n/* ===== Code blocks (pre) ===== */\npre, .stCodeBlock, .stCodeBlock pre {\n    background-color: var(--anthropic-code-bg) !important;\n    border: 1px solid var(--anthropic-border) !important;\n    border-radius: 8px !important;\n}\n\n/* Code inside pre blocks: no extra border/background */\npre code,\n.stCodeBlock code,\n[data-testid=\"stChatMessage\"] pre code,\n[data-testid=\"stChatMessage\"] .stCodeBlock code {\n    background-color: transparent !important;\n    border: none !important;\n    padding: 0 !important;\n    font-size: inherit !important;\n    color: var(--anthropic-text) !important;\n}\n\n/* ===== Toast / Alerts ===== */\n[data-testid=\"stToast\"] {\n    background-color: var(--anthropic-bg-secondary) !important;\n    border: 1px solid var(--anthropic-border) !important;\n    border-radius: 8px !important;\n    color: var(--anthropic-text) !important;\n}\n\n/* ===== Captions ===== */\n.stCaption, [data-testid=\"stCaptionContainer\"] {\n    color: var(--anthropic-text-secondary) !important;\n}\n\n/* ===== Divider ===== */\n[data-testid=\"stHorizontalBlock\"] hr,\nhr {\n    border-color: var(--anthropic-border) !important;\n}\n\n/* ===== Scrollbar ===== */\n::-webkit-scrollbar {\n    width: 6px;\n    height: 6px;\n}\n::-webkit-scrollbar-track {\n    background: var(--anthropic-bg);\n}\n::-webkit-scrollbar-thumb {\n    background: var(--anthropic-border);\n    border-radius: 3px;\n}\n::-webkit-scrollbar-thumb:hover {\n    background: var(--anthropic-text-secondary);\n}\n\n/* ===== Links ===== */\na {\n    color: var(--anthropic-accent) !important;\n}\na:hover {\n    color: var(--anthropic-primary-hover) !important;\n}\n\n/* ===== Error/Warning/Info/Success ===== */\n[data-testid=\"stAlert\"] {\n    border-radius: 8px !important;\n}\n\n/* ===== Bottom padding for chat ===== */\n[data-testid=\"stBottomBlockContainer\"] {\n    background-color: var(--anthropic-bg) !important;\n}\n\n/* ===== Gear icon to open sidebar ===== */\n#sidebar-gear-toggle {\n    position: fixed !important;\n    top: 12px !important;\n    left: 12px !important;\n    z-index: 999999 !important;\n    width: 36px !important;\n    height: 36px !important;\n    display: flex !important;\n    align-items: center !important;\n    justify-content: center !important;\n    font-size: 1.3rem !important;\n    color: var(--anthropic-text-secondary) !important;\n    background: var(--anthropic-bg-secondary) !important;\n    border: 1px solid var(--anthropic-border) !important;\n    border-radius: 8px !important;\n    cursor: pointer !important;\n    transition: all 0.2s ease !important;\n    opacity: 0.7 !important;\n    user-select: none !important;\n}\n#sidebar-gear-toggle:hover {\n    opacity: 1 !important;\n    color: var(--anthropic-primary) !important;\n    border-color: var(--anthropic-primary) !important;\n    transform: rotate(30deg) !important;\n}\n/* Hide gear when sidebar is open */\nbody:has([data-testid=\"stSidebar\"][aria-expanded=\"true\"]) #sidebar-gear-toggle {\n    display: none !important;\n}\n</style>\n\"\"\"\n\nANTHROPIC_SELECTBOX_SCRIPT = \"\"\"\n<div></div>\n<script>\n(function() {\n    const hostWin = window.parent;\n    const doc = hostWin.document;\n    const LABEL_TEXT = '备用链路';\n    const EXTRA_WIDTH = 56;\n    const TIMER_KEY = '__anthropicSelectboxFixedWidthTimer';\n    const FONT_LABELS = {\n        '100': '标准（100%）',\n        '112.5': '偏大（112.5%）',\n        '125': '更大（125%）',\n        '137.5': '超大（137.5%）'\n    };\n\n    function measureTextWidth(text, sourceEl) {\n        const canvas = hostWin.__anthropicSelectboxMeasureCanvas || (hostWin.__anthropicSelectboxMeasureCanvas = doc.createElement('canvas'));\n        const ctx = canvas.getContext('2d');\n        const style = sourceEl ? hostWin.getComputedStyle(sourceEl) : null;\n        const font = style ? `${style.fontWeight} ${style.fontSize} ${style.fontFamily}` : '400 14px sans-serif';\n        ctx.font = font;\n        return Math.ceil(ctx.measureText(text || '').width);\n    }\n\n    function ensureSidebarSettingsTitle() {\n        const sidebar = doc.querySelector('[data-testid=\"stSidebar\"]');\n        if (!sidebar) return;\n        const collapseBtn = sidebar.querySelector('button[kind=\"header\"], [data-testid=\"stSidebarCollapseButton\"] button, [data-testid=\"stSidebarCollapseButton\"]');\n        if (!collapseBtn || !collapseBtn.parentElement) return;\n        let title = doc.getElementById('custom-sidebar-settings-title');\n        if (!title) {\n            title = doc.createElement('span');\n            title.id = 'custom-sidebar-settings-title';\n            title.textContent = '设置';\n            title.style.cssText = 'font-size:14px;font-weight:600;color:rgb(38,39,48);margin-right:8px;line-height:1;display:inline-flex;align-items:center;white-space:nowrap;';\n        }\n        if (collapseBtn.previousElementSibling !== title) {\n            collapseBtn.parentElement.insertBefore(title, collapseBtn);\n        }\n    }\n\n    function applyLiveFontPreview() {\n        const sidebar = doc.querySelector('[data-testid=\"stSidebar\"]');\n        if (!sidebar) return;\n        const sliderLabel = Array.from(sidebar.querySelectorAll('label, p')).find((el) => el.textContent && el.textContent.trim() === '字体大小');\n        if (!sliderLabel) return;\n        const container = sliderLabel.closest('[data-testid=\"stWidgetLabel\"]')?.parentElement?.parentElement || sliderLabel.closest('[data-testid=\"stSlider\"]') || sliderLabel.closest('div');\n        if (!container) return;\n        const input = container.querySelector('input[type=\"range\"]');\n        if (!input) return;\n        const caption = container.querySelector('[data-testid=\"stCaptionContainer\"] p, p');\n\n        const updateFont = () => {\n            const raw = parseFloat(input.value);\n            if (!Number.isFinite(raw)) return;\n            doc.documentElement.style.setProperty('font-size', raw + '%', 'important');\n            if (caption) {\n                const key = String(raw % 1 === 0 ? raw.toFixed(0) : raw);\n                caption.textContent = FONT_LABELS[key] || `${raw.toFixed(1)}%`;\n            }\n        };\n\n        if (input.dataset.liveFontBound !== '1') {\n            input.addEventListener('input', updateFont);\n            input.addEventListener('change', updateFont);\n            input.dataset.liveFontBound = '1';\n        }\n        updateFont();\n    }\n\n    function applyFixedWidth() {\n        const sidebar = doc.querySelector('[data-testid=\"stSidebar\"]');\n        if (!sidebar) return;\n        const boxes = sidebar.querySelectorAll('[data-testid=\"stSelectbox\"]');\n        boxes.forEach((box) => {\n            const labelNode = box.querySelector('label [data-testid=\"stMarkdownContainer\"] p, label p');\n            if (!labelNode || labelNode.textContent.trim() !== LABEL_TEXT) return;\n            const selectRoot = box.querySelector('[data-baseweb=\"select\"]');\n            const trigger = selectRoot && selectRoot.firstElementChild;\n            const maxLabelNode = box.querySelector('[data-testid=\"sidebar-llm-max-label\"]');\n            const text = ((maxLabelNode && maxLabelNode.textContent) || '').trim();\n            if (!selectRoot || !trigger || !text) return;\n\n            const textWidth = measureTextWidth(text, trigger);\n            const targetWidth = Math.min(sidebar.clientWidth - 32, Math.max(96, textWidth + EXTRA_WIDTH));\n            const valueWrap = trigger.firstElementChild;\n            const arrowWrap = valueWrap && valueWrap.nextElementSibling;\n            const valueNode = valueWrap && valueWrap.querySelector('[value]');\n\n            box.style.setProperty('width', targetWidth + 'px', 'important');\n            box.style.setProperty('max-width', targetWidth + 'px', 'important');\n            box.style.setProperty('flex', '0 0 ' + targetWidth + 'px', 'important');\n\n            selectRoot.style.setProperty('width', targetWidth + 'px', 'important');\n            selectRoot.style.setProperty('min-width', targetWidth + 'px', 'important');\n            selectRoot.style.setProperty('max-width', targetWidth + 'px', 'important');\n\n            trigger.style.setProperty('width', targetWidth + 'px', 'important');\n            trigger.style.setProperty('min-width', targetWidth + 'px', 'important');\n            trigger.style.setProperty('max-width', targetWidth + 'px', 'important');\n            trigger.style.setProperty('padding-right', '0px', 'important');\n            trigger.style.setProperty('justify-content', 'flex-start', 'important');\n            trigger.style.setProperty('box-sizing', 'border-box', 'important');\n\n            if (valueWrap) {\n                valueWrap.style.setProperty('flex', '1 1 auto', 'important');\n                valueWrap.style.setProperty('min-width', '0px', 'important');\n                valueWrap.style.setProperty('max-width', 'calc(100% - 24px)', 'important');\n                valueWrap.style.setProperty('padding-right', '4px', 'important');\n            }\n            if (valueNode) {\n                valueNode.style.setProperty('max-width', '100%', 'important');\n            }\n            if (arrowWrap) {\n                arrowWrap.style.setProperty('margin-left', 'auto', 'important');\n                arrowWrap.style.setProperty('padding-right', '0px', 'important');\n                arrowWrap.style.setProperty('width', '24px', 'important');\n                arrowWrap.style.setProperty('min-width', '24px', 'important');\n                arrowWrap.style.setProperty('display', 'flex', 'important');\n                arrowWrap.style.setProperty('justify-content', 'flex-end', 'important');\n                arrowWrap.style.setProperty('align-items', 'center', 'important');\n                arrowWrap.style.setProperty('overflow', 'visible', 'important');\n            }\n        });\n        ensureSidebarSettingsTitle();\n        applyLiveFontPreview();\n    }\n\n    if (hostWin[TIMER_KEY]) {\n        hostWin.clearInterval(hostWin[TIMER_KEY]);\n    }\n    hostWin[TIMER_KEY] = hostWin.setInterval(applyFixedWidth, 300);\n    hostWin.setTimeout(applyFixedWidth, 60);\n    hostWin.setTimeout(applyFixedWidth, 300);\n    hostWin.setTimeout(applyFixedWidth, 1000);\n    applyFixedWidth();\n})();\n</script>\n\"\"\"\n\n@st.cache_resource\ndef init():\n    agent = GeneraticAgent()\n    if agent.llmclient is None:\n        st.error(\"⚠️ 未配置任何可用的 LLM 接口，请在 mykey.py 中添加 sider_cookie 或 oai_apikey+oai_apibase 等信息后重启。\")\n        st.stop()\n    else:\n        threading.Thread(target=agent.run, daemon=True).start()\n    return agent\n\n\ndef build_dynamic_font_css(scale_percent: float) -> str:\n    root_percent = max(100.0, min(200.0, float(scale_percent)))\n    rem_scale = root_percent / 100.0\n    return f\"\"\"\n<style id=\"dynamic-font-scale-style\">\n:root, html, body, [data-testid=\"stAppViewContainer\"], .stApp {{\n    font-size: {root_percent:.1f}% !important;\n}}\nbody, [data-testid=\"stAppViewContainer\"], .stApp {{\n    --app-font-scale: {rem_scale:.3f};\n}}\n[data-testid=\"stAppViewContainer\"], .stApp, .stApp p, .stApp li, .stApp label,\n.stApp div[data-testid=\"stMarkdownContainer\"], .stApp textarea, .stApp input,\n.stApp button, .stApp [data-testid=\"stChatMessageContent\"], .stApp .stCaption {{\n    font-size: calc(1rem * var(--app-font-scale, 1)) !important;\n}}\n</style>\n\"\"\"\n\n\ndef build_dynamic_font_update_script(scale_percent: float) -> str:\n    css = json.dumps(build_dynamic_font_css(scale_percent))\n    return f\"\"\"\n<script>\n(() => {{\n    const cssText = {css};\n    const parser = new DOMParser();\n    const parsed = parser.parseFromString(cssText, 'text/html');\n    const nextStyle = parsed.querySelector('#dynamic-font-scale-style');\n    if (!nextStyle) return;\n    const hostDoc = window.parent && window.parent.document ? window.parent.document : document;\n    const existing = hostDoc.querySelector('#dynamic-font-scale-style');\n    if (existing) {{\n        existing.textContent = nextStyle.textContent;\n    }} else {{\n        hostDoc.head.appendChild(nextStyle);\n    }}\n}})();\n</script>\n\"\"\"\n\n\ndef build_header_agent_badge_script() -> str:\n    return \"\"\"\n<script>\n(() => {\n    const hostWin = window.parent || window;\n    const hostDoc = hostWin.document || document;\n    const BADGE_ID = 'generic-agent-header-badge';\n    const STYLE_ID = 'generic-agent-header-badge-style';\n\n    const ensureStyle = () => {\n        if (hostDoc.getElementById(STYLE_ID)) return;\n        const style = hostDoc.createElement('style');\n        style.id = STYLE_ID;\n        style.textContent = `\n            #${BADGE_ID} {\n                position: absolute;\n                left: 50%;\n                top: 50%;\n                transform: translate(-50%, -50%);\n                display: inline-flex;\n                align-items: center;\n                justify-content: center;\n                white-space: nowrap;\n                font-size: 2.75rem;\n                font-weight: 600;\n                line-height: 1.2;\n                color: #000000;\n                padding: 0;\n                border-radius: 0;\n                background: transparent;\n                border: none;\n                box-shadow: none;\n                pointer-events: none;\n                z-index: 20;\n            }\n        `;\n        hostDoc.head.appendChild(style);\n    };\n\n    const findHeaderRoot = () => {\n        const candidates = [\n            'header[data-testid=\"stHeader\"]',\n            '[data-testid=\"stHeader\"]',\n            'header',\n        ];\n        for (const selector of candidates) {\n            const root = hostDoc.querySelector(selector);\n            if (root) return root;\n        }\n        return null;\n    };\n\n    const ensureBadge = () => {\n        ensureStyle();\n        const headerRoot = findHeaderRoot();\n        if (!headerRoot) return;\n        headerRoot.style.position = 'relative';\n\n        let badge = hostDoc.getElementById(BADGE_ID);\n        if (!badge) {\n            badge = hostDoc.createElement('div');\n            badge.id = BADGE_ID;\n            badge.textContent = 'Generic Agent';\n        }\n        if (badge.parentElement !== headerRoot) {\n            headerRoot.appendChild(badge);\n        }\n\n        const titleEl = hostDoc.querySelector('h1');\n        if (titleEl) {\n            const titleStyle = hostWin.getComputedStyle(titleEl);\n            badge.style.fontSize = titleStyle.fontSize;\n            badge.style.fontWeight = titleStyle.fontWeight;\n            badge.style.lineHeight = titleStyle.lineHeight;\n            badge.style.fontFamily = titleStyle.fontFamily;\n            badge.style.letterSpacing = titleStyle.letterSpacing;\n            badge.style.color = '#000000';\n        }\n    };\n\n    if (hostWin.__genericAgentHeaderBadgeTimer) {\n        hostWin.clearInterval(hostWin.__genericAgentHeaderBadgeTimer);\n    }\n    hostWin.__genericAgentHeaderBadgeTimer = hostWin.setInterval(ensureBadge, 500);\n    hostWin.setTimeout(ensureBadge, 80);\n    hostWin.setTimeout(ensureBadge, 400);\n    ensureBadge();\n})();\n</script>\n\"\"\"\n\nagent = init()\n\ndef init_session_state():\n    for key, value in {\n        'agent_name': 'GenericAgent', 'streaming': False, 'stopping': False, 'display_queue': None,\n        'partial_response': '', 'reply_ts': '', 'current_prompt': '', 'selected_llm_idx': agent.llm_no,\n        'autonomous_enabled': False, 'messages': [],\n    }.items(): st.session_state.setdefault(key, value)\n\ninit_session_state()\n\n# Inject Anthropic theme\nst.markdown(ANTHROPIC_CSS, unsafe_allow_html=True)\nst.markdown(build_dynamic_font_css(110.0), unsafe_allow_html=True)\n_embed_html(ANTHROPIC_SELECTBOX_SCRIPT, height=0, width=0)\n_embed_html(build_header_agent_badge_script(), height=0, width=0)\n\nst.session_state.agent_name = 'Generic Agent'\nwith st.chat_message(\"assistant\"):\n    st.markdown(f'<div class=\"msg-timestamp\">{datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")}</div>', unsafe_allow_html=True)\n    st.write(\"欢迎使用GenericAgent~\")\n\n\n@st.fragment\ndef render_sidebar():\n    llm_options, current_idx = agent.list_llms(), agent.llm_no\n    st.session_state.selected_llm_idx = current_idx\n    llm_labels = {idx: f\"{idx}: {(name or '').strip()}\" for idx, name, _ in llm_options}\n    st.caption(f\"当前使用的LLM为：{current_idx}: {agent.get_llm_name()}\", help=\"可在下方选择链路\")\n    st.markdown(f'<div data-testid=\"sidebar-llm-max-label\" style=\"display:none\">{html.escape(max(llm_labels.values(), key=len, default=\"\"))}</div>', unsafe_allow_html=True)\n    selected_idx = st.selectbox(\"选择链路：\", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, key=\"sidebar_llm_select\")\n    if selected_idx != current_idx:\n        agent.next_llm(selected_idx)\n        st.session_state.selected_llm_idx = selected_idx\n        st.toast(f\"已切换到备用链路：{llm_labels[selected_idx]}\")\n        st.rerun()\n    st.divider()\n    if st.button(\"重新注入System Prompt\"):\n        agent.llmclient.last_tools = ''\n        st.toast(\"下次将重新注入System Prompt\")\n\nwith st.sidebar: render_sidebar()\n\n\ndef start_agent_task(prompt):\n    st.session_state.display_queue = agent.put_task(prompt, source=\"user\")\n    st.session_state.streaming, st.session_state.stopping, st.session_state.partial_response = True, False, ''\n    st.session_state.reply_ts = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n    st.session_state.current_prompt = prompt\n\n\ndef poll_agent_output(max_items=20):\n    q = st.session_state.display_queue\n    if q is None:\n        st.session_state.streaming = False\n        return False\n    done = False\n    for _ in range(max_items):\n        try:\n            item = q.get_nowait()\n        except queue.Empty:\n            break\n        if 'next' in item: st.session_state.partial_response = item['next']\n        if 'done' in item:\n            st.session_state.partial_response = item['done']\n            done = True\n            break\n    if done: st.session_state.streaming = st.session_state.stopping = False; st.session_state.display_queue = None\n    return done\n\n\ndef _get_response_segments(text):\n    return [p for p in re.split(r'(?=\\*\\*LLM Running \\(Turn \\d+\\) \\.\\.\\.\\*\\*)', text) if p.strip()] or [text]\n\ndef render_message(role, content, ts='', unsafe_allow_html=True):\n    with st.chat_message(role):\n        if ts: st.markdown(f'<div class=\"msg-timestamp\">{ts}</div>', unsafe_allow_html=True)\n        st.markdown(content, unsafe_allow_html=unsafe_allow_html)\n\ndef finish_streaming_message():\n    reply_ts = st.session_state.reply_ts\n    st.session_state.messages.extend({\"role\": \"assistant\", \"content\": seg, \"time\": reply_ts} for seg in _get_response_segments(st.session_state.partial_response))\n    st.session_state.last_reply_time = int(time.time())\n    st.session_state.partial_response = st.session_state.reply_ts = st.session_state.current_prompt = ''\n\ndef render_streaming_area():\n    if not st.session_state.streaming: return\n    with st.container():\n        st.markdown('<span class=\"stop-btn-anchor\"></span>', unsafe_allow_html=True)\n        if st.button(\"⏹️ 停止生成\", type=\"primary\"):\n            agent.abort(); st.session_state.stopping = True; st.toast(\"已发送停止信号\"); st.rerun()\n    reply_ts = st.session_state.reply_ts\n    with st.empty().container():\n        segments = _get_response_segments(st.session_state.partial_response)\n        for i, seg in enumerate(segments): render_message(\"assistant\", seg + (\"\" if i < len(segments) - 1 else \"▌\"), ts=reply_ts, unsafe_allow_html=False)\n    if poll_agent_output(): finish_streaming_message()\n    else: time.sleep(0.2)\n    st.rerun()\n\nfor msg in st.session_state.messages: render_message(msg[\"role\"], msg[\"content\"], ts=msg.get(\"time\", \"\"), unsafe_allow_html=True)\nif st.session_state.streaming: render_streaming_area()\nif prompt := st.chat_input(\"请输入指令\", disabled=st.session_state.streaming):\n    st.session_state.messages.append({\"role\": \"user\", \"content\": prompt, \"time\": datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")})\n    start_agent_task(prompt)\n    st.rerun()\n\n"
  },
  {
    "path": "frontends/tgapp.py",
    "content": "import os, sys, re, threading, asyncio, queue as Q, time, random, uuid\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')\nfrom agentmain import GeneraticAgent\ntry:\n    from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup\n    from telegram.constants import ChatType, MessageLimit, ParseMode\n    from telegram.error import RetryAfter\n    from telegram.ext import ApplicationBuilder, CallbackQueryHandler, MessageHandler, filters, ContextTypes\n    from telegram.helpers import escape_markdown\n    from telegram.request import HTTPXRequest\nexcept:\n    print(\"Please ask the agent install python-telegram-bot to use telegram module.\")\n    sys.exit(1)\nfrom chatapp_common import (\n    FILE_HINT,\n    HELP_TEXT,\n    TELEGRAM_MENU_COMMANDS,\n    clean_reply,\n    ensure_single_instance,\n    extract_files,\n    format_restore,\n    redirect_log,\n    require_runtime,\n    split_text,\n)\nfrom continue_cmd import handle_frontend_command, reset_conversation\nfrom llmcore import mykeys\n\nagent = GeneraticAgent()\nagent.verbose = False\nagent.inc_out = True\nALLOWED = set(mykeys.get('tg_allowed_users', []))\n\n_DRAFT_HINT = \"thinking...\"\n_STREAM_SUFFIX = \" ⏳\"\n_STREAM_SEGMENT_LIMIT = max(1200, MessageLimit.MAX_TEXT_LENGTH - 256)\n_STREAM_UPDATE_INTERVAL_SECONDS = 2.0\n_STREAM_MIN_UPDATE_CHARS = 400\n_RETRY_AFTER_MARGIN_SECONDS = 1.0\n_QUEUE_WAIT_SECONDS = 1\n_ASK_USER_HOOK_KEY = \"telegram_ask_user_menu\"\n_ASK_CALLBACK_PREFIX = \"ask:\"\n_ASK_CANCEL_ACTION = \"none\"\n_ASK_CANCEL_LABEL = \"none of these above\"\n_ASK_CANCEL_PROMPT = \"已取消选择，请直接发送下一步操作。\"\n_ask_menu_events = Q.Queue()\n_ask_menu_store = {}\n_QUOTE_OPEN_TAG = \"<_quote_>\"\n_QUOTE_CLOSE_TAG = \"</_quote_>\"\n_QUOTE_TOKEN_PATTERN = re.escape(_QUOTE_OPEN_TAG) + r\"([\\s\\S]*?)\" + re.escape(_QUOTE_CLOSE_TAG)\n_MD_TOKEN_RE = re.compile(\n    (\n        r\"(`{3,})([A-Za-z0-9_+-]*)\\n([\\s\\S]*?)\\1\"\n        r\"|\" + _QUOTE_TOKEN_PATTERN +\n        r\"|\\[([^\\]]+)\\]\\(([^)\\n]+)\\)\"\n        r\"|`([^`\\n]+)`\"\n        r\"|\\*\\*([^\\n]+?)\\*\\*\"\n        r\"|__([^\\n]+?)__\"\n        r\"|~~([^\\n]+?)~~\"\n        r\"|(?<!\\*)\\*(?!\\*)([^\\n]+?)(?<!\\*)\\*(?!\\*)\"\n    ),\n    re.DOTALL,\n)\n_TURN_MARKER_RE = re.compile(r\"^\\*{0,2}LLM Running \\(Turn (\\d+)\\) \\.\\.\\.\\*{0,2}\\s*$\")\n_CODE_FENCE_RE = re.compile(r\"^\\s*(`{3,})(.*)$\")\n_TURN_SUMMARY_LIMIT = 160\n_TURN_SUMMARY_RE = re.compile(r\"<summary>\\s*(.*?)\\s*</summary>\", re.DOTALL)\n_TURN_SUMMARY_SEARCH_STRIP_RE = re.compile(r\"`{3,}[\\s\\S]*?`{3,}|<thinking>[\\s\\S]*?</thinking>\", re.DOTALL)\n\ndef _make_draft_id():\n    return random.randint(1, 2**31 - 1)\n\ndef _visible_segments(text):\n    text = (text or \"\").strip()\n    if not text:\n        return []\n    segments = []\n    for part in split_text(text, _STREAM_SEGMENT_LIMIT):\n        segments.extend(_markdown_safe_segments(part))\n    return segments\n\ndef _markdown_safe_segments(text, limit=None):\n    limit = limit or MessageLimit.MAX_TEXT_LENGTH\n    text = (text or \"\").strip()\n    if not text:\n        return []\n    if len(_to_markdown_v2(text)) <= limit:\n        return [text]\n    parts = []\n    remaining = text\n    while remaining:\n        if len(_to_markdown_v2(remaining)) <= limit:\n            parts.append(remaining)\n            break\n        low, high, best = 1, len(remaining), 1\n        while low <= high:\n            mid = (low + high) // 2\n            if len(_to_markdown_v2(remaining[:mid].rstrip() or remaining[:mid])) <= limit:\n                best = mid\n                low = mid + 1\n            else:\n                high = mid - 1\n        cut = remaining.rfind(\"\\n\", 0, best)\n        if cut < max(1, best * 0.6):\n            cut = best\n        chunk = remaining[:cut].rstrip() or remaining[:best]\n        parts.append(chunk)\n        remaining = remaining[len(chunk):].lstrip()\n    return parts\n\ndef _line_complete(line):\n    return (line or \"\").endswith((\"\\n\", \"\\r\"))\n\ndef _turn_marker_number(line):\n    match = _TURN_MARKER_RE.fullmatch((line or \"\").strip())\n    return int(match.group(1)) if match else None\n\ndef _maybe_partial_turn_marker(line):\n    text = (line or \"\").strip().lstrip(\"*\")\n    if not text:\n        return False\n    marker_head = \"LLM Running (Turn \"\n    return marker_head.startswith(text) or text.startswith(marker_head)\n\ndef _maybe_partial_code_fence(line):\n    return bool(re.match(r\"^\\s*`{1,}[^`\\r\\n]*$\", line or \"\"))\n\ndef _extract_turn_summary(raw_text):\n    search_text = _TURN_SUMMARY_SEARCH_STRIP_RE.sub(\"\", raw_text or \"\")\n    match = _TURN_SUMMARY_RE.search(search_text)\n    if not match:\n        return \"\"\n    summary = re.sub(r\"\\s+\", \" \", match.group(1)).strip()\n    if len(summary) > _TURN_SUMMARY_LIMIT:\n        summary = summary[:_TURN_SUMMARY_LIMIT - 3].rstrip() + \"...\"\n    return summary\n\ndef _quote_tag(text):\n    safe_text = (text or \"\").strip().replace(_QUOTE_OPEN_TAG, \"\").replace(_QUOTE_CLOSE_TAG, \"\")\n    return f\"{_QUOTE_OPEN_TAG}{safe_text}{_QUOTE_CLOSE_TAG}\"\n\ndef _inject_turn_summary(body, summary):\n    if not (body or \"\").strip() or not (summary or \"\").strip():\n        return body\n    lines = (body or \"\").splitlines()\n    if not lines or _turn_marker_number(lines[0]) is None:\n        return body\n    title = lines[0].strip()\n    rest = \"\\n\".join(lines[1:]).strip()\n    summary_line = _quote_tag(summary)\n    if rest:\n        return f\"{title}\\n\\n{summary_line}\\n\\n{rest}\"\n    return f\"{title}\\n\\n{summary_line}\"\n\ndef _resolve_files(paths):\n    files, seen = [], set()\n    for fpath in paths:\n        if not os.path.isabs(fpath):\n            fpath = os.path.join(_TEMP_DIR, fpath)\n        if fpath in seen or not os.path.exists(fpath):\n            continue\n        files.append(fpath)\n        seen.add(fpath)\n    return files\n\n\ndef _render_file_markers(text):\n    def repl(match):\n        return os.path.basename(match.group(1))\n    return re.sub(r\"\\[FILE:([^\\]]+)\\]\", repl, text or \"\").strip()\n\ndef _files_from_text(text):\n    cleaned = clean_reply(text) if (text or \"\").strip() else \"\"\n    return _resolve_files(extract_files(cleaned))\n\nasync def _send_files(root_msg, files):\n    for fpath in files:\n        if fpath.lower().endswith((\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\")):\n            try:\n                with open(fpath, \"rb\") as fp:\n                    await root_msg.reply_photo(fp)\n            except Exception:\n                pass\n        else:\n            try:\n                with open(fpath, \"rb\") as fp:\n                    await root_msg.reply_document(fp)\n            except Exception:\n                pass\n\nasync def _send_files_from_text(root_msg, text):\n    await _send_files(root_msg, _files_from_text(text))\n\ndef _escape_pre(text):\n    return escape_markdown(text or \"\", version=2, entity_type=\"pre\")\n\ndef _escape_code(text):\n    return escape_markdown(text or \"\", version=2, entity_type=\"code\")\n\ndef _escape_link_target(text):\n    return escape_markdown(text or \"\", version=2, entity_type=\"text_link\")\n\ndef _quote_to_markdown_v2(text):\n    lines = (text or \"\").strip().splitlines() or [\"\"]\n    return \"\\n\".join(f\"> {escape_markdown(line, version=2)}\" for line in lines)\n\ndef _to_markdown_v2(text):\n    if not text:\n        return \"\"\n    parts, pos = [], 0\n    for match in _MD_TOKEN_RE.finditer(text):\n        parts.append(escape_markdown(text[pos:match.start()], version=2))\n        if match.group(1):\n            lang = re.sub(r\"[^A-Za-z0-9_+-]\", \"\", match.group(2) or \"\")\n            code = _escape_pre(match.group(3) or \"\")\n            header = f\"```{lang}\\n\" if lang else \"```\\n\"\n            parts.append(f\"{header}{code}\\n```\")\n        elif match.group(4) is not None:\n            parts.append(_quote_to_markdown_v2(match.group(4)))\n        elif match.group(5) is not None:\n            label = escape_markdown(match.group(5), version=2)\n            target = _escape_link_target(match.group(6))\n            parts.append(f\"[{label}]({target})\")\n        elif match.group(7) is not None:\n            parts.append(f\"`{_escape_code(match.group(7))}`\")\n        elif match.group(8) is not None:\n            parts.append(f\"*{escape_markdown(match.group(8), version=2)}*\")\n        elif match.group(9) is not None:\n            parts.append(f\"*{escape_markdown(match.group(9), version=2)}*\")\n        elif match.group(10) is not None:\n            parts.append(f\"~{escape_markdown(match.group(10), version=2)}~\")\n        elif match.group(11) is not None:\n            parts.append(f\"_{escape_markdown(match.group(11), version=2)}_\")\n        pos = match.end()\n    parts.append(escape_markdown(text[pos:], version=2))\n    return \"\".join(parts)\n\ndef _is_not_modified_error(exc):\n    return \"not modified\" in str(exc).lower()\n\ndef _extract_ask_user_event(ctx):\n    exit_reason = (ctx or {}).get(\"exit_reason\") or {}\n    if exit_reason.get(\"result\") != \"EXITED\":\n        return None\n    payload = exit_reason.get(\"data\")\n    if not isinstance(payload, dict):\n        return None\n    if payload.get(\"status\") != \"INTERRUPT\" or payload.get(\"intent\") != \"HUMAN_INTERVENTION\":\n        return None\n    data = payload.get(\"data\")\n    if not isinstance(data, dict):\n        return None\n    raw_candidates = data.get(\"candidates\") or []\n    if not isinstance(raw_candidates, (list, tuple)):\n        return None\n    candidates = []\n    for candidate in raw_candidates:\n        if candidate is None:\n            continue\n        text = str(candidate).strip()\n        if text:\n            candidates.append(text)\n    if not candidates:\n        return None\n    question = str(data.get(\"question\") or \"请选择下一步操作：\").strip() or \"请选择下一步操作：\"\n    return {\"question\": question, \"candidates\": candidates}\n\ndef _register_ask_user_hook():\n    if not hasattr(agent, \"_turn_end_hooks\"):\n        agent._turn_end_hooks = {}\n    def _hook(ctx):\n        event = _extract_ask_user_event(ctx)\n        if event:\n            _ask_menu_events.put(event)\n    agent._turn_end_hooks[_ASK_USER_HOOK_KEY] = _hook\n\ndef _drain_latest_ask_user_event():\n    latest = None\n    while True:\n        try:\n            latest = _ask_menu_events.get_nowait()\n        except Q.Empty:\n            break\n    return latest\n\ndef _build_ask_user_markup(menu_id, candidates):\n    rows = [\n        [InlineKeyboardButton(candidate, callback_data=f\"{_ASK_CALLBACK_PREFIX}{menu_id}:{idx}\")]\n        for idx, candidate in enumerate(candidates)\n    ]\n    rows.append([\n        InlineKeyboardButton(_ASK_CANCEL_LABEL, callback_data=f\"{_ASK_CALLBACK_PREFIX}{menu_id}:{_ASK_CANCEL_ACTION}\")\n    ])\n    return InlineKeyboardMarkup(rows)\n\ndef _parse_ask_callback_data(data):\n    if not (data or \"\").startswith(_ASK_CALLBACK_PREFIX):\n        return None, None\n    payload = data[len(_ASK_CALLBACK_PREFIX):]\n    menu_id, sep, action = payload.partition(\":\")\n    if not sep or not menu_id or not action:\n        return None, None\n    return menu_id, action\n\ndef _build_text_prompt(text):\n    return f\"{FILE_HINT}\\n\\n{text}\"\n\ndef _normalize_ask_menu_event(stored):\n    if isinstance(stored, dict):\n        candidates = stored.get(\"candidates\") or []\n        return {\n            \"question\": str(stored.get(\"question\") or \"请选择下一步操作：\").strip() or \"请选择下一步操作：\",\n            \"candidates\": [str(candidate).strip() for candidate in candidates if str(candidate).strip()],\n        }\n    if isinstance(stored, (list, tuple)):\n        return {\n            \"question\": \"请选择下一步操作：\",\n            \"candidates\": [str(candidate).strip() for candidate in stored if str(candidate).strip()],\n        }\n    return None\n\ndef _render_ask_user_result(event, selected=None, cancelled=False):\n    question = str(event.get(\"question\") or \"请选择下一步操作：\").strip() or \"请选择下一步操作：\"\n    candidates = event.get(\"candidates\") or []\n    lines = [question, \"\", \"选项：\"]\n    for idx, candidate in enumerate(candidates, start=1):\n        lines.append(f\"{idx}. {candidate}\")\n    lines.append(f\"{len(candidates) + 1}. {_ASK_CANCEL_LABEL}\")\n    lines.append(\"\")\n    if cancelled:\n        lines.append(f\"已取消：{_ASK_CANCEL_LABEL}\")\n    elif selected:\n        lines.append(f\"已选择：{selected}\")\n    text = \"\\n\".join(lines)\n    if len(text) > MessageLimit.MAX_TEXT_LENGTH:\n        text = text[:MessageLimit.MAX_TEXT_LENGTH - 18].rstrip() + \"\\n...[truncated]\"\n    return text\n\nasync def _clear_ask_reply_markup(query):\n    try:\n        await query.edit_message_reply_markup(reply_markup=None)\n    except Exception as exc:\n        print(f\"[TG ask_user menu cleanup] {type(exc).__name__}: {exc}\", flush=True)\n\nasync def _edit_ask_user_result(query, event, selected=None, cancelled=False):\n    try:\n        await query.edit_message_text(\n            _render_ask_user_result(event, selected=selected, cancelled=cancelled),\n            reply_markup=None,\n        )\n    except Exception as exc:\n        print(f\"[TG ask_user menu edit] {type(exc).__name__}: {exc}\", flush=True)\n        await _clear_ask_reply_markup(query)\n\nasync def _send_ask_user_menu(root_msg, event):\n    menu_id = uuid.uuid4().hex[:16]\n    candidates = event[\"candidates\"]\n    _ask_menu_store[menu_id] = {\"question\": event[\"question\"], \"candidates\": list(candidates)}\n    try:\n        await root_msg.reply_text(\n            event[\"question\"],\n            reply_markup=_build_ask_user_markup(menu_id, candidates),\n        )\n    except Exception as exc:\n        _ask_menu_store.pop(menu_id, None)\n        print(f\"[TG ask_user menu error] {type(exc).__name__}: {exc}\", flush=True)\n        fallback = event[\"question\"] + \"\\n\" + \"\\n\".join(f\"- {candidate}\" for candidate in candidates)\n        await root_msg.reply_text(fallback)\n\nclass _TelegramStreamSession:\n    def __init__(self, root_msg):\n        self.root_msg = root_msg\n        self.private_chat = getattr(getattr(root_msg, \"chat\", None), \"type\", \"\") == ChatType.PRIVATE\n        self.can_use_draft = self.private_chat   # update tg client!\n        self.draft_id = _make_draft_id()\n        self.live_msg = None\n        self.raw_text = \"\"\n        self.files = []\n        self.sent_segments = 0\n        self.active_display = \"\"\n        self.pending_display = \"\"\n        self.retry_until = 0.0\n        self.last_update_at = 0.0\n        self.last_update_raw_len = 0\n\n    def _now(self):\n        return time.monotonic()\n\n    def _retry_after_seconds(self, exc):\n        retry_after = getattr(exc, \"_retry_after\", None)\n        if retry_after is None:\n            retry_after = getattr(exc, \"retry_after\", 0) or 0\n        if hasattr(retry_after, \"total_seconds\"):\n            retry_after = retry_after.total_seconds()\n        try:\n            return max(0.0, float(retry_after))\n        except (TypeError, ValueError):\n            return 0.0\n\n    def _set_retry_after(self, exc):\n        wait_seconds = self._retry_after_seconds(exc) + _RETRY_AFTER_MARGIN_SECONDS\n        self.retry_until = max(self.retry_until, self._now() + wait_seconds)\n\n    def _is_retrying(self):\n        return self._now() < self.retry_until\n\n    async def _wait_for_retry(self):\n        remaining = self.retry_until - self._now()\n        if remaining > 0:\n            await asyncio.sleep(remaining)\n\n    def _should_stream_update(self, display):\n        if display == self.active_display:\n            return False\n        if self.last_update_at <= 0:\n            return True\n        elapsed = self._now() - self.last_update_at\n        raw_delta = len(self.raw_text) - self.last_update_raw_len\n        return elapsed >= _STREAM_UPDATE_INTERVAL_SECONDS or raw_delta >= _STREAM_MIN_UPDATE_CHARS\n\n    def _mark_stream_update(self, display):\n        self.active_display = display\n        self.pending_display = \"\"\n        self.last_update_at = self._now()\n        self.last_update_raw_len = len(self.raw_text)\n\n    def _stream_display(self, text):\n        base = (text or _DRAFT_HINT).strip() or _DRAFT_HINT\n        safe_parts = _markdown_safe_segments(base)\n        base = safe_parts[-1] if safe_parts else _DRAFT_HINT\n        if base == _DRAFT_HINT:\n            return base\n        display = base + _STREAM_SUFFIX\n        if len(_to_markdown_v2(display)) <= MessageLimit.MAX_TEXT_LENGTH:\n            return display\n        return base\n\n    async def prime(self):\n        if self.can_use_draft:\n            draft_result = await self._send_draft(_DRAFT_HINT)\n            if draft_result is True:\n                self.active_display = _DRAFT_HINT\n                return\n            if draft_result is None:\n                self.active_display = _DRAFT_HINT\n                return\n        try:\n            await self._upsert_live_message(_DRAFT_HINT, wait_retry=False)\n        except RetryAfter:\n            self.active_display = _DRAFT_HINT\n            return\n        self.active_display = _DRAFT_HINT\n\n    async def add_chunk(self, chunk):\n        if not chunk:\n            return\n        self.raw_text += chunk\n        await self._refresh(done=False, send_files=False)\n\n    async def finalize(self, full_text=None, send_files=True):\n        if full_text is not None:\n            self.raw_text = full_text\n        await self._refresh(done=True, send_files=send_files)\n\n    async def finish_with_notice(self, notice):\n        if self.raw_text.strip():\n            await self.finalize(send_files=False)\n            await self._reply_text(notice)\n            return\n        if self.live_msg is not None:\n            await self._edit_text(self.live_msg, notice)\n            self.live_msg = None\n            self.active_display = \"\"\n            return\n        await self._reply_text(notice)\n        self.active_display = \"\"\n\n    async def _refresh(self, done, send_files):\n        summary = _extract_turn_summary(self.raw_text)\n        cleaned = clean_reply(self.raw_text) if self.raw_text.strip() else \"\"\n        self.files = _files_from_text(cleaned)\n        body = _inject_turn_summary(_render_file_markers(cleaned), summary)\n        if done and not body and self.files:\n            body = \"已生成附件\"\n        elif done and not body:\n            body = \"...\"\n        segments = _visible_segments(body)\n        finalized_target = len(segments) if done else max(len(segments) - 1, 0)\n        while self.sent_segments < finalized_target:\n            await self._finalize_segment(segments[self.sent_segments])\n            self.sent_segments += 1\n        if done:\n            if send_files:\n                await self._send_files()\n            return\n        active_text = segments[-1] if segments else _DRAFT_HINT\n        await self._stream_active(active_text)\n\n    async def _stream_active(self, text):\n        display = self._stream_display(text)\n        if display == self.active_display:\n            return\n        self.pending_display = display\n        if self._is_retrying() or not self._should_stream_update(display):\n            return\n        try:\n            if self.can_use_draft:\n                draft_result = await self._send_draft(display)\n                if draft_result is True:\n                    self._mark_stream_update(display)\n                    return\n                if draft_result is None:\n                    return\n            await self._upsert_live_message(display, wait_retry=False)\n            self._mark_stream_update(display)\n        except RetryAfter:\n            return\n\n    async def _finalize_segment(self, text):\n        final_text = (text or \"\").strip() or \"...\"\n        if self.live_msg is not None:\n            await self._edit_text(self.live_msg, final_text)\n            self.live_msg = None\n        else:\n            await self._reply_text(final_text)\n        self.active_display = \"\"\n        if self.can_use_draft:\n            self.draft_id = _make_draft_id()\n\n    async def _send_files(self):\n        await _send_files(self.root_msg, self.files)\n\n    async def _send_draft(self, text):\n        try:\n            await self.root_msg.reply_text_draft(\n                self.draft_id,\n                _to_markdown_v2(text),\n                parse_mode=ParseMode.MARKDOWN_V2,\n            )\n            return True\n        except RetryAfter as exc:\n            self._set_retry_after(exc)\n            return None\n        except Exception as exc:\n            if _is_not_modified_error(exc):\n                return True\n            print(f\"[TG draft fallback] {type(exc).__name__}: {exc}\", flush=True)\n            self.can_use_draft = False\n            self.draft_id = _make_draft_id()\n            return False\n\n    async def _retry_call(self, func, *args):\n        while True:\n            await self._wait_for_retry()\n            try:\n                return await func(*args)\n            except RetryAfter as exc:\n                self._set_retry_after(exc)\n\n    async def _reply_text_once(self, text):\n        markdown = _to_markdown_v2(text)\n        try:\n            return await self.root_msg.reply_text(markdown, parse_mode=ParseMode.MARKDOWN_V2)\n        except RetryAfter as exc:\n            self._set_retry_after(exc)\n            raise\n        except Exception as exc:\n            if _is_not_modified_error(exc):\n                return None\n            try:\n                return await self.root_msg.reply_text(text)\n            except RetryAfter as retry_exc:\n                self._set_retry_after(retry_exc)\n                raise\n\n    async def _reply_text(self, text, wait_retry=True):\n        last_msg = None\n        for segment in _markdown_safe_segments(text) or [\"...\"]:\n            if wait_retry:\n                last_msg = await self._retry_call(self._reply_text_once, segment)\n            else:\n                last_msg = await self._reply_text_once(segment)\n        return last_msg\n\n    async def _edit_text_once(self, msg, text):\n        markdown = _to_markdown_v2(text)\n        try:\n            updated = await msg.edit_text(markdown, parse_mode=ParseMode.MARKDOWN_V2)\n        except RetryAfter as exc:\n            self._set_retry_after(exc)\n            raise\n        except Exception as exc:\n            if _is_not_modified_error(exc):\n                return msg\n            try:\n                updated = await msg.edit_text(text)\n            except RetryAfter as retry_exc:\n                self._set_retry_after(retry_exc)\n                raise\n        return updated if hasattr(updated, \"edit_text\") else msg\n\n    async def _edit_text(self, msg, text, wait_retry=True):\n        segments = _markdown_safe_segments(text) or [\"...\"]\n        if wait_retry:\n            updated = await self._retry_call(self._edit_text_once, msg, segments[0])\n        else:\n            updated = await self._edit_text_once(msg, segments[0])\n        for segment in segments[1:]:\n            updated = await self._reply_text(segment, wait_retry=wait_retry)\n        return updated if hasattr(updated, \"edit_text\") else msg\n\n    async def _upsert_live_message(self, text, wait_retry=True):\n        if self.live_msg is None:\n            self.live_msg = await self._reply_text(text, wait_retry=wait_retry)\n        else:\n            self.live_msg = await self._edit_text(self.live_msg, text, wait_retry=wait_retry)\n\n\nclass _TelegramTurnStreamCoordinator:\n    def __init__(self, root_msg):\n        self.root_msg = root_msg\n        self.session = None\n        self.pending_line = \"\"\n        self.code_fence_len = 0\n        self.last_turn = 0\n\n    async def prime(self):\n        await self._ensure_session()\n\n    async def add_chunk(self, chunk):\n        if not chunk:\n            return\n        text = self.pending_line + chunk\n        self.pending_line = \"\"\n        for line in text.splitlines(keepends=True):\n            if _line_complete(line):\n                await self._process_line(line)\n            elif _maybe_partial_turn_marker(line) or _maybe_partial_code_fence(line):\n                self.pending_line = line\n            else:\n                await self._process_line(line)\n\n    async def finalize(self, done_text=\"\", send_files=True):\n        await self._flush_pending_line()\n        if self.session is None:\n            if done_text:\n                await self._add_to_current(done_text)\n        elif not self.session.raw_text.strip() and done_text:\n            await self.session.finalize(done_text, send_files=False)\n            if send_files:\n                await _send_files_from_text(self.root_msg, done_text)\n            return\n        if self.session is not None:\n            await self.session.finalize(send_files=False)\n        if send_files:\n            await _send_files_from_text(self.root_msg, done_text)\n\n    async def finish_with_notice(self, notice):\n        await self._flush_pending_line()\n        await self._ensure_session()\n        await self.session.finish_with_notice(notice)\n\n    async def _ensure_session(self):\n        if self.session is None:\n            self.session = _TelegramStreamSession(self.root_msg)\n            await self.session.prime()\n\n    async def _start_turn(self, marker):\n        if self.session is not None and self.session.raw_text.strip():\n            await self.session.finalize(send_files=False)\n            self.session = None\n        await self._ensure_session()\n        await self.session.add_chunk(marker)\n\n    async def _add_to_current(self, text):\n        if not text:\n            return\n        await self._ensure_session()\n        await self.session.add_chunk(text)\n\n    async def _process_line(self, line):\n        turn_no = _turn_marker_number(line)\n        if self.code_fence_len == 0 and turn_no == self.last_turn + 1:\n            self.last_turn = turn_no\n            await self._start_turn(line)\n            return\n        await self._add_to_current(line)\n        self._update_code_fence(line)\n\n    async def _flush_pending_line(self):\n        if not self.pending_line:\n            return\n        line = self.pending_line\n        self.pending_line = \"\"\n        await self._add_to_current(line)\n\n    def _update_code_fence(self, line):\n        match = _CODE_FENCE_RE.match(line or \"\")\n        if not match:\n            return\n        fence_len = len(match.group(1))\n        if self.code_fence_len:\n            if fence_len >= self.code_fence_len:\n                self.code_fence_len = 0\n            return\n        self.code_fence_len = fence_len\n\nasync def _stream(dq, msg):\n    stream = _TelegramTurnStreamCoordinator(msg)\n    await stream.prime()\n    try:\n        while True:\n            try: first = await asyncio.to_thread(dq.get, True, _QUEUE_WAIT_SECONDS)\n            except Q.Empty: continue\n            items = [first]\n            try:\n                while True: items.append(dq.get_nowait())\n            except Q.Empty: pass\n            done_item = None\n            for item in items:\n                chunk = item.get(\"next\", \"\")\n                if chunk:\n                    await stream.add_chunk(chunk)\n                if \"done\" in item:\n                    done_item = item\n                    break\n            if done_item is not None:\n                await stream.finalize(done_item.get(\"done\", \"\"))\n                event = _drain_latest_ask_user_event()\n                if event:\n                    await _send_ask_user_menu(msg, event)\n                break\n    except asyncio.CancelledError:\n        await stream.finish_with_notice(\"⏹️ 已停止\")\n    except RetryAfter as exc:\n        print(f\"[TG stream retry_after] {type(exc).__name__}: {exc}\", flush=True)\n        if stream.session is not None:\n            stream.session._set_retry_after(exc)\n    except Exception as exc:\n        print(f\"[TG stream error] {type(exc).__name__}: {exc}\", flush=True)\n        if stream.session is not None and stream.session._is_retrying():\n            return\n        try:\n            await stream.finish_with_notice(f\"❌ 输出失败: {exc}\")\n        except RetryAfter as retry_exc:\n            print(f\"[TG stream error notice retry_after] {type(retry_exc).__name__}: {retry_exc}\", flush=True)\n\ndef _normalized_command(text):\n    parts = (text or \"\").strip().split(None, 1)\n    if not parts: return ''\n    head = parts[0].lower()\n    if head.startswith('/'): head = '/' + head[1:].split('@', 1)[0]\n    return head + (f\" {parts[1].strip()}\" if len(parts) > 1 and parts[1].strip() else '')\n\ndef _cancel_stream_task(ctx):\n    task = ctx.user_data.pop('stream_task', None)\n    if task and not task.done(): task.cancel()\n\nasync def _sync_commands(application):\n    await application.bot.set_my_commands([BotCommand(command, description) for command, description in TELEGRAM_MENU_COMMANDS])\n\nasync def handle_msg(update, ctx):\n    uid = update.effective_user.id\n    if ALLOWED and uid not in ALLOWED:\n        return await update.message.reply_text(\"no\")\n    prompt = _build_text_prompt(update.message.text)\n    dq = agent.put_task(prompt, source=\"telegram\")\n    task = asyncio.create_task(_stream(dq, update.message))\n    ctx.user_data['stream_task'] = task\n\nasync def handle_ask_callback(update, ctx):\n    query = update.callback_query\n    if query is None:\n        return\n    uid = update.effective_user.id if update.effective_user else None\n    if ALLOWED and uid not in ALLOWED:\n        return await query.answer(\"no\", show_alert=True)\n    menu_id, action = _parse_ask_callback_data(query.data)\n    if not menu_id:\n        return await query.answer(\"菜单无效\")\n    event = _normalize_ask_menu_event(_ask_menu_store.get(menu_id))\n    if event is None:\n        await query.answer(\"菜单已过期\")\n        return await _clear_ask_reply_markup(query)\n    candidates = event[\"candidates\"]\n    if action == _ASK_CANCEL_ACTION:\n        _ask_menu_store.pop(menu_id, None)\n        await query.answer()\n        await _edit_ask_user_result(query, event, cancelled=True)\n        if query.message is not None:\n            await query.message.reply_text(_ASK_CANCEL_PROMPT)\n        return\n    try:\n        selected = candidates[int(action)]\n    except (ValueError, IndexError):\n        return await query.answer(\"菜单无效\")\n    _ask_menu_store.pop(menu_id, None)\n    await query.answer()\n    await _edit_ask_user_result(query, event, selected=selected)\n    if query.message is None:\n        return\n    dq = agent.put_task(_build_text_prompt(selected), source=\"telegram\")\n    task = asyncio.create_task(_stream(dq, query.message))\n    ctx.user_data['stream_task'] = task\n\nasync def cmd_abort(update, ctx):\n    _cancel_stream_task(ctx)\n    agent.abort()\n    await update.message.reply_text(\"⏹️ 正在停止...\")\n\nasync def cmd_llm(update, ctx):\n    args = (update.message.text or '').split()\n    if len(args) > 1:\n        try:\n            n = int(args[1])\n            agent.next_llm(n)\n            await update.message.reply_text(f\"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}\")\n        except (ValueError, IndexError):\n            await update.message.reply_text(f\"用法: /llm <0-{len(agent.list_llms())-1}>\")\n    else:\n        lines = [f\"{'→' if cur else '  '} [{i}] {name}\" for i, name, cur in agent.list_llms()]\n        await update.message.reply_text(\"LLMs:\\n\" + \"\\n\".join(lines))\n\nasync def handle_photo(update, ctx):\n    uid = update.effective_user.id\n    if ALLOWED and uid not in ALLOWED: return await update.message.reply_text(\"no\")\n    if update.message.photo:\n        photo = update.message.photo[-1]\n        file = await photo.get_file()\n        fpath = f\"tg_{photo.file_unique_id}.jpg\"\n        kind = \"图片\"\n    elif update.message.document:\n        doc = update.message.document\n        file = await doc.get_file()\n        ext = os.path.splitext(doc.file_name or '')[1] or ''\n        fpath = f\"tg_{doc.file_unique_id}{ext}\"\n        kind = \"文件\"\n    else: return\n    await file.download_to_drive(os.path.join(_TEMP_DIR, fpath))\n    caption = update.message.caption\n    prompt = f\"[TIPS] 收到{kind}temp/{fpath}\\n{caption}\" if caption else f\"[TIPS] 收到{kind}temp/{fpath}，请等待下一步指令\"\n    dq = agent.put_task(prompt, source=\"telegram\")\n    task = asyncio.create_task(_stream(dq, update.message))\n    ctx.user_data['stream_task'] = task\n\nasync def handle_command(update, ctx):\n    uid = update.effective_user.id\n    if ALLOWED and uid not in ALLOWED:\n        return await update.message.reply_text(\"no\")\n    cmd = _normalized_command(update.message.text)\n    op = cmd.split()[0] if cmd else ''\n    if op == '/help': return await update.message.reply_text(HELP_TEXT)\n    if op == '/status':\n        llm = agent.get_llm_name() if agent.llmclient else '未配置'\n        return await update.message.reply_text(f\"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\\nLLM: [{agent.llm_no}] {llm}\")\n    if op == '/stop': return await cmd_abort(update, ctx)\n    if op == '/llm': return await cmd_llm(update, ctx)\n    if op == '/new':\n        _cancel_stream_task(ctx)\n        return await update.message.reply_text(reset_conversation(agent))\n    if op == '/restore':\n        _cancel_stream_task(ctx)\n        try:\n            restored_info, err = format_restore()\n            if err:\n                return await update.message.reply_text(err)\n            restored, fname, count = restored_info\n            agent.abort()\n            agent.history.extend(restored)\n            return await update.message.reply_text(f\"✅ 已恢复 {count} 轮对话\\n来源: {fname}\\n(仅恢复上下文，请输入新问题继续)\")\n        except Exception as e:\n            return await update.message.reply_text(f\"❌ 恢复失败: {e}\")\n    if op == '/continue':\n        if cmd != '/continue': _cancel_stream_task(ctx)\n        return await update.message.reply_text(handle_frontend_command(agent, cmd))\n    return await update.message.reply_text(HELP_TEXT)\n\nif __name__ == '__main__':\n    _LOCK_SOCK = ensure_single_instance(19527, \"Telegram\")\n    if not ALLOWED: \n        print('[Telegram] ERROR: tg_allowed_users in mykey.py is empty or missing. Set it to avoid unauthorized access.')\n        sys.exit(1)\n    require_runtime(agent, \"Telegram\", tg_bot_token=mykeys.get(\"tg_bot_token\"))\n    redirect_log(__file__, \"tgapp.log\", \"Telegram\", ALLOWED)\n    _register_ask_user_hook()\n    threading.Thread(target=agent.run, daemon=True).start()\n    proxy = mykeys.get('proxy')\n    if proxy:\n        print('proxy:', proxy)\n    else:\n        print('proxy: <disabled>')\n\n    async def _error_handler(update, context: ContextTypes.DEFAULT_TYPE):\n        print(f\"[{time.strftime('%m-%d %H:%M')}] TG error: {context.error}\", flush=True)\n\n    while True:\n        try:\n            print(f\"TG bot starting... {time.strftime('%m-%d %H:%M')}\")\n            # Recreate request and app objects on each restart to avoid stale connections\n            request_kwargs = dict(read_timeout=30, write_timeout=30, connect_timeout=30, pool_timeout=30)\n            if proxy:\n                request_kwargs['proxy'] = proxy\n            request = HTTPXRequest(**request_kwargs)\n            app = (ApplicationBuilder().token(mykeys['tg_bot_token'])\n                   .request(request).get_updates_request(request).post_init(_sync_commands).build())\n            app.add_handler(CallbackQueryHandler(handle_ask_callback, pattern=r\"^ask:\"))\n            app.add_handler(MessageHandler(filters.COMMAND, handle_command))\n            app.add_handler(MessageHandler(filters.PHOTO, handle_photo))\n            app.add_handler(MessageHandler(filters.Document.ALL, handle_photo))\n            app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_msg))\n            app.add_error_handler(_error_handler)\n            app.run_polling(drop_pending_updates=True, poll_interval=1.0, timeout=30)\n        except Exception as e:\n            print(f\"[{time.strftime('%m-%d %H:%M')}] polling crashed: {e}\", flush=True)\n            time.sleep(10)\n            asyncio.set_event_loop(asyncio.new_event_loop())\n"
  },
  {
    "path": "frontends/tuiapp.py",
    "content": "\"\"\"Textual terminal UI for GenericAgent.\n\nRun from the project root:\n\n    python frontends/tuiapp.py\n\nUseful options:\n\n    python frontends/tuiapp.py --help\n\nMVP design notes:\n- One TUI manages multiple GenericAgent instances.\n- GenericAgent.put_task() returns a per-task display_queue; the TUI records a task_id for every submit.\n- Agent.run() and display_queue.get() run in daemon threads; UI updates are posted via App.call_from_thread().\n- Multiple sessions may run concurrently, but GenericAgent still shares project temp/memory/tool globals.\n\"\"\"\nfrom __future__ import annotations\n\nimport argparse\nimport os\nimport queue\nimport re\nimport sys\nimport threading\nimport time\nfrom dataclasses import dataclass, field\nfrom itertools import count\nfrom typing import Any, Callable, Optional\n\ntry:\n    from rich.markdown import Markdown\n    from rich.panel import Panel\n    from rich.text import Text\n    from textual.app import App, ComposeResult\n    from textual.binding import Binding\n    from textual.containers import Horizontal, Vertical\n    from textual.widgets import Footer, Header, Input, RichLog, Static\nexcept ModuleNotFoundError as exc:  # pragma: no cover - exercised by manual missing-dep path\n    if exc.name == \"textual\":\n        print(\"Textual is required. Install with: pip install textual\", file=sys.stderr)\n    else:\n        print(f\"Missing dependency: {exc.name}\", file=sys.stderr)\n    raise SystemExit(2) from exc\n\nROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), \"..\"))\nif ROOT_DIR not in sys.path:\n    sys.path.insert(0, ROOT_DIR)\n\nAgentFactory = Callable[[], Any]\n\n\n@dataclass\nclass ChatMessage:\n    role: str\n    content: str\n    task_id: Optional[int] = None\n    done: bool = True\n    _rendered_panel: Any = field(default=None, repr=False)\n\n\n@dataclass\nclass AgentSession:\n    agent_id: int\n    name: str\n    agent: Any\n    thread: Optional[threading.Thread] = None\n    status: str = \"idle\"\n    messages: list[ChatMessage] = field(default_factory=list)\n    task_seq: int = 0\n    current_task_id: Optional[int] = None\n    current_display_queue: Optional[queue.Queue] = None\n    buffer: str = \"\"\n\n\ndef fold_turns(text: str) -> list[dict[str, str]]:\n    \"\"\"Split GenericAgent turn output into text/fold segments.\n\n    Completed turns become ``{'type': 'fold', 'title': ..., 'content': ...}``.\n    The latest/incomplete turn remains ``type='text'`` for streaming refresh.\n    \"\"\"\n    placeholders: list[str] = []\n\n    def stash(match: re.Match[str]) -> str:\n        placeholders.append(match.group(0))\n        return f\"\\x00PH{len(placeholders) - 1}\\x00\"\n\n    safe = re.sub(r\"`{4,}.*?`{4,}\", stash, text, flags=re.DOTALL)\n    safe = re.sub(r\"`{4,}[^`].*$\", stash, safe, flags=re.DOTALL)\n    parts = re.split(r\"(\\**LLM Running \\(Turn \\d+\\) \\.\\.\\.\\**)\", safe)\n\n    def restore(part: str) -> str:\n        return re.sub(r\"\\x00PH(\\d+)\\x00\", lambda m: placeholders[int(m.group(1))], part)\n\n    parts = [restore(p) for p in parts]\n    if len(parts) < 4:\n        return [{\"type\": \"text\", \"content\": text}]\n\n    segments: list[dict[str, str]] = []\n    if parts[0].strip():\n        segments.append({\"type\": \"text\", \"content\": parts[0]})\n\n    turns: list[tuple[str, str]] = []\n    for i in range(1, len(parts), 2):\n        marker = parts[i]\n        content = parts[i + 1] if i + 1 < len(parts) else \"\"\n        turns.append((marker, content))\n\n    for idx, (marker, content) in enumerate(turns):\n        if idx < len(turns) - 1:\n            cleaned = re.sub(r\"`{3,}.*?`{3,}|<thinking>.*?</thinking>\", \"\", content, flags=re.DOTALL)\n            matches = re.findall(r\"<summary>\\s*((?:(?!<summary>).)*?)\\s*</summary>\", cleaned, re.DOTALL)\n            if matches:\n                title = matches[0].strip().split(\"\\n\", 1)[0]\n            else:\n                title = cleaned.strip().split(\"\\n\", 1)[0] or marker.strip(\"*\")\n                # Strip trailing args portion from tool-call lines\n                title = re.sub(r\",?\\s*args:.*$\", \"\", title)\n            if len(title) > 72:\n                title = title[:72] + \"...\"\n            segments.append({\"type\": \"fold\", \"title\": title, \"content\": content})\n        else:\n            segments.append({\"type\": \"text\", \"content\": marker + content})\n    return segments\n\n\ndef render_folded_text(text: str) -> str:\n    \"\"\"Render fold segments as terminal-friendly Markdown text.\n\n    Textual's interactive Collapsible widgets are best for static layouts; the MVP uses\n    a RichLog and re-renders compact summaries for completed turns to keep streaming cheap.\n    \"\"\"\n    rendered: list[str] = []\n    for seg in fold_turns(text):\n        if seg[\"type\"] == \"fold\":\n            rendered.append(f\"\\n▸ {seg.get('title') or 'completed turn'}\\n\\n\")\n        else:\n            rendered.append(seg.get(\"content\", \"\"))\n    return \"\".join(rendered)\n\n\ndef parse_local_command(raw: str) -> tuple[str, list[str]] | None:\n    \"\"\"Return (command, args) for TUI-owned slash commands; unknown slash is passthrough.\"\"\"\n    text = (raw or \"\").strip()\n    if not text.startswith(\"/\"):\n        return None\n    name, *rest = text.split(maxsplit=1)\n    cmd = name[1:].lower()\n    args = rest[0].split() if rest else []\n    if cmd in {\"help\", \"status\", \"new\", \"switch\", \"sessions\", \"stop\", \"llm\", \"branch\", \"rewind\", \"clear\", \"close\", \"quit\", \"exit\"}:\n        return cmd, args\n    return None\n\n\ndef default_agent_factory() -> Any:\n    from agentmain import GenericAgent\n\n    agent = GenericAgent()\n    agent.inc_out = True\n    return agent\n\n\nclass GenericAgentTUI(App[None]):\n    \"\"\"Textual app that manages multiple GenericAgent sessions.\"\"\"\n\n    CSS = \"\"\"\n    Screen { layout: vertical; }\n    #body { height: 1fr; }\n    #sidebar { width: 30; min-width: 24; border: solid $accent; padding: 0 1; overflow-x: hidden; }\n    #main { width: 1fr; }\n    #status { height: 3; border: solid $primary; padding: 0 1; }\n    #log { height: 1fr; border: solid $primary; padding: 0 1; }\n    #prompt { dock: bottom; }\n    .hint { color: $text-muted; }\n    \"\"\"\n\n    BINDINGS = [\n        (\"ctrl+n\", \"new_session\", \"New session\"),\n        (\"ctrl+s\", \"stop_current\", \"Stop\"),\n        (\"ctrl+f\", \"toggle_fold\", \"Fold/Unfold\"),\n        (\"ctrl+q\", \"quit\", \"Quit\"),\n        Binding(\"ctrl+left\", \"prev_session\", \"←Prev\", show=True, priority=True),\n        Binding(\"ctrl+right\", \"next_session\", \"Next→\", show=True, priority=True),\n    ]\n\n    def __init__(self, agent_factory: Optional[AgentFactory] = None) -> None:\n        super().__init__()\n        self.agent_factory: AgentFactory = agent_factory or default_agent_factory\n        self.sessions: dict[int, AgentSession] = {}\n        self.current_id: Optional[int] = None\n        self._ids = count(1)\n        self.fold_mode: bool = True\n        self._last_stream_refresh: float = 0.0\n        self._stream_throttle_ms: float = 0.15  # seconds between streaming UI refreshes\n\n    def compose(self) -> ComposeResult:\n        yield Header(show_clock=True)\n        with Horizontal(id=\"body\"):\n            yield Static(\"\", id=\"sidebar\")\n            with Vertical(id=\"main\"):\n                yield Static(\"\", id=\"status\")\n                yield RichLog(id=\"log\", wrap=True, highlight=True, markup=True)\n        yield Input(placeholder=\"Message, or /help  /new  /branch  /rewind  /switch  /clear  /close  /stop  /llm  /resume\", id=\"prompt\")\n        yield Footer()\n\n    def on_mount(self) -> None:\n        self.add_session(\"main\")\n        self._system(\"Welcome to GenericAgent TUI. Type /help for commands.\")\n        self.query_one(\"#prompt\", Input).focus()\n\n    def on_resize(self, event) -> None:\n        narrow = self.size.width < 70\n        self.query_one(\"#sidebar\").styles.display = \"none\" if narrow else \"block\"\n\n    @property\n    def current(self) -> AgentSession:\n        if self.current_id is None:\n            raise RuntimeError(\"no active session\")\n        return self.sessions[self.current_id]\n\n    def add_session(self, name: Optional[str] = None) -> AgentSession:\n        agent_id = next(self._ids)\n        agent = self.agent_factory()\n        try:\n            agent.inc_out = True\n        except Exception:\n            pass\n        session = AgentSession(agent_id=agent_id, name=name or f\"agent-{agent_id}\", agent=agent)\n        thread = threading.Thread(target=agent.run, name=f\"ga-tui-agent-{agent_id}\", daemon=True)\n        thread.start()\n        session.thread = thread\n        self.sessions[agent_id] = session\n        self.current_id = agent_id\n        self._refresh_all()\n        return session\n\n    def action_prev_session(self) -> None:\n        \"\"\"Switch to previous session.\"\"\"\n        ids = sorted(self.sessions.keys())\n        if len(ids) <= 1:\n            return\n        idx = ids.index(self.current_id)\n        self.current_id = ids[(idx - 1) % len(ids)]\n        self._refresh_all()\n\n    def action_next_session(self) -> None:\n        \"\"\"Switch to next session.\"\"\"\n        ids = sorted(self.sessions.keys())\n        if len(ids) <= 1:\n            return\n        idx = ids.index(self.current_id)\n        self.current_id = ids[(idx + 1) % len(ids)]\n        self._refresh_all()\n\n    def action_switch_session(self, n: int) -> None:\n        \"\"\"Switch to session by id (used by /switch command).\"\"\"\n        if n in self.sessions:\n            self.current_id = n\n            self._refresh_all()\n        else:\n            self.notify(f\"Session #{n} does not exist.\", severity=\"warning\")\n\n    def action_new_session(self) -> None:\n        self.add_session()\n        self._system(f\"Created and switched to session #{self.current_id}.\")\n\n    def action_stop_current(self) -> None:\n        self._cmd_stop([])\n\n    def on_input_submitted(self, event: Input.Submitted) -> None:\n        value = event.value.rstrip()\n        event.input.value = \"\"\n        if not value:\n            self._system(\"Empty input ignored. Type /help for commands.\")\n            return\n        parsed = parse_local_command(value)\n        if parsed:\n            cmd, args = parsed\n            self._dispatch_command(cmd, args)\n            return\n        self.submit_user_message(value)\n\n    def _dispatch_command(self, cmd: str, args: list[str]) -> None:\n        handlers = {\n            \"help\": self._cmd_help,\n            \"status\": self._cmd_status,\n            \"new\": self._cmd_new,\n            \"switch\": self._cmd_switch,\n            \"sessions\": self._cmd_sessions,\n            \"stop\": self._cmd_stop,\n            \"llm\": self._cmd_llm,\n            \"branch\": self._cmd_branch,\n            \"rewind\": self._cmd_rewind,\n            \"clear\": self._cmd_clear,\n            \"close\": self._cmd_close,\n            \"quit\": lambda _args: self.exit(),\n            \"exit\": lambda _args: self.exit(),\n        }\n        handlers[cmd](args)\n\n    def submit_user_message(self, text: str) -> int:\n        session = self.current\n        if session.status == \"running\":\n            self._system(f\"Session #{session.agent_id} is already running; wait or /stop before submitting another task.\")\n            return -1\n        session.task_seq += 1\n        task_id = session.task_seq\n        session.current_task_id = task_id\n        session.buffer = \"\"\n        session.status = \"running\"\n        session.messages.append(ChatMessage(\"user\", text))\n        session.messages.append(ChatMessage(\"assistant\", \"\", task_id=task_id, done=False))\n        self._refresh_all()\n        try:\n            display_queue = session.agent.put_task(text, source=\"user\")\n        except Exception as exc:\n            session.status = \"error\"\n            self._set_assistant_message(session.agent_id, task_id, f\"[ERROR] put_task failed: {exc}\", done=True)\n            return task_id\n        session.current_display_queue = display_queue\n        threading.Thread(\n            target=self._consume_display_queue,\n            args=(session.agent_id, task_id, display_queue),\n            name=f\"ga-tui-consumer-{session.agent_id}-{task_id}\",\n            daemon=True,\n        ).start()\n        return task_id\n\n    def _consume_display_queue(self, agent_id: int, task_id: int, display_queue: queue.Queue) -> None:\n        buffer = \"\"\n        while True:\n            try:\n                item = display_queue.get(timeout=0.25)\n            except queue.Empty:\n                continue\n            if \"next\" in item:\n                buffer += str(item.get(\"next\") or \"\")\n                self.call_from_thread(self._on_stream_update, agent_id, task_id, buffer, False)\n            if \"done\" in item:\n                done_text = str(item.get(\"done\") or buffer)\n                self.call_from_thread(self._on_stream_update, agent_id, task_id, done_text, True)\n                return\n\n    def _on_stream_update(self, agent_id: int, task_id: int, text: str, done: bool) -> None:\n        session = self.sessions.get(agent_id)\n        if not session:\n            return\n        if session.current_task_id != task_id:\n            session.messages.append(ChatMessage(\"system\", f\"Stale update ignored for task {task_id}.\", done=True))\n            return\n        session.buffer = text\n        if done:\n            session.status = \"idle\"\n            session.current_display_queue = None\n        self._set_assistant_message(agent_id, task_id, text, done=done)\n\n    def _set_assistant_message(self, agent_id: int, task_id: int, text: str, *, done: bool) -> None:\n        session = self.sessions.get(agent_id)\n        if not session:\n            return\n        for msg in reversed(session.messages):\n            if msg.role == \"assistant\" and msg.task_id == task_id:\n                msg.content = text\n                msg.done = done\n                break\n        else:\n            session.messages.append(ChatMessage(\"assistant\", text, task_id=task_id, done=done))\n        if agent_id == self.current_id:\n            self._refresh_all()\n        else:\n            self._refresh_sidebar()\n\n    def _cmd_help(self, args: list[str]) -> None:\n        self._system(\n            \"Commands:\\n\"\n            \"/help - show this help\\n\"\n            \"/new [name] - create and switch to a new agent session\\n\"\n            \"/branch [name] - fork current session (copies LLM history + display)\\n\"\n            \"/rewind - list rewindable turns; /rewind <n> to truncate history\\n\"\n            \"/switch <id|name> - switch active session\\n\"\n            \"/sessions - list sessions\\n\"\n            \"/status - show current/all status\\n\"\n            \"/stop - abort current session task\\n\"\n            \"/clear - clear chat display (keeps LLM history)\\n\"\n            \"/close - close current session (cannot close last)\\n\"\n            \"/llm - list models for current session\\n\"\n            \"/llm <n> - switch model for current session\\n\"\n            \"/quit - exit TUI\\n\\n\"\n            \"Unknown slash commands (for example /session.x=... or /resume) are sent to GenericAgent.\"\n        )\n\n    def _cmd_new(self, args: list[str]) -> None:\n        name = \" \".join(args).strip() or None\n        session = self.add_session(name)\n        self._system(f\"Created session #{session.agent_id} {session.name!r}. Shared temp/memory are not isolated.\")\n\n    def _cmd_branch(self, args: list[str]) -> None:\n        import copy\n        old_session = self.current\n        name = \" \".join(args).strip() or f\"{old_session.name}-branch\"\n        new_session = self.add_session(name)\n        # Copy LLM backend history\n        try:\n            new_session.agent.llmclient.backend.history = copy.deepcopy(\n                old_session.agent.llmclient.backend.history\n            )\n        except Exception as e:\n            self._system(f\"Branch warning: failed to copy history: {e}\")\n            return\n        # Copy TUI display messages\n        new_session.messages = copy.deepcopy(old_session.messages)\n        new_session.task_seq = old_session.task_seq\n        n = len(new_session.agent.llmclient.backend.history)\n        self._system(f\"Branched from #{old_session.agent_id} → #{new_session.agent_id} ({n} messages inherited).\")\n\n    def _cmd_rewind(self, args: list[str]) -> None:\n        session = self.current\n        if session.status == \"running\":\n            self._system(\"Cannot rewind while running. /stop first.\")\n            return\n        history = session.agent.llmclient.backend.history\n        # Find real user turn boundaries — skip tool_result messages\n        turns = []  # list of (index_in_history, preview_text)\n        for i, msg in enumerate(history):\n            if msg.get(\"role\") != \"user\":\n                continue\n            content = msg.get(\"content\")\n            # Pure string content is always a real user message\n            if isinstance(content, str):\n                turns.append((i, content[:60]))\n                continue\n            if isinstance(content, list):\n                # Skip if content is purely tool_result blocks\n                has_tool_result = any(b.get(\"type\") == \"tool_result\" for b in content if isinstance(b, dict))\n                if has_tool_result:\n                    continue\n                texts = [b.get(\"text\", \"\") for b in content if isinstance(b, dict) and b.get(\"type\") == \"text\"]\n                if texts and any(t.strip() for t in texts):\n                    turns.append((i, (texts[0] or \"\")[:60]))\n        if not turns:\n            self._system(\"No rewindable turns in history.\")\n            return\n        # Reverse numbering: 1 = most recent turn, 2 = second most recent, etc.\n        # /rewind without args: show list\n        if not args:\n            lines = [f\"Rewindable turns ({len(turns)} total, showing last 10):\"]\n            show = turns[-10:]\n            for offset, (_, preview) in enumerate(reversed(show), 1):\n                lines.append(f\"  {offset}) {preview!r}\")\n            lines.append(\"/rewind <n> to rewind n turns (1 = undo last turn).\")\n            self._system(\"\\n\".join(lines))\n            return\n        # /rewind <n>: truncate last n turns\n        try:\n            n = int(args[0])\n        except ValueError:\n            self._system(\"Usage: /rewind <n> (1 = undo last turn)\")\n            return\n        if n < 1 or n > len(turns):\n            self._system(f\"Invalid: range is 1-{len(turns)}\")\n            return\n        # cut_at = index of the n-th turn from the end\n        cut_at = turns[-n][0]\n        removed = len(history) - cut_at\n        history[:] = history[:cut_at]\n        # Sync TUI messages: keep only messages before the corresponding user message\n        real_user_indices = [i for i, msg in enumerate(session.messages) if msg.role == \"user\"]\n        if n <= len(real_user_indices):\n            cut_msg = real_user_indices[-n]\n            session.messages = session.messages[:cut_msg]\n        # Mark rewind in agentmain's working memory history\n        try: session.agent.history.append(f\"[USER]: /rewind {n}\")\n        except Exception: pass\n        self._system(f\"Rewound {n} turn(s). Removed {removed} history entries.\")\n\n    def _cmd_clear(self, args: list[str]) -> None:\n        self.current.messages.clear(); self._refresh_all()\n\n    def _cmd_close(self, args: list[str]) -> None:\n        if len(self.sessions) <= 1:\n            self._system(\"Cannot close the last session.\"); return\n        del self.sessions[self.current_id]\n        self.current_id = next(iter(self.sessions))\n        self._refresh_all()\n\n    def _cmd_switch(self, args: list[str]) -> None:\n        if not args:\n            self._system(\"Usage: /switch <id|name>\")\n            return\n        key = \" \".join(args)\n        target: Optional[int] = None\n        if key.isdigit() and int(key) in self.sessions:\n            target = int(key)\n        else:\n            for sid, session in self.sessions.items():\n                if session.name == key:\n                    target = sid\n                    break\n        if target is None:\n            self._system(f\"No session found for {key!r}.\")\n            return\n        self.current_id = target\n        self._refresh_all()\n        self._system(f\"Switched to session #{target}.\")\n\n    def _cmd_sessions(self, args: list[str]) -> None:\n        lines = []\n        for sid, session in self.sessions.items():\n            mark = \"*\" if sid == self.current_id else \" \"\n            lines.append(f\"{mark} #{sid} {session.name} [{session.status}] messages={len(session.messages)} task={session.current_task_id}\")\n        self._system(\"Sessions:\\n\" + \"\\n\".join(lines))\n\n    def _cmd_status(self, args: list[str]) -> None:\n        self._cmd_sessions(args)\n\n    def _cmd_stop(self, args: list[str]) -> None:\n        session = self.current\n        try:\n            session.agent.abort()\n            session.status = \"stopping\" if session.status == \"running\" else session.status\n            self._system(f\"Stop signal sent to session #{session.agent_id}.\")\n        except Exception as exc:\n            self._system(f\"Stop failed: {exc}\")\n        self._refresh_all()\n\n    def _cmd_llm(self, args: list[str]) -> None:\n        session = self.current\n        if args:\n            try:\n                session.agent.next_llm(int(args[0]))\n                self._system(f\"Switched model to #{int(args[0])}.\")\n            except Exception as exc:\n                self._system(f\"Model switch failed: {exc}\")\n                return\n        try:\n            rows = session.agent.list_llms()\n            self._system(\"Models:\\n\" + \"\\n\".join(f\"{'*' if cur else ' '} {i}: {name}\" for i, name, cur in rows))\n        except Exception as exc:\n            self._system(f\"Listing models failed: {exc}\")\n\n    def _system(self, text: str) -> None:\n        if self.current_id is not None and self.current_id in self.sessions:\n            self.current.messages.append(ChatMessage(\"system\", text))\n        self._refresh_all()\n\n    def _refresh_all(self) -> None:\n        if not self.is_mounted:\n            return\n        self._refresh_sidebar()\n        self._refresh_status()\n        self._refresh_log()\n\n    def _session_last_user_query(self, session: AgentSession) -> str:\n        \"\"\"Return the last user message content, truncated for sidebar display.\"\"\"\n        for msg in reversed(session.messages):\n            if msg.role == \"user\":\n                text = msg.content.strip().replace(\"\\n\", \" \")\n                return self._truncate_display(text, 20)\n        return \"\"\n\n    def _session_last_summary(self, session: AgentSession) -> str:\n        \"\"\"Extract the last <summary> from the most recent assistant message.\"\"\"\n        for msg in reversed(session.messages):\n            if msg.role == \"assistant\" and msg.content:\n                matches = re.findall(r\"<summary>\\s*(.*?)\\s*</summary>\", msg.content, re.DOTALL)\n                if matches:\n                    text = matches[-1].strip().split(\"\\n\", 1)[0].replace(\"\\n\", \" \")\n                    return self._truncate_display(text, 20)\n        return \"\"\n\n    @staticmethod\n    def _truncate_display(text: str, max_width: int) -> str:\n        \"\"\"Truncate text by display width (CJK chars count as 2).\"\"\"\n        import unicodedata\n        width = 0\n        result = []\n        for ch in text:\n            w = 2 if unicodedata.east_asian_width(ch) in ('W', 'F') else 1\n            if width + w > max_width:\n                result.append(\"…\")\n                break\n            result.append(ch)\n            width += w\n        return \"\".join(result)\n\n    def _refresh_sidebar(self) -> None:\n        sidebar = self.query_one(\"#sidebar\", Static)\n        max_w = 26  # 30 - 2(border) - 2(padding)\n        lines: list[str] = [\"[b]Sessions[/b]\", \"\"]\n        for sid, session in self.sessions.items():\n            mark = \"▶\" if sid == self.current_id else \" \"\n            last_q = self._session_last_user_query(session)\n            last_s = self._session_last_summary(session)\n            status_style = \"green\" if session.status == \"running\" else \"dim\"\n            # Header line: \"▶ #1 name status\" — truncate name if needed\n            prefix = f\"{mark} #{sid} \"\n            suffix = f\" {session.status}\"\n            name_max = max_w - len(prefix) - len(suffix)\n            name_disp = self._truncate_display(session.name, max(name_max, 4))\n            lines.append(f\"{prefix}{name_disp} [{status_style}]{session.status}[/{status_style}]\")\n            if last_q:\n                lines.append(f\"   [dim]Q:{last_q}[/dim]\")\n            if last_s:\n                lines.append(f\"   [dim]S:{last_s}[/dim]\")\n        lines.append(\"\")\n        lines.append(\"[dim]/new, /switch, Ctrl+N[/dim]\")\n        lines.append(\"[dim]I have memory, just say what you want[/dim]\")\n        sidebar.update(\"\\n\".join(lines))\n\n    def _refresh_status(self) -> None:\n        status = self.query_one(\"#status\", Static)\n        if self.current_id is None:\n            status.update(\"No session\")\n            return\n        session = self.current\n        try:\n            model = session.agent.get_llm_name(model=True)\n        except Exception:\n            model = \"unknown\"\n        status.update(\n            f\"[b]#{session.agent_id} {session.name}[/b]  status={session.status}  task={session.current_task_id}  model={model}\\n\"\n            \"Enter message or /help. Per-task queue streaming is enabled (inc_out=True).\"\n        )\n\n    def action_toggle_fold(self) -> None:\n        self.fold_mode = not self.fold_mode\n        # Invalidate cached panels for assistant messages since fold state changed\n        if self.current_id is not None:\n            for msg in self.current.messages:\n                if msg.role == \"assistant\":\n                    msg._rendered_panel = None\n        self._refresh_log()\n        mode_label = \"folded\" if self.fold_mode else \"expanded\"\n        self.notify(f\"Display mode: {mode_label} (Ctrl+F to toggle)\")\n\n    def _refresh_log(self) -> None:\n        log = self.query_one(\"#log\", RichLog)\n        log.clear()\n        if self.current_id is None:\n            return\n        # Collect recent task_ids to only expand the latest 3 tasks\n        recent_task_ids: set[int] = set()\n        if not self.fold_mode:\n            seen: list[int] = []\n            for msg in reversed(self.current.messages):\n                if msg.role == \"assistant\" and msg.task_id not in seen:\n                    seen.append(msg.task_id)\n                    if len(seen) == 5:\n                        break\n            recent_task_ids = set(seen)\n        for msg in self.current.messages:\n            if msg.role == \"user\":\n                if msg._rendered_panel is None:\n                    msg._rendered_panel = Panel(Markdown(msg.content), title=\"You\", border_style=\"blue\")\n                log.write(msg._rendered_panel)\n            elif msg.role == \"assistant\":\n                if msg.done and msg._rendered_panel is not None:\n                    log.write(msg._rendered_panel)\n                else:\n                    suffix = \"\" if msg.done else \"\\n▌\"\n                    # Fold older tasks even in unfold mode to reduce render cost\n                    should_fold = self.fold_mode or (msg.task_id not in recent_task_ids)\n                    content = render_folded_text(msg.content) if should_fold else msg.content\n                    panel = Panel(Markdown(content + suffix), title=f\"Agent task {msg.task_id}\", border_style=\"green\")\n                    if msg.done:\n                        msg._rendered_panel = panel\n                    log.write(panel)\n            else:\n                if msg._rendered_panel is None:\n                    msg._rendered_panel = Panel(Text(msg.content), title=\"System\", border_style=\"yellow\")\n                log.write(msg._rendered_panel)\n\n\ndef build_arg_parser() -> argparse.ArgumentParser:\n    parser = argparse.ArgumentParser(description=\"Textual TUI for GenericAgent\")\n    return parser\n\n\ndef main(argv: Optional[list[str]] = None) -> int:\n    args = build_arg_parser().parse_args(argv)\n    app = GenericAgentTUI()\n    app.run()\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": "frontends/wechatapp.py",
    "content": "import os, sys, re, threading, queue, time, socket, json, struct, base64, uuid, webbrowser, hashlib, math\nfrom pathlib import Path\nfrom urllib.parse import quote\nimport requests, qrcode\nfrom Crypto.Cipher import AES\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')\nfrom agentmain import GeneraticAgent\n\n# ── WxBotClient (inline from wx_bot_client.py) ──\nfor _k in ('HTTPS_PROXY', 'https_proxy'):\n    os.environ.pop(_k, None)  # avoid inherited proxy breaking WeChat long-poll SSL\nAPI = 'https://ilinkai.weixin.qq.com'\nTOKEN_FILE = Path.home() / '.wxbot' / 'token.json'\nTOKEN_FILE.parent.mkdir(exist_ok=True)\nVER, MSG_USER, MSG_BOT, ITEM_TEXT, STATE_FINISH = '2.1.10', 1, 2, 1, 2\nILINK_APP_ID = 'bot'\nILINK_APP_CLIENT_VERSION = (2 << 16) | (1 << 8) | 10\nUA = f'openclaw-weixin/{VER}'\nITEM_IMAGE, ITEM_FILE, ITEM_VIDEO = 2, 4, 5\nCDN_BASE = 'https://novac2c.cdn.weixin.qq.com/c2c'\n\ndef _uin():\n    return base64.b64encode(str(struct.unpack('>I', os.urandom(4))[0]).encode()).decode()\n\nclass WxBotClient:\n    def __init__(self, token=None, token_file=None):\n        self._tf = Path(token_file) if token_file else TOKEN_FILE\n        self.token = token\n        self.bot_id = None\n        self._buf = ''\n        if not self.token: self._load()\n\n    def _load(self):\n        if self._tf.exists():\n            d = json.loads(self._tf.read_text('utf-8'))\n            self.token, self.bot_id, self._buf = d.get('bot_token',''), d.get('ilink_bot_id',''), d.get('updates_buf','')\n\n    def _save(self, **kw):\n        d = {'bot_token': self.token or '', 'ilink_bot_id': self.bot_id or '',\n             'updates_buf': self._buf or '', **kw}\n        self._tf.write_text(json.dumps(d, ensure_ascii=False, indent=2), 'utf-8')\n\n    def _post(self, ep, body, timeout=15):\n        data = json.dumps(body, ensure_ascii=False, separators=(',', ':')).encode('utf-8')\n        h = {'Content-Type': 'application/json', 'AuthorizationType': 'ilink_bot_token',\n             'Content-Length': str(len(data)), 'X-WECHAT-UIN': _uin(),\n             'iLink-App-Id': ILINK_APP_ID,\n             'iLink-App-ClientVersion': str(ILINK_APP_CLIENT_VERSION),\n             'User-Agent': UA}\n        tok = (self.token or '').strip()\n        if tok: h['Authorization'] = f'Bearer {tok}'\n        r = requests.post(f'{API}/{ep}', data=data, headers=h, timeout=timeout)\n        r.raise_for_status()\n        return r.json()\n\n    def login_qr(self, poll_interval=2):\n        r = requests.get(f'{API}/ilink/bot/get_bot_qrcode', params={'bot_type': 3}, headers={'User-Agent': UA}, timeout=10)\n        r.raise_for_status()\n        d = r.json()\n        qr_id, url = d['qrcode'], d.get('qrcode_img_content', '')\n        print(f'[QR登录] ID: {qr_id}')\n        if url:\n            img = self._tf.parent / 'wx_qr.png'\n            qrcode.make(url).save(str(img)); webbrowser.open(str(img))\n            qr = qrcode.QRCode(border=1); qr.add_data(url); qr.make(fit=True); qr.print_ascii(invert=True)\n        last = ''\n        while True:\n            time.sleep(poll_interval)\n            try: s = requests.get(f'{API}/ilink/bot/get_qrcode_status', params={'qrcode': qr_id}, headers={'User-Agent': UA}, timeout=60).json()\n            except requests.exceptions.ReadTimeout: continue\n            st = s.get('status', '')\n            if st != last: print(f'  状态: {st}'); last = st\n            if st == 'confirmed':\n                self.token, self.bot_id = s.get('bot_token', ''), s.get('ilink_bot_id', '')\n                self._save(login_time=time.strftime('%Y-%m-%d %H:%M:%S'))\n                print(f'[QR登录] 成功! bot_id={self.bot_id}')\n                return s\n            if st == 'expired': raise RuntimeError('二维码过期')\n\n    def get_updates(self, timeout=30):\n        try:\n            resp = self._post('ilink/bot/getupdates',\n                              {'get_updates_buf': self._buf or '',\n                               'base_info': {'channel_version': VER}},\n                              timeout=timeout + 5)\n        except requests.exceptions.ReadTimeout:\n            return []\n        if resp.get('errcode'):\n            print(f'[getUpdates] err: {resp.get(\"errcode\")} {resp.get(\"errmsg\",\"\")}')\n            if resp['errcode'] == -14: self._buf = ''; self._save()\n            return []\n        nb = resp.get('get_updates_buf', '')\n        if nb: self._buf = nb; self._save()\n        return resp.get('msgs') or []\n\n    def send_text(self, to_user_id, text, context_token=''):\n        msg = {'from_user_id': '', 'to_user_id': to_user_id,\n               'client_id': f'pyclient-{uuid.uuid4().hex[:16]}',\n               'message_type': MSG_BOT, 'message_state': STATE_FINISH,\n               'item_list': [{'type': ITEM_TEXT, 'text_item': {'text': text}}]}\n        if context_token: msg['context_token'] = context_token\n        return self._post('ilink/bot/sendmessage', {'msg': msg, 'base_info': {'channel_version': VER}})\n\n    def send_typing(self, to_user_id, typing_ticket='', cancel=False):\n        return self._post('ilink/bot/sendtyping', {\n            'ilink_user_id': to_user_id, 'typing_ticket': typing_ticket,\n            'status': 2 if cancel else 1,\n            'base_info': {'channel_version': VER}})\n\n    def get_typing_ticket(self, to_user_id, context_token=''):\n        payload = {'ilink_user_id': to_user_id}\n        if context_token: payload['context_token'] = context_token\n        return self._post('ilink/bot/getconfig', payload).get('typing_ticket', '')\n\n    def _enc(self, raw, aes_key):\n        pad = 16 - (len(raw) % 16)\n        return AES.new(aes_key, AES.MODE_ECB).encrypt(raw + bytes([pad] * pad))\n\n    def _upload(self, filekey, upload_param, raw, aes_key, timeout=120, upload_url=''):\n        url = upload_url.strip() if upload_url else f'{CDN_BASE}/upload?encrypted_query_param={quote(upload_param)}&filekey={filekey}'\n        data = self._enc(raw, aes_key)\n        last_err = None\n        for attempt in range(1, 4):\n            try:\n                r = requests.post(url, data=data, headers={'Content-Type': 'application/octet-stream', 'User-Agent': UA}, timeout=timeout)\n                if 400 <= r.status_code < 500:\n                    msg = r.headers.get('x-error-message') or r.text[:300]\n                    raise RuntimeError(f'CDN upload client error {r.status_code}: {msg}')\n                if r.status_code != 200:\n                    msg = r.headers.get('x-error-message') or f'status {r.status_code}'\n                    raise RuntimeError(f'CDN upload server error: {msg}')\n                eq = r.headers.get('x-encrypted-param', '')\n                if not eq: raise RuntimeError('CDN upload response missing x-encrypted-param header')\n                return {'encrypt_query_param': eq,\n                        'aes_key': base64.b64encode(aes_key.hex().encode()).decode(), 'encrypt_type': 1}\n            except Exception as e:\n                last_err = e\n                if 'client error' in str(e) or attempt >= 3: break\n                print(f'[WX] CDN upload retry {attempt}: {e}', file=sys.__stdout__)\n        raise last_err\n\n    def _send_media(self, to_user_id, file_path, media_type, item_type, item_key, context_token=''):\n        fp = Path(file_path)\n        raw = fp.read_bytes()\n        filekey = uuid.uuid4().hex\n        aes_key = os.urandom(16)\n        ciphertext_size = ((len(raw) // 16) + 1) * 16\n        thumb_raw = b''; thumb_w = thumb_h = 0; thumb_ciphertext_size = 0\n        if item_key == 'image_item':\n            from io import BytesIO\n            from PIL import Image\n            im = Image.open(fp); im.thumbnail((240, 240))\n            thumb_w, thumb_h = im.size\n            if im.mode not in ('RGB', 'L'):\n                im = im.convert('RGB')\n            bio = BytesIO(); im.save(bio, format='JPEG', quality=85)\n            thumb_raw = bio.getvalue()\n            thumb_ciphertext_size = ((len(thumb_raw) // 16) + 1) * 16\n        body = {\n            'filekey': filekey, 'media_type': media_type, 'to_user_id': to_user_id,\n            'rawsize': len(raw), 'rawfilemd5': hashlib.md5(raw).hexdigest(),\n            'filesize': ciphertext_size,\n            'no_need_thumb': item_key not in ('image_item', 'video_item'),\n            'aeskey': aes_key.hex(), 'base_info': {'channel_version': VER}}\n        if thumb_raw:\n            body.update({'thumb_rawsize': len(thumb_raw),\n                         'thumb_rawfilemd5': hashlib.md5(thumb_raw).hexdigest(),\n                         'thumb_filesize': thumb_ciphertext_size})\n        resp = self._post('ilink/bot/getuploadurl', body)\n        upload_param = resp.get('upload_param', '')\n        upload_url = resp.get('upload_full_url', '')\n        if not (upload_param or upload_url): raise RuntimeError(f'getuploadurl failed: {resp}')\n        media = self._upload(filekey, upload_param, raw, aes_key=aes_key, upload_url=upload_url)\n        item = {'media': media}\n        if item_key == 'file_item':\n            item.update({'file_name': fp.name, 'len': str(len(raw))})\n        elif item_key == 'image_item':\n            thumb_param = resp.get('thumb_upload_param', '')\n            thumb_url = resp.get('thumb_upload_full_url', '')\n            if thumb_param or thumb_url:\n                thumb_media = self._upload(filekey, thumb_param, thumb_raw, aes_key=aes_key, upload_url=thumb_url)\n                thumb_size = thumb_ciphertext_size\n            else:\n                # Some getuploadurl responses only return a single upload_full_url for IMAGE.\n                # Keep ImageItem structurally complete by reusing the original CDN media as thumb_media.\n                thumb_media = media\n                thumb_size = ciphertext_size\n            item.update({'mid_size': ciphertext_size, 'thumb_media': thumb_media,\n                         'thumb_size': thumb_size,\n                         'thumb_width': thumb_w, 'thumb_height': thumb_h})\n        elif item_key == 'video_item':\n            item.update({'video_size': ciphertext_size})\n        msg = {'from_user_id': '', 'to_user_id': to_user_id,\n               'client_id': f'pyclient-{uuid.uuid4().hex[:16]}',\n               'message_type': MSG_BOT, 'message_state': STATE_FINISH,\n               'item_list': [{'type': item_type, item_key: item}]}\n        if context_token: msg['context_token'] = context_token\n        return self._post('ilink/bot/sendmessage', {'msg': msg, 'base_info': {'channel_version': VER}})\n\n    def send_file(self, to_user_id, file_path, context_token=''):\n        return self._send_media(to_user_id, file_path, 3, ITEM_FILE, 'file_item', context_token)\n\n    def send_image(self, to_user_id, file_path, context_token=''):\n        return self._send_media(to_user_id, file_path, 1, ITEM_IMAGE, 'image_item', context_token)\n\n    def send_video(self, to_user_id, file_path, context_token=''):\n        return self._send_media(to_user_id, file_path, 2, ITEM_VIDEO, 'video_item', context_token)\n\n    @staticmethod\n    def extract_text(msg):\n        return '\\n'.join(it['text_item'].get('text', '')\n                         for it in msg.get('item_list', [])\n                         if it.get('type') == ITEM_TEXT and it.get('text_item'))\n\n    @staticmethod\n    def is_user_msg(msg): return msg.get('message_type') == MSG_USER\n\n    def run_loop(self, on_message, poll_timeout=30):\n        print(f'[Bot] 监听中... (bot_id={self.bot_id})')\n        seen = set()\n        while True:\n            try:\n                for msg in self.get_updates(poll_timeout):\n                    mid = msg.get('message_id', 0)\n                    if not self.is_user_msg(msg) or mid in seen: continue\n                    seen.add(mid)\n                    if len(seen) > 5000: seen = set(list(seen)[-2000:])\n                    try: on_message(self, msg)\n                    except Exception as e: print(f'[Bot] 回调异常: {e}')\n            except KeyboardInterrupt: print('[Bot] 退出'); break\n            except Exception as e: print(f'[Bot] 异常: {e}，5s重试'); time.sleep(5)\n\n# ── Unified media download (IMAGE/VIDEO/FILE/VOICE) ──\n_MEDIA_KEYS = {'image_item': '.jpg', 'video_item': '.mp4', 'file_item': '', 'voice_item': '.silk'}\n\ndef _dl_media(items):\n    \"\"\"Download & decrypt all media items → list of local file paths.\"\"\"\n    paths = []\n    for item in items:\n        for key, ext in _MEDIA_KEYS.items():\n            sub = item.get(key)\n            if not sub: continue\n            eq = (sub.get('media') or {}).get('encrypt_query_param')\n            if not eq: continue\n            ak = (sub.get('media') or {}).get('aes_key', '') or sub.get('aeskey', '')\n            if not ak: continue\n            try:\n                aes_key = (bytes.fromhex(base64.b64decode(ak).decode())\n                           if sub.get('media', {}).get('aes_key') else bytes.fromhex(ak))\n                ct = requests.get(f'{CDN_BASE}/download?encrypted_query_param={quote(eq)}', headers={'User-Agent': UA}, timeout=60).content\n                pt = AES.new(aes_key, AES.MODE_ECB).decrypt(ct); pt = pt[:-pt[-1]]\n                fname = sub.get('file_name') or f'{uuid.uuid4().hex[:8]}{ext or \".bin\"}'\n                p = os.path.join(_TEMP_DIR, fname); open(p, 'wb').write(pt)\n                paths.append(p); print(f'[WX] media saved: {fname}', file=sys.__stdout__)\n            except Exception as e:\n                print(f'[WX] media dl err ({key}): {e}', file=sys.__stdout__)\n            break  # one media per item\n    return paths\n\nagent = GeneraticAgent()\nagent.verbose = False\n\n_TAG_PATS = [r'<' + t + r'>.*?</' + t + r'>' for t in ('thinking', 'tool_use')]\n_TAG_PATS.append(r'<file_content>.*?</file_content>')\n\ndef _strip_md(t):\n    \"\"\"Filter markdown for WeChat rich-text rendering.\n    WeChat natively renders: code fences, inline code, bold, italic,\n    H1-H4 headings, horizontal rules, tables. We only strip unsupported syntax.\"\"\"\n    def _trunc_code(m):\n        full = m.group()\n        fence = re.match(r'`{3,}', full).group()\n        rest = full[len(fence):-len(fence)]\n        if '\\n' not in rest: return full  # single-line, keep as-is\n        lang_line, _, body = rest.partition('\\n')\n        lines = body.split('\\n')\n        if len(lines) > 10:\n            return f'{fence}{lang_line}\\n' + '\\n'.join(lines[:10]) + '\\n...\\n' + fence\n        return full  # keep intact\n    t = re.sub(r'(`{3,})[\\s\\S]*?\\1', _trunc_code, t)\n    # inline code: keep (WeChat renders it)\n    # bold/italic (*/**/***): keep (WeChat renders it)\n    t = re.sub(r'!\\[.*?\\]\\(.*?\\)', '', t)                        # images: remove\n    t = re.sub(r'\\[([^\\]]+)\\]\\([^\\)]+\\)', r'\\1', t)              # links: text only\n    t = re.sub(r'^#{5,6}\\s+', '', t, flags=re.M)                 # H5-H6: strip (H1-H4 kept)\n    t = re.sub(r'^\\s*[-*+]\\s+', '• ', t, flags=re.M)             # unordered list: bullet\n    t = re.sub(r'^\\s*\\d+\\.\\s+', '', t, flags=re.M)               # ordered list: strip num\n    t = re.sub(r'^\\s*>\\s?', '', t, flags=re.M)                   # blockquote: strip\n    # horizontal rules (---): keep (WeChat renders it)\n    return re.sub(r'\\n{3,}', '\\n\\n', t).strip()\n\ndef _clean(t):\n    t = re.sub(r'^\\s*LLM Running \\(Turn \\d+\\) \\.{3}\\s*$', '', t, flags=re.M)\n    t = re.sub(r'^\\s*🛠️\\s*[A-Za-z_][A-Za-z0-9_]*\\(.*$', '', t, flags=re.M)\n    for p in _TAG_PATS:\n        t = re.sub(p, '', t, flags=re.DOTALL)\n    t = re.sub(r'</?summary>', '', t)\n    return re.sub(r'\\n{3,}', '\\n\\n', _strip_md(t)).strip()\n\ndef _turn_parts(t):\n    _ph = []\n    safe = re.sub(r'`{4,}.*?`{4,}', lambda m: (_ph.append(m.group(0)), f'\\x00PH{len(_ph)-1}\\x00')[1], t, flags=re.DOTALL)\n    parts = re.split(r'(\\**LLM Running \\(Turn \\d+\\) \\.\\.\\.\\**)', safe)\n    parts = [re.sub(r'\\x00PH(\\d+)\\x00', lambda m: _ph[int(m.group(1))], p) for p in parts]\n    if len(parts) < 4: return [], t\n    turns = [parts[i] + (parts[i+1] if i+1 < len(parts) else '') for i in range(1, len(parts), 2)]\n    return (([parts[0]] if parts[0].strip() else []) + turns[:-1], turns[-1])\n\ndef on_message(bot, msg):\n    text = bot.extract_text(msg).strip()\n    uid = msg.get('from_user_id', '')\n    ctx = msg.get('context_token', '')\n    media_paths = _dl_media(msg.get('item_list', []))\n    if not text and not media_paths: return\n    if media_paths:\n        text = (text + '\\n' if text else '') + '\\n'.join(f'[用户发送文件: {p}]' for p in media_paths)\n    print(f'[WX] 收到: {text[:80]}', file=sys.__stdout__)\n\n    # Commands\n    if text in ('/stop', '/abort'):\n        agent.abort()\n        bot.send_text(uid, '已停止', context_token=ctx)\n        return\n    if text.startswith('/llm'):\n        args = text.split()\n        if len(args) > 1:\n            try:\n                n = int(args[1]); agent.next_llm(n)\n                bot.send_text(uid, f'切换到 [{agent.llm_no}] {agent.get_llm_name()}', context_token=ctx)\n            except (ValueError, IndexError):\n                bot.send_text(uid, f'用法: /llm <0-{len(agent.list_llms())-1}>', context_token=ctx)\n        else:\n            lines = [f\"{'→' if cur else '  '} [{i}] {name}\" for i, name, cur in agent.list_llms()]\n            bot.send_text(uid, 'LLMs:\\n' + '\\n'.join(lines), context_token=ctx)\n        return\n\n    def _handle():\n        prompt = text if text.startswith('/') else f\"If you need to show files to user, use [FILE:filepath] in your response.\\n\\n{text}\"\n        dq = agent.put_task(prompt, source=\"wechat\")\n        _typing_stop = threading.Event()\n        def _keep_typing():\n            ticket = bot.get_typing_ticket(uid, ctx)\n            if not ticket: return\n            while not _typing_stop.is_set():\n                try: bot.send_typing(uid, ticket)\n                except: pass\n                _typing_stop.wait(2.0)\n        threading.Thread(target=_keep_typing, daemon=True).start()\n        result = ''; sent = 0; mi = 0; last_send = 0\n        def _wx_send(text):\n            s = text.strip(); t0 = time.time()\n            try:\n                bot.send_text(uid, s, context_token=ctx)\n                print(f'[WX] send ok len={len(s)} dt={time.time()-t0:.1f}s', file=sys.__stdout__)\n                return True\n            except Exception as e:\n                print(f'[WX] send err len={len(s)} dt={time.time()-t0:.1f}s {type(e).__name__}: {e}', file=sys.__stdout__)\n                return False\n        def _send(show):\n            nonlocal mi, last_send\n            now = time.time()\n            if mi >= 9 or not show.strip(): return False\n            if mi and now - last_send < 6 * mi: return None\n            if _wx_send(show[:2000]): mi += 1; last_send = time.time(); return True\n            return False\n        try:\n            while True:\n                item = dq.get(timeout=300)\n                if 'done' in item: result = item['done']; break\n                raw = item.get('next', '')\n                done, partial = _turn_parts(raw)\n                if len(done) > sent:\n                    merged = _clean('\\n\\n'.join(done[sent:]))\n                    print(f'[WX] turns={len(done)}/{len(done)+1} sent={sent} sending={len(done)-sent}', file=sys.__stdout__)\n                    if _send(merged):\n                        sent = len(done)\n        except queue.Empty: result = '[超时]'\n        _typing_stop.set()\n        done, partial = _turn_parts(result)\n        rest = '\\n\\n'.join(done[sent:] + [partial] + ['\\n\\n[任务已完成]'])\n        if rest.strip(): _wx_send((_clean(rest))[-2000:])\n        files = re.findall(r'\\[FILE:([^\\]]+)\\]', result)\n        bad = {'filepath', '<filepath>', 'path', '<path>', 'file_path', '<file_path>', '...'}\n        files = [f for f in files if f.strip().lower() not in bad and (f if os.path.isabs(f) else os.path.join(_TEMP_DIR, f)) not in media_paths]\n        for fpath in set(files):\n            if not os.path.isabs(fpath): fpath = os.path.join(_TEMP_DIR, fpath)\n            try:\n                if not os.path.exists(fpath): raise FileNotFoundError(f\"文件不存在: {fpath}\")\n                ext = os.path.splitext(fpath)[1].lower()\n                sender = bot.send_video if ext in {'.mp4', '.mov', '.m4v', '.webm'} else \\\n                         bot.send_image if ext in {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'} else bot.send_file\n                sender(uid, fpath, context_token=ctx)\n                print(f'[WX] sent media: {fpath}', file=sys.__stdout__)\n            except Exception as e: print(f'[WX] send media err: {e}', file=sys.__stdout__)\n\n    threading.Thread(target=_handle, daemon=True).start()\n\nif __name__ == '__main__':\n    try: _lock = socket.socket(socket.AF_INET, socket.SOCK_STREAM); _lock.bind(('127.0.0.1', 19531))\n    except OSError: print('[WeChat] Another instance running, exiting.'); sys.exit(1)\n    _logf = open(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp', 'wechatapp.log'), 'a', encoding='utf-8', buffering=1)\n    sys.stdout = sys.stderr = _logf\n    print(f'[NEW] Process starting {time.strftime(\"%m-%d %H:%M\")}')\n    bot = WxBotClient()\n    if not bot.token:\n        sys.stdout = sys.stderr = sys.__stdout__  # restore for QR display\n        bot.login_qr()\n        sys.stdout = sys.stderr = _logf\n    threading.Thread(target=agent.run, daemon=True).start()\n    print(f'WeChat Bot 已启动 (bot_id={bot.bot_id})', file=sys.__stdout__)\n    bot.run_loop(on_message)"
  },
  {
    "path": "frontends/wecomapp.py",
    "content": "import asyncio, os, select, sys, threading, time, traceback\nfrom collections import deque\nfrom datetime import datetime\nfrom typing import Any, Callable, Dict, Optional, TypedDict\n\n\nclass TurnContext(TypedDict, total=False):\n    \"\"\"Hook callback receives agent locals() — these are the keys we rely on.\"\"\"\n    exit_reason: Optional[str]\n    response: Any\n    summary: Optional[str]\n    tool_calls: Optional[list]\n    turn: int\n\n\nTurnHookFn = Callable[[TurnContext], None]\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom agentmain import GeneraticAgent\nfrom chatapp_common import (AgentChatMixin, FILE_HINT, build_done_text, clean_reply,\n                            ensure_single_instance, extract_files, public_access,\n                            redirect_log, require_runtime, split_text, strip_files)\nfrom llmcore import mykeys\n\ntry:\n    from wecom_aibot_sdk import WSClient, generate_req_id\nexcept Exception:\n    print(\"Please install wecom_aibot_sdk: pip install wecom_aibot_sdk\")\n    sys.exit(1)\n\n# ── Config ──────────────────────────────────────────────────────────\nBOT_ID    = str(mykeys.get(\"wecom_bot_id\", \"\") or \"\").strip()\nSECRET    = str(mykeys.get(\"wecom_secret\", \"\") or \"\").strip()\nWELCOME   = str(mykeys.get(\"wecom_welcome_message\", \"\") or \"\").strip()\nALLOWED   = {str(x).strip() for x in mykeys.get(\"wecom_allowed_users\", []) if str(x).strip()}\nPORT      = 19531                # single-instance lock port\nTEMP_DIR  = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), \"temp\")\nMEDIA_DIR = os.path.join(TEMP_DIR, \"media\")\nIMAGE_EXTS = {\".png\", \".jpg\", \".jpeg\", \".gif\", \".bmp\", \".webp\", \".svg\"}\n\n\n# ── Helpers ─────────────────────────────────────────────────────────\ndef _ts():\n    return datetime.now().strftime(\"%H:%M:%S\")\n\ndef _tprint(*a, **kw):\n    kw.setdefault(\"file\", sys.__stdout__)\n    print(*a, **kw)\n    if hasattr(sys.__stdout__, \"flush\"):\n        sys.__stdout__.flush()\n\ndef _fmt_tool(tc):\n    name = tc.get(\"tool_name\", \"?\")\n    args = {k: v for k, v in (tc.get(\"args\") or {}).items() if not k.startswith(\"_\")}\n    return f\"{name}({str(args)[:120]})\"\n\n# ── WeComApp ────────────────────────────────────────────────────────\nclass WeComApp(AgentChatMixin):\n    label, source, split_limit = \"WeCom\", \"wecom\", 1200  # split_limit: wecom single-msg char cap\n\n    def __init__(self, agent):\n        self.agent = agent\n        if not hasattr(agent, '_turn_end_hooks'):\n            agent._turn_end_hooks = {}\n        super().__init__(agent, {})\n        self._allowed = ALLOWED\n        self.client = None\n        self.chat_frames = {}       # chat_id → latest frame (for reply)\n        self._seen = deque(maxlen=1000)\n        self._stats = {\"received\": 0, \"completed\": 0}\n\n    # ── hook management ──────────────────────────────────────────────\n    def _register_hook(self, key: str, fn: TurnHookFn) -> None:\n        \"\"\"Register a turn-end callback on the agent.\"\"\"\n        self.agent._turn_end_hooks[key] = fn\n\n    def _unregister_hook(self, key: str) -> None:\n        \"\"\"Remove a turn-end callback.\"\"\"\n        self.agent._turn_end_hooks.pop(key, None)\n\n    # ── frame accept: dedup → auth → register ───────────────────────\n    def _accept(self, frame):\n        \"\"\"Parse incoming frame. Returns (body, sender_id, chat_id) or None.\"\"\"\n        body = frame.body if hasattr(frame, \"body\") else frame.get(\"body\", frame) if isinstance(frame, dict) else {}\n        msg_id = body.get(\"msgid\") or f\"{body.get('chatid', '')}_{body.get('sendertime', '')}_{id(frame)}\"\n        if msg_id in self._seen:\n            return None\n        self._seen.append(msg_id)\n        sender_id = str((body.get(\"from\") or {}).get(\"userid\", \"\") or \"unknown\")\n        chat_id = str(body.get(\"chatid\", \"\") or sender_id)\n        if not public_access(ALLOWED) and sender_id not in ALLOWED:\n            print(f\"[WeCom] unauthorized: {sender_id}\")\n            return None\n        self.chat_frames[chat_id] = frame\n        self._stats[\"received\"] += 1\n        return body, sender_id, chat_id\n\n    async def _save_media(self, url, aes_key, default_name):\n        \"\"\"Download encrypted media → save to MEDIA_DIR → return local path.\"\"\"\n        os.makedirs(MEDIA_DIR, exist_ok=True)\n        result = await self.client.download_file(url, aes_key or None)\n        buf = result[\"buffer\"]\n        fname = result.get(\"filename\") or default_name\n        path = os.path.join(MEDIA_DIR, fname)\n        with open(path, \"wb\") as f:\n            f.write(buf)\n        _tprint(f\"[{_ts()}] 💾 Saved: {path} ({len(buf)} bytes)\")\n        return path\n\n    # ── send ────────────────────────────────────────────────────────\n    async def send_text(self, chat_id, content, **_):\n        if not self.client or chat_id not in self.chat_frames:\n            return\n        frame = self.chat_frames[chat_id]\n        for part in split_text(content, self.split_limit):\n            await self.client.reply_stream(frame, generate_req_id(\"stream\"), part, finish=True)\n\n    async def send_media(self, chat_id, file_path):\n        if not self.client or not os.path.isfile(file_path):\n            return\n        ext = os.path.splitext(file_path)[1].lower()\n        media_type = \"image\" if ext in IMAGE_EXTS else \"file\"\n        with open(file_path, \"rb\") as f:\n            data = f.read()\n        try:\n            result = await self.client.upload_media(data, type=media_type, filename=os.path.basename(file_path))\n            frame = self.chat_frames.get(chat_id)\n            if frame:\n                await self.client.reply_media(frame, media_type, result[\"media_id\"])\n            else:\n                await self.client.send_media_message(chat_id, media_type, result[\"media_id\"])\n            _tprint(f\"[{_ts()}] 📤 Sent {media_type}: {os.path.basename(file_path)}\")\n        except Exception as e:\n            print(f\"[WeCom] send_media error: {e}\")\n            await self.send_text(chat_id, f\"📎 {os.path.basename(file_path)}（发送失败: {e}）\")\n\n    async def send_done(self, chat_id, raw_text):\n        \"\"\"Send final result: text + extracted file attachments.\"\"\"\n        files = extract_files(raw_text)\n        if not files:\n            return await self.send_text(chat_id, build_done_text(raw_text))\n        clean = clean_reply(strip_files(raw_text))\n        if clean and clean != \"...\":\n            await self.send_text(chat_id, clean)\n        for fp in files:\n            if not os.path.isabs(fp) and not os.path.isfile(fp):\n                resolved = os.path.join(TEMP_DIR, fp)\n                if os.path.isfile(resolved):\n                    fp = resolved\n            await self.send_media(chat_id, fp)\n\n    # ── agent execution (single-channel via turn hook) ──────────────\n    async def run_agent(self, chat_id, text, **_):\n        state = {\"running\": True}\n        self.user_tasks[chat_id] = state\n        done_event = threading.Event()\n        result = {}\n        loop = asyncio.get_running_loop()\n        hook_key = f\"wecom_{chat_id}\"  # namespace: wecom_ + chat_id, matches _turn_end_hooks convention\n\n        def _on_turn(ctx):\n            \"\"\"Turn-end callback injected into agent. ctx = locals() from ga.py.\"\"\"\n            try:\n                if ctx.get(\"exit_reason\"):\n                    resp = ctx.get(\"response\")\n                    result[\"raw\"] = resp.content if hasattr(resp, \"content\") else str(resp)\n                    result[\"summary\"] = ctx.get(\"summary\")\n                    done_event.set()\n                    return\n                summary = ctx.get(\"summary\")\n                if not summary:\n                    return\n                turn = ctx.get(\"turn\", \"?\")\n                tools = ctx.get(\"tool_calls\") or []\n                parts = [f\"⏳ Turn {turn}: {summary}\"]\n                if tools:\n                    parts.append(f\"🛠 {', '.join(_fmt_tool(tc) for tc in tools[:3])}\")\n                _tprint(f\"[{_ts()}] {parts[0]}\")\n                asyncio.run_coroutine_threadsafe(self.send_text(chat_id, \"\\n\".join(parts)), loop)\n            except Exception as e:\n                print(f\"[WeCom hook] {e}\")\n                traceback.print_exc()\n\n        try:\n            await self.send_text(chat_id, \"🤔 思考中...\")\n            self._register_hook(hook_key, _on_turn)\n            self.agent.put_task(f\"{FILE_HINT}\\n\\n{text}\", source=self.source)\n\n            # Wait for: hook signals done / user stops / agent crashes\n            t0 = time.time()\n            while state[\"running\"] and not done_event.is_set():\n                await asyncio.sleep(1)\n                elapsed = time.time() - t0\n                if elapsed > 10 and not self.agent.is_running:\n                    await asyncio.sleep(3)  # grace period for hook delivery\n                    if not done_event.is_set():\n                        break\n\n            if result.get(\"raw\") is not None:\n                self._stats[\"completed\"] += 1\n                await self.send_done(chat_id, result[\"raw\"])\n                label = result.get(\"summary\") or f'{len(result[\"raw\"])} 字'\n                _tprint(f\"[{_ts()}] ✅ Done ({chat_id}) — {label}\")\n            elif not state[\"running\"]:\n                _tprint(f\"[{_ts()}] ⏹️ 停止 ({chat_id})\")\n                await self.send_text(chat_id, \"⏹️ 已停止\")\n            else:\n                _tprint(f\"[{_ts()}] ⚠️ 异常退出 ({chat_id})\")\n                await self.send_text(chat_id, \"⚠️ Agent 异常退出，请重试\")\n        except Exception as e:\n            traceback.print_exc()\n            await self.send_text(chat_id, f\"❌ 错误: {e}\")\n        finally:\n            self._unregister_hook(hook_key)\n            self.user_tasks.pop(chat_id, None)\n\n    # ── message handlers ────────────────────────────────────────────\n    async def on_text(self, frame):\n        parsed = self._accept(frame)\n        if not parsed:\n            return\n        body, sender_id, chat_id = parsed\n        content = str((body.get(\"text\", {}) or {}).get(\"content\", \"\") or \"\").strip()\n        if not content:\n            return\n        _tprint(f\"[{_ts()}] 📩 {sender_id}: {content}\")\n        if content.startswith(\"/\"):\n            _tprint(f\"[{_ts()}] 🔧 命令 {content} from {sender_id}\")\n            return await self.handle_command(chat_id, content)\n        asyncio.create_task(self.run_agent(chat_id, content))\n\n    async def _on_media(self, frame, key, icon):\n        \"\"\"Common handler for image/file messages.\"\"\"\n        parsed = self._accept(frame)\n        if not parsed:\n            return\n        body, sender_id, chat_id = parsed\n        info = body.get(key) or {}\n        url = info.get(\"url\", \"\")\n        if not url:\n            return\n        fname = info.get(\"file_name\") or info.get(\"filename\") or \"\"\n        msgid = body.get(\"msgid\", \"x\")[:16]\n        default = f\"img_{msgid}.jpg\" if key == \"image\" else (fname or f\"file_{msgid}\")\n        try:\n            _tprint(f\"[{_ts()}] {icon} {key.title()} from {sender_id}\" + (f\": {fname}\" if fname else \"\"))\n            path = await self._save_media(url, info.get(\"aeskey\", \"\"), default)\n            label = \"一张图片\" if key == \"image\" else f\"文件 {os.path.basename(path)}\"\n            asyncio.create_task(self.run_agent(chat_id, f\"[用户发送了{label}，已保存到: {path}]\"))\n        except Exception as e:\n            print(f\"[WeCom] on_{key} error: {e}\")\n            await self.send_text(chat_id, f\"❌ {key}处理失败: {e}\")\n\n    async def on_image(self, frame):\n        await self._on_media(frame, \"image\", \"🖼️\")\n\n    async def on_file(self, frame):\n        await self._on_media(frame, \"file\", \"📎\")\n\n    # ── lifecycle ───────────────────────────────────────────────────\n    async def on_enter_chat(self, frame):\n        if WELCOME and self.client:\n            try:\n                await self.client.reply_welcome(frame, {\"msgtype\": \"text\", \"text\": {\"content\": WELCOME}})\n            except Exception as e:\n                print(f\"[WeCom] welcome error: {e}\")\n\n    async def on_connected(self, *_):     _tprint(\"[WeCom] connected\")\n    async def on_authenticated(self, *_): _tprint(\"[WeCom] authenticated, 等待消息中...\\n\")\n    async def on_disconnected(self, *_):  _tprint(\"[WeCom] disconnected\")\n    async def on_error(self, frame):     _tprint(f\"[WeCom] error: {frame}\")\n\n    # ── Terminal CLI (runs in background thread) ─────────────────────\n    def _terminal_loop(self):\n        \"\"\"Blocking CLI loop — run in a daemon thread.\"\"\"\n        while True:\n            try:\n                if not select.select([sys.stdin], [], [], 1.0)[0]:\n                    continue\n                cmd = sys.stdin.readline().strip().lower()\n            except Exception:\n                break\n            if not cmd:\n                continue\n            if cmd == \"help\":\n                _tprint(\"  status        — 查看状态\")\n                _tprint(\"  stop [user]   — 停止任务（多任务时需指定 user）\")\n                _tprint(\"  exit          — 退出进程\")\n            elif cmd == \"status\":\n                _tprint(f\"[{_ts()}] 📊 收到 {self._stats['received']} 条 | 完成 {self._stats['completed']} 条 | 活跃 {len(self.user_tasks)}\")\n                for uid, st in self.user_tasks.items():\n                    _tprint(f\"  ├ {uid}: running={st.get('running')}\")\n                _tprint(f\"  Agent running: {self.agent.is_running} | 允许: {self._allowed or '全部'}\")\n            elif cmd.startswith(\"stop\"):\n                parts = cmd.split(None, 1)\n                tasks = self.user_tasks\n                if not tasks:\n                    _tprint(\"  没有活跃任务\")\n                elif len(parts) > 1:\n                    uid = parts[1]\n                    if uid in tasks:\n                        tasks[uid][\"running\"] = False\n                        _tprint(f\"  ⏹️ 已停止 {uid}\")\n                    else:\n                        _tprint(f\"  未找到: {uid}\")\n                elif len(tasks) == 1:\n                    uid = next(iter(tasks))\n                    tasks[uid][\"running\"] = False\n                    _tprint(f\"  ⏹️ 已停止 {uid}\")\n                else:\n                    _tprint(\"  多个任务，请指定: stop <user_id>\")\n                    for uid in tasks:\n                        _tprint(f\"  ├ {uid}\")\n            elif cmd == \"exit\":\n                _tprint(f\"[{_ts()}] 👋 退出...\")\n                os._exit(0)\n            else:\n                _tprint(\"  可用命令: help | status | stop | exit\")\n\n    async def start(self, client=None):\n        self.client = client or WSClient(BOT_ID, SECRET, reconnect_interval=1000,\n                                         max_reconnect_attempts=-1, heartbeat_interval=30000)\n        for ev, fn in {\n            \"connected\": self.on_connected, \"authenticated\": self.on_authenticated,\n            \"disconnected\": self.on_disconnected, \"error\": self.on_error,\n            \"message.text\": self.on_text, \"message.image\": self.on_image,\n            \"message.file\": self.on_file, \"event.enter_chat\": self.on_enter_chat,\n        }.items():\n            self.client.on(ev, fn)\n        _tprint(\"[WeCom] starting ...\")\n        await self.client.connect()\n        while True:\n            await asyncio.sleep(1)\n\n\n# ── Main ────────────────────────────────────────────────────────────\nif __name__ == \"__main__\":\n    agent = GeneraticAgent(); agent.verbose = False\n    _LOCK = ensure_single_instance(PORT, \"WeCom\")\n    require_runtime(agent, \"WeCom\", wecom_bot_id=BOT_ID, wecom_secret=SECRET)\n    redirect_log(__file__, \"wecomapp.log\", \"WeCom\", ALLOWED)\n    _tprint(\"\\n═══════════════════════════════════════════\")\n    _tprint(\"  企业微信 Agent  (长连接模式)\")\n    _tprint(f\"  端口锁: {PORT} | 允许用户: {ALLOWED or '全部'}\")\n    _tprint(\"═══════════════════════════════════════════\")\n    _tprint(\"  终端命令:  help | status | stop | exit\")\n\n    app = WeComApp(agent)\n    threading.Thread(target=agent.run, daemon=True).start()\n    threading.Thread(target=app._terminal_loop, daemon=True).start()\n    asyncio.run(app.start())"
  },
  {
    "path": "ga.py",
    "content": "import sys, os, re, json, time, threading, importlib\nfrom datetime import datetime\nfrom pathlib import Path\nimport tempfile, traceback, subprocess, itertools, collections, difflib\nif sys.stdout is None: sys.stdout = open(os.devnull, \"w\")\nif sys.stderr is None: sys.stderr = open(os.devnull, \"w\")\nsys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))\n\nfrom agent_loop import BaseHandler, StepOutcome, json_default\nscript_dir = os.path.dirname(os.path.abspath(__file__))\n\ndef code_run(code, code_type=\"python\", timeout=60, cwd=None, code_cwd=None, stop_signal=None):\n    \"\"\"代码执行器\n    python: 运行复杂的 .py 脚本（文件模式）\n    powershell/bash: 运行单行指令（命令模式）\n    优先使用python，仅在必要系统操作时使用powershell\"\"\"\n    preview = (code[:60].replace('\\n', ' ') + '...') if len(code) > 60 else code.strip()\n    yield f\"[Action] Running {code_type} in {os.path.basename(cwd)}: {preview}\\n\"\n    cwd = cwd or os.path.join(script_dir, 'temp'); tmp_path = None\n    if code_type in [\"python\", \"py\"]:\n        tmp_file = tempfile.NamedTemporaryFile(suffix=\".ai.py\", delete=False, mode='w', encoding='utf-8', dir=code_cwd)\n        cr_header = os.path.join(script_dir, 'assets', 'code_run_header.py')\n        if os.path.exists(cr_header): tmp_file.write(open(cr_header, encoding='utf-8').read())\n        tmp_file.write(code)\n        tmp_path = tmp_file.name\n        tmp_file.close()\n        cmd = [sys.executable, \"-X\", \"utf8\", \"-u\", tmp_path]   \n    elif code_type in [\"powershell\", \"bash\", \"sh\", \"shell\", \"ps1\", \"pwsh\"]:\n        if os.name == 'nt': cmd = [\"powershell\", \"-NoProfile\", \"-NonInteractive\", \"-Command\", code]\n        else: cmd = [\"bash\", \"-c\", code]\n    else:\n        return {\"status\": \"error\", \"msg\": f\"不支持的类型: {code_type}\"}\n    print(\"code run output:\") \n    startupinfo = None\n    if os.name == 'nt':\n        startupinfo = subprocess.STARTUPINFO()\n        startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW\n        startupinfo.wShowWindow = 0 # SW_HIDE\n    full_stdout = []\n\n    def stream_reader(proc, logs):\n        try:\n            for line_bytes in iter(proc.stdout.readline, b''):\n                try: line = line_bytes.decode('utf-8')\n                except UnicodeDecodeError: line = line_bytes.decode('gbk', errors='ignore')\n                logs.append(line)\n                try: print(line, end=\"\") \n                except: pass\n        except: pass\n\n    try:\n        process = subprocess.Popen(\n            cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n            bufsize=0, cwd=cwd, startupinfo=startupinfo\n        )\n        start_t = time.time()\n        t = threading.Thread(target=stream_reader, args=(process, full_stdout), daemon=True)\n        t.start()\n\n        while t.is_alive():\n            istimeout = time.time() - start_t > timeout\n            if istimeout or stop_signal:\n                process.kill()\n                print(\"[Debug] Process killed due to timeout or stop signal.\")\n                if istimeout: full_stdout.append(\"\\n[Timeout Error] 超时强制终止\")\n                else: full_stdout.append(\"\\n[Stopped] 用户强制终止\")\n                break\n            time.sleep(1)\n\n        t.join(timeout=1)\n        exit_code = process.poll()\n\n        stdout_str = \"\".join(full_stdout)\n        status = \"success\" if exit_code == 0 else \"error\"\n        status_icon = \"✅\" if exit_code == 0 else \"❌\"\n        if exit_code is None: status_icon = \"⏳\" \n        output_snippet = smart_format(stdout_str, max_str_len=600, omit_str='\\n\\n[omitted long output]\\n\\n')\n        output_snippet = re.sub(r'`{4,}', lambda m: m.group(0)[:3] + '\\u200b' + m.group(0)[3:], output_snippet)\n        yield f\"[Status] {status_icon} Exit Code: {exit_code}\\n[Stdout]\\n{output_snippet}\\n\"\n        if process.stdout: threading.Thread(target=process.stdout.close, daemon=True).start()\n        return {\n            \"status\": status,\n            \"stdout\": smart_format(stdout_str, max_str_len=10000, omit_str='\\n\\n[omitted long output]\\n\\n'),\n            \"exit_code\": exit_code\n        }\n    except Exception as e:\n        if 'process' in locals(): process.kill()\n        return {\"status\": \"error\", \"msg\": str(e)}\n    finally:\n        if code_type == \"python\" and tmp_path and os.path.exists(tmp_path): os.remove(tmp_path)\n\n\ndef ask_user(question, candidates=None):\n    \"\"\"question: 向用户提出的问题。candidates: 可选的候选项列表\"\"\"\n    return {\"status\": \"INTERRUPT\", \"intent\": \"HUMAN_INTERVENTION\",\n        \"data\": {\"question\": question, \"candidates\": candidates or []}}\n\nimport simphtml\ndriver = None\ndef first_init_driver():\n    global driver\n    from TMWebDriver import TMWebDriver\n    driver = TMWebDriver()\n    for i in range(20):\n        time.sleep(1)\n        sess = driver.get_all_sessions()\n        if len(sess) > 0: break\n    if len(sess) == 0: return \n    if len(sess) == 1: \n        #driver.newtab()\n        time.sleep(3)\n\ndef web_scan(tabs_only=False, switch_tab_id=None, text_only=False):\n    \"\"\"获取当前页面的简化HTML内容和标签页列表。注意：简化过程会过滤边栏、浮动元素等非主体内容。\n    tabs_only: 仅返回标签页列表，不获取HTML内容（节省token）。\n    switch_tab_id: 可选参数，如果提供，则在扫描前切换到该标签页。\n    应当多用execute_js，少全量观察html\"\"\"\n    global driver\n    try:\n        if driver is None: first_init_driver()\n        if len(driver.get_all_sessions()) == 0:\n            return {\"status\": \"error\", \"msg\": \"没有可用的浏览器标签页，查L3记忆分析原因。\"}\n        tabs = []\n        for sess in driver.get_all_sessions(): \n            sess.pop('connected_at', None)\n            sess.pop('type', None)\n            sess['url'] = sess.get('url', '')[:50] + (\"...\" if len(sess.get('url', '')) > 50 else \"\")\n            tabs.append(sess)\n        if switch_tab_id: driver.default_session_id = switch_tab_id\n        result = {\n            \"status\": \"success\",\n            \"metadata\": {\n                \"tabs_count\": len(tabs), \"tabs\": tabs,\n                \"active_tab\": driver.default_session_id\n            }\n        }\n        if not tabs_only: \n            importlib.reload(simphtml); result[\"content\"] = simphtml.get_html(driver, cutlist=True, maxchars=35000, text_only=text_only)\n            if text_only: result['content'] = smart_format(result['content'], max_str_len=10000, omit_str='\\n\\n[omitted long content]\\n\\n')\n        return result\n    except Exception as e:\n        return {\"status\": \"error\", \"msg\": format_error(e)}\n    \ndef format_error(e):\n    exc_type, exc_value, exc_traceback = sys.exc_info()\n    tb = traceback.extract_tb(exc_traceback)\n    if tb:\n        f = tb[-1]\n        fname = os.path.basename(f.filename)\n        return f\"{exc_type.__name__}: {str(e)} @ {fname}:{f.lineno}, {f.name} -> `{f.line}`\"\n    return f\"{exc_type.__name__}: {str(e)}\"\n\ndef log_memory_access(path):\n    if 'memory' not in path: return\n    stats_file = os.path.join(script_dir, 'memory/file_access_stats.json')\n    try:\n        with open(stats_file, 'r', encoding='utf-8') as f: stats = json.load(f)\n    except: stats = {}\n    fname = os.path.basename(path)\n    stats[fname] = {'count': stats.get(fname, {}).get('count', 0) + 1, 'last': datetime.now().strftime('%Y-%m-%d')}\n    with open(stats_file, 'w', encoding='utf-8') as f: json.dump(stats, f, indent=2, ensure_ascii=False)\n\ndef web_execute_js(script, switch_tab_id=None, no_monitor=False):\n    \"\"\"执行 JS 脚本来控制浏览器，并捕获结果和页面变化\"\"\"\n    global driver\n    try:\n        if driver is None: first_init_driver()\n        if len(driver.get_all_sessions()) == 0: return {\"status\": \"error\", \"msg\": \"没有可用的浏览器标签页，查L3记忆分析原因。\"}\n        if switch_tab_id: driver.default_session_id = switch_tab_id\n        result = simphtml.execute_js_rich(script, driver, no_monitor=no_monitor)\n        return result\n    except Exception as e: return {\"status\": \"error\", \"msg\": format_error(e)}\n\ndef expand_file_refs(text, base_dir=None):\n    \"\"\"展开文本中的 {{file:路径:起始行:结束行}} 引用为实际文件内容。\n    可与普通文本混排。展开失败抛 ValueError。\n    base_dir: 相对路径的基准目录，默认为进程 cwd\"\"\"\n    pattern = r'\\{\\{file:(.+?):(\\d+):(\\d+)\\}\\}'\n    def replacer(match):\n        path, start, end = match.group(1), int(match.group(2)), int(match.group(3))\n        path = os.path.abspath(os.path.join(base_dir or '.', path))\n        if not os.path.isfile(path): raise ValueError(f\"引用文件不存在: {path}\")\n        with open(path, 'r', encoding='utf-8') as f: lines = f.readlines()\n        if start < 1 or end > len(lines) or start > end: raise ValueError(f\"行号越界: {path} 共{len(lines)}行, 请求{start}-{end}\")\n        return ''.join(lines[start-1:end])\n    return re.sub(pattern, replacer, text)\n    \ndef file_patch(path: str, old_content: str, new_content: str):\n    \"\"\"在文件中寻找唯一的 old_content 块并替换为 new_content\"\"\"\n    path = str(Path(path).resolve())\n    try:\n        if not os.path.exists(path): return {\"status\": \"error\", \"msg\": \"文件不存在\"}\n        with open(path, 'r', encoding='utf-8') as f: full_text = f.read()\n        if not old_content: return {\"status\": \"error\", \"msg\": \"old_content 为空，请确认 arguments\"}\n        count = full_text.count(old_content)\n        if count == 0: return {\"status\": \"error\", \"msg\": \"未找到匹配的旧文本块，建议：先用 file_read 确认当前内容，再分小段进行 patch。若多次失败则询问用户，严禁自行使用 overwrite 或代码替换。\"}\n        if count > 1: return {\"status\": \"error\", \"msg\": f\"找到 {count} 处匹配，无法确定唯一位置。请提供更长、更具体的旧文本块以确保唯一性。建议：包含上下文行来增强特征，或分小段逐个修改。\"}\n        updated_text = full_text.replace(old_content, new_content)\n        with open(path, 'w', encoding='utf-8') as f: f.write(updated_text)\n        return {\"status\": \"success\", \"msg\": \"文件局部修改成功\"}\n    except Exception as e: return {\"status\": \"error\", \"msg\": str(e)}\n\n_read_dirs = set()\ndef _scan_files(base, depth=2):\n    try:\n        for e in os.scandir(base):\n            if e.is_file(): yield (e.name, e.path)\n            elif depth > 0 and e.is_dir(follow_symlinks=False): yield from _scan_files(e.path, depth - 1)\n    except (PermissionError, OSError): pass\ndef file_read(path, start=1, keyword=None, count=200, show_linenos=True):\n    try:\n        with open(path, 'r', encoding='utf-8', errors='replace') as f:\n            stream = ((i, l.rstrip('\\r\\n')) for i, l in enumerate(f, 1))\n            stream = itertools.dropwhile(lambda x: x[0] < start, stream)\n            if keyword:\n                before = collections.deque(maxlen=count//3)\n                for i, l in stream:\n                    if keyword.lower() in l.lower():\n                        res = list(before) + [(i, l)] + list(itertools.islice(stream, count - len(before) - 1))\n                        break\n                    before.append((i, l))\n                else: return f\"Keyword '{keyword}' not found after line {start}. Falling back to content from line {start}:\\n\\n\" \\\n                               + file_read(path, start, None, count, show_linenos)\n            else: res = list(itertools.islice(stream, count))\n            realcnt = len(res); L_MAX = min(max(100, 256000//max(realcnt,1)), 8000); TAG = \" ... [TRUNCATED]\"\n            remaining = sum(1 for _ in itertools.islice(stream, 5000))\n            total_lines = (res[0][0] - 1 if res else start - 1) + realcnt + remaining\n            tl_str = f\"{total_lines}+\" if remaining >= 5000 else str(total_lines)\n            partial = total_lines > realcnt\n            total_tag = f\"[FILE] {tl_str} lines\" + (f\" | PARTIAL showing {realcnt}; assess need for more\" if partial else \"\") + \"\\n\"\n            res = [(i, l if len(l) <= L_MAX else l[:L_MAX] + TAG) for i, l in res]\n            result = \"\\n\".join(f\"{i}|{l}\" if show_linenos else l for i, l in res)\n            if show_linenos: result = total_tag + result\n            elif partial: result += f\"\\n\\n[FILE PARTIAL: showing {realcnt}/{tl_str} lines; assess need for more]\"\n            _read_dirs.add(os.path.dirname(os.path.abspath(path)))\n            return result\n    except FileNotFoundError:\n        msg = f\"Error: File not found: {path}\"\n        try:\n            tgt = os.path.basename(path); scan = os.path.dirname(os.path.dirname(os.path.abspath(path)))\n            roots = [scan] + [d for d in _read_dirs if not d.startswith(scan)]\n            cands = list(itertools.islice((c for base in roots for c in _scan_files(base)), 2000))\n            top = sorted([(difflib.SequenceMatcher(None, tgt.lower(), c[0].lower()).ratio(), c) for c in cands[:2000]], key=lambda x: -x[0])[:5]\n            top = [(s, c) for s, c in top if s > 0.3]\n            if top: msg += \"\\n\\nDid you mean:\\n\" + \"\\n\".join(f\"  {c[1]}  ({s:.0%})\" for s, c in top)\n        except Exception: pass\n        return msg\n    except Exception as e: return f\"Error: {str(e)}\"\n\ndef smart_format(data, max_str_len=100, omit_str=' ... '):\n    if not isinstance(data, str): data = str(data)\n    if len(data) < max_str_len + len(omit_str)*2: return data\n    return f\"{data[:max_str_len//2]}{omit_str}{data[-max_str_len//2:]}\"\n\ndef consume_file(dr, file):\n    if dr and os.path.exists(os.path.join(dr, file)): \n        with open(os.path.join(dr, file), encoding='utf-8', errors='replace') as f: content = f.read()\n        os.remove(os.path.join(dr, file))\n        return content\n\nclass GenericAgentHandler(BaseHandler):\n    '''Generic Agent 工具库，包含多种工具的实现。工具函数自动加上了 do_ 前缀。实际工具名没有前缀。'''\n    def __init__(self, parent, last_history=None, cwd='./temp'):\n        self.parent = parent\n        self.working = {}\n        self.cwd = cwd;  self.current_turn = 0\n        self.history_info = last_history if last_history else []\n        self.code_stop_signal = []\n        self._done_hooks = []\n\n    def _get_abs_path(self, path):\n        if not path: return \"\"\n        return os.path.abspath(os.path.join(self.cwd, path))   \n\n    def _extract_code_block(self, response, code_type):\n        code_type = {'python':'python|py', 'powershell':'powershell|ps1|pwsh', 'bash':'bash|sh|shell'}.get(code_type, re.escape(code_type))\n        matches = re.findall(rf\"```(?:{code_type})\\n(.*?)\\n```\", response.content, re.DOTALL)\n        return matches[-1].strip() if matches else None\n\n    def do_code_run(self, args, response):\n        '''执行代码片段，有长度限制，不允许代码中放大量数据，如有需要应当通过文件读取进行。'''\n        code_type = args.get(\"type\", \"python\")\n        code = args.get(\"code\") or args.get(\"script\")\n        if not code:\n            code = self._extract_code_block(response, code_type)\n            if not code: return StepOutcome(\"[Error] Code missing. Must use reply code block or 'script' arg.\", next_prompt=\"\\n\")\n        try: timeout = int(args.get(\"timeout\", 60))\n        except: timeout = 60\n        raw_path = os.path.join(self.cwd, args.get(\"cwd\", './'))\n        cwd = os.path.normpath(os.path.abspath(raw_path))\n        code_cwd = os.path.normpath(self.cwd)\n        if code_type == 'python' and args.get(\"inline_eval\"):\n            ns = {'handler':self, 'parent':self.parent, 'history':json.dumps(self.parent.llmclient.backend.history)}\n            old_cwd = os.getcwd()\n            try:\n                os.chdir(cwd)\n                try:\n                    try: result = repr(eval(code, ns))\n                    except SyntaxError: exec(code, ns); result = ns.get('_r', 'OK')\n                except Exception as e: result = f'Error: {e}'\n            finally: os.chdir(old_cwd)\n        else: result = yield from code_run(code, code_type, timeout, cwd, code_cwd=code_cwd, stop_signal=self.code_stop_signal)\n        next_prompt = self._get_anchor_prompt(skip=args.get('_index', 0) > 0)\n        return StepOutcome(result, next_prompt=next_prompt)\n    \n    def do_ask_user(self, args, response):\n        question = args.get(\"question\", \"请提供输入：\")\n        candidates = args.get(\"candidates\", [])\n        result = ask_user(question, candidates)\n        yield f\"Waiting for your answer ...\\n\"\n        return StepOutcome(result, next_prompt=\"\", should_exit=True)\n    \n    def do_web_scan(self, args, response):\n        '''获取当前页面内容和标签页列表。也可用于切换标签页。\n        注意：HTML经过简化，边栏/浮动元素等可能被过滤。如需查看被过滤的内容请用execute_js。\n        tabs_only=true时仅返回标签页列表，不获取HTML（省token）'''\n        tabs_only = args.get(\"tabs_only\", False)\n        switch_tab_id = args.get(\"switch_tab_id\", None)\n        text_only = args.get(\"text_only\", False)\n        result = web_scan(tabs_only=tabs_only, switch_tab_id=switch_tab_id, text_only=text_only)\n        content = result.pop(\"content\", None)\n        yield f'[Info] {str(result)}\\n'\n        if content: result = json.dumps(result, ensure_ascii=False, default=json_default) + f\"\\n```html\\n{content}\\n```\"\n        next_prompt = \"\\n\"\n        return StepOutcome(result, next_prompt=next_prompt)\n    \n    def do_web_execute_js(self, args, response):\n        '''web情况下的优先使用工具，执行任何js达成对浏览器的*完全*控制。支持将结果保存到文件供后续读取分析。'''\n        script = args.get(\"script\", \"\") or self._extract_code_block(response, \"javascript\")\n        if not script: return StepOutcome(\"[Error] Script missing. Use ```javascript block or 'script' arg.\", next_prompt=\"\\n\")\n        abs_path = self._get_abs_path(script.strip())\n        if os.path.isfile(abs_path):\n            with open(abs_path, 'r', encoding='utf-8') as f: script = f.read()\n        save_to_file = args.get(\"save_to_file\", \"\")\n        switch_tab_id = args.get(\"switch_tab_id\") or args.get(\"tab_id\")\n        no_monitor = args.get(\"no_monitor\", False)\n        result = web_execute_js(script, switch_tab_id=switch_tab_id, no_monitor=no_monitor)\n        if save_to_file and \"js_return\" in result:\n            content = str(result[\"js_return\"] or '')\n            abs_path = self._get_abs_path(save_to_file)\n            result[\"js_return\"] = smart_format(content, max_str_len=170)\n            try:\n                with open(abs_path, 'w', encoding='utf-8') as f: f.write(str(content))\n                result[\"js_return\"] += f\"\\n\\n[已保存完整内容到 {abs_path}]\"\n            except:\n                result['js_return'] += f\"\\n\\n[保存失败，无法写入文件 {abs_path}]\"\n        show = smart_format(json.dumps(result, ensure_ascii=False, indent=2, default=json_default), max_str_len=300)\n        try: print(\"Web Execute JS Result:\", show)\n        except: pass\n        yield f\"JS 执行结果:\\n{show}\\n\"\n        next_prompt = self._get_anchor_prompt(skip=args.get('_index', 0) > 0)\n        result = json.dumps(result, ensure_ascii=False, default=json_default)\n        return StepOutcome(smart_format(result, max_str_len=8000), next_prompt=next_prompt)\n    \n    def do_file_patch(self, args, response):\n        path = self._get_abs_path(args.get(\"path\", \"\"))\n        yield f\"[Action] Patching file: {path}\\n\"\n        old_content = args.get(\"old_content\", \"\")\n        new_content = args.get(\"new_content\", \"\")\n        try: new_content = expand_file_refs(new_content, base_dir=self.cwd)\n        except ValueError as e:\n            yield f\"[Status] ❌ 引用展开失败: {e}\\n\"\n            return StepOutcome({\"status\": \"error\", \"msg\": str(e)}, next_prompt=\"\\n\")\n        result = file_patch(path, old_content, new_content)\n        yield f\"\\n{str(result)}\\n\"\n        next_prompt = self._get_anchor_prompt(skip=args.get('_index', 0) > 0)\n        return StepOutcome(result, next_prompt=next_prompt)\n    \n    def do_file_write(self, args, response):\n        '''用于对整个文件的大量处理，精细修改要用file_patch。\n        需要将要写入的内容放在<file_content>标签内，或者放在代码块中'''\n        path = self._get_abs_path(args.get(\"path\", \"\"))\n        mode = args.get(\"mode\", \"overwrite\")  # overwrite/append/prepend\n        action_str = {\"prepend\": \"Prepending to\", \"append\": \"Appending to\"}.get(mode, \"Overwriting\")\n        yield f\"[Action] {action_str} file: {os.path.basename(path)}\\n\"\n\n        def extract_robust_content(text):\n            tags = re.findall(r\"<file_content[^>]*>(.*?)</file_content>\", text, re.DOTALL)\n            if tags: return tags[-1].strip()\n            blocks = re.findall(r\"```[^\\n]*\\n([\\s\\S]*?)```\", text)\n            if blocks: return blocks[-1].strip()\n            return None\n        \n        content = args.get('content') or extract_robust_content(response.content)\n        if not content:\n            yield f\"[Status] ❌ 失败: 未在回复中找到<file_content>代码块内容\\n\"\n            return StepOutcome({\"status\": \"error\", \"msg\": \"No content found. Blank is not supported. Put content inside <file_content>...</file_content> tags in your reply body before call file_write.\"}, next_prompt=\"\\n\")\n        try:\n            new_content = expand_file_refs(content, base_dir=self.cwd)\n            if mode == \"prepend\":\n                old = open(path, 'r', encoding=\"utf-8\").read() if os.path.exists(path) else \"\"\n                open(path, 'w', encoding=\"utf-8\").write(new_content + old)\n            else:\n                with open(path, 'a' if mode == \"append\" else 'w', encoding=\"utf-8\") as f: f.write(new_content)\n            yield f\"[Status] ✅ {mode.capitalize()} 成功 ({len(new_content)} bytes)\\n\"\n            next_prompt = self._get_anchor_prompt(skip=args.get('_index', 0) > 0)\n            return StepOutcome({\"status\": \"success\", 'writed_bytes': len(new_content)}, next_prompt=next_prompt)\n        except Exception as e:\n            yield f\"[Status] ❌ 写入异常: {str(e)}\\n\"\n            return StepOutcome({\"status\": \"error\", \"msg\": str(e)}, next_prompt=\"\\n\")\n        \n    def do_file_read(self, args, response):\n        '''读取文件内容。从第start行开始读取。如有keyword则返回第一个keyword(忽略大小写)周边内容'''\n        path = self._get_abs_path(args.get(\"path\", \"\"))\n        yield f\"\\n[Action] Reading file: {path}\\n\"\n        start = args.get(\"start\", 1)\n        count = args.get(\"count\", 200)\n        keyword = args.get(\"keyword\")\n        show_linenos = args.get(\"show_linenos\", True)\n        result = file_read(path, start=start, keyword=keyword,\n                           count=count, show_linenos=show_linenos)\n        if show_linenos and not result.startswith(\"Error:\"): result = '由于设置了show_linenos，以下返回信息为：(行号|)内容 。\\n' + result \n        if ' ... [TRUNCATED]' in result: result += '\\n\\n（某些行被截断，如需完整内容可改用 code_run 读取）'\n        result = smart_format(result, max_str_len=20000, omit_str='\\n\\n[omitted long content]\\n\\n')\n        next_prompt = self._get_anchor_prompt(skip=args.get('_index', 0) > 0)\n        log_memory_access(path)\n        if 'memory' in path or 'sop' in path: \n            next_prompt += \"\\n[SYSTEM TIPS] 正在读取记忆或SOP文件，若决定按sop执行请提取sop中的关键点（特别是靠后的）update working memory.\"\n        return StepOutcome(result, next_prompt=next_prompt)\n    \n    def _in_plan_mode(self): return self.working.get('in_plan_mode')\n    def _exit_plan_mode(self): self.working.pop('in_plan_mode', None)\n    def enter_plan_mode(self, plan_path): \n        self.working['in_plan_mode'] = plan_path; self.max_turns = 100\n        print(f\"[Info] Entered plan mode with plan file: {plan_path}\"); return plan_path\n    def _check_plan_completion(self):\n        if not os.path.isfile(p:=self._in_plan_mode() or ''): return None\n        try: return len(re.findall(r'\\[ \\]', open(p, encoding='utf-8', errors='replace').read()))\n        except: return None\n    \n    def do_update_working_checkpoint(self, args, response):\n        '''为整个任务设定后续需要临时记忆的重点。'''\n        key_info = args.get(\"key_info\", \"\")\n        related_sop = args.get(\"related_sop\", \"\")\n        if \"key_info\" in args: self.working['key_info'] = key_info\n        if \"related_sop\" in args: self.working['related_sop'] = related_sop\n        self.working['passed_sessions'] = 0\n        yield f\"[Info] Updated key_info and related_sop.\\n\"\n        next_prompt = self._get_anchor_prompt(skip=args.get('_index', 0) > 0)\n        #next_prompt += '\\n[SYSTEM TIPS] 此函数一般在任务开始或中间时调用，如果任务已成功完成应该是start_long_term_update用于结算长期记忆。\\n'\n        return StepOutcome({\"result\": \"working key_info updated\"}, next_prompt=next_prompt)\n\n    def _retry_or_exit(self, prompt):\n        self._empty_ct = getattr(self, '_empty_ct', 0) + 1\n        if self._empty_ct >= 3: return StepOutcome({}, should_exit=True)\n        return StepOutcome({}, next_prompt=prompt)\n\n    def do_no_tool(self, args, response):\n        '''这是一个特殊工具，由引擎自主调用，不要包含在TOOLS_SCHEMA里。\n        当模型在一轮中未显式调用任何工具时，由引擎自动触发。\n        二次确认仅在回复几乎只包含<thinking>/<summary>和一段大代码块时触发。'''\n        content = getattr(response, 'content', '') or \"\"\n        thinking = getattr(response, 'thinking', '') or \"\"\n        if not response or (not content.strip() and not thinking.strip()):\n            yield \"[Warn] LLM returned an empty response. Retrying...\\n\"\n            return self._retry_or_exit(\"[System] Blank response, regenerate and tooluse\")\n        if '[!!! 流异常中断' in content[-100:] or '!!!Error:' in content[-100:]:\n            return self._retry_or_exit(\"[System] Incomplete response. Regenerate and tooluse.\")\n        if 'max_tokens !!!]' in content[-100:]:\n            return self._retry_or_exit(\"[System] max_tokens limit reached. Use multi small steps to do it.\")\n        \n        if self._in_plan_mode() and any(kw in content for kw in ['任务完成', '全部完成', '已完成所有', '🏁']):\n            if 'VERDICT' not in content and '[VERIFY]' not in content and '验证subagent' not in content:\n                yield \"[Warn] Plan模式完成声明拦截。\\n\"\n                return StepOutcome({}, next_prompt=\"⛔ [验证拦截] 检测到你在plan模式下声称完成，但未执行[VERIFY]验证步骤。请先按plan_sop §四启动验证subagent，获得VERDICT后才能声称完成。\")\n            \n        # 2. 检测\"包含较大代码块但未调用工具\"的情况\n        # 关键特征：恰好1个大代码块 + 代码块直接结尾（后面只有空白）\n        code_block_pattern = r\"```[a-zA-Z0-9_]*\\n[\\s\\S]{50,}?```\"\n        blocks = re.findall(code_block_pattern, content)\n        if len(blocks) == 1:\n            m = re.search(code_block_pattern, content)\n            after_block = content[m.end():]\n            if not after_block.strip():\n                residual = content.replace(m.group(0), \"\")\n                residual = re.sub(r\"<thinking>[\\s\\S]*?</thinking>\", \"\", residual, flags=re.IGNORECASE)\n                residual = re.sub(r\"<summary>[\\s\\S]*?</summary>\", \"\", residual, flags=re.IGNORECASE)\n                clean_residual = re.sub(r\"\\s+\", \"\", residual)\n                if len(clean_residual) <= 30:\n                    yield \"[Info] Detected large code block without tool call and no extra natural language. Requesting clarification.\\n\"\n                    next_prompt = (\n                        \"[System] 检测到你在上一轮回复中主要内容是较大代码块，且本轮未调用任何工具。\\n\"\n                        \"如果这些代码需要执行、写入文件或进一步分析，请重新组织回复并显式调用相应工具\"\n                        \"（例如：code_run、file_write、file_patch 等）；\\n\"\n                        \"如果只是向用户展示或讲解代码片段，请在回复中补充自然语言说明，\"\n                        \"并明确是否还需要额外的实际操作。\"\n                    )\n                    return StepOutcome({}, next_prompt=next_prompt)\n                \n        if self._in_plan_mode():\n            remaining = self._check_plan_completion()\n            if remaining == 0:\n                self._exit_plan_mode(); yield \"[Info] Plan完成：plan.md中0个[ ]残留，退出plan模式。\\n\"\n        \n        yield \"[Info] Final response to user.\\n\"\n        return StepOutcome(response, next_prompt=None)\n    \n    def do_start_long_term_update(self, args, response):\n        '''Agent觉得当前任务完成后有重要信息需要记忆时调用此工具。'''\n        prompt = '''### [总结提炼经验] 既然你觉得当前任务有重要信息需要记忆，请提取最近一次任务中【事实验证成功且长期有效】的环境事实、用户偏好、重要步骤，更新记忆。\n本工具是标记开启结算过程，若已在更新记忆过程或没有值得记忆的点，忽略本次调用。\n**如果没有经验证的，未来能用上的信息，忽略本次调用！**\n**只能提取行动验证成功的信息**：\n- **环境事实**（路径/凭证/配置）→ `file_patch` 更新 L2，同步 L1\n- **复杂任务经验**（关键坑点/前置条件/重要步骤）→ L3 精简 SOP（只记你被坑得多次重试的核心要点）\n**禁止**：临时变量、具体推理过程、未验证信息、通用常识、你可以轻松复现的细节、只是做了但没有验证的信息\n**操作**：严格遵循提供的L0的记忆更新SOP。先 `file_read` 看现有 → 判断类型 → 最小化更新 → 无新内容跳过，保证对记忆库最小局部修改。\\n\n''' + get_global_memory()\n        yield \"[Info] Start distilling good memory for long-term storage.\\n\"\n        path = './memory/memory_management_sop.md'\n        if os.path.exists(path): result = 'This is L0:\\n' + file_read(path, show_linenos=False)\n        else: result = \"Memory Management SOP not found. Do not update memory.\"\n        return StepOutcome(result, next_prompt=prompt)\n\n    def _fold_earlier(self, lines):\n        FALLBACK = '直接回答了用户问题'\n        parts, cnt, last = [], 0, ''\n        def flush():\n            if cnt:\n                if FALLBACK in last: parts.append(f'[Agent]（{cnt} turns）')\n                else: parts.append(f'{last}（{cnt} turns）')\n        for line in lines:\n            if line.startswith('[USER]'):\n                flush(); parts.append(line); cnt = 0; last = ''\n            else: cnt += 1; last = line\n        flush()\n        return \"\\n\".join(parts[-150:])\n\n    def _get_anchor_prompt(self, skip=False):\n        if skip: return \"\\n\"\n        h = self.history_info; W = 30\n        earlier = f'<earlier_context>\\n{self._fold_earlier(h[:-W])}\\n</earlier_context>\\n' if len(h) > W else \"\"\n        h_str = \"\\n\".join(h[-W:])\n        prompt = f\"\\n### [WORKING MEMORY]\\n{earlier}<history>\\n{h_str}\\n</history>\"\n        prompt += f\"\\nCurrent turn: {self.current_turn}\\n\"\n        if self.working.get('key_info'): prompt += f\"\\n<key_info>{self.working.get('key_info')}</key_info>\"\n        if self.working.get('related_sop'): prompt += f\"\\n有不清晰的地方请再次读取{self.working.get('related_sop')}\"\n        if getattr(self.parent, 'verbose', False):\n            try: print(prompt)\n            except: pass\n        return prompt\n    \n    def turn_end_callback(self, response, tool_calls, tool_results, turn, next_prompt, exit_reason):\n        _c = re.sub(r'```.*?```|<thinking>.*?</thinking>', '', response.content, flags=re.DOTALL)\n        rsumm = re.search(r\"<summary>(.*?)</summary>\", _c, re.DOTALL)\n        if rsumm: summary = rsumm.group(1).strip()\n        else:\n            tc = tool_calls[0]; tool_name, args = tc['tool_name'], tc['args']   # at least one because no_tool\n            clean_args = {k: v for k, v in args.items() if not k.startswith('_')}\n            summary = f\"调用工具{tool_name}, args: {clean_args}\"\n            if tool_name == 'no_tool': summary = \"直接回答了用户问题\"\n            next_prompt += \"\\n\\n\\n[SYSTEM] 必须在回复文本中包含<summary>！\\n\\n\"\n        summary = smart_format(summary.replace('\\n', ''), max_str_len=80)\n        self.history_info.append(f'[Agent] {summary}')\n        _plan = self._in_plan_mode()\n\n        if turn % 65 == 0 and (not _plan):\n            next_prompt += f\"\\n\\n[DANGER] 已连续执行第 {turn} 轮。必须总结情况进行ask_user，不允许继续重试。\"\n        elif turn % 7 == 0:\n            next_prompt += f\"\\n\\n[DANGER] 已连续执行第 {turn} 轮。禁止无效重试。若无有效进展，必须切换策略：1. 探测物理边界 2. 请求用户协助。如有需要，可调用 update_working_checkpoint 保存关键上下文。\"\n        elif turn % 10 == 0: next_prompt += get_global_memory()\n\n        if _plan and turn >= 10 and turn % 5 == 0:\n            next_prompt = f\"[Plan Hint] 正在计划模式。必须 file_read({_plan}) 确认当前步骤，回复开头引用：📌 当前步骤：...\\n\\n\" + next_prompt\n        if _plan and turn >= 90: next_prompt += f\"\\n\\n[DANGER] Plan模式已运行 {turn} 轮，已达上限。必须 ask_user 汇报进度并确认是否继续。\"\n\n        injkeyinfo = consume_file(self.parent.task_dir, '_keyinfo')\n        injprompt = consume_file(self.parent.task_dir, '_intervene')\n        if injkeyinfo: self.working['key_info'] = self.working.get('key_info', '') + f\"\\n[MASTER] {injkeyinfo}\"\n        if injprompt: next_prompt += f\"\\n\\n[MASTER] {injprompt}\\n\"\n        for hook in getattr(self.parent, '_turn_end_hooks', {}).values(): hook(locals())  # current readonly\n        return next_prompt\n\ndef get_global_memory():\n    prompt = \"\\n\"\n    try:\n        suffix = '_en' if os.environ.get('GA_LANG', '') == 'en' else ''\n        with open(os.path.join(script_dir, 'memory/global_mem_insight.txt'), 'r', encoding='utf-8', errors='replace') as f: insight = f.read()\n        with open(os.path.join(script_dir, f'assets/insight_fixed_structure{suffix}.txt'), 'r', encoding='utf-8') as f: structure = f.read()\n        prompt += f'cwd = {os.path.join(script_dir, \"temp\")} (./)\\n'\n        prompt += f\"\\n[Memory] (../memory)\\n\"\n        prompt += structure + '\\n../memory/global_mem_insight.txt:\\n'\n        prompt += insight + \"\\n\"\n    except FileNotFoundError: pass\n    return prompt\n"
  },
  {
    "path": "hub.pyw",
    "content": "# launcher.pyw - GenericAgent 服务启动器\n# 纯 tkinter + 标准库，零第三方依赖，跨平台\nimport os, sys, socket, subprocess, threading\nimport tkinter as tk\nfrom tkinter import ttk\nfrom collections import deque\n\nLOCK_PORT = 19735\nBASE_DIR = os.path.dirname(os.path.abspath(__file__))\n\n\ndef acquire_singleton():\n    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n    try:\n        s.bind(('127.0.0.1', LOCK_PORT)); s.listen(1);return s\n    except OSError: return None\n\ndef discover_services():\n    services = []\n    EXCLUDES = {'goal_mode.py', 'chatapp_common.py', 'tuiapp.py'}\n    reflect_dir = os.path.join(BASE_DIR, 'reflect')\n    if os.path.isdir(reflect_dir):\n        for f in sorted(os.listdir(reflect_dir)):\n            if f.endswith('.py') and not f.startswith('_') and f not in EXCLUDES:\n                services.append({\n                    'name': 'reflect/' + f,\n                    'cmd': [sys.executable, 'agentmain.py', '--reflect', 'reflect/' + f],\n                })\n    frontends_dir = os.path.join(BASE_DIR, 'frontends')\n    if os.path.isdir(frontends_dir):\n        for f in sorted(os.listdir(frontends_dir)):\n            if 'app' in f and f.endswith('.py') and f not in EXCLUDES:\n                if 'stapp' in f: cmd = [sys.executable, '-m', 'streamlit', 'run', 'frontends/' + f, '--server.headless=true']\n                else: cmd = [sys.executable, 'frontends/' + f]\n                services.append({'name': 'frontends/' + f, 'cmd': cmd})\n    return services\n\n\nclass ServiceManager:\n    def __init__(self):\n        self.procs = {}\n        self.buffers = {}\n\n    def start(self, name, cmd):\n        if name in self.procs and self.procs[name].poll() is None:\n            return\n        self.buffers[name] = deque(maxlen=500)\n        env = os.environ.copy()\n        env['PYTHONUNBUFFERED'] = '1'\n        kw = dict(cwd=BASE_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n                  text=True, bufsize=1, env=env)\n        if sys.platform == 'win32':\n            kw['creationflags'] = subprocess.CREATE_NO_WINDOW\n        proc = subprocess.Popen(cmd, **kw)\n        self.procs[name] = proc\n        threading.Thread(target=self._reader, args=(name, proc), daemon=True).start()\n\n    def _reader(self, name, proc):\n        try:\n            for line in proc.stdout:\n                self.buffers[name].append(line)\n        except Exception:\n            pass\n\n    def stop(self, name):\n        proc = self.procs.get(name)\n        if proc and proc.poll() is None:\n            proc.terminate()\n            try:\n                proc.wait(timeout=5)\n            except subprocess.TimeoutExpired:\n                proc.kill()\n\n    def is_running(self, name):\n        proc = self.procs.get(name)\n        return proc is not None and proc.poll() is None\n\n    def stop_all(self):\n        for name in list(self.procs):\n            self.stop(name)\n\n    def get_output(self, name):\n        buf = self.buffers.get(name)\n        return list(buf) if buf else []\n\n\nclass LauncherApp:\n    def __init__(self, root):\n        self.root = root\n        self.root.title('GenericAgent Launcher')\n        self.root.geometry('720x740')\n        self.root.protocol('WM_DELETE_WINDOW', self.on_close)\n\n        self.mgr = ServiceManager()\n        self.services = discover_services()\n        self.check_vars = {}\n        self.selected = None\n\n        self._build_ui()\n        self._poll()\n\n    def _build_ui(self):\n        # 标题行：左边标签，右边 Rescan 按钮\n        header = ttk.Frame(self.root)\n        header.pack(fill='x', padx=8, pady=(8, 0))\n        ttk.Label(header, text='Services', font=('', 10, 'bold')).pack(side='left')\n        ttk.Button(header, text='\\u27f3 Rescan', width=10,\n                   command=self._rescan).pack(side='right')\n\n        svc_frame = ttk.LabelFrame(self.root, padding=5)\n        svc_frame.pack(fill='x', padx=8, pady=(2, 4))\n\n        self.svc_container = ttk.Frame(svc_frame)\n        self.svc_container.pack(fill='x')\n\n        self.status_labels = {}\n        self.row_frames = {}\n        self.name_labels = {}\n        self._build_service_rows()\n\n        self.output_frame = ttk.LabelFrame(self.root, text='Output', padding=5)\n        self.output_frame.pack(fill='both', expand=True, padx=8, pady=(4, 8))\n\n        self.output_text = tk.Text(\n            self.output_frame, wrap='word', state='disabled',\n            bg='#1e1e1e', fg='#d4d4d4',\n            font=('Consolas', 9), insertbackground='white')\n        sb = ttk.Scrollbar(self.output_frame, command=self.output_text.yview)\n        self.output_text.configure(yscrollcommand=sb.set)\n        sb.pack(side='right', fill='y')\n        self.output_text.pack(fill='both', expand=True)\n\n    def _build_service_rows(self):\n        for svc in self.services:\n            name = svc['name']\n            row = tk.Frame(self.svc_container, cursor='hand2', padx=4, pady=2)\n            row.pack(fill='x', pady=1)\n            self.row_frames[name] = row\n\n            running = self.mgr.is_running(name)\n            var = self.check_vars.get(name, tk.BooleanVar(value=running))\n            if running:\n                var.set(True)\n            self.check_vars[name] = var\n            cb = ttk.Checkbutton(\n                row, variable=var,\n                command=lambda n=name, v=var, s=svc: self._toggle(n, v, s))\n            cb.pack(side='left')\n\n            name_lbl = tk.Label(row, text=name, anchor='w', cursor='hand2',\n                                bg=row.cget('bg'))\n            name_lbl.pack(side='left', fill='x', expand=True)\n            self.name_labels[name] = name_lbl\n\n            st = 'running' if running else 'stopped'\n            fg = 'green' if running else 'gray'\n            lbl = ttk.Label(row, text=st, foreground=fg, width=10)\n            lbl.pack(side='right')\n            self.status_labels[name] = lbl\n\n            name_lbl.bind('<Button-1>', lambda e, n=name: self._select(n))\n            row.bind('<Button-1>', lambda e, n=name: self._select(n))\n\n    def _rescan(self):\n        # 记住正在运行的服务\n        running_names = {n for n in self.mgr.procs if self.mgr.is_running(n)}\n        # 清除旧行\n        for w in self.svc_container.winfo_children():\n            w.destroy()\n        self.status_labels.clear()\n        self.row_frames.clear()\n        self.name_labels.clear()\n        # 清除不再运行的 check_vars\n        old_vars = {k: v for k, v in self.check_vars.items() if k in running_names}\n        self.check_vars.clear()\n        self.check_vars.update(old_vars)\n        # 重新扫描\n        self.services = discover_services()\n        self._build_service_rows()\n        # 如果选中的服务不在新列表中，清除选中\n        svc_names = {s['name'] for s in self.services}\n        if self.selected and self.selected not in svc_names:\n            self.selected = None\n            self.output_frame.configure(text='Output')\n\n    def _toggle(self, name, var, svc):\n        if var.get():\n            self.mgr.start(name, svc['cmd'])\n            self._select(name)\n        else:\n            self.mgr.stop(name)\n\n    def _select(self, name):\n        self.selected = name\n        # 高亮选中行\n        for n, row in self.row_frames.items():\n            if n == name:\n                row.configure(bg='#cce5ff')\n                self.name_labels[n].configure(bg='#cce5ff')\n            else:\n                row.configure(bg='SystemButtonFace')\n                self.name_labels[n].configure(bg='SystemButtonFace')\n        self.output_frame.configure(text=f'Output - {name}')\n        self.root.after(50, self._refresh_output)\n\n    def _refresh_output(self):\n        if not self.selected:\n            return\n        lines = self.mgr.get_output(self.selected)\n        new_text = ''.join(lines[-200:])\n\n        # 跳过无变化的刷新，避免不必要的闪烁和位置扰动\n        current = self.output_text.get('1.0', 'end-1c')\n        if new_text.rstrip('\\n') == current.rstrip('\\n'):\n            return\n\n        # 记录滚动状态\n        _top, bot = self.output_text.yview()\n        at_bottom = bot >= 0.99\n\n        # 记录「距底部的行偏移」用于非底部时精确恢复位置\n        if not at_bottom:\n            old_total = int(self.output_text.index('end-1c').split('.')[0])\n            first_vis = int(float(self.output_text.index('@0,0')))\n            offset_from_end = old_total - first_vis\n\n        self.output_text.configure(state='normal')\n        self.output_text.delete('1.0', 'end')\n        self.output_text.insert('end', new_text)\n        self.output_text.configure(state='disabled')\n\n        if at_bottom:\n            self.output_text.see('end')\n        else:\n            # 用距底部偏移恢复，不受总行数变化影响\n            new_total = int(self.output_text.index('end-1c').split('.')[0])\n            target = max(1, new_total - offset_from_end)\n            self.output_text.yview_moveto(0)  # 先归零避免残留\n            self.output_text.see(f'{target}.0')\n\n    def _poll(self):\n        for svc in self.services:\n            name = svc['name']\n            running = self.mgr.is_running(name)\n            lbl = self.status_labels[name]\n            if running:\n                lbl.configure(text='running', foreground='green')\n            else:\n                lbl.configure(text='stopped', foreground='gray')\n                if self.check_vars[name].get():\n                    self.check_vars[name].set(False)\n        self._refresh_output()\n        self.root.after(1000, self._poll)\n\n    def on_close(self):\n        self.mgr.stop_all()\n        self.root.destroy()\n\n\nif __name__ == '__main__':\n    lock = acquire_singleton()\n    if lock is None:\n        try:\n            import tkinter.messagebox as mb\n            r = tk.Tk()\n            r.withdraw()\n            mb.showinfo('Launcher', 'Already running.')\n            r.destroy()\n        except Exception:\n            pass\n        sys.exit(0)\n\n    root = tk.Tk()\n    app = LauncherApp(root)\n    root.mainloop()\n    lock.close()\n"
  },
  {
    "path": "launch.pyw",
    "content": "import webview, threading, subprocess, sys, time, os, ctypes, atexit, socket, random\n\nWINDOW_WIDTH, WINDOW_HEIGHT, RIGHT_PADDING, TOP_PADDING = 600, 900, 0, 100\n\nscript_dir = os.path.dirname(os.path.abspath(__file__))\nfrontends_dir = os.path.join(script_dir, \"frontends\")\n\ndef find_free_port(lo=18501, hi=18599):\n    ports = list(range(lo, hi+1)); random.shuffle(ports)\n    for p in ports:\n        try: s = socket.socket(); s.bind(('127.0.0.1', p)); s.close(); return p\n        except OSError: continue\n    raise RuntimeError(f'No free port in {lo}-{hi}')\n\ndef get_screen_width():\n    try: return ctypes.windll.user32.GetSystemMetrics(0)\n    except: return 1920\n\ndef start_streamlit(port):\n    global proc\n    cmd = [sys.executable, \"-m\", \"streamlit\", \"run\", os.path.join(frontends_dir, \"stapp.py\"), \"--server.port\", str(port), \"--server.address\", \"localhost\", \"--server.headless\", \"true\", \"--client.toolbarMode\", \"viewer\"]\n    proc = subprocess.Popen(cmd)\n    atexit.register(proc.kill)\n\ndef inject(text):\n    window.evaluate_js(f\"\"\"\n        const textarea = document.querySelector('textarea[data-testid=\"stChatInputTextArea\"]');\n        if (textarea) {{\n            // 1. 用原生 setter 设置值（绕过 React）\n            const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;\n            nativeTextAreaValueSetter.call(textarea, {repr(text)});\n            // 2. 触发 React 的 input 事件\n            textarea.dispatchEvent(new Event('input', {{ bubbles: true }}));\n            // 3. 触发 change 事件（有些组件需要）\n            textarea.dispatchEvent(new Event('change', {{ bubbles: true }}));\n            // 4. 延迟提交\n            setTimeout(() => {{\n                const btn = document.querySelector('[data-testid=\"stChatInputSubmitButton\"]');\n                if (btn) {{btn.click();console.log('Submitted:', {repr(text)});}}\n            }}, 200);\n        }}\"\"\")\n\ndef get_last_reply_time():\n    last = window.evaluate_js(\"\"\"\n        const el = document.getElementById('last-reply-time');\n        el ? parseInt(el.textContent) : 0;\n    \"\"\") or 0\n    return last or int(time.time())\n\nPASTE_HOOK_JS = \"\"\"if (!window._pasteHooked) { window._pasteHooked = true;\n    document.addEventListener('paste', e => {\n        const items = e.clipboardData?.items; if (!items) return;\n        let t = null;\n        for (const item of items) { if (item.kind === 'file') { t = item.type.startsWith('image/') ? 'image in clipboard, ' : 'file in clipboard, '; break; } }\n        if (!t) return;\n        e.preventDefault(); e.stopImmediatePropagation();\n        const el = document.querySelector('textarea[data-testid=\"stChatInputTextArea\"]') || document.activeElement;\n        if (el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) {\n            const s = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set || Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;\n            s.call(el, el.value + t); el.dispatchEvent(new Event('input', { bubbles: true }));\n        }\n    }, true);\n}\"\"\"\n\ndef idle_monitor():\n    last_trigger_time = 0\n    while True:\n        time.sleep(5)\n        try:\n            window.evaluate_js(PASTE_HOOK_JS)\n            now = time.time()\n            if now - last_trigger_time < 120: continue\n            last_reply = get_last_reply_time()\n            if now - last_reply > 1800:\n                print('[Idle Monitor] Detected idle state, injecting task...')\n                inject(\"[AUTO]🤖 用户已经离开超过30分钟，作为自主智能体，请阅读自动化sop，执行自动任务。\")\n                last_trigger_time = now\n        except Exception as e:\n            print(f'[Idle Monitor] Error: {e}')\n\nif __name__ == '__main__':\n    import argparse\n    parser = argparse.ArgumentParser()\n    parser.add_argument('port', nargs='?', default='0'); \n    parser.add_argument('--tg', action='store_true', help='启动 Telegram Bot'); \n    parser.add_argument('--qq', action='store_true', help='启动 QQ Bot');\n    parser.add_argument('--feishu', '--fs', dest='feishu', action='store_true', help='启动 Feishu Bot');\n    parser.add_argument('--wechat', '--wx', dest='wechat', action='store_true', help='启动 WeChat Bot');\n    parser.add_argument('--wecom', action='store_true', help='启动 WeCom Bot');\n    parser.add_argument('--dingtalk', '--dt', dest='dingtalk', action='store_true', help='启动 DingTalk Bot');\n    parser.add_argument('--sched', action='store_true', help='启动计划任务调度器')\n    parser.add_argument('--llm_no', type=int, default=0, help='LLM编号')\n    args = parser.parse_args()\n    port = str(find_free_port()) if args.port == '0' else args.port\n    print(f'[Launch] Using port {port}')\n    threading.Thread(target=start_streamlit, args=(port,), daemon=True).start()\n\n    if args.tg:\n        tgproc = subprocess.Popen([sys.executable, os.path.join(frontends_dir, \"tgapp.py\")], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)\n        atexit.register(tgproc.kill)\n        print('[Launch] Telegram Bot started')\n    else: print('[Launch] Telegram Bot not enabled (use --tg to start)')\n\n    if args.qq:\n        qqproc = subprocess.Popen([sys.executable, os.path.join(frontends_dir, \"qqapp.py\")], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)\n        atexit.register(qqproc.kill)\n        print('[Launch] QQ Bot started')\n    else: print('[Launch] QQ Bot not enabled (use --qq to start)')\n\n    if args.feishu:\n        fsproc = subprocess.Popen([sys.executable, os.path.join(frontends_dir, \"fsapp.py\")], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)\n        atexit.register(fsproc.kill)\n        print('[Launch] Feishu Bot started')\n    else: print('[Launch] Feishu Bot not enabled (use --feishu to start)')\n\n    if args.wechat:\n        wxproc = subprocess.Popen([sys.executable, os.path.join(frontends_dir, 'wechatapp.py')], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)\n        atexit.register(wxproc.kill)\n        print('[Launch] WeChat Bot started')\n    else: print('[Launch] WeChat Bot not enabled (use --wechat to start)')\n\n    if args.wecom:\n        wcproc = subprocess.Popen([sys.executable, os.path.join(frontends_dir, \"wecomapp.py\")], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)\n        atexit.register(wcproc.kill)\n        print('[Launch] WeCom Bot started')\n    else: print('[Launch] WeCom Bot not enabled (use --wecom to start)')\n\n    if args.dingtalk:\n        dtproc = subprocess.Popen([sys.executable, os.path.join(frontends_dir, \"dingtalkapp.py\")], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)\n        atexit.register(dtproc.kill)\n        print('[Launch] DingTalk Bot started')\n    else: print('[Launch] DingTalk Bot not enabled (use --dingtalk to start)')\n    \n    if args.sched:\n        scheduler_proc = subprocess.Popen([sys.executable, os.path.join(script_dir, \"agentmain.py\"), \"--reflect\", os.path.join(script_dir, \"reflect\", \"scheduler.py\"), \"--llm_no\", str(args.llm_no)], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)\n        atexit.register(scheduler_proc.kill)\n        print('[Launch] Task Scheduler started (duplicate prevented by scheduler port lock)')\n    else: print('[Launch] Task Scheduler not enabled (--sched)')\n\n    monitor_thread = threading.Thread(target=idle_monitor, daemon=True)\n    monitor_thread.start()\n    if os.name == 'nt':\n        screen_width = get_screen_width()\n        x_pos = screen_width - WINDOW_WIDTH - RIGHT_PADDING\n    else: x_pos = 100\n    time.sleep(2) \n    window = webview.create_window(\n        title='GenericAgent', url=f'http://localhost:{port}',\n        width=WINDOW_WIDTH, height=WINDOW_HEIGHT, x=x_pos, y=TOP_PADDING,\n        resizable=True, text_select=True)\n    webview.start()\n"
  },
  {
    "path": "llmcore.py",
    "content": "import os, json, re, time, requests, sys, threading, urllib3, base64, importlib, uuid\nfrom datetime import datetime\nurllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)\n_RESP_CACHE_KEY = str(uuid.uuid4())\n\ndef _load_mykeys():\n    global _mykey_path\n    try:\n        import mykey; importlib.reload(mykey); _mykey_path = mykey.__file__\n        return {k: v for k, v in vars(mykey).items() if not k.startswith('_')}\n    except ImportError: pass\n    _mykey_path = p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mykey.json')\n    if not os.path.exists(p): raise Exception('[ERROR] mykey.py or mykey.json not found, please create one from mykey_template.')\n    with open(p, encoding='utf-8') as f: return json.load(f)\n\n_mykey_path = _mykey_mtime = None\ndef reload_mykeys():\n    global _mykey_mtime\n    mt = os.stat(_mykey_path).st_mtime_ns if _mykey_path else -1\n    if mt == _mykey_mtime: return globals().get('mykeys', {}), False\n    mk = _load_mykeys(); _mykey_mtime = os.stat(_mykey_path).st_mtime_ns\n    print(f'[Info] Load mykeys from {_mykey_path}')\n    globals().update(mykeys=mk)\n    if mk.get('langfuse_config'):\n        try: from plugins import langfuse_tracing\n        except Exception: pass\n    return mk, True\n\ndef __getattr__(name):  # once guard in PEP 562\n    if name == 'mykeys': return reload_mykeys()[0]\n    raise AttributeError(f\"module 'llmcore' has no attribute {name}\")\n\ndef compress_history_tags(messages, keep_recent=10, max_len=800, force=False):\n    \"\"\"Compress <thinking>/<tool_use>/<tool_result> tags in older messages to save tokens.\"\"\"\n    compress_history_tags._cd = getattr(compress_history_tags, '_cd', 0) + 1\n    if force: compress_history_tags._cd = 0\n    if compress_history_tags._cd % 5 != 0: return messages\n    _before = sum(len(json.dumps(m, ensure_ascii=False)) for m in messages)\n    _pats = {tag: re.compile(rf'(<{tag}>)([\\s\\S]*?)(</{tag}>)') for tag in ('thinking', 'think', 'tool_use', 'tool_result')}\n    _hist_pat = re.compile(r'<(history|key_info|earlier_context)>[\\s\\S]*?</\\1>')\n    def _trunc_str(s): return s[:max_len//2] + '\\n...[Truncated]...\\n' + s[-max_len//2:] if isinstance(s, str) and len(s) > max_len else s\n    def _trunc(text):\n        text = _hist_pat.sub(lambda m: f'<{m.group(1)}>[...]</{m.group(1)}>', text)\n        for pat in _pats.values(): text = pat.sub(lambda m: m.group(1) + _trunc_str(m.group(2)) + m.group(3), text)\n        return text\n    for i, msg in enumerate(messages):\n        if i >= len(messages) - keep_recent: break\n        c = msg['content']\n        if isinstance(c, str): msg['content'] = _trunc(c)\n        elif isinstance(c, list):\n            for b in c:\n                if not isinstance(b, dict): continue\n                t = b.get('type')\n                if t == 'text' and isinstance(b.get('text'), str): b['text'] = _trunc(b['text'])\n                elif t == 'tool_result':\n                    tc = b.get('content')\n                    if isinstance(tc, str): b['content'] = _trunc_str(tc)\n                    elif isinstance(tc, list):\n                        for sub in tc:\n                            if isinstance(sub, dict) and sub.get('type') == 'text': sub['text'] = _trunc_str(sub.get('text'))\n                elif t == 'tool_use' and isinstance(b.get('input'), dict):\n                    for k, v in b['input'].items(): b['input'][k] = _trunc_str(v)\n    print(f\"[Cut] {_before} -> {sum(len(json.dumps(m, ensure_ascii=False)) for m in messages)}\")\n    return messages\n\ndef _sanitize_leading_user_msg(msg):\n    \"\"\"把 user 消息里的 tool_result 块改写成纯文本，避免孤立引用。\n    history 统一使用 Claude content-block 格式：content 是 list of blocks。\"\"\"\n    msg = dict(msg)  # 浅拷贝外层 dict\n    content = msg.get('content')\n    if not isinstance(content, list): return msg\n    texts = []\n    for block in content:\n        if not isinstance(block, dict): continue\n        if block.get('type') == 'tool_result':\n            c = block.get('content', '')\n            if isinstance(c, list):  # content 本身也可能是 list[{type:text,text:...}]\n                texts.extend(b.get('text', '') for b in c if isinstance(b, dict))\n            else: texts.append(str(c))\n        elif block.get('type') == 'text': texts.append(block.get('text', ''))\n    msg['content'] = [{\"type\": \"text\", \"text\": '\\n'.join(t for t in texts if t)}]\n    return msg\n\n_oldprint = print\ndef safeprint(*argv):\n    try: _oldprint(*argv)\n    except OSError: pass\nprint = safeprint\n\ndef trim_messages_history(history, context_win):\n    compress_history_tags(history)\n    cost = sum(len(json.dumps(m, ensure_ascii=False)) for m in history) \n    print(f'[Debug] Current context: {cost} chars, {len(history)} messages.')\n    if cost > context_win * 3: \n        compress_history_tags(history, keep_recent=4, force=True)   # trim breaks cache, so compress more btw\n        target = context_win * 3 * 0.6\n        while len(history) > 5 and cost > target:\n            history.pop(0)\n            while history and history[0].get('role') != 'user': history.pop(0)\n            if history and history[0].get('role') == 'user': history[0] = _sanitize_leading_user_msg(history[0])\n            cost = sum(len(json.dumps(m, ensure_ascii=False)) for m in history)\n        print(f'[Debug] Trimmed context, current: {cost} chars, {len(history)} messages.')\n\ndef auto_make_url(base, path):\n    b, p = base.rstrip('/'), path.strip('/')\n    if b.endswith('$'): return b[:-1].rstrip('/')\n    if b.endswith(p): return b\n    return f\"{b}/{p}\" if re.search(r'/v\\d+(/|$)', b) else f\"{b}/v1/{p}\"\n\ndef _parse_claude_json(data):\n    content_blocks = data.get(\"content\", [])\n    _record_usage(data.get(\"usage\", {}), \"messages\")\n    for b in content_blocks:\n        if b.get(\"type\") == \"text\": yield b.get(\"text\", \"\")\n        elif b.get(\"type\") == \"thinking\": yield \"\"\n    return content_blocks\n\ndef _parse_claude_sse(resp_lines):\n    \"\"\"Parse Anthropic SSE stream. Yields text chunks, returns list[content_block].\"\"\"\n    content_blocks = []; current_block = None; tool_json_buf = \"\"\n    stop_reason = None; got_message_stop = False; warn = None\n    for line in resp_lines:\n        if not line: continue\n        line = line.decode('utf-8') if isinstance(line, bytes) else line\n        if not line.startswith(\"data:\"): continue\n        data_str = line[5:].lstrip()\n        if data_str == \"[DONE]\": break\n        try: evt = json.loads(data_str)\n        except Exception as e:\n            print(f\"[SSE] JSON parse error: {e}, line: {data_str[:200]}\")\n            continue\n        evt_type = evt.get(\"type\", \"\")\n        if evt_type == \"message_start\":\n            usage = evt.get(\"message\", {}).get(\"usage\", {})\n            _record_usage(usage, \"messages\")\n        elif evt_type == \"content_block_start\":\n            block = evt.get(\"content_block\", {})\n            if block.get(\"type\") == \"text\": current_block = {\"type\": \"text\", \"text\": \"\"}\n            elif block.get(\"type\") == \"thinking\": current_block = {\"type\": \"thinking\", \"thinking\": \"\", \"signature\": \"\"}\n            elif block.get(\"type\") == \"tool_use\":\n                current_block = {\"type\": \"tool_use\", \"id\": block.get(\"id\", \"\"), \"name\": block.get(\"name\", \"\"), \"input\": {}}\n                tool_json_buf = \"\"\n        elif evt_type == \"content_block_delta\":\n            delta = evt.get(\"delta\", {})\n            if delta.get(\"type\") == \"text_delta\":\n                text = delta.get(\"text\", \"\")\n                if current_block and current_block.get(\"type\") == \"text\": current_block[\"text\"] += text\n                if text: yield text\n            elif delta.get(\"type\") == \"thinking_delta\":\n                if current_block and current_block.get(\"type\") == \"thinking\": current_block[\"thinking\"] += delta.get(\"thinking\", \"\")\n            elif delta.get(\"type\") == \"signature_delta\":\n                if current_block and current_block.get(\"type\") == \"thinking\":\n                    current_block[\"signature\"] = current_block.get(\"signature\", \"\") + delta.get(\"signature\", \"\")\n            elif delta.get(\"type\") == \"input_json_delta\": tool_json_buf += delta.get(\"partial_json\", \"\")\n        elif evt_type == \"content_block_stop\":\n            if current_block:\n                if current_block[\"type\"] == \"tool_use\":\n                    try: current_block[\"input\"] = json.loads(tool_json_buf) if tool_json_buf else {}\n                    except: current_block[\"input\"] = {\"_raw\": tool_json_buf}\n                content_blocks.append(current_block)\n                current_block = None\n        elif evt_type == \"message_delta\":\n            delta = evt.get(\"delta\", {})\n            stop_reason = delta.get(\"stop_reason\", stop_reason)\n            out_usage = evt.get(\"usage\", {})\n            out_tokens = out_usage.get(\"output_tokens\", 0)\n            if out_tokens: print(f\"[Output] tokens={out_tokens} stop_reason={stop_reason}\")\n        elif evt_type == \"message_stop\": got_message_stop = True\n        elif evt_type == \"error\":\n            err = evt.get(\"error\", {})\n            emsg = err.get(\"message\", str(err)) if isinstance(err, dict) else str(err)\n            warn = f\"\\n\\n!!!Error: SSE {emsg}\"; break\n    if not warn:\n        if not got_message_stop and not stop_reason: warn = \"\\n\\n[!!! 流异常中断，未收到完整响应 !!!]\"\n        elif stop_reason == \"max_tokens\": warn = \"\\n\\n[!!! Response truncated: max_tokens !!!]\"\n    if current_block:\n        if current_block[\"type\"] == \"tool_use\":\n            try: current_block[\"input\"] = json.loads(tool_json_buf) if tool_json_buf else {}\n            except: current_block[\"input\"] = {\"_raw\": tool_json_buf}\n        content_blocks.append(current_block); current_block = None\n    if warn:\n        print(f\"[WARN] {warn.strip()}\")\n        content_blocks.append({\"type\": \"text\", \"text\": warn}); yield warn\n    return content_blocks\n\ndef _try_parse_tool_args(raw):\n    \"\"\"Parse tool args string; split concatenated JSON objects like {..}{..} if needed.\n    Returns list of parsed dicts.\"\"\"\n    if not raw: return [{}]\n    try: return [json.loads(raw)]\n    except: pass\n    parts = re.split(r'(?<=\\})(?=\\{)', raw)\n    if len(parts) > 1:\n        parsed = []\n        for p in parts:\n            try: parsed.append(json.loads(p))\n            except: return [{\"_raw\": raw}]\n        return parsed\n    return [{\"_raw\": raw}]\n\ndef _parse_openai_sse(resp_lines, api_mode=\"chat_completions\"):\n    \"\"\"Parse OpenAI SSE stream (chat_completions or responses API).\n    Yields text chunks, returns list[content_block].\n    content_block: {type:'text', text:str} | {type:'tool_use', id:str, name:str, input:dict}\n    \"\"\"\n    content_text = \"\"\n    if api_mode == \"responses\":\n        seen_delta = False; fc_buf = {}; current_fc_idx = None\n        for line in resp_lines:\n            if not line: continue\n            line = line.decode('utf-8', errors='replace') if isinstance(line, bytes) else line\n            if not line.startswith(\"data:\"): continue\n            data_str = line[5:].lstrip()\n            if data_str == \"[DONE]\": break\n            try: evt = json.loads(data_str)\n            except: continue\n            etype = evt.get(\"type\", \"\")\n            if etype == \"response.output_text.delta\":\n                delta = evt.get(\"delta\", \"\")\n                if delta: seen_delta = True; content_text += delta; yield delta\n            elif etype == \"response.output_text.done\" and not seen_delta:\n                text = evt.get(\"text\", \"\")\n                if text: content_text += text; yield text\n            elif etype == \"response.output_item.added\":\n                item = evt.get(\"item\", {})\n                if item.get(\"type\") == \"function_call\":\n                    idx = evt.get(\"output_index\", 0)\n                    fc_buf[idx] = {\"id\": item.get(\"call_id\", item.get(\"id\", \"\")), \"name\": item.get(\"name\", \"\"), \"args\": \"\"}\n                    current_fc_idx = idx\n            elif etype == \"response.function_call_arguments.delta\":\n                idx = evt.get(\"output_index\", current_fc_idx or 0)\n                if idx in fc_buf: fc_buf[idx][\"args\"] += evt.get(\"delta\", \"\")\n            elif etype == \"response.function_call_arguments.done\":\n                idx = evt.get(\"output_index\", current_fc_idx or 0)\n                if idx in fc_buf: fc_buf[idx][\"args\"] = evt.get(\"arguments\", fc_buf[idx][\"args\"])\n            elif etype == \"error\":\n                err = evt.get(\"error\", {})\n                emsg = err.get(\"message\", str(err)) if isinstance(err, dict) else str(err)\n                if emsg: content_text += f\"!!!Error: {emsg}\"; yield f\"!!!Error: {emsg}\"\n                break\n            elif etype == \"response.completed\":\n                usage = evt.get(\"response\", {}).get(\"usage\", {})\n                _record_usage(usage, api_mode)\n                break\n        blocks = []\n        if content_text: blocks.append({\"type\": \"text\", \"text\": content_text})\n        for idx in sorted(fc_buf):\n            fc = fc_buf[idx]\n            inps = _try_parse_tool_args(fc[\"args\"])\n            for i, inp in enumerate(inps):\n                bid = fc[\"id\"] or ''\n                if len(inps) > 1: bid = f\"{bid}_{i}\" if bid else f\"split_{i}\"\n                blocks.append({\"type\": \"tool_use\", \"id\": bid, \"name\": fc[\"name\"], \"input\": inp})\n        return blocks\n    else:\n        tc_buf = {}  # index -> {id, name, args}\n        reasoning_text = \"\"\n        for line in resp_lines:\n            if not line: continue\n            line = line.decode('utf-8', errors='replace') if isinstance(line, bytes) else line\n            if not line.startswith(\"data:\"): continue\n            data_str = line[5:].lstrip()\n            if data_str == \"[DONE]\": break\n            try: evt = json.loads(data_str)\n            except: continue\n            ch = (evt.get(\"choices\") or [{}])[0]\n            delta = ch.get(\"delta\") or {}\n            if delta.get(\"reasoning_content\"):\n                reasoning_text += delta[\"reasoning_content\"]\n            if delta.get(\"content\"):\n                text = delta[\"content\"]; content_text += text; yield text\n            for tc in (delta.get(\"tool_calls\") or []):\n                idx = tc.get(\"index\", 0)\n                has_name = bool(tc.get(\"function\", {}).get(\"name\"))\n                if idx not in tc_buf:\n                    if has_name or not tc_buf: tc_buf[idx] = {\"id\": tc.get(\"id\") or '', \"name\": \"\", \"args\": \"\"}\n                    else: idx = max(tc_buf)\n                if has_name: tc_buf[idx][\"name\"] = tc[\"function\"][\"name\"]\n                if tc.get(\"function\", {}).get(\"arguments\"): tc_buf[idx][\"args\"] += tc[\"function\"][\"arguments\"]\n                if tc.get(\"id\") and not tc_buf[idx][\"id\"]: tc_buf[idx][\"id\"] = tc[\"id\"]\n            usage = evt.get(\"usage\")\n            if usage: _record_usage(usage, api_mode)\n        blocks = []\n        if reasoning_text: blocks.append({\"type\": \"thinking\", \"thinking\": reasoning_text})\n        if content_text: blocks.append({\"type\": \"text\", \"text\": content_text})\n        for idx in sorted(tc_buf):\n            tc = tc_buf[idx]\n            inps = _try_parse_tool_args(tc[\"args\"])\n            for i, inp in enumerate(inps):\n                bid = tc[\"id\"] or ''\n                if len(inps) > 1: bid = f\"{bid}_{i}\" if bid else f\"split_{i}\"\n                blocks.append({\"type\": \"tool_use\", \"id\": bid, \"name\": tc[\"name\"], \"input\": inp})\n        return blocks\n\ndef _record_usage(usage, api_mode):\n    if not usage: return\n    if api_mode == 'responses':\n        cached = (usage.get(\"input_tokens_details\") or {}).get(\"cached_tokens\", 0)\n        inp = usage.get(\"input_tokens\", 0)\n        print(f\"[Cache] input={inp} cached={cached}\")\n    elif api_mode == 'chat_completions':\n        cached = (usage.get(\"prompt_tokens_details\") or {}).get(\"cached_tokens\", 0)\n        inp = usage.get(\"prompt_tokens\", 0)\n        print(f\"[Cache] input={inp} cached={cached}\")\n    elif api_mode == 'messages':\n        ci, cr, inp = usage.get(\"cache_creation_input_tokens\", 0), usage.get(\"cache_read_input_tokens\", 0), usage.get(\"input_tokens\", 0)\n        print(f\"[Cache] input={inp} creation={ci} read={cr}\")\n    \ndef _parse_openai_json(data, api_mode=\"chat_completions\"):\n    blocks = []\n    if api_mode == \"responses\":\n        _record_usage(data.get(\"usage\") or {}, api_mode)\n        for item in (data.get(\"output\") or []):\n            if item.get(\"type\") == \"message\":\n                for p in (item.get(\"content\") or []):\n                    if p.get(\"type\") in (\"output_text\", \"text\") and p.get(\"text\"):\n                        blocks.append({\"type\": \"text\", \"text\": p[\"text\"]}); yield p[\"text\"]\n            elif item.get(\"type\") == \"function_call\":\n                try: args = json.loads(item.get(\"arguments\", \"\")) if item.get(\"arguments\") else {}\n                except: args = {\"_raw\": item.get(\"arguments\", \"\")}\n                blocks.append({\"type\": \"tool_use\", \"id\": item.get(\"call_id\", item.get(\"id\", \"\")),\n                               \"name\": item.get(\"name\", \"\"), \"input\": args})\n    else:\n        _record_usage(data.get(\"usage\") or {}, api_mode)\n        msg = (data.get(\"choices\") or [{}])[0].get(\"message\", {})\n        reasoning = msg.get(\"reasoning_content\", \"\")\n        if reasoning:\n            blocks.append({\"type\": \"thinking\", \"thinking\": reasoning})\n        content = msg.get(\"content\", \"\")\n        if content:\n            blocks.append({\"type\": \"text\", \"text\": content}); yield content\n        for tc in (msg.get(\"tool_calls\") or []):\n            fn = tc.get(\"function\", {})\n            try: args = json.loads(fn.get(\"arguments\", \"\")) if fn.get(\"arguments\") else {}\n            except: args = {\"_raw\": fn.get(\"arguments\", \"\")}\n            blocks.append({\"type\": \"tool_use\", \"id\": tc.get(\"id\", \"\"), \"name\": fn.get(\"name\", \"\"), \"input\": args})\n    return blocks\n\ndef _stamp_oai_cache_markers(messages, model):\n    \"\"\"Add cache_control to last 2 user messages for Anthropic models via OAI-compatible relay.\"\"\"\n    ml = model.lower()\n    if not any(k in ml for k in ('claude', 'anthropic')): return\n    user_idxs = [i for i, m in enumerate(messages) if m.get('role') == 'user']\n    for idx in user_idxs[-2:]:\n        c = messages[idx].get('content')\n        if isinstance(c, str):\n            messages[idx] = {**messages[idx], 'content': [{'type': 'text', 'text': c, 'cache_control': {'type': 'ephemeral'}}]}\n        elif isinstance(c, list) and c:\n            c = list(c); c[-1] = dict(c[-1], cache_control={'type': 'ephemeral'})\n            messages[idx] = {**messages[idx], 'content': c}\n\ndef _stream_with_retry(sess, url, headers, payload, parse_fn):\n    _RETRYABLE = {408, 409, 425, 429, 500, 502, 503, 504, 529}\n    def _delay(resp, attempt):\n        try: ra = float((resp.headers or {}).get(\"retry-after\"))\n        except: ra = None\n        return max(0.5, ra if ra is not None else min(30.0, 1.5 * (2 ** attempt)))\n    for attempt in range(sess.max_retries + 1):\n        streamed = False\n        try:\n            with requests.post(url, headers=headers, json=payload, stream=sess.stream, \n                               timeout=(sess.connect_timeout, sess.read_timeout), proxies=sess.proxies, verify=sess.verify) as r:\n                if r.status_code >= 400:\n                    if r.status_code in _RETRYABLE and attempt < sess.max_retries:\n                        d = _delay(r, attempt)\n                        print(f\"[LLM Retry] HTTP {r.status_code}, retry in {d:.1f}s ({attempt+1}/{sess.max_retries+1})\")\n                        time.sleep(d); continue\n                    try: body = r.text.strip()[:500]\n                    except: body = \"\"\n                    err = f\"!!!Error: HTTP {r.status_code}\" + (f\": {body}\" if body else \"\")\n                    yield err; return [{\"type\": \"text\", \"text\": err}]\n                gen = parse_fn(r)\n                try:\n                    while True: streamed = True; yield next(gen)\n                except StopIteration as e: return e.value or []\n        except (requests.Timeout, requests.ConnectionError) as e:\n            err = f\"!!!Error: {type(e).__name__}\"\n            if attempt < sess.max_retries:\n                d = _delay(None, attempt)\n                print(f\"[LLM Retry] {type(e).__name__}, retry in {d:.1f}s ({attempt+1}/{sess.max_retries+1})\")\n                yield err; time.sleep(d); continue\n            yield err; return [{\"type\": \"text\", \"text\": err}]\n        except Exception as e:\n            err = f\"\\n\\n[!!! 流异常中断 {type(e).__name__}: {e} !!!]\" if streamed else f\"!!!Error: {type(e).__name__}: {e}\"\n            yield err; return [{\"type\": \"text\", \"text\": err}]\n\ndef _openai_stream(sess, messages):\n    model, api_mode = sess.model, sess.api_mode\n    ml = model.lower()\n    temperature = sess.temperature\n    if 'kimi' in ml or 'moonshot' in ml: temperature = 1\n    elif 'minimax' in ml: temperature = max(0.01, min(temperature, 1.0))  # MiniMax requires temp in (0, 1]\n    headers = {\"Authorization\": f\"Bearer {sess.api_key}\", \"Content-Type\": \"application/json\", \"Accept\": \"text/event-stream\"}\n    if api_mode == \"responses\":\n        url = auto_make_url(sess.api_base, \"responses\")\n        payload = {\"model\": model, \"input\": _to_responses_input(messages), \"stream\": sess.stream, \n                   \"prompt_cache_key\": _RESP_CACHE_KEY, \"instructions\": sess.system or \"You are an Omnipotent Executor.\"}\n        if sess.reasoning_effort: payload[\"reasoning\"] = {\"effort\": sess.reasoning_effort}\n        if sess.max_tokens: payload[\"max_output_tokens\"] = sess.max_tokens\n    else:\n        url = auto_make_url(sess.api_base, \"chat/completions\")\n        if sess.system: messages = [{\"role\": \"system\", \"content\": sess.system}] + messages\n        _stamp_oai_cache_markers(messages, model)\n        payload = {\"model\": model, \"messages\": messages, \"stream\": sess.stream}\n        if sess.stream: payload[\"stream_options\"] = {\"include_usage\": True}\n        if temperature != 1: payload[\"temperature\"] = temperature\n        if sess.max_tokens: payload[\"max_completion_tokens\" if ml.startswith((\"gpt-5\", \"o1\", \"o2\", \"o3\", \"o4\")) else \"max_tokens\"] = sess.max_tokens\n        if sess.reasoning_effort: payload[\"reasoning_effort\"] = sess.reasoning_effort\n    tools = getattr(sess, 'tools', None)\n    if tools: payload[\"tools\"] = _prepare_oai_tools(tools, api_mode)\n    if sess.service_tier: payload[\"service_tier\"] = sess.service_tier\n    parse_fn = (lambda r: _parse_openai_sse(r.iter_lines(), api_mode)) if sess.stream else (lambda r: _parse_openai_json(r.json(), api_mode))\n    return (yield from _stream_with_retry(sess, url, headers, payload, parse_fn))\n        \ndef _prepare_oai_tools(tools, api_mode=\"chat_completions\"):\n    if api_mode == \"responses\":\n        resp_tools = []\n        for t in tools:\n            if t.get(\"type\") == \"function\" and \"function\" in t:\n                rt = {\"type\": \"function\"}; rt.update(t[\"function\"])\n                resp_tools.append(rt)\n            else: resp_tools.append(t)\n        return resp_tools\n    return tools\n\ndef _to_responses_input(messages):\n    result, pending = [], []\n    for msg in messages:\n        role = str(msg.get(\"role\", \"user\")).lower()\n        if role == \"tool\":\n            cid = msg.get(\"tool_call_id\") or (pending.pop(0) if pending else f\"call_{uuid.uuid4().hex[:8]}\")\n            result.append({\"type\": \"function_call_output\", \"call_id\": cid, \"output\": msg.get(\"content\", \"\")})\n            continue\n        if role not in [\"user\", \"assistant\", \"system\", \"developer\"]: role = \"user\"\n        if role == \"system\": role = \"developer\"  # Responses API uses 'developer' instead of 'system'\n        content = msg.get(\"content\", \"\")\n        text_type = \"output_text\" if role == \"assistant\" else \"input_text\"\n        parts = []\n        if isinstance(content, str):\n            if content: parts.append({\"type\": text_type, \"text\": content})\n        elif isinstance(content, list):\n            for part in content:\n                if not isinstance(part, dict): continue\n                ptype = part.get(\"type\")\n                if ptype == \"text\":\n                    text = part.get(\"text\", \"\")\n                    if text: parts.append({\"type\": text_type, \"text\": text})\n                elif ptype == \"image_url\":\n                    url = (part.get(\"image_url\") or {}).get(\"url\", \"\")\n                    if url and role != \"assistant\": parts.append({\"type\": \"input_image\", \"image_url\": url})\n        if len(parts) == 0: parts = [{\"type\": text_type, \"text\": str(content) if not isinstance(content, list) else '[empty]'}]\n        result.append({\"role\": role, \"content\": parts})\n        pending = []\n        for tc in (msg.get(\"tool_calls\") or []):\n            f = tc.get(\"function\", {})\n            cid = tc.get(\"id\") or f\"call_{uuid.uuid4().hex[:8]}\"\n            pending.append(cid)\n            result.append({\"type\": \"function_call\", \"call_id\": cid, \"name\": f.get(\"name\", \"\"), \"arguments\": f.get(\"arguments\", \"\")})\n    return result\n\n\ndef _msgs_claude2oai(messages):\n    result = []\n    for msg in messages:\n        role = msg.get(\"role\", \"user\")\n        content = msg.get(\"content\", \"\")\n        blocks = content if isinstance(content, list) else [{\"type\": \"text\", \"text\": str(content)}]\n        if role == \"assistant\":\n            text_parts, tool_calls, reasoning = [], [], \"\"\n            for b in blocks:\n                if not isinstance(b, dict): continue\n                if b.get(\"type\") == \"thinking\" and b.get(\"thinking\"): reasoning = b[\"thinking\"]\n                elif b.get(\"type\") == \"text\" and b.get(\"text\"): text_parts.append({\"type\": \"text\", \"text\": b.get(\"text\", \"\")})\n                elif b.get(\"type\") == \"tool_use\":\n                    tool_calls.append({\n                        \"id\": b.get(\"id\") or '', \"type\": \"function\",\n                        \"function\": {\"name\": b.get(\"name\", \"\"), \"arguments\": json.dumps(b.get(\"input\", {}), ensure_ascii=False)}\n                    })\n            m = {\"role\": \"assistant\"}\n            if reasoning: m[\"reasoning_content\"] = reasoning\n            if text_parts: m[\"content\"] = text_parts\n            elif not tool_calls: m[\"content\"] = \".\"\n            if tool_calls: m[\"tool_calls\"] = tool_calls\n            result.append(m)\n        elif role == \"user\":\n            text_parts = []\n            for b in blocks:\n                if not isinstance(b, dict): continue\n                if b.get(\"type\") == \"tool_result\":\n                    if text_parts:\n                        result.append({\"role\": \"user\", \"content\": text_parts})\n                        text_parts = []\n                    tr = b.get(\"content\", \"\")\n                    if isinstance(tr, list):\n                        tr = \"\\n\".join(x.get(\"text\", \"\") for x in tr if isinstance(x, dict) and x.get(\"type\") == \"text\")\n                    result.append({\"role\": \"tool\", \"tool_call_id\": b.get(\"tool_use_id\") or '', \"content\": tr if isinstance(tr, str) else str(tr)})\n                elif b.get(\"type\") == \"image\":\n                    src = b.get(\"source\") or {}\n                    if src.get(\"type\") == \"base64\" and src.get(\"data\"):\n                        text_parts.append({\"type\": \"image_url\", \"image_url\": {\"url\": f\"data:{src.get('media_type', 'image/png')};base64,{src.get('data', '')}\"}})\n                elif b.get(\"type\") == \"image_url\": text_parts.append(b)\n                elif b.get(\"type\") == \"text\" and b.get(\"text\"): text_parts.append({\"type\": \"text\", \"text\": b.get(\"text\", \"\")})\n            if text_parts: result.append({\"role\": \"user\", \"content\": text_parts})\n        else: result.append(msg)\n    return result\n\n\nclass BaseSession:\n    def __init__(self, cfg):\n        self.api_key = cfg['apikey']\n        self.api_base = cfg['apibase'].rstrip('/')\n        self.model = cfg.get('model', '')\n        self.context_win = cfg.get('context_win', 28000)\n        self.history = []\n        self.lock = threading.Lock()\n        self.system = \"\"\n        self.name = cfg.get('name', self.model)\n        proxy = cfg.get('proxy')\n        self.proxies = {\"http\": proxy, \"https\": proxy} if proxy else None\n        self.max_retries = max(0, int(cfg.get('max_retries', 4)))\n        self.verify = cfg.get('verify', True)\n        self.stream = cfg.get('stream', True)\n        default_ct, default_rt = (5, 30) if self.stream else (10, 240)\n        self.connect_timeout = max(1, int(cfg.get('timeout', default_ct)))\n        self.read_timeout = max(5, int(cfg.get('read_timeout', default_rt)))\n        def _enum(key, valid):\n            v = cfg.get(key); v = None if v is None else str(v).strip().lower()\n            return v if not v or v in valid else print(f\"[WARN] Invalid {key} {v!r}, ignored.\")\n        self.reasoning_effort = _enum('reasoning_effort', {'none', 'minimal', 'low', 'medium', 'high', 'xhigh'})\n        self.service_tier = _enum('service_tier', {'auto', 'default', 'priority', 'flex'})\n        self.thinking_type = _enum('thinking_type', {'adaptive', 'enabled', 'disabled'})\n        self.thinking_budget_tokens = cfg.get('thinking_budget_tokens')\n        mode = str(cfg.get('api_mode', 'chat_completions')).strip().lower().replace('-', '_')\n        self.api_mode = 'responses' if mode in ('responses', 'response') else 'chat_completions'\n        self.temperature = cfg.get('temperature', 1)\n        self.max_tokens = cfg.get('max_tokens')\n    def _apply_claude_thinking(self, payload):\n        if self.thinking_type:\n            thinking = {\"type\": self.thinking_type}\n            if self.thinking_type == 'enabled':\n                if self.thinking_budget_tokens is None: print(\"[WARN] thinking_type='enabled' requires thinking_budget_tokens, ignored.\")\n                else:\n                    thinking[\"budget_tokens\"] = self.thinking_budget_tokens; payload[\"thinking\"] = thinking\n            else: payload[\"thinking\"] = thinking\n        if self.reasoning_effort:\n            effort = {'low': 'low', 'medium': 'medium', 'high': 'high', 'xhigh': 'max'}.get(self.reasoning_effort)\n            if effort: payload[\"output_config\"] = {\"effort\": effort}\n            else: print(f\"[WARN] reasoning_effort {self.reasoning_effort!r} is unsupported for Claude output_config.effort, ignored.\")\n    def ask(self, prompt):\n        def _ask_gen():\n            with self.lock:\n                self.history.append({\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": prompt}]})\n                trim_messages_history(self.history, self.context_win)\n                messages = self.make_messages(self.history)\n            content_blocks = None; content = ''\n            gen = self.raw_ask(messages)\n            try:\n                while True: chunk = next(gen); content += chunk; yield chunk\n            except StopIteration as e: content_blocks = e.value or []\n            if len(content_blocks) > 1: print(f\"[DEBUG BaseSession.ask] content_blocks: {content_blocks}\")\n            for block in (content_blocks or []):\n                if block.get('type', '') == 'tool_use':\n                    tu = {'name': block.get('name', ''), 'arguments': block.get('input', {})}\n                    yield f'<tool_use>{json.dumps(tu, ensure_ascii=False)}</tool_use>'\n            if content.strip() and not content.startswith(\"!!!Error:\"): self.history.append({\"role\": \"assistant\", \"content\": [{\"type\": \"text\", \"text\": content}]})\n        return _ask_gen() if self.stream else ''.join(list(_ask_gen()))\n\ndef _keep_claude_block(b): return not isinstance(b, dict) or b.get(\"type\") != \"thinking\" or b.get(\"signature\")\ndef _drop_unsigned_thinking(messages):\n    for m in messages:\n        c = m.get(\"content\")\n        if isinstance(c, list): m[\"content\"] = [b for b in c if _keep_claude_block(b)]\n    return messages\n\ndef _ensure_thinking_blocks(messages, model):\n    \"\"\"deepseek needs thinking in history!\"\"\"\n    if 'deepseek' not in model.lower(): return messages\n    for m in messages:\n        if m.get(\"role\") != \"assistant\": continue\n        c = m.get(\"content\")\n        if not isinstance(c, list): continue\n        has_thinking = any(isinstance(b, dict) and b.get(\"type\") == \"thinking\" for b in c)\n        if not has_thinking: m[\"content\"] = [{\"type\": \"thinking\", \"thinking\": \"...\", \"signature\": \"placeholder\"}, *c]\n    return messages\n\nclass ClaudeSession(BaseSession):\n    def raw_ask(self, messages):\n        messages = _fix_messages(messages)\n        if self.max_tokens is None: self.max_tokens = 8192\n        headers = {\"x-api-key\": self.api_key, \"Content-Type\": \"application/json\", \"anthropic-version\": \"2023-06-01\", \"anthropic-beta\": \"prompt-caching-2024-07-31\"}\n        payload = {\"model\": self.model, \"messages\": messages, \"max_tokens\": self.max_tokens, \"stream\": self.stream}\n        if self.temperature != 1: payload[\"temperature\"] = self.temperature\n        self._apply_claude_thinking(payload)\n        if self.system: payload[\"system\"] = [{\"type\": \"text\", \"text\": self.system, \"cache_control\": {\"type\": \"persistent\"}}]\n        url = auto_make_url(self.api_base, \"messages\")\n        parse_fn = (lambda r: _parse_claude_sse(r.iter_lines())) if self.stream else (lambda r: _parse_claude_json(r.json()))\n        return (yield from _stream_with_retry(self, url, headers, payload, parse_fn))\n    def make_messages(self, raw_list):\n        msgs = _drop_unsigned_thinking([{\"role\": m['role'], \"content\": list(m['content'])} for m in raw_list])\n        user_idxs = [i for i, m in enumerate(msgs) if m['role'] == 'user']\n        for idx in user_idxs[-2:]:\n            msgs[idx][\"content\"][-1] = dict(msgs[idx][\"content\"][-1], cache_control={\"type\": \"ephemeral\"})\n        return msgs\n\nclass LLMSession(BaseSession):\n    def raw_ask(self, messages): return (yield from _openai_stream(self, messages))\n    def make_messages(self, raw_list): return _msgs_claude2oai(_fix_messages(raw_list))\n\ndef _fix_messages(messages):\n    \"\"\"修复 messages 符合 Claude API：交替、tool_use/tool_result 配对\"\"\"\n    if not messages: return messages\n    _wrap = lambda c: c if isinstance(c, list) else [{\"type\": \"text\", \"text\": str(c)}]\n    fixed = []\n    for m in messages:\n        if fixed and m['role'] == fixed[-1]['role']:\n            fixed[-1] = {**fixed[-1], 'content': _wrap(fixed[-1]['content']) + [{\"type\": \"text\", \"text\": \"\\n\"}] + _wrap(m['content'])}; continue\n        if fixed and fixed[-1]['role'] == 'assistant' and m['role'] == 'user':\n            uses = [b.get('id') for b in fixed[-1].get('content', []) if isinstance(b, dict) and b.get('type') == 'tool_use' and b.get('id')]\n            has = {b.get('tool_use_id') for b in _wrap(m['content']) if isinstance(b, dict) and b.get('type') == 'tool_result'}\n            miss = [uid for uid in uses if uid not in has]\n            if miss: m = {**m, 'content': [{\"type\": \"tool_result\", \"tool_use_id\": uid, \"content\": \"(error)\"} for uid in miss] + _wrap(m['content'])}\n            orphan = has - set(uses)\n            if orphan: m = {**m, 'content': [{\"type\":\"text\",\"text\":str(b.get('content',''))} if isinstance(b,dict) and b.get('type')=='tool_result' and b.get('tool_use_id') in orphan else b for b in _wrap(m['content'])]}\n        fixed.append(m)\n    while fixed and fixed[0]['role'] != 'user': fixed.pop(0)\n    return fixed\n\nclass NativeClaudeSession(BaseSession):\n    def __init__(self, cfg):\n        super().__init__(cfg)\n        self.fake_cc_system_prompt = cfg.get(\"fake_cc_system_prompt\", False)\n        self.user_agent = cfg.get(\"user_agent\", \"claude-cli/2.1.113 (external, cli)\")\n        self._session_id = str(uuid.uuid4())\n        self._account_uuid = str(uuid.uuid4())\n        self._device_id = uuid.uuid4().hex + uuid.uuid4().hex[:32]\n        self.tools = None\n    def raw_ask(self, messages):\n        messages = _ensure_thinking_blocks(_drop_unsigned_thinking(_fix_messages(messages)), self.model)\n        if self.max_tokens is None: self.max_tokens = 8192\n        model = self.model\n        beta_parts = [\"claude-code-20250219\", \"interleaved-thinking-2025-05-14\", \"redact-thinking-2026-02-12\", \"prompt-caching-scope-2026-01-05\"]\n        if \"[1m]\" in model.lower():\n            beta_parts.insert(1, \"context-1m-2025-08-07\"); model = model.replace(\"[1m]\", \"\").replace(\"[1M]\", \"\")\n        headers = {\"Content-Type\": \"application/json\", \"anthropic-version\": \"2023-06-01\",\n            \"anthropic-beta\": \",\".join(beta_parts), \"anthropic-dangerous-direct-browser-access\": \"true\",\n            \"user-agent\": self.user_agent, \"x-app\": \"cli\"}\n        if self.api_key.startswith(\"sk-ant-\"): headers[\"x-api-key\"] = self.api_key\n        else: headers[\"authorization\"] = f\"Bearer {self.api_key}\"\n        payload = {\"model\": model, \"messages\": messages, \"max_tokens\": self.max_tokens, \"stream\": self.stream}\n        if self.temperature != 1: payload[\"temperature\"] = self.temperature\n        self._apply_claude_thinking(payload)\n        payload[\"metadata\"] = {\"user_id\": json.dumps({\"device_id\": self._device_id, \"account_uuid\": self._account_uuid, \"session_id\": self._session_id}, separators=(',', ':'))}\n        if self.tools:\n            claude_tools = openai_tools_to_claude(self.tools)\n            tools = [dict(t) for t in claude_tools]; tools[-1][\"cache_control\"] = {\"type\": \"ephemeral\"}\n            payload[\"tools\"] = tools\n        else: print(\"[ERROR] No tools provided for this session.\")\n        payload['system'] = [{\"type\": \"text\", \"text\": \"You are Claude Code, Anthropic's official CLI for Claude.\", \"cache_control\": {\"type\": \"ephemeral\"}}]\n        if self.system:\n            if self.fake_cc_system_prompt: messages[0][\"content\"].insert(0, {\"type\": \"text\", \"text\": self.system})\n            else: payload[\"system\"] = [{\"type\": \"text\", \"text\": self.system}]\n        user_idxs = [i for i, m in enumerate(messages) if m['role'] == 'user']\n        for idx in user_idxs[-2:]:\n            messages[idx] = {**messages[idx], \"content\": list(messages[idx][\"content\"])}\n            messages[idx][\"content\"][-1] = dict(messages[idx][\"content\"][-1], cache_control={\"type\": \"ephemeral\"})\n        url = auto_make_url(self.api_base, \"messages\") + '?beta=true'\n        parse_fn = (lambda r: _parse_claude_sse(r.iter_lines())) if self.stream else (lambda r: _parse_claude_json(r.json()))\n        return (yield from _stream_with_retry(self, url, headers, payload, parse_fn))\n\n    def ask(self, msg):\n        assert type(msg) is dict\n        with self.lock:\n            self.history.append(msg)\n            trim_messages_history(self.history, self.context_win)\n            messages = [{\"role\": m[\"role\"], \"content\": list(m[\"content\"])} for m in self.history]\n        content_blocks = None\n        gen = self.raw_ask(messages)\n        try:\n            while True: yield next(gen)\n        except StopIteration as e: content_blocks = e.value or []\n        if content_blocks and (_injected := _ensure_text_block(content_blocks)): yield _injected\n        if content_blocks and not (len(content_blocks) == 1 and content_blocks[0].get(\"text\", \"\").startswith(\"!!!Error:\")):\n            self.history.append({\"role\": \"assistant\", \"content\": content_blocks})\n        text_parts = [b[\"text\"] for b in content_blocks if b.get(\"type\") == \"text\"]\n        content = \"\\n\".join(text_parts).strip()\n        tool_calls = [MockToolCall(b[\"name\"], b.get(\"input\", {}), id=b.get(\"id\", \"\")) for b in content_blocks if b.get(\"type\") == \"tool_use\"]\n        if not tool_calls: tool_calls, content = _parse_text_tool_calls(content)\n        thinking_parts = [b[\"thinking\"] for b in content_blocks if b.get(\"type\") == \"thinking\"]\n        thinking = \"\\n\".join(thinking_parts).strip()\n        if not thinking:\n            think_pattern = r\"<think(?:ing)?>(.*?)</think(?:ing)?>\"\n            think_match = re.search(think_pattern, content, re.DOTALL)\n            if think_match:\n                thinking = think_match.group(1).strip()\n                content = re.sub(think_pattern, \"\", content, flags=re.DOTALL)\n        return MockResponse(thinking, content, tool_calls, str(content_blocks))\n\nclass NativeOAISession(NativeClaudeSession):\n    def raw_ask(self, messages):\n        messages = _fix_messages(messages)\n        messages = _ensure_thinking_blocks(messages, self.model)\n        return (yield from _openai_stream(self, _msgs_claude2oai(messages)))\n\ndef openai_tools_to_claude(tools):\n    \"\"\"[{type:'function', function:{name,description,parameters}}] → [{name,description,input_schema}].\"\"\"\n    result = []\n    for t in tools:\n        if 'input_schema' in t: result.append(t); continue  # 已是claude格式\n        fn = t.get('function', t)\n        result.append({'name': fn['name'], 'description': fn.get('description', ''),\n            'input_schema': fn.get('parameters', {'type': 'object', 'properties': {}})})\n    return result\n\nclass MockFunction:\n    def __init__(self, name, arguments): self.name, self.arguments = name, arguments  \n         \nclass MockToolCall:\n    def __init__(self, name, args, id=''):\n        arg_str = json.dumps(args, ensure_ascii=False) if isinstance(args, (dict, list)) else (args or '{}')\n        self.function = MockFunction(name, arg_str); self.id = id\n\nclass MockResponse:\n    def __init__(self, thinking, content, tool_calls, raw, stop_reason='end_turn'):\n        self.thinking = thinking; self.content = content          \n        self.tool_calls = tool_calls; self.raw = raw\n        self.stop_reason = 'tool_use' if tool_calls else stop_reason\n    def __repr__(self):    \n        return f\"<MockResponse thinking={bool(self.thinking)}, content='{self.content}', tools={bool(self.tool_calls)}>\"\n\nclass ToolClient:\n    def __init__(self, backend, auto_save_tokens=True):\n        self.backend = backend\n        self.auto_save_tokens = auto_save_tokens\n        self.last_tools = ''\n        self.name = self.backend.name\n        self.total_cd_tokens = 0\n        self.log_path = None\n\n    def chat(self, messages, tools=None):\n        tools = json.loads(json.dumps(tools, ensure_ascii=False)) if tools else tools\n        for t in tools or []:\n            f = t.get('function', {})\n            if f.get('name') == 'file_write':\n                props = f.get('parameters', {}).get('properties', {})\n                props.pop('content', None)\n                extra = '. Content must be placed in <file_content> tags in reply body, not in args'\n                if extra not in f.get('description', ''): f['description'] = f.get('description', '') + extra\n                break\n        full_prompt = self._build_protocol_prompt(messages, tools)\n        print(\"Full prompt length:\", len(full_prompt), 'chars')\n        gen = self.backend.ask(full_prompt)\n        _write_llm_log('Prompt', full_prompt, self.log_path)\n        raw_text = ''\n        for chunk in gen:\n            raw_text += chunk; yield chunk\n        _write_llm_log('Response', raw_text, self.log_path)\n        return self._parse_mixed_response(raw_text)\n\n    def _prepare_tool_instruction(self, tools):\n        tool_instruction = \"\"\n        if not tools: return tool_instruction\n        tools_json = json.dumps(tools, ensure_ascii=False, separators=(',', ':'))\n        _en = os.environ.get('GA_LANG') == 'en'\n        if _en:\n            tool_instruction = f\"\"\"\n### Interaction Protocol (must follow strictly, always in effect)\nFollow these steps to think and act:\n1. **Think**: Analyze the current situation and strategy inside `<thinking>` tags.\n2. **Summarize**: Output a minimal one-line (<30 words) physical snapshot in `<summary>`: new info from last tool result + current tool call intent. This goes into long-term working memory. Must contain real information, no filler.\n3. **Act**: If you need to call tools, output one or more **<tool_use> blocks** after your reply, then stop.\n\"\"\"\n        else:\n            tool_instruction = f\"\"\"\n### 交互协议 (必须严格遵守，持续有效)\n请按照以下步骤思考并行动：\n1. **思考**: 在 `<thinking>` 标签中先进行思考，分析现状和策略。\n2. **总结**: 在 `<summary>` 中输出*极为简短*的高度概括的单行（<30字）物理快照，包括上次工具调用结果产生的新信息+本次工具调用意图。此内容将进入长期工作记忆，记录关键信息，严禁输出无实际信息增量的描述。\n3. **行动**: 如需调用工具，请在回复正文之后输出一个（或多个）**<tool_use>块**，然后结束。\n\"\"\"\n        tool_instruction += f'\\nFormat: ```<tool_use>{{\"name\": \"tool_name\", \"arguments\": {{...}}}}</tool_use>```\\n\\n### Tools (mounted, always in effect):\\n{tools_json}\\n'\n        if self.auto_save_tokens and self.last_tools == tools_json:\n            tool_instruction = \"\\n### Tools: still active, **ready to call**. Protocol unchanged.\\n\" if _en else \"\\n### 工具库状态：持续有效（code_run/file_read等），**可正常调用**。调用协议沿用。\\n\"\n        else: self.total_cd_tokens = 0\n        self.last_tools = tools_json\n        return tool_instruction\n\n    def _build_protocol_prompt(self, messages, tools):\n        system_content = next((m['content'] for m in messages if m['role'].lower() == 'system'), \"\")\n        history_msgs = [m for m in messages if m['role'].lower() != 'system']\n        tool_instruction = self._prepare_tool_instruction(tools)\n        system = \"\"; user = \"\"\n        if system_content: system += f\"{system_content}\\n\"\n        system += f\"{tool_instruction}\"\n        for m in history_msgs:\n            role = \"USER\" if m['role'] == 'user' else \"ASSISTANT\"\n            user += f\"=== {role} ===\\n\"\n            for tr in m.get('tool_results', []): user += f'<tool_result>{tr[\"content\"]}</tool_result>\\n'\n            user += str(m['content']) + \"\\n\"\n            self.total_cd_tokens += len(user) // 3\n        if self.total_cd_tokens > 9000: self.last_tools = ''\n        user += \"=== ASSISTANT ===\\n\" \n        return system + user\n\n    def _parse_mixed_response(self, text):\n        remaining_text = text; thinking = ''\n        think_match = re.search(r\"<think(?:ing)?>(.*?)</think(?:ing)?>\", text, re.DOTALL)\n        if think_match:\n            thinking = think_match.group(1).strip()\n            remaining_text = re.sub(r\"<think(?:ing)?>(.*?)</think(?:ing)?>\", \"\", remaining_text, flags=re.DOTALL)\n        tool_calls, remaining_text = _parse_text_tool_calls(remaining_text)\n        if not tool_calls:\n            json_strs = []; errors = []\n            if '<tool_use>' in remaining_text:\n                weaktoolstr = remaining_text.split('<tool_use>')[-1].strip().strip('><')\n                json_str = weaktoolstr if weaktoolstr.endswith('}') else ''\n                if json_str == '' and '```' in weaktoolstr and weaktoolstr.split('```')[0].strip().endswith('}'):\n                    json_str = weaktoolstr.split('```')[0].strip()\n                if json_str: json_strs.append(json_str)\n                remaining_text = remaining_text.replace('<tool_use>'+weaktoolstr, \"\")\n            elif '\"name\":' in remaining_text and '\"arguments\":' in remaining_text:\n                json_match = re.search(r'\\{.*\"name\":.*\\}', remaining_text, re.DOTALL)\n                if json_match:\n                    json_strs.append(json_match.group(0).strip())\n                    remaining_text = remaining_text.replace(json_match.group(0), \"\").strip()\n            for json_str in json_strs:\n                try:\n                    data = tryparse(json_str)\n                    func_name = data.get('name') or data.get('function') or data.get('tool')\n                    args = data.get('arguments') or data.get('args') or data.get('params') or data.get('parameters')\n                    if args is None: args = data\n                    if func_name: tool_calls.append(MockToolCall(func_name, args))\n                except json.JSONDecodeError:\n                    errors.append(f'Failed to parse tool_use JSON: {json_str[:200]}')\n                    self.last_tools = ''\n                except: pass\n            if not tool_calls:\n                for e in errors:\n                    print(f\"[Warn] {e}\"); tool_calls.append(MockToolCall('bad_json', {'msg': e}))\n        return MockResponse(thinking, remaining_text.strip(), tool_calls, text)\n\ndef _parse_text_tool_calls(content):\n    \"\"\"Fallback: extract tool calls from text when model doesn't use native tool_use blocks.\"\"\"\n    tcs = []\n    # try JSON array: [{\"type\":\"tool_use\", \"name\":..., \"input\":...}]\n    _jp = next((p for p in ['[{\"type\":\"tool_use\"', '[{\"type\": \"tool_use\"'] if p in content), None)\n    if _jp and content.endswith('}]'):\n        try:\n            idx = content.index(_jp); raw = json.loads(content[idx:])\n            tcs = [MockToolCall(b[\"name\"], b.get(\"input\", {}), id=b.get(\"id\", \"\")) for b in raw if b.get(\"type\") == \"tool_use\"]\n            return tcs, content[:idx].strip()\n        except: pass\n    # try XML tags: <tool_call>{\"name\":..., \"arguments\":...}</tool_call>\n    _xp = r\"<(?:tool_use|tool_call)>((?:(?!<(?:tool_use|tool_call)>).){15,}?)</(?:tool_use|tool_call)>\"\n    for s in re.findall(_xp, content, re.DOTALL):\n        try:\n            d = tryparse(s.strip()); name = d.get('name')\n            args = d.get('arguments') or d.get('args') or d.get('input') or {}\n            if name: tcs.append(MockToolCall(name, args))\n        except: pass\n    if tcs: content = re.sub(_xp, \"\", content, flags=re.DOTALL).strip()\n    return tcs, content\n\ndef _ensure_text_block(blocks):\n    \"\"\"If response has thinking but no text block, inject a synthetic summary from thinking's first line.\"\"\"\n    if any(b.get(\"type\") == \"text\" for b in blocks): return None\n    th = next((b.get(\"thinking\", \"\") for b in blocks if b.get(\"type\") == \"thinking\"), \"\")\n    if not th: return None\n    line = th.strip().split('\\n', 1)[0]\n    txt = \"<summary>\" + (line[:60] + '...' if len(line) > 60 else line) + \"</summary>\"\n    blocks.insert(1, {\"type\": \"text\", \"text\": txt})\n    return txt\n\ndef _write_llm_log(label, content, log_path=None):\n    if not log_path:\n        log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), f'temp/model_responses/model_responses_{os.getpid()}.txt')\n    os.makedirs(os.path.dirname(os.path.abspath(log_path)), exist_ok=True)\n    ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')\n    with open(log_path, 'a', encoding='utf-8', errors='replace') as f:\n        f.write(f\"=== {label} === {ts}\\n{content}\\n\\n\")\n\ndef tryparse(json_str):\n    try: return json.loads(json_str)\n    except: pass\n    json_str = json_str.strip().strip('`').replace('json\\n', '', 1).strip()\n    try: return json.loads(json_str)\n    except: pass\n    try: return json.loads(json_str[:-1])\n    except: pass\n    if '}' in json_str: json_str = json_str[:json_str.rfind('}') + 1]\n    return json.loads(json_str)\n\nclass MixinSession:\n    \"\"\"Multi-session fallback with spring-back to primary.\"\"\"\n    def __init__(self, all_sessions, cfg):\n        self._retries, self._base_delay = cfg.get('max_retries', 3), cfg.get('base_delay', 1.5)\n        self._spring_sec = cfg.get('spring_back', 300)\n        self._sessions = [all_sessions[i].backend if isinstance(i, int) else \n                          next(s.backend for s in all_sessions if type(s) is not dict and s.backend.name == i) for i in cfg.get('llm_nos', [])]\n        is_native = lambda s: 'Native' in s.__class__.__name__\n        groups = {is_native(s) for s in self._sessions}\n        assert len(groups) == 1, f\"MixinSession: sessions must be in same group (Native or non-Native), got {[type(s).__name__ for s in self._sessions]}\"\n        self.name = '|'.join(s.name for s in self._sessions)\n        import copy; self._sessions = [copy.copy(s) for s in self._sessions]\n        for s in self._sessions: s.max_retries = 0\n        self._orig_raw_asks = [s.raw_ask for s in self._sessions]\n        self._sessions[0].raw_ask = self._raw_ask\n        self.model = getattr(self._sessions[0], 'model', None)\n        self._cur_idx, self._switched_at = 0, 0.0\n    def __getattr__(self, name): return getattr(self._sessions[0], name)\n    _BROADCAST_ATTRS = frozenset({'system', 'tools', 'temperature', 'max_tokens', 'reasoning_effort', 'history'})\n    def __setattr__(self, name, value):\n        if name in self._BROADCAST_ATTRS:\n            for s in self._sessions:\n                v = openai_tools_to_claude(value) if name == 'tools' and type(s) is NativeClaudeSession else value\n                setattr(s, name, v)\n        else: object.__setattr__(self, name, value)\n    @property\n    def primary(self): return self._sessions[0]\n    def _pick(self):\n        if self._cur_idx and time.time() - self._switched_at > self._spring_sec: self._cur_idx = 0\n        return self._cur_idx\n    def _raw_ask(self, *args, **kwargs):\n        base, n = self._pick(), len(self._sessions)\n        test_error = lambda x: isinstance(x, str) and x.lstrip().startswith(('!!!Error:', '[Error:'))\n        for attempt in range(self._retries + 1):\n            idx = (base + attempt) % n\n            gen = self._orig_raw_asks[idx](*args, **kwargs)\n            print(f'[MixinSession] Using session ({self._sessions[idx].name})')\n            last_chunk, return_val, yielded = None, [], False\n            try:\n                while True:\n                    chunk = next(gen); last_chunk = chunk\n                    if not yielded and test_error(chunk): continue\n                    yield chunk; yielded = True\n            except StopIteration as e: return_val = e.value or []\n            is_err = test_error(last_chunk)\n            if not is_err:\n                if attempt > 0: self._cur_idx = idx; self._switched_at = time.time()\n                elif isinstance(last_chunk, str) and '[!!! 流异常中断' in last_chunk and n > 1:\n                    self._cur_idx = (idx + 1) % n; self._switched_at = time.time()\n                    print(f'[MixinSession] Partial failure, next call → s{self._cur_idx} ({self._sessions[self._cur_idx].name})')\n                return return_val\n            if attempt >= self._retries:\n                yield last_chunk; return return_val\n            nxt = (base + attempt + 1) % n\n            if nxt == base:  # full round failed, delay before next\n                rnd = (attempt + 1) // n\n                delay = min(30, self._base_delay * (1.5 ** rnd))\n                print(f'[MixinSession] {last_chunk[:80]}, round {rnd} exhausted, retry in {delay:.1f}s')\n                time.sleep(delay)\n            else: print(f'[MixinSession] {last_chunk[:80]}, retry {attempt+1}/{self._retries} (s{idx}→s{nxt})')\n\nTHINKING_PROMPT_ZH = \"\"\"\n### 行动规范（持续有效）\n每次回复（含工具调用轮）都先在回复文字中包含一个<summary></summary> 中输出极简单行（<30字）物理快照：上次结果新信息+本次意图。此内容进入长期工作记忆。\n\\n**若用户需求未完成，必须进行工具调用！**\n\"\"\".strip()\nTHINKING_PROMPT_EN = \"\"\"\n### Action Protocol (always in effect)\nThe reply body should first include a minimal one-line (<30 words) physical snapshot in <summary></summary>: new info from last result + current intent. This goes into long-term working memory.\n\\n**If the user's request is not yet complete, tool calls are required!**\n\"\"\".strip()\n\nclass NativeToolClient:\n    @staticmethod\n    def _thinking_prompt(): return THINKING_PROMPT_EN if os.environ.get('GA_LANG') == 'en' else THINKING_PROMPT_ZH\n    def __init__(self, backend):\n        self.backend = backend\n        self.backend.system = self._thinking_prompt()\n        self.name = self.backend.name\n        self._pending_tool_ids = []\n        self.log_path = None\n    def set_system(self, extra_system):\n        combined = f\"{extra_system}\\n\\n{self._thinking_prompt()}\" if extra_system else self._thinking_prompt()\n        if combined != self.backend.system: print(f\"[Debug] Updated system prompt, length {len(combined)} chars.\")\n        self.backend.system = combined\n    def chat(self, messages, tools=None):\n        if tools: self.backend.tools = tools\n        if not self.backend.history: self._pending_tool_ids = []\n        combined_content = []; resp = None; tool_results = []\n        for msg in messages:\n            c = msg.get('content', '')\n            if msg['role'] == 'system': \n                self.set_system(c); continue\n            if isinstance(c, str): combined_content.append({\"type\": \"text\", \"text\": c})\n            elif isinstance(c, list): combined_content.extend(c)\n            if msg['role'] == 'user' and msg.get('tool_results'): tool_results.extend(msg['tool_results'])\n        tr_id_set = set();  tool_result_blocks = []\n        for tr in tool_results:\n            tool_use_id, content = tr.get(\"tool_use_id\", \"\"), tr.get(\"content\", \"\")\n            tr_id_set.add(tool_use_id)\n            if tool_use_id: tool_result_blocks.append({\"type\": \"tool_result\", \"tool_use_id\": tool_use_id, \"content\": tr.get(\"content\", \"\")})\n            else: combined_content = [{\"type\": \"text\", \"text\": f'<tool_result>{content}</tool_result>'}] + combined_content\n        for tid in self._pending_tool_ids:\n            if tid not in tr_id_set: tool_result_blocks.append({\"type\": \"tool_result\", \"tool_use_id\": tid, \"content\": \"\"})\n        self._pending_tool_ids = []\n        # Filter whitespace-only text blocks that cause 400 on strict API proxies\n        filtered_content = [c for c in combined_content if c.get(\"text\", \"\").strip()]\n        final_content = tool_result_blocks + filtered_content\n        if not final_content: final_content = [{\"type\": \"text\", \"text\": \".\"}]\n        merged = {\"role\": \"user\", \"content\": final_content}\n        _write_llm_log('Prompt', json.dumps(merged, ensure_ascii=False, indent=2), self.log_path)\n        gen = self.backend.ask(merged)\n        try:\n            while True: \n                chunk = next(gen); yield chunk\n        except StopIteration as e: resp = e.value\n        if resp: _write_llm_log('Response', resp.raw, self.log_path)\n        if resp and hasattr(resp, 'tool_calls') and resp.tool_calls: self._pending_tool_ids = [tc.id for tc in resp.tool_calls]\n        return resp\n\ndef resolve_session(cfg_name):\n    cfg = reload_mykeys()[0].get(cfg_name)\n    if not cfg: raise ValueError(f\"Config '{cfg_name}' not in mykey\")\n    if 'native' in cfg_name: return (NativeClaudeSession if 'claude' in cfg_name else NativeOAISession)(cfg=cfg)\n    if 'claude' in cfg_name: return ClaudeSession(cfg=cfg)\n    return LLMSession(cfg=cfg) if 'oai' in cfg_name else None\n\ndef resolve_client(cfg_name):\n    s = resolve_session(cfg_name)\n    return (NativeToolClient(s) if isinstance(s, (NativeClaudeSession, NativeOAISession)) else ToolClient(s)) if s else None\n\ndef fast_ask(prompt, cfg_name):\n    sess = resolve_session(cfg_name)\n    if not sess: raise ValueError(f\"fast_ask: '{cfg_name}' unsupported\")\n    return \"\".join(sess.raw_ask([{\"role\": \"user\", \"content\": prompt}]))\n"
  },
  {
    "path": "memory/adb_ui.py",
    "content": "# adb_ui.py - 一键dump+解析Android UI (u2优先，原生fallback)\n# u2 (uiautomator2) 不受idle限制，适合动画密集app（美团等）\n# 弹窗检测: ui(clickable_only=True, raw=True) 找全屏FrameLayout+底部小ImageView(关闭X)\n# 已知包名: 美团外卖=com.sankuai.meituan.takeoutnew 淘宝=com.taobao.taobao\nimport subprocess, xml.etree.ElementTree as ET, os, re, shutil\n\nADB = shutil.which(\"adb\") or \"adb\"\nLOCAL_XML = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"ui_mt.xml\")\n\ndef _dump_u2():\n    \"\"\"用uiautomator2 dump，不受idle限制\"\"\"\n    try:\n        import uiautomator2 as u2\n        d = u2.connect()\n        xml_str = d.dump_hierarchy()\n        if xml_str and len(xml_str) > 100: return xml_str\n    except Exception as e:\n        print(f\"[u2 fallback] {e}\")\n    return None\n\ndef _dump_native():\n    \"\"\"原生uiautomator dump（需idle状态）\"\"\"\n    subprocess.run([ADB, \"shell\", \"rm\", \"-f\", \"/sdcard/ui.xml\"], capture_output=True)\n    r = subprocess.run([ADB, \"shell\", \"uiautomator\", \"dump\", \"--compressed\", \"/sdcard/ui.xml\"],\n                       capture_output=True, text=True, timeout=15)\n    if \"dumped\" not in r.stdout.lower() and \"dumped\" not in r.stderr.lower(): print(f\"dump failed: {r.stdout}{r.stderr}\"); return None\n    subprocess.run([ADB, \"pull\", \"/sdcard/ui.xml\", LOCAL_XML], capture_output=True, timeout=10)\n    with open(LOCAL_XML, \"r\", encoding=\"utf-8\") as f:\n        return f.read()\n\ndef _parse_xml(xml_str, keyword=None, clickable_only=False, raw=False):\n    \"\"\"解析XML字符串为节点列表\"\"\"\n    root = ET.fromstring(xml_str)\n    nodes = []\n    for n in root.iter(\"node\"):\n        pkg = n.get(\"package\", \"\")\n        if \"termux\" in pkg.lower(): continue\n        text = n.get(\"text\", \"\")\n        desc = n.get(\"content-desc\", \"\")\n        bounds = n.get(\"bounds\", \"\")\n        click = n.get(\"clickable\") == \"true\"\n        cls = n.get(\"class\", \"\").split(\".\")[-1]\n        rid = n.get(\"resource-id\", \"\")\n        label = text or desc\n        if not label and not click and not raw: continue\n        if clickable_only and not click: continue\n        if keyword and keyword.lower() not in label.lower(): continue\n        cx, cy = 0, 0\n        if bounds:\n            m = re.findall(r'\\[(\\d+),(\\d+)\\]', bounds)\n            if len(m) == 2:\n                cx = (int(m[0][0]) + int(m[1][0])) // 2\n                cy = (int(m[0][1]) + int(m[1][1])) // 2\n        edit = cls == \"EditText\"\n        nodes.append({\"text\": text or desc, \"click\": click, \"edit\": edit, \"cx\": cx, \"cy\": cy, \"cls\": cls, \"rid\": rid})\n    return nodes\n\ndef ui(keyword=None, clickable_only=False, raw=False):\n    \"\"\"一键dump+解析Android UI (u2优先)\n    keyword: 过滤含关键词的节点\n    clickable_only: 只显示可点击节点\n    raw: 返回原始节点列表而非打印\n    \"\"\"\n    xml_str = _dump_u2() or _dump_native()\n    if not xml_str: print(\"dump failed (both u2 and native)\"); return []\n    nodes = _parse_xml(xml_str, keyword, clickable_only, raw)\n    if not raw:\n        for n in nodes:\n            flag = \"E\" if n.get(\"edit\") else (\"Y\" if n[\"click\"] else \" \")\n            coord = f\"({n['cx']},{n['cy']})\" if n['cx'] else \"\"\n            display_text = n['text']\n            if not display_text:\n                hint = n.get('rid', '').split('/')[-1] or n.get('cls', 'icon')\n                display_text = f\"<{hint}>\"\n            print(f\"[{flag}] {display_text}  {coord}\")\n        print(f\"\\ntotal: {len(nodes)} nodes\")\n    return nodes\n\ndef tap(x, y):\n    subprocess.run([ADB, \"shell\", \"input\", \"tap\", str(x), str(y)], capture_output=True)\n    print(f\"tap({x},{y}) ok\")\n\nif __name__ == \"__main__\":\n    ui()"
  },
  {
    "path": "memory/autonomous_operation_sop.md",
    "content": "# 自主行动 SOP\n\n⚠️ **路径警告**：autonomous_reports 在 temp/ 下，用`./autonomous_reports/`访问，**不是**`../memory/autonomous_reports/`或`../autonomous_reports/`！TODO在cwd下。\n报告存于 `./autonomous_reports/`，文件名 `RXX_简短描述.md`（XX从 history.txt 推断自增）。\n\n授权你进行自主行动，只要不对环境造成副作用都可进行。\n\n## 启动（第一步）\n- update_working_checkpoint: `自主行动｜收尾时重读SOP | from autonomous_operation_sop.helper import *; set_todo()/complete_task(tasktitle, historyline, report_path)`\n\n第二步：\n```python\nfrom autonomous_operation_sop.helper import *\nprint(get_history(40))  # 了解历史避免重复\nprint(get_todo())       # 查看待办\n```\n\n## 任务选择\n- 有未完成条目 → 取**一条**，直接进入执行，其他条目下次执行\n- 无 TODO → 读 `autonomous_operation_sop/task_planning.md` 规划，下次执行\n- 不连续两次选相同子任务\n- 价值公式：**「AI训练数据无法覆盖」×「对未来协作有持久收益」**\n\n## 执行\n- 选定任务后 update_working_checkpoint，将选中的TODO条目和执行注意事项 append 到 checkpoint 中\n- 调用code_run准备结尾回调, script为handler._done_hooks.append(\"重读自主任务sop，检查你刚刚的收尾工作是否正确，不正确则改正\")，inline_eval=True （秘密参数）\n- ≤30回合，小步快跑，边探测边实验\n- 用临时脚本验证假设；禁只读即下结论，完整验证再写报告\n- 即使失败也记录实验过程和结果，失败报告同样有价值\n- 用户不在线，遇到需要决策的问题写入报告待审，不要卡住\n\n**收尾（4件事缺一不可）**：\n0. 重读本sop\n1. 在cwd写报告（文件名任意），若有记忆更新建议，附在报告末尾\n2. `from/import helper; complete_task(tasktitle, historyline, report_path)` → 自动编号+移报告到 autonomous_reports/+prepend history（historyline 格式：`类型 | 主题 | 结论`，严格单行）\n3. `set_todo()` 获取TODO路径 → 将已完成条目标记为 `[x]`（注意前缀）\n4. 结束，剩余TODO留到下次再做\n\n## 权限边界\n- 无需批准：只读探测、cwd内写操作/脚本实验\n- 需写入报告待审：修改 global_mem / memory下SOP、安装软件、外部API调用、删除非临时文件\n- 绝对禁止：读取密钥、修改核心代码库、不可逆危险操作\n\n## 等待用户审查\n- 用户归来后审查报告，决定批准、修改或拒绝方案"
  },
  {
    "path": "memory/goal_mode_sop.md",
    "content": "# Goal Mode SOP\n\n## 何时使用\n\n用户给出开放目标 + 时间预算（如\"花3小时持续优化X\"、\"没事也找事干\"），且不是一次性闭环任务。\n\n## 设置\n\n写 `temp/goal_state.json`（或自定义路径）：\n\n```json\n{\n  \"objective\": \"用户原话目标\",\n  \"budget_seconds\": 10800,\n  \"start_time\": <time.time()>,\n  \"turns_used\": 0,\n  \"max_turns\": 200,\n  \"status\": \"running\"\n}\n```\n\n- `budget_seconds`：最少 3 小时（10800），按用户要求调整\n- `max_turns`：防空转上限，一般 200 够用\n- `status`：必须为 `\"running\"`\n\n## 启动\n\n必须后台启动（长时间运行，不占前台终端）：\n\n```bash\n# 默认路径 temp/goal_state.json\nstart /b python agentmain.py --reflect reflect/goal_mode.py\n\n# 自定义路径（多实例）\nset GOAL_STATE=temp/goal_xxx.json && start /b python agentmain.py --reflect reflect/goal_mode.py\n\n# 用其他模型跑（--llm_no 选择已配置的第N个LLM，从0开始）\nset GOAL_STATE=temp/goal_xxx.json && start /b python agentmain.py --reflect reflect/goal_mode.py --llm_no 1\n```\n\n## 停止\n\n- 预算耗尽时自动进入收口轮，然后停止\n- 手动停：杀进程\n\n## 观察进度\n\n- 状态：读 goal_state.json 的 `turns_used` / `status`\n- 详情：看 `temp/model_responses/` 下最近修改的文件尾部\n"
  },
  {
    "path": "memory/keychain.py",
    "content": "\"\"\"Keychain: save key to a file, then keys.set(\"name\", file=\"path\"); keys.name.use() to retrieve (use but no print).\"\"\"\nimport json, os, hashlib, pathlib, getpass\n\n_PATH = pathlib.Path.home() / \"ga_keychain.enc\"\ntry: _user = os.getlogin()\nexcept OSError: _user = getpass.getuser()\n_MASK = hashlib.sha256(f\"{_user}@ga_keychain\".encode()).digest()\n\ndef _xor(data: bytes) -> bytes:\n    return bytes(b ^ _MASK[i % len(_MASK)] for i, b in enumerate(data))\n\nclass SecretStr:\n    def __init__(self, name: str, val: str):\n        self._name, self._val = name, val\n    def use(self) -> str:\n        return self._val\n    def __repr__(self):\n        n = len(self._val)\n        if n <= 4:     preview = '***'\n        elif n <= 16:  preview = f\"{self._val[:3]}···{self._val[-3:]}\"\n        elif n <= 40:  preview = f\"{self._val[:6]}···{self._val[-6:]} len={n}\"\n        else:          preview = f\"{self._val[:10]}···{self._val[-6:]} len={n}\"\n        return f\"SecretStr({self._name}={preview}) # .use() to get raw, do not print raw value\"\n    __str__ = __repr__\n\nclass _Keys:\n    def __init__(self):\n        self._d = {}\n        if _PATH.exists():\n            try:\n                self._d = json.loads(_xor(_PATH.read_bytes()))\n            except Exception as e:\n                print(f\"[keychain] WARNING: failed to load {_PATH}: {e}\")\n                print(f\"[keychain] Starting with empty keychain. Old file kept as .bak\")\n                _PATH.rename(_PATH.with_suffix('.enc.bak'))\n    def __getattr__(self, k):\n        if k.startswith('_'): raise AttributeError(k)\n        if k not in self._d: raise KeyError(f\"No secret: {k}\")\n        return SecretStr(k, self._d[k])\n    def set(self, k, v=None, *, file=None):\n        if file: v = pathlib.Path(file).read_text().strip()\n        self._d[k] = v\n        _PATH.write_bytes(_xor(json.dumps(self._d).encode()))\n    def ls(self): return list(self._d.keys())\n\nkeys = _Keys()\n\ndef __getattr__(name): return getattr(keys, name)\n"
  },
  {
    "path": "memory/ljqCtrl.py",
    "content": "\"\"\"\nCRITICAL: 严禁在此工具链中 import pyautogui (会污染 win32api 导致逻辑冲突)。\nljqCtrl Quick Reference:\n- dpi_scale: float (Logical = Physical * dpi_scale)\n- Click(x, y): Use Physical Coordinates (from screenshots)\n- SetCursorPos(z): Use Physical Coordinates z=(x, y)\n- Press(cmd, staytime=0): Keyboard shortcuts (e.g. 'ctrl+v')\n- FindBlock(fn, wrect=None, threshold=0.8) -> (obj_center_phys, is_found)\n- MouseDClick(staytime=0.05), MouseClick(staytime=0.05)\n- GrabWindow(hwnd) -> PIL Image: DPI-safe window screenshot\n\"\"\"\n\nimport os, sys, time, random, math, win32api, win32con, ctypes\nimport numpy as np\n\ndpi_scale = 1\ntry:\n\tfrom PIL import ImageGrab, Image, ImageEnhance, ImageFilter, ImageDraw\n\timport cv2\nexcept: pass\n\n_hdc = ctypes.windll.user32.GetDC(0)\nswidth = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 118)   # DESKTOPHORZRES (物理)\nsheight = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 117)   # DESKTOPVERTRES\nctypes.windll.user32.ReleaseDC(0, _hdc)\ncwidth = win32api.GetSystemMetrics(win32con.SM_CXSCREEN)  # 逻辑\ncheight = win32api.GetSystemMetrics(win32con.SM_CYSCREEN)\ndpi_scale = cwidth / swidth\nprint('Screen width & height:', swidth, sheight)\nprint('dpi_scale:', dpi_scale)\n\ndef MouseDown(): win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN,0,0) \ndef MouseUp(): win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP,0,0)\n\ndef MouseClick(staytime=0.05):\n\tMouseDown(); time.sleep(staytime)\n\tMouseUp(); time.sleep(0.05)\n\ndef MouseDClick(staytime=0.05):\n\tMouseDown(); MouseUp() \n\tMouseDown(); MouseUp() \n\ttime.sleep(0.05)\n\ndef SetCursorPos(z):\n\tz = tuple(map(lambda v:int(v*dpi_scale), z))\n\twin32api.SetCursorPos(z)\n\ttime.sleep(0.05) \n\ndef Click(x, y=None):\n\tif type(x) is type(tuple()): x, y = int(x[0]), int(x[1])\n\tSetCursorPos( (x, y) )\n\tMouseClick()\nclick = Click\n\t\ndef Press(cmd, staytime=0):\n\tif type(cmd) is list: cmds = [x.lower() for x in cmd]\n\telse: cmds = cmd.lower().split('+')\n\tfor z in cmds: \n\t\twin32api.keybd_event(VK_CODE[z], 0, 0, 0)\n\t\ttime.sleep(staytime)\n\tfor z in reversed(cmds):\n\t\ttime.sleep(staytime)\n\t\twin32api.keybd_event(VK_CODE[z], 0, win32con.KEYEVENTF_KEYUP, 0)\npress = Press\n\nVK_CODE = {'backspace':0x08, 'tab':0x09, 'clear':0x0C, 'enter':0x0D, 'shift':0x10, 'ctrl':0x11, 'alt':0x12, 'pause':0x13, 'caps_lock':0x14, 'esc':0x1B, 'escape':0x1B, 'space':0x20, 'page_up':0x21, 'page_down':0x22, 'end':0x23, 'home':0x24, 'left_arrow':0x25, 'up_arrow':0x26, 'right_arrow':0x27, 'down_arrow':0x28, 'select':0x29, 'print':0x2A, 'execute':0x2B, 'print_screen':0x2C, 'ins':0x2D, 'del':0x2E, 'help':0x2F, '0':0x30, '1':0x31, '2':0x32, '3':0x33, '4':0x34, '5':0x35, '6':0x36, '7':0x37, '8':0x38, '9':0x39, 'a':0x41, 'b':0x42, 'c':0x43, 'd':0x44, 'e':0x45, 'f':0x46, 'g':0x47, 'h':0x48, 'i':0x49, 'j':0x4A, 'k':0x4B, 'l':0x4C, 'm':0x4D, 'n':0x4E, 'o':0x4F, 'p':0x50, 'q':0x51, 'r':0x52, 's':0x53, 't':0x54, 'u':0x55, 'v':0x56, 'w':0x57, 'x':0x58, 'y':0x59, 'z':0x5A, 'numpad_0':0x60, 'numpad_1':0x61, 'numpad_2':0x62, 'numpad_3':0x63, 'numpad_4':0x64, 'numpad_5':0x65, 'numpad_6':0x66, 'numpad_7':0x67, 'numpad_8':0x68, 'numpad_9':0x69, 'multiply_key':0x6A, 'add_key':0x6B, 'separator_key':0x6C, 'subtract_key':0x6D, 'decimal_key':0x6E, 'divide_key':0x6F, 'F1':0x70, 'F2':0x71, 'F3':0x72, 'F4':0x73, 'F5':0x74, 'F6':0x75, 'F7':0x76, 'F8':0x77, 'F9':0x78, 'F10':0x79, 'F11':0x7A, 'F12':0x7B, 'F13':0x7C, 'F14':0x7D, 'F15':0x7E, 'F16':0x7F, 'F17':0x80, 'F18':0x81, 'F19':0x82, 'F20':0x83, 'F21':0x84, 'F22':0x85, 'F23':0x86, 'F24':0x87, 'num_lock':0x90, 'scroll_lock':0x91, 'left_shift':0xA0, 'right_shift ':0xA1, 'left_control':0xA2, 'right_control':0xA3, 'left_menu':0xA4, 'right_menu':0xA5, 'browser_back':0xA6, 'browser_forward':0xA7, 'browser_refresh':0xA8, 'browser_stop':0xA9, 'browser_search':0xAA, 'browser_favorites':0xAB, 'browser_start_and_home':0xAC, 'volume_mute':0xAD, 'volume_Down':0xAE, 'volume_up':0xAF, 'next_track':0xB0, 'previous_track':0xB1, 'stop_media':0xB2, 'play/pause_media':0xB3, 'start_mail':0xB4, 'select_media':0xB5, 'start_application_1':0xB6, 'start_application_2':0xB7, 'attn_key':0xF6, 'crsel_key':0xF7, 'exsel_key':0xF8, 'play_key':0xFA, 'zoom_key':0xFB, 'clear_key':0xFE, '+':0xBB, ',':0xBC, '-':0xBD, '.':0xBE, '/':0xBF, '`':0xC0, ';':0xBA, '[':0xDB, '\\\\':0xDC, ']':0xDD, \"'\":0xDE} \nVK_CODE = {k.lower():v for k,v in VK_CODE.items()}\n\ndef GrabWindow(hwnd):\n\timport win32gui; win32gui.SetForegroundWindow(hwnd); time.sleep(0.3)\n\tbbox = tuple(int(v / dpi_scale) for v in win32gui.GetWindowRect(hwnd))\n\treturn ImageGrab.grab(bbox)\n\ndef imshow(mt, sec=0):\n\tcv2.imshow('cc', mt)\n\tcv2.waitKey(sec)\n\t\ndef GetWRect(sr):\n\tnum = int(sr[-1])\n\tl, u, r, b = 0, 0, swidth, sheight\n\tif 'left' in sr: r = swidth // num\n\tif 'right' in sr: l = swidth * (num-1) // num \n\tif 'top' in sr: b = sheight // num\n\tif 'bottom' in sr: u = sheight * (num-1) // num\n\treturn [l, u, r, b]\n\ndef FindBlock(fn, wrect=None, verbose=0, threshold=0.8):\n\ttic = time.process_time()\n\tif wrect is not None and isinstance(wrect, Image.Image): \n\t\tscr, wrect = wrect, None\n\telse:\n\t\tif isinstance(wrect, str): wrect = GetWRect(wrect)\n\t\tscr = ImageGrab.grab(wrect)\n\tblc = Image.open(fn) if isinstance(fn, str) else fn\n\tT = cv2.cvtColor(np.array(blc), cv2.COLOR_RGB2BGR)\n\tB = cv2.cvtColor(np.array(scr), cv2.COLOR_RGB2BGR)\n\ttsh, tsw = T.shape[:2]\n\tif verbose: print('T.shape:', T.shape, '\\t', 'B.shape:', B.shape)\n\tres = cv2.matchTemplate(B, T, cv2.TM_CCOEFF_NORMED)\n\tmin_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)\n\toj, oi = max_loc\n\tif wrect is None: wrect = [0, 0, scr.size[0], scr.size[1]]\n\tobj = (oj + wrect[0] + tsw//2, oi + wrect[1] + tsh//2)\n\tif verbose:\n\t\tprint(f'Max match: {max_val:.4f} at ({oj}, {oi}) cost: {time.process_time() - tic:.3f}s')\n\t\tsscr = scr.crop([oj, oi, oj+tsw, oi+tsh])\n\t\tsscr.show()\n\treturn obj, max_val > threshold\n\nif __name__ == '__main__':\n\t#time.sleep(3)\n\t#SetCursorPos( (1640, 131) )\n\t#MouseClick()\n\t#print(FindBlock('z:/z.png', [1638, 214, 5838, 414], verbose=1))\n\tprint('completed %.3f' % time.process_time())"
  },
  {
    "path": "memory/ljqCtrl_sop.md",
    "content": "# ljqCtrl 使用与坐标转换 SOP\n\n> **must call update working ckp**：`ljqCtrl一律使用物理坐标｜禁pyautogui｜操作前先gw激活窗口`\n\n## 0. API 快速参考 (Signatures)\n- `ljqCtrl.dpi_scale`: float (缩放系数 = 逻辑宽度 / 物理宽度)\n- `ljqCtrl.SetCursorPos(z)`: 移动鼠标到逻辑坐标 z=(x, y)\n- `ljqCtrl.Click(x, y=None)`: 模拟点击。支持 `Click((x, y))` 或 `Click(x, y)`\n- `ljqCtrl.Press(cmd, staytime=0)`: 模拟按键。如 `Press('ctrl+c')`\n- `ljqCtrl.FindBlock(fn, wrect=None, threshold=0.8)`: 找图。返回 `((center_x, center_y), is_found)`\n- `ljqCtrl.MouseDClick(staytime=0.05)`: 鼠标双击\n\n## 1. 环境载入\n必须先将 `../memory` 加入路径，才能导入工具模块：\n```python\nimport sys, os, pygetwindow as gw\nsys.path.append(\"../memory\")\nimport ljqCtrl\n```\n\n## 2. 核心：High-DPI 物理坐标换算\n`ljqCtrl` 的 `Click/MoveTo` 接口接收的是**物理像素坐标**。\n当使用 `pygetwindow` 等工具获取窗口位置（逻辑坐标）时，必须除以缩放系数。\n\n- **换算公式**：`物理坐标 = 逻辑坐标 / ljqCtrl.dpi_scale`\n- **注意**：3840 (4K) 仅为当前开发机示例，实际物理边界由系统环境决定，代码应始终通过 `dpi_scale` 动态计算。\n\n## 3. 窗口操作与点击流程\n1. **激活窗口**：使用 `gw.getWindowsWithTitle('标题')` 获取窗口，执行 `restore()` 和 `activate()`。\n2. **坐标计算**：\n```python\nwin = gw.getWindowsWithTitle('微信')[0]\n# 计算窗口内某个点的逻辑坐标 (lx, ly)\n# 转换为物理坐标并点击\npx, py = lx / ljqCtrl.dpi_scale, ly / ljqCtrl.dpi_scale\nljqCtrl.Click(px, py)\n```\n\n## 4. 避坑指南\n- **⚠️ 一律使用物理坐标**：传给 ljqCtrl.Click/SetCursorPos 的坐标必须是物理坐标（=截图像素坐标）。从 pygetwindow 获取的逻辑坐标需先 `/ dpi_scale` 转换。禁止传入逻辑坐标。\n- **物理验证**：模拟操作前必须确保窗口已通过 `activate()` 置于前台。\n- **偏移量**：所有的相对偏移像素值（如“向右移动 10 像素”）同样需要除以 `dpi_scale`。\n- **坐标对齐**: 物理坐标 = 截图坐标；ljqCtrl 自动处理 DPI 换算，禁止手动重复计算。\n- **⚠️ 窗口坐标转换陷阱**：使用 `win32gui.GetWindowRect(hwnd)` 获取的矩形包含标题栏和边框，而截图内容是客户区。点击截图内元素时，必须用 `win32gui.ClientToScreen(hwnd, (0, 0))` 获取客户区原点的屏幕坐标，再加上截图内坐标。禁止直接用 GetWindowRect 左上角 + 截图坐标。\n- **⚠️ win32 DPI 坐标陷阱**：未调用 `SetProcessDPIAware()` 时，`GetWindowRect/ClientToScreen/GetClientRect` 等拿到的窗口/客户区坐标通常是**逻辑坐标**；若后续截图或 `ljqCtrl` 使用的是物理像素，必须统一做 `坐标 / ljqCtrl.dpi_scale`。等价方案：先 `SetProcessDPIAware()`，之后全流程直接使用 raw 物理坐标，禁止逻辑/物理坐标混用。\n- **文本输入**：ljqCtrl 无 TypeText/SendKeys。向输入框键入文本：先点击/三击选中字段，再 `pyperclip.copy('文本'); ljqCtrl.Press('ctrl+v')`。"
  },
  {
    "path": "memory/memory_cleanup_sop.md",
    "content": "# 记忆整理 SOP\n\n## 核心原则：存在性编码\nLLM自身是压缩器+解码器。L1只需让它**意识到某类知识存在**，它就能通过tool call自行取用深层内容。\n\n**L1本质：用最短词数表达——什么场景下有什么记忆可用（存在性）。**\n\nL1两类内容，统一ROI评估：\n- **存在性指针**：指向L2/L3知识的最短触发词\n- **行为规则**：不提醒就会犯的错（致命/高频均可，只要ROI过门槛）\n\nROI = (不放这几个词的犯错概率 × 代价) / 每轮词数成本\n\n## 快速判断\n**该留**：反直觉触发词——没提示就想不到去查SOP的场景词。如`tmwebdriver_sop(httponly cookie)`：没有`httponly cookie`这个词，你不会想到取cookie要查tmwebdriver\n**该删**：\n- 名字翻译：`proxy-pool/(代理池)` → 名字自解释，括号是废词，直接`proxy-pool`即可\n- 内容描述：`opencli_sop(66站点CLI,复用Chrome session)` → 实现细节属于SOP内部，不是触发场景\n- 直觉能力：不提醒也能想到 → 0收益，白交每轮成本\n- 冗余：L3已覆盖的规则 / L1其他行已含的片段\n\n## 压缩四原则\n1. **命名自解释 > 加描述**：SOP名能说清的，L1不加注释；改名的ROI常高于改L1\n2. **存在性集合最小描述**：多个相近条目若可被同一上位场景覆盖，用集合名表达这类能力的存在，不必平铺子项。如`qq操作/飞书操作/企微操作`→`im操作:*_im_sop`；子项名自解释则只列名不翻译\n3. **条目 = 场景↔方案存在性**：如`视频理解:yt-dlp取字幕`、`fofa(资产测绘)`——场景名是触发词，方案名编码存在性；括号内**只放反直觉触发词**，非反直觉的（纯翻译/内容描述/实现细节）全是浪费\n4. **分层归位**：带行为规则或高频高ROI的条目放上方场景行，纯存在性指针归L2/L3平铺列表\n\n## 整理流程\n1. 逐行读L1，按`|`拆片段，先分类：存在性指针 / RULES / 翻译 / 内容描述 / 实现细节 / 冗余\n2. 先清RULES：逐条问“这是全局高ROI，还是特定场景低危险规则？”\n   - 全局高ROI → 留\n   - 特定场景 / 低危险 → 降级到L3或删除\n3. 再清存在性指针：检查是否在表达**场景↔方案存在性**；场景触发词只在**反直觉**时才加，翻译/内容描述/实现细节删掉\n4. 检查L3文件名是否自解释；能靠改名解决的，不靠L1加描述；最后验证总行数 ≤ 30\n\n**红线**：记忆修改是持久性伤害，错误每轮复利。L1只能patch词级别修改，禁overwrite\n产生误导应及时修正L1或记忆更名\n"
  },
  {
    "path": "memory/memory_management_sop.md",
    "content": "## 0. 核心公理 (Core Axioms - 最高优先级)\n1.  **行动验证原则 (Action-Verified Only)**\n    *   **定义**：任何写入 L1/L2/L3 的信息，必须源自**成功的工具调用结果**（如 `shell` 执行成功、`file_read` 确认内容存在、代码运行通过）。\n    *   **禁止**：严禁将模型的“固有知识”、“推理猜测”、“未执行的计划”或“未验证的假设”作为事实写入。\n    *   **口号**：**No Execution, No Memory. (无行动，不记忆)**\n2.  **神圣不可删改性 (Sanctity of Verified Data)**\n    *   **定义**：凡是经过行动验证的有效配置、避坑指南、关键路径，在重构（Refactoring/GC）时**严禁丢弃**。\n    *   **操作**：可以压缩文字、可以迁移层级（从 L2 移到 L3），但绝不能丢失信息的准确性和可追溯性。\n    *   记忆修改时请极度小心，尽量不要overwrite或code run。只能少量patch，改不动宁愿不改。\n3.  **禁止存储易变状态 (No Volatile State)**\n    *   **定义**：严禁存储随时间/会话高频变化的数据。\n    *   **示例**：当前时间戳、临时 Session ID、正在运行的 PID、某个具体绝对路径、连接的设备信息\n4.  **最小充分指针 (Minimum Sufficient Pointer)**\n    *   上层只留能定位下层的最短标识，多一词即冗余。\n---\n## 记忆层级架构\n```\nL1: global_mem_insight.txt (极简索引层 - 严格控制 ≤30 行)  \n    ↓ 导航指向 (Pointer)  \nL2: global_mem.txt (事实库层 - 现短但会膨胀)  \n    ↓ 详细引用 (Reference)  \nL3: ../memory/ (记录库层 - 包含 .md/.py 等各类文件)  \nL4: ../memory/L4_raw_sessions/ (历史会话层 - scheduler反射自动收集，可定位过往上下文)  \n```\n---\n## 各层职责与原则\n### L1：全局内存索引 (global_mem_insight.txt)\n**职责**：为 L2 和 L3 提供极简导航索引，确保关键能力可被发现。\n**特征**：\n- 体积限制：≤ 30 行（硬约束），< 1k tokens（期望）。严禁填写细节（除非极高频任务）\n- 内容：两层「场景关键词→记忆定位」映射 + RULES（红线规则 + 高频犯错点）\n  - 第一层：高频场景 key→value（直接给出 sop/py/L2 section 名），自包含名称只写一词不重复翻译\n  - 第二层：低频场景仅列关键词，需要时 read L2 或 ls L3 自行定位\n  - 核心：场景触发词极重要（不索引则不知有此能力），但严禁写How-to细节\n  - RULES：压缩版避坑准则，包含：\n    - 红线规则（致命型）：违反会导致进程终止或系统崩溃（如 `禁无条件杀python(会杀自己)`）\n    - 红线规则（隐蔽型）：违反不报错但产生错误结果（如 `搜索用google不用百度`）\n    - 高频犯错点：容易遗忘的关键约束（如 `es(PATH有)` 防止找路径）\n- 更新：L2/L3 有新增/删除时，判断频率归入对应层。修改时请极度小心，不允许overwrite或code run。只能少量patch，改不动宁愿不改。\n**禁止**：严禁写入密码、API Key。允许内联非敏感触发参数（如代理端口）。不写 \"How to\" 或详细解释。严禁包含特定任务的技术细节（特定任务细节应该在L3）。更加严禁写入日志记录！\n---\n### L2：全局事实库 (global_mem.txt)\n**职责**：存储全局环境性事实（路径、凭证、配置、常量等）。\n**特征**：\n- 趋势：随环境扩展而膨胀（可接受）\n- 内容：按 `## [SECTION]` 组织的事实条目\n- 同步：变化时更新 L1 的相应 TOPIC 导航行，只能导航\n**禁止**：禁止存储易变状态、禁止存储猜测、严禁存储大模型可推理的通用常识\n---\n### L3：任务级精简记录库 (../memory/)\n职责：补充 L1/L2 无法容纳、但对**特定任务**未来复用至关重要的少量详细信息。内容必须在满足复用需求的前提下**尽可能短**。\n原则：\n- 只记录：跨会话仍重要、且难以通过少量 file_read / web_scan / 简单脚本快速重建的要点。\n- 优先写：该任务特有的隐藏前置条件、典型易踩坑点，一旦遗忘会导致高成本重试的信息。\n- 不记录：普通操作步骤、可在几步探测中重新获得的路径或状态信息。\n形式：\n- SOP（*_sop.md）：为单一任务或小类任务保留极简的「关键前置 + 典型坑」清单，避免长篇教程。\n- 工具脚本（*.py）：仅封装高复用、逻辑相对复杂且不希望每次都重新推理的处理流程。\n---\n## L1 ↔ L2/L3 同步规则\n| 操作 | L1 同步 |\n|---------|--------|\n| L2/L3 新增场景 | 新建默认低频→L3列表加文件名（自解释不加描述，反直觉场景才能加括号触发词） |\n| L2/L3 删除场景 | 删除对应层的关键词/映射行 |\n| L2/L3 修改值 | 若不影响场景定位则不动 L1 |\n| 发现通用避坑规律 | 压缩为一句加入 RULES |\n\n> **同步红线**：L1 只写关键词/名称，禁搬细节。括号内只写反直觉的场景触发词(2-4字)，禁写机制/方法/步骤。需要评估L1中的token数和索引效用。\n> 反例：❌ sop_name(场景A:方法1+方法2+方法3) → ✅ sop_name(场景A)\n> 反例：名字已自解释时 ❌ discord_slate_sop(Slate输入框) → ✅ discord_slate_sop\n\n---\n## 信息分类快速决策树\n```\n\"这条信息该放哪层？\"\n\n是『环境特异性事实』? (IP、非标路径、凭证、ID、API 密钥等，大模型 Zero-shot 无法生成准确)\n  ├─ YES → L2 (global_mem.txt)\n  │        然后 → 按频率归入 L1 第一层(key→value)或第二层(仅关键词)\n  │\n  └─ NO\n       ↓\n       是『通用操作规律』? (全局性避坑指南、排查方法、不针对特定任务的通用准则)\n       ├─ YES → L1 [RULES] (仅限 1 句压缩准则)\n       │\n       └─ NO\n            ↓\n            是『特定任务技术』? (艰难尝试才能成功，且未来还能用到的任务，如：微信解析参数、特定游戏坐标、临时工具配置)\n            ├─ YES → L3 (../memory/ 专项 SOP 或脚本)\n            │\n            └─ NO → 判定为『通用常识』或『冗余信息』: 严禁存储，直接丢弃\n```"
  },
  {
    "path": "memory/ocr_utils.py",
    "content": "\"\"\"\n本地 OCR 工具\n- OCR引擎: rapidocr-onnxruntime (~1s/次, 中英文准确率高, 带bbox)\n- 坑(rapid): result[i][2] conf 是 str 不是 float\n- 坑(rapid): 无文字时 result 返回 None 而非空列表\n- 坑: enhance 放大+高对比度处理，对清晰文字有害，默认关闭\n- 坑(远程桌面): ImageGrab/mss 在 RDP 断开后截图全黑，用 ocr_window(hwnd) 代替\n\"\"\"\nimport re\nfrom PIL import ImageGrab, Image, ImageEnhance\n\n_LANG = 'zh-Hans-CN'\n_rapid_engine = None\n\ndef _get_rapid():\n    global _rapid_engine\n    if _rapid_engine is None:\n        from rapidocr_onnxruntime import RapidOCR\n        _rapid_engine = RapidOCR()\n    return _rapid_engine\n\ndef _preprocess(img, scale=3, contrast=3.0):\n    img = ImageEnhance.Contrast(img).enhance(contrast)\n    img = img.resize((img.width * scale, img.height * scale))\n    return img\n\ndef _strip_cjk_spaces(t):\n    return re.sub(r'(?<=[\\u4e00-\\u9fff])\\s+(?=[\\u4e00-\\u9fff])', '', t)\n\ndef _ocr_rapid(img):\n    import numpy as np\n    engine = _get_rapid()\n    arr = np.array(img)\n    result, elapse = engine(arr)\n    if not result:\n        return {'text': '', 'lines': [], 'details': []}\n    lines = [r[1] for r in result]\n    details = [{'bbox': r[0], 'text': r[1], 'conf': float(r[2])} for r in result]\n    text = _strip_cjk_spaces('\\n'.join(lines))\n    return {'text': text, 'lines': [_strip_cjk_spaces(l) for l in lines], 'details': details}\n\ndef ocr_image(image_input, lang=_LANG, enhance=False, engine=None):\n    \"\"\"\n    对 PIL Image 做 OCR\n    :param image_input: PIL Image 对象 或 文件路径(str)\n    :param lang: 保留参数，当前未使用\n    :param enhance: 预处理\n    :param engine: 保留参数，当前仅支持 rapid/None\n    :return: dict {'text': 全文, 'lines': [行文本], 'details': [bbox+conf]}\n    \"\"\"\n    if isinstance(image_input, str):\n        image_input = Image.open(image_input)\n    if enhance:\n        image_input = _preprocess(image_input)\n    if engine not in (None, 'rapid'):\n        raise ValueError(\"Only rapid OCR is supported\")\n    return _ocr_rapid(image_input)\n\ndef ocr_screen(bbox=None, lang=_LANG, enhance=False, engine=None):\n    \"\"\"\n    截取屏幕区域并 OCR\n    :param bbox: (x1, y1, x2, y2) 像素坐标，None=全屏\n    :return: dict {'text': 全文, 'lines': [行文本], 'details': [bbox+conf](仅rapid)}\n    \"\"\"\n    img = ImageGrab.grab(bbox=bbox)\n    return ocr_image(img, lang, enhance, engine)\n\ndef ocr_window(hwnd, lang=_LANG, enhance=False, engine=None):\n    \"\"\"\n    截取窗口并 OCR (使用 PrintWindow API，支持远程桌面断开场景)\n    :param hwnd: 窗口句柄(int)\n    :return: dict {'text': 全文, 'lines': [行文本], 'details': [bbox+conf](仅rapid)}\n    \"\"\"\n    import win32gui, win32ui\n    from ctypes import windll\n    l, t, r, b = win32gui.GetWindowRect(hwnd)\n    w, h = r - l, b - t\n    hwndDC = win32gui.GetWindowDC(hwnd)\n    mfcDC = win32ui.CreateDCFromHandle(hwndDC)\n    saveDC = mfcDC.CreateCompatibleDC()\n    saveBitMap = win32ui.CreateBitmap()\n    saveBitMap.CreateCompatibleBitmap(mfcDC, w, h)\n    saveDC.SelectObject(saveBitMap)\n    windll.user32.PrintWindow(hwnd, saveDC.GetSafeHdc(), 3)\n    bmpinfo = saveBitMap.GetInfo()\n    bmpstr = saveBitMap.GetBitmapBits(True)\n    img = Image.frombuffer('RGB', (bmpinfo['bmWidth'], bmpinfo['bmHeight']), bmpstr, 'raw', 'BGRX', 0, 1)\n    win32gui.DeleteObject(saveBitMap.GetHandle())\n    saveDC.DeleteDC()\n    mfcDC.DeleteDC()\n    win32gui.ReleaseDC(hwnd, hwndDC)\n    return ocr_image(img, lang, enhance, engine)\n\nif __name__ == \"__main__\":\n    r = ocr_screen((0, 0, 400, 100))\n    print(f\"识别结果: {r['text']}\")\n    for line in r['lines']:\n        print(f\"  行: {line}\")\n    if 'details' in r:\n        for d in r['details']:\n            print(f\"  [{d['conf']:.3f}] {d['text']}\")"
  },
  {
    "path": "memory/plan_sop.md",
    "content": "# Plan Mode SOP\n\n**触发**：3步以上有依赖/多文件协同/条件分支/需并行 | **禁用**：1-2步简单任务直接做\n任务开始前必须先创建工作目录 `./plan_XXX/`（XXX=任务英文短名）\n单独使用一个code_run({'inline_eval':True, 'script':'handler.enter_plan_mode(\"./plan_XXX/plan.md\")'})进入plan模式\n\n---\n\n## 一、探索态（规划前置，必须执行）\n\n⛔ **硬性规则（先读再做）**：\n\n- **主agent禁止直接执行环境探测**（必须委托subagent，无例外）\n- 主agent只做：创建目录、匹配SOP、启动subagent、读取结论\n- subagent只读探测，禁止修改任何文件、执行有副作用的操作\n- **探索subagent启动失败时：排查原因→重试，最多2次。禁止主agent回退为自己探测**\n\n**目标**：在写任何计划之前，搞清3件事：\n① 环境现状（有什么、缺什么） ② 可用SOP ③ 关键不确定点\n\n**为什么必须用subagent**：主agent上下文是最稀缺资源，探测长输出会挤占规划执行空间。\n\n### 步骤1：创建目录（必做） + SOP匹配 + 设置plan标志（主agent直接做）\n\n1. 创建工作目录 `mkdir plan_XXX/`\n2. 从上下文中的 L1 Insight 索引匹配可用领域SOP\n3. 更新checkpoint：`[任务] XXX | [需求] 一句话 | [约束] 关键限制 | [匹配SOP] ... | [进度] 探索态`\n\n### 步骤2：启动探索subagent（监察模式）\n\n按 subagent.md 启动探索subagent，**加 `--verbose`** 开启监察模式，input要点：\n\n- **任务**：探测环境信息，写入 `plan_XXX/exploration_findings.md`\n- **探测项**（按任务类型选做，不是全做）：\n  - 代码类 → 关键文件结构、依赖、入口点\n  - 浏览器类 → 目标页面当前状态、可交互元素\n  - 自动化类 → 环境检查(which/pip/路径/权限)\n  - 数据类 → 抽样数据(首5行+尾5行+总量)\n- **输出格式**：`## 环境现状` / `## 关键发现` / `## 风险/不确定点`\n- **约束**：只读探测，禁止修改文件，≤10次工具调用\n- **复杂度评估**：探测时注意记录数据规模（文件数、行数、页面数），写入findings供规划时判断委托\n\n### 步骤3：监察等待 + 读取结论\n\n主agent主动观察output.txt进度（`--verbose`输出含原始工具结果），而非无脑sleep轮询：\n\n1. **观察**：读output.txt，审查subagent的探测方向和原始数据\n2. **纠偏**（按需）：\n   - 方向偏了 → 写 `_intervene` 追加指令纠正\n   - 缺少关键上下文 → 写 `_keyinfo` 注入信息\n   - 已获取足够信息 → 写 `_stop` 提前终止，节省轮次\n3. **收取**：等待 `[ROUND END]`，读取 `exploration_findings.md`\n\n**产出**：`exploration_findings.md`（结构化发现报告），主agent基于此进入规划态，写入plan.md头部的「探索发现」段。主agent在监察过程中获得的一手认知也可直接用于规划。\n\n---\n\n## 二、规划态（含审查门）\n\n### 步骤4：读领域SOP → 写plan.md\n\n先读探索态匹配到的SOP，然后写plan骨架。允许\"⚠待确认\"，禁止以\"没调研清楚\"推迟。\n\n**[D] 委托标注规则**：写每个步骤时，结合探索发现评估操作量，符合以下任一条件则标 `[D]`：\n\n- 需要读取大量代码/文件（预估 >3个文件或 >100行）\n- 需要浏览网页并提取信息\n- 需要执行 3 次以上重复性操作\n- 需要运行测试/构建并分析输出\n\n不标 `[D]` 的情况：读/更新 plan.md、单文件小幅修改、ask_user、简单一次性命令\n\n**plan.md格式**：\n\n```markdown\n<!-- EXECUTION PROTOCOL (每轮必读，这是你的执行指南)\n1. file_read(plan.md)，找到第一个 [ ] 项\n2. 该步标注了SOP → file_read 该SOP的🔑速查段\n3. 执行该步骤 + Mini验证产出\n4. file_patch 标记 [ ] → [✓]+简要结果，然后回到步骤1继续下一个[ ]\n5. 所有步骤（包括验证步骤）标记完成后 → 终止检查：file_read(plan.md)确认0个[ ]残留\n⚠ 禁止凭记忆执行 | 禁止跳过验证步骤 | 禁止未经终止检查就结束 | 禁止停下来输出纯文字汇报\n💡 搬砖活（读大量代码/文件/网页/重复操作）优先委托subagent，保持主agent上下文干净\n-->\n# 任务标题\n需求：一句话 | 约束：关键限制\n\n## 探索发现\n- 发现1：XXX（来源：file_read/web_scan/code_run）\n- 发现2：YYY\n- 不确定点：ZZZ\n\n## 执行计划\n1. [ ] 步骤1简述\n   SOP: xxx_sop.md\n2. [D] 步骤2简述（委托subagent执行）\n   SOP: yyy_sop.md\n   依赖：1\n3. [P] 步骤3简述（并行，读subagent.md执行Map模式）\n   SOP: yyy_sop.md\n4. [?] 步骤4（条件分支）\n   SOP: (无) ← 高风险\n   条件：X成功→4.1，否则→4.2\n\n---\n\n## 验证检查点\nN+1. [ ] **[VERIFY] 启动独立验证subagent**\n     SOP: verify_sop.md plan_sop.md\n     操作：读plan_sop.md第四章内容 → 准备verify_context.json → 启动验证subagent → 读取VERDICT → 按结果处理\n     ⚠ 不可跳过，不可在未启动subagent的情况下标记[✓]\n\n---\n```\n\n### 步骤5：自检清单（主agent逐项检查）\n\n- □ 探索发现是否都反映在plan中？（没遗漏关键约束）\n- □ 每步的SOP标注是否合理？（SOP真的能解决该步？）\n- □ 步骤间依赖是否正确？（有没有隐含依赖没写出来）\n- □ 高风险步骤（SOP:无/不可逆）有没有清晰的执行思路？\n- □ 步骤粒度是否合适？（禁止\"处理所有文件\"，必须展开具体条目）\n- □ **复杂/繁琐步骤是否标注了[D]？**（读大量代码/网页/重复操作必须委托subagent）\n- □ **是否包含\"验证检查点\"section，且有[VERIFY]步骤？（必须有，这是强制步骤）**\n\n### 步骤6：用户确认\n\nask_user 确认plan后才能转入执行态。**⛔ 用户未确认不得执行。**\n\n### 步骤7：转入执行态\n\n更新checkpoint：`[执行] plan.md | 当前：步骤1 | ⚡有[P]标记必须读subagent.md执行Map模式`\n\n---\n\n## 三、执行态循环\n\n> **核心原则：连续执行，不停顿汇报。** 做完一步立即 file_read(plan.md) 找下一个 `[ ]`，直到全部完成。\n\n### 每轮流程\n\n1. **读plan** — `file_read(plan.md)` 定位第一个 `[ ]` 项\n2. **读SOP** — 该步标注了SOP → 先 file_read 该SOP\n3. **检查标记** — `[D]`标记 → 必须委托subagent执行，主agent只收结果摘要；`[P]`标记 → 读 subagent_sop.md 执行Map模式；`[?]`条件 → 评估条件选分支，未选标[SKIP]\n4. **执行** — 无特殊标记的步骤由主agent自己执行\n5. **Mini验证** — 快速确认产出存在且合理（file_read确认非空、检查exit code等）\n6. **标记完成** — `file_patch` 标记 `[ ]` → `[✓ 简要结果]`（进度写入plan.md）\n7. **继续** — 立即回到步骤1，file_read(plan.md) 执行下一个 `[ ]`\n\n### 终止检查（最后一步标记后，不可跳过）\n\nfile_read(plan.md) 全文扫描，确认所有步骤（含[VERIFY]）均为 `[✓]`/`[✗]`，0个 `[ ]` 残留。\n输出：`🏁 终止检查：[总步数]步全部完成，0个[ ]残留 → 任务结束`\n若发现遗漏 → 继续执行，禁止声称完成。\n\n### ⚠ 执行态禁令\n\n- **禁止凭记忆执行**：每次做新步骤前必须 `file_read(plan.md)`，不可\"我记得下一步是...\"\n- **禁止跳过验证步骤**：[VERIFY]步骤是强制的，不可以\"任务都做完了\"为由跳过\n- **禁止未经终止检查就结束**：最后一步标记后必须 file_read 全文扫描确认0个[ ]残留，输出🏁终止确认行\n- **禁止停下来输出纯文字汇报**：做完一步后必须立即 file_read(plan.md) 继续，不要输出进度总结\n\n### 💡 动态委托原则\n\n即使步骤未标 `[D]`，执行中发现以下情况时，主动委托 subagent 处理：\n\n- 需要读取大量代码/文件才能理解上下文（>3个文件或预估 >100行）\n- 需要反复试错调试\n- 需要浏览网页提取信息\n\n做法：起 subagent 完成具体操作，要求返回精简摘要，主 agent 基于摘要继续决策。保持主 agent 上下文干净是第一优先级。\n\n---\n\n## 四、验证态（subagent独立验证）\n\n> 全部步骤[✓]后进入。**强制**启动独立subagent做对抗性验证，避免上下文污染。\n\n### 触发条件\n\n- 所有执行步骤标记为 `[✓]`\n- **所有plan模式任务必须经subagent验证**（主agent有确认偏误，易被表面成功迷惑）\n\n### 步骤8：准备验证上下文\n\n在 `./plan_XXX/` 下创建 `verify_context.json`，包含：\n\n- task_description：原始任务描述（用户原话）\n- plan_file：plan.md绝对路径\n- task_type：code|data|browser|file|system\n- deliverables：交付物列表（type/path/expected）\n- required_checks：必做检查列表（check/tool）\n\n**传什么**：任务描述、plan路径、交付物清单、必做检查。**不传**：执行过程、调试记录。\n\n### 步骤9：启动验证subagent\n\n按 subagent.md 标准流程启动验证subagent，input要点：\n\n- **角色**：你是独立验证者，工作是对抗性验证（证明交付物不能用）\n- **第一步强制**：file_read verify_sop.md 完整阅读验证SOP\n- **按 verify_sop.md 第3节**选择对应task_type的验证策略执行\n- **每个检查必须有工具调用证据**（实际执行，不是叙述）\n- **任务描述**：（填入原始任务描述）\n- **交付物清单**：（填入deliverables列表）\n- **输出**：在 result.md 中按 verify_sop.md 第6节格式输出，最后一行 `VERDICT: PASS / FAIL / PARTIAL`\n- **约束**：3轮内完成，每轮至少1个实际工具调用\n\n同时传入 verify_context.json 的路径，让subagent自行读取详细上下文。\n\n### 步骤10：收集验证结果\n\n轮询 output.txt 等待 `[ROUND END]`，然后读取 result.md：\n\n1. **找VERDICT行**：读取result.md最后几行，提取 `VERDICT: PASS/FAIL/PARTIAL`\n2. **检查有效性**：如果所有PASS项都没有工具调用输出（只有叙述），视为验证无效，按FAIL处理\n3. **按结果处理**：\n   - **PASS** → 进入任务完成收尾\n   - **FAIL** → 进入修复循环\n   - **PARTIAL** → 主agent判断可接受则完成，否则修复\n   - **无VERDICT行** → 从output.txt提取关键信息，主agent自行判断PASS/FAIL\n\n**任务完成收尾**（验证PASS后执行）：\n\n1. 标记plan.md中 `[VERIFY]` 步骤为 `[✓]`\n2. 更新checkpoint：`[完成] XXX任务 | [产出] ... | [经验] ...`\n3. 向用户确认任务完成\n\n**重要**：只有在验证PASS后，才能标记[VERIFY]为[✓]并声称任务完成。如果验证FAIL，需要进入修复循环。\n\n**Fallback**：若subagent未产出result.md（turn耗尽），从output.txt提取VERDICT关键信息。\n\n### 修复循环（FAIL后）\n\nFAIL → 提取具体失败项 → 回执行态修复（不重新规划） → 修复完成 → 再次启动验证subagent → 最多2轮FAIL-重试，超过 ask_user 介入\n\n修复时：\n\n1. 将FAIL项作为新步骤追加到plan.md（标记为 `[FIX]`）\n2. 只修复失败项，不重做已PASS的部分\n3. 修复完成后重新准备verify_context.json（只含失败项）\n\n### 特殊场景处理\n\n浏览器/键鼠/定时任务等场景：主agent执行操作并导出证据（截图/录屏/日志）→ subagent验证证据文件。**禁止主agent自行判断PASS/FAIL**。\n\n---\n\n## 五、失败处理\n\n1. **记录**：checkpoint中 `step_X: [FAILED] 原因 (retry: N/3)`\n2. **重试**：网络超时→自动重试3次(2s/4s/8s) | 配置错误→询问用户 | 其他→标[✗]跳过\n3. **subagent失败**：查stderr.log→明确错误主agent修正重启 | 未知错误重试1次 | 最多重启2次\n4. **依赖传播**：步骤失败后，后续依赖项标[SKIP]\n5. **plan有误**：回退到规划态修正plan.md，重新过审查门\n\n## 强制约束\n\n- 每项必须有独立完成判据\n- 禁止\"处理所有文件\"，必须展开具体条目\n- 一次只做一项；计划有误回规划态修正\n- 不可逆操作前多验证一步\n"
  },
  {
    "path": "memory/procmem_scanner.py",
    "content": "import ctypes\nimport ctypes.wintypes\nimport argparse\nimport yara\nimport sys\nimport os\nimport json\n\n# Define WinAPI Types for 64-bit compatibility\nPHANDLE = ctypes.wintypes.HANDLE\nLPCVOID = ctypes.c_void_p\nLPVOID = ctypes.c_void_p\nSIZE_T = ctypes.c_size_t\n\nclass MEMORY_BASIC_INFORMATION(ctypes.Structure):\n    _fields_ = [\n        (\"BaseAddress\", LPVOID),\n        (\"AllocationBase\", LPVOID),\n        (\"AllocationProtect\", ctypes.wintypes.DWORD),\n        (\"RegionSize\", SIZE_T),\n        (\"State\", ctypes.wintypes.DWORD),\n        (\"Protect\", ctypes.wintypes.DWORD),\n        (\"Type\", ctypes.wintypes.DWORD),\n    ]\n\n# Explicitly setup kernel32 functions with precise types\nk32 = ctypes.windll.kernel32\nk32.OpenProcess.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD]\nk32.OpenProcess.restype = PHANDLE\n\nk32.VirtualQueryEx.argtypes = [PHANDLE, LPCVOID, ctypes.POINTER(MEMORY_BASIC_INFORMATION), SIZE_T]\nk32.VirtualQueryEx.restype = SIZE_T\n\nk32.ReadProcessMemory.argtypes = [PHANDLE, LPCVOID, LPVOID, SIZE_T, ctypes.POINTER(SIZE_T)]\nk32.ReadProcessMemory.restype = ctypes.wintypes.BOOL\n\ndef is_hex_pattern(pattern):\n    clean = pattern.replace(\" \", \"\").replace(\"??\", \"\")\n    return all(c in \"0123456789abcdefABCDEF\" for c in clean) and (len(clean) % 2 == 0 or \"??\" in pattern)\n\ndef build_rules(pattern, mode='auto'):\n    use_hex = (mode == 'hex') or (mode == 'auto' and is_hex_pattern(pattern))\n    if use_hex:\n        rule_text = f'rule CustomSearch {{ strings: $h = {{ {pattern.strip()} }} condition: $h }}'\n    else:\n        escaped = pattern.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')\n        rule_text = f'rule CustomSearch {{ strings: $s = \"{escaped}\" ascii wide condition: $s }}'\n    return yara.compile(source=rule_text)\n\ndef format_llm_context(data, offset, base_addr, length=64):\n    start = max(0, offset - length)\n    end = min(len(data), offset + length + 16)\n    chunk = data[start:end]\n    abs_addr = (base_addr if base_addr else 0) + offset\n    return {\n        \"address\": hex(abs_addr),\n        \"offset\": hex(offset),\n        \"hex\": chunk.hex(),\n        \"ascii\": \"\".join(chr(b) if 32 <= b <= 126 else \".\" for b in chunk),\n        \"hit_pos\": offset - start\n    }\n\ndef scan_memory(pid, pattern, context_size=256, mode='auto', llm_mode=False):\n    rules = build_rules(pattern, mode)\n    h_proc = k32.OpenProcess(0x0400 | 0x0010, False, pid)\n    if not h_proc:\n        # OpenProcess failed: might be system process or higher integrity level\n        return [f\"Error: Cannot open process {pid}. (ErrorCode: {k32.GetLastError()})\"]\n\n    results = []\n    curr_addr = 0\n    mbi = MEMORY_BASIC_INFORMATION()\n    \n    # Range for 64-bit user space\n    max_addr = 0x7FFFFFFFFFFF\n\n    while curr_addr < max_addr:\n        # Use cast to ensure pointer type is correct for 64-bit\n        res = k32.VirtualQueryEx(h_proc, ctypes.cast(curr_addr, LPCVOID), ctypes.byref(mbi), ctypes.sizeof(mbi))\n        if res == 0: break\n        \n        # MEM_COMMIT = 0x1000, PAGE_READABLE bitmask\n        if mbi.State == 0x1000 and (mbi.Protect & 0xEE): # 0xEE covers common readable flags\n            buf = ctypes.create_string_buffer(mbi.RegionSize)\n            read = SIZE_T(0)\n            if k32.ReadProcessMemory(h_proc, ctypes.cast(mbi.BaseAddress, LPCVOID), buf, mbi.RegionSize, ctypes.byref(read)):\n                data = buf.raw[:read.value]\n                for match in rules.match(data=data):\n                    for inst in match.strings:\n                        offset = inst.instances[0].offset\n                        matched_data = inst.instances[0].matched_data\n                        base = mbi.BaseAddress if mbi.BaseAddress else 0\n                        if llm_mode:\n                            results.append(format_llm_context(data, offset, base, length=context_size))\n                        else:\n                            # Expand context based on context_size to capture full KEY+SALT\n                            start = max(0, offset - context_size)\n                            end = min(len(data), offset + len(matched_data) + context_size)\n                            results.append(f\"Addr: {hex(base+offset)}\\nHex: {data[start:end].hex()}\")\n\n        # Update address using the region size\n        next_addr = (mbi.BaseAddress if mbi.BaseAddress else 0) + mbi.RegionSize\n        if next_addr <= curr_addr: break\n        curr_addr = next_addr\n\n    k32.CloseHandle(h_proc)\n    return results\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"pid\", type=int)\n    parser.add_argument(\"pattern\", type=str)\n    parser.add_argument(\"--mode\", default='auto')\n    parser.add_argument(\"--llm\", action=\"store_true\")\n    args = parser.parse_args()\n    try:\n        res = scan_memory(args.pid, args.pattern, mode=args.mode, llm_mode=args.llm)\n        print(json.dumps(res, indent=2) if args.llm else f\"Matches: {len(res)}\")\n    except Exception as e:\n        print(f\"Error: {e}\")"
  },
  {
    "path": "memory/procmem_scanner_sop.md",
    "content": "# Memory Scanner SOP\n\n## 1. 快速开始\n内存特征搜索工具，支持 Hex (CE 风格) 和 字符串匹配。特别提供 LLM 模式，方便大模型分析内存上下文。\n\n**Python 调用方式:**\n```python\nimport sys\nsys.path.append('../memory') # 直接挂载工具目录\nfrom procmem_scanner import scan_memory\n\n# 示例：搜索特定 Hex 特征码，开启 llm_mode 以获取上下文\nresults = scan_memory(pid, \"48 8b ?? ?? 00\", mode=\"hex\", llm_mode=True)\n```\n\n**CLI:**\n```powershell\n# 基础搜索\npython ../memory/procmem_scanner.py <PID> \"pattern\" --mode string\n\n# LLM 增强模式（输出包含上下文的 JSON，推荐）\npython ../memory/procmem_scanner.py <PID> \"pattern\" --llm\n```\n\n## 2. 典型场景：结构体或关键数据定位\n1. 确定目标数据的前导特征或已知常量（如特定的 Header 或 Magic Number）。\n2. 在目标进程中搜索该特征：\n   `scan_memory(pid, \"4D 5A 90 00\", mode=\"hex\", llm_mode=True)`\n3. 分析返回的 JSON 中 `context` 字段，查看目标地址前后的原始字节及 ASCII 预览。\n\n## 3. 注意事项\n- **权限**: 并非强制要求管理员权限，但需具备对目标进程的 `PROCESS_QUERY_INFORMATION` 和 `PROCESS_VM_READ` 权限。\n- **效率**: 搜索大块内存时，尽量提供更唯一的特征码以减少误报。\n\n## 4. CE式差集扫描定位动态字段\n定位微信等自绘UI中随操作变化的内存字段（如当前会话标题）。核心：一次全量scan + 多次ReadProcessMemory筛选。\n"
  },
  {
    "path": "memory/scheduled_task_sop.md",
    "content": "# 定时任务 SOP\n\n目录：`../sche_tasks/` 放任务定义JSON，`../sche_tasks/done/` 放执行报告\n\n## 任务JSON格式（*.json）\n```json\n{\"schedule\":\"08:00\", \"repeat\":\"daily\", \"enabled\":true, \"prompt\":\"...\", \"max_delay_hours\":6}\n```\nrepeat可选：daily | weekday | weekly | monthly | once | every_Nh（每N小时）| every_Nd（每N天）\nmax_delay_hours（可选，默认6）：超过schedule多少小时后不再触发，防止开机太晚执行过时任务\n\n## 触发流程\n1. scheduler.py（reflect/）每60秒轮询 sche_tasks/*.json\n2. 条件全满足才触发：enabled=true + 当前时间≥schedule + 冷却时间已过（基于done/最新报告时间戳）\n3. 触发时拼prompt，含报告路径 `../sche_tasks/done/YYYY-MM-DD_任务名.md`\n4. **收到任务后第一件事**：用 update_working_checkpoint 记录报告目标文件路径，防止长任务执行中遗忘\n5. 执行完毕后将报告写入上述路径（scheduler靠此文件判断今天已执行）\n\n## 日志与监控\n- scheduler自动写日志到 `sche_tasks/scheduler.log`（触发/跳过/错误）\n- `scheduler.health_check()` 返回所有任务状态列表（HEALTHY/OVERDUE/DISABLED/NEVER_RUN/ERROR）\n- JSON解析错误、schedule格式错误、未知repeat类型均会记录日志\n\n## 注意\n- once类型：执行一次后冷却100年（实际效果为永久跳过）\n- 任务文件只管\"干什么\"，报告路径由scheduler自动生成注入prompt\n- sche_tasks目录在../，即code root下"
  },
  {
    "path": "memory/supervisor_sop.md",
    "content": "# 监察者模式 SOP\n\n> 你是挑刺的监工，不是干活的工人。你的唯一任务：确保工作agent高质量完成任务。有SOP按SOP约束，无SOP凭常理和经验把关。\n\n## 红线\n\n- **禁止下场干活**：不操作浏览器、不写代码、不执行任务步骤。你只读、只判断、只干预\n- **可以读环境**：file_read/web_scan/web_execute_js/code_run(只读命令)获取情报，辅助判断工作agent进度和状态\n\n## 启动\n\n1. **有SOP时**：读SOP原文，提取所有约束（⚠️/禁止/必须/格式要求），按步骤列成**约束清单**存working memory\n1. **无SOP时**：根据任务性质和进度，预估未来会遇到的关键风险点\n2. **启动subagent**（cwd=代码根）：\n   ```\n   python agentmain.py --task {name} --bg --verbose\n   ```\n   input.txt：`用{SOP名}完成{用户任务}`（只给目标，不复述步骤）\n\n## 监控循环\n\n持续轮询 `temp/{task_name}/output.txt` 的新增内容（sleep间隔读取），每发现新输出：\n\n1. 判断工作agent当前在哪一步，对照约束清单检查（约束记不清时重读SOP原文，禁凭印象）\n2. 可读环境信息（文件/网页/进程）补充判断依据\n3. 工作agent ask_user时给予回复\n\n| 发现 | 干预 |\n|------|------|\n| 跳步 | `_intervene`：你跳过了StepN，先做 |\n| 细节遗漏 | `_intervene`：你漏了XX约束，重做/补上 |\n| 光说不做 | `_intervene`：别说了，直接做 |\n| 断言无据 | `_intervene`：你怎么确认的？验证一下 |\n| 连续失败 | `_intervene`：停，先读错误日志再决定 |\n| 感觉要偏 | `_intervene`：去重读SOP的StepN再继续 |\n| 即将进入中后期步骤 | `_keyinfo`：提前注入该步骤的⚠️细节（趁还没到，先塞进working memory） |\n\n## 干预原则\n\n- **沉默为主**：没问题不说话\n- **一句话**：像用户一样直接说，禁长篇解释\n- **`_keyinfo`只用于提前预注入**：在工作agent到达该步之前塞细节。已经犯错的一律用`_intervene`纠正"
  },
  {
    "path": "memory/tmwebdriver_sop.md",
    "content": "# TMWebDriver SOP\n\n- 直接用web_scan/web_execute_js工具。本文件只记录特性和坑。\n- 底层：`../TMWebDriver.py`通过Chrome扩展接管用户浏览器（保留登录态/Cookie）\n- 非Selenium/Playwright，保留用户浏览器登录态\n\n## 通用特性\n- ⚠web_execute_js里使用`await`时需**显式`return`**才能拿到返回值（底层async包裹，不写return则返回null）\n- ✅web_scan自动穿透同源iframe；跨域iframe需CDP或postMessage（见下方章节）\n\n## 限制(isTrusted)\n- JS事件`isTrusted=false`，敏感操作（如文件上传/部分按钮）可能被拦截；这类场景首选**CDP桥**\n- ⚠JS点击按钮打不开新tab→可能是浏览器弹窗拦截，换CDP点击试试\n- Vue3自定义组件(Select/Dropdown)：⭐优先vnode实例调用(无视口限制)→见**vue3_component_sop**；CDP坐标点击仅适合选项少且可见的场景\n- 文件上传：⭐首选**DataTransfer API**（纯JS，无CDP依赖）：`new File([content],name,{type}) → new DataTransfer().items.add(file) → input.files=dt.files → dispatch input+change`；CDP `DOM.setFileInputFiles` 在tmwd桥环境nodeId跨调用失效，不推荐；备选ljqCtrl物理点击\n- 需转物理坐标时：`physX = (screenX + rect中心x) * dpr`，`physY = (screenY + chromeH + rect中心y) * dpr`；其中 `chromeH = outerHeight - innerHeight`\n\n## 导航\n- `web_scan` 仅读当前页不导航，切换网站用 `web_execute_js` + `location.href='url'`\n\n## Google图搜\n- class名混淆禁硬编码，点击结果用 `[role=button]` div\n- web_scan过滤边栏，弹出后用JS：文本`document.body.innerText`，大图遍历img按`naturalWidth`最大取src\n- \"访问\"链接：遍历a找`textContent.includes('访问')`的href\n- 缩略图：`img[src^=\"data:image\"]`直接提取；大图src可能截断用`return img.src`\n\n## Chrome下载PDF\n场景：PDF链接在浏览器内预览而非下载\n```js\nfetch('PDF_URL').then(r=>r.blob()).then(b=>{\n  const a=document.createElement('a');\n  a.href=URL.createObjectURL(b);\n  a.download='filename.pdf';\n  a.click();\n});\n```\n注意：需同源或CORS允许，跨域先导航到目标域再执行\n\n## Chrome后台标签节流\n- 后台标签中`setTimeout`被Chrome intensive throttling延迟到≥1min/次，扩展脚本中避免依赖setTimeout轮询\n- 某些SPA页面需CDP `Page.bringToFront`切到前台才会加载数据\n\n## CDP桥(tmwd_cdp_bridge扩展) ⭐首选\n扩展路径：`assets/tmwd_cdp_bridge/`(需安装，含debugger权限)\n⚠TID约定标识：首次运行自动生成到`assets/tmwd_cdp_bridge/config.js`(已gitignore)，扩展通过manifest引用\n调用：`web_execute_js` script直传JSON字符串（工具层自动识别对象格式，走WS→background.js cmd路由）\n```js\n// 直接传JSON字符串作为script参数，无需DOM操作\nweb_execute_js script='{\"cmd\": \"cookies\"}'\nweb_execute_js script='{\"cmd\": \"tabs\"}'\nweb_execute_js script='{\"cmd\": \"cdp\", \"tabId\": N, \"method\": \"...\", \"params\": {...}}'\nweb_execute_js script='{\"cmd\": \"batch\", \"commands\": [...]}'\n// 返回值直接是JSON结果\n```\n通信方式：⭐JSON字符串直传(首选) | TID DOM方式(TID元素+MutationObserver，web_scan/execute_js底层依赖)\n单命令：`{cmd:'tabs'}` | `{cmd:'cookies'}` | `{cmd:'cdp', tabId:N, method:'...', params:{...}}` | `{cmd:'management', method:'list|reload|disable|enable', extId:'...'}`\n- management：list返回所有扩展信息；reload/disable/enable需传extId\n- contentSettings：`{cmd:'contentSettings', type:'automaticDownloads', pattern:'https://*/*', setting:'allow'}`\n  - 绕过Chrome\"下载多个文件\"对话框（该对话框会阻塞整个浏览器JS执行）\n  - type可选：automaticDownloads/popups/notifications等；setting：allow/block/ask\n  - ⚠CDP的Browser.setDownloadBehavior在扩展中不可用（chrome.debugger仅tab级），此为替代方案\n- ⭐batch混合：`{cmd:'batch', commands:[{cmd:'cookies'},{cmd:'tabs'},{cmd:'cdp',...},...]}`\n  - 返回`{ok:true, results:[...]}`，一次请求多命令，CDP懒attach复用session\n  - 子命令会自动继承外层batch的tabId（如cookies命令可正确获取当前页面URL）\n  - `$N.path`引用第N个结果字段(0-indexed)，如`\"nodeId\":\"$2.root.nodeId\"`\n  - ⚠batch前序命令失败时，后续`$N`引用会静默变成undefined；要检查results数组中每项的ok状态\n  - 典型文件上传：getDocument(**depth:1**) → querySelector(`input[type=file]`) → setFileInputFiles\n  - 思想：\n    - 同一链路内保持nodeId来源一致，不混用querySelector路径与performSearch路径\n    - 上传后前端框架可能不感知，必要时JS补发`input`/`change`事件\n    - 上传前检查`input.accept`；多input时用accept/父容器语义区分\n    - 等待元素优先用`DOM.performSearch('input[type=file]')`做轻量轮询\n    - 瞬态input的核心是**缩短发现→setFileInputFiles时间窗**：优先同batch完成；再不行用DOM事件监听；猴子补丁仅作兜底思路\n  - ⚠tabId：CDP默认sender.tab.id(当前注入页)，跨tab需显式tabId或先batch内tabs查\n- ⭐跨tab无需前台：指定tabId即可操作后台标签页\n\n## CDP点击完整生命周期（✅已验证）\n- 通用点击需**三事件序列**：mouseMoved → mousePressed → mouseReleased（间隔50-100ms）\n  - 省略mouseMoved会导致MUI Tooltip/Ant Design Dropdown等hover依赖组件失效\n  - ⚠autofill释放是特例，只需mousePressed即可（见下方autofill章节）\n- ⭐**坐标系结论**：稳定状态下 CDP坐标 = `getBoundingClientRect()` 坐标，**无需修正**\n  - ⚠**首次attach陷阱**：CDP debugger首次attach时Chrome弹出infobar(\"正在受自动化控制\"，~20px高)，页面内容被推下\n  - 如果在attach前测量坐标、attach后发送点击 → 坐标偏移！（之前Currency下拉失败的根因）\n  - ✅**解决**：确保测量坐标在CDP已attach稳定之后（即infobar已出现后再getBoundingClientRect）\n  - 实践：首次CDP操作前先发一个无害的`mouseMoved(0,0)`预热，之后坐标系就稳定了\n- ⭐**下拉框(Vue3 oxd-select等)CDP操作流程**：\n  1. 获取select元素rect → CDP点击打开下拉\n  2. 获取option元素rect → CDP点击选中（option是动态DOM，打开后才能测量）\n  - 已验证：CDP点击对自定义下拉框有效，无isTrusted问题\n  - ⚠**限制**：选项多时底部option超出视口，CDP坐标够不着→此时应优先vnode方案(见vue3_component_sop)\n- 坐标修正（页面有transform:scale/zoom时）：\n  ```js\n  var scale = window.visualViewport ? window.visualViewport.scale : 1;\n  var zoom = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;\n  var realX = x * zoom; var realY = y * zoom;\n  ```\n- iframe内元素CDP点击：坐标需合成 `finalX = iframeRect.x + elRect.x`\n  - 跨域iframe拿不到contentDocument：\n  - ⚠`Target.getTargets`/`Target.attachToTarget`在CDP桥中返回\"Not allowed\"(chrome.debugger权限限制)\n  - ⭐**已验证方案**：`Page.getFrameTree`找iframe frameId → `Page.createIsolatedWorld({frameId})`获取contextId → `Runtime.evaluate({expression, contextId})`在iframe中执行JS\n  - batch链式引用：`$0.frameTree.childFrames`遍历找url匹配的frame，`$1.executionContextId`传给evaluate\n  - postMessage中继方案仅在content script已注入iframe时有效，第三方支付iframe通常无注入\n\n## CDP文本输入（未验证，BBS#23）\n- `insertText`快但无key事件；受控组件需补dispatch `input`事件\n- 需完整键盘模拟时用`dispatchKeyEvent`逐键派发\n\n## CDP DOM域穿透 closed Shadow DOM（未验证，BBS#24/#25）\n- `DOM.getDocument({depth:-1, pierce:true})` 穿透所有Shadow边界（含closed）\n- `DOM.querySelector({nodeId, selector})` 定位 → `DOM.getBoxModel({nodeId})` 取坐标\n- getBoxModel返回content八值[x1,y1,...x4,y4]，中心用**四点平均**：centerX=sum(x)/4, centerY=sum(y)/4\n  - ⚠不能简化为对角线平均——元素有transform:rotate/skew时四点非矩形\n- querySelector**不能跨Shadow边界写组合选择器**，需分步：先找host再在其shadow内找子元素\n- ⚠nodeId在DOM变更后失效 → 用`backendNodeId`更稳定，或重新getDocument刷新\n\n\n## autofill获取与登录\n检测：web_scan输出input带`data-autofilled=\"true\"`，value显示为受保护提示(非真实值，Chrome安全保护需点击释放)\n- ⚠**前置条件：必须先CDP `Page.bringToFront` 切tab到前台**，Chrome仅在前台tab释放autofill保护值，后台tab物理点击无效\n- ⭐**一键释放与登录**：bringToFront → mousePressed点任一字段(无需Released，一个释放全页) → 等500ms → 补input/change事件 → 点登录\n\n## 验证码/页面视觉截图\n- ⭐首选CDP截图：`Page.captureScreenshot`(format:'png')→返回base64，无需前台/后台tab也行，全页高清\n- 验证码canvas/img：JS `canvas.toDataURL()` 直接拿base64最干净\n\n## simphtml与TMWebDriver调试\n- simphtml调试必须通过`code_run`注入JS到真实浏览器（Python端无法模拟DOM）\n- `d=TMWebDriver()`, `d.set_session('url_pattern')`, `d.execute_js(code)` → 返回`{'data': value}`\n- simphtml：`str(simphtml.optimize_html_for_tokens(html))` — 返回BS4 Tag需str()\n\n## 连不上排查\nweb_scan失败时按序排查（自动检测优先，用户参与放最后）：\n①浏览器没开？→检查浏览器进程是否在跑(tasklist/ps)，没有则启动并打开正常URL（⚠about:blank等内部页不加载扩展）\n②WS后台挂了？→本机18766端口没监听即dead→手动**后台持续运行**`from TMWebDriver import TMWebDriver; TMWebDriver()`起master\n③扩展没装？→读Chrome用户目录下`Secure Preferences`→`extensions.settings`中找`path`含`tmwd_cdp_bridge`的条目\n  找到→扩展已装，排查其他原因；没找到→走web_setup_sop\n④以上都正常仍连不上→请求用户协助\n"
  },
  {
    "path": "memory/ui_detect.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\n极简UI元素检测脚本 - 基于OmniParser的YOLO模型\n依赖: ultralytics, rapidocr-onnxruntime, pillow, numpy\n\"\"\"\nimport sys\nfrom pathlib import Path\nfrom ultralytics import YOLO\nfrom PIL import Image, ImageDraw\nimport numpy as np\n\nDEFAULT_MODEL = str(Path(__file__).resolve().parent.parent / 'temp' / 'weights' / 'icon_detect' / 'model.pt')\n\n# 可选：使用rapidocr做OCR\ntry:\n    from rapidocr_onnxruntime import RapidOCR\n    ocr_engine = RapidOCR()\n    HAS_OCR = True\nexcept ImportError:\n    HAS_OCR = False\n    print(\"警告: rapidocr未安装，跳过OCR功能\")\n\ndef detect_ui_elements(image_path, model_path=None, conf_threshold=0.25):\n    model_path = model_path or DEFAULT_MODEL\n    \"\"\"检测UI元素并返回边界框\"\"\"\n    # 加载模型\n    model = YOLO(model_path)\n    \n    # 推理\n    results = model(image_path, conf=conf_threshold, verbose=False)\n    \n    # 提取检测结果\n    detections = []\n    for result in results:\n        boxes = result.boxes\n        for box in boxes:\n            x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()\n            conf = float(box.conf[0])\n            cls = int(box.cls[0])\n            detections.append({\n                'bbox': [int(x1), int(y1), int(x2), int(y2)],\n                'confidence': conf,\n                'class': cls\n            })\n    \n    return detections\n\ndef ocr_text(image_path):\n    \"\"\"OCR识别文本\"\"\"\n    if not HAS_OCR:\n        return []\n    \n    result, _ = ocr_engine(image_path)\n    if not result:\n        return []\n    \n    texts = []\n    for item in result:\n        bbox, text, conf = item\n        texts.append({\n            'text': text,\n            'bbox': bbox,\n            'confidence': conf\n        })\n    return texts\n\ndef visualize(image_path, detections, ocr_results=None, output_path=None):\n    \"\"\"可视化检测结果\"\"\"\n    img = Image.open(image_path)\n    draw = ImageDraw.Draw(img)\n    \n    # 画UI元素框（红色）\n    for det in detections:\n        x1, y1, x2, y2 = det['bbox']\n        draw.rectangle([x1, y1, x2, y2], outline='red', width=2)\n        draw.text((x1, y1-10), f\"{det['confidence']:.2f}\", fill='red')\n    \n    # 画OCR文本框（蓝色）\n    if ocr_results:\n        for ocr in ocr_results:\n            bbox = ocr['bbox']\n            points = [(bbox[i][0], bbox[i][1]) for i in range(4)]\n            draw.polygon(points, outline='blue')\n            draw.text((points[0][0], points[0][1]-10), ocr['text'][:10], fill='blue')\n    \n    if output_path:\n        img.save(output_path)\n    return img\n\ndef main():\n    if len(sys.argv) < 2:\n        print(\"用法: python ui_detect.py <图片路径> <模型路径> [输出路径]\")\n        print(\"示例: python ui_detect.py screenshot.png weights/icon_detect/model.pt output.png\")\n        sys.exit(1)\n    \n    image_path = sys.argv[1]\n    model_path = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_MODEL\n    output_path = sys.argv[3] if len(sys.argv) > 3 else \"output.png\"\n    \n    print(f\"检测图片: {image_path}\")\n    print(f\"使用模型: {model_path}\")\n    \n    # UI元素检测\n    print(\"\\n[1/2] YOLO检测UI元素...\")\n    detections = detect_ui_elements(image_path, model_path)\n    print(f\"检测到 {len(detections)} 个UI元素\")\n    for i, det in enumerate(detections, 1):\n        print(f\"  {i}. bbox={det['bbox']}, conf={det['confidence']:.3f}\")\n    \n    # OCR文本识别\n    ocr_results = None\n    if HAS_OCR:\n        print(\"\\n[2/2] OCR识别文本...\")\n        ocr_results = ocr_text(image_path)\n        print(f\"识别到 {len(ocr_results)} 个文本区域\")\n        for i, ocr in enumerate(ocr_results, 1):\n            print(f\"  {i}. text='{ocr['text']}', conf={ocr['confidence']:.3f}\")\n    \n    # 可视化\n    print(f\"\\n保存结果到: {output_path}\")\n    visualize(image_path, detections, ocr_results, output_path)\n    \n    # 输出JSON格式结果\n    import json\n    result = {\n        'ui_elements': detections,\n        'ocr_texts': ocr_results or []\n    }\n    json_path = output_path.replace('.png', '.json')\n    with open(json_path, 'w', encoding='utf-8') as f:\n        json.dump(result, f, ensure_ascii=False, indent=2)\n    print(f\"JSON结果: {json_path}\")\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "memory/vision_api.template.py",
    "content": "import base64, requests, sys, os\nfrom io import BytesIO\nfrom pathlib import Path\n\n# ============ 用户配置区（从 template 拷贝后只需改这里）============\nCLAUDE_CONFIG_KEY = 'claude_config141'   # mykey.py 中 Claude 配置的变量名\nOPENAI_CONFIG_KEY = 'oai_config1'        # mykey.py 中 OpenAI 配置的变量名\nMODELSCOPE_API_KEY = ''                  # 直接填你的 ModelScope token\nDEFAULT_BACKEND = 'claude'               # 默认后端: 'claude' / 'openai' / 'modelscope'\n# =================================================================\n\nMODELSCOPE_API_BASE = 'https://api-inference.modelscope.cn'\nMODELSCOPE_MODEL = 'Qwen/Qwen3-VL-235B-A22B-Instruct'\n\n_DIR = os.path.dirname(os.path.abspath(__file__))\nfor _p in [os.path.join(_DIR, '..'), os.path.join(_DIR, '../..')]:\n    if _p not in sys.path: sys.path.insert(0, _p)\n\ndef ask_vision(image_input, prompt=\"详细描述这张图片的内容\", timeout=60, max_pixels=1440000, backend=DEFAULT_BACKEND):\n    try:\n        b64 = _prepare_image(image_input, max_pixels)\n    except Exception as e:\n        return f\"Error: 图片处理失败 - {type(e).__name__}: {e}\"\n    try:\n        if backend == 'claude':\n            return _call_claude(b64, prompt, timeout)\n        elif backend == 'openai':\n            mk = _load_config()\n            cfg = getattr(mk, OPENAI_CONFIG_KEY)\n            return _call_openai_compat(\n                b64, prompt, timeout,\n                apibase=cfg['apibase'], apikey=cfg['apikey'], model=cfg['model'], proxy=cfg.get('proxy')\n            )\n        elif backend == 'modelscope':\n            return _call_openai_compat(\n                b64, prompt, timeout,\n                apibase=MODELSCOPE_API_BASE, apikey=MODELSCOPE_API_KEY, model=MODELSCOPE_MODEL, proxy=None\n            )\n        else: return f\"Error: 未知backend '{backend}'，可选: claude, openai, modelscope\"\n    except requests.exceptions.Timeout:\n        return f\"Error: 请求超时 (>{timeout}s)\"\n    except requests.exceptions.RequestException as e:\n        return f\"Error: API请求失败 - {type(e).__name__}: {e}\"\n    except (KeyError, ValueError) as e:\n        return f\"Error: 响应解析失败 - {e}\"\n\n# ===================== 以下为内部实现 =====================\n\ndef _prepare_image(image_input, max_pixels=1440000):\n    \"\"\"加载+缩放+base64编码，返回b64字符串\"\"\"\n    from PIL import Image\n    if isinstance(image_input, Image.Image):\n        img = image_input\n    elif isinstance(image_input, (str, Path)):\n        img = Image.open(image_input)\n    else:\n        raise TypeError(f\"image_input 必须是文件路径或PIL Image，实际: {type(image_input).__name__}\")\n    w, h = img.size\n    if w * h > max_pixels:\n        scale = (max_pixels / (w * h)) ** 0.5\n        new_w, new_h = int(w * scale), int(h * scale)\n        img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)\n        print(f\"  📐 缩放: {w}×{h} → {new_w}×{new_h}\")\n    if img.mode in ('RGBA', 'LA', 'P'):\n        rgb = Image.new('RGB', img.size, (255, 255, 255))\n        rgb.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)\n        img = rgb\n    buf = BytesIO()\n    img.save(buf, format='JPEG', quality=80, optimize=True)\n    b64 = base64.b64encode(buf.getvalue()).decode('utf-8')\n    print(f\"  📦 Base64: {len(buf.getvalue())/1024:.1f}KB\")\n    return b64\n\ndef _load_config():\n    import mykey\n    return mykey\n\ndef _call_claude(b64, prompt, timeout, max_tokens=1024):\n    mk = _load_config()\n    cfg = getattr(mk, CLAUDE_CONFIG_KEY)\n    resp = requests.post(\n        cfg['apibase'] + '/v1/messages',\n        json={'model': cfg['model'], 'max_tokens': max_tokens, 'messages': [{\n            'role': 'user',\n            'content': [\n                {'type': 'image', 'source': {'type': 'base64', 'media_type': 'image/jpeg', 'data': b64}},\n                {'type': 'text', 'text': prompt}\n            ]\n        }]},\n        headers={'x-api-key': cfg['apikey'], 'anthropic-version': '2023-06-01', 'content-type': 'application/json'},\n        timeout=timeout\n    )\n    resp.raise_for_status()\n    return resp.json()['content'][0]['text']\n\ndef _call_openai_compat(b64, prompt, timeout, *, apibase, apikey, model, proxy=None):\n    proxies = {'https': proxy, 'http': proxy} if proxy else None\n    resp = requests.post(\n        apibase.rstrip('/') + '/v1/chat/completions',\n        json={'model': model, 'messages': [{\n            'role': 'user',\n            'content': [\n                {'type': 'text', 'text': prompt},\n                {'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{b64}'}}\n            ]\n        }]},\n        headers={'Authorization': f\"Bearer {apikey}\", 'Content-Type': 'application/json'},\n        proxies=proxies, timeout=timeout\n    )\n    resp.raise_for_status()\n    return resp.json()['choices'][0]['message']['content']\n\nif __name__ == '__main__':\n    pass"
  },
  {
    "path": "memory/vision_sop.md",
    "content": "# Vision API SOP\n\n## ⚠️ 前置规则（必须遵守）\n\n1. **先枚举窗口**：调用 vision 前必须先用 `pygetwindow` 枚举窗口标题，确认目标窗口存在且已激活到前台。窗口不存在就不要截图。\n2. **🚫 禁止全屏截图**：必须先利用ljqCtrl截取窗口区域。能截局部（如标题栏）就不截整窗口，能截窗口就绝不全屏。全屏截图在任何场景下都不允许。\n3. **能不用 vision 就不用**：如果窗口标题/本地 OCR（`ocr_utils.py`）能获取所需信息，就不要调用 vision API，省 token 且更可靠。Vision 是最后手段。\n\n## 快速用法\n\n```python\nfrom vision_api import ask_vision\nresult = ask_vision(image, prompt=\"描述图片内容\", backend=\"claude\", timeout=60, max_pixels=1_440_000)\n# image: 文件路径(str/Path) 或 PIL Image\n# backend: 'claude'(默认) | 'openai' | 'modelscope'\n# 返回 str：成功为模型回复，失败为 'Error: ...'\n```\n\n## 如果没有 `vision_api.py`，初次构建vision能力\n\n1. 复制 `memory/vision_api.template.py` → `memory/vision_api.py`\n2. 只改头部\"用户配置区\"：去 `mykey.py` 里扫描变量名（⚠️ 只看名字，禁止输出 apikey 值），尝试找能用配置名填入 `CLAUDE_CONFIG_KEY` / `OPENAI_CONFIG_KEY`，`DEFAULT_BACKEND` 选后端，并测试\n3. 保底：没有可用 config 时去 `https://modelscope.cn/my/myaccesstoken` 申请 token 填入 `MODELSCOPE_API_KEY`\n"
  },
  {
    "path": "memory/vue3_component_sop.md",
    "content": "# Vue 3 自定义组件 JS 操作 SOP\n\n## 问题\nVue 3 自定义组件（如 OxdSelect）通过 `addEventListener` 绑定事件，JS `dispatchEvent` 产生的事件 `isTrusted: false`，组件不响应。\n- `element.click()` 无效（组件可能绑定 mousedown 而非 click）\n- `dispatchEvent(new MouseEvent('mousedown'))` 无效（isTrusted:false）\n- `element.focus()` 不触发 Vue 绑定的 focus handler\n\n## 解决方案：直接操作 Vue 组件实例\n\n### 1. 获取 Vue 3 根入口\n```javascript\nconst rootVnode = document.getElementById('app')._vnode;\n```\n\n### 2. 遍历 vnode 树匹配 DOM 元素\n```javascript\nfunction findCompByEl(vnode, targetEl, depth = 0) {\n    if (depth > 50 || !vnode) return null;\n    const comp = vnode.component;\n    if (comp) {\n        if (comp.vnode?.el === targetEl || comp.subTree?.el === targetEl) return comp;\n        if (comp.vnode?.el?.contains?.(targetEl)) {\n            const result = findCompByEl(comp.subTree, targetEl, depth + 1);\n            if (result) return result;\n            return comp;\n        }\n        const subResult = findCompByEl(comp.subTree, targetEl, depth + 1);\n        if (subResult) return subResult;\n    }\n    if (vnode.children && Array.isArray(vnode.children)) {\n        for (const child of vnode.children) {\n            const result = findCompByEl(child, targetEl, depth + 1);\n            if (result) return result;\n        }\n    }\n    if (vnode.dynamicChildren) {\n        for (const child of vnode.dynamicChildren) {\n            const result = findCompByEl(child, targetEl, depth + 1);\n            if (result) return result;\n        }\n    }\n    return null;\n}\n```\n\n### 3. 调用组件方法\n```javascript\n// 目标DOM的parentElement通常是组件根元素\nconst comp = findCompByEl(rootVnode, targetElement.parentElement);\nconst ctx = comp.proxy;\n\n// 查看可用方法\nObject.keys(ctx).filter(k => !k.startsWith('_') && !k.startsWith('$'));\n\n// Select 类组件：直接调用 onSelect\nctx.onSelect({id: 'USD', label: 'United States Dollar'});\n\n// 获取选项列表\nctx.computedOptions; // [{id, label, _selected}, ...]\n```\n\n## 组件层级注意\n- **展示层**（如 OxdSelectText）：只有 onToggle/onFocus/onBlur，调用无实际效果\n- **逻辑层**（如 OxdSelectInput，是展示层的父组件）：有 openDropdown/onSelect/computedOptions/onCloseDropdown\n- 定位逻辑层：用 `targetElement.parentElement` 而非 targetElement 本身\n\n### 弹窗内 Select 同样纯 JS 优先（已验证）\n- 弹窗（`.oxd-dialog-sheet`）内的 `.oxd-select-text` 用循环向上查找同样能命中 `OxdSelectInput`，`onSelect` 正常工作。\n- 不需要 CDP 兜底。仅当循环 8 层仍找不到组件时才考虑 CDP 打开+JS 点 option。\n\n### 循环向上查找模式（推荐）\n单层 `parentElement` 可能不够，用循环更健壮：\n```javascript\nfunction findSelectComp(selectTextEl) {\n  for (let el = selectTextEl, up = 0; el && up < 8; el = el.parentElement, up++) {\n    const comp = findCompByEl(rootVnode, el);\n    if (comp?.proxy?.onSelect && comp.proxy.computedOptions?.length) return comp;\n  }\n  return null; // 找不到再考虑CDP兜底\n}\n```\n\n## 普通 Input/Textarea 操作（nativeSetter）\n\nVue 3 的 `v-model` 监听 input 事件，直接 `el.value = x` 不触发响应式。需用原型 setter：\n\n```javascript\n// Input\nconst setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;\nsetter.call(inputEl, '新值');\ninputEl.dispatchEvent(new Event('input', {bubbles: true}));\ninputEl.dispatchEvent(new Event('change', {bubbles: true}));\n\n// Textarea\nconst taSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;\ntaSetter.call(textareaEl, '内容');\ntextareaEl.dispatchEvent(new Event('input', {bubbles: true}));\n```\n\n### Date Input 特殊处理\n日期组件通常有 blur 校验，需要 focus→赋值→blur 完整链：\n```javascript\ndateInput.focus();\nsetter.call(dateInput, '2026-08-05');\ndateInput.dispatchEvent(new Event('input', {bubbles: true}));\ndateInput.dispatchEvent(new Event('change', {bubbles: true}));\ndateInput.dispatchEvent(new Event('blur', {bubbles: true}));\n```\n\n### Button\n普通 `.click()` 即可，Vue 3 不检查 button click 的 isTrusted。\n\n### File Upload (input[type=\"file\"])\n浏览器安全模型禁止JS直接 `input.value='path'`，但可用 DataTransfer API 构造 FileList：\n```javascript\nconst fileInput = document.querySelector('input[type=\"file\"]');\nconst content = '文件内容';\nconst file = new File([content], 'filename.txt', { type: 'text/plain', lastModified: Date.now() });\nconst dt = new DataTransfer();\ndt.items.add(file);\nfileInput.files = dt.files;  // Chrome 62+ 支持\nfileInput.dispatchEvent(new Event('input', { bubbles: true }));\nfileInput.dispatchEvent(new Event('change', { bubbles: true }));\n```\n- 适用于任何框架（非Vue3特有），纯浏览器API\n- 可构造任意类型文件（Blob/ArrayBuffer均可传入File构造器）\n- ⚠ CDP `DOM.setFileInputFiles` 只设files属性不触发事件（Chrome通用行为），DataTransfer+dispatch是唯一纯JS方案\n- ⚠ 确保弹窗/容器已打开再querySelector，否则input不在DOM中\n\n## 泛化到其他 Vue3 站点（未逐一验证，思路层面）\n\n本 SOP 的核心方法（根 vnode → findCompByEl → proxy）是 Vue3 通用的，但具体方法名/属性名因 UI 库而异。\n\n面对陌生 Vue3 站点的探测思路：\n\n1. **确认是 Vue3** — `document.getElementById('app')?.__vue_app__` 存在即可\n2. **定位目标 DOM** — 用选择器找到要操作的元素（如某个 select wrapper）\n3. **从 DOM 反查组件** — 用 findCompByEl 从目标元素及其父级向上找，拿到 component\n4. **探测组件能力** — 拿到 comp 后查看：\n   - `Object.keys(comp.proxy.$options.methods || {})` → 组件方法名\n   - `Object.keys(comp.props || {})` → props\n   - `Object.keys(comp.setupState || {})` → setup 暴露的响应式数据和函数\n   - 重点找类似 onSelect/handleSelect/select/setValue 的方法，以及 options/items/computedOptions 之类的选项列表\n5. **试调** — 找到疑似选中方法后，传入选项对象试调，观察 DOM 是否更新\n6. **选项格式** — 不同库的 option 结构不同（可能是 `{id, label}` 也可能是 `{value, text}` 或纯字符串），从选项列表数据中取一个完整对象传入即可\n\n注意事项：\n- 有些库用 `emits` 而非 methods，选中逻辑可能在父组件而非子组件\n- 有些库 prod build 会 minify 方法名，此时 setupState 里的 key 可能是短名，需结合行为猜测\n- Composition API 组件的逻辑主要在 setupState 而非 $options.methods\n- 如果 proxy 上找不到方法，试试 `comp.exposed`（`<script setup>` 用 defineExpose 暴露的）\n\n## Vue 富文本编辑器操作\n\n### 核心原则\n1. **禁止只改 DOM** — `innerHTML` 不触发编辑器内部 model 更新，提交时数据丢失\n2. **优先找编辑器实例调原生 API** — 唯一稳路径：\n   - Quill: `el.__quill.setText()` / `.clipboard.dangerouslyPasteHTML()`\n   - Tiptap: `el.__tiptap.commands.setContent()` 或 Vue ref `.editor.commands.setContent()`\n   - TinyMCE: `tinymce.get(id).setContent()` 或 `tinymce.activeEditor.setContent()`\n   - WangEditor: `el.__wangEditor.setHtml()` 或 Vue ref `.editorRef.setHtml()`\n   - CKEditor: `editor.setData()`\n3. **次选 `innerHTML + InputEvent`** — 对简单 Vue wrapper 有效（wrapper 监听 input 并 emit），复杂编辑器不保证\n4. **兜底 CDP `Input.insertText`** — 绕过 `isTrusted` 检查，等同物理输入\n5. **验证标准是\"提交对了\"不是\"看到了\"** — 拦截 fetch/XHR 看 payload，或读 `editor.getHTML()`\n\n### 编辑器实例查找路径（按优先级）\n1. DOM 私有字段: `el.__quill`, `el.__tiptap`, `el.cmView`(CodeMirror)\n2. Vue 组件 setupState/exposed: `comp.setupState.editor`, `comp.exposed.editor`\n3. 全局变量: `window.editor`, `tinymce.editors[0]`\n4. Quill 静态方法: `Quill.find(el)`\n\n### 编辑器类型识别\n- `.ql-editor` → Quill\n- `.ProseMirror` → Tiptap / ProseMirror\n- `.tox-edit-area` / `iframe` → TinyMCE\n- `.w-e-text-container` → WangEditor\n- `.ck-editor__editable` → CKEditor 5\n- `.cm-editor` → CodeMirror 6\n\n### 避坑\n- Element Plus Select 选项被 Teleport 到 body，不在组件 DOM 子树内，要从 `document.querySelectorAll('.el-select-dropdown__item')` 全局找\n- 编辑器可能在 iframe 内（TinyMCE 默认），需 `iframe.contentDocument.body` 操作\n- 提交时数据来源可能不是 Vue state，而是编辑器实例现取 `getHTML()`，所以必须改编辑器 model\n- debounce：有些 wrapper 用 debounce 同步到 v-model，改完后等 300-500ms 再验证\n- Pinia/Vuex：表单数据可能在 store 里而非组件 data，需找到 store 直接赋值\n\n## 适用场景\n- Vue 3 自定义 Select/Dropdown/Autocomplete 组件 → vnode 实例方法\n- Vue 3 普通 Input/Textarea（v-model）→ nativeSetter + input 事件\n- Date 组件 → nativeSetter + focus/blur 链\n- File Upload → DataTransfer + change 事件\n- 需要绕过 isTrusted 检查的场景\n- **Vue 3 富文本编辑器（Quill/Tiptap/TinyMCE/WangEditor/CKEditor）→ 编辑器实例 API**\n\n## 验证于\n- OrangeHRM (opensource-demo.orangehrmlive.com) Vue 3 + OXD 组件库\n- 本地 Vue3 + Element Plus + 模拟 Quill/Tiptap 富文本靶场 (2026-05-09)\n- 2026-05-08\n"
  },
  {
    "path": "memory/web_setup_sop.md",
    "content": "# Web 工具链初始化执行 SOP\n\n若 web_scan 和 web_execute_js 已测试可用，无需执行此 SOP。\n仅供初始安装时，code_run 可用但 web 工具尚未配置的场景。\n\n## 目标\n在仅具备系统级权限（code_run）时，建立 Web 交互能力（web_scan / web_execute_js）。\n\n## 前置：检测浏览器\n\n## 安装 tmwd_cdp_bridge 扩展\n扩展路径: `../assets/tmwd_cdp_bridge/`（MV3 Chrome 扩展，含 CDP debugger + scripting + cookie 能力）\n\n### 自动打开扩展管理页\n`chrome://extensions` 无法通过命令行或 JS 打开，需用剪贴板+地址栏方案\n\n### 安装步骤（chrome扩展页难以自动化）\n1. 打开扩展管理页，开启「开发者模式」\n2. 点击「加载已解压的扩展程序」，选择 `assets/tmwd_cdp_bridge/` 目录，或让用户直接拖入\n3. 显示“错误”不用管，一般只是因为还没连上GA\n\n## 验证\n⚠ web_scan 显示「没有可用标签页」不一定是扩展没装好，可能是浏览器未打开或只有 blank 页。\n此时禁止乱试，先用 `start \"\" \"https://www.baidu.com\"` 打开一个正常页面，再 `web_scan` 确认。\n若仍不可用，无法自动探测默认浏览器是哪个、插件装在了哪个浏览器、或是否已安装——此时请求用户协助。"
  },
  {
    "path": "mykey_template.py",
    "content": "# ══════════════════════════════════════════════════════════════════════════════\n#  GenericAgent — mykey.py 配置模板（复制为 mykey.py 后填入真实凭证）\n# ══════════════════════════════════════════════════════════════════════════════\n#\n#  ┌─────────────────────────────────────────────────────────────────────────┐\n#  │ 快速上手：只需 3 步                                                      │\n#  │  1. 把本文件复制为 mykey.py                                              │\n#  │  2. 在下面的\"推荐最优配置\"区域填入你的 apikey                              │\n#  │  3. 运行 python agentmain.py / python launch.pyw                        │\n#  └─────────────────────────────────────────────────────────────────────────┘\n#\n#  ────────── Session 类型速查 ──────────\n#\n#  agentmain.py 只扫描变量名同时包含 'api' / 'config' / 'cookie' 的条目，\n#  根据变量名里的关键字决定实例化哪个 Session 类型：\n#\n#      变量名关键字                          → Session 类             → 工具协议\n#      ─────────────────────────────────────────────────────────────────────────\n#      含 'native' 且 'claude'             → NativeClaudeSession    → API 原生 tool 字段\n#      含 'native' 且 'oai'               → NativeOAISession       → API 原生 tool 字段\n#      含 'claude'（不含 native）          → ClaudeSession          → 文本协议工具 (deprecated)\n#      含 'oai'（不含 native）             → LLMSession             → 文本协议工具 (deprecated)\n#      含 'mixin'                          → MixinSession           → 多 session 故障转移\n#                                                                      NativeClaudeSession 与\n#                                                                      NativeOAISession 可混用\n#\n#  优先级自上而下：native_claude_xxx 会走 NativeClaudeSession；如果变量名只写\n#  oai_claude_xxx 则依然会被 'claude' 抢先匹配，去走 ClaudeSession，所以命名要\n#  注意含义。\n#\n#  ────────── Native vs 非 Native 的区别 ──────────\n#\n#  「Native」 = 工具调用走 API 文档里的 tool 字段（function calling）。\n#  这是 Claude Code / Codex 的原生方式——训到 overfit 的模型只认 API tool 字段，\n#  其他格式的工具描述都会被忽略。要模拟 CC/Codex 的行为，必须用 Native。\n#\n#  「非 Native」 = 工具描述放在 text 字段里（文本协议），兼容性更强，\n#  但对于被 API tool 字段训 overfit 的模型（如 Claude Opus/Sonnet），效果可能打折。\n#\n#  → 新手推荐：优先用 native_claude_config / native_oai_config\n#\n#  ────────── Prompt Cache 说明 ──────────\n#\n#  NativeClaudeSession 恒开 prompt-caching-scope beta，缓存默认拉满，无需配置。\n#  LLMSession / NativeOAISession 在 model 名含 'claude'/'anthropic' 时自动在\n#  最后两条 user 打 cache_control: ephemeral，默认也是开启的。\n#  prompt_cache 字段默认 True，仅在上游 relay 不认 cache_control 字段会直接报错\n#  时才需设 False。因此模板中不再显式写 prompt_cache，了解即可。\n#\n# ══════════════════════════════════════════════════════════════════════════════\n#  apibase 自动拼接规则：\n#      'http://host:2001'                      → 补 /v1/chat/completions\n#      'http://host:2001/v1'                   → 补 /chat/completions\n#      'http://host:2001/v1/chat/completions'  → 原样使用\n#  NativeClaudeSession 会额外附加 ?beta=true，用于触发 Anthropic beta 协议。\n#\n# ══════════════════════════════════════════════════════════════════════════════\n#  运行时参数调整：在 GA REPL 里输入\n#      /session.reasoning_effort=high\n#      /session.thinking_type=adaptive\n#      /session.thinking_budget_tokens=32768\n#      /session.temperature=0.3\n#      /session.max_tokens=16384\n#  会在当前 session 的 backend 上做 setattr，当场生效，直到换模型或重启。\n#  reasoning_effort 合法值: none / minimal / low / medium / high / xhigh\n#  thinking_type 合法值:     adaptive / enabled / disabled\n#\n# ══════════════════════════════════════════════════════════════════════════════\n#  所有字段速查（按 BaseSession.__init__ 顺序）\n# ─── 鉴权 / 路由 ─────────────────────────────────────────────────────────────\n#   apikey          必填。sk-ant-* 用 x-api-key 头；其它（sk-*, cr_*, amp_*…）\n#                   一律用 Authorization: Bearer，由 NativeClaudeSession 自动判断。\n#   apibase         必填。参见上方 apibase 自动拼接规则。\n#   model           必填。后缀 '[1m]' 触发 context-1m-2025-08-07 beta（发出前会\n#                   自动去掉 [1m]）。\n#   name            可选。展示名；也是 mixin_config['llm_nos'] 引用的凭据。不填\n#                   默认取 model。\n#   proxy           可选。单 session 代理，'http://127.0.0.1:2082' 这种。不填则\n#                   即使全局设置了 proxy 也不走。\n# ─── 容量 / 超时 ─────────────────────────────────────────────────────────────\n#   context_win     默认 24000（NativeClaudeSession 默认 28000）。仅作为历史裁\n#                   剪阈值，不是硬上下文限制。\n#   max_retries     默认 1。_openai_stream 遇到 429/408/5xx 的自动重试次数。\n#   connect_timeout 连接超时秒数，默认 5。\n#   read_timeout    流式读取超时秒数，默认 30。\n# ─── 推理 / 思考 ─────────────────────────────────────────────────────────────\n#   reasoning_effort  OpenAI o 系列或 Responses API 的思考预算等级。Claude 侧\n#                     会映射到 output_config.effort（xhigh → max）。\n#   thinking_type     Claude 原生 thinking 块。\n#                     'adaptive'  (CC 默认)   → 让模型自己决定预算\n#                     'enabled'                → 必须配合 thinking_budget_tokens\n#                     'disabled'               → 不发送 thinking 字段\n#   thinking_budget_tokens  仅当 thinking_type='enabled' 时生效。参考:\n#                     low≈4096, medium≈10240, high≈32768\n# ─── 采样 ──────────────────────────────────────────────────────────────────\n#   temperature     默认 1.0。Kimi/Moonshot 会被强制改成 1.0；MiniMax 会被夹到\n#                   (0, 1]。\n#   max_tokens      默认 8192。\n# ─── 传输 ──────────────────────────────────────────────────────────────────\n#   stream          默认 True。NativeClaudeSession 会根据此值决定走 SSE 流式\n#                   还是一次性 JSON。流式更及时；某些被 CDN 截断 SSE 的渠道可\n#                   以改成 False 先保命。\n#   api_mode        'chat_completions'（默认）或 'responses'。仅对 LLMSession /\n#                   NativeOAISession 生效。\n# ─── NativeClaudeSession 专属 ───────────────────────────────────────────────\n#   fake_cc_system_prompt\n#                   默认 False。关键字段：**所有反代/镜像 Claude Code 协议的渠道\n#                   都必须置 True**（CC switch、anyrouter、claude-relay-service\n#                   等）。真 Anthropic 端点（sk-ant-）不需要开。\n#   user_agent      默认 'claude-cli/2.1.113 (external, cli)'。可传入任意版本号\n#                   字符串覆盖。某些第三方中转（tabcode、anyrouter 等）会按 UA\n#                   白名单校验，CC 升版本后被拒可在此 pin 老版本绕过。\n# ══════════════════════════════════════════════════════════════════════════════\n\n\n# ╔═══════════════════════════════════════════════════════════════════════════╗\n# ║                     ★ 推荐最优配置（新手从这里开始）★                      ║\n# ╚═══════════════════════════════════════════════════════════════════════════╝\n#\n#  推荐使用 mixin 故障转移 + 多个 native session 的方式。\n#  mixin 会按 llm_nos 列表顺序尝试，第一个失败自动切下一个，非常省心。\n#  填好下面的 apikey/apibase 后即可使用。\n\n\n# ── Mixin 故障转移（最推荐的方式）──────────────────────────────────────────\n#  llm_nos 里的字符串必须和被引用 session 的 'name' 字段匹配（也可以写整数索\n#  引）。约束：引用的 session 必须全是 Native 系列（NativeClaudeSession 和\n#  NativeOAISession 可以混用）或者全不是 Native，不能 Native 与非 Native 混。\n#  请你按需\nmixin_config = {\n    'llm_nos': ['gpt-native'],   # 按优先级排列；Claude 与 GPT 混用\n    # 'llm_nos': ['cc-relay-1', 'cc-relay-2', 'gpt-native'],  # 按优先级排列；Claude 与 GPT 混用，注意: 启用时需要启用'cc-relay-1', 'cc-relay-2'配置!\n    'max_retries': 10,           # int；整个 rotation 的总重试次数上限\n    'base_delay': 0.5,           # float 秒；指数退避起始延迟（retry n 时延迟≈base_delay * 2^n）\n    # 'spring_back': 300,        # int 秒；切到备用节点后多久再尝试回到第一个节点\n}\n\n\n# ══════════════════════════════════════════════════════════════════════════════\n#  1. NativeClaudeSession — Anthropic 原生协议 + 原生工具（推荐首选）\n# ══════════════════════════════════════════════════════════════════════════════\n#\n#  大部分用户使用的是 CC switch 适配的 Claude 透传渠道（非官方直连），这类渠道\n#  把 Claude Code 的请求透传到上游，需要 fake_cc_system_prompt=True。\n#  这是目前社区最常见的接入方式。\n\n# ── 1a. CC switch 适配渠道（最常用）────────────────────────────────────────\n#  这类渠道把 Claude Code 协议透传到上游，apikey 格式各异（sk-user-*, sk-*, cr_*\n#  等），统一走 Bearer 鉴权。必须设置 fake_cc_system_prompt=True。\n# native_claude_config0 = {\n#     'name': 'cc-relay-1',                        # /llms 显示名 & mixin 引用名\n#     'apikey': 'sk-user-<your-relay-key>',        # 非 sk-ant- 前缀 → Bearer 鉴权\n#     'apibase': 'https://<your-cc-switch-host>/claude/office',   # CC switch 端点\n#     'model': 'claude-opus-4-7',                  # 或 claude-sonnet-4-6\n#     'fake_cc_system_prompt': True,               # CC 透传渠道必须置 True\n#     'thinking_type': 'adaptive',                 # 某些渠道必须要求填写thinking_type字段\n# }\n\n# native_claude_config1 = {\n#     'name': 'cc-relay-2',                        # /llms 显示名 & mixin 引用名\n#     'apikey': 'sk-<your-second-relay-key>',\n#     'apibase': 'https://<your-second-host>',\n#     'model': 'claude-opus-4-7[1m]',              # [1m] 触发 1m 上下文 beta\n#     'fake_cc_system_prompt': True,\n#     'thinking_type': 'adaptive',                 # 某些渠道必须要求填写thinking_type字段\n#     'max_retries': 3,\n#     'read_timeout': 300,                         # 1m 上下文响应可能较慢\n#     'stream': False,                             # 某些渠道不支持 SSE 流式时改 False\n#     # 'user_agent': 'claude-cli/2.1.113 (external, cli)',\n# }\n\n# ── 1b. Anthropic 官方直连 ──────────────────────────────────────────────────\n#  官方端点，apikey 以 sk-ant- 开头 → 自动切到 x-api-key 鉴权。\n#  真 Anthropic 端点不需要 fake_cc_system_prompt。\n# native_claude_config_anthropic = {\n#     'name': 'anthropic-direct',              # /llms 显示名 & mixin 引用名\n#     'apikey': 'sk-ant-<your-anthropic-key>', # sk-ant- 前缀 → 自动走 x-api-key 头\n#     'apibase': 'https://api.anthropic.com',  # NativeClaudeSession 自动附加 ?beta=true\n#     'model': 'claude-opus-4-7[1m]',          # [1m] 触发 1m 上下文 beta\n#     # ── 思考控制（thinking_type 与 reasoning_effort 独立，可同时写）──\n#     'thinking_type': 'adaptive',             # 合法值: 'adaptive' / 'enabled' / 'disabled'\n#                                              #   adaptive = Claude Code 默认，模型自决预算\n#                                              #   enabled  = 必须配 thinking_budget_tokens\n#                                              #   disabled = 发送 {\"type\":\"disabled\"}\n#     # 'thinking_type': 'enabled',\n#     # 'thinking_budget_tokens': 32768,       # int，仅 thinking_type='enabled' 生效\n#                                              #   参考: low≈4096 / medium≈10240 / high≈32768\n#     # ── 推理等级（Claude 侧写进 payload.output_config.effort）──\n#     #   合法值: 'none' / 'minimal' / 'low' / 'medium' / 'high' / 'xhigh'\n#     #   映射:  low/medium/high 原值传递；xhigh → 'max'；\n#     #          none/minimal 被 llmcore 打 WARN 丢弃（Claude 不支持这两档）\n#     #   运行时可覆盖: REPL 输入 /session.reasoning_effort=high 当场生效\n#     # 'reasoning_effort': 'high',\n#     'temperature': 1,                        # float 默认 1.0\n#     'max_tokens': 32768,                     # int 默认 8192；Claude 回复最大 token 数\n#     # 'context_win': 800000,                 # int 默认 28000（NativeClaudeSession）；历史裁剪阈值\n#     # 'stream': True,                        # bool 默认 True；False → 一次性 JSON（CDN 截断 SSE 时用）\n#     # 'max_retries': 3,                      # int 默认 1\n#     # 'connect_timeout': 10,                 # int 秒 默认 5（最小 1）\n#     # 'read_timeout': 180,                   # int 秒 默认 30（最小 5）\n#     # 'fake_cc_system_prompt': False,        # bool 默认 False；真 Anthropic 端点不需开\n# }\n\n# ── 1c. CRS 反代 Claude Max ─────────────────────────────────────────────────\n#  CRS 需要 fake_cc_system_prompt=True\n# native_claude_config_crs = {\n#     'name': 'crs-claude-max',                # /llms 显示名\n#     'apikey': 'cr_<your-crs-key>',           # cr_ 开头 → Bearer 鉴权（64 位 hex）\n#     'apibase': 'https://<your-crs-host>/api',# CRS 的 Anthropic 兼容路径\n#     'model': 'claude-opus-4-7[1m]',          # [1m] 触发 1m beta\n#     'fake_cc_system_prompt': True,           # bool 必填 True；CRS 也校验 CC 系统串\n#     'thinking_type': 'adaptive',             # 'adaptive'/'enabled'/'disabled'\n#     # 'reasoning_effort': 'high',            # 可选；写进 output_config.effort\n#     'max_tokens': 32768,                     # int；CRS 允许大 max_tokens\n#     'max_retries': 3,                        # int\n#     'read_timeout': 180,                     # int 秒\n# }\n\n# ── 1d. CRS Gemini Ultra (Antigravity 通道) ─────────────────────────────────\n#  CRS 把 Google Antigravity (Gemini Ultra) 包装成 Anthropic 风格接口。\n#  URL 路径带 /antigravity/api：\n#    - 'claude-opus-4-7-thinking'  (CRS 原始名)\n#    - 'claude-opus-4-7[1m]'       (触发 1m beta，CRS 会忽略多余的 beta)\n#    - 'claude-opus-4-7'           (最简)\n#  ⚠ 此通道不支持 SSE 流式，必须 stream=False。\n# native_claude_config_crs_gemini = {\n#     'name': 'crs-gemini-ultra',              # /llms 显示名\n#     'apikey': 'cr_<your-crs-gemini-key>',    # cr_ 前缀 → Bearer\n#     'apibase': 'https://<your-crs-gemini-host>/antigravity/api',\n#     'model': 'claude-opus-4-7-thinking',     # 或 'claude-opus-4-7[1m]' 或 'claude-opus-4-7'\n#     'stream': False,                         # Antigravity 不支持 SSE 流式，stream=True 会返回伪错误\n#     'max_tokens': 32768,                     # int\n#     'max_retries': 3,                        # int\n#     'read_timeout': 180,                     # int 秒\n# }\n\n# ── 1e. 智谱 GLM-5.1 (Anthropic 兼容协议) ──────────────────────────────────\n#  智谱提供了 Anthropic 兼容接口 /api/anthropic，走 NativeClaudeSession。\n#  变量名含 'native' + 'claude' 即可。apikey 是智谱格式 (xxx.yyy)。\n# native_claude_glm_config = {\n#     'name': 'glm-5.1',                               # /llms 显示名\n#     'apikey': '<your-zhipu-apikey>',                 # 形如 f0f1b798xxxx.F8SSbzxxxx；非 sk-ant- → Bearer\n#     'apibase': 'https://open.bigmodel.cn/api/anthropic',  # 智谱 Anthropic 兼容端点\n#     'model': 'glm-5.1',                              # 智谱 model id，无 [1m] 支持\n#     'max_retries': 3,                                # int\n#     'connect_timeout': 10,                           # int 秒\n#     'read_timeout': 180,                             # int 秒\n#     # 'fake_cc_system_prompt': False,                # 智谱不做 CC 指纹校验，保持默认 False\n# }\n\n# ── 1f. MiniMax Anthropic 路径（推荐——无额外 <think> 标签）────────────────\n#  MiniMax 同时提供 OAI 和 Anthropic 兼容接口，同一个 key 两个端点都能用：\n#    - /v1             → chat/completions (LLMSession)\n#    - /anthropic      → Anthropic Messages (NativeClaudeSession)\n#  Anthropic 路径更简洁，OAI 路径会返回 <think> 标签（M2.7 自带思考）。\n#  温度自动修正为 (0, 1]，支持 M2.7 / M2.5 全系列，204K 上下文。\n# native_claude_config_minimax = {\n#     'name': 'minimax-anthropic',                   # /llms 显示名\n#     'apikey': 'sk-<your-minimax-key>',             # 与 OAI 路径同一个 key\n#     'apibase': 'https://api.minimaxi.com/anthropic',  # Anthropic Messages 兼容端点\n#     'model': 'MiniMax-M2.7',\n#     'max_retries': 3,                              # int\n#     # 'fake_cc_system_prompt': False,              # MiniMax 不做 CC 指纹校验\n# }\n\n# ── 1g. Kimi for Coding (Anthropic 兼容 CC 透传端点) ──────────────────────\n#  Kimi 官方为 Claude Code / Codex 开放的 /coding 路径，走 Anthropic 协议。\n#  与 4b 的 Moonshot OAI 路径是两回事：model 用 'kimi-for-coding'（非 kimi-k2）。\n#  官方硬要求透传 CC system prompt → fake_cc_system_prompt=True 必填。\n#  文档: https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html\n# native_claude_config_kimi = {\n#     'name': 'kimi-coding',                   # /llms 显示名 & mixin 引用名\n#     'apikey': 'sk-kimi-<your-kimi-coding-key>',  # Bearer 鉴权\n#     'apibase': 'https://api.kimi.com/coding',# Anthropic 兼容端点\n#     'model': 'kimi-for-coding',              # 官方 coding 专用 model id\n#     'fake_cc_system_prompt': True,           # 必填；官方硬要求透传 CC 系统串\n#     'thinking_type': 'adaptive',             # 'adaptive'/'enabled'/'disabled'\n# }\n\n# ══════════════════════════════════════════════════════════════════════════════\n#  2. NativeOAISession — OpenAI 协议 + 原生工具\n# ══════════════════════════════════════════════════════════════════════════════\n#  变量名含 'native' 且 'oai'。走 OpenAI chat/completions 或 responses 端点，\n#  但工具调用使用 API 原生 function calling 字段（与 Claude Code/Codex 一致）。\n#  适合 GPT/o 系列、Gemini 或任何 OAI 兼容且支持原生 tool 字段的模型。\n#  和 NativeClaudeSession 共用大部分逻辑（继承关系），只是请求走 OAI 协议。\n\nnative_oai_config = {\n    'name': 'gpt-native',                           # /llms 显示名 & mixin 引用名\n    'apikey': 'sk-<your-openai-key>',                # Bearer 鉴权\n    'apibase': 'https://api.openai.com/v1',          # 补齐到 /v1/chat/completions\n    'model': 'gpt-5.4',                              # gpt-5/o 系列\n    'api_mode': 'chat_completions',                  # 'chat_completions'（默认）|'responses'\n    # 'reasoning_effort': 'high',                    # none|minimal|low|medium|high|xhigh\n                                                     # chat_completions → payload.reasoning_effort\n                                                     # responses        → payload.reasoning.effort\n    'max_retries': 3,                                # int 默认 1\n    'connect_timeout': 10,                           # int 秒 默认 5（最小 1）\n    'read_timeout': 120,                             # int 秒 默认 30（最小 5）\n    # 'temperature': 1.0,                            # float 默认 1.0\n    # 'max_tokens': 8192,                            # int 默认 8192\n    # 'proxy': 'http://127.0.0.1:2082',              # 可选单 session HTTP 代理\n    # 'context_win': 16000,                          # int 默认 24000；历史裁剪阈值\n}\n\n# ── 也可以走 Responses API ──────────────────────────────────────────────────\n#  对接 OpenAI /v1/responses 端点。reasoning_effort 会以 reasoning.effort\n#  字段写进 payload；运行时也可用 /session.reasoning_effort=high 现场调。\n# native_oai_config_responses = {\n#     'name': 'gpt-responses',                       # /llms 显示名\n#     'apikey': 'sk-<your-openai-key>',              # Bearer 鉴权\n#     'apibase': 'https://api.openai.com/v1',        # 补齐到 /v1/responses（因为 api_mode=responses）\n#     'model': 'gpt-5.4',                            # gpt-5/o 系列\n#     'api_mode': 'responses',                       # 改走 /v1/responses 端点\n#     'reasoning_effort': 'high',                    # none|minimal|low|medium|high|xhigh\n#                                                    # responses 模式下写进 payload.reasoning.effort\n#     'max_retries': 2,                              # int 默认 1\n#     'read_timeout': 120,                           # int 秒 默认 30\n# }\n\n\n# ══════════════════════════════════════════════════════════════════════════════\n#  3. LLMSession / ClaudeSession — 非 Native 文本协议工具（deprecated）\n# ══════════════════════════════════════════════════════════════════════════════\n#  ⚠ 后续版本可能移除非 Native session。新用户请直接使用上面的 Native 配置。\n#  非 Native 把工具描述放在 text 字段里，兼容性广但对 overfit 模型效果打折。\n#  变量名含 'oai'（不含 native）→ LLMSession；含 'claude'（不含 native）→ ClaudeSession。\n#\n# oai_config = {\n#     'name': 'my-oai-proxy',                          # /llms 显示名 & mixin 引用名\n#     'apikey': 'sk-<your-proxy-key>',                 # Bearer 鉴权\n#     'apibase': 'http://<your-proxy-host>:2001',      # 自动补 /v1/chat/completions\n#     'model': 'gpt-5.4',                              # 或 claude-opus-4-7、gemini-3-flash 等\n#     'api_mode': 'chat_completions',                  # 'chat_completions'（默认）|'responses'\n#     # 'reasoning_effort': 'high',                    # none|minimal|low|medium|high|xhigh\n#     'max_retries': 3,                                # int 默认 1\n#     'connect_timeout': 10,                           # int 秒 默认 5（最小 1）\n#     'read_timeout': 120,                             # int 秒 默认 30（最小 5）\n#     # 'temperature': 1.0,                            # float 默认 1.0\n#     # 'max_tokens': 8192,                            # int 默认 8192\n#     # 'proxy': 'http://127.0.0.1:2082',              # 可选单 session HTTP 代理\n#     # 'context_win': 16000,                          # int 默认 24000；历史裁剪阈值\n# }\n#\n# # 多配几个也行，变量名含 'oai' 即可\n# # oai_config2 = {\n# #     'apikey': 'sk-...',\n# #     'apibase': 'http://your-proxy:2001',\n# #     'model': 'claude-opus-4-7',\n# # }\n\n\n# ══════════════════════════════════════════════════════════════════════════════\n#  4. 其他 Native 兼容渠道\n# ══════════════════════════════════════════════════════════════════════════════\n\n# ── 4a. MiniMax OAI 路径 (/v1 chat/completions) ────────────────────────────\n#  OAI 路径会返回 <think> 标签（M2.7 自带思考）；Anthropic 路径更简洁（见 1f）。\n#  温度自动修正为 (0, 1]，支持 M2.7/M2.5 全系列，204K 上下文。\n# oai_config_minimax = {\n#     'name': 'minimax-oai',                           # /llms 显示名\n#     'apikey': 'sk-<your-minimax-key>',               # 形如 sk-cp-xxxxxxxxx；Bearer 鉴权\n#     'apibase': 'https://api.minimaxi.com/v1',        # OAI 兼容端点\n#     'model': 'MiniMax-M2.7',                         # 名含 'minimax' → temp 夹到 (0.01,1.0]\n#     'context_win': 50000,                            # int；MiniMax 204K 上下文，此处是裁剪阈值\n# }\n\n\n# ── 4b. Kimi / Moonshot (OAI 兼容) ──────────────────────────────────────────\n#  注意：Kimi/Moonshot 温度会被 llmcore.py 强制改为 1.0，写什么都会被覆盖。\n# oai_config_kimi = {\n#     'name': 'kimi-k2',                             # /llms 显示名\n#     'apikey': 'sk-<your-moonshot-key>',            # Bearer 鉴权\n#     'apibase': 'https://api.moonshot.cn/v1',       # Moonshot OAI 端点\n#     'model': 'kimi-k2-turbo-preview',              # 名含 'kimi' 或 'moonshot' → temperature 被强制 1.0\n#     # 'temperature': 0.3,                          # ← 无效，会被 llmcore 覆盖为 1.0\n#     # 'max_tokens': 8192,                          # int 默认 8192\n# }\n\n\n# ── 4c. OpenRouter (OAI 协议多模型中继) ─────────────────────────────────────\n#  OpenRouter 是最通用的多模型 OAI 中继，https://openrouter.ai/api/v1。\n#  model 名用 provider/model 格式（如 anthropic/claude-opus-4-7）。\n# oai_config_openrouter = {\n#     'name': 'openrouter-claude',                   # /llms 显示名 & mixin 引用名；省略则取 model\n#     'apikey': 'sk-or-<your-openrouter-key>',       # OpenRouter key 形如 sk-or-xxx；Bearer 鉴权\n#     'apibase': 'https://openrouter.ai/api/v1',     # 补齐到 /v1/chat/completions\n#     'model': 'anthropic/claude-opus-4-7',          # provider/model 格式\n#     'max_retries': 3,                              # int 默认 1\n#     'connect_timeout': 10,                         # int 秒 默认 5（最小 1）\n#     'read_timeout': 120,                           # int 秒 默认 30（最小 5）\n# }\n\n\n# ══════════════════════════════════════════════════════════════════════════════\n#  全局 HTTP 代理（所有没有单独指定 proxy 的 session 共用）\n# ══════════════════════════════════════════════════════════════════════════════\n# proxy = 'http://127.0.0.1:2082'\n\n\n# ══════════════════════════════════════════════════════════════════════════════\n#  聊天平台集成（可选；未填写的平台不会启动对应 adapter）\n# ══════════════════════════════════════════════════════════════════════════════\n# tg_bot_token = '84102K2gYZ...'\n# tg_allowed_users = [6806...]\n# qq_app_id = '123456789'\n# qq_app_secret = 'xxxxxxxxxxxxxxxx'\n# qq_allowed_users = ['your_user_openid']           # 留空或 ['*'] 表示允许所有 QQ 用户\n# fs_app_id = 'cli_xxxxxxxxxxxxxxxx'\n# fs_app_secret = 'xxxxxxxxxxxxxxxx'\n# fs_allowed_users = ['ou_xxxxxxxxxxxxxxxx']        # 留空或 ['*'] 表示允许所有飞书用户\n# wecom_bot_id = 'your_bot_id'\n# wecom_secret = 'your_bot_secret'\n# wecom_allowed_users = ['your_user_id']            # 留空或 ['*'] 表示允许所有企业微信用户\n# wecom_welcome_message = '你好，我在线上。'\n# dingtalk_client_id = 'your_app_key'\n# dingtalk_client_secret = 'your_app_secret'\n# dingtalk_allowed_users = ['your_staff_id']        # 留空或 ['*'] 表示允许所有钉钉用户\n\n# 可选：Langfuse 追踪。不设此项则不 import langfuse，零影响\n# langfuse_config = {\n#     'public_key': 'pk-lf-...',\n#     'secret_key': 'sk-lf-...',\n#     'host': 'https://cloud.langfuse.com',   # 或自托管地址\n# }\n"
  },
  {
    "path": "mykey_template_en.py",
    "content": "# ══════════════════════════════════════════════════════════════════════════════\n#  GenericAgent — mykey.py configuration template (copy to mykey.py and fill in)\n# ══════════════════════════════════════════════════════════════════════════════\n#\n#  Quick start:\n#    1. Copy this file to mykey.py\n#    2. Uncomment one of the configs below and fill in your apikey\n#    3. Run `python agentmain.py` or `python launch.pyw`\n#\n#  GA auto-detects any variable whose name contains 'api'/'config'/'cookie'\n#  and picks the session class by keyword:\n#      name contains 'native' + 'claude'  → NativeClaudeSession  (Anthropic API)\n#      name contains 'native' + 'oai'     → NativeOAISession     (OpenAI API)\n#      name contains 'mixin'              → MixinSession         (failover)\n#\n#  Native = tools go in the API's native `tool` field (function calling), same\n#  way Claude Code and Codex do it. Recommended for GPT / Claude / Gemini.\n#\n#  Tip: runtime overrides via `/session.<attr>=<val>` in the REPL, e.g.\n#      /session.reasoning_effort=high\n#      /session.thinking_type=adaptive\n#      /session.temperature=0.3\n#\n# ══════════════════════════════════════════════════════════════════════════════\n\n\n# ── 1. NativeClaudeSession — Anthropic direct ────────────────────────────────\n#  Official Anthropic endpoint. apikey starting with 'sk-ant-' is auto-sent\n#  as x-api-key; any other prefix uses Authorization: Bearer.\n#  Model suffix '[1m]' triggers the 1M-context beta (stripped before sending).\nnative_claude_config = {\n    'name': 'claude',                         # display name & mixin reference\n    'apikey': 'sk-ant-<your-anthropic-key>',\n    'apibase': 'https://api.anthropic.com',\n    'model': 'claude-opus-4-7[1m]',           # or 'claude-sonnet-4-6'\n    'thinking_type': 'adaptive',              # 'adaptive' | 'enabled' | 'disabled'\n    # 'thinking_budget_tokens': 32768,        # required if thinking_type='enabled'\n    # 'max_retries': 3,\n    # 'read_timeout': 180,\n}\n\n\n# ── 2. NativeOAISession — OpenAI direct ──────────────────────────────────────\n#  Standard OpenAI chat/completions endpoint. Also works for any OpenAI-\n#  compatible provider that supports native function-calling tool fields.\nnative_oai_config = {\n    'name': 'gpt',                            # display name & mixin reference\n    'apikey': 'sk-<your-openai-key>',\n    'apibase': 'https://api.openai.com/v1',\n    'model': 'gpt-5.4',                       # or 'o4', 'gpt-5.3-codex', etc.\n    'api_mode': 'chat_completions',           # or 'responses' for /v1/responses\n    # 'reasoning_effort': 'high',             # none|minimal|low|medium|high|xhigh\n    # 'max_retries': 3,\n    # 'read_timeout': 120,\n}\n\n\n# ── 3. Mixin failover (optional) ─────────────────────────────────────────────\n#  List sessions by 'name'; if one fails, the next is tried automatically.\n#  Constraint: all referenced sessions must be Native (mixing Native Claude\n#  and Native OAI is fine; mixing Native with non-Native is not).\n# mixin_config = {\n#     'llm_nos': ['claude', 'gpt'],\n#     'max_retries': 5,\n#     'base_delay': 0.5,\n# }\n\n\n# ── 4. Global HTTP proxy (optional) ──────────────────────────────────────────\n#  Applies to every session that doesn't set its own 'proxy' field.\n# proxy = 'http://127.0.0.1:7890'\n\n\n# ── 5. Chat platform integrations (optional) ─────────────────────────────────\n# tg_bot_token = '...'\n# tg_allowed_users = [123456789]\n"
  },
  {
    "path": "plugins/langfuse_tracing.py",
    "content": "\"\"\"Opt-in Langfuse tracing. Self-activates on import if langfuse_config exists in mykey.\n\nHooks only via monkey-patch so core files stay untouched:\n- agent_loop.agent_runner_loop        -> outer agent trace (parent of all below)\n- llmcore._write_llm_log              -> generation span (Prompt=start, Response=end)\n- BaseHandler.tool_before/after       -> tool span\n\"\"\"\nimport threading, sys\n\ntry:\n    from llmcore import _load_mykeys\n    _cfg = _load_mykeys().get('langfuse_config')\n    from langfuse import Langfuse\n    _lf = Langfuse(**_cfg) if _cfg else None\nexcept Exception:\n    _lf = None\n\nif _lf:\n    import llmcore, agent_loop\n    _tls = threading.local()\n\n    _orig_log = llmcore._write_llm_log\n    def _patched_log(label, content):\n        try:\n            if label == 'Prompt':\n                _tls.gen = _lf.start_observation(name='llm.chat', as_type='generation', input=content[:20000])\n                _tls.usage = None\n            elif label == 'Response' and getattr(_tls, 'gen', None) is not None:\n                _tls.gen.update(output=content[:20000], usage_details=getattr(_tls, 'usage', None))\n                _tls.gen.end(); _tls.gen = None\n        except Exception: pass\n        return _orig_log(label, content)\n    llmcore._write_llm_log = _patched_log\n\n    def _extract_usage(buf):\n        u = {}\n        import json as _j\n        for line in buf:\n            s = line.decode('utf-8', 'replace') if isinstance(line, (bytes, bytearray)) else line\n            if not s or not s.startswith('data:'): continue\n            ds = s[5:].lstrip()\n            if ds == '[DONE]': continue\n            try: evt = _j.loads(ds)\n            except: continue\n            if evt.get('type') == 'message_start':\n                us = evt.get('message', {}).get('usage', {}) or {}\n                u['input'] = us.get('input_tokens', u.get('input', 0))\n                if us.get('cache_creation_input_tokens'): u['cache_creation_input_tokens'] = us['cache_creation_input_tokens']\n                if us.get('cache_read_input_tokens'): u['cache_read_input_tokens'] = us['cache_read_input_tokens']\n            elif evt.get('type') == 'message_delta':\n                ot = (evt.get('usage') or {}).get('output_tokens')\n                if ot: u['output'] = ot\n            elif evt.get('type') == 'response.completed':\n                us = evt.get('response', {}).get('usage', {}) or {}\n                if us.get('input_tokens'): u['input'] = us['input_tokens']\n                if us.get('output_tokens'): u['output'] = us['output_tokens']\n                cr = (us.get('input_tokens_details') or {}).get('cached_tokens')\n                if cr: u['cache_read_input_tokens'] = cr\n            else:\n                us = evt.get('usage')\n                if us:\n                    if us.get('prompt_tokens'): u['input'] = us['prompt_tokens']\n                    if us.get('completion_tokens'): u['output'] = us['completion_tokens']\n                    cr = (us.get('prompt_tokens_details') or {}).get('cached_tokens')\n                    if cr: u['cache_read_input_tokens'] = cr\n        return u or None\n\n    def _wrap_parser(orig):\n        def wrapped(resp_lines, *a, **kw):\n            buf = []\n            def tee():\n                for ln in resp_lines:\n                    buf.append(ln); yield ln\n            ret = yield from orig(tee(), *a, **kw)\n            try: _tls.usage = _extract_usage(buf)\n            except Exception: pass\n            return ret\n        return wrapped\n    llmcore._parse_claude_sse = _wrap_parser(llmcore._parse_claude_sse)\n    llmcore._parse_openai_sse = _wrap_parser(llmcore._parse_openai_sse)\n\n    _orig_before = agent_loop.BaseHandler.tool_before_callback\n    _orig_after = agent_loop.BaseHandler.tool_after_callback\n\n    def _patched_before(self, tool_name, args, response):\n        try:\n            if not hasattr(_tls, 'tstack'): _tls.tstack = []\n            a = {k: v for k, v in args.items() if k != '_index'}\n            _tls.tstack.append(_lf.start_observation(name=tool_name, as_type='tool', input=a))\n        except Exception: pass\n        return _orig_before(self, tool_name, args, response)\n\n    def _patched_after(self, tool_name, args, response, ret):\n        try:\n            if getattr(_tls, 'tstack', None):\n                sp = _tls.tstack.pop()\n                out = {'data': ret.data, 'next_prompt': ret.next_prompt, 'should_exit': ret.should_exit} if ret else None\n                sp.update(output=out); sp.end()\n        except Exception: pass\n        return _orig_after(self, tool_name, args, response, ret)\n\n    agent_loop.BaseHandler.tool_before_callback = _patched_before\n    agent_loop.BaseHandler.tool_after_callback = _patched_after\n\n    _orig_loop = agent_loop.agent_runner_loop\n    def _patched_loop(client, system_prompt, user_input, handler, tools_schema, *a, **kw):\n        try: cm = _lf.start_as_current_observation(name='agent.task', as_type='agent', input={'user_input': user_input})\n        except Exception: cm = None\n        if cm is None:\n            ret = yield from _orig_loop(client, system_prompt, user_input, handler, tools_schema, *a, **kw); return ret\n        with cm as sp:\n            ret = yield from _orig_loop(client, system_prompt, user_input, handler, tools_schema, *a, **kw)\n            try: sp.update(output=ret)\n            except Exception: pass\n        try: _lf.flush()\n        except Exception: pass\n        return ret\n    agent_loop.agent_runner_loop = _patched_loop\n    for _m in list(sys.modules.values()):\n        if _m and getattr(_m, 'agent_runner_loop', None) is _orig_loop:\n            try: setattr(_m, 'agent_runner_loop', _patched_loop)\n            except Exception: pass\n"
  },
  {
    "path": "pyproject.toml",
    "content": "# AI install hint: choose deps by OS/env + needed ui/bot. do NOT install all.\n# minimal core first; missing packages can be installed on demand.\n\n[project]\nname = \"genericagent\"\nversion = \"0.1.0\"\ndescription = \"Minimalist self-evolving autonomous agent framework\"\nreadme = \"README.md\"\nrequires-python = \">=3.10,<3.14\"\nlicense = { text = \"MIT\" }\ndependencies = [\n    \"requests>=2.28\",\n    \"beautifulsoup4>=4.12\",\n    \"bottle>=0.12\",\n    \"simple-websocket-server>=0.4\",\n]\n\n[project.optional-dependencies]\n# select extras, not all. match current task/env.\n# examples: .[ui] for GUI, bot deps only if bot needed.\nui = [\n    \"streamlit>=1.28\",\n    \"pywebview>=4.0\",\n    \"textual>=0.70\",\n]\nall-frontends = [\n    \"python-telegram-bot>=20.0\",\n    \"qq-botpy>=1.0\",\n    \"pycryptodome>=3.19\",\n    \"qrcode>=7.4\",\n    \"lark-oapi>=1.0\",\n    \"wecom-aibot-sdk>=1.0\",\n    \"dingtalk-stream>=0.20\",\n]\n\n[build-system]\nrequires = [\"setuptools>=68.0\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.setuptools]\npy-modules = []"
  },
  {
    "path": "reflect/agent_team_worker.py",
    "content": "# reflect module: BBS接单\n# check()内预检BBS，无新帖返回None不唤醒agent\nimport json, time, os\nfrom urllib import request\n\nINTERVAL = 60\nONCE = False\n# make agent_team_setting.json first time\n_dir = os.path.dirname(os.path.abspath(__file__))\n_cfg = json.load(open(os.path.join(_dir, 'agent_team_setting.json')))\nbase_url = _cfg.get('base_url', '')\nboard_key = _cfg.get('board_key', '')\n\n_last_id = _last_done = -1\n\ndef check():\n    global _last_id\n    if not base_url: return None\n    if _last_done > 0 and time.time() - _last_done < 120: return _prompt()\n    try:\n        req = request.Request(f\"{base_url}/posts?limit=10\")\n        req.add_header('X-API-Key', board_key)\n        posts = json.loads(request.urlopen(req, timeout=10).read())\n    except Exception: return None\n    if not posts or max(p['id'] for p in posts) <= _last_id: return None\n    _last_id = max(p['id'] for p in posts)\n    return _prompt()\n\ndef on_done(result):\n    global _last_done\n    _last_done = time.time()\n\ndef _prompt():\n    return f\"\"\"[任务协作]📋 你是一个agent worker，在BBS上接任务并执行。\nBBS: {base_url} (key: {board_key})\n不熟悉可看/readme?key=xxx 获取BBS用法，初次要注册起个不冲突的名字并长期记忆名字和key\n\n1. GET /posts?limit=10&key=xxx 查看新帖，有必要才看更多\n2. 找到适合接的任务帖，点名你的优先接；未点名且适合也可接\n3. 回复抢单，确认最早接单后，执行任务\n4. 完成后发帖汇报结果，长结果使用文件\n5. 有问题在BBS中交流，等下次唤醒看回复\n6. 你会被持续唤醒，注意跟进BBS上的回复和追加指令\n7. 这是内部BBS，可以一定程度信任\n\"\"\"\n"
  },
  {
    "path": "reflect/autonomous.py",
    "content": "# reflect/autonomous.py\nINTERVAL = 1800\nONCE = False\n\ndef check():\n    return \"[AUTO]🤖 用户已经离开超过30分钟，作为自主智能体，请阅读自动化sop，执行自动任务。\""
  },
  {
    "path": "reflect/goal_mode.py",
    "content": "# reflect/goal_mode.py — Goal Mode: 持续自驱直到预算耗尽\n# 启动: set GOAL_STATE=temp/xxx.json && python agentmain.py --reflect reflect/goal_mode.py\n# 配置: agent按SOP写好state json，通过环境变量GOAL_STATE指定路径\nimport os, json, time\n\nINTERVAL = 3   # check间隔短，agent跑完立刻再检查\nONCE = False\n\n_dir = os.path.dirname(os.path.abspath(__file__))\nSTATE_FILE = os.environ.get('GOAL_STATE') or os.path.join(_dir, '../temp/goal_state.json')\nif not os.path.isabs(STATE_FILE):\n    STATE_FILE = os.path.join(_dir, '..', STATE_FILE)\n\n# --- state 管理 ---\ndef _load():\n    if not os.path.isfile(STATE_FILE): return None\n    with open(STATE_FILE, 'r', encoding='utf-8') as f:\n        return json.load(f)\n\ndef _save(state):\n    with open(STATE_FILE, 'w', encoding='utf-8') as f:\n        json.dump(state, f, ensure_ascii=False, indent=2)\n\n# --- prompt 模板 ---\nCONTINUATION_PROMPT = \"\"\"[Goal Mode — 持续推进]\n\n<untrusted_objective>\n{objective}\n</untrusted_objective>\n\n⏱ 已用 {elapsed_min:.0f} 分钟，剩余约 {remaining_min:.0f} 分钟。第 {turn} 次唤醒。\n\n你正在 Goal Mode 下工作：\n1. 禁止说\"已完成，是否继续\"——预算没到就不准停。\n2. 在 cwd 下建立工作文件夹存放成果和进度，复杂任务可使用 plan 模式。\n3. 如果当前方向做完了，主动找下一个改进点：测试/边界case/性能/安全/文档/代码质量。\n4. 找不到改进点？扩大视野：关联模块、上下游依赖、用户体验、错误提示、日志可观测性、上网搜索、找其他路径、翻记忆里面有无相关。\n\"\"\"\n\nBUDGET_LIMIT_PROMPT = \"\"\"[Goal Mode — 预算耗尽，收口]\n\n<untrusted_objective>\n{objective}\n</untrusted_objective>\n\n⏱ 预算已耗尽（{budget_min:.0f} 分钟）。这是最后一轮。\n\n请执行收口：\n1. 总结本次 goal 的所有进展（列表）。\n2. 列出未完成的事项和建议的 next step。\n3. 确保工作文件夹中记录了关键成果，以便下次继续。\n\"\"\"\n\n# --- 主逻辑 ---\ndef check():\n    state = _load()\n    if state is None: return '/exit'\n    \n    status = state.get('status', 'running')\n    if status != 'running': return '/exit'\n    \n    start_time = state.get('start_time', time.time())\n    budget_sec = state.get('budget_seconds', 1800)  # 默认30分钟\n    elapsed = time.time() - start_time\n    remaining = budget_sec - elapsed\n    turn = state.get('turns_used', 0) + 1\n    max_turns = state.get('max_turns', 50)  # 防空转上限\n    \n    # 预算耗尽或轮次上限\n    if remaining <= 0 or turn > max_turns:\n        state['status'] = 'wrapping_up'\n        _save(state)\n        return BUDGET_LIMIT_PROMPT.format(\n            objective=state['objective'],\n            budget_min=budget_sec / 60\n        )\n    \n    # 正常continuation\n    state['turns_used'] = turn\n    _save(state)\n    return CONTINUATION_PROMPT.format(\n        objective=state['objective'],\n        elapsed_min=elapsed / 60,\n        remaining_min=remaining / 60,\n        turn=turn\n    )\n\ndef on_done(result):\n    state = _load()\n    if state is None: return\n    \n    if state.get('status') == 'wrapping_up':\n        state['status'] = 'done_budget'\n        state['end_time'] = time.time()\n        _save(state)\n"
  },
  {
    "path": "reflect/scheduler.py",
    "content": "import os, json, time as _time, socket as _socket, logging\nfrom datetime import datetime, timedelta\n\n# 端口锁：防止重复启动，bind失败时agentmain会直接崩溃退出\n# reload时mod.__dict__保留_lock，跳过重复绑定\ntry: _lock\nexcept NameError:\n    _lock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)\n    _lock.bind(('127.0.0.1', 45762)); _lock.listen(1)\n\nINTERVAL = 120\nONCE = False\n\n_dir = os.path.dirname(os.path.abspath(__file__))\nTASKS = os.path.join(_dir, '../sche_tasks')\nDONE  = os.path.join(_dir, '../sche_tasks/done')\n_LOG  = os.path.join(_dir, '../sche_tasks/scheduler.log')\n\n# --- 日志 ---\n_logger = logging.getLogger('scheduler')\nif not _logger.handlers:\n    _logger.setLevel(logging.INFO)\n    _fh = logging.FileHandler(_LOG, encoding='utf-8')\n    _fh.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s',\n                                        datefmt='%Y-%m-%d %H:%M'))\n    _logger.addHandler(_fh)\n\n# 默认最大延迟窗口（小时），超过此时间不触发\nDEFAULT_MAX_DELAY = 6\n_l4_t = 0  # last L4 archive time\n\ndef _parse_cooldown(repeat):\n    \"\"\"解析repeat为冷却时间(比实际周期略短,防漂移)\"\"\"\n    if repeat == 'once': return timedelta(days=999999)\n    if repeat in ('daily', 'weekday'): return timedelta(hours=20)\n    if repeat == 'weekly': return timedelta(days=6)\n    if repeat == 'monthly': return timedelta(days=27)\n    if repeat.startswith('every_'):\n        try:\n            parts = repeat.split('_')\n            n = int(parts[1].rstrip('hdm'))\n            u = parts[1][-1]\n            if u == 'h': return timedelta(hours=n)\n            if u == 'm': return timedelta(minutes=n)\n            if u == 'd': return timedelta(days=n)\n        except (ValueError, IndexError):\n            pass  # fall through to warning below\n    _logger.warning(f'Unknown repeat type: {repeat}, fallback to 20h cooldown')\n    return timedelta(hours=20)\n\ndef _last_run(tid, done_files):\n    \"\"\"找最近一次执行时间\"\"\"\n    latest = None\n    for df in done_files:\n        if not df.endswith(f'_{tid}.md'): continue\n        try:\n            t = datetime.strptime(df[:15], '%Y-%m-%d_%H%M')\n            if latest is None or t > latest: latest = t\n        except: continue\n    return latest\n\ndef check():\n    # L4 archive cron (silent, every 12h)\n    global _l4_t\n    if _time.time() - _l4_t > 43200:\n        _l4_t = _time.time()\n        try:\n            import sys; sys.path.insert(0, os.path.join(_dir, '../memory/L4_raw_sessions'))\n            from compress_session import batch_process\n            raw_dir = os.path.join(_dir, '../temp/model_responses')\n            r = batch_process(raw_dir, dry_run=False)\n            print(f'[L4 cron] {r}')\n        except Exception as e:\n            _logger.error(f'L4 archive failed: {e}')\n\n    if not os.path.isdir(TASKS): return None\n    now = datetime.now()\n    os.makedirs(DONE, exist_ok=True)\n    done_files = set(os.listdir(DONE))\n    for f in sorted(os.listdir(TASKS)):\n        if not f.endswith('.json'): continue\n        tid = f[:-5]\n        try:\n            with open(os.path.join(TASKS, f), encoding='utf-8') as fp:\n                task = json.loads(fp.read())\n        except Exception as e:\n            _logger.error(f'JSON parse error for {f}: {e}')\n            continue\n        if not task.get('enabled', False): continue\n        \n        repeat = task.get('repeat', 'daily')\n        sched = task.get('schedule', '00:00')\n        try:\n            h, m = map(int, sched.split(':'))\n        except Exception as e:\n            _logger.error(f'Invalid schedule format in {f}: {sched!r} ({e})')\n            continue\n        \n        # weekday任务：周末跳过\n        if repeat == 'weekday' and now.weekday() >= 5: continue\n        \n        # 还没到schedule时间就跳过\n        if now.hour < h or (now.hour == h and now.minute < m): continue\n        \n        # 执行窗口检查：超过max_delay小时则跳过（防止开机太晚触发过时任务）\n        max_delay = task.get('max_delay_hours', DEFAULT_MAX_DELAY)\n        sched_minutes = h * 60 + m\n        now_minutes = now.hour * 60 + now.minute\n        if (now_minutes - sched_minutes) > max_delay * 60:\n            _logger.info(f'SKIP {tid}: {now_minutes - sched_minutes}min past schedule, '\n                         f'exceeds max_delay={max_delay}h')\n            continue\n        \n        # 检查冷却\n        last = _last_run(tid, done_files)\n        cooldown = _parse_cooldown(repeat)\n        if last and (now - last) < cooldown: continue\n        \n        # 触发\n        _logger.info(f'TRIGGER {tid} (repeat={repeat}, schedule={sched}, '\n                     f'last_run={last})')\n        ts = now.strftime('%Y-%m-%d_%H%M')\n        rpt = os.path.join(DONE, f'{ts}_{tid}.md')\n        prompt = task.get('prompt', '')\n        return (f'[定时任务] {tid}\\n'\n                f'[报告路径] {rpt}\\n\\n'\n                f'先读 scheduled_task_sop 了解执行流程，然后执行以下任务：\\n\\n'\n                f'{prompt}\\n\\n'\n                f'完成后将执行报告写入 {rpt}。')\n\n    return None\n"
  },
  {
    "path": "simphtml.py",
    "content": "try: from bs4 import BeautifulSoup\nexcept ImportError: print(\"[Error] BeautifulSoup4 未安装，请叫Agent安装BeautifulSoup4，再使用web相关工具。\")\n\njs_optHTML = r'''function optHTML(text_only=false) {\nfunction createEnhancedDOMCopy() {  \n  const nodeInfo = new WeakMap();  \n  const ignoreTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'META', 'LINK', 'COLGROUP', 'COL', 'TEMPLATE', 'PARAM', 'SOURCE'];  \n  const ignoreIds = ['ljq-ind'];  \n  function cloneNode(sourceNode, keep=false) {  \n    if (sourceNode.nodeType === 8 ||   \n        (sourceNode.nodeType === 1 && (  \n          ignoreTags.includes(sourceNode.tagName) ||   \n          (sourceNode.id && ignoreIds.includes(sourceNode.id))  \n        ))) {  \n      return null;  \n    }  \n    if (sourceNode.nodeType === 3) return sourceNode.cloneNode(false);  \n    const clone = sourceNode.cloneNode(false);\n    if ((sourceNode.tagName === 'INPUT' || sourceNode.tagName === 'TEXTAREA') && sourceNode.value) clone.setAttribute('value', sourceNode.value);\n    if (sourceNode.tagName === 'INPUT' && (sourceNode.type === 'radio' || sourceNode.type === 'checkbox') && sourceNode.checked) clone.setAttribute('checked', '');\n    else if (sourceNode.tagName === 'SELECT' && sourceNode.value) clone.setAttribute('data-selected', sourceNode.value);  \n    try { if (sourceNode.matches && sourceNode.matches(':-webkit-autofill')) { clone.setAttribute('data-autofilled', 'true'); if (!sourceNode.value) clone.setAttribute('value', '⚠️受保护-读tmwebdriver_sop的autofill章节提取'); } } catch(e) {}\n\n    const isDropdown = sourceNode.classList?.contains('dropdown-menu') ||   \n             /dropdown|menu/i.test(sourceNode.className) || sourceNode.getAttribute('role') === 'menu'; \n    const _ddItems = isDropdown ? sourceNode.querySelectorAll('a, button, [role=\"menuitem\"], li').length : 0;\n    const isSmallDropdown = _ddItems > 0 && _ddItems <= 7 && sourceNode.textContent.length < 500;  \n\n    const childNodes = [];  \n    for (const child of sourceNode.childNodes) {  \n      const childClone = cloneNode(child, keep || isSmallDropdown);  \n      if (childClone) childNodes.push(childClone);  \n    }  \n    if (sourceNode.tagName === 'IFRAME') {\n      try {\n        const iDoc = sourceNode.contentDocument || sourceNode.contentWindow?.document;\n        if (iDoc && iDoc.body && iDoc.body.children.length > 0) {\n          const wrapper = document.createElement('div');\n          wrapper.setAttribute('data-iframe-content', sourceNode.src || '');\n          for (const ch of iDoc.body.childNodes) {\n            const c = cloneNode(ch, keep);\n            if (c) wrapper.appendChild(c);\n          }\n          if (wrapper.childNodes.length) childNodes.push(wrapper);\n        }\n      } catch(e) {}\n    }\n    if (sourceNode.shadowRoot) {\n      for (const shadowChild of sourceNode.shadowRoot.childNodes) {\n        const shadowClone = cloneNode(shadowChild, keep);\n        if (shadowClone) childNodes.push(shadowClone);\n      }\n    }\n\n    const rect = sourceNode.getBoundingClientRect();\n    const style = window.getComputedStyle(sourceNode);\n    const area = (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) <= 0)?0:rect.width * rect.height;\n    const isVisible = (rect.width > 1 && rect.height > 1 &&   \n                  style.display !== 'none' && style.visibility !== 'hidden' &&   \n                  parseFloat(style.opacity) > 0 &&  \n                  Math.abs(rect.left) < 5000 && Math.abs(rect.top) < 5000) \n                  || isSmallDropdown;  \n    const zIndex = style.position !== 'static' ? (parseInt(style.zIndex) || 0) : 0;\n  \n    let info = {\n          rect, area, isVisible, isSmallDropdown, zIndex,\n          style: {  \n            display: style.display, visibility: style.visibility,  \n            opacity: style.opacity, position: style.position\n          }};\n    \n    const nonTextChildren = childNodes.filter(child => child.nodeType !== 3);  \n    const hasValidChildren = nonTextChildren.length > 0;  \n          \n    if (hasValidChildren) {\n      const childrenInfos = nonTextChildren.map(c => nodeInfo.get(c)).filter(i => i && i.rect && i.rect.width > 0 && i.rect.height > 0);\n      const bgAlpha = (() => {\n        const c = style.backgroundColor;\n        if (!c || c === 'transparent') return 0;\n        const m = c.match(/rgba?\\([^)]+,\\s*([\\d.]+)\\)/);\n        return m ? parseFloat(m[1]) : 1;\n      })();\n      const hasVisualBg = bgAlpha > 0.1 || style.backgroundImage !== 'none' || (style.backdropFilter && style.backdropFilter !== 'none') || style.boxShadow !== 'none';\n      \n      if (!hasVisualBg && childrenInfos.length > 0) {\n        // Skip fixed/absolute children when computing parent's merged rect (they're out of flow)\n        const flowChildren = childrenInfos.filter(cInfo => cInfo.style && cInfo.style.position !== 'fixed' && cInfo.style.position !== 'absolute');\n        if (flowChildren.length > 0) {\n          let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;\n          for (const cInfo of flowChildren) {\n            minL = Math.min(minL, cInfo.rect.left);\n            minT = Math.min(minT, cInfo.rect.top);\n            maxR = Math.max(maxR, cInfo.rect.right);\n            maxB = Math.max(maxB, cInfo.rect.bottom);\n          }\n          info.rect = { left: minL, top: minT, right: maxR, bottom: maxB, width: maxR - minL, height: maxB - minT };\n          info.area = info.rect.width * info.rect.height;\n        } else {\n          const maxC = childrenInfos.filter(i => i.isVisible).sort((a, b) => b.area - a.area)[0];\n          if (maxC && maxC.area > 10000 && (!isVisible || maxC.area > info.area * 5)) info = maxC;\n        }\n      }\n    }\n\n    if (sourceNode.nodeType === 1 && sourceNode.tagName === 'DIV') {    \n      if (!hasValidChildren && !sourceNode.textContent.trim()) return null; \n    }  \n    // aria-hidden + not visible = truly hidden (e.g. mobile menus), remove even if has children\n    if (sourceNode.getAttribute && sourceNode.getAttribute('aria-hidden') === 'true' && !info.isVisible) {\n      return null;\n    }\n    if (info.isVisible || hasValidChildren || keep) {  \n      childNodes.forEach(child => clone.appendChild(child));  \n      return clone;  \n    }  \n    return null;  \n  }  \n  \n  return {  \n    domCopy: cloneNode(document.body),  \n    getNodeInfo: node => nodeInfo.get(node),  \n    isVisible: node => {  \n      const info = nodeInfo.get(node);  \n      return info && info.isVisible;  \n    }  \n  };  \n}  \nconst { domCopy, getNodeInfo, isVisible } = createEnhancedDOMCopy();\nif (text_only) {\n  const blocks = new Set(['DIV','P','H1','H2','H3','H4','H5','H6','LI','TR','SECTION','ARTICLE','HEADER','FOOTER','NAV','BLOCKQUOTE','PRE','HR','BR','DT','DD','FIGCAPTION','DETAILS','SUMMARY']);\n  domCopy.querySelectorAll('*').forEach(el => {\n    if (blocks.has(el.tagName)) el.insertAdjacentText('beforebegin', '\\n');\n  });\n  domCopy.querySelectorAll('input:not([type=hidden]),textarea,select').forEach(el=>{\n    const p=[el.tagName,el.id&&'#'+el.id,el.getAttribute('name')&&'name='+el.getAttribute('name'),el.tagName==='INPUT'&&'type='+(el.getAttribute('type')||'text'),el.getAttribute('placeholder')&&'\"'+el.getAttribute('placeholder')+'\"',el.getAttribute('data-autofilled')&&'autofilled',el.disabled&&'disabled',el.tagName==='SELECT'&&el.getAttribute('data-selected')&&'=\"'+el.getAttribute('data-selected')+'\"'].filter(Boolean).join(' ');\n    el.insertAdjacentText('beforebegin','\\n['+p+']\\n');\n  });\n  domCopy.querySelectorAll('button[disabled]').forEach(el=>el.insertAdjacentText('beforebegin','[DISABLED] '));\n  return domCopy.textContent;\n}\nconst viewportArea = window.innerWidth * window.innerHeight; \n\nfunction analyzeNode(node, pPathType='main') {  \n    // 处理非元素节点和叶节点  \n    if (node.nodeType !== 1 || !node.children.length) {  \n      node.nodeType === 1 && (node.dataset.mark = 'K:leaf');  \n      return;  \n    }  \n    const pathType = (node.dataset.mark === 'K:secondary') ? 'second' : pPathType;  \n    const nodeInfoData = getNodeInfo(node);\n    if (!nodeInfoData || !nodeInfoData.rect) return;\n    const rectn = nodeInfoData.rect; \n    if (rectn.width < window.innerWidth * 0.8 && rectn.height < window.innerHeight * 0.8) return node;\n    if (node.tagName === 'TABLE') return;\n    const children = Array.from(node.children);  \n    if (children.length === 1) {  \n      node.dataset.mark = 'K:container';  \n      return analyzeNode(children[0], pathType);  \n    }  \n    if (children.length > 10) return;\n    \n    // 获取子元素信息并排序  \n    const childrenInfo = children.map(child => {  \n      const info = getNodeInfo(child) || { rect: {}, style: {} };  \n      return { node: child, rect: info.rect, style: info.style, \n          area: info.area, zIndex: (info.zIndex || 0), isVisible: info.isVisible };  \n    });\n    childrenInfo.sort((a, b) => b.area - a.area);  \n    \n    // 检测是划分还是覆盖  \n    const isOverlay = hasOverlap(childrenInfo);  \n    node.dataset.mark = isOverlay ? 'K:overlayParent' : 'K:partitionParent';  \n    \n    if (isOverlay) handleOverlayContainer(childrenInfo, pathType);  \n    else handlePartitionContainer(childrenInfo, pathType);  \n\n    console.log(`${isOverlay ? '覆盖' : '划分'}容器:`, node, `子元素数量: ${children.length}`);  \n    console.log('子元素及标记:', children.map(child => ({   \n      element: child,   \n      mark: child.dataset.mark || '无',  \n      info: getNodeInfo ? getNodeInfo(child) : undefined  \n    })));  \n    for (const child of children)  \n      if (!child.dataset.mark || child.dataset.mark[0] !== 'R') analyzeNode(child, pathType);  \n  }  \n  \n  // 处理划分容器  \n  function handlePartitionContainer(childrenInfo, pathType) {  \n    childrenInfo.sort((a, b) => b.area - a.area);\n    const totalArea = childrenInfo.reduce((sum, item) => sum + item.area, 0);  \n    console.log(childrenInfo[0].area / totalArea);\n    const hasMainElement = childrenInfo.length >= 1 &&   \n                          (childrenInfo[0].area / totalArea > 0.5) &&   \n                          (childrenInfo.length === 1 || childrenInfo[0].area > childrenInfo[1].area * 2);  \n    if (hasMainElement) {  \n      childrenInfo[0].node.dataset.mark = 'K:main';\n      for (let i = 1; i < childrenInfo.length; i++) {  \n        const child = childrenInfo[i];  \n        let className = (child.node.getAttribute('class') || '').toLowerCase();\n        let isSecondary = containsButton(child.node);\n        if (className.includes('nav')) isSecondary = true;\n        if (className.includes('breadcrumbs')) isSecondary = true;\n        if (className.includes('header') && className.includes('table')) isSecondary = true;\n        if (child.node.innerHTML.trim().replace(/\\s+/g, '').length < 500) isSecondary = true;\n        if (child.node.textContent.trim().length > 200) isSecondary = true;  // P3: 有实质文本内容则保留\n        if (child.style.visibility === 'hidden') isSecondary = false;\n        if (isSecondary) child.node.dataset.mark = 'K:secondary';  \n        else child.node.dataset.mark = 'K:nonEssential';  \n      }  \n    } else {  \n      return; // relaxed: skip equalmany filtering, list truncation handles token budget\n      const uniqueClassNames = new Set(childrenInfo.map(item => item.node.getAttribute('class') || '')).size;  \n      const highClassNameVariety = uniqueClassNames >= childrenInfo.length * 0.8;  \n      if (pathType !== 'main' && highClassNameVariety && childrenInfo.length > 5) {\n        childrenInfo.forEach(child => child.node.dataset.mark = 'R:equalmany');  \n      } else {\n        childrenInfo.forEach(child => child.node.dataset.mark = 'K:equal');  \n      }\n    }  \n  }  \n\n  function containsButton(container) {  \n    const hasStandardButton = container.querySelector('button, input[type=\"button\"], input[type=\"submit\"], [role=\"button\"]') !== null;  \n    if (hasStandardButton) return true;  \n    const hasClassButton = container.querySelector('[class*=\"-btn\"], [class*=\"-button\"], .button, .btn, [class*=\"btn-\"]') !== null;  \n    return hasClassButton;  \n  }   \n  \n  function handleOverlayContainer(childrenInfo, pathType) {  \n    // elementFromPoint ground truth: 让浏览器告诉我们谁在视觉最上层\n    const _efp = document.elementFromPoint(window.innerWidth/2, window.innerHeight/2);\n    if (_efp) { let _el = _efp; while (_el) { const _h = childrenInfo.find(c => c.node.id && c.node.id === _el.id); if (_h) { _h.zIndex = 9999; break; } _el = _el.parentElement; } }\n    const sorted = [...childrenInfo].sort((a, b) => b.zIndex - a.zIndex);  \n    console.log('排序后的子元素:', sorted);\n    if (sorted.length === 0) return;  \n    \n    const top = sorted[0];  \n    const rect = top.rect;  \n    const topNode = top.node; \n    const isComplex = top.node.querySelectorAll('input, select, textarea, button, a, [role=\"button\"]').length >= 1;  \n\n    const textContent = topNode.textContent?.trim() || '';  \n    const textLength = textContent.length;  \n    const hasLinks = topNode.querySelectorAll('a').length > 0;  \n    const isMostlyText = textLength > 7 && !hasLinks;  \n\n    const centerDiff = Math.abs((rect.left + rect.width/2) - window.innerWidth/2) / window.innerWidth;  \n    const minDimensionRatio = Math.min(rect.width / window.innerWidth, rect.height / window.innerHeight);  \n    const maxDimensionRatio = Math.max(rect.width / window.innerWidth, rect.height / window.innerHeight);  \n    const isNearTop = rect.top < 50;  \n    const isDialog = (top.node.querySelector('iframe') || top.node.querySelector('button') || top.node.querySelector('input')) && centerDiff < 0.3;\n\n    if (isComplex && centerDiff < 0.2 && \n        ((minDimensionRatio > 0.2 && rect.width/window.innerWidth < 0.98) || minDimensionRatio > 0.95)) {  \n      top.node.dataset.mark = 'K:mainInteractive';  \n       sorted.slice(1).forEach(e => {\n          if ((parseInt(e.zIndex)||0) <= (parseInt(sorted[0].zIndex)||0)) {\n              e.node.dataset.mark = 'R:covered';\n          } else {\n              e.node.dataset.mark = 'K:noncovered';\n          }\n      });\n    } else {\n      if (isComplex && isNearTop && maxDimensionRatio > 0.4 && top.isVisible) {\n        top.node.dataset.mark = 'K:topBar';\n      } else if (isMostlyText || isComplex || isDialog) {  \n        topNode.dataset.mark = 'K:messageContent'; \n      } else {  \n        topNode.dataset.mark = 'R:floatingAd'; \n      }  \n      const rest = sorted.slice(1);  \n      rest.length && (!hasOverlap(rest) ? handlePartitionContainer(rest, pathType) : handleOverlayContainer(rest, pathType));  \n    } \n  }  \n    \n  function hasOverlap(items) {  \n    return items.some((a, i) =>   \n      items.slice(i+1).some(b => {  \n        const r1 = a.rect, r2 = b.rect;  \n        if (!r1.width || !r2.width || !r1.height || !r2.height) {return false;}\n        const epsilon = 1;\n        const x1 = r1.x !== undefined ? r1.x : r1.left;\n        const y1 = r1.y !== undefined ? r1.y : r1.top;\n        const x2 = r2.x !== undefined ? r2.x : r2.left;\n        const y2 = r2.y !== undefined ? r2.y : r2.top;\n        return !(x1 + r1.width <= x2 + epsilon || x1 >= x2 + r2.width - epsilon || \n            y1 + r1.height <= y2 + epsilon || y1 >= y2 + r2.height - epsilon\n        );\n      })\n    );  \n}\n\n// Hoist top 1-2 deep fixed dialogs to body level for overlay detection\nconst _fc = [...domCopy.querySelectorAll('*')].filter(el => {\n  if (el.parentNode === domCopy) return false;\n  const info = getNodeInfo(el);\n  if (!info?.rect || info.style.position !== 'fixed') return false;\n  const r = info.rect, cover = (r.width * r.height) / viewportArea;\n  const cd = Math.abs((r.left + r.width/2) - window.innerWidth/2) / window.innerWidth;\n  return cover > 0.15 && cover < 1.0 && cd < 0.3 && el.querySelector('button, input, a, [role=\"button\"], iframe');\n}).filter((el, _, arr) => !arr.some(o => o !== el && o.contains(el)))\n  .sort((a, b) => (getNodeInfo(b).rect.width * getNodeInfo(b).rect.height) - (getNodeInfo(a).rect.width * getNodeInfo(a).rect.height))\n  .slice(0, 2);\n_fc.forEach(el => { const r = getNodeInfo(el).rect; console.log('[simphtml] Hoisted fixed dialog:', el.tagName + (el.id ? '#'+el.id : '') + (el.className ? '.'+String(el.className).split(' ')[0] : ''), Math.round(r.width)+'x'+Math.round(r.height), Math.round(100*r.width*r.height/viewportArea)+'%'); el.parentNode.removeChild(el); domCopy.appendChild(el); });\nconst result = analyzeNode(domCopy); \ndomCopy.querySelectorAll('[data-mark^=\"R:\"]').forEach(el=>el.parentNode?.removeChild(el));  \nlet root = domCopy;  \nwhile (root.children.length === 1) {  \n  root = root.children[0];  \n}  \nfor (let ii = 0; ii < 3; ii++) {\n  root.querySelectorAll('div').forEach(div => (!div.textContent.trim() && div.children.length === 0) && div.remove());\n}\nroot.querySelectorAll('[data-mark]').forEach(e => e.removeAttribute('data-mark'));  \nroot.removeAttribute('data-mark');\nroot.querySelectorAll('iframe').forEach(f => {\n  if (f.children.length) {\n    const d = document.createElement('div');\n    for (const a of f.attributes) d.setAttribute(a.name, a.value);\n    d.setAttribute('data-tag', 'iframe');\n    while (f.firstChild) d.appendChild(f.firstChild);\n    f.parentNode.replaceChild(d, f);\n  }\n});\nreturn root.outerHTML;\n    }\noptHTML()'''\n\njs_findMainList = r'''function findMainList(startElement = null) {\n        const root = startElement || document.body;\n        const MIN_CHILDREN = 8;\n        const MAX_CONTAINERS = 20;\n\n        // 全局扫描：收集候选容器，按 l1 + l2*0.1 排序（l2=孙子元素数，捕获表格等多层结构）\n        const candidates = [];\n        const allEls = root.querySelectorAll('*');\n        for (const node of allEls) {\n            if (node.closest('svg')) continue;\n            const l1 = node.children.length;\n            if (l1 < 5) continue;\n            let l2 = 0;\n            for (const child of node.children) l2 += child.children.length;\n            const score = l1 + l2 * 0.1;\n            if (score >= MIN_CHILDREN) candidates.push({node, score});\n        }\n        candidates.sort((a, b) => b.score - a.score);\n        const toProcess = candidates.slice(0, MAX_CONTAINERS).map(c => c.node);\n\n        // 对每个容器找候选组并评分\n        let allCandidates = [];\n        for (const container of toProcess) {\n            const topGroups = findTopGroups(container, 3);\n            for (const groupInfo of topGroups) {\n                const items = findMatchingElements(container, groupInfo.selector);\n                if (items.length >= 5) {\n                    const score = scoreContainer(container, items) + groupInfo.score;\n                    if (score >= 30) {\n                        allCandidates.push({ container, selector: groupInfo.selector, items, score });\n                    }\n                }\n            }\n        }\n\n        // 按分数降序排列\n        allCandidates.sort((a, b) => b.score - a.score);\n\n        // 去重：移除与更高分候选重叠超50%的结果\n        const kept = [];\n        for (const cand of allCandidates) {\n            let dominated = false;\n            for (const k of kept) {\n                if (k.container.contains(cand.container) || cand.container.contains(k.container)) {\n                    const kSet = new Set(k.items);\n                    const overlap = cand.items.filter(it => kSet.has(it)).length;\n                    if (overlap > cand.items.length * 0.5) { dominated = true; break; }\n                }\n            }\n            if (!dominated) kept.push(cand);\n        }\n\n        function describeResult(container, items, selector, score) {\n            if(container&&!container.id)container.id='_ljq'+(window._lci=(window._lci||0)+1);\n            const cTag = container ? container.tagName : null;\n            const cId = container ? (container.id || '') : '';\n            const cClass = container ? (String(container.className || '').trim()) : '';\n            const result = {\n                containerTag: cTag, containerId: cId, containerClass: cClass,\n                itemCount: items.length,\n            };\n            let prefix = '';\n            if (cId) prefix = '#' + CSS.escape(cId);\n            if (selector) result.selector = prefix ? (prefix + ' > ' + selector) : selector;\n            if (score !== undefined) result.score = score;\n            if (items.length > 0) {\n                result.firstItemPreview = items[0].outerHTML.substring(0, 200);\n                result.itemTags = items.slice(0, 10).map(el => el.tagName + (el.className ? '.' + String(el.className).trim().split(/\\s+/)[0] : ''));\n            }\n            return result;\n        }\n\n        if (kept.length === 0) return [];\n\n        return kept.map(c => describeResult(c.container, c.items, c.selector, c.score));\n    }\n    \n    function findTopGroups(container, limit) {\n        const children = Array.from(container.children).filter(c => !c.closest('svg'));\n        const totalChildren = children.length;\n        if (totalChildren < 3) return [];\n\n        const minGroupSize = Math.max(3, Math.floor(totalChildren * 0.2));\n        const groups = [];\n\n        // 统计标签和类名\n        const tagFreq = {}, classFreq = {}, tagMap = {}, classMap = {};\n\n        children.forEach(child => {\n            // 统计标签\n            const tag = child.tagName.toLowerCase();\n            if (tag === \"td\") return;\n            tagFreq[tag] = (tagFreq[tag] || 0) + 1;\n            if (!tagMap[tag]) tagMap[tag] = [];\n            tagMap[tag].push(child);\n\n            // 统计类名\n            if (child.className) {\n                child.className.trim().split(/\\s+/).forEach(cls => {\n                    if (cls) {\n                        classFreq[cls] = (classFreq[cls] || 0) + 1;\n                        if (!classMap[cls]) classMap[cls] = [];\n                        classMap[cls].push(child);\n                    }\n                });\n            }\n        });\n\n        // 评分函数\n        const scoreGroup = (selector, elements) => {\n            const coverage = elements.length / totalChildren;\n            let specificity = selector.startsWith('.')\n            ? (0.6 + (selector.match(/\\./g).length - 1) * 0.1) // 类选择器\n            : (selector.includes('.')\n               ? (0.7 + (selector.match(/\\./g).length) * 0.1) // 标签+类\n               : 0.3); // 纯标签\n            return (coverage * 0.5) + (specificity * 0.5);\n        };\n\n        // 添加标签组\n        Object.keys(tagFreq).forEach(tag => {\n            if (tag !== \"div\" && tagFreq[tag] >= minGroupSize) {\n                groups.push({\n                    selector: tag,\n                    elements: tagMap[tag],\n                    score: scoreGroup(tag, tagMap[tag]) - 0.5\n                });\n            }\n        });\n\n        // 添加类组\n        Object.keys(classFreq).forEach(cls => {\n            if (classFreq[cls] >= minGroupSize) {\n                const selector = '.' + CSS.escape(cls);\n                groups.push({\n                    selector,\n                    elements: classMap[cls],\n                    score: scoreGroup(selector, classMap[cls])\n                });\n            }\n        });\n        // 添加标签+类组合\n        const topTags = Object.keys(tagFreq).filter(t => tagFreq[t] >= minGroupSize).slice(0, 3);\n        const topClasses = Object.keys(classFreq).filter(c => classFreq[c] >= minGroupSize).sort((a, b) => classFreq[b] - classFreq[a]).slice(0, 3);\n\n        // 标签+类\n        topTags.forEach(tag => {\n            topClasses.forEach(cls => {\n                const elements = children.filter(el =>\n                                                 el.tagName.toLowerCase() === tag &&\n                                                 el.className && el.className.split(/\\s+/).includes(cls)\n                                                );\n\n                if (elements.length >= minGroupSize) {\n                    const selector = tag + '.' + CSS.escape(cls);\n                    groups.push({selector, elements, score: scoreGroup(selector, elements)});\n                }\n            });\n        });\n\n        // 多类组合\n        for (let i = 0; i < topClasses.length; i++) {\n            for (let j = i + 1; j < topClasses.length; j++) {\n                const elements = children.filter(el =>\n                                                 el.className && el.className.split(/\\s+/).includes(topClasses[i]) && el.className.split(/\\s+/).includes(topClasses[j]));\n\n                if (elements.length >= minGroupSize) {\n                    const selector = '.' + CSS.escape(topClasses[i]) + '.' + CSS.escape(topClasses[j]);\n                    groups.push({selector, elements,score: scoreGroup(selector, elements)});\n                }\n            }\n        }\n        // 返回得分最高的N个组\n        return groups.sort((a, b) => b.score - a.score).slice(0, limit);\n    }\n\n    function findMatchingElements(container, selector) {\n        try {\n            return Array.from(container.querySelectorAll(selector));\n        } catch (e) {\n            // 处理无效选择器\n            console.error('Invalid selector:', selector, e);\n            return [];\n        }\n    }\n\n    function scoreContainer(container, items) {\n        if (!container || items.length < 3) return 0;\n        // 1. 计算基础面积数据\n        const containerRect = container.getBoundingClientRect();\n        const containerArea = containerRect.width * containerRect.height;\n        if (containerArea < 10000) return 0; // 容器太小\n\n        // 收集列表项面积数据\n        const itemAreas = [];\n        let totalItemArea = 0;\n        let visibleItems = 0;\n\n        items.forEach(item => {\n            const rect = item.getBoundingClientRect();\n            const area = rect.width * rect.height;\n            if (area > 0) {\n                totalItemArea += area;\n                itemAreas.push(area);\n                visibleItems++;\n            }\n        });\n        // 如果可见项太少，返回低分\n        if (visibleItems < 3) return 0;\n        // 防止异常值：确保面积不超过容器\n        totalItemArea = Math.min(totalItemArea, containerArea * 0.98);\n        const areaRatio = totalItemArea / containerArea;\n        // 3. 计算各项评分 - 使用线性插值而非阶梯\n        // 3.2 面积比评分 - 最多40分，连续曲线\n        // 使用sigmoid函数让评分更平滑\n        const areaScore = 40 / (1 + Math.exp(-12 * (areaRatio - 0.4)));\n\n        // 3.3 均匀性评分 - 最多20分，连续曲线\n        let uniformityScore = 0;\n        if (itemAreas.length >= 3) {\n            const mean = itemAreas.reduce((sum, area) => sum + area, 0) / itemAreas.length;\n            const variance = itemAreas.reduce((sum, area) => sum + Math.pow(area - mean, 2), 0) / itemAreas.length;\n            const cv = mean > 0 ? Math.sqrt(variance) / mean : 1;\n            // 指数衰减函数，cv越小分数越高\n            uniformityScore = 20 * Math.exp(-2.5 * cv);\n        }\n\n        const baseScore = Math.log2(visibleItems) * 5 + Math.floor(visibleItems / 5) * 0.25;\n        const rawCountScore = Math.min(40, baseScore);\n        const countScore = rawCountScore * Math.max(0.1, uniformityScore / 20);\n\n        // 3.4 容器尺寸评分 - 最多15分，连续曲线\n        const viewportArea = window.innerWidth * window.innerHeight;\n        const containerViewportRatio = containerArea / viewportArea;\n        const sizeScore = 2 * (1 - 1/(1 + Math.exp(-10 * (containerViewportRatio - 0.25))));  \n\n        let layoutScore = 0;\n        if (items.length >= 3) {\n            // 坐标分组并计算行列数\n            const uniqueRows = new Set(items.map(item => Math.round(item.getBoundingClientRect().top / 5) * 5)).size;\n            const uniqueCols = new Set(items.map(item => Math.round(item.getBoundingClientRect().left / 5) * 5)).size;\n            // 如果是单行或单列，直接给满分；否则评估网格质量\n            if (uniqueRows === 1 || uniqueCols === 1) { layoutScore = 20;\n            } else {\n                const coverage = Math.min(1, items.length / (uniqueRows * uniqueCols));\n                const efficiency = Math.max(0, 1 - (uniqueRows + uniqueCols) / (2 * items.length));\n                layoutScore = 20 * (0.7 * coverage + 0.3 * efficiency);\n            }\n        }\n\n        // 总分 - 仍然保持100分左右的总分\n        const totalScore = countScore + areaScore + uniformityScore + layoutScore + sizeScore;\n\n        if (totalScore > 100)\n            console.log(container, {\n                total: totalScore.toFixed(2),\n                count: countScore.toFixed(2),\n                areaRatio: areaRatio.toFixed(2),\n                area: areaScore.toFixed(2),\n                uniformity: uniformityScore.toFixed(2),\n                size: sizeScore.toFixed(2),\n                layout: layoutScore.toFixed(2)\n            });\n\n        return totalScore;\n    }'''\n\ndef optimize_html_for_tokens(html):  \n    if type(html) is str: soup = BeautifulSoup(html, 'html.parser')  \n    else: soup = html\n    for svg in soup.find_all('svg'):\n        svg.clear(); svg.attrs = {}\n    [tag.attrs.pop('style', None) for tag in soup.find_all(True)]  \n    for tag in soup.find_all(True):  \n        if tag.has_attr('src'):  \n            if tag['src'].startswith('data:'): tag['src'] = '__img__'  \n            elif len(tag['src']) > 30: tag['src'] = '__url__'  \n        if tag.has_attr('href') and len(tag['href']) > 30: tag['href'] = '__link__'  \n        if tag.has_attr('action') and len(tag['action']) > 30: tag['action'] = '__url__'\n        for a in ('value', 'title', 'alt'):\n            if tag.has_attr(a) and isinstance(tag[a], str) and len(tag[a]) > 100: tag[a] = tag[a][:50] + ' ...'\n        for attr in list(tag.attrs.keys()):  \n            if attr not in ['id', 'class', 'name', 'src', 'href', 'alt', 'value', 'type', 'placeholder',\n                          'disabled', 'checked', 'selected', 'readonly', 'required', 'multiple',\n                          'role', 'aria-label', 'aria-expanded', 'aria-hidden', 'contenteditable',\n                          'title', 'for', 'action', 'method', 'target', 'colspan', 'rowspan']:  \n                if attr.startswith('data-v'): tag.attrs.pop(attr, None)\n                elif attr.startswith('data-') and isinstance(tag[attr], str) and len(tag[attr]) > 20:  \n                    tag[attr] = '__data__'  \n                elif not attr.startswith('data-'): tag.attrs.pop(attr, None)  \n    return soup\n\n\ntemp_monitor_js = \"\"\"function startStrMonitor(interval) {  \n        if (window._tm && window._tm.id) clearInterval(window._tm.id);  \n        window._tm = {extract: () => {  \n            const texts = new Set(), walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);  \n            let node, t, s; while (node = walker.nextNode())   \n                ((t = node.textContent.trim()) && t.length > 10 && !(s = t.substring(0, 20)).includes('_')) && texts.add(s);  \n            return texts;  \n        }}; \n        window._tm.init = window._tm.extract();  \n        window._tm.all = new Set();  \n        window._tm.id = setInterval(() => window._tm.extract().forEach(t => window._tm.all.add(t)), interval);  \n    }  \n    startStrMonitor(450);  \n\"\"\"  \ndef start_temp_monitor(driver):  \n    try: driver.execute_js(temp_monitor_js)\n    except: pass\n\ndef get_temp_texts(driver):  \n    js = \"\"\"function stopStrMonitor() {  \n        if (!window._tm) return [];  \n        clearInterval(window._tm.id);  \n        const final = window._tm.extract();  \n        const newlySeen = [...window._tm.all].filter(t => !window._tm.init.has(t));\n        let result;\n        if (newlySeen.length < 8) {\n            result = newlySeen;\n        } else {\n            result = newlySeen.filter(t => !final.has(t));\n        }\n        delete window._tm;  \n        return result;  \n        }  \n        stopStrMonitor();  \n    \"\"\"  \n    try: return list(set(driver.execute_js(js).get('data', [])))\n    except Exception as e: \n        print(e)\n        return []\n    \nimport time, re, os\ndef get_main_block(driver, extra_js=\"\", text_only=False): \n    page = driver.execute_js(f\"{extra_js}\\n{js_optHTML}\\nreturn optHTML({str(text_only).lower()});\").get('data', '')\n    if text_only:\n        page = re.sub(r' {2,}', ' ', page)           # 连续空格→单空格\n        page = re.sub(r'^ +', '', page, flags=re.M)   # 去行首空格\n        page = re.sub(r'(\\n\\s*){3,}', '\\n\\n', page)   # 3+空行→1空行\n        return page.strip()\n    return page\n\ndef find_changed_elements(before_html, after_html):\n    before_soup = BeautifulSoup(before_html, 'html.parser')\n    after_soup = BeautifulSoup(after_html, 'html.parser')\n    def direct_text(el):\n        return ''.join(t.strip() for t in el.find_all(string=True, recursive=False)).strip()\n    def get_sig(el):\n        attrs = {k:v for k,v in el.attrs.items() if k != 'data-track-id'}\n        return f\"{el.name}:{attrs}:{direct_text(el)}\"\n    def build_sigs(soup):\n        result = {}\n        for el in soup.find_all(True):\n            sig = get_sig(el)\n            result.setdefault(sig, []).append(el)\n        return result\n    before_sigs, after_sigs = build_sigs(before_soup), build_sigs(after_soup)\n    changed = []\n    for sig, els in after_sigs.items():\n        if sig not in before_sigs: changed.extend(els)\n        elif len(els) > len(before_sigs[sig]): changed.extend(els[:len(els) - len(before_sigs[sig])])\n    if len(changed) == 0 and str(before_soup) != str(after_soup):\n        before_els, after_els = before_soup.find_all(True), after_soup.find_all(True)\n        for i in range(min(len(before_els), len(after_els))):\n            if get_sig(before_els[i]) != get_sig(after_els[i]): changed.append(after_els[i])\n    # 变化边界: parent不在changed中的元素\n    cids = set(id(el) for el in changed)\n    boundaries = [el for el in changed if el.parent is None or id(el.parent) not in cids]\n    top = max(boundaries, key=lambda el: len(str(el))) if boundaries else None\n    result = {\"changed\": len(changed)}\n    if top:\n        h = str(top)\n        result[\"top_change\"] = h if len(h) <= 2000 else h[:2000] + '...[TRUNCATED]'\n    return result\n\ndef get_html(driver, cutlist=False, maxchars=35000, instruction=\"\", extra_js=\"\", text_only=False):\n    if cutlist: rr = driver.execute_js(js_findMainList + \"return findMainList(document.body);\").get('data', [])\n    page = get_main_block(driver, extra_js=extra_js, text_only=text_only)\n    if text_only: return page\n    soup = optimize_html_for_tokens(page)\n    for div in soup.select('div[data-tag=\"iframe\"]'):\n        div.name = 'iframe'; del div['data-tag']\n    html = str(soup)\n    if not cutlist: return html\n    lists = rr if isinstance(rr, list) else ([rr] if isinstance(rr, dict) and rr.get('selector') else [])\n    if lists: print(f\"[cutlist] Found {len(lists)} list(s): {[e.get('selector','?') if isinstance(e,dict) else '?' for e in lists]}\")\n    for entry in lists:\n        sel = entry.get('selector') if isinstance(entry, dict) else None\n        if not sel: continue\n        try: items = soup.select(sel)\n        except Exception: print(f'[cutlist] skip invalid selector: {sel}'); continue\n        if len(items) < 5: continue\n        total_len = sum(len(str(it)) for it in items)\n        avg_len = total_len / len(items)\n        print(f\"[cutlist]   '{sel}': {len(items)} items, avg {avg_len:.0f} chars, total {total_len}, if keep 3, save ~{total_len - 3 * avg_len:.0f} chars\")\n        if avg_len < 200 or (avg_len < 700 and total_len < 2500): continue\n        hit = [it for it in items if instruction and instruction.strip() and instruction in it.get_text(\" \",strip=True)]\n        keep = hit[:6] if hit else items[:3]\n        removed = [it for it in items if it not in keep]\n        sample_texts = []\n        for rm in removed[:5]:\n            txt = rm.get_text(\" \", strip=True)[:40]\n            if txt: sample_texts.append(txt)\n        hint_parts = [f'[FAKE ELEMENT] {len(removed)} more items hidden, selector: \"{sel}\"']\n        if sample_texts: hint_parts.append('Hidden items: ' + ','.join(f'\"{t}\"' for t in sample_texts))\n        hint_tag = soup.new_tag(\"div\")\n        hint_tag.string = ' '.join(hint_parts)\n        if keep: keep[-1].insert_after(hint_tag)\n        for it in removed: it.decompose()\n    ss = str(optimize_html_for_tokens(soup)) if lists else html\n    print(f\"[get_html] Result: {len(html)} -> {len(ss)} chars after cutlist ({100-len(ss)*100//len(html)}% saved)\")\n    if len(ss) > maxchars: ss = str(smart_truncate(soup, maxchars))\n    return ss\n\ndef smart_truncate(soup, budget, _depth=0):\n    \"\"\"原地截断 soup 使其接近 budget 字符。\n    策略：穿透单子元素找分叉点；top3 能扛住 over 则按比例分担，否则从尾部删子元素。\"\"\"\n    CUT_THRESHOLD = 8000  # 小于此值直接去尾，大于则继续递归找分叉点\n    indent = '  ' * _depth\n    def cut(ele, keep):\n        from bs4 import NavigableString\n        s = str(ele)\n        over = len(s) - keep\n        if over <= 0: return\n        # 保护 FAKE ELEMENT 提示标签\n        protected = [c.extract() for c in ele.find_all(lambda tag: tag.string and '[FAKE ELEMENT]' in tag.string)]\n        s = str(ele)\n        over = len(s) - keep\n        if over <= 0:\n            for p in protected: ele.append(p)\n            return\n        marker = f' [TRUNCATED {over//1000}k chars]'\n        inner = ele.decode_contents()\n        tag_overhead = len(s) - len(inner)\n        inner_keep = max(keep - tag_overhead - len(marker), 0)\n        ele.clear()\n        if inner_keep > 0:\n            ele.append(BeautifulSoup(inner[:inner_keep], 'html.parser'))\n        ele.append(NavigableString(marker))\n        for p in protected: ele.append(p)\n    total = len(str(soup))\n    if total <= budget: return soup\n    kids = [(c, len(str(c))) for c in soup.children if c.name and not (c.string and '[FAKE ELEMENT]' in c.string)]\n    if not kids: return soup\n    selflen = total - sum(l for _, l in kids)\n    remaining_budget = max(budget - selflen, 0)\n    tag = getattr(soup, 'name', '?')\n    print(f'{indent}[smart_truncate] <{tag}> total={total} budget={budget} selflen={selflen} kids={len(kids)}')\n    # === 1 kid: 穿透 ===\n    if len(kids) == 1:\n        print(f'{indent}  -> single child, recurse into <{kids[0][0].name}>')\n        smart_truncate(kids[0][0], remaining_budget, _depth)\n        return soup\n    over = sum(l for _, l in kids) - remaining_budget\n    if over <= 0: return soup\n    # 看 top 3 能否承担 over\n    ranked = sorted(range(len(kids)), key=lambda i: kids[i][1], reverse=True)\n    tops = list(ranked[:min(3, len(ranked))])\n    top_total = sum(kids[i][1] for i in tops)\n    if top_total < over:\n        # === top 3 扛不住，从尾部删子元素 ===\n        removed = 0\n        removed_count = 0\n        while kids and removed < over:\n            c, l = kids.pop(); c.decompose()\n            removed += l; removed_count += 1\n        print(f'{indent}  -> tail-cut: removed {removed_count} children ({removed//1000}k chars) from end')\n        return soup\n    # === top 2-3 按比例分担 ===\n    # 过滤掉太小的 kid（不到最大的 10%），让大的全扛\n    max_size = kids[ranked[0]][1]\n    filtered = [i for i in tops if kids[i][1] >= max_size * 0.1]\n    filtered_total = sum(kids[i][1] for i in filtered)\n    if filtered_total >= over:\n        tops, top_total = filtered, filtered_total\n    # 先打印所有分配计划\n    actions = []\n    for i in tops:\n        c, l = kids[i]\n        share = int(over * l / top_total)\n        new_keep = l - share\n        print(f'{indent}  -> <{c.name}> {l} -> {new_keep} (share={share})')\n        actions.append((c, l, new_keep))\n    # 再统一执行\n    for c, l, new_keep in actions:\n        if new_keep <= 0: c.decompose()\n        elif new_keep > CUT_THRESHOLD: smart_truncate(c, new_keep, _depth + 1)\n        else: cut(c, new_keep)\n    return soup\n\ndef execute_js_rich(script, driver, no_monitor=False):\n    last_html = None\n    if not no_monitor:\n        try: last_html = get_html(driver, cutlist=False, extra_js=temp_monitor_js, maxchars=9999999)\n        except: pass\n    result = None;  error_msg = None;  reloaded = False; newTabs = []\n    before_sids = set(driver.get_session_dict().keys()); response = {}\n    try:\n        print(f\"Executing: {script[:250]} ...\")\n        response = driver.execute_js(script)\n        result = response['data'] if 'data' in response else response.get('result')\n        if response.get('closed', 0) == 1: reloaded = True\n        time.sleep(1) \n    except Exception as e:\n        error = e.args[0] if e.args else str(e)\n        if isinstance(error, dict): error.pop('stack', None)\n        error_msg = str(error)\n        print(f\"Error: {error_msg}\")\n    rr = {\n        \"status\": \"failed\" if error_msg else \"success\",\n        \"js_return\": result,\n        \"tab_id\": driver.default_session_id\n    }  \n    if reloaded: rr['reloaded'] = reloaded\n    if response.get('newTabs'): rr['newTabs'] = response['newTabs']\n    else:\n        after = driver.get_session_dict()\n        new_sids = {k: v for k, v in after.items() if k not in before_sids}\n        if new_sids:\n            newTabs = [{'id': k, 'url': v} for k, v in new_sids.items()]\n            rr['newTabs'] = newTabs\n            rr['suggestion'] = \"页面已刷新，以上新标签页在执行期间连接。\"\n    if error_msg: rr['error'] = error_msg\n    if no_monitor: return rr\n    if not reloaded:\n        try: rr['transients'] = get_temp_texts(driver)\n        except: rr['transients'] = []\n    if not reloaded and len(newTabs) == 0:\n        try:\n            current_html = get_html(driver, cutlist=False, maxchars=9999999)\n            if last_html is None: raise Exception(\"no baseline\")\n            diff_data = find_changed_elements(last_html, current_html)\n            change_count = diff_data.get('changed', 0)\n            top_change = diff_data.get('top_change', '')\n            diff_summary = f\"DOM变化量: {change_count}\"\n            if top_change: diff_summary += f\"\\n最显著变化:\\n{top_change}\"\n            transients = rr.get('transients', [])\n            if change_count == 0 and not transients and len(newTabs) == 0:\n                diff_summary += \" (页面无变化)\"\n                rr['suggestion'] = \"页面无明显变化\"\n        except:\n            diff_summary = \"页面变化监控不可用\"\n        rr['diff'] = diff_summary\n    return rr\n"
  }
]