chore: sync framework to upstream lsdefine/GenericAgent @ c85b59e (excluding conductor.py + frontends/desktop/)

This commit is contained in:
dd3xp committed 2026-06-23 00:16:37 +08:00
1 parent de00ede0cf
commit eaffd01e6c
52 files changed
+7865 -1030

No files matched your search

+4 -4
View File
@@ -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
+119 -95
View File
@@ -10,6 +10,7 @@
<p>
<a href="https://gaagent.ai"><img src="https://img.shields.io/badge/Official_Website-gaagent.ai-00A67E?style=flat-square" alt="Official Website"/></a>
<a href="https://arxiv.org/abs/2604.17091"><img src="https://img.shields.io/badge/Technical_Report-PDF-EA4335?style=flat-square&logo=adobeacrobatreader&logoColor=white" alt="Technical Report"/></a>
<a href="https://github.com/JinyiHan99/GA-Technical-Report"><img src="https://img.shields.io/badge/Code_%26_Data-Reproduction-181717?style=flat-square&logo=github" alt="Reproduction Repo"/></a>
<a href="https://datawhalechina.github.io/hello-generic-agent/"><img src="https://img.shields.io/badge/Tutorial-Datawhale-blue?style=flat-square" alt="Tutorial"/></a>
@@ -24,8 +25,7 @@
</div>
> 📌 **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
<table>
<tr>
<td align="center" width="50%"><b>🧋 Food Delivery Order</b></td>
<td align="center" width="50%"><b>📈 Quantitative Stock Screening</b></td>
<td align="center" width="50%"><b>🛡️ Real-Browser CAPTCHA Survival</b></td>
<td align="center" width="50%"><b>🌐 Autonomous Web Exploration</b></td>
</tr>
<tr>
<td><img src="assets/demo/discord_hcaptcha_real_browser.gif" width="100%" alt="Discord hCaptcha passed in real browser"></td>
<td><img src="assets/demo/autonomous_explore.png" width="100%" alt="Web Exploration"></td>
</tr>
<tr>
<td><sub>While configuring a Discord bot, an hCaptcha <i>"Are you human?"</i> challenge pops up mid-task — GA's real browser session passes it and the task continues. See <a href="#browser-realness-of-ga-web-tools">Browser Realness</a>.</sub></td>
<td><sub>Autonomously browses and periodically summarizes web content.</sub></td>
</tr>
<tr>
<td align="center"><b>🧋 Food Delivery Order</b></td>
<td align="center"><b>📈 Quantitative Stock Screening</b></td>
</tr>
<tr>
<td><img src="assets/demo/order_tea.gif" width="100%" alt="Order Tea"></td>
@@ -85,25 +98,16 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe
<td><sub><i>"Find GEM stocks with EXPMA golden cross, turnover &gt; 5%"</i> — quantitative screening.</sub></td>
</tr>
<tr>
<td align="center"><b>🌐 Autonomous Web Exploration</b></td>
<td align="center"><b>💰 Expense Tracking</b></td>
<td align="center"><b>💬 Batch Messaging</b></td>
</tr>
<tr>
<td><img src="assets/demo/autonomous_explore.png" width="100%" alt="Web Exploration"></td>
<td><img src="assets/demo/alipay_expense.png" width="100%" alt="Alipay Expense"></td>
<td align="center"><img src="assets/demo/wechat_batch.png" width="65%" alt="WeChat Batch"></td>
</tr>
<tr>
<td><sub>Autonomously browses and periodically summarizes web content.</sub></td>
<td><sub><i>"Find expenses over ¥2K in the last 3 months"</i> — drives Alipay via ADB.</sub></td>
</tr>
<tr>
<td align="center" colspan="2"><b>💬 Batch Messaging</b></td>
</tr>
<tr>
<td colspan="2" align="center"><img src="assets/demo/wechat_batch.png" width="50%" alt="WeChat Batch"></td>
</tr>
<tr>
<td colspan="2"><sub>Sends bulk WeChat messages, fully driving the WeChat client.</sub></td>
<td><sub>Sends bulk WeChat messages, fully driving the WeChat client.</sub></td>
</tr>
</table>
@@ -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
```
<details>
@@ -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."*
</details>
@@ -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
</tr>
</table>
### 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 👏
<div align="center">
<table>
<tr>
<td align="center"><strong>WeChat Group 20</strong><br/><img src="assets/images/wechat_group20.jpg" alt="WeChat Group 20 QR" width="240"/></td>
</tr>
</table>
</div>
### 🚩 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 通过 **分层记忆 × 最小工具集 × 自主执行循环**
</tr>
</table>
### 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 通过 **分层记忆 × 最小工具集 × 自主执行循环**
<div align="center">
<table>
<tr>
<td align="center"><strong>微信群 20</strong><br/><img src="assets/images/wechat_group20.jpg" alt="微信群 20 二维码" width="240"/></td>
<td align="center"><strong>微信群 21</strong><br/><img src="assets/images/wechat_group21.jpg" alt="微信群 21 二维码" width="240"/></td>
</tr>
</table>
</div>
@@ -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 名义的第三方网站、机构、组织或个人均非官方。*
---
+6 -7
View File
@@ -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:
+29 -25
View File
@@ -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
+49 -25
View File
@@ -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-<your-anthropic-key>',
'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-<your-openai-key>',
'name': 'gpt-native', 'apikey': 'sk-<your-key>',
'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-<your-key>',
'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-<your-key>',
'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-<your-deepseek-key>',
@@ -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-<your-key>',
@@ -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...<your-minimax-key>',
'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 协议',
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

+577
View File
@@ -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 <InstallDir>\.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`""
+289
View File
@@ -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 <INSTALL_DIR>/.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" <<EOF
export PORTABLE_DEV_ROOT="$PORTABLE_ROOT"
export GENERICAGENT_HOME="$GA_DIR"
export UV_PYTHON_INSTALL_DIR="$PORTABLE_ROOT/uv-python"
export UV_CACHE_DIR="$PORTABLE_ROOT/uv-cache"
export PATH="$BIN:$PYTHON_DIR:\$PATH"
echo "Activated GenericAgent portable env: \$GENERICAGENT_HOME"
EOF
else
cat > "$ENV_SH" <<EOF
export PORTABLE_DEV_ROOT="$PORTABLE_ROOT"
export GENERICAGENT_HOME="$GA_DIR"
export UV_PYTHON_INSTALL_DIR="$PORTABLE_ROOT/uv-python"
export UV_CACHE_DIR="$PORTABLE_ROOT/uv-cache"
export UV_DEFAULT_INDEX="$MAINLAND_INDEX"
export PIP_INDEX_URL="$MAINLAND_INDEX"
export PATH="$BIN:$PYTHON_DIR:\$PATH"
echo "Activated GenericAgent portable env: \$GENERICAGENT_HOME"
EOF
fi
ok "Verification:"
"$UV_EXE" --version
"$PYTHON_EXE" --version
if [[ -n "$GIT_EXE" ]]; then "$GIT_EXE" --version; fi
"$PYTHON_EXE" -c "import requests, bs4, bottle; print('deps ok')"
# Copy mykey template if mykey.py does not exist (GLOBAL mode only)
MYKEY_DST="$GA_DIR/mykey.py"
if [[ "$GLOBAL" == "1" && ! -f "$MYKEY_DST" ]]; then
MYKEY_TPL="$GA_DIR/mykey_template_en.py"
if [[ -f "$MYKEY_TPL" ]]; then
cp "$MYKEY_TPL" "$MYKEY_DST"
ok "Copied mykey_template_en.py -> mykey.py"
fi
fi
# Final banner
echo ""
if [[ "$GLOBAL" == "1" ]]; then
cat <<EOF
╔═══════════════════════════════════════════════╗
║ ✅ GenericAgent installed successfully! ║
╠═══════════════════════════════════════════════╣
║ 📁 Location: $GA_DIR
║ 🔑 Config: edit mykey.py (copied from template)
║ 🚀 Launch: ga tui / ga launch / ga hub
╚═══════════════════════════════════════════════╝
EOF
else
cat <<EOF
╔═══════════════════════════════════════════════╗
║ ✅ GenericAgent 安装完成! ║
╠═══════════════════════════════════════════════╣
║ 📁 安装目录: $GA_DIR
║ 🔑 配置密钥: ga configure
║ 🚀 启动: ga tui / ga launch / ga hub
╚═══════════════════════════════════════════════╝
EOF
fi
echo ""
ok "Activate env: source \"$ENV_SH\""
+1 -1
View File
@@ -2,7 +2,7 @@
需要时read L2 或 ls ../memory/ 查L3
L0(META-SOP): memory_management_sop
L2: 现空
L3: memory_cleanup_sop(记忆整理) | skill_search | ui_detect.py | ocr_utils.py | subagent | web_setup_sop | plan_sop
L3: memory_cleanup_sop(记忆整理) | ui_detect.py | ocr_utils.py | subagent | web_setup_sop | plan_sop
| procmem_scanner | keychain | ljqCtrl_sop+.py | tmwebdriver_sop | autonomous_operation_sop | scheduled_task_sop | vision_sop | adb_ui.py
L4: L4_raw_sessions/ 历史会话
+1 -1
View File
@@ -2,7 +2,7 @@
Read L2 or ls ../memory/ for L3 when needed
L0(META-SOP): memory_management_sop
L2: currently empty
L3: memory_cleanup_sop(memory cleanup) | skill_search | ui_detect.py | ocr_utils.py | subagent | web_setup_sop | plan_sop
L3: memory_cleanup_sop(memory cleanup) | ui_detect.py | ocr_utils.py | subagent | web_setup_sop | plan_sop
| procmem_scanner | keychain | ljqCtrl_sop+.py | tmwebdriver_sop | autonomous_operation_sop | scheduled_task_sop | vision_sop | adb_ui.py
L4: L4_raw_sessions/ historical sessions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

