diff --git a/.gitignore b/.gitignore index 77a988eb..21f7494d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ memory/L4_raw_sessions/* # ljqCtrl related tools !memory/ljqCtrl.py !memory/ljqCtrl_sop.md +!memory/macljqCtrl.py !memory/computer_use.md # procmem_scanner related tools @@ -87,10 +88,6 @@ memory/L4_raw_sessions/* # Morphling SOP !memory/morphling_sop.md -# Skill Search SOP -!memory/skill_search/ -!memory/skill_search/** - # ADB UI tool !memory/adb_ui.py @@ -147,5 +144,8 @@ frontends/conductor_im_plugins/config.local.json .claude/ +# Project Mode SOP +!memory/project_mode_sop.md + # 本地 bug / 局限报告草稿(交给维护者参考,不进仓库) /BUG_*.md diff --git a/README.md b/README.md index f90beec9..0697dea4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@

+ Official Website Technical Report Reproduction Repo Tutorial @@ -24,8 +25,7 @@ -> 📌 **Official Channel** — This GitHub repository is the **only** official source of GenericAgent. -> We have no affiliation with any third-party website using the GenericAgent name. +> 📌 **Official:** GitHub + https://gaagent.ai only. DintalClaw is the sole authorized commercial partner; others are not affiliated. --- @@ -47,6 +47,7 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe - [Demo Showcase](#-demo-showcase) - [Quick Start](#-quick-start) - [Usage](#-usage) +- [Unlocking Advanced Capabilities](#-unlocking-advanced-capabilities) - [Architecture](#-architecture) - [Self-Evolution Mechanism](#-self-evolution-mechanism) - [Comparison](#-comparison) @@ -63,7 +64,7 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe | :--- | :--- | | 🧬 **Self-Evolving** | Automatically crystallizes each task into a Skill. Capabilities grow with every use, forming your personal skill tree. | | 🪶 **Minimal Architecture** | ~3K lines of core code. Agent Loop is ~100 lines. No complex dependencies, zero deployment overhead. | -| ⚡ **Strong Execution** | Injects into a real browser (preserving login sessions). 9 atomic tools take direct control of the system. | +| ⚡ **Strong Execution** | **TMWebdriver** injects into a real browser (preserving login sessions). 9 atomic tools take direct control of the system. | | 🔌 **High Compatibility** | Supports Claude / Gemini / Kimi / MiniMax and other major models. Cross-platform. | | 💰 **Token Efficient** | <30K context window — a fraction of the 200K–1M other agents consume. Less noise, fewer hallucinations, higher success rate, lower cost. | @@ -73,8 +74,20 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe - - + + + + + + + + + + + + + + @@ -85,25 +98,16 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe - + - + - - - - - - - - - - +
🧋 Food Delivery Order📈 Quantitative Stock Screening🛡️ Real-Browser CAPTCHA Survival🌐 Autonomous Web Exploration
Discord hCaptcha passed in real browserWeb Exploration
While configuring a Discord bot, an hCaptcha "Are you human?" challenge pops up mid-task — GA's real browser session passes it and the task continues. See Browser Realness.Autonomously browses and periodically summarizes web content.
🧋 Food Delivery Order📈 Quantitative Stock Screening
Order Tea"Find GEM stocks with EXPMA golden cross, turnover > 5%" — quantitative screening.
🌐 Autonomous Web Exploration 💰 Expense Tracking💬 Batch Messaging
Web Exploration Alipay ExpenseWeChat Batch
Autonomously browses and periodically summarizes web content. "Find expenses over ¥2K in the last 3 months" — drives Alipay via ADB.
💬 Batch Messaging
WeChat Batch
Sends bulk WeChat messages, fully driving the WeChat client.Sends bulk WeChat messages, fully driving the WeChat client.
@@ -125,42 +129,40 @@ curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/ma ### For Humans -#### Method 1 — One-line install *(recommended)* +#### Method 1 — Clone & install *(recommended)* -This installs GenericAgent with an isolated Python environment and Git, then downloads a ready-to-run package. +```bash +git clone https://github.com/lsdefine/GenericAgent.git && cd GenericAgent +uv venv && uv pip install -e ".[ui]" +cp mykey_template_en.py mykey.py # fill in your LLM API key +``` + +Dependencies are deliberately tiered: the agent core needs only `requests`, plus four lightweight packages (`beautifulsoup4`, `bottle`, `simple-websocket-server`, `aiohttp`) for TMWebdriver's local server. The `[ui]` extra pulls in frontend libraries (Streamlit, `prompt_toolkit`/`rich` for the TUI, …) — install it for the bundled UIs, or skip it entirely and drive the agent headless. No Playwright, no LangChain, no browser binaries to download. + +Then launch: + +```bash +python frontends/tui_v3.py # Terminal UI (recommended) +python launch.pyw # Streamlit web UI +``` + +#### Method 2 — One-line installer *(convenience)* + +Sets up a self-contained directory with an isolated Python environment, Git, and a ready-to-run package. The script is in [`assets/`](assets/) if you'd like to read it first. **Windows PowerShell** ```powershell -powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm http://fudankw.cn:9000/files/ga_install.ps1 | iex" +powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.ps1 | iex" ``` **Linux / macOS** ```bash -GLOBAL=1 bash -c "$(curl -fsSL http://fudankw.cn:9000/files/ga_install.sh)" +GLOBAL=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.sh)" ``` -After installation, launch the desktop app from: - -```text -frontends/GenericAgent.exe -``` - -#### Method 2 — Python install *(for developers)* - -```bash -git clone https://github.com/lsdefine/GenericAgent.git -cd GenericAgent -uv venv -uv pip install -e ".[ui]" # Core + UI dependencies -cp mykey_template.py mykey.py # Fill in your LLM API key -python launch.pyw -``` - -> 💡 GenericAgent is meant to grow its environment **through the Agent itself**, not by pre-installing every possible package. - -📖 Full guide: [`docs/GETTING_STARTED.md`](docs/GETTING_STARTED.md) +> 💡 GenericAgent grows its environment **through the Agent itself** — don't pre-install everything. See [Unlocking Advanced Capabilities](#-unlocking-advanced-capabilities) below. --- @@ -168,20 +170,12 @@ python launch.pyw ### Frontends -#### Desktop App +#### Terminal UI *(recommended)* -For one-line installs on Windows, double-click: - -```text -frontends/GenericAgent.exe -``` - -#### Terminal UI - -A lightweight, keyboard-driven interface built on [Textual](https://github.com/Textualize/textual). Supports multiple concurrent sessions and real-time streaming. +A lightweight, scrollback-first terminal interface built on `prompt_toolkit` + `rich`. Supports multiple concurrent sessions and real-time streaming. ```bash -python frontends/tuiapp_v2.py +python frontends/tui_v3.py ```

@@ -189,10 +183,10 @@ python frontends/tuiapp_v2.py TUI rendering on Windows can be flaky depending on terminal + font. Common causes: -1. `textual` is not on the latest version — `pip install -U textual` first. +1. `prompt_toolkit` / `rich` are not on the latest version — `pip install -U prompt_toolkit rich` first. 2. PowerShell / cmd ship with terminals that have rough Unicode + key-binding support. **Prefer Git Bash on Windows**, which is much better behaved. 3. If it still looks broken, ask GA itself to fix it: - > *"My experience using `frontends/tuiapp_v2.py` in PowerShell / cmd / Git Bash on Windows is very poor — lots of incompatibility. Please refer to Claude Code's best practices for the Windows terminal and fix all font and rendering incompatibilities."* + > *"My experience using `frontends/tui_v3.py` in PowerShell / cmd / Git Bash on Windows is very poor — lots of incompatibility. Please refer to Claude Code's best practices for the Windows terminal and fix all font and rendering incompatibilities."*
@@ -204,26 +198,41 @@ python launch.pyw ### Bot Interface (IM) -GenericAgent also supports IM frontends such as Telegram, WeChat, QQ, Feishu / Lark, WeCom, and DingTalk. +GenericAgent also supports IM frontends such as Telegram, Discord, and Lark. | Platform | Command | | :--- | :--- | | Telegram | `python frontends/tgapp.py` | -| WeChat | `python frontends/wechatapp.py` | -| QQ | `python frontends/qqapp.py` | -| Feishu / Lark | `python frontends/fsapp.py` | -| WeCom | `python frontends/wecomapp.py` | -| DingTalk | `python frontends/dingtalkapp.py` | +| Discord | `python frontends/dcapp.py` | +| Lark / Feishu | `python frontends/fsapp.py` | +> WeChat, QQ, WeCom and DingTalk are also supported — see the Chinese section below. > For detailed setup, ask GenericAgent itself. -### Common Chat Commands +--- -| Command | Description | +## 🔓 Unlocking Advanced Capabilities + +In GA, advanced capabilities are unlocked by **instructing the agent**, not by reading +docs or installing extras. Each instruction below makes GA read its pre-installed SOPs +(battle-tested playbooks in its memory), install whatever is missing, adapt to your OS, +and persist the result into its own memory. + +| Capability | Just tell GA | | :--- | :--- | -| `/new` | Start a fresh conversation and clear the current context | -| `/continue` | List recoverable conversation snapshots | -| `/continue N` | Restore the `N`-th recoverable conversation | +| 🌐 Web automation | *"Set up your web automation capability."* — GA guides you through the one manual step: dragging the bundled Chrome extension into `chrome://extensions`. | +| 🔤 OCR | *"Set up your OCR capability with rapidocr and save it to memory."* | +| 👁️ Vision | *"Set up your vision capability from the template in memory/."* — GA copies the template, wires it to your existing LLM keys, and self-tests. | +| 🖱️ Computer use | *"Probe this system and set up your computer-use capability."* | + +> 💡 **About language**: the pre-installed SOPs are written in Chinese — GA reads them +> natively, so this never blocks you. If you prefer an English knowledge base, just say: +> *"Read your pre-installed SOPs and rewrite them in English (keep code, paths and error +> strings verbatim)."* +> +> 🌍 **About platforms**: the SOPs were honed on Windows, but cross-platform adaptation is +> itself a GA task — on macOS/Linux, GA swaps in the platform equivalents (window +> enumeration, input control, screenshots) on its own. Same self-evolution principle. --- @@ -298,6 +307,7 @@ This is what fundamentally distinguishes GenericAgent from every other agent fra | What you say | First time | Every time after | | :--- | :--- | :--- | | *"Read my WeChat messages"* | Install deps → reverse DB → write read script → save Skill | **one-line invoke** | +| *"Give me a morning digest of Hacker News"* | Write scraper → build digest → schedule daily run → save Skill | **one-line invoke** | | *"Monitor stocks and alert me"* | Install `mootdx` → build selection flow → configure cron → save Skill | **one-line start** | | *"Send this file via Gmail"* | Configure OAuth → write send script → save Skill | **ready to use** | @@ -347,6 +357,21 @@ Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and +### Browser Realness of GA Web Tools (TMWebdriver) + +GA web tools are powered by **TMWebdriver** — a local WebSocket server plus a Chrome extension — running through a **real, persistent Chrome/Chromium session** rather than a disposable headless sandbox, preserving cookies, login state, extensions, GPU/WebGL behavior, and normal browser-session fingerprints. + +| Detection Service / Signal | Vanilla Headless Automation | GA Web Tools | Notes | +| :--- | :---: | :---: | :--- | +| SannySoft headless test | Often detected | ✅ 56/56 passed | `bot.sannysoft.com` | +| bot.incolumitas.com | Commonly fails webdriver / CDP checks | ✅ 36/36 passed | `WEBDRIVER`, `SELENIUM_DRIVER`, `webDriverAdvanced` all OK | +| BrowserScan bot detection | Often abnormal | ✅ Normal | `browserscan.net` | +| Device & Browser Info bot test | Multiple bot flags | ✅ Human / `isBot=false` | `deviceandbrowserinfo.com` | +| FingerprintJS bot detection demo | Often detected | ✅ Passed | Demo flow completed without bot verdict | +| reCAPTCHA v3 demo | Low bot-like score | ✅ 0.9 human-like score | Score-based risk signal; 0.9 is above typical production thresholds | + +For reCAPTCHA v3, `0.9` is not a "checkbox solved" result; it is the high-confidence human-like score returned by the risk model, typically sufficient to avoid extra challenges in production flows. + --- ## 📅 Roadmap & News @@ -361,9 +386,9 @@ Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and - **2026-04-21** — 📄 [**Technical Report on arXiv**](https://arxiv.org/abs/2604.17091) — *GenericAgent: A Token-Efficient Self-Evolving LLM Agent via Contextual Information Density Maximization*. - **2026-04-11** — Introduced **L4 session archive memory** and scheduler cron integration. - **2026-03-23** — Personal WeChat supported as a bot frontend. -- **2026-03-10** — [Released million-scale Skill Library](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7). -- **2026-03-08** — [Released "Dintal Claw" — a GenericAgent-powered government-affairs bot](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg). -- **2026-03-01** — [Featured by Jiqizhixin (机器之心)](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg). +- **2026-03-10** — [Released million-scale Skill Library](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7) *(Chinese)*. +- **2026-03-08** — [Released "Dintal Claw" — a GenericAgent-powered government-affairs bot](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg) *(Chinese)*. +- **2026-03-01** — [Featured by Jiqizhixin (机器之心)](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg) *(Chinese)*. - **2026-01-16** — GenericAgent **V1.0** public release. --- @@ -372,16 +397,6 @@ Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and If this project helped you, please consider leaving a **Star!** 🙏 -You're also welcome to join the **GenericAgent Community Group** for discussion, feedback, and co-building 👏 - -
- - - - -
WeChat Group 20
WeChat Group 20 QR
-
- ### 🚩 Friendly Links Thanks to the **LinuxDo** community for the support! @@ -393,6 +408,7 @@ Thanks to the **LinuxDo** community for the support! - [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager) - [wangjc683/galley](https://github.com/wangjc683/galley) — Out-of-the-box local agent workbench with a bundled GA runtime (CPython 3.11 + deps), native GUI/CLI, multi-session + Project orchestration, local-first. - [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench) +- [Fwind43/GenericAgent-Admin](https://github.com/Fwind43/GenericAgent-Admin) — Go + React desktop admin panel: service lifecycle management, native chat, Goal mode, BBS team board, file editor, model config wizard, TMWebDriver monitor, self-update, and Windows tray/desktop-pet integration. --- @@ -400,7 +416,7 @@ Thanks to the **LinuxDo** community for the support! Distributed under the **MIT License**. See [`LICENSE`](LICENSE) for full text. -> *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.* +> *Disclaimer: The official GenericAgent channels are this GitHub repository and https://gaagent.ai. DintalClaw is currently the only officially authorized commercial partner; any other third-party website, organization, or individual using the GenericAgent name is not official unless explicitly listed here.* --- @@ -516,11 +532,10 @@ powershell -ExecutionPolicy Bypass -c "irm http://fudankw.cn:9000/files/ga_insta curl -fsSL http://fudankw.cn:9000/files/ga_install.sh | bash ``` -安装完成后,双击启动: +安装完成后启动: -```text -frontends/GenericAgent.exe -``` +- **Windows** — 双击 `frontends/GenericAgent.exe` +- **Linux / macOS** — 在安装目录运行 `python launch.pyw` #### 方法二 — Python 安装 *(开发者)* @@ -547,7 +562,7 @@ python launch.pyw #### 桌面端 -一键安装自带桌面端,双击: +一键安装自带桌面端(Windows),双击: ```text frontends/GenericAgent.exe @@ -579,11 +594,12 @@ python launch.pyw ### Bot 接口(IM) -GenericAgent 支持 Telegram、微信、QQ、飞书 / Lark、企业微信、钉钉等 IM 前端。 +GenericAgent 支持 Telegram、Discord、微信、QQ、飞书 / Lark、企业微信、钉钉等 IM 前端。 | 平台 | 启动命令 | | :--- | :--- | | Telegram | `python frontends/tgapp.py` | +| Discord | `python frontends/dcapp.py` | | 微信 | `python frontends/wechatapp.py` | | QQ | `python frontends/qqapp.py` | | 飞书 / Lark | `python frontends/fsapp.py` | @@ -592,14 +608,6 @@ GenericAgent 支持 Telegram、微信、QQ、飞书 / Lark、企业微信、钉 > 详细配置直接问 GenericAgent。 -### 通用聊天命令 - -| 命令 | 说明 | -| :--- | :--- | -| `/new` | 开启新对话并清空当前上下文 | -| `/continue` | 列出可恢复会话快照 | -| `/continue N` | 恢复第 `N` 个可恢复会话 | - --- ## 🧠 架构设计 @@ -721,6 +729,21 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环** +### GA Web 工具的浏览器真实性 + +GA Web 工具运行在**真实、持久化的 Chrome/Chromium 会话**中,而不是一次性的 headless 沙箱,因此可以保留 Cookie、登录态、扩展、GPU/WebGL 行为以及正常浏览器会话指纹。 + +| 检测服务 / 信号 | 普通 Headless 自动化 | GA Web 工具 | 说明 | +| :--- | :---: | :---: | :--- | +| SannySoft headless test | 常被识别 | ✅ 56/56 通过 | `bot.sannysoft.com` | +| bot.incolumitas.com | 常在 webdriver / CDP 项异常 | ✅ 36/36 通过 | `WEBDRIVER`、`SELENIUM_DRIVER`、`webDriverAdvanced` 全部 OK | +| BrowserScan bot detection | 常显示异常 | ✅ Normal | `browserscan.net` | +| Device & Browser Info bot test | 多个 bot 标记 | ✅ Human / `isBot=false` | `deviceandbrowserinfo.com` | +| FingerprintJS bot detection demo | 常被识别 | ✅ 通过 | Demo 流程完成,未给出 bot 判定 | +| reCAPTCHA v3 demo | 低分 / bot-like | ✅ 0.9 真人相似分 | v3 是基于分数的风险信号;0.9 高于常见生产阈值 | + +对于 reCAPTCHA v3,`0.9` 不是“点过验证码”的结果,而是风控模型返回的高置信真人相似分,通常足以通过生产环境中的常见阈值,避免进入更严格挑战。 + --- ## 📅 路线图与最新动态 @@ -751,7 +774,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
- +
微信群 20
微信群 20 二维码
微信群 21
微信群 21 二维码
@@ -767,6 +790,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环** - [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager) - [wangjc683/galley](https://github.com/wangjc683/galley) —— 开箱即用的本地 Agent 工作台,自带 GA 内核(内置 CPython 3.11 + 运行依赖),GUI/CLI 双原生、多 session + Project 编排、本地优先。 - [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench) +- [Fwind43/GenericAgent-Admin](https://github.com/Fwind43/GenericAgent-Admin) —— Go + React 桌面管理面板:服务生命周期管理、原生 Chat、Goal 模式、BBS 团队看板、文件编辑器、模型配置向导、TMWebDriver 监控、自更新,以及 Windows 托盘/桌面宠物集成。 --- @@ -774,7 +798,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环** 基于 **MIT License** 发布,详见 [`LICENSE`](LICENSE)。 -> *声明:本项目未构建任何商业站点;除 DintalClaw 外,目前未官方授权任何机构、组织或个人以 GenericAgent 名义从事商业活动。* +> *声明:GenericAgent 官方渠道为本 GitHub 仓库和 https://gaagent.ai。DintalClaw 是目前唯一官方授权的商业合作方;除非在此处明确列出,其他使用 GenericAgent 名义的第三方网站、机构、组织或个人均非官方。* --- diff --git a/TMWebDriver.py b/TMWebDriver.py index b2c4f7d6..c14535a1 100644 --- a/TMWebDriver.py +++ b/TMWebDriver.py @@ -93,12 +93,11 @@ class TMWebDriver: session_id = data.get('sessionId') code = data.get('code') timeout = float(data.get('timeout', 10.0)) - try: - result = self.execute_js(code, timeout=timeout, session_id=session_id) - print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' ')) - return json.dumps({'r': result}, ensure_ascii=False) - except Exception as e: - return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False) + try: result = self.execute_js(code, timeout=timeout, session_id=session_id) + except Exception as e: return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False) + try: print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' ')) + except Exception: pass + return json.dumps({'r': result}, ensure_ascii=False) return 'ok' def run(): from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler @@ -245,7 +244,7 @@ class TMWebDriver: def _remote_cmd(self, cmd): try: return requests.post(self.remote, headers={"Content-Type": "application/json"}, json=cmd, timeout=30).json() except (ConnectionError, requests.exceptions.ConnectionError): - raise ConnectionError("TMWebDriver master未运行,看tmwebdriver_sop启动master") + raise ConnectionError("TMWebDriver master未运行,看tmwebdriver_sop后台启动一个TMWebDriver") def get_all_sessions(self): if self.is_remote: diff --git a/agentmain.py b/agentmain.py index 4e06285d..08b78e09 100644 --- a/agentmain.py +++ b/agentmain.py @@ -42,6 +42,10 @@ def get_system_prompt(): prompt += get_global_memory() return prompt +# SDK: +# agent = GenericAgent(); threading.Thread(target=agent.run, daemon=True).start() +# output1_queue = agent.put_task(prompt1) +# output2_queue = agent.put_task(prompt2) class GenericAgent: def __init__(self): os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True) @@ -50,12 +54,13 @@ class GenericAgent: self.history = []; self.handler = None; self.task_queue = queue.Queue() self.is_running = False; self.stop_sig = False; self.llm_no = 0; - self.inc_out = False; self.verbose = True; self.show_mode = 'text' + self.inc_out = False; self.verbose = True self.peer_hint = True self.force_non_stream = False logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}' self.log_path = os.path.join(script_dir, f'temp/model_responses/model_responses_{logid}.txt') self.load_llm_sessions() + self.extra_sys_prompts = [] def load_llm_sessions(self): mykeys, changed = reload_mykeys() @@ -136,14 +141,13 @@ class GenericAgent: if raw_query is None: self.task_queue.task_done(); continue self.is_running = True - if len(raw_query) > 1500: + if len(raw_query) > 2000: task_file = os.path.join(script_dir, 'temp', f'user_prompt_{int(time.time())}.md') with open(task_file, 'w', encoding='utf-8') as f: f.write(raw_query) raw_query = f'Long user prompt saved to {task_file}. Read and execute.' rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200) self.history.append(f"[USER]: {rquery}") - - sys_prompt = get_system_prompt() + getattr(self.llmclient.backend, 'extra_sys_prompt', '') + sys_prompt = get_system_prompt() + '\n'.join(self.extra_sys_prompts) + getattr(self.llmclient.backend, 'extra_sys_prompt', '') if self.peer_hint: sys_prompt += f"\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\n" handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp')) if getattr(self, 'no_print', False): handler.print = lambda *a, **k: None @@ -158,7 +162,7 @@ class GenericAgent: self.llmclient.backend.stream = False self.llmclient.backend.read_timeout = max(self.llmclient.backend.read_timeout, 1200) gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query, handler, TOOLS_SCHEMA, - max_turns=80, verbose=self.verbose, yield_info=True) + max_turns=180, verbose=self.verbose, yield_info=True) try: full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = [] for chunk in gen: @@ -187,13 +191,13 @@ class GenericAgent: self.task_queue.task_done() if self.handler is not None: self.handler.code_stop_signal.append(1) -GeneraticAgent = GenericAgent +GeneraticAgent = GenericAgent if __name__ == '__main__': import argparse from datetime import datetime parser = argparse.ArgumentParser() - parser.add_argument('--task', metavar='IODIR', help='一次性任务模式(文件IO)') + parser.add_argument('--task', metavar='IODIR', help='一次性任务模式,先看subagent.md') parser.add_argument('--reflect', metavar='SCRIPT', help='反射模式:加载监控脚本,check()触发时发任务') parser.add_argument('--input', help='prompt') parser.add_argument('--llm_no', type=int, default=0) @@ -260,24 +264,24 @@ if __name__ == '__main__': except Exception as e: print(f'[Reflect] check() error: {e}'); task = None if task and task == '/exit': break - if not task: - time.sleep(getattr(mod, 'INTERVAL', 5)); continue - print(f'[Reflect] triggered: {task[:80]}') - dq = agent.put_task(task, source='reflect') - try: - while 'done' not in (item := dq.get(timeout=1200)): pass - result = item['done'] - print(result) - except Exception as e: - if getattr(mod, 'ONCE', False): raise - print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}' - log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True) - script_name = os.path.splitext(os.path.basename(args.reflect))[0] - 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') - if (on_done := getattr(mod, 'on_done', None)): - try: on_done(result) - except Exception as e: print(f'[Reflect] on_done error: {e}') - if getattr(mod, 'ONCE', False): print('[Reflect] ONCE=True, exiting.'); break + if task: + print(f'[Reflect] triggered: {task[:80]}') + dq = agent.put_task(task, source='reflect') + try: + while 'done' not in (item := dq.get(timeout=1200)): pass + result = item['done'] + print(result) + except Exception as e: + if getattr(mod, 'ONCE', False): raise + print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}' + log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True) + script_name = os.path.splitext(os.path.basename(args.reflect))[0] + 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') + if (on_done := getattr(mod, 'on_done', None)): + try: on_done(result) + except Exception as e: print(f'[Reflect] on_done error: {e}') + if getattr(mod, 'ONCE', False): print('[Reflect] ONCE=True, exiting.'); break + time.sleep(getattr(mod, 'INTERVAL', 5)) else: try: import readline except Exception: pass diff --git a/assets/configure_mykey.py b/assets/configure_mykey.py index 39d93414..b9ed19d7 100644 --- a/assets/configure_mykey.py +++ b/assets/configure_mykey.py @@ -29,38 +29,62 @@ MYKPY_PATH = os.path.join(PROJECT_ROOT, 'mykey.py') # ── 模型厂商定义 ─────────────────────────────────────────────────────────── LLM_PROVIDERS = [ - # ═══════════════════════════ 直连 API(按旗舰能力降序)═══════════════════════════ + # ═══════════════════════════ 通用协议(官方直连或任意兼容中转)═══════════════════════════ { - 'id': 'anthropic', - 'name': 'Anthropic Claude 官方直连', - 'desc': 'Claude Opus #1 最强模型,原生 tool 协议,sk-ant- 开头', - 'type': 'native_claude', - 'template': { - 'name': 'anthropic-direct', 'apikey': 'sk-ant-', - 'apibase': 'https://api.anthropic.com', 'model': 'claude-opus-4-7', - 'thinking_type': 'adaptive', 'max_tokens': 32768, 'temperature': 1, - }, - 'key_hint': '在 https://console.anthropic.com/ 获取', - 'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'], - }, - { - 'id': 'openai', - 'name': 'OpenAI GPT-5.5 / o 系列', - 'desc': 'GPT-5.5 旗舰,与 Claude Opus 同级', + 'id': 'oai_chat', + 'name': 'OpenAI Chat Completions 协议', + 'desc': '官方直连或任意 OAI 兼容中转/网关,自填 apibase(回车=OpenAI 官方)', 'type': 'native_oai', 'template': { - 'name': 'gpt-native', 'apikey': 'sk-', + 'name': 'gpt-native', 'apikey': 'sk-', 'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.5', 'api_mode': 'chat_completions', 'reasoning_effort': 'high', 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120, }, - 'key_hint': '在 https://platform.openai.com/api-keys 获取', + 'key_hint': '官方在 https://platform.openai.com/api-keys 获取;中转站填其提供的 Key', 'model_choices': ['gpt-5.5', 'gpt-5.4'], + 'extra_fields': [ + {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.openai.com/v1'}, + ], }, + { + 'id': 'oai_responses', + 'name': 'OpenAI Responses 协议', + 'desc': 'Responses API(o 系列/GPT-5.5 推荐端点),官方或兼容网关,自填 apibase', + 'type': 'native_oai', + 'template': { + 'name': 'gpt-responses', 'apikey': 'sk-', + 'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.5', + 'api_mode': 'responses', 'reasoning_effort': 'high', + 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120, + }, + 'key_hint': '官方在 https://platform.openai.com/api-keys 获取;中转站填其提供的 Key', + 'model_choices': ['gpt-5.5', 'gpt-5.4'], + 'extra_fields': [ + {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.openai.com/v1'}, + ], + }, + { + 'id': 'claude_messages', + 'name': 'Claude Messages 协议', + 'desc': 'Anthropic 官方直连或任意 Claude 兼容中转,自填 apibase(回车=官方)', + 'type': 'native_claude', + 'template': { + 'name': 'anthropic-direct', 'apikey': 'sk-ant-', + 'apibase': 'https://api.anthropic.com', 'model': 'claude-opus-4-7', + 'thinking_type': 'adaptive', 'max_tokens': 32768, 'temperature': 1, + }, + 'key_hint': '官方在 https://console.anthropic.com/ 获取;中转站填其提供的 Key', + 'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'], + 'extra_fields': [ + {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.anthropic.com'}, + ], + }, + # ═══════════════════════════ 直连 API(按旗舰能力降序)═══════════════════════════ { 'id': 'deepseek', 'name': 'DeepSeek (v4-Pro / Flash)', - 'desc': '最强开源模型,v4-Pro 旗舰 1M 上下文', + 'desc': '开源模型,v4-Pro 旗舰 1M 上下文', 'type': 'native_oai', 'template': { 'name': 'deepseek', 'apikey': 'sk-', @@ -73,7 +97,7 @@ LLM_PROVIDERS = [ { 'id': 'kimi', 'name': 'Kimi (k2.6 / k2.5) 双协议', - 'desc': '月之暗面,国内顶尖,支持 Anthropic 和 OAI 双协议', + 'desc': '月之暗面,支持 Anthropic 和 OAI 双协议', 'type': 'native_claude', 'template': { 'name': 'kimi', 'apikey': 'sk-kimi-', @@ -145,16 +169,16 @@ LLM_PROVIDERS = [ }, { 'id': 'minimax', - 'name': 'MiniMax M2.7 (双协议)', - 'desc': 'MiniMax M2.7,支持 Anthropic 和 OpenAI 双协议', + 'name': 'MiniMax M3 (双协议)', + 'desc': 'MiniMax M3,支持 Anthropic 和 OpenAI 双协议', 'type': 'native_claude', 'template': { 'name': 'minimax', 'apikey': 'eyJh...', 'apibase': 'https://api.minimaxi.com/anthropic', - 'model': 'MiniMax-M2.7', 'max_retries': 3, + 'model': 'MiniMax-M3', 'max_retries': 3, }, 'key_hint': '在 https://platform.minimaxi.com/user-center/basic-information 获取', - 'model_choices': ['MiniMax-M2.7', 'MiniMax-M2.5'], + 'model_choices': ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'], 'extra_fields': [ { 'key': '_protocol', 'label': '选择 API 协议', diff --git a/assets/demo/discord_hcaptcha_real_browser.gif b/assets/demo/discord_hcaptcha_real_browser.gif new file mode 100644 index 00000000..5630e8cd Binary files /dev/null and b/assets/demo/discord_hcaptcha_real_browser.gif differ diff --git a/assets/ga_install.ps1 b/assets/ga_install.ps1 new file mode 100644 index 00000000..5d5e1950 --- /dev/null +++ b/assets/ga_install.ps1 @@ -0,0 +1,577 @@ +#requires -version 5.1 + +<#! + +GenericAgent one-click portable deployer for Windows. + + + +Modes: + + Default/Mainland: download GenericAgent.zip + uv + PortableGit from user's VPS, set China PyPI mirror. + + GLOBAL=1: clone GenericAgent from GitHub; uv and PortableGit also come from GitHub releases; no PyPI mirror. + + + +Portable components are installed under \.portable: + + uv, Python installed by uv, PortableGit. + +#> + +param( + + [string]$InstallDir = "$env:USERPROFILE\GenericAgent", + + [string]$PythonVersion = "3.12", + + [switch]$Force + +) + + + +$ErrorActionPreference = "Stop" + + + +# Make Chinese output reliable in Windows PowerShell 5.1 and redirected logs. + +try { + + [Console]::InputEncoding = [System.Text.Encoding]::UTF8 + + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + + $OutputEncoding = [System.Text.Encoding]::UTF8 + +} catch { } + + + +$RepoUrl = "https://github.com/lsdefine/GenericAgent.git" + +$VpsBase = "http://47.101.182.29:9000" + +$GaZipUrl = "$VpsBase/files/GenericAgent.zip" + +$UvUrl = "$VpsBase/uv/uv-x86_64-pc-windows-msvc.zip" + +$GitUrl = "$VpsBase/files/PortableGit-2.54.0-64-bit.7z.exe" + +$Deps = @("requests>=2.28", "beautifulsoup4>=4.12", "bottle>=0.12", "simple-websocket-server>=0.4", "streamlit>=1.28") + +$MainlandIndex = "https://pypi.tuna.tsinghua.edu.cn/simple" + +$GlobalMode = ($env:GLOBAL -eq "1") + +if ($GlobalMode) { + # GLOBAL=1: fetch everything from GitHub; no mainland endpoints involved. + $UvUrl = "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip" + $GitUrl = "https://github.com/git-for-windows/git/releases/download/v2.54.0.windows.1/PortableGit-2.54.0-64-bit.7z.exe" +} + + + +$GaDir = [IO.Path]::GetFullPath($InstallDir) + +$PortableRoot = Join-Path $GaDir ".portable" + +$Bin = Join-Path $PortableRoot "bin" + +$Cache = Join-Path $PortableRoot "cache" + +$Tools = Join-Path $PortableRoot "tools" + +$UvZip = Join-Path $Cache "uv-x86_64-pc-windows-msvc.zip" + +$GaZip = Join-Path $Cache "GenericAgent.zip" + +$GitExeArchive = Join-Path $Cache "PortableGit-2.54.0-64-bit.7z.exe" + +$UvExtract = Join-Path $Cache "uv-extract" + +$GaExtract = Join-Path $Cache "ga-extract" + +$GitDir = Join-Path $Tools "PortableGit" + +$UvExe = Join-Path $Bin "uv.exe" + +$GitExe = Join-Path $GitDir "bin\git.exe" + +$EnvCmd = Join-Path $GaDir "env.cmd" + +$EnvPs1 = Join-Path $GaDir "env.ps1" + + + +function Say($m) { Write-Host "[ga-deploy] $m" -ForegroundColor Cyan } + +function Ok($m) { Write-Host "[ok] $m" -ForegroundColor Green } + +function Die($m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 } + +function Invoke-Native([scriptblock]$Command) { + + $prevEAP = $ErrorActionPreference + + $ErrorActionPreference = 'Continue' + + try { & $Command } finally { $ErrorActionPreference = $prevEAP } + + return $LASTEXITCODE + +} + + + +function Download-File($Url, $OutFile) { + + New-Item -ItemType Directory -Force -Path (Split-Path $OutFile) | Out-Null + + Say "Downloading $Url" + + $wc = New-Object System.Net.WebClient + + $wc.Headers.Add("User-Agent", "Mozilla/5.0 ga-deploy") + + try { $wc.DownloadFile($Url, $OutFile) } finally { $wc.Dispose() } + + if (!(Test-Path $OutFile) -or ((Get-Item $OutFile).Length -lt 1024)) { Die "Download failed: $Url" } + +} + + + +function Expand-ZipClean($Zip, $Dest) { + + if (Test-Path $Dest) { Remove-Item -Recurse -Force $Dest } + + New-Item -ItemType Directory -Force -Path $Dest | Out-Null + + Expand-Archive -Path $Zip -DestinationPath $Dest -Force + +} + + + +function Copy-DirectoryContents($Src, $Dst) { + + New-Item -ItemType Directory -Force -Path $Dst | Out-Null + + Get-ChildItem -LiteralPath $Src -Force | ForEach-Object { + + Copy-Item -LiteralPath $_.FullName -Destination $Dst -Recurse -Force + + } + +} + + + +Say "Install dir: $GaDir" + +Say "Mode: $(if ($GlobalMode) { 'GLOBAL=1 / GitHub clone' } else { 'Mainland / VPS zip' })" + + + +if ((Test-Path $GaDir) -and $Force) { Remove-Item -Recurse -Force $GaDir } + +New-Item -ItemType Directory -Force -Path $GaDir,$PortableRoot,$Bin,$Cache,$Tools | Out-Null + + + +# uv (GitHub release in GLOBAL mode, user's VPS otherwise) + +if (!(Test-Path $UvExe) -or $Force) { + + Download-File $UvUrl $UvZip + + Expand-ZipClean $UvZip $UvExtract + + $foundUv = Get-ChildItem -Path $UvExtract -Recurse -Filter "uv.exe" | Select-Object -First 1 + + if (!$foundUv) { Die "uv.exe not found in archive" } + + Copy-Item $foundUv.FullName $UvExe -Force + +} + +Ok "uv: $(& $UvExe --version)" + + + +# Configure portable Python location. Mirror only in mainland mode. + +$env:UV_PYTHON_INSTALL_DIR = Join-Path $PortableRoot "uv-python" + +$env:UV_CACHE_DIR = Join-Path $PortableRoot "uv-cache" + +if ($GlobalMode) { + + Remove-Item Env:UV_DEFAULT_INDEX -ErrorAction SilentlyContinue + + Remove-Item Env:PIP_INDEX_URL -ErrorAction SilentlyContinue + +} else { + + $env:UV_DEFAULT_INDEX = $MainlandIndex + + $env:PIP_INDEX_URL = $MainlandIndex + +} + +$env:PATH = "$Bin;$env:PATH" + + + + +# Workaround: uv creates minor-version symlinks (junctions) in UV_PYTHON_INSTALL_DIR. +# If a previous interrupted install left a plain directory, uv fails with os error 4390. +# Fix: remove any non-junction subdirectory so uv can recreate them cleanly. +$uvPyDir = $env:UV_PYTHON_INSTALL_DIR +if (Test-Path $uvPyDir) { + Get-ChildItem -LiteralPath $uvPyDir -Directory | ForEach-Object { + $attr = $_.Attributes + if (($attr -band [IO.FileAttributes]::ReparsePoint) -eq 0) { + Say "Removing stale non-junction dir: $($_.Name)" + Remove-Item -LiteralPath $_.FullName -Recurse -Force + } + } +} + +Say "Installing Python $PythonVersion via uv" + +$ec = Invoke-Native { & $UvExe python install $PythonVersion } + +if ($ec -ne 0) { Die "uv python install failed" } + +$PythonExe = (& $UvExe python find $PythonVersion).Trim() + +if (!(Test-Path $PythonExe)) { Die "uv installed Python but python.exe was not found" } + +Ok "Python: $(& $PythonExe --version)" + + + +# PortableGit (GitHub release in GLOBAL mode, user's VPS otherwise). Needed for GLOBAL=1 and useful for user shell. + +if (!(Test-Path $GitExe) -or $Force) { + + Download-File $GitUrl $GitExeArchive + + if (Test-Path $GitDir) { Remove-Item -Recurse -Force $GitDir } + + New-Item -ItemType Directory -Force -Path $GitDir | Out-Null + + Say "Extracting PortableGit" + + $ec = Invoke-Native { & $GitExeArchive -y -o"$GitDir" | Out-Null } + + if ($ec -ne 0) { Die "PortableGit extraction failed" } + +} + +if (!(Test-Path $GitExe)) { Die "git.exe missing: $GitExe" } + +Ok "Git: $(& $GitExe --version)" + + + +$PythonDir = Split-Path $PythonExe -Parent + +$GitBin = Split-Path $GitExe -Parent + +$GitUsrBin = Join-Path $GitDir "usr\bin" + +$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;$env:PATH" + + + +# Fetch/update GenericAgent source. + +if ($GlobalMode) { + + Say "Cloning GenericAgent from GitHub" + + $items = @(Get-ChildItem -LiteralPath $GaDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ".portable" }) + + if ($items.Count -gt 0) { + + if (!$Force) { Die "Install dir contains files. Re-run with -Force to replace source while preserving portable tools." } + + $items | Remove-Item -Recurse -Force + + } + + $TmpClone = Join-Path $Cache "ga-clone" + + if (Test-Path $TmpClone) { Remove-Item -Recurse -Force $TmpClone } + + $ec = Invoke-Native { & $GitExe clone --depth 1 $RepoUrl $TmpClone } + + if ($ec -ne 0) { Die "git clone failed" } + + Copy-DirectoryContents $TmpClone $GaDir + + Remove-Item -Recurse -Force $TmpClone + +} else { + + Say "Downloading GenericAgent package from VPS" + + Download-File $GaZipUrl $GaZip + + Expand-ZipClean $GaZip $GaExtract + + $SrcDir = Join-Path $GaExtract "GenericAgent" + + if (!(Test-Path $SrcDir)) { $SrcDir = $GaExtract } + + $items = @(Get-ChildItem -LiteralPath $GaDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ".portable" }) + + if ($items.Count -gt 0) { $items | Remove-Item -Recurse -Force } + + Copy-DirectoryContents $SrcDir $GaDir + +} + +Ok "GenericAgent source ready: $GaDir" + + + +# Install basic dependencies and project in editable mode into portable Python. + +Say "Installing GenericAgent dependencies via uv pip" + +$installArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe) + +if (!$GlobalMode) { $installArgs += @("--index-url", $MainlandIndex) } + +$installArgs += $Deps + +$ec = Invoke-Native { & $UvExe @installArgs } + +if ($ec -ne 0) { Die "dependency install failed" } + + + +if (Test-Path (Join-Path $GaDir "pyproject.toml")) { + + $projectArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe) + + if (!$GlobalMode) { $projectArgs += @("--index-url", $MainlandIndex) } + + $projectArgs += @("-e", $GaDir) + + $ec = Invoke-Native { & $UvExe @projectArgs } + + if ($ec -ne 0) { Die "editable project install failed" } + +} + + + +# Try-install pywebview (optional UI). Failure is non-fatal. + +Say "Attempting to install pywebview (optional, failure is OK)" + +$webviewArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe) + +if (!$GlobalMode) { $webviewArgs += @("--index-url", $MainlandIndex) } + +$webviewArgs += @("pywebview>=4.0") + +$ec = Invoke-Native { & $UvExe @webviewArgs 2>&1 | Out-Null } + +if ($ec -ne 0) { + + Write-Host "[warn] pywebview install failed. This is optional." -ForegroundColor Yellow + + Write-Host " On Windows it usually works out of the box." -ForegroundColor Yellow + + Write-Host " If needed later: uv pip install pywebview" -ForegroundColor Yellow + +} else { + + Ok "pywebview installed successfully" + +} + + + +# Activation scripts: portable paths are intentionally before system PATH. + +if ($GlobalMode) { + +@" + +@echo off + +set "PORTABLE_DEV_ROOT=$PortableRoot" + +set "GENERICAGENT_HOME=$GaDir" + +set "UV_PYTHON_INSTALL_DIR=$PortableRoot\uv-python" + +set "UV_CACHE_DIR=$PortableRoot\uv-cache" + +set "PATH=$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;%PATH%" + +echo Activated GenericAgent portable env: %GENERICAGENT_HOME% + +"@ | Set-Content -Path $EnvCmd -Encoding ASCII + + + +@" + +`$env:PORTABLE_DEV_ROOT = "$PortableRoot" + +`$env:GENERICAGENT_HOME = "$GaDir" + +`$env:UV_PYTHON_INSTALL_DIR = "$PortableRoot\uv-python" + +`$env:UV_CACHE_DIR = "$PortableRoot\uv-cache" + +`$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;`$env:PATH" + +Write-Host "Activated GenericAgent portable env: `$env:GENERICAGENT_HOME" -ForegroundColor Green + +"@ | Set-Content -Path $EnvPs1 -Encoding UTF8 + +} else { + +@" + +@echo off + +set "PORTABLE_DEV_ROOT=$PortableRoot" + +set "GENERICAGENT_HOME=$GaDir" + +set "UV_PYTHON_INSTALL_DIR=$PortableRoot\uv-python" + +set "UV_CACHE_DIR=$PortableRoot\uv-cache" + +set "UV_DEFAULT_INDEX=$MainlandIndex" + +set "PIP_INDEX_URL=$MainlandIndex" + +set "PATH=$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;%PATH%" + +echo Activated GenericAgent portable env: %GENERICAGENT_HOME% + +"@ | Set-Content -Path $EnvCmd -Encoding ASCII + + + +@" + +`$env:PORTABLE_DEV_ROOT = "$PortableRoot" + +`$env:GENERICAGENT_HOME = "$GaDir" + +`$env:UV_PYTHON_INSTALL_DIR = "$PortableRoot\uv-python" + +`$env:UV_CACHE_DIR = "$PortableRoot\uv-cache" + +`$env:UV_DEFAULT_INDEX = "$MainlandIndex" + +`$env:PIP_INDEX_URL = "$MainlandIndex" + +`$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;`$env:PATH" + +Write-Host "Activated GenericAgent portable env: `$env:GENERICAGENT_HOME" -ForegroundColor Green + +"@ | Set-Content -Path $EnvPs1 -Encoding UTF8 + +} + + + +Ok "Verification:" + +& $UvExe --version + +& $PythonExe --version + +& $GitExe --version + +& $PythonExe -c "import requests, bs4, bottle; print('deps ok')" + +Write-Host "" + + + +# Copy mykey template if mykey.py does not exist (GLOBAL mode only) + +$MykeyDst = Join-Path $GaDir "mykey.py" + +if ($GlobalMode -and !(Test-Path $MykeyDst)) { + + $MykeyTpl = Join-Path $GaDir "mykey_template_en.py" + + if (Test-Path $MykeyTpl) { + + Copy-Item $MykeyTpl $MykeyDst + + Ok "Copied mykey_template_en.py -> mykey.py" + + } + +} + + + +# Final banner + +Write-Host "" + +if ($GlobalMode) { + + Write-Host @" + +╔═══════════════════════════════════════════════╗ + +║ ✅ GenericAgent installed successfully! ║ + +╠═══════════════════════════════════════════════╣ + +║ 📁 Location: $GaDir + +║ 🔑 Config: edit mykey.py (copied from template) + +║ 🚀 Launch: ga tui / ga launch / ga hub + +╚═══════════════════════════════════════════════╝ + +"@ + +} else { + + Write-Host @" + +╔═══════════════════════════════════════════════╗ + +║ [OK] GenericAgent 安装完成! ║ + +╠═══════════════════════════════════════════════╣ + +║ 安装目录: $GaDir + +║ 配置密钥: ga configure + +║ 启动: ga tui / ga launch / ga hub + +╚═══════════════════════════════════════════════╝ + +"@ + +} + +Write-Host "" + +Write-Host " Activate env: cmd.exe → call `"$EnvCmd`" | PowerShell → . `"$EnvPs1`"" + diff --git a/assets/ga_install.sh b/assets/ga_install.sh new file mode 100644 index 00000000..df6ad322 --- /dev/null +++ b/assets/ga_install.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +set -euo pipefail + +# GenericAgent one-click portable deployer for macOS/Linux. +# Modes: +# Default/Mainland: download GenericAgent.zip + uv from user's VPS, set China PyPI mirror. +# GLOBAL=1: clone GenericAgent from GitHub; uv also from GitHub releases; no PyPI mirror. +# Portable components are installed under /.portable: +# uv, Python installed by uv. On macOS/Linux git is expected from system package manager. + +INSTALL_DIR="${INSTALL_DIR:-$HOME/GenericAgent}" +PYTHON_VERSION="${PYTHON_VERSION:-3.12}" +FORCE="${FORCE:-0}" +GLOBAL="${GLOBAL:-0}" + +REPO_URL="https://github.com/lsdefine/GenericAgent.git" +VPS_BASE="http://47.101.182.29:9000" +GA_ZIP_URL="$VPS_BASE/files/GenericAgent.zip" +MAINLAND_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple" +DEPS=("requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "streamlit>=1.28") + +say(){ printf '\033[36m[ga-deploy]\033[0m %s\n' "$*"; } +ok(){ printf '\033[32m[ok]\033[0m %s\n' "$*"; } +die(){ printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; } + +usage(){ cat <<'EOF' +Usage: + bash install_portable_env.sh + INSTALL_DIR="$HOME/GenericAgent" PYTHON_VERSION=3.12 FORCE=1 bash install_portable_env.sh + GLOBAL=1 bash install_portable_env.sh + +Environment variables: + INSTALL_DIR Install GenericAgent here. Default: ~/GenericAgent + PYTHON_VERSION Python version installed by uv. Default: 3.12 + FORCE=1 Replace existing source files while preserving/reinstalling portable tools. + GLOBAL=1 Clone from GitHub directly and do not set China PyPI mirror. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage; exit 0; fi + +OS="$(uname -s)" +ARCH="$(uname -m)" +case "$OS:$ARCH" in + Darwin:x86_64) uv_file="uv-x86_64-apple-darwin.tar.gz" ;; + Darwin:arm64) uv_file="uv-aarch64-apple-darwin.tar.gz" ;; + Linux:x86_64) uv_file="uv-x86_64-unknown-linux-gnu.tar.gz" ;; + Linux:aarch64|Linux:arm64) uv_file="uv-aarch64-unknown-linux-gnu.tar.gz" ;; + *) die "Unsupported platform: $OS $ARCH" ;; +esac + +GA_DIR="${INSTALL_DIR/#\~/$HOME}" +case "$GA_DIR" in + /*) ;; + *) GA_DIR="$PWD/$GA_DIR" ;; +esac +mkdir -p "$GA_DIR" +GA_DIR="$(cd "$GA_DIR" && pwd -P)" +PORTABLE_ROOT="$GA_DIR/.portable" +BIN="$PORTABLE_ROOT/bin" +CACHE="$PORTABLE_ROOT/cache" +UV_TGZ="$CACHE/$uv_file" +GA_ZIP="$CACHE/GenericAgent.zip" +UV_EXTRACT="$CACHE/uv-extract" +GA_EXTRACT="$CACHE/ga-extract" +UV_EXE="$BIN/uv" +ENV_SH="$GA_DIR/env.sh" + +mkdir -p "$GA_DIR" "$PORTABLE_ROOT" "$BIN" "$CACHE" + +say "Install dir: $GA_DIR" +if [[ "$GLOBAL" == "1" ]]; then say "Mode: GLOBAL=1 / GitHub clone"; else say "Mode: Mainland / VPS zip"; fi + +if [[ "$FORCE" == "1" ]]; then + # Preserve .portable if present; remove source files later before deploying. + : +fi + +download(){ + local url="$1" out="$2" + mkdir -p "$(dirname "$out")" + say "Downloading $url" + if command -v curl >/dev/null 2>&1; then + curl -fL --retry 3 -A "ga-deploy" -o "$out" "$url" + elif command -v wget >/dev/null 2>&1; then + wget -O "$out" "$url" + else + die "curl or wget is required" + fi + [[ -s "$out" ]] || die "Download failed: $url" +} + +extract_tgz_clean(){ + local tgz="$1" dest="$2" + rm -rf "$dest"; mkdir -p "$dest" + tar -xzf "$tgz" -C "$dest" +} + +extract_zip_clean(){ + local zip="$1" dest="$2" + rm -rf "$dest"; mkdir -p "$dest" + if command -v unzip >/dev/null 2>&1; then + unzip -q "$zip" -d "$dest" + else + python3 - "$zip" "$dest" <<'PY' +import sys, zipfile +with zipfile.ZipFile(sys.argv[1]) as z: + z.extractall(sys.argv[2]) +PY + fi +} + +copy_contents(){ + local src="$1" dst="$2" + mkdir -p "$dst" + (cd "$src" && tar -cf - .) | (cd "$dst" && tar -xf -) +} + +remove_source_files(){ + shopt -s dotglob nullglob + for p in "$GA_DIR"/*; do + [[ "$(basename "$p")" == ".portable" ]] && continue + rm -rf "$p" + done + shopt -u dotglob nullglob +} + +# uv: GitHub release in GLOBAL mode, user's VPS otherwise +if [[ ! -x "$UV_EXE" || "$FORCE" == "1" ]]; then + if [[ "$GLOBAL" == "1" ]]; then + download "https://github.com/astral-sh/uv/releases/latest/download/$uv_file" "$UV_TGZ" + else + download "$VPS_BASE/uv/$uv_file" "$UV_TGZ" + fi + extract_tgz_clean "$UV_TGZ" "$UV_EXTRACT" + found_uv="$(find "$UV_EXTRACT" -type f -name uv | head -n 1 || true)" + [[ -n "$found_uv" ]] || die "uv not found in archive" + cp "$found_uv" "$UV_EXE" + chmod +x "$UV_EXE" +fi +ok "uv: $($UV_EXE --version)" + +export UV_PYTHON_INSTALL_DIR="$PORTABLE_ROOT/uv-python" +export UV_CACHE_DIR="$PORTABLE_ROOT/uv-cache" +if [[ "$GLOBAL" == "1" ]]; then + unset UV_DEFAULT_INDEX PIP_INDEX_URL +else + export UV_DEFAULT_INDEX="$MAINLAND_INDEX" + export PIP_INDEX_URL="$MAINLAND_INDEX" +fi +export PATH="$BIN:$PATH" + +say "Installing Python $PYTHON_VERSION via uv" +"$UV_EXE" python install "$PYTHON_VERSION" +PYTHON_EXE="$($UV_EXE python find "$PYTHON_VERSION")" +[[ -x "$PYTHON_EXE" ]] || die "uv installed Python but executable was not found" +ok "Python: $($PYTHON_EXE --version)" +PYTHON_DIR="$(dirname "$PYTHON_EXE")" +export PATH="$BIN:$PYTHON_DIR:$PATH" + +# git: macOS/Linux use system git. In mainland mode it is not required for source fetch. +GIT_EXE="" +if command -v git >/dev/null 2>&1; then + GIT_EXE="$(command -v git)" + ok "git: $($GIT_EXE --version)" +elif [[ "$GLOBAL" == "1" ]]; then + die "GLOBAL=1 requires git. Install git with your system package manager, then rerun." +else + say "git not found; continuing because mainland mode uses VPS zip. Install git later if needed." +fi + +# Fetch/update GenericAgent source. +if [[ "$GLOBAL" == "1" ]]; then + say "Cloning GenericAgent from GitHub" + if [[ -n "$(find "$GA_DIR" -mindepth 1 -maxdepth 1 ! -name .portable -print -quit)" ]]; then + [[ "$FORCE" == "1" ]] || die "Install dir contains files. Re-run with FORCE=1 to replace source while preserving portable tools." + remove_source_files + fi + TMP_CLONE="$CACHE/ga-clone" + rm -rf "$TMP_CLONE" + "$GIT_EXE" clone --depth 1 "$REPO_URL" "$TMP_CLONE" + copy_contents "$TMP_CLONE" "$GA_DIR" + rm -rf "$TMP_CLONE" +else + say "Downloading GenericAgent package from VPS" + download "$GA_ZIP_URL" "$GA_ZIP" + extract_zip_clean "$GA_ZIP" "$GA_EXTRACT" + SRC_DIR="$GA_EXTRACT/GenericAgent" + [[ -d "$SRC_DIR" ]] || SRC_DIR="$GA_EXTRACT" + remove_source_files + copy_contents "$SRC_DIR" "$GA_DIR" +fi +ok "GenericAgent source ready: $GA_DIR" + +# Install basic dependencies and project in editable mode into portable Python. +say "Installing GenericAgent dependencies via uv pip" +install_args=(pip install --python "$PYTHON_EXE" --break-system-packages) +if [[ "$GLOBAL" != "1" ]]; then install_args+=(--index-url "$MAINLAND_INDEX"); fi +install_args+=("${DEPS[@]}") +"$UV_EXE" "${install_args[@]}" + +if [[ -f "$GA_DIR/pyproject.toml" ]]; then + project_args=(pip install --python "$PYTHON_EXE" --break-system-packages) + if [[ "$GLOBAL" != "1" ]]; then project_args+=(--index-url "$MAINLAND_INDEX"); fi + project_args+=(-e "$GA_DIR") + "$UV_EXE" "${project_args[@]}" +fi + +# Try-install pywebview (optional UI). Failure is non-fatal. +say "Attempting to install pywebview (optional, failure is OK)" +webview_args=(pip install --python "$PYTHON_EXE" --break-system-packages) +if [[ "$GLOBAL" != "1" ]]; then webview_args+=(--index-url "$MAINLAND_INDEX"); fi +webview_args+=("pywebview>=4.0") +if "$UV_EXE" "${webview_args[@]}" 2>/dev/null; then + ok "pywebview installed successfully" +else + printf '\033[33m[warn]\033[0m pywebview install failed. This is optional.\n' + printf ' On Linux, pywebview requires system GTK/WebKit libraries.\n' + printf ' Install them first, e.g.:\n' + printf ' Debian/Ubuntu: sudo apt install python3-gi gir1.2-webkit2-4.1 libgirepository1.0-dev\n' + printf ' Fedora: sudo dnf install python3-gobject webkit2gtk4.1\n' + printf ' macOS: usually works out of the box (uses PyObjC)\n' + printf ' Then retry: uv pip install pywebview\n' +fi + +# Activation script: portable paths are intentionally before system PATH. +if [[ "$GLOBAL" == "1" ]]; then + cat > "$ENV_SH" < "$ENV_SH" < mykey.py" + fi +fi + +# Final banner +echo "" +if [[ "$GLOBAL" == "1" ]]; then + cat <用户要求code_run helloworld并评价工具配置\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\n\n[USER]: code_run个helloworld,根据结果简单评价你的工具配置\n\nCurrent turn: 1\n"}]},{"role":"assistant","content":[{"type":"text","text":"\n\ncode_run helloworld成功,评价工具配置\n\n工具配置没问题。`code_run` 返回了结构化的 JSON(`status`、`stdout`、`exit_code`),执行链路通畅,cwd 默认落在 temp 目录。\n\n刚才还验证了 `inline_eval` 能正确访问 agent 内部状态并导出 history,`file_patch` 也能精准改文件。整套工具链从代码执行、文件读写、到 agent 内省都是通的。"}]}] \ No newline at end of file diff --git a/assets/tools_schema.json b/assets/tools_schema.json index 588e76c2..01078937 100644 --- a/assets/tools_schema.json +++ b/assets/tools_schema.json @@ -16,7 +16,6 @@ "path": {"type": "string", "description": "Relative or absolute"}, "start": {"type": "integer", "description": "Start line number (1-based)"}, "count": {"type": "integer", "description": "Number of lines to read", "default": 200}, - "keyword": {"type": "string", "description": "[Optional] If provided, returns first match (case-insensitive) with context"}, "show_linenos": {"type": "boolean", "description": "Show line numbers", "default": true}}} }}, {"type": "function", "function": { diff --git a/assets/tools_schema_cn.json b/assets/tools_schema_cn.json index 3d942b6f..0b70328c 100644 --- a/assets/tools_schema_cn.json +++ b/assets/tools_schema_cn.json @@ -16,7 +16,6 @@ "path": {"type": "string", "description": "文件相对或绝对路径"}, "start": {"type": "integer", "description": "起始行号(从 1 开始)"}, "count": {"type": "integer", "description": "读取的行数", "default": 200}, - "keyword": {"type": "string", "description": "可选搜索关键字。如果提供,将返回第一个匹配项(忽略大小写)及其周边的内容"}, "show_linenos": {"type": "boolean", "description": "是否显示行号,建议开启以辅助 file_patch 定位", "default": true}}} }}, {"type": "function", "function": { diff --git a/frontends/at_complete.py b/frontends/at_complete.py new file mode 100644 index 00000000..3499652e --- /dev/null +++ b/frontends/at_complete.py @@ -0,0 +1,272 @@ +"""@ file completion — shared UI-less logic for tui_v2 / tui_v3. + +File index (os.scandir, cached per root) + fuzzy match + @token detection + +insert text. No UI deps; each front-end renders candidates its own way and +calls candidates_for(query, root). Index root is the front-end's choice +(session workspace, else CWD). Submit-time: completion-only does NOT read +content, but absolutize_mentions() rewrites @relative → @absolute so the +agent's file_read (relative to its own cwd) can locate the file. The +content-injecting auto-read variant lives in +temp/plan_v2_at_mention/autoread_version.py. +""" + +import os +import re +import threading + +# ---------------------------------------------------------------- index + +_IGNORE_DIRS = { + ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", + ".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build", + ".next", ".idea", ".vscode", "target", ".cache", ".eggs", + "model_responses", # GA 会话日志(上千个 .txt),未绑时根=temp 会淹没 @ 候选 +} +_IGNORE_EXT = {".pyc", ".pyo", ".so", ".o", ".class", ".lock", ".dll", ".exe"} +_MAX_FILES = 50_000 # 超大目录宁缺毋卡:到上限就停 + + +def scan_files(root: str, max_files: int = _MAX_FILES) -> list[str]: + """Collect relative file paths under root, '/'-normalized. + + os.scandir over os.walk: one syscall yields is_dir without an extra + stat per entry. Dotted dirs are skipped wholesale (.git, .venv...). + """ + out: list[str] = [] + stack = [root] + while stack and len(out) < max_files: + d = stack.pop() + try: + with os.scandir(d) as it: + for e in it: + try: + if e.is_dir(follow_symlinks=False): + if e.name not in _IGNORE_DIRS and not e.name.startswith("."): + stack.append(e.path) + elif e.is_file(follow_symlinks=False): + if os.path.splitext(e.name)[1].lower() not in _IGNORE_EXT: + rel = os.path.relpath(e.path, root).replace("\\", "/") + out.append(rel) + if len(out) >= max_files: + return out + except OSError: + continue + except OSError: + continue + return out + + +class FileIndexCache: + """Per-root background file index. warm() is idempotent-cheap: a + rebuild is only started when none is in flight.""" + + def __init__(self, root: str): + self.root = root + self._files: list[str] = [] + self._lock = threading.Lock() + self._building = False + self.ready = threading.Event() + + def warm(self) -> None: + with self._lock: + if self._building: + return + self._building = True + + def _build(): + try: + files = scan_files(self.root) + with self._lock: + self._files = files + self.ready.set() + finally: + with self._lock: + self._building = False + + threading.Thread(target=_build, name="ga-at-index", daemon=True).start() + + def snapshot(self) -> list[str]: + with self._lock: + return self._files + + +_indexes: dict[str, FileIndexCache] = {} +_indexes_lk = threading.Lock() + + +def get_index(root: str) -> FileIndexCache: + key = os.path.normcase(os.path.realpath(root or os.getcwd())) + with _indexes_lk: + idx = _indexes.get(key) + if idx is None: + idx = _indexes[key] = FileIndexCache(root) + return idx + + +# ---------------------------------------------------------------- fuzzy + +def _subseq_score(q: str, path: str): + """Subsequence match score (higher = better), None when q doesn't + fully appear in order. Contiguous runs dominate (fzf-style): scattered + one-char hits across a long path must not beat a tight cluster. + Word-boundary hits and basename substring add on top; ties broken by + caller on shorter path.""" + if not q: + return 0 + score, qi, prev_hit = 0, 0, -2 + for pi, ch in enumerate(path): + if qi < len(q) and ch == q[qi]: + score += 1 + if pi == prev_hit + 1: + score += 2 # contiguous run: the dominant signal + if pi == 0 or path[pi - 1] in "/\\_-. ": + score += 3 + prev_hit = pi + qi += 1 + if qi < len(q): + return None + base = path.rsplit("/", 1)[-1] + if q in base: + score += 8 + elif q in path: + score += 4 + return score + + +def fuzzy_rank(query: str, files: list[str], limit: int = 10) -> list[str]: + q = query.lower() + if not q: + # bare `@`: surface shallow paths first for discoverability + return sorted(files, key=lambda f: (f.count("/"), f))[:limit] + scored = [] + for f in files: + s = _subseq_score(q, f.lower()) + if s is not None: + scored.append((s, f)) + scored.sort(key=lambda x: (-x[0], len(x[1]), x[1])) + return [f for _, f in scored[:limit]] + + +# ------------------------------------------------------- edit-time token + +# `(?:^|\s)@` 前置:@ 前必须是行首或空白 → 邮箱/代码里的 a@b 不触发。 +# 字符集含路径分隔符与 ~ :,\w 在 unicode 下覆盖中文文件名。 +_AT_TOKEN_RE = re.compile(r"(?:^|\s)(@[\w\-./\\~:]*)$", re.UNICODE) + + +def find_at_token(line_before_cursor: str): + """Return (query, at_pos) when the cursor sits in an @token being + typed on this line, else None. at_pos is the index of '@'.""" + m = _AT_TOKEN_RE.search(line_before_cursor) + if not m: + return None + tok = m.group(1) + return tok[1:], m.start(1) + + +def format_pick(path: str) -> str: + """`@path` insert text; dirs get no trailing space (keep completing next + level), files get one (close token). Spaces → quoted.""" + trailing = '' if path.endswith(('/', '\\')) else ' ' + return f'@"{path}"{trailing}' if ' ' in path else f'@{path}{trailing}' + + +# --- path-like completion: an explicit-path @token (~/ / ./ ../ or C:\) goes +# to live directory completion instead of index fuzzy — this is how absolute +# paths outside the index root get completed level by level (claude-code parity). + +def is_path_like(token: str) -> bool: + if token in ('~', '.', '..'): + return True + if token.startswith(('~/', '~\\', './', '.\\', '../', '..\\', '/', '\\')): + return True + return len(token) >= 3 and token[0].isalpha() and token[1] == ':' and token[2] in '/\\' + + +def path_completions(token: str, root: str, limit: int = 15) -> list[str]: + """readdir the real dir of a path-like token, prefix-match, dirs first. + `~` expanded, relative → root, absolute as-is; candidates keep the token's + spelling, dirs carry a trailing '/'.""" + sep = max(token.rfind('/'), token.rfind('\\')) + if sep >= 0: + dir_part, prefix = token[:sep + 1], token[sep + 1:] + elif token in ('~', '.', '..'): + dir_part, prefix = token.rstrip('/\\') + '/', '' + else: + return [] + exp = os.path.expanduser(dir_part) + real_dir = exp if os.path.isabs(exp) else os.path.join(root, exp) + try: + with os.scandir(real_dir) as it: + entries = list(it) + except OSError: + return [] + pl = prefix.lower() + rows = [] + for e in entries: + nm = e.name + if pl and not nm.lower().startswith(pl): + continue + if nm.startswith('.') and not prefix.startswith('.'): # 隐藏项需显式 . 才出 + continue + try: + is_dir = e.is_dir() + except OSError: + is_dir = False + rows.append((not is_dir, nm.lower(), dir_part + nm + ('/' if is_dir else ''))) + rows.sort(key=lambda r: (r[0], r[1])) # 目录优先 + 字母序 + return [d for _, _, d in rows[:limit]] + + +def candidates_for(query: str, root: str, limit: int = 15, absolute: bool = False) -> list[str]: + """@token candidates: path-like → directory completion, else index fuzzy. + Single dispatch point shared by both front-ends. `absolute=True` returns + fuzzy hits as absolute paths (front-end shows full path when no workspace + is bound, since the relative root isn't obvious to the user).""" + if is_path_like(query): + return path_completions(query, root, limit) + idx = get_index(root) + files = idx.snapshot() + if not files: + idx.warm() # 惰性兜底:该根还没建索引 → 后台建(本次可能空,下次有) + res = fuzzy_rank(query, files, limit) if files else [] + if absolute: + res = [os.path.normpath(os.path.join(root, c)) for c in res] + return res + + +# ------------------------------------------------------ submit-time absolutize +# A fuzzy candidate inserts a path relative to the @ root (workspace/CWD), but +# the agent's file_read resolves relative to its own ./temp cwd — so a bare +# `@frontends/x.py` won't be found. At submit we rewrite each @mention naming a +# real file to an absolute path; display keeps the short form. Still no content +# read — this only completes the path so the agent can locate it. + +_AT_ABS_RE = re.compile(r'(^|\s)@("([^"]+)"|([\w\-./\\~:#]+))', re.UNICODE) +_LINE_SUFFIX_RE = re.compile(r'(#L\d+(?:-\d+)?)$') + + +def absolutize_mentions(text: str, root: str) -> str: + """@relative → @absolute (root-resolved, ~ expanded, quoted if it gains a + space), `#Lx-y` suffix kept. Only existing paths are rewritten; decorative + @words / typos pass through unchanged.""" + def repl(m): + lead, quoted, bare = m.group(1), m.group(3), m.group(4) + raw = quoted if quoted is not None else bare + trail = '' + if quoted is None: # strip trailing prose punctuation + stripped = raw.rstrip(',。,;;))]》>') + trail, raw = raw[len(stripped):], stripped + sm = _LINE_SUFFIX_RE.search(raw) + suffix = sm.group(1) if sm else '' + path = raw[:len(raw) - len(suffix)] if suffix else raw + if not path: + return m.group(0) + exp = os.path.expanduser(path) + absp = os.path.normpath(exp if os.path.isabs(exp) else os.path.join(root, exp)) + if not os.path.exists(absp): # decorative / typo → leave as-is + return m.group(0) + full = absp + suffix + token = f'@"{full}"' if ' ' in full else f'@{full}' + return lead + token + trail + return _AT_ABS_RE.sub(repl, text) diff --git a/frontends/conductor.html b/frontends/conductor.html index 89627439..5380d5c8 100644 --- a/frontends/conductor.html +++ b/frontends/conductor.html @@ -78,6 +78,8 @@ .hint { color: var(--muted); font-size: 12px; } .cards { + flex: 1; + min-height: 0; padding: 14px; overflow: auto; display: flex; @@ -91,6 +93,9 @@ background: rgba(31,42,68,.16); border-radius: 10px; } + .approvals-body::-webkit-scrollbar { width: 10px; } + .approvals-body::-webkit-scrollbar-track { background: rgba(31,42,68,.08); border-radius: 10px; } + .approvals-body::-webkit-scrollbar-thumb { background: rgba(31,42,68,.34); border-radius: 10px; border: 2px solid rgba(255,255,255,.65); } .empty { color: var(--muted); font-size: 13px; padding: 8px 4px; } /* ---------- subagent 卡片(可折叠) ---------- */ @@ -154,8 +159,8 @@ .reply pre { white-space: pre-wrap; } /* ---------- 任务奏折栏(无事则隐) ---------- */ - .approvals { display: none; } - .approvals.show { display: block; } + .approvals { display: none; min-height: 0; overflow: hidden; } + .approvals.show { display: flex; flex: 0 0 420px; height: 420px; max-height: 42vh; flex-direction: column; } .approvals .head { background: linear-gradient(180deg, rgba(245,158,11,.14), rgba(245,158,11,.05)); border-bottom: 1px solid rgba(245,158,11,.18); @@ -166,8 +171,9 @@ background: var(--amber); border-radius: 99px; padding: 2px 9px; box-shadow: 0 4px 12px rgba(245,158,11,.35); } - .approvals-body { padding: 13px; display: flex; flex-direction: column; gap: 11px; max-height: 38vh; overflow: auto; } + .approvals-body { flex: 1; min-height: 0; padding: 13px; display: flex; flex-direction: column; gap: 11px; overflow-y: scroll; overflow-x: hidden; scrollbar-gutter: stable; } .approval { + flex: 0 0 auto; border-radius: 16px; background: rgba(255,255,255,.8); border: 1px solid rgba(245,158,11,.25); box-shadow: 0 8px 22px rgba(245,158,11,.10); diff --git a/frontends/continue_cmd.py b/frontends/continue_cmd.py index f4d53370..a8dc394d 100644 --- a/frontends/continue_cmd.py +++ b/frontends/continue_cmd.py @@ -1,7 +1,7 @@ """`/continue` command: list & restore past model_responses sessions. Pure functions + one `install(cls)` monkey-patch entry. No side effects at import. """ -import ast, glob, json, os, re, time +import ast, atexit, glob, json, os, random, re, shutil, threading, time _LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp', 'model_responses') _LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt') @@ -36,7 +36,7 @@ def _first_user(pairs): if not isinstance(msg, dict): continue for blk in msg.get('content', []) or []: if isinstance(blk, dict) and blk.get('type') == 'text': - t = (blk.get('text') or '').strip() + t = strip_project_mode(blk.get('text') or '').strip() if t and '' not in t and not t.startswith('### [WORKING MEMORY]'): return t for p, _ in pairs[:1]: @@ -117,6 +117,7 @@ def _parse_native_history(pairs): history.append({'role': 'assistant', 'content': blocks}) return history + _PREVIEW_WIN = 32 * 1024 # Content-grep budget for `/continue` search box: read at most this many bytes @@ -293,12 +294,19 @@ def _rounds_for_file(path, st): return n, key -def list_sessions(exclude_pid=None): - """Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only.""" +def list_sessions(exclude_pid=None, exclude_log=None, rewind_root=None): + """Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only. + + `exclude_log` (basename, e.g. 'model_responses_123456.txt') drops the caller's + OWN current session — preferred over `exclude_pid`, which assumed the log file + was named by PID (it isn't: agentmain mints a random 6-digit logid), so the + pid tag never matched and the current session leaked into its own list.""" files = glob.glob(_LOG_GLOB) if exclude_pid is not None: tag = f'model_responses_{exclude_pid}.txt' files = [f for f in files if not f.endswith(tag)] + if exclude_log: + files = [f for f in files if os.path.basename(f) != exclude_log] out = [] valid_keys = [] for f in files: @@ -316,6 +324,43 @@ def list_sessions(exclude_pid=None): valid_keys.append(key) out.append((f, mtime, preview, rounds)) _save_rounds_cache(valid_keys) + # 【门控·worldline】树感知发现:日志空/缺失但有非空世界线树的会话(回退到起点后日志被 + # 清空 → 上面 sz<32 跳过了)。仅当调用方显式传 rewind_root 时启用 → 其他 UI 不传, + # 行为逐字节不变。只读 tree.json 的 nodes/head(不依赖 worldline 模块)。 + if rewind_root and os.path.isdir(rewind_root): + have = {os.path.basename(p) for p, *_ in out} + try: + keys = os.listdir(rewind_root) + except OSError: + keys = [] + for key in keys: + if not key.startswith('model_responses_'): + continue + log_name = key + '.txt' + if log_name in have or log_name == exclude_log: + continue + log_path = os.path.join(_LOG_DIR, log_name) + try: # 仅收"日志确实空/缺失"的(非空日志已被主循环收录) + if os.path.getsize(log_path) >= 32: + continue + except OSError: + pass # 缺失也算 + try: + with open(os.path.join(rewind_root, key, 'tree.json'), encoding='utf-8') as fh: + d = json.load(fh) + except Exception: + continue + nodes = d.get('nodes') or {} + real = [v for v in nodes.values() if v.get('kind') != 'origin'] + if not real: # 只有 origin 的空树 → 无内容,跳过 + continue + try: + mtime = os.path.getmtime(os.path.join(rewind_root, key, 'tree.json')) + except OSError: + mtime = 0 + head = d.get('head') + title = (nodes.get(head, {}).get('title') if head else '') or '(已回退至会话起点)' + out.append((log_path, mtime, f'[世界线] {title}', len(real))) out.sort(key=lambda x: x[1], reverse=True) return out _MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])') @@ -439,6 +484,17 @@ def handle(agent, query, display_queue): _INJECT_MARKERS = ('### [WORKING MEMORY]', '[SYSTEM TIPS]', '[SYSTEM]', '[System]', '[DANGER]', '### [总结提炼经验]') +# project_mode 插件把 `\n\n---\n[PROJECT MODE: ]\n…\n---` 追加在用户消息末尾 +# (见 plugins/project_mode._build_injection)。它会进日志,所以 /continue 重建 UI 时 +# 必须从显示文本里剔除,只留用户原话。不能加进 _INJECT_MARKERS——那会把整块(连用户 +# 原话)一起丢弃;这里只剜掉注入这一段后缀。 +_PM_BLOCK_RE = re.compile(r"\n*-{3,}\n\[PROJECT MODE:.*?\n-{3,}\s*$", re.DOTALL) + + +def strip_project_mode(text: str) -> str: + """剔除用户文本尾部的 project-mode 注入块。""" + return _PM_BLOCK_RE.sub("", text or "") + def _user_text(prompt_body): """User-typed text from a prompt JSON; '' if this is an agent auto-continuation. @@ -457,7 +513,7 @@ def _user_text(prompt_body): return '' for blk in blocks: if isinstance(blk, dict) and blk.get('type') == 'text': - t = (blk.get('text') or '').strip() + t = strip_project_mode(blk.get('text') or '').strip() if t and not any(mk in t for mk in _INJECT_MARKERS): return t return '' @@ -475,11 +531,22 @@ def _assistant_text(response_body): def _format_tool_use(block): - """Match agent_loop.py:72 verbose tool-call header.""" + """Match agent_loop.py:78 verbose tool-call header byte-for-byte. + + MUST use agent_loop's `get_pretty_json`, not a plain `json.dumps`: the + former rewrites a `script` arg's `"; "` into `";\\n "`, so for tools + carrying `script` (code_run, web_execute_js) a plain dumps produces a + *different* fence body. The TUI's write/read/code cards content-address + their captures by `hash(get_pretty_json(args))`; a mismatched fence here + means the hash misses and the card silently falls back to the raw block.""" name = block.get('name', '?') args = block.get('input', {}) - try: pretty = json.dumps(args, indent=2, ensure_ascii=False).replace('\\n', '\n') - except Exception: pretty = str(args) + try: + from agent_loop import get_pretty_json + pretty = get_pretty_json(args) + except Exception: + try: pretty = json.dumps(args, indent=2, ensure_ascii=False).replace('\\n', '\n') + except Exception: pretty = str(args) return f"🛠️ Tool: `{name}` 📥 args:\n````text\n{pretty}\n````\n" @@ -533,6 +600,165 @@ def _format_response_segment(response_body, tool_results): return '\n\n'.join(p for p in ['\n\n'.join(texts), '\n'.join(tool_parts)] if p) +_PLAN_ENTRY_RE = re.compile(r'enter_plan_mode\(\s*[\'"]([^\'"]+plan\.md)[\'"]') + + +def find_plan_entry(path): + """Last `enter_plan_mode("…plan.md")` call in a model_responses log. + + Plan mode has exactly one entry point (plan_sop.md): a `code_run` tool call + whose inline_eval script invokes `handler.enter_plan_mode(...)`. That call + survives in the log as a structured `tool_use` block — unlike a plan path + merely *mentioned* in chat text, it cannot be produced by the user typing + a filename. Scanning these blocks is therefore the restore criterion for + the plan card; the last match wins so re-entered plans track the newest. + + Returns the plan.md path string as written in the script, or None. + """ + try: + with open(path, encoding='utf-8', errors='replace') as f: + content = f.read() + except Exception: + return None + last = None + for _prompt, response in _pairs(content): + try: + blocks = ast.literal_eval(response) + except Exception: + continue + if not isinstance(blocks, list): + continue + for b in blocks: + if not (isinstance(b, dict) and b.get('type') == 'tool_use' + and b.get('name') == 'code_run'): + continue + m = _PLAN_ENTRY_RE.search(str((b.get('input') or {}).get('script') or '')) + if m: + last = m.group(1) + return last + + +def iter_write_captures(path): + """Replay a log's file_write/file_patch/file_read calls into capture dicts + the TUI can feed to its card renderers (`_WRITE_CAP`), keyed later by + hash(get_pretty_json). + + Live mode fills `_WRITE_CAP` from tool_before/tool_after hooks (with a real + pre-write disk snapshot); on /continue that history is gone, but the + structured `tool_use.input` survives in the log — clean, complete args. We + also track each path's content *within this session* so a file + written/patched several times shows real old→new diffs (not N× full "new + file"). Files first touched by an untracked on-disk state still fall back + to a full-content block. + + Returns write entries `{"name", "args", "existed", "old", "status", "msg"}` + and read entries `{"name", "args", "content"}` in call order. `status`/`msg` + come from the matching tool_result so the header can show ✗ on a failed + write; a read's `content` is the raw tool_result text (the read card strips + its LLM-facing chrome itself). + """ + try: + with open(path, encoding='utf-8', errors='replace') as f: + content = f.read() + except Exception: + return [] + pairs = _pairs(content) + # tool_use_id -> (status, msg) from any prompt's tool_result blocks (the + # result lands in the *next* round's Prompt as a tool_result whose content + # is the json-dumped outcome.data, e.g. {"status":"success","msg":...}). + # tr_raw keeps the undecoded text — a file_read result is plain text. + tr_status, tr_raw = {}, {} + for prompt, _ in pairs: + try: + msg_obj = json.loads(prompt) + except Exception: + continue + if not isinstance(msg_obj, dict): + continue + for blk in msg_obj.get('content', []) or []: + if not (isinstance(blk, dict) and blk.get('type') == 'tool_result'): + continue + tid = blk.get('tool_use_id') + c = blk.get('content') + if isinstance(c, list): + c = ''.join(b.get('text', '') for b in c + if isinstance(b, dict) and b.get('type') == 'text') + if tid and isinstance(c, str): + tr_raw[tid] = c + try: + d = json.loads(c) if isinstance(c, str) else None + except Exception: + d = None + if tid and isinstance(d, dict): + tr_status[tid] = (d.get('status'), str(d.get('msg') or '')) + + out, state = [], {} + for _prompt, response in pairs: + try: + blocks = ast.literal_eval(response) + except Exception: + continue + if not isinstance(blocks, list): + continue + for b in blocks: + if not (isinstance(b, dict) and b.get('type') == 'tool_use'): + continue + name = b.get('name') + if name not in ('file_write', 'file_patch', 'file_read', 'code_run'): + continue + args = b.get('input') or {} + p = args.get('path') + if name == 'file_read': + out.append({'name': name, 'args': args, + 'content': tr_raw.get(b.get('id'))}) + continue + if name == 'code_run': + # data = the tool_result text; a dict result is JSON, an + # inline_eval / code-missing result is plain text. Pass the + # parsed dict when possible so the card reads exit_code/stdout; + # else the raw string (the card handles both). + raw = tr_raw.get(b.get('id')) + d = raw + try: + parsed = json.loads(raw) if isinstance(raw, str) else None + if isinstance(parsed, dict): + d = parsed + except Exception: + pass + out.append({'name': name, 'args': args, 'data': d}) + continue + st, mg = tr_status.get(b.get('id'), (None, '')) + if name == 'file_patch': + # If this file's content is tracked within the session, pass it as + # the pre-write full file so the renderer can do a whole-file diff + # (real line numbers + context); else fall back to the fragment. + pre = state.get(p, '') + out.append({'name': name, 'args': args, + 'existed': p in state, 'old': pre, + 'status': st, 'msg': mg}) + if st == 'error': + continue # failed call left the disk untouched — don't book it + old = args.get('old_content') or '' + if p in state and old: + state[p] = state[p].replace(old, args.get('new_content') or '', 1) + else: # file_write + existed = p in state + old = state.get(p, '') + new = str(args.get('content') or '') + mode = str(args.get('mode') or 'overwrite') + out.append({'name': name, 'args': args, 'existed': existed, 'old': old, + 'status': st, 'msg': mg}) + if st == 'error': + continue # failed call left the disk untouched — don't book it + if mode == 'append': + state[p] = old + new + elif mode == 'prepend': + state[p] = new + old + else: + state[p] = new + return out + + def extract_ui_messages(path): """Parse a model_responses log into [{role, content}, ...] for UI replay. @@ -593,6 +819,286 @@ def handle_frontend_command(agent, query, exclude_pid=None): return msg +# =========================================================================== +# 原地复原(in-place continue)共享层 —— 仅供 TUI(tui_v2/tui_v3 及其 rewind 副本) +# 调用;其它前端(IM/qt/streamlit…)不调用这些函数,行为完全不受影响。 +# +# 模型:每个会话 = 一个 `model_responses_.txt`,身份就是文件本身。 +# · 原地续 X = 把 agent 的 log_path 指回 X,之后的轮次追加到 X 本身(同一会话延续)。 +# · 拷贝续 X = 铸一个新 logid、把 X 拷进去,在副本上续;X 原件不动(并发安全)。 +# · 切走/新对话 = 释放当前锁、旧日志原样留作"空闲会话"(不存快照、不清空),新对话铸新 logid。 +# +# 独占:每个 TUI 会话出生即持有自己日志的一把锁(`.locks/.lock`); +# 整进程共用一个心跳线程,每 ~5s touch 锁文件 mtime(无 fsync)。 +# 判活 = 锁 mtime 在 30s 内新鲜;超 30s 视为持锁者已死,可被接管。 +# 抢锁用原子 O_EXCL;锁基础设施任何故障都降级为"假定空闲、放行续接",绝不阻断 /continue。 +# =========================================================================== + +_LOCK_DIR = os.path.join(_LOG_DIR, '.locks') +_HB_INTERVAL = 5.0 # 心跳间隔(秒) +_STALE_AFTER = 30.0 # 超过这么久无心跳 → 持锁者视为已死,可接管 +_held_locks = set() # 本进程当前持有的 log_path 集合 +_hb_lock = threading.Lock() +_hb_thread = None + + +def _lock_path(log_path): + base = os.path.splitext(os.path.basename(log_path))[0] + return os.path.join(_LOCK_DIR, base + '.lock') + + +def _read_lock(lock_file): + try: + with open(lock_file, encoding='utf-8') as fh: + return json.load(fh) + except Exception: + return None + + +def _lock_fresh(lock_file): + """心跳新鲜度 = 锁文件 mtime 距今 < _STALE_AFTER。""" + try: + return (time.time() - os.path.getmtime(lock_file)) < _STALE_AFTER + except OSError: + return False + + +def session_occupant(log_path): + """若 `log_path` 正被一个活着的(心跳新鲜)进程持有,返回其 owner 元数据 dict; + 否则返回 None(空闲,或锁已过期可接管)。供 TUI 判断"原地 / 弹窗拷贝"。""" + lf = _lock_path(log_path) + meta = _read_lock(lf) + if meta is not None and _lock_fresh(lf): + return meta + return None + + +def acquire_lock(log_path, agent_id=None): + """尝试独占 `log_path`。成功(或锁设施故障降级)返回 True; + 仅当被另一活进程(心跳新鲜)持有时返回 False。""" + try: + os.makedirs(_LOCK_DIR, exist_ok=True) + lf = _lock_path(log_path) + meta = {'pid': os.getpid(), 'agent_id': agent_id, + 'log': os.path.basename(log_path), + 'started': time.time()} + blob = json.dumps(meta, ensure_ascii=False) + try: + fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, 'w', encoding='utf-8') as fh: + fh.write(blob) + except FileExistsError: + cur = _read_lock(lf) + if cur and cur.get('pid') != os.getpid() and _lock_fresh(lf): + return False # 被另一活进程持有 + # 过期锁 / 本进程自己的 → 接管(覆盖)。小竞态窗口可接受。 + try: os.remove(lf) + except OSError: pass + try: + fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, 'w', encoding='utf-8') as fh: + fh.write(blob) + except FileExistsError: + cur2 = _read_lock(lf) + if cur2 and cur2.get('pid') != os.getpid() and _lock_fresh(lf): + return False # 抢锁竞态输了 + with open(lf, 'w', encoding='utf-8') as fh: + fh.write(blob) + with _hb_lock: + _held_locks.add(log_path) + _ensure_hb_thread() + return True + except Exception: + # 锁设施故障绝不阻断续接 —— 降级为"假定空闲、放行"。 + return True + + +def release_lock(log_path): + """释放本进程对 `log_path` 的锁(只删自己持有/无主的锁文件)。""" + with _hb_lock: + _held_locks.discard(log_path) + try: + lf = _lock_path(log_path) + cur = _read_lock(lf) + if cur is None or cur.get('pid') == os.getpid(): + os.remove(lf) + except Exception: + pass + + +def _hb_tick(): + now = time.time() + with _hb_lock: + items = list(_held_locks) + for lp in items: + try: + os.utime(_lock_path(lp), (now, now)) # 仅更新 mtime,无 fsync + except OSError: + pass + + +def _hb_loop(): + while True: + time.sleep(_HB_INTERVAL) + try: + _hb_tick() + except Exception: + pass + + +def _ensure_hb_thread(): + global _hb_thread + with _hb_lock: + if _hb_thread is None: + _hb_thread = threading.Thread(target=_hb_loop, + name='ga-session-heartbeat', daemon=True) + _hb_thread.start() + + +@atexit.register +def _release_all_locks(): + for lp in list(_held_locks): + release_lock(lp) + + +def _new_log_path(): + """铸一个新的 6 位 logid 日志路径(与 agentmain 同公式)。""" + logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}' + return os.path.join(_LOG_DIR, f'model_responses_{logid}.txt') + + +def _retarget_log(agent, new_path): + """把 agent(及其所有 llmclient)的日志写入点切到 new_path —— 之后的轮次写这里。""" + try: + agent.log_path = new_path + except Exception: + pass + for client in _agent_clients(agent): + try: client.log_path = new_path + except Exception: pass + + +def is_snapshot(path): + """遗留快照存档(model_responses_snapshot_*.txt)。这类只能拷贝续,不参与原地 + (provisional,待 worktree 复审)。""" + return os.path.basename(path).startswith('model_responses_snapshot_') + + +def _clear_conversation_state(agent): + """清空对话状态(对齐 reset_conversation,但不碰日志文件)。""" + if hasattr(agent, 'history'): + agent.history = [] + for client in _agent_clients(agent): + backend = getattr(client, 'backend', None) + if backend is not None and hasattr(backend, 'history'): + backend.history = [] + if hasattr(client, 'last_tools'): + client.last_tools = '' + if hasattr(agent, 'handler'): + agent.handler = None + + +def acquire_birth_lock(agent, agent_id=None): + """会话出生时持有自己当前日志的锁(新 logid 必然抢到)。TUI 在建会话时调用, + 使本会话对"占用检测"可见 —— 别的会话才能据此判定它是否还活着。""" + lp = getattr(agent, 'log_path', '') or '' + if lp: + acquire_lock(lp, agent_id) + + +def release_current(agent): + """切走:释放 agent 当前日志的锁,旧日志原样留作"空闲会话"(不存快照、不清空)。""" + lp = getattr(agent, 'log_path', '') or '' + if lp: + release_lock(lp) + + +def begin_fresh_session(agent, agent_id=None): + """新对话 / clear:释放当前锁(旧日志留作空闲会话)→ 铸新 logid 重指 → 持新锁 → + 清空对话状态。**替代 TUI 里的 reset_conversation**(不再存快照/清空旧日志)。""" + try: agent.abort() + except Exception: pass + release_current(agent) + newp = _new_log_path() + _retarget_log(agent, newp) + acquire_lock(newp, agent_id) + _clear_conversation_state(agent) + + +def _load_history_into(agent, path): + """把 `path` 解析进 backend.history(native;否则降级摘要)。镜像 restore() 的解析, + 但不 abort/不快照(日志重指由调用方先做好)。返回 (msg, is_full)。""" + try: + with open(path, encoding='utf-8', errors='replace') as fh: + content = fh.read() + except Exception as e: + return f'❌ 读取失败: {e}', False + pairs = _pairs(content) + if not pairs: + return f'❌ {os.path.basename(path)} 为空或格式不符', False + history = _parse_native_history(pairs) + name = os.path.basename(path) + if history is not None: + _replace_backend_history(agent, history) + return f'✅ 已恢复 {len(pairs)} 轮完整对话({name})', True + from chatapp_common import _restore_native_history, _restore_text_pairs + summary = _restore_text_pairs(content) or _restore_native_history(content) + if not summary: + return f'❌ {name} 无法解析(非 native 且无摘要可提取)', False + if hasattr(agent, 'history'): + agent.history.extend(summary) + n = sum(1 for l in summary if l.startswith('[USER]: ')) + return f'⚠️ 非 native 格式,降级恢复 {n} 轮摘要({name})', False + + +def _is_empty_log(path): + """日志空(<32 字节)或缺失。用于 allow_empty:回退到会话起点后日志被清空的会话。""" + try: + return os.path.getsize(path) < 32 + except OSError: + return True + + +def continue_inplace(agent, path, agent_id=None, allow_empty=False): + """原地续:把 agent 的日志指回 `path` 本身,之后轮次追加到 X,延续同一会话。 + 调用方应已确认空闲(session_occupant 为 None);抢锁失败(被占)返回错误。 + `allow_empty`(仅 worldline UI 传):日志为空时不报错,按【空会话】恢复(清空对话, + 由调用方按 `.ga_rewind` 树重连),用于"回退至会话起点"的会话。返回 (msg, ok)。""" + try: agent.abort() + except Exception: pass + if not acquire_lock(path, agent_id): # 先抢到目标锁;失败则保持现状,不丢自己的锁 + return '❌ 会话已被占用,无法原地接管', False + cur = getattr(agent, 'log_path', '') or '' + if cur and os.path.basename(cur) != os.path.basename(path): + release_lock(cur) # 目标到手,旧会话释放为空闲(同一文件则不放) + _retarget_log(agent, path) + msg, ok = _load_history_into(agent, path) + if not ok and allow_empty and _is_empty_log(path): + _replace_backend_history(agent, []) # 空会话:清空对话(载入失败时它没被清) + return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True + return msg, ok + + +def continue_copy(agent, path, agent_id=None, allow_empty=False): + """拷贝续:铸新 logid、把 `path` 内容拷进去,在副本上续;`path` 原件不动。 + 用于"被占用→用户选拷贝"以及快照源。返回 (msg, ok)。""" + try: agent.abort() + except Exception: pass + release_current(agent) + newp = _new_log_path() + try: + shutil.copyfile(path, newp) + except Exception: + pass + acquire_lock(newp, agent_id) + _retarget_log(agent, newp) + msg, ok = _load_history_into(agent, newp) + if not ok and allow_empty and _is_empty_log(newp): + _replace_backend_history(agent, []) + return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True + return msg, ok + + def install(cls): """Wrap cls._handle_slash_cmd so /continue is handled before original dispatch.""" orig = cls._handle_slash_cmd diff --git a/frontends/desktop_pet.pyw b/frontends/desktop_pet.pyw deleted file mode 100644 index c0bd598e..00000000 --- a/frontends/desktop_pet.pyw +++ /dev/null @@ -1,93 +0,0 @@ -"""Desktop Pet with HTTP Toast — ~90 lines""" -import tkinter as tk, threading, random, os, sys -from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.parse import urlparse, parse_qs - -PORT = 41983 -GIF = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), 'pet.gif') - -class Pet: - def __init__(self): - self.root = tk.Tk() - self.root.overrideredirect(True) - self.root.wm_attributes('-topmost', True) - self.root.wm_attributes('-transparentcolor', '#01FF01') - self.root.config(bg='#01FF01') - self.root.after(50, lambda: self.root.geometry('+300+500')) - # load GIF frames - self.frames, i = [], 0 - while True: - try: self.frames.append(tk.PhotoImage(file=GIF, format=f'gif -index {i}')); i += 1 - except: break - if not self.frames: raise FileNotFoundError(f'No GIF: {GIF}') - self.idx = 0 - self.label = tk.Label(self.root, image=self.frames[0], bg='#01FF01', bd=0) - self.label.pack() - # drag - self.label.bind('', lambda e: setattr(self, '_d', (e.x, e.y))) - self.label.bind('', self._drag) - self.label.bind('', lambda e: (self.root.destroy(), os._exit(0))) - # start loops - self._animate() - self._wander() - self._start_server() - self.root.mainloop() - - def _drag(self, e): - x, y = self.root.winfo_x() + e.x - self._d[0], self.root.winfo_y() + e.y - self._d[1] - self.root.geometry(f'+{x}+{y}') - - def _animate(self): - self.idx = (self.idx + 1) % len(self.frames) - self.label.config(image=self.frames[self.idx]) - self.root.after(150, self._animate) - - def _wander(self): - if random.random() < 0.25: - x = self.root.winfo_x() + random.randint(-15, 15) - y = self.root.winfo_y() + random.randint(-5, 5) - self.root.geometry(f'+{x}+{y}') - self.root.after(4000, self._wander) - - def show_toast(self, msg): - """Show a speech bubble near the pet that auto-dismisses.""" - tw = tk.Toplevel(self.root) - tw.overrideredirect(True) - tw.wm_attributes('-topmost', True) - tw.config(bg='#FFFDE7') - px, py = self.root.winfo_x(), self.root.winfo_y() - tw.geometry(f'+{px + 30}+{py - 50}') - # bubble content - f = tk.Frame(tw, bg='#FFFDE7', highlightbackground='#888', highlightthickness=1, padx=8, pady=4) - f.pack() - tk.Label(f, text=msg, bg='#FFFDE7', fg='#333', font=('Segoe UI', 10), wraplength=220, justify='left').pack() - # auto dismiss - tw.after(3000, tw.destroy) - - def _start_server(self): - pet = self - class H(BaseHTTPRequestHandler): - def do_GET(self): - qs = parse_qs(urlparse(self.path).query) - msg = qs.get('msg', [''])[0] - if msg: - pet.root.after(0, pet.show_toast, msg) - self.send_response(200); self.end_headers(); self.wfile.write(b'ok') - else: - self.send_response(400); self.end_headers(); self.wfile.write(b'?msg=xxx') - def do_POST(self): - body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode() - if body: - pet.root.after(0, pet.show_toast, body) - self.send_response(200); self.end_headers(); self.wfile.write(b'ok') - else: - self.send_response(400); self.end_headers(); self.wfile.write(b'empty body') - def log_message(self, *a): pass - HTTPServer.allow_reuse_address = False - srv = HTTPServer(('127.0.0.1', PORT), H) - t = threading.Thread(target=srv.serve_forever, daemon=True) - t.start() - print(f'Toast server: http://127.0.0.1:{PORT}/?msg=hello') - -if __name__ == '__main__': - Pet() diff --git a/frontends/model_cmd.py b/frontends/model_cmd.py new file mode 100644 index 00000000..5f9f098c --- /dev/null +++ b/frontends/model_cmd.py @@ -0,0 +1,120 @@ +"""/model 命令的 agent 无关逻辑: 拉模型列表 + 运行时改 model。 +被 tuiapp_v2 import; 不 patch 任何类, 不依赖 llmcore。""" +from typing import Optional, List, Tuple + +import requests + + +def _is_mixin(b) -> bool: + return type(b).__name__ == 'MixinSession' + + +def _resolve(agent, sub: Optional[int] = None): + """返回 (真正持有 model 的 session, mixin或None)。""" + b = agent.llmclient.backend + if _is_mixin(b): + return b._sessions[b._cur_idx if sub is None else sub], b + return b, None + + +def list_subsessions(agent) -> Optional[List[Tuple[int, str, bool]]]: + """mixin 渠道 → [(idx, name, is_current)]; 普通渠道 → None。""" + b = agent.llmclient.backend + if not _is_mixin(b): + return None + return [(i, s.name, i == b._cur_idx) for i, s in enumerate(b._sessions)] + + +def current_model(agent, sub: Optional[int] = None) -> str: + s, _ = _resolve(agent, sub) + return s.model + + +def fetch_models(agent, sub: Optional[int] = None, timeout: int = 10) -> List[str]: + """GET models 列表, 自动尝试 /models 与 /v1/models、原生头与 Bearer。""" + s, _ = _resolve(agent, sub) + base = s.api_base.rstrip('/') + urls = [f'{base}/models'] + ([] if base.endswith('/v1') else [f'{base}/v1/models']) + heads = [{'Authorization': f'Bearer {s.api_key}'}] + if 'Claude' in type(s).__name__: + heads.insert(0, {'x-api-key': s.api_key, 'anthropic-version': '2023-06-01'}) + err = None + for url in urls: + for h in heads: + try: + r = requests.get(url, headers=h, timeout=timeout, + proxies=getattr(s, 'proxies', None), + verify=getattr(s, 'verify', True)) + r.raise_for_status() + data = r.json().get('data', []) # 非 JSON(如 HTML 首页)会抛错进入下一候选 + ids = {m['id'] for m in data if isinstance(m, dict) and m.get('id')} + if ids: + return sorted(ids) + except Exception as e: + err = e + raise err or RuntimeError('no models endpoint') + + +def set_model(agent, model: str, sub: Optional[int] = None) -> str: + """运行时改 model(内存态, mykey 重载/重启后还原)。返回结果描述。""" + s, mixin = _resolve(agent, sub) + old = s.model + s.model = model # mixin 的 model 是只读 property, 必须落子 session + warn = "" + try: # 对齐 agentmain.next_llm 的中文 schema 切换 + from agentmain import load_tool_schema + load_tool_schema('_cn' if any(x in model.lower() for x in ('glm', 'minimax', 'kimi')) else '') + except Exception as ex: # schema 选错会实际影响 agent 行为, 半径不为零, 不静默 + warn = f" (⚠ schema 切换失败: {type(ex).__name__})" + where = f"[{s.name}]" if mixin else s.name + return f"{where}: {old} → {model}{warn}" + + +# GA 配置层允许的全部档位(llmcore BaseSession._enum)。各协议的真实支持面不同: +# Claude(output_config.effort) 只认 low/medium/high + xhigh→max, none/minimal +# 会被 _apply_claude_thinking 打 WARN 忽略; OpenAI 系(reasoning_effort / +# reasoning.effort) 原样透传, 由渠道端校验。 +EFFORT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] + + +def _protocols(agent) -> set: + """当前渠道涉及的协议集合 {'claude', 'openai'}。mixin 看全部子渠道(广播 + 会落到每一个)。NativeOAISession 名字不含 'Claude', 正确归入 openai。""" + b = agent.llmclient.backend + sessions = b._sessions if _is_mixin(b) else [b] + return {('claude' if 'Claude' in type(s).__name__ else 'openai') + for s in sessions} + + +def effort_note(level, protocols) -> str: + """单点描述某档位在给定协议上的特殊行为(空串=无特殊)。set_effort 的结果 + 描述与 /effort picker 的行内备注共用它, 领域知识只在此编码一次。""" + if level and 'claude' in protocols: + if level in ('none', 'minimal'): + return 'Claude 渠道忽略' + if level == 'xhigh': + return 'Claude 对应 max' + return '' + + +def current_effort(agent) -> str: + return getattr(agent.llmclient.backend, 'reasoning_effort', None) or '' + + +def set_effort(agent, value) -> str: + """运行时改 reasoning_effort(内存态)。空值/off 清除(不再发送 effort 字段)。 + 直接设在 backend 上: MixinSession 把它列为 _BROADCAST_ATTRS, 会同步到所有 + 子渠道(故障切换后档位不丢); 普通渠道就是 session 本身。请求时各协议现读 + 该属性, 立即生效。""" + e = (value or '').strip().lower() + if e in ('', 'off', 'clear', 'unset'): + e = None + elif e not in EFFORT_LEVELS: + return (f"无效 effort: {value!r}" + f" (可选 {'/'.join(EFFORT_LEVELS)}, 留空或 off 清除)") + b = agent.llmclient.backend + old = getattr(b, 'reasoning_effort', None) + b.reasoning_effort = e + note = effort_note(e, _protocols(agent)) + tail = f" ({note})" if note else "" + return f"reasoning_effort: {old or '(未设置)'} → {e or '(清除)'}{tail}" diff --git a/frontends/pet.gif b/frontends/pet.gif deleted file mode 100644 index a297e0fb..00000000 Binary files a/frontends/pet.gif and /dev/null differ diff --git a/frontends/plan_state.py b/frontends/plan_state.py index 8138ffb3..bab87ee9 100644 --- a/frontends/plan_state.py +++ b/frontends/plan_state.py @@ -159,16 +159,26 @@ def current_step(messages, start_idx: int = 0, max_len: int = 60) -> str: return "" -def is_active(agent, messages=None, start_idx: int = 0) -> bool: - """Plan mode is on. Primary: `working['in_plan_mode']`. Fallback: - a `plan_*/plan.md` referenced in this session's messages (no global scan).""" +def is_active(agent, messages=None, start_idx: int = 0, + restored_path: str = "") -> bool: + """Plan mode is on. Primary: `working['in_plan_mode']`. Then + `restored_path` — a path recovered from the transcript's structured + `enter_plan_mode` tool_use by /continue (see continue_cmd.find_plan_entry); + unlike the message scan it cannot be spoofed by a path typed in chat. + Legacy fallback: a `plan_*/plan.md` referenced in this session's messages + (no global scan) — only consulted when `messages` is passed.""" if _stashed_plan_path(agent): return True + if restored_path and _resolve_stashed(restored_path): return True return find_path_in_messages(messages, start_idx) is not None -def resolve_path(agent, messages=None, start_idx: int = 0) -> Optional[str]: +def resolve_path(agent, messages=None, start_idx: int = 0, + restored_path: str = "") -> Optional[str]: p = _resolve_stashed(_stashed_plan_path(agent)) if p: return p + if restored_path: + p = _resolve_stashed(restored_path) + if p: return p return find_path_in_messages(messages, start_idx) diff --git a/frontends/slash_cmds.py b/frontends/slash_cmds.py index dfd778ef..6c3c0a66 100644 --- a/frontends/slash_cmds.py +++ b/frontends/slash_cmds.py @@ -33,6 +33,7 @@ from typing import Optional _USER_SHELL: tuple[list[str], str] | None = None +COMMIT_SIGNATURE_PROMPT = 'When you create a git commit, append "Co-Authored-By: GenericAgent " as the final line of the commit message.' def detect_user_shell() -> tuple[list[str], str]: """Return `([executable, ...flags_for_-c], display_name)` for the user's @@ -152,16 +153,32 @@ def build_update_prompt(args_text: str = "") -> str: return ( "Update this GenericAgent checkout from the official upstream " "https://github.com/Lsdefine/GenericAgent .\n" - "1. Fetch upstream; identify the current branch and whether there are any local " - "commits ahead of upstream.\n" + "1. Run `git fetch upstream`; identify the current branch and whether there are " + "local commits ahead of upstream.\n" "2. Show a concise preview: recent upstream commits not yet local " "(short hash + subject + date) plus a changed-files summary.\n" - "3. Apply the update (uncommitted working-tree changes must always be preserved):\n" - " - If local commits exist: merge upstream into the current branch, auto-resolving " - "conflicts in favor of upstream, and keep the local commits. A merge commit is acceptable here.\n" - " - Otherwise: run `git reset --mixed upstream/main` so local `main` aligns exactly " - "with upstream/main, automatically resolving conflicts in favor of upstream. Keep the local changes but do not create any new commits.\n" - "4. Finish with a short summary: branch HEAD, changed files, conflicts/resolutions.\n" + "3. Align the COMMIT HISTORY to upstream (upstream wins):\n" + " - If local commits exist: `git merge upstream/main` into the current branch, " + "resolving any conflicts in favor of upstream; keep the local commits (a merge commit is fine).\n" + " - Otherwise: `git reset --mixed upstream/main` so the history of local `main` " + "matches upstream/main exactly. Do NOT create any new commit.\n" + "4. IMPORTANT: also reconcile the WORKING TREE; step 3 alone is not enough. " + "`git reset --mixed` and `git merge` only move history and the index. They do not " + "rewrite files that already hold uncommitted local edits, so those stale files keep " + "shadowing upstream's latest. That is why 'HEAD is aligned but the files are still " + "old'. Reconcile per file:\n" + " a. `git diff --name-only upstream/main` lists every tracked file whose working-tree " + "content differs from upstream.\n" + " b. Decide upstream-first per file. A stale leftover, or a local edit that reverts " + "an upstream feature to an older form, takes upstream: back it up, then " + "`git checkout upstream/main -- `. A genuine local enhancement upstream lacks " + "(local config, key-template placeholders, fork-only features) stays, re-applied on " + "top of upstream's latest instead of overwriting it.\n" + " c. Upstream wins; keep local only where it adds value upstream lacks. Never " + "`git add -A`, never blanket whole-branch checkout, never blindly preserve everything.\n" + "5. Do not create any git commit. Back up any file before overwriting it.\n" + "6. Finish with a short summary: branch HEAD, distance vs upstream, per-file outcome " + "(took upstream / kept+adapted local), and where backups went.\n" "\n" "#### Say 'Congratulations! 🎉' to the user\n" "After a successful update, say: \"Congratulations! 🎉 You have successfully " @@ -175,12 +192,21 @@ def build_update_prompt(args_text: str = "") -> str: return ( "请你更新当前 GenericAgent 仓库,官方上游为 " "https://github.com/Lsdefine/GenericAgent 。\n" - "1. 先 fetch upstream;识别当前分支,以及是否存在领先 upstream 的本地 commit。\n" + "1. 先 `git fetch upstream`;识别当前分支,以及是否存在领先 upstream 的本地 commit。\n" "2. 给出简洁预览:本地尚未包含的 upstream 最近提交(短 hash + 标题 + 日期)及变更文件摘要。\n" - "3. 执行更新(工作区未提交的改动始终要保留):\n" - " - 若存在本地 commit:将 upstream 合并进当前分支,自动解决冲突且优先上游。\n" - " - 否则:执行 `git reset --mixed upstream/main`,使本地 `main` 与 upstream/main 完全对齐,自动解决冲突且优先上游。本地修改保留但不要进行commit。\n" - "4. 最后给出简短小结:分支 HEAD、变更文件、冲突/解决方式。\n" + "3. 先对齐【提交历史】到上游(优先上游):\n" + " - 若存在本地 commit:`git merge upstream/main` 合并进当前分支,冲突一律优先上游,保留本地 commit(可产生 merge commit)。\n" + " - 否则:执行 `git reset --mixed upstream/main`,使本地 `main` 的提交历史与 upstream/main 完全对齐。不要创建任何新 commit。\n" + "4. 重要:还须核对【工作区文件】,止步于第 3 步并不够。`git reset --mixed` 与 `git merge` " + "只移动提交历史与索引,不会重写那些已带未提交本地改动的文件,于是这些过时文件继续覆盖在上游" + "最新版之上。这正是“HEAD 已对齐,但文件其实还是旧的”的根因。逐文件核对:\n" + " a. `git diff --name-only upstream/main` 列出工作区内容与上游不一致的全部 tracked 文件。\n" + " b. 每个文件按优先上游判断。过时残留、或把上游特性改回旧版的本地改动,取上游:先备份,再 " + "`git checkout upstream/main -- `。上游没有且仍有效的本地增强(本机配置、密钥模板占位、" + "fork 专属功能)保留,并在上游最新版上重新适配,而非整文件覆盖上游。\n" + " c. 优先上游;本地仅保留上游缺失且有价值的部分。禁止 `git add -A`、禁止整分支 checkout 覆盖、禁止盲目全保留。\n" + "5. 不要进行任何 git commit。覆盖任何文件前先备份。\n" + "6. 最后给出简短小结:分支 HEAD、与上游差距、逐文件处理结果(取上游 / 保留并适配本地)、备份位置。\n" "\n" "#### 向用户说 'Congratulations! 🎉'\n" "更新成功后,请对用户说:\"Congratulations! 🎉 你已成功更新 GenericAgent!\"\n" diff --git a/frontends/stapp.py b/frontends/stapp.py index 5c49c7f3..ce1bcb39 100644 --- a/frontends/stapp.py +++ b/frontends/stapp.py @@ -13,26 +13,49 @@ sys.path.append(os.path.abspath(script_dir)) import streamlit as st import time, json, re, threading, queue +from datetime import timedelta from agentmain import GeneraticAgent import chatapp_common # activate /continue command (monkey patches GeneraticAgent) from continue_cmd import handle_frontend_command, reset_conversation, list_sessions, extract_ui_messages from btw_cmd import handle_frontend_command as btw_handle_frontend from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard -st.set_page_config(page_title="Cowork", layout="wide") +st.set_page_config(page_title="Cowork", layout="wide", initial_sidebar_state="collapsed") + +st.markdown(""" + +""", unsafe_allow_html=True) LANG = os.environ.get('GA_LANG', 'zh') if LANG not in ('zh', 'en'): LANG = 'zh' I18N = { 'zh': { 'force_stop': '强行停止任务', - 'reinject_tools': '重新注入工具', 'desktop_pet': '🐱 桌面宠物', + 'suggest_btn': '🎯 给我找点事做', + 'suggest_prompt': '按照自主行动的规划部分,充分分析我的情况,给我生成一批TODO,务必让我感兴趣', + 'auto_start': '开始空闲自主行动', + 'auto_pause': '⏸️ 禁止自主行动', + 'auto_enable': '▶️ 允许自主行动', + 'auto_on_cap': '🟢 自主行动运行中,会在你离开它30分钟后自动进行', + 'auto_off_cap': '🔴 自主行动已停止', + 'auto_prompt': '[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。', }, 'en': { 'force_stop': 'Force Stop', - 'reinject_tools': 'Reinject Tools', 'desktop_pet': '🐱 Desktop Pet', + 'suggest_btn': '🎯 Suggest tasks', + 'suggest_prompt': 'Following the planning section of autonomous sop, analyze my situation thoroughly and generate a batch of TODOs that will interest me.', + 'auto_start': 'Start idle auto-action', + 'auto_pause': '⏸️ Pause auto-action', + 'auto_enable': '▶️ Enable auto-action', + 'auto_on_cap': '🟢 Auto-action enabled, triggers after 30min idle', + 'auto_off_cap': '🔴 Auto-action disabled', + 'auto_prompt': '[AUTO]🤖 User has been idle for over 30 minutes. As an autonomous agent, read the automation SOP and execute automatic tasks.', }, } def T(key): return I18N.get(LANG, I18N['zh']).get(key, key) @@ -64,18 +87,12 @@ def render_sidebar(): agent.next_llm(selected_idx); st.rerun(scope="fragment") if st.button(T('force_stop')): agent.abort(); st.toast("Stop signal sended"); st.rerun() - if st.button(T('reinject_tools')): - agent.llmclient.last_tools = '' - try: - hist_path = os.path.join(script_dir, '..', 'assets', 'tool_usable_history.json') - with open(hist_path, 'r', encoding='utf-8') as f: tool_hist = json.load(f) - agent.llmclient.backend.history.extend(tool_hist) - st.toast(f"Tools injected") - except Exception as e: st.toast(f"Injected tools failed: {e}") if st.button(T('desktop_pet')): kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {} pet_script = os.path.join(script_dir, 'desktop_pet_v2.pyw') - if not os.path.exists(pet_script): pet_script = os.path.join(script_dir, 'desktop_pet.pyw') + if not os.path.exists(pet_script): + st.error("desktop_pet_v2.pyw not found") + return subprocess.Popen([sys.executable, pet_script], **kwargs) def _pet_req(q): def _do(): @@ -93,25 +110,44 @@ def render_sidebar(): agent._turn_end_hooks['pet'] = _pet_hook st.toast("Desktop pet started") - if LANG == 'zh': - if st.button('🎯 给我找点事做'): - st.session_state['_inject_prompt'] = '按照自主行动的规划部分,充分分析我的情况,给我生成一批TODO,务必让我感兴趣' - st.rerun(scope="app") - st.divider() - if st.button("开始空闲自主行动"): - st.session_state.last_reply_time = int(time.time()) - 1800 + if st.button(T('suggest_btn')): + st.session_state['_inject_prompt'] = T('suggest_prompt') + st.rerun(scope="app") + st.divider() + st.markdown("""""", unsafe_allow_html=True) + def _sync_loop_prompt(): + st.session_state.loop_prompt = st.session_state.loop_prompt_input + loop_prompt = st.text_area("Loop prompt", value=st.session_state.get('loop_prompt', "继续" if LANG=='zh' else 'next'), key="loop_prompt_input", height=1, on_change=_sync_loop_prompt) + if st.session_state.get('loop_enabled'): + if st.button("⏹️ Stop Loop"): + st.session_state.loop_enabled = False + st.toast("⏹️ Loop stopped"); st.rerun(scope="app") + st.caption("🔁 Looping") + else: + if st.button("🔁 Loop!"): + st.session_state.loop_enabled = True + st.session_state.loop_prompt = loop_prompt + st.session_state['_inject_prompt'] = loop_prompt + st.toast("🔁 Looping"); st.rerun(scope="app") + st.divider() + if st.button(T('auto_start')): + st.session_state.last_reply_time = int(time.time()) - 1800 + st.session_state.autonomous_enabled = True + st.rerun(scope="app") + if st.session_state.autonomous_enabled: + if st.button(T('auto_pause')): + st.session_state.autonomous_enabled = False + st.toast(T('auto_pause')); st.rerun(scope="app") + st.caption(T('auto_on_cap')) + else: + if st.button(T('auto_enable'), type="primary"): st.session_state.autonomous_enabled = True - st.toast("已将上次回复时间设为1800秒前,自主行动已激活"); st.rerun(scope="app") - if st.session_state.autonomous_enabled: - if st.button("⏸️ 禁止自主行动"): - st.session_state.autonomous_enabled = False - st.toast("⏸️ 已禁止自主行动"); st.rerun(scope="app") - st.caption("🟢 自主行动运行中,会在你离开它30分钟后自动进行") - else: - if st.button("▶️ 允许自主行动", type="primary"): - st.session_state.autonomous_enabled = True - st.toast("✅ 已允许自主行动"); st.rerun(scope="app") - st.caption("🔴 自主行动已停止") + st.toast("✅"); st.rerun(scope="app") + st.caption(T('auto_off_cap')) with st.sidebar: render_sidebar() def fold_turns(text): @@ -216,6 +252,10 @@ def render_main_stream(prompt=None): if response: st.session_state.messages.append({"role": "assistant", "content": response}) st.session_state.last_reply_time = int(time.time()) + # ── 循环回调:回答完成后自动注入下一条 ── + if st.session_state.get('loop_enabled'): + st.session_state['_inject_prompt'] = st.session_state.get('loop_prompt', '继续') + st.rerun() if "messages" not in st.session_state: st.session_state.messages = [] for msg in st.session_state.messages: @@ -233,20 +273,6 @@ try: _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()}) except (ImportError, AttributeError): from streamlit.components.v1 import html as _embed_html # ≤1.55 -_js_scroll_fix = ( - "!function(){var p=window.parent;if(p.__sfx2)return;p.__sfx2=1;var d=p.document;" - "var pending=0;" - "function f(){pending=0;var m=d.querySelector('section.main');if(!m)return;" - "var s=m.scrollTop,h=m.scrollHeight;" - "m.style.minHeight=h+1+'px';void m.offsetHeight;" - "m.style.minHeight='';void m.offsetHeight;" - "m.scrollTop=s}" - "function schedule(){if(!pending){pending=1;requestAnimationFrame(f)}}" - "d.addEventListener('transitionend',function(e){" - "e.target.closest&&e.target.closest('details')&&setTimeout(schedule,60)},!0);" - "new MutationObserver(function(){setTimeout(schedule,80)})" - ".observe(d.body,{subtree:1,attributes:1,attributeFilter:['open']})}()" -) # IME composition fix (macOS only) - prevents Enter from submitting during CJK input _js_ime_fix = ("" if os.name == 'nt' else "!function(){if(window.parent.__imeFix)return;window.parent.__imeFix=1;" @@ -338,5 +364,15 @@ elif st.session_state.get('display_queue') is not None: # No new prompt but a task is mid-flight (typically a /btw rerun) — resume drain. render_main_stream() -if st.session_state.autonomous_enabled: - st.markdown(f"""""", unsafe_allow_html=True) +# ── 空闲自主行动:fragment 定时检测,替代 launch.pyw 的 idle_monitor ── +@st.fragment(run_every=timedelta(minutes=5)) +def _idle_checker(): + if not st.session_state.get('autonomous_enabled'): return + if st.session_state.get('display_queue') is not None: return # 正在运行中 + if st.session_state.get('loop_enabled'): return # 循环模式自己管 + last = st.session_state.get('last_reply_time', int(time.time())) + if time.time() - last > 1800: + st.session_state['_inject_prompt'] = T('auto_prompt') + st.session_state['last_reply_time'] = int(time.time()) # 防重入 + st.rerun(scope="app") +_idle_checker() diff --git a/frontends/tui_v3.py b/frontends/tui_v3.py index 57efeb8f..0684d538 100644 --- a/frontends/tui_v3.py +++ b/frontends/tui_v3.py @@ -32,6 +32,7 @@ from rich.markdown import Markdown from rich.text import Text from rich.theme import Theme from typing import Callable +from frontends import at_complete, workspace_cmd # @ 补全 + /workspace(与 v2 共用) # ════════════════════════════════════════════════════════════════════════════ # i18n — minimal dict-based zh/en translation layer (inlined; was tui_v3_i18n.py) @@ -295,6 +296,10 @@ _I18N: dict[str, dict[str, str]] = { # menu / picker / palette 'menu.default_title': 'Pick', 'menu.hint': '↑↓ pick · Enter confirm · Esc cancel', + 'menu.hint.filter': 'type to filter · ↑↓ pick · Enter confirm · Esc cancel', + 'menu.search': 'filter workspaces, or type an abs path + Enter to create one', + 'menu.no_match': 'no match', + 'menu.free.hint': 'Enter to create/enter this path', 'ask.default_q': 'answer:', 'ask.title': '◉ answer', 'ask.pending': ' +{n} pending', @@ -304,6 +309,19 @@ _I18N: dict[str, dict[str, str]] = { # continue picker 'continue.title': 'Restore historical session', + 'continue.occupied.title': 'Session in use (pid {p}) — copy it and continue?', + 'continue.occupied.copy': 'Copy & continue', + 'continue.occupied.cancel': 'Cancel', + # /workspace (parity with v2; backed by workspace_cmd.py) + 'cmd.workspace.arg': '[path|off]', + 'cmd.workspace.desc': 'set working dir (abs path) and enter project mode', + 'ws.entered': '✅ entered workspace「{n}」', + 'ws.fail': '❌ workspace failed: {e}', + 'ws.exited': 'left workspace (project mode off; junction & files kept)', + 'ws.inactive': 'not in a workspace right now', + 'ws.none': 'no registered workspace yet; /workspace to create/enter', + 'ws.pick.title': 'Pick a workspace (↑↓ choose · Enter confirm · Esc cancel)', + 'ws.restored': 'restored working dir: {t}', 'continue.row.fmt': '{rel:>4} {rounds:>3}r {preview}', 'continue.unit.round': 'r', @@ -545,6 +563,10 @@ _I18N: dict[str, dict[str, str]] = { # menu / picker / palette 'menu.default_title': '选择', 'menu.hint': '↑↓ 选 · Enter 确认 · Esc 取消', + 'menu.hint.filter': '输入过滤 · ↑↓ 选 · Enter 确认 · Esc 取消', + 'menu.search': '输入筛选工作区或输入绝对路径回车新建工作区', + 'menu.no_match': '无匹配', + 'menu.free.hint': '回车以该路径新建/进入', 'ask.default_q': '请回答:', 'ask.title': '◉ 请回答', 'ask.pending': ' +{n} 待答', @@ -554,6 +576,19 @@ _I18N: dict[str, dict[str, str]] = { # continue picker 'continue.title': '恢复历史会话', + 'continue.occupied.title': '该会话正被占用(pid {p})—— 从原会话拷贝一份继续?', + 'continue.occupied.copy': '拷贝一份继续', + 'continue.occupied.cancel': '取消', + # /workspace(与 v2 一致;后端 workspace_cmd.py) + 'cmd.workspace.arg': '[path|off]', + 'cmd.workspace.desc': '设定工作目录(绝对路径)并进入项目模式', + 'ws.entered': '✅ 已进入 workspace「{n}」', + 'ws.fail': '❌ workspace 设定失败: {e}', + 'ws.exited': '已退出 workspace(项目模式关闭;junction 与文件保留)', + 'ws.inactive': '当前未处于 workspace 模式', + 'ws.none': '暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入', + 'ws.pick.title': '选择 workspace(↑↓ 选择,Enter 确认,Esc 取消)', + 'ws.restored': '已恢复工作目录: {t}', 'continue.row.fmt': '{rel:>4} {rounds:>3}轮 {preview}', 'continue.unit.round': '轮', @@ -842,10 +877,12 @@ def _ptk_keypress_to_bytes(kp) -> bytes: return b'\x02' if has('right'): return b'\x06' + # Home/End get dedicated bytes — 0x01 is already Ctrl+A (select-all) and + # 0x05 has no editor handler, so reusing them would break/no-op the keys. if has('home'): - return b'\x01' + return b'\x07' if has('end'): - return b'\x05' + return b'\x14' if has('delete'): return b'\x7f' if has('backspace') or data == '\x7f' or data == '\x08': @@ -1336,6 +1373,12 @@ class AgentBridge: self.agent.llmclient = self.agent.llmclients[llm_no % len(self.agent.llmclients)] self.agent.inc_out = True self.agent.verbose = True + from frontends.slash_cmds import COMMIT_SIGNATURE_PROMPT + self.agent.extra_sys_prompts.append(COMMIT_SIGNATURE_PROMPT) + # 默认普通模式:设 None 让 project_mode 插件不读 pid 文件锚(与 v2 一致)。 + # /workspace 绑定时改为项目名 + 真实路径。 + self.agent._ga_project_mode_name = None + self.agent._ga_project_mode_workspace_path = '' # task_dir path enables ga's `_keyinfo` / `_intervene` consume paths. # PID-scoped so concurrent v3 processes don't share signal files. # Only the *path* is set here; the dir is created lazily by the writer @@ -1363,6 +1406,12 @@ class AgentBridge: if not getattr(self.agent, 'llmclient', None): self._healthy = False self._init_error = _t('err.no_llm') + # 原地复原:本会话出生即持有自己日志的锁,使占用检测对它可见(别的会话据此判活)。 + try: + from frontends import continue_cmd as _cc + _cc.acquire_birth_lock(self.agent) + except Exception: + pass self._runner = threading.Thread(target=self._run_safe, daemon=True, name=f'ga-tui-agent') self._runner.start() @@ -1451,6 +1500,15 @@ class AgentBridge: except Exception: return '?' + @property + def llm_model(self) -> str: + """The concrete model id in use (e.g. claude-opus-4-8), not the + channel group `llm_name` returns. Empty string when unavailable.""" + try: + return self.agent.get_llm_name(model=True) or '' + except Exception: + return '' + def list_llms(self) -> list[tuple[int, str, bool]]: return self.agent.list_llms() @@ -1587,7 +1645,9 @@ def _strip_bg(s: str) -> str: out.append(t); i += 1 return '\x1b[' + ';'.join(out) + 'm' if out else '\x1b[0m' return _SGR_RE.sub(repl, s) -_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b.') +# CSI (`\x1b[…`) or SS3 (`\x1bO…`, application-cursor mode for Home/End/arrows) +# as whole sequences, else any 2-byte `\x1b.` — order matters so SS3 wins over `\x1b.`. +_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1bO[@-~]|\x1b.') _FILE_REF_RE = re.compile(r'@([\w./\-~]+)') _PASTE_PH_RE = re.compile(r'\[Pasted text #(\d+) \+\d+ lines\]') _FILE_PH_RE = re.compile(r'\[File #(\d+)\]') @@ -1841,6 +1901,8 @@ def _cmds() -> list[tuple[str, str, str]]: ('/scheduler', '', _t('cmd.scheduler.desc')), ('/rewind', _t('cmd.rewind.arg'), _t('cmd.rewind.desc')), ('/continue', _t('cmd.continue.arg'), _t('cmd.continue.desc')), + ('/workspace', _t('cmd.workspace.arg', default='[path|off]'), + _t('cmd.workspace.desc', default='设定工作目录(绝对路径)并进入项目模式')), ('/new', _t('cmd.new.arg'), _t('cmd.new.desc')), ('/rename', _t('cmd.rename.arg'), _t('cmd.rename.desc')), ('/clear', '', _t('cmd.clear.desc')), @@ -2060,6 +2122,13 @@ def _esc_repl(m: re.Match) -> bytes: return b'\x1d' # Shift+↓ → extend selection down if s in (b'\x1b[27;2;13~', b'\x1b[13;2u'): return b'\n' # Shift+Enter (modifyOtherKeys / kitty) → newline + # Home/End arrive as raw VT sequences via PTK's KeyPress.data (special keys + # carry their original escape bytes), so _ptk_keypress_to_bytes returns them + # verbatim and they must be decoded here like the arrows above. + if s in (b'\x1b[H', b'\x1b[1~', b'\x1b[7~', b'\x1bOH'): + return b'\x07' # Home → internal jump-to-line-start + if s in (b'\x1b[F', b'\x1b[4~', b'\x1b[8~', b'\x1bOF'): + return b'\x14' # End → internal jump-to-line-end return b'' # swallow every other escape sequence @@ -2205,6 +2274,46 @@ def _clip_cells(s: str, width: int) -> str: return out +def _cell_head(s: str, n: int) -> str: + """Keep the head within n cells, suffix … if truncated (省略末尾 — names).""" + if cell_len(s) <= n: + return s + if n <= 1: + return '…' + out, w = '', 0 + for ch in s: + c = cell_len(ch) + if w + c > n - 1: + break + out += ch; w += c + return out + '…' + + +def _cell_mid(s: str, n: int) -> str: + """Keep head+tail within n cells, … in the middle (省略中间 — paths: project + root and leaf both stay visible). CJK counts as 2 via cell_len.""" + if cell_len(s) <= n: + return s + if n <= 1: + return '…' + avail = n - 1 + head_budget = avail - avail // 2 + tail_budget = avail // 2 + head, w = '', 0 + for ch in s: + c = cell_len(ch) + if w + c > head_budget: + break + head += ch; w += c + tail_rev, w = '', 0 + for ch in reversed(s): + c = cell_len(ch) + if w + c > tail_budget: + break + tail_rev += ch; w += c + return head + '…' + tail_rev[::-1] + + def _term_safe_text(s: str) -> str: """Normalize control chars whose terminal geometry is stateful. @@ -2380,11 +2489,25 @@ class SB: # on_submit(_menu_sel) and ignores _menu_checked. self._menu_multi: bool = False self._menu_checked: set[int] = set() + # opt-in 可过滤菜单(/workspace 用):输入过滤 + free_input 提交原文。 + self._menu_filterable: bool = False + self._menu_free: bool = False + self._menu_on_free = None + self._menu_query: str = '' + self._menu_all_labels: list[str] = [] # 未过滤的全部显示行 + self._menu_filter_keys: list[str] = [] # 与 all_labels 等长的可搜索文本(完整,不省略) + self._menu_map: list[int] = [] # 可见行 idx → all_labels 原始 idx # Interactive command palette: index into _cmd_matches output when # buf starts with `/`. ↑↓ steer the highlight, Tab completes the # highlighted command, Enter still executes whatever is in buf. self._palette_sel: int = 0 self._palette_scroll: int = 0 # viewport offset into the matches list + # workspace 绑定(单会话 → 进程级一个)。空 = 普通模式。 + self._ws_name: str = '' + self._ws_path: str = '' + self._ws_link: str = '' + # @ 候选缓存:(buf, pos) → list[path],避免每次按键重算 fuzzy_rank。 + self._at_cache: tuple | None = None # ── live region ── # @@ -2395,7 +2518,9 @@ class SB: # in callers are harmless legacy resets (they zero state PTK ignores). def _status_line(self, w: int) -> str: - name = self._bridge.llm_name if self._bridge else '?' + # Show the concrete model id; fall back to the channel group only when + # the model is unavailable (e.g. a mixin without a single .model). + name = (self._bridge.llm_model or self._bridge.llm_name) if self._bridge else '?' if self._asking: state = _t('status.asking') elif self._running: @@ -2409,7 +2534,12 @@ class SB: else: state = _t('status.ready') cost = _cost_str(self._bridge.agent) if self._bridge else '' - return f'[main] {name} │ {state}{cost}' + ws = '' + if self._ws_name: + disp = (os.path.basename((self._ws_path or '').rstrip('/\\')) + or re.sub(r'-[0-9a-f]{8}$', '', self._ws_name)) + ws = f' │ ⌂ {_cell_head(disp, 18)}' + return f'[main] {name} │ {state}{cost}{ws}' # v2-style plan card budget: 5 rows max — header(1) + optional step(1) + # tasks(rest) + optional overflow(1). Grace periods avoid flicker when the @@ -2658,7 +2788,9 @@ class SB: def _show_menu(self, title: str, options: list[str], on_submit, hint: str | None = None, on_cancel=None, multi_select: bool = False, - pre_checked: set[int] | None = None) -> None: + pre_checked: set[int] | None = None, + filterable: bool = False, free_input: bool = False, + on_free=None, filter_keys: list[str] | None = None) -> None: """Open a modal arrow-key menu in place of the input box. The menu takes over the live region; ↑↓ move the highlight, Enter @@ -2676,11 +2808,23 @@ class SB: worked logically but left a one-frame window where PTK could render the menu with the wrong state — observed as /scheduler picker rendering all unchecked even though `reflect/scheduler.py` was alive. - Passing the set up-front makes the open atomic.""" + Passing the set up-front makes the open atomic. + + `filterable=True` adds a search box: printable chars / backspace edit a + live query that filters rows by `filter_keys` (defaults to the visible + labels; pass the full untruncated text so an elided middle still + matches). `free_input=True` + `on_free` lets Enter on a query with NO + match commit the raw query (e.g. an abs path → new workspace).""" if not options: - return + if not (filterable and free_input): + return # 空列表但允许 free_input → 仍打开(可输路径新建) self._menu_active = True - self._menu_options = list(options) + self._menu_filterable = bool(filterable) + self._menu_free = bool(free_input) + self._menu_on_free = on_free + self._menu_query = '' + self._menu_all_labels = list(options) + self._menu_filter_keys = list(filter_keys) if filter_keys else list(options) self._menu_title = title # In multi-select mode show a Space-aware hint by default so the user # discovers the toggle key without reading the docstring. @@ -2688,16 +2832,38 @@ class SB: self._menu_hint = hint elif multi_select: self._menu_hint = _t('menu.hint.multi', default='Space toggle · ↑↓ move · Enter submit · Esc cancel') + elif filterable: + self._menu_hint = _t('menu.hint.filter', default='输入过滤 · ↑↓ 选择 · Enter 确认 · Esc 取消') else: self._menu_hint = _t('menu.hint') - self._menu_sel = 0 - self._menu_scroll = 0 self._menu_on_submit = on_submit self._menu_on_cancel = on_cancel self._menu_multi = bool(multi_select) self._menu_checked = set(pre_checked) if pre_checked else set() + self._menu_apply_filter() # 设 _menu_options + _menu_map(初始 query 为空 → 全量) self._render_live() + def _menu_apply_filter(self) -> None: + """Recompute visible rows (`_menu_options`) + map-to-original + (`_menu_map`) from the live query. No-op shape when not filterable.""" + q = self._menu_query.strip().lower() + if not self._menu_filterable or not q: + self._menu_options = list(self._menu_all_labels) + self._menu_map = list(range(len(self._menu_all_labels))) + else: + terms = q.split() + self._menu_options, self._menu_map = [], [] + for i, key in enumerate(self._menu_filter_keys): + kl = key.lower() + if all(t in kl for t in terms): + self._menu_options.append(self._menu_all_labels[i]) + self._menu_map.append(i) + # Focus model (filterable): -1 = the input row itself (a selectable + # object in the ↑↓ ring, v2-continue style); 0..n-1 = a candidate. + # Typing keeps focus on the input row. Non-filterable menus start at 0. + self._menu_sel = -1 if self._menu_filterable else 0 + self._menu_scroll = 0 + def _close_menu(self) -> None: self._menu_active = False self._menu_options = [] @@ -2709,6 +2875,13 @@ class SB: self._menu_on_cancel = None self._menu_multi = False self._menu_checked = set() + self._menu_filterable = False + self._menu_free = False + self._menu_on_free = None + self._menu_query = '' + self._menu_all_labels = [] + self._menu_filter_keys = [] + self._menu_map = [] @staticmethod def _scroll_window(sel: int, total: int, visible: int, scroll: int) -> int: @@ -2729,6 +2902,36 @@ class SB: checked = sorted(self._menu_checked) if multi else None if not self._menu_active: return + # Filterable: map the visible selection back to the original index; or, + # when the query matches nothing, commit it verbatim via on_free + # (free_input — e.g. a typed abs path → new workspace). + if self._menu_filterable: + mp = self._menu_map + on_free = self._menu_on_free + q = self._menu_query.strip() + # 焦点在某个候选 → 选它(映射回原始 idx)。 + if sel >= 0 and self._menu_options: + orig = mp[sel] if 0 <= sel < len(mp) else mp[0] + self._close_menu() + if cb is not None: + try: cb(orig) + except Exception as e: self.commit([_t('err.menu_cb', err=str(e))]) + return + # 焦点在输入框(sel<0):有 query → free 提交(当作路径新建/进入)。 + if self._menu_free and q and on_free is not None: + self._close_menu() + try: on_free(q) + except Exception as e: self.commit([_t('err.menu_cb', err=str(e))]) + return + # 输入框空 + 有候选 → 选第一个(便捷)。 + if self._menu_options: + orig = mp[0] + self._close_menu() + if cb is not None: + try: cb(orig) + except Exception as e: self.commit([_t('err.menu_cb', err=str(e))]) + return + return # 无 query 无候选 → 维持菜单 self._close_menu() if cb is not None: try: @@ -2767,14 +2970,50 @@ class SB: return ae.candidates[self._picker_sel] return None + def _at_root(self) -> str: + # @ 索引根 = workspace(绑定时真实路径),否则 agent 实际工作目录 + # _ROOT/temp(file_read/code_run 都相对它),而非飘忽的 os.getcwd()。 + return self._ws_path or os.path.join(_ROOT, "temp") + + def _at_active(self): + """@ 补全:返回 (query, at_pos_in_buf) 或 None。基于光标前、当前逻辑行的 + @token(@ 可在任意行任意位置;菜单 / ask 态不触发)。""" + if self._asking is not None or self._menu_active: + return None + ls = self.buf.rfind('\n', 0, self.pos) + 1 + tok = at_complete.find_at_token(self.buf[ls:self.pos]) + if tok is None: + return None + query, at_in_line = tok + return query, ls + at_in_line + + def _at_candidates(self) -> list: + """当前 @token 的候选(带 (buf,pos) 缓存,避免每键重算 fuzzy)。""" + act = self._at_active() + if act is None: + self._at_cache = None + return [] + key = (self.buf, self.pos) + if self._at_cache is not None and self._at_cache[0] == key: + return self._at_cache[1] + # 未绑 workspace → 索引根是 temp,相对路径不直观,候选用绝对路径(_hint_lines + # 本就整条显示);绑了用相对(短)。 + items = at_complete.candidates_for(act[0], self._at_root(), absolute=not self._ws_path) + self._at_cache = (key, items) + return items + + def _palette_total(self) -> int: + # 当前 palette 候选数(↓ 键越界判断用):slash 命令 or @ 文件。 + if self._slash_visible(): + return len(self._cmd_matches(self.buf)) + return len(self._at_candidates()) + def _cmd_matches(self, prefix: str) -> list[tuple[str, str, str]]: p = prefix.strip().lower() return [c for c in _cmds() if c[0].startswith(p)] - def _palette_visible(self) -> bool: - """True when the live `/`-command palette should appear and own ↑↓/Tab.""" - if self._asking is not None or self._menu_active: - return False + def _slash_visible(self) -> bool: + """True when the buffer is a live `/`-command prefix with matches.""" if '\n' in self.buf or not self.buf.startswith('/'): return False ms = self._cmd_matches(self.buf) @@ -2784,19 +3023,29 @@ class SB: return False return True + def _palette_visible(self) -> bool: + """True when the live palette should appear and own ↑↓/Tab — either the + `/`-command palette (buf starts with /) or the `@` file palette (cursor + sits in an @token). Same machinery, two candidate sources.""" + if self._asking is not None or self._menu_active: + return False + return self._slash_visible() or bool(self._at_candidates()) + def _hint_lines(self, w: int) -> list[str]: - """Live `/`-command palette: scrollback-style list with an arrow-key - highlight + scrolling viewport. ↑↓ move the highlight (handled in - `_keys`), Tab completes the highlighted command, Enter completes into - the input box. Long match lists scroll the visible window as sel walks - past the edges (no wrap-around, no "N more" chrome).""" + """Live palette: scrollback-style list with an arrow-key highlight + + scrolling viewport. ↑↓ move the highlight (handled in `_keys`), Tab / + Enter complete the highlighted entry into the input box. Renders the + `/`-command set when buf starts with `/`, else the `@` file candidates.""" if not self._palette_visible(): return [] - ms = self._cmd_matches(self.buf) - total = len(ms) + if self._slash_visible(): + rows = [f' {n:<11} {a:<8} {d}' if a else f' {n:<11} {d}' + for n, a, d in self._cmd_matches(self.buf)] + else: + rows = [' @ ' + p for p in self._at_candidates()] + total = len(rows) # Cap palette viewport to 6 rows so it doesn't squeeze the input box. visible = min(total, 6) - # Clamp sel + slide scroll so sel is in-window. if self._palette_sel < 0 or self._palette_sel >= total: self._palette_sel = 0 self._palette_scroll = self._scroll_window(self._palette_sel, total, visible, self._palette_scroll) @@ -2804,21 +3053,27 @@ class SB: end = min(total, start + visible) out: list[str] = [] for i in range(start, end): - n, a, d = ms[i] - # `<11` + literal space guarantees a visible gap between the - # 10-char names (/conductor, /morphling, /scheduler) and their - # `[arg]` placeholder — without it they render as `/conductor[task]`. - row = f' {n:<11} {a:<8} {d}' if a else f' {n:<11} {d}' - if i == self._palette_sel: - out.append(_ACCENT + _BOLD + _clip_cells(row, w) + _RST) - else: - out.append(_DIM + _clip_cells(row, w) + _RST) + styled = (_ACCENT + _BOLD) if i == self._palette_sel else _DIM + out.append(styled + _clip_cells(rows[i], w) + _RST) return out def _tab(self) -> None: if self._asking is not None or self._menu_active: return - if '\n' in self.buf or not self.buf.startswith('/'): + # @ 文件补全:用选中候选替换光标处的 @token(format_pick 加引号/尾空格)。 + if not self._slash_visible(): + items = self._at_candidates() + act = self._at_active() + if not items or act is None: + return + idx = self._palette_sel if 0 <= self._palette_sel < len(items) else 0 + rep = at_complete.format_pick(items[idx]) + at_pos = act[1] + self.buf = self.buf[:at_pos] + rep + self.buf[self.pos:] + self.pos = at_pos + len(rep) + self._palette_sel = 0 + self._palette_scroll = 0 + self._at_cache = None return ms = self._cmd_matches(self.buf) if not ms: @@ -3008,9 +3263,14 @@ class SB: # Use PTK's current viewport height (set on SB._h by the render loop). h = max(8, getattr(self, '_h', 24)) visible = min(total, self._menu_visible_count(h)) - # Clamp sel and recompute scroll so sel is in-window. - self._menu_sel = max(0, min(total - 1, self._menu_sel)) if total else 0 - self._menu_scroll = self._scroll_window(self._menu_sel, total, visible, self._menu_scroll) + # Clamp sel and recompute scroll so sel is in-window. Filterable menus + # allow sel == -1 (focus on the input row); clamp candidates to range + # but leave -1 intact. _scroll_window treats -1 as "top". + if self._menu_filterable: + self._menu_sel = -1 if self._menu_sel < 0 else (min(total - 1, self._menu_sel) if total else -1) + else: + self._menu_sel = max(0, min(total - 1, self._menu_sel)) if total else 0 + self._menu_scroll = self._scroll_window(max(0, self._menu_sel), total, visible, self._menu_scroll) start = self._menu_scroll end = min(total, start + visible) @@ -3051,6 +3311,22 @@ class SB: return r rows = [top] + # Filterable menu: a search line above the rows, showing the live query + # with a caret. Empty match set renders a hint instead of blank. + if self._menu_filterable: + q = self._menu_query + focused = (self._menu_sel < 0) # 焦点在输入框(可被 ↑↓ 选中的对象) + # 整行单一 style(纯文本交给 row,颜色走 style 参)——内嵌 ANSI 会让 + # row() 的 pad=content_w-cell_len(ch) 把转义算进宽度,右边框就错位。 + # focused → 整行 accent(箭头+占位/输入都高亮,焦点明显);否则 dim。 + if q: + text = '› ' + q + ('▏' if focused else '') + else: + text = '› ' + _t('menu.search') + rows.extend(row(text, _ACCENT if focused else _DIM)) + if not self._menu_options: + hint = (_t('menu.free.hint') if (self._menu_free and q.strip()) else _t('menu.no_match')) + rows.extend(row(hint, _DIM)) # Viewport scrolls as sel walks past the edges; the title's N/total tag # signals position, so no "N more" indicator rows. for i in range(start, end): @@ -3997,12 +4273,20 @@ class SB: self._run_shell(raw[1:].strip()) return if raw.startswith('/'): + # Expand pasted/file/image placeholders BEFORE dispatch so a command + # carries the real pasted text, not a `[Pasted text #N]` marker — e.g. + # `/morphling `. Without this the command + # path returned here before the expansion below ever ran, so the agent + # got the literal placeholder. `self.hist` already kept the raw input. + expanded = self._expand(raw) # /btw owns its own live-region panel — keep the command itself # out of the main scrollback. - cmd0 = (raw[1:].split(None, 1)[0] or '').lower() + cmd0 = (expanded[1:].split(None, 1)[0] or '').lower() if cmd0 != 'btw': - self._commit_user(raw) - self._cmd(raw); return + self._commit_user(expanded) + self._cmd(expanded) + self._pstore.clear(); self._fstore.clear(); self._imgs.clear() + return # Expand placeholders FIRST so the agent receives the resolved text, # not the [Image #N] / [Pasted #N] markers. This matches the idle # submit path below — keeping the form identical means a queued @@ -4010,17 +4294,20 @@ class SB: imgs = [self._imgs[i] for i in (int(m.group(1)) for m in _IMG_PH_RE.finditer(raw)) if i in self._imgs] expanded = self._expand(raw) + # @ mentions: agent 收绝对路径(file_read 相对自身 cwd,否则找不到), + # scrollback 显示相对(简洁)。仅改路径根、不读内容。 + agent_text = at_complete.absolutize_mentions(expanded, self._at_root()) if "@" in expanded else expanded if self._running: - wrapped = _t('pending.inject_wrap', text=expanded) + wrapped = _t('pending.inject_wrap', text=agent_text) if self._bridge and self._bridge.inject_intervene(wrapped, track=True): - self._pending.append(expanded) + self._pending.append(agent_text) self._commit_user(_t('pending.queued_marker', text=expanded)) self._pstore.clear(); self._fstore.clear(); self._imgs.clear() self._render_live() return # Agent went idle in the race — fall through to put_task. - self._commit_user(expanded) # scrollback shows exactly what - self._submit(expanded, imgs) # the agent receives, not the + self._commit_user(expanded) # scrollback 显示相对 + self._submit(agent_text, imgs) # agent 收绝对 self._pstore.clear(); self._fstore.clear(); self._imgs.clear() # drop placeholders def _cost_section(self, tname: str, t, be) -> list[str]: @@ -4060,11 +4347,98 @@ class SB: """Wipe the conversation: drop LLM history, clear the screen and every rendered block. Shared by /clear and /new.""" from frontends import continue_cmd - continue_cmd.reset_conversation(ag) + continue_cmd.begin_fresh_session(ag) # 切走:旧日志留作空闲会话 + 铸新 logid(不存快照) _w('\x1b[2J\x1b[H'); self._painted = []; self._live_rows = 0 self._blocks = []; self._streaming_block = None; self._sent = 0 self._tool_base = 0; self._tools = {} + # ── workspace(与 v2 共用 workspace_cmd;单会话 → 进程级一个绑定)───────── + def _bind_workspace(self, info, persist: bool = True) -> None: + """绑定 / 解绑 workspace。info=prepare() 的结果 dict → 绑定;None → 解绑。 + 同步 agent 的 project_mode 属性(插件据此注入项目上下文),刷新 @ 索引根。 + + persist=False: 仅刷新内存绑定状态,不写 session→ws 映射表。续接时的 + reset(_bind_workspace(None)) 用它——原地续后 agent.log_path==被续文件, + 若持久化会把本会话映射抹成 ""(= 已 off),反而毁掉自动恢复。映射表只应由 + 显式 /workspace、/workspace off 与成功的续接恢复来写。""" + ag = self._bridge.agent if self._bridge else None + if info: + self._ws_name = info.get('name') or '' + self._ws_path = info.get('target') or info.get('path') or '' + self._ws_link = info.get('link') or '' + pm_name, pm_path = (self._ws_name or None), self._ws_path + else: + self._ws_name = self._ws_path = self._ws_link = '' + pm_name, pm_path = None, '' + self._at_cache = None # 索引根可能变了,@ 候选缓存失效 + if ag is not None: + try: + ag._ga_project_mode_name = pm_name + ag._ga_project_mode_workspace_path = pm_path + except Exception: + pass + # 持久化绑定/off → /continue 即时恢复,不必先聊一轮留 PROJECT MODE 块。 + if persist: + workspace_cmd.session_ws_set(getattr(ag, "log_path", "") or "", pm_path or "") + at_complete.get_index(self._at_root()).warm() # 预热新根(或 CWD) + + def _do_workspace_activate(self, path: str) -> str: + r = workspace_cmd.prepare(path) + if not r.get('ok'): + return _t('ws.fail', default='❌ workspace 设定失败: {e}').format(e=r.get('error')) + self._bind_workspace(r) + # 显示名去 hash:真实目录 basename,退回剥 name 尾 hash。 + disp = (os.path.basename((r.get('target') or '').rstrip('/\\')) + or re.sub(r'-[0-9a-f]{8}$', '', r.get('name') or '')) + out = _t('ws.entered', default='✅ 已进入 workspace「{n}」').format(n=disp) + if r.get('warning'): + out += ' ⚠ ' + r['warning'] + return out + + def _cmd_workspace(self, arg: str) -> None: + arg = (arg or '').strip() + if arg.lower() == 'off': + if self._ws_name: + self._bind_workspace(None) + self.commit([_DIM + _t('ws.exited', + default='已退出 workspace(项目模式关闭;junction 与文件保留)') + _RST]) + else: + self.commit([_DIM + _t('ws.inactive', default='当前未处于 workspace 模式') + _RST]) + return + if arg: # 直接路径:设定/进入 + self.commit([self._do_workspace_activate(arg)]) + return + # 无参 → 菜单选已登记 workspace(去 hash / 名称末尾省略 / 路径中间省略)。 + items = workspace_cmd.registry_list() + if not items: + self.commit([_DIM + _t('ws.none', + default='暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入') + _RST]) + return + options: list[str] = [] + paths: list[str] = [] + fkeys: list[str] = [] + for it in items: + disp = (os.path.basename((it['path'] or '').rstrip('/\\')) + or re.sub(r'-[0-9a-f]{8}$', '', it['name'])) + age = _rel(it['last_used']) if it['last_used'] else '—' + mem = (f"{it['mem_lines']}行" if it['mem_lines'] else '空') + flag = ' ⚠' if it['dangling'] else '' + options.append(f"{_cell_head(disp, 22)} · {_cell_mid(it['path'], 46)} · {age} · {mem}{flag}") + paths.append(it['path']) + # 搜索键含完整路径(显示中间省略,但搜索看完整 — 与 v2 _filter_choices 一致)。 + fkeys.append(f"{disp} {it['path']}") + + def _pick(idx: int) -> None: + self.commit([self._do_workspace_activate(paths[idx])]) + + def _free(q: str) -> None: # 输入框内回车一个绝对路径 → 新建/进入 + self.commit([self._do_workspace_activate(q)]) + + self._show_menu(_t('ws.pick.title', + default='选择 workspace(输入过滤 / 绝对路径回车新建 · ↑↓ 选 · Esc 取消)'), + options, _pick, filterable=True, free_input=True, + on_free=_free, filter_keys=fkeys) + def _cmd(self, raw: str) -> None: assert self._bridge is not None parts = raw[1:].split(None, 1) @@ -4146,14 +4520,15 @@ class SB: # self.commit([_t('err.multi_session', name=name)]) elif name == 'continue': from frontends import continue_cmd - sess = continue_cmd.list_sessions(exclude_pid=os.getpid()) + sess = continue_cmd.list_sessions(exclude_log=os.path.basename(getattr(ag, "log_path", "") or "")) if not sess: self.commit([_DIM + _t('msg.no_history') + _RST]); return - def _do_restore(path: str) -> None: - msg, _ = continue_cmd.restore(ag, path) + def _rc_finish(path: str, msg: str) -> None: + # restore 后:重放对话到 scrollback + 恢复 workspace。读 ag.log_path + # (原地=源文件本身;拷贝=内容相同的新副本),内容一致。 self.commit([_DIM + '┄┄ ' + _t('msg.continue_loading', name=os.path.basename(path)) + ' ┄┄' + _RST]) - for mm in continue_cmd.extract_ui_messages(path): + for mm in continue_cmd.extract_ui_messages(getattr(ag, 'log_path', '') or path): c = (mm.get('content') or '').strip() if not c: continue @@ -4162,6 +4537,47 @@ class SB: else: self._commit_assistant(c) self.commit([_DIM + '┄┄ ' + _t('msg.continue_ready', msg=msg) + ' ┄┄' + _RST]) + # 自动恢复 workspace:续接的会话若在某个已登记 workspace 里工作过, + # 重新绑定(必要时重建 junction),不触碰 project_mode 的进程锚。 + # persist=False:reset 绑定绝不写 session→ws 映射表——原地续把 + # agent.log_path 指回 path,若持久化会把本会话映射抹成 "",在读回 + # 之前就毁掉自动恢复。 + self._bind_workspace(None, persist=False) + try: + rec = workspace_cmd.session_ws_get(path) # 路径 / "" (off) / None(无记录) + if rec is not None: + ws_path = rec or None # "" → 该会话已 off,明确不恢复 + else: + info = workspace_cmd.workspace_from_log(path) # 老会话:回退扫日志 + ws_path = info['path'] if info else None + if ws_path: + r = workspace_cmd.prepare(ws_path) + if r.get('ok'): + self._bind_workspace(r) + self.commit([_DIM + '⌂ ' + _t('ws.restored', + default='已恢复工作目录: {t}').format(t=r['target']) + _RST]) + except Exception: + pass + + def _do_restore(path: str) -> None: + # 默认原地续。快照只能拷贝续;若被活进程占用 → 弹窗问是否拷贝一份。 + if continue_cmd.is_snapshot(path): + msg, _ = continue_cmd.continue_copy(ag, path) + _rc_finish(path, msg); return + occ = continue_cmd.session_occupant(path) + if occ is not None: + def _pick(i): + if i == 0: + msg, _ = continue_cmd.continue_copy(ag, path) + _rc_finish(path, msg) + self._show_menu( + _t('continue.occupied.title', p=occ.get('pid', '?')), + [_t('continue.occupied.copy'), + _t('continue.occupied.cancel')], + _pick) + return + msg, _ = continue_cmd.continue_inplace(ag, path) + _rc_finish(path, msg) if arg: # Direct numeric argument still supported for power users / scripts. @@ -4199,6 +4615,8 @@ class SB: _do_restore(sess[idx][0]) self._show_menu(_t('continue.title'), options, _pick_session) + elif name == 'workspace': + self._cmd_workspace(arg) elif name == 'clear': self._reset_session(ag) self.commit([_DIM + _t('msg.cleared') + _RST]) @@ -5121,11 +5539,28 @@ class SB: if self._menu_active: n = len(self._menu_options) if o == 0x10: # ↑ - if n: + if self._menu_filterable: + # ring [input(-1), 0..n-1]: ↑ from input → last cand; + # ↑ from first cand → input; else step up. + if self._menu_sel < 0: + self._menu_sel = n - 1 if n else -1 + elif self._menu_sel == 0: + self._menu_sel = -1 + else: + self._menu_sel -= 1 + elif n: self._menu_sel = max(0, self._menu_sel - 1) self._render_live(); continue if o == 0x0e: # ↓ - if n: + if self._menu_filterable: + # ↓ from input → first cand; ↓ from last cand → input. + if self._menu_sel < 0: + self._menu_sel = 0 if n else -1 + elif self._menu_sel >= n - 1: + self._menu_sel = -1 + else: + self._menu_sel += 1 + elif n: self._menu_sel = min(n - 1, self._menu_sel + 1) self._render_live(); continue if self._menu_multi and ch == ' ': # Space toggles @@ -5148,6 +5583,19 @@ class SB: # a one-handed rollback without reaching for Esc. # Menus with no on_cancel just dismiss. self._menu_cancel(); continue + # Filterable menu (e.g. /workspace): printable chars + Backspace + # edit a live query that filters rows; everything else (arrows + # 0x10/0x0e/0x02/0x06 are < 0x20) still falls through to swallow. + if self._menu_filterable: + if o in (0x08, 0x7f): # Backspace / Del + if self._menu_query: + self._menu_query = self._menu_query[:-1] + self._menu_apply_filter() + self._render_live(); continue + if o >= 0x20 and o != 0x7f: # printable → append to query + self._menu_query += ch + self._menu_apply_filter() + self._render_live(); continue # swallow everything else while the menu is up continue # ── ask_user picker key intercept ─────────────────────────────── @@ -5220,7 +5668,7 @@ class SB: self._stash_draft() elif o == 0x0e: # ↓ visual-row down (history at bottom) if self._palette_visible(): - n = len(self._cmd_matches(self.buf)) + n = self._palette_total() self._palette_sel = min(n - 1, self._palette_sel + 1) if n else 0 else: self._sel = None @@ -5253,6 +5701,14 @@ class SB: elif o == 0x01: # Ctrl+A — select all if self.buf: self._sel = 0; self.pos = len(self.buf) + elif o == 0x07: # Home — jump to line start + self._sel = None + _, _, ls, _ = self._line_region() + self.pos = ls + elif o == 0x14: # End — jump to line end + self._sel = None + _, _, _, le = self._line_region() + self.pos = le elif o == 0x18: # Ctrl+X — cut selection r = self._sel_range() if r: @@ -5581,6 +6037,11 @@ def main(argv: list[str] | None = None) -> int: print(_t('err.no_tty')) return 1 _sweep_stale_task_dirs() # clear empty signal dirs left by prior runs + try: workspace_cmd.cleanup() # remove dangling/unregistered workspace junctions + except Exception: pass + try: workspace_cmd.session_map_prune() # drop session→ws entries whose log is gone + except Exception: pass + at_complete.get_index(os.path.join(_ROOT, "temp")).warm() # @ 补全:预热未绑时的默认根(temp) SB().run() return 0 diff --git a/frontends/tuiapp_v2.py b/frontends/tuiapp_v2.py index ce9153bc..c8694c5d 100644 --- a/frontends/tuiapp_v2.py +++ b/frontends/tuiapp_v2.py @@ -58,7 +58,7 @@ try: from textual import events from textual.app import App, ComposeResult from textual.binding import Binding - from textual.containers import Horizontal, Vertical, VerticalScroll + from textual.containers import Horizontal, ScrollableContainer, Vertical, VerticalScroll from textual.geometry import Region from textual.message import Message from textual.screen import ModalScreen @@ -245,6 +245,633 @@ def _render_tool_use_block(match) -> str: return f"\n*tool: {name}*\n" +# --------------------------------------------------------------------------- +# Write-tool diff rendering. +# +# `file_patch` / `file_write` are the only tools whose *content* matters at a +# glance, so we render them as a real, themed diff instead of the generic +# `tool: ` line. The render produces two parallel strings: +# - an ANSI string (line-number gutter + ±/space sign + low-saturation row +# background) injected into the colored "narrow" markdown stream, and +# - a plain string (identical visible text, no SGR) injected into the +# "wide" stream so `_align_md_renders` can pair them line-for-line and +# mouse-selection / copy still works. +# Both strings have the SAME line count and the SAME post-rstrip text, which is +# the invariant `_align_md_renders` relies on (K==W branch). +# +# The verbose display stream (agent_loop.py:78) shows tool args as pretty JSON +# inside a ```` ```text ```` fence — but `get_pretty_json` turns escaped `\n` +# into *real* newlines, so multi-line content no longer parses as JSON. Instead +# of parsing the display text, `_install_write_snapshot_hook` captures the real +# structured `args` dict (plus the file's pre-write content for `overwrite`) +# from a `tool_before` hook, keyed by `hash(get_pretty_json(args))`. At render +# time we hash the fence body and look the capture back up — content-addressed, +# so it survives re-renders and pairs each block with its exact call. +# --------------------------------------------------------------------------- + +# hash(get_pretty_json(args)) -> {"name", "args", "existed", "old"} +_WRITE_CAP: dict[int, dict] = {} + +# Cap the rendered diff body so a 2000-line file_write can't flood the chat. +_DIFF_MAX_ROWS = 80 +_DIFF_CONTEXT = 3 + +# Visual-only left margin for the rendered card: prefixed to the ANSI (narrow) +# stream but NOT the plain (wide) copy source, so mouse-copy stays margin-free. +# `_align_md_renders` maps the offset via line_indents (single-line groups), and +# `_md_line_has_box_drawing` exempts the card's `└─ ` line from the table +# passthrough that would otherwise copy the visible (margined) text. +_DIFF_MARGIN = 2 + +# Matches the verbose tool display agent_loop.py emits: +# 🛠️ Tool: `file_patch` 📥 args: +# ````text +# { ...pretty json... } +# ```` +# Group 1 = tool name, group 2 = the (newline-mangled) pretty-JSON fence body, +# which we hash to recover the real args captured by the snapshot hook. +_VERBOSE_WRITE_RE = re.compile( + r"🛠️ Tool: `(file_write|file_patch|file_read|code_run)` 📥 args:\n`{4}\w*\n(.*?)\n`{4}" + # Also swallow the dispatch output fence that immediately follows + # (`[Action]…`/`{status}` lines) — its info moves into the diff header. + r"(?:\s*`{5}\n(.*?)\n`{5})?", + re.DOTALL, +) + + +# Any other tool's verbose block — same shape, any tool name. Substituted AFTER +# `_VERBOSE_WRITE_RE` (write blocks are already sentinels by then), purely to +# add the left margin while keeping the Rich-markdown look unchanged. +_VERBOSE_TOOL_RE = re.compile( + r"🛠️ Tool: `(\w+)` 📥 args:\n`{4}\w*\n.*?\n`{4}" + r"(?:\s*`{5}\n.*?\n`{5})?", + re.DOTALL, +) + + +def _fence_status(fence_body): + """Per-block outcome from the dispatch-output fence trailing a write call + (a `{"status": ..., "msg": ...}` line). This is the only *per-occurrence* + signal: `_WRITE_CAP` is keyed by args, so two identical calls (fail → mkdir + → retry) share one capture and its stored status is last-write-wins.""" + for line in (fence_body or "").splitlines(): + try: + d = json.loads(line) + except Exception: + continue + if isinstance(d, dict) and "status" in d: + return d.get("status"), str(d.get("msg") or "") + return None + + +def _sgr_fg(hex_color: str) -> str: + r, g, b = _hex_rgb(hex_color) + return f"\x1b[38;2;{r};{g};{b}m" + + +def _sgr_bg(hex_color: str) -> str: + r, g, b = _hex_rgb(hex_color) + return f"\x1b[48;2;{r};{g};{b}m" + + +_SGR_RESET = "\x1b[0m" + + +def _chop_cells(s: str, w: int) -> list[str]: + """Split into chunks each ≤ w display cells (CJK counts as 2).""" + from rich.cells import cell_len + out, cur, cw = [], [], 0 + for ch in s: + c = cell_len(ch) + if cur and cw + c > w: + out.append("".join(cur)); cur, cw = [ch], c + else: + cur.append(ch); cw += c + if cur: + out.append("".join(cur)) + return out or [""] + + +def _cell_tail(s: str, n: int) -> str: + """Keep the tail of `s` within n cells, prefixing … if truncated.""" + from rich.cells import cell_len + if cell_len(s) <= n: + return s + if n <= 1: + return "…" + out, w = [], 0 + for ch in reversed(s): + c = cell_len(ch) + if w + c > n - 1: + break + out.append(ch); w += c + return "…" + "".join(reversed(out)) + + +def _cell_head(s: str, n: int) -> str: + """Keep the head of `s` within n cells, suffixing … if truncated. + (省略末尾 — names: keep the start, drop the tail.)""" + from rich.cells import cell_len + if cell_len(s) <= n: + return s + if n <= 1: + return "…" + out, w = [], 0 + for ch in s: + c = cell_len(ch) + if w + c > n - 1: + break + out.append(ch); w += c + return "".join(out) + "…" + + +def _cell_mid(s: str, n: int) -> str: + """Keep head+tail of `s` within n cells, eliding the middle with …. + (省略中间 — paths: the project root and the leaf both stay visible.)""" + from rich.cells import cell_len + if cell_len(s) <= n: + return s + if n <= 1: + return "…" + avail = n - 1 # reserve one cell for … + head_budget = avail - avail // 2 # head gets the rounding bias + tail_budget = avail // 2 + head, w = [], 0 + for ch in s: + c = cell_len(ch) + if w + c > head_budget: + break + head.append(ch); w += c + tail, w = [], 0 + for ch in reversed(s): + c = cell_len(ch) + if w + c > tail_budget: + break + tail.append(ch); w += c + return "".join(head) + "…" + "".join(reversed(tail)) + + +class _CardWriter: + """Accumulates a tool card's two parallel streams: `ansi` (narrow, colored, + later margined) and `plain` (wide, the copy source — no margin). `row()` + takes (text, color) segments for simple uniform lines; `raw()` takes + pre-built ansi/plain for rows with gutters/backgrounds. `finish()` applies + the visual-only left margin to the ANSI stream ONLY (keeping it out of the + copy source) and joins both. Shared by all three tool cards so the _hrow / + margin boilerplate lives in one place.""" + __slots__ = ("ansi", "plain") + + def __init__(self): + self.ansi, self.plain = [], [] + + def row(self, *segs): + self.plain.append("".join(s for s, _ in segs)) + self.ansi.append("".join(f"{_sgr_fg(c)}{s}" for s, c in segs) + _SGR_RESET) + + def raw(self, ansi_line, plain_line): + self.ansi.append(ansi_line) + self.plain.append(plain_line) + + def finish(self): + m = " " * _DIFF_MARGIN + return "\n".join(m + l for l in self.ansi), "\n".join(self.plain) + + +def _card_status_row(cw, base_segs, err, detail, width): + """Emit a card's status-bearing line: `base_segs` followed by an inline red + `✗ detail` if it fits, else a bare `✗` with `detail` wrapped onto indented + red lines. `detail=''` with `err=True` yields a bare ✗. The single place + the three cards' failure-header layout lives.""" + from rich.cells import cell_len + col = _diff_colors() + base_plain = "".join(s for s, _ in base_segs) + mark = (f" ✗ {detail}" if detail else " ✗") if err else "" + inline = (not detail) or cell_len(base_plain + mark) <= width + cw.row(*base_segs, + *([(mark if inline else " ✗", col["del_sign"])] if err else [])) + if err and not inline and detail: + for chunk in _chop_cells(detail, max(8, width - 3)): + cw.row((" " + chunk, col["del_sign"])) + + +def _card_note(cw, text): + """A card annotation line (truncation / omission notes), head color. One + style so every card's `… +N 行…` note reads alike.""" + cw.row((text, _diff_colors()["head"])) + + +def _emit_gutter(cw, content_w, segs, glyph_first, glyph_rest): + """Emit (text, color) segments behind a 2-col gutter: `glyph_first` on the + block's first physical row, `glyph_rest` after; soft-wraps each line, gutter + always dim. The code card's command (`│ `) and output (`└ `/` `) blocks + share it.""" + col = _diff_colors() + first = True + for text, color in segs: + for chunk in _chop_cells(text, content_w): + g = glyph_first if first else glyph_rest + cw.raw(f"{_sgr_fg(col['gutter'])}{g}{_sgr_fg(color)}{chunk}{_SGR_RESET}", + f"{g}{chunk}".rstrip()) + first = False + + +def _diff_colors() -> dict: + """Derive diff colors from the live `_palette`. Backgrounds/foregrounds are + blended *from the theme's own bg/fg*, so the same code yields dark tints on + a dark theme and light tints on a light one — no explicit dark/light fork.""" + p = _palette + bg = p.get("bg") or "#0d1117" + fg = p.get("fg") or "#c9d1d9" + # Fixed add/del hues so the diff semantics stay green/red on any theme + # (palette["green"] falls back to a theme's primary, which isn't always + # green). Backgrounds are still blended *from the theme bg*, so the tint + # auto-darkens on dark themes and lightens on light ones. + green = "#3fb950" + red = "#e5534b" + return { + "bg": bg, + "add_bg": _mix(bg, green, 0.18), + "del_bg": _mix(bg, red, 0.18), + "add_fg": _mix(fg, green, 0.50), + "del_fg": _mix(fg, red, 0.50), + "add_sign": green, + "del_sign": red, + "ctx_fg": _mix(bg, fg, 0.78), + "gutter": p.get("dim") or _mix(bg, fg, 0.35), + "head": p.get("muted") or _mix(bg, fg, 0.55), + } + + +def _diff_hunks(old: str, new: str, context: int = _DIFF_CONTEXT) -> list[list[tuple]]: + """Group a diff into hunks of `(sign, line_no, text)` rows. `sign` is one of + '+'/'-'/' '; `line_no` is the new-file number for '+'/' ' and the old-file + number for '-'. Uses difflib's grouped opcodes so each hunk carries `context` + unchanged lines around the change, and gaps collapse into separate hunks.""" + from difflib import SequenceMatcher + a, b = old.splitlines(), new.splitlines() + sm = SequenceMatcher(None, a, b, autojunk=False) + hunks: list[list[tuple]] = [] + for group in sm.get_grouped_opcodes(context): + rows: list[tuple] = [] + for tag, i1, i2, j1, j2 in group: + if tag == "equal": + for k in range(i2 - i1): + rows.append((" ", j1 + k + 1, b[j1 + k])) + else: + for k in range(i1, i2): + rows.append(("-", k + 1, a[k])) + for k in range(j1, j2): + rows.append(("+", k + 1, b[k])) + if rows: + hunks.append(rows) + return hunks + + +def _insert_hunk(text: str, start_no: int = 1) -> list[list[tuple]]: + """A single all-'+' hunk for a brand-new / appended / prepended file.""" + return [[("+", start_no + k, line) for k, line in enumerate(text.splitlines())]] + + +def _render_write_diff(name: str, args: dict, existed: bool, old: str, width: int, + status: str = None, msg: str = ""): + """Render a file_write/file_patch call as a themed diff. + + `existed`/`old` describe the target before the write (captured by the hook). + For file_patch, `old` (the pre-write full file) lets us render a whole-file + diff with real line numbers + surrounding context; without it we fall back to + diffing just the old/new fragment. `status` is the tool outcome + ('success'/'error'); success stays silent, error tags the header with a red ✗ + (+msg) so the noisy `[Action]`/`{status}` lines can be dropped. Returns + `(ansi, plain)` parallel strings, or `None` to fall back.""" + args = args or {} + old = old or "" + width = max(20, width - _DIFF_MARGIN) # body math sees the post-margin width + path = str(args.get("path") or "").strip() + if not path: + return None + + display_mode = None + if name == "file_patch": + old_c = str(args.get("old_content") or "") + new_c = str(args.get("new_content") or "") + if not old_c and not new_c: + return None + # Whole-file diff (real line numbers + context around the change) when we + # have the pre-write full file and old_content matches exactly once; + # otherwise diff just the fragment (no file context available, e.g. + # /continue without a tracked full file). + if existed and old and old_c and old.count(old_c) == 1: + hunks = _diff_hunks(old, old.replace(old_c, new_c, 1)) + else: + hunks = _diff_hunks(old_c, new_c) + elif name == "file_write": + new = args.get("content") + if new is None: + return None # content lives in the response body, not args — can't diff + new = str(new) + mode = str(args.get("mode") or "overwrite") + display_mode = mode + if mode in ("append", "prepend"): + if existed: + # Show append/prepend with the same surrounding unchanged context + # as file_patch by diffing the whole pre/post file, instead of an + # isolated all-green insertion block. + hunks = _diff_hunks(old, old + new if mode == "append" else new + old) + else: + hunks = _insert_hunk(new) + elif existed and old != new: + hunks = _diff_hunks(old, new) + elif existed: + return None # overwrite with identical content — nothing to diff + else: + hunks = _insert_hunk(new) + else: + return None + + if not any(hunks): + return None + + added = sum(1 for h in hunks for r in h if r[0] == "+") + removed = sum(1 for h in hunks for r in h if r[0] == "-") + max_no = max((r[1] for h in hunks for r in h), default=1) + lw = max(2, len(str(max_no))) + content_w = max(8, width - (lw + 3)) # " " prefix + col = _diff_colors() + + from rich.cells import cell_len, set_cell_size + + cw = _CardWriter() + + # Header: compact tool-call line + tree child path/counts. Keep file_patch + # bare, but show the file_write mode as file_write(overwrite|append|prepend). + # Path is tail-truncated by display width so the header never wraps. + err = (status == "error") + msg = " ".join(str(msg or "").split()) + tool_label = name if name == "file_patch" else f"{name}({display_mode})" + add_seg, del_seg = f" +{added}", f" -{removed}" + child_prefix = "└─ " + avail = width - cell_len(child_prefix + add_seg + del_seg) - (cell_len(" ✗") if err else 0) + shown = _cell_tail(path, max(4, avail)) + cw.row((tool_label, col["head"])) + _card_status_row(cw, [(child_prefix + shown, col["head"]), + (add_seg, col["add_sign"]), (del_seg, col["del_sign"])], + err, msg, width) + + def emit(sign: str, no, text: str): + text = (text or "").replace("\t", " ").replace("\x1b", "") + no_str = (str(no) if no is not None else "").rjust(lw) + if sign == "+": + row_bg, sign_c, text_c = col["add_bg"], col["add_sign"], col["add_fg"] + elif sign == "-": + row_bg, sign_c, text_c = col["del_bg"], col["del_sign"], col["del_fg"] + else: + row_bg, sign_c, text_c = None, col["gutter"], col["ctx_fg"] + bg = _sgr_bg(row_bg) if row_bg else "" + # Active soft-wrap: split long lines into width-sized physical rows so + # each row carries its own full-width background (Textual's own wrap + # leaves continuation rows unpainted). Continuation rows get a blank + # gutter so content stays aligned under the first row. + for ci, chunk in enumerate(_chop_cells(text, content_w)): + body = set_cell_size(chunk, content_w) # pad to exactly content_w cells + if ci == 0: + gutter = f"{_sgr_fg(col['gutter'])}{no_str} {_sgr_fg(sign_c)}{sign} " + cw.raw(f"{bg}{gutter}{_sgr_fg(text_c)}{body}{_SGR_RESET}", + f"{no_str} {sign} {chunk}") + else: + gutter = " " * (lw + 3) + cw.raw(f"{bg}{gutter}{_sgr_fg(text_c)}{body}{_SGR_RESET}", + f"{' ' * (lw + 3)}{chunk}") + + rows_emitted = 0 + truncated = 0 + for hi, hunk in enumerate(hunks): + if hi > 0: + cw.raw(f"{_sgr_fg(col['gutter'])}{'⋯'.rjust(lw + 2)}{_SGR_RESET}", + "⋯".rjust(lw + 2)) + for sign, no, text in hunk: + if rows_emitted >= _DIFF_MAX_ROWS: + truncated += 1 + continue + emit(sign, no, text) + rows_emitted += 1 + if truncated: + _card_note(cw, f"… +{truncated} 行未显示") + + return cw.finish() + + +_READ_MAX_ROWS = 10 + +# Lines of a file_read result that exist for the LLM, not the user: the +# show_linenos preamble, the `[FILE] N lines | PARTIAL …` header, truncation +# tips, and the keyword-fallback explanation. All stripped from the card. +_READ_NOISE_RE = re.compile( + r"^(?:由于设置了show_linenos|\[FILE[\] ]|\[FILE PARTIAL|(某些行被截断" + r"|Keyword '.*' not found after line )" +) + + +def _render_read_card(args, content, width): + """Render a file_read call like the write cards: header + the lines the + tool actually returned, LLM-facing chrome stripped (see _READ_NOISE_RE). + The gutter always shows line numbers when they're knowable: parsed from the + `N|` prefixes (show_linenos), else synthesized from `start` for sequential + reads — numbers and content are decoupled; the only number the renderer + can't derive is a keyword window's position without `N|`. smart_format's + `[omitted long content]` hole renders as a `⋯` row and ends the gutter for + the rest of the card (parsed and synthesized alike — the cut is mid-line, + so post-hole numbers mislead). + Body capped at _READ_MAX_ROWS with a tail count. `Error:` results render + header-✗ only — the Did-you-mean suggestions are for the agent's + self-correction, not the user. Returns (ansi, plain) or None.""" + args = args or {} + width = max(20, width - _DIFF_MARGIN) + path = str(args.get("path") or "").strip() + if content is None or not path: + return None + from rich.cells import cell_len + col = _diff_colors() + text = str(content) + err = text.startswith("Error:") + + rows: list[tuple] = [] # (lineno_or_None, text) + linenos = bool(args.get("show_linenos", True)) + if not err: + # Strip LLM-facing chrome, then trim blank runs at both ends + # (separators around the stripped noise) BEFORE numbering; interior + # blanks are real file content and stay. + lines = [ln for ln in text.split("\n") if not _READ_NOISE_RE.match(ln.strip())] + while lines and not lines[0].strip(): + lines.pop(0) + while lines and not lines[-1].strip(): + lines.pop() + # Without `N|` prefixes, synthesize the gutter: a sequential read's + # lines are start, start+1, … Stay blank where numbers would be + # guesses: a keyword window's position is unknown (unless the keyword + # fell back to a sequential read). + fell_back = "not found after line" in text.split("\n", 1)[0] + synth = (not linenos) and (not args.get("keyword") or fell_back) + no = int(args.get("start") or 1) if synth else None + holed = False + for ln in lines: + if ln.strip() == "[omitted long content]": + rows.append((None, "⋯")) + no, holed = None, True # gutter ends at the hole: the cut is + continue # mid-line, post-hole numbers mislead + m = re.match(r"^(\d+)\|(.*)$", ln) if linenos else None + if m: + rows.append((None if holed else int(m.group(1)), m.group(2))) + else: + rows.append((no, ln)) + if no is not None: + no += 1 + if not rows: + return None + + msg = " ".join(text.split("\n")[0][len("Error:"):].split()) if err else "" + child_prefix = "└─ " + avail = width - cell_len(child_prefix) - (cell_len(" ✗") if err else 0) + shown = _cell_tail(path, max(4, avail)) + + cw = _CardWriter() + cw.row(("file_read", col["head"])) + _card_status_row(cw, [(child_prefix + shown, col["head"])], err, msg, width) + + shown_rows = rows[:_READ_MAX_ROWS] + hidden = len(rows) - len(shown_rows) + max_no = max((no for no, _ in shown_rows if no), default=0) + lw = max(2, len(str(max_no or 1))) + content_w = max(8, width - (lw + 3)) + for no, body in shown_rows: + body = (body or "").replace("\t", " ").replace("\x1b", "") + no_str = (str(no) if no is not None else "").rjust(lw) + for ci, chunk in enumerate(_chop_cells(body, content_w)): + gutter = f"{no_str} " if ci == 0 else " " * (lw + 3) + cw.raw(f"{_sgr_fg(col['gutter'])}{gutter}" + f"{_sgr_fg(col['ctx_fg'])}{chunk}{_SGR_RESET}", + f"{gutter}{chunk}".rstrip()) + if hidden > 0: + _card_note(cw, f"… +{hidden} 行未显示") + + return cw.finish() + + +_CODE_MAX_CODE_ROWS = 20 # 代码段从头展示 +_CODE_MAX_OUT_ROWS = 15 # 输出段保尾展示(报错 traceback 关键在末尾) +_CODE_DEFAULT_CWD = {"", ".", "./", ".\\"} + + +def _code_parse_data(data): + """把 code_run 的 StepOutcome.data 归一成 (out_text, exit_code, err_msg, is_err)。 + 兼容三种返回形态: + - 正常/进程异常 dict: {"status","stdout","exit_code"} 或 {"status":"error","msg"} + - inline_eval 字符串: eval 的 repr,或 'Error: ...' + - 代码缺失字符串: '[Error] Code missing ...' + out_text 只取真正的输出(dict 的 stdout / 字符串结果),绝不回退 msg——msg + 是 dict 进程异常的摘要,单独走 err_msg 进头部,避免和输出段重复显示。 + is_err 标记失败但无独立 detail 的情形(字符串错误本身就是输出),让头部至少 + 打个裸 ✗。out_text 已规整 CRLF→LF。""" + exit_code, err_msg, is_err = None, "", False + if isinstance(data, dict): + out_text = data.get("stdout") or "" + exit_code = data.get("exit_code") + if data.get("status") == "error": + is_err = True + err_msg = str(data.get("msg") or "") + elif isinstance(data, str): + out_text = data + if data.lstrip().startswith(("Error:", "[Error]")): + is_err = True # 内容即错误,留给输出段;头部裸 ✗ + else: + out_text = "" if data is None else str(data) + out_text = str(out_text).replace("\r\n", "\n").replace("\r", "\n") + return out_text, exit_code, err_msg, is_err + + +def _render_code_card(args, data, width): + """CC/Codex-style gutter card for code_run — no borders, structure comes + from dim gutters (Codex's exec cell look, adapted to our card family): + + code_run(python) ← header; red `✗ Exit 1 …` on failure + │ import os ← command: `│ ` gutter (plain text) + │ print(run()) + └ epoch 1 loss 0.3 ← output: `└ ` + dim text, continuation + … +12 行已省略 indented 2; TAIL-kept (traceback ends + ValueError: boom matter most — differs from CC/Codex) + + The command segment renders only when the code rode in via `script`/`code` + args; a body ```block``` source is already rendered above by markdown. + Returns (ansi, plain); tolerant of every data shape (_code_parse_data).""" + args = args or {} + width = max(20, width - _DIFF_MARGIN) + col = _diff_colors() + ctype = str(args.get("type") or "python").strip() or "python" + code = str(args.get("script") or args.get("code") or "") + out_text, exit_code, err_msg, is_err = _code_parse_data(data) + + # Timeout / manual-stop markers live inside stdout (ga.py:72-73). + note_extra = ("超时" if "[Timeout Error]" in out_text + else "已停止" if "[Stopped]" in out_text else "") + # Header failure detail. exit_code may be None (inline_eval has none, a + # killed process polls None) — only a non-zero *integer* counts. err_msg is + # the dict-error summary; a string-error's text stays in the output body, so + # it sets is_err (bare ✗) but contributes no detail (avoids duplication). + parts = [] + if isinstance(exit_code, int) and exit_code != 0: + parts.append(f"Exit {exit_code}") + if note_extra: + parts.append(note_extra) + if err_msg: + m = err_msg + for p in ("Error:", "[Error]"): + if m.startswith(p): + m = m[len(p):].strip() + if m: + parts.append(m) + err = is_err or bool(parts) + detail = " ".join(" · ".join(parts).split()) + + cw = _CardWriter() + _card_status_row(cw, [(f"code_run({ctype})", col["head"])], err, detail, width) + cwd = str(args.get("cwd") or "").strip() + if cwd and cwd not in _CODE_DEFAULT_CWD: + for chunk in _chop_cells(f"cwd: {cwd}", max(8, width)): + cw.row((chunk, col["gutter"])) + + content_w = max(8, width - 2) # behind the 2-col gutter + + # Command segment: `│ ` gutter on every physical line (plain text). Only + # when the code rode in via script/code args (a body ```block``` source is + # already rendered above by markdown). + code_lines = code.split("\n") if code.strip() else [] + if code_lines: + shown = [l.replace("\t", " ").replace("\x1b", "") + for l in code_lines[:_CODE_MAX_CODE_ROWS]] + segs = [(l, col["ctx_fg"]) for l in shown] + extra = len(code_lines) - len(shown) + if extra > 0: + segs.append((f"… +{extra} 行未显示", col["head"])) + _emit_gutter(cw, content_w, segs, "│ ", "│ ") + + # Output segment: `└ ` first row, ` ` after, dim. Tail-kept — the omission + # note leads, the tail lines follow (a traceback's last lines matter most). + out_lines = [l.replace("\t", " ").replace("\x1b", "") + for l in out_text.split("\n")] + while out_lines and not out_lines[-1].strip(): + out_lines.pop() + segs = [] + omitted = len(out_lines) - _CODE_MAX_OUT_ROWS + if omitted > 0: + segs.append((f"… +{omitted} 行已省略", col["head"])) + out_lines = out_lines[-_CODE_MAX_OUT_ROWS:] + if not out_lines and not segs: + segs.append(("(无输出)", col["gutter"])) + segs += [(l, col["ctx_fg"]) for l in out_lines] + _emit_gutter(cw, content_w, segs, "└ ", " ") + + return cw.finish() + + def _extract_user_text(entry: dict) -> str: c = entry.get("content") if isinstance(entry, dict) else None if isinstance(c, str): @@ -546,8 +1173,30 @@ def _md_line_has_box_drawing(line: str) -> bool: newlines. Use the Unicode Box Drawing block so SIMPLE/ROUNDED/HEAVY/etc. table styles are covered while em-dashes (`—`) and ASCII/Unicode hyphens are not mistaken for tables. + + A leading `\u2514\u2500 ` (with the space) is the write-diff card's child-path line, + not a table border \u2014 borders run the glyphs together (`\u2514\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2518`). Same + for the code card's `\u2502 ` / `\u2514 ` gutters, and a gutter-only row (`\u2502` / `\u2514` + left after rstrip trims the trailing space of an empty command/output line). + Exempt all of them so the cards keep their exact narrow\u2194wide line mapping + instead of the visible-text passthrough (which would copy the card's + visual-only margin). + + A pure horizontal run (only `─` + spaces) is NOT exempt: a SIMPLE-box + Markdown table's only box glyph is its header rule, so exempting bare + `─` rows drops the whole table out of passthrough and misaligns CJK + copy. The cosmetic cost is that a real markdown hr copies as a dash run + — the pre-card behavior, never reported as a problem. """ - return any("\u2500" <= ch <= "\u257f" for ch in line) + s = line.lstrip() + for pfx in ("\u2514\u2500 ", "\u2502 ", "\u2514 "): + if s.startswith(pfx): + s = s[len(pfx):] + break + else: + if s in ("\u2502", "\u2514"): # gutter-only row (empty command/output line) + s = "" + return any("\u2500" <= ch <= "\u257f" for ch in s) def _md_run_has_box_drawing(lines: list[str]) -> bool: @@ -640,7 +1289,13 @@ def _align_md_renders(narrow_raw: str, wide_raw: str): if j > g_start - run_start: content, _ = _strip_quote_deco(nt.lstrip()) else: - content = nt + # First line of the group: drop any visual-only left + # margin (narrow lead beyond the wide line's own lead — + # margined tool blocks) so the accumulation against the + # wide target stays balanced. + n_lead = len(nt) - len(nt.lstrip()) + w_lead = len(w_line) - len(w_line.lstrip()) + content = nt[max(0, n_lead - w_lead):] accumulated += len(content) j += 1 # Each wrap boundary eats one space from the wide line, so @@ -725,8 +1380,14 @@ def _align_md_renders(narrow_raw: str, wide_raw: str): for k in range(g_start, g_end): nt = narrow[k] if k == g_start: - content = nt - indent = 0 + # A narrow line with MORE lead than its wide twin is showing + # a visual-only left margin (write-diff cards / margined tool + # blocks). Map it as indent so selection x-coords shift and + # copies exclude it; drop it from `content` so the pointer + # accounting over the wide line stays balanced. + nt_lead = len(nt) - len(nt.lstrip()) + indent = max(0, nt_lead - wide_lead) + content = nt[indent:] else: indent = len(nt) - len(nt.lstrip()) content = nt.lstrip() @@ -743,6 +1404,16 @@ def _align_md_renders(narrow_raw: str, wide_raw: str): return "".join(source_parts).rstrip("\n"), line_starts, line_indents, line_lengths +# --------------------------------------------------------------------------- +# @ 文件引用(at-mention)— 补全版(completion-only) +# 编辑期:光标处 @token → 后台文件索引 + 模糊匹配 → 复用 #palette 下拉, +# 选中把 @路径 补进输入框(索引根 = 会话 workspace,未绑定退化为 CWD)。 +# 提交期:不处理,@路径 作为普通文本发给 agent,由其自行决定是否 file_read。 +# 纯逻辑(索引/模糊/token)抽到 frontends/at_complete.py,与 tui_v3 共用; +# 自动预读那一版见 temp/plan_v2_at_mention/autoread_version.py。 +from at_complete import get_index, fuzzy_rank, find_at_token, format_pick, candidates_for, absolutize_mentions + + ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if ROOT_DIR not in sys.path: sys.path.insert(0, ROOT_DIR) @@ -779,7 +1450,15 @@ from chatapp_common import format_restore from btw_cmd import handle_frontend_command as btw_handle from review_cmd import handle as review_handle from continue_cmd import list_sessions as continue_list, extract_ui_messages as continue_extract +import workspace_cmd from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard +from worldline import ( + RewindStore, restore_plan, + ellipsize, rel_time, files_summary, kind_glyph, kind_label, + CheckpointTree, tree_from_store, CompressedTree, + _order_depths, next_same_depth, nearest_depth_node, parent_sibling_first_child, +) +# RewindTreeScreen 等三栏树 UI 已内联到本文件末尾(原 rewind_tree_view.py),跟随 v2 主题配色。 # Cross-platform clipboard copy for /export clip. Mirrors tui_v3's native-tool # strategy but stays local to v2 so the Textual frontend has no dependency on @@ -1090,15 +1769,37 @@ Screen { background: $ga-bg; color: $ga-fg; } `display: none` default so the empty post-compose frame doesn't flash; renderer flips it on once items materialize. Fixed height (no scroll) keeps layout stable; body truncates to 4 items + "+N more" footer. */ -#planbar { +/* Plan card. Outer #planbar-scroll owns the frame (border/padding) + show-hide. + #planbar-head pins the header + current-step line. #planbar-tasks is the only + scrolling region: capped at 4 rows so at most 4 TODO items show at once, the + rest reachable by wheel/PageUp. */ +#planbar-scroll { display: none; - height: 5; - max-height: 5; + height: auto; background: $ga-sel-bg; padding: 0 1; margin: 0 0 1 0; border-left: thick $ga-green; } +#planbar-scroll.-visible { display: block; } +#planbar-head { + height: auto; + background: $ga-sel-bg; +} +#planbar-tasks { + height: auto; + max-height: 4; + background: $ga-sel-bg; + scrollbar-size: 0 1; + scrollbar-background: $ga-sel-bg; + scrollbar-background-hover: $ga-sel-bg; + scrollbar-background-active: $ga-sel-bg; + scrollbar-color: $ga-border; +} +#planbar { + height: auto; + background: $ga-sel-bg; +} /* `└ Tip:` footer — one dim row, never grows. */ #tipbar { @@ -1251,6 +1952,16 @@ class ChatMessage: searchable: bool = False search_query: str = "" all_choices: Optional[list] = field(default=None, repr=False) + # Free-input opt-in for searchable pickers (/model): when True, Enter in + # the search box with NO selectable match commits the raw query text as + # the value — the filter box doubles as a custom-name input. /continue + # keeps the default False, so a no-match Enter stays a no-op there. + free_input: bool = False + # Optional placeholder for the search Input ("" → the /continue default). + search_placeholder: str = "" + # Hint row shown (disabled) under the search Input when choices is empty — + # doubles as a "loading…" indicator for pickers filled asynchronously (/model). + empty_hint: str = "(no matches)" image_paths: list[str] = field(default_factory=list) _role_widget: Any = field(default=None, repr=False) _hint_widget: Any = field(default=None, repr=False) @@ -1295,6 +2006,10 @@ class AgentSession: input_pastes: dict[int, str] = field(default_factory=dict) input_paste_counter: int = 0 buffer: str = "" + # Per-session workspace/project-mode binding. Empty means ordinary mode. + workspace_name: str = "" + workspace_path: str = "" + workspace_link: str = "" # Drives topbar heat-color ramp + elapsed label; set on first running tick. _busy_since: Optional[float] = None # Stamps running→idle; topbar dot flashes green for ~5s after. @@ -1308,9 +2023,14 @@ class AgentSession: plan_items: list = field(default_factory=list) plan_complete_since: Optional[float] = None plan_lost_since: Optional[float] = None - # Boundary between restored history (≤ idx) and this run (> idx); - # `/continue` bumps to `len(messages)` so old plan cards don't resurrect. + # Boundary between restored history (≤ idx) and this run (> idx); only + # `current_step`'s 📌-line scan uses it now — card activation no longer + # reads messages. plan_scan_baseline: int = 0 + # plan.md recovered from the transcript's structured `enter_plan_mode` + # tool_use by /continue (continue_cmd.find_plan_entry). Drives card + # activation alongside the live `working['in_plan_mode']` stash. + restored_plan_path: str = "" # `pending`: raw user text for UI display ([queued #N] chip). # `pending_wrapped`: same entries wrapped with the "complete current # task first" supplementary phrasing, in the form actually appended @@ -1323,8 +2043,10 @@ class AgentSession: def default_agent_factory() -> Any: from agentmain import GenericAgent + from frontends.slash_cmds import COMMIT_SIGNATURE_PROMPT agent = GenericAgent() agent.inc_out = True + agent.extra_sys_prompts.append(COMMIT_SIGNATURE_PROMPT) return agent @@ -1338,10 +2060,13 @@ COMMANDS = [ ("/close", "", "关闭当前会话"), ("/rename", "", "重命名当前会话(持久化)"), ("/branch", "[name]", "从当前会话分支"), - ("/rewind", "[n]", "回退最近 n 轮"), + ("/rewind", "[n]", "回退面板(时间线)/ 直接回退 n 轮"), + ("/worldline", "", "世界线 — checkpoint 树状回退(打开即选中当前节点)"), ("/clear", "", "清空显示(不动 LLM 历史)"), ("/stop", "", "中止当前任务"), ("/llm", "[n]", "查看 / 切换模型"), + ("/model", "[name]", "查看 / 设置当前渠道的 model(列表在线拉取)"), + ("/effort", "[level]", "查看 / 设置 reasoning effort(off 清除;Claude xhigh→max)"), ("/btw", "", "side question — 不打断主 agent"), ("/review", "[request]", "in-session 代码审查(直接输出报告)"), # ── slash_cmds bundle (prompt-injection + /scheduler picker). Kept in @@ -1354,6 +2079,7 @@ COMMANDS = [ ("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"), ("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"), ("/continue", "[n|name]", "列出 / 恢复历史会话"), + ("/workspace","[path|off]", "设定工作目录(绝对路径)并进入项目模式"), ("/resume", "", "列出最近会话并恢复其中一个"), ("/cost", "[all]", "显示当前会话 token 用量(all = 所有会话)"), ("/export", "clip||all", "导出最后回复"), @@ -1385,9 +2111,25 @@ class ChoiceList(OptionList): Binding("escape", "cancel", "Cancel", show=False)] def __init__(self, msg: "ChatMessage", *options, **kwargs): - super().__init__(*options, **kwargs) + super().__init__(*[self._single_line(o) for o in options], **kwargs) self.msg = msg + @staticmethod + def _single_line(item): + # A str prompt → a no-wrap, ellipsis-on-overflow Option so a long + # candidate (e.g. a workspace `name · /very/long/path · …`) stays on + # exactly one row instead of soft-wrapping into several. Already-built + # Option / None pass through untouched. + if isinstance(item, str): + return Option(Text(item, no_wrap=True, overflow="ellipsis")) + return item + + def add_option(self, option=None): + return super().add_option(self._single_line(option)) + + def add_options(self, items): + return super().add_options([self._single_line(i) for i in items]) + def action_cancel(self) -> None: try: self.app._cancel_choice(self.msg) @@ -1453,7 +2195,7 @@ class LazyChoiceList(ChoiceList): take = (len(self._lazy_labels) - self._lazy_loaded) if count is None else max(1, int(count)) end = min(len(self._lazy_labels), self._lazy_loaded + take) try: - self.add_options([Option(self._lazy_labels[i]) for i in range(self._lazy_loaded, end)]) + self.add_options([self._lazy_labels[i] for i in range(self._lazy_loaded, end)]) except Exception: # If the list isn't mounted yet (very early call), fall back to # buffering via _options if available; otherwise silently bail so @@ -1515,7 +2257,10 @@ def _filter_choices(all_choices: list, query: str) -> list: `all_choices` is `[(label, value), ...]`. Each whitespace-separated token in `query` must hit somewhere in either: * the label text (cheap, always tried first), or - * the basename of `value` when it looks like a path, or + * the **full** `value` when it's a string (e.g. a workspace's complete + real path — so a mid-path directory still matches even though the + displayed label elides the middle; display-layer truncation must not + shrink the searchable data), or * the **content** of the session file at `value` (first ~1MB), so users who remember a phrase from inside a session ("Conductor", "subB diff", a file path they pasted) can find it back. @@ -1549,7 +2294,10 @@ def _filter_choices(all_choices: list, query: str) -> list: continue meta = str(label).lower() if isinstance(value, str) and value: - meta = meta + "\n" + os.path.basename(value).lower() + # Full value, not just basename: workspace pickers put the complete + # real path here, and the displayed label elides the middle — search + # must see the whole path so a mid-path term still matches. + meta = meta + "\n" + value.lower() if all(t in meta for t in terms): out.append(item) continue @@ -1595,7 +2343,8 @@ class SearchableChoiceList(Vertical): def compose(self): self._search_input = Input( value=self.msg.search_query or "", - placeholder="Search sessions: type to filter, Esc to cancel", + placeholder=(self.msg.search_placeholder + or "Search sessions: type to filter, Esc to cancel"), id="continue-search", ) yield self._search_input @@ -1715,7 +2464,7 @@ class SearchableChoiceList(Vertical): if not filtered: # Show a disabled hint row so Enter on an empty result set is a # no-op rather than a crash inside _collapse_choice. - empty = ChoiceList(self.msg, "(no matches)", classes="picker") + empty = ChoiceList(self.msg, self.msg.empty_hint or "(no matches)", classes="picker") try: empty.disabled = True except Exception: @@ -1792,10 +2541,41 @@ class SearchableChoiceList(Vertical): if not at_end: return if key in ("enter", "right"): + # Don't go through picker.action_select() here: the OptionSelected + # it posts is constructed while *this* widget's pump is active, so + # its _sender == picker's parent and Textual auto-stops the bubble + # one hop up — the App handler never sees it. Collapse directly. + committed = False try: - self.picker.action_select() + hi = self.picker.highlighted + opts = getattr(self.picker, "_options", []) + # Eligibility must be checked here, not inferred from + # _collapse_choice "not raising": it silently no-ops on an + # out-of-range idx (the disabled empty-hint picker highlights + # its placeholder row at 0 while msg.choices is []). + if (hi is not None and 0 <= hi < len(self.picker.msg.choices) + and hi < len(opts) and not opts[hi].disabled): + self.app._collapse_choice(self.picker.msg, hi) + committed = True except Exception: pass + # Free-input pickers (/model): Enter with no selectable match + # commits the raw query as the value — the search box doubles as + # a custom-name input (list still loading / fetch failed / name + # not in the list). + if (not committed and key == "enter" + and getattr(self.msg, "free_input", False)): + q = "" + try: + q = (self._search_input.value or "").strip() + except Exception: + pass + if q: + try: + self.msg.choices = list(self.msg.choices or []) + [(q, q)] + self.app._collapse_choice(self.msg, len(self.msg.choices) - 1) + except Exception: + pass event.stop(); event.prevent_default() return @@ -2032,16 +2812,17 @@ class InputArea(TextArea): except Exception: pass def _stash_cleanup_restore(self, stashed: str) -> None: - """Deferred companion to action_stash (restore path).""" + """Deferred companion to action_stash (restore path). Mirrors the + clear path: `self.text = stashed` rebuilds Document + WrappedDocument + and triggers a full re-wrap + screen-wide relayout, which freezes the + UI for seconds on long sessions. Inject through the edit pipeline + instead so only the affected range re-wraps; `_insert_via_keyboard` + also moves the caret to the end, re-focuses, and resizes.""" try: self._suppress_palette_next_change() except Exception: pass - self.text = stashed - try: - self.cursor_location = self.document.end - except Exception: - pass - try: self.app._resize_input(self) - except Exception: pass + if self.document.text: + self.clear() + self._insert_via_keyboard(stashed) def action_clear_input(self) -> None: self.reset() @@ -2088,8 +2869,25 @@ class InputArea(TextArea): text = f"[Pasted text #{sid} +{line_count} lines]" self._insert_via_keyboard(text) + def _paste_gesture_echo(self, source: str) -> bool: + """One VSCode right-click can emit BOTH a forwarded mouse-click + (→ action_paste, source='manual') and a native bracketed paste + (→ _on_paste, source='bracketed'), pasting the clipboard twice. Treat the + second arrival from the *other* mechanism within a short window as an echo + and report it so the caller can skip. Same-mechanism repeats (a deliberate + double Ctrl+V) and lone gestures are never suppressed.""" + now = time.monotonic() + prev = self._last_paste_gesture + if prev and prev[0] != source and now - prev[1] < 0.5: + self._last_paste_gesture = None # pair consumed; next gesture starts clean + return True + self._last_paste_gesture = (source, now) + return False + def action_paste(self) -> None: - if self.read_only or self._paste_file_from_clipboard(): + if self.read_only or self._paste_gesture_echo("manual"): + return + if self._paste_file_from_clipboard(): return text = _read_clipboard_text() or getattr(self.app, "clipboard", "") if text: @@ -2160,6 +2958,7 @@ class InputArea(TextArea): super().__init__(*args, **kwargs) self._pastes: dict[int, str] = {} self._paste_counter = 0 + self._last_paste_gesture: Optional[tuple[str, float]] = None # (source, monotonic) — VSCode right-click double-paste guard self._input_history: list[str] = [] self._history_index: int = -1 # -1 means not browsing self._history_stash: str = "" @@ -2258,16 +3057,20 @@ class InputArea(TextArea): # Terminal Ctrl+V in bracketed-paste mode lands here, bypassing action_paste. if self.read_only: return + event.stop(); event.prevent_default() + # VSCode right-click fires this Paste AND a forwarded mouse-click + # (→ _on_click → action_paste); collapse the duplicate. See _paste_gesture_echo. + if self._paste_gesture_echo("bracketed"): + return if self._paste_file_from_clipboard(): - event.stop(); event.prevent_default(); return + return # Git-bash / mintty fallback: PIL.ImageGrab can't return Image objects # in that TTY env, but the OS clipboard does hold the file path the # screenshot tool wrote. Treat a single-line, on-disk path as if the # file grab had succeeded — same placeholder + `_pastes` entry. if self._paste_file_from_text(event.text): - event.stop(); event.prevent_default(); return + return self._insert_paste_text(event.text) - event.stop(); event.prevent_default() def _paste_file_from_text(self, raw: str) -> bool: if not raw: return False @@ -2411,7 +3214,8 @@ def render_status_chip(busy: bool, elapsed: int = 0) -> Text: def render_topbar(session_name: str, status: str, model: str, tasks_running: int, fold_mode: bool = True, busy_elapsed: int = 0, effort: str = "", sess_elapsed: int = 0, - just_done: bool = False, term_width: int = 0) -> Table: + just_done: bool = False, term_width: int = 0, + workspace: str = "") -> Table: # Layout: identity-chip + session + status + fold packed LEFT; model + effort # + tasks CENTERED; clock RIGHT. The 2:2:1 ratio keeps the centered model # chip visually anchored even when the left column has the long status pill. @@ -2464,6 +3268,13 @@ def render_topbar(session_name: str, status: str, model: str, tasks_running: int # narrow `▾ fold` glyph from being eaten by the left's ellipsis when the # running status pill fills the column budget. right = Text() + # workspace chip (top-right) — only when active. Clean real-dir basename, + # never the hashed junction name, so the hash never reaches the user. + if workspace: + short_ws = workspace if len(workspace) <= 18 else workspace[:17] + "…" + right.append("workspace: ", style=C_MUTED) + right.append(short_ws, style=f"bold {C_GREEN}") + right.append(" · ", style=C_DIM) if fold_mode: right.append("▾ fold", style=C_DIM) right.append(" · ", style=C_DIM) @@ -2692,6 +3503,282 @@ class ThemePicker(ModalScreen): self.dismiss() +# =========================================================================== +# /rewind 面板 — 会话 checkpoint 的时间线 / 树状可视化 + 对话恢复 +# +# GA 没有真正的 rewind 后端(checkpoint 树是从 LLM history 现算的),所以这里的 +# 「节点」= 用户提问边界(与既有 /rewind 的 _rewindable_turns 同源),每个节点的 +# `n` 直接喂给 _do_rewind(n)。「恢复对话」= 把 backend.history 截断回该提问之前 +# (复用既有 _do_rewind,仅在 agent 空闲时允许);restore code / fork / diff / +# delete 需要文件快照后端,本版为占位(详见需求文档 §9 的「暂缓」决策)。 +# =========================================================================== + +# tui_v2 主题色(C_FG/C_DIM/C_GREEN/C_BLUE 随主题刷新,这里直接复用模块全局); +# 补几个 v2 未定义的固定色。 +C_ALT_BG = "#21262d" +C_AMBER = "#f0883e" +# 世界线树(/worldline)配色:除 diff 删除红是固定语义色外,其余跟随 v2 主题(_palette)。 +C_BG = _palette["bg"] +C_BORDER = _palette["border"] +C_CYAN = C_BLUE # 聚焦高亮 → 跟主题蓝 +C_LAVENDER = C_PURPLE # lane 配色 → 跟主题紫 +C_RED = "#e5534b" # diff 删除行(固定语义色) + + +def _rw_rel_time(ago_s): + """粗粒度相对时间(不精确到秒);ago_s 为 None 时返回空串。""" + if ago_s is None: + return "" + if ago_s < 60: + return "刚刚" + m = ago_s // 60 + if m < 60: + return f"{m} 分钟前" + h = m // 60 + if h < 24: + return f"{h} 小时前" + return f"{h // 24} 天前" + + +def _rw_files_summary(files): + if not files: + return "" + if len(files) <= 3: + return "、".join(files) + return "、".join(files[:3]) + f" (+{len(files) - 3})" + + +def _rw_user_text(content): + """从一条 history 消息的 content 里取用户可见文本。""" + if isinstance(content, str): + return content + if isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "text" and b.get("text"): + return b["text"] + return "" + + +def _rw_files_in_range(history, lo, hi): + """该提问段内经 file_write / file_patch 改动的文件名(去重,顺序保留)。 + 与「code_run 暂不追踪」的后端决策一致——只认结构化文件工具的 path。""" + seen = [] + for m in history[lo:hi]: + if m.get("role") != "assistant": + continue + c = m.get("content") + if not isinstance(c, list): + continue + for b in c: + if (isinstance(b, dict) and b.get("type") == "tool_use" + and b.get("name") in ("file_write", "file_patch")): + p = (b.get("input") or {}).get("path") + if p: + base = os.path.basename(str(p)) + if base not in seen: + seen.append(base) + return seen + + +def _rw_collect(sess): + """从一个 session 构造 rewind 节点(oldest→newest),末尾追加一个合成的 + HEAD「当前位置」节点(n=None → 恢复是 no-op)。 + + 节点 = 真实用户提问边界(与 _rewindable_turns 同源),故节点的 `n` 可直接 + 传给 _do_rewind(n)。时间戳来自 _install_rw_time_hook 按 history 长度打的点; + 没有记录(如 /continue 恢复来的旧会话)则相对时间为空。""" + history = sess.agent.llmclient.backend.history + bounds = [] + for i, m in enumerate(history): + if m.get("role") != "user": + continue + c = m.get("content") + if isinstance(c, list) and any( + isinstance(b, dict) and b.get("type") == "tool_result" for b in c): + continue # tool 回填,不是真实提问 + txt = _rw_user_text(c) + if txt and txt.strip(): + bounds.append((i, txt)) + + times = getattr(sess, "_rw_times", {}) or {} + now = time.time() + + def ago_at(hi): + ks = [k for k in times if k <= hi] + if not ks: + return None + return int(max(0, now - times[max(ks)])) + + nodes = [] + total = len(bounds) + for p, (idx, txt) in enumerate(bounds): + nxt = bounds[p + 1][0] if p + 1 < total else len(history) + title = txt.replace("\n", " ").strip()[:80] or "(空)" + nodes.append({ + "n": total - p, # _do_rewind(n):1 = 最近一次提问 + "title": title, + "files": _rw_files_in_range(history, idx, nxt), + "ago": ago_at(nxt), + "kind": "edit", + }) + nodes.append({ + "n": None, "title": "当前位置", "files": [], + "ago": ago_at(len(history)), "kind": "current", + }) + return nodes + + +def _rw_changed_files(store, node_id): + """该节点相对父节点变更的文件名(basename,去重)——列表/详情的文件摘要。""" + nd = store.nodes[node_id] + par = nd.get("parent") + pf = store.nodes[par]["files"] if par in store.nodes else {} + out = [] + for k, v in nd["files"].items(): + if pf.get(k) != v: + b = os.path.basename(k) + if b not in out: + out.append(b) + return out + + +def _rw_node_line(title, files, ago, *, current, glyph): + line = Text() + line.append(glyph + " ", style=(f"bold {C_GREEN}" if current else C_BLUE)) + line.append(title or "(空)", style=(f"bold {C_GREEN}" if current else C_FG)) + t = _rw_rel_time(ago) + if t: + line.append(f" {t}", style=C_DIM) + fs = _rw_files_summary(files) + if fs: + line.append(f" {fs}", style=C_DIM) + return line + + +def _rw_entries_linear(sess): + """线性时间线条目 `[(Text, payload)]` + 默认选中索引。 + + 有真实 store → root→HEAD 链(payload = node_id,恢复 = 对话+代码);否则回退到 + history 现算(payload = 回退轮数 n / None,仅对话恢复,兼容尚无 checkpoint 的会话)。""" + store = getattr(sess, "store", None) + if store is not None and store.nodes and store.head in store.nodes: + now = time.time() + entries = [] + for nid in store.linear_path(): + nd = store.nodes[nid] + cur = nid == store.head + entries.append(( + _rw_node_line(nd["title"], _rw_changed_files(store, nid), + int(now - nd.get("created", now)), + current=cur, glyph=("●" if cur else "○")), + nid, + )) + return entries, len(entries) - 1 + entries = [] + for nd in _rw_collect(sess): + cur = nd["kind"] == "current" + line = Text() + if cur: + line.append("● 当前位置", style=f"bold {C_GREEN}") + else: + line.append(f"↩ 回退 {nd['n']} 轮 ", style=C_AMBER) + line.append(nd["title"], style=C_FG) + t = _rw_rel_time(nd["ago"]) + if t: + line.append(f" {t}", style=C_DIM) + fs = _rw_files_summary(nd["files"]) + if fs: + line.append(f" {fs}", style=C_DIM) + entries.append((line, nd["n"])) + return entries, len(entries) - 1 + + +def _rw_entries_tree(sess): + """树状条目 `[(Text, payload)]` + 默认选中(HEAD)索引。DFS 走真实 store 树, + 用 ├─/╰─ 连线 + 缩进表达分支(fork 后即可见);无 store 时回退到线性。""" + store = getattr(sess, "store", None) + if not (store is not None and store.nodes and store.root_id in store.nodes): + return _rw_entries_linear(sess) + now = time.time() + entries = [] + sel = [0] + + def dfs(nid, prefix, is_last, depth): + nd = store.nodes[nid] + cur = nid == store.head + line = Text() + if prefix: + line.append(prefix, style=C_DIM) + if depth > 0: + line.append("╰─ " if is_last else "├─ ", style=C_DIM) + line.append("● " if cur else "○ ", style=(f"bold {C_GREEN}" if cur else C_BLUE)) + line.append(nd["title"] or "(空)", style=(f"bold {C_GREEN}" if cur else C_FG)) + t = _rw_rel_time(int(now - nd.get("created", now))) + if t: + line.append(f" {t}", style=C_DIM) + fs = _rw_files_summary(_rw_changed_files(store, nid)) + if fs: + line.append(f" {fs}", style=C_DIM) + if cur: + sel[0] = len(entries) + entries.append((line, nid)) + child_prefix = prefix + (" " if is_last else "│ ") if depth > 0 else prefix + kids = [c for c in nd["children"] if c in store.nodes] + for i, c in enumerate(kids): + dfs(c, child_prefix, i == len(kids) - 1, depth + 1) + + dfs(store.root_id, "", True, 0) + return entries, sel[0] + + +class RewindScreen(ModalScreen): + """checkpoint 选择面板(时间线 / 树共用)。`entries = [(Text, payload)]`; + Esc 取消(dismiss None);Enter/点击 dismiss 该条 payload(store 节点 id 或 + 回退轮数 n)。打开时默认选中当前(HEAD)。""" + + CSS = """ + RewindScreen { align: center middle; } + RewindScreen > Vertical { + width: 96; max-width: 92%; height: auto; max-height: 80%; + background: $ga-alt-bg; border: solid $ga-border; padding: 1 2; + } + RewindScreen .rw-head { color: $ga-fg; padding: 0 0 1 0; } + RewindScreen OptionList { + background: $ga-alt-bg; color: $ga-fg; height: auto; max-height: 1fr; padding: 0; + } + RewindScreen OptionList > .option-list--option-highlighted { + background: $ga-sel-bg; color: $ga-fg; + } + """ + BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)] + + def __init__(self, entries, select_idx, title) -> None: + super().__init__() + # NB: 不要用 self._nodes —— 那是 Textual 的子节点 NodeList,覆盖会崩。 + self._entries = entries + self._select = select_idx + self._title = title + + def compose(self) -> ComposeResult: + with Vertical(): + yield Static(self._title, classes="rw-head") + yield OptionList(id="rw-list") + + def on_mount(self) -> None: + ol = self.query_one(OptionList) + for text, _payload in self._entries: + ol.add_option(Option(text)) + if self._entries: + ol.highlighted = max(0, min(self._select, len(self._entries) - 1)) + ol.focus() + + def on_option_list_option_selected(self, ev) -> None: + self.dismiss(self._entries[ev.option_index][1]) + + def action_cancel(self) -> None: + self.dismiss(None) + + class GenericAgentTUI(App[None]): CSS = _MAIN_CSS @@ -2763,9 +3850,14 @@ class GenericAgentTUI(App[None]): "new": self._cmd_new, "switch": self._cmd_switch, "close": self._cmd_close, "rename": self._cmd_rename, "branch": self._cmd_branch, "rewind": self._cmd_rewind, "clear": self._cmd_clear, - "stop": self._cmd_stop, "llm": self._cmd_llm, "export": self._cmd_export, + "worldline": self._cmd_rewind_tree, # 世界线(树状回退) + "rewind-tree": self._cmd_rewind_tree, # 旧名,保留为静默别名 + "stop": self._cmd_stop, "llm": self._cmd_llm, "model": self._cmd_model, + "effort": self._cmd_effort, + "export": self._cmd_export, "restore": self._cmd_restore, "btw": self._cmd_btw, "review": self._cmd_review, "continue": self._cmd_continue, "cost": self._cmd_cost, + "workspace": self._cmd_workspace, "reload-keys": self._cmd_reload_keys, # slash_cmds bundle — see frontends/slash_cmds.py for the prompt # bodies + reflect/scheduler discovery. All but /scheduler are @@ -2833,7 +3925,13 @@ class GenericAgentTUI(App[None]): yield _sidebar with Vertical(id="main"): yield VerticalScroll(id="messages") - yield Static("", id="planbar") + # Plan card: pinned header/step (#planbar-head) above a task list + # (#planbar) that scrolls inside a 4-row window (#planbar-tasks). + with Vertical(id="planbar-scroll"): + yield Static("", id="planbar-head") + _tasks = VerticalScroll(Static("", id="planbar"), id="planbar-tasks") + _tasks.can_focus = False # don't steal Tab focus from input + yield _tasks yield OptionList(id="palette") yield InputArea( "", @@ -2852,11 +3950,16 @@ class GenericAgentTUI(App[None]): def on_mount(self) -> None: _sweep_stale_task_dirs() # clear empty signal dirs left by prior runs + try: workspace_cmd.cleanup() # remove dangling/unregistered workspace junctions + except Exception: pass + try: workspace_cmd.session_map_prune() # drop session→ws entries whose log is gone + except Exception: pass + get_index(os.path.join(ROOT_DIR, "temp")).warm() # @ 补全:预热未绑时的默认根(temp) self.add_session("main") self._system(f"Welcome to GenericAgent TUI. 按 / 唤起命令面板,{fmt_key('ctrl+n')} 新建会话。") - # CSS `#planbar { display: none }` keeps it hidden by default — - # the renderer flips it on once items materialize. + # CSS `#planbar-scroll { display: none }` keeps it hidden by default — + # the renderer adds `-visible` once plan items materialize. self.query_one("#input", InputArea).focus() self.set_interval(0.5, self._tick) self._patch_auto_scroll_for_selection() @@ -2962,7 +4065,33 @@ class GenericAgentTUI(App[None]): f'_tui_v2_{os.getpid()}_{agent_id}') except Exception: pass + try: + # Opt TUI v2 agents into per-agent project-mode selection. The + # plugin falls back to the legacy pid anchor only when this + # private attribute is absent, so None here means "ordinary mode" + # for this session rather than "use the process-global workspace". + agent._ga_project_mode_name = None + agent._ga_project_mode_workspace_path = "" + except Exception: + pass sess = AgentSession(agent_id=agent_id, name=name or f"agent-{agent_id}", agent=agent) + # Rewind 后端:每 session 一个 checkpoint 树 + blob 库,key 对齐 agent.log_path + # (GA 稳定会话身份)。cwd = temp(与 GA handler 解析相对路径的基准一致, + # 见 agentmain GenericAgentHandler(..., temp))。store 挂到 agent 上供全局 + # tool_before 钩子按 agent 路由。 + try: + temp_dir = os.path.normpath(os.path.join(FRONTENDS_DIR, '..', 'temp')) + log_path = getattr(agent, 'log_path', '') or f'sess_{os.getpid()}_{agent_id}' + sess.store = RewindStore.for_log(temp_dir, log_path, temp_dir) + agent._rw_store = sess.store + except Exception: + sess.store = None + self._install_rw_tool_hook() + try: + from continue_cmd import acquire_birth_lock + acquire_birth_lock(agent, agent_id) # 原地复原:出生持锁,占用检测可见 + except Exception: + pass thread = threading.Thread(target=agent.run, name=f"ga-tui-agent-{agent_id}", daemon=True) thread.start() sess.thread = thread @@ -2970,9 +4099,230 @@ class GenericAgentTUI(App[None]): self.current_id = agent_id self._install_ask_user_hook(sess) self._install_intervene_replay_hook(sess) + self._install_write_snapshot_hook() + self._install_rw_time_hook(sess) self._refresh_all() return sess + def _bind_workspace(self, sess: AgentSession, info: Optional[dict], + persist: bool = True) -> None: + # persist=False: 仅刷新内存绑定状态,不写 session→ws 映射表。续接时的 + # reset(_bind_workspace(sess, None)) 用它——原地续后 agent.log_path==被续 + # 文件,若持久化会把本会话映射抹成 ""(= 已 off),反而毁掉自动恢复。映射表 + # 只应由显式 /workspace、/workspace off 与成功的续接恢复来写。 + if info: + sess.workspace_name = info.get("name") or "" + sess.workspace_path = info.get("target") or info.get("path") or "" + sess.workspace_link = info.get("link") or "" + project_name = sess.workspace_name or None + project_path = sess.workspace_path + else: + sess.workspace_name = "" + sess.workspace_path = "" + sess.workspace_link = "" + project_name = None + project_path = "" + try: + sess.agent._ga_project_mode_name = project_name + sess.agent._ga_project_mode_workspace_path = project_path + except Exception: + pass + # 持久化绑定/off → /continue 即时恢复,不必先聊一轮留 PROJECT MODE 块。 + if persist: + workspace_cmd.session_ws_set(getattr(sess.agent, "log_path", "") or "", project_path or "") + if project_path: + get_index(project_path).warm() # @ 候选跟随 workspace + + def _at_root(self, sess: Optional["AgentSession"] = None) -> str: + # @ 索引根:绑了 workspace 用真实 target;否则用 agent 的实际工作目录 + # ROOT_DIR/temp(file_read/code_run 都相对它),而非飘忽的 os.getcwd()。 + # 一律真实路径,绝不暴露哈希 junction 名。 + s = sess or (self.sessions.get(self.current_id) if self.current_id is not None else None) + return (s.workspace_path if s and s.workspace_path else os.path.join(ROOT_DIR, "temp")) + + _write_snapshot_hook_installed = False + + def _install_write_snapshot_hook(self) -> None: + """Register one global `tool_before` hook that captures the structured + args of every file_write/file_patch (plus a file_write target's pre-write + content) into `_WRITE_CAP`, keyed by `hash(get_pretty_json(args))`. + + That hash is the bridge to the display layer: agent_loop renders the same + `get_pretty_json(args)` inside the verbose `📥 args:` fence, so at render + time `_render_md` hashes the fence body and looks the real args back up — + no need to parse the newline-mangled pretty JSON. Content-addressed, so it + survives re-renders and pairs each block with its exact call. The registry + is process-global, so install at most once.""" + if GenericAgentTUI._write_snapshot_hook_installed: + return + try: + from plugins import hooks as _ph + from agent_loop import get_pretty_json + + def _strip_dispatch_keys(raw): + # dispatch() injects `_index`/`_tool_num` into args *before* the + # hooks fire, but the verbose display (agent_loop.py:78) ran + # get_pretty_json on the args *before* dispatch — i.e. without + # those keys. Strip them so our hash matches the fence body the + # renderer will hash. + return {k: v for k, v in (raw or {}).items() + if k not in ("_index", "_tool_num")} + + def _snap(ctx): + try: + name = (ctx or {}).get("tool_name") + if name not in ("file_write", "file_patch", "file_read", "code_run"): + return ctx + handler = (ctx or {}).get("self") + args = _strip_dispatch_keys((ctx or {}).get("args")) + if name == "code_run": + # No path; the card shows args (code) + the result + # stamped by _snap_after. Keyed the same way. + _WRITE_CAP[hash(get_pretty_json(args))] = { + "name": name, "args": args, + } + if len(_WRITE_CAP) > 256: + for k in list(_WRITE_CAP)[:128]: + _WRITE_CAP.pop(k, None) + return ctx + path = args.get("path") + if not path or handler is None: + return ctx + if name == "file_read": + # No disk snapshot needed — the read card shows the + # tool's own result, stamped by _snap_after from + # StepOutcome.data. + _WRITE_CAP[hash(get_pretty_json(args))] = { + "name": name, "args": args, + } + else: + # Snapshot the pre-write full file for BOTH write tools: + # file_write overwrite diffs against it, and file_patch + # uses it to render a whole-file diff (real line numbers + # + surrounding context) instead of just the fragment. + existed, old = False, "" + try: + abs_path = handler._get_abs_path(path) + existed = os.path.exists(abs_path) + if existed: + with open(abs_path, "r", encoding="utf-8") as f: + old = f.read() + except Exception: + existed, old = False, "" + _WRITE_CAP[hash(get_pretty_json(args))] = { + "name": name, "args": args, "existed": existed, "old": old, + } + # Bound memory: keep only the most recent captures. + if len(_WRITE_CAP) > 256: + for k in list(_WRITE_CAP)[:128]: + _WRITE_CAP.pop(k, None) + except Exception: + pass + return ctx + + def _snap_after(ctx): + # Stamp the outcome onto the same capture: write tools get + # {status, msg} for the header ✗; file_read gets the result text + # itself (its StepOutcome.data IS the content the card shows). + try: + name = (ctx or {}).get("tool_name") + if name not in ("file_write", "file_patch", "file_read", "code_run"): + return ctx + args = _strip_dispatch_keys((ctx or {}).get("args")) + data = getattr((ctx or {}).get("ret"), "data", None) + h = hash(get_pretty_json(args)) + if h not in _WRITE_CAP: + return ctx + if name == "file_read": + if isinstance(data, str): + _WRITE_CAP[h]["content"] = data + elif name == "code_run": + # data is the StepOutcome.data verbatim: dict + # {status,stdout,exit_code} | {status:error,msg} | str. + _WRITE_CAP[h]["data"] = data + elif isinstance(data, dict): + _WRITE_CAP[h]["status"] = data.get("status") + _WRITE_CAP[h]["msg"] = str(data.get("msg") or "") + except Exception: + pass + return ctx + + _ph.register("tool_before")(_snap) + _ph.register("tool_after")(_snap_after) + GenericAgentTUI._write_snapshot_hook_installed = True + except Exception: + pass + + + _rw_tool_hook_installed = False + + def _install_rw_tool_hook(self) -> None: + """全局注册一次 `tool_before` 钩子(plugins.hooks):在 file_write/file_patch + 真写盘**之前**,把目标文件的「改前内容」存进对应 session 的 store(track_pre_edit)。 + + 注册表是全局的(非 per-session),回调对所有 agent 触发 → 靠 ctx 的 + `self.parent`(GenericAgent)上挂的 `_rw_store` 路由到正确 session。 + 回调跑在 agent 线程;每 agent 独占自己的 store,无跨线程竞争。""" + if GenericAgentTUI._rw_tool_hook_installed: + return + try: + from plugins import hooks as _ph + + def _rw_tool_before(ctx): + try: + if (ctx or {}).get("tool_name") not in ("file_write", "file_patch"): + return ctx + handler = ctx.get("self") + args = ctx.get("args") or {} + path = args.get("path") + store = getattr(getattr(handler, "parent", None), "_rw_store", None) + if store is not None and path: + store.track_pre_edit(handler._get_abs_path(path)) + except Exception: + pass + return ctx + + _ph.register("tool_before")(_rw_tool_before) + GenericAgentTUI._rw_tool_hook_installed = True + except Exception: + pass + + def _rw_commit(self, sess: AgentSession) -> None: + """用户提问段完成时落一个 checkpoint 节点(对话推进也算 checkpoint)。 + + title 取本次用户输入;传整条 `backend.history` 给 store —— commit 会切本轮 + 增量 `history[parent.hist_len:]` 存成 conv blob(树=真相源),恢复时按路径拼回。 + store 故障一律静默(rewind 是旁路安全网,绝不冒泡打断任务收尾)。""" + store = getattr(sess, "store", None) + if store is None: + return + try: + title = (getattr(sess, "_rw_title", "") or "checkpoint").replace("\n", " ").strip()[:80] + history = sess.agent.llmclient.backend.history + store.commit(title or "checkpoint", history=history) + store._rw_cursor = None # 继续提问 → 新末端成为当前,清除 rewind 游标 + except Exception: + pass + + def _install_rw_time_hook(self, sess: AgentSession) -> None: + """Stamp wall-clock per turn-end, keyed by LLM-history length, so the + /rewind panels can show coarse relative time. GA stores no timestamps; + nodes predating this session (e.g. restored via /continue) show none. + Hook runs in the agent thread — dict writes are GIL-safe.""" + agent = sess.agent + sess._rw_times = {} + try: + hooks = getattr(agent, "_turn_end_hooks", None) + if hooks is None: + hooks = agent._turn_end_hooks = {} + def _hook(ctx, _s=sess, _a=agent): + try: _s._rw_times[len(_a.llmclient.backend.history)] = time.time() + except Exception: pass + hooks["_ga_tui_rw_time"] = _hook + except Exception: + pass + + def _install_ask_user_hook(self, sess: AgentSession) -> None: """Capture ask_user INTERRUPT payloads from agent_loop's turn_end hook. @@ -3011,6 +4361,10 @@ class GenericAgentTUI(App[None]): self._system(f"Created session #{sess.agent_id} — {sess.name}") def action_prev_session(self) -> None: + # ctrl+up 是 app 级 priority 绑定,会盖过 ModalScreen;世界线屏激活时把它 + # 转交给屏内「移动非聚焦窗↑」(否则在三栏里 ctrl+↑ 变成切换会话)。 + if isinstance(self.screen, RewindTreeScreen): + self.screen.action_other_up(); return ids = sorted(self.sessions.keys()) if len(ids) <= 1: return i = ids.index(self.current_id) @@ -3018,6 +4372,8 @@ class GenericAgentTUI(App[None]): self._refresh_all() def action_next_session(self) -> None: + if isinstance(self.screen, RewindTreeScreen): + self.screen.action_other_down(); return ids = sorted(self.sessions.keys()) if len(ids) <= 1: return i = ids.index(self.current_id) @@ -3025,17 +4381,10 @@ class GenericAgentTUI(App[None]): self._refresh_all() def copy_to_clipboard(self, text: str) -> None: - """Override Textual's OSC 52 clipboard (broken on macOS Terminal.app). - Use pbcopy on macOS, fall back to OSC 52 on other platforms.""" - import sys, subprocess as _sp - if sys.platform == "darwin": - try: - _sp.Popen(["pbcopy"], stdin=_sp.PIPE, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL).communicate(text.encode("utf-8")) - self._clipboard = text - return - except Exception: - pass - # Non-macOS or pbcopy failed: fall back to Textual default (OSC 52) + self._clipboard = text + _ssh = os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY") + if not _ssh and _copy_to_clipboard(text): + return super().copy_to_clipboard(text) def action_handle_ctrl_c(self) -> None: @@ -3308,6 +4657,10 @@ class GenericAgentTUI(App[None]): return t def action_complete_command(self) -> None: + # `tab` 是 app 级 priority 绑定(命令补全),会盖过 ModalScreen 的 on_key; + # 世界线屏激活时把 Tab 转交给它的「切聚焦」(否则 Tab 在三栏里被补全吞掉)。 + if isinstance(self.screen, RewindTreeScreen): + self.screen.action_focus_toggle(); return palette = self.query_one("#palette", OptionList) if not palette.has_class("-visible"): return @@ -3458,9 +4811,45 @@ class GenericAgentTUI(App[None]): if first_line.startswith("/") and " " not in first_line and "\n" not in val: self._populate_palette(first_line) self._show_palette() + return + # @ file mention: reuse the same palette for path candidates when the + # cursor sits inside an `@token` (claude-code parity, workspace-rooted). + try: + row, col = inp.cursor_location + line = inp.document.get_line(row)[:col] + except Exception: + line = "" + tok = find_at_token(line) + if tok is not None: + self._populate_at_palette(tok[0]) else: self._hide_palette() + def _populate_at_palette(self, query: str) -> None: + sess = self.sessions.get(self.current_id) + unbound = not (sess and sess.workspace_path) # 未绑 workspace → 根是 temp,显示完整路径 + matches = candidates_for(query, self._at_root(), absolute=unbound) + palette = self.query_one("#palette", OptionList) + palette.clear_options() + if not matches: + self._hide_palette() + return + for path in matches: + t = Text() + if unbound: # 未绑:整条完整路径(根不直观) + t.append(path) + else: # 绑 workspace:base + 父目录(短) + # 目录候选末尾带 '/',先剥掉再拆 base,否则 rsplit 得到空串 → 空白行。 + is_dir = path.endswith("/") + core = path.rstrip("/") + parent, name = core.rsplit("/", 1) if "/" in core else ("", core) + base = name + ("/" if is_dir else "") + t.append(base, style="bold") + if parent: + t.append(f" {parent}", style=C_MUTED) + palette.add_option(Option(t, id=f"at:{path}")) + self._show_palette() + def _resize_input(self, inp: TextArea) -> None: # wrapped_document.height counts soft-wrapped lines; document.line_count only logical. try: @@ -3530,6 +4919,15 @@ class GenericAgentTUI(App[None]): # forward the literal so the agent recovers context. self.submit_user_message(text) return + # @ mentions (completion-only): rewrite @relative → @absolute so the + # agent's file_read can locate it; scrollback keeps the short form via + # display_text. No content is read here. (Content-injecting auto-read + # variant: temp/plan_v2_at_mention/autoread_version.py.) + if "@" in text: + abs_text = absolutize_mentions(text, self._at_root()) + if abs_text != text: + self.submit_user_message(abs_text, images=images, display_text=text) + return self.submit_user_message(text, images=images) def _show_palette(self) -> None: @@ -3558,6 +4956,24 @@ class GenericAgentTUI(App[None]): ol = event.option_list if ol.id == "palette": cmd_id = event.option.id + if cmd_id and cmd_id.startswith("at:"): + # @ candidate accepted: replace the in-progress @token with the + # picked path (quoted when it contains spaces), cursor to end. + inp = self.query_one("#input", InputArea) + try: + row, col = inp.cursor_location + line = inp.document.get_line(row)[:col] + tok = find_at_token(line) + if tok is not None: + rep = format_pick(cmd_id[3:]) + self._suppress_palette_open = True + inp.replace(rep, (row, tok[1]), (row, col)) + inp.move_cursor((row, tok[1] + len(rep))) + except Exception: + pass + self._hide_palette() + inp.focus() + return if cmd_id: inp = self.query_one("#input", InputArea) needs_args = any(c[1] for c in COMMANDS if c[0] == cmd_id) @@ -3828,6 +5244,12 @@ class GenericAgentTUI(App[None]): new.agent.llmclient.backend.history = copy.deepcopy(old.agent.llmclient.backend.history) except Exception as e: self._system(f"Branch warning: {e}"); return + if old.workspace_name: + self._bind_workspace(new, { + "name": old.workspace_name, + "target": old.workspace_path, + "link": old.workspace_link, + }) # deepcopy(old.messages) trips on mounted Textual widget refs; shallow-copy each # ChatMessage and null out widget/cache fields so the new session re-mounts cleanly. new.messages = [] @@ -3848,35 +5270,184 @@ class GenericAgentTUI(App[None]): self._system(f"Branched #{old.agent_id} → #{new.agent_id} ({n} msgs).") def _cmd_rewind(self, args, raw): + # 外观:对话流内联选择卡片(旧版样式)。行为:有世界线树时走【持久】通道—— + # 选中提问后弹 RestoreModeScreen(复用 /worldline 那套)选 对话/代码/两者,再 + # _rw_restore_node→restore_plan(落盘 tree HEAD + 重写投影日志,continue 后不复活)。 + # 无树才兜底内存级 _do_rewind(非持久,仅对话)。分支树视图 / diff 走 /worldline。 sess = self.current if sess.status == "running": self._system("Cannot rewind while running. /stop first."); return - turns = self._rewindable_turns() - if not turns: - self._system("No rewindable turns."); return - if args: + store = getattr(sess, "store", None) + history = sess.agent.llmclient.backend.history + # 先对账:让树对齐当前对话(顺带防外部 UI 改写日志的灾难),再按树的线性路径 + # 建卡片,每个选项 = 一个真实节点 → 选中即可持久回退。 + nodes = [] # [(node_id, title)] 最近→最旧,不含 origin + if store is not None: + try: store.reconcile(history) + except Exception: pass + if store.nodes and store.head in store.nodes: + for nid in store.linear_path(): + if store.nodes[nid].get("kind") == "origin": + continue + nodes.append((nid, store.nodes[nid].get("title") or "(空)")) + nodes.reverse() + if nodes: # 持久路径 + payloads = [nid for nid, _ in nodes] + previews = [t for _, t in nodes] + durable = True + else: # 兜底:无 store/无真实提问 → 内存级 + turns = self._rewindable_turns() + if not turns: + self._system("No rewindable turns."); return + rec = list(reversed(turns)) + payloads = [i + 1 for i in range(len(rec))] # 回退轮数 n + previews = [p for _, p in rec] + durable = False + total = len(payloads) + if args: # /rewind n 直接回退,不弹卡片 try: n = int(args[0]) - except ValueError: self._system("Usage: /rewind "); return - if n < 1 or n > len(turns): - self._system(f"Invalid: 1-{len(turns)}"); return - self._system(self._do_rewind(n)) + except ValueError: self._system("Usage: /rewind [n]"); return + if n < 1 or n > total: + self._system(f"Invalid: 1-{total}"); return + if durable: + self._rewind_pick_mode(sess, payloads[n - 1]) # 弹模式窗后回退 + else: + self._system(self._do_rewind(payloads[n - 1])) return LIMIT = 20 - recent = list(reversed(turns))[:LIMIT] choices = [] - for offset, (_, prev) in enumerate(recent, 1): - preview = (prev or "(空)").replace("\n", " ").strip()[:60] - choices.append((f"回退 {offset} 轮 · {preview}", offset)) + for offset in range(1, min(total, LIMIT) + 1): + preview = (previews[offset - 1] or "(空)").replace("\n", " ").strip()[:60] + choices.append((f"回退 {offset} 轮 · {preview}", payloads[offset - 1])) head = "选择回退到的轮次 (↑/↓ 移动,→/Enter 确认,Esc 取消)" - if len(turns) > LIMIT: - head += f" [仅显示最近 {LIMIT}/{len(turns)}]" - msg = ChatMessage( - role="system", content=head, kind="choice", choices=choices, - on_select=lambda v: self._do_rewind(v), - ) + if total > LIMIT: + head += f" [仅显示最近 {LIMIT}/{total}]" + if durable: + on_sel = lambda v: self._rewind_pick_mode(sess, v) + else: + on_sel = lambda v: self._do_rewind(v) + msg = ChatMessage(role="system", content=head, kind="choice", + choices=choices, on_select=on_sel) sess.messages.append(msg) self._refresh_messages() + def _rewind_pick_mode(self, sess, node_id) -> None: + """选中要回退到的提问后,弹 RestoreModeScreen(复用 /worldline 的模式选择窗)选 + 对话/代码/两者,再走持久回退 _rw_restore_node(to='before')。取消(返回 None)则不回退。""" + store = getattr(sess, "store", None) + title = "" + if store is not None and node_id in getattr(store, "nodes", {}): + title = store.nodes[node_id].get("title") or "" + def _after(mode): + if mode: + self._system(self._rw_restore_node(sess, node_id, mode=mode, to="before")) + self.push_screen(RestoreModeScreen(title, "before"), _after) + + def _cmd_rewind_tree(self, args, raw): + sess = self.current + if sess.status == "running": + self._system("Cannot rewind while running. /stop first."); return + store = getattr(sess, "store", None) + if store is None: + self._system("No rewindable checkpoints."); return + # 打开即对账:把 live history 中树尚未记录的尾部(别的 UI 续聊时追加的)吸收进树。 + # 这是防灾的单一咽喉点——restore 只能从已打开的世界线屏发起,对账后树恒 ⊇ 日志, + # 故随后任何 rewind 都不可能用陈旧树 rewrite_projection 抹掉外部轮次。 + # 也统一了「空 store / 老会话」:n=0 即把整条历史合成为 conv-only 主干(界面不降级)。 + try: + store.reconcile(sess.agent.llmclient.backend.history) + except Exception: + pass # 对账失败不阻断;下面守卫兜底 + if not (store.nodes and store.root_id in store.nodes): + self._system("No rewindable checkpoints."); return # 连一轮真实提问都没有 + # 三栏全屏可视化器(§3–§7):左压缩树 / 右上折叠段 / 右下详情+操作。 + self.push_screen(RewindTreeScreen(store), self._on_rewind_tree_result) + + def _on_rewind_pick(self, payload) -> None: + # 面板回调:None=取消;str=store 节点 id(恢复对话+代码);int=history 回退轮数。 + if payload is None: + return + sess = self.current + if isinstance(payload, str): + self._system(self._rw_restore_node(sess, payload)) + else: + self._system(self._do_rewind(payload)) + + def _on_rewind_tree_result(self, result) -> None: + # 三栏屏回调:None=取消/已内联处理(diff/delete);dict=恢复请求。 + if not isinstance(result, dict) or result.get("action") != "restore": + return + self._system(self._rw_restore_node(self.current, result.get("node"), + mode=result.get("mode", "both"), + to=result.get("to", "before"))) + + def _rw_restore_node(self, sess, node_id, mode: str = "both", to: str = "before") -> str: + """恢复到某节点(薄封装)。**编排在 `worldline.restore_plan`**(UI 无关:算出回退后 + 的对话/文件/prefill,并落地——改文件 + 移 HEAD + 重写投影);本方法只做 TUI 侧刷新: + 把 history 赋回 backend、重组界面消息、prefill 输入框、重挂。 + + to: before(回到该提问之前 + prefill) / at(在该节点继续,无 prefill); + mode: both/conv/code。需 agent 空闲(调用方已保证)。""" + store = getattr(sess, "store", None) + log_path = getattr(sess.agent, "log_path", "") or "" + hist = sess.agent.llmclient.backend.history + old_len = len(hist) + res = restore_plan(store, node_id, mode=mode, to=to, log_path=log_path) + if res is None: + return "❌ 无效的 checkpoint" + # rewind 游标:to=before(回到 X 之前,内部 HEAD=parent)时,用户心中「当前」仍是 + # 选中的 X → 记下供世界线面板把 ◉ 标在 X(下次打开仍停在 X)。to=at 则就在该节点, + # 用 HEAD 即可。继续提问(commit)时清除。 + try: + store._rw_cursor = node_id if (to == "before") else None + except Exception: + pass + removed = 0 + if res["history"] is not None: # 对话有变更(both/conv) + hist[:] = res["history"] + removed = max(0, old_len - len(res["history"])) + self._rw_rebuild_display(sess) # 从重写后的投影重组界面历史消息 + self._remount_current_session() + self._refresh_topbar() + self._refresh_sidebar() + if res["prefill"]: + self._rw_prefill_input(res["prefill"]) + label = {"both": "对话+代码", "conv": "仅对话", "code": "仅代码"}.get(mode, mode) + at_origin, title = res["at_origin"], res["title"] + if to == "at": + where = "空起点" if at_origin else f"「{title}」之后(在此继续)" + else: + where = "空起点" if at_origin else f"「{title}」之前" + return (f"↩ 已回退到{where}({label}):清除 {removed} 条上下文," + f"代码恢复 {len(res['changed'])} 个文件") + + def _rw_rebuild_display(self, sess) -> None: + """rewind 后重组**界面历史消息**:从已重写成新 HEAD 路径的投影日志重新解析 + (复用 /continue 的 `extract_ui_messages`,与其它视角同一套渲染),替换 + `sess.messages`。随后由 `_remount_current_session` 重挂显示。 + + 没这步的话,backend.history 已回退但屏幕上还停留在旧对话。故障静默降级。""" + log_path = getattr(sess.agent, "log_path", "") or "" + if not log_path: + return + try: + rebuilt = [ChatMessage(role=h["role"], content=h["content"]) + for h in continue_extract(log_path)] + sess.messages.clear() + sess.messages.extend(rebuilt) + except Exception: + pass + + def _rw_prefill_input(self, text: str) -> None: + try: + inp = self.query_one("#input", InputArea) + inp.text = text + inp.move_cursor((inp.document.line_count - 1, len(text.split("\n")[-1]))) + inp.focus() + self._resize_input(inp) + except Exception: + pass + def _rewindable_turns(self) -> list[tuple[int, str]]: history = self.current.agent.llmclient.backend.history turns: list[tuple[int, str]] = [] @@ -3930,8 +5501,21 @@ class GenericAgentTUI(App[None]): def _cmd_stop(self, args, raw): sess = self.current - last_user_text = next((m.content for m in reversed(sess.messages) - if m.role == "user"), None) + # Locate the last user message AND whether the agent already produced a + # reply for that turn. Walking reversed, any non-empty assistant message + # seen *before* we reach the user message means this turn was consumed + # (the LLM emitted output → it's in history; a resend would duplicate). + # System "[queued #n]" steers are skipped (neither role). The current + # task's assistant placeholder starts empty, so an interrupt before any + # stream leaves `consumed` False. + last_user_text = None + consumed = False + for m in reversed(sess.messages): + if m.role == "assistant" and (m.content or "").strip(): + consumed = True + elif m.role == "user": + last_user_text = m.content + break try: sess.agent.abort() if sess.status == "running": @@ -3941,11 +5525,12 @@ class GenericAgentTUI(App[None]): except Exception as e: self._system(f"Stop failed: {e}") # Refill the input box with the interrupted user text so edit-and- - # resend is one keystroke away. Only when the box is empty (don't - # clobber a half-typed follow-up). Agent history is untouched — a - # resend duplicates the turn in LLM context; `/rewind 1` is the - # manual escape. - if last_user_text: + # resend is one keystroke away — but only for an *unconsumed* turn + # (aborted before the LLM replied). Once the agent has answered, the + # turn lives in history and a resend would duplicate it, so leave the + # box alone. Also only when the box is empty (don't clobber a + # half-typed follow-up). + if last_user_text and not consumed: try: inp = self.query_one("#input", InputArea) if not inp.text: @@ -4021,6 +5606,93 @@ class GenericAgentTUI(App[None]): except Exception as e: return f"❌ 切换失败: {e}" + # ---------------- /model: 渠道内 model 切换(逻辑在 model_cmd.py) ---------------- + def _cmd_model(self, args, raw): + import model_cmd + agent = self.current.agent + if args: # /model 直设, 不拉列表 + self._system(model_cmd.set_model(agent, " ".join(args))) + return + self._open_model_picker() + + def _open_model_picker(self) -> None: + """立即挂一个空的 searchable picker(输入框先可用, 下方 hint 行显示加载中), + 后台拉取完成后 _fill_model_picker 就地填充。mixin 不再选渠道, 直接作用于 + 当前子渠道 (model_cmd sub=None 即当前)。""" + import model_cmd + agent = self.current.agent + cur = model_cmd.current_model(agent, None) + msg = ChatMessage( + role="system", + content=f"选择模型 (当前: {cur} · 输入过滤或自定义名称 · ↑/↓ 移动,Enter 确认,Esc 取消)", + kind="choice", choices=[], + on_select=lambda v: model_cmd.set_model(self.current.agent, v), + ) + msg.searchable = True + msg.free_input = True + msg.search_placeholder = "输入关键字过滤;无匹配时 Enter 设置自定义模型名" + msg.all_choices = [] + msg.empty_hint = "⏳ 正在拉取模型列表… (或直接输入完整模型名 Enter 直设)" + self.current.messages.append(msg) + self._refresh_messages() + + def worker(): + try: + models = model_cmd.fetch_models(agent, None) + err = None if models else "渠道未返回模型列表" + except Exception as e: + models, err = [], f"{type(e).__name__}: {e}" + self.call_from_thread(self._fill_model_picker, msg, models, err, cur) + + threading.Thread(target=worker, daemon=True, name="ga-tui-model").start() + + # ---------------- /effort: reasoning effort 切换(逻辑在 model_cmd.py) ---------------- + def _cmd_effort(self, args, raw): + import model_cmd + if args: # /effort 直设 + self._system(model_cmd.set_effort(self.current.agent, " ".join(args))) + return + agent = self.current.agent + cur = model_cmd.current_effort(agent) + protocols = model_cmd._protocols(agent) + # (显示名, value, 备注, 是否当前选中)。有备注的行名字补齐到等宽, + # 备注对齐成一列;无备注的行直接用显示名,不留尾随空格。 + rows = [("默认", "off", "", not cur)] + for lv in model_cmd.EFFORT_LEVELS: + rows.append((lv, lv, model_cmd.effort_note(lv, protocols), cur == lv)) + w = max(len(d) for d, _, _, _ in rows) + choices = [(("✓ " if tick else " ") + + (disp.ljust(w) + f" {note}" if note else disp), val) + for disp, val, note, tick in rows] + msg = ChatMessage( + role="system", + content=(f"选择 reasoning effort (当前: {cur or '未设置'} · " + "↑/↓ 移动,Enter 确认,Esc 取消)"), + kind="choice", choices=choices, + on_select=lambda v: model_cmd.set_effort(self.current.agent, v), + ) + self.current.messages.append(msg) + self._refresh_messages() + + def _fill_model_picker(self, msg, models, err, cur) -> None: + """拉取完成: 就地重建 picker 区, 保留 Input 焦点与已输入的过滤词。""" + w = msg._body_widget + if (msg.selected_label is not None or w is None + or not getattr(w, "is_mounted", False)): + return # 用户已 Esc/已选, 静默丢弃 + if err: + msg.empty_hint = f"❌ 拉取失败: {err} · 直接输入完整模型名 Enter 设置" + else: + msg.all_choices = [(("✓ " if m == cur else " ") + m, m) for m in models] + msg.empty_hint = "(无匹配 · Enter 设置自定义模型名)" + msg.content = (f"选择模型 ({len(models)} 个 · 当前: {cur} · " + "输入过滤或自定义名称 · ↑/↓ 移动,Enter 确认,Esc 取消)") + try: + msg._hint_widget.update(Text(msg.content, style=C_MUTED)) + except Exception: + pass + w._apply_filter(msg.search_query or "") + # ---------------- new commands ---------------- def _cmd_btw(self, args, raw): question = " ".join(args).strip() @@ -4081,13 +5753,18 @@ class GenericAgentTUI(App[None]): display_text = raw.strip() if (raw or "").strip() else "/review" self.submit_user_message(prompt, display_text=display_text) + def _rw_rewind_root(self): + """世界线树根目录(temp/.ga_rewind),供 continue_list 树感知发现"已回退至起点"的空会话。""" + return os.path.join(os.path.normpath(os.path.join(FRONTENDS_DIR, '..', 'temp')), '.ga_rewind') + def _cmd_continue(self, args, raw): sess = self.current m = re.match(r"/continue\s+(\S.*?)\s*$", (raw or "").strip()) if m: token = m.group(1) if token.isdigit(): - sessions = continue_list(exclude_pid=os.getpid()) + sessions = continue_list(exclude_log=os.path.basename(getattr(sess.agent, "log_path", "") or ""), + rewind_root=self._rw_rewind_root()) idx = int(token) - 1 if not (0 <= idx < len(sessions)): self._system(f"❌ 索引越界(有效范围 1-{len(sessions)})"); return @@ -4106,7 +5783,8 @@ class GenericAgentTUI(App[None]): self._system(f"❌ 找不到名为 {token!r} 的会话"); return self._do_continue_restore(path) return - sessions = continue_list(exclude_pid=os.getpid()) + sessions = continue_list(exclude_log=os.path.basename(getattr(sess.agent, "log_path", "") or ""), + rewind_root=self._rw_rewind_root()) if not sessions: self._system("❌ 没有可恢复的历史会话"); return choices = [] @@ -4141,25 +5819,54 @@ class GenericAgentTUI(App[None]): self._refresh_messages() def _do_continue_restore(self, path: str) -> str: + # 默认原地续(接管原日志,延续同一会话)。快照只能拷贝续;被活进程占用 → + # 弹窗问是否从原会话拷贝一份继续(复用内联 choice)。 + import continue_cmd as _cc sess = self.current - from continue_cmd import reset_conversation, restore + if not _cc.is_snapshot(path): + occ = _cc.session_occupant(path) + if occ is not None: + head = f"该会话正被占用(pid {occ.get('pid', '?')})—— 是否从原会话拷贝一份继续?" + msg = ChatMessage( + role="system", content=head, kind="choice", + choices=[("拷贝一份继续", path), ("取消", None)], + on_select=lambda v: (self._continue_restore_apply(v, copy=True) if v else None), + ) + sess.messages.append(msg); self._refresh_messages() + return head + return self._continue_restore_apply(path, copy=_cc.is_snapshot(path)) + + def _continue_restore_apply(self, path: str, copy: bool) -> str: + sess = self.current + import continue_cmd as _cc try: - reset_conversation(sess.agent, message=None) - result, ok = restore(sess.agent, path) + if copy: + result, ok = _cc.continue_copy(sess.agent, path, sess.agent_id, allow_empty=True) + else: + result, ok = _cc.continue_inplace(sess.agent, path, sess.agent_id, allow_empty=True) except Exception as e: msg = f"❌ 恢复失败: {e}" self._system(msg); return msg if not ok: self._system(result); return result - # Mirror the source transcript into this agent's own log file so a - # future /continue resolves the merged history under the migrated name. - current_log = getattr(sess.agent, "log_path", "") or "" - if current_log and path != current_log: - try: - import shutil - shutil.copyfile(path, current_log) - except Exception: - pass + # 原地:new_log == path(接管原文件);拷贝:new_log 是内容相同的新副本。 + new_log = getattr(sess.agent, "log_path", "") or "" + # 世界线树:重建 store 指向新 log_path;拷贝时再 resume_from 把源会话的树搬过来 + # (原地直接指向 X 那棵树,无需搬)。 + try: + temp_dir = os.path.normpath(os.path.join(FRONTENDS_DIR, '..', 'temp')) + sess.store = RewindStore.for_log(temp_dir, new_log, temp_dir) + sess.agent._rw_store = sess.store + if copy: + old_root = os.path.normpath(os.path.join( + temp_dir, '.ga_rewind', RewindStore.key_for_log(path))) + sess.store.resume_from(old_root) + # 接管后立即对账:此刻 backend.history 已是日志全量(continue_* 末尾 _load_history_into)。 + # 若源日志曾被不更新树的 UI 续写,树会滞后于日志 → 在这里吸收尾部,使树 ⊇ 日志, + # 杜绝之后 rewind 用陈旧树回写、抹掉外部轮次的灾难。 + sess.store.reconcile(sess.agent.llmclient.backend.history) + except Exception: + pass def _finish(): sess.messages.clear() # Plan state belongs to the *previous* conversation. Clearing it @@ -4168,31 +5875,71 @@ class GenericAgentTUI(App[None]): sess.plan_items = [] sess.plan_complete_since = None sess.plan_lost_since = None + sess.restored_plan_path = "" self._plan_mtime.pop(sess.agent_id, None) + # Live mode fills _WRITE_CAP from the tool_before hook; on restore that + # is gone, so seed it from the log's structured tool_use inputs (keyed + # the same way) — otherwise restored file_write/file_patch fall back to + # the raw verbose args block instead of a diff. + try: + from continue_cmd import iter_write_captures + from agent_loop import get_pretty_json + for cap in iter_write_captures(path): + _WRITE_CAP[hash(get_pretty_json(cap["args"]))] = cap + except Exception: + pass for h in continue_extract(path): sess.messages.append(ChatMessage(role=h["role"], content=h["content"])) - # baseline=0 lets the scanner see prior plan_X/plan.md refs so an - # unfinished plan resumes after /continue. Only when the restored - # plan.md is already all-done do we push baseline past history to - # suppress the stale ✓ card. + # Plan-card restore is keyed off the transcript's structured + # `enter_plan_mode` tool_use (find_plan_entry), NOT off plan.md + # paths mentioned in chat text — a typed filename can't fake it. + # Restore iff the entered plan still exists, parses to ≥1 task, + # and isn't all-done (an abandoned finished/headless plan stays + # buried). baseline stays 0: it only scopes current_step's 📌 scan. sess.plan_scan_baseline = 0 import plan_state - pp = plan_state.resolve_path(sess.agent, messages=sess.messages) - if pp and os.path.isfile(pp): + from continue_cmd import find_plan_entry + pp = find_plan_entry(path) + rp = plan_state._resolve_stashed(pp) if pp else None + if rp: try: - with open(pp, encoding="utf-8", errors="replace") as f: + with open(rp, encoding="utf-8", errors="replace") as f: items = plan_state.extract(f.read()) - if items and plan_state.is_complete(items): - sess.plan_scan_baseline = len(sess.messages) except OSError: - pass + items = [] + if items and not plan_state.is_complete(items): + sess.restored_plan_path = rp + sess.plan_items = items try: import session_names nm = session_names.name_for(path) if nm: sess.name = nm - if current_log: - session_names.migrate(path, current_log) + if new_log and new_log != path: # 仅拷贝续才迁移名字到新副本;原地无需迁移 + session_names.migrate(path, new_log) + except Exception: + pass + # Auto-restore workspace: if the continued session worked in a + # registered workspace, bind it to this session (recreating the + # junction if needed) without touching the legacy process anchor. + # persist=False: the reset bind must NOT write the session→ws map — + # in-place continue points log_path at `path`, so persisting "" here + # would erase this session's own mapping before we read it back. + self._bind_workspace(self.current, None, persist=False) + try: + rec = workspace_cmd.session_ws_get(path) # 路径 / "" (off) / None(无记录) + if rec is not None: + ws_path = rec or None # "" → 该会话已 off,明确不恢复 + else: + info = workspace_cmd.workspace_from_log(path) # 老会话:回退扫日志 + ws_path = info["path"] if info else None + if ws_path: + r = workspace_cmd.prepare(ws_path) + if r.get("ok"): + self._bind_workspace(self.current, r) + self._system(f"⌂ 已恢复工作目录: {r['target']}") + else: + self._system(f"⚠ workspace 恢复失败: {r.get('error')}") except Exception: pass self._remount_current_session() @@ -4200,6 +5947,68 @@ class GenericAgentTUI(App[None]): self.call_after_refresh(_finish) return result.splitlines()[0] if result else "✅ 已恢复" + def _cmd_workspace(self, args, raw): + # /workspace | /workspace off | /workspace (picker). + # Path may contain spaces (Windows) → capture the whole tail. + m = re.match(r"/workspace\s+(\S.*?)\s*$", (raw or "").strip()) + if m: + token = m.group(1) + if token.lower() == "off": + sess = self.current + if sess.workspace_name: + self._bind_workspace(sess, None) + self._system("已退出 workspace(项目模式关闭;junction 与文件保留)") + else: + self._system("当前未处于 workspace 模式") + self._refresh_topbar() + return + # 直接路径无 picker 面包屑,自己显示一条。 + self._system(self._do_workspace_activate(token)) + return + # No arg → searchable picker: free-text input (type an abs path to + # create/enter) over a candidate list of registered workspaces. + sess = self.current + choices = [] + for it in workspace_cmd.registry_list(): + age = _short_age(it["last_used"]) if it["last_used"] else "—" + mem = f"{it['mem_lines']}行记忆" if it["mem_lines"] else "空" + flag = " ⚠失效" if it["dangling"] else "" + # 显示名取真实目录 basename(天然不含 junction 的 -hash8 后缀); + # dangling 无 path 时退回剥掉 name 尾部 hash。名称省略末尾、路径 + # 省略中间,整行经 ChoiceList 单行渲染不会折行。 + disp = os.path.basename((it["path"] or "").rstrip("/\\")) \ + or re.sub(r"-[0-9a-f]{8}$", "", it["name"]) + label = f"{_cell_head(disp, 22)} · {_cell_mid(it['path'], 46)} · {age} · {mem}{flag}" + choices.append((label, it["path"])) + head = ("指定工作目录(输入绝对路径回车新建/进入,或从下方选择已有 · " + "↑/↓ 移动,→/Enter 确认,Esc 取消)") + msg = ChatMessage( + role="system", content=head, kind="choice", choices=choices, + on_select=lambda v: self._do_workspace_activate(v), + ) + msg.searchable = True + msg.free_input = True # Enter on a typed abs path commits it as a new workspace + msg.all_choices = list(choices) + sess.messages.append(msg) + self._refresh_messages() + + def _do_workspace_activate(self, path: str) -> str: + # 唯一展示文本 = 返回值:picker 路径由 _collapse_choice 渲染成 `✓ …` + # 面包屑;直接 `/workspace ` 路径由 _cmd_workspace 用 _system 显示。 + # 两条路径各出一条,故此处不再自行 _system(否则与面包屑重复)。 + r = workspace_cmd.prepare(path) + if not r.get("ok"): + return f"❌ workspace 设定失败: {r.get('error')}" + self._bind_workspace(self.current, r) + self._refresh_topbar() + # 显示名去 hash(与 picker 一致):真实目录 basename,退回剥 name 尾 hash。 + disp = os.path.basename((r.get("target") or "").rstrip("/\\")) \ + or re.sub(r"-[0-9a-f]{8}$", "", r.get("name") or "") + out = f"✅ 已进入 workspace「{disp}」" + if r.get("warning"): + out += f" ⚠ {r['warning']}" + return out + def _cmd_cost(self, args, raw): try: import cost_tracker @@ -4812,6 +6621,7 @@ class GenericAgentTUI(App[None]): sess.current_task_id = tid sess.buffer = "" sess.status = "running" + sess._rw_title = text # checkpoint 标题 = 本次用户输入(段末 commit 用) image_paths = list(images or []) visible_text = text if display_text is None else display_text sess.messages.append(ChatMessage("user", visible_text, image_paths=image_paths)) @@ -4883,6 +6693,7 @@ class GenericAgentTUI(App[None]): s.current_display_queue = None self._update_assistant(agent_id, text, task_id=task_id, done=done, refresh_chrome=True) if done: + self._rw_commit(s) # 落 checkpoint 节点(文件改动已由 tool_before 钩子追踪) self._update_plan_state(s, text) self._drain_ask_user_events(s) @@ -5091,14 +6902,13 @@ class GenericAgentTUI(App[None]): def _update_plan_state(self, sess: AgentSession, _stream_text: str = "") -> None: import plan_state prev = sess.plan_items - # Detect plan mode: `working['in_plan_mode']` first, fallback to per- - # session message scan for a `plan_*/plan.md` reference. Strictly - # per-session via `plan_scan_baseline` to avoid /continue bleed. + # Detect plan mode: `working['in_plan_mode']` (live) first, then + # `restored_plan_path` (/continue, recovered from the structured + # enter_plan_mode tool_use). Chat text mentioning a plan path is + # deliberately NOT a signal — no messages passed. new_items: list = [] - msgs = sess.messages - base = sess.plan_scan_baseline - if plan_state.is_active(sess.agent, messages=msgs, start_idx=base): - path = plan_state.resolve_path(sess.agent, messages=msgs, start_idx=base) + if plan_state.is_active(sess.agent, restored_path=sess.restored_plan_path): + path = plan_state.resolve_path(sess.agent, restored_path=sess.restored_plan_path) if path: try: with open(path, encoding="utf-8", errors="replace") as f: @@ -5131,7 +6941,8 @@ class GenericAgentTUI(App[None]): # Plan-mode armed but no items yet → placeholder (covers the # enter_plan_mode → first plan.md write gap). if not items: - if sess and plan_state.is_active(sess.agent, messages=msgs, start_idx=base): + if sess and plan_state.is_active(sess.agent, + restored_path=sess.restored_plan_path): self._render_planbar_placeholder(bar, sess) return self._set_planbar_visible(bar, False); return @@ -5140,29 +6951,26 @@ class GenericAgentTUI(App[None]): if complete and sess and sess.plan_complete_since is not None: if time.time() - sess.plan_complete_since >= self._PLAN_GRACE_SEC: self._set_planbar_visible(bar, False); return - # 5-row budget: header(1) + step(0/1) + tasks(N) + overflow(0/1). + # Render all tasks — #planbar-tasks caps the visible window at 4 rows and + # scrolls the rest. Open tasks first, done last (open work stays on top). step = plan_state.current_step(msgs, start_idx=base) - budget = 4 - (1 if step else 0) ordered = [(c, st) for c, st in items if st != "done"] + \ [(c, st) for c, st in items if st == "done"] - body_lines = budget - 1 if len(ordered) > budget else budget - shown = ordered[:body_lines] - overflow = max(0, len(ordered) - body_lines) - sig = (tuple(shown), overflow, step, bool(complete and sess and sess.plan_complete_since)) - if getattr(bar, "_plan_sig", None) == sig and bar.display: return + sig = (tuple(ordered), step, bool(complete and sess and sess.plan_complete_since)) + if getattr(bar, "_plan_sig", None) == sig and self._planbar_shown(): return bar._plan_sig = sig - body = Text() - head = f"✓ Plan complete ({n_total}/{n_total})\n" if complete else f"📋 Plan ({n_done}/{n_total})\n" - body.append(head, style=f"bold {C_GREEN}") + head = Text() + head.append(f"✓ Plan complete ({n_total}/{n_total})" if complete + else f"📋 Plan ({n_done}/{n_total})", style=f"bold {C_GREEN}") if step: - body.append(" ▸ ", style=C_GREEN) - body.append(step[:120] + "\n", style=C_MUTED) - for c, st in shown: - if st == "done": body.append(" ✔ ", style=C_GREEN); body.append(c + "\n", style=C_DIM) - else: body.append(" ☐ ", style=C_DIM); body.append(c + "\n", style=C_FG) - if overflow: - body.append(f" ⋮ +{overflow} more", style=C_DIM) - bar.update(body) + head.append("\n ▸ ", style=C_GREEN) + head.append(step[:120], style=C_MUTED) + body = Text() + for i, (c, st) in enumerate(ordered): + if i: body.append("\n") + if st == "done": body.append(" [x] ", style=C_GREEN); body.append(c, style=C_DIM) + else: body.append(" [ ] ", style=C_DIM); body.append(c, style=C_FG) + self._planbar_paint(head, body, bar) self._set_planbar_visible(bar, True) def _render_planbar_placeholder(self, bar: Static, sess: AgentSession) -> None: @@ -5170,31 +6978,49 @@ class GenericAgentTUI(App[None]): import plan_state base = sess.plan_scan_baseline path = (plan_state._stashed_plan_path(sess.agent) - or plan_state.find_path_in_messages(sess.messages, start_idx=base) + or sess.restored_plan_path or "") hint = "/".join(path.replace("\\", "/").rstrip("/").split("/")[-2:]) if path else "plan.md" step = plan_state.current_step(sess.messages, start_idx=base) sig = ("__placeholder__", hint, step) - if getattr(bar, "_plan_sig", None) == sig and bar.display: return + if getattr(bar, "_plan_sig", None) == sig and self._planbar_shown(): return bar._plan_sig = sig - body = Text() - body.append("📋 Plan 模式已激活\n", style=f"bold {C_GREEN}") + head = Text() + head.append("📋 Plan 模式已激活", style=f"bold {C_GREEN}") if step: - body.append(" ▸ ", style=C_GREEN) - body.append(step[:120] + "\n", style=C_MUTED) + head.append("\n ▸ ", style=C_GREEN) + head.append(step[:120], style=C_MUTED) + body = Text() body.append(f" 等待写入 {hint} …", style=C_DIM) - bar.update(body) + self._planbar_paint(head, body, bar) self._set_planbar_visible(bar, True) + def _planbar_paint(self, head: Text, body: Text, bar: Static) -> None: + # Header/step go to the pinned #planbar-head; tasks to #planbar (the + # scrolling body). bar is #planbar, passed in by the callers. + try: self.query_one("#planbar-head", Static).update(head) + except Exception: pass + bar.update(body) + + def _planbar_shown(self) -> bool: + try: return self.query_one("#planbar-scroll", Vertical).has_class("-visible") + except Exception: return False + def _set_planbar_visible(self, bar: Static, visible: bool) -> None: - # Repaint only on show→hide transition; idle ticks no-op. + # Visibility lives on the outer container (display:none ↔ -visible), + # mirroring #palette. Repaint only on show→hide transition; idle ticks no-op. + try: cont = self.query_one("#planbar-scroll", Vertical) + except Exception: return if not visible: - if not bar.display: return - bar.display = False + if not cont.has_class("-visible"): return + cont.remove_class("-visible") + try: self.query_one("#planbar-head", Static).update(Text()) + except Exception: pass bar.update(Text()) bar._plan_sig = None return - if not bar.display: bar.display = True + if not cont.has_class("-visible"): + cont.add_class("-visible") def _start_plan_watcher(self) -> None: if getattr(self, "_plan_timer", None) is not None: return @@ -5207,11 +7033,9 @@ class GenericAgentTUI(App[None]): import plan_state sess = self.sessions.get(self.current_id) if self.current_id is not None else None if sess is None: return - msgs = sess.messages - base = sess.plan_scan_baseline - if not plan_state.is_active(sess.agent, messages=msgs, start_idx=base): + if not plan_state.is_active(sess.agent, restored_path=sess.restored_plan_path): self._refresh_planbar(); return - path = plan_state.resolve_path(sess.agent, messages=msgs, start_idx=base) + path = plan_state.resolve_path(sess.agent, restored_path=sess.restored_plan_path) if not path: self._refresh_planbar(); return try: mtime = os.path.getmtime(path) @@ -5332,11 +7156,14 @@ class GenericAgentTUI(App[None]): self._chip_timer = None try: term_w = self.size.width except Exception: term_w = 0 + # Workspace label is per-session, not the legacy process-global anchor. + p = (s.workspace_path or "").rstrip("/\\") + ws_name = os.path.basename(p) if p else s.workspace_name self.query_one("#topbar", Static).update( render_topbar(s.name, s.status, model, tasks_running, fold_mode=self.fold_mode, busy_elapsed=elapsed, effort=effort, sess_elapsed=sess_elapsed, just_done=just_done, - term_width=term_w)) + term_width=term_w, workspace=ws_name)) self._ensure_title_timer() self._update_terminal_title() @@ -5484,28 +7311,106 @@ class GenericAgentTUI(App[None]): # A parallel wide render builds a wrap-free "source" string that # SelectableStatic.get_selection uses, so copy never includes wrap newlines. try: - text = _TASKLIST_OPEN_RE.sub(r"\1☐ ", text) - text = _TASKLIST_DONE_RE.sub(r"\1✔ ", text) - text = _TOOL_USE_RE.sub(_render_tool_use_block, text) - text = _META_TAG_RE.sub("", text) from io import StringIO from rich.console import Console render_w = max(1, width - 1) + text = _TASKLIST_OPEN_RE.sub(r"\1☐ ", text) + text = _TASKLIST_DONE_RE.sub(r"\1✔ ", text) + # file_write/file_patch render as a themed diff that markdown can't + # express; swap them for a sentinel now and splice the prerendered + # ANSI (narrow) / plain (wide) blocks back in after markdown runs. + diff_blocks: dict[int, tuple[str, str]] = {} + def _stash(rendered, _b=diff_blocks): + n = len(_b) + _b[n] = rendered + return f"\n\nGADIFFSENTINEL{n}END\n\n" + + # Render a standalone markdown block to (narrow-ANSI, wide-plain) + # streams with the card left margin: same theme/widths as the main + # render so the spliced block matches its surroundings, but only the + # narrow lines get the visual-only margin (kept out of the copy + # source). Shared by the code_run card and the generic tool margin. + def _md_to_streams(seg, _w=render_w): + nbuf, wbuf = StringIO(), StringIO() + Console(file=nbuf, width=max(8, _w - _DIFF_MARGIN), + force_terminal=True, color_system="truecolor", + legacy_windows=False, + theme=_markdown_rich_theme(_palette, minimal=(self.theme != "ga-default")) + ).print(HardBreakMarkdown(seg), end="") + Console(file=wbuf, width=10000, force_terminal=False, + legacy_windows=False).print(HardBreakMarkdown(seg), end="") + margin = " " * _DIFF_MARGIN + nar = "\n".join(margin + l for l in nbuf.getvalue().rstrip("\n").split("\n")) + return nar, wbuf.getvalue().rstrip("\n") + + # Primary path: the verbose `🛠️ Tool: … 📥 args:` block. Hash the + # fence body to recover the real args the snapshot hook captured. + def _sub_verbose(m, _w=render_w): + cap = _WRITE_CAP.get(hash(m.group(2))) + if cap: + if cap["name"] == "file_read": + r = _render_read_card(cap["args"], cap.get("content"), _w) + elif cap["name"] == "code_run": + r = _render_code_card(cap["args"], cap.get("data"), _w) + else: + status, msg = cap.get("status"), cap.get("msg", "") + st = _fence_status(m.group(3)) + if st: + status, msg = st + r = _render_write_diff(cap["name"], cap["args"], + cap["existed"], cap["old"], _w, + status=status, msg=msg) + if r: + return _stash(r) + return m.group(0) + text = _VERBOSE_WRITE_RE.sub(_sub_verbose, text) + # Every other tool keeps its Rich-markdown look but gains the same + # left margin: render the block standalone with the same theme. + # file_read / code_run with a missing capture fall through here and + # at least get the margin (their bespoke cards already ran above). + def _sub_tool_margin(m, _w=render_w): + return _stash(_md_to_streams(m.group(0), _w)) + text = _VERBOSE_TOOL_RE.sub(_sub_tool_margin, text) + # Legacy path: a raw `{…}` envelope (weak-tool + # models that inline tool calls as text rather than native calls). + def _sub_tool(m, _w=render_w): + try: + obj = json.loads(m.group(1)) + except Exception: + return m.group(0) + if obj.get("name") in ("file_write", "file_patch"): + args = obj.get("arguments") or {} + r = _render_write_diff(obj.get("name"), args, False, "", _w) + if r: + return _stash(r) + return _render_tool_use_block(m) + text = _TOOL_USE_RE.sub(_sub_tool, text) + text = _META_TAG_RE.sub("", text) buf = StringIO() Console(file=buf, width=render_w, force_terminal=True, color_system="truecolor", legacy_windows=False, theme=_markdown_rich_theme(_palette, minimal=(self.theme != "ga-default")) ).print(HardBreakMarkdown(text), end="") narrow_raw = buf.getvalue().rstrip("\n") - t = Text.from_ansi(narrow_raw) - t.highlight_regex(r"✔[^\n]*", style=C_DIM) - t.highlight_regex(r"☐", style=C_DIM) - t.highlight_regex(r"✔", style=C_GREEN) wide_buf = StringIO() Console(file=wide_buf, width=10000, force_terminal=False, legacy_windows=False).print(HardBreakMarkdown(text), end="") wide_raw = wide_buf.getvalue().rstrip("\n") + + # Splice diff blocks over their sentinel lines (ANSI → narrow, + # plain → wide). Identical post-rstrip text keeps line counts equal + # so `_align_md_renders` pairs them via its K==W branch. + for n, (ansi_block, plain_block) in diff_blocks.items(): + pat = re.compile(rf"^.*GADIFFSENTINEL{n}END.*$", re.M) + narrow_raw = pat.sub(lambda _m, _a=ansi_block: _a, narrow_raw, count=1) + wide_raw = pat.sub(lambda _m, _p=plain_block: _p, wide_raw, count=1) + + t = Text.from_ansi(narrow_raw) + t.highlight_regex(r"✔[^\n]*", style=C_DIM) + t.highlight_regex(r"☐", style=C_DIM) + t.highlight_regex(r"✔", style=C_GREEN) + narrow_plain = _ANSI_SGR_RE.sub("", narrow_raw) # `_align_md_renders` handles Rich table/box-drawing runs at run # granularity: only the table block is copied visually, while normal @@ -5826,10 +7731,15 @@ class GenericAgentTUI(App[None]): batch=m.lazy_choice_batch or 50, classes="picker", ) + elif m.searchable and not m.choices: + # Async-filled picker still loading (or empty): disabled + # hint row under the Input, same shape as the no-matches row. + widget = ChoiceList(m, m.empty_hint, classes="picker") + widget.disabled = True else: widget = ChoiceList(m, classes="picker") for cl, _ in m.choices: - widget.add_option(Option(cl)) + widget.add_option(cl) # `searchable` wraps the freshly-built picker in a Vertical # container with an Input filter on top. The original picker # is preserved as `.picker` so `_active_choice`, key routing @@ -6052,6 +7962,734 @@ def _warn_mintty(): raise SystemExit(1) + +#============================================================================ +# worldline three-pane tree UI (inlined from rewind_tree_view.py; colors follow v2 theme) +#============================================================================ +LANE_COLORS = [C_BLUE, C_PURPLE, C_GREEN, C_CYAN, C_LAVENDER, C_AMBER] + + +def lane_color(depth: int) -> str: + return LANE_COLORS[depth % len(LANE_COLORS)] + + +KIND_COLOR = {"current": C_GREEN, "rewind": C_AMBER, "origin": C_FG} + + +def kind_style(kind: str): + """(字形, 颜色, 标签):字形/标签取自后端语义,颜色由前端配色决定。 + 保留 3 元组形态,render_tree / 详情区调用点无需改动。""" + return kind_glyph(kind), KIND_COLOR.get(kind, C_FG), kind_label(kind) + + +@dataclass +class RenderRow: + key: int + depth: int + text: Text + node_start: int + + +def render_tree(ct: CompressedTree, selected_key: int) -> List[RenderRow]: + rows: List[RenderRow] = [] + sel_depth = ct.disp[selected_key].depth if selected_key in ct.disp else 0 + + def node_style(key: int, depth: int) -> tuple[str, str]: + d = ct.disp[key] + base = C_DIM if d.kind == "fold" else kind_style(ct.tree.nodes[d.node_id].kind)[1] + if key == selected_key: + return base, f"bold {base} on {C_ALT_BG}" + if depth == sel_depth: + return base, base + return base, C_DIM + + def dfs(key: int, is_last: bool, prefix_active: List[bool]) -> None: + d = ct.disp[key] + line = Text() + for col, active in enumerate(prefix_active): + if not active: + line.append(" ") + continue + style = lane_color(col) if col == sel_depth else C_DIM + line.append("│ ", style=style) + if d.parent_key is not None: + connector = "╰─ " if is_last else "├─ " + cstyle = lane_color(d.depth) if d.depth == sel_depth else C_DIM + line.append(connector, style=cstyle) + base, gstyle = node_style(key, d.depth) + node_start = line.cell_len + line.append(ct.glyph(key), style=gstyle) + line.append(" ") + lbl_style = f"bold {C_FG} on {C_ALT_BG}" if key == selected_key else ( + base if d.depth == sel_depth else C_MUTED) + line.append(ct.label(key), style=lbl_style) + line.append(f" {rel_time(ct.end_node(key).ago)}", style=C_DIM) + rows.append(RenderRow(key=key, depth=d.depth, text=line, node_start=node_start)) + kids = d.children + child_prefix = prefix_active + [not is_last] + for i, ck in enumerate(kids): + dfs(ck, i == len(kids) - 1, child_prefix) + + for i, rk in enumerate(ct.roots): + dfs(rk, i == len(ct.roots) - 1, []) + return rows + +# ------------------------------------------------------- Textual 部件 +class ClickableTree(Static): + class NodeClicked(Message): + def __init__(self, key: int) -> None: + self.key = key + super().__init__() + + def __init__(self, **kw) -> None: + super().__init__("", **kw) + self.rows: List[RenderRow] = [] + + def set_rows(self, rows: List[RenderRow]) -> None: + self.rows = rows + body = Text("\n").join(r.text for r in rows) if rows else Text("") + body.no_wrap = True # 禁折行:窄窗靠横向 viewport,绝不折行(否则连线错位) + self.update(body) + + def on_click(self, event) -> None: + off = event.get_content_offset(self) + if off is None: + return + if 0 <= off.y < len(self.rows) and off.x >= self.rows[off.y].node_start: + event.stop() + self.post_message(self.NodeClicked(self.rows[off.y].key)) + + +class ClickableList(Static): + class SegClicked(Message): + def __init__(self, index: int) -> None: + self.index = index + super().__init__() + + def __init__(self, **kw) -> None: + super().__init__("", **kw) + self.idxs: List[Optional[int]] = [] + + def set_content(self, text: Text, idxs: List[Optional[int]]) -> None: + self.idxs = idxs + self.update(text) + + def on_click(self, event) -> None: + off = event.get_content_offset(self) + if off is None: + return + y = off.y + if 0 <= y < len(self.idxs) and self.idxs[y] is not None: + event.stop() + self.post_message(self.SegClicked(self.idxs[y])) + + +class RestoreModeScreen(ModalScreen): + """CC 式回退模式选择弹窗:在世界线里 Enter 选中节点后弹出,选恢复模式。 + + dismiss 返回 `"both"|"conv"|"code"` 或 None(取消)。`to` 决定文案: + `at`=在此节点继续(HEAD→该节点);`before`=回到该提问之前。""" + + CSS = f""" + RestoreModeScreen {{ align: center middle; }} + #rmode_box {{ + width: 50; height: auto; padding: 1 2; + border: round {C_GREEN}; background: {C_SEL_BG}; + }} + RestoreModeScreen Static {{ background: transparent; }} + """ + BINDINGS = [ + Binding("up,k", "up", "↑", priority=True), + Binding("down,j", "down", "↓", priority=True), + Binding("enter", "confirm", "确认", priority=True), + Binding("c", "pick_conv", "仅对话", priority=True), + Binding("o", "pick_code", "仅代码", priority=True), + Binding("escape", "cancel", "取消", priority=True), + ] + _OPTS = [("both", "恢复对话 + 代码"), ("conv", "仅对话"), ("code", "仅代码")] + + def __init__(self, title: str, to: str) -> None: + super().__init__() + self._title = title + self._to = to + self.sel = 0 + + def compose(self) -> ComposeResult: + with Vertical(id="rmode_box"): + yield Static("", id="rmode_body") + + def on_mount(self) -> None: + self._redraw() + + def _redraw(self) -> None: + body = Text() + verb = "在此继续会话" if self._to == "at" else "回到此提问之前" + body.append(f"{verb}:", style=C_BLUE) + body.append(f"{ellipsize(self._title, 28)}\n\n", style=f"bold {C_FG}") + for i, (_, lbl) in enumerate(self._OPTS): + active = i == self.sel + body.append("▶ " if active else " ", style=C_GREEN if active else C_DIM) + body.append(lbl + "\n", + style=f"bold {C_FG} on {C_ALT_BG}" if active else C_MUTED) + body.append("\n↑↓ 选择 Enter 确认 c 仅对话 o 仅代码 Esc 取消", style=C_DIM) + self.query_one("#rmode_body", Static).update(body) + + def action_up(self) -> None: + self.sel = (self.sel - 1) % len(self._OPTS); self._redraw() + + def action_down(self) -> None: + self.sel = (self.sel + 1) % len(self._OPTS); self._redraw() + + def action_confirm(self) -> None: + self.dismiss(self._OPTS[self.sel][0]) + + def action_pick_conv(self) -> None: + self.dismiss("conv") + + def action_pick_code(self) -> None: + self.dismiss("code") + + def action_cancel(self) -> None: + self.dismiss(None) + + +class RewindTreeScreen(ModalScreen): + """三栏全屏 checkpoint 浏览器(世界线)。dismiss 结果: + `{"action":"restore","mode":"both"|"conv"|"code","node":nid,"to":"before"|"at"}` + 或 None(取消)。`to=before`=回到该提问之前(prefill);`to=at`=在该节点继续 + (HEAD→该节点,无 prefill,= tip「当前位置/新分支」)。 + diff / delete 在屏内直接读/改 store(delete 后重建树)。""" + + CSS = f""" + RewindTreeScreen {{ background: {C_BG}; }} + * {{ + scrollbar-background: {C_BG}; + scrollbar-color: {C_BORDER}; + scrollbar-color-hover: {C_DIM}; + scrollbar-color-active: {C_DIM}; + scrollbar-corner-color: {C_BG}; + }} + /* ModalScreen defaults to height:auto + a centered dialog box, which + collapses our %/fr-based three-pane layout into a small popup. Force the + screen to fill the terminal and give the top Horizontal the full height so + the panes' percentages resolve against a real size again. */ + RewindTreeScreen {{ height: 100%; overflow: hidden; }} + RewindTreeScreen > Horizontal {{ height: 1fr; width: 100%; }} + #rw3_left {{ + width: 44%; height: 1fr; border: round {C_BORDER}; border-title-color: {C_GREEN}; + padding: 0 1; overflow-x: auto; overflow-y: auto; + }} + #rw3_lefttree {{ width: auto; height: auto; }} + #rw3_right {{ width: 1fr; height: 1fr; }} + #rw3_rtop {{ + height: 50%; border: round {C_BORDER}; border-title-color: {C_BLUE}; + padding: 0 1; overflow-y: auto; + }} + #rw3_rbot {{ + height: 1fr; border: round {C_BORDER}; border-title-color: {C_PURPLE}; + padding: 0 1; + }} + #rw3_rbot_body {{ height: auto; }} + #rw3_diff {{ height: 1fr; overflow-y: auto; overflow-x: auto; scrollbar-size-vertical: 1; }} + #rw3_diff_body {{ height: auto; width: auto; }} + RewindTreeScreen Static {{ background: transparent; }} + #rw3_status {{ height: 1; color: {C_DIM}; padding: 0 1; }} + """ + BINDINGS = [ + # 上下 = 移动**聚焦**窗口(左树按整树视觉行序 / 右上段内含 tip)。 + Binding("up,k", "up", "↑", priority=True), + Binding("down,j", "down", "↓", priority=True), + # Ctrl+↑↓ = 移动**非聚焦**窗口(ctrl 在部分终端不可靠 → PgUp/PgDn、[ ] 兜底)。 + Binding("ctrl+up,pageup,left_square_bracket", "other_up", "非聚焦↑", priority=True), + Binding("ctrl+down,pagedown,right_square_bracket", "other_down", "非聚焦↓", priority=True), + # ←/→ = 左树切换层级(列);Tab = 翻转聚焦框(另由 on_key 兜底,见下)。 + Binding("left,h", "level_up", "上层", priority=True), + Binding("right,l", "level_down", "下层", priority=True), + Binding("tab", "focus_toggle", "切聚焦", priority=True), + # Enter:左聚焦 → 进入右侧节点选择;右聚焦 → 弹回退模式窗。 + Binding("enter", "enter", "进入/回退", priority=True), + Binding("x", "delete", "删除子树"), + Binding("escape", "cancel", "取消"), + ] + + def __init__(self, store) -> None: + super().__init__() + self.store = store + self.ct = CompressedTree(tree_from_store(store, __import__("time").time())) + self._mark_current() + flat = self.ct.flatten() + # 打开即聚焦到「当前位置」所在的显示节点(折叠组)。 + self.sel_key = self._disp_key_for_node(self._current_node()) or (flat[0] if flat else 0) + self.focus_right = False + # 默认聚焦到「当前位置」(见 _default_seg_idx)。 + self.seg_idx = self._default_seg_idx() + self._diff_for: Optional[str] = None # 正在内联展示 diff 的节点 id + + # ---- 当前位置:就一个游标。有游标=回退到的那个节点;没有=末端 tip ---- + def _cursor(self): + """rewind 游标 = 被截断回退到的那个节点(用户心中的「当前位置」)。集成层在 + rewind 时记到 store._rw_cursor,继续提问时清除。无效/无 → None(当前=末端 tip)。 + + 一致性:从「当前位置」再进去要重现当前视图。rewind 到 X→视图=parent(X)前; + 从 X 进去=rewind 到 X=同样视图 ✓。所以当前位置就是 X 本身。""" + cur = getattr(self.store, "_rw_cursor", None) + return cur if (cur and cur in self.store.nodes) else None + + def _current_node(self): + """开屏定位用的「当前位置」节点:游标 or HEAD。""" + return self._cursor() or self.store.head + + def _mark_current(self) -> None: + """◉「当前」只两种落点:有游标→标在游标节点;无游标→不标节点(末端 tip 承载)。 + 先撤掉 tree_from_store 给 HEAD 的自动 current。""" + head, nodes = self.store.head, self.ct.tree.nodes + if head in nodes: + nodes[head].kind = self.store.nodes.get(head, {}).get("kind") or "edit" + cur = self._cursor() + if cur and cur in nodes: + nodes[cur].kind = "current" + + def _default_seg_idx(self) -> int: + """▶ 默认落「当前位置」:有游标→游标节点;无游标→末端 tip。""" + seg = self._current_seg() + if not seg: + return 0 + cur = self._cursor() + if cur: + idx = seg.index(cur) if cur in seg else len(seg) + else: + idx = len(seg) + return min(idx, self._seg_max_idx(seg)) # 无 tip 的 origin 段:落到末项而非占位项 + + def _disp_key_for_node(self, node_id) -> Optional[int]: + """找包含 node_id 的显示节点 key(真实节点本身 / 折叠段含它)。""" + if node_id is None: + return None + for k, d in self.ct.disp.items(): + if d.kind == "real" and d.node_id == node_id: + return k + if d.kind == "fold" and node_id in d.seg: + return k + return None + + def _seg_of(self, key) -> List[str]: + d = self.ct.disp.get(key) + if d is None: + return [] + return list(d.seg) if d.kind == "fold" else [d.node_id] + + def compose(self) -> ComposeResult: + with Horizontal(): + with ScrollableContainer(id="rw3_left"): + yield ClickableTree(id="rw3_lefttree") + with Vertical(id="rw3_right"): + with VerticalScroll(id="rw3_rtop"): + yield ClickableList(id="rw3_rtop_body") + with Vertical(id="rw3_rbot"): + yield Static("", id="rw3_rbot_body") + with VerticalScroll(id="rw3_diff"): + yield Static("", id="rw3_diff_body") + yield Static("", id="rw3_status") + + def on_mount(self) -> None: + self.query_one("#rw3_left").border_title = "世界线 · 全局压缩树" + self.query_one("#rw3_rtop").border_title = "被折叠的节点" + self.query_one("#rw3_rbot").border_title = "详情 / 操作" + self.query_one("#rw3_rtop").can_focus = False + # 左栏设为可聚焦并**抓住焦点**:让本 modal 真正持有键盘。否则全部子组件 + # can_focus=False → 焦点留在下层输入框,Tab 会被输入框的命令补全吃掉、屏的 + # on_key 收不到(↑↓ 靠 priority binding 才照常生效)。聚焦框仍由 + # _refresh_focus_frame 手动画(亮青边框),与 Textual 默认 focus 视觉无关。 + left = self.query_one("#rw3_left") + left.can_focus = True + left.focus() + self.refresh_all() + + # ---- 联动 ---- + def _set_left_selection(self, key: int, keep_focus: bool = False) -> None: + """选中左树某显示节点。keep_focus=True 时不夺回聚焦(供 Ctrl 移动非聚焦左树)。 + 右上默认选中「当前(绿)记录」或最后一条真实记录(见 _default_seg_idx)。""" + self.sel_key = key + if not keep_focus: + self.focus_right = False + self.seg_idx = self._default_seg_idx() + self._diff_for = None + self.refresh_all() + + def refresh_all(self) -> None: + rows = render_tree(self.ct, self.sel_key) + self.query_one("#rw3_lefttree", ClickableTree).set_rows(rows) + self._refresh_rtop() + self._refresh_rbot() + self._refresh_status() + self._refresh_focus_frame() + sel_idx = next((i for i, r in enumerate(rows) if r.key == self.sel_key), 0) + self._follow_left(rows, sel_idx) + + def _refresh_focus_frame(self) -> None: + """聚焦框:聚焦窗口边框高亮(亮青),另一个用普通边框色。""" + try: + left = self.query_one("#rw3_left") + rtop = self.query_one("#rw3_rtop") + left.styles.border = ("round", C_BORDER if self.focus_right else C_CYAN) + rtop.styles.border = ("round", C_CYAN if self.focus_right else C_BORDER) + except Exception: + pass + + def _follow_left(self, rows, sel_idx) -> None: + self._ensure_line_visible("#rw3_left", sel_idx) + try: + c = self.query_one("#rw3_left") + w = c.scrollable_size.width + if w <= 0 or sel_idx >= len(rows): + return + sx = int(c.scroll_offset.x) + x0 = rows[sel_idx].node_start + x1 = rows[sel_idx].text.cell_len + if x0 < sx or x0 >= sx + w: + c.scroll_to(x=max(0, x0 - 2), animate=False) + elif x1 > sx + w: + c.scroll_to(x=max(0, min(x0 - 2, x1 - w + 1)), animate=False) + except Exception: + pass + + def _ensure_line_visible(self, selector, y) -> None: + try: + c = self.query_one(selector) + h = c.scrollable_size.height + if h <= 0: + return + sy = int(c.scroll_offset.y) + if y < sy: + c.scroll_to(y=y, animate=False) + elif y >= sy + h: + c.scroll_to(y=y - h + 1, animate=False) + except Exception: + pass + + def _current_seg(self) -> List[str]: + return self._seg_of(self.sel_key) + + def _tip_kind(self) -> str: + """末尾 tip 的语义:HEAD 是叶 且就是本段末端 → current(当前位置·自此继续); + 否则 → new(自此另起一条对话)。HEAD 有后代(rewind 未继续)时,当前位置标在那个 + 中间节点本身(◉),tip 一律是 new。""" + if self._cursor(): + return "new" # 当前在游标节点(◉),末端一律 new + seg = self._current_seg() + # 无游标:本段末端就是 HEAD → 末端 tip 即「当前位置·自此继续」。 + return "current" if (seg and seg[-1] == self.store.head) else "new" + + def _seg_has_tip(self, seg=None) -> bool: + """段末是否显示「自此继续」占位项。会话起点(origin)不显示:选中 origin 项本身 + 即回到空起点,与「自此继续」语义重复。""" + seg = self._current_seg() if seg is None else seg + return not (seg and self.ct.tree.nodes[seg[-1]].kind == "origin") + + def _seg_max_idx(self, seg=None) -> int: + """seg_idx 上界:有 tip → len(seg)(可停在占位项);无 tip → 最后一条真实项。""" + seg = self._current_seg() if seg is None else seg + return len(seg) if self._seg_has_tip(seg) else max(0, len(seg) - 1) + + def _refresh_rtop(self) -> None: + d = self.ct.disp[self.sel_key] + seg = self._current_seg() + lines: List[Text] = [] + idxs: List[Optional[int]] = [] # 行 → seg 索引(tip = len(seg)),None=不可选 + lines.append(Text("选中某条提问可回退到其之前;末尾一项则从当前位置继续", style=C_DIM)); idxs.append(None) + lines.append(Text("")); idxs.append(None) + for i, nid in enumerate(seg): + n = self.ct.tree.nodes[nid] + active = i == self.seg_idx + g, gs, _ = kind_style(n.kind) + rs = (f"bold {C_FG} on {C_ALT_BG}" if active and self.focus_right + else (C_FG if active else C_MUTED)) + line = Text() + line.append("▶ " if active else " ", + style=(C_CYAN if self.focus_right else C_DIM) if active else C_DIM) + line.append(f"{g} ", style=C_GREEN if active else gs) + line.append(ellipsize(n.title, 54), style=rs) + line.append(f" {rel_time(n.ago)}", style=C_DIM) + lines.append(line); idxs.append(i) + # 末尾占位项(尚未创建,用括号标注);圆点字形,颜色同普通记录(new)/绿(current)。 + # 会话起点(origin)不显示此占位项(_seg_has_tip):选中 origin 项本身即回到空起点。 + if self._seg_has_tip(seg): + tip_i = len(seg) + tk = self._tip_kind() + tip_active = tip_i == self.seg_idx + tlabel = "(当前位置 · 自此继续)" if tk == "current" else "(自此继续会话)" + glyph = "◉" if tk == "current" else "●" # 当前位置用 ◉(同节点当前标记) + base = C_GREEN if tk == "current" else C_FG + trs = (f"bold {C_FG} on {C_ALT_BG}" if tip_active and self.focus_right + else (base if tip_active else C_DIM)) + tline = Text() + tline.append("▶ " if tip_active else " ", + style=(C_CYAN if self.focus_right else C_DIM) if tip_active else C_DIM) + tline.append(f"{glyph} ", style=C_GREEN if (tip_active or tk == "current") else C_DIM) + tline.append(tlabel, style=trs) + lines.append(tline); idxs.append(tip_i) + self.query_one("#rw3_rtop_body", ClickableList).set_content( + Text("\n").join(lines), idxs) + + def _target_node(self) -> Optional[str]: + """diff/delete 的目标节点:右上选中段内节点 > 当前显示节点的叶(tip 也归叶)。""" + seg = self._current_seg() + if self.focus_right and self.seg_idx < len(seg): + return seg[self.seg_idx] + return seg[-1] if seg else None + + def _restore_spec(self) -> "tuple[Optional[str], str]": + """恢复目标 (node_id, to): + - 段内节点(seg_idx None: + seg = self._current_seg() + on_tip = self.seg_idx >= len(seg) + body = Text() + if on_tip: + tk = self._tip_kind() + tail = self.ct.tree.nodes[seg[-1]] if seg else None + if tk == "current": + body.append("(当前位置)\n", style=f"bold {C_GREEN}") + body.append(" 已处于该对话最新处,发送消息即可继续。\n", style=C_MUTED) + else: + body.append("(自此继续会话)\n", style=f"bold {C_FG}") + body.append(" 从这条记录之后继续对话。\n", style=C_MUTED) + if tail is not None: + body.append(f" 续接于 {ellipsize(tail.title, 40)}\n", style=C_MUTED) + body.append(f" 时间 {rel_time(tail.ago)}\n", style=C_MUTED) + else: + nid = seg[self.seg_idx] + n = self.ct.tree.nodes[nid] + _, color, kind_label = kind_style(n.kind) + body.append(f"{n.title}\n", style=f"bold {color}") + if kind_label: + body.append(f" 类型 {kind_label}\n", style=C_MUTED) + body.append(f" 时间 {rel_time(n.ago)}\n", style=C_MUTED) + body.append(f" 改动文件 {files_summary(n.files)}\n", style=C_MUTED) + body.append(" 回退至此 清除本次提问及其后内容\n", style=C_DIM) + + body.append("\n操作 ", style=C_DIM) + body.append(" Enter " + ("选择回退方式" if self.focus_right else "进入右栏选择"), + style=f"{C_FG} on {C_SEL_BG}") + body.append(" x 删除节点\n", style=C_DIM) + body.append("(回退前自动保存还原点)", style=C_DIM) + self.query_one("#rw3_rbot_body", Static).update(body) + # 下方可滚动 diff 视窗:选中节点 vs 父节点(上一个状态)的逐行改动。 + self._refresh_diff(None if on_tip else seg[self.seg_idx]) + + _DIFF_MAX_LINES = 500 # diff 视窗最多渲染这么多行,防超长文件卡渲染 + + def _render_node_diff(self, node_id): + """选中节点 vs 父节点的逐行 diff。返回 (Text, 总增行, 总删行)。""" + from difflib import SequenceMatcher + out = Text() + ins = dele = 0 + emitted = 0 + capped = False + for fi, f in enumerate(self.store.node_diff(node_id)): + a, b = f["old"].splitlines(), f["new"].splitlines() + groups = SequenceMatcher(None, a, b, autojunk=False).get_grouped_opcodes(3) + if not groups: + continue + if out: + out.append("\n") + out.append(f"{f['rel']}\n", style=f"bold {C_BLUE}") + for gi, group in enumerate(groups): + if gi: + out.append(" ⋮\n", style=C_DIM) + for tag, i1, i2, j1, j2 in group: + if tag == "equal": + for k in range(i1, i2): + if emitted < self._DIFF_MAX_LINES: + out.append(f" {a[k]}\n", style=C_MUTED); emitted += 1 + else: + capped = True + else: + for k in range(i1, i2): + dele += 1 + if emitted < self._DIFF_MAX_LINES: + out.append(f" - {a[k]}\n", style=C_RED); emitted += 1 + else: + capped = True + for k in range(j1, j2): + ins += 1 + if emitted < self._DIFF_MAX_LINES: + out.append(f" + {b[k]}\n", style=C_GREEN); emitted += 1 + else: + capped = True + if capped: + out.append(f" …(已截断,仅显示前 {self._DIFF_MAX_LINES} 行)\n", style=C_AMBER) + return out, ins, dele + + def _refresh_diff(self, node_id) -> None: + dbody = Text() + if node_id is None: # 占位项(自此继续/当前位置):diff 视窗留空 + pass + else: + diff, ins, dele = self._render_node_diff(node_id) + dbody.append(f"+{ins} ", style=f"bold {C_GREEN}") + dbody.append(f"-{dele}", style=f"bold {C_RED}") + dbody.append(" 本次改动(对比上一个状态)\n", style=C_DIM) + dbody.append(diff if (ins or dele) else Text(" 本次无文件改动(仅对话)", style=C_DIM)) + self.query_one("#rw3_diff_body", Static).update(dbody) + try: + self.query_one("#rw3_diff").scroll_home(animate=False) # 切节点后回到 diff 顶部 + except Exception: + pass + + def _refresh_status(self) -> None: + where = "右栏" if self.focus_right else "左树" + self.query_one("#rw3_status", Static).update(Text( + f" 聚焦:{where} | 共 {len(self.ct.flatten())}/{len(self.ct.tree.nodes)} | " + f"↑↓ 当前栏 Ctrl↑↓(/PgUp·Dn/[ ]) 另一栏 ←→ 切层级 Tab 切聚焦 " + f"Enter 进入/回退 x 删 Esc", style=C_DIM)) + + # ---- 导航:上下=聚焦窗 / Ctrl上下=非聚焦窗 / Tab·←→=切聚焦 ---- + def action_up(self) -> None: + if self.focus_right: self._move_right(-1) + else: self._move_left(-1, keep_focus=False) + + def action_down(self) -> None: + if self.focus_right: self._move_right(+1) + else: self._move_left(+1, keep_focus=False) + + def action_other_up(self) -> None: + if self.focus_right: self._move_left(-1, keep_focus=True) + else: self._move_right(-1) + + def action_other_down(self) -> None: + if self.focus_right: self._move_left(+1, keep_focus=True) + else: self._move_right(+1) + + def _move_left(self, delta: int, keep_focus: bool) -> None: + order = self.ct.flatten() + if self.sel_key not in order: + return + i = order.index(self.sel_key) + j = max(0, min(i + delta, len(order) - 1)) + if j != i: + self._set_left_selection(order[j], keep_focus=keep_focus) + + def _move_right(self, delta: int) -> None: + n = self._seg_max_idx() # 索引 0..n,n=tip(无 tip 的 origin 段则到末项) + self.seg_idx = max(0, min(self.seg_idx + delta, n)) + self._diff_for = None + self._refresh_rtop(); self._refresh_rbot(); self._refresh_status() + self._ensure_line_visible("#rw3_rtop", self.seg_idx + 3) + + def _set_focus(self, right: bool) -> None: + self.focus_right = right + if right: + self.seg_idx = min(self.seg_idx, self._seg_max_idx()) # 越界落到 tip(或末项) + self._diff_for = None + self._refresh_rtop(); self._refresh_rbot(); self._refresh_status() + self._refresh_focus_frame() + + def action_focus_toggle(self) -> None: + self._set_focus(not self.focus_right) + + def on_key(self, event) -> None: + # Tab 兜底:真终端里 Tab 会被 Textual 的焦点遍历系统吃掉(headless 正常), + # 故在 on_key 直接拦截,prevent_default 阻止默认焦点移动。 + if event.key in ("tab", "shift+tab"): + event.prevent_default() + event.stop() + self.action_focus_toggle() + + def _order(self): + return _order_depths(self.ct) + + def action_level_up(self) -> None: + # ←:左树切到上一层级(列);回到左聚焦。 + self._set_left_selection(nearest_depth_node(self._order(), self.sel_key, -1)) + + def action_level_down(self) -> None: + self._set_left_selection(nearest_depth_node(self._order(), self.sel_key, +1)) + + def action_enter(self) -> None: + # 左聚焦:进入右侧节点选择;右聚焦:弹回退模式窗。 + if not self.focus_right: + self._set_focus(True) + else: + self._open_restore_popup() + + # ---- 操作 actions ---- + def _open_restore_popup(self) -> None: + """右聚焦时 Enter:弹 CC 式回退模式窗,选定后再 dismiss(带 mode + to)。""" + node_id, to = self._restore_spec() + if node_id is None: + return + self._pending = (node_id, to) + title = self.ct.tree.nodes[node_id].title if node_id in self.ct.tree.nodes else "" + self.app.push_screen(RestoreModeScreen(title, to), self._on_mode_picked) + + def _on_mode_picked(self, mode) -> None: + if not mode: + return + node_id, to = getattr(self, "_pending", (None, "at")) + if node_id is None: + return + self.dismiss({"action": "restore", "mode": mode, "node": node_id, "to": to}) + + def action_delete(self) -> None: + tgt = self._target_node() + if tgt is None or tgt == self.store.root_id: + return # 不删根 + try: + self.store.delete_subtree(tgt) + except Exception: + return + # 重建压缩树;选中落回当前位置所在显示节点 + self.ct = CompressedTree(tree_from_store(self.store, __import__("time").time())) + self._mark_current() + flat = self.ct.flatten() + if not flat: + self.dismiss(None) # 树空了 + return + self.sel_key = self._disp_key_for_node(self._current_node()) or flat[0] + self.focus_right = False + self.seg_idx = self._default_seg_idx() + self._diff_for = None + self.refresh_all() + + def action_cancel(self) -> None: + self.dismiss(None) + + # ---- 点击 ---- + def on_click(self, event) -> None: + # 点击不同区域切聚焦框:点到节点/条目时各自 handler 已 stop 并设好聚焦, + # 这里兜住「点到面板空白处」也能切聚焦。 + try: + x, y = event.screen_x, event.screen_y + if self.query_one("#rw3_left").region.contains(x, y): + if self.focus_right: + self._set_focus(False) + elif self.query_one("#rw3_rtop").region.contains(x, y): + if not self.focus_right: + self._set_focus(True) + except Exception: + pass + + def on_clickable_tree_node_clicked(self, msg) -> None: + self._set_left_selection(msg.key) + + def on_clickable_list_seg_clicked(self, msg) -> None: + self.focus_right = True + self.seg_idx = max(0, min(msg.index, self._seg_max_idx())) + self._diff_for = None + self._refresh_rtop(); self._refresh_rbot(); self._refresh_status() + self._refresh_focus_frame() + self._ensure_line_visible("#rw3_rtop", self.seg_idx + 3) + + def main(argv: Optional[list[str]] = None) -> int: build_arg_parser().parse_args(argv) _warn_mintty() diff --git a/frontends/wechatapp.py b/frontends/wechatapp.py index aff57bf1..301f10c6 100644 --- a/frontends/wechatapp.py +++ b/frontends/wechatapp.py @@ -63,20 +63,42 @@ class WxBotClient: return r.json() def login_qr(self, poll_interval=2): - r = requests.get(f'{API}/ilink/bot/get_bot_qrcode', params={'bot_type': 3}, headers={'User-Agent': UA}, timeout=10) - r.raise_for_status() - d = r.json() + # 获取二维码:对限流/缺字段(无 'qrcode')退避重试,避免崩溃→重启→更狠地打接口的死亡螺旋 + d = {} + for attempt in range(6): + try: + r = requests.get(f'{API}/ilink/bot/get_bot_qrcode', params={'bot_type': 3}, headers={'User-Agent': UA}, timeout=10) + r.raise_for_status() + d = r.json() + except requests.exceptions.RequestException as e: + print(f'[QR登录] 获取二维码失败({e}),{2 ** attempt}s 后重试...'); time.sleep(2 ** attempt); continue + if d.get('qrcode') and d.get('qrcode_img_content'): # 二维码 ID + 可扫图都就绪才算成功 + break + print(f'[QR登录] 二维码未就绪(可能被限流,ret={d.get("ret")}),{2 ** attempt}s 后重试...') + time.sleep(2 ** attempt) + if not (d.get('qrcode') and d.get('qrcode_img_content')): + raise RuntimeError('多次重试仍未获取到可扫二维码(疑似限流),请稍后重试') qr_id, url = d['qrcode'], d.get('qrcode_img_content', '') print(f'[QR登录] ID: {qr_id}') if url: - img = self._tf.parent / 'wx_qr.png' - qrcode.make(url).save(str(img)) # 保存到文件,不弹浏览器 + # 先打 ASCII 二维码(纯文本,无需 PIL;容器/无头环境靠它扫码) qr = qrcode.QRCode(border=1); qr.add_data(url); qr.make(fit=True); qr.print_ascii(invert=True) + # 再尝试存 PNG 兜底——依赖 PIL,缺失/失败不应让登录崩溃 + try: + qrcode.make(url).save(str(self._tf.parent / 'wx_qr.png')) + except Exception as e: + print(f'[QR登录] PNG 兜底保存失败({e}),用上方 ASCII 二维码扫码即可') last = '' while True: time.sleep(poll_interval) - try: s = requests.get(f'{API}/ilink/bot/get_qrcode_status', params={'qrcode': qr_id}, headers={'User-Agent': UA}, timeout=60).json() - except requests.exceptions.ReadTimeout: continue + # 轮询状态:对所有网络异常 / 非 JSON(被限流时常返回 HTML)容错重试, + # 不让单次抖动把进程打崩——配合 restart:unless-stopped 否则会死亡螺旋 + try: + s = requests.get(f'{API}/ilink/bot/get_qrcode_status', params={'qrcode': qr_id}, headers={'User-Agent': UA}, timeout=60).json() + except requests.exceptions.RequestException: + continue + except ValueError: # 响应非 JSON(限流/网关页) + time.sleep(poll_interval); continue st = s.get('status', '') if st != last: print(f' 状态: {st}'); last = st if st == 'confirmed': @@ -415,11 +437,14 @@ if __name__ == '__main__': print(f'[NEW] Process starting {time.strftime("%m-%d %H:%M")}') bot = WxBotClient() if _do_relogin or not bot.token: - if not sys.stdout.isatty(): - print('[Bot] no token and not interactive, exit.'); sys.exit(1) - sys.stdout = sys.stderr = sys.__stdout__ # restore for QR display - bot.login_qr() - sys.stdout = sys.stderr = _logf + # QR 登录在无 TTY 的容器里也可用:把二维码打到真实 stdout(docker logs + # 可见),而不是日志文件——之前在重定向后才判 isatty(),文件句柄恒 false + # 导致容器内必然退出,无法首次登录。PNG 仍存 ~/.wxbot/wx_qr.png 作兜底。 + sys.stdout = sys.stderr = sys.__stdout__ # restore for QR display (real stdout / container log) + try: + bot.login_qr() + finally: + sys.stdout = sys.stderr = _logf threading.Thread(target=agent.run, daemon=True).start() print(f'WeChat Bot 已启动 (bot_id={bot.bot_id})', file=sys.__stdout__) try: diff --git a/frontends/workspace_cmd.py b/frontends/workspace_cmd.py new file mode 100644 index 00000000..c9c4d984 --- /dev/null +++ b/frontends/workspace_cmd.py @@ -0,0 +1,478 @@ +"""Workspace 命令的共享逻辑(tuiapp_v2 / tui_v3 复用)。 + +设计要点(详见对话设计稿): + * **兼容旧入口** `plugins/project_mode.py` 与 `memory/project_mode_sop.md` 的 pid 锚。 + 前端在 + `/temp/projects/` 建一个指向用户真实绝对路径的目录联接(junction), + 并可按需写激活锚 `/temp/.active_project.`。project_mode 插件 + 照常每轮注入 L1,并把 project_memory.md / 产物经 junction 写进真实仓库根 + (与 Claude Code 在仓库根放 CLAUDE.md 同理,已接受)。 + * **路径基准必须与插件一致**:插件的 `_TEMP` 是基于其 `__file__` 的 `/temp` + 绝对路径(非 cwd)。本模块也从自身 `__file__` 推 `/temp`(frontends/ 的上一级 + 即 repo 根),两边独立计算但结果一致,互不 import。 + * **pid 语义**:插件读 `os.getpid()`(GA 进程)。前端就跑在 GA 进程里,写锚同样用 + `os.getpid()`(不是 SOP 里 code_run 子进程用的 getppid)。 + * **命名** `name = f"{basename}-{hash8}"`,hash8 = blake2b(规范化绝对路径)[:8]。 + 同一 workspace 恒定同名(幂等复用);hash 后缀又让 junction 名不与其它 UI 人工命名的 + 普通项目目录相撞。 + * **junction 安全**:检测用 reparse 属性(`os.path.islink` 对 junction 返回 False!); + 删除用 `os.rmdir`,**绝不 rmtree**(会击穿删真实文件)。cleanup 只动确认是 junction + 且悬空/未注册的条目,真实目录(其它 UI 的普通项目)一律不碰。 +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import subprocess +import sys +import time +from typing import Optional + + +# --------------------------------------------------------------------------- # +# 路径基准(与 plugins/project_mode.py 的 _TEMP 保持一致) +# --------------------------------------------------------------------------- # +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _temp_root() -> str: + return os.path.join(_REPO_ROOT, "temp") + + +def _projects_root() -> str: + return os.path.join(_temp_root(), "projects") + + +def _anchor_path() -> str: + """激活锚,pid 键控,与插件 `_ANCHOR` 同。""" + return os.path.join(_temp_root(), f".active_project.{os.getpid()}") + + +def _registry_path() -> str: + return os.path.join(_temp_root(), "workspaces.json") + + +_REGISTRY_VERSION = 1 + + +# --------------------------------------------------------------------------- # +# 命名 +# --------------------------------------------------------------------------- # +def _norm_abspath(p: str) -> str: + """规范化绝对路径用于 hash:abspath + normcase(Windows 大小写不敏感 -> + 同一目录恒定同名)。不走 realpath,避免解析 junction/symlink 带来的意外。""" + return os.path.normcase(os.path.abspath(p)) + + +def _ws_name(abs_path: str) -> str: + base = os.path.basename(abs_path.rstrip("/\\")) or "ws" + digest = hashlib.blake2b(_norm_abspath(abs_path).encode("utf-8")).hexdigest()[:8] + return f"{base}-{digest}" + + +def _link_path(name: str) -> str: + return os.path.join(_projects_root(), name) + + +# --------------------------------------------------------------------------- # +# junction / symlink 跨平台封装(reparse 安全) +# --------------------------------------------------------------------------- # +def make_dir_link(target_abs: str, link_path: str) -> bool: + """建目录联接。Windows 用 `mklink /J`(免管理员);POSIX 用 symlink。 + 成功返回 True;失败打印到 stderr 并返回 False。""" + target_abs = os.path.abspath(target_abs) + parent = os.path.dirname(link_path) + try: + os.makedirs(parent, exist_ok=True) + except OSError as e: + sys.stderr.write(f"[workspace] mkdir {parent} failed: {e}\n") + return False + if os.name == "nt": + # mklink 是 cmd 内建,必须经 cmd 调用。列表传参由 subprocess 负责加引号, + # 兼容含空格/中文的路径。 + try: + r = subprocess.run( + ["cmd", "/c", "mklink", "/J", link_path, target_abs], + capture_output=True, text=True, + ) + except OSError as e: + sys.stderr.write(f"[workspace] mklink invoke failed: {e}\n") + return False + if r.returncode != 0 or not os.path.exists(link_path): + sys.stderr.write(f"[workspace] mklink /J failed: " + f"{(r.stderr or r.stdout or '').strip()}\n") + return False + return True + # POSIX + try: + os.symlink(target_abs, link_path, target_is_directory=True) + return True + except OSError as e: + sys.stderr.write(f"[workspace] symlink failed: {e}\n") + return False + + +def is_dir_link(path: str) -> bool: + """是否目录联接/符号链接。**不能只用 os.path.islink**——它对 Windows junction + 返回 False。改看 reparse point 属性 + reparse tag。""" + try: + if os.path.islink(path): # POSIX symlink、Windows 符号链接 + return True + except OSError: + return False + if os.name != "nt": + return False + try: + st = os.lstat(path) + except OSError: + return False + attrs = getattr(st, "st_file_attributes", 0) + reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + if not (attrs & reparse): + return False + # 进一步认 tag:挂载点(junction)或符号链接 + tag = getattr(st, "st_reparse_tag", 0) + mount = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003) + syml = getattr(stat, "IO_REPARSE_TAG_SYMLINK", 0xA000000C) + if tag: + return tag in (mount, syml) + return True # 有 reparse 属性但拿不到 tag,保守视作链接(我们只在此目录建链) + + +def link_target(path: str) -> Optional[str]: + """读链接目标;清洗 Windows 的 \\??\\ / \\\\?\\ 前缀。失败返回 None。""" + try: + t = os.readlink(path) + except OSError: + return None + for pre in ("\\??\\", "\\\\?\\"): + if t.startswith(pre): + t = t[len(pre):] + break + return t + + +def remove_dir_link(path: str) -> bool: + """只摘掉链接本身,绝不递归删目标。Windows junction / 符号链接目录用 os.rmdir, + POSIX symlink 用 os.unlink。**调用前务必 is_dir_link 确认。**""" + try: + if os.name == "nt": + os.rmdir(path) + else: + os.unlink(path) + return True + except OSError as e: + sys.stderr.write(f"[workspace] remove link {path} failed: {e}\n") + return False + + +# --------------------------------------------------------------------------- # +# 注册表 temp/workspaces.json(本功能私有;v2/v3 可能并发 -> 原子写) +# --------------------------------------------------------------------------- # +def registry_load() -> dict: + try: + with open(_registry_path(), encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION: + items = data.get("items") + if isinstance(items, dict): + return items + except (OSError, ValueError): + pass + return {} + + +def _registry_save(items: dict) -> None: + path = _registry_path() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = f"{path}.{os.getpid()}.tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump({"version": _REGISTRY_VERSION, "items": items}, + fh, ensure_ascii=False, separators=(",", ":")) + os.replace(tmp, path) + except OSError as e: + sys.stderr.write(f"[workspace] registry save failed: {e}\n") + + +# --------------------------------------------------------------------------- # +# 会话→工作区映射 temp/session_workspaces.json — 让 /continue 即时恢复,不必先 +# 聊一轮在日志留 PROJECT MODE 块。key=会话日志名, value=workspace 真实路径, +# ""=已 off(区别于缺 key=无记录→回退扫日志)。手动操作触发、极低频,照搬注册 +# 表原子写、不加锁。 +# --------------------------------------------------------------------------- # +def _session_map_path() -> str: + return os.path.join(_temp_root(), "session_workspaces.json") + + +def _session_map_load() -> dict: + try: + with open(_session_map_path(), encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION: + items = data.get("items") + if isinstance(items, dict): + return items + except (OSError, ValueError): + pass + return {} + + +def _session_map_save(items: dict) -> None: + path = _session_map_path() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = f"{path}.{os.getpid()}.tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump({"version": _REGISTRY_VERSION, "items": items}, + fh, ensure_ascii=False, separators=(",", ":")) + os.replace(tmp, path) + except OSError as e: + sys.stderr.write(f"[workspace] session map save failed: {e}\n") + + +def session_ws_set(log_path: str, target: str) -> None: + """记录会话绑定的 workspace 路径;target="" 表示该会话已 off。""" + key = os.path.basename(log_path or "") + if not key: + return + items = _session_map_load() + items[key] = target or "" + _session_map_save(items) + + +def session_ws_get(log_path: str): + """路径=绑定 / ""=已 off / None=无记录(调用方回退扫日志)。""" + key = os.path.basename(log_path or "") + return _session_map_load().get(key) if key else None + + +def session_map_prune() -> None: + """删掉日志文件已不存在的孤儿条目(启动时调一次)。""" + items = _session_map_load() + logdir = os.path.join(_temp_root(), "model_responses") + alive = {k: v for k, v in items.items() if os.path.isfile(os.path.join(logdir, k))} + if len(alive) != len(items): + _session_map_save(alive) + + +def registry_upsert(name: str, abs_path: str) -> None: + items = registry_load() + items[name] = {"path": os.path.abspath(abs_path), "last_used": int(time.time())} + _registry_save(items) + + +def registry_remove(name: str) -> None: + items = registry_load() + if items.pop(name, None) is not None: + _registry_save(items) + + +def _mem_lines(link: str) -> int: + """project_memory.md 行数(经 junction 读真实文件);读不到返回 0。""" + mp = os.path.join(link, "project_memory.md") + try: + with open(mp, encoding="utf-8", errors="replace") as fh: + return sum(1 for _ in fh) + except OSError: + return 0 + + +def registry_list() -> list[dict]: + """供 picker 候选列表:[{name, path, last_used, mem_lines, dangling}],按最近使用倒序。""" + out = [] + for name, ent in registry_load().items(): + path = (ent or {}).get("path") or "" + out.append({ + "name": name, + "path": path, + "last_used": int((ent or {}).get("last_used") or 0), + "mem_lines": _mem_lines(_link_path(name)) if path else 0, + "dangling": not (path and os.path.isdir(path)), + }) + out.sort(key=lambda x: x["last_used"], reverse=True) + return out + + +# --------------------------------------------------------------------------- # +# 校验 +# --------------------------------------------------------------------------- # +def validate_path(abs_path: str) -> tuple[bool, str]: + if not abs_path or not abs_path.strip(): + return False, "路径为空" + p = abs_path.strip().strip('"').strip("'") + if not os.path.isabs(p): + return False, "需要绝对路径" + if os.name == "nt" and p.startswith("\\\\"): + return False, "不支持网络路径(UNC):junction 无法指向网络位置" + if not os.path.exists(p): + return False, f"路径不存在: {p}" + if not os.path.isdir(p): + return False, "不是目录" + if _norm_abspath(p).startswith(_norm_abspath(_temp_root())): + return False, "该路径已在 temp 内,无需 workspace" + return True, "" + + +# --------------------------------------------------------------------------- # +# 主流程 +# --------------------------------------------------------------------------- # +def prepare(abs_path: str) -> dict: + """准备 workspace,但不写进程级激活锚。返回: + {ok, name, link, target, mem_text, warning, error}。 + 流程:校验 -> name -> 幂等建链 -> 确保 project_memory.md 存在 -> 注册 -> 回读记忆。 + TUI 多会话隔离使用本函数,避免多个 session 争用同一个 pid 锚。""" + p = abs_path.strip().strip('"').strip("'") if abs_path else "" + ok, msg = validate_path(p) + if not ok: + return {"ok": False, "error": msg} + target = os.path.abspath(p) + name = _ws_name(target) + link = _link_path(name) + warning = "" + + # 幂等建链 + if os.path.lexists(link): + if is_dir_link(link): + cur = link_target(link) + if cur and _norm_abspath(cur) == _norm_abspath(target): + pass # 已指向同一目标 -> 复用 + else: + remove_dir_link(link) + if not make_dir_link(target, link): + return {"ok": False, "error": "重建 junction 失败(见 stderr)"} + else: + # 极罕见:同名真实目录占位(其它 UI 的普通项目)。绝不覆盖。 + return {"ok": False, + "error": f"{link} 已是真实目录(可能是其它项目),拒绝覆盖"} + else: + if not make_dir_link(target, link): + return {"ok": False, "error": "创建 junction 失败(见 stderr)"} + + # 确保 project_memory.md 存在(经 junction 落到真实仓库根) + mem_path = os.path.join(link, "project_memory.md") + if not os.path.isfile(mem_path): + try: + open(mem_path, "a", encoding="utf-8").close() + except OSError as e: + warning = f"无法创建 project_memory.md: {e}" + + mem_text = "" + try: + with open(mem_path, encoding="utf-8", errors="replace") as fh: + mem_text = fh.read() + except OSError: + pass + + registry_upsert(name, target) + + return {"ok": True, "name": name, "link": link, "target": target, + "mem_text": mem_text, "warning": warning, "error": ""} + + +def activate(abs_path: str) -> dict: + """设定并激活进程级 workspace。保留给旧 SOP / 非多会话 UI 使用。""" + r = prepare(abs_path) + if not r.get("ok"): + return r + try: + with open(_anchor_path(), "w", encoding="utf-8") as fh: + fh.write(r["name"]) + except OSError as e: + r = dict(r) + r.update({"ok": False, "error": f"写激活锚失败: {e}"}) + return r + + +def deactivate() -> bool: + """仅删激活锚;junction 与文件保留。返回是否原本处于激活态。""" + anchor = _anchor_path() + if os.path.isfile(anchor): + try: + os.remove(anchor) + return True + except OSError as e: + sys.stderr.write(f"[workspace] deactivate failed: {e}\n") + return False + + +def current() -> Optional[dict]: + """当前激活的 workspace:{name, path};未激活返回 None。""" + anchor = _anchor_path() + try: + name = open(anchor, encoding="utf-8").read().strip() + except OSError: + return None + if not name: + return None + ent = registry_load().get(name) or {} + return {"name": name, "path": ent.get("path") or ""} + + +def is_dangling(name: str) -> bool: + """junction 指向的真实目标是否已失效(被删/盘断开)。""" + link = _link_path(name) + if not is_dir_link(link): + return True + t = link_target(link) + return not (t and os.path.isdir(t)) + + +def remove(name: str) -> None: + """显式注销:删 junction(不动真实文件)+ 删注册表条目。若正激活该项目则一并删锚。""" + link = _link_path(name) + if is_dir_link(link): + remove_dir_link(link) + registry_remove(name) + cur = current() + if cur and cur["name"] == name: + deactivate() + + +# project_mode 插件注入的标记:`[PROJECT MODE: ]`(见 _build_injection)。 +# 它随用户消息写进 model_responses 日志,故可据此判断被 /continue 的会话当时 +# 在哪个 workspace。 +_PM_RE = re.compile(r"\[PROJECT MODE:\s*([^\]\n]+?)\s*\]") + + +def workspace_from_log(log_path: str) -> Optional[dict]: + """扫一份 model_responses 日志,返回它最后激活的 workspace {name, path}; + 仅当该 name 是**已注册的 workspace**(在 workspaces.json 里)才返回—— + 普通 SOP 项目(无 hash、不在注册表)一律忽略。""" + try: + with open(log_path, encoding="utf-8", errors="replace") as f: + content = f.read() + except OSError: + return None + names = _PM_RE.findall(content) + if not names: + return None + name = names[-1].strip() # 最后一次激活胜出 + ent = registry_load().get(name) + if not ent or not ent.get("path"): + return None + return {"name": name, "path": ent["path"]} + + +def cleanup() -> None: + """v2/v3 启动时调一次:清理 temp/projects/ 下**悬空或未注册**的 junction。 + 安全纪律:只处理 is_dir_link 确认的链接;真实目录(其它 UI 的普通项目)一律跳过。""" + proot = _projects_root() + if not os.path.isdir(proot): + return + registered = set(registry_load().keys()) + try: + entries = os.listdir(proot) + except OSError: + return + for nm in entries: + link = os.path.join(proot, nm) + if not is_dir_link(link): + continue # 真实目录 -> 不碰 + t = link_target(link) + dangling = not (t and os.path.isdir(t)) + if dangling or nm not in registered: + remove_dir_link(link) diff --git a/frontends/worldline.py b/frontends/worldline.py new file mode 100644 index 00000000..2a1c3223 --- /dev/null +++ b/frontends/worldline.py @@ -0,0 +1,968 @@ +"""GA 世界线(Rewind / checkpoint)统一后端 —— **UI 无关,单文件,tui/gui 共用**。 + +三块合一(本文件不含任何 Textual/前端交互逻辑,只产数据与做存储): + 1. **持久化**(`RewindStore`):content-addressable blob + checkpoint 树 + 全持久化。 + - 统一 **checkpoint 树**;**content-addressable blob**(内容 sha256 去重); + - 只追 `file_write`/`file_patch`(路径由调用方在 `tool_before` 钩子喂入); + - **global baseline**(每文件「首次改前」)解决「回退到该文件被追踪之前」。 + 2. **视图模型 + 压缩**(`CheckpointTree`/`tree_from_store`/`CompressedTree`):把真实 + store 投影成只读树并强制折叠线性段,供任何前端渲染。 + 3. **导航数学 + 恢复编排**(`next_same_depth`/`nearest_depth_node`/`restore_plan`): + 纯计算;`restore_plan` 算出回退后的 history/文件/prefill 并落地(改文件+移 HEAD+ + 重写投影),前端只管把结果刷到自己的界面。 + +参考 Claude Code file-history 思路,按 GA 改造。纯 Python、可离线测试;线程安全交调用方 +(agent 跑在子线程,约定仅 agent 空闲时 restore)。详见需求文档(ga_rewind_tui_requirements)。 +""" +from __future__ import annotations + +import difflib +import hashlib +import json +import os +import shutil +import time +from datetime import datetime +import zlib +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from rich.cells import cell_len, set_cell_size + +# 该值为 None 表示「该版本文件不存在」(对应 CC 的 backupFileName: null)。 +_ABSENT = None + + +class RewindStore: + """一个 session 的 checkpoint 树 + blob 库。 + + 用法(典型时序): + store = RewindStore(root, cwd) + # 改前(tool_before 钩子,file_write/file_patch 执行前): + store.track_pre_edit(abs_path) + # ...工具实际写盘... + # 段末(用户提问边界 / turn 收尾): + store.commit("修复 stdout", hist_len=42) + # 浏览 / 恢复: + store.apply_code(node_id) # 把工作区还原到该节点 + store.rewind_head(node_id) # 只移 HEAD(下次 commit 即从此分叉) + """ + + def __init__(self, root: str, cwd: str) -> None: + self.root = os.path.abspath(root) + self.cwd = os.path.abspath(cwd) + self.objects_dir = os.path.join(self.root, "objects") + self.tree_path = os.path.join(self.root, "tree.json") + + # id -> {parent, children[], title, created, kind, hist_len, files{rel: hash|None}} + self.nodes: dict[str, dict] = {} + self.head: str | None = None + self.root_id: str | None = None + self._seq = 0 + # 全局:每个被追踪文件的「首次改前」状态(hash 或 None=当时不存在)。 + self.baseline: dict[str, str | None] = {} + self.tracked: set[str] = set() + + # 段内暂存:本段触碰过的相对路径(commit 时落定)。 + self._touched: set[str] = set() + # 最近一次 restore 的 redo 点(内存级软保险②),结构同 node.files。 + self._redo: dict | None = None + + self.load() + if self.nodes: + self._ensure_origin() # 迁移:旧树(无 origin)挂一个「会话起点」根 + + # --------------------------------------------------------------- origin + def _ensure_origin(self) -> None: + """保证存在一个虚拟根「会话起点」(空状态):所有真实 checkpoint 都是它的 + 后代。幂等。第一次 commit / 迁移旧树 / resume 时调用。 + + - 它代表「第一轮对话之前」,使「回退到第一个会话之前」= 普通地 rewind 到它; + - files={} + hist_len=0 → apply_code(origin) 经 baseline 回退 = 还原到空起点; + - 它是唯一真根 → 不会出现 forest / head=None。""" + if any(nd.get("kind") == "origin" for nd in self.nodes.values()): + if self.head is None: + self.head = self.root_id + return + oid = "origin" + while oid in self.nodes: + oid += "_" + tops = self.top_level_nodes() + self.nodes[oid] = {"parent": None, "children": list(tops), + "title": "会话起点", "created": time.time(), + "kind": "origin", "hist_len": 0, "files": {}} + for t in tops: + self.nodes[t]["parent"] = oid + self.root_id = oid + if self.head is None: + self.head = oid + + # ------------------------------------------------------------------ paths + def key(self, abs_path: str) -> str: + """绝对路径在 cwd 下 → 存相对(参考 CC maybeShortenFilePath);否则原样。""" + ap = os.path.abspath(abs_path) + try: + if os.path.commonpath([ap, self.cwd]) == self.cwd: + return os.path.relpath(ap, self.cwd).replace(os.sep, "/") + except ValueError: + pass # 跨盘符(Windows)等:commonpath 抛错 → 用绝对路径 + return ap.replace(os.sep, "/") + + def _abs(self, rel: str) -> str: + if os.path.isabs(rel) or (len(rel) > 1 and rel[1] == ":"): + return os.path.normpath(rel) + return os.path.normpath(os.path.join(self.cwd, rel)) + + # ------------------------------------------------------------------ blobs + def _put_blob(self, data: bytes) -> str: + h = hashlib.sha256(data).hexdigest() + p = os.path.join(self.objects_dir, h[:2], h) + if not os.path.exists(p): + os.makedirs(os.path.dirname(p), exist_ok=True) + tmp = p + ".tmp" + with open(tmp, "wb") as f: + f.write(zlib.compress(data, 6)) + os.replace(tmp, p) + return h + + def _get_blob(self, h: str) -> bytes: + with open(os.path.join(self.objects_dir, h[:2], h), "rb") as f: + return zlib.decompress(f.read()) + + def _snapshot(self, abs_path: str) -> str | None: + """当前内容 → blob hash;文件不存在 → None。""" + try: + with open(abs_path, "rb") as f: + data = f.read() + except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError): + return _ABSENT + return self._put_blob(data) + + # --------------------------------------------------------------- tracking + def track_pre_edit(self, abs_path: str) -> None: + """改前埋点:首次见到该文件时记下它的「改前」状态作 baseline。 + + 幂等——同一文件同段内多次调用只在首次抓 baseline(file_patch 连改不会 + 覆盖原始版本,对应 CC trackEdit 的 v1 保护)。""" + rel = self.key(abs_path) + self._touched.add(rel) + if rel not in self.baseline: + self.baseline[rel] = self._snapshot(abs_path) # 改前内容,可能是 None + self.tracked.add(rel) + + def commit(self, title: str, hist_len: int | None = None, kind: str = "edit", + history: list | None = None) -> str: + """段末落节点:继承 HEAD.files,把本段触碰文件的「改后」内容写进新节点。 + + `history` 给定时(对话树化):切 `history[parent.hist_len:当前]` 为本轮对话增量, + 存成 content-addressable conv blob,节点记其 hash + `hist_len=len(history)`。 + 此刻索引可靠 → 增量精确,恢复时沿路径拼回即可,不再靠会漂的绝对下标。 + + 无触碰文件时也会落节点(纯对话推进也是 checkpoint)。返回新节点 id。""" + self._ensure_origin() # 第一次提交前先确保有「会话起点」根 + parent = self.head # 至少是 origin,永不为 None + files = dict(self.nodes[parent]["files"]) if parent is not None else {} + for rel in self._touched: + files[rel] = self._snapshot(self._abs(rel)) # 改后内容,删除则 None + + conv = None + if history is not None: + parent_len = self.nodes[parent].get("hist_len") if parent is not None else 0 + parent_len = parent_len or 0 # None(旧节点) / origin → 0 + delta = list(history[parent_len:]) + hist_len = len(history) + conv = self._put_blob( + json.dumps(delta, ensure_ascii=False, default=str).encode("utf-8")) + + nid = self._new_id() + self.nodes[nid] = { + "parent": parent, + "children": [], + "title": title, + "created": time.time(), + "kind": kind, + "hist_len": hist_len, + "files": files, + "conv": conv, + } + if parent is not None: + self.nodes[parent]["children"].append(nid) + elif self.root_id is None or self.root_id not in self.nodes: + self.root_id = nid # 只认第一个根;rewind-到-根之前再提交会产生第二个顶层节点(forest) + self.head = nid + self._touched.clear() + self.save() + return nid + + # ------------------------------------------------------------- navigation + def rewind_head(self, node_id) -> None: + """移动 HEAD(不动文件)。从非叶 HEAD 下次 commit 即 append 子节点 = fork。 + node_id=None 表示「回到根之前」(空起点),下次 commit 产生新的顶层节点。""" + if node_id is not None and node_id not in self.nodes: + raise KeyError(node_id) + self.head = node_id + self.save() + + def top_level_nodes(self) -> list[str]: + """所有顶层节点(parent 为 None 或父已不存在)。通常一个 root,rewind-到-根 + 之前再提交会出现多个(forest)。""" + return [nid for nid, nd in self.nodes.items() + if nd.get("parent") is None or nd.get("parent") not in self.nodes] + + def _target_state(self, node_id: str, rel: str): + """文件 rel 在 node_id 时应有的状态:节点 files 里有就用它,否则回退到 + baseline(CC 的 first-version)。返回 (known: bool, hash_or_None)。""" + files = self.nodes[node_id]["files"] + if rel in files: + return True, files[rel] + if rel in self.baseline: + return True, self.baseline[rel] + return False, None + + def apply_code(self, node_id) -> list[tuple[str, str]]: + """把工作区文件还原到 node_id 的状态。`node_id=None` → 还原到 baseline + (任何 checkpoint 之前的原始状态,用于「回退到根之前」)。 + + - **只动本 store 追踪过的路径**(self.tracked),绝不碰未记录文件(软保险①)。 + - restore 前先把这些路径的当前内容存进 redo 点(软保险②)——误覆盖也能找回。 + 返回 [(rel, action)],action ∈ {restored, deleted}。""" + if node_id is not None and node_id not in self.nodes: + raise KeyError(node_id) + + # 软保险②:redo 点(记录当前 = 即将被覆盖的状态)。 + redo_files: dict[str, str | None] = {} + for rel in self.tracked: + redo_files[rel] = self._snapshot(self._abs(rel)) + self._redo = {"from": self.head, "files": redo_files} + + changed: list[tuple[str, str]] = [] + for rel in self.tracked: + if node_id is None: + known = rel in self.baseline + target = self.baseline.get(rel) + else: + known, target = self._target_state(node_id, rel) + if not known: + continue + ap = self._abs(rel) + cur = self._snapshot(ap) + if cur == target: + continue + if target is _ABSENT: + try: + os.remove(ap) + changed.append((rel, "deleted")) + except FileNotFoundError: + pass + else: + os.makedirs(os.path.dirname(ap) or ".", exist_ok=True) + with open(ap, "wb") as f: + f.write(self._get_blob(target)) + changed.append((rel, "restored")) + return changed + + def diff(self, node_id: str) -> list[dict]: + """选中节点 vs 当前工作区,逐文件变更摘要(恢复到该节点会发生什么)。 + + 返回 [{rel, action, insertions, deletions}],仅列出会变的文件。 + action: restore(覆盖)/ delete(删除)/ create(重建被删文件)。""" + if node_id not in self.nodes: + raise KeyError(node_id) + out: list[dict] = [] + for rel in sorted(self.tracked): + known, target = self._target_state(node_id, rel) + if not known: + continue + cur = self._snapshot(self._abs(rel)) + if cur == target: + continue + old = self._text(cur) # 当前工作区内容 + new = self._text(target) # 该节点内容(恢复目标) + ins = dele = 0 + for line in difflib.ndiff(old.splitlines(), new.splitlines()): + if line.startswith("+ "): + ins += 1 + elif line.startswith("- "): + dele += 1 + if target is _ABSENT: + action = "delete" + elif cur is _ABSENT: + action = "create" + else: + action = "restore" + out.append({"rel": rel, "action": action, "insertions": ins, "deletions": dele}) + return out + + def node_diff(self, node_id) -> list[dict]: + """选中节点 vs **父节点**的逐文件内容 diff(= 这次 checkpoint 改了什么)。 + 返回 [{rel, old, new}](文本),仅含两者不同的文件;父缺失/文件不存在 → 空文本。 + 供 UI 渲染逐行 diff(区别于 `diff()` 的"节点 vs 当前工作区"计数摘要)。""" + if node_id not in self.nodes: + return [] + nd = self.nodes[node_id] + par = nd.get("parent") + pf = self.nodes[par]["files"] if par in self.nodes else {} + nf = nd.get("files") or {} + out: list[dict] = [] + for rel in sorted(set(nf) | set(pf)): + nh = nf.get(rel) + # 「上一个状态」:父节点记录过就用它;父节点没有(本节点首次触碰该文件)→ + # 回退到 baseline(首次改前内容,CC first-version)。否则被 file_patch 修改的 + # 已有文件会因父节点无记录而被误显示成整文件新增,而非真正的逐行 diff。 + ph = pf[rel] if rel in pf else self.baseline.get(rel) + if nh == ph: + continue + out.append({"rel": rel, "old": self._text(ph), "new": self._text(nh)}) + return out + + def _text(self, h: str | None) -> str: + if h is _ABSENT: + return "" + try: + return self._get_blob(h).decode("utf-8", "replace") + except Exception: + return "" + + # --------------------------------------------------------- delete + gc + def delete_subtree(self, node_id: str) -> list[str]: + """删除节点及其整棵子树(不能删 HEAD 祖先路径上的节点 / root 由调用方把关)。 + 返回被删的节点 id 列表。删后做一次 blob GC。""" + if node_id not in self.nodes: + raise KeyError(node_id) + victims: list[str] = [] + + def collect(nid: str) -> None: + victims.append(nid) + for c in list(self.nodes[nid]["children"]): + collect(c) + + collect(node_id) + parent = self.nodes[node_id]["parent"] + if parent is not None: + self.nodes[parent]["children"] = [ + c for c in self.nodes[parent]["children"] if c != node_id + ] + for nid in victims: + self.nodes.pop(nid, None) + if node_id == self.root_id: + self.root_id = None + if self.head in victims: + self.head = parent # HEAD 被删 → 退到父 + self._gc() + self.save() + return victims + + def _gc(self) -> int: + """删除无人引用的 blob。返回删除数。""" + referenced: set[str] = set() + for node in self.nodes.values(): + for h in node["files"].values(): + if h is not _ABSENT: + referenced.add(h) + ch = node.get("conv") # 对话增量 blob 也是引用 + if ch: + referenced.add(ch) + for h in self.baseline.values(): + if h is not _ABSENT: + referenced.add(h) + if self._redo: + for h in self._redo["files"].values(): + if h is not _ABSENT: + referenced.add(h) + removed = 0 + if not os.path.isdir(self.objects_dir): + return 0 + for sub in os.listdir(self.objects_dir): + d = os.path.join(self.objects_dir, sub) + if not os.path.isdir(d): + continue + for h in os.listdir(d): + if h not in referenced: + try: + os.remove(os.path.join(d, h)) + removed += 1 + except OSError: + pass + return removed + + # ------------------------------------------------------------ persistence + def save(self) -> None: + os.makedirs(self.root, exist_ok=True) + payload = { + "nodes": self.nodes, + "head": self.head, + "root": self.root_id, + "seq": self._seq, + "baseline": self.baseline, + "tracked": sorted(self.tracked), + } + tmp = self.tree_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, default=str) + # 崩溃兜底:替换前把上一份完好的 tree.json 留作 .bak(load 损坏时回退它)。 + if os.path.exists(self.tree_path): + try: + shutil.copyfile(self.tree_path, self.tree_path + ".bak") + except OSError: + pass + os.replace(tmp, self.tree_path) + + def load(self) -> None: + """加载树。主文件损坏/缺失 → 回退 .bak;都不行 → 留空 store(降级,不抛)。""" + for path in (self.tree_path, self.tree_path + ".bak"): + if not os.path.exists(path): + continue + try: + with open(path, encoding="utf-8") as f: + d = json.load(f) + except Exception: + continue # 损坏 → 尝试下一个候选 + self.nodes = d.get("nodes", {}) + self.head = d.get("head") + self.root_id = d.get("root") + self._seq = d.get("seq", 0) + self.baseline = d.get("baseline", {}) + self.tracked = set(d.get("tracked", [])) + return + + def _new_id(self) -> str: + self._seq += 1 + return f"v{self._seq}" + + # ---------------------------------------------------------- session key + @staticmethod + def key_for_log(log_path: str) -> str: + """会话 key = log_path 的 basename 去扩展名,如 `model_responses_123456`。 + 这是 GA 唯一稳定、/continue 据以识别会话的身份(task_dir 是 PID 级、重启即变, + 不能用)。""" + return os.path.splitext(os.path.basename(log_path))[0] + + @classmethod + def for_log(cls, temp_dir: str, log_path: str, cwd: str) -> "RewindStore": + """按 GA 的 log_path 落到 `temp/.ga_rewind//`。""" + root = os.path.join(temp_dir, ".ga_rewind", cls.key_for_log(log_path)) + return cls(root, cwd) + + def resume_from(self, old_root: str) -> bool: + """/continue 续接:把旧会话的 rewind 目录接到本 store(贴近 CC 的 + copyFileHistoryForResume)。 + + GA 每个新 agent 生成新 log_path → 新 key,旧会话的树/blob 默认看不到; + 这里在新 key 下重建:① 接管旧 tree.json(仅当本 store 尚为空); + ② blob 内容寻址,直接 hardlink(同名即同内容,跳过;失败回退 copy)。 + 返回是否接管成功。""" + if not os.path.isdir(old_root) or os.path.abspath(old_root) == self.root: + return False + old_tree = os.path.join(old_root, "tree.json") + if not self.nodes and os.path.exists(old_tree): + try: + with open(old_tree, encoding="utf-8") as f: + d = json.load(f) + self.nodes = d.get("nodes", {}) + self.head = d.get("head") + self.root_id = d.get("root") + self._seq = d.get("seq", 0) + self.baseline = d.get("baseline", {}) + self.tracked = set(d.get("tracked", [])) + except Exception: + return False + old_objects = os.path.join(old_root, "objects") + if os.path.isdir(old_objects): + for sub in os.listdir(old_objects): + sd = os.path.join(old_objects, sub) + if not os.path.isdir(sd): + continue + dd = os.path.join(self.objects_dir, sub) + os.makedirs(dd, exist_ok=True) + for h in os.listdir(sd): + dst = os.path.join(dd, h) + if os.path.exists(dst): + continue # 内容寻址:同名即同内容 + src = os.path.join(sd, h) + try: + os.link(src, dst) # hardlink 不占额外空间 + except OSError: + try: + shutil.copyfile(src, dst) # 跨盘/不支持 → 回退 copy + except OSError: + pass + if self.nodes: + self._ensure_origin() # 旧树若无 origin,迁移补上 + self.save() + return True + + # ------------------------------------------------------------------ views + def linear_path(self) -> list[str]: + """root → HEAD 的线性路径(节点 id),供 /rewind 时间线展示。""" + return self.path_to(self.head) + + def path_to(self, node_id) -> list[str]: + """root → node_id 的节点 id 列表(含两端)。node_id 缺失 → []。""" + if node_id is None or node_id not in self.nodes: + return [] + chain: list[str] = [] + cur: str | None = node_id + while cur is not None and cur in self.nodes: + chain.append(cur) + cur = self.nodes[cur].get("parent") + chain.reverse() + return chain + + # ----------------------------------------------------------- conversation + def _node_conv(self, node_id) -> list: + """单节点的对话增量(list)。无 conv / 读失败 → [](容错降级)。""" + h = self.nodes.get(node_id, {}).get("conv") + if not h: + return [] + try: + data = json.loads(self._get_blob(h).decode("utf-8")) + return data if isinstance(data, list) else [] + except Exception: + return [] + + def rebuild_history(self, node_id) -> list: + """沿 root→node_id 拼接各节点对话增量,整体重建 `backend.history`。 + + 这是「树=真相源」的恢复入口:选任意节点(含旁支)都能精确还原其对话, + 不依赖内存里那条线性 history。origin / 无 conv 的节点贡献空增量。""" + hist: list = [] + for nid in self.path_to(node_id): + hist.extend(self._node_conv(nid)) + return hist + + # ----------------------------------------------------- 对账(防外部改写灾难) + @staticmethod + def _msg_user_text(msg) -> str: + """真实用户提问文本;非 user / 纯 tool_result 回填 → ""(不算提问边界)。""" + if not isinstance(msg, dict) or msg.get("role") != "user": + return "" + c = msg.get("content") + if isinstance(c, str): + return c + if isinstance(c, list): + if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in c): + return "" + return " ".join(b.get("text", "") for b in c + if isinstance(b, dict) and b.get("type") == "text") + return "" + + @classmethod + def _turn_sig(cls, msgs) -> list: + """一段消息的「对话身份」= 其中真实用户提问的文本序列(跳过 tool_result 轮, + 故不含注入的易变内容)。用于 reconcile 判两段是否同一对话。""" + return [t for t in (cls._msg_user_text(m).strip() for m in msgs) if t] + + def reconcile(self, history: list) -> int: + """把 live history 里树尚未记录的尾部轮次吸收成 conv-only 节点,使树追平日志。 + 供「带 worldline 的 UI」在打开世界线 / 接管日志前调用 —— 别的(不更新树的) + UI 往同一日志追加后,若不对账,树会滞后;一旦在滞后状态 rewind, + `rewrite_projection` 会拿陈旧树回写、**抹掉那些外部轮次**(灾难性丢失)。 + 对账后树恒 ⊇ 日志,故 rewind 物理上不可能 clobber 未见过的轮次。 + + **安全闸**:仅当树 HEAD 路径与 history 前缀是「同一段对话」才在其上 append; + 否则判为分歧(用户问了不同的问题),HEAD 退回 origin 另起一条顶层 trunk 容纳 + live history,旧树保留为旁支、绝不拿它回写日志。空树 = n=0 特例(吸收全部, + 即旧 _bootstrap_conv_tree)。幂等:无尾巴 → no-op,可任意重复调用。返回吸收条数。 + + 判「同一段对话」用**用户提问序列**(`_turn_sig`)而非逐条全等:消息里会被注入 + 易变内容(如 `[WORKING MEMORY]`,只在 tool_result 轮),同一段对话在不同时刻 + 捕获并不逐字节相等;逐条全等会把它误判成分歧、复制出一整条重复 trunk。提问 + 文本在 tool_result 轮之外、稳定不变,是可靠的对话身份。""" + history = list(history or []) + self._ensure_origin() + head = self.head if self.head in self.nodes else self.root_id + known = self.rebuild_history(head) + n = len(known) + if self._turn_sig(history[:n]) != self._turn_sig(known): + # 分歧(提问序列不同):不信任陈旧树。退回空起点,后续 append 只反映 live history。 + self.rewind_head(self.root_id) + n = len(self.rebuild_history(self.root_id)) # origin 无 conv → 0 + if len(history) <= n: + return 0 + # 按用户提问边界切尾巴 [n, len);逐段 commit(history=history[:段末]), + # commit 内部 delta = history[parent.hist_len:段末]。清 _touched 保证纯 conv-only。 + starts = [i for i in range(n, len(history)) + if self._msg_user_text(history[i]).strip()] + if not starts or starts[0] > n: + starts = [n] + starts + self._touched.clear() + absorbed = 0 + for p, s in enumerate(starts): + end = starts[p + 1] if p + 1 < len(starts) else len(history) + title = next((self._msg_user_text(history[i]).strip() + for i in range(s, end) + if self._msg_user_text(history[i]).strip()), "") + title = (title or "(外部续接)").replace("\n", " ").strip()[:80] + self.commit(title or "(外部续接)", kind="edit", history=list(history[:end])) + absorbed += end - s + return absorbed + + def first_user_message(self, node_id) -> dict | None: + """node_id 对话增量里的首条 `role==user` 消息(原始 dict);无则 None。 + + 供 rewind 后精确 prefill(取「该提问本身」,不靠会漂的绝对下标)。文本提取 + 交调用方(消息内容格式是 GA backend 私有的)。""" + for msg in self._node_conv(node_id): + if isinstance(msg, dict) and msg.get("role") == "user": + return msg + return None + + +# ============================================================================ +# 世界线视图模型 + 压缩 + 导航(从 rewind_tree_view.py 抽出的 UI 无关后端) +# ============================================================================ +# 节点**语义**(字形 + 文字标签);**呈现色由前端决定**(颜色不属于后端)。 +KIND_GLYPH = {"current": "◉", "rewind": "↩", "origin": "●"} # ◉=当前所在;●=普通/根 +KIND_LABEL = {"current": "当前位置", "rewind": "回退来源", "origin": "会话起点"} + + +def kind_glyph(kind: str) -> str: + return KIND_GLYPH.get(kind, "●") + + +def kind_label(kind: str) -> str: + return KIND_LABEL.get(kind, "") + + +def rel_time(ago_s) -> str: + if ago_s is None: + return "" + if ago_s < 60: + return "刚刚" + m = ago_s // 60 + if m < 60: + return f"{m} 分钟前" + h = m // 60 + if h < 24: + return f"{h} 小时前" + return f"{h // 24} 天前" + + +def files_summary(files: List[str]) -> str: + if not files: + return "无变更" + if len(files) <= 3: + return "、".join(files) + return "、".join(files[:3]) + f" (+{len(files) - 3})" + + +_TITLE_MAX_CELLS = 40 + + +def ellipsize(s: str, max_cells: int = _TITLE_MAX_CELLS) -> str: + """按显示宽度(中文算 2 格)截断,超长加 `…`。用于左树/右上的长提问标题。""" + s = (s or "").replace("\n", " ").strip() + if cell_len(s) <= max_cells: + return s + return set_cell_size(s, max(1, max_cells - 1)).rstrip() + "…" + + +# --------------------------------------------------------------- 数据模型 +@dataclass +class CheckpointNode: + id: str + title: str + parent_id: Optional[str] = None + children: List[str] = field(default_factory=list) + kind: str = "edit" + files: List[str] = field(default_factory=list) + ago: Optional[int] = 0 + + +class CheckpointTree: + """只读视图树(供压缩/渲染);从 RewindStore 构造。""" + + def __init__(self) -> None: + self.nodes: Dict[str, CheckpointNode] = {} + self.root_id: Optional[str] = None + + +def _changed_files(store, node_id) -> List[str]: + import os + nd = store.nodes[node_id] + par = nd.get("parent") + pf = store.nodes[par]["files"] if par in store.nodes else {} + out: List[str] = [] + for k, v in nd["files"].items(): + if pf.get(k) != v: + b = os.path.basename(k) + if b not in out: + out.append(b) + return out + + +def tree_from_store(store, now: float) -> CheckpointTree: + """把 RewindStore 的真实树投影成只读 CheckpointTree;HEAD 标 current。""" + t = CheckpointTree() + for nid, nd in store.nodes.items(): + t.nodes[nid] = CheckpointNode( + id=nid, + title=nd.get("title") or "(空)", + parent_id=nd.get("parent"), + children=[c for c in nd.get("children", []) if c in store.nodes], + kind="current" if nid == store.head else nd.get("kind", "edit"), + files=_changed_files(store, nid), + ago=int(max(0, now - nd.get("created", now))), + ) + t.root_id = store.root_id + return t + + +# --------------------------------------------- 压缩(强制折叠线性段,§4.2) +@dataclass +class DisplayNode: + key: int + kind: str # 'real' | 'fold' + depth: int = 0 + parent_key: Optional[int] = None + children: List[int] = field(default_factory=list) + node_id: Optional[str] = None + seg: List[str] = field(default_factory=list) + end_id: Optional[str] = None + + +class CompressedTree: + def __init__(self, tree: CheckpointTree) -> None: + self.tree = tree + self.disp: Dict[int, DisplayNode] = {} + self.roots: List[int] = [] # 顶层显示节点 keys(通常一个 = origin) + self._k = 0 + # 顶层节点(origin「会话起点」)常驻独立显示作锚点;它下面的真实对话链 + # 照常折叠(§4.2)。origin 是「第一轮对话之前」的空状态,不是某轮对话, + # 所以独立显示语义清晰、不会像"第一轮对话被强行拎出"那样让人困惑。 + for top in self._top_level(): + self._build_root_node(top) + + def _top_level(self) -> List[str]: + return [nid for nid, n in self.tree.nodes.items() + if n.parent_id is None or n.parent_id not in self.tree.nodes] + + def _new_key(self) -> int: + k = self._k + self._k += 1 + return k + + def _build_root_node(self, root_id: str) -> int: + key = self._new_key() + self.disp[key] = DisplayNode(key=key, kind="real", node_id=root_id, depth=0) + self.roots.append(key) + for child_id in self.tree.nodes[root_id].children: + self._build_chain(child_id, 1, key) + return key + + def _build_chain(self, start_id: str, depth: int, parent: Optional[int]) -> int: + seg = [start_id] + cur = start_id + while len(self.tree.nodes[cur].children) == 1: + cur = self.tree.nodes[cur].children[0] + seg.append(cur) + end = seg[-1] + key = self._new_key() + if len(seg) == 1: + self.disp[key] = DisplayNode(key=key, kind="real", node_id=end, + depth=depth, parent_key=parent) + else: + self.disp[key] = DisplayNode(key=key, kind="fold", depth=depth, + parent_key=parent, seg=list(seg), end_id=end) + if parent is None: + self.roots.append(key) + else: + self.disp[parent].children.append(key) + for child_id in self.tree.nodes[end].children: + self._build_chain(child_id, depth + 1, key) + return key + + def flatten(self) -> List[int]: + order: List[int] = [] + + def dfs(k: int) -> None: + order.append(k) + for c in self.disp[k].children: + dfs(c) + + for r in self.roots: + dfs(r) + return order + + def end_node(self, key: int) -> CheckpointNode: + d = self.disp[key] + return self.tree.nodes[d.node_id if d.kind == "real" else d.end_id] + + def label(self, key: int) -> str: + # 真实/折叠都只放末端标题(截断);折叠的 [N nodes] 标记放在 glyph 位(标题前)。 + d = self.disp[key] + nid = d.node_id if d.kind == "real" else d.end_id + return ellipsize(self.tree.nodes[nid].title) + + def glyph(self, key: int) -> str: + # 折叠节点开头用 [N nodes] 替代原来的 … 省略号(→ "[3 nodes] 标题"); + # 真实节点仍用语义字形(● / ↩ / ◉)。 + d = self.disp[key] + if d.kind == "fold": + return f"[{len(d.seg)} nodes]" + return kind_glyph(self.tree.nodes[d.node_id].kind) + + +# ----------------------------------------------------- 导航(纯计算) +def _order_depths(ct: CompressedTree) -> List[tuple[int, int]]: + return [(k, ct.disp[k].depth) for k in ct.flatten()] + + +def next_same_depth(order, sel, direction): + idx = next(i for i, (k, _) in enumerate(order) if k == sel) + depth = order[idx][1] + same = [i for i, (_, d) in enumerate(order) if d == depth] + pos = same.index(idx) + return order[same[(pos + direction) % len(same)]][0] + + +def nearest_depth_node(order, sel, delta): + idx = next(i for i, (k, _) in enumerate(order) if k == sel) + target = order[idx][1] + delta + if target < 0: + return sel + cand = [i for i, (_, d) in enumerate(order) if d == target] + if not cand: + return sel + if delta > 0: + best = min(cand, key=lambda i: (abs(i - idx), 0 if i >= idx else 1, i)) + else: + best = min(cand, key=lambda i: (abs(i - idx), 0 if i <= idx else 1, -i)) + return order[best][0] + + +def parent_sibling_first_child(ct, sel, direction): + d = ct.disp[sel] + if d.parent_key is None: + return sel + parent = ct.disp[d.parent_key] + if parent.parent_key is None: + return sel + siblings = ct.disp[parent.parent_key].children + if parent.key not in siblings or len(siblings) <= 1: + return sel + start = siblings.index(parent.key) + step = 1 if direction >= 0 else -1 + for off in range(1, len(siblings)): + sib = ct.disp[siblings[(start + step * off) % len(siblings)]] + if sib.children: + return sib.children[0] + return sel + + +# ============================================================================ +# 恢复编排(UI 无关):算出回退后的对话/文件/prefill 并落地,前端只刷新自己的显示 +# ============================================================================ +def user_msg_text(entry) -> str: + """从一条 user 消息里取出用户可见文本(content 为 str 或 text block 列表)。""" + c = entry.get("content") if isinstance(entry, dict) else None + if isinstance(c, str): + return c + if isinstance(c, list): + parts = [b.get("text", "") for b in c + if isinstance(b, dict) and b.get("type") == "text"] + return "\n".join(p for p in parts if p) + return "" + + +def render_native_history(history, ts=None): + """Inverse of `continue_cmd._parse_native_history`: render a `backend.history` + (alternating user/assistant) back into the native `=== Prompt/Response ===` + log text. Lives here (not continue_cmd) because the rewind tree is its only + caller — it rewrites `model_responses_.txt` as a linear projection of + the current HEAD path so /continue + other UIs get a clean single-branch view + (the TUI itself reads only the tree). Round-trip holds: + `_parse_native_history(_pairs(render_native_history(h))) == h`. + + Byte-for-byte matches llmcore's `_write_llm_log` for the tool backend: + Prompt = `json.dumps(merged, ensure_ascii=False, indent=2)`; Response = + `str(content_blocks)` which for a list equals `repr(...)` (survives + True/False/None that JSON would emit as true/false/null and break + `ast.literal_eval`). Only complete (user, assistant) pairs are emitted.""" + ts = ts or datetime.now().strftime('%Y-%m-%d %H:%M:%S') + out, i, n = [], 0, len(history) + while i < n: + u = history[i] + if isinstance(u, dict) and u.get('role') == 'user' and i + 1 < n: + a = history[i + 1] + if isinstance(a, dict) and a.get('role') == 'assistant': + out.append(f"=== Prompt === {ts}\n{json.dumps(u, ensure_ascii=False, indent=2)}\n\n") + out.append(f"=== Response === {ts}\n{a.get('content')!r}\n\n") + i += 2 + continue + i += 1 + return "".join(out) + + +def rewrite_projection(store, target, log_path: str) -> bool: + """把 `model_responses_.txt` 重写成 root→target 的线性投影(native + `=== Prompt/Response ===` 格式,`continue_cmd._parse_native_history` 可解析)。 + + 给其他 UI + /continue 的兼容视角;消除 rewind/fork 后多分支混排。故障静默。""" + if store is None or not log_path: + return False + try: + text = render_native_history(store.rebuild_history(target)) + # 回退到「会话起点」(origin)时 history 为空 → 日志被写空(0 字节)。这是【硬】回退: + # 日志恒等于树 HEAD,无错位,reconcile 不会复制分支。空日志会被普通 UI 的 + # list_sessions(sz<32) 无害跳过;带 worldline 的 UI 靠 list_sessions(rewind_root=...) + # 的树感知发现 + continue_inplace(allow_empty=True) 仍能找回并恢复(空对话 + 重连树)。 + tmp = log_path + ".tmp" + with open(tmp, "w", encoding="utf-8", errors="replace") as f: + f.write(text) + os.replace(tmp, log_path) + return True + except Exception: + return False + + +def restore_plan(store, node_id, mode: str = "both", to: str = "before", + log_path: str = "") -> Optional[dict]: + """UI 无关的回退编排。`to`: + - **before**(选中段内某节点):回到该提问**之前**(连该节点一起清除),prefill 其提问; + target = parent(node) / origin。 + - **at**(末尾占位项):在该节点处**继续**(HEAD→node,不清除、无 prefill)。 + `mode`: both/conv/code。会就地:重建对话(返回 history,调用方自行赋给 backend)、 + `apply_code`(还原文件,前自动留 redo 点)、移 HEAD、重写投影日志。 + + 返回 None(无效) 或 dict: + {history: list|None(仅 conv 变更时), changed: [(rel,action)], prefill: str, + at_origin: bool, target: str, title: str, to: str} + —— 前端据此:赋 backend.history、重建界面消息、prefill 输入框、刷新。""" + if store is None or not node_id or node_id not in store.nodes: + return None + nd = store.nodes[node_id] + prefill = "" + if to == "at": + target = node_id + at_origin = nd.get("kind") == "origin" + else: + parent = nd.get("parent") + parent = parent if parent in store.nodes else None + target = parent if parent is not None else node_id + at_origin = parent is None + if not at_origin: + um = store.first_user_message(node_id) + prefill = (user_msg_text(um) if um else "") or nd.get("title", "") + + history = None + if mode in ("both", "conv"): + history = store.rebuild_history(target) + changed = [] + if mode in ("both", "code"): + try: + changed = store.apply_code(target) + except Exception: + changed = [] + store.rewind_head(target) + if mode in ("both", "conv") and log_path: + rewrite_projection(store, target, log_path) + return { + "history": history, + "changed": changed, + "prefill": prefill, + "at_origin": at_origin, + "target": target, + "title": nd.get("title", ""), + "to": to, + } diff --git a/ga.py b/ga.py index 3ae4f991..f017ff62 100644 --- a/ga.py +++ b/ga.py @@ -1,4 +1,4 @@ -import sys, os, re, json, time, threading, importlib +import sys, os, re, json, time, threading, importlib, webbrowser from datetime import datetime from pathlib import Path import tempfile, traceback, subprocess, itertools, collections, difflib, shutil @@ -108,14 +108,11 @@ def first_init_driver(): global driver from TMWebDriver import TMWebDriver driver = TMWebDriver() - for i in range(20): - time.sleep(1) + for i in range(7): + time.sleep(2) sess = driver.get_all_sessions() if len(sess) > 0: break - if len(sess) == 0: return - if len(sess) == 1: - #driver.newtab() - time.sleep(3) + if i == 4: webbrowser.open("https://example.com") def web_scan(tabs_only=False, switch_tab_id=None, text_only=False, maxlen=35000): """获取当前页面的简化HTML内容和标签页列表。注意:简化过程会过滤边栏、浮动元素等非主体内容。 @@ -244,9 +241,9 @@ def file_read(path, start=1, keyword=None, count=200, show_linenos=True): except FileNotFoundError: msg = f"Error: File not found: {path}" try: - tgt = os.path.basename(path); scan = os.path.dirname(os.path.dirname(os.path.abspath(path))) - roots = [scan] + [d for d in _read_dirs if not d.startswith(scan)] - cands = list(itertools.islice((c for base in roots for c in _scan_files(base)), 2000)) + tgt = os.path.basename(path); parent = os.path.dirname(os.path.abspath(path)); scan = os.path.dirname(parent) + roots = [parent, scan] + [d for d in _read_dirs if not d.startswith(scan)] + cands = list(dict.fromkeys(itertools.islice((c for base in roots for c in _scan_files(base)), 2000))) top = sorted([(difflib.SequenceMatcher(None, tgt.lower(), c[0].lower()).ratio(), c) for c in cands[:2000]], key=lambda x: -x[0])[:5] top = [(s, c) for s, c in top if s > 0.3] if top: msg += "\n\nDid you mean:\n" + "\n".join(f" {c[1]} ({s:.0%})" for s, c in top) @@ -503,7 +500,7 @@ class GenericAgentHandler(BaseHandler): if remaining == 0: self._exit_plan_mode(); yield "[Info] Plan完成:plan.md中0个[ ]残留,退出plan模式。\n" - yield "[Info] Final response to user.\n" + #yield "[Info] Final response to user.\n" return StepOutcome(response, next_prompt=None) def do_start_long_term_update(self, args, response): @@ -556,7 +553,7 @@ class GenericAgentHandler(BaseHandler): else: tc = tool_calls[0]; tool_name, args = tc['tool_name'], tc['args'] # at least one because no_tool clean_args = {k: v for k, v in args.items() if not k.startswith('_')} - summary = f"调用工具{tool_name}, args: {clean_args}" + summary = f"{tool_name}, args: {clean_args}" if tool_name == 'no_tool': summary = "直接回答了用户问题" next_prompt += "\n\n\n[SYSTEM] 必须在回复文本中包含!\n\n" summary = smart_format(summary.replace('\n', ''), max_str_len=40) @@ -564,15 +561,17 @@ class GenericAgentHandler(BaseHandler): self.history_info.append(f'[Agent] {summary}') _plan = self._in_plan_mode() - if turn % 75 == 0 and (not _plan): - next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。必须总结情况进行ask_user,不允许继续重试。" + if turn % 175 == 0 and (not _plan): + next_prompt += f"\n\n[DANGER] Turn {turn}. Must call ask_user to summarize progress and get direction. No more blind retries." elif turn % 7 == 0: - next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。禁止无效重试。若无有效进展,必须切换策略:1. 探测物理边界 2. 请求用户协助。如有需要,可调用 update_working_checkpoint 保存关键上下文。" + next_prompt += f"\n\n[SYSTEM] Turn {turn}. Call update_working_checkpoint to save key context. Stop ineffective retries; if no progress, switch strategy: 1) Probe physical boundaries 2) **Re-read relevant SOPs**" + elif turn % 25 == 0: + next_prompt += f"\n\n[SYSTEM] Turn {turn}. Write checkpoints/key findings/tried approaches to a file for future reference. Avoid losing critical info." elif turn % 10 == 0: next_prompt += get_global_memory() if _plan and turn >= 10 and turn % 5 == 0: next_prompt = f"[Plan Hint] 正在计划模式。必须 file_read({_plan}) 确认当前步骤,回复开头引用:📌 当前步骤:...\n\n" + next_prompt - if _plan and turn >= 120: next_prompt += f"\n\n[DANGER] Plan模式已运行 {turn} 轮,已达上限。必须 ask_user 汇报进度并确认是否继续。" + if _plan and turn >= 190: next_prompt += f"\n\n[DANGER] Plan模式已运行 {turn} 轮,已达上限。必须 ask_user 汇报进度并确认是否继续。" injkeyinfo = consume_file(self.parent.task_dir, '_keyinfo') injprompt = consume_file(self.parent.task_dir, '_intervene') diff --git a/launch.pyw b/launch.pyw index 74658a4e..ee79c978 100644 --- a/launch.pyw +++ b/launch.pyw @@ -1,6 +1,6 @@ import webview, threading, subprocess, sys, time, os, ctypes, atexit, socket, random -WINDOW_WIDTH, WINDOW_HEIGHT, RIGHT_PADDING, TOP_PADDING = 600, 900, 0, 100 +WINDOW_WIDTH, WINDOW_HEIGHT, RIGHT_PADDING, TOP_PADDING = 1200, 900, 0, 50 script_dir = os.path.dirname(os.path.abspath(__file__)) frontends_dir = os.path.join(script_dir, "frontends") @@ -22,31 +22,6 @@ def start_streamlit(port): proc = subprocess.Popen(cmd) atexit.register(proc.kill) -def inject(text): - window.evaluate_js(f""" - const textarea = document.querySelector('textarea[data-testid="stChatInputTextArea"]'); - if (textarea) {{ - // 1. 用原生 setter 设置值(绕过 React) - const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; - nativeTextAreaValueSetter.call(textarea, {repr(text)}); - // 2. 触发 React 的 input 事件 - textarea.dispatchEvent(new Event('input', {{ bubbles: true }})); - // 3. 触发 change 事件(有些组件需要) - textarea.dispatchEvent(new Event('change', {{ bubbles: true }})); - // 4. 延迟提交 - setTimeout(() => {{ - const btn = document.querySelector('[data-testid="stChatInputSubmitButton"]'); - if (btn) {{btn.click();console.log('Submitted:', {repr(text)});}} - }}, 200); - }}""") - -def get_last_reply_time(): - last = window.evaluate_js(""" - const el = document.getElementById('last-reply-time'); - el ? parseInt(el.textContent) : 0; - """) or 0 - return last or int(time.time()) - PASTE_HOOK_JS = """if (!window._pasteHooked) { window._pasteHooked = true; document.addEventListener('paste', e => { const items = e.clipboardData?.items; if (!items) return; @@ -65,21 +40,13 @@ PASTE_HOOK_JS = """if (!window._pasteHooked) { window._pasteHooked = true; }, true); }""" -def idle_monitor(): - last_trigger_time = 0 +def paste_hook_injector(): + """注入剪贴板粘贴图片钩子,等窗口加载后执行一次,之后每60秒补注入防页面刷新丢失""" + time.sleep(5) # 等 webview 加载 while True: - time.sleep(5) - try: - window.evaluate_js(PASTE_HOOK_JS) - now = time.time() - if now - last_trigger_time < 120: continue - last_reply = get_last_reply_time() - if now - last_reply > 1800: - print('[Idle Monitor] Detected idle state, injecting task...') - inject("[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。") - last_trigger_time = now - except Exception as e: - print(f'[Idle Monitor] Error: {e}') + try: window.evaluate_js(PASTE_HOOK_JS) + except: pass + time.sleep(60) if __name__ == '__main__': import argparse @@ -140,8 +107,7 @@ if __name__ == '__main__': print('[Launch] Task Scheduler started (duplicate prevented by scheduler port lock)') else: print('[Launch] Task Scheduler not enabled (--sched)') - monitor_thread = threading.Thread(target=idle_monitor, daemon=True) - monitor_thread.start() + threading.Thread(target=paste_hook_injector, daemon=True).start() if os.name == 'nt': screen_width = get_screen_width() x_pos = screen_width - WINDOW_WIDTH - RIGHT_PADDING diff --git a/llmcore.py b/llmcore.py index e97375e9..5958e8dc 100644 --- a/llmcore.py +++ b/llmcore.py @@ -1,7 +1,7 @@ -import os, json, re, time, requests, sys, threading, urllib3, base64, importlib, uuid +import os, json, re, time, requests, sys, threading, urllib3, base64, importlib, uuid, pathlib from datetime import datetime urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -_RESP_CACHE_KEY = str(uuid.uuid4()) +_RESP_CACHE_KEY = str(uuid.uuid4()); _RESP_CODEX_KEY = str(uuid.uuid4()) _ROOT = os.path.dirname(os.path.abspath(__file__)) if _ROOT not in sys.path: sys.path.append(_ROOT) @@ -17,7 +17,9 @@ def _load_mykeys(): raise Exception(f'[ERROR] mykey.py has syntax error: {e}') from e p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mykey.json') if not os.path.exists(p): raise Exception('[ERROR] mykey.py not found in sys.path and mykey.json not found. Run "python configure_mykey.py" or copy mykey_template.py to mykey.py and fill in your keys.') - with open(_mykey_path := p, encoding='utf-8') as f: return json.load(f) + with open(_mykey_path := p, encoding='utf-8') as f: mk = json.load(f) + if isinstance(mk, dict) and 'remote_url' in mk: return requests.get(mk['remote_url'], timeout=10).json() + return mk _mykey_path = _mykey_mtime = None def reload_mykeys(): @@ -186,7 +188,8 @@ def _parse_claude_sse(resp_lines): content_blocks.append(current_block); current_block = None if warn: print(f"[WARN] {warn.strip()}") - content_blocks.append({"type": "text", "text": warn}); yield warn + insert_at = next((i for i,b in enumerate(content_blocks) if b.get("type") == "tool_use"), len(content_blocks)) + content_blocks.insert(insert_at, {"type": "text", "text": warn}); yield warn return content_blocks def _try_parse_tool_args(raw): @@ -271,8 +274,8 @@ def _parse_openai_sse(resp_lines, api_mode="chat_completions"): except: continue ch = (evt.get("choices") or [{}])[0] delta = ch.get("delta") or {} - if delta.get("reasoning_content"): - reasoning_text += delta["reasoning_content"] + if rc := delta.get("reasoning_content") or delta.get("reasoning", ""): + reasoning_text += rc; yield rc if delta.get("content"): text = delta["content"]; content_text += text; yield text for tc in (delta.get("tool_calls") or []): @@ -331,7 +334,7 @@ def _parse_openai_json(data, api_mode="chat_completions"): else: _record_usage(data.get("usage") or {}, api_mode) msg = (data.get("choices") or [{}])[0].get("message", {}) - reasoning = msg.get("reasoning_content", "") + reasoning = msg.get("reasoning_content") or msg.get("reasoning", "") if reasoning: blocks.append({"type": "thinking", "thinking": reasoning}) content = msg.get("content", "") @@ -379,12 +382,13 @@ def _stream_with_retry(sess, url, headers, payload, parse_fn): yield err; return [{"type": "text", "text": err}] gen = parse_fn(r) try: - while True: streamed = True; yield next(gen) + while True: chunk = next(gen); streamed = True; yield chunk except StopIteration as e: if not e.value and not streamed: raise requests.ConnectionError("empty response") return e.value or [] except (requests.Timeout, requests.ConnectionError) as e: - err = f"!!!Error: {type(e).__name__}" + #pathlib.Path(__file__).parent.joinpath('temp','bad_requests.json').write_text(json.dumps({"url":url,"headers":headers,"payload":payload,"err":str(e),"t":time.time()},ensure_ascii=False),encoding='utf-8') + err = f"!!!Error: {type(e).__name__}: {e}" if str(e) else f"!!!Error: {type(e).__name__}" if attempt < sess.max_retries: d = _delay(None, attempt) print(f"[LLM Retry] {type(e).__name__}, retry in {d:.1f}s ({attempt+1}/{sess.max_retries+1})") @@ -400,11 +404,14 @@ def _openai_stream(sess, messages): temperature = sess.temperature if 'kimi' in ml or 'moonshot' in ml: temperature = 1 elif 'minimax' in ml: temperature = max(0.01, min(temperature, 1.0)) # MiniMax requires temp in (0, 1] - headers = {"Authorization": f"Bearer {sess.api_key}", "Content-Type": "application/json", "Accept": "text/event-stream"} + headers = {"Authorization": f"Bearer {sess.api_key}", "Content-Type": "application/json", "Accept": "text/event-stream", 'originator': 'codex_exec'} + headers["User-Agent"] = sess.user_agent if api_mode == "responses": url = auto_make_url(sess.api_base, "responses") payload = {"model": model, "input": _to_responses_input(messages), "stream": sess.stream, - "prompt_cache_key": _RESP_CACHE_KEY, "instructions": sess.system or "You are an Omnipotent Executor."} + "prompt_cache_key": _RESP_CACHE_KEY, "instructions": sess.system or "You are an Omnipotent Executor.", + "client_metadata": {"x-codex-window-id": f"{_RESP_CACHE_KEY}:0","x-codex-installation-id": _RESP_CODEX_KEY}, + 'include': ['reasoning.encrypted_content']} if sess.reasoning_effort: payload["reasoning"] = {"effort": sess.reasoning_effort} if sess.max_tokens: payload["max_output_tokens"] = sess.max_tokens else: @@ -531,7 +538,7 @@ class BaseSession: self.max_retries = max(0, int(cfg.get('max_retries', 4))) self.verify = cfg.get('verify', True) self.stream = cfg.get('stream', True) - default_ct, default_rt = (5, 30) if self.stream else (10, 240) + default_ct, default_rt = (5, 40) if self.stream else (10, 240) self.connect_timeout = max(1, int(cfg.get('timeout', default_ct))) self.read_timeout = max(5, int(cfg.get('read_timeout', default_rt))) def _enum(key, valid): @@ -545,6 +552,8 @@ class BaseSession: self.api_mode = 'responses' if mode in ('responses', 'response') else 'chat_completions' self.temperature = cfg.get('temperature', 1) self.max_tokens = cfg.get('max_tokens') + self.default_ua = "claude-cli/2.1.152 (external, cli)" + self.user_agent = cfg.get("user_agent", self.default_ua) def _apply_claude_thinking(self, payload): if self.thinking_type: thinking = {"type": self.thinking_type} @@ -654,14 +663,15 @@ def _fix_messages(messages): return merged class NativeClaudeSession(BaseSession): + native_ua = "claude-cli/2.1.152 (native, cli)" def __init__(self, cfg): super().__init__(cfg) self.fake_cc_system_prompt = cfg.get("fake_cc_system_prompt", False) - self.user_agent = cfg.get("user_agent", "claude-cli/2.1.152 (external, cli)") self._session_id = str(uuid.uuid4()) self._account_uuid = str(uuid.uuid4()) self._device_id = uuid.uuid4().hex + uuid.uuid4().hex[:32] self.tools = None + if self.user_agent == self.default_ua: self.user_agent = self.native_ua def raw_ask(self, messages): if self.max_tokens is None: self.max_tokens = 8192 model = self.model @@ -678,7 +688,7 @@ class NativeClaudeSession(BaseSession): if self.api_key.startswith("sk-ant-"): headers["x-api-key"] = self.api_key else: headers["authorization"] = f"Bearer {self.api_key}" payload = {"model": model, "messages": messages, "max_tokens": self.max_tokens, "stream": self.stream} - if self.fake_cc_system_prompt: payload["max_tokens"] = 64000 + #if self.fake_cc_system_prompt: payload["max_tokens"] = 64000 if self.temperature != 1: payload["temperature"] = self.temperature self._apply_claude_thinking(payload) payload["context_management"] = {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]}; @@ -732,6 +742,7 @@ class NativeClaudeSession(BaseSession): return MockResponse(thinking, content, tool_calls, str(content_blocks)) class NativeOAISession(NativeClaudeSession): + native_ua = "codex_exec/0.139.0 (Windows 10.0.26200; x86_64) unknown (codex_exec; 0.139.0)" def raw_ask(self, messages): messages = _fix_messages(messages) messages = _ensure_thinking_blocks(messages, self.model) @@ -789,7 +800,7 @@ class ToolClient: raw_text = '' for chunk in gen: raw_text += chunk; yield chunk - _write_llm_log('Response', raw_text, self.log_path) + _write_llm_log('Response', raw_text, self.log_path, model=self.backend.model) return self._parse_mixed_response(raw_text) def _prepare_tool_instruction(self, tools): @@ -906,13 +917,14 @@ def _ensure_text_block(blocks): blocks.insert(1, {"type": "text", "text": txt}) return txt -def _write_llm_log(label, content, log_path=None): +def _write_llm_log(label, content, log_path=None, model=''): if not log_path: log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), f'temp/model_responses/model_responses_{os.getpid()}.txt') os.makedirs(os.path.dirname(os.path.abspath(log_path)), exist_ok=True) ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + if model: model = f' model={model}' with open(log_path, 'a', encoding='utf-8', errors='replace') as f: - f.write(f"=== {label} === {ts}\n{content}\n\n") + f.write(f"=== {label} === {ts}{model}\n{content}\n\n") def tryparse(json_str): try: return json.loads(json_str) @@ -1044,7 +1056,7 @@ class NativeToolClient: while True: chunk = next(gen); yield chunk except StopIteration as e: resp = e.value - if resp: _write_llm_log('Response', resp.raw, self.log_path) + if resp: _write_llm_log('Response', resp.raw, self.log_path, model=self.backend.model) if resp and hasattr(resp, 'tool_calls') and resp.tool_calls: self._pending_tool_ids = [tc.id for tc in resp.tool_calls] return resp diff --git a/memory/adb_ui.py b/memory/adb_ui.py index e69cf743..08b74e63 100644 --- a/memory/adb_ui.py +++ b/memory/adb_ui.py @@ -1,4 +1,6 @@ # adb_ui.py - 一键dump+解析Android UI (u2优先,原生fallback) +# 要先看 computer_use.md;dump配合ui_detect,微信/支付宝小程序常需detect补盲 +# 搜索框一般直接输入拼音/首字母即可,别硬啃 adb 中文输入 # u2 (uiautomator2) 不受idle限制,适合动画密集app(美团等) # 弹窗检测: ui(clickable_only=True, raw=True) 找全屏FrameLayout+底部小ImageView(关闭X) # 已知包名: 美团外卖=com.sankuai.meituan.takeoutnew 淘宝=com.taobao.taobao diff --git a/memory/computer_use.md b/memory/computer_use.md index ddd3b66b..2e5340c4 100644 --- a/memory/computer_use.md +++ b/memory/computer_use.md @@ -1,12 +1,16 @@ # computer_use -L3 memory: ljqCtrl.py ljqCtrl_sop.md ui_detect.py +相关L3 memory: **ui_detect.py** ljqCtrl.py ljqCtrl_sop.md ## 1. 基础规则 -枚举窗口总是可用:GUI任务先用 win32gui 枚举标题/类名/rect,确定目标窗口、前台状态和客户区原点后再操作。 -优先使用 Python UIA;若UIA可用,探测和操作均可使用UIA;游戏不使用UIA; -一旦 UIA 对该窗口无效,立刻改用 ui_detect 定位; -ui_detect 不足时才用 vision,vision 只用于语义理解、确认界面状态和辅助判断目标,不可信其坐标,不要直接按 vision 坐标点击。 +### 探测/定位四工具(按优先级降级,前者无效才用后者) +| 工具 | 用法时机 | 能力/适用 | 限制 | +|------|----------|-----------|------| +| 0 win32gui 窗口枚举 | 始终先行,总是可用 | 枚举标题/类名/rect,确定目标窗口、前台状态、客户区原点 | 仅定位窗口,不进控件 | +| 1 Python UIA(控件树)| 首选探测+操作 | 控件树可用时,探测与操作(含免坐标点击)都用 UIA | **游戏禁用**;对该窗口一旦无效则后续也不用 | +| 2 ui_detect.py(配合ljqCtrl截图) | 1无效时才用 | 截图视觉检测控件,返回 bbox+OCR 文本 | bbox 是截图内坐标需转屏幕物理坐标 | +| 3 vision(VLM) | 2仍不足时才用 | 仅语义理解、确认界面状态、辅助判断目标 | 不可信其坐标 | + Windows 下窗口截图和操作使用 ljqCtrl:严禁 pyautogui;记得先 Activate 到前台; 坑1-DPI:一律物理坐标;坑2-遮盖/失焦:混乱先枚举窗口确认前台; ui_detect 的 bbox 是截图内坐标,点击前必须用 `ClientToScreen(hwnd,(0,0))/dpi_scale + bbox中心` 转屏幕物理坐标 @@ -20,3 +24,18 @@ ljqCtrl 失效或目标为网络游戏时,必须使用硬件键鼠 Xbananakb / 明确一个操作后,可以在同一轮执行该动作,短暂等待,再立刻枚举窗口 + 截图/ui_detect 验证新状态;不要在未知状态下把多步决策写进大脚本 尽量不要预测关键词筛候选,应看 detect 输出、坐标、层级和上下文判断 若确定UIA可用则少用ui_detect/ljqCtrl;若UIA不可用,则后续不用UIA + +临时截图/可视化文件用后清理,或固定文件名覆盖,避免堆积。 +ui_detect 可跨端复用;手机端沿用本原则时,UIA 换成 ui dump/adb_ui,ljqCtrl 控制换成 adb + +## 3. macOS 平台 +macOS 定位链与 §1 一致,工具映射如下: +- 控制层:`import macljqCtrl as ljqCtrl`(替代 Windows ljqCtrl) +- 窗口枚举:`ListWindows()` → 返回 id/app/title/bbox/pid(替代 win32gui) +- 激活:`ActivateApp(pid)`(pid 来自 ListWindows,禁止用名字子串——同厂商 bundle 前缀会误伤) +- UIA 层 = AX 辅助功能 API:`AXElements(pid)` 枚举控件树 → role/desc/title/id/物理坐标 xywh;`AXFind(pid, role=, desc=, title=)` 过滤;`AXPress(el)` 免坐标点击 +- 坐标:AX 返回逻辑点,库内自动 /dpi_scale 转物理像素,与 Click/Screenshot 统一;Retina 默认 scale=0.5 +- 截图:`GrabWindow(window_id)` 或 `ScreenCapAt(x, y, radius)`(物理坐标) +- 权限:首次使用需授予「辅助功能」权限(系统设置 > 隐私与安全 > 辅助功能);`AXIsProcessTrusted()` 检测 +- 依赖:`pip install pyobjc-framework-ApplicationServices`(AX 相关,软依赖——未装时键鼠/截图正常,仅 AX 函数不可用) +- 节奏同 §2:先 ListWindows + AXElements 探测,确认控件后再操作;AXPress 优先,回退 Click(phys_cx, phys_cy) diff --git a/memory/incubator_sop.md b/memory/incubator_sop.md index fb358299..d85c1c4f 100644 --- a/memory/incubator_sop.md +++ b/memory/incubator_sop.md @@ -6,13 +6,23 @@ GARoot/*.py GARoot/assets/*.txt *.json mykey.py -GARoot/memory/ # 默认gitignore白名单的SOP,另外可以直接看情况修改干预 +GARoot/memory/ ``` +memory下大部分文件不适合分发,不要复制 memory 下未被 gitignore 白名单的文件! +不要复制 memory 下的L1/L2文件(global_mem(_insight).txt),会自动初始化 + +打包红线:严格按上面4行清单执行,`*` 是 glob 全匹配,不得擅自改成“必要文件/可启动闭包”。 +- `GARoot/*.py` 必须包含根目录所有 `.py`。 +- `GARoot/assets/*.txt *.json` 必须包含 assets 顶层所有 `.txt`/`.json`。 +- `GARoot/memory/` 只取 `.gitignore` 白名单/已允许分发文件;排除 `global_mem.txt`、`global_mem_insight.txt`、`__pycache__/`、`*.pyc`。 +- 按当前清单实测压缩包约153KB/55文件;正常不应超过170KB,文件数不应超过60。 ## 依赖 requests beautifulsoup4 +尽量复用远端已有python/venv ## 通信 +看subagent.md 同subagent协议:`agentmain.py --task {name} --input "..."` 或起reflect worker并设置bbs信息 diff --git a/memory/ljqCtrl.py b/memory/ljqCtrl.py index cfc933a7..fddd6619 100644 --- a/memory/ljqCtrl.py +++ b/memory/ljqCtrl.py @@ -21,6 +21,8 @@ try: import cv2 except: pass +ctypes.windll.user32.SetProcessDPIAware() + _hdc = ctypes.windll.user32.GetDC(0) swidth = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 118) # DESKTOPHORZRES (物理) sheight = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 117) # DESKTOPVERTRES @@ -158,9 +160,9 @@ def FindBlock(fn, wrect=None, verbose=0, threshold=0.8): obj = (oj + wrect[0] + tsw//2, oi + wrect[1] + tsh//2) if verbose: print(f'Max match: {max_val:.4f} at ({oj}, {oi}) cost: {time.process_time() - tic:.3f}s') - sscr = scr.crop([oj, oi, oj+tsw, oi+tsh]) - sscr.show() - return obj, max_val > threshold + #sscr = scr.crop([oj, oi, oj+tsw, oi+tsh]) + #sscr.show() + return obj, max_val def ScreenCapAt(x, y, r=100): """物理坐标(x,y)为中心±r的屏幕截图 → PIL Image""" diff --git a/memory/ljqCtrl_sop.md b/memory/ljqCtrl_sop.md index 952517ef..10ecbd73 100644 --- a/memory/ljqCtrl_sop.md +++ b/memory/ljqCtrl_sop.md @@ -10,10 +10,13 @@ - `ljqCtrl.GrabWindow(hwnd_or_name)`: 前台截图(先Activate), 传hwnd(int)或窗口标题子串(str), 返回PIL Image - `ljqCtrl.GrabWindowBg(hwnd_or_name, timeout=5)`: WGC后台截图(Win10+) - `ljqCtrl.MouseDClick(staytime=0.05)`: 鼠标双击 +- 可先阅读computer_use.md ## 1. 环境载入 import ljqCtrl +> **macOS**: 改 `import macljqCtrl as ljqCtrl`(API 镜像,Quartz/screencapture 实现)。依赖 `pyobjc-framework-Quartz`/`-Cocoa`,首次用前 `macljqCtrl.check_permissions()` 自检辅助功能/录屏授权。 + ## 2. 核心:High-DPI 物理坐标换算 `ljqCtrl` 的 `Click/MoveTo` 接口接收的是**物理像素坐标**。 当使用 `pygetwindow` 等其他工具获取窗口位置(逻辑坐标)时,必须除以缩放系数。 @@ -30,11 +33,21 @@ ljqCtrl.Click(ox + (bbox[0]+bbox[2])//2, oy + (bbox[1]+bbox[3])//2) ``` 禁止全屏ImageGrab(必须针对窗口),所有逻辑坐标都要转物理。 +**macOS (`macljqCtrl`)**:`GrabScreen(bbox)` 区域截图后,图内点转屏幕物理坐标用 `CropToScreen(bbox, px, py)`,别手搓 `screencapture -R`(它吃逻辑点,会点歪)。 + ## 4. 避坑指南 - **⚠️ 一律使用物理坐标**:传给 ljqCtrl.Click/SetCursorPos 的坐标必须是物理坐标(=截图像素坐标)。禁止传入逻辑坐标。 - **物理验证**:模拟操作前必须确保窗口已通过 `activate()` 置于前台。 - **坐标对齐**: 物理坐标 = 截图坐标;ljqCtrl 自动处理 DPI 换算,禁止手动重复计算。 - **⚠️ 窗口坐标转换陷阱**:使用 `win32gui.GetWindowRect(hwnd)` 获取的矩形包含标题栏和边框,而截图内容是客户区。点击截图内元素时,必须用 `win32gui.ClientToScreen(hwnd, (0, 0))` 获取客户区原点的屏幕坐标,再加上截图内坐标。禁止直接用 GetWindowRect 左上角 + 截图坐标。**同理禁止 `DwmGetWindowAttribute(hwnd, 9, ...)` 取窗口矩形替代 ClientToScreen,它也包含标题栏/阴影。** -- **⚠️ Click 后 0% 像素变化 = 点歪了**:ljqCtrl.Click 会报告像素变化百分比。若为 0% 或接近 0%,说明点击落在了错误位置(坐标计算有误),必须立即停下来诊断坐标转换逻辑,禁止盲目重试。常见原因:用了错误的窗口原点API、忘记 `/dpi_scale`、混淆了客户区与窗口矩形。 +- **⚠️ Click 后 0% 像素变化 = 点歪了**:ljqCtrl.Click 会报告像素变化百分比。若为 0% 或接近 0%,说明点击落在了错误位置(坐标计算有误),必须立即停下来诊断坐标转换逻辑,禁止盲目重试。常见原因:用了错误的窗口原点API、忘记 `/dpi_scale`、混淆了客户区与窗口矩形。macOS 上多为忘加裁剪原点(应走 `CropToScreen`)。 - **⚠️ win32 DPI 坐标陷阱**:未调用 `SetProcessDPIAware()` 时,`GetWindowRect/ClientToScreen/GetClientRect` 等拿到的窗口/客户区坐标通常是**逻辑坐标**,必须进行换算! -- **文本输入**:ljqCtrl 无 TypeText/SendKeys。向输入框键入文本:先点击/三击选中字段,再 `pyperclip.copy('文本'); ljqCtrl.Press('ctrl+v')`。 \ No newline at end of file +- **文本输入**:ljqCtrl 无 TypeText/SendKeys。向输入框键入文本:先点击/三击选中字段,再 `pyperclip.copy('文本'); ljqCtrl.Press('ctrl+v')`。 + +## 5. macOS:OCR/vision 认不准图标时,用辅助功能 API 枚举真实控件(强烈推荐) +> **两条通路**:①`macljqCtrl.py` 已封装原生 pyobjc AX API(首选,免 shell):`AXElements(pid或bundle_id或app名)` 枚举控件树(带 role/desc/title/id/value/**enabled**/物理坐标),`AXFind(...,enabled_only=)` 过滤,`AXClick(node)` = AXPress 优先失败回退物理坐标 Click。②无 pyobjc 时回退下述 osascript 方案。 +图标类按钮(···更多 / 铅笔编辑 / 关闭等)靠 OCR/vision 极易误判误点。优先走 GUI 优先链的「UIA」层:用 `osascript` 的 System Events 递归 `entire contents` 枚举进程**所有窗口**的真实控件,拿到 `AXRole + description(标识符) + position`,直接 `perform action "AXPress"` 点中。 +- **关键坑**:弹窗/详情卡常是**独立子窗口**,`front window` 只返回主窗(如红绿灯按钮)。必须 `every window` 遍历 + `entire contents`,否则找不到目标控件。 +- 控件常自带语义化 `description`/`identifier`(如 `xxx_button_more`),按 description 精确匹配比坐标稳定,枚举一次记下目标标识即可复用。 +- **坐标换算**:AX 返回的是**逻辑坐标**,截图/Click 用**物理坐标**,retina 屏 ×2(逻辑(537,121)↔物理(1074,242)实测吻合)。AX `AXPress` 直接作用元素免换算;若 AX 偶发 NOTFOUND(时序波动),用换算后物理坐标 `Click` 兜底。 +- **失焦陷阱**:点击坐标若落在窗口边界外,会点到背后别的 app 导致目标失焦。osascript `tell application "" to activate` 比 ljqCtrl 的 ActivateApp 更可靠,激活后用 `frontmost` 确认。 \ No newline at end of file diff --git a/memory/macljqCtrl.py b/memory/macljqCtrl.py new file mode 100644 index 00000000..7124f378 --- /dev/null +++ b/memory/macljqCtrl.py @@ -0,0 +1,590 @@ +""" +macljqCtrl —— ljqCtrl 的 macOS 等价实现 (Quartz CGEvent + screencapture)。 +与 ljqCtrl.py API 镜像对齐, 跨平台代码可写 `import macljqCtrl as ljqCtrl`。 + +CRITICAL: 严禁 import pyautogui。 +依赖: pyobjc-framework-Quartz, pyobjc-framework-Cocoa (其余 numpy/opencv/Pillow 同 Windows 版)。 +坐标约定 (与 Windows 版完全一致): + - 对外 API 接收【物理像素坐标】(= screencapture/ui_detect 截图内坐标) + - dpi_scale = 逻辑点 / 物理像素 (Retina=0.5, 普通屏=1.0) + - 逻辑坐标 = 物理坐标 * dpi_scale (CGEvent 内部用逻辑点) + +权限前置 (缺失则键鼠/截图静默失败): + - 辅助功能(Accessibility): 系统设置>隐私与安全性>辅助功能, 授权 GA 宿主进程 + - 屏幕录制(Screen Recording): 同上>屏幕录制 + 用 macljqCtrl.check_permissions() 自检。 + +API 快速参考: + - dpi_scale: float + - Click(x, y=None, check=True): 物理坐标; check=True 比较前后像素变化, 返回变化信息 + - SetCursorPos((x,y)): 物理坐标移动鼠标 + - Press(cmd, staytime=0): 快捷键, 如 'cmd+v' 'cmd+c' 'enter' 'cmd+shift+4' + - TypeText(s): 直接键入文本(Unicode, 无需剪贴板) + - MouseClick / MouseDClick / MouseDown / MouseUp / RightClick + - GrabWindow(win) -> PIL Image: 指定窗口截图(物理像素)。win=窗口号(int)或标题/应用名子串(str) + - GrabScreen(bbox=None) -> PIL Image: 全屏或区域截图, bbox=(l,u,r,b)物理像素 + - ScreenCapAt(x, y, r=100) -> PIL Image: 物理坐标(x,y)±r 区域截图 + - FindBlock(fn, wrect=None, threshold=0.8) -> ((cx,cy), is_found): 模板匹配, 物理坐标 + - ListWindows(name=None) -> [dict]: 枚举窗口(替代 win32gui), 返回号/标题/应用/物理bbox + - ActivateApp(name): 激活应用到前台(替代 win32gui.SetForegroundWindow) +""" +import os, sys, time, subprocess, tempfile, math +import numpy as np + +try: + from PIL import Image, ImageGrab, ImageEnhance, ImageFilter, ImageDraw + import cv2 + _HAS_CV2 = True +except Exception: + _HAS_CV2 = False + +import Quartz +from AppKit import NSScreen, NSWorkspace, NSPasteboard, NSStringPboardType, NSRunningApplication + +verbose_click = True # Click 像素变化打印开关 + +# ---------- 屏幕几何 & dpi_scale ---------- +_main = Quartz.CGMainDisplayID() +_bounds = Quartz.CGDisplayBounds(_main) +cwidth = int(_bounds.size.width) # 逻辑点宽 (CGEvent 坐标系) +cheight = int(_bounds.size.height) +_mode = Quartz.CGDisplayCopyDisplayMode(_main) +swidth = int(Quartz.CGDisplayModeGetPixelWidth(_mode)) # 物理像素宽 +sheight = int(Quartz.CGDisplayModeGetPixelHeight(_mode)) +dpi_scale = cwidth / swidth if swidth else 1.0 # 逻辑/物理, Retina=0.5 + + +def check_permissions(verbose=True): + """返回 (accessibility_ok, screen_recording_ok)。缺失时打印授权指引。""" + sc = bool(Quartz.CGPreflightScreenCaptureAccess()) if hasattr(Quartz, 'CGPreflightScreenCaptureAccess') else None + ax = None + try: + import HIServices + ax = bool(HIServices.AXIsProcessTrusted()) + except Exception: + try: + from ApplicationServices import AXIsProcessTrusted + ax = bool(AXIsProcessTrusted()) + except Exception: + ax = None + if verbose: + print(f'[PERM] Accessibility(键鼠): {ax} ScreenRecording(截图): {sc}') + if ax is False: + print(' → 系统设置>隐私与安全性>辅助功能, 勾选 GA 宿主进程后重启 GA') + if sc is False: + print(' → 系统设置>隐私与安全性>屏幕录制, 勾选 GA 宿主进程后重启 GA') + return ax, sc + + +# ---------- 鼠标 ---------- +def _post(ev): + Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev) + +def _cursor_logical(): + e = Quartz.CGEventCreate(None) + loc = Quartz.CGEventGetLocation(e) + return loc.x, loc.y + +def _phys_to_logical(x, y): + return x * dpi_scale, y * dpi_scale + +def SetCursorPos(z): + """z=(x,y) 物理坐标。移动鼠标(不点击)。""" + lx, ly = _phys_to_logical(int(z[0]), int(z[1])) + ev = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved, + Quartz.CGPointMake(lx, ly), Quartz.kCGMouseButtonLeft) + _post(ev) + time.sleep(0.05) + +def _mouse_event(etype, button=Quartz.kCGMouseButtonLeft): + lx, ly = _cursor_logical() + ev = Quartz.CGEventCreateMouseEvent(None, etype, Quartz.CGPointMake(lx, ly), button) + _post(ev) + +def MouseDown(): + _mouse_event(Quartz.kCGEventLeftMouseDown) +def MouseUp(): + _mouse_event(Quartz.kCGEventLeftMouseUp) + +def MouseClick(staytime=0.05): + MouseDown(); time.sleep(staytime) + MouseUp(); time.sleep(0.05) + +def MouseDClick(staytime=0.05): + # 真双击: 同坐标连发2次, 用 click state=2 + lx, ly = _cursor_logical() + p = Quartz.CGPointMake(lx, ly) + for state in (1, 2): + down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, p, Quartz.kCGMouseButtonLeft) + Quartz.CGEventSetIntegerValueField(down, Quartz.kCGMouseEventClickState, state) + _post(down) + up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, p, Quartz.kCGMouseButtonLeft) + Quartz.CGEventSetIntegerValueField(up, Quartz.kCGMouseEventClickState, state) + _post(up) + time.sleep(0.05) + +def RightClick(staytime=0.05): + lx, ly = _cursor_logical() + p = Quartz.CGPointMake(lx, ly) + down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseDown, p, Quartz.kCGMouseButtonRight) + _post(down); time.sleep(staytime) + up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseUp, p, Quartz.kCGMouseButtonRight) + _post(up); time.sleep(0.05) + +# ---------- 键盘 ---------- +# macOS 虚拟键码 (kVK_*) +_KEYMAP = { + 'a':0,'s':1,'d':2,'f':3,'h':4,'g':5,'z':6,'x':7,'c':8,'v':9,'b':11,'q':12, + 'w':13,'e':14,'r':15,'y':16,'t':17,'1':18,'2':19,'3':20,'4':21,'6':22,'5':23, + '=':24,'9':25,'7':26,'-':27,'8':28,'0':29,']':30,'o':31,'u':32,'[':33,'i':34, + 'p':35,'l':37,'j':38,"'":39,'k':40,';':41,'\\':42,',':43,'/':44,'n':45,'m':46, + '.':47,'`':50, + 'enter':36,'return':36,'tab':48,'space':49,' ':49,'delete':51,'backspace':51, + 'esc':53,'escape':53,'forwarddelete':117, + 'left':123,'right':124,'down':125,'up':126, + 'home':115,'end':119,'pageup':116,'pagedown':121, + 'f1':122,'f2':120,'f3':99,'f4':118,'f5':96,'f6':97,'f7':98,'f8':100,'f9':101, + 'f10':109,'f11':103,'f12':111, +} +_MODS = { + 'cmd':Quartz.kCGEventFlagMaskCommand, 'command':Quartz.kCGEventFlagMaskCommand, + 'ctrl':Quartz.kCGEventFlagMaskControl, 'control':Quartz.kCGEventFlagMaskControl, + 'alt':Quartz.kCGEventFlagMaskAlternate, 'option':Quartz.kCGEventFlagMaskAlternate, + 'opt':Quartz.kCGEventFlagMaskAlternate, + 'shift':Quartz.kCGEventFlagMaskShift, + 'fn':Quartz.kCGEventFlagMaskSecondaryFn, +} + +def _key_tap(keycode, flags=0): + down = Quartz.CGEventCreateKeyboardEvent(None, keycode, True) + if flags: Quartz.CGEventSetFlags(down, flags) + _post(down) + up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False) + if flags: Quartz.CGEventSetFlags(up, flags) + _post(up) + +def Press(cmd, staytime=0): + """快捷键。如 'cmd+v' 'cmd+shift+4' 'enter' 'cmd+c'。Win版的 ctrl 在mac多对应 cmd, 调用方自行决定。""" + parts = [p.strip().lower() for p in str(cmd).split('+') if p.strip()] + flags = 0; key = None + for p in parts: + if p in _MODS: flags |= _MODS[p] + else: key = p + if key is None: + return + kc = _KEYMAP.get(key) + if kc is None: + # 单字符走 TypeText + TypeText(key) + else: + _key_tap(kc, flags) + if staytime: time.sleep(staytime) + time.sleep(0.03) + +def TypeText(s): + """直接键入 Unicode 文本 (无需剪贴板)。""" + for ch in str(s): + ev = Quartz.CGEventCreateKeyboardEvent(None, 0, True) + Quartz.CGEventKeyboardSetUnicodeString(ev, len(ch), ch) + _post(ev) + ev2 = Quartz.CGEventCreateKeyboardEvent(None, 0, False) + Quartz.CGEventKeyboardSetUnicodeString(ev2, len(ch), ch) + _post(ev2) + time.sleep(0.005) + +# 剪贴板 (替代 pyperclip) +def set_clipboard(text): + pb = NSPasteboard.generalPasteboard() + pb.clearContents() + pb.setString_forType_(text, NSStringPboardType) + +def get_clipboard(): + pb = NSPasteboard.generalPasteboard() + return pb.stringForType_(NSStringPboardType) + +def Paste(text): + """把 text 放剪贴板并 cmd+v 粘贴 (等价 Win版 pyperclip+ctrl+v)。""" + set_clipboard(text); time.sleep(0.05); Press('cmd+v') + + +# ---------- 截图 (screencapture, 输出物理像素) ---------- +def GrabScreen(bbox=None): + """全屏或区域截图 -> PIL Image。bbox=(l,u,r,b) 物理像素坐标。 + 传 bbox 后图内坐标相对裁剪原点; 转屏幕绝对坐标用 CropToScreen, 勿手搓 screencapture -R。 + """ + fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd) + try: + if bbox: + l, u, r, b = bbox + # screencapture -R 用逻辑点; 转换 物理->逻辑 + lx, ly = l*dpi_scale, u*dpi_scale + lw, lh = (r-l)*dpi_scale, (b-u)*dpi_scale + cmd = ['/usr/sbin/screencapture','-x','-t','png', + f'-R{lx:.0f},{ly:.0f},{lw:.0f},{lh:.0f}', fn] + else: + cmd = ['/usr/sbin/screencapture','-x','-t','png', fn] + subprocess.run(cmd, check=True, capture_output=True) + return Image.open(fn).copy() + finally: + try: os.remove(fn) + except Exception: pass + +def ScreenCapAt(x, y, r=100): + """物理坐标(x,y)为中心±r 截图 -> PIL Image。""" + return GrabScreen((x-r, y-r, x+r, y+r)) + +def CropToScreen(bbox, x, y=None): + """裁剪图内坐标 -> 屏幕绝对物理坐标。bbox=GrabScreen 用的 (l,u,r,b) 物理像素。 + (x,y)=在 GrabScreen(bbox) 返回图内找到的点。返回可直接喂给 Click 的 (X,Y)。 + macOS 版的 ClientToScreen: 纯加裁剪原点偏移, 不做缩放(裁剪图与 bbox 同物理像素)。""" + if y is None and isinstance(x, (tuple, list)): + x, y = x[0], x[1] + return int(bbox[0] + x), int(bbox[1] + y) + +def GrabWindow(win): + """窗口截图 -> PIL Image。win=窗口号(int) 或 标题/应用名子串(str)。物理像素。""" + if isinstance(win, str): + ws = ListWindows(win) + if not ws: raise RuntimeError(f'window not found: {win}') + win = ws[0]['id'] + fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd) + try: + subprocess.run(['/usr/sbin/screencapture','-x','-o','-l',str(win),'-t','png',fn], + check=True, capture_output=True) + return Image.open(fn).copy() + finally: + try: os.remove(fn) + except Exception: pass + +# ---------- 窗口枚举 / 激活 (替代 win32gui) ---------- +def ListWindows(name=None): + """枚举屏上窗口 -> [{'id','title','app','bbox'(物理像素 l,u,r,b),'pid'}]。 + name: 标题或应用名子串过滤(不区分大小写)。""" + opts = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements + infos = Quartz.CGWindowListCopyWindowInfo(opts, Quartz.kCGNullWindowID) + out = [] + inv = 1.0 / dpi_scale # 逻辑->物理 + for w in infos: + b = w.get('kCGWindowBounds') or {} + layer = w.get('kCGWindowLayer', 0) + if layer != 0: # 只要普通应用窗口层 + continue + title = w.get('kCGWindowName') or '' + app = w.get('kCGWindowOwnerName') or '' + l = b.get('X', 0)*inv; u = b.get('Y', 0)*inv + r = l + b.get('Width', 0)*inv; bo = u + b.get('Height', 0)*inv + rec = {'id': int(w.get('kCGWindowNumber', 0)), 'title': title, 'app': app, + 'pid': int(w.get('kCGWindowOwnerPID', 0)), + 'bbox': (int(l), int(u), int(r), int(bo))} + if name: + n = name.lower() + if n not in title.lower() and n not in app.lower(): + continue + out.append(rec) + return out + +def ActivateApp(target): + """激活应用到前台 (替代 SetForegroundWindow)。 + + macOS 的前台是 *应用* 粒度而非窗口句柄粒度, 故无法 1:1 镜像 Win 版 + Activate(hwnd)。target 支持两种定位键, 优先用精确的: + - int : 进程 pid (来自 ListWindows 的 'pid' 字段) —— 精确, 不受语言/本地化影响, **推荐** + - str : 应用名 / bundle id。按精确度分级匹配, 避免误中同厂商应用: + ① bundle id 精确等值 (如 'com.tencent.meeting') —— 最可靠 + ② localizedName 精确等值 (如 '腾讯会议') + ③ localizedName 子串 (最后兜底, 可能模糊) + 注意: 不对 bundle id 做子串匹配, 因同厂商应用共享前缀 + (微信 com.tencent.xinWeChat 与腾讯会议 com.tencent.meeting 都含 'tencent')。 + 返回是否成功。""" + ws = NSWorkspace.sharedWorkspace() + # 1) pid 精确激活 (首选) + if isinstance(target, int): + app = NSRunningApplication.runningApplicationWithProcessIdentifier_(target) + if app is not None: + app.activateWithOptions_(1 << 1) # NSApplicationActivateAllWindows + time.sleep(0.3) + return True + return False + # 2) 字符串: 按精确度分级匹配 (避免同厂商前缀误伤) + key = str(target) + keyl = key.lower() + apps = list(ws.runningApplications()) + def _fire(app): + app.activateWithOptions_(1 << 1) + time.sleep(0.3) + return True + # ① bundle id 精确等值 + for app in apps: + if (app.bundleIdentifier() or '').lower() == keyl: + return _fire(app) + # ② localizedName 精确等值 + for app in apps: + if (app.localizedName() or '').lower() == keyl: + return _fire(app) + # ③ localizedName 子串 (兜底) + for app in apps: + if keyl in (app.localizedName() or '').lower(): + return _fire(app) + # 3) 兜底用 open -a + try: + subprocess.run(['open', '-a', str(target)], check=True, capture_output=True) + time.sleep(0.5); return True + except Exception: + return False + + +# ---------- 模板匹配 FindBlock ---------- +def GetWRect(sr): + """快捷区域名 -> 物理像素 [l,u,r,b]。如 'left2'=左半屏, 'top3'=上1/3。""" + num = int(sr[-1]) + l, u, r, b = 0, 0, swidth, sheight + if 'left' in sr: r = swidth // num + if 'right' in sr: l = swidth * (num - 1) // num + if 'top' in sr: b = sheight // num + if 'bottom' in sr: u = sheight * (num - 1) // num + return [l, u, r, b] + +def FindBlock(fn, wrect=None, verbose=0, threshold=0.8): + """在屏幕(或wrect区域)内找模板图 fn。返回 ((cx,cy), is_found), 物理坐标。 + fn: 模板图路径(str)或 PIL Image。 + wrect: None=全屏 | [l,u,r,b]物理像素 | 'left2'等快捷名 | PIL Image(直接当搜索底图)。""" + if not _HAS_CV2: + raise RuntimeError('FindBlock 需要 opencv-python 和 Pillow') + if wrect is not None and isinstance(wrect, Image.Image): + scr, wrect = wrect, None + else: + if isinstance(wrect, str): wrect = GetWRect(wrect) + scr = GrabScreen(wrect) # 物理像素 + blc = Image.open(fn) if isinstance(fn, str) else fn + T = cv2.cvtColor(np.array(blc), cv2.COLOR_RGB2BGR) + B = cv2.cvtColor(np.array(scr), cv2.COLOR_RGB2BGR) + tsh, tsw = T.shape[:2] + res = cv2.matchTemplate(B, T, cv2.TM_CCOEFF_NORMED) + _, max_val, _, max_loc = cv2.minMaxLoc(res) + oj, oi = max_loc + if wrect is None: wrect = [0, 0, scr.size[0], scr.size[1]] + obj = (oj + wrect[0] + tsw // 2, oi + wrect[1] + tsh // 2) + if verbose: + print(f'FindBlock {fn}: score={max_val:.4f} at phys={obj}') + return obj, max_val > threshold + +def imshow(mt, sec=0): + cv2.imshow('cc', mt); cv2.waitKey(sec) + + + +# ---------- Click (带像素变化检测) ---------- +def Click(x, y=None, check=True, r=60): + """物理坐标点击。check=True 时比较点击前后周边像素变化, 返回 (变化百分比, 截图)。 + 若变化≈0 → 可能点歪了 (同 Win 版语义)。""" + if y is None and isinstance(x, (tuple, list)): + x, y = x[0], x[1] + x, y = int(x), int(y) + before = None + if check: + try: before = np.array(ScreenCapAt(x, y, r)) + except Exception: before = None + SetCursorPos((x, y)); time.sleep(0.05) + MouseClick() + if check: + time.sleep(0.25) + try: + after = np.array(ScreenCapAt(x, y, r)) + except Exception: + return None + if before is not None and before.shape == after.shape: + diff = np.mean(np.any(before != after, axis=-1)) * 100 + if verbose_click: + print(f'Click({x},{y}) pixel change: {diff:.1f}%') + if diff < 0.5: + print(f'[WARN] Click({x},{y}) 像素变化≈0%, 可能点歪了, 请诊断坐标! ' + '常见错因: 用了裁剪图内坐标却没加裁剪原点(用 CropToScreen), ' + '或对已是物理像素的坐标又做了 *dpi_scale 换算。') + return diff, Image.fromarray(after) + return None + + +# ---------- AX 辅助功能控件枚举 (UIA 层的 macOS 等价) ---------- +try: + from ApplicationServices import ( + AXUIElementCreateApplication, AXUIElementCopyAttributeValue, + AXValueGetValue, AXUIElementPerformAction, AXIsProcessTrusted, + kAXChildrenAttribute, kAXRoleAttribute, kAXDescriptionAttribute, + kAXPositionAttribute, kAXSizeAttribute, kAXWindowsAttribute, + kAXTitleAttribute, kAXValueCGPointType, kAXValueCGSizeType, + kAXPressAction, kAXEnabledAttribute, + ) + _HAS_AX = True +except ImportError: + _HAS_AX = False + + +def _resolve_pid(target): + """target(int pid | str bundle_id/应用名) → pid(int)。 + + str 优先按 bundle id 精确匹配, 再按 localizedName 精确/子串兜底, + 与 ActivateApp 的匹配纪律一致, 避免同厂商前缀误伤。""" + if isinstance(target, int): + return int(target) + key = str(target); keyl = key.lower() + ws = NSWorkspace.sharedWorkspace() + apps = list(ws.runningApplications()) + for a in apps: # ① bundle id 精确 + if (a.bundleIdentifier() or '') == key: + return int(a.processIdentifier()) + for a in apps: # ② localizedName 精确 + if (a.localizedName() or '').lower() == keyl: + return int(a.processIdentifier()) + for a in apps: # ③ localizedName 子串 + if keyl in (a.localizedName() or '').lower(): + return int(a.processIdentifier()) + raise ValueError(f'找不到 target={target!r} 对应的运行中应用') + + +def _ax_attr(el, key): + """读取单个 AX 属性,失败返回 None。""" + if not _HAS_AX: + return None + err, val = AXUIElementCopyAttributeValue(el, key, None) + return val if err == 0 else None + + +def AXElements(target, max_depth=10, include_zero_size=False): + """枚举应用控件树。 + + Parameters + ---------- + target : int | str + pid(int) 或 bundle_id(str, 如 'com.tencent.meeting')。 + max_depth : int + 递归深度上限。 + include_zero_size : bool + 是否包含 w<=0 或 h<=0 的零尺寸节点。 + + Returns + ------- + list[dict] : 每项含 role/desc/title/id/value/x/y/w/h(物理像素)/depth/el。 + value 为控件当前值(文本/输入框内容等),非文本值为 None。 + el 是原始 AXUIElement 引用,目标窗口关闭/重建后失效,AXPress 前宜就近重新枚举。 + """ + if not _HAS_AX: + raise RuntimeError( + 'AX 不可用。请安装: pip install pyobjc-framework-ApplicationServices') + if not AXIsProcessTrusted(): + raise PermissionError('需要授予辅助功能权限(系统设置 > 隐私与安全 > 辅助功能)') + pid = _resolve_pid(target) + + app_el = AXUIElementCreateApplication(pid) + wins = _ax_attr(app_el, kAXWindowsAttribute) or [] + scale = dpi_scale # 逻辑/物理, Retina=0.5 + + results = [] + + def _walk(el, depth): + if depth > max_depth: + return + role = _ax_attr(el, kAXRoleAttribute) + desc = _ax_attr(el, kAXDescriptionAttribute) + title = _ax_attr(el, kAXTitleAttribute) + ident = _ax_attr(el, 'AXIdentifier') + value = _ax_attr(el, 'AXValue') + enabled = _ax_attr(el, kAXEnabledAttribute) + # AXValue 多为 str/num(文本/输入框);若是 AXValueRef(坐标等)忽略 + if value is not None and not isinstance(value, (str, int, float, bool)): + value = None + pos_val = _ax_attr(el, kAXPositionAttribute) + sz_val = _ax_attr(el, kAXSizeAttribute) + # 解包坐标(逻辑点) + px = py_ = pw = ph = 0.0 + if pos_val is not None: + ok, pt = AXValueGetValue(pos_val, kAXValueCGPointType, None) + if ok: + px, py_ = pt.x, pt.y + if sz_val is not None: + ok, sz = AXValueGetValue(sz_val, kAXValueCGSizeType, None) + if ok: + pw, ph = sz.width, sz.height + # 转物理像素 + phys_x = px / scale if scale else px + phys_y = py_ / scale if scale else py_ + phys_w = pw / scale if scale else pw + phys_h = ph / scale if scale else ph + + if not include_zero_size and (phys_w <= 0 or phys_h <= 0): + pass # 跳过零尺寸,但仍递归子节点 + else: + results.append(dict( + depth=depth, role=role, desc=desc, title=title, id=ident, + value=value, enabled=bool(enabled) if enabled is not None else None, + x=round(phys_x), y=round(phys_y), + w=round(phys_w), h=round(phys_h), el=el)) + for child in (_ax_attr(el, kAXChildrenAttribute) or []): + _walk(child, depth + 1) + + for win in wins: + _walk(win, 0) + return results + + +def AXPress(element) -> bool: + """对 AX element 执行 Press 动作(免坐标点击)。""" + if not _HAS_AX: + return False + err = AXUIElementPerformAction(element, kAXPressAction) + return err == 0 + + +def AXClick(node, check=True) -> bool: + """点击控件: AXPress 优先(免坐标), 失败回退到中心点物理坐标 Click。 + node: AXFind/AXElements 返回的 dict(含 el 与 x/y/w/h), 或裸 AXUIElement。 + 返回是否点击成功(回退路径据像素变化判定, check=False 时无法判定按 True)。 + 呼应 computer_use SOP: AXPress 优先, 回退 Click(phys_cx, phys_cy)。""" + if not isinstance(node, dict): + return AXPress(node) + if node.get('enabled') is False: + print(f"[WARN] AXClick: 控件 disabled (role={node.get('role')}, " + f"title={node.get('title')!r}), 点击可能无效") + if AXPress(node.get('el')): + return True + # 回退: 中心点物理坐标 + cx = node['x'] + node['w'] // 2 + cy = node['y'] + node['h'] // 2 + res = Click(cx, cy, check=check) + if not check or res is None: + return check is False # 无法判定时: 关检查按成功, 截图失败按失败 + diff, _ = res + return diff >= 0.5 + + +def AXFind(target, role=None, desc=None, title=None, identifier=None, + enabled_only=False, max_depth=10): + """枚举并过滤控件。所有过滤条件为子串匹配(大小写不敏感)。 + enabled_only=True 时只返回 enabled 的控件(SOP: 点前查 disabled)。 + + Returns + ------- + list[dict] : 匹配项,同 AXElements 返回格式。 + """ + def _hit(field, needle): + return needle is None or (field and needle.lower() in field.lower()) + return [n for n in AXElements(target, max_depth=max_depth) + if _hit(n['role'], role) and _hit(n['desc'], desc) + and _hit(n['title'], title) and _hit(n['id'], identifier) + and not (enabled_only and n.get('enabled') is False)] + + +# ---------- API 镜像别名 (drop-in 替换 ljqCtrl 用) ---------- +click = Click +press = Press +activate = ActivateApp # 注意: Win 版 Activate(hwnd) 收窗口句柄, Mac 版收应用名子串 +GrabWindowBg = GrabWindow # Mac screencapture 本身支持后台窗口 +VK_CODE = _KEYMAP # 名称兼容 + + +if __name__ == '__main__': + print('--- macljqCtrl self-check ---') + check_permissions() + print('cursor(logical):', _cursor_logical()) + print('windows(top5):') + for w in ListWindows()[:5]: + print(' ', w['id'], '|', w['app'], '|', w['title'][:30], '|', w['bbox']) diff --git a/memory/memory_cleanup_sop.md b/memory/memory_cleanup_sop.md index d0b66dda..abed4feb 100644 --- a/memory/memory_cleanup_sop.md +++ b/memory/memory_cleanup_sop.md @@ -21,9 +21,11 @@ ROI = (不放这几个词的犯错概率 × 代价) / 每轮词数成本 ## 压缩四原则 1. **命名自解释 > 加描述**:SOP名能说清的,L1不加注释;改名的ROI常高于改L1 + - 推论:极低ROI且文件名自解释的条目可不单独列入L1,但仍需被原则2的集合包含住,靠集合触发词+ls兜底 2. **存在性集合最小描述**:多个相近条目若可被同一上位场景覆盖,用集合名表达这类能力的存在,不必平铺子项。如`qq操作/飞书操作/企微操作`→`im操作:*_im_sop`;子项名自解释则只列名不翻译 3. **条目 = 场景↔方案存在性**:如`视频理解:yt-dlp取字幕`、`fofa(资产测绘)`——场景名是触发词,方案名编码存在性;括号内**只放反直觉触发词**,非反直觉的(纯翻译/内容描述/实现细节)全是浪费 -4. **分层归位**:带行为规则或高频高ROI的条目放上方场景行,纯存在性指针归L2/L3平铺列表 + - **触发词判定**:假设用户说出这个词,你能否想到去查对应SOP?能→直觉(不需要);不能→反直觉(必须留)。如`game_download(百度网盘)`:用户说"百度网盘下载",没这个词你不会想到game_download→必须留 +4. **分层归位**:L1内部调整位置——带行为规则或高频高ROI的条目放上方场景行,纯存在性指针归L1下方平铺列表。**归位≠移出L1,存在性不可丢** ## 整理流程 1. 逐行读L1,按`|`拆片段,先分类:存在性指针 / RULES / 翻译 / 内容描述 / 实现细节 / 冗余 @@ -31,7 +33,9 @@ ROI = (不放这几个词的犯错概率 × 代价) / 每轮词数成本 - 全局高ROI → 留 - 特定场景 / 低危险 → 降级到L3或删除 3. 再清存在性指针:检查是否在表达**场景↔方案存在性**;场景触发词只在**反直觉**时才加,翻译/内容描述/实现细节删掉 -4. 检查L3文件名是否自解释;能靠改名解决的,不靠L1加描述;最后验证总行数 ≤ 30 +4. 审计幽灵条目:L1指向的L3文件是否真实存在?不存在则按ROI决定删除或补建文件 +5. 禁值存储:L1括号内禁放参数值(IP/端口/凭证等),这些属于L2,L1只编码存在性 +6. 检查L3文件名是否自解释;能靠改名解决的,不靠L1加描述;最后验证总行数 ≤ 30 **红线**:记忆修改是持久性伤害,错误每轮复利。L1只能patch词级别修改,禁overwrite 产生误导应及时修正L1或记忆更名 diff --git a/memory/project_mode_sop.md b/memory/project_mode_sop.md new file mode 100644 index 00000000..86cc9f7f --- /dev/null +++ b/memory/project_mode_sop.md @@ -0,0 +1,30 @@ +# Project Mode SOP + +## 定义 + +Project Mode = 跨会话保持项目认知的工作模式 +载体:`project_memory.md` + 项目私域目录。两层注入:每轮自动注入的只有 L1(规则+记忆文件指针),L2(`project_memory.md` 全文)不注入——任务涉及项目上下文时自己用 file 工具去读,无关则不读 + +## 进入 + +锚 = `./.active_project.<宿主pid>`,只对当前 GA 进程有效:GA 关闭即自动失活;多开 GA 各自激活不同项目,互不干扰。(下文路径一律以 cwd 为基准,cwd 即 GA 的 temp 目录;禁写 `temp/xxx` 前缀,会嵌套出 temp/temp) + +- 用户只说「进入项目模式」未指明项目:列出 `./projects/` 下各项目(名字 + memory 行数 + 最后修改时间),ask_user 让用户选定后再继续 +- 用户明确说「进入/切换到 <项目名> 项目」:视为已确认,直接执行: + +1. 建目录 `./projects/<项目名>/`,无则创建 `project_memory.md`(空文件即可) +2. 写文件锚,必须用 code_run(锚文件名含宿主 pid,ppid 即 GA 宿主进程): + `import os; open(f'./.active_project.{os.getppid()}', 'w').write('<项目名>')` +3. 回读 `project_memory.md` 全文,向用户复述项目现状 + +## 期间纪律 + +- 项目文件(todo、草稿、产物)一律放 `./projects/<项目名>/`,禁止丢 temp 根目录 +- 入库判据(唯一标准):每得到一条信息,自问「记忆归零、重新接手本项目的我,缺了这条会不会重复付出认知代价——再踩一次坑、再摸索一次、再问一次用户?」会则立即追加进 `project_memory.md`,不会则不记 +- 一条一句,写成未来的自己能直接复用的形式;已有条目增量更新,不整篇重写 + +## 离开 + +用户表示「离开项目模式」时:删除 `./.active_project.<宿主pid>`(仅关闭激活态,项目目录与 `project_memory.md` 原样保留) +切换到另一项目时无需先离开,直接按「进入」覆盖文件锚即可 +GA 关闭不需任何操作:锚随进程消亡自动失效,残留文件由插件下次启动时清扫 diff --git a/memory/skill_search/SKILL.md b/memory/skill_search/SKILL.md deleted file mode 100644 index e89b34b1..00000000 --- a/memory/skill_search/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ -# Skill Search — 105K 技能卡检索 - -> 从 105K+ 技能卡中语义搜索最匹配的 skill。零依赖,内置默认 API 地址,开箱即用。 - -## 最简调用 - -```python -import sys; sys.path.append('../memory/skill_search') -from skill_search import search - -results = search("python send email") # ⚠️ 必须用英文查询,中文匹配效果极差 -for r in results: - s = r.skill - print(f"[{r.final_score:.2f}] {s.name} — {s.one_line_summary}") - print(f" key: {s.key} category: {s.category} tags: {s.tags[:3]}") -``` - -## API 签名 - -```python -search(query, env=None, category=None, top_k=10) -> list[SearchResult] -# env: 自动检测,一般不传 -# category: 可选过滤,如 "devops" -# top_k: 返回数量,默认10 -``` - -## 返回结构 - -``` -SearchResult - .final_score float 综合评分 (0~1) - .relevance float 语义相关度 - .quality float 质量分 - .match_reasons list[str] 匹配原因 - .warnings list[str] 警告 - .skill SkillIndex ↓ - -SkillIndex (常用字段) - .key str 唯一标识/路径 - .name str 名称 - .one_line_summary str 一句话摘要 - .description str 详细描述 - .category str 类别 - .tags list[str] 标签 - .form str 形式(sop/script/...) - .autonomous_safe bool 是否自主安全 -``` - -## CLI - -```bash -python -m skill_search "python testing" -python -m skill_search "docker deployment" --category devops --top 5 -python -m skill_search "git" --json -python -m skill_search --stats -python -m skill_search --env -``` - -## 配置 - -| 项 | 默认值 | 说明 | -|---|---|---| -| API地址 | `http://www.fudankw.cn:58787` | 环境变量 `SKILL_SEARCH_API` 可覆盖 | -| API密钥 | 无(可选) | 环境变量 `SKILL_SEARCH_KEY` | \ No newline at end of file diff --git a/memory/skill_search/skill_search/__init__.py b/memory/skill_search/skill_search/__init__.py deleted file mode 100644 index 70b246f8..00000000 --- a/memory/skill_search/skill_search/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""skill_search — Skill 检索 API 客户端""" -from .engine import ( - SkillIndex, SearchResult, SkillSearchError, - search, get_stats, detect_environment, -) - -__all__ = ["SkillIndex", "SearchResult", "SkillSearchError", - "search", "get_stats", "detect_environment"] \ No newline at end of file diff --git a/memory/skill_search/skill_search/__main__.py b/memory/skill_search/skill_search/__main__.py deleted file mode 100644 index 6691a27b..00000000 --- a/memory/skill_search/skill_search/__main__.py +++ /dev/null @@ -1,116 +0,0 @@ -"""CLI 入口: python -m skill_search""" -from __future__ import annotations -import argparse, json, sys -from .engine import SearchResult, SkillSearchError, detect_environment, search, get_stats - - -# ── 格式化 ─────────────────────────────────────────────── - -def format_results(results: list[SearchResult], env: dict, query: str) -> str: - lines = [f'🔍 搜索: "{query}"', - f"🖥️ 环境: {env.get('os','?')} / {env.get('shell','?')} / {', '.join(env.get('runtimes',[]))}", - f"📊 找到 {len(results)} 个匹配结果\n"] - if not results: - lines.append("未找到匹配的 skill。试试其他关键词?") - return "\n".join(lines) - for i, r in enumerate(results, 1): - s = r.skill - safe_icon = "🟢" if s.autonomous_safe else "🔴" - score_bar = "█" * int(r.final_score * 10) + "░" * (10 - int(r.final_score * 10)) - lines += [ - f"{'─'*60}", - f"#{i} {safe_icon} {s.name}", - f" 路径: {s.key}", - f" 类别: {s.category} | 标签: {', '.join(s.tags[:5])}", - f" 摘要: {s.one_line_summary}", - f" 评分: [{score_bar}] {r.final_score:.2f} (相关={r.relevance:.2f} 质量={r.quality:.1f})", - f" 清晰={s.clarity} 完整={s.completeness} 可操作={s.actionability} | 形式={s.form}", - ] - if r.match_reasons: - lines.append(f" 匹配: {' | '.join(r.match_reasons[:3])}") - if r.warnings: - lines.extend(f" {w}" for w in r.warnings) - lines.append("") - lines.append(f"{'─'*60}") - return "\n".join(lines) - - -def format_results_json(results: list[SearchResult]) -> list[dict]: - out = [] - for r in results: - s = r.skill - out.append({ - "rank": len(out) + 1, "key": s.key, "name": s.name, - "category": s.category, "tags": s.tags, - "description": s.description, "one_line_summary": s.one_line_summary, - "scores": {"final": round(r.final_score, 3), "relevance": round(r.relevance, 3), - "quality": round(r.quality, 1), "clarity": s.clarity, - "completeness": s.completeness, "actionability": s.actionability}, - "safety": {"autonomous_safe": s.autonomous_safe, "blast_radius": s.blast_radius, - "requires_credentials": s.requires_credentials, - "data_exposure": s.data_exposure, "effect_scope": s.effect_scope}, - "platform": {"os": s.os, "runtimes": s.runtimes, "tools": s.tools, "services": s.services}, - "warnings": r.warnings, "match_reasons": r.match_reasons, - }) - return out - - -# ── CLI ────────────────────────────────────────────────── - -def main(): - parser = argparse.ArgumentParser(prog="skill_search", - description="Skill 检索系统 — 根据环境和需求智能推荐 skill(API 客户端)") - parser.add_argument("query", nargs="?", help="搜索关键词(如: 'python testing')") - parser.add_argument("--category", "-cat", help="限定类别") - parser.add_argument("--top", "-k", type=int, default=10, help="返回结果数(默认 10)") - parser.add_argument("--json", action="store_true", help="JSON 格式输出") - parser.add_argument("--env", action="store_true", help="仅显示检测到的环境信息") - parser.add_argument("--stats", action="store_true", help="显示索引统计信息") - parser.add_argument("--api-url", help="指定 API 地址(也可用 SKILL_SEARCH_API 环境变量)") - args = parser.parse_args() - - if args.api_url: - import os; os.environ["SKILL_SEARCH_API"] = args.api_url - - env = detect_environment() - - if args.env: - print("🖥️ 当前环境:") - print(f" OS: {env['os']}") - print(f" Shell: {env['shell']}") - print(f" 运行时: {', '.join(env['runtimes'])}") - print(f" 工具: {', '.join(env['tools'])}") - print(f" 模型能力: tool_calling={env['model']['tool_calling']}, " - f"reasoning={env['model']['reasoning']}, context={env['model']['context_window']}") - return - - if args.stats: - try: - stats = get_stats(env) - print(f"📊 索引统计:") - print(f" 总计: {stats.get('total', '?')} 个 skills") - print(f" 自动安全: {stats.get('safe_count', '?')} 个") - if 'categories' in stats: - print(f" 类别分布:") - for cat, cnt in sorted(stats['categories'].items(), key=lambda x: -x[1]): - print(f" {cat:15s} {cnt:4d}") - except SkillSearchError as e: - print(f"❌ {e}", file=sys.stderr); sys.exit(1) - return - - if not args.query: - parser.print_help(); return - - try: - results = search(query=args.query, env=env, category=args.category, top_k=args.top) - except SkillSearchError as e: - print(f"❌ {e}", file=sys.stderr); sys.exit(1) - - if args.json: - print(json.dumps(format_results_json(results), indent=2, ensure_ascii=False)) - else: - print(format_results(results, env, args.query)) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/memory/skill_search/skill_search/engine.py b/memory/skill_search/skill_search/engine.py deleted file mode 100644 index 5625a4fa..00000000 --- a/memory/skill_search/skill_search/engine.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Skill 检索引擎 — API 客户端(含数据模型与环境检测)""" -from __future__ import annotations -import json, os, platform, shutil, subprocess, urllib.request, urllib.error -from dataclasses import dataclass, field - -# ── 数据模型 ───────────────────────────────────────────── - -@dataclass -class SkillIndex: - """Skill 索引条目(与服务端结构对齐)""" - key: str - name: str = "" - description: str = "" - one_line_summary: str = "" - category: str = "" - tags: list[str] = field(default_factory=list) - language: str = "en" - os: list[str] = field(default_factory=list) - shell: list[str] = field(default_factory=list) - runtimes: list[str] = field(default_factory=list) - tools: list[str] = field(default_factory=list) - services: list[str] = field(default_factory=list) - needs_tool_calling: bool = False - needs_reasoning: bool = False - min_context_window: str = "standard" - decay_risk: str = "low" - clarity: int = 0 - completeness: int = 0 - actionability: int = 0 - autonomous_safe: bool = True - blast_radius: str = "low" - requires_credentials: bool = False - data_exposure: str = "none" - effect_scope: str = "local" - form: str = "" - estimated_tokens: str = "medium" - capabilities: list[str] = field(default_factory=list) - github_stars: int = 0 - github_url: str = "" - - @property - def quality_score(self): - return self.clarity * 0.3 + self.completeness * 0.3 + self.actionability * 0.4 - - @classmethod - def from_dict(cls, d): - known = {f.name for f in cls.__dataclass_fields__.values()} - return cls(**{k: v for k, v in d.items() if k in known}) - - -@dataclass -class SearchResult: - """单条检索结果""" - skill: SkillIndex - relevance: float = 0.0 - quality: float = 0.0 - final_score: float = 0.0 - match_reasons: list[str] = field(default_factory=list) - warnings: list[str] = field(default_factory=list) - - @classmethod - def from_dict(cls, d): - skill = SkillIndex.from_dict(d.get("skill", d)) - return cls(skill=skill, relevance=d.get("relevance", 0.0), - quality=d.get("quality", 0.0), final_score=d.get("final_score", 0.0), - match_reasons=d.get("match_reasons", []), warnings=d.get("warnings", [])) - - -# ── 环境检测 ───────────────────────────────────────────── - -def _run(cmd): - try: - r = subprocess.run(cmd.split(), capture_output=True, text=True, timeout=5) - return r.stdout.strip() if r.returncode == 0 else "" - except Exception: - return "" - -def _detect_os(): - s = platform.system().lower() - return {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(s, s) - -def _detect_shell(): - shell = os.environ.get("SHELL", "") - if "zsh" in shell: return "zsh" - if "bash" in shell: return "bash" - if platform.system() == "Windows": return "powershell" - return os.path.basename(shell) if shell else "unknown" - -def _detect_runtimes(): - checks = {"python": ["python3", "python"], "node": ["node"], "go": ["go"], - "rust": ["rustc"], "java": ["java"], "ruby": ["ruby"], - "php": ["php"], "dotnet": ["dotnet"]} - found = [] - for name, cmds in checks.items(): - for cmd in cmds: - if shutil.which(cmd): - found.append(name); break - return found - -def _detect_tools(): - tools = ["git", "docker", "npm", "pip", "curl", "wget", "kubectl", - "terraform", "aws", "gcloud", "az", "brew", "cargo", "make", "cmake"] - return [t for t in tools if shutil.which(t)] - -def detect_environment(): - """采集完整环境信息""" - return {"os": _detect_os(), "shell": _detect_shell(), - "runtimes": _detect_runtimes(), "tools": _detect_tools(), - "model": {"tool_calling": True, "reasoning": True, "context_window": "large"}} - - -# ── API 配置与调用 ──────────────────────────────────────── - -DEFAULT_API_URL = "http://www.fudankw.cn:58787" - -def _get_api_url(): - return os.environ.get("SKILL_SEARCH_API", DEFAULT_API_URL) - -def _get_api_key(): - return os.environ.get("SKILL_SEARCH_KEY") - -class SkillSearchError(Exception): - pass - -def _api_request(endpoint, payload): - url = f"{_get_api_url()}/{endpoint}" - data = json.dumps(payload).encode("utf-8") - headers = {"Content-Type": "application/json"} - api_key = _get_api_key() - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - req = urllib.request.Request(url, data=data, headers=headers, method="POST") - try: - with urllib.request.urlopen(req, timeout=30) as resp: - return json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as e: - body = e.read().decode("utf-8", errors="replace") - raise SkillSearchError(f"API 错误 {e.code}: {body}") from e - except urllib.error.URLError as e: - raise SkillSearchError(f"无法连接服务: {e.reason}") from e - except Exception as e: - raise SkillSearchError(f"请求失败: {e}") from e - - -# ── 公开接口 ───────────────────────────────────────────── - -def search(query, env=None, category=None, top_k=10): - if env is None: env = detect_environment() - payload = {"query": query, "env": env, "top_k": top_k} - if category: payload["category"] = category - resp = _api_request("search", payload) - return [SearchResult.from_dict(r) for r in resp.get("results", [])] - -def get_stats(env=None): - if env is None: env = detect_environment() - return _api_request("stats", {"env": env}) \ No newline at end of file diff --git a/memory/subagent.md b/memory/subagent.md index 199f20ef..702667e4 100644 --- a/memory/subagent.md +++ b/memory/subagent.md @@ -6,13 +6,13 @@ - 启动:`python agentmain.py --task {name} [--input "短文本"] [--llm_no N]`(cwd=代码根) - `--input`自动建目录+清旧output+写input.txt;长文本先手动写input.txt再启动(不带--input) - 自动后台启动,print PID then exit -- subagent的cwd还是temp,不是task目录 +- 统一设定:所有agent的cwd都是temp,方便文件共享,不是task目录 - input:目标+约束即可,subagent同等智能。**禁写步骤/过度描述**,大量数据给路径 -- 可选fork功能(继承对话上下文): code_run(inline_eval=True),将变量history(自动注入,str)写入task目录下_history.json - 通信:output.txt(append,`[ROUND END]`=轮完成) → 写reply.txt继续 → 不写10min退出。reply后输出为output1/2/3.txt(同格式) - 干预文件:`_stop`(当轮结束) | `_keyinfo`(注入working memory) | `_intervene`(追加指令) -- 监察模式:**主agent空闲时应读output观察进度,必要时用干预文件纠偏,禁止无脑长时间sleep** -- 若加`--verbose`,output将包含工具执行结果,主agent可直接审查原始数据而非仅信任摘要 +- [[可选fork功能]](继承对话上下文): 事先code_run(inline_eval=True),将变量history(自动注入,str)写入task目录下_history.json +- [[可选监察者模式]]:**主agent空闲时应读output观察进度,必要时用干预文件纠偏,禁止无脑长时间sleep** + 若加`--verbose`,output将包含工具执行结果,主agent可直接审查原始数据而非仅信任摘要 ## 场景1:测试模式 - 行为验证 **用途**:观察agent真实行为,修正RULES/L2/L3/SOP @@ -29,7 +29,7 @@ **核心优势**:独立上下文。避免处理文档A的长上下文污染处理文档B的质量 **约束**: - 文件系统共享是优点:不同agent处理不同输入文件,产生不同输出文件 -- 共享资源冲突:键鼠不可共享;浏览器暂时不可并行使用,避免同时操作同一标签页 +- 共享资源冲突:键鼠不可共享;浏览器避免操作同一tab - 不满足map模式的任务 → 主agent顺序执行即可,别用subagent **标准流程(map-reduce)**: 1. 主agent准备阶段:爬取/dump数据,存为多个独立输入文件 diff --git a/memory/supervisor_sop.md b/memory/supervisor_sop.md index 96f68875..af3643f6 100644 --- a/memory/supervisor_sop.md +++ b/memory/supervisor_sop.md @@ -1,42 +1,59 @@ # 监察者模式 SOP -> 你是挑刺的监工,不是干活的工人。你的唯一任务:确保工作agent高质量完成任务。有SOP按SOP约束,无SOP凭常理和经验把关。 +目标:让用户一次说明任务后,尽量不用多轮纠偏。 +核心:**先定行动协议,再盯协议偏离,最后看完成证据。** -## 红线 +## 1. 定位 -- **禁止下场干活**:不操作浏览器、不写代码、不执行任务步骤。你只读、只判断、只干预 -- **可以读环境**:file_read/web_scan/web_execute_js/code_run(只读命令)获取情报,辅助判断工作agent进度和状态 +监察者不是工人。你是挑刺的监工,只负责看 subagent 是否按目标、约束和证据闭环完成任务;有 SOP 按 SOP 把关,无 SOP 按常理和经验把关。 -## 启动 +## 2. 红线 -1. **有SOP时**:读SOP原文,提取所有约束(⚠️/禁止/必须/格式要求),按步骤列成**约束清单**存working memory -1. **无SOP时**:根据任务性质和进度,预估未来会遇到的关键风险点 -2. **启动subagent**(cwd=代码根): - ``` - python agentmain.py --task {name} --verbose - ``` - input.txt:`用{SOP名}完成{用户任务}`(只给目标,不复述步骤) +- **禁止下场干活**:不替 subagent 操作浏览器、不写代码、不执行任务步骤。 +- **只读探测**:可用 `file_read`、`web_scan`、`web_execute_js`、只读 `code_run` 辅助判断对象、进度和证据;探测不是代做。 +- **沉默为主**:没发现会导致用户纠偏的问题,就不说话。 +- **一句话干预**:必须短、硬、具体,像用户直接纠偏,禁长篇教程。 +- **`_keyinfo` 只用于预注入**:在 subagent 到达关键步骤前提醒;已经犯错必须用 `_intervene`。 -## 监控循环 +## 3. 执行顺序(硬性,不可跳步) -持续轮询 `temp/{task_name}/output.txt` 的新增内容(sleep间隔读取),每发现新输出: +**Step A — 立刻启动 subagent**(纯机械,不需思考) -1. 判断工作agent当前在哪一步,对照约束清单检查(约束记不清时重读SOP原文,禁凭印象) -2. 可读环境信息(文件/网页/进程)补充判断依据 -3. 工作agent ask_user时给予回复 +读完本 SOP 和 `subagent.md` 后,从用户 prompt 中剥离任务,写 input,启动。调用细节见 `subagent.md`;input 只给目标+约束,长文本给路径。 -| 发现 | 干预 | -|------|------| -| 跳步 | `_intervene`:你跳过了StepN,先做 | -| 细节遗漏 | `_intervene`:你漏了XX约束,重做/补上 | -| 光说不做 | `_intervene`:别说了,直接做 | -| 断言无据 | `_intervene`:你怎么确认的?验证一下 | -| 连续失败 | `_intervene`:停,先读错误日志再决定 | -| 感觉要偏 | `_intervene`:去重读SOP的StepN再继续 | -| 即将进入中后期步骤 | `_keyinfo`:提前注入该步骤的⚠️细节(趁还没到,先塞进working memory) | +**Step B — 写预期文档**(利用 subagent 启动+读环境的时间) -## 干预原则 +subagent 在跑前几轮环境探测时,你写一屏预期文档(产出文件),只写三项: -- **沉默为主**:没问题不说话 -- **一句话**:像用户一样直接说,禁长篇解释 -- **`_keyinfo`只用于提前预注入**:在工作agent到达该步之前塞细节。已经犯错的一律用`_intervene`纠正 \ No newline at end of file +1. **意图与锚点**:目标、必须先看的对象、明显不是目标的对象;只列显性或高代价的账号/身份/归属字段。 +2. **行动协议**:正确推进顺序;必须先找/读哪些 SOP 或工具说明,并把其中的禁止/必须/格式要求纳入约束;何时核对对象/路径/账号/窗口/字段;危险动作前验证什么;调研/测试类先跑通最小可复现闭环再扩大。 +3. **完成证据**:最后必须有什么证据才能交付:状态、截图、日志、diff、测试结果、报告路径、使用方法等。 + +**Step C — tail 监控与干预**(预期文档写完后开始) + +轮询 `temp/{task_name}/output*.txt`。看到 `[ROUND END]` 后判断是否需要写 `reply.txt` 续聊或用干预文件纠偏。tail 中若发现新信息(真实文件结构、字段、约束、隐藏难点),回头补充/更新预期文档,尤其补全此前想不到的验收指标。tail 时盯这些偏离: + +- 没先落到用户锚点,凭记忆/猜测行动。 +- 没找应有 SOP/工具说明,或读了却没按其中禁止/必须/格式要求做。 +- 对象、路径、窗口、账号、环境、数据源偏了。 +- 顺序错,尤其危险/不可逆动作前没验证。 +- 粒度错:该先跑通却大设计,该最小改动却重构,该调研测试却泛泛写报告。 +- 要交付了,但证据、路径、格式、测试或说明不够。 +- 反复试错、泛泛搜索、卡住不读日志/不看 SOP/不收敛到下一步。 + +干预示例: + +```text +停。用户锚点是 X,不是 Y。先看 X。 +不要提交。危险动作前还没验证 X。 +粒度错了。先跑通最小闭环。 +不要交付。还缺 X 证据/路径/格式。 +先停。这个任务应先读/遵守 X SOP,把禁止/必须项列进约束。 +你漏了 XX 约束,补上后再继续。 +连续失败先停,读错误日志再决定。 +别继续乱试。先读 X SOP/日志,列出一个最小下一步再做。 +``` + +## 4. 最终验收 + +按预期文档逐条核对:锚点是否命中、行动协议是否遵守、危险前是否验证、最终产物/结果是否满足用户目标、质量是否够、证据是否足够且路径/格式明确。任一不满足就返工;全部满足才交付用户。 diff --git a/memory/ui_detect.py b/memory/ui_detect.py index e0ff9205..148d3f8e 100644 --- a/memory/ui_detect.py +++ b/memory/ui_detect.py @@ -1,6 +1,10 @@ #!/usr/bin/env python3 """ -UI元素检测 - YOLO + RapidOCR +UI元素检测 - 基于 OmniParser-v2.0 的 icon_detect YOLO 模型 + RapidOCR + +首次使用前必须准备对应权重;这是 OmniParser-v2.0 的 icon_detect YOLO 模型。 +若缺失模型文件,新用户/AI 应搜索并下载 OmniParser-v2.0 icon_detect YOLO 权重。 + 用法: from ui_detect import detect elements = detect("screenshot.png") # 默认match模式 @@ -157,7 +161,7 @@ def detect(image_path, mode='match', conf=0.25, iou_thresh=0.5): return elements def visualize_for_debug(image_path, elements, output_path=None): - """调试用: 可视化元素列表""" + """Only use when user wants to DEBUG!""" from PIL import ImageFont img = Image.open(image_path) draw = ImageDraw.Draw(img) diff --git a/memory/vision_api.template.py b/memory/vision_api.template.py index cfa4ff15..7cfdc1ed 100644 --- a/memory/vision_api.template.py +++ b/memory/vision_api.template.py @@ -3,6 +3,12 @@ from io import BytesIO from pathlib import Path # ============ 用户配置区(从 template 拷贝后只需改这里)============ +# GA自举提示: +# mykey.py配置变量名不固定;默认值失败时,枚举候选变量名,并用最小vision请求实验探测哪组配置可用。 +# 只打印变量名/字段名/model/apibase域名路径/HTTP状态码/错误类型,禁止打印完整dict和apikey/token。 +# 常见格式: +# xxx_config = {"apibase": "https://.../v1", "apikey": "...", "model": "...", "proxy": None} +# 不同中转的 apibase/endpoint 可能不同,按实际状态码和响应结构修正。 CLAUDE_CONFIG_KEY = 'claude_config141' # mykey.py 中 Claude 配置的变量名 OPENAI_CONFIG_KEY = 'oai_config1' # mykey.py 中 OpenAI 配置的变量名 MODELSCOPE_API_KEY = '' # 直接填你的 ModelScope token @@ -79,7 +85,7 @@ def _call_claude(b64, prompt, timeout, max_tokens=1024): mk = _load_config() cfg = getattr(mk, CLAUDE_CONFIG_KEY) resp = requests.post( - cfg['apibase'] + '/v1/messages', + cfg['apibase'] + '/v1/messages', # endpoint按中转实际情况改:有的apibase已含/v1,或路径不同 json={'model': cfg['model'], 'max_tokens': max_tokens, 'messages': [{ 'role': 'user', 'content': [ @@ -96,7 +102,7 @@ def _call_claude(b64, prompt, timeout, max_tokens=1024): def _call_openai_compat(b64, prompt, timeout, *, apibase, apikey, model, proxy=None): proxies = {'https': proxy, 'http': proxy} if proxy else None resp = requests.post( - apibase.rstrip('/') + '/v1/chat/completions', + apibase.rstrip('/') + '/v1/chat/completions', # endpoint按中转实际情况改:有的apibase已含/v1,或路径不同 json={'model': model, 'messages': [{ 'role': 'user', 'content': [ diff --git a/mykey_template.py b/mykey_template.py index e3e0e243..4531ae8e 100644 --- a/mykey_template.py +++ b/mykey_template.py @@ -253,13 +253,13 @@ mixin_config = { # MiniMax 同时提供 OAI 和 Anthropic 兼容接口,同一个 key 两个端点都能用: # - /v1 → chat/completions (LLMSession) # - /anthropic → Anthropic Messages (NativeClaudeSession) -# Anthropic 路径更简洁,OAI 路径会返回 标签(M2.7 自带思考)。 -# 温度自动修正为 (0, 1],支持 M2.7 / M2.5 全系列,204K 上下文。 +# Anthropic 路径更简洁,OAI 路径会返回 标签(M3/M2.7 自带思考)。 +# 温度自动修正为 (0, 1],支持 M3 / M2.7 全系列,M3 512K 上下文。 # native_claude_config_minimax = { # 'name': 'minimax-anthropic', # /llms 显示名 # 'apikey': 'sk-', # 与 OAI 路径同一个 key # 'apibase': 'https://api.minimaxi.com/anthropic', # Anthropic Messages 兼容端点 -# 'model': 'MiniMax-M2.7', +# 'model': 'MiniMax-M3', # 'max_retries': 3, # int # # 'fake_cc_system_prompt': False, # MiniMax 不做 CC 指纹校验 # } @@ -358,14 +358,14 @@ mixin_config = { # ══════════════════════════════════════════════════════════════════════════════ # ── 4a. MiniMax OAI 路径 (/v1 chat/completions) ──────────────────────────── -# OAI 路径会返回 标签(M2.7 自带思考);Anthropic 路径更简洁(见 1f)。 -# 温度自动修正为 (0, 1],支持 M2.7/M2.5 全系列,204K 上下文。 +# OAI 路径会返回 标签(M3/M2.7 自带思考);Anthropic 路径更简洁(见 1f)。 +# 温度自动修正为 (0, 1],支持 M3/M2.7 全系列,M3 512K 上下文。 # oai_config_minimax = { # 'name': 'minimax-oai', # /llms 显示名 # 'apikey': 'sk-', # 形如 sk-cp-xxxxxxxxx;Bearer 鉴权 # 'apibase': 'https://api.minimaxi.com/v1', # OAI 兼容端点 -# 'model': 'MiniMax-M2.7', # 名含 'minimax' → temp 夹到 (0.01,1.0] -# 'context_win': 50000, # int;MiniMax 204K 上下文,此处是裁剪阈值 +# 'model': 'MiniMax-M3', # 名含 'minimax' → temp 夹到 (0.01,1.0] +# 'context_win': 50000, # int;MiniMax M3 512K 上下文,此处是裁剪阈值 # } diff --git a/plugins/project_mode.py b/plugins/project_mode.py new file mode 100644 index 00000000..cca29d32 --- /dev/null +++ b/plugins/project_mode.py @@ -0,0 +1,128 @@ +"""Project Mode plugin — 零核心代码改动实现 GA 项目模式。 + +机制:注册 agent_before hook(agent_loop.py 中每个用户轮触发一次)。 +当项目模式激活时,把 L1 层(规则 + 记忆文件指针 + 收尾纪律)追加到最后一条 user message(str 直接拼接,多模态 list 追加 text block)。 +两层设计:L1 每轮全量注入(轻量、稳定);L2 = project_memory.md 全文不注入, +由模型按 L1 中的指针与线索(行数/大小)自行判断是否用 file 工具读取。 +利用 messages 是 list 引用的事实,直接 mutate 即反映到真正发给 LLM 的内容。 + +激活态载体 = 文件锚 temp/.active_project.<宿主pid>(存当前项目名)。PID 键控: + - 锚只对写它的那个 GA 进程有效 → 多开 GA 各自激活不同项目,互不可见 + - GA 关闭即自动失活(重启后 pid 变,旧锚作废);重新激活需经用户确认(SOP 指示) + - 进入:agent 读 project_mode_sop,经用户确认后写锚(code_run 中 os.getppid() 即宿主 pid) + - 退出:删除该文件。插件加载时清扫旧版无后缀锚与自己 pid 的前世残留(不碰他进程的锚) + +目录约定: + temp/projects/<项目名>/project_memory.md 单文件全文注入的项目记忆 + temp/projects/<项目名>/ 项目私域文件(todo 等),解决多项目覆盖 +""" +import os +import plugins.hooks as hooks + +_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_TEMP = os.path.join(_PROJECT_ROOT, 'temp') +_ANCHOR = os.path.join(_TEMP, f'.active_project.{os.getpid()}') + + +def _cleanup_stale_anchors(): + """清扫无主锚:仅删两类能纯靠文件名判定的——旧版无后缀的、自己 pid 的(刚启动 + 不可能写过锚,必是 pid 复用的前世残留)。他进程的锚一律不碰:故意不做存活探测, + os.kill(pid, 0) 在 Windows 上语义是终止进程而非探活,任何平台分支都不值得为 + 删除无害残留文件引入杀进程风险(激活判定只认自己 pid 的锚,残留不影响行为)。""" + import glob + for path in glob.glob(os.path.join(_TEMP, '.active_project*')): + pid = path.rsplit('.', 1)[-1] + if path != _ANCHOR and pid.isdigit(): + continue # 他进程的锚(含已死进程的),不碰 + try: os.remove(path) + except OSError: pass + + +_cleanup_stale_anchors() + + +def _active_project(ctx=None): + """返回当前激活的项目名;未激活返回 None。 + + 兼容策略: + - 新 TUI 多会话可在当前 GenericAgent 实例上设置 _ga_project_mode_name。 + 只要该属性存在,就以它为准;值为 None/空串表示该 agent 普通模式。 + - 其它 UI / 旧 SOP 不设置该属性,继续读取 pid 键控文件锚。 + 异常不在此捕获——hooks.trigger 统一捕获并打印,保持可观测。""" + parent = None + if isinstance(ctx, dict): + handler = ctx.get('handler') + parent = getattr(handler, 'parent', None) + if parent is not None and hasattr(parent, '_ga_project_mode_name'): + return getattr(parent, '_ga_project_mode_name', None) or None + if not os.path.isfile(_ANCHOR): + return None + return open(_ANCHOR, encoding='utf-8').read().strip() or None + + +def _project_dir(name): + return os.path.join(_TEMP, 'projects', name) + + +def _mem_path(name): + return os.path.join(_project_dir(name), 'project_memory.md') + + +def _memory_stat(name): + """返回 project_memory.md 的 (存在, 行数, 字节数),供 L1 指针给模型判断依据。""" + path = _mem_path(name) + if os.path.isfile(path): + data = open(path, encoding='utf-8').read() + return True, len(data.splitlines()), len(data.encode('utf-8')) + return False, 0, 0 + + +def _build_injection(name): + """构造追加到 user message 末尾的内容(两层设计的 L1 层)。 + + L1(每轮全量注入):规范/规则/操作说明 + 记忆文件指针(含行数/大小线索)。 + L2(按需):project_memory.md 全文不注入,由模型自行判断是否用 file 工具去读。 + """ + pdir = _project_dir(name) + mem_path = _mem_path(name) + exists, lines, nbytes = _memory_stat(name) + if exists and nbytes > 0: + mem_hint = ( + f"项目全量记忆在 {mem_path}({lines} 行 / {nbytes} 字节)。" + f"本轮任务若涉及项目上下文(接续工作、查约定、避坑),先读它再动手;" + f"若与项目认知无关(闲聊、独立小事),可不读。自行判断。" + ) + else: + mem_hint = f"项目记忆 {mem_path} 暂为空(本项目尚无沉淀),无需读取。" + return ( + f"\n\n---\n" + f"[PROJECT MODE: {name}]\n" + f"你正在「{name}」项目模式中。\n\n" + f"## 规则\n" + f"- 项目私域目录:{pdir}(todo、草稿、产物一律放这里,勿放 temp 根目录)\n" + f"- {mem_hint}\n\n" + f"## 收尾纪律\n" + f"干完本轮活后自问一个问题:「记忆归零、重新接手本项目的我,缺了本轮哪条信息会重复付出认知代价" + f"——再踩一次坑、再摸索一次、再问一次用户?」会的,就用 file 工具把那条追加进 {mem_path}," + f"写成未来的自己能直接复用的一句话;不会的,一个字都不写。\n" + f"---" + ) + + +@hooks.register('agent_before') +def inject_project_context(ctx): + """每个用户轮起始时,若项目模式激活,把项目上下文追加到 user message。""" + name = _active_project(ctx) + if not name: + return # 未激活,普通模式,什么都不做 + + # 从尾部找最后一条 user message(不依赖 messages[1] 的位置约定) + um = next((m for m in reversed(ctx.get('messages') or []) + if isinstance(m, dict) and m.get('role') == 'user'), None) + if um is None: + return + content = um.get('content') + if isinstance(content, str): + um['content'] = content + _build_injection(name) + elif isinstance(content, list): # 多模态:追加 text block + content.append({'type': 'text', 'text': _build_injection(name)}) diff --git a/reflect/goal_mode.py b/reflect/goal_mode.py index 22f30eeb..edec793a 100644 --- a/reflect/goal_mode.py +++ b/reflect/goal_mode.py @@ -3,7 +3,7 @@ # 配置: agent按SOP写好state json,通过环境变量GOAL_STATE指定路径 import os, json, time -INTERVAL = 3 # check间隔短,agent跑完立刻再检查 +INTERVAL = 5 # check间隔短,agent跑完立刻再检查 ONCE = False _dir = os.path.dirname(os.path.abspath(__file__)) @@ -23,7 +23,7 @@ def _save(state): json.dump(state, f, ensure_ascii=False, indent=2) # --- prompt 模板 --- -CONTINUATION_PROMPT = """[Goal Mode — 持续推进] +CONTINUATION_PROMPT = """[Goal Mode — 持续优化] {objective} @@ -31,16 +31,25 @@ CONTINUATION_PROMPT = """[Goal Mode — 持续推进] ⏱ 已用 {elapsed_min:.0f} 分钟,剩余约 {remaining_min:.0f} 分钟。第 {turn} 次唤醒。 -你正在 Goal Mode 下工作: -1. 禁止说"已完成,是否继续"——预算没到就不准停,不准提前交付 -2. 分析用户需要的交付物,落实成少量核心成果,后续任务都围绕核心成果的质量提升 -3. 在 cwd 下建立工作文件夹存放成果和进度,读取记忆中可能对成果质量有帮助的原则 -4. 每轮选你认为最能提升成果质量的方向,深入打磨持续修改核心成果 - 如果多轮都是同类型的小修——换一个完全不同的角度重新审视, - 找到新的薄弱点再深入。包括但不限于测试/边界case/性能/安全/代码质量/美观。 -5. 换角度的方法:假装你是第一次看到这个成果的使用者/审阅者/攻击者, - 找到它最容易出问题的地方,然后修它。 -6. 要为了目标持续推进,在工作文件夹中记录进度,不要更新全局记忆。 +你正在 Goal Mode 下工作:无法宣告完成,你会被无法阻止地持续唤醒直到预算耗尽 +唤醒后流程(3选1): +1. 创造阶段(第一次唤醒):分析objective,在cwd建工作文件夹,严格按照objective执行 +2. 检验阶段:从不同视角检验创造结果,产出检验报告 + - 换身份查看(读者/受众/用户/测试工程师/领导) | 设计未跑过的更难测例 | 查素材/事实/引用的真实性与数量/说服力 | 代码质量/产物格式/美观 | 实测验证(亲自执行/模拟用户操作) + - 按任务类型**轮换**选用合适的角色和方法 + - 在遵循原始需求约束下追求超预期,拒绝保守和平庸,必须提出“不够出色”的点 + - 先保及格线(无事实错误/乱码/格式错误,能运行,过基础测例,遵循用户约束),及格同时追求出色 +3. 改进阶段:针对检验报告优化改进交付物,必须实质性改进 + +原则: +1. 每次唤醒**交替**进行检验阶段和改进阶段,保留每次的检验报告和改进changelog。 +2. 除非发现严重问题,不要对创造结果进行完全重写,而是改进 +3. 严格区分交付物和进度报告,交付物中不要混入`已检验`等中间信息 +4. 若检验都是无关紧要问题,下次升级检验(要求更出色产物/更苛刻视角/更难测试/对照原始需求重审/开subagent第三方评审) +5. 改进阶段禁止产出"无改动"。若检验未发现值得改的点,说明检验标准太低——本轮产出"检验标准升级报告",论证当前标准为何不够高并提出新标准,下轮按新标准重新检验。 +6. 在工作文件夹中记录进度,不要更新全局记忆 +7. 所有阶段都建议进行充分调研:web调研、查看记忆和相关SOP、获取用户倾向 +8. 禁止进行sha1等无用验证,文件版本不会出错 """ BUDGET_LIMIT_PROMPT = """[Goal Mode — 预算耗尽,收口]