@@ -1,4 +1,5 @@
// Disable alert/confirm/prompt to prevent page JS from blocking extension
try{delete Navigator.prototype.webdriver;delete navigator.webdriver}catch(e){}
(function() {
const _log = console.log.bind(console);
function toast(type, msg) {
-1
View File
@@ -1 +0,0 @@
[{"role":"user","content":[{"type":"text","text":"code_run个helloworld,根据结果简单评价你的工具配置"}]},{"role":"assistant","content":[{"type":"text","text":"\n\n<summary>用户要求code_run helloworld并评价工具配置</summary>\n\n```python\nprint(\"Hello, World!\")\n```"},{"type":"tool_use","id":"tooluse_GlmTNw190WGgK4sBBOiDHK","name":"code_run","input":{"script":"print(\"Hello, World!\")"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"tooluse_GlmTNw190WGgK4sBBOiDHK","content":"{\"status\": \"success\", \"stdout\": \"Hello, World!\\r\\n\", \"exit_code\": 0}"},{"type":"text","text":"\n### [WORKING MEMORY]\n<history>\n\n[USER]: code_run个helloworld,根据结果简单评价你的工具配置\n</history>\nCurrent turn: 1\n"}]},{"role":"assistant","content":[{"type":"text","text":"\n\n<summary>code_run helloworld成功,评价工具配置</summary>\n\n工具配置没问题。`code_run` 返回了结构化的 JSON(`status`、`stdout`、`exit_code`),执行链路通畅,cwd 默认落在 temp 目录。\n\n刚才还验证了 `inline_eval` 能正确访问 agent 内部状态并导出 history,`file_patch` 也能精准改文件。整套工具链从代码执行、文件读写、到 agent 内省都是通的。"}]}]
-1
View File
@@ -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": {
-1
View File
@@ -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": {
+272
View File
@@ -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)
+9 -3
View File
@@ -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);
+514 -8
View File
@@ -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 '<history>' 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: <name>]\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_<logid>.txt`,身份就是文件本身。
# · 原地续 X = 把 agent 的 log_path 指回 X,之后的轮次追加到 X 本身(同一会话延续)。
# · 拷贝续 X = 铸一个新 logid、把 X 拷进去,在副本上续;X 原件不动(并发安全)。
# · 切走/新对话 = 释放当前锁、旧日志原样留作"空闲会话"(不存快照、不清空),新对话铸新 logid。
#
# 独占:每个 TUI 会话出生即持有自己日志的一把锁(`.locks/<logid>.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
-93
View File
@@ -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('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y)))
self.label.bind('<B1-Motion>', self._drag)
self.label.bind('<Double-1>', 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()
+120
View File
@@ -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}"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

+14 -4
View File
@@ -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)
+39 -13
View File
@@ -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 <bot@gaagent.ai>" 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 -- <file>`. 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 -- <file>`。上游没有且仍有效的本地增强(本机配置、密钥模板占位、"
"fork 专属功能)保留,并在上游最新版上重新适配,而非整文件覆盖上游。\n"
" c. 优先上游;本地仅保留上游缺失且有价值的部分。禁止 `git add -A`、禁止整分支 checkout 覆盖、禁止盲目全保留。\n"
"5. 不要进行任何 git commit。覆盖任何文件前先备份。\n"
"6. 最后给出简短小结:分支 HEAD、与上游差距、逐文件处理结果(取上游 / 保留并适配本地)、备份位置。\n"
"\n"
"#### 向用户说 'Congratulations! 🎉'\n"
"更新成功后,请对用户说:\"Congratulations! 🎉 你已成功更新 GenericAgent!\"\n"
+82 -46
View File
@@ -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("""
<style>
[data-testid="stBottom"]{position:fixed!important;bottom:0!important;left:0!important;right:0!important;width:100vw!important;z-index:999;background:var(--background-color,#fff)}
@media (min-width:768px){[data-testid="stSidebar"][aria-expanded="true"]~div [data-testid="stBottom"]{left:300px!important;width:calc(100vw - 300px)!important}}
.stMainBlockContainer{padding-bottom:10rem!important}
</style>
""", 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("""<style>
[data-testid="stSidebar"] .stTextArea textarea {
field-sizing: content; min-height: 1.6em !important; height: auto !important;
}
</style>""", 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"""<div id="last-reply-time" style="display:none">{st.session_state.get('last_reply_time', int(time.time()))}</div>""", 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()
+512 -51
View File
@@ -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 <abs path> 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 <pasted multi-line target>`. 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
+2786 -148
View File
File diff suppressed because it is too large. Load diff
+37 -12
View File
@@ -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:
+478
View File
@@ -0,0 +1,478 @@
"""Workspace 命令的共享逻辑(tuiapp_v2 / tui_v3 复用)。
设计要点(详见对话设计稿):
* **兼容旧入口** `plugins/project_mode.py` 与 `memory/project_mode_sop.md` 的 pid 锚。
前端在
`<repo>/temp/projects/<name>` 建一个指向用户真实绝对路径的目录联接(junction),
并可按需写激活锚 `<repo>/temp/.active_project.<pid>`。project_mode 插件
照常每轮注入 L1,并把 project_memory.md / 产物经 junction 写进真实仓库根
(与 Claude Code 在仓库根放 CLAUDE.md 同理,已接受)。
* **路径基准必须与插件一致**:插件的 `_TEMP` 是基于其 `__file__` 的 `<repo>/temp`
绝对路径(非 cwd)。本模块也从自身 `__file__` 推 `<repo>/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: <name>]`(见 _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)
+968
View File
@@ -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/<key>/`。"""
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_<key>.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_<key>.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,
}
+15 -16
View File
@@ -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] 必须在回复文本中包含<summary>!\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')
+8 -42
View File
@@ -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
+30 -18
View File
@@ -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
+2
View File
@@ -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
+24 -5
View File
@@ -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)
+11 -1
View File
@@ -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信息
+5 -3
View File
@@ -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"""
+15 -2
View File
@@ -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')`。
- **文本输入**: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 "<App>" to activate` 比 ljqCtrl 的 ActivateApp 更可靠,激活后用 `frontmost` 确认。
+590
View File
@@ -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'])
+6 -2
View File
@@ -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或记忆更名
+30
View File
@@ -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 关闭不需任何操作:锚随进程消亡自动失效,残留文件由插件下次启动时清扫
-64
View File
@@ -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` |
@@ -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"]
@@ -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()
-156
View File
@@ -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})
+5 -5
View File
@@ -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数据,存为多个独立输入文件
+47 -30
View File
@@ -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`纠正
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. 最终验收
按预期文档逐条核对:锚点是否命中、行动协议是否遵守、危险前是否验证、最终产物/结果是否满足用户目标、质量是否够、证据是否足够且路径/格式明确。任一不满足就返工;全部满足才交付用户。
+6 -2
View File
@@ -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)
+8 -2
View File
@@ -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': [
+7 -7
View File
@@ -253,13 +253,13 @@ mixin_config = {
# MiniMax 同时提供 OAI 和 Anthropic 兼容接口,同一个 key 两个端点都能用:
# - /v1 → chat/completions (LLMSession)
# - /anthropic → Anthropic Messages (NativeClaudeSession)
# Anthropic 路径更简洁,OAI 路径会返回 <think> 标签(M2.7 自带思考)。
# 温度自动修正为 (0, 1],支持 M2.7 / M2.5 全系列,204K 上下文。
# Anthropic 路径更简洁,OAI 路径会返回 <think> 标签(M3/M2.7 自带思考)。
# 温度自动修正为 (0, 1],支持 M3 / M2.7 全系列,M3 512K 上下文。
# native_claude_config_minimax = {
# 'name': 'minimax-anthropic', # /llms 显示名
# 'apikey': 'sk-<your-minimax-key>', # 与 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 路径会返回 <think> 标签(M2.7 自带思考);Anthropic 路径更简洁(见 1f)。
# 温度自动修正为 (0, 1],支持 M2.7/M2.5 全系列,204K 上下文。
# OAI 路径会返回 <think> 标签(M3/M2.7 自带思考);Anthropic 路径更简洁(见 1f)。
# 温度自动修正为 (0, 1],支持 M3/M2.7 全系列,M3 512K 上下文。
# oai_config_minimax = {
# 'name': 'minimax-oai', # /llms 显示名
# 'apikey': 'sk-<your-minimax-key>', # 形如 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 上下文,此处是裁剪阈值
# }
+128
View File
@@ -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)})
+21 -12
View File
@@ -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>
{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 — 预算耗尽,收口]