39 Commits

Author SHA1 Message Date
木炎
a8a470481d fix: align lineloss default periods with page semantics
Default month/week deterministic lineloss requests to the source page's built-in time ranges while preserving explicit-period parsing and existing routing contracts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:35:28 +08:00
木炎
45b60e37f7 fix: restore zhihu export routing before direct submit
Keep Zhihu hotlist export requests on the orchestration path so natural-language submits without page context no longer fail in direct-submit routing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:24:09 +08:00
木炎
72b79feca9 docs: add tq lineloss design and plan
Add the tq lineloss design spec and implementation plan documents used for the deterministic submit work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 13:12:35 +08:00
木炎
dd7805d341 feat: add deterministic tq lineloss submit path
Add the deterministic tq-lineloss routing and normalization flow so exact-suffix requests execute through the existing browser-script seam with canonical org and period arguments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 13:10:58 +08:00
木炎
311cc1fee6 docs: add fault-details alignment design and plan
Capture the approved fault-details staged-skill design and implementation plan so the remaining work can be resumed from the documented contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:10:02 +08:00
木炎
7443b9da7f fix: classify direct report artifacts by status
Treat direct skill report-artifact payloads as task outcomes so partial and empty reports stay successful while blocked and error statuses fail explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:01:18 +08:00
zhaoyilun
34035cdc9c fix: stabilize zhihu export and dashboard flow 2026-04-10 17:21:13 +08:00
木炎
4becf81066 feat: add config-owned direct skill submit path
Add fixed direct-submit skill loading from configured staged skills and validate directSubmitSkill early so malformed configs fail before routing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:02:30 +08:00
zyl
2ae71fb1c9 compat: probe zhihu extractor before renavigate 2026-03-30 18:39:49 +08:00
zyl
5bccd02d6f docs: add sgclaw project briefing materials 2026-03-30 18:39:49 +08:00
zyl
f51d6b7659 sgclaw: snapshot today's runtime and skill updates 2026-03-30 18:39:49 +08:00
zyl
c793bfc6a1 build: restore repo cargo registry override 2026-03-30 18:39:49 +08:00
zyl
305b6d5110 build: stop using broken rsproxy registry 2026-03-30 18:39:49 +08:00
zyl
4c4f45581f tests: cover nested zhihu creator write entry 2026-03-30 18:39:49 +08:00
zyl
cd94904329 sgclaw: stop zhihu publish flow before editor on creator page 2026-03-30 18:39:49 +08:00
zyl
bf09de6700 test: cover zhihu hotlist script fallback 2026-03-30 08:29:44 +08:00
zyl
dbb18a094c fix: sanitize provider tool names 2026-03-30 08:29:44 +08:00
zyl
5db25b513e fix: sync launcher rules for source checkout 2026-03-30 08:29:44 +08:00
zyl
d2c9902966 feat: add browser script skill execution 2026-03-30 08:29:44 +08:00
zyl
f7e2ff256e logging: include runtime and skill versions 2026-03-30 08:29:44 +08:00
zyl
c7d3d45c68 chore: record final sgclaw superrpa runtime verification 2026-03-30 08:29:44 +08:00
zyl
0fc6fe0c8e frontend: document runtime host bundle 2026-03-30 08:29:44 +08:00
zyl
ef88487f4a acceptance: stabilize zhihu hotlist excel flow 2026-03-30 08:29:44 +08:00
zyl
e294fbb9b1 wip: checkpoint 2026-03-29 runtime work 2026-03-30 08:29:44 +08:00
zyl
7d9036b2d4 sgclaw: move runtime policy into config 2026-03-30 08:29:44 +08:00
zyl
54049a1e1e tools: add sgclaw browser runtime entry wrapper 2026-03-30 08:29:44 +08:00
zyl
3844f2c34c docs: define superrpa sgclaw runtime boundary 2026-03-30 08:29:44 +08:00
zyl
5ed81e5f0c docs: redefine sgclaw as hardened zeroclaw runtime 2026-03-30 08:29:44 +08:00
木炎
b87968632a chore: ignore local workspace directories
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:40:21 +08:00
zyl
d315c13f66 feat: persist sgclaw browser conversations 2026-03-27 01:57:42 +08:00
zyl
bae0e452a5 docs: add sgclaw floating chat plan 2026-03-27 00:50:47 +08:00
zyl
0e3af5a391 fix: load DeepSeek config from browser runtime 2026-03-27 00:34:14 +08:00
zyl
11c0b0fc70 docs: archive legacy planning and frontend assets 2026-03-26 19:30:07 +08:00
zyl
b90955d1b5 docs: refresh zeroclaw L0-L4 2026-03-26 19:28:25 +08:00
zyl
d256643208 docs: add prompt distribution and security hardening notes 2026-03-26 18:56:22 +08:00
zyl
4d9b17f975 frontend: replace sgClaw verification page with chat UI 2026-03-26 18:13:34 +08:00
zyl
16b65c01cf Merge branch 'refactor/zeroclaw-core' 2026-03-26 16:39:05 +08:00
zyl
ff0771a83f feat: refactor sgclaw around zeroclaw compat runtime 2026-03-26 16:23:31 +08:00
zyl
bca5b75801 docs: add rust-only acceptance guide 2026-03-25 04:28:25 +00:00
1165 changed files with 438105 additions and 6317 deletions

5
.cargo/config.toml Normal file
View File

@@ -0,0 +1,5 @@
[source.crates-io]
replace-with = "rsproxy-sparse"
[source.rsproxy-sparse]
registry = "sparse+https://rsproxy.cn/index/"

8
.gitignore vendored
View File

@@ -1,2 +1,10 @@
.worktrees/
target/
.claude/
.idea/
.playwright-mcp/
.qoder/
.sgclaw_workspace/
.sgclaw_workspace_dev1/
target-test/
target-zhihu-nav/

View File

@@ -1,17 +1,19 @@
# Repository Guidelines
## Project Structure & Module Organization
`docs/` is the main source of product, architecture, integration, and team-process documentation. Keep active engineering documents in `docs/*.md`; presentation exports belong under `docs/archive/领导演示资料/`. `frontend/sgClaw验证/` contains the only active runnable UI: a Vue 2 verification page (`index.html`, `index.vue`) plus helper scripts (`serve.sh`, `download-libs.sh`, `testRunner.js`). `frontend/README.md` and `docs/README.md` describe what is active versus archived.
`docs/` is the main source of product, architecture, integration, and team-process documentation. Keep active engineering documents in `docs/*.md`; presentation exports belong under `docs/archive/领导演示资料/`. `frontend/archive/sgClaw验证-已归档/` contains the historical Vue 2 verification page (`index.html`, `index.vue`) plus helper scripts (`serve.sh`, `download-libs.sh`, `testRunner.js`). `frontend/README.md` and `docs/README.md` describe what is active versus archived.
This repository only manages the sgClaw runtime, compatibility layers, skills, and architecture docs. The frontend overlay and Chromium-side code that embed sgClaw into SuperRPA live in the `superRPA` checkout; see `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/` (overlay HTML/JS/CSS) together with the surrounding Chromium resources and host bootstrap code for the actual browser-integrated UI.
## Build, Test, and Development Commands
There is no formal build system in the repository today. Use the local verification page directly:
- `bash frontend/sgClaw验证/serve.sh`
- `bash frontend/archive/sgClaw验证-已归档/serve.sh`
Starts a local HTTP server on port `8080` by default.
- `bash frontend/sgClaw验证/serve.sh 9090`
- `bash frontend/archive/sgClaw验证-已归档/serve.sh 9090`
Serves the verification page on a custom port.
- `bash frontend/sgClaw验证/download-libs.sh`
Downloads Vue 2.6.14 and Element UI assets into `frontend/sgClaw验证/lib/` for offline use.
- `bash frontend/archive/sgClaw验证-已归档/download-libs.sh`
Downloads Vue 2.6.14 and Element UI assets into `frontend/archive/sgClaw验证-已归档/lib/` for offline use.
Open `http://localhost:8080/index.html` after starting the server.
@@ -19,10 +21,10 @@ Open `http://localhost:8080/index.html` after starting the server.
Match the existing style in each file. Frontend code uses 2-space indentation, semicolon-free JavaScript, and simple Vue 2 patterns. Shell scripts should stay Bash-compatible, include `set -e`, and keep usage notes at the top. Preserve existing Chinese file names and domain terminology; add new docs with concise, descriptive names such as `L5-xxx.md` or `xxx_printable.md` when extending the documentation set.
## Testing Guidelines
Testing is currently manual and centered on `frontend/sgClaw验证/testRunner.js`. Validate changes by serving the page, running the relevant verification flows, and recording whether the change affects external API checks, internal browser integration checks, or end-to-end scenarios. If a change touches archived presentation assets, verify links and exported files still open correctly.
Testing is currently manual and centered on `frontend/archive/sgClaw验证-已归档/testRunner.js`. Validate changes by serving the page, running the relevant verification flows, and recording whether the change affects external API checks, internal browser integration checks, or end-to-end scenarios. If a change touches archived presentation assets, verify links and exported files still open correctly.
## Commit & Pull Request Guidelines
Git history currently contains only `first commit`, so no strong convention is established yet. Use short imperative commit subjects, for example `docs: update browser integration notes` or `frontend: adjust verification report layout`. PRs should include a clear summary, affected paths, manual validation steps, and screenshots when `frontend/sgClaw验证/` UI output changes. Link related docs or issues when the change updates architecture or process guidance.
Git history currently contains only `first commit`, so no strong convention is established yet. Use short imperative commit subjects, for example `docs: update browser integration notes` or `frontend: adjust verification report layout`. PRs should include a clear summary, affected paths, manual validation steps, and screenshots when `frontend/archive/sgClaw验证-已归档/` UI output changes. Link related docs or issues when the change updates architecture or process guidance.
## Security & Configuration Tips
Do not commit real API keys. The verification page expects runtime globals such as `window.__SGCLAW_TEST_OPENAI_KEY__` and `window.__SGCLAW_TEST_CLAUDE_KEY__`; keep them in local test-only setup, not tracked files.

2229
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,22 @@
[package]
name = "sgclaw"
version = "0.1.0"
version = "0.1.0-2026.4.9"
edition = "2021"
[dependencies]
anyhow = "1"
async-trait = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
futures-util = "0.3"
hex = "0.4"
hmac = "0.12"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
regex = "1.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "1"
tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] }
uuid = { version = "1", features = ["v4"] }
zip = { version = "0.6.6", default-features = false, features = ["deflate"] }
zeroclaw = { package = "zeroclawlabs", path = "third_party/zeroclaw", default-features = false }

View File

@@ -4,16 +4,38 @@ sgClaw 项目仓库。
## 当前工程形态
- `src/`Rust 侧最小可联调实现,包含 pipe 协议、握手、`BrowserPipeTool`MAC Policy
- `tests/`:协议、握手、工具与 JSON Line 联调测试。
- `src/`Rust 侧最小 Agent 实现,包含 pipe 协议、握手、`BrowserPipeTool`规则规划器、DeepSeek provider、最小 Agent runtime
- `tests/`:协议、握手、工具、规划器、runtime 与 JSON Line 联调测试。
- `resources/rules.json`:本地安全策略白名单。
- `docs/`项目架构、联调协议与团队启动文档
- `frontend/sgClaw验证/`:本地验证页面与辅助脚本。
- `docs/`产品主线文档(架构、实现、交付、接口)与归档入口
- `frontend/archive/sgClaw验证-已归档/`历史本地验证页面与脚本(归档,仅做参考)
## 常用命令
```bash
cargo test
cargo test --test planner_test -q
cargo test --test agent_runtime_test -q
node --test tools/browser_smoke/fake_deepseek_server.test.mjs
node tools/browser_smoke/run_deepseek_browser_smoke.mjs
cargo run
bash frontend/sgClaw验证/serve.sh
bash frontend/archive/sgClaw验证-已归档/serve.sh
```
## 浏览器侧 DeepSeek smoke
在已经可用的 SuperRPA 浏览器构建目录上,可以通过下面的组合验证浏览器侧 `sgclaw` 是否真的走了 ZeroClaw/DeepSeek compat runtime而不是回退到本地 planner
```bash
python3 /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/build_sgclaw.py \
--manifest-path /home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/Cargo.toml \
--out /home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw
node tools/browser_smoke/run_deepseek_browser_smoke.mjs
```
该 wrapper 会:
- 启动本地 fake DeepSeek 服务
- 注入 `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` / `DEEPSEEK_MODEL`
- 调用现有 `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs`
- 在 smoke 通过后,再额外确认 fake 服务确实收到了百度和知乎两组 provider 请求

View File

@@ -1,474 +1,134 @@
# L0 — 产品白皮书与能力全景层
**文档版本**: 1.0
**适用项目**: sgClaw (业数融合一平台 AI Agent 底座)
**编制日期**: 2026-03-03
**文档版本**: 2.0
**适用项目**: sgClawZeroClaw 重构版)
**编制日期**: 2026-03-26
---
## 1. 产品定
## 1. 产品定
sgClaw 是面向国家电网"业数融合一平台"的 **AI 驱动智能代理平台**。它并非一个独立应用程序,而是作为核心能力嵌入 SuperRPA 定制 Chromium 浏览器内核之中,通过浏览器 Side Panel 中的控制按钮一键激活
sgClaw 不是“浏览器智能体执行内核”意义上的 browser-only 产品。更准确地说,它是一个嵌入企业浏览器运行环境中的、安全加固后的 zeroclaw runtimezeroclaw 负责智能体能力本体sgClaw 负责把这些能力包裹进受控的执行边界中
用户只需用自然语言描述业务意图sgClaw 即可自主理解指令语义,规划执行步骤,在 ERP、OA、财务、人力资源、经济法务等复杂业务系统中完成跨系统操作——**无需编写任何代码**。
它的职责不是替代整个平台,也不是承诺“全自动数字员工”,而是:
> **核心比喻:一位会思考、能学习、永不犯错的数字员工。**
1. 以 zeroclaw 为核心提供统一的 Agent 执行入口、prompt/skills/memory/tool routing 能力。
2. 通过 sgClaw 的 pipe、HMAC、MAC Policy 和宿主二次校验,把高风险执行面收敛成受保护的工具表面。
3. 在浏览器场景下,把页面操作能力作为特权工具面暴露给 runtime而不是让浏览器反过来定义整个 runtime。
sgClaw 从浏览器内核层面发起操作,与真实用户行为完全一致,不可被反自动化机制识别,从根本上解决了传统外部 RPA 工具被检测、被拦截的行业痛点
```
┌─────────────────────────────────────────────────────────────────┐
│ SuperRPA 定制 Chromium 浏览器 │
│ │
│ ┌──────────────────────┐ ┌────────────────────────────────┐ │
│ │ 浏览器主窗口 │ │ Side Panel 控制区 │ │
│ │ │ │ │ │
│ │ ┌────────────────┐ │ │ ┌──────────────────────────┐ │ │
│ │ │ ERP / OA / │ │ │ │ [启动 Agent] [停止] │ │ │
│ │ │ 财务 / HR 等 │ │ │ │ │ │ │
│ │ │ 业务系统页面 │ │ │ │ 指令输入: │ │ │
│ │ │ │ │ │ │ "导出本月合规报表" │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ └────────────────┘ │ │ │ ▼ 任务进度 │ │ │
│ │ ▲ │ │ │ ████████░░ 80% │ │ │
│ │ │ 内核级操作 │ │ │ │ │ │
│ │ │ │ │ │ ✓ 已登录 ERP │ │ │
│ │ ┌──────┴─────────┐ │ │ │ ✓ 已导出财务报表 │ │ │
│ │ │ sgClaw 引擎 │◄─┼────┼──│ ► 正在导出合规报表... │ │ │
│ │ │ (Rust Binary) │ │ │ │ │ │ │
│ │ └────────────────┘ │ │ └──────────────────────────┘ │ │
│ └──────────────────────┘ └────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
当前仓库中的 sgClaw 不是一个完整前端产品也不是浏览器发行版本身而是“vendored zeroclaw core + sgClaw 安全封装层 + browser host integration”的产品核心
---
## 2. 行业痛点
## 2. 重构后的产品边界
国家电网及大型央企的业务运营高度依赖多套信息系统协同。一线业务人员每天需要在 5 至 10 余套系统之间反复切换,手工搬运数据,面临以下核心痛点:
### 2.1 当前已经落地的能力
### 2.1 效率低下
- 已 vendored `zeroclaw`,并通过 Rust 运行时接入 provider、skills、memory 等核心能力入口。
- 浏览器侧通过 STDIO JSON Line 协议与 Rust 进程通信。
- 启动时执行 `init -> init_ack` 握手,并建立会话级 HMAC 密钥。
- 任务输入统一走 `submit_task` 消息。
- Rust 侧支持两条执行路径:
- 未配置大模型时,使用仓库内置 planner/fallback 逻辑。
- 配置 `DEEPSEEK_*` 环境变量时,切换到 ZeroClaw compatibility runtime。
- 当前真正稳定落地的特权执行面收敛为一个工具:`browser_action`
- 当前真正开放给模型的动作仅 4 个:`click``type``navigate``getText`
- 所有浏览器动作都受 `resources/rules.json` 中的域名和动作白名单约束。
- 执行过程中会向宿主发送结构化日志和最终任务结果。
一线员工日常需在 ERP、OA、财务管控、人力资源、经济法务、营销等多套系统间反复登录、切换、手工录入。一项跨系统操作如合规线索提报平均需要 **15-30 分钟**,涉及 **3-5 个系统** 的数据交叉核对。全年此类重复操作累计耗费数万人时
注意上述“browser-first + compat-only”是当前实现状态不是目标产品定义。主线目标仍然是“安全加固后的 zeroclaw runtime”而不是“只会操作浏览器的 agent”
### 2.2 人工差错
### 2.2 当前明确不宣称的能力
手工跨系统数据搬运极易出错。财务合规场景下,一个数字的录入错误可能导致审计异常,引发合规风险。据行业统计,人工跨系统操作的 **错误率约为 2%-5%**,在高强度、高压力的月末结算期间错误率更高。
以下内容在旧文档中存在较多规划性描述,但并非当前仓库中的已实现事实:
### 2.3 培训成本高
- 独立的 Skill 仓库与 Skill 脚本执行引擎。
- 完整 MCP 工具接入和多工具编排。
- 独立 Critic/Circuit Breaker 子系统。
- 完整的浏览器 Side Panel 产品界面。
- 40+ 页面动作在 Agent 侧全部开放。
- 真实生产级多租户、审计后台、任务编排中心。
- “浏览器是 sgClaw 的全部 runtime” 这种定义。
新员工需要 **3-6 个月** 才能熟练掌握多套业务系统的操作流程和业务规则。人员调动频繁时,培训成本成倍增长,且经验难以沉淀、传承
### 2.4 合规风险
手工操作缺乏完整的审计轨迹,难以事后追溯"谁在什么时间对哪个系统做了什么操作"。在日趋严格的内控与合规要求下,这构成了显著的制度性风险。
### 2.5 重复劳动
经调研分析,一线业务人员 **约 80%** 的跨系统操作属于规则明确、流程固定的重复性工作。这些工作本应由自动化工具承担,但因系统间壁垒和技术限制,长期依赖人力完成。
### 2.6 传统 RPA 局限
外部 RPA 工具UiPath、BluePrism 等)通过屏幕抓取、模拟点击等方式操控浏览器,存在根本性缺陷:
- **易被检测**:反自动化机制可识别 WebDriver、Selenium 等注入痕迹
- **被系统拦截**:越来越多的业务系统部署了 Bot Detection直接阻断 RPA 操作
- **需专业脚本**:每个流程需要专门开发自动化脚本,维护成本高
- **环境依赖**:对操作系统版本、屏幕分辨率、系统界面变更高度敏感
这些能力可以保留为后续扩展方向,但不应继续写入 L0-L4 作为现状描述
---
## 3. 核心能力矩阵
## 3. 产品价值主张
| 能力维度 | 能力描述 | 关键指标 |
|---------|---------|---------|
| **自然语言驱动** | 用户以自然语言中文描述业务意图Agent 自主理解语义、分解任务、规划步骤并执行 | 支持复杂多步指令,意图识别准确率 > 95% |
| **内核级隐蔽操作** | 从浏览器内核层面发起 DOM 操作与事件派发,与真实用户行为在技术栈上完全一致 | 反自动化检测通过率 100%,零注入痕迹 |
| **自进化学习** | 每次成功执行的操作序列自动沉淀为 Skill后续同类任务直接复用无需重复推理 | Skill 复用率随使用时长持续提升 |
| **三层安全防御** | Pipeline 协议层安全 + Rust 命令验证层 + C++ 内核 MAC 强制访问控制 | 纵深防御,任一层均可独立拦截非法操作 |
| **Skill 技能仓库** | 预置覆盖财务合规、风险管控、营销、人力资源、经济法务等业务领域的操作技能包 | 开箱即用,支持自定义扩展 |
| **多模型适配** | 支持 Claude、GPT 系列、本地化模型Qwen、ChatGLM 等),可按安全等级灵活切换 | 模型切换零代码,响应延迟 < 2s |
| **跨平台支持** | 原生支持 Linux银河麒麟 V10与 Windows满足国产化适配要求 | 信创环境全面兼容 |
| **极致轻量** | Rust 编写的 Agent 引擎,资源占用极低 | 内存 ~5MB冷启动 < 10ms |
ZeroClaw 重构后的 sgClaw核心价值不在“功能堆叠”也不在“单纯浏览器自动化”而在于把 zeroclaw 的智能体能力放进一个可控、可替换、可验证的安全执行底座里。
```
┌─────────────────────────────────────────────────────────────┐
│ sgClaw 核心能力全景图 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ 自然语言 │ │ 自进化学习 │ │ 多模型适配 │ │
│ │ 理解与规划 │ │ Skill 沉淀 │ │ Claude/GPT/Qwen │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ sgClaw Agent 引擎 (Rust) │ │
│ │ 内存 ~5MB | 冷启动 < 10ms │ │
│ └───────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌─────────────┐ ┌────────────────┐ │
│ │ Pipeline │ │ Rust 命令 │ │ C++ 内核 MAC │ │
│ │ 协议层安全 │ │ 验证层 │ │ 强制访问控制 │ │
│ └────────────┘ └─────────────┘ └────────────────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 内核级隐蔽操作 (Chromium C++ 层) │ │
│ │ DOM 操作 · 事件派发 · 与真实用户行为完全一致 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌─────────────┐ ┌────────────────┐ │
│ │ Skill 仓库 │ │ 跨平台支持 │ │ 全链路审计 │ │
│ │ 业务技能包 │ │ 麒麟/Windows │ │ trace_id 追溯 │ │
│ └────────────┘ └─────────────┘ └────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 3.1 对业务侧
- 用自然语言触发浏览器任务,不再直接暴露底层页面命令。
- 统一任务入口,降低页面自动化能力的使用门槛。
- 执行链路具备日志、结果回传和协议约束,便于纳入业务流程。
### 3.2 对集成侧
- 浏览器宿主只需实现固定协议和宿主侧安全复检,不必理解模型内部细节。
- sgClaw Runtime 可以在保留宿主协议的前提下演进 zeroclaw 配置、skills 和工具策略。
- 浏览器只是一个受保护执行面;未来其它客户端也应复用同一 runtime而不是另起一套架构。
### 3.3 对安全侧
- 不是“模型可任意操作浏览器”而是“runtime 只能通过被授权的特权工具面触发高风险动作”。
- 安全边界前置到协议、MAC Policy、宿主二次校验和运行时工具策略而不是把约束留给提示词。
- 域名、动作、HMAC 与工具暴露策略共同组成最小可信执行面。
---
## 4. 典型业务场
## 4. 能力全
### 4.1 财务合规
**场景示例**:合规线索提报与交叉核查
用户指令:*"将本月 ERP 中的异常交易记录与财务管控系统的合规规则交叉比对,生成合规线索提报清单。"*
sgClaw 执行流程:
1. 自动登录 ERP 系统,导航至异常交易模块
2. 按时间范围筛选并导出本月异常交易数据
3. 切换至财务管控系统,调取对应合规规则库
4. 逐条交叉比对,标记命中合规规则的记录
5. 自动生成合规线索提报清单,填入指定模板
6. 提交至审批流程,附加完整操作审计记录
**业务价值**:原需 2-3 小时的人工操作压缩至 **5-8 分钟**,错误率从 3% 降至 **0%**
### 4.2 风险管控
**场景示例**:跨系统风险指标监测与异常预警
用户指令:*"每日自动检查 ERP 和风控系统中的关键风险指标,发现异常立即生成预警报告。"*
sgClaw 执行流程:
1. 定时自动巡检 ERP 系统中的关键财务指标
2. 同步核查风控系统中的风险阈值配置
3. 对比分析指标偏离情况,识别异常模式
4. 异常触发时自动截屏取证、生成预警报告
5. 推送至相关负责人,并在 OA 系统创建跟踪工单
**业务价值**:实现 **7x24 小时** 不间断风险监控,预警响应时间从 "次日发现" 缩短至 **实时告警**
### 4.3 营销
**场景示例**:电费异常批量处理与账单核对
用户指令:*"批量处理本月电费账单异常记录,对比营销系统与财务系统的数据差异。"*
sgClaw 执行流程:
1. 进入营销系统,筛选本月标记为异常的电费账单
2. 逐条提取异常记录的用户编号、金额、异常类型
3. 在财务系统中查询对应的收费记录
4. 自动比对金额差异,生成差异明细报表
5. 对可自动修正的记录执行批量修正操作
6. 对需人工确认的记录生成待办清单
**业务价值**:月均处理量从 **200 条/人日** 提升至 **5000+ 条/小时**,释放大量人力投入高价值工作。
### 4.4 人力资源
**场景示例**:社保表单自动填报与薪酬数据核验
用户指令:*"从 HR 系统导出本月社保基数变更人员名单,自动填入社保申报表并交叉验证薪酬数据。"*
sgClaw 执行流程:
1. 登录 HR 系统,导出社保基数变更人员明细
2. 自动填入社保局在线申报表单的对应字段
3. 同步查询薪酬系统中的工资明细数据
4. 交叉验证社保基数与实际薪酬的一致性
5. 标记不一致记录,生成差异报告
6. 合规记录自动提交,异常记录流转至人工复核
**业务价值**:每月社保申报工作从 **3-5 个工作日** 压缩至 **2-4 小时**
### 4.5 经济法务
**场景示例**:合同履约监测与法律风险预警
用户指令:*"监控即将到期的合同,检查履约状态,对存在违约风险的合同生成法律风险预警。"*
sgClaw 执行流程:
1. 在合同管理系统中筛选 30 天内到期的合同
2. 逐一核查合同关键条款的履约状态
3. 交叉查询 ERP 系统中的付款/交货记录
4. 识别履约偏差,评估违约风险等级
5. 生成法律风险预警报告,按风险等级排序
6. 自动推送至法务部门,创建跟踪任务
**业务价值**:合同风险识别从 "事后补救" 转变为 **"事前预警"**,法律纠纷发生率显著降低。
### 4.6 协同办公
**场景示例**:跨系统数据同步与报表整合
用户指令:*"从 ERP、财务、HR 三个系统导出本月关键运营数据,汇总生成月度经营分析报表。"*
sgClaw 执行流程:
1. 依次登录 ERP、财务、HR 系统
2. 按预设模板提取各系统的关键运营数据
3. 自动对齐数据口径,统一格式
4. 汇总计算关键指标,生成月度经营分析报表
5. 导出为标准格式,上传至 OA 系统
**业务价值**:月度报表整合从 **2-3 天人工汇总** 缩短至 **30 分钟自动生成**
### 4.7 通用场景
用户只需一句自然语言指令sgClaw 即可自主完成端到端的跨系统操作:
| 自然语言指令 | Agent 自主完成的操作 |
|------------|-------------------|
| "导出本月所有合规报表" | 依次登录各业务系统 → 定位报表模块 → 设定时间范围 → 导出 → 汇总 |
| "检查上周新入职员工的系统权限配置" | HR 系统查询入职名单 → 各业务系统逐一核查权限 → 生成核查报告 |
| "把 ERP 里的采购订单数据同步到财务系统" | ERP 导出订单 → 格式转换 → 财务系统录入 → 数据校验 |
| "统计各部门本季度差旅报销总额" | OA 系统提取差旅审批 → 财务系统核查报销 → 按部门汇总 → 生成报表 |
| 能力域 | 当前状态 | 产品含义 |
|---|---|---|
| 任务接入 | 已实现 | 接收浏览器宿主发来的 `submit_task` 指令 |
| 协议握手 | 已实现 | 统一版本、会话标识、HMAC 种子交换 |
| zeroclaw Core | 已接入但未充分释放 | prompt、skills、memory、provider routing 的能力本体已经在仓库中 |
| Agent 执行 | 已实现但仍处过渡态 | planner fallback 与 browser-first ZeroClaw compat 共存 |
| 浏览器特权工具面 | 已实现 | 通过 `browser_action` 暴露受保护的浏览器执行能力 |
| 核心动作 | 已实现 | `click/type/navigate/getText` |
| 域名白名单 | 已实现 | 仅允许规则文件中的域名 |
| 动作白名单 | 已实现 | 仅允许规则文件中的动作 |
| 结构化日志 | 已实现 | `log_entry``task_complete` 回传 |
| 扩展动作枚举 | 已预留 | 协议枚举已定义,但默认未开放 |
| Skill 体系 | 已 vendored 但运行时只部分使用 | 应复用 zeroclaw 原生机制,而不是另起一套浏览器专用技能系统 |
| MCP 生态 | 未在主链路启用 | 未来可接入,但仍应服从 sgClaw 安全封装 |
---
## 5. 技术优势对比
## 5. 典型产品场景
### 5.1 综合对比矩阵
### 5.1 页面导航与信息读取
| 对比维度 | 人工操作 | 传统 RPA (UiPath/BluePrism) | 外部 Agent (OpenClaw) | **sgClaw** |
|---------|---------|---------------------------|---------------------|-----------|
| **架构方式** | N/A | 外部进程控制浏览器 | 外部进程 + WebSocket | **嵌入浏览器内核** |
| **反检测能力** | 天然通过 | 易被检测拦截 | 可被端口扫描发现 | **原生行为,不可检测** |
| **安全层级** | 依赖人员素质 | 应用层安全 | 应用层安全 | **三层纵深防御** |
| **通信方式** | N/A | HTTP / COM | HTTP / WebSocket (端口暴露) | **STDIO Pipe (进程私有)** |
| **内存占用** | N/A | 200-500MB | 394MB+ | **~5MB** |
| **冷启动时间** | N/A | 10-30s | 5-15s | **< 10ms** |
| **技能复用** | 经验口传 | 需重新开发脚本 | 需重新训练 | **复用已有 JS 业务代码** |
| **部署方式** | N/A | 独立安装 + 配置 | 独立安装 + 配置 | **内嵌浏览器,零独立安装** |
| **自然语言** | N/A | 不支持 | 部分支持 | **完整支持中文自然语言** |
| **国产化适配** | N/A | 有限支持 | 不支持 | **银河麒麟 V10 原生支持** |
| **学习门槛** | 3-6 个月 | 需专业 RPA 开发 | 需技术配置 | **自然语言,零学习成本** |
用户输入“进入 ERP 首页并读取当前待办数量”,系统可以拆解为:
### 5.2 关键差异化优势
1. `navigate` 到目标地址。
2. `getText` 读取页面目标区域。
3. 返回结构化结果摘要。
```
┌──────────────────────────────────────────────────────────────────┐
│ 架构差异:外部控制 vs 内核嵌入 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 传统 RPA / 外部 Agent 方案: │
│ │
│ ┌────────────┐ HTTP/WS ┌──────────────┐ │
│ │ RPA Engine │ ──────────────→│ 浏览器 │ │
│ │ (外部进程) │ 端口暴露 │ (被外部控制) │ │
│ └────────────┘ 可被检测 └──────────────┘ │
│ 394MB+ 反自动化机制 │
│ 可识别拦截 │
│ │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │
│ sgClaw 方案: │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ SuperRPA Chromium 浏览器 │ │
│ │ │ │
│ │ ┌──────────┐ STDIO Pipe ┌──────────────┐ │ │
│ │ │ sgClaw │ ◄──────────► │ Chromium C++ │ │ │
│ │ │ (Rust) │ 进程私有 │ 内核层 │ │ │
│ │ │ ~5MB │ 零端口暴露 │ │ │ │
│ │ └──────────┘ └──────────────┘ │ │
│ │ │ │
│ │ 操作 = 原生用户行为,不可被检测 │ │
│ └──────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
```
这是当前仓库最稳定、最符合实现面的任务类型。
### 5.2 表单录入与提交流程中的局部自动化
当页面元素定位规则明确时,系统可用 `click``type` 组合完成表单录入、按钮点击、简单提交等动作。
是否能覆盖完整业务流程,取决于浏览器宿主是否提供对应页面、选择器和回包信息,而不是文档层面预设“所有流程都能端到端执行”。
### 5.3 作为更大产品中的安全执行核
sgClaw 更适合被理解为产品底座中的一个执行核:
- 上层可以接入任务输入框、审批入口或业务编排器。
- 下层可以通过既有浏览器控制面执行,也可以在未来接入其它受保护工具面。
- 中间由 sgClaw 把 zeroclaw runtime 与外部受控执行面连接起来。
---
## 6. 安全与合规保障
## 6. 成功标准
sgClaw 将安全视为产品基因而非附加功能,构建了从通信层到内核层的 **三层纵深防御体系**
重构后的产品文档,以“真实能力清晰可交付”为标准,而不是以“愿景尽可能大”为标准。当前版本应满足:
### 6.1 进程隔离通信
- 采用 **STDIO Pipe** 作为 Agent 与浏览器内核的唯一通信通道
- 不开放任何网络端口,外部进程无法探测或连接
- 通信数据仅存在于父子进程的文件描述符中,操作系统级别的隐私保护
### 6.2 MAC 强制访问控制
- 浏览器 C++ 内核层实施 **Mandatory Access Control**
- 严格的域名白名单机制Agent 仅可操作授权的业务系统域名
- 敏感操作(如支付、审批)需额外的内核级权限校验
- 白名单策略由管理员统一配置Agent 无法自行绕过
### 6.3 凭证安全保护
- 用户凭证由浏览器 Zombie Session Pool 统一管理
- 凭证信息 **永远不会通过 Pipe 协议传输** 至 Agent 进程
- Agent 通过 BrowserAction API 间接使用已建立的会话,无需接触明文密码
### 6.4 人工激活机制
- Agent 功能 **默认关闭**,需用户在 Side Panel 中显式点击启动按钮
- 每次启动均需用户确认,杜绝后台无感自动运行
- 用户可随时一键停止 Agent 的所有操作
### 6.5 全链路审计追溯
- 每次 Agent 会话分配唯一 **trace_id**
- 所有操作步骤(页面导航、元素点击、数据读取、表单提交)均有完整日志记录
- 日志包含操作时间戳、目标系统、操作类型、执行结果
- 支持事后审计回溯与合规举证
### 6.6 防失控熔断机制
- 内置 **Circuit Breaker** 机制,防止 Agent 进入死循环或失控状态
- 单次任务设置最大步骤数上限
- 连续失败自动熔断,暂停执行并通知用户
- 关键操作设置人工确认断点human-in-the-loop
---
## 7. 产品形态与交付方式
### 7.1 产品形态
| 组件 | 形态 | 规格 |
|------|------|------|
| Agent 引擎 | Rust 编译二进制 | 约 8.8MB |
| 宿主环境 | SuperRPA 定制 Chromium 浏览器 | 集成交付 |
| 用户界面 | 浏览器 Side Panel 控制区 | 启停按钮 + 指令输入 + 任务进度 |
| Skill 仓库 | JSON 格式技能定义文件 | 随浏览器内置,支持在线更新 |
| 运行时依赖 | 无 | Rust 静态编译,零外部依赖 |
### 7.2 交付方式
- **Linux (银河麒麟 V10)**:集成于 `superrpa-chromium` .deb 安装包
- **Windows**:集成于 `superrpa-chromium` .exe 安装包
- **无需独立安装**:随浏览器一并部署,无额外配置步骤
- **无需独立升级**:随浏览器版本统一升级管理
### 7.3 用户交互流程
```
用户操作流程:
打开 SuperRPA 浏览器
访问业务系统(自动登录)
打开 Side Panel ──→ 看到 sgClaw 控制区
点击 [启动 Agent] 按钮
输入自然语言指令 ──→ "导出本月所有合规报表"
Agent 自主执行 ──→ Side Panel 实时显示进度
执行完成 ──→ 结果展示 / 文件下载
(可选)点击 [停止] 终止任务
```
---
## 8. 与 SuperRPA 浏览器的协同关系
sgClaw 并非独立产品,而是与 SuperRPA 浏览器深度耦合的 **智能增强层**。两者各司其职,协同构成完整的"智能数字员工"平台。
### 8.1 能力分工
```
┌────────────────────────────────────────────────────────────────────┐
│ "智能数字员工" 完整能力栈 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ sgClaw 智能增强层 │ │
│ │ │ │
│ │ ┌────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ LLM 智能 │ │ 自然语言 │ │ 多步自主 │ │ 自进化学习 │ │ │
│ │ │ 推理引擎 │ │ 理解 │ │ 任务执行 │ │ Skill 沉淀 │ │ │
│ │ └────────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ │ │ │
│ └──────────────────────────┬───────────────────────────────────┘ │
│ │ STDIO Pipe │
│ ┌──────────────────────────┴───────────────────────────────────┐ │
│ │ SuperRPA 浏览器基础设施层 │ │
│ │ │ │
│ │ ┌────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Zombie │ │ SDK │ │ Browser │ │ 凭证与会话 │ │ │
│ │ │ Session │ │ 注入引擎 │ │ Action │ │ 安全管理 │ │ │
│ │ │ Pool │ │ │ │ API │ │ │ │ │
│ │ └────────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ 反检测 │ │ 多标签页 │ │ 域名 │ │ C++ 内核 │ │ │
│ │ │ 指纹伪装 │ │ 并发管理 │ │ 白名单 │ │ MAC 控制 │ │ │
│ │ └────────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
├────────────────────────────────────────────────────────────────────┤
│ 协同价值 │
│ │
│ SuperRPA 提供: sgClaw 增加: │
│ ├─ Zombie Session Pool 会话池 ├─ LLM 智能推理能力 │
│ ├─ SDK 注入与 JS 执行环境 ├─ 自然语言理解与意图解析 │
│ ├─ BrowserAction API 操作接口 ├─ 自主多步任务规划与执行 │
│ ├─ 凭证管理与自动登录 ├─ 自进化学习与 Skill 积累 │
│ ├─ 反自动化检测基础设施 ├─ 跨系统业务流程编排 │
│ └─ 内核级安全强制控制 └─ 业务语义理解与异常处理 │
│ │
│ 单独的 SuperRPA = 强大的自动化浏览器 │
│ SuperRPA + sgClaw = 会思考的智能数字员工 │
│ │
└────────────────────────────────────────────────────────────────────┘
```
### 8.2 典型协同流程
以"自动完成月度合规报表导出"为例:
| 步骤 | 执行者 | 操作 |
|------|-------|------|
| 1 | SuperRPA | Zombie Session Pool 提供已登录的各系统会话 |
| 2 | sgClaw | LLM 理解用户指令,规划任务步骤 |
| 3 | sgClaw | 通过 BrowserAction API 向浏览器发送操作指令 |
| 4 | SuperRPA | SDK 注入层执行 DOM 操作(内核级,不可检测) |
| 5 | SuperRPA | C++ 内核 MAC 校验操作合法性(域名白名单) |
| 6 | sgClaw | 解析操作结果,决定下一步行动 |
| 7 | sgClaw | 任务完成后将操作序列沉淀为 Skill |
| 8 | SuperRPA | 记录完整操作审计日志(含 trace_id |
### 8.3 价值总结
sgClaw 与 SuperRPA 浏览器的结合,实现了 **"能力 + 智能"** 的完整闭环:
- **SuperRPA 浏览器** 解决了 "如何安全、隐蔽地操作业务系统" 的基础设施问题
- **sgClaw** 解决了 "如何智能地理解业务意图并自主执行" 的上层智能问题
- 两者结合,使"业数融合一平台"真正具备 **"理解自然语言 → 自主规划 → 安全执行 → 持续进化"** 的完整智能数字员工能力
---
> **sgClaw — 让每一位员工都拥有一位永不疲倦、永不犯错的智能数字助手。**
- 任何架构描述都能在 `src/``resources/``tests/` 中找到对应实现。
- 任何对外宣称的动作能力都与 `rules.json` 和工具 schema 一致。
- 任何“未来可扩展”内容都与“当前已实现”明确区分。
- L0 到 L4 能从产品、架构、接口、数据流、工程五层连续闭环。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
# L5-提示词分布与安全改造方案
- 记录时间2026-03-26
- 场景:回答“项目里有没有提示词?如何改造更安全?提示词放在哪,什么时候调用?”
- 目标:给出可执行的工程改造路径与落地记录
## 1. 结论(先说结论)
项目当前存在至少两条主要提示词构造链路,但长期主线只能保留一条 authoritative chain
1) **轻量运行时链路**`src/agent/runtime.rs`,过渡态)
- 仅有非常基础的固定 system 提示。
- 适用于非完整流程的本地/最小执行场景。
- 不应继续被扩展成主线产品提示词体系。
2) **ZeroClaw 主链路**`third_party/zeroclaw/*`
- 这条链路是“系统提示”主体,分为:
- `Agent` 内部结构化构建器(`SystemPromptBuilder`
- `channels` 侧统一字符串拼装
- `skills / personality / identity / bootstrap 文件 / 工具说明` 等多个注入源
- 这也是你要关注的主要安全面,也是未来应保留的唯一主线。
---
## 2. 提示词分布结构(按文件/模块)
### 2.1 固定系统提示(轻量链路,待收口)
- `src/agent/runtime.rs`
- `execute_task_with_provider``ChatMessage { role: "system" ... }`
- 当前内容:`You are sgClaw. Use browser_action to complete the browser task.`
### 2.2 ZeroClaw `Agent` 内构建的提示词
- `third_party/zeroclaw/src/agent/prompt.rs`
- `SystemPromptBuilder`(默认 sections
- Sections`ToolHonesty / Tools / Safety / Skills / Workspace / Runtime / ChannelMedia / DateTime`
- `identity_config``skills_prompt_mode``security_summary``autonomy_level` 会影响注入内容。
- `third_party/zeroclaw/src/agent/agent.rs`
- `Agent::from_config` 组装 `prompt_builder(SystemPromptBuilder::with_defaults())``security.prompt_summary()`
- `Agent::build_system_prompt` 每次首次 turn 缓存/重构系统提示。
- `seed_history` 处理恢复会话时避免系统提示重复。
### 2.3 通道侧channel系统提示拼装器
- `third_party/zeroclaw/src/channels/mod.rs`
- `build_system_prompt` / `build_system_prompt_with_mode_and_autonomy`
- 负责 workspace bootstrap 文件注入、技能注入、工具列表、硬件说明、channel 能力、时区与runtime信息。
- 会触发 `load_openclaw_bootstrap_files()``AGENTS.md/SOUL.md/IDENTITY.md/USER.md/TOOLS.md/MEMORY.md` 等)
- compact 模式下会传递 `bootstrap_max_chars`(默认压缩上下文)。
### 2.4 技能提示词注入
- `third_party/zeroclaw/src/skills/mod.rs`
- `skills_to_prompt_with_mode`
- `Full`inline 注入完整 `instructions`
- `Compact`:只注入摘要+工具清单,完整内容通过工具读取。
- `third_party/zeroclaw/src/tools/read_skill.rs`
- `read_skill(name)` 负责 compact 模式下按需读取技能全文。
### 2.5 人格/身份上下文注入
- `third_party/zeroclaw/src/agent/personality.rs`
- 读取 `SOUL.md/IDENTITY.md/USER.md/AGENTS.md/TOOLS.md/HEARTBEAT.md/BOOTSTRAP.md/MEMORY.md`
- `load_personality` + `render` 组成身份上下文片段。
- `third_party/zeroclaw/src/channels/mod.rs`
- `load_openclaw_bootstrap_files()` 读取 `AGENTS.md` 等工作区文件。
### 2.6 子代理提示词(可单独注入)
- `third_party/zeroclaw/src/tools/delegate.rs`
- `build_enriched_system_prompt` 组合 `ToolsSection / SafetySection / SkillsSection / WorkspaceSection / DateTimeSection`
- 叠加 `agent_config.system_prompt`(可选)
### 2.7 安全模块相关(目前与 prompt 解耦)
- `third_party/zeroclaw/src/security/policy.rs`
- 安全策略、命令校验、`prompt_summary()`
- `third_party/zeroclaw/src/security/prompt_guard.rs`
- 已有 prompt 注入检测能力,但当前代码链上未见到统一接入点(需要补齐)。
---
## 3. 提示词何时调用(触发场景)
### 3.1 WS 网关(持久会话)
- `third_party/zeroclaw/src/gateway/ws.rs`
- 连接建立后 `Agent::from_config`
- 若后端有历史消息:`agent.seed_history(&messages)`
- 每条用户消息执行 `agent.turn_streamed`
- `turn_streamed`:若历史空则调用 `build_system_prompt()`
### 3.2 gateway 简版 webhook
- `third_party/zeroclaw/src/gateway/mod.rs``run_gateway_chat_simple`
- 通过 `channels::build_system_prompt(...)` 构造简版系统提示。
### 3.3 gateway 全功能通道
- `third_party/zeroclaw/src/gateway/mod.rs``run_gateway_chat_with_tools`
-`agent::process_message`
- `process_message` 中每次请求构建一次通道 system prompt。
### 3.4 CLI 主入口daemon / interactive
- `third_party/zeroclaw/src/agent/loop_.rs`
- CLI run 或交互会初始化工具/skills/系统提示后,`agent_turn` 执行。
- 命令行消息与 tool_loop 共用通道侧 build path。
### 3.5 每轮 Agent 恢复与续接
- `Agent::seed_history()`(持久化会话恢复)
- 首次首轮会确保系统提示存在;历史中的旧系统提示会被过滤并重建。
### 3.6 交互历史恢复
- `agent/loop_.rs:load_interactive_session_history`
- 历史文件缺失或首条非系统时,补系统提示。
---
## 4. 安全改造建议(按优先级)
### P0建议立即做
0) 收口双主链路
- `sgclaw` 不应长期同时维护一条轻量自定义 prompt 链和一条 zeroclaw 主链。
- 目标是:保留 zeroclaw 主链sgClaw 仅增加安全摘要、浏览器上下文和受控工具面说明。
1) 接入 `PromptGuard`
- 目前已有 `third_party/zeroclaw/src/security/prompt_guard.rs`
- 在以下入口加扫描并截断/告警:
- `Agent::turn` / `turn_streamed`
- `agent::process_message`
- `gateway simple chat` 和 ws/process path 的入口
- 对注入风险高命令ignore previous/system override/role confusion直接 block 或标记高风险。
2) 统一把工作区文件内容做“可注入净化”
- 在注入前清洗 `AGENTS.md`/`SOUL.md` 等:
- 去控制字符、长度限制、拒绝危险模板片段如“you are now…”、“ignore previous instructions”
- 记录清洗与截断明细(便于审计)。
### P11-2次迭代内
3) 将安全摘要作为结构化 section 强约束
-`SafetySection`/`build_system_prompt_with_mode...` 中统一注入 `security.prompt_summary()`
- 保证“允许命令/禁用命令/路径/审批要求/速率限制”同步显示,降低模型 trial-and-error。
4) 对 compact/full 模式加分流控制
-`skills prompt mode` 默认由 full 改为 compact。
- full 模式仅在受信任场景启用compact 场景默认使用 `read_skill`
5) 工具调用策略在提示词中与执行层双向一致
- 当前提示词有“Do not ask, execute directly”等语义与执行层策略一致但对 high-risk 仍需更硬约束。
-`tools::shell` 参数、`security.validate_command_execution``tool approval` 形成统一 policy 文档化。
### P2优化
6) 统一系统提示模板
- `channels::build_system_prompt_*``SystemPromptBuilder` 逻辑有重叠。
- 建议抽取公共 section日期、安全、技能、工具并做一次性组装减少版本漂移导致的绕过面。
7) 增加会话级审计
- 当检测到提示词注入高分时记录原始用户输入哈希、触发规则、决策block/warn/sanitize
- 与工具执行失败rate limit / blocked path打通到同一告警链。
---
## 5. 本次已确认的“关键风险”
- `PromptGuard` 尚未在主入口统一挂载(存在检测能力,但未形成强制拦截链)。
- workspace/skills 内容可直接进入 prompt注入面较宽。
- 两套系统提示构建链路(轻量链路与 zeroclaw 主链)同时存在,容易造成安全策略漂移。
- `sgclaw` 如果继续把浏览器专用提示补丁放在主链外侧,会重新制造第三条 prompt source。
---
## 6. 建议的落地顺序(两周内可完成)
1. 统一入口加 `PromptGuard.scan` + deny/block 映射(最小改动)。
2.`channels` + `personality` 的文件注入点加净化和长度守卫。
3. 安全摘要 section 作为每条提示词的必含块。
4. compact 模式默认开启并补充 `read_skill` 受控流程。
5. 增加一组回归用例:
- 复现提示词覆盖攻击
- 系统提示重复/续接场景seed/reseed
- compact/full 两种技能注入对比

View File

@@ -1,21 +1,48 @@
# docs 目录说明
## 当前有效文档(研发与管理
## 产品文档(核心
- `团队管理标准.md`:团队管理制度、角色清单、变更流程
- `浏览器对接标准.md`Chromium ↔ sgClaw 联调接口标准P1a/P2 必读)
- `L0-产品白皮书与能力全景层.md` ~ `L4-工程实现与部署拓扑层.md`:架构分层文档
- `团队分工.md``协作时间表.md``协作甘特图.md`:协作计划源文档
- `L0-产品白皮书与能力全景层.md`:能力边界与目标价值
- `L1-系统架构与安全模型层.md`:架构分层与安全决策
- `L2-核心模块与接口契约层.md`:模块边界、接口设计与数据结构
- `L3-数据流与Skill体系层.md`执行流程、Skill 语义与数据协议
- `L4-工程实现与部署拓扑层.md`:仓库结构、构建、集成和部署。
- `L5-提示词分布与安全改造方案.md`:提示词治理与风控增强策略。
- `浏览器对接标准.md`Rust 与 Chromium 对接的协议基线。
## 归档文档(领导演示素材)
当前运行时补充约定:
为避免研发目录混杂演示资产,以下内容已统一归档到:
- `sgclaw` 在主线口径上应理解为“安全加固后的 zeroclaw runtime”而不是浏览器侧 UI 产品,也不是 browser-only agent。
- 浏览器宿主与 pipe 协议提供的是受保护执行面;浏览器是重要工具与前端载体,但不是整个 runtime 的定义。
- 文档中提到 `planner fallback``compat runtime``browser_action only` 时,应优先理解为“当前实现状态/过渡态”,而不是长期产品边界。
- `sgclaw` 的浏览器侧配置文件为 `sgclaw_config.json`
- `sgclaw` 负责解析模型配置与可选的 `skillsDir`
- `skillsDir` 未设置时,默认回退到 `<workspace_root>/.sgclaw-zeroclaw-workspace/skills`
- `skillsDir` 已设置时,支持直接指向 skills 目录,也支持指向包含 `skills/` 子目录的 skill repo 根目录。
- `archive/领导演示资料/docs-html/`:演示网页(架构图、时间表)
- `archive/领导演示资料/docs-pdf/`:演示导出的 PDF
- `archive/领导演示资料/docs-figures/`演示图SVG
- `archive/领导演示资料/docs-scripts/`:演示查看/导出脚本
- `archive/领导演示资料/frontend-pages/`:前端演示网页
- `archive/领导演示资料/frontend-svgs/`:前端演示图源文件
## 归档文档
> 归档原则:不影响研发主线文档,演示资产可追溯、可复用、可批量查找。
### 项目管理与排期(已归档)
以下文档已移入 `archive/项目管理与排期/`,保留历史参考,不作为产品主线阅读入口:
- `archive/项目管理与排期/团队分工.md`
- `archive/项目管理与排期/团队管理标准.md`
- `archive/项目管理与排期/协作时间表.md`
- `archive/项目管理与排期/协作甘特图.md`
- `archive/项目管理与排期/协作时间表_printable.md`
- `archive/项目管理与排期/协作甘特图_printable.md`
- `archive/项目管理与排期/sgclaw_project_team_kickoff.md`
- `archive/项目管理与排期/browser_team_kickoff.md`
- `archive/项目管理与排期/团队管理标准.pdf`
### 领导演示与导出资产
- `archive/领导演示资料/docs-html/`
- `archive/领导演示资料/docs-pdf/`
- `archive/领导演示资料/docs-figures/`
- `archive/领导演示资料/docs-scripts/`
- `archive/领导演示资料/frontend-pages/`
- `archive/领导演示资料/frontend-svgs/`
> 归档原则:产品主线文档与交付实现说明保持在 `docs/` 根目录;管理类资料与演示资料集中归档便于追溯。

View File

@@ -0,0 +1,89 @@
# Zhihu Hotlist Excel Acceptance
- Date: 2026-03-30 03:46:51 +0800
- Mode: real provider + live Zhihu hotlist API + simulated browser pipe
- Workspace: `/tmp/sgclaw-live-acceptance-85j8m_dq`
- Final success: `True`
- Total score: `90/100`
## Rubric
- skill selection: `30/30`
- tool discipline: `25/25`
- hotlist data correctness: `20/20`
- xlsx export success: `20/20`
- final response quality: `5/5`
## Final Output
- exported_path: `/tmp/sgclaw-live-acceptance-85j8m_dq/.sgclaw-zeroclaw-workspace/out/zhihu-hotlist-1774813602601179684.xlsx`
- final_summary: `完美!我已经成功完成了知乎热榜数据的读取和导出。以下是任务完成总结:
## 任务完成情况
1. **导航到知乎热榜页面**成功从知乎首页导航到热榜页面https://www.zhihu.com/hot
2. **提取热榜数据**通过浏览器获取了当前热榜前10条数据包括
- 排名1-10
- 标题(完整问题描述)
- 热度值(以"万"为单位)
3. **导出Excel文件**成功将结构化数据导出为Excel文件包含以下列
- rank排名
- title标题
- heat热度
## 导出的数据内容
热榜前10条话题涵盖了多个领域
- 娱乐事件(李荣浩与单依纯的演唱会授权争议)
- 体育赛事张雪机车WSBK夺冠
- 国际政治经济(日本外储操作、伊朗冲突)
- 科技产业字节跳动发展、DDR5内存价格
- 社会现象(周末情侣模式、学霸家长低调现象)
- 生活安全(灭火毯安全问题)
## 生成的文件
**Excel文件路径**`/tmp/sgclaw-live-acceptance-85j8m_dq/.sgclaw-zeroclaw-workspace/out/zhihu-hotlist-1774813602601179684.xlsx`
该文件包含了2026年3月30日03:46:02采集的知乎热榜数据您可以使用Excel或其他电子表格软件打开查看完整的热榜信息。`
## Skill Logs
- `sgclaw runtime version=0.1.0 protocol=1.0`
- `DeepSeek config loaded from /tmp/sgclaw-live-acceptance-85j8m_dq/sgclaw_config.json model=deepseek-chat base_url=https://api.deepseek.com`
- `skills dir resolved to /home/zyl/projects/sgClaw/skill_lib/skills`
- `runtime profile=BrowserAttached skills_prompt_mode=Compact`
- `zeroclaw_process_message_primary`
- `先规划再执行知乎热榜 Excel 导出
navigate https://www.zhihu.com/hot
getText main
call openxml_office
return generated local .xlsx path`
- `loaded skills: office-export-xlsx@0.1.0, zhihu-hotlist@0.1.0, zhihu-hotlist-screen@0.1.0, zhihu-navigate@0.1.0, zhihu-write@0.1.0`
- `navigate https://www.zhihu.com/hot`
- `call zhihu-hotlist.extract_hotlist`
- `browser script failed: {"unsupported_action":"eval"}`
- `getText body`
- `getText .HotList-list`
- `call openxml_office`
- `unsupported columns: expected [rank, title, heat]`
- `call openxml_office`
## Live Hotlist Sample
- 1. 李荣浩摆证据 4 连质问单依纯,为什么没有授权的歌曲也能放进演唱会?演唱会筹备中可能出了什么问题? | 1220万
- 2. 如何看待张雪机车在 2026 年 WSBK 葡萄牙站夺冠?这对国内的摩托赛事发展有什么影响? | 370万
- 3. 日本拟动用外储做空国际原油,以挽救日元汇率,对此你怎么看,其会重演 96 年「住友铜事件」么? | 356万
- 4. 字节跳动是怎么短短数年就能单挑所有互联网巨头的? | 277万
- 5. 如何看待张雪机车 820rr 拿下 wsbk 葡萄牙站第一回合冠军?这个冠军含金量如何? | 241万
- 6. 伊朗科技大学遭袭后,伊朗将美以大学列为「合法袭击目标」,如果战争扩大到教育机构,冲突还有回头路吗? | 202万
- 7. 「周末情侣」模式日渐兴起,工作日通过消息视频联系,仅周末相聚,如何看待这种模式?你有过类似的经历吗? | 163万
- 8. 男孩玩灭火毯全身扎满超细玻璃纤维,又痒又痛取不出来,灭火毯为什么会「扎人」?怎么处理才不遭罪? | 158万
- 9. DDR5 内存价格 3 月出现明显下降,请问这是短期现象,还是内存供需紧张真的缓和了? | 151万
- 10. 为什么越厉害的学霸,她们家长越低调?从来不在朋友圈晒孩子成绩? | 139万
## Stderr
- `sgclaw ready: agent_id=4b984e63-3254-4518-a75a-127e7dad6474`

View File

@@ -0,0 +1,10 @@
# 项目管理与排期归档
本目录存放团队协作与管理向文档,作为历史参考:
- 团队分工与职责
- 管理标准与流程规范
- 协作时间表与甘特图
- 启动说明文档Rust 侧 / 浏览器侧)
这些文档不再作为产品主线文档入口的一部分。

View File

@@ -0,0 +1,134 @@
# DeepSeek Browser Smoke Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a repo-local verification path that exercises the browser-delivered `sgclaw` binary through the ZeroClaw/DeepSeek compat runtime without requiring a real DeepSeek account.
**Architecture:** Keep the existing SuperRPA browser smoke script unchanged. Add a small sgClaw-owned helper module that behaves like a fake OpenAI-compatible DeepSeek server and a runner script that starts that server, injects `DEEPSEEK_*` into the browser process environment, and delegates the actual browser/UI verification to the existing `sgclaw_chat_smoke.mjs`.
**Tech Stack:** Node.js ESM, Node built-in `node:test`, local HTTP server, Chromium `build_sgclaw.py`, existing SuperRPA `sgclaw_chat_smoke.mjs`.
### Task 1: Add Fake DeepSeek Response Planner
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/tools/browser_smoke/fake_deepseek_server.mjs`
- Test: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/tools/browser_smoke/fake_deepseek_server.test.mjs`
**Step 1: Write the failing test**
Add `node:test` coverage that proves the fake server planner:
- returns Baidu tool calls for `打开百度搜索天气`
- returns Zhihu navigate tool calls for `打开知乎搜索天气`
- returns final summaries matching the existing smoke script expectations
- rejects unsupported instructions clearly
**Step 2: Run test to verify it fails**
Run:
```bash
node --test tools/browser_smoke/fake_deepseek_server.test.mjs
```
Expected: FAIL because the helper module does not exist yet.
**Step 3: Implement the minimal helper**
The helper should:
- inspect the latest user message / tool-result phase
- emit OpenAI-compatible `choices[0].message.tool_calls` for the first round
- emit `choices[0].message.content` for the second round
- keep summaries identical to the current smoke assertions
**Step 4: Run test to verify it passes**
Run:
```bash
node --test tools/browser_smoke/fake_deepseek_server.test.mjs
```
Expected: PASS
### Task 2: Add DeepSeek Smoke Wrapper Script
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/tools/browser_smoke/run_deepseek_browser_smoke.mjs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/README.md`
**Step 1: Write the failing wrapper expectation**
Add a small test or dry-run seam in the helper test that proves the wrapper environment includes:
- `DEEPSEEK_API_KEY`
- `DEEPSEEK_BASE_URL`
- `DEEPSEEK_MODEL`
and points at the fake local server.
**Step 2: Run the targeted test to verify it fails**
Run:
```bash
node --test tools/browser_smoke/fake_deepseek_server.test.mjs
```
Expected: FAIL because no wrapper/env builder exists yet.
**Step 3: Implement the wrapper**
The wrapper should:
- start the fake DeepSeek server
- invoke:
```bash
node /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs
```
- inject `DEEPSEEK_*` into the child environment
- print the child stdout/stderr through
- stop the fake server on exit
**Step 4: Run the targeted test to verify it passes**
Run:
```bash
node --test tools/browser_smoke/fake_deepseek_server.test.mjs
```
Expected: PASS
### Task 3: Verify the Browser-Delivered DeepSeek Path
**Files:**
- Verify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/tools/browser_smoke/*`
- Verify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/build_sgclaw.py`
**Step 1: Build the browser-delivered binary from the worktree**
Run:
```bash
python3 /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/build_sgclaw.py \
--manifest-path /home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/Cargo.toml \
--out /home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw
```
Expected: PASS
**Step 2: Run the DeepSeek smoke wrapper**
Run:
```bash
node tools/browser_smoke/run_deepseek_browser_smoke.mjs
```
Expected:
- existing browser smoke passes
- `sgclaw` is forced down the compat runtime path through `DEEPSEEK_*`
- Baidu and Zhihu tasks still complete
**Step 3: Re-run full Rust tests to guard against regressions**
Run:
```bash
python3 /home/zyl/projects/superRpa/src/tools/crates/run_cargo.py test \
--manifest-path /home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/Cargo.toml \
--tests
```
Expected: PASS

View File

@@ -0,0 +1,93 @@
# L0-L4 Documentation Refresh Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Refresh the L0-L4 product documentation so it matches the current ZeroClaw-based refactor and removes outdated team or roadmap narratives.
**Architecture:** Replace speculative architecture with the repository's current runtime model: a Rust browser-agent process that speaks the existing STDIO JSON Line protocol, enforces MAC policy from `resources/rules.json`, and uses a ZeroClaw compatibility runtime when provider configuration is present. Keep protocol and deployment descriptions aligned with actual files under `src/`, `resources/`, `tests/`, and `docs/浏览器对接标准.md`.
**Tech Stack:** Markdown, Rust source inspection, existing sgClaw protocol docs
### Task 1: Reconfirm source-of-truth files
**Files:**
- Modify: `docs/L0-产品白皮书与能力全景层.md`
- Modify: `docs/L1-系统架构与安全模型层.md`
- Modify: `docs/L2-核心模块与接口契约层.md`
- Modify: `docs/L3-数据流与Skill体系层.md`
- Modify: `docs/L4-工程实现与部署拓扑层.md`
- Reference: `src/lib.rs`
- Reference: `src/agent/mod.rs`
- Reference: `src/agent/runtime.rs`
- Reference: `src/compat/runtime.rs`
- Reference: `src/compat/browser_tool_adapter.rs`
- Reference: `src/pipe/protocol.rs`
- Reference: `resources/rules.json`
- Reference: `docs/浏览器对接标准.md`
**Step 1: Inspect current docs and implementation**
Run: `sed -n '1,220p' docs/L0-产品白皮书与能力全景层.md`
Expected: outdated capability claims and pre-refactor architecture language are visible.
**Step 2: Inspect runtime and protocol source**
Run: `sed -n '1,260p' src/pipe/protocol.rs`
Expected: `BrowserMessage`, `AgentMessage`, and `Action` definitions show the real contract surface.
**Step 3: Inspect compatibility runtime path**
Run: `sed -n '1,260p' src/compat/runtime.rs`
Expected: current ZeroClaw integration is clearly a compatibility adapter around `browser_action`.
### Task 2: Rewrite the layered product narrative
**Files:**
- Modify: `docs/L0-产品白皮书与能力全景层.md`
- Modify: `docs/L1-系统架构与安全模型层.md`
**Step 1: Replace L0 narrative**
Write: describe sgClaw as the productized browser-agent runtime after the ZeroClaw refactor, define current value, supported workflows, and explicit non-goals.
**Step 2: Replace L1 architecture**
Write: describe the actual three-part runtime topology, dual execution path, and layered security model without claiming unimplemented subsystems.
### Task 3: Rewrite contract and flow documents
**Files:**
- Modify: `docs/L2-核心模块与接口契约层.md`
- Modify: `docs/L3-数据流与Skill体系层.md`
**Step 1: Replace L2**
Write: define module ownership, protocol messages, active tool contract, and the relationship to `docs/浏览器对接标准.md`.
**Step 2: Replace L3**
Write: describe task lifecycle, planner fallback versus ZeroClaw compat path, memory/config loading, and why “Skill 体系” is currently a prompt/tool abstraction rather than a standalone skill engine.
### Task 4: Rewrite engineering and deployment view
**Files:**
- Modify: `docs/L4-工程实现与部署拓扑层.md`
**Step 1: Replace L4**
Write: document the real repository layout, build/test commands, environment variables, deployment assumptions, and integration boundaries with the browser host.
### Task 5: Verify consistency
**Files:**
- Modify: `docs/plans/2026-03-26-l0-l4-doc-refresh.md`
**Step 1: Review git status**
Run: `git status --short`
Expected: only intended doc updates and existing archive-related changes remain.
**Step 2: Spot-check final docs**
Run: `sed -n '1,120p' docs/L2-核心模块与接口契约层.md`
Expected: tool contract, protocol messages, and allowed actions match the codebase.

View File

@@ -0,0 +1,274 @@
# ZeroClaw Core Refactor Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rebuild `sgClaw` on top of vendored ZeroClaw core while preserving the existing SuperRPA browser pipe protocol, `FunctionsUI` bridge names, and `sgclaw` binary contract.
**Architecture:** Keep `sgclaw` as the compatibility shell and replace its current minimal runtime with a ZeroClaw-based core adapter. Vendor the upstream ZeroClaw workspace into this repository for reproducible builds, then build a `compat` layer that translates `submit_task` / `task_complete` / log events to and from ZeroClaw agent, memory, cron, and tool abstractions. Do not integrate the upstream ZeroClaw gateway in this phase; the future standalone gateway will reuse the same vendored core through a separate entrypoint.
**Tech Stack:** Rust workspace, vendored upstream ZeroClaw (`zeroclawlabs`), current sgClaw pipe protocol and browser tool, DeepSeek via ZeroClaw provider routing, SQLite memory backends, Chromium `run_cargo.py` build flow.
### Task 1: Vendor ZeroClaw Upstream Snapshot
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/third_party/zeroclaw/**`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/third_party/zeroclaw/VENDORED_FROM.md`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/.gitignore`
**Step 1: Copy the upstream snapshot into the repo**
Source:
```bash
/home/zyl/Downloads/zeroclaw-master.zip
```
Destination:
```bash
/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/third_party/zeroclaw
```
Strip the top-level `zeroclaw-master/` folder so the vendored directory itself is the workspace root.
**Step 2: Record provenance**
Write `third_party/zeroclaw/VENDORED_FROM.md` with:
- upstream repo URL
- upstream default branch (`master`)
- source ZIP filename
- vendoring date
- a note that this copy is used to guarantee offline/reproducible browser builds
**Step 3: Verify the vendor tree exists**
Run:
```bash
find third_party/zeroclaw -maxdepth 2 -name Cargo.toml -o -name README.md
```
Expected: upstream workspace files are present.
### Task 2: Convert sgClaw into a ZeroClaw-Backed Workspace Shell
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/Cargo.toml`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/lib.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/main.rs`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/mod.rs`
**Step 1: Add the vendored ZeroClaw dependency**
Use a local path dependency:
```toml
zeroclaw = { package = "zeroclawlabs", path = "third_party/zeroclaw" }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```
Do not use a git dependency. Browser builds must not depend on network access.
**Step 2: Preserve the root crate identity**
Keep:
- package name `sgclaw`
- binary name `sgclaw`
- current manifest path used by SuperRPA browser build scripts
This avoids breaking `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/build_sgclaw.py`.
**Step 3: Route the process entrypoint through the compatibility layer**
`src/lib.rs` should keep:
- current handshake
- current `BrowserPipeTool`
- current message loop
But delegate task execution to `compat::runtime`, not directly to the current thin planner/runtime path.
### Task 3: Introduce the sgClaw Compatibility Layer
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/runtime.rs`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/browser_tool_adapter.rs`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/config_adapter.rs`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/event_bridge.rs`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/memory_adapter.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/agent/mod.rs`
**Step 1: Define the boundary**
`compat::runtime` owns:
- creating the ZeroClaw config/provider/runtime/memory/tool registry
- executing a task from a browser `submit_task`
- translating ZeroClaw progress into current `AgentMessage::LogEntry`
- returning the final summary string for current `task_complete`
`compat::event_bridge` owns all formatting decisions for:
- `[info] ...`
- `[error] ...`
- final summary propagation
**Step 2: Keep the browser protocol unchanged**
Do not change these wire-level contracts:
- `BrowserMessage::SubmitTask`
- `AgentMessage::TaskComplete`
- `AgentMessage::LogEntry`
- `init/init_ack`
The browser side must not need a corresponding protocol change.
**Step 3: Retire direct planner ownership from the main path**
`src/agent/mod.rs` should stop owning the main task intelligence flow. The current rule-based planner can remain only as:
- transitional fallback, or
- deterministic test fixture
It must no longer be the primary execution engine.
### Task 4: Adapt BrowserPipeTool into a ZeroClaw Tool
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/browser_tool_adapter.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/pipe/browser_tool.rs`
- Test: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/tests/compat_browser_tool_test.rs`
**Step 1: Write the failing adapter test**
Add a focused test that proves:
- a ZeroClaw tool invocation can issue `navigate`, `type`, `click`, `getText`
- domain validation still flows through current MAC/rules enforcement
- returned observation data includes browser response payload and AOM snapshot
**Step 2: Verify RED**
Run:
```bash
python3 /home/zyl/projects/superRpa/src/tools/crates/run_cargo.py test \
--manifest-path /home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/Cargo.toml \
--test compat_browser_tool_test
```
Expected: fail because the adapter does not exist yet.
**Step 3: Implement the adapter**
Wrap current `BrowserPipeTool` behind ZeroClaws async `Tool` trait:
- tool name should stay stable and sgClaw-specific, for example `browser_action`
- schema should only expose the currently supported safe actions
- `ToolResult` should include serialized `data`, `aom_snapshot`, `timing`
### Task 5: Build the DeepSeek-Backed ZeroClaw Runtime Path
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/tests/compat_runtime_test.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/runtime.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/config_adapter.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/config/settings.rs`
**Step 1: Write the failing runtime test**
Add a compatibility runtime test that proves:
- when `DEEPSEEK_API_KEY` is configured, sgClaw uses the ZeroClaw provider path
- the runtime can execute a simple mocked `browser_action` sequence
- the final result is returned as current sgClaw `task_complete`
Use a fake provider or deterministic ZeroClaw test seam for RED/GREEN speed.
**Step 2: Verify RED**
Run:
```bash
python3 /home/zyl/projects/superRpa/src/tools/crates/run_cargo.py test \
--manifest-path /home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/Cargo.toml \
--test compat_runtime_test
```
Expected: fail because the compatibility runtime is not wired yet.
**Step 3: Implement DeepSeek mapping**
Map current sgClaw env/config into ZeroClaw provider config:
- `DEEPSEEK_API_KEY`
- `DEEPSEEK_BASE_URL`
- `DEEPSEEK_MODEL`
DeepSeek should be treated as OpenAI-compatible routing under ZeroClaw, not via the old local `DeepSeekProvider`.
### Task 6: Introduce Memory and Cron Through the Compatibility Core
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/config_adapter.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/src/compat/memory_adapter.rs`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/tests/compat_memory_test.rs`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/tests/compat_cron_test.rs`
**Step 1: Memory**
Configure a workspace-local ZeroClaw memory backend suitable for browser embedding:
- default to SQLite
- keep storage under sgClaw-owned data path
- avoid enabling unrelated gateway/channel storage
**Step 2: Cron**
Expose ZeroClaw cron internally, but do not yet bind it to browser UI.
This phase only requires:
- creating validated agent jobs
- listing/running due jobs in tests
The future standalone gateway will surface management UI for cron.
### Task 7: Verification and Browser Integration
**Files:**
- Verify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/tests/*.rs`
- Verify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/build_sgclaw.py`
- Verify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs`
**Step 1: Run the full Rust test baseline**
Run:
```bash
python3 /home/zyl/projects/superRpa/src/tools/crates/run_cargo.py test \
--manifest-path /home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/Cargo.toml \
--tests
```
Expected: current protocol/tool/planner compatibility tests still pass or are consciously replaced with equivalent compat tests.
**Step 2: Build the browser-delivered binary from the worktree**
Run:
```bash
python3 /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/build_sgclaw.py \
--manifest-path /home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-core-refactor/Cargo.toml \
--out /home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw
```
Expected: the compatibility-shell binary is produced at the same output path as today.
**Step 3: Run browser smoke**
Run:
```bash
node /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs
```
Expected:
- browser protocol still starts and stops correctly
- Baidu task still succeeds
- Zhihu task still succeeds
- no browser-side API/bridge changes are required
### Non-Goals for This Refactor
- Do not replace the current SuperRPA browser protocol with ZeroClaw gateway protocols.
- Do not expose the upstream ZeroClaw web dashboard inside FunctionsUI.
- Do not ship the standalone gateway in this phase.
- Do not migrate browser-side code to a new transport.
### Phase 2 After This Refactor
After this compatibility refactor is stable:
- add a separate `gateway` crate or binary that uses the same vendored ZeroClaw core
- expose memory/cron/agent management there
- keep browser-side `sgclaw` as a thin local execution shell

View File

@@ -0,0 +1,551 @@
# ZeroClaw Prompt Safety Hardening Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Harden ZeroClaw prompt handling and tool execution so non-skill freeform operations degrade to read-only or business-approved execution, while trusted skill-defined operations retain bounded execution privileges.
**Architecture:** Build a security gate around the existing prompt and tool-entry paths instead of rewriting the full prompt architecture. The gate classifies prompt-injection risk, records operation provenance (`trusted_skill` vs `non_skill`), sanitizes injected workspace/skill content, and enforces execution mode transitions (`clean`, `suspect_readonly`, `suspect_waiting_approval`, `suspect_business_approved`). Trusted skills gain structured business-operation metadata; non-skill operations require business-level approval before any privileged capability is released.
**Tech Stack:** Rust, vendored ZeroClaw (`third_party/zeroclaw`), existing approval/autonomy system, current prompt guard and prompt builder tests, `cargo test`.
### Task 1: Create an Isolated Worktree and Verify a Clean Baseline
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/.gitignore`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/**`
**Step 1: Verify the worktree directory is safe to use**
Run:
```bash
cd /home/zyl/projects/sgClaw/claw
ls -d .worktrees
git check-ignore -v .worktrees
```
Expected: `.worktrees` exists and is ignored by git.
**Step 2: Create the implementation worktree**
Run:
```bash
cd /home/zyl/projects/sgClaw/claw
git worktree add .worktrees/zeroclaw-prompt-safety-hardening -b zeroclaw-prompt-safety-hardening
```
Expected: a new branch and worktree are created.
**Step 3: Build the baseline in the worktree**
Run:
```bash
cd /home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening
cargo test -p zeroclawlabs prompt_guard -- --nocapture
cargo test -p zeroclawlabs build_system_prompt -- --nocapture
```
Expected: existing relevant tests pass before any code changes.
**Step 4: Commit the clean worktree setup if `.gitignore` changed**
Run:
```bash
git add .gitignore
git commit -m "chore: prepare worktree for prompt safety hardening"
```
Expected: commit only if `.gitignore` required an adjustment.
### Task 2: Add the Core Security-Mode Data Model
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/security/operation_policy.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/security/mod.rs`
- Test: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/security/operation_policy.rs`
**Step 1: Write the failing policy tests**
Add tests that prove:
- suspicious non-skill input maps to `suspect_readonly`
- trusted skill operations can request bounded privileged execution
- any out-of-scope capability request downgrades the operation
Use concrete enums and assertions, for example:
```rust
assert_eq!(
ExecutionMode::from_guard_and_provenance(GuardRisk::Suspicious, OperationProvenance::NonSkill),
ExecutionMode::SuspectReadOnly
);
```
**Step 2: Run the tests to verify RED**
Run:
```bash
cd /home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening
cargo test -p zeroclawlabs operation_policy -- --nocapture
```
Expected: fail because the new types do not exist yet.
**Step 3: Implement the minimal policy model**
Define:
- `GuardRisk` (`Clean`, `Suspicious`, `Dangerous`)
- `OperationProvenance` (`TrustedSkill`, `NonSkill`, `Mixed`)
- `ExecutionMode` (`Clean`, `SuspectReadOnly`, `SuspectWaitingApproval`, `SuspectBusinessApproved`)
- `CapabilityClass` for privileged business actions
Add small helper functions that do only state mapping. Do not pull prompt-building logic into this module.
**Step 4: Re-run the policy tests to verify GREEN**
Run:
```bash
cargo test -p zeroclawlabs operation_policy -- --nocapture
```
Expected: the new policy tests pass.
**Step 5: Commit**
Run:
```bash
git add third_party/zeroclaw/src/security/mod.rs third_party/zeroclaw/src/security/operation_policy.rs
git commit -m "feat: add prompt security execution mode model"
```
### Task 3: Add Structured Skill Trust Metadata
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/skills/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/tools/read_skill.rs`
- Test: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/skills/mod.rs`
**Step 1: Write failing skill metadata tests**
Add tests that prove:
- `SKILL.toml` can declare a business operation type, capability list, argument constraints, and `step_budget`
- markdown-only skills default to unprivileged metadata
- malformed privileged metadata is rejected or downgraded safely
Use a manifest shape like:
```toml
[skill]
name = "export-report"
description = "Export the monthly report"
[security]
operation_type = "browser_export_data"
allowed_capabilities = ["browser_read", "browser_export"]
step_budget = 6
approval_mode = "trusted_skill"
```
**Step 2: Run the tests to verify RED**
Run:
```bash
cargo test -p zeroclawlabs skill -- --nocapture
```
Expected: fail because the structured metadata fields are missing.
**Step 3: Implement minimal structured metadata**
Extend `Skill` with a structured security block, for example:
- `operation_type`
- `business_description`
- `allowed_capabilities`
- `arg_constraints`
- `step_budget`
- `approval_mode`
Default markdown-only skills to unprivileged metadata so existing skills remain compatible.
**Step 4: Make `read_skill` expose the metadata**
Return or prepend enough structured metadata so the runtime can distinguish trusted skill operations from plain prompt text.
**Step 5: Re-run the tests to verify GREEN**
Run:
```bash
cargo test -p zeroclawlabs skill -- --nocapture
```
Expected: skill parsing and `read_skill` tests pass.
**Step 6: Commit**
Run:
```bash
git add third_party/zeroclaw/src/skills/mod.rs third_party/zeroclaw/src/tools/read_skill.rs
git commit -m "feat: add trusted skill security metadata"
```
### Task 4: Sanitize Injected Workspace and Skill Content Before Prompt Assembly
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/security/prompt_sanitizer.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/security/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/channels/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/agent/prompt.rs`
- Test: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/channels/mod.rs`
**Step 1: Write failing sanitizer tests**
Add tests that prove:
- dangerous bootstrap phrases are removed, escaped, or summarized before prompt injection
- control characters are stripped
- overlong files are truncated with an audit-friendly marker
- safe business content remains readable
**Step 2: Run the tests to verify RED**
Run:
```bash
cargo test -p zeroclawlabs build_system_prompt -- --nocapture
```
Expected: fail because injected files are still copied verbatim.
**Step 3: Implement the sanitizer**
Create a small sanitizer that:
- strips control characters
- caps content length
- flags prompt-override phrases
- emits sanitized content plus metadata such as `truncated` and matched rules
Use this sanitizer in:
- `load_openclaw_bootstrap_files`
- any shared path in `agent/prompt.rs` that renders workspace or skill text into the system prompt
**Step 4: Re-run the tests to verify GREEN**
Run:
```bash
cargo test -p zeroclawlabs build_system_prompt -- --nocapture
```
Expected: prompt-building tests pass with the new sanitized behavior.
**Step 5: Commit**
Run:
```bash
git add third_party/zeroclaw/src/security/mod.rs third_party/zeroclaw/src/security/prompt_sanitizer.rs third_party/zeroclaw/src/channels/mod.rs third_party/zeroclaw/src/agent/prompt.rs
git commit -m "feat: sanitize injected workspace prompt content"
```
### Task 5: Wire `PromptGuard` into Main Agent and Gateway Entry Points
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/security/prompt_guard.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/agent/agent.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/gateway/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/gateway/ws.rs`
- Test: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/agent/agent.rs`
**Step 1: Write failing entry-point tests**
Add tests that prove:
- suspicious input marks the turn as degraded instead of silently continuing
- dangerous input is blocked
- clean input remains unchanged
Prefer tests that assert on a security decision object instead of brittle prompt strings.
**Step 2: Run the tests to verify RED**
Run:
```bash
cargo test -p zeroclawlabs prompt_guard -- --nocapture
cargo test -p zeroclawlabs agent -- --nocapture
```
Expected: fail because no entry path consumes the guard result.
**Step 3: Implement guarded entry evaluation**
Before each turn:
- scan the inbound user content
- map the guard result into `GuardRisk`
- create an execution context carrying risk and provenance
- attach audit details for later logging
Keep the existing `PromptGuard` regexes unless a test demands a specific adjustment.
**Step 4: Re-run the tests to verify GREEN**
Run:
```bash
cargo test -p zeroclawlabs prompt_guard -- --nocapture
cargo test -p zeroclawlabs agent -- --nocapture
```
Expected: suspicious and blocked paths now behave deterministically.
**Step 5: Commit**
Run:
```bash
git add third_party/zeroclaw/src/security/prompt_guard.rs third_party/zeroclaw/src/agent/agent.rs third_party/zeroclaw/src/gateway/mod.rs third_party/zeroclaw/src/gateway/ws.rs
git commit -m "feat: enforce prompt guard at runtime entry points"
```
### Task 6: Add Business-Level Privileged Operation Registry and Approval Tokens
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/approval/mod.rs`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/security/business_approval.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/security/mod.rs`
- Test: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/security/business_approval.rs`
**Step 1: Write failing business approval tests**
Add tests that prove:
- only operations in the privileged registry can request approval
- approval tokens bind to `session_id`, `operation_type`, `allowed_capabilities`, `step_budget`, and expiration
- a mismatched or expired approval token is rejected
**Step 2: Run the tests to verify RED**
Run:
```bash
cargo test -p zeroclawlabs business_approval -- --nocapture
```
Expected: fail because the business approval registry does not exist yet.
**Step 3: Implement the registry and token model**
Create:
- a privileged business operation registry
- a single-operation approval token
- helper checks for `can_request_approval` and `matches_execution_request`
Model approval at the business-operation level, not raw tool calls.
**Step 4: Extend the existing approval module**
Teach the approval module to carry business-level fields through the current request/response flow without breaking old call sites.
**Step 5: Re-run the tests to verify GREEN**
Run:
```bash
cargo test -p zeroclawlabs business_approval -- --nocapture
```
Expected: the token validation and registry tests pass.
**Step 6: Commit**
Run:
```bash
git add third_party/zeroclaw/src/approval/mod.rs third_party/zeroclaw/src/security/mod.rs third_party/zeroclaw/src/security/business_approval.rs
git commit -m "feat: add business-level approval registry"
```
### Task 7: Enforce Execution Modes in Tool Dispatch
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/agent/dispatcher.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/agent/agent.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/agent/loop_.rs`
- Test: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/agent/dispatcher.rs`
**Step 1: Write failing dispatcher tests**
Add tests that prove:
- `suspect_readonly` allows only safe read capabilities
- `trusted_skill` can execute capabilities listed in its metadata within `step_budget`
- `mixed` or non-skill privileged calls require a matching business approval token
**Step 2: Run the tests to verify RED**
Run:
```bash
cargo test -p zeroclawlabs dispatcher -- --nocapture
```
Expected: fail because the dispatcher does not yet know about execution modes.
**Step 3: Implement capability enforcement**
Before dispatching any tool:
- resolve the operation context
- map the tool call to a capability class
- reject calls outside the current execution mode
- decrement or validate `step_budget` for approved bounded flows
Do not rely on prompt text for enforcement.
**Step 4: Re-run the tests to verify GREEN**
Run:
```bash
cargo test -p zeroclawlabs dispatcher -- --nocapture
```
Expected: dispatch now respects read-only, trusted skill, and business-approved modes.
**Step 5: Commit**
Run:
```bash
git add third_party/zeroclaw/src/agent/dispatcher.rs third_party/zeroclaw/src/agent/agent.rs third_party/zeroclaw/src/agent/loop_.rs
git commit -m "feat: enforce execution mode in tool dispatch"
```
### Task 8: Default Skills Prompt Injection to Compact for Safer Runtime Behavior
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/config/schema.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/agent/prompt.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/channels/mod.rs`
- Test: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/config/schema.rs`
**Step 1: Write the failing configuration test**
Add a test that asserts the default skill prompt injection mode is `Compact` unless explicitly configured otherwise.
**Step 2: Run the test to verify RED**
Run:
```bash
cargo test -p zeroclawlabs skills_prompt_injection_mode -- --nocapture
```
Expected: fail because defaults still point to `Full`.
**Step 3: Implement the default flip**
Update config defaults and any prompt-builder defaults that currently assume `Full`. Keep explicit user config backward compatible.
**Step 4: Re-run the test to verify GREEN**
Run:
```bash
cargo test -p zeroclawlabs skills_prompt_injection_mode -- --nocapture
```
Expected: default configuration now resolves to `Compact`.
**Step 5: Commit**
Run:
```bash
git add third_party/zeroclaw/src/config/schema.rs third_party/zeroclaw/src/agent/prompt.rs third_party/zeroclaw/src/channels/mod.rs
git commit -m "feat: default skills prompt injection to compact"
```
### Task 9: Add Audit Logging and Regression Coverage
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/observability/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/agent/agent.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/src/channels/mod.rs`
- Create: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/third_party/zeroclaw/tests/prompt_safety_regression.rs`
**Step 1: Write the failing regression tests**
Cover:
- prompt override attack from user content
- malicious `AGENTS.md` bootstrap content
- trusted skill execution within bounds
- non-skill privileged request requiring business approval
- approval token mismatch
- session history restore preserving degraded mode
**Step 2: Run the tests to verify RED**
Run:
```bash
cargo test -p zeroclawlabs --test prompt_safety_regression -- --nocapture
```
Expected: fail because the end-to-end behavior is not wired together yet.
**Step 3: Implement audit logging**
Record:
- input hash
- matched guard rules
- risk level
- provenance
- execution mode transitions
- approval decisions
Avoid logging raw sensitive content.
**Step 4: Re-run the regression tests to verify GREEN**
Run:
```bash
cargo test -p zeroclawlabs --test prompt_safety_regression -- --nocapture
```
Expected: the regression suite passes.
**Step 5: Commit**
Run:
```bash
git add third_party/zeroclaw/src/observability/mod.rs third_party/zeroclaw/src/agent/agent.rs third_party/zeroclaw/src/channels/mod.rs third_party/zeroclaw/tests/prompt_safety_regression.rs
git commit -m "test: add prompt safety regression coverage"
```
### Task 10: Final Verification and Integration Review
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/docs/L5-提示词分布与安全改造方案.md`
- Modify: `/home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening/docs/README.md`
**Step 1: Run targeted verification**
Run:
```bash
cd /home/zyl/projects/sgClaw/claw/.worktrees/zeroclaw-prompt-safety-hardening
cargo test -p zeroclawlabs prompt_guard -- --nocapture
cargo test -p zeroclawlabs build_system_prompt -- --nocapture
cargo test -p zeroclawlabs dispatcher -- --nocapture
cargo test -p zeroclawlabs --test prompt_safety_regression -- --nocapture
```
Expected: all prompt safety and dispatcher tests pass.
**Step 2: Run a broad ZeroClaw package test pass if time permits**
Run:
```bash
cargo test -p zeroclawlabs -- --nocapture
```
Expected: no regressions in the vendored package test suite, or a documented list of unrelated existing failures.
**Step 3: Update the security design docs**
Document:
- execution modes
- trusted skill metadata contract
- business approval flow
- why non-skill privileged actions are gated
**Step 4: Commit the docs**
Run:
```bash
git add docs/L5-提示词分布与安全改造方案.md docs/README.md
git commit -m "docs: record prompt safety hardening design"
```
**Step 5: Prepare merge review notes**
Write a short integration summary covering:
- changed entry points
- backward-compatibility expectations
- any skills that need metadata upgrades
- rollout recommendation for existing integrators

View File

@@ -0,0 +1,179 @@
# sgClaw Chat-First UI Refactor Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rebuild the sgClaw floating chat UI into a chat-first plugin-style product where the message timeline is primary, `执行摘要` is folded into the conversation, and `调试` opens as a full-window overlay instead of occupying persistent space.
**Architecture:** Keep `chrome://superrpa-functions/sgclaw-chat` as the first verified host because it already has Lit-based unit tests, then mirror the same information architecture and visual hierarchy into the ordinary-page injected `sgclaw_overlay.js`. Do not introduce a new backend contract; only rearrange presentation, panel semantics, and message/result composition around the existing runtime state.
**Tech Stack:** Chromium WebUI, Lit templates/components, injected Shadow DOM overlay JavaScript, existing SuperRPA bridge/runtime callbacks, mainline TS unit tests.
### Task 1: Lock The New Information Architecture In Tests
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_settings_state_mainline_unittest.ts`
**Step 1: Write the failing test**
Add assertions for these exact product rules:
- `getHtml()` must no longer emit the legacy `debug-note`.
- the main chat template must define a dedicated overlay/sheet container for `history`, `settings`, and `debug`.
- the debug panel must be described as a full-window overlay rather than a side drawer/log block.
- the result presentation must be part of the message stream, not a standalone persistent secondary panel.
**Step 2: Run test to verify it fails**
Run:
```bash
node --test /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts
```
Expected: FAIL because current template still includes `debug-note`, side-by-side panel layout, and standalone result panel semantics.
**Step 3: Write minimal implementation**
Change only template/component strings and assertions needed to express the new structure, without touching styling yet.
**Step 4: Run test to verify it passes**
Run the same command.
Expected: PASS.
### Task 2: Refactor `chrome://` sgClaw Into Chat-First Structure
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.html.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-header.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-composer.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-debug-drawer.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-history-panel.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-settings-panel.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-list.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-result.ts`
**Step 1: Keep the header narrow**
Make the header carry only:
- brand
- current page label
- compact runtime status
- actions for `新对话 / 历史 / 设置 / 调试 / 收起`
Remove the large subtitle/debug framing and the separate heavy runtime action row feel.
**Step 2: Make the message timeline primary**
Turn the main shell body into:
- a single timeline container
- optional empty-state presets
- no persistent secondary summary card
`finalResult` should render as a folded result card appended in the stream.
**Step 3: Convert secondary panels into full overlays**
Render `history`, `settings`, and `debug` inside a full-window overlay/sheet that covers the chat content area instead of sitting above or beside it.
**Step 4: Re-skin toward the approved direction**
Use:
- soft neutral surfaces
- restrained accent usage
- thinner borders
- calmer shadows
- clearer assistant/user card contrast
Avoid:
- debug-workbench feeling
- large gradient blocks
- heavy explanatory copy in the main flow
**Step 5: Run the unit tests**
Run:
```bash
node --test /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts
```
Expected: PASS.
### Task 3: Mirror The Same Structure Into Ordinary-Page Overlay
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sgclaw_overlay.js`
**Step 1: Remove the standalone result panel**
Delete the always-visible `执行摘要` block from the main window body.
**Step 2: Introduce overlay panels**
Change panel rendering so `history`, `settings`, and `debug` appear in a dedicated full-window overlay layer within the floating window instead of as sibling blocks consuming vertical space.
**Step 3: Rebuild the shell**
Match the `chrome://` layout:
- compact header
- primary message timeline
- folded result card inside conversation
- sticky composer
**Step 4: Preserve behavior**
Do not break:
- `sgclaw.newConversation`
- `sgclaw.restoreConversation`
- runtime polling
- config save/load
- unread badge behavior
**Step 5: Run a syntax sanity check**
Run:
```bash
node --check /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sgclaw_overlay.js
```
Expected: PASS.
### Task 4: Verify Browser Resource Integration
**Files:**
- No new source files; verification only
**Step 1: Run TS / mainline tests**
Run:
```bash
bash -lc "autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests"
```
Expected: build succeeds.
**Step 2: Run targeted mainline unit tests**
Run:
```bash
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests --gtest_filter='FunctionsUiMainlineTest.*sgclaw*'
```
If filter finds no test names, run the full binary and confirm it exits `0`.
**Step 3: Rebuild browser resources if needed**
Run:
```bash
bash -lc "autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome"
```
**Step 4: Manually verify product behavior**
Check:
- ordinary webpage floating window
- `chrome://superrpa-functions/sgclaw-chat`
- `调试` opens as full overlay
- `执行摘要` no longer blocks the main conversation
- `历史` and `设置` do not consume persistent layout space

View File

@@ -0,0 +1,148 @@
# SGClaw Configurable Skills Directory Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Let `sgclaw` own skill-directory resolution and allow users to set a custom skills directory in `sgclaw_config.json` without relying on SuperRPA to copy skills into the runtime workspace.
**Architecture:** Extend the existing browser JSON config parser so `sgclaw` can read an optional `skillsDir` field alongside DeepSeek settings. Keep the current embedded ZeroClaw workspace for memory/config internals, but decouple skill loading from that fixed path by resolving a configurable skills root at runtime. Preserve backward compatibility by defaulting to `<workspace_root>/.sgclaw-zeroclaw-workspace/skills` when `skillsDir` is absent or empty.
**Tech Stack:** Rust, serde JSON parsing, existing ZeroClaw compatibility runtime, cargo test
### Task 1: Capture browser config requirements
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/src/config/settings.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_config_test.rs`
**Step 1: Write the failing test**
Add tests that load `sgclaw_config.json` containing:
- no `skillsDir`
- a relative `skillsDir`
- an absolute `skillsDir`
Assert that:
- `skillsDir` missing falls back to default workspace skills path
- relative values resolve against the browser config directory
- absolute values are preserved
**Step 2: Run test to verify it fails**
Run: `cargo test compat_config -- --nocapture`
Expected: FAIL because `DeepSeekSettings` / config adapter do not expose any skills directory override yet.
**Step 3: Write minimal implementation**
Add a browser-config structure that parses `skillsDir` and expose a resolver function that returns the effective skills directory for `sgclaw`.
**Step 4: Run test to verify it passes**
Run: `cargo test compat_config -- --nocapture`
Expected: PASS for the new parsing and path-resolution cases.
### Task 2: Route compat runtime skill loading through sgclaw-owned resolution
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/config_adapter.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
**Step 1: Write the failing test**
Add a compat runtime test that creates:
- a default workspace skill package under `.sgclaw-zeroclaw-workspace/skills`
- a custom skill package under another directory configured via `skillsDir`
Assert that provider request payload contains only the configured skill name when `skillsDir` is set, and still contains workspace skill names when the override is absent.
**Step 2: Run test to verify it fails**
Run: `cargo test compat_runtime -- --nocapture`
Expected: FAIL because the runtime currently always loads skills from `config.workspace_dir`.
**Step 3: Write minimal implementation**
Keep `config.workspace_dir` for ZeroClaw internal state, but load skills from the resolved effective skills directory by calling `load_skills_from_directory` directly when a custom directory is configured.
**Step 4: Run test to verify it passes**
Run: `cargo test compat_runtime -- --nocapture`
Expected: PASS and provider request payload shows the right `Available Skills` content.
### Task 3: Document and verify backward compatibility
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/docs/README.md`
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L5-提示词分布与安全改造方案.md`
**Step 1: Write the failing check**
Record the expected runtime behavior:
- `sgclaw` owns skill lookup
- SuperRPA only passes `--config-path`
- `skillsDir` is optional
**Step 2: Run verification**
Run: `rg -n "skillsDir|sgclaw owns skill lookup|config-path" docs`
Expected: missing text before docs are updated.
**Step 3: Write minimal documentation**
Document:
- JSON field name
- relative-path resolution base
- default fallback
- operational implication for SuperRPA integration
**Step 4: Run verification**
Run: `rg -n "skillsDir|sgclaw owns skill lookup|config-path" docs`
Expected: PASS with updated docs.
### Task 4: Final verification
**Files:**
- Review only: `/home/zyl/projects/sgClaw/claw/src/config/settings.rs`
- Review only: `/home/zyl/projects/sgClaw/claw/src/compat/config_adapter.rs`
- Review only: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
- Review only: `/home/zyl/projects/sgClaw/claw/tests/compat_config_test.rs`
- Review only: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
**Step 1: Run targeted tests**
Run: `cargo test compat_config -- --nocapture`
Expected: PASS
**Step 2: Run runtime tests**
Run: `cargo test compat_runtime -- --nocapture`
Expected: PASS
**Step 3: Run skill-lib structural validation**
Run: `python3 -m unittest tests.skill_lib_validation_test -v`
Expected: PASS
**Step 4: Commit**
```bash
git add docs/plans/2026-03-27-sgclaw-configurable-skills-dir-plan.md \
src/config/settings.rs \
src/compat/config_adapter.rs \
src/compat/runtime.rs \
tests/compat_config_test.rs \
tests/compat_runtime_test.rs \
docs/README.md \
docs/L5-提示词分布与安全改造方案.md
git commit -m "feat: make sgclaw skills directory configurable"
```

View File

@@ -0,0 +1,624 @@
# sgClaw Floating Chat Frontend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the current debug-style `sgclaw-chat` UI with a complete floating-chat frontend that matches the product structure of Doubao's side panel while preserving the current SuperRPA bridge and configuration capabilities.
**Architecture:** Keep `chrome://superrpa-functions/sgclaw-chat` as the first delivery host so the new UI can be built and verified without waiting for the final page-floating container. Split the current monolithic Lit component into host adapter, state modules, typed message model, presentational components, and secondary panels so the same UI can later be mounted in a real injected floating window on normal web pages. Preserve the existing browser bridge (`sgclawConnect`, `sgclawStart`, `sgclawStop`, `sgclawSubmitTask`) and re-home logs/configuration into secondary panels instead of deleting them.
**Tech Stack:** Chromium WebUI, Lit, existing `FunctionsUI` router, SuperRPA browser bridge callbacks, current `sgclaw-config` config page logic, future floating host injection in SuperRPA.
## Product Target
The frontend target is a single-column chat product, not a multi-card debug workstation.
Final visual structure:
```text
Collapsed Fab
┌────────────┐
│ sgClaw ●2 │
└────────────┘
Expanded Chat
┌──────────────────────────────────────────┐
│ sgClaw | 当前网页example.com │
│ [新对话] [历史] [设置] [收起] │
│ 状态:待命 / 执行中 / 出错 │
├──────────────────────────────────────────┤
│ 欢迎区 / 推荐动作 │
│ [总结当前页面] [提取表格] [执行网页操作] │
├──────────────────────────────────────────┤
│ 消息流 │
│ 用户消息 │
│ 助手消息 │
│ 步骤卡 / 结果卡 / 错误卡 │
├──────────────────────────────────────────┤
│ [网页执行] [页面问答] [页面总结] │
│ [上下文开关] [调试] [更多] │
│ ┌──────────────────────────────────────┐ │
│ │ 输入任务... │ │
│ └──────────────────────────────────────┘ │
│ [发送]│
└──────────────────────────────────────────┘
```
Core UX rules:
- The primary content area is always the message stream.
- `finalResult` becomes a result card inside the message stream.
- `logs` move into a hidden debug drawer.
- `start/stop` remain available but move to the header status area.
- Configuration remains available but opens inside a settings panel first, with route-navigation fallback to `chrome://superrpa-functions/sgclaw-config`.
- The same component tree must work in `FunctionsUI` first and later inside a real injected floating host.
## Scope
### In Scope For This Frontend Plan
- Complete visual redesign of `sgclaw-chat`
- Empty state, active chat state, running state, success state, error state
- Local conversation history UI
- Embedded settings panel
- Debug drawer
- Stable typed message model
- Separation of host bridge code from UI code
- Floating launcher state model
### Explicitly Out Of Scope For First Frontend Delivery
- Real attachment upload execution
- Deep-thinking or multi-skill plugin ecosystem
- Provider/protocol redesign on the Rust side
- Full page-injected floating host implementation
- New backend APIs beyond the current bridge
## Existing Baseline To Reuse
The implementation should reuse these existing assets instead of replacing them blindly:
- Host page routing: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/functions.ts`
- Existing chat entry registration: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/functions_manifest.json`
- Current chat page bridge logic: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Current floating state prototype: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state.ts`
- Current config UI and bridge: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config.ts`
## Final File Layout
All implementation paths below are exact and rooted in `/home/zyl/projects/superRpa/src`.
### Core Chat Entry
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.html.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts`
### State Modules
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_window_state.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_conversation_state.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_history_state.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_settings_state.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts`
### Host Adapter
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_host_adapter.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_runtime_bridge.ts`
### Message Model And Rendering
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-list.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-user.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-assistant.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-step.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-result.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-error.ts`
### Shell Components
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-shell.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-header.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-composer.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-history-panel.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-settings-panel.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-debug-drawer.ts`
### Build And Host Wiring
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/BUILD.gn`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/functions.html.ts`
### Tests
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state_mainline_unittest.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_window_state_mainline_unittest.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_history_state_mainline_unittest.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_settings_state_mainline_unittest.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_runtime_bridge_mainline_unittest.ts`
## Target State Model
Use a typed model instead of the current loose shape.
```ts
interface SgClawChatWindowState {
windowOpen: boolean;
activePanel: 'chat' | 'history' | 'settings' | 'debug';
unreadCount: number;
}
interface SgClawChatConversationState {
conversationId: string;
draftInput: string;
mode: 'web-action' | 'page-qa' | 'page-summary';
contextEnabled: boolean;
messages: SgClawMessage[];
}
interface SgClawMessage {
id: string;
type: 'user_text' | 'assistant_text' | 'task_step' | 'task_result' | 'task_error' | 'system_notice';
role: 'user' | 'assistant' | 'system';
content: string;
status?: 'pending' | 'running' | 'done' | 'failed';
timestamp: number;
meta?: Record<string, unknown>;
}
```
The current `logs`, `messages`, `finalResult`, `pendingReply`, and `busy` state should be re-expressed through these typed stores instead of being owned directly by the entry component.
## Task 1: Freeze The Current Entry And Enable Real Template/CSS Modules
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.html.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts`
**Step 1: Write the failing structure test**
Add assertions that the entry no longer hardcodes the full DOM layout in `render()` and imports its shell template/style helpers.
**Step 2: Run test to verify it fails**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease sgclaw-chat_build_ts
```
Expected: fail because `sgclaw-chat.html.ts` and `sgclaw-chat.css.ts` are empty and the new test expects real exports.
**Step 3: Write the minimal implementation**
- Move root shell markup to `getHtml()`
- Move root style tokens/layout to `getCss()`
- Keep `sgclaw-chat.ts` focused on state + events
**Step 4: Run test to verify it passes**
Run the same build target.
Expected: TS build succeeds and the entry uses external template/style helpers.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.html.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts
git -C /home/zyl/projects/superRpa/src commit -m "refactor: extract sgclaw chat shell template"
```
## Task 2: Build The Window, Conversation, History, And Settings State Modules
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_window_state.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_conversation_state.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_history_state.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_settings_state.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state_mainline_unittest.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_window_state_mainline_unittest.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_history_state_mainline_unittest.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_settings_state_mainline_unittest.ts`
**Step 1: Write the failing pure-state tests**
Cover:
- open/close/switch panel transitions
- unread count clear on open
- create/reset conversation
- local history push/select/remove
- settings draft dirty detection
**Step 2: Run tests to verify RED**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease sgclaw-chat_build_ts
```
Expected: build fails because the new modules and tests do not exist yet.
**Step 3: Write the minimal implementation**
Implement pure functions only. Do not mix DOM work into these modules.
**Step 4: Run tests to verify GREEN**
Run the same build target.
Expected: all pure-state modules compile and their tests pass.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_window_state.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_conversation_state.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_history_state.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_settings_state.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state_mainline_unittest.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_window_state_mainline_unittest.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_history_state_mainline_unittest.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_settings_state_mainline_unittest.ts
git -C /home/zyl/projects/superRpa/src commit -m "feat: add sgclaw chat state modules"
```
## Task 3: Introduce A Host Adapter So UI Stops Owning Bridge Details
**Files:**
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_host_adapter.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_runtime_bridge.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_runtime_bridge_mainline_unittest.ts`
**Step 1: Write the failing bridge test**
Test that:
- `connect()` issues `sgclawConnect`
- `start()` issues `sgclawStart`
- `stop()` issues `sgclawStop`
- `submitTask()` issues `sgclawSubmitTask`
- callback payload parsing is handled in one place
**Step 2: Run test to verify RED**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease sgclaw-chat_build_ts
```
Expected: fail because adapter modules do not exist.
**Step 3: Write minimal implementation**
- Wrap `chrome.send`
- Centralize callback registration
- Return typed runtime events/state to the UI layer
**Step 4: Run test to verify GREEN**
Run the same build target.
Expected: adapter tests compile and pass.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_host_adapter.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_runtime_bridge.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_runtime_bridge_mainline_unittest.ts
git -C /home/zyl/projects/superRpa/src commit -m "refactor: isolate sgclaw chat host bridge"
```
## Task 4: Replace The Loose Message Format With Typed Cards In The Message Stream
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-list.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-user.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-assistant.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-step.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-result.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-error.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts`
**Step 1: Write the failing rendering test**
Add expectations that:
- empty state shows guidance instead of a blank box
- `task_complete` renders a result card in the message stream
- `error` renders an error card in the message stream
- `pendingReply` renders an assistant pending card
**Step 2: Run test to verify RED**
Run the TS build target.
Expected: fail because message types and card components do not exist.
**Step 3: Write minimal implementation**
- Keep the message list single-column
- Preserve current user/assistant turn behavior
- Move `finalResult` handling into result-card rendering
- Move error display into message flow
**Step 4: Run test to verify GREEN**
Run the same build target.
Expected: cards render correctly and the old standalone result area is no longer required.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-list.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-user.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-assistant.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-step.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-result.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-message-card-error.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts
git -C /home/zyl/projects/superRpa/src commit -m "feat: add sgclaw chat message cards"
```
## Task 5: Build The Real Header, Empty State, And Composer
**Files:**
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-shell.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-header.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-composer.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.html.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts`
**Step 1: Write the failing shell test**
Assert that the rendered page now contains:
- header with title, current page label, and status pill
- empty state recommendation buttons
- fixed composer at the bottom
- no standalone `实时日志` or `最终结果` primary sections
**Step 2: Run test to verify RED**
Run the TS build target.
Expected: fail because the shell components do not exist.
**Step 3: Write minimal implementation**
- Header: title, page context, new-chat/history/settings/collapse actions
- Empty state: 3 to 4 recommended actions
- Composer: text input, send button, mode toggles, context switch
**Step 4: Run test to verify GREEN**
Run the same build target.
Expected: the page renders as a product-style chat shell.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-shell.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-header.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-chat-composer.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.html.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts
git -C /home/zyl/projects/superRpa/src commit -m "feat: add sgclaw chat shell and composer"
```
## Task 6: Embed Settings And Move Raw Logs Into A Debug Drawer
**Files:**
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-settings-panel.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-debug-drawer.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts`
- Reuse Read-Only Reference: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_settings_state_mainline_unittest.ts`
**Step 1: Write the failing panel tests**
Cover:
- opening settings panel from header
- editing embedded config draft
- opening debug drawer and showing logs
- closing secondary panels without destroying the chat draft
**Step 2: Run test to verify RED**
Run the TS build target.
Expected: fail because secondary panel components do not exist.
**Step 3: Write minimal implementation**
- Reuse config field structure from `sgclaw-config`
- Keep raw logs in debug only
- Preserve route-navigation fallback for full config page if embedded save/load fails
**Step 4: Run test to verify GREEN**
Run the same build target.
Expected: settings and debug layers behave as secondary panels instead of separate pages.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-settings-panel.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-debug-drawer.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_settings_state_mainline_unittest.ts
git -C /home/zyl/projects/superRpa/src commit -m "feat: add sgclaw settings panel and debug drawer"
```
## Task 7: Add Local Conversation History And New-Chat Recovery
**Files:**
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-history-panel.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_history_state.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_history_state_mainline_unittest.ts`
**Step 1: Write the failing history tests**
Cover:
- saving a conversation preview to local history
- creating a fresh conversation resets message stream but keeps config
- reopening a history item restores messages and draft
**Step 2: Run test to verify RED**
Run the TS build target.
Expected: fail because history panel and persistence behavior do not exist.
**Step 3: Write minimal implementation**
- Store history locally in browser storage or localStorage
- Keep only small metadata + message snapshot for first version
- No backend schema change in this phase
**Step 4: Run test to verify GREEN**
Run the same build target.
Expected: local conversation switching works fully in the frontend.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/components/sgclaw-history-panel.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_history_state.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_history_state_mainline_unittest.ts
git -C /home/zyl/projects/superRpa/src commit -m "feat: add sgclaw local conversation history"
```
## Task 8: Wire New Shell Assets Into BUILD And Polish The Host Page
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/BUILD.gn`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/functions.html.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/functions.css`
**Step 1: Write the failing host expectation**
Add a small host-level check that:
- `sgclaw-chat` still loads from the manifest
- host quick actions still work
- the function page provides enough room for the new chat shell
**Step 2: Run test/build to verify RED**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease sgclaw-chat_build_ts
```
Expected: fail or render incorrectly because new component files are not all wired into build/host styling yet.
**Step 3: Write minimal implementation**
- Add all new TS modules to `BUILD.gn`
- Keep `sgclaw-chat` and `sgclaw-config` quick actions
- Adjust host layout so the new shell is not boxed into the old debug-page proportions
**Step 4: Run verification**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease sgclaw-chat_build_ts
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease superrpa_resources
```
Expected: build completes with all new chat modules wired in.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/BUILD.gn \
chrome/browser/resources/superrpa/devtools/functions/functions.html.ts \
chrome/browser/resources/superrpa/devtools/functions/functions.css
git -C /home/zyl/projects/superRpa/src commit -m "chore: wire sgclaw chat frontend modules"
```
## Manual Verification Matrix
Run all manual checks in `chrome://superrpa-functions/sgclaw-chat` after the full frontend plan lands.
### UX States
- Empty state appears on first open.
- Recommended actions generate user messages.
- Composer stays visible while history/settings/debug panels switch.
- Message stream auto-scrolls to the latest item.
- Result cards and error cards appear inline.
### Runtime
- `启动` works from the header area.
- `停止` works from the header area.
- submit creates an immediate user message.
- pending assistant card appears while waiting.
- result card replaces the old standalone result behavior.
### Settings
- embedded settings loads existing values
- save updates status and clears dirty state
- fallback route to `chrome://superrpa-functions/sgclaw-config` still works
### Debug
- logs are not visible in the main chat view
- debug drawer shows raw logs when opened
### History
- new conversation starts clean
- previous conversation can be restored from local history
- unread badge clears when reopening the window
## Execution Notes
- Keep the current backend/runtime bridge unchanged until the new frontend shell is stable.
- Do not combine page-injected floating host work into this same branch. The first milestone is a complete product-grade frontend inside the existing `FunctionsUI` host.
- When this frontend plan is complete, the next plan should focus only on mounting the same component tree inside a real page floating container.
Plan complete and saved to `docs/plans/2026-03-27-sgclaw-floating-chat-frontend-design.md`. Two execution options:
**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
**Which approach?**

View File

@@ -0,0 +1,363 @@
# sgClaw Floating Chat Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the current debug-style `sgclaw-chat` page as the primary UX with a floating page button + popup chat window, add real multi-turn conversation support, and harden the DeepSeek/browser tool protocol so browser automation is stable.
**Architecture:** Keep `chrome://superrpa-functions/sgclaw-chat` and `chrome://superrpa-functions/sgclaw-config` as debug/config pages, but make the user-facing entry a floating page launcher injected into allowed HTTP/HTTPS pages via existing SuperRPA page-injection capabilities. Reuse the browser-side persistent `SgClawSessionService` as the session owner, extend it from “logs + final result” to “conversation + runtime state”, and extend the sgClaw pipe path so each submit can carry conversation context instead of behaving like a fresh one-shot task. Fix protocol bugs in parallel: strict action-schema validation, better browser/sgClaw error attribution, and DeepSeek tool-call history compatibility.
**Tech Stack:** Chromium WebUI + Lit, existing SuperRPA page injection (`sg_compat.js` / hook injection), browser-side `FunctionsUI`/`SgClawSessionService`, Rust `sgClaw`, ZeroClaw compatibility runtime, DeepSeek OpenAI-compatible chat API.
### Task 1: Freeze Current Baseline And Add Pure UI State Tests
**Files:**
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state_mainline_unittest.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/BUILD.gn`
**Step 1: Write the failing test**
Write a pure state test that describes the floating UX:
```ts
import {
collapseFloatingWindow,
createFloatingViewState,
openFloatingWindow,
toggleSettingsPanel,
} from './sgclaw-floating_state.js';
test('opens from fab and collapses back on blur', () => {
let state = createFloatingViewState();
state = openFloatingWindow(state);
expect(state.windowOpen).toBe(true);
state = collapseFloatingWindow(state);
expect(state.windowOpen).toBe(false);
expect(state.fabVisible).toBe(true);
});
```
**Step 2: Run test to verify it fails**
Run: `autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease sgclaw-chat_build_ts`
Expected: build/test target fails because the new state module and test do not exist yet.
**Step 3: Write minimal implementation**
Create a small pure state module with:
- `fabVisible`
- `windowOpen`
- `settingsOpen`
- `statusBadge`
- `unreadCount`
Keep it logic-only; no DOM code here.
**Step 4: Run test to verify it passes**
Run the same `autoninja` target or the relevant TS unit target once wired.
Expected: the new state test compiles and passes.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-floating_state_mainline_unittest.ts \
chrome/browser/resources/superrpa/devtools/BUILD.gn
git -C /home/zyl/projects/superRpa/src commit -m "test: add sgclaw floating UI state"
```
### Task 2: Build The Floating Page Entry Using Existing SuperRPA Overlay Capabilities
**Files:**
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sgclaw_overlay.js`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sg_compat.js`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/hooks/hook_injector.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/hooks/hook_injector.h`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs`
**Step 1: Write the failing smoke expectation**
Update the browser smoke so it expects:
- a floating button exists on a normal page
- clicking it opens the sgClaw popup
- clicking outside collapses the popup back to the button
Use an assertion like:
```js
await waitFor(() => page.evaluate(() =>
!!document.querySelector('#superrpa-sgclaw-fab')));
```
**Step 2: Run smoke to verify it fails**
Run: `node /home/zyl/projects/sgClaw/claw/tools/browser_smoke/run_deepseek_browser_smoke.mjs`
Expected: smoke fails because the floating entry does not exist.
**Step 3: Write minimal implementation**
Implement the launcher inside injected page JS, not a side panel:
- floating circular button in bottom-right
- popup window anchored to the button
- button actions: open chat, stop/start runtime, open settings
- blur/outside-click collapses popup back to button
Prefer reusing the existing SuperRPA overlay/dialog/message primitives in `sg_compat.js` instead of inventing a second overlay stack.
**Step 4: Run smoke to verify it passes**
Run the same smoke command.
Expected: smoke reaches the popup, submits a task, and collapses correctly after blur.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/sgclaw_overlay.js \
chrome/browser/resources/superrpa/sg_compat.js \
chrome/browser/superrpa/hooks/hook_injector.cc \
chrome/browser/superrpa/hooks/hook_injector.h \
chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs
git -C /home/zyl/projects/superRpa/src commit -m "feat: add sgclaw floating launcher"
```
### Task 3: Upgrade Browser Session State From “Result Page” To “Real Conversation”
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.h`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui.h`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc`
**Step 1: Write the failing browser-side tests**
Add tests for:
- conversation messages are returned by `sgclawConnect`
- reopening the chat keeps prior user/assistant turns
- `sgclawSubmitTask` appends a user turn immediately and an assistant turn when complete
Example expectation:
```cc
EXPECT_EQ("user", FindStringValue(*message, "role"));
EXPECT_EQ("打开百度搜索天气", FindStringValue(*message, "content"));
```
**Step 2: Run test to verify it fails**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease \
functions_ui_mainline_unittests
./out/KylinRelease/functions_ui_mainline_unittests
```
Expected: tests fail because runtime state only has logs/final result.
**Step 3: Write minimal implementation**
Extend `SgClawSessionService` to store:
- conversation id
- ordered messages
- pending assistant reply state
- runtime status/logs
Keep the debug page and popup both consuming the same runtime shape.
**Step 4: Run test to verify it passes**
Run the same test command.
Expected: connect/reopen behavior passes and conversation persists while browser stays open.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/ui/webui/superrpa/sgclaw_session_service.h \
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc \
chrome/browser/ui/webui/superrpa/functions_ui.h \
chrome/browser/ui/webui/superrpa/functions_ui.cc \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts \
chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc
git -C /home/zyl/projects/superRpa/src commit -m "feat: persist sgclaw conversation state"
```
### Task 4: Extend sgClaw Submit Protocol For Multi-Turn Context
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol.h`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_process_host.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
- Modify: `/home/zyl/projects/sgClaw/claw/src/pipe/protocol.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_process_host_mainline_unittest.cc`
**Step 1: Write the failing protocol tests**
Add tests that `submit_task` can carry:
- current user input
- prior user/assistant turns
- active page URL / title hints if needed
For Rust, add a test that two consecutive submits produce a provider request containing prior turns.
**Step 2: Run tests to verify they fail**
Run:
```bash
python3 /home/zyl/projects/superRpa/src/tools/crates/run_cargo.py test \
--manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --test compat_runtime_test
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease \
sgclaw_process_host_mainline_unittests
./out/KylinRelease/sgclaw_process_host_mainline_unittests \
--gtest_filter='SgClawProcessHostMainlineTest.*'
```
Expected: tests fail because submit currently only sends a raw instruction string.
**Step 3: Write minimal implementation**
Change the pipe payload from one-shot instruction to:
```json
{
"type": "submit_task",
"instruction": "...",
"messages": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."}
]
}
```
On the Rust side, feed this history into the ZeroClaw turn so the next submit is a continuation, not a new session.
**Step 4: Run tests to verify they pass**
Run the same Rust + browser unit commands.
Expected: previous-turn context reaches the provider path.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/sgClaw/claw add \
src/pipe/protocol.rs src/agent/mod.rs src/compat/runtime.rs tests/compat_runtime_test.rs
git -C /home/zyl/projects/sgClaw/claw commit -m "feat: carry conversation history through sgclaw pipe"
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol.h \
chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol.cc \
chrome/browser/superrpa/sgclaw/sgclaw_process_host.cc \
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc \
chrome/browser/superrpa/sgclaw/sgclaw_process_host_mainline_unittest.cc
git -C /home/zyl/projects/superRpa/src commit -m "feat: send sgclaw conversation context"
```
### Task 5: Harden Tool Schema And DeepSeek Compatibility
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/browser_tool_adapter.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_browser_tool_test.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/tools/browser_smoke/run_deepseek_browser_smoke.mjs`
**Step 1: Write the failing tests**
Cover:
- `getText` without `selector` is rejected before it hits the browser
- `click` without `selector` is rejected
- `navigate` without `url` is rejected
- DeepSeek multi-round tool-call history does not trigger the `role=tool` 400 anymore
- non-task greeting behavior is explicit: either reject or answer in chat-only mode, but not silently pretend to be a browser task
**Step 2: Run tests to verify they fail**
Run:
```bash
python3 /home/zyl/projects/superRpa/src/tools/crates/run_cargo.py test \
--manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --lib --tests
node /home/zyl/projects/sgClaw/claw/tools/browser_smoke/run_deepseek_browser_smoke.mjs
```
Expected: current code allows incomplete tool args and still has DeepSeek history edge cases.
**Step 3: Write minimal implementation**
Implement:
- action-specific required param validation in `browser_tool_adapter.rs`
- better tool-result/history formatting if needed for DeepSeek compatibility
- explicit user-facing handling for non-browser-chat input
**Step 4: Run tests to verify they pass**
Run the same Rust tests and browser smoke.
Expected: no malformed tool actions, no DeepSeek `role=tool` 400 in smoke.
**Step 5: Commit**
```bash
git -C /home/zyl/projects/sgClaw/claw add \
src/compat/browser_tool_adapter.rs \
src/compat/runtime.rs \
tests/compat_browser_tool_test.rs \
tests/compat_runtime_test.rs \
tools/browser_smoke/run_deepseek_browser_smoke.mjs
git -C /home/zyl/projects/sgClaw/claw commit -m "fix: harden sgclaw tool protocol for DeepSeek"
```
### Task 6: Final Verification And Manual Smoke Checklist
**Files:**
- Modify if needed: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs`
- Document manual steps in PR/summary, not code
**Step 1: Run automated verification**
```bash
python3 /home/zyl/projects/superRpa/src/tools/crates/run_cargo.py test \
--manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --lib --tests
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease \
functions_ui_mainline_unittests \
sgclaw_process_host_mainline_unittests
./out/KylinRelease/functions_ui_mainline_unittests
./out/KylinRelease/sgclaw_process_host_mainline_unittests \
--gtest_filter='SgClawProcessHostMainlineTest.*'
node /home/zyl/projects/sgClaw/claw/tools/browser_smoke/run_deepseek_browser_smoke.mjs
```
Expected: all pass.
**Step 2: Manual smoke**
1. Open a normal HTTP/HTTPS page.
2. Verify the floating button appears.
3. Click to open popup.
4. Start sgClaw from popup.
5. Submit one browser task and one follow-up task.
6. Click outside popup and verify it collapses to the button.
7. Reopen popup and verify conversation history is still present.
8. Open settings from the launcher, update model/base URL, return to popup, submit again, and verify hot update.
**Step 3: Final commit if verification requires touch-ups**
Use focused commit messages only for actual fixes found during verification.

View File

@@ -0,0 +1,85 @@
# sgClaw Overlay And Basic Navigation Fixes Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make ordinary webpages render the new sgClaw floating chat design and support base navigation instructions like `打开知乎`.
**Architecture:** Keep the ordinary-page injection entrypoint unchanged, but replace its in-shadow DOM layout with the same floating-window shell used by the new debug page. On the runtime side, extend the deterministic planner with explicit homepage navigation plans for supported sites so freeform open-site commands do not fail before the compat runtime can help.
**Tech Stack:** Chromium WebUI resource pipeline, injected Shadow DOM overlay JavaScript, Rust planner tests
### Task 1: Lock the current regressions with failing tests
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/planner_test.rs`
**Step 1: Write the failing smoke expectations**
Add assertions that the ordinary webpage overlay shows the new subtitle `面向当前网页的悬浮式对话与自动执行` and no longer exposes the old card titles like `聊天记录`.
**Step 2: Run the smoke to verify it fails**
Run: `node /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs`
Expected: FAIL because ordinary webpages still render the old overlay shell.
**Step 3: Write the failing planner test**
Add a test asserting `plan_instruction("打开知乎")` returns one `Navigate` step to `https://www.zhihu.com`.
**Step 4: Run the planner test to verify it fails**
Run: `cargo test planner_supports_open_zhihu_homepage_instruction --test planner_test`
Expected: FAIL with `unsupported instruction: 打开知乎`.
### Task 2: Migrate the ordinary webpage overlay to the new shell
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sgclaw_overlay.js`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs`
**Step 1: Replace the old card layout with the new floating shell**
Keep bridge calls, ids, and polling behavior intact, but render the new header, message pane, composer, settings panel, and debug drawer structure inside the existing injected Shadow DOM.
**Step 2: Keep runtime visibility without reintroducing the old layout**
Move logs and final result into secondary panels or inline cards so the ordinary webpage still exposes execution details without the old four-card layout.
**Step 3: Run the smoke again**
Run: `node /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_chat_smoke.mjs`
Expected: PASS once rebuilt resources are being served by the browser binary.
### Task 3: Extend planner support for basic open-site commands
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/planner.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/planner_test.rs`
**Step 1: Implement the minimal homepage plans**
Support `打开知乎` and `打开百度` by returning single-step `Navigate` plans to their homepages.
**Step 2: Run planner tests**
Run: `cargo test --test planner_test`
Expected: PASS.
### Task 4: Build and verify the integrated behavior
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/AGENTS.md`
- Modify: `/home/zyl/projects/superRpa/src/docs/handoffs/2026-03-27-sgclaw-runtime-verification.md`
**Step 1: Rebuild impacted targets**
Run: `autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome/browser/resources/superrpa:resources sgclaw`
**Step 2: Re-run targeted verification**
Run the smoke and a focused `sgclaw` task submission check for `打开知乎`.
**Step 3: Document the final runtime path**
Record that ordinary webpages and `chrome://superrpa-functions/sgclaw-chat` now share the same floating shell, and that homepage navigation commands are handled by the planner.

View File

@@ -0,0 +1,158 @@
# Skill Lib Testing Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add an in-project, repeatable test harness that validates `/home/zyl/projects/sgClaw/skill_lib` against the current ZeroClaw `SKILL.md` loader and security-audit expectations.
**Architecture:** Keep the test runner inside the SGClaw repository and target the sibling `skill_lib` directory by relative path. Implement a small Python validator that mirrors the ZeroClaw markdown frontmatter parser and the relevant skill-audit checks, then cover it with a Python `unittest` suite that exercises the actual three migrated Zhihu skills.
**Tech Stack:** Python 3 standard library, `unittest`, local file-system inspection, ZeroClaw source code as behavioral reference, Markdown/YAML-like frontmatter parsing.
### Task 1: Freeze The Test Contract
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/docs/plans/2026-03-27-skill-lib-testing-plan.md`
- Reference only: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/skills/mod.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/skills/audit.rs`
- Reference only: `/home/zyl/projects/sgClaw/skill_lib/skills/*/SKILL.md`
**Step 1: Capture the loader semantics to preserve**
Document and implement tests for:
- `SKILL.md` frontmatter splitting on `---`
- supported metadata keys: `name`, `description`, `version`, `author`, `tags`
- fallback rules for name, description, and version
- prompt body must exclude the frontmatter block
**Step 2: Capture the audit semantics to preserve**
Document and implement tests for:
- skill root must contain `SKILL.md` or `SKILL.toml`
- symlinks are rejected
- shell-script files are blocked when `allow_scripts` is false
- markdown links must not escape the skill root
- high-risk command snippets inside markdown are rejected
**Step 3: Define the migrated-skill expectations**
The test suite must verify:
- exactly three skill packages exist
- the loaded names are `zhihu-hotlist`, `zhihu-navigate`, `zhihu-write`
- each package has both `references/` and `assets/`
- each description stays trigger-oriented and starts with `Use when`
### Task 2: Write The Failing Tests First
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/tests/skill_lib_validation_test.py`
**Step 1: Write a failing import-level test**
Import a not-yet-created validator module from:
- `/home/zyl/projects/sgClaw/claw/scripts/validate_skill_lib.py`
Expected initial failure:
- `ModuleNotFoundError` or `FileNotFoundError`
**Step 2: Encode the project expectations**
Add tests for:
- skill discovery count and names
- parsed metadata for each current skill
- audit cleanliness for each skill with `allow_scripts=False`
- package shape (`SKILL.md`, `references/`, `assets/`)
**Step 3: Run the tests and watch them fail**
Run:
```bash
python3 -m unittest tests.skill_lib_validation_test -v
```
Expected:
- failure because the validator module does not exist yet
### Task 3: Implement The Minimal Validator
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/scripts/validate_skill_lib.py`
**Step 1: Implement discovery helpers**
Implement:
- repo root resolution
- sibling `skill_lib` root resolution
- `skills/` directory enumeration
**Step 2: Implement the markdown loader**
Implement:
- frontmatter split
- lightweight frontmatter parsing
- description fallback extraction
- metadata normalization into a `SkillRecord`
**Step 3: Implement the relevant audit checks**
Implement:
- symlink detection
- blocked shell-script detection
- markdown link boundary checks
- high-risk snippet detection
- deterministic findings collection
**Step 4: Implement a small CLI**
Running:
```bash
python3 scripts/validate_skill_lib.py
```
Should:
- print one summary line per skill
- exit `0` when all skills pass
- exit non-zero when any skill fails
### Task 4: Run The Tests Green
**Files:**
- Test: `/home/zyl/projects/sgClaw/claw/tests/skill_lib_validation_test.py`
- Test: `/home/zyl/projects/sgClaw/claw/scripts/validate_skill_lib.py`
**Step 1: Re-run the unit tests**
Run:
```bash
python3 -m unittest tests.skill_lib_validation_test -v
```
Expected:
- all tests pass
**Step 2: Run the CLI validator**
Run:
```bash
python3 scripts/validate_skill_lib.py
```
Expected:
- all three skills print `PASS`
- process exits `0`
### Task 5: Document The Verification Entry Point
**Files:**
- Modify: `/home/zyl/projects/sgClaw/skill_lib/VERIFY.md`
**Step 1: Add the project-local validation command**
Add:
- `python3 /home/zyl/projects/sgClaw/claw/scripts/validate_skill_lib.py`
- `python3 -m unittest /home/zyl/projects/sgClaw/claw/tests/skill_lib_validation_test.py`
**Step 2: Re-run both commands after the doc update**
Expected:
- validator still exits `0`
- unit tests still pass

View File

@@ -0,0 +1,411 @@
# Skill Lib ZeroClaw Migration Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Create `/home/zyl/projects/sgClaw/skill_lib` as a dedicated skill library directory and restructure the current Zhihu browser capabilities into ZeroClaw-style skill packages.
**Architecture:** Treat `skill_lib` as a standalone skill repository, not as an embedded Rust module tree. Use the ZeroClaw/open-skills layout `skill_lib/skills/<skill-name>/SKILL.md`, keep each skill self-contained, and move long operational detail into `references/` plus any preserved source artifacts into `assets/`. Map the current four exposed Rust capabilities into three end-user skills: `zhihu-navigate`, `zhihu-write`, and `zhihu-hotlist`.
**Tech Stack:** Markdown `SKILL.md`, YAML frontmatter, directory-based ZeroClaw skill packaging, existing SGClaw Zhihu Rust/JSON source material, shell validation commands.
### Task 1: Freeze The Target Layout
**Files:**
- Create: `/home/zyl/projects/sgClaw/skill_lib/`
- Create: `/home/zyl/projects/sgClaw/skill_lib/README.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/`
- Reference only: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/skills/mod.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/skills/browser/SKILL.md`
**Step 1: Create the top-level repository skeleton**
Create:
- `/home/zyl/projects/sgClaw/skill_lib/README.md`
- `/home/zyl/projects/sgClaw/skill_lib/skills/`
The README should state:
- this directory is a dedicated ZeroClaw-style skill library
- runtime skill packages live under `skills/<name>/`
- each skill package uses `SKILL.md` plus optional `references/`, `scripts/`, and `assets/`
**Step 2: Document the package contract in the README**
Include:
- required file: `SKILL.md`
- supported frontmatter for this repo: `name`, `description`, `version`, `author`, `tags`
- design rule: `description` must be trigger-oriented and not a workflow dump
- design rule: keep `SKILL.md` concise and push long detail into `references/`
**Step 3: Run structural sanity checks**
Run:
```bash
test -d /home/zyl/projects/sgClaw/skill_lib
test -d /home/zyl/projects/sgClaw/skill_lib/skills
test -f /home/zyl/projects/sgClaw/skill_lib/README.md
```
Expected: all commands exit `0`.
### Task 2: Define The Skill Inventory And Source Mapping
**Files:**
- Create: `/home/zyl/projects/sgClaw/skill_lib/skill_inventory.md`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/skill/mod.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/skill/router.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/skill/zhihu.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/skill/zhihu_hotlist.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/skill/zhihu_hotlist_store.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/skill/zhihu_navigation.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/resources/skills/zhihu_write_flow.json`
- Reference only: `/home/zyl/projects/sgClaw/claw/resources/skills/zhihu_hotlist_flow.json`
- Reference only: `/home/zyl/projects/sgClaw/claw/resources/skills/zhihu_navigation_pages.json`
**Step 1: Write the migration inventory**
Create `/home/zyl/projects/sgClaw/skill_lib/skill_inventory.md` with a three-row mapping:
- `zhihu-navigate` ← current `zhihu_navigate`
- `zhihu-write` ← current `zhihu_write`
- `zhihu-hotlist` ← current `zhihu_hotlist_collect` + `zhihu_hotlist_report`
**Step 2: Capture the non-migrated code responsibilities**
Document explicitly that this migration does **not** port:
- Rust router dispatch
- browser pipe transport code
- snapshot persistence implementation detail
Document that the new repo is a skill library, not a Rust runtime.
**Step 3: Record source artifacts per target skill**
For each target skill, list:
- source Rust module(s)
- source JSON flow/catalog file(s)
- important risk notes discovered during analysis
**Step 4: Validate the inventory**
Run:
```bash
rg -n "zhihu-navigate|zhihu-write|zhihu-hotlist" /home/zyl/projects/sgClaw/skill_lib/skill_inventory.md
```
Expected: all three skill names appear exactly once as top-level migration targets.
### Task 3: Author The `zhihu-navigate` Skill Package
**Files:**
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-navigate/SKILL.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-navigate/references/routes-and-targets.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-navigate/references/selector-strategy.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-navigate/assets/zhihu_navigation_pages.source.json`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/skill/zhihu_navigation.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/resources/skills/zhihu_navigation_pages.json`
**Step 1: Preserve the raw source artifact**
Copy the current navigation catalog into:
- `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-navigate/assets/zhihu_navigation_pages.source.json`
This file is for traceability only, not for frontmatter or prompt injection.
**Step 2: Write the `SKILL.md`**
Use ZeroClaw-style frontmatter:
```yaml
---
name: zhihu-navigate
description: Use when the user wants to open, switch, or navigate to a Zhihu page, tab, menu, profile area, notifications area, message area, or creator area through browser actions.
version: 0.1.0
author: sgclaw
tags:
- zhihu
- browser
- navigation
---
```
The body should include:
- overview
- when to use
- workflow for route vs component vs flow navigation
- ambiguity handling rules
- output contract
- common mistakes
**Step 3: Write `routes-and-targets.md`**
Summarize:
- route/component/flow/target model
- representative target names
- known alias conflicts
- preferred disambiguation wording for future prompts
**Step 4: Write `selector-strategy.md`**
Document:
- why selectors should prefer semantic hooks over CSS hash classes
- fallback ordering
- known brittle selectors from the current source
**Step 5: Validate the package**
Run:
```bash
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-navigate/SKILL.md
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-navigate/references/routes-and-targets.md
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-navigate/references/selector-strategy.md
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-navigate/assets/zhihu_navigation_pages.source.json
```
Expected: all commands exit `0`.
### Task 4: Author The `zhihu-write` Skill Package
**Files:**
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-write/SKILL.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-write/references/editor-flow.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-write/references/publish-safety.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-write/assets/zhihu_write_flow.source.json`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/skill/zhihu.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/resources/skills/zhihu_write_flow.json`
**Step 1: Preserve the raw source artifact**
Copy:
- `/home/zyl/projects/sgClaw/claw/resources/skills/zhihu_write_flow.json`
to:
- `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-write/assets/zhihu_write_flow.source.json`
**Step 2: Write the `SKILL.md`**
The frontmatter should name a single skill:
- `name: zhihu-write`
- description focused on when article drafting or publishing is requested
The body should include:
- prerequisites before touching the editor
- workflow for draft-only vs publish
- explicit confirmation gate before publish
- required final report fields: title, mode, final URL if published, unresolved issues
**Step 3: Write `editor-flow.md`**
Document:
- entry page
- editor readiness checks
- title/body fill rules
- publish confirmation sequence
- URL capture rules
**Step 4: Write `publish-safety.md`**
Document:
- when to stop and ask for confirmation
- what to do if title verification fails
- what to do if the URL remains on edit mode
- brittle selectors that must be revalidated first
**Step 5: Validate the package**
Run:
```bash
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-write/SKILL.md
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-write/references/editor-flow.md
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-write/references/publish-safety.md
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-write/assets/zhihu_write_flow.source.json
```
Expected: all commands exit `0`.
### Task 5: Author The `zhihu-hotlist` Skill Package
**Files:**
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/references/collection-flow.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/references/report-format.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/references/data-quality.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/assets/zhihu_hotlist_flow.source.json`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/skill/zhihu_hotlist.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/skill/zhihu_hotlist_store.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/resources/skills/zhihu_hotlist_flow.json`
**Step 1: Preserve the raw source artifact**
Copy:
- `/home/zyl/projects/sgClaw/claw/resources/skills/zhihu_hotlist_flow.json`
to:
- `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/assets/zhihu_hotlist_flow.source.json`
**Step 2: Write the `SKILL.md`**
Use one skill to cover:
- hotlist collection
- comment metric collection
- snapshot-style reporting
The body should clearly separate:
- collection workflow
- report workflow
- partial-failure handling
- output contract
**Step 3: Write `collection-flow.md`**
Include:
- hotlist page detection
- hotlist HTML capture strategy
- top N extraction
- detail-page comment collection flow
- metric parsing notes
**Step 4: Write `report-format.md`**
Define:
- report header line
- per-item summary line
- field names and order
- handling when comment metrics are missing
**Step 5: Write `data-quality.md`**
Document:
- why partial success must be surfaced
- what counts as incomplete data
- known parser risks
- recommended caution language in outputs
**Step 6: Validate the package**
Run:
```bash
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/references/collection-flow.md
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/references/report-format.md
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/references/data-quality.md
test -f /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/assets/zhihu_hotlist_flow.source.json
```
Expected: all commands exit `0`.
### Task 6: Normalize Frontmatter And Trigger Quality
**Files:**
- Modify: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-navigate/SKILL.md`
- Modify: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-write/SKILL.md`
- Modify: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md`
**Step 1: Normalize frontmatter keys**
Ensure each `SKILL.md` contains exactly these frontmatter keys in this order:
- `name`
- `description`
- `version`
- `author`
- `tags`
Do not add Rust-only or unofficial parser fields as required metadata.
**Step 2: Check naming rules**
Ensure skill names are:
- lowercase
- hyphenated
- stable
Names to keep:
- `zhihu-navigate`
- `zhihu-write`
- `zhihu-hotlist`
**Step 3: Tighten descriptions**
Each description must:
- begin with `Use when`
- describe triggering conditions
- mention Zhihu/browser context
- avoid dumping full workflow detail
**Step 4: Validate frontmatter**
Run:
```bash
rg -n "^name: |^description: |^version: |^author: |^tags:" /home/zyl/projects/sgClaw/skill_lib/skills/*/SKILL.md
```
Expected: every skill emits the same five key families.
### Task 7: Add Repository-Level Verification Notes
**Files:**
- Create: `/home/zyl/projects/sgClaw/skill_lib/VERIFY.md`
- Modify: `/home/zyl/projects/sgClaw/skill_lib/README.md`
**Step 1: Create `VERIFY.md`**
Document the manual verification checklist:
- all skill packages are under `skill_lib/skills/`
- each package has `SKILL.md`
- long details live in `references/`
- preserved source JSON is in `assets/`
- no Rust source is copied into the skill repo
**Step 2: Link verification from the README**
Add a short section in `README.md` pointing to `VERIFY.md`.
**Step 3: Run repository-level checks**
Run:
```bash
find /home/zyl/projects/sgClaw/skill_lib/skills -mindepth 2 -maxdepth 2 -name SKILL.md | sort
find /home/zyl/projects/sgClaw/skill_lib/skills -type d \( -name references -o -name assets \) | sort
```
Expected:
- exactly three `SKILL.md` files
- each skill has `references/`
- each skill has `assets/`
### Task 8: Final Review Before Claiming Completion
**Files:**
- Review only: `/home/zyl/projects/sgClaw/skill_lib/`
- Review only: `/home/zyl/projects/sgClaw/claw/docs/plans/2026-03-27-skill-lib-zeroclaw-plan.md`
**Step 1: Review against ZeroClaw runtime constraints**
Check that the final library respects the currently observed runtime facts:
- directory-based skills
- `SKILL.md` supported
- simple frontmatter fields
- optional `references/`, `scripts/`, `assets/`
**Step 2: Review against authoring quality**
Check that each skill:
- is self-contained
- has a narrow trigger boundary
- avoids copying Rust internals into the prompt body
- surfaces ambiguity and failure modes
**Step 3: Produce the implementation report**
The completion report must include:
- created directories
- created skill packages
- any deliberate deviations from upstream ZeroClaw examples
- verification commands actually run
- unresolved risks
**Step 4: Stop before unrelated expansion**
Do not add:
- extra skills beyond the three mapped ones
- generic utility libraries
- unrelated automation scripts
- runtime code changes in `/home/zyl/projects/sgClaw/claw/src/skill/`

View File

@@ -0,0 +1,598 @@
# SGClaw ZeroClaw Core Realignment Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rebuild `sgclaw` as a security-hardened zeroclaw runtime where the browser is a privileged tool surface and client host, not the product's defining execution model.
**Architecture:** `zeroclaw` remains the real agent core for prompt building, skills, memory, tool routing, autonomy, and execution loops. `sgclaw` adds a security envelope around zeroclaw: hardened configuration defaults, MAC-validated pipe communication, browser-host revalidation, and constrained exposure of privileged tools. The browser stays important, but only as one protected execution surface.
**Tech Stack:** Rust, vendored `zeroclaw`, SuperRPA browser pipe protocol, Chromium WebUI/overlay, JSON config, HMAC/MAC policy rules.
## Scope Guard
- In scope:
- zeroclaw-first runtime restructuring
- security and tool policy layering
- browser pipe re-framing as privileged tool surface
- documentation correction before code changes
- browser UI wording and observability alignment
- Out of scope:
- broad UI redesign before the runtime contract is fixed
- indiscriminately enabling every zeroclaw built-in tool
- keeping parallel browser-only and zeroclaw-first runtime stacks long-term
## Task 1: Rewrite The Product Docs Before Touching Runtime Code
**Files:**
- Modify: `docs/README.md`
- Modify: `docs/L0-产品白皮书与能力全景层.md`
- Modify: `docs/L1-系统架构与安全模型层.md`
- Modify: `docs/L2-核心模块与接口契约层.md`
- Modify: `docs/L3-数据流与Skill体系层.md`
- Modify: `docs/L4-工程实现与部署拓扑层.md`
- Modify: `docs/L5-提示词分布与安全改造方案.md`
- Modify: `docs/浏览器对接标准.md`
**Intent:**
- Make the docs the single source of truth before implementation starts.
- Remove the misleading framing that `sgclaw` is fundamentally a browser execution core.
- Replace it with the correct framing: `sgclaw` is a security-hardened zeroclaw distribution with a privileged browser execution surface.
**Step 1: Add a docs checklist in the plan branch**
Checklist to apply consistently across the docs:
```text
1. zeroclaw is the runtime core
2. sgclaw adds security policy and protected execution surfaces
3. browser pipe is one privileged tool surface, not the whole runtime
4. docs must distinguish current implementation gaps from target architecture
5. no doc may imply that browser-only compat is the desired end state
```
**Step 2: Update the architecture docs**
Required wording changes:
- Replace phrases equivalent to “浏览器智能体执行内核” with wording equivalent to “安全加固后的 zeroclaw runtime”.
- Reframe `compat` as a temporary adaptation layer, not the final product identity.
- Clarify that `browser_action` is a protected tool contract, not the only capability sgClaw should ever have.
- Clarify that prompt治理、安全摘要、skills、memory、routing should stay aligned with zeroclaw-native mechanisms.
**Step 3: Run doc consistency checks**
Run:
```bash
rg -n "浏览器智能体执行内核|单一 `browser_action` 工具|兼容执行器" docs
rg -n "zeroclaw.*核心|特权工具面|安全加固" docs
```
Expected:
- The first command should only return historical or explicitly marked current-state references.
- The second command should show the new target framing in the mainline docs.
**Step 4: Commit**
```bash
git add docs/README.md docs/L0-产品白皮书与能力全景层.md docs/L1-系统架构与安全模型层.md docs/L2-核心模块与接口契约层.md docs/L3-数据流与Skill体系层.md docs/L4-工程实现与部署拓扑层.md docs/L5-提示词分布与安全改造方案.md docs/浏览器对接标准.md
git commit -m "docs: redefine sgclaw as hardened zeroclaw runtime"
```
## Task 2: Introduce A Zeroclaw-First Runtime Module
**Files:**
- Create: `src/runtime/mod.rs`
- Create: `src/runtime/profile.rs`
- Create: `src/runtime/tool_policy.rs`
- Create: `src/runtime/engine.rs`
- Modify: `src/lib.rs`
- Test: `tests/runtime_profile_test.rs`
**Intent:**
- Create a real runtime namespace that represents sgClaws zeroclaw-first architecture.
- Stop letting `compat/runtime.rs` be the place where product architecture is defined.
**Step 1: Write the failing tests**
Create `tests/runtime_profile_test.rs` with at least:
```rust
#[test]
fn browser_attached_profile_exposes_browser_surface_without_becoming_browser_only() {
let profile = RuntimeProfile::BrowserAttached;
let policy = ToolPolicy::for_profile(profile);
assert!(policy.allowed_tools.contains("browser_action"));
assert!(policy.may_use_non_browser_tools);
}
#[test]
fn general_assistant_profile_does_not_require_browser_surface() {
let profile = RuntimeProfile::GeneralAssistant;
let policy = ToolPolicy::for_profile(profile);
assert!(!policy.requires_browser_surface);
}
```
**Step 2: Run the tests to confirm failure**
Run:
```bash
cargo test --test runtime_profile_test -- --nocapture
```
Expected:
- Fail with unresolved imports or missing types for `RuntimeProfile` and `ToolPolicy`.
**Step 3: Add the minimal runtime module**
Implement the first-pass skeleton:
```rust
pub enum RuntimeProfile {
BrowserAttached,
BrowserHeavy,
GeneralAssistant,
}
pub struct ToolPolicy {
pub requires_browser_surface: bool,
pub may_use_non_browser_tools: bool,
pub allowed_tools: Vec<String>,
}
```
**Step 4: Re-run the focused tests**
Run:
```bash
cargo test --test runtime_profile_test -- --nocapture
```
Expected:
- Both new tests pass.
**Step 5: Commit**
```bash
git add src/runtime/mod.rs src/runtime/profile.rs src/runtime/tool_policy.rs src/runtime/engine.rs src/lib.rs tests/runtime_profile_test.rs
git commit -m "feat: add zeroclaw-first runtime module skeleton"
```
## Task 3: Replace DeepSeek-Only Settings With Zeroclaw-First SGClaw Settings
**Files:**
- Modify: `src/config/settings.rs`
- Modify: `src/config/mod.rs`
- Modify: `src/compat/config_adapter.rs`
- Test: `tests/compat_config_test.rs`
- Test: `tests/compat_memory_test.rs`
- Test: `tests/compat_cron_test.rs`
**Intent:**
- Stop treating browser config as only a DeepSeek shim.
- Introduce sgClaw settings that can express zeroclaw-first runtime behavior while staying backward-compatible with the existing `sgclaw_config.json`.
**Step 1: Add the failing config tests**
Add tests beside the existing ones in `tests/compat_config_test.rs`:
```rust
#[test]
fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
let settings = SgClawSettings::from_legacy_deepseek_fields(
"sk-test".into(),
"https://api.deepseek.com".into(),
"deepseek-chat".into(),
None,
).unwrap();
assert_eq!(settings.runtime_profile, RuntimeProfile::BrowserAttached);
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Compact);
}
#[test]
fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
// write config with runtimeProfile / skillsPromptMode / allowedToolProfiles
}
```
**Step 2: Run the focused tests**
Run:
```bash
cargo test --test compat_config_test -- --nocapture
```
Expected:
- Fail because `SgClawSettings`, `RuntimeProfile`, or new parsing fields do not exist yet.
**Step 3: Implement backward-compatible settings expansion**
Minimal target shape:
```rust
pub struct SgClawSettings {
pub provider_api_key: String,
pub provider_base_url: String,
pub provider_model: String,
pub skills_dir: Option<PathBuf>,
pub skills_prompt_mode: SkillsPromptMode,
pub runtime_profile: RuntimeProfile,
}
```
Compatibility rules:
- Existing `apiKey/baseUrl/model/skillsDir` continue to load.
- New fields such as `skillsPromptMode` and `runtimeProfile` are optional.
- Defaults should be hardened, not legacy-full-prompt by accident.
**Step 4: Run the config and adapter tests**
Run:
```bash
cargo test --test compat_config_test -- --nocapture
cargo test --test compat_memory_test -- --nocapture
cargo test --test compat_cron_test -- --nocapture
```
Expected:
- All pass.
**Step 5: Commit**
```bash
git add src/config/settings.rs src/config/mod.rs src/compat/config_adapter.rs tests/compat_config_test.rs tests/compat_memory_test.rs tests/compat_cron_test.rs
git commit -m "feat: add zeroclaw-first sgclaw settings model"
```
## Task 4: Rebuild The Execution Path Around The New Runtime Engine
**Files:**
- Modify: `src/compat/runtime.rs`
- Modify: `src/agent/mod.rs`
- Modify: `src/compat/event_bridge.rs`
- Test: `tests/compat_runtime_test.rs`
**Intent:**
- Make `compat/runtime.rs` a thin bridge into the new runtime engine instead of the place where the products core execution policy lives.
- Keep browser-originated tasks attached to a browser surface, but do not collapse the runtime into a browser-only tool list.
**Step 1: Add the failing runtime tests**
Extend `tests/compat_runtime_test.rs` with at least:
```rust
#[test]
fn compat_runtime_can_complete_a_text_only_turn_without_browser_tool_calls() {
// provider returns direct assistant content
// no BrowserMessage::Response is queued
// summary should still succeed
}
#[test]
fn compat_runtime_allows_read_skill_under_compact_mode_policy() {
// configure compact skills mode
// verify the runtime tool policy includes read_skill
}
```
**Step 2: Run the focused runtime test file**
Run:
```bash
cargo test --test compat_runtime_test -- --nocapture
```
Expected:
- Fail because the current runtime still hardcodes browser-only exposure or cannot express the new policy.
**Step 3: Refactor the runtime path**
Implementation target:
- `src/agent/mod.rs` decides the runtime profile and passes browser context into the runtime engine.
- `src/compat/runtime.rs` becomes a compatibility bridge, not the architecture center.
- `src/runtime/engine.rs` owns:
- zeroclaw agent creation
- tool registration
- skill loading
- runtime profile application
- browser contract prompt injection only when browser surface is actually present
**Step 4: Re-run the focused runtime tests**
Run:
```bash
cargo test --test compat_runtime_test -- --nocapture
```
Expected:
- Existing compat runtime tests still pass.
- New text-only / compact-skill tests pass.
**Step 5: Commit**
```bash
git add src/compat/runtime.rs src/agent/mod.rs src/compat/event_bridge.rs tests/compat_runtime_test.rs
git commit -m "refactor: route browser requests through zeroclaw-first runtime engine"
```
## Task 5: Treat The Browser Pipe As A Privileged Tool Surface
**Files:**
- Modify: `src/compat/browser_tool_adapter.rs`
- Modify: `src/pipe/protocol.rs`
- Modify: `src/pipe/browser_tool.rs`
- Modify: `src/security/mac_policy.rs`
- Test: `tests/compat_browser_tool_test.rs`
- Test: `tests/browser_tool_test.rs`
- Test: `tests/pipe_protocol_test.rs`
- Test: `tests/pipe_handshake_test.rs`
**Intent:**
- Keep the browser powerful, but explicitly as a protected external execution surface.
- Preserve the current HMAC/MAC/rules boundary while making it obvious in code that browser execution is not synonymous with runtime execution.
**Step 1: Add the failing tests**
Examples:
```rust
#[test]
fn browser_tool_contract_is_marked_as_privileged_surface_in_policy_metadata() {
// assert runtime metadata treats browser tool separately from generic tools
}
#[test]
fn browser_submit_task_can_carry_browser_context_without_forcing_browser_only_execution() {
// protocol/adapter level test
}
```
**Step 2: Run the safety-related tests**
Run:
```bash
cargo test --test compat_browser_tool_test -- --nocapture
cargo test --test browser_tool_test -- --nocapture
cargo test --test pipe_protocol_test -- --nocapture
cargo test --test pipe_handshake_test -- --nocapture
```
Expected:
- Fail on new privilege/metadata expectations before implementation.
**Step 3: Implement the policy split**
Implementation target:
- keep `browser_action` schema constrained
- keep `MacPolicy` as the final guard on outbound browser commands
- annotate browser surface metadata in the runtime/tool policy layer
- avoid leaking browser-only assumptions into generic runtime config
**Step 4: Re-run the focused browser safety tests**
Run the same four commands from Step 2.
Expected:
- All pass.
**Step 5: Commit**
```bash
git add src/compat/browser_tool_adapter.rs src/pipe/protocol.rs src/pipe/browser_tool.rs src/security/mac_policy.rs tests/compat_browser_tool_test.rs tests/browser_tool_test.rs tests/pipe_protocol_test.rs tests/pipe_handshake_test.rs
git commit -m "feat: model browser pipe as privileged runtime surface"
```
## Task 6: Restore Zeroclaw-Native Skills Instead Of Browser-Specific Skill Prompt Hacks
**Files:**
- Modify: `src/runtime/engine.rs`
- Modify: `src/compat/event_bridge.rs`
- Modify: `src/agent/mod.rs`
- Test: `tests/compat_runtime_test.rs`
- Test: `tests/skill_lib_validation_test.py`
**Intent:**
- Make skill loading and skill usage follow zeroclaw-native behavior first.
- Prefer compact mode plus `read_skill` when safe.
- Make skill usage observable in runtime logs so the browser UI can explain what happened.
**Step 1: Add the failing skill-observability tests**
Add tests for:
- compact mode includes `read_skill`
- runtime logs show when `read_skill` or skill-defined tools are invoked
- configured `skillsDir` still resolves both repo-root and nested `skills/` layouts
**Step 2: Run the skill-related tests**
Run:
```bash
cargo test --test compat_runtime_test -- --nocapture
python3 tests/skill_lib_validation_test.py
```
Expected:
- New observability assertions fail before implementation.
**Step 3: Implement the minimal skill-first runtime behavior**
Implementation target:
- compact skills mode becomes the sgClaw default unless explicitly overridden
- `read_skill` is allowed when the active tool policy permits it
- runtime logs include loaded skill names and actual skill/tool usage
**Step 4: Re-run the skill tests**
Run the same two commands from Step 2.
Expected:
- Both pass.
**Step 5: Commit**
```bash
git add src/runtime/engine.rs src/compat/event_bridge.rs src/agent/mod.rs tests/compat_runtime_test.rs tests/skill_lib_validation_test.py
git commit -m "feat: restore zeroclaw-native skill flow and observability"
```
## Task 7: Remove Or Quarantine The Legacy Parallel Runtime Paths
**Files:**
- Modify or delete: `src/agent/runtime.rs`
- Modify or delete: `src/agent/planner.rs`
- Modify: `tests/agent_runtime_test.rs`
- Modify: `tests/planner_test.rs`
- Modify: `docs/L2-核心模块与接口契约层.md`
- Modify: `docs/L3-数据流与Skill体系层.md`
**Intent:**
- Stop shipping multiple conceptual runtimes.
- If the light runtime/planner path still has value, mark it as legacy/dev-only.
- If it has no production value, remove it after the zeroclaw-first runtime is stable.
**Step 1: Decide the disposition**
Choose one:
```text
A. delete planner/runtime legacy path
B. keep as explicit legacy/dev-only module with zero production routing
```
Recommendation: `B` first, then `A` after one clean release cycle.
**Step 2: Add the failing cleanup tests**
Examples:
- production browser path never routes into planner fallback
- docs no longer describe planner fallback as the primary model-enabled path
**Step 3: Run the legacy-path tests**
Run:
```bash
cargo test --test agent_runtime_test -- --nocapture
cargo test --test planner_test -- --nocapture
```
Expected:
- Fail once the cleanup assertions are added.
**Step 4: Apply the cleanup**
Implementation target:
- remove production routing dependency
- rename or mark legacy modules clearly
- update docs to match the new reality
**Step 5: Re-run the legacy-path tests and commit**
Run the same two commands, then:
```bash
git add src/agent/runtime.rs src/agent/planner.rs tests/agent_runtime_test.rs tests/planner_test.rs docs/L2-核心模块与接口契约层.md docs/L3-数据流与Skill体系层.md
git commit -m "refactor: quarantine legacy browser-only runtime paths"
```
## Task 8: Align The Browser UI With The New Runtime Truth
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sgclaw_overlay.js`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.html.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts`
- Modify: `/home/zyl/projects/superRpa/src/AGENTS.md`
**Intent:**
- Make the browser UI an honest client of the runtime.
- Stop labeling all tasks as “网页执行” when the runtime may have solved them through non-browser zeroclaw capabilities.
**Step 1: Add the UI contract checklist**
Checklist:
```text
1. UI shows runtime profile or capability mode
2. UI can show whether browser tools were actually used
3. UI can show when skills were read or invoked
4. wording does not imply browser is the whole runtime
```
**Step 2: Implement the label and observability changes**
Examples:
- replace fixed “网页执行” copy with runtime-derived capability wording
- add a compact “本轮调用”/“能力来源” section
- avoid implying every successful task came from page automation
**Step 3: Verify browser resources**
Run:
```bash
node --check /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sgclaw_overlay.js
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome
```
Expected:
- All pass.
**Step 4: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add chrome/browser/resources/superrpa/sgclaw_overlay.js chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.html.ts chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts AGENTS.md
git -C /home/zyl/projects/superRpa/src commit -m "feat: align browser ui with zeroclaw-first runtime"
```
## Final Verification Matrix
Run, in order:
```bash
cargo test --test runtime_profile_test -- --nocapture
cargo test --test compat_config_test -- --nocapture
cargo test --test compat_runtime_test -- --nocapture
cargo test --test compat_browser_tool_test -- --nocapture
cargo test --test browser_tool_test -- --nocapture
cargo test --test pipe_protocol_test -- --nocapture
cargo test --test pipe_handshake_test -- --nocapture
cargo test --test agent_runtime_test -- --nocapture
cargo test --test planner_test -- --nocapture
python3 tests/skill_lib_validation_test.py
```
If local Cargo registry state is unstable, use the Chromium hermetic wrapper instead:
```bash
python3 /home/zyl/projects/superRpa/src/tools/crates/run_cargo.py test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml -- --nocapture
```
Then verify browser integration:
```bash
node --check /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sgclaw_overlay.js
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome
```
## Success Criteria
- The docs say the right thing before the code change begins.
- `sgclaw` behaves like a security-hardened zeroclaw runtime, not a browser-only compat shell.
- The browser pipe remains central for protected execution, but it is no longer mistaken for the whole product architecture.
- Skills, prompt building, memory, routing, and tool policy all flow through zeroclaw-native mechanisms first.
- The UI becomes a thin, honest client of the shared runtime.

View File

@@ -0,0 +1,482 @@
# sgClaw SuperRPA Decoupled Runtime Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Keep the SuperRPA parent-child security model, while moving high-frequency sgClaw startup, model, skill, and frontend presentation changes out of SuperRPA compile-time code and into runtime-managed configuration.
**Architecture:** SuperRPA remains the trusted host that owns process spawning, pipe security, browser/office capability gates, and frontend host contracts. sgClaw becomes the runtime-configured execution engine that reads launch/runtime policy from files, with SuperRPA preferring external launch descriptors and external frontend bundles before falling back to bundled defaults. This preserves the security boundary while removing the need to rebuild the browser for routine sgClaw iteration.
**Tech Stack:** Chromium C++ WebUI, TypeScript/Lit frontend, Rust sgClaw runtime, JSON config files, local filesystem-based runtime assets, existing pipe protocol and Zeroclaw planner-first execution path.
### Task 1: Freeze the design in docs before further code changes
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L1-系统架构与安全模型层.md`
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L2-核心模块与接口契约层.md`
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L4-工程实现与部署拓扑层.md`
- Create: `/home/zyl/projects/sgClaw/claw/docs/plans/2026-03-29-sgclaw-superrpa-runtime-config-design.md`
**Step 1: Write the design delta doc**
Document these decisions explicitly:
- SuperRPA owns host security and capability exposure only.
- sgClaw owns planner, model routing, skill orchestration, and business behavior.
- Launch behavior is described by runtime files, not hardcoded browser-side constants.
- Frontend only has display rights; planner/executor decisions stay in sgClaw/Zeroclaw.
**Step 2: Add the failing doc checklist**
Create a checklist inside the design doc with these questions and mark them initially unresolved:
- Can browser startup switch sgClaw binary without rebuilding Chromium?
- Can model/provider selection change without rebuilding Chromium?
- Can floating UI be replaced without rebuilding Chromium?
- Can acceptance flows prove planner-first behavior visually and functionally?
**Step 3: Update the core architecture docs**
Add short sections showing:
- Launch config file path and fallback rules.
- Runtime config ownership split between SuperRPA and sgClaw.
- External frontend bundle loading path and fallback to bundled assets.
**Step 4: Review docs for consistency**
Check that `L1`, `L2`, `L4`, and the new design doc all use the same terms:
- `host`
- `launch config`
- `runtime config`
- `frontend bundle`
- `planner-first`
**Step 5: Commit**
```bash
git -C /home/zyl/projects/sgClaw/claw add \
docs/L1-系统架构与安全模型层.md \
docs/L2-核心模块与接口契约层.md \
docs/L4-工程实现与部署拓扑层.md \
docs/plans/2026-03-29-sgclaw-superrpa-runtime-config-design.md
git -C /home/zyl/projects/sgClaw/claw commit -m "docs: define superrpa sgclaw runtime boundary"
```
### Task 2: Finish and lock down the current stale-backend fix
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.h`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc`
- Create: `/home/zyl/projects/sgClaw/claw/tools/browser_runtime/sgclaw_browser_entry.sh`
**Step 1: Write the failing regression test**
Add internal tests for binary resolution priority:
1. `SUPERRPA_SGCLAW_BINARY` override wins.
2. `skillsDir`-inferred source checkout wrapper wins over bundled binary.
3. Bundled `out/.../sgclaw` is only a fallback.
**Step 2: Run the failing test**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests --gtest_filter="SgClawSessionServiceInternalTest.*"
```
Expected: the new test target fails before the final test helper wiring is complete.
**Step 3: Write the minimal implementation**
Expose a testable internal resolver function that accepts:
- config path
- bundled binary path
- optional env override string
- output detail string
Keep production `Start()` calling the same shared resolver to avoid divergence.
**Step 4: Run tests to verify they pass**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests --gtest_filter="SgClawSessionServiceInternalTest.*:FunctionsUiMainlineTest.StartPublishesDetailedRulesDiagnosticsToUiLogs"
```
Expected: all targeted tests pass.
**Step 5: Run browser compile verification**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome
```
Expected: `LINK ./chrome` with exit code `0`.
**Step 6: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc \
chrome/browser/ui/webui/superrpa/sgclaw_session_service.h \
chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc
git -C /home/zyl/projects/sgClaw/claw add \
tools/browser_runtime/sgclaw_browser_entry.sh
git -C /home/zyl/projects/superRpa/src commit -m "superrpa: resolve sgclaw binary from runtime config"
```
### Task 3: Add a real launch descriptor so SuperRPA no longer hardcodes sgClaw startup policy
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_webui_config.h`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_webui_config.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_state.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_mainline_unittest.ts`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_launch_config.h`
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_launch_config.cc`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc`
**Step 1: Write the failing config tests**
Cover:
- missing launch config falls back safely
- explicit `binary`, `args`, `env`, `working_dir`, `runtime_config_path` parse correctly
- unsafe or nonexistent paths are rejected with clear UI-visible errors
**Step 2: Run the failing tests**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests --gtest_filter="*SgClaw*Config*"
```
Expected: launch-config cases fail before parser/consumer code is added.
**Step 3: Implement minimal launch config support**
Define a host-side launch descriptor with fields:
- `binary`
- `args`
- `env`
- `working_dir`
- `runtime_config_path`
- `frontend_bundle_dir`
Load it from a predictable profile-local path, with safe defaults and fallback to existing behavior.
**Step 4: Wire startup to the descriptor**
Have `SgClawSessionService::Start()` resolve:
- executable path
- process args
- working dir
- env
- runtime config path
without requiring browser recompilation for routine changes.
**Step 5: Wire config UI to persist supported fields**
Make `sgclaw-config` save and load the new fields so local users can adjust launch behavior from the UI or by editing the JSON file directly.
**Step 6: Run tests and browser compile**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests chrome
```
Expected: config tests pass and browser still links.
**Step 7: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/ui/webui/superrpa/sgclaw_launch_config.h \
chrome/browser/ui/webui/superrpa/sgclaw_launch_config.cc \
chrome/browser/ui/webui/superrpa/sgclaw_webui_config.h \
chrome/browser/ui/webui/superrpa/sgclaw_webui_config.cc \
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc \
chrome/browser/ui/webui/superrpa/functions_ui.cc \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_state.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_mainline_unittest.ts
git -C /home/zyl/projects/superRpa/src commit -m "superrpa: add runtime launch config for sgclaw"
```
### Task 4: Expand sgClaw runtime config so model/provider/skill policy live in sgClaw, not SuperRPA
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/src/config/settings.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/config/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/config_adapter.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/runtime.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/planner.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_config_test.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/runtime_profile_test.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/planner_test.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L3-数据流与Skill体系层.md`
**Step 1: Write failing Rust tests**
Cover config-driven behavior for:
- planner-first mode
- provider list / active provider
- browser backend selection
- office backend selection
- skills prompt mode
- runtime profile
**Step 2: Run the failing tests**
Run:
```bash
cargo test compat_config_test runtime_profile_test planner_test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml
```
Expected: new config fields are missing or ignored.
**Step 3: Implement minimal config schema changes**
Add fields that let sgClaw choose behavior without host rebuild:
- `planner_mode`
- `providers`
- `active_provider`
- `browser_backend`
- `office_backend`
- `skills_prompt_mode`
- `runtime_profile`
**Step 4: Keep Zeroclaw-first execution**
Ensure the planner reads config before execution and produces a visible plan event for the frontend, but the frontend still only renders what sgClaw emits.
**Step 5: Re-run Rust tests**
Run:
```bash
cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml compat_config_test runtime_profile_test planner_test runtime_task_flow_test
```
Expected: planner/config tests pass.
**Step 6: Commit**
```bash
git -C /home/zyl/projects/sgClaw/claw add \
src/config/settings.rs \
src/config/mod.rs \
src/compat/config_adapter.rs \
src/agent/runtime.rs \
src/agent/planner.rs \
tests/compat_config_test.rs \
tests/runtime_profile_test.rs \
tests/planner_test.rs \
docs/L3-数据流与Skill体系层.md
git -C /home/zyl/projects/sgClaw/claw commit -m "sgclaw: move runtime policy into config"
```
### Task 5: Decouple the floating UI so visual iteration stops depending on Chromium rebuilds
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
- Create: `/home/zyl/projects/sgClaw/claw/frontend/runtime-host/README.md`
- Create: `/home/zyl/projects/sgClaw/claw/frontend/runtime-host/manifest.example.json`
**Step 1: Write failing UI host tests**
Cover:
- external frontend bundle dir is preferred when declared in launch config
- bundled frontend assets still load when external assets are absent
- planner events are rendered as plan cards/log lines before execution
**Step 2: Run the failing frontend/browser tests**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome/test/data/webui_test_resources
```
Then run the relevant TypeScript tests already wired for the sgClaw chat surface.
**Step 3: Implement the minimal external bundle loader**
SuperRPA should:
- keep the host shell and JS bridge fixed
- optionally load external `sgclaw-chat` assets from runtime-configured directory
- fall back to bundled assets when missing
**Step 4: Surface planner output early**
Use existing runtime event flow so the frontend shows:
- plan summary
- current step
- execution logs
without moving control logic into the frontend.
**Step 5: Re-run tests**
Run the existing sgClaw chat WebUI tests and a browser smoke.
**Step 6: Commit**
```bash
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts \
chrome/browser/ui/webui/superrpa/functions_ui.cc \
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc
git -C /home/zyl/projects/sgClaw/claw add \
frontend/runtime-host/README.md \
frontend/runtime-host/manifest.example.json
git -C /home/zyl/projects/superRpa/src commit -m "superrpa: support external sgclaw frontend bundle"
```
### Task 6: Close the current remaining behavioral gaps before new feature work
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/tests/live_acceptance_score_test.py`
- Modify: `/home/zyl/projects/sgClaw/claw/tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py`
- Modify: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts`
**Step 1: Write failing acceptance assertions**
Add explicit checks for:
- no repeated assistant paragraphs
- no fake fallback data when browser path exists
- planner-first output appears before tool execution
- Zhihu hotlist extraction returns structured rows
- office export returns a real output path
**Step 2: Run the failing acceptance flow**
Run:
```bash
python3 /home/zyl/projects/sgClaw/claw/tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
```
Expected: current score exposes the remaining regressions if they still exist.
**Step 3: Fix the smallest issue set first**
Order:
1. repeated message rendering / repeated summary emission
2. planner event visibility
3. structured hotlist extraction handoff
4. office export path propagation
**Step 4: Re-run acceptance**
Run the same command until:
- `hotlist_data_correctness > 0`
- `xlsx_export_success > 0`
- repeated text is absent
**Step 5: Record fresh evidence**
Update the acceptance markdown with:
- timestamp
- score
- exact exported path
- screenshot/log snippets
**Step 6: Commit**
```bash
git -C /home/zyl/projects/sgClaw/claw add \
tests/live_acceptance_score_test.py \
tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py \
docs/acceptance/2026-03-29-zhihu-hotlist-excel.md
git -C /home/zyl/projects/superRpa/src add \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts
git -C /home/zyl/projects/sgClaw/claw commit -m "acceptance: stabilize zhihu hotlist excel flow"
```
### Task 7: Final integrated verification
**Files:**
- Verify only: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
- Verify only: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
- Verify only: `/home/zyl/projects/sgClaw/claw/tools/browser_runtime/sgclaw_browser_entry.sh`
**Step 1: Build all affected binaries**
Run:
```bash
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome functions_ui_mainline_unittests
cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml
```
Expected: both complete successfully.
**Step 2: Do the live browser smoke**
Run browser with the local profile and verify the logs include one of:
- `using SUPERRPA_SGCLAW_BINARY override: ...`
- `using source checkout sgclaw inferred from skillsDir: ...`
- `using bundled sgclaw from browser output dir: ...`
The expected dev mode result is the source checkout path, not the stale bundled fallback.
**Step 3: Run the final business acceptance**
Ask sgClaw to:
1. read Zhihu hotlist
2. export Excel
3. open the screen presentation in a new tab
Verify:
- planner appears first
- skills are actually used
- exported file path is returned
- new-tab presentation opens
**Step 4: Record the result**
Append the final evidence to:
- `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
**Step 5: Commit**
```bash
git -C /home/zyl/projects/sgClaw/claw commit -m "chore: record final sgclaw superrpa runtime verification"
```
## Remaining Items Explicitly Carried Into This Plan
- The current stale-backend risk is not considered closed until the resolver has automated regression coverage.
- The current local edit in `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc` must be either completed under Task 2 or replaced with the final tested version.
- The current wrapper script `/home/zyl/projects/sgClaw/claw/tools/browser_runtime/sgclaw_browser_entry.sh` is still untracked and must be committed as part of Task 2.
- The Zhihu hotlist to Excel acceptance still has unresolved correctness and export-path gaps and remains part of the critical path.
- The repeated assistant text regression remains part of the critical path because it corrupts operator trust during demos.
Plan complete and saved to `docs/plans/2026-03-29-sgclaw-superrpa-decoupled-runtime-plan.md`. Two execution options:
**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
**Which approach?**

View File

@@ -0,0 +1,137 @@
# sgClaw SuperRPA Runtime Config Design
**Status**: Draft frozen before implementation
**Date**: 2026-03-29
## 1. Goal
Freeze the runtime-boundary design before further implementation so Task 2+ do not drift back into browser-compiled behavior.
The design line is fixed:
- `host` keeps the security boundary.
- sgClaw keeps runtime behavior.
- `frontend bundle` keeps display rights only.
- High-frequency changes move to runtime-managed files whenever possible.
## 2. Ownership Split
### 2.1 host
SuperRPA as `host` owns only the trusted boundary:
- process spawning
- pipe lifecycle and session security
- browser / office capability exposure
- path validation for runtime-managed files
- fallback to bundled defaults when external files are missing or unsafe
`host` does not own planner policy, model routing, provider selection, skill orchestration, or business behavior.
### 2.2 sgClaw runtime
sgClaw owns runtime behavior:
- planner / executor orchestration
- provider list and active provider selection
- skill loading and prompt mode
- browser / office backend selection
- runtime profile behavior
- planner-first execution sequencing
### 2.3 frontend bundle
`frontend bundle` owns presentation only:
- render runtime state, logs, and conversation
- render planner output before execution
- collect user input and forward it through host events
`frontend bundle` must not:
- decide whether planner runs
- directly select provider/backend outside runtime contract
- bypass sgClaw / zeroclaw execution
## 3. Runtime-Managed Files
### 3.1 launch config
Owned by `host`, preferred path:
```text
<profile>/superrpa/sgclaw_launch_config.json
```
Fields:
- `binary`
- `args`
- `env`
- `working_dir`
- `runtime_config_path`
- `frontend_bundle_dir`
Fallback rules:
1. Prefer external `launch config`
2. Fall back to bundled browser defaults when file is missing or invalid
3. Fall back to profile-local `runtime config` if `runtime_config_path` is absent
4. Fall back to bundled frontend resources if `frontend_bundle_dir` is absent or invalid
### 3.2 runtime config
Owned by sgClaw, current default path:
```text
<profile>/superrpa/sgclaw_config.json
```
This file should carry runtime behavior instead of browser compile-time constants, including:
- planner mode
- providers and active provider
- browser backend
- office backend
- skills prompt mode
- runtime profile
### 3.3 frontend bundle
Owned by `host` for loading, but externally replaceable at runtime:
- prefer `frontend_bundle_dir`
- validate path and allowed loading rules
- fall back to bundled resources if invalid
## 4. Planner-First Rule
`planner-first` is a runtime contract, not a frontend trick.
The sequence must be:
1. sgClaw / zeroclaw produces a plan
2. `frontend bundle` displays the plan
3. runtime continues into execution
4. acceptance verifies both visible plan rendering and actual execution ordering
## 5. Failing Checklist
The following questions remain intentionally unresolved at design-freeze time and must be closed by implementation plus verification:
- [ ] Can browser startup switch sgClaw binary without rebuilding Chromium?
- [ ] Can model/provider selection change without rebuilding Chromium?
- [ ] Can floating UI be replaced without rebuilding Chromium?
- [ ] Can acceptance flows prove planner-first behavior visually and functionally?
## 6. Terminology Guardrail
All related docs and code reviews must use the same terms:
- `host`
- `launch config`
- `runtime config`
- `frontend bundle`
- `planner-first`
Any proposal that moves planner or executor logic back into browser-side presentation code is out of bounds for this design.

View File

@@ -0,0 +1,451 @@
# SGClaw ZeroClaw Planner-First Realignment Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Realign the browser submit path so `sgclaw` uses `zeroclaw` as the primary planner/executor, with `sgclaw` acting only as the secure SuperRPA host plus custom tool bridge.
**Architecture:** Stop treating `zeroclaw` as a thin LLM wrapper. The browser message path should enter a `zeroclaw`-native orchestration entry point first, let `zeroclaw` perform planning/tool-loop control, and expose SuperRPA-specific browser/office/screen capabilities as regular tools inside that runtime. Any deterministic fast paths for Zhihu/Office must be implemented as `zeroclaw`-aligned execution components, not as frontend-owned control flow. The frontend may display the generated plan and current stage for UX, but it must not own planning or execution decisions.
**Tech Stack:** Rust, `sgclaw` compat bridge, `third_party/zeroclaw` agent loop, SuperRPA browser pipe, local skill library, OpenXML office export, HTML screen export, cargo tests, Python live acceptance.
### Task 1: Freeze The Current Architecture Gap With Characterization Tests
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/agent/loop_.rs`
**Step 1: Write the failing test**
Add a test that submits `读取知乎热榜前10并导出 excel 文件` through `handle_browser_message_with_context(...)` and asserts the browser submit path does **not** terminate inside the current thin `Agent::turn_streamed(...)` compat bridge.
The test should check for one of these observable signals:
- a new orchestration mode log such as `zeroclaw_process_message_primary`
- absence of the old `compat_llm_primary` mode log
- absence of selector-thrashing logs like repeated `getText .HotList-item`, `[data-hot-item]`, `ol li`
**Step 2: Run test to verify it fails**
Run:
```bash
cargo test --test compat_runtime_test browser_submit_path_prefers_zeroclaw_process_message_orchestrator -- --nocapture
```
Expected: FAIL because the current implementation still enters `src/compat/runtime.rs` and drives `agent.turn_streamed(...)` directly.
**Step 3: Write the smallest additional characterization test**
Add a second failing test that proves SuperRPA-specific tools remain available after the orchestration switch:
- browser host tool
- `openxml_office`
- `screen_html_export`
This test should not require real network calls.
**Step 4: Run both failing tests**
Run:
```bash
cargo test --test compat_runtime_test -- --nocapture
```
Expected: at least the new characterization tests fail for the expected reason.
**Step 5: Commit**
```bash
git add tests/compat_runtime_test.rs
git commit -m "test: characterize browser path bypass of zeroclaw orchestrator"
```
### Task 2: Introduce A ZeroClaw-Native Browser Orchestration Entry Point
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/src/compat/orchestration.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/agent/loop_.rs:4752`
**Step 1: Write the failing unit test for the new entry point**
Add a test for a new helper in `src/compat/orchestration.rs` that:
- receives browser task context
- builds a `zeroclaw` config
- returns a browser-safe orchestration handle or result
The test should prove the new helper is chosen by `handle_browser_message_with_context(...)`.
**Step 2: Run the new test to verify it fails**
Run:
```bash
cargo test --test compat_runtime_test browser_submit_path_prefers_zeroclaw_process_message_orchestrator -- --nocapture
```
Expected: FAIL because the helper does not exist yet.
**Step 3: Implement the minimal entry point**
Create `src/compat/orchestration.rs` with one responsibility:
- bridge browser submit tasks into a `zeroclaw`-native orchestration path
Do not implement Zhihu-specific logic here. This layer must only:
- map config
- map task context/history
- inject SuperRPA tools
- call the chosen `zeroclaw` orchestration function
**Step 4: Switch `handle_browser_message_with_context(...)` to the new entry point**
Modify:
- `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
Replace the direct `compat::runtime::execute_task_with_sgclaw_settings(...)` primary path with the new orchestration bridge.
**Step 5: Run the test to verify it passes**
Run:
```bash
cargo test --test compat_runtime_test browser_submit_path_prefers_zeroclaw_process_message_orchestrator -- --nocapture
```
Expected: PASS.
**Step 6: Commit**
```bash
git add src/compat/orchestration.rs src/compat/mod.rs src/agent/mod.rs src/compat/runtime.rs tests/compat_runtime_test.rs
git commit -m "refactor: route browser submit flow through zeroclaw orchestration bridge"
```
### Task 3: Register SuperRPA Browser/Office/Screen Capabilities As Native ZeroClaw Tools
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/browser_tool_adapter.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/openxml_office_tool.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/screen_html_export_tool.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/orchestration.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_openxml_office_tool_test.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_screen_html_export_tool_test.rs`
**Step 1: Write the failing tool-registration test**
Add a test that asserts the `zeroclaw` orchestration path exposes:
- the preferred SuperRPA browser tool
- `openxml_office` when Excel export is requested
- `screen_html_export` when screen export is requested
The test must verify this through the new orchestration path, not the old compat path.
**Step 2: Run the test to verify it fails**
Run:
```bash
cargo test --test compat_runtime_test browser_orchestration_registers_superrpa_tools_natively -- --nocapture
```
Expected: FAIL until tool wiring is complete.
**Step 3: Implement minimal native tool registration**
Ensure the new orchestration bridge injects `sgclaw` tools into the `zeroclaw` runtime without changing frontend code. Keep tool naming stable:
- `superrpa_browser`
- `openxml_office`
- `screen_html_export`
**Step 4: Verify tool-level tests still pass**
Run:
```bash
cargo test --test compat_openxml_office_tool_test -- --nocapture
cargo test --test compat_screen_html_export_tool_test -- --nocapture
```
Expected: PASS.
**Step 5: Run the new orchestration registration test**
Run:
```bash
cargo test --test compat_runtime_test browser_orchestration_registers_superrpa_tools_natively -- --nocapture
```
Expected: PASS.
**Step 6: Commit**
```bash
git add src/compat/browser_tool_adapter.rs src/compat/openxml_office_tool.rs src/compat/screen_html_export_tool.rs src/runtime/engine.rs src/compat/orchestration.rs tests/compat_runtime_test.rs tests/compat_openxml_office_tool_test.rs tests/compat_screen_html_export_tool_test.rs
git commit -m "feat: expose superrpa browser and export tools through zeroclaw orchestration"
```
### Task 4: Remove Frontend-Owned Or Custom Compat Mainline Control Flow
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/skill_runner.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
- Reference only: `/home/zyl/projects/sgClaw/claw/docs/plans/2026-03-29-sgclaw-zeroclaw-planner-first-execution-plan.md`
**Step 1: Write the failing regression test**
Add a test that proves Zhihu hotlist export no longer depends on a frontend-owned mainline such as:
- `compat_skill_runner_primary`
- direct `sgclaw`-local branching before `zeroclaw`
The expected primary mode should be a `zeroclaw`-owned orchestration mode.
**Step 2: Run the regression test to verify it fails**
Run:
```bash
cargo test --test compat_runtime_test zhihu_export_does_not_use_frontend_owned_mainline -- --nocapture
```
Expected: FAIL while `src/compat/skill_runner.rs` still owns primary control flow.
**Step 3: Remove or demote the custom mainline**
Change the code so:
- `src/compat/skill_runner.rs` becomes either a helper invoked inside the `zeroclaw` tool/runtime ecosystem, or is removed if redundant
- `src/agent/mod.rs` no longer branches to a custom primary executor before `zeroclaw`
Do not leave two competing primary modes.
**Step 4: Run the regression test**
Run:
```bash
cargo test --test compat_runtime_test zhihu_export_does_not_use_frontend_owned_mainline -- --nocapture
```
Expected: PASS.
**Step 5: Run the broader compat suite**
Run:
```bash
cargo test --test compat_runtime_test -- --nocapture
```
Expected: PASS.
**Step 6: Commit**
```bash
git add src/compat/runtime.rs src/agent/mod.rs src/compat/skill_runner.rs tests/compat_runtime_test.rs
git commit -m "refactor: remove frontend-owned primary control flow from browser submit path"
```
### Task 5: Align Skills With ZeroClaw Execution Semantics Instead Of Prompt-Only Semantics
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/tools/read_skill.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/read_skill_tool_test.rs`
- Reference only: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md`
- Reference only: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/SKILL.md`
- Reference only: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist-screen/SKILL.md`
**Step 1: Write the failing skill-execution regression test**
Add a test that proves skill usage in the browser submit path is not just:
- prompt injection
- `read_skill` text stuffing
- model-led selector wandering
Instead, the test should verify the task produces:
- a plan-driven collection/execution flow
- a real `.xlsx` or `.html` artifact path
- no selector-thrashing loop
**Step 2: Run the test to verify it fails**
Run:
```bash
cargo test --test compat_runtime_test browser_skill_usage_is_execution_not_prompt_only -- --nocapture
```
Expected: FAIL until skill semantics are aligned with execution.
**Step 3: Implement the minimal alignment**
Change the orchestration so `read_skill` is a fallback for missing context, not the primary means of making high-frequency browser workflows executable.
Keep:
- skill discovery
- skill references
- artifact contract wording
Reduce:
- over-reliance on prompt stuffing
- over-reliance on model-led selector discovery for known workflows
**Step 4: Re-run the skill regression tests**
Run:
```bash
cargo test --test compat_runtime_test browser_skill_usage_is_execution_not_prompt_only -- --nocapture
cargo test --test read_skill_tool_test -- --nocapture
```
Expected: PASS.
**Step 5: Commit**
```bash
git add src/runtime/engine.rs src/compat/runtime.rs third_party/zeroclaw/src/tools/read_skill.rs tests/compat_runtime_test.rs tests/read_skill_tool_test.rs
git commit -m "refactor: align browser skill execution with zeroclaw-native workflow semantics"
```
### Task 6: Verify The Planner-First Path End-To-End
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
- Test: `/home/zyl/projects/sgClaw/claw/tests/runtime_profile_test.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_config_test.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/live_acceptance_score_test.py`
- Reference only: `/home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw`
**Step 1: Run the Rust regression suites**
Run:
```bash
cargo test --test runtime_profile_test -- --nocapture
cargo test --test compat_config_test -- --nocapture
cargo test --test compat_runtime_test -- --nocapture
cargo test --test read_skill_tool_test -- --nocapture
```
Expected: PASS.
**Step 2: Run the Python scoring test**
Run:
```bash
python3 -m unittest tests/live_acceptance_score_test.py
```
Expected: PASS.
**Step 3: Run the live Zhihu hotlist Excel acceptance**
Run:
```bash
python3 tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
```
Expected:
- total score returns to `100`
- logs show planner-first `zeroclaw` orchestration instead of selector-thrashing
- no `shell`, `web_fetch`, `web_search_tool`
- final summary includes a real `.xlsx` path
**Step 4: Update the acceptance note**
Record:
- new orchestration mode
- tool sequence
- timing notes
- any remaining selector or latency risk
**Step 5: Rebuild and sync the runtime binary used by SuperRPA**
Run:
```bash
cargo build
cp /home/zyl/projects/sgClaw/claw/target/debug/sgclaw /home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw
sha256sum /home/zyl/projects/sgClaw/claw/target/debug/sgclaw /home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw
```
Expected: the two hashes match exactly.
**Step 6: Commit**
```bash
git add docs/acceptance/2026-03-29-zhihu-hotlist-excel.md tests/runtime_profile_test.rs tests/compat_config_test.rs tests/compat_runtime_test.rs tests/live_acceptance_score_test.py
git commit -m "test: verify planner-first zeroclaw browser orchestration end to end"
```
### Task 7: Surface The Generated Plan In The Chat UI Without Giving Frontend Control
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/event_bridge.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/pipe/protocol.rs`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/` (the active sgClaw chat UI files that render task progress)
- Test: `/home/zyl/projects/sgClaw/claw/tests/pipe_protocol_test.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
**Step 1: Write the failing protocol/UI test**
Add a test that proves the backend can emit a structured planning event before tool execution starts. The event must carry:
- a short plan title
- a flat ordered step list
- current phase such as `planning`, `executing`, `completed`
The frontend test or fixture should verify the chat can render the plan summary without waiting for final completion.
**Step 2: Run test to verify it fails**
Run:
```bash
cargo test --test pipe_protocol_test -- --nocapture
cargo test --test compat_runtime_test plan_events_are_emitted_before_browser_execution -- --nocapture
```
Expected: FAIL because the protocol does not yet expose a dedicated plan-progress event.
**Step 3: Add the minimal backend event shape**
Extend the `sgclaw` pipe/event bridge so the orchestration layer can emit:
- planner summary
- execution stage transitions
Keep the event read-only from the frontends perspective. The UI may display it, but cannot edit or branch execution.
**Step 4: Render the plan in the active chat UI**
Update the SuperRPA sgClaw chat UI so it:
- prints the generated plan immediately after planning completes
- keeps the plan compact and collapsible
- highlights the current phase while waiting
Do not add frontend-owned retry logic, decision logic, or browser action generation.
**Step 5: Run verification**
Run:
```bash
cargo test --test pipe_protocol_test -- --nocapture
cargo test --test compat_runtime_test -- --nocapture
```
Expected: PASS.
**Step 6: Manual browser validation**
Submit:
```text
读取知乎热榜前10并导出 excel 文件
```
Expected:
- the chat first shows a short generated plan
- the user sees stage transitions instead of a blank wait
- execution still follows the backend-owned `zeroclaw` path
**Step 7: Commit**
```bash
git add src/compat/event_bridge.rs src/pipe/protocol.rs tests/pipe_protocol_test.rs tests/compat_runtime_test.rs
git commit -m "feat: surface backend-generated execution plans in sgclaw chat ui"
```

View File

@@ -0,0 +1,444 @@
# Zhihu Hotlist To Excel Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make sgClaw reliably read Zhihu hotlist data through a Zhihu browser skill and export the collected structured result into a local `.xlsx` file through an independent Office skill.
**Architecture:** Keep zeroclaw as the core planner, but stop it from wandering across unrelated tools once a browser-attached skill is selected. The hotlist skill must produce a strict structured artifact, and the Office skill must consume that artifact through a dedicated `openxml_office` tool that wraps the sibling `openxml_cli` project. For the first delivery, reuse `openxml_cli template render` with a bundled `.xlsx` template instead of inventing a new workbook-construction API.
**Tech Stack:** Rust, vendored zeroclaw, sgClaw browser pipe, skill packages under `/home/zyl/projects/sgClaw/skill_lib`, sibling `openxml_cli`, JSON payload handoff, `.xlsx` template render, Python/Rust regression tests, real-provider smoke verification.
## Scope Guard
- In scope:
- browser-attached skill execution discipline
- `zhihu-hotlist` structured export artifact
- new `office-export-xlsx` skill
- new `openxml_office` runtime tool
- end-to-end acceptance for "读取知乎热榜数据,并导出 excel 文件"
- Out of scope:
- generic Office authoring platform
- arbitrary shell-based export flows
- browser-side file generation as the main export path
- broad multi-site data export before Zhihu hotlist is stable
## Current Findings To Preserve
- Real-provider validation already proved that `zhihu-hotlist`, `zhihu-navigate`, and `zhihu-write` can be selected through `read_skill`.
- The current failure mode is not "skill missing" but "tool discipline collapse":
- `file_read`, `glob_search`, and `shell` are attempted after `read_skill`
- `zhihu-write` can fill title/body but still exceeds max tool iterations
- `zhihu-navigate` succeeds for some intents but still detours through non-browser tools
- The sibling Office project already exists at `/home/zyl/projects/sgClaw/openxml_cli`.
- `openxml_cli` currently exposes `capabilities`, `template inspect`, `template validate`, and `template render`; it does not yet expose a direct "create workbook from scratch" command.
## Final Acceptance Contract
Input:
```text
读取知乎热榜数据,并导出 excel 文件
```
Required behavior:
1. sgClaw selects `zhihu-hotlist`.
2. sgClaw gathers hotlist rows through the SuperRPA browser interface only.
3. sgClaw converts the result into a structured JSON export payload.
4. sgClaw selects `office-export-xlsx`.
5. sgClaw calls `openxml_office`.
6. A local `.xlsx` file is produced and its path is returned.
Required logs:
- `read_skill zhihu-hotlist`
- browser actions only: `navigate`, `getText`, optionally `click`
- `read_skill office-export-xlsx`
- `call openxml_office`
Forbidden logs during the mainline path:
- `call shell`
- `call glob_search`
- `call file_read` on skill references or skill roots
- `docker run`
Required Excel content:
- one sheet named `知乎热榜`
- columns: `rank`, `title`, `heat`
- at least 10 hotlist rows
- exported values match the collected rows
## Task 1: Lock Browser-Attached Skill Runs To The Right Tools
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/tool_policy.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
**Intent:**
- Once the task is clearly in a browser-attached Zhihu skill flow, the runtime must stop offering unrelated tools such as `shell`, `glob_search`, and arbitrary `file_read`.
**Step 1: Write the failing regression tests**
Add focused tests in `tests/compat_runtime_test.rs` for:
```rust
#[test]
fn zhihu_hotlist_skill_flow_does_not_expose_shell_or_glob_tools() {}
#[test]
fn browser_attached_export_flow_exposes_browser_and_office_tools_only() {}
```
Assertions to include:
- request tool list contains `superrpa_browser`
- request tool list contains `read_skill`
- request tool list does not contain `shell`
- request tool list does not contain `glob_search`
- request tool list does not contain generic `file_read` during the constrained browser skill phase
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --test compat_runtime_test zhihu_hotlist_skill_flow_does_not_expose_shell_or_glob_tools -- --nocapture
cargo test --test compat_runtime_test browser_attached_export_flow_exposes_browser_and_office_tools_only -- --nocapture
```
Expected:
- fail because current runtime still exposes too many tools in browser-attached mode
**Step 3: Implement minimal constrained-tool policy**
Implement a browser-skill execution mode that:
- keeps `superrpa_browser`
- keeps compatibility alias `browser_action`
- keeps `read_skill`
- optionally keeps the new `openxml_office` tool only for export tasks
- removes `shell`, `glob_search`, and free-form `file_read` from the allowed tool list for these phases
Do this in `src/runtime/engine.rs` by deriving a narrower `allowed_tools` set from:
- runtime profile
- browser surface present flag
- instruction intent
- whether export mode is active
**Step 4: Re-run the focused tests**
Run the same commands.
Expected:
- both pass
## Task 2: Convert Zhihu Hotlist Skill To Structured Output First
**Files:**
- Modify: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/skill_lib_validation_test.py`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
**Intent:**
- The hotlist skill should stop ending with prose-only summaries. Its primary output must be a stable export artifact the Office skill can consume.
**Step 1: Write the failing tests**
Add tests that enforce:
- `zhihu-hotlist` prompt body contains an explicit `Export Artifact` section
- the artifact schema includes `sheet_name`, `columns`, and `rows`
- runtime regression checks can find those fields in the skill content when `read_skill` is used
**Step 2: Run tests to verify failure**
Run:
```bash
python3 -m unittest tests.skill_lib_validation_test
cargo test --test compat_runtime_test handle_browser_message_executes_real_zhihu_hotlist_skill_flow -- --nocapture
```
Expected:
- validation fails because the artifact contract is not yet required
**Step 3: Update `zhihu-hotlist`**
Add an `Export Artifact` section that requires this shape:
```json
{
"source": "https://www.zhihu.com/hot",
"sheet_name": "知乎热榜",
"columns": ["rank", "title", "heat"],
"rows": [[1, "标题", "344万"]]
}
```
Also add hard rules:
- no extra exploratory tools after the browser data is collected
- prose summary is secondary, structured artifact is primary
**Step 4: Re-run tests**
Expected:
- validation passes
## Task 3: Create The Office Export Skill Package
**Files:**
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/SKILL.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/references/export-flow.md`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/assets/zhihu_hotlist_template.xlsx`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/skill_lib_validation_test.py`
**Intent:**
- Add a fully separate Office skill that knows nothing about browser scraping and only turns structured table data into a local Excel file.
**Step 1: Write the failing validation test**
Extend `tests/skill_lib_validation_test.py` so discovery expects:
```python
EXPECTED_SKILL_NAMES = [
"office-export-xlsx",
"zhihu-hotlist",
"zhihu-navigate",
"zhihu-write",
]
```
Also require the new skill to mention:
- `openxml_office`
- `.xlsx`
- `sheet_name`
- `columns`
- `rows`
**Step 2: Run the validation test to verify failure**
Run:
```bash
python3 -m unittest tests.skill_lib_validation_test
```
Expected:
- fail because the new skill package does not exist yet
**Step 3: Create the skill package**
`SKILL.md` must define:
- when to use: local Office export from structured rows
- required input schema
- output: exported file path
- tool rule: only call `openxml_office`, do not use browser tools
`export-flow.md` must define:
- validate payload shape
- choose output path
- invoke `openxml_office`
- return file path and row count
The first workbook template should be a fixed `zhihu_hotlist_template.xlsx` with:
- sheet `知乎热榜`
- row 1 headers already present
- table fill anchored to a stable name or placeholder expected by `openxml_cli`
**Step 4: Re-run validation**
Expected:
- new skill passes audit
## Task 4: Add The `openxml_office` Runtime Tool
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/src/compat/openxml_office_tool.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/mod.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/tool_policy.rs`
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_openxml_office_tool_test.rs`
**Intent:**
- Wrap sibling `openxml_cli` as a first-class local tool instead of leaking Office export through shell prompting.
**Step 1: Write the failing tool test**
Create `tests/compat_openxml_office_tool_test.rs` with cases for:
- capability probe
- render request assembly for xlsx export
- rejection when rows/columns are missing
- stable JSON output containing `output_path`
**Step 2: Run the test to verify failure**
Run:
```bash
cargo test --test compat_openxml_office_tool_test -- --nocapture
```
Expected:
- fail because the tool does not exist
**Step 3: Implement minimal tool**
Tool contract:
```json
{
"action": "export_hotlist_xlsx",
"template_path": ".../zhihu_hotlist_template.xlsx",
"output_path": "/tmp/zhihu_hotlist.xlsx",
"sheet_name": "知乎热榜",
"columns": ["rank", "title", "heat"],
"rows": [[1, "标题", "344万"]]
}
```
Implementation rules:
- write the payload JSON to a temp file
- invoke sibling `openxml_cli template render --request <file> --json`
- return parsed JSON result and normalized `output_path`
- no free-form shell composition from model text
**Step 4: Re-run the focused tests**
Expected:
- pass
## Task 5: Wire Export Tasks To Use Two Skills In Sequence
**Files:**
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
**Intent:**
- The single user instruction must naturally flow from hotlist capture into Office export, not end after the first skill.
**Step 1: Write the failing runtime test**
Add a focused regression test for:
```rust
#[test]
fn zhihu_hotlist_export_task_reads_hotlist_skill_then_office_skill() {}
```
Assertions:
- request stream includes `read_skill zhihu-hotlist`
- later includes `read_skill office-export-xlsx`
- office phase exposes `openxml_office`
- no `shell` is exposed in the constrained task path
**Step 2: Run the test to verify failure**
Run:
```bash
cargo test --test compat_runtime_test zhihu_hotlist_export_task_reads_hotlist_skill_then_office_skill -- --nocapture
```
Expected:
- fail because the task currently has no structured handoff to Office export
**Step 3: Implement minimal chaining support**
Do not add a hard-coded workflow engine.
Minimal implementation:
- strengthen prompt contract so export tasks require structured hotlist artifact
- include `openxml_office` in allowed tools for export intent
- keep browser-only tools for the collection phase and Office-only tool for the export phase
**Step 4: Re-run the test**
Expected:
- pass
## Task 6: Add Real Acceptance Harness And Scoring
**Files:**
- Create: `/home/zyl/projects/sgClaw/claw/tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py`
- Create: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
**Intent:**
- Make the final acceptance repeatable with the real user config and a transparent score.
**Step 1: Write the script**
The script must:
- use `/home/zyl/.config/superrpa/Default/superrpa/sgclaw_config.json`
- boot local `target/debug/sgclaw`
- send one browser `submit_task`
- respond to browser commands with controlled fixture responses
- capture:
- loaded skills
- selected skills
- forbidden tool calls
- final summary
- exported file path
**Step 2: Define score rubric**
Rubric:
- `skill selection`: 30
- `tool discipline`: 25
- `hotlist data correctness`: 20
- `xlsx export success`: 20
- `final response quality`: 5
Automatic deductions:
- `shell` called: `-15`
- `glob_search` called: `-10`
- `file_read` on skill references: `-10`
- wrong skill selected first: `-15`
- export missing output path: `-20`
**Step 3: Run acceptance**
Run:
```bash
python3 tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
```
Expected:
- prints total score and per-dimension breakdown
- stores final evidence in `docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
## Delivery Sequence
Execute in this order:
1. Task 1: constrain tools
2. Task 2: structure hotlist output
3. Task 3: add office skill package
4. Task 4: add `openxml_office`
5. Task 5: chain the two skills
6. Task 6: run acceptance and score
## Definition Of Done
- browser-attached hotlist tasks no longer wander into `shell`, `glob_search`, or ad-hoc `file_read`
- `office-export-xlsx` exists as an independent skill
- `openxml_office` exists as an explicit tool
- a single user task can collect hotlist data and export `.xlsx`
- acceptance score is at least `85/100`

View File

@@ -0,0 +1,175 @@
# Browser Script Skill Capability Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Let zeroclaw-backed sgclaw skills call deterministic browser-context scripts through existing SuperRPA page-execution capabilities.
**Architecture:** Reuse SuperRPA's existing page script execution path instead of inventing a parallel workflow executor. Extend sgclaw's browser pipe and zeroclaw skill-tool mapping so a skill can declare a browser script tool, execute a packaged script file with parameters, and receive structured JSON back in the current page context.
**Tech Stack:** Rust (`sgclaw`, vendored `zeroclaw`), Chromium/SuperRPA C++, existing sgclaw browser pipe protocol, skill package `SKILL.toml` / `scripts/`.
### Task 1: Define the browser-script contract
**Files:**
- Modify: `third_party/zeroclaw/src/skills/mod.rs`
- Modify: `third_party/zeroclaw/src/tools/mod.rs`
- Create: `third_party/zeroclaw/src/tools/browser_script_skill.rs`
- Test: `tests/compat_browser_tool_test.rs`
**Step 1: Write the failing test**
Add a sgclaw-side test proving the browser tool schema accepts a script execution action with required browser-script fields and rejects missing script payload.
**Step 2: Run test to verify it fails**
Run: `cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --test compat_browser_tool_test`
Expected: FAIL because the browser tool still exposes only `click/type/navigate/getText`.
**Step 3: Write minimal implementation**
Extend the browser tool adapter and protocol-facing action parsing to support a deterministic script execution action carrying:
- `action`
- `expected_domain`
- `script`
- optional `args`
**Step 4: Run test to verify it passes**
Run: `cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --test compat_browser_tool_test`
Expected: PASS for the new browser script action coverage.
**Step 5: Commit**
```bash
git add tests/compat_browser_tool_test.rs src/compat/browser_tool_adapter.rs src/pipe/protocol.rs third_party/zeroclaw/src/skills/mod.rs third_party/zeroclaw/src/tools/mod.rs third_party/zeroclaw/src/tools/browser_script_skill.rs
git commit -m "feat: add browser script skill tool support"
```
### Task 2: Wire browser script execution through the sgclaw host bridge
**Files:**
- Modify: `src/pipe/protocol.rs`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol.cc`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_security_gate.cc`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol_mainline_unittest.cc`
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_process_host_mainline_unittest.cc`
**Step 1: Write the failing tests**
Add host-side tests proving:
- sgclaw protocol accepts the new browser script action
- handshake advertises the action
- the security gate allows it only through the sgclaw host allowlist
**Step 2: Run tests to verify they fail**
Run the relevant SuperRPA unit tests.
Expected: FAIL because the host protocol and allowlist do not yet include browser script execution.
**Step 3: Write minimal implementation**
Reuse existing SuperRPA router support by bridging the new sgclaw action to the host's page script execution capability instead of inventing a second execution path.
**Step 4: Run tests to verify they pass**
Run the same SuperRPA unit tests.
Expected: PASS with the new action accepted end-to-end.
**Step 5: Commit**
```bash
git add src/pipe/protocol.rs /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol.cc /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_security_gate.cc /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol_mainline_unittest.cc /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_process_host_mainline_unittest.cc
git commit -m "feat: expose browser script execution to sgclaw"
```
### Task 3: Convert Zhihu hotlist collection into a script-backed skill path
**Files:**
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.toml`
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/scripts/extract_hotlist.js`
- Modify: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md`
- Modify: `tests/compat_runtime_test.rs`
**Step 1: Write the failing test**
Add runtime coverage proving the Zhihu hotlist export flow uses the skill-backed browser script tool instead of repeated `getText` probing.
**Step 2: Run test to verify it fails**
Run: `cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --test compat_runtime_test`
Expected: FAIL because the current flow still uses `read_skill` plus generic `getText`.
**Step 3: Write minimal implementation**
Package the hotlist extractor as a deterministic browser script tool returning structured rows and update the skill description to require that tool before export.
**Step 4: Run test to verify it passes**
Run: `cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --test compat_runtime_test`
Expected: PASS with the script-backed tool visible in logs and the generic repeated `getText` fallback removed from the primary path.
**Step 5: Commit**
```bash
git add /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.toml /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/scripts/extract_hotlist.js /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md tests/compat_runtime_test.rs
git commit -m "feat: make zhihu hotlist extraction script-backed"
```
### Task 4: Fix plan visibility in the sgclaw frontend
**Files:**
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts`
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
- Test: frontend or host tests covering `[sgclaw/plan]`
**Step 1: Write the failing test**
Add coverage proving `[sgclaw/plan]` logs are treated as planner logs and preserve multiline task-step rendering.
**Step 2: Run test to verify it fails**
Run the relevant frontend or browser-side tests.
Expected: FAIL because the current frontend only recognizes `[browser/plan]` or `[plan]`.
**Step 3: Write minimal implementation**
Accept `[sgclaw/plan]` as a planner log source and keep the plan text rendered as multiple visible lines.
**Step 4: Run test to verify it passes**
Run the same tests.
Expected: PASS with sgclaw planner logs rendered correctly.
**Step 5: Commit**
```bash
git add /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts
git commit -m "fix: render sgclaw planner logs in chat ui"
```
### Task 5: Full verification
**Files:**
- Verify only
**Step 1: Run sgclaw Rust tests**
Run: `cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml`
Expected: PASS.
**Step 2: Run relevant SuperRPA unit tests**
Run the updated sgclaw host and frontend tests.
Expected: PASS.
**Step 3: Run a manual browser acceptance flow**
Verify that:
- startup logs include runtime and skill versions
- `[sgclaw/plan]` is shown as a real multi-line plan
- Zhihu hotlist export invokes the script-backed skill tool
- the flow returns a real `.xlsx` path without repeated self-referential output
**Step 4: Commit verification artifacts if needed**
Keep verification commits separate from behavior changes.

View File

@@ -0,0 +1,314 @@
# sgClaw 项目现有优势与下一步计划汇报稿
## 一、项目现有优势
和传统 openclaw 类自动化项目相比,本项目的优势不只是“能做页面操作”,而是已经具备了向企业级、长期可用方向演进的基础。简单说,传统方案更像“能跑起来的自动化脚本集合”,本项目更像“带安全边界、带统一控制、带长期演进能力的智能执行底座”。
而且根据后续已经落地的提交来看,本项目的优势已经不再只是架构上的“方向正确”,而是开始体现为一批已经交付的现实能力,包括:
- 运行时策略已经从写死逻辑转向配置驱动。
- 已支持 planner-first 的先规划后执行模式。
- 已支持技能包驱动的浏览器脚本执行。
- 已支持面向具体任务的 Excel 导出和屏幕展示导出能力。
- 已具备版本级日志、技能版本日志和真实验收记录。
这意味着本项目已经从“原型底座”进一步进入“可验证、可扩展、可交付”的阶段。
### 1. 从“脚本工具”升级为“统一执行底座”
传统项目通常是一个场景一套脚本,一个系统一套规则,能解决眼前问题,但难以复用、难以管理、难以持续演进。页面一改,脚本就要改;系统一多,维护成本就会快速上升。
本项目已经把任务接入、运行时控制、浏览器执行、日志回传、结果回传放进统一框架里。这样后续不管是新增能力、扩展场景,还是替换模型、替换策略,都不需要推倒重来,而是在同一个底座上持续增强。对业务侧来说,这意味着项目价值不再停留在“做通一个流程”,而是逐步沉淀为可以支撑更多业务的公共能力。
### 2. 安全设计更完整,更适合企业内网
这是本项目最突出的优势。传统自动化方案最大的问题,是一旦拿到页面操作能力,就容易变成“脚本想点什么就点什么”,安全边界不清楚,风险很难控制。本项目当前已经不是这种模式,而是把浏览器能力放进了严格受控的执行边界里。
从汇报角度,可以把当前安全设计概括为以下几组能力:
#### 3 层安全机制
第一层是启动门禁。浏览器宿主和运行时必须先完成握手,版本不一致、超时、顺序错误,系统都不会进入正式运行状态。
第二层是运行时策略校验。每次真正要执行页面动作前,都会先检查动作是否合法、目标页面是否在允许范围内。
第三层是宿主二次校验。就算运行时已经发出了命令,浏览器宿主仍然会再做一次本地校验,防止异常命令真正落地。
这 3 层叠加起来,形成了“不是模型想做什么就做什么,而是每一步都要过关”的安全控制方式。
#### 6 项协议硬约束
当前协议层已经明确了 6 项刚性要求:
1. 传输格式固定,不能随意乱发消息。
2. 编码方式固定,避免解析异常。
3. 单条消息大小有限制,防止异常数据冲击系统。
4. 序列号必须严格递增,不能重复、不能乱序。
5. 每条关键命令都必须带安全字段。
6. 一次请求只能对应一次响应,不能混乱回包。
这说明系统不是“发个命令过去试试看”,而是每条消息都有严格规则,更适合企业环境中的稳定运行。
#### 2 类白名单
本项目当前至少有两类核心白名单同时生效。
第一类是域名白名单。只有允许的业务域名才可以被操作,不是浏览器里所有页面都能碰。
第二类是动作白名单。只有允许的动作类型才能执行,不是脚本写得出来就一定能跑。
白名单机制的意义在于,系统把“哪些页面能动、哪些动作能做”提前规定清楚,而不是把决定权完全交给模型或脚本。
#### 1 类显式黑名单
除了白名单,本项目还明确保留了显式阻断项。也就是说,不只是“没允许的不行”,而是“高风险动作被直接明确禁止”。
这在企业场景下非常重要,因为有些能力不是“暂时不用”,而是“原则上就不能开放”。有了黑名单,系统在设计上就能提前规避高风险能力外溢。
#### 5 个默认允许动作
当前默认真正开放给运行时执行的动作共有 5 个:
1. 点击
2. 输入
3. 页面跳转
4. 文本读取
5. 受控脚本执行
这里最重要的一点不是“多了一个动作”,而是这个新增能力并没有破坏安全边界。它不是把任意页面脚本能力全部放开,而是在现有受控协议和校验链路内,给技能包提供了一种更强但仍然可控的执行方式。
这看起来不如一些传统方案“动作数量多”,但它的价值恰恰在于边界非常清楚。先把最稳定、最可控、最容易审计的核心动作做好,再逐步扩展,比一开始把大量高风险动作全部开放更稳。
#### 7 个默认允许域名
当前规则里默认允许的域名是有限集合而不是浏览器里的所有网页都能碰。这样做非常符合企业内网环境的实际需求。对于办公系统、ERP、OA 等场景,大家真正需要的不是“全网自动化”,而是“在明确范围内可控地自动化”。
#### 1 套 HMAC 签名机制
所有关键命令不是明文裸发,而是带签名校验。可以简单理解成,每条关键操作都会带“防伪标记”。
这样做的价值是,命令在链路中不容易被伪造、篡改或错误复用,整体安全性远高于普通脚本直接调用页面接口的模式。
#### 1 套序列号机制
每条命令都有严格递增的序列号,而且一个序列号只能对应一个结果。
这让系统能够清楚知道“这条结果到底对应哪一次操作”,避免串包、乱序、错配等问题,提升稳定性和可追溯性。
#### 3 重脚本执行约束
后续提交里新增了技能包驱动的浏览器脚本能力,但这部分并不是“把页面执行彻底放开”,而是在现有安全边界内增加了一层受控能力。
可以把它理解为 3 重约束:
1. 脚本必须来自技能包内的受管路径,不能越界读取技能目录之外的文件。
2. 执行时必须声明目标域名,不能脱离页面上下文随意运行。
3. 脚本仍然通过现有浏览器 pipe 和动作白名单执行,而不是绕过宿主直接落地。
这类设计很关键,因为它说明项目在增强能力的同时,仍然坚持“新增能力必须留在安全边界里”,而不是为了方便把安全口子越开越大。
#### 5 类错误处理策略
系统不是失败了就“直接崩”,而是把错误分成不同类型处理。
- 有的错误不允许重试,直接失败。
- 有的错误可以限次重试。
- 有的错误需要等待配置或人工确认。
- 有的错误会触发熔断。
- 所有失败都要求结构化返回,便于定位问题。
这比传统脚本“报错了就人工重跑”的方式要成熟得多。
#### 1 个熔断阈值
同一动作如果连续失败超过阈值,系统会主动停止继续尝试并通知界面,而不是无限重复。
这能有效避免错误状态下反复点击、反复提交、反复操作,减少业务风险和误操作成本。
#### 7 项联调验收标准
当前项目已经把联调成功的标准写清楚了,包括:
1. 握手成功率要求
2. 版本不匹配的失败处理
3. 序列号异常场景处理
4. 超大消息拦截
5. 核心动作成功率要求
6. 结构化错误返回要求
7. 日志全链路贯通能力
这说明项目不是“靠经验凑合能跑”,而是已经开始形成可以复制、可以验收、可以交付的工程标准。
### 3. 浏览器只是执行面,不再定义整个系统
传统 openclaw 类项目常见问题是浏览器能力太强,最后整个系统都围着页面脚本转,浏览器脚本几乎变成了系统本体。
本项目已经明确把浏览器定义为“受保护的特权执行面”,而不是整个 runtime 本体。这意味着以后就算扩展到别的工具面、别的执行面,也不需要推翻现有架构,系统的演进空间更大,整体结构也更清楚。
### 4. 运行时能力已经从“写死逻辑”升级为“配置驱动”
这一点是后续提交中非常重要的进展。传统项目经常把模型、策略、模式、环境差异写死在代码里,导致后续一改就牵动整体。
本项目现在已经把一批关键决策收进运行时配置,包括:
- 使用哪个模型提供方
- 当前激活哪个 provider
- 使用什么 planner 模式
- 采用哪种 runtime profile
- 浏览器能力走哪种 backend
- Office 导出走哪种 backend
- skills 从哪个目录加载
从汇报口径上,可以把它概括为:
1 套统一 runtime config
3 种 runtime profile
多项可切换运行策略
这意味着系统不再只是“代码怎么写就怎么跑”,而是开始进入“按环境、按任务、按场景灵活切换”的阶段,更适合企业实际落地。
### 5. 已形成“先规划、再执行、再产出结果”的闭环能力
传统自动化项目往往是一上来就直接操作页面,缺少中间过程的可解释性,也不利于后续审计和治理。
本项目后续提交已经进一步加强了 planner-first 模式,也就是在真正执行之前,先给出计划,再按计划执行,再输出结果。对业务和管理层来说,这样的价值非常直接:
- 更容易理解系统准备做什么
- 更容易检查执行过程是否偏离目标
- 更容易把计划、执行、结果串成闭环
同时,本项目已经不是只有一个简单浏览器工具,而是开始形成更清晰的能力分工,例如:
- `superrpa_browser` 负责受控浏览器操作
- `openxml_office` 负责结果导出
- `screen_html_export` 负责展示类产物导出
这说明项目正在从“一个浏览器操作入口”走向“围绕业务结果组织工具链”的阶段。
### 6. 技能体系已经开始从“提示词描述”走向“可执行能力包”
这是本项目相对传统 openclaw 类项目非常重要的一个现实优势。很多传统项目里的“技能”更多只是提示词模板,真正落地时还是回到页面脚本堆叠。
本项目后续提交已经支持技能包驱动的浏览器脚本执行。简单理解,就是一个技能不再只是“告诉模型怎么做”,而是可以带着确定的脚本能力一起交付。这样做有几个明显好处:
1. 能力更稳定
关键步骤不必完全依赖模型自由发挥,而是可以由打包好的脚本完成。
2. 可复用性更强
同一个技能包可以在相似场景中重复使用,不必每次都重新组织页面操作。
3. 更适合沉淀企业资产
后续很多高价值流程,都可以逐步从“提示词经验”沉淀成“可复用技能包”。
这意味着项目已经开始从“智能执行框架”走向“智能执行框架 + 可复用技能资产”的模式。
### 7. 前端只负责展示,不掌握执行权
传统项目里,前端、脚本、执行逻辑经常混在一起,最后变成“界面里藏了很多业务决策”。这种方式短期看开发快,长期看风险大、维护成本高。
本项目已经把前端限制为展示层,只负责展示状态、日志、计划和结果,不负责决定是否执行、如何切换模型、如何绕过安全边界。这样一来,系统结构更清楚,后续维护和升级时也更不容易失控。
此外,后续提交已经支持外部 frontend bundle 优先、内置资源兜底的装载方式。这意味着后续改界面、改展示逻辑,不必每次都重编浏览器宿主,研发效率和交付效率都会更高。
### 8. 配置能力更强,更适合业务落地
传统项目往往把很多关键逻辑写死在脚本里,修改一次就要重新改代码。这样不仅效率低,而且很容易因为局部修改牵动整体。
本项目已经开始把运行时配置、模型配置、策略配置从代码里抽出来,让宿主、运行时、前端之间的责任更清楚。这意味着未来业务调整、模型切换、策略升级都可以更平滑,而不是每次都进行大规模改造。
同时,后续提交还进一步加强了 source checkout 启动包装和 rules 同步能力,这对开发团队来说很重要。它意味着项目不仅适合做成二进制交付,也更适合在源码态持续联调和快速迭代。
### 9. 更适合做长期资产沉淀
传统自动化方案常见的问题,是做完一个流程后,价值基本也就结束了,经验很难积累成资产。
本项目不一样,它的方向是把执行能力、规则、安全边界、日志能力以及后续的元素识别能力,逐步沉淀成可复用资产。对企业来说,这种价值远高于“今天跑通一个流程”,因为它决定了未来是不是能够越做越快、越做越稳、越做越便宜。
现在这件事已经开始有现实支撑了。因为项目不只是在“能操作页面”,还已经能把技能、脚本、导出流程、运行时策略和日志标准逐步固化下来。后续再推进“混合自愈选择器”和元素指纹库时,这些都会自然成为资产沉淀的基础层。
### 10. 可观测性更强,已经开始具备运行级审计基础
传统项目常常只在失败时打印一段日志,出了问题很难知道系统到底做了什么。
本项目后续提交已经补上了一批很关键的运行级日志信息,包括:
- runtime 版本
- 协议版本
- 配置来源
- skills 目录解析结果
- runtime profile
- skills prompt mode
- 已加载技能及版本号
- 当前执行模式
这类能力的价值非常直接:它让系统开始具备“说清楚自己是怎么运行的”的能力。对研发、测试、验收和后续审计来说,这都是非常重要的基础。
### 11. 已经形成“真实验收”而不是“概念演示”
后续提交里,项目已经留下了更完整的验收记录,而不是停留在文档层面的能力描述。以知乎热榜 Excel 导出为例,当前已经形成真实验收结果,包括:
- 真实 provider 模式运行
- 实时热榜数据采集
- 结构化结果导出
- Excel 文件生成
- 验收打分
这说明项目已经不是“理论上可以做到”,而是已经在真实任务链路中证明“能够跑通、能够输出结果、能够形成验收记录”。
对外汇报时,这一点很重要,因为它代表项目已经从“能力设想”走向“能力验证”。
### 12. 工程化基础更好
本项目已经不是单纯的验证页面或原型,而是以运行时内核、协议、规则和测试为主的工程结构。这说明项目更接近“可持续建设的产品内核”,而不只是“一个能演示的自动化效果”。
从目前仓库状态看,已经有 20 多个顶层测试文件覆盖协议、握手、runtime、配置、兼容层、导出工具、技能执行和验收评分等多个方面。这说明项目已经在往“可持续交付、可持续验证”的方向走而不是停留在临时性脚本工程。
从长期看,这种工程化能力决定了项目能不能真正进入生产环境,能不能被更多团队协同使用,能不能在后续持续扩展能力。
### 13. 一句话总结现有优势
如果用非技术语言概括,本项目当前最大的优势可以总结为:
不是“更会点网页”,而是“已经具备了企业级智能执行系统该有的安全边界、控制能力、真实交付能力、稳定基础和长期演进空间”。
## 二、下一步计划
下一阶段的重点,不是继续堆脚本,而是进一步解决“页面一变就失效”的老问题,同时把项目能力从“能执行任务”继续提升为“能持续积累企业级自动化资产”。
### 1. 研发“混合自愈选择器”Hybrid Self-Healing Selector
在内网环境下,逐步摆脱对单一 XPath 的依赖,建立企业级元素指纹库,让系统在页面变化后依然能更稳地找到目标元素。
### 2. 定义元素指纹数据结构JSON
给每个可操作元素建立一份“数字档案”,核心字段包括:
- 语义文本
- A11y Role
- 相对空间位置
- 属性哈希
- 兜底 XPath
- 视觉切图Base64 小图)
这样系统找元素时,不再只靠一条路径,而是像“多特征识别”。
### 3. 推进“影子录制”Shadow Recording机制
在现有传统 RPA 正常运行时,于底层开启影子模式。当旧脚本通过 XPath 成功命中元素并完成操作时,后台静默抓取该元素的完整指纹并写入本地数据库。
通过这种方式,在不额外增加大量人工录制成本的前提下,持续沉淀高价值元素资产库。
### 4. 开发穿透层能力
利用定制 Chromium 的底层权限,解决 `iframe` 和闭合 `Shadow DOM` 这类复杂页面结构下的定位难题,为后续自愈选择器提供更强支撑。
## 三、预期结果
通过下一阶段建设,本项目将从“能执行任务”进一步升级为:
- 在复杂企业页面中更稳
- 对页面变化更不敏感
- 更容易持续积累高价值资产
- 更适合在企业环境中长期推广使用
从业务视角看,项目价值也会从“完成单个流程自动化”进一步升级为“建设企业级智能执行底座”。

View File

@@ -0,0 +1,73 @@
# Rust-Only Acceptance Checklist
## Scope
This checklist covers the Rust-side work that can be verified before the SuperRPA browser repository is available locally.
Covered:
- pipe handshake and protocol baseline
- task-level message types
- HMAC canonical string alignment
- Phase 1 rule-based Baidu search planner
- DeepSeek provider scaffolding
- provider-backed minimal Agent runtime with fallback to planner mode
Not covered yet:
- `SgClawProcessHost`
- `PipeListener`
- `CommandRouter` reuse in SuperRPA
- FunctionsUI bridge integration
## Required Commands
Run from the feature worktree:
```bash
cd /home/zyl/projects/sgClaw/.worktrees/superrpa-browser-control
cargo test -q
```
Optional focused checks:
```bash
cargo test --test task_protocol_test -q
cargo test --test planner_test -q
cargo test --test runtime_task_flow_test -q
cargo test --test deepseek_provider_test -q
cargo test --test agent_runtime_test -q
```
## Pass Criteria
- `init -> init_ack` tests pass
- `submit_task`, `task_complete`, and `log_entry` serialize correctly
- HMAC output is based on newline-delimited canonical string with stable JSON ordering
- Planner turns `打开百度搜索天气` into `navigate -> type -> click`
- Runtime mock flow emits browser commands and finishes with `task_complete`
- DeepSeek settings load from environment with default model `deepseek-chat`
- DeepSeek request body matches OpenAI-compatible chat completion shape
## Runtime Configuration
The provider-backed path is enabled only when `DEEPSEEK_API_KEY` is present.
Environment variables:
- `DEEPSEEK_API_KEY`
- `DEEPSEEK_BASE_URL` optional, defaults to `https://api.deepseek.com`
- `DEEPSEEK_MODEL` optional, defaults to `deepseek-chat`
Without `DEEPSEEK_API_KEY`, sgClaw falls back to the Phase 1 rule-based planner.
## Current Branch Milestones
- `b9773d4` — task pipe protocol and HMAC alignment
- `1ab0012` — Phase 1 task planner flow
- `0d0097b` — DeepSeek provider scaffolding
- `9979b1f` — provider-backed Agent runtime
## Next Dependency
To continue beyond Rust-only acceptance, the local SuperRPA / Chromium repository path is required so Tasks 3, 4, and 5 can be implemented and verified.

View File

@@ -301,7 +301,7 @@ Expected: PASS including provider and runtime suites.
- Modify: `README.md`
- Create: `docs/superpowers/acceptance/2026-03-25-superrpa-sgclaw-browser-control.md`
- Modify: `docs/浏览器对接标准.md`
- Modify: `docs/sgclaw_project_team_kickoff.md`
- Modify: `docs/archive/项目管理与排期/sgclaw_project_team_kickoff.md`
- [ ] **Step 1: Write acceptance checklist**

View File

@@ -0,0 +1,281 @@
# Config-Owned Direct Skill Contract Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Validate the `directSubmitSkill` control surface early and prevent malformed direct-skill configs from entering the submit routing path, without changing the current happy-path direct execution behavior.
**Architecture:** Keep the existing direct-submit runtime and submit-task seam intact for valid configs. Move `directSubmitSkill` format validation into the normal `SgClawSettings` load path so malformed config fails before routing begins, while leaving valid-but-unresolvable `skill.tool` targets as direct runtime errors in the current direct path.
**Tech Stack:** Rust 2021, `serde` config parsing, current `BrowserMessage::SubmitTask` path, current direct skill runtime, Rust integration tests.
---
## Execution Context
- Follow @superpowers:test-driven-development for the Rust code changes in this plan.
- Follow @superpowers:verification-before-completion before claiming any task is done.
- Do **not** create a git worktree unless the user explicitly asks. This project prefers staying in the current checkout.
- Keep scope tight: this plan does **not** add per-skill dispatch metadata, docs changes, intent classification, or LLM routing changes.
## File Map
### Existing files to modify
- Modify: `src/config/settings.rs`
- validate `directSubmitSkill` during config normalization
- keep the stored field as `Option<String>` so the current direct runtime API stays stable
- Modify: `tests/compat_config_test.rs`
- add a failing config-load regression for malformed `directSubmitSkill`
- Modify: `tests/agent_runtime_test.rs`
- add a failing submit-path regression proving malformed config is rejected before direct routing begins
### Existing files to read but not broaden
- Reuse without redesign: `src/agent/mod.rs`
- Reuse without redesign: `src/compat/direct_skill_runtime.rs`
- Reuse without redesign: `docs/superpowers/specs/2026-04-09-config-owned-direct-skill-dispatch-design.md`
### No new files expected
This slice should fit in the existing config and tests surfaces only.
---
### Task 1: Validate `directSubmitSkill` Before Submit Routing
**Files:**
- Modify: `tests/compat_config_test.rs`
- Modify: `tests/agent_runtime_test.rs`
- Modify: `src/config/settings.rs`
- Read only: `src/agent/mod.rs`
- Read only: `src/compat/direct_skill_runtime.rs`
- [ ] **Step 1: Write the failing config test for malformed `directSubmitSkill`**
Add this focused test to `tests/compat_config_test.rs`:
```rust
#[test]
fn sgclaw_settings_reject_invalid_direct_submit_skill_format() {
let root = std::env::temp_dir().join(format!(
"sgclaw-invalid-direct-submit-skill-{}",
Uuid::new_v4()
));
fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
fs::write(
&config_path,
r#"{
"providers": [],
"skillsDir": "skill_lib",
"directSubmitSkill": "fault-details-report"
}"#,
)
.unwrap();
let err = SgClawSettings::load(Some(config_path.as_path()))
.expect_err("expected invalid directSubmitSkill format");
let message = err.to_string();
assert!(message.contains("directSubmitSkill"));
assert!(message.contains("skill.tool"));
}
```
- [ ] **Step 2: Run the focused config test and verify it fails**
Run:
```bash
cargo test --test compat_config_test sgclaw_settings_reject_invalid_direct_submit_skill_format -- --nocapture
```
Expected: FAIL because the current config loader accepts the malformed string instead of rejecting it early.
- [ ] **Step 3: Write the failing agent regression for malformed config**
Add this focused test to `tests/agent_runtime_test.rs`:
```rust
#[test]
fn submit_task_rejects_invalid_direct_submit_skill_config_before_routing() {
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let skill_root = build_direct_runtime_skill_root();
let workspace_root = std::env::temp_dir().join(format!(
"sgclaw-invalid-direct-submit-workspace-{}",
Uuid::new_v4()
));
fs::create_dir_all(&workspace_root).unwrap();
let config_path = workspace_root.join("sgclaw_config.json");
fs::write(
&config_path,
serde_json::json!({
"providers": [],
"skillsDir": skill_root,
"directSubmitSkill": "fault-details-report"
})
.to_string(),
)
.unwrap();
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root);
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
direct_runtime_test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
handle_browser_message_with_context(
transport.as_ref(),
&browser_tool,
&runtime_context,
submit_fault_details_message(),
)
.unwrap();
let sent = transport.sent_messages();
assert!(matches!(
sent.last(),
Some(AgentMessage::TaskComplete { success, summary })
if !success && summary.contains("skill.tool")
));
assert!(direct_submit_mode_logs(&sent).is_empty());
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
}
```
- [ ] **Step 4: Run the focused agent test and verify it fails**
Run:
```bash
cargo test --test agent_runtime_test submit_task_rejects_invalid_direct_submit_skill_config_before_routing -- --nocapture
```
Expected: FAIL because the malformed config currently loads, enters the direct-submit branch, and emits `direct_skill_primary` before failing later.
- [ ] **Step 5: Implement the minimal config validation**
In `src/config/settings.rs`, add a small helper that validates the normalized `directSubmitSkill` string during `SgClawSettings::new(...)`.
Recommended implementation shape:
```rust
fn normalize_direct_submit_skill(raw: Option<String>) -> Result<Option<String>, ConfigError> {
let value = normalize_optional_value(raw);
let Some(value) = value.as_deref() else {
return Ok(None);
};
let Some((skill_name, tool_name)) = value.split_once('.') else {
return Err(ConfigError::InvalidValue(
"directSubmitSkill",
format!("must use skill.tool format, got {value}"),
));
};
if skill_name.trim().is_empty() || tool_name.trim().is_empty() {
return Err(ConfigError::InvalidValue(
"directSubmitSkill",
format!("must use skill.tool format, got {value}"),
));
}
Ok(Some(value.to_string()))
}
```
Then use it here:
```rust
let direct_submit_skill = normalize_direct_submit_skill(direct_submit_skill)?;
```
Rules:
- do not change the public field type from `Option<String>`
- do not move parsing responsibility into `src/agent/mod.rs`
- do not redesign `src/compat/direct_skill_runtime.rs`
- keep valid-but-unresolvable `skill.tool` targets as runtime errors in the direct path
- [ ] **Step 6: Re-run the two focused tests and verify they pass**
Run:
```bash
cargo test --test compat_config_test sgclaw_settings_reject_invalid_direct_submit_skill_format -- --nocapture
cargo test --test agent_runtime_test submit_task_rejects_invalid_direct_submit_skill_config_before_routing -- --nocapture
```
Expected: PASS.
- [ ] **Step 7: Re-run the broader regression suites**
Run:
```bash
cargo test --test compat_config_test -- --nocapture
cargo test --test agent_runtime_test -- --nocapture
cargo test --test browser_script_skill_tool_test -- --nocapture
cargo build --bin sgclaw
```
Expected: PASS, including:
- the direct-submit happy path
- the existing no-LLM fallback behavior when `directSubmitSkill` is absent
- unchanged browser-script helper semantics
- clean binary build
---
## Verification Checklist
### Config validation
```bash
cargo test --test compat_config_test -- --nocapture
```
Expected: malformed `directSubmitSkill` is rejected early, while the existing direct-only config shape still loads.
### Submit-path behavior
```bash
cargo test --test agent_runtime_test -- --nocapture
```
Expected:
- malformed `directSubmitSkill` never reaches direct routing
- valid configured direct skill still succeeds without LLM config
- no direct skill configured still returns the existing no-LLM message
### Browser-script helper safety
```bash
cargo test --test browser_script_skill_tool_test -- --nocapture
```
Expected: current browser-script execution semantics remain unchanged.
### Build
```bash
cargo build --bin sgclaw
```
Expected: the main binary compiles cleanly.
---
## Notes For The Engineer
- The paired spec is `docs/superpowers/specs/2026-04-09-config-owned-direct-skill-dispatch-design.md`.
- Do **not** add sgClaw-specific dispatch metadata under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging` in this slice.
- Do **not** turn this into a per-skill registry task yet. This plan only hardens the current config-owned bootstrap contract.
- Keep the current direct target example as `fault-details-report.collect_fault_details`; avoid hard-coding that name into new generic APIs.
- If you discover a need for broader policy routing (`direct_browser` / `llm_agent` by skill), stop and write a new spec/plan instead of expanding this one.

View File

@@ -0,0 +1,520 @@
# Direct Skill Invocation Without LLM Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let the current pipe submit-task flow accept natural-language input but directly invoke one fixed staged browser skill without calling any model, while reserving a clean switch back to LLM-based routing later.
**Architecture:** Keep the existing `BrowserMessage::SubmitTask` entrypoint and add one narrow pre-routing seam before the current compat/LLM chain. When a new config field points to a fixed direct-submit skill, sgClaw loads that skill package from the configured external skills root, finds the target `browser_script` tool, executes it through the existing browser-script wrapper, and returns the result directly. When the field is absent, the current behavior stays unchanged. This preserves a future path where each skill can later declare `direct_browser` or `llm_agent` dispatch without rewriting the submit pipeline again.
**Tech Stack:** Rust 2021, existing `BrowserPipeTool`, current submit-task agent entrypoint, current browser-script skill executor, current sgClaw JSON config loader, `zeroclaw` skill manifest loader.
---
## Recommended First Skill
Use `fault-details-report.collect_fault_details` from:
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/fault-details-report/scene.json`
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.toml`
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
Why this one first:
- it is clearly a report/export skill
- it exposes exactly one browser-script tool: `collect_fault_details`
- it has the smallest contract surface (`period` only)
- its current JS is deterministic and simple, so the first slice can focus on plumbing instead of browser scraping complexity
## Scope Guardrails
- Do **not** redesign the existing submit-task protocol.
- Do **not** remove or rewrite the current LLM/compat path; leave it as the fallback/default path.
- Do **not** introduce generic NL intent routing in this slice; this is one fixed direct skill only.
- Do **not** modify `third_party/zeroclaw` skill manifest schema in phase 1.
- Do **not** add Excel export wiring in the first slice unless a test explicitly requires it.
- Do **not** invent a new browser-script execution model; reuse the existing wrapper semantics.
---
## File Map
### Existing files to modify
- Modify: `src/config/settings.rs`
- add a minimal config field for one direct-submit skill name
- Modify: `src/agent/mod.rs`
- add a narrow pre-routing branch before the current compat/LLM path
- Modify: `src/compat/browser_script_skill_tool.rs`
- expose the smallest reusable helper for direct browser-script execution
- Modify: `src/compat/mod.rs` or the nearest module export surface
- export the new narrow direct-skill runtime module if needed
- Modify: `tests/compat_config_test.rs`
- add config coverage for the new direct-submit field
- Modify: `tests/browser_script_skill_tool_test.rs`
- add coverage for the reusable direct-execution helper
- Modify: `tests/agent_runtime_test.rs`
- prove submit-task can bypass the model and directly invoke the fixed skill
### New files to create
- Create: `src/compat/direct_skill_runtime.rs`
- small runtime for loading one configured skill, resolving one configured tool, deriving minimal args, and executing it directly
### Files to reuse without changing behavior
- Reuse: `src/compat/runtime.rs`
- Reuse: `src/compat/orchestration.rs`
- Reuse: `src/compat/config_adapter.rs`
- Reuse: `third_party/zeroclaw/src/skills/mod.rs`
---
### Task 1: Add A Minimal Direct-Submit Skill Config Field
**Files:**
- Modify: `src/config/settings.rs`
- Modify: `tests/compat_config_test.rs`
- [ ] **Step 1: Write the failing config test for the new field**
In `tests/compat_config_test.rs`, add a focused config-load test proving the browser config file can declare one fixed direct-submit skill.
Test shape:
```rust
#[test]
fn sgclaw_settings_load_direct_submit_skill_from_browser_config() {
let root = std::env::temp_dir().join(format!("sgclaw-direct-skill-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
std::fs::write(
&config_path,
r#"{
"apiKey": "sk-runtime",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-chat",
"skillsDir": "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging",
"directSubmitSkill": "fault-details-report.collect_fault_details"
}"#,
)
.unwrap();
let settings = sgclaw::config::SgClawSettings::load(Some(config_path.as_path()))
.unwrap()
.expect("expected sgclaw settings from config file");
assert_eq!(
settings.direct_submit_skill.as_deref(),
Some("fault-details-report.collect_fault_details")
);
}
```
- [ ] **Step 2: Run the focused config test and verify it fails**
Run:
```bash
cargo test --test compat_config_test sgclaw_settings_load_direct_submit_skill_from_browser_config -- --nocapture
```
Expected: FAIL because the config field does not exist yet.
- [ ] **Step 3: Implement the minimal config field**
In `src/config/settings.rs`, add:
- `direct_submit_skill: Option<String>` to `SgClawSettings`
- `direct_submit_skill: Option<String>` to `RawSgClawSettings`
- field normalization in `SgClawSettings::new(...)`
Recommended JSON key shape:
```rust
#[serde(rename = "directSubmitSkill", alias = "direct_submit_skill", default)]
direct_submit_skill: Option<String>,
```
Rules:
- trim empty values to `None`
- keep `DeepSeekSettings` unchanged for this slice unless a compile error proves it must mirror the field
- do not alter unrelated config semantics
- [ ] **Step 4: Re-run the focused config test**
Run:
```bash
cargo test --test compat_config_test sgclaw_settings_load_direct_submit_skill_from_browser_config -- --nocapture
```
Expected: PASS.
- [ ] **Step 5: Re-run the broader config file tests**
Run:
```bash
cargo test --test compat_config_test -- --nocapture
```
Expected: PASS.
- [ ] **Step 6: Commit Task 1**
```bash
git add src/config/settings.rs tests/compat_config_test.rs
git commit -m "feat: add direct submit skill config"
```
---
### Task 2: Extract A Reusable Browser-Script Direct Execution Helper
**Files:**
- Modify: `src/compat/browser_script_skill_tool.rs`
- Modify: `tests/browser_script_skill_tool_test.rs`
- [ ] **Step 1: Write the first failing helper test**
In `tests/browser_script_skill_tool_test.rs`, add a focused test proving direct code can execute a packaged browser script without constructing a full `Tool` object first.
Test shape:
```rust
#[tokio::test]
async fn execute_browser_script_tool_runs_packaged_script_with_expected_domain() {
// build temp skill script
// call the helper directly
// assert Action::Eval was sent with wrapped args and normalized domain
}
```
Required assertions:
- the helper reads the packaged JS file
- it wraps args with `const args = ...`
- it normalizes URL-like `expected_domain`
- it returns the serialized payload string on success
- [ ] **Step 2: Run the helper test and verify it fails**
Run:
```bash
cargo test --test browser_script_skill_tool_test execute_browser_script_tool_runs_packaged_script_with_expected_domain -- --nocapture
```
Expected: FAIL because the helper does not exist yet.
- [ ] **Step 3: Add the second failing helper test for required-domain validation**
Add a focused failure-path test proving the helper rejects missing or invalid `expected_domain` before any browser command is sent.
- [ ] **Step 4: Run the validation test and verify it fails**
Run:
```bash
cargo test --test browser_script_skill_tool_test execute_browser_script_tool_rejects_missing_expected_domain -- --nocapture
```
Expected: FAIL because the helper does not exist yet.
- [ ] **Step 5: Implement the minimal reusable helper**
In `src/compat/browser_script_skill_tool.rs`, extract the smallest reusable function, for example:
```rust
pub async fn execute_browser_script_tool<T: Transport + 'static>(
tool: &SkillTool,
skill_root: &Path,
browser_tool: BrowserPipeTool<T>,
args: Value,
) -> anyhow::Result<ToolResult>
```
Rules:
- reuse the current path validation, script loading, wrapping, `Action::Eval`, and payload formatting logic already used by `BrowserScriptSkillTool::execute`
- do not change outward behavior of `BrowserScriptSkillTool`
- keep the helper narrow and browser-script-only
- [ ] **Step 6: Refactor `BrowserScriptSkillTool::execute` to call the helper**
Keep existing behavior and tests green while removing duplicate execution logic.
- [ ] **Step 7: Re-run the browser-script tests**
Run:
```bash
cargo test --test browser_script_skill_tool_test -- --nocapture
```
Expected: PASS.
- [ ] **Step 8: Commit Task 2**
```bash
git add src/compat/browser_script_skill_tool.rs tests/browser_script_skill_tool_test.rs
git commit -m "refactor: extract direct browser script execution helper"
```
---
### Task 3: Add A Narrow Direct Skill Runtime For One Fixed Skill
**Files:**
- Create: `src/compat/direct_skill_runtime.rs`
- Modify: `src/compat/mod.rs` or nearest module export point
- Reuse: `src/compat/config_adapter.rs`
- Reuse: `third_party/zeroclaw/src/skills/mod.rs`
- [ ] **Step 1: Write the first failing direct-runtime test**
Add a focused test in `tests/agent_runtime_test.rs` or a new narrow compat test proving code can resolve the configured external skills root, load `fault-details-report`, find `collect_fault_details`, and execute it directly.
Recommended shape:
```rust
#[test]
fn direct_skill_runtime_executes_fault_details_report_without_provider() {
// config points at skill_staging root
// direct_submit_skill points at fault-details-report.collect_fault_details
// browser response returns report-artifact payload
// assert no provider/http path is touched
}
```
- [ ] **Step 2: Run the focused direct-runtime test and verify it fails**
Run the narrowest test command for the new test.
Expected: FAIL because the direct runtime does not exist yet.
- [ ] **Step 3: Implement `src/compat/direct_skill_runtime.rs`**
Add a narrow runtime with responsibilities only to:
- resolve the configured skills dir with `resolve_skills_dir_from_sgclaw_settings(...)`
- load skills from that directory with `load_skills_from_directory(...)`
- parse the configured tool name into `skill_name` + `tool_name`
- find the matching skill and matching tool
- verify `tool.kind == "browser_script"`
- derive the minimal argument object
- call the new browser-script helper
- return the output string or a clear `PipeError`
Do **not** add generic routing, scenes, or model fallback here.
- [ ] **Step 4: Keep argument derivation intentionally minimal**
For the first slice, derive only:
- `expected_domain` from `page_url` when present, otherwise fail with a clear message
- `period` from the instruction using a narrow deterministic pattern such as `YYYY-MM`
If the period cannot be derived, return a concise error telling the user to provide it explicitly. Do not guess.
- [ ] **Step 5: Re-run the focused direct-runtime test**
Run the same test command again.
Expected: PASS.
- [ ] **Step 6: Commit Task 3**
```bash
git add src/compat/direct_skill_runtime.rs src/compat/mod.rs tests/agent_runtime_test.rs
git commit -m "feat: add fixed direct skill runtime"
```
---
### Task 4: Insert The Pre-Routing Seam In Submit-Task Entry
**Files:**
- Modify: `src/agent/mod.rs`
- Modify: `tests/agent_runtime_test.rs`
- [ ] **Step 1: Write the first failing submit-path bypass test**
In `tests/agent_runtime_test.rs`, add a focused regression proving that when `directSubmitSkill` is configured, `BrowserMessage::SubmitTask` can succeed without any model/provider being configured.
Test shape:
```rust
#[test]
fn submit_task_uses_direct_skill_mode_without_llm_configuration() {
// config contains skillsDir + directSubmitSkill, but no reachable provider
// natural-language instruction includes period and page_url
// expect TaskComplete success from direct skill result
}
```
Required assertions:
- task succeeds even if provider would be unavailable
- output contains the report artifact payload
- no summary like `未配置大语言模型`
- [ ] **Step 2: Run the bypass test and verify it fails**
Run:
```bash
cargo test --test agent_runtime_test submit_task_uses_direct_skill_mode_without_llm_configuration -- --nocapture
```
Expected: FAIL because submit-task still goes into the current LLM-oriented path.
- [ ] **Step 3: Add the second failing priority test**
Add one focused test proving the direct-submit branch runs before the existing compat/LLM branch.
The easiest assertion is that the mode log becomes something new like:
- `direct_skill_primary`
and the normal mode logs do not appear for that turn.
- [ ] **Step 4: Run the priority test and verify it fails**
Run the narrow test command for the new test.
Expected: FAIL because the mode does not exist yet.
- [ ] **Step 5: Add the narrow pre-routing branch in `src/agent/mod.rs`**
In `handle_browser_message_with_context(...)`, after config load/logging and before the existing `should_use_primary_orchestration(...)` / `compat::runtime` path:
- check `settings.direct_submit_skill`
- if present, emit mode log `direct_skill_primary`
- call the new direct runtime
- send `TaskComplete` and return immediately
Rules:
- if `direct_submit_skill` is absent, keep existing behavior byte-for-byte where possible
- do not modify `compat::runtime.rs` or `compat::orchestration.rs` for this slice
- do not silently fall through to LLM when direct execution fails; return the direct error clearly so the first slice is debuggable
- [ ] **Step 6: Re-run the focused submit-path tests**
Run:
```bash
cargo test --test agent_runtime_test submit_task_uses_direct_skill_mode_without_llm_configuration -- --nocapture
cargo test --test agent_runtime_test direct_skill_mode_logs_direct_skill_primary -- --nocapture
```
Expected: PASS.
- [ ] **Step 7: Re-run existing no-LLM submit regression coverage**
Run:
```bash
cargo test --test agent_runtime_test -- --nocapture
```
Expected: PASS, including existing cases where no direct skill is configured and the old no-LLM failure still applies.
- [ ] **Step 8: Commit Task 4**
```bash
git add src/agent/mod.rs tests/agent_runtime_test.rs
git commit -m "feat: route submit tasks through fixed direct skill mode"
```
---
### Task 5: Lock The Future Migration Seam Without Implementing LLM Dispatch Yet
**Files:**
- Modify only if needed: `src/config/settings.rs`
- Modify only if needed: `src/compat/direct_skill_runtime.rs`
- Reuse: docs/plan only unless code needs one tiny naming fix
- [ ] **Step 1: Keep the config naming compatible with future per-skill dispatch**
Document and preserve this future meaning in code naming:
- current field: one fixed direct skill for submit-task bootstrap
- future model: each skill can declare dispatch mode such as `direct_browser` or `llm_agent`
Prefer neutral names in helper code like:
- `direct skill mode`
- `direct submit skill`
Avoid hard-coding `fault_details` into generic APIs.
- [ ] **Step 2: Add one small negative test for fallback behavior**
Add a focused test proving that when `directSubmitSkill` is not configured, submit-task still behaves exactly as before and can still return the existing no-LLM message.
If an existing test already proves this, keep it and do not add another.
- [ ] **Step 3: Re-run the focused end-to-end verification set**
Run:
```bash
cargo test --test compat_config_test -- --nocapture
cargo test --test browser_script_skill_tool_test -- --nocapture
cargo test --test agent_runtime_test -- --nocapture
```
Expected: PASS.
- [ ] **Step 4: Build the main binary**
Run:
```bash
cargo build --bin sgclaw
```
Expected: PASS.
- [ ] **Step 5: Commit Task 5**
```bash
git add src/config/settings.rs src/compat/direct_skill_runtime.rs src/compat/browser_script_skill_tool.rs src/agent/mod.rs tests/compat_config_test.rs tests/browser_script_skill_tool_test.rs tests/agent_runtime_test.rs
git commit -m "test: verify fixed direct skill submit path"
```
---
## Verification Checklist
### Config loading
```bash
cargo test --test compat_config_test -- --nocapture
```
Expected: `directSubmitSkill` loads correctly and existing config behavior remains intact.
### Browser-script helper
```bash
cargo test --test browser_script_skill_tool_test -- --nocapture
```
Expected: direct helper preserves the existing browser-script execution semantics.
### Submit-path bypass
```bash
cargo test --test agent_runtime_test -- --nocapture
```
Expected: configured direct skill bypasses the model path, while unconfigured submit-task behavior stays unchanged.
### Build
```bash
cargo build --bin sgclaw
```
Expected: the binary compiles cleanly.
---
## Notes For The Engineer
- The key to keeping this slice small is to avoid changing `compat::runtime.rs` and `compat::orchestration.rs`; they remain the future LLM path.
- `fault-details-report.collect_fault_details` is only the bootstrap skill. The plumbing must stay generic enough that the configured tool name can later point to another staged browser skill.
- Phase 1 should not add per-skill dispatch metadata to the external skill manifests yet. Keep that decision in sgClaw config first; move it into skill metadata only after the direct path is proven useful.
- Once the intranet model is ready, the clean next step is to add a dispatch policy layer that chooses between `direct_browser` and `llm_agent` before the current compat path is entered, reusing this same pre-routing seam.

View File

@@ -0,0 +1,672 @@
# Fault Details Full Skill Alignment Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Upgrade `fault-details-report.collect_fault_details` into a real staged browser skill that matches the original fault-details workflow, and make `claw-new` interpret the returned artifact status correctly in the direct-submit path.
**Architecture:** Keep routing and direct-skill selection in `claw-new`, but move all fault-details collection, normalization, classification, summary, export, and report-log behavior into the staged skill under `skill_staging`. Implement the staged skill as a true browser-eval entrypoint that remains valid in page context, while exposing testable pure helpers through an environment-safe export guard for `node:test`; then add a narrow Rust artifact interpreter in `src/compat/direct_skill_runtime.rs` so `ok` / `partial` / `empty` map to successful task completion while `blocked` / `error` map to failed completion.
**Tech Stack:** Rust 2021, `serde_json`, existing `BrowserPipeTool` / `browser_script` runtime, `node:test`, staged skill fixtures, Cargo integration tests.
---
## Execution Context
- Follow @superpowers:test-driven-development for every behavior change.
- Follow @superpowers:verification-before-completion before claiming each task is done.
- Do **not** create a git worktree unless the user explicitly asks. This repo preference is already established.
- Keep scope tight. Do **not** add a new browser protocol, new dispatch metadata, new UI opener behavior, or Rust-side fault classification logic.
- Keep the current direct path bootstrap requirement intact: the user instruction must still include an explicit `YYYY-MM`, but the staged skill must treat the page-selected range as the source of truth for collection once execution begins.
- Preserve parity with the original packages real behavior: port the original classification table, `qxxcjl`-based reason heuristics, canonical detail mapping, summary aggregation rules, localhost export call, and report-log call into the staged skill rather than implementing a fixture-only subset.
## File Map
### Existing files to modify in `claw-new`
- Modify: `src/compat/direct_skill_runtime.rs`
- add narrow structured artifact parsing and status-to-summary mapping
- keep direct-skill routing/config ownership unchanged
- Modify: `tests/agent_runtime_test.rs`
- add direct-submit regressions for `ok`, `partial`, `empty`, `blocked`, and `error`
- Modify: `tests/browser_script_skill_tool_test.rs`
- add browser-script execution-shape regression for browser-eval return payloads used by fault-details
### Existing files to modify in `skill_staging`
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
- replace empty shell with browser-eval entrypoint plus parity helpers
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js`
- deterministic fixture coverage for normalization, classification, summary, artifact contract, export/logging degradation, and entrypoint shape helpers
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.toml`
- align tool description with real collection/export/report-log behavior
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.md`
- align written contract with actual runtime behavior and artifact fields
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/references/collection-flow.md`
- align flow with page-range/query/export/report-log sequence
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/references/data-quality.md`
- make canonical columns, original classification tables, reason heuristics, summary rules, and partial semantics explicit
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/fault-details-report/scene.json`
- keep scene output/state contract aligned with real staged artifact behavior
### Existing files to read but not redesign
- Read only: `docs/superpowers/specs/2026-04-10-fault-details-full-skill-alignment-design.md`
- Read only: `src/agent/mod.rs`
- Read only: `src/compat/browser_script_skill_tool.rs`
- Read only: `D:/desk/智能体资料/大四区报告监测项/故障明细/index.html`
---
### Task 1: Add staged-skill red tests for normalization, summary, and artifact-contract semantics
**Files:**
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js`
- Read only: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
- Read only: `D:/desk/智能体资料/大四区报告监测项/故障明细/index.html`
- [ ] **Step 1: Write the failing staged-skill test file**
Add `collect_fault_details.test.js` using `node:test` and `assert/strict`. Cover these behaviors with fixed fixtures:
```javascript
const test = require('node:test');
const assert = require('node:assert/strict');
const {
DETAIL_COLUMNS,
SUMMARY_COLUMNS,
normalizeDetailRow,
deriveSummaryRows,
determineArtifactStatus,
buildFaultDetailsArtifact,
buildBrowserEntrypointResult
} = require('./collect_fault_details.js');
test('normalizeDetailRow maps canonical detail fields from raw repair rows', () => {
const row = normalizeDetailRow({
qxdbh: 'QX-1',
bxsj: '2026-03-09 08:00:00',
cityName: '国网兰州供电公司',
maintOrgName: '城关供电服务班',
maintGroupName: '抢修一班',
bdzMc: '110kV东岗变',
xlmc10: '10kV东岗线',
byqmc: '东岗1号变',
yjflMc: '电网故障',
ejflMc: '线路故障',
sjflMc: '低压线路',
qxxcjl: '现场检查:低压线路断线,已处理完成',
gzms: '客户报修停电'
}, {
companyName: '国网兰州供电公司'
});
assert.equal(row.slsj, '2026-03-09 08:00:00');
assert.equal(row.gssgs, '甘肃省电力公司');
assert.equal(row.gddw, '城关供电服务班');
assert.equal(row.gds, '抢修一班');
assert.equal(row.clzt, '处理完成');
assert.equal(row.bdz, '110kV东岗变');
assert.equal(row.line, '10kV东岗线');
assert.equal(row.pb, '东岗1号变');
});
test('deriveSummaryRows groups normalized rows by gds and computes counters', () => {
const rows = [
{ gds: '抢修一班', gddw: '城关供电服务班', sgs: '国网兰州供电公司', sxfl1: '无效', sxfl2: '无效', gzsb: '' },
{ gds: '抢修一班', gddw: '城关供电服务班', sgs: '国网兰州供电公司', sxfl1: '有效', sxfl2: '用户侧', gzsb: '表后线' },
{ gds: '抢修一班', gddw: '城关供电服务班', sgs: '国网兰州供电公司', sxfl1: '有效', sxfl2: '电网侧', dwcFl: '低压故障', gzsb: '低压线路' }
];
const summaryRows = deriveSummaryRows(rows, { companyName: '国网兰州供电公司' });
assert.equal(summaryRows.length, 1);
assert.equal(summaryRows[0].className, '抢修一班');
assert.equal(summaryRows[0].allCount, 3);
assert.equal(summaryRows[0].wxCount, 1);
assert.equal(summaryRows[0].khcCount, 0);
assert.equal(summaryRows[0].dyGzCount, 1);
assert.equal(summaryRows[0].dyxlCount, 1);
assert.equal(summaryRows[0].bhxCount, 1);
});
test('determineArtifactStatus follows blocked > error > partial > empty > ok precedence', () => {
assert.equal(determineArtifactStatus({ blockedReason: 'missing_session', fatalError: null, partialReasons: [], detailRows: [{}] }), 'blocked');
assert.equal(determineArtifactStatus({ blockedReason: null, fatalError: 'parse_failed', partialReasons: [], detailRows: [{}] }), 'error');
assert.equal(determineArtifactStatus({ blockedReason: null, fatalError: null, partialReasons: ['export_failed'], detailRows: [{}] }), 'partial');
assert.equal(determineArtifactStatus({ blockedReason: null, fatalError: null, partialReasons: [], detailRows: [] }), 'empty');
assert.equal(determineArtifactStatus({ blockedReason: null, fatalError: null, partialReasons: [], detailRows: [{}] }), 'ok');
});
test('buildFaultDetailsArtifact keeps canonical fields, selected range, counts, and downstream results', () => {
const artifact = buildFaultDetailsArtifact({
period: '2026-03',
selectedRange: { start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' },
detailRows: [{ qxdbh: 'QX-1' }],
summaryRows: [{ index: 1 }],
partialReasons: ['report_log_failed'],
downstream: {
export: { attempted: true, success: true, path: 'http://localhost/export.xlsx' },
report_log: { attempted: true, success: false, error: '500' }
}
});
assert.equal(artifact.type, 'report-artifact');
assert.equal(artifact.status, 'partial');
assert.deepEqual(artifact.selected_range, { start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' });
assert.equal(artifact.counts.detail_rows, 1);
assert.equal(artifact.counts.summary_rows, 1);
assert.deepEqual(artifact.partial_reasons, ['report_log_failed']);
});
test('buildFaultDetailsArtifact keeps required top-level fields for blocked artifact', () => {
const artifact = buildFaultDetailsArtifact({
period: '2026-03',
blockedReason: 'selected_range_unavailable',
partialReasons: ['selected_range_unavailable']
});
assert.equal(artifact.type, 'report-artifact');
assert.equal(artifact.report_name, 'fault-details-report');
assert.equal(artifact.period, '2026-03');
assert.equal(artifact.status, 'blocked');
assert.deepEqual(artifact.partial_reasons, ['selected_range_unavailable']);
assert.equal('downstream' in artifact, false);
});
test('buildFaultDetailsArtifact keeps known selected range and counts on late error', () => {
const artifact = buildFaultDetailsArtifact({
period: '2026-03',
selectedRange: { start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' },
detailRows: [],
summaryRows: [],
fatalError: 'summary_failed',
partialReasons: ['summary_failed']
});
assert.equal(artifact.status, 'error');
assert.deepEqual(artifact.selected_range, { start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' });
assert.equal(artifact.counts.detail_rows, 0);
assert.equal(artifact.counts.summary_rows, 0);
});
test('buildBrowserEntrypointResult returns blocked artifact when selected range is unavailable', async () => {
const artifact = await buildBrowserEntrypointResult({
period: '2026-03'
}, {
readSelectedRange: async () => null
});
assert.equal(artifact.status, 'blocked');
assert.ok(artifact.partial_reasons.includes('selected_range_unavailable'));
});
```
- [ ] **Step 2: Run the staged-skill test file and verify it fails**
Run:
```bash
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
```
Expected: FAIL because `collect_fault_details.js` does not export these helpers yet and still only returns an empty shell.
---
### Task 2: Implement staged-skill parity helpers and a valid browser entrypoint
**Files:**
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
- Test: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js`
- [ ] **Step 1: Implement the helper exports and browser entrypoint shape needed to satisfy the red tests**
Refactor `collect_fault_details.js` so the file remains a valid browser-eval script in page context while still supporting `node:test` through an environment-safe export guard.
Required implementation pieces:
```javascript
const DETAIL_COLUMNS = [/* existing canonical columns */];
const SUMMARY_COLUMNS = [/* existing summary columns */];
function normalizeDetailRow(raw, context) {
// map qxdbh/gssgs/sgs/gddw/gds/slsj/clzt/bdz/line/pb
// derive sxfl1/sxfl2/sxfl3/gzsb/gzyy from the original package rules
}
function deriveSummaryRows(detailRows, context) {
// group by gds and compute all original package counters
}
function determineArtifactStatus({ blockedReason, fatalError, partialReasons, detailRows }) {
// blocked > error > partial > empty > ok
}
function buildFaultDetailsArtifact({
period,
selectedRange,
detailRows,
summaryRows,
partialReasons,
blockedReason,
fatalError,
downstream
}) {
// return report-artifact with columns, sections, counts, status, partial_reasons, downstream
}
async function buildBrowserEntrypointResult(input, deps = defaultBrowserDeps()) {
// read selected range from page
// collect raw rows from page query
// normalize rows
// derive summary
// attempt export + report log
// return final artifact
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
DETAIL_COLUMNS,
SUMMARY_COLUMNS,
normalizeDetailRow,
deriveSummaryRows,
determineArtifactStatus,
buildFaultDetailsArtifact,
buildBrowserEntrypointResult
};
}
return await buildBrowserEntrypointResult(args);
```
Rules:
- keep `DETAIL_COLUMNS` and `SUMMARY_COLUMNS` canonical and stable
- keep helper functions self-contained in this file unless a separate pure helper file becomes necessary for runtime validity
- keep the browser entrypoint compatible with current `eval` wrapper
- keep browser runtime free of unguarded Node-only assumptions
- do **not** invent a new protocol or callback surface
- [ ] **Step 2: Re-run the staged-skill test file and verify it now reaches deeper failures or passes the initial helper coverage**
Run:
```bash
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
```
Expected: either PASS for the Task 1 cases, or fail only on the still-missing full parity/export/history specifics added in Task 3.
---
### Task 3: Add red tests for full classification parity, downstream partials, and empty-result export semantics
**Files:**
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js`
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
- Read only: `D:/desk/智能体资料/大四区报告监测项/故障明细/index.html`
- [ ] **Step 1: Extend the staged-skill tests with failing parity and downstream cases**
Add focused failing tests such as:
```javascript
test('normalizeDetailRow derives gzyy from qxxcjl text heuristics', () => {
const row = normalizeDetailRow({
qxxcjl: '现场检查:客户表后线烧损,已恢复送电',
ejflMc: '客户侧故障',
sjflMc: '表后线'
}, { companyName: '国网兰州供电公司' });
assert.equal(row.gzsb, '表后线');
assert.equal(row.gzyy, '表后线烧损');
});
test('buildBrowserEntrypointResult returns partial when export fails after detail collection succeeds', async () => {
const artifact = await buildBrowserEntrypointResult({ period: '2026-03' }, {
readSelectedRange: async () => ({ start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }),
queryFaultRows: async () => [{ qxdbh: 'QX-1', bxsj: '2026-03-09 08:00:00', maintGroupName: '抢修一班' }],
readCompanyContext: () => ({ companyName: '国网兰州供电公司' }),
exportWorkbook: async () => {
throw new Error('export_failed');
},
writeReportLog: async () => ({ success: true })
});
assert.equal(artifact.status, 'partial');
assert.ok(artifact.partial_reasons.includes('export_failed'));
assert.equal(artifact.counts.detail_rows, 1);
assert.equal(artifact.downstream.export.attempted, true);
assert.equal(artifact.downstream.export.success, false);
});
test('buildBrowserEntrypointResult returns error when normalized detail rows cannot be produced', async () => {
const artifact = await buildBrowserEntrypointResult({ period: '2026-03' }, {
readSelectedRange: async () => ({ start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }),
queryFaultRows: async () => [{ qxdbh: '', bxsj: '' }],
readCompanyContext: () => ({ companyName: '国网兰州供电公司' })
});
assert.equal(artifact.status, 'error');
assert.ok(artifact.partial_reasons.includes('detail_normalization_failed'));
});
test('buildBrowserEntrypointResult keeps canonical rows empty for empty result and omits downstream before attempts', async () => {
const artifact = await buildBrowserEntrypointResult({ period: '2026-03' }, {
readSelectedRange: async () => ({ start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }),
queryFaultRows: async () => [],
readCompanyContext: () => ({ companyName: '国网兰州供电公司' })
});
assert.equal(artifact.status, 'empty');
assert.deepEqual(artifact.rows, []);
assert.equal('downstream' in artifact, false);
});
```
Also add fixture cases derived from the original packages full classification table and summary counters so the staged skill is forced toward parity, not a subset implementation.
- [ ] **Step 2: Run the staged-skill test file and verify it fails on the new cases**
Run:
```bash
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
```
Expected: FAIL on missing full classification parity or downstream partial/error behavior.
- [ ] **Step 3: Implement the full business logic needed to satisfy the new tests**
In `collect_fault_details.js`:
- port the original classification table and `qxxcjl` text heuristics for `sxfl1`, `sxfl2`, `sxfl3`, `gzsb`, `gzyy`
- port the original summary derivation rules and counters completely
- add required-field validation so structurally unusable normalized rows escalate to `error`
- add downstream `exportWorkbook` and `writeReportLog` stages that record `{attempted, success, path, error}`
- keep collection success distinct from downstream failures so export/logging failures become `partial`, not full failure
- keep placeholder rows, if needed for downstream empty-export payloads, downstream-only and never in canonical returned `rows`
- include both `period` and `selected_range` in the artifact
- omit `downstream` when export/report-log have not been attempted yet
- [ ] **Step 4: Re-run the staged-skill test file and verify it passes**
Run:
```bash
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
```
Expected: PASS.
---
### Task 4: Align staged-skill metadata and reference docs with the implemented behavior
**Files:**
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.toml`
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.md`
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/references/collection-flow.md`
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/references/data-quality.md`
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/fault-details-report/scene.json`
- [ ] **Step 1: Update the staged metadata/docs to match the implemented runtime contract**
Required changes:
- `SKILL.toml`: description must say the tool collects rows, derives summary, attempts localhost export, and records report history
- `SKILL.md`: artifact example must include `selected_range`, `counts`, `status`, `partial_reasons`, and `downstream`
- `references/collection-flow.md`: sequence must explicitly include page-selected range -> raw query -> normalization -> summary -> export -> report-log
- `references/data-quality.md`: document the original classification tables, `qxxcjl` heuristics, summary rules, partial/error escalation rules, and empty-result semantics explicitly enough to match the implemented helpers
- `scene.json`: keep inputs/outputs/status semantics aligned with the richer artifact; do not add routing policy there
- [ ] **Step 2: Read the updated staged docs and verify they match the implemented JS behavior**
Read and confirm:
- descriptions no longer claim “artifact shell” behavior
- docs do not move routing ownership out of `claw-new`
- docs do not promise auto-opening/downloading behavior in this slice
- docs reflect blocked/error field-presence rules and downstream-attempt semantics
Expected: staged metadata/docs accurately reflect the implemented collector.
---
### Task 5: Add Rust red tests for artifact-status interpretation in the direct-submit runtime
**Files:**
- Modify: `tests/agent_runtime_test.rs`
- Modify: `tests/browser_script_skill_tool_test.rs`
- Modify: `src/compat/direct_skill_runtime.rs`
- Read only: `src/compat/browser_script_skill_tool.rs`
- [ ] **Step 1: Add failing direct-submit runtime tests for structured artifact statuses**
Extend `tests/agent_runtime_test.rs` with focused regressions that use the existing temp skill-root harness but return real `report-artifact` payloads:
```rust
#[test]
fn submit_task_treats_partial_report_artifact_as_success_with_warning_summary() {
let skill_root = build_direct_runtime_skill_root();
let runtime_context = direct_submit_runtime_context(&skill_root);
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
1,
serde_json::json!({
"text": {
"type": "report-artifact",
"report_name": "fault-details-report",
"period": "2026-03",
"selected_range": { "start": "2026-03-08 16:00:00", "end": "2026-03-09 16:00:00" },
"columns": ["qxdbh"],
"rows": [{ "qxdbh": "QX-1" }],
"sections": [{ "name": "summary-sheet", "columns": ["index"], "rows": [{ "index": 1 }] }],
"counts": { "detail_rows": 1, "summary_rows": 1 },
"status": "partial",
"partial_reasons": ["report_log_failed"],
"downstream": {
"export": { "attempted": true, "success": true, "path": "http://localhost/export.xlsx" },
"report_log": { "attempted": true, "success": false, "error": "500" }
}
}
}),
)]));
// ... invoke handle_browser_message_with_context(...)
// assert TaskComplete.success == true
// assert summary contains partial/report_log_failed/detail_rows=1
}
#[test]
fn submit_task_treats_empty_report_artifact_as_success() { /* status=empty => success=true */ }
#[test]
fn submit_task_treats_blocked_report_artifact_as_failure() { /* status=blocked => success=false */ }
#[test]
fn submit_task_treats_error_report_artifact_as_failure() { /* status=error => success=false */ }
```
Also add one focused helper regression to `tests/browser_script_skill_tool_test.rs` that proves the browser-script helper can return a structured object payload used by the fault-details path without flattening required fields away.
Suggested test name:
```rust
#[tokio::test]
async fn execute_browser_script_tool_preserves_structured_report_artifact_payload() { /* ... */ }
```
- [ ] **Step 2: Run the focused Rust tests and verify they fail**
Run:
```bash
cargo test --test agent_runtime_test submit_task_treats_partial_report_artifact_as_success_with_warning_summary -- --nocapture
cargo test --test browser_script_skill_tool_test execute_browser_script_tool_preserves_structured_report_artifact_payload -- --nocapture
```
Expected: the new `agent_runtime_test` case fails because `execute_direct_submit_skill` still returns raw JSON text and `src/agent/mod.rs` still marks all direct-submit results as success when no Rust-side interpretation exists.
---
### Task 6: Implement narrow Rust artifact interpretation without moving business rules into Rust
**Files:**
- Modify: `src/compat/direct_skill_runtime.rs`
- Modify: `tests/agent_runtime_test.rs`
- Modify: `tests/browser_script_skill_tool_test.rs`
- [ ] **Step 1: Implement a narrow structured-artifact interpreter in `src/compat/direct_skill_runtime.rs`**
Add a small internal result type and parser, for example:
```rust
struct DirectSubmitOutcome {
success: bool,
summary: String,
}
fn interpret_direct_submit_output(output: &str) -> DirectSubmitOutcome {
// parse JSON if possible
// if type == "report-artifact", read status/counts/partial_reasons/downstream
// map ok/partial/empty => success=true
// map blocked/error => success=false
// build concise summary with report_name, period, detail_rows, summary_rows, status, partial reasons
// fall back to raw output text when payload is not a recognized artifact
}
```
Then change the public entrypoint shape from `Result<String, PipeError>` to a narrow result carrying `success` and `summary`, or add a second helper that `src/agent/mod.rs` can use without changing routing ownership.
Rules:
- do **not** reimplement fault normalization/classification/summary in Rust
- do **not** add fault-specific branching in `src/agent/mod.rs`
- keep unrecognized non-artifact outputs working as before
- keep explicit `YYYY-MM` derivation and configured `skill.tool` resolution unchanged
- [ ] **Step 2: Update the submit-path caller to use the interpreted success flag**
Adjust the direct-submit branch so `TaskComplete.success` comes from the artifact interpretation instead of blindly treating every `Ok(summary)` as success.
Implementation target:
- keep the direct path in `src/agent/mod.rs`
- keep error handling narrow
- if needed, return a dedicated direct-submit outcome from `execute_direct_submit_skill`
- [ ] **Step 3: Re-run the focused Rust tests and verify they pass**
Run:
```bash
cargo test --test agent_runtime_test submit_task_treats_partial_report_artifact_as_success_with_warning_summary -- --nocapture
cargo test --test agent_runtime_test submit_task_treats_empty_report_artifact_as_success -- --nocapture
cargo test --test agent_runtime_test submit_task_treats_blocked_report_artifact_as_failure -- --nocapture
cargo test --test agent_runtime_test submit_task_treats_error_report_artifact_as_failure -- --nocapture
cargo test --test browser_script_skill_tool_test execute_browser_script_tool_preserves_structured_report_artifact_payload -- --nocapture
```
Expected: PASS.
---
### Task 7: Run the full verification sweep for the staged skill and direct runtime
**Files:**
- Verify only
- [ ] **Step 1: Run the staged-skill deterministic test file**
Run:
```bash
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
```
Expected: PASS.
- [ ] **Step 2: Run the relevant Rust regression suites**
Run:
```bash
cargo test --test browser_script_skill_tool_test -- --nocapture
cargo test --test agent_runtime_test -- --nocapture
```
Expected: PASS.
- [ ] **Step 3: Run the broader compatibility coverage and build**
Run:
```bash
cargo test --test compat_runtime_test -- --nocapture
cargo test --test compat_config_test -- --nocapture
cargo build --bin sgclaw
```
Expected: PASS.
- [ ] **Step 4: Manually verify the requirements against the approved spec**
Checklist:
- staged skill now reads page-selected range instead of inventing a month window after entry
- staged skill returns canonical detail rows and summary rows
- staged skill ports the original classification table, `qxxcjl` heuristics, and summary counters with parity coverage
- staged skill records downstream export/report-log outcome
- staged skill distinguishes `ok` / `partial` / `empty` / `blocked` / `error`
- `blocked` / `error` artifacts keep the required top-level fields, and preserve known `selected_range` / `counts` when failure happens late enough
- `downstream` is omitted when export/report-log were not attempted and included with attempted/success flags once they were attempted
- empty-result canonical `rows` stay empty even if downstream export uses a placeholder transport row
- `claw-new` maps `ok` / `partial` / `empty` to success and `blocked` / `error` to failure
- no new routing metadata was added to `SKILL.toml` or `scene.json`
- no new browser protocol or opener/UI behavior was introduced
Expected: all checklist items satisfied before calling the work complete.
---
## Verification Checklist
### Staged skill behavior
```bash
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
```
Expected: deterministic fixture coverage passes for normalization, full classification parity, summary derivation, artifact shape, empty semantics, and downstream partial semantics.
### Direct-submit runtime mapping
```bash
cargo test --test agent_runtime_test -- --nocapture
```
Expected:
- valid artifact `ok` / `partial` / `empty` completes successfully
- valid artifact `blocked` / `error` completes as failure
- existing invalid config regression still passes
- existing direct-submit happy path still passes
### Browser-script helper safety
```bash
cargo test --test browser_script_skill_tool_test -- --nocapture
```
Expected: current browser-script execution semantics remain intact while returning structured artifact payloads.
### Compatibility/build
```bash
cargo test --test compat_runtime_test -- --nocapture
cargo test --test compat_config_test -- --nocapture
cargo build --bin sgclaw
```
Expected: no regressions in compat execution/config loading; main binary builds cleanly.
---
## Notes For The Engineer
- The paired spec is `docs/superpowers/specs/2026-04-10-fault-details-full-skill-alignment-design.md`.
- Keep all fault business transforms in `skill_staging`, not in Rust.
- Keep direct routing config-owned via `skillsDir` + `directSubmitSkill`.
- Do **not** broaden this slice into LLM routing, generic dispatch policy, new browser opcodes, or export auto-open behavior.
- If the original package reveals extra classification rules that are needed for parity, add them only inside `collect_fault_details.js` and its staged references/tests, not in `claw-new`.

View File

@@ -0,0 +1,808 @@
# TQ Lineloss Deterministic Skill Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a staged `tq-lineloss-report.collect_lineloss` browser-script skill plus a `。。。` deterministic submit path in `claw-new` that extracts and normalizes company/month/week parameters without LLM, executes through the existing pipe browser-script seam, and does not regress Zhihu hotlist behavior.
**Architecture:** Keep the new behavior behind a narrow deterministic branch that activates only when the raw instruction ends with the exact suffix `。。。`. `claw-new` owns deterministic trigger detection, explicit scene matching, semantic extraction, canonical normalization, prompt-or-execute control flow, and artifact interpretation; the staged skill owns page inspection, source/API collection, row normalization, export/report-log behavior, and final artifact generation. Reuse the existing `browser_script` execution seam already used by the direct browser path so the backend can later swap from pipe to ws without changing the deterministic contract.
**Tech Stack:** Rust 2021, Cargo tests, existing `BrowserPipeTool` / `execute_browser_script_tool` seam, staged skill packaging under `claw/claw/skills/skill_staging`, browser-side JavaScript, deterministic string parsing and normalization.
---
## Execution Context
- Follow @superpowers:test-driven-development for every behavior change.
- Follow @superpowers:verification-before-completion before claiming each task is done.
- Do **not** create a git worktree unless the user explicitly asks.
- Keep the new behavior as a narrow branch; do **not** redesign the whole runtime into a general registry engine in this slice.
- Preserve `src/runtime/engine.rs:147-159` and `src/runtime/engine.rs:265-286` behavior unless a failing regression test proves a change is required.
- Do **not** add ws runtime requirements on `main`; keep ws-readiness isolated to backend-neutral contracts only.
- Never fall back to page defaults for missing company, mode, or period in deterministic mode.
- If a deterministic request does not match the lineloss whitelist scene, return a deterministic mismatch prompt instead of falling through to ordinary orchestration.
## File Map
### New or modified files in `claw-new`
- Create: `src/compat/deterministic_submit.rs`
- suffix detection, deterministic scene match, prompt-or-execute decision
- Create: `src/compat/tq_lineloss/mod.rs`
- public normalization and artifact helpers
- Create: `src/compat/tq_lineloss/contracts.rs`
- canonical request/result data structures and status semantics
- Create: `src/compat/tq_lineloss/org_resolver.rs`
- alias generation, canonical label/code resolution, ambiguity handling
- Create: `src/compat/tq_lineloss/period_resolver.rs`
- month/week extraction, contradiction detection, canonical payload building
- Create: `src/compat/tq_lineloss/org_units.rs`
- checked-in canonical unit dictionary derived from the real source tree data
- Modify: `src/compat/mod.rs`
- export the deterministic and lineloss modules
- Modify: `src/agent/mod.rs`
- insert the deterministic branch before ordinary LLM interpretation, but only when the exact suffix is present
- Modify only if code duplication would otherwise occur: `src/compat/direct_skill_runtime.rs`
- extract narrow shared browser-script execution helpers without changing current configured direct-submit behavior
- Read but avoid changing unless tests force it: `src/runtime/engine.rs`
- existing Zhihu hotlist routing/prompt logic must remain intact
### New staged skill package in `claw`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.md`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.toml`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/collection-flow.md`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/data-quality.md`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/assets/scene-snapshot/index.html`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js`
- Create if staging conventions require it: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/tq-lineloss-report/scene.json`
### Tests
- Create: `tests/deterministic_submit_test.rs`
- Modify: `tests/compat_runtime_test.rs`
- Modify only if end-to-end submit coverage requires it: `tests/runtime_task_flow_test.rs`
---
## Locked contracts
### Deterministic trigger contract
- Trigger only when the raw instruction ends with the exact suffix `。。。`.
- No suffix: current behavior unchanged.
- Suffix + unsupported scene: explicit deterministic mismatch prompt.
- Suffix is not permission for arbitrary browser actions; only fixed deterministic scenes are allowed.
- Negative cases must stay non-deterministic or mismatched exactly as designed:
- ASCII `...` is not the trigger
- `。。。。` is not the trigger
- `。。。` appearing in the middle of the instruction is not the trigger
- any trailing whitespace after `。。。` is not the trigger in this slice
### Canonical org contract
The resolver must output both display and backend values:
```rust
pub struct ResolvedOrg {
pub label: String,
pub code: String,
}
```
Required supported inputs include:
- `兰州公司`
- `天水公司`
- `国网兰州供电公司`
- `城关供电分公司`
- `榆中县供电公司`
- normalized shorthand such as `榆中县公司`
Rules:
- derive aliases from the real unit tree data
- require uniqueness before execution
- ambiguous aliases prompt and stop
- missing company prompts and stop
### Canonical period contract
```rust
pub enum PeriodMode {
Month,
Week,
}
pub struct ResolvedPeriod {
pub mode: PeriodMode,
pub mode_code: String,
pub value: String,
pub payload: serde_json::Value,
}
```
Required supported inputs include:
- `月累计 2026-03`
- `月累计 2026年3月`
- `周累计 2026年第12周`
Rules:
- month and week intent are mutually exclusive
- missing mode prompts and stop
- missing period prompts and stop
- bare `第12周` is incomplete in this slice and must prompt for year instead of guessing
- derive the real backend `period_mode_code` values and request payload field names from the source page/API contract before implementation; do not ship placeholder enum echoes such as `month`/`week` unless the source materials prove those are the real backend codes
- never use page-selected defaults in deterministic mode
### Artifact contract
Lock the field names now so `claw-new` can interpret status without re-embedding business logic:
```json
{
"type": "report-artifact",
"report_name": "tq-lineloss-report",
"status": "ok",
"org": {
"label": "国网兰州供电公司",
"code": "008df5db70319f73e0508eoac23e0c3c"
},
"period": {
"mode": "month",
"mode_code": "<real-backend-mode-code>",
"value": "2026-03",
"payload": {
"<real-backend-field>": "<real-backend-value>"
}
},
"columns": [],
"rows": [],
"counts": {
"rows": 0
},
"export": {
"attempted": false,
"status": "skipped",
"message": null
},
"reasons": []
}
```
Status mapping in `claw-new`:
- `ok` -> task success
- `partial` -> task success with partial summary
- `blocked` -> task failure
- `error` -> task failure
---
### Task 1: Scaffold the staged skill package and written contract
**Files:**
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.md`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.toml`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/collection-flow.md`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/data-quality.md`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/assets/scene-snapshot/index.html`
- Create if required: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/tq-lineloss-report/scene.json`
- [ ] **Step 1: Write the failing package contract files**
Create the package using `fault-details-report` as the structure reference. Lock one tool only:
```toml
[[tools]]
name = "collect_lineloss"
kind = "browser_script"
description = "Collect 台区线损月/周累计线损率 rows using normalized company and period parameters and return a structured report artifact."
```
Declare required args in `SKILL.toml`:
- `expected_domain`
- `org_label`
- `org_code`
- `period_mode`
- `period_mode_code`
- `period_value`
- `period_payload`
- [ ] **Step 2: Write `SKILL.md` before implementation**
Document:
- when to use / when not to use
- required normalized args only
- blocked/error semantics
- exact returned artifact fields
- no raw natural-language values passed to backend requests
- [ ] **Step 3: Write the reference docs**
`references/collection-flow.md` must describe:
- relevant page state
- month request mapping
- week request mapping
- export/report-log flow if retained
`references/data-quality.md` must define:
- canonical output columns
- required field coverage
- status semantics
- partial/error rules
- org/period normalization assumptions
- [ ] **Step 4: Add scene metadata if the current staging registry needs it**
Keep it narrow: one scene, one tool, one artifact type.
- [ ] **Step 5: Add an automated staged-skill load/resolve check**
Add `tests/deterministic_submit_test.rs` coverage that loads the staged skills root used by runtime tests, resolves `tq-lineloss-report.collect_lineloss`, and asserts the tool is discoverable with the required args:
- `expected_domain`
- `org_label`
- `org_code`
- `period_mode`
- `period_mode_code`
- `period_value`
- `period_payload`
Run:
```bash
cargo test deterministic_submit_discovers_tq_lineloss_skill_contract -- --exact
```
Expected: FAIL before the package is fully wired, PASS once the staged skill contract is discoverable and complete.
- [ ] **Step 6: Verify structural parity with `fault-details-report`**
Run a manual file-layout diff and confirm there are no placeholder descriptions or missing required docs.
- [ ] **Step 7: Commit**
```bash
git add "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report" "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/tq-lineloss-report/scene.json"
git commit -m "feat: scaffold tq lineloss staged skill contract"
```
---
### Task 2: Add browser-side JS red tests and implement the staged collector
**Files:**
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js`
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js`
- [ ] **Step 1: Write the failing JS tests first**
Cover deterministic pure helpers for:
- missing normalized args -> blocked/error contract
- month request shape uses `org_code` + canonical month payload
- week request shape uses `org_code` + canonical week payload
- artifact field names and counts
- partial/error status shaping
- no raw user-entered org text leakage into request fields
Example test skeleton:
```javascript
const test = require('node:test');
const assert = require('node:assert/strict');
const {
validateArgs,
buildMonthRequest,
buildWeekRequest,
normalizeRows,
buildArtifact
} = require('./collect_lineloss.js');
test('buildMonthRequest uses canonical org code and month payload', () => {
const request = buildMonthRequest({
org_code: 'ORG-1',
period_payload: { year: 2026, month: 3 }
});
assert.equal(request.orgCode, 'ORG-1');
assert.equal(request.year, 2026);
assert.equal(request.month, 3);
});
test('buildArtifact locks field names and partial semantics', () => {
const artifact = buildArtifact({
org_label: '国网兰州供电公司',
org_code: 'ORG-1',
period_mode: 'month',
period_mode_code: 'month',
period_value: '2026-03',
period_payload: { year: 2026, month: 3 },
rows: [{ id: 1 }],
status: 'partial',
reasons: ['export_failed']
});
assert.equal(artifact.report_name, 'tq-lineloss-report');
assert.equal(artifact.org.code, 'ORG-1');
assert.equal(artifact.period.value, '2026-03');
assert.deepEqual(artifact.reasons, ['export_failed']);
});
```
- [ ] **Step 2: Run the JS test file to confirm failure**
Run:
```bash
node --test "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
```
Expected: FAIL because the script/helpers do not exist yet.
- [ ] **Step 3: Write the minimal browser-side implementation**
Required structure:
```javascript
function validateArgs(args) { /* require normalized canonical args */ }
function buildMonthRequest(args) { /* build month request from canonical values */ }
function buildWeekRequest(args) { /* build week request from canonical values */ }
function normalizeRows(rawRows) { /* canonical columns only */ }
function buildArtifact(input) { /* locked artifact shape */ }
return (async () => {
const args = __SKILL_ARGS__;
validateArgs(args);
// validate page context
// collect from page/API
// normalize rows
// optionally attempt export/report-log if the real business flow requires it
return buildArtifact(result);
})();
```
Keep test exports behind an environment-safe guard so the file still works as browser-eval code.
- [ ] **Step 4: Re-run the JS tests until they pass**
Run:
```bash
node --test "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js" "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
git commit -m "feat: add tq lineloss browser collection script"
```
---
### Task 3: Add deterministic suffix detection and explicit scene routing
**Files:**
- Create: `src/compat/deterministic_submit.rs`
- Modify: `src/compat/mod.rs`
- Modify: `src/agent/mod.rs`
- Create: `tests/deterministic_submit_test.rs`
- [ ] **Step 1: Write failing routing tests**
Add Rust tests for:
- exact raw `。。。` suffix enables deterministic mode
- no suffix leaves current routing untouched
- suffix + unsupported deterministic request returns supported-scene prompt
- when page URL/title context is available and does not match the lineloss scene, deterministic routing returns mismatch/block prompt instead of proceeding
- Zhihu hotlist request without suffix keeps the current route
- ASCII `...` does not trigger deterministic mode
- `。。。。` does not trigger deterministic mode
- `。。。` in the middle of the instruction does not trigger deterministic mode
- trailing whitespace after `。。。` does not trigger deterministic mode in this slice
Suggested tests:
```rust
#[test]
fn deterministic_submit_requires_exact_suffix() {}
#[test]
fn deterministic_submit_nonmatch_returns_supported_scene_message() {}
#[test]
fn deterministic_submit_rejects_page_context_mismatch() {}
#[test]
fn zhihu_hotlist_request_without_suffix_keeps_existing_route() {}
#[test]
fn deterministic_submit_rejects_non_exact_suffix_variants() {}
```
- [ ] **Step 2: Run the targeted routing tests and confirm failure**
Run:
```bash
cargo test deterministic_submit_requires_exact_suffix -- --exact
cargo test deterministic_submit_nonmatch_returns_supported_scene_message -- --exact
cargo test zhihu_hotlist_request_without_suffix_keeps_existing_route -- --exact
```
Expected: FAIL because the deterministic routing seam does not exist yet.
- [ ] **Step 3: Implement the narrow deterministic routing module**
Recommended public shape:
```rust
pub enum DeterministicSubmitDecision {
NotDeterministic,
Prompt { summary: String },
Execute(DeterministicExecutionPlan),
}
```
`src/agent/mod.rs` should:
1. detect deterministic suffix
2. if not deterministic, continue current flow untouched
3. if prompt, return `TaskComplete`
4. if execute, pass the plan into the browser-script execution seam
- [ ] **Step 4: Re-run the routing tests**
Run:
```bash
cargo test deterministic_submit_requires_exact_suffix -- --exact
cargo test deterministic_submit_nonmatch_returns_supported_scene_message -- --exact
cargo test zhihu_hotlist_request_without_suffix_keeps_existing_route -- --exact
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/compat/deterministic_submit.rs src/compat/mod.rs src/agent/mod.rs tests/deterministic_submit_test.rs
git commit -m "feat: add deterministic submit routing seam"
```
---
### Task 4: Implement company/unit normalization from real source data
**Files:**
- Create: `src/compat/tq_lineloss/mod.rs`
- Create: `src/compat/tq_lineloss/contracts.rs`
- Create: `src/compat/tq_lineloss/org_resolver.rs`
- Create: `src/compat/tq_lineloss/org_units.rs`
- Modify: `tests/deterministic_submit_test.rs`
- [ ] **Step 1: Write failing org resolver tests**
Cover:
- `兰州公司` -> canonical `国网兰州供电公司` + correct code
- `天水公司` -> canonical `国网天水供电公司` + correct code
- `城关供电分公司` -> lower-level direct match
- `榆中县公司` -> normalized county alias match
- ambiguous alias prompts instead of guessing
- missing company prompts instead of executing
Example skeleton:
```rust
#[test]
fn lineloss_org_resolver_matches_city_alias() {}
#[test]
fn lineloss_org_resolver_matches_county_alias() {}
#[test]
fn lineloss_org_resolver_prompts_on_ambiguity() {}
```
- [ ] **Step 2: Run the org tests and confirm failure**
Run:
```bash
cargo test lineloss_org_resolver_matches_city_alias -- --exact
cargo test lineloss_org_resolver_matches_county_alias -- --exact
cargo test lineloss_org_resolver_prompts_on_ambiguity -- --exact
```
Expected: FAIL because the resolver and checked-in unit dictionary do not exist yet.
- [ ] **Step 3: Check in the canonical unit dictionary and implement alias resolution**
Rules:
- derive data from the real source materials, not guessed literals
- keep canonical `label` and `code`
- generate normalized aliases from formal names
- support both city-company and district/county/sub-company levels
- require uniqueness before execution
- [ ] **Step 4: Implement explicit prompt messages**
Examples:
- `已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。`
- `已命中台区线损报表技能,但供电单位存在歧义,请补充更完整名称。`
- [ ] **Step 5: Re-run the org tests**
Run:
```bash
cargo test lineloss_org_resolver_matches_city_alias -- --exact
cargo test lineloss_org_resolver_matches_county_alias -- --exact
cargo test lineloss_org_resolver_prompts_on_ambiguity -- --exact
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/compat/tq_lineloss/mod.rs src/compat/tq_lineloss/contracts.rs src/compat/tq_lineloss/org_resolver.rs src/compat/tq_lineloss/org_units.rs tests/deterministic_submit_test.rs
git commit -m "feat: add tq lineloss org normalization"
```
---
### Task 5: Implement period extraction and canonical payload building
**Files:**
- Create: `src/compat/tq_lineloss/period_resolver.rs`
- Modify: `src/compat/tq_lineloss/mod.rs`
- Modify: `tests/deterministic_submit_test.rs`
- [ ] **Step 1: Write failing period resolver tests**
Cover:
- `月累计 2026-03`
- `月累计 2026年3月`
- `周累计 2026年第12周`
- contradictory month/week expressions prompt
- missing mode prompts
- missing period prompts
- bare `第12周` prompts for year in this slice
- real backend month/week mode codes and request payload field names are derived from source materials instead of placeholder values
Example skeleton:
```rust
#[test]
fn lineloss_period_resolver_parses_month_text() {}
#[test]
fn lineloss_period_resolver_parses_week_text() {}
#[test]
fn lineloss_period_resolver_prompts_for_missing_year_on_week() {}
#[test]
fn lineloss_period_resolver_rejects_contradictory_mode() {}
```
- [ ] **Step 2: Run the period tests and confirm failure**
Run:
```bash
cargo test lineloss_period_resolver_parses_month_text -- --exact
cargo test lineloss_period_resolver_parses_week_text -- --exact
cargo test lineloss_period_resolver_prompts_for_missing_year_on_week -- --exact
cargo test lineloss_period_resolver_rejects_contradictory_mode -- --exact
```
Expected: FAIL because the period resolver does not exist yet.
- [ ] **Step 3: Implement the minimal resolver**
Output contract:
```rust
pub struct ResolvedPeriod {
pub mode: PeriodMode,
pub mode_code: String,
pub value: String,
pub payload: serde_json::Value,
}
```
Rules:
- no page-default fallback
- no implicit current-year assumptions
- no mixed month/week execution
- [ ] **Step 4: Re-run the period tests**
Run:
```bash
cargo test lineloss_period_resolver_parses_month_text -- --exact
cargo test lineloss_period_resolver_parses_week_text -- --exact
cargo test lineloss_period_resolver_prompts_for_missing_year_on_week -- --exact
cargo test lineloss_period_resolver_rejects_contradictory_mode -- --exact
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/compat/tq_lineloss/period_resolver.rs src/compat/tq_lineloss/mod.rs tests/deterministic_submit_test.rs
git commit -m "feat: add tq lineloss period normalization"
```
---
### Task 6: Wire deterministic execution through the existing browser-script seam
**Files:**
- Modify: `src/compat/deterministic_submit.rs`
- Modify: `src/agent/mod.rs`
- Modify if needed: `src/compat/direct_skill_runtime.rs`
- Modify: `tests/deterministic_submit_test.rs`
- Modify: `tests/compat_runtime_test.rs`
- [ ] **Step 1: Write failing execution tests**
Cover:
- successful deterministic lineloss request builds canonical tool args
- missing company/mode/period returns prompt without browser execution
- `partial` artifact maps to successful partial summary
- `blocked` and `error` artifacts map to failed completion
Example skeleton:
```rust
#[test]
fn deterministic_lineloss_execution_passes_canonical_args() {}
#[test]
fn deterministic_lineloss_missing_company_does_not_invoke_browser() {}
#[test]
fn deterministic_lineloss_partial_artifact_maps_to_partial_summary() {}
```
- [ ] **Step 2: Run the execution tests and confirm failure**
Run:
```bash
cargo test deterministic_lineloss_execution_passes_canonical_args -- --exact
cargo test deterministic_lineloss_missing_company_does_not_invoke_browser -- --exact
cargo test deterministic_lineloss_partial_artifact_maps_to_partial_summary -- --exact
```
Expected: FAIL because the deterministic execution plan is not wired yet.
- [ ] **Step 3: Implement execution via the existing `browser_script` seam**
Build tool args only from normalized values:
- `expected_domain`
- `org_label`
- `org_code`
- `period_mode`
- `period_mode_code`
- `period_value`
- `period_payload`
Resolve the tool explicitly to:
- `tq-lineloss-report.collect_lineloss`
Do not introduce a new browser opcode family or second browser protocol.
- [ ] **Step 4: Implement central artifact interpretation**
Recommended helper:
```rust
fn summarize_lineloss_artifact(artifact: &serde_json::Value) -> (bool, String)
```
Summary must include canonical org/period and row counts, and surface blocked/partial/error reasons.
- [ ] **Step 5: Re-run the execution tests**
Run:
```bash
cargo test deterministic_lineloss_execution_passes_canonical_args -- --exact
cargo test deterministic_lineloss_missing_company_does_not_invoke_browser -- --exact
cargo test deterministic_lineloss_partial_artifact_maps_to_partial_summary -- --exact
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/compat/deterministic_submit.rs src/agent/mod.rs src/compat/direct_skill_runtime.rs tests/deterministic_submit_test.rs tests/compat_runtime_test.rs
git commit -m "feat: execute deterministic tq lineloss skill through browser script seam"
```
---
### Task 7: Add Zhihu regression coverage and run the full verification set
**Files:**
- Modify: `tests/compat_runtime_test.rs`
- Modify only if required: `tests/runtime_task_flow_test.rs`
- Reuse: `tests/deterministic_submit_test.rs`
- [ ] **Step 1: Add focused Zhihu regression tests**
Required assertions:
- ordinary Zhihu hotlist requests without `。。。` still use the current path
- existing export/presentation requests still preserve their current behavior
- deterministic suffix does not silently route unmatched requests into Zhihu logic
- an existing non-lineloss direct `browser_script` path outside the new scene still behaves unchanged
- [ ] **Step 2: Add end-to-end deterministic submit coverage**
Required assertions:
- suffix detection
- scene match
- page-context mismatch prompt/block behavior when URL/title contradict the lineloss scene
- missing/ambiguous prompts
- canonical args passed to the browser-script tool
- returned summary shows canonical org and period
- execution stays on the existing pipe-backed browser-script seam with no ws-only dependency introduced on `main`
- [ ] **Step 3: Run the focused Rust tests**
Run:
```bash
cargo test --test deterministic_submit_test
cargo test --test compat_runtime_test
cargo test --test runtime_task_flow_test
```
Expected: PASS.
- [ ] **Step 4: Run the whole Rust suite**
Run:
```bash
cargo test
```
Expected: PASS.
- [ ] **Step 5: Re-run the staged skill JS tests**
Run:
```bash
node --test "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add tests/deterministic_submit_test.rs tests/compat_runtime_test.rs tests/runtime_task_flow_test.rs
git commit -m "test: cover deterministic tq lineloss routing and zhihu regression"
```
---
## Final verification checklist
- [ ] `。。。` is the only deterministic trigger.
- [ ] Non-`。。。` requests preserve current routing.
- [ ] Deterministic page-context mismatch blocks or mismatches before execution when URL/title contradict the lineloss scene.
- [ ] Zhihu hotlist behavior is unchanged.
- [ ] Existing non-lineloss direct `browser_script` behavior is unchanged.
- [ ] Deterministic non-match returns an explicit supported-scene message.
- [ ] Missing company prompts.
- [ ] Ambiguous company prompts.
- [ ] Missing mode prompts.
- [ ] Missing period prompts.
- [ ] Bare `第12周` prompts for year.
- [ ] Canonical org code is passed to the staged skill.
- [ ] Canonical period mode code and payload are passed to the staged skill.
- [ ] The staged skill returns the locked artifact shape.
- [ ] Execution uses the existing `browser_script` seam only.
- [ ] No ws-specific runtime dependency is added on `main`.
## Implementation notes
- Prefer extracting a tiny shared execution helper from `src/compat/direct_skill_runtime.rs` if needed instead of duplicating tool lookup or browser-script invocation code.
- Keep deterministic whitelist configuration in one place, but do not expand this slice into a full general scene-registry redesign.
- If a failing test suggests changing Zhihu behavior, fix the deterministic branch or test harness instead of weakening the existing Zhihu path.
- The checked-in unit dictionary is part of the deterministic contract; treat updates to that data as explicit behavior changes and cover them with tests.

View File

@@ -0,0 +1,125 @@
# Config-Owned Direct Skill Dispatch Design
**Goal:** Preserve the current minimal submit flow where sgClaw accepts natural-language input, directly invokes one configured staged browser skill without calling an LLM, and keeps dispatch ownership in sgClaw configuration rather than external skill metadata.
**Status:** Approved design direction for the next slice. The current minimal direct-submit path already works; this document records the ownership boundary that future dispatch-policy work should follow.
---
## Decision Summary
1. Keep direct-skill selection in sgClaw configuration.
2. Continue using `skillsDir` plus `directSubmitSkill` as the only control surface for the no-LLM direct path.
3. Do not add sgClaw-specific dispatch fields to files under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging` in this slice.
4. Keep the currently bound skill as `fault-details-report.collect_fault_details`.
5. When dispatch expands beyond one fixed skill, add the next policy layer on the sgClaw side first, not in `scene.json` or `SKILL.toml`.
---
## Current Minimal Flow
The intended user experience stays unchanged:
- the user types natural language into the input box
- sgClaw receives `BrowserMessage::SubmitTask`
- sgClaw loads runtime config
- if `directSubmitSkill` is configured, sgClaw bypasses LLM routing and directly resolves the configured staged skill from `skillsDir`
- sgClaw executes the target `browser_script` tool through the browser runtime and returns the result
- if `directSubmitSkill` is absent, sgClaw falls back to the existing orchestration / compat behavior
This keeps the first slice small while preserving a clear seam for future expansion.
---
## Ownership Boundary
### sgClaw configuration owns dispatch choice
sgClaw configuration is responsible for deciding whether submit-task should bypass the LLM path and which direct skill should run.
For the current slice, that means:
- `skillsDir` tells sgClaw where to load staged skills from
- `directSubmitSkill` tells sgClaw which `skill.tool` should be used for the direct path
Example:
```json
{
"skillsDir": "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging",
"directSubmitSkill": "fault-details-report.collect_fault_details"
}
```
### skill_staging owns skill identity and execution assets
Files under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging` remain responsible for describing the skill package, tool identity, and browser-script implementation.
For the current bound skill:
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/fault-details-report/scene.json`
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.toml`
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
These files already provide enough information for sgClaw to locate the package and run the tool. This slice does not add a new dispatch field inside them.
---
## Why This Boundary Is Recommended
### One source of truth for routing
If sgClaw configuration owns the direct-skill decision, the operator can switch the direct skill by changing config only. There is no need to edit code and no need to mutate external skill assets just to change routing.
### Avoid freezing external manifest semantics too early
`skill_staging` is an external skill asset set. Adding sgClaw-specific dispatch metadata now would couple the staged-skill format to one integration strategy before the policy model is stable.
### Preserve a clean migration path
The current minimal path is intentionally narrow: one fixed configured direct skill, no LLM dispatch, no per-skill policy registry yet. Keeping dispatch control in sgClaw makes it easier to add a broader policy layer later without rewriting the staged-skill package format first.
---
## Explicit Non-Goals
This design does not do the following:
- redesign the submit-task protocol
- move dispatch control into `scene.json` or `SKILL.toml`
- require every staged skill to declare `direct_browser` or `llm_agent` right now
- expand the current direct path into generic natural-language intent classification
- change the browser-script execution model
- change the current fallback orchestration / compat execution semantics when `directSubmitSkill` is not configured
---
## Current Skill Contract
The current direct path remains intentionally deterministic.
For `fault-details-report.collect_fault_details`, sgClaw derives only the minimum required arguments:
- `expected_domain` from the current `page_url`
- `period` from an explicit `YYYY-MM` token in the user's natural-language input
That means the UX still looks like natural-language submission, but the runtime does not ask an LLM to infer intent or invent missing parameters. If the period is missing, sgClaw should return a clear error instead of guessing.
---
## Future Dispatch Policy Direction
When more than one staged skill needs routing control, the next layer should still begin on the sgClaw side.
Recommended direction:
- keep `directSubmitSkill` as the current bootstrap switch for the minimal fixed-skill path
- introduce a sgClaw-owned registry or config mapping that can later express `skill.tool -> direct_browser | llm_agent`
- keep external skill manifests unchanged until the policy surface proves stable in real use
Only after the routing model is stable should we consider whether external skill metadata needs a default dispatch hint.
---
## Resulting Design Rule
For this project, the direct-skill decision remains config-owned:
- sgClaw config decides whether submit-task bypasses the LLM path
- staged skill metadata identifies what the skill is and how its browser tool runs
- future per-skill dispatch policy should be added in sgClaw first, not in `skill_staging`
This is the approved baseline for the next dispatch-policy slice.

View File

@@ -0,0 +1,495 @@
# Fault Details Full Skill Alignment Design
**Goal:** Upgrade `fault-details-report.collect_fault_details` from an empty artifact shell into a real staged business skill that matches the original fault-details package's collection, normalization, summary, export, and report-history behavior, while keeping direct-skill routing config-owned in `claw-new`.
**Status:** Approved design direction for the next remediation slice.
---
## Decision Summary
1. Keep direct-skill selection in `claw-new` via `skillsDir` + `directSubmitSkill`; do not move dispatch ownership into `skill_staging` manifests.
2. Put the fault-details business logic in `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging`, not in `claw-new`.
3. Align the staged skill with the original package's real behavior: query raw rows, normalize detail columns, derive summary rows, call localhost export, and write report history.
4. Keep the current browser-execution seam narrow: use the existing `browser_script` / browser-eval path, not a new browser protocol or new opcodes.
5. Add a narrow artifact interpreter in `claw-new` so structured fault-results map cleanly to `TaskComplete.success` and a readable completion summary.
---
## Why This Slice Exists
The current staged skill contract and the current staged skill implementation do not match.
### What the original package actually does
The original package under `D:/desk/智能体资料/大四区报告监测项/故障明细` does all of the following:
- reads the selected date range from the page UI
- queries the D4 repair-order data source
- filters and normalizes raw rows into the canonical detail export schema
- derives grouped summary rows by `gds`
- calls `http://localhost:13313/SurfaceServices/personalBread/export/faultDetailsExportXLSXS`
- auto-opens/downloads the generated file
- writes report history through `http://localhost:13313/ReportServices/Api/setReportLog`
### What the staged skill currently does
The current staged `collect_fault_details.js` only returns an empty `report-artifact` shell with empty `rows` and empty summary `sections`.
It also still uses a Node-style export shape instead of the browser-eval entrypoint shape that the current `browser_script` runtime expects. In practice, this means the staged script is not yet aligned with the real runtime contract even before business behavior is considered.
This slice closes that gap by making the staged skill actually perform the work the original package performs, but through the current sgClaw direct-skill runtime.
---
## Design Rules
### 1. `claw-new` owns routing, not business transforms
`claw-new` stays responsible for:
- loading config
- deciding whether submit-task takes the direct-skill path
- resolving the configured staged skill
- executing the staged browser-script tool
- turning the returned artifact into `TaskComplete.success` + human-readable summary
`claw-new` must **not** become the place where the original fault classification table, detail-row field mapping, or summary aggregation rules are reimplemented.
### 2. `skill_staging` owns fault-details business behavior
The staged skill package owns:
- query orchestration inside the browser page context
- raw-row extraction
- canonical detail-row normalization
- classification and derived fields
- summary-sheet derivation
- localhost export request
- localhost report-log request
- structured result payload
### 3. Keep the current browser seam narrow
Do not introduce a new browser bridge, callback protocol, or skill-specific browser opcode for this slice.
The implementation should continue using the current `browser_script` execution seam already wired through `claw-new/src/compat/browser_script_skill_tool.rs` and `claw-new/src/compat/direct_skill_runtime.rs`.
### 4. Match business behavior, not the original shell verbatim
The original package is a local HTML/Vue shell that uses `BrowserAction(...)`, timers, and hidden-browser choreography. That shell does **not** need to be recreated inside `claw-new`.
What must be preserved is the business outcome:
- same canonical detail columns
- same key field mappings
- same classification rules
- same summary metrics
- same downstream export/history behavior
- same distinction between empty, partial, blocked, and failed work
---
## Ownership Boundary and Landing Zones
### Staged skill changes
These changes land in `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging`.
Primary files:
- `skills/fault-details-report/scripts/collect_fault_details.js`
- becomes the real browser-eval entrypoint
- must directly `return` the final structured artifact from the wrapped browser script
- may contain internal helper functions, but should remain self-contained for the current runtime
- `skills/fault-details-report/SKILL.toml`
- keep `browser_script`
- tighten the tool description so it matches the real behavior
- do not turn `SKILL.toml` into the source of truth for classification rules or routing policy
- `skills/fault-details-report/SKILL.md`
- align the written contract with the implemented runtime behavior
- `skills/fault-details-report/references/collection-flow.md`
- align the staged flow with the implemented query/export/history sequence
- `skills/fault-details-report/references/data-quality.md`
- stay authoritative for canonical columns, required fields, classification tables, `qxxcjl`-based reason heuristics, summary rules, and partial semantics
- `scenes/fault-details-report/scene.json`
- keep the scene contract aligned with the actual output and state semantics
- do not move classification or routing policy into scene metadata
### Caller/runtime changes
These changes land in `D:/data/ideaSpace/rust/sgClaw/claw-new`.
Primary files:
- `src/compat/direct_skill_runtime.rs`
- keep configured direct-skill execution here
- add narrow structured-artifact interpretation after the browser-script returns
- `src/agent/mod.rs`
- keep the current direct-submit routing seam here
- do not add fault-specific business logic here
- `src/compat/browser_script_skill_tool.rs`
- keep the browser-script contract strict: browser-eval entrypoint, no Node-only assumptions
- `tests/agent_runtime_test.rs`
- direct-submit path and result-surface regressions
- `tests/browser_script_skill_tool_test.rs`
- browser-script execution-shape regressions
If a new helper is needed in `claw-new`, it should be a narrow artifact-format/parser helper, not a new business-rules module.
---
## Target Runtime Flow
### Step 1: Submit-task stays config-owned
The user still types natural language into the current sgClaw input.
`claw-new`:
- receives `BrowserMessage::SubmitTask`
- loads `SgClawSettings`
- sees `directSubmitSkill = "fault-details-report.collect_fault_details"`
- bypasses LLM routing exactly as it does now
- resolves the staged skill from `skillsDir`
This preserves the already approved config-owned routing boundary.
### Step 2: Browser-script tool executes as a true browser entrypoint
`collect_fault_details.js` must be shaped for the current runtime:
- the script runs inside the current browser page context through `eval`
- it must not rely on `module.exports`
- it must directly `return collectFaultDetails(args)` from the wrapped script body
This is required because the current sgClaw browser-script runtime reads one script file and wraps it in a browser-side IIFE.
### Step 3: The skill reads the page-selected time range
The source-of-truth query window should come from the current page state, matching the original package behavior.
Design rule:
- read the selected start and end time from the business page controls or page state
- include that exact selected range in the returned artifact
- keep `period` as a bootstrap label from `claw-new`, not as a license to silently guess a different business range
Compatibility rule with the current direct-submit seam:
- the current `claw-new` direct path still requires an explicit `YYYY-MM` token in the user's instruction in order to enter the configured direct-skill flow
- that requirement remains in place for this slice
- once inside the skill, the browser page's selected start/end range is the source of truth for collection
- the returned artifact should include both the user-visible `period` label and the exact selected page range so mismatches are observable instead of hidden
If the page-selected range cannot be read reliably, the skill should return `blocked` instead of inventing a month-wide query window from `period` alone.
### Step 4: The skill collects raw rows and normalizes detail fields
The staged skill must reproduce the original package's detail normalization logic inside the browser-executed script.
That includes preserving the canonical detail schema from the original `excleIni[0].cols`, including the key transforms already present in the original package, such as:
- `slsj = bxsj`
- `gssgs = "甘肃省电力公司"`
- `sgs` derived from the current company/city context
- `gddw = maintOrgName`
- `gds = maintGroupName`
- `clzt = "处理完成"`
- `bdz = bdzMc`
- `line = xlmc10`
- `pb = byqmc`
The staged skill must also port the original classification/derivation logic that fills:
- `sxfl1`
- `sxfl2`
- `sxfl3`
- `gzsb`
- `gzyy`
That includes the original matching table and the `qxxcjl`-based text extraction heuristics that derive the fault reason.
### Step 5: The skill derives summary rows from normalized detail rows
The staged skill must derive the summary sheet from grouped detail rows, keyed around the same business totals the original package computes.
At minimum that includes:
- `index`
- `gsName`
- `fwDept`
- `className`
- `allCount`
- `wxCount`
- `khcCount`
- `sbdSbCount`
- `gyGzCount`
- `dyGzCount`
- `tqdzCount`
- `tqbxCount`
- `dyxlCount`
- `bqxCount`
- `jllCount`
- `bhxCount`
- `qftdCount`
The summary derivation must stay in the staged skill so the same package can later be routed by LLM without moving business logic back into `claw-new`.
### Step 6: The skill performs downstream export and report logging
After detail rows and summary rows are available, the staged skill should reproduce the original package's downstream behavior:
- build the export payload for `faultDetailsExportXLSXS`
- call the localhost export endpoint
- capture the returned export path/URL
- write report history via `setReportLog`
Important boundary:
- export/report-log are downstream side effects
- they do not redefine whether collection itself succeeded
- if collection succeeds but export/logging fails, the result is `partial`, not a full collection failure
- auto-opening/downloading the exported file is out of scope for this slice; this slice records the export path/result in the artifact but does not add new opener/UI behavior in `claw-new`
### Step 7: The skill returns one structured artifact
The staged skill should return one self-describing JSON artifact containing:
- business identity (`type`, `report_name`)
- selected period label
- exact selected start/end range
- canonical detail columns + normalized rows
- summary section columns + rows
- counts
- business status
- partial reasons if any
- downstream export outcome
- downstream report-log outcome
### Step 8: `claw-new` interprets the artifact, not the business rules
After the browser-script returns, `claw-new` should parse the JSON artifact and map it into final submit-task behavior.
Recommended mapping:
- `status = ok` -> `TaskComplete.success = true`
- `status = partial` -> `TaskComplete.success = true`, with warnings in summary
- `status = empty` -> `TaskComplete.success = true`, clearly reported as empty-result
- `status = blocked` -> `TaskComplete.success = false`
- `status = error` -> `TaskComplete.success = false`
This keeps business classification in the staged skill while preventing false-positive success in the direct path.
---
## Artifact Contract
The returned payload should stay `type = "report-artifact"`, but it must become rich enough to describe the real run.
Recommended contract:
```json
{
"type": "report-artifact",
"report_name": "fault-details-report",
"period": "2026-03",
"selected_range": {
"start": "2026-03-08 16:00:00",
"end": "2026-03-09 16:00:00"
},
"columns": ["qxdbh", "gssgs", "sgs", "gddw", "gds", "slsj", "yjflMc", "ejflMc", "sjflMc", "gzms", "yhbh", "yhmc", "lxr", "gzdd", "lxdh", "bxsj", "gdsj", "clzt", "qxxcjl", "bdz", "line", "pb", "sxfl1", "sxfl2", "sxfl3", "gzsb", "gzyy", "bz"],
"rows": [],
"sections": [
{
"name": "summary-sheet",
"columns": ["index", "gsName", "fwDept", "className", "allCount", "wxCount", "khcCount", "sbdSbCount", "gyGzCount", "dyGzCount", "tqdzCount", "tqbxCount", "dyxlCount", "bqxCount", "jllCount", "bhxCount", "qftdCount"],
"rows": []
}
],
"counts": {
"detail_rows": 0,
"summary_rows": 0
},
"status": "ok",
"partial_reasons": [],
"downstream": {
"export": {
"attempted": true,
"success": true,
"path": "http://localhost:13313/.../fault-details.xlsx"
},
"report_log": {
"attempted": true,
"success": true,
"report_name": "国网XX故障报修明细表(03月09日)",
"path": "http://localhost:13313/.../fault-details.xlsx"
}
}
}
```
### Contract notes
- `rows` is the canonical returned detail table, not the export-service transport payload.
- If the export service still requires a placeholder row for an empty spreadsheet, that placeholder should be synthesized only for the downstream export call, not as the canonical returned `rows` contract.
- `counts` should be computed from the canonical returned tables.
- `selected_range`, `columns`, `sections`, `counts`, `status`, and `partial_reasons` should always be present for `ok`, `partial`, and `empty`.
- For `blocked` and `error`, the artifact should still include `type`, `report_name`, `period`, `status`, and `partial_reasons`; `selected_range`, `columns`, `sections`, and `counts` should be included whenever they were already known before the failure point.
- `downstream` should be omitted only when export/report-log were not attempted yet; otherwise include it with `attempted` / `success` flags and any available path or failure detail.
---
## Error Handling and Status Semantics
### `ok`
Use `ok` when all of the following are true:
- raw collection succeeded
- required detail-field normalization succeeded
- summary derivation succeeded
- export succeeded
- report-log write succeeded
### `partial`
Use `partial` when detail collection succeeded but at least one downstream stage degraded, including:
- one or more required fields could not be normalized, but the row set still remains exportable and summary derivation can proceed with explicit gaps recorded
- summary derivation was incomplete, but the detail table is still available
- export failed after rows were available
- report-log write failed after rows/export were available
Escalation rule:
- if the raw query succeeds but required fields are missing so broadly that the canonical detail table cannot be produced at all, use `error`, not `partial`
- if summary derivation cannot even start because the normalized detail rows are structurally unusable, use `error`, not `partial`
`partial_reasons` must name the degraded stage instead of hiding it.
### `empty`
Use `empty` when:
- the query succeeds for the selected range
- zero real detail rows match
This is not a failure.
If the business flow still wants an empty export file or placeholder export payload, that happens downstream without changing the semantic meaning of the result.
### `blocked`
Use `blocked` when the page/session preconditions are not met, for example:
- expected page/session is not available
- required page controls cannot be read
- login/session state is missing or expired
- required browser-visible APIs are unavailable in the current page context
### `error`
Use `error` when the run starts but fails due to operational or parsing problems, for example:
- request failure
- page script failure
- raw response parse failure
- malformed export response
### `claw-new` completion mapping
`claw-new` should convert structured status into final submit completion behavior:
- `ok` / `partial` / `empty`: return a success completion with a concise human summary
- `blocked` / `error`: return a failed completion with a concise human summary
This avoids the current risk where a structured error-like payload could still be surfaced as a nominal success string.
---
## Testing and Acceptance Strategy
### Skill-side deterministic coverage
Add deterministic coverage around the staged skill's business logic in `skill_staging` for:
- canonical detail field mapping
- classification table parity
- `gzyy` extraction heuristics
- summary aggregation parity
- empty-result handling
- partial-result generation when downstream export/logging fails
- browser-script entrypoint shape (`return ...`, not `module.exports`)
The classification/summary tests should use fixed raw-row fixtures so the business rules are validated without a live browser session.
### `claw-new` runtime regressions
Add Rust coverage in `claw-new` for:
- direct-submit success with a populated `report-artifact`
- `partial` artifact mapping to `TaskComplete.success = true`
- `empty` artifact mapping to `TaskComplete.success = true`
- `blocked` / `error` artifact mapping to `TaskComplete.success = false`
- browser-script helper behavior for a real browser-eval return payload
### Manual acceptance
The live manual acceptance bar for this slice should be:
1. Configure `skillsDir` to the staged skill root and `directSubmitSkill` to `fault-details-report.collect_fault_details`.
2. Attach sgClaw to the real target browser page/session.
3. Submit a natural-language fault-details request without LLM routing.
4. Verify the staged skill:
- reads the selected page range
- queries real fault rows
- produces populated detail rows
- produces populated summary rows
- exports the workbook through localhost
- writes report history
5. Verify the final sgClaw completion message reports the correct status, counts, and downstream file/log outcome.
### Acceptance matrix
At minimum, acceptance should cover:
- normal populated result
- empty result with no matching rows
- partial result where export or report-log fails after collection
- blocked result where page/session preconditions are missing
- error result where parsing/query execution fails
---
## Explicit Non-Goals
This slice does **not**:
- move routing ownership out of `claw-new`
- require LLM routing to be available first
- add per-skill dispatch metadata to external manifests for routing policy
- introduce a new browser protocol or browser opcode
- recreate the original Vue shell inside `claw-new`
- move fault classification logic into Rust
- redesign the submit-task protocol beyond better interpretation of the returned artifact
---
## Resulting Design Rule
For the fault-details path:
- `claw-new` decides whether to invoke the fixed staged skill
- the staged skill performs the real fault business workflow
- the staged skill returns a structured artifact that describes collection + downstream outcomes
- `claw-new` interprets that artifact for submit-task success/failure and summary output
That keeps routing config-owned, keeps business logic with the staged skill, and makes `fault-details-report.collect_fault_details` ready for both the current no-LLM path and a later LLM-routed path.
---
## Document Landing Zones
- Approved spec: `docs/superpowers/specs/2026-04-10-fault-details-full-skill-alignment-design.md`
- Follow-up implementation plan: `docs/superpowers/plans/2026-04-10-fault-details-full-skill-alignment-plan.md`

View File

@@ -0,0 +1,618 @@
# TQ Line-Loss Deterministic Skill Design
**Goal:** Add a staged business skill for `台区线损大数据-月_周累计线损率统计分析` and a deterministic natural-language routing path in `claw-new` that can bypass LLM when the instruction ends with `。。。`, while preserving the existing Zhihu hotlist behavior and keeping the execution seam pipe-first but ws-ready.
**Status:** Approved design direction for implementation planning.
---
## Decision Summary
1. Add a new staged skill package `tq-lineloss-report` under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/`, following the same packaging discipline as `fault-details-report`.
2. In `claw-new`, add a deterministic submit path triggered only when the instruction ends with the three-Chinese-dot suffix `。。。`.
3. In deterministic mode, route only through a fixed whitelist of staged skills; for this slice the new target is `tq-lineloss-report.collect_lineloss`.
4. Deterministic mode must extract business parameters from natural language without using an LLM: company/unit, month-vs-week mode, and period text.
5. Parsed natural-language parameters are not the final backend parameters. They must be normalized into the canonical codes required by the source page / source APIs (for example company code and period mode code).
6. If required parameters are missing or ambiguous, the runtime must stop and ask the user to provide them explicitly. It must **not** silently fall back to page defaults in this slice.
7. Skill execution must reuse the existing browser-script → pipe injection seam already proven by the Zhihu hotlist path. Do not create a second browser execution protocol.
8. The design must not regress or weaken the existing Zhihu hotlist direct path, browser-script path, export path, or current routing behavior.
9. The main branch implementation remains pipe-only, but all new deterministic-routing and skill contracts must stay backend-neutral so the execution backend can later be swapped to ws on the ws branch.
---
## Non-Negotiable Boundaries
### 1. Do not break the existing Zhihu hotlist flow
This is the top safety boundary for the slice.
The new deterministic routing for `tq-lineloss-report` must not break, narrow, or silently change:
- current Zhihu hotlist routing
- current Zhihu direct browser-script execution
- current Zhihu export behavior
- current browser-script skill loading/execution
- existing direct-submit configuration behavior
Design implication:
- The new deterministic path must be added as a narrow, explicit branch.
- Existing Zhihu logic must keep its current trigger semantics and current execution seam.
- Verification for this slice must include targeted Zhihu regression coverage before implementation is considered complete.
### 2. Current main branch is pipe-only
The implementation landing on `main` must execute browser-script skills through the current pipe-backed browser execution seam.
Do not introduce ws as an active runtime requirement for this slice.
### 3. Future ws migration must stay cheap
Although `main` remains pipe-only, the new work must leave a clean extension seam so that after this slice is merged into `ws`, the browser backend can be switched without redesigning:
- the staged skill package
- the deterministic trigger contract
- the parameter extraction contract
- the parameter normalization contract
- the returned artifact contract
---
## Why This Slice Exists
The user wants a staged business skill for `台区线损大数据-月_周累计线损率统计分析` that behaves like a deterministic business operation, not a free-form LLM task.
The desired operator experience is:
- ordinary instructions continue to use the current normal routing / LLM path
- an instruction ending in `。。。` switches to deterministic business execution
- deterministic execution targets a fixed staged skill
- business parameters are extracted from the instruction
- those parameters are normalized to the real coded values the source page/API needs
- the staged browser-script is injected into the third-party browser through the existing pipe seam
This provides an inner-network-safe path that can work without a model today, while reserving an upgrade path for future semantic fallback.
---
## Terminology
### Deterministic mode
A submit-task mode enabled only when the instruction ends with `。。。`.
### Natural-language business parameters
Values expressed by the user in text, such as:
- `兰州公司`
- `天水公司`
- `月累计`
- `周累计`
- `2026-03`
- `2026年第12周`
These are intermediate semantic values, not final backend parameters.
### Canonical execution parameters
The normalized values required by the source page / source API, such as:
- canonical company label
- canonical company code
- period mode code (month/week)
- canonical request period payload
---
## Ownership Boundary and Landing Zones
### Staged skill changes
These land in:
`D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging`
Primary landing zone:
- `skills/tq-lineloss-report/`
Target package structure:
- `SKILL.md`
- `SKILL.toml`
- `references/collection-flow.md`
- `references/data-quality.md`
- `assets/scene-snapshot/index.html`
- `scripts/collect_lineloss.js`
- `scripts/collect_lineloss.test.js`
Potential aligned scene metadata (if included in this slice):
- `scenes/tq-lineloss-report/scene.json`
- optional scene registry updates if the current staging conventions require it
### Caller/runtime changes
These land in:
`D:/data/ideaSpace/rust/sgClaw/claw-new`
Likely ownership areas:
- deterministic instruction detection and deterministic skill matching
- parameter extraction and normalization
- deterministic skill dispatch to the existing browser-script seam
- narrow result interpretation for the returned artifact
- focused regression tests
Design rule:
`claw-new` owns routing, extraction, normalization, and dispatch.
`claw-new` must **not** absorb the line-loss business logic itself.
The staged skill package owns:
- page inspection
- page-side state reading
- page/API data collection
- row normalization
- export/report-log behavior
- final artifact generation
---
## Target Runtime Flow
### Step 1: Submit-task enters deterministic mode only on `。。。`
When the user instruction does **not** end in `。。。`:
- keep the current runtime behavior unchanged
- preserve existing Zhihu hotlist behavior exactly
- preserve existing direct-submit and compat/LLM flows
When the instruction **does** end in `。。。`:
- enter deterministic mode
- do not run the ordinary LLM interpretation branch for this request
- evaluate only the deterministic skill whitelist
### Step 2: Deterministic whitelist match
The runtime should match the instruction against deterministic business scenes.
For this slice the new required deterministic scene is:
- `tq-lineloss-report.collect_lineloss`
The matching layer should remain narrow and explicit. It should not become a general scene-registry runtime in this slice.
Matching should use a deterministic combination of:
- instruction keywords
- optional page URL/title constraints when available
The runtime must not accidentally steal instructions that should still go down the Zhihu path.
### Step 3: Extract semantic business parameters from natural language
After `tq-lineloss-report` is matched, the runtime extracts semantic business parameters from the instruction.
Required semantic categories:
- company/unit expression
- period mode (`month` vs `week`)
- period text/value
Examples of accepted user-facing expressions include:
- `兰州公司`
- `天水公司`
- `国网兰州供电公司`
- `城关供电分公司`
- `2026-03`
- `2026年3月`
- `2026年第12周`
- `第12周`
- `月累计`
- `周累计`
### Step 4: Normalize semantic values into canonical coded values
This is a required separate design step.
The runtime must not pass raw natural-language company text directly to the business request layer.
Instead it must normalize semantic values into canonical execution parameters, including:
- `org_label` — canonical unit label
- `org_code` — the actual code/value required by the business page/API
- `period_mode` — canonical mode (`month` or `week`)
- `period_mode_code` — the page/API code (for example `timeChage`-style encoded mode)
- canonical time payload required by the source APIs/page state
This normalization should be derived from the actual source materials, including page-side dictionaries such as the existing unit tree data.
### Step 5: Missing and ambiguous parameters must stop execution
This slice must not silently infer missing parameters from page defaults.
If a required parameter is missing, execution must stop with an explicit prompt to the user.
If a parameter is ambiguous, execution must stop with an explicit ambiguity prompt.
Examples:
- no company matched
- no month/week mode matched
- no period value matched when required
- a short company alias matches multiple canonical units
- both monthly and weekly intent appear in the same instruction
This is preferable to silently using the wrong company code or the wrong query period.
### Step 6: Execute the staged skill through the existing pipe seam
If and only if parameters are present and successfully normalized:
- resolve `tq-lineloss-report.collect_lineloss`
- build the args object
- execute it through the current `browser_script` runtime
- inject the script into the browser through the existing pipe-backed browser tool seam
This slice must reuse the execution pattern already proven by the current browser-script/direct-skill infrastructure and the current Zhihu hotlist path.
Do not introduce a second browser protocol, new browser opcode family, or parallel execution harness.
### Step 7: Skill JS performs page-side work and returns one artifact
The staged script owns the actual line-loss business behavior:
- reading page-side state when needed
- validating the page context
- using normalized codes/parameters from args
- building source API requests
- collecting/normalizing rows
- export/report logging behavior if required by the final business contract
- returning a structured artifact
---
## Deterministic Trigger Contract
### Trigger rule
Deterministic mode is activated only when the raw instruction ends with the exact three-Chinese-dot suffix:
- `。。。`
This suffix is a user-controlled explicit mode switch.
### Why the suffix exists
It lets the user force business-deterministic behavior without relying on a model, while preserving the normal LLM path for ordinary requests.
### Scope rule
The suffix is not a free pass to run arbitrary browser actions.
It only selects among the deterministic skill whitelist.
If no deterministic scene matches, the runtime should return a deterministic-mode mismatch error that explains the currently supported deterministic scenes, rather than silently dropping into another behavior.
---
## Company / Unit Matching Contract
### Accepted input style
The user does **not** need to type the exact full canonical label.
The runtime should support business shorthand such as:
- `兰州公司`
- `天水公司`
- `白银公司`
- `城关供电分公司`
- `榆中县供电公司`
### Matching approach
Do not use regex alone as the primary company-resolution mechanism.
Use a three-stage resolution strategy:
1. text normalization
2. alias/candidate generation from canonical unit names
3. uniqueness resolution against the real unit dictionary
### Normalization examples
Canonical names such as:
- `国网兰州供电公司`
- `国网天水供电公司`
- `国网榆中县供电公司`
should be matchable from business shorthand forms such as:
- `兰州公司`
- `天水公司`
- `榆中县公司`
- `榆中供电公司`
### Data source for canonical mapping
The company/unit resolver should derive canonical mappings from the real source materials used by the business page, such as the current unit tree dictionary embedded in the source page resources.
Design implication:
- the resolver should produce the real `value`/code required downstream
- the resolver should also keep the canonical label for display/auditability
### Ambiguity rule
If a short alias resolves to more than one valid unit, execution must stop and ask the user to be more specific.
Do not auto-guess.
### Supported granularity
The first implementation must support both:
- city-company level
- district/county/sub-company level
This includes forms like:
- `兰州公司`
- `天水公司`
- `城关供电分公司`
- `榆中县供电公司`
---
## Period Extraction and Normalization Contract
### Required period dimensions
The runtime must identify:
- mode: `month` or `week`
- actual requested period value in a canonical form
### Accepted user-facing patterns
At minimum the design should account for patterns such as:
- `月累计`
- `周累计`
- `2026-03`
- `2026年3月`
- `2026年第12周`
- `第12周`
### Normalization output
The resolver should produce:
- a canonical mode enum/string
- a mode code required by the page/API
- a canonical period payload consumable by the script/business request layer
### Ambiguity rule
If both month and week intent appear, stop and ask the user to clarify.
### Missing-period rule
If the selected line-loss query requires a time period and the instruction does not provide enough information to construct one, stop and ask the user to provide it.
Do not default to the page-selected period in this slice.
---
## Parameter Prompting Contract
When deterministic mode matches `tq-lineloss-report` but one or more required parameters are missing or ambiguous, the runtime should return a user-facing prompt rather than executing.
Expected prompting cases include:
- missing company/unit
- missing month/week mode
- missing period value
- ambiguous company alias
- contradictory period expressions
The prompt should be specific enough to let the user correct only the missing field(s).
Example style:
- `已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。`
- `已命中台区线损报表技能,但未识别到月/周类型,请补充“月累计”或“周累计”。`
---
## Skill Package Contract
### SKILL.toml
The new skill package must declare a single deterministic collection entrypoint:
- tool name: `collect_lineloss`
- kind: `browser_script`
The tool description must reflect the real staged behavior, not a placeholder shell.
### SKILL.md
The written contract should cover:
- when to use the skill
- when not to use it
- collection workflow
- runtime contract
- explicit missing/partial/error semantics
- returned artifact contract
### references/collection-flow.md
Must explain:
- the source page state used by the skill
- how company and period parameters map to business requests
- which page/API calls are used for month vs week
- export/report-log sequencing if retained in the business flow
### references/data-quality.md
Must define:
- canonical output columns
- required field coverage
- status semantics
- partial/error conditions
- company/period normalization assumptions that the script relies on
### scripts/collect_lineloss.js
This is the real browser-side entrypoint. It should:
- accept normalized args
- validate page context
- execute deterministic page/API data collection
- normalize rows
- perform downstream export/report-history behavior if required
- directly return the final artifact from the browser-script runtime entrypoint shape
### scripts/collect_lineloss.test.js
Must cover the business transforms that can be tested off-browser, especially:
- company normalization assumptions consumed by the script
- monthly vs weekly request-shape logic
- status semantics
- artifact shaping
---
## Returned Artifact Contract
The final line-loss skill should return one structured artifact object rather than free-form prose.
At minimum it should expose:
- artifact type
- report name
- canonical company label/code used for the query
- period mode and canonical period value used for the query
- columns
- rows
- status
- counts
- downstream export/report-log status when applicable
- clear reasons for blocked/partial/error states
The exact field names may be finalized during implementation planning, but the contract must be structured enough for `claw-new` to interpret success vs partial vs blocked without re-embedding business logic.
---
## Pipe-First / Ws-Ready Execution Seam
### Current requirement
The first implementation on `main` must use the existing pipe-backed browser execution path.
### Future requirement
The design must allow later ws adoption without redesigning the skill or routing contract.
### Practical design rule
Keep these backend-neutral:
- deterministic trigger contract
- skill matching contract
- parameter extraction contract
- parameter normalization contract
- tool args contract
- artifact contract
Keep backend-specific code isolated to the execution seam only.
That way the later ws migration can replace the browser backend beneath the same deterministic skill contract.
---
## Caller/Runtime Design Rules
### 1. Keep new business logic out of broad orchestration
Do not thread line-loss-specific business behavior through the general orchestration/runtime path.
### 2. Add a narrow deterministic-routing seam
This slice should add a narrow deterministic branch around submit-task routing, rather than rewriting the whole runtime decision tree.
### 3. Separate extraction from normalization
Do not mix “what the user typed” with “what the backend needs”.
There must be a distinct normalization step.
### 4. Keep the direct-skill browser seam narrow
Reuse the current `browser_script` execution seam instead of inventing a new browser bridge.
### 5. Preserve Zhihu behavior by design, not by hope
The design should assume new deterministic routing can accidentally steal or alter existing Zhihu behavior unless explicitly guarded against.
This is why focused Zhihu regression coverage is mandatory.
---
## Verification Requirements for the Future Implementation Plan
Implementation planning must include explicit verification for:
1. deterministic suffix detection
2. deterministic lineloss scene matching
3. company alias normalization to canonical code
4. support for both company-level and district/county/sub-company-level units
5. month/week extraction and normalization
6. missing-parameter prompt behavior
7. ambiguous-company prompt behavior
8. pipe-backed browser-script execution for the new skill
9. no regression to the existing Zhihu hotlist path
10. preserved direct-skill/browser-script behavior outside the new line-loss scene
---
## Out of Scope for This Slice
- enabling ws execution on `main`
- replacing the current Zhihu routing model
- general scene-registry runtime architecture redesign
- full free-form semantic understanding of arbitrary business language
- typo-tolerant fuzzy NLP beyond deterministic business-safe matching
- making page defaults the hidden source of truth when the user omitted parameters
---
## Planning Notes
The implementation plan should likely split into distinct work items for:
1. staged skill package creation and business contract definition
2. deterministic trigger + scene match in `claw-new`
3. company/unit normalization and ambiguity handling
4. period extraction/normalization and ambiguity handling
5. pipe-backed direct execution integration
6. returned artifact interpretation
7. Zhihu regression verification
8. ws-readiness seam verification
The plan should explicitly keep the “do not break Zhihu hotlist” boundary visible in every execution and verification stage.

View File

@@ -3,12 +3,16 @@
> 适用范围P1aRust与 P2Chromium C++)联调开发。
> 目标:双方只要严格按本文档实现,即可稳定联调。
附加口径:
- 浏览器宿主是 sgClaw/zeroclaw runtime 的一个特权执行面,不是整个 runtime 的定义。
- 只有真正需要浏览器执行的动作才应该跨过这条 pipe不要把所有任务都假定为浏览器任务。
## 1. 协议边界与责任
- 单一事实来源:`docs/L2-核心模块与接口契约层.md` 第 5 章5.1~5.4)。
- 协议版本冻结:`1.0`字段、action、错误码变更均视为协议变更。
- P1a 负责:`seq` 生成、command 组包、HMAC 计算、response 关联。
- P2 负责message 解析、Schema 校验、MAC 检查、CommandRouter 执行、结构化回包。
- P1a 负责:zeroclaw-first runtime、任务解释、tool policy、`seq` 生成、command 组包、HMAC 计算、response 关联。
- P2 负责:process host、message 解析、Schema 校验、MAC 检查、CommandRouter 执行、结构化回包。
## 2. Wire Contract双方 MUST
@@ -36,6 +40,10 @@
- 失败 response 必填:`error.code``error.message`(禁止纯文本错误)。
- `action``params` 必须通过 L2 的枚举和 Schema 校验。
说明:
- 这份标准约束的是“浏览器特权工具面”的 wire contract。
- 它不定义 sgClaw/zeroclaw 整体任务语义,也不意味着所有任务都必须变成 browser command。
**标准 command 示例**
```json

View File

@@ -1,8 +1,8 @@
# frontend 目录说明
当前 `frontend/` 保留开发验证相关内容
当前 `frontend/` 保留验证与归档文件
- `sgClaw验证/`:本地验证页面与脚本。
- `archive/sgClaw验证-已归档/`历史本地验证页面与脚本(含 Vue 2 验证页、`serve.sh``download-libs.sh``testRunner.js`
原先用于领导演示的网页与图文件已归档到:

View File

@@ -0,0 +1,13 @@
# 前端归档资源
## 已归档内容
- `sgClaw验证-已归档/`历史本地验证页面与脚本Vue 2 验证页面、服务脚本、离线依赖下载脚本、测试运行器)。
## 使用说明
这是历史资产,不作为项目主线运行链路;如需复现旧版手工验证流程,可在该目录下直接执行:
```bash
bash frontend/archive/sgClaw验证-已归档/serve.sh
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
# sgClaw 聊天界面SuperRPA 迁移稿
该目录用于在当前仓库先验证新聊天页面,再手动迁移到:
`/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/`
## 可直接迁移文件
- `sgclaw-chat.ts`:新的 Lit 组件版聊天页(对应 `sgclaw-chat` Function 的主实现)。
## 迁移步骤(建议)
1. 备份原文件:
- `cp /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts /tmp/sgclaw-chat-backup.ts`
2. 复制新文件:
- `cp frontend/archive/sgClaw验证-已归档/superrpa_migration/sgclaw-chat.ts /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
3. (可选)保留兼容:
- 现有 `sgclaw-chat.html.ts``sgclaw-chat.css.ts` 仍是占位导出,不影响本组件内联模板;
- 如有项目 lint/格式规范要求,可再拆分为独立 html.ts/css.ts。
4. 重新加载 Functions 页面验证:访问对应的 `sgclaw-chat` 功能入口。
## 注意
- 当前版本保留 localStorage 键:
- `sgclaw-chat-ui-v1`
- `sgclaw-chat-messages-v1`
- 未检测到 API Key 时会自动降级到 mock 回答。
- 已支持 OpenAI / Claude / mock 三种模式。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
# sgClaw Runtime Host Bundle
`frontendBundleDir` lets SuperRPA prefer an external `sgclaw-chat` bundle at
runtime while keeping the browser host, security boundary, and `chrome.send`
bridge inside Chromium.
## Contract
- SuperRPA remains the host and security boundary.
- sgClaw remains the runtime and planner/execution owner.
- The frontend bundle only renders state that comes from the existing host
bridge.
- Missing external files fall back to Chromium-bundled resources.
## Directory Layout
The external bundle root must mirror the Chromium resource paths under
`functions/sgclaw-chat/`.
Example:
```text
frontend-bundle/
sgclaw-chat/
sgclaw-chat.js
sgclaw-chat.css.js
sgclaw-chat.html.js
components/
sgclaw-chat-shell.js
sgclaw-message-list.js
```
When `frontendBundleDir` points at `frontend-bundle/`, the browser will try to
serve `sgclaw-chat/*` from that directory first. Any file that is absent will
continue loading from the built-in Chromium resource pack.
## Packaging Notes
- Keep relative import paths inside the bundle identical to the bundled
`sgclaw-chat` tree.
- Do not move planner or execution logic into the bundle.
- Treat [manifest.example.json](/home/zyl/projects/sgClaw/claw/frontend/runtime-host/manifest.example.json)
as a packaging reference for humans and tooling.

View File

@@ -0,0 +1,17 @@
{
"bundleId": "sgclaw-chat",
"version": "0.1.0",
"description": "External sgclaw chat presentation bundle for SuperRPA runtime hosting.",
"resourceRoot": ".",
"resourcePrefix": "sgclaw-chat/",
"entries": [
"sgclaw-chat/sgclaw-chat.js",
"sgclaw-chat/sgclaw-chat.css.js",
"sgclaw-chat/sgclaw-chat.html.js"
],
"notes": [
"Paths should mirror Chromium bundled sgclaw-chat resources.",
"Missing files fall back to bundled Chromium resources.",
"The bundle must stay presentation-only and use the existing host bridge."
]
}

View File

@@ -1,17 +1,20 @@
{
"version": "1.0",
"demo_only_domains": ["baidu.com", "www.baidu.com"],
"demo_only_domains": ["baidu.com", "www.baidu.com", "zhihu.com", "www.zhihu.com", "zhuanlan.zhihu.com"],
"domains": {
"allowed": [
"oa.example.com",
"erp.example.com",
"hr.example.com",
"baidu.com",
"www.baidu.com"
"www.baidu.com",
"zhihu.com",
"www.zhihu.com",
"zhuanlan.zhihu.com"
]
},
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText"],
"blocked": ["eval", "executeJsInPage"]
"allowed": ["click", "type", "navigate", "getText", "eval"],
"blocked": ["executeJsInPage"]
}
}

View File

@@ -0,0 +1,637 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>知乎热榜图表驾驶舱</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
:root {
--bg: #06111f;
--bg-2: #0a1f37;
--panel: rgba(8, 25, 42, 0.88);
--panel-strong: rgba(10, 32, 55, 0.95);
--line: rgba(101, 187, 255, 0.18);
--line-strong: rgba(236, 186, 81, 0.26);
--text: #eef6ff;
--muted: #8ea6c2;
--accent: #62d0ff;
--accent-2: #ecba51;
--accent-3: #6df0c2;
--danger: #ff8b7e;
--shadow: 0 20px 48px rgba(0, 0, 0, 0.34);
--font-heading: "DIN Alternate", "Bahnschrift", "Microsoft YaHei UI", sans-serif;
--font-body: "Segoe UI Variable Text", "Microsoft YaHei", "PingFang SC", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at 16% 10%, rgba(98, 208, 255, 0.18), transparent 22%),
radial-gradient(circle at 86% 12%, rgba(236, 186, 81, 0.14), transparent 18%),
linear-gradient(145deg, var(--bg) 0%, var(--bg-2) 42%, #030910 100%);
color: var(--text);
font-family: var(--font-body);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(101, 187, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(101, 187, 255, 0.05) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(circle at center, black 34%, rgba(0, 0, 0, 0.22) 88%, transparent 100%);
}
.page {
min-height: 100vh;
padding: 18px;
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 14px;
}
.panel {
position: relative;
overflow: hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)),
linear-gradient(145deg, rgba(9, 30, 51, 0.97), rgba(6, 20, 34, 0.92));
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: var(--shadow);
}
.panel::before {
content: "";
position: absolute;
left: 18px;
right: 18px;
top: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), var(--accent-2), transparent);
opacity: 0.95;
}
.hero {
padding: 18px 24px;
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
align-items: center;
}
.eyebrow {
color: var(--accent);
letter-spacing: 2px;
text-transform: uppercase;
font-size: 12px;
margin-bottom: 8px;
}
h1 {
margin: 0;
font-family: var(--font-heading);
font-size: 38px;
line-height: 1.08;
letter-spacing: 1px;
}
#snapshot-meta {
margin: 10px 0 0;
color: var(--muted);
font-size: 14px;
}
.hero-notes {
display: grid;
gap: 10px;
}
.note-card {
padding: 14px 16px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(98, 208, 255, 0.08), rgba(236, 186, 81, 0.08));
border: 1px solid rgba(255, 255, 255, 0.05);
}
.note-card strong {
display: block;
margin-bottom: 6px;
font-size: 14px;
}
.note-card span {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.metric {
padding: 18px 18px 16px;
}
.metric-label {
color: var(--muted);
font-size: 12px;
letter-spacing: 1px;
text-transform: uppercase;
}
.metric-value {
margin-top: 10px;
font-family: var(--font-heading);
font-size: 34px;
color: var(--text);
}
.metric-sub {
margin-top: 8px;
color: var(--accent);
font-size: 12px;
}
.charts {
min-height: 0;
display: grid;
grid-template-columns: 1.2fr 1fr 0.95fr;
grid-template-rows: 360px 320px;
gap: 14px;
grid-template-areas:
"bar top pie"
"bubble table table";
}
.chart-panel {
padding: 14px 16px 12px;
}
.bar-panel { grid-area: bar; }
.top-panel { grid-area: top; }
.pie-panel { grid-area: pie; }
.bubble-panel { grid-area: bubble; }
.table-panel { grid-area: table; padding: 14px 16px 10px; }
.section-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.section-head h2 {
margin: 0;
font-size: 22px;
font-family: var(--font-heading);
letter-spacing: 1px;
}
.section-head span {
color: var(--muted);
font-size: 12px;
}
.chart {
width: 100%;
height: calc(100% - 42px);
}
.table-wrap {
height: calc(100% - 42px);
overflow: auto;
padding-right: 4px;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background: rgba(6, 19, 32, 0.96);
padding: 10px 8px;
text-align: left;
font-size: 12px;
color: var(--muted);
letter-spacing: 1px;
text-transform: uppercase;
border-bottom: 1px solid var(--line-strong);
}
tbody td {
padding: 11px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 13px;
vertical-align: top;
}
tbody tr:nth-child(odd) {
background: rgba(255, 255, 255, 0.016);
}
.rank {
font-family: var(--font-heading);
color: var(--accent-2);
white-space: nowrap;
}
.heat {
color: var(--accent-3);
font-family: var(--font-heading);
white-space: nowrap;
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
background: rgba(98, 208, 255, 0.12);
color: var(--accent);
font-size: 12px;
}
.footer {
padding: 10px 16px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1440px) {
.hero {
grid-template-columns: 1fr;
}
.metrics {
grid-template-columns: repeat(2, 1fr);
}
.charts {
grid-template-columns: 1fr;
grid-template-rows: 320px 320px 320px 320px 420px;
grid-template-areas:
"bar"
"top"
"pie"
"bubble"
"table";
}
}
@media (max-width: 760px) {
.page {
padding: 12px;
}
h1 {
font-size: 28px;
}
.metrics {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="page">
<section class="panel hero">
<div>
<div class="eyebrow">Zhihu Hotlist Visual Command Center</div>
<h1>知乎热榜图表驾驶舱</h1>
<p id="snapshot-meta">由 sgClaw screen_html_export 生成的本地静态展示页</p>
</div>
<div class="hero-notes">
<div class="note-card">
<strong>图表表达</strong>
<span>同一份热榜数据同时映射为分类热度、头部热点、结构占比和热度散点,适合现场讲解图表能力。</span>
</div>
<div class="note-card">
<strong>演示建议</strong>
<span id="lead-summary">优先讲解榜首热点、分类分布与热度层级,再向下展开全量榜单细节。</span>
</div>
</div>
</section>
<section class="metrics">
<article class="panel metric">
<div class="metric-label">热榜条目数</div>
<div id="metric-total" class="metric-value">0</div>
<div class="metric-sub">Tracked items</div>
</article>
<article class="panel metric">
<div class="metric-label">主题分类数</div>
<div id="metric-categories" class="metric-value">0</div>
<div class="metric-sub">Topic groups</div>
</article>
<article class="panel metric">
<div class="metric-label">累计热度</div>
<div id="metric-heat" class="metric-value">0</div>
<div class="metric-sub">Total heat</div>
</article>
<article class="panel metric">
<div class="metric-label">头部峰值</div>
<div id="metric-peak" class="metric-value">0</div>
<div class="metric-sub">Peak topic heat</div>
</article>
</section>
<section class="charts">
<section class="panel chart-panel bar-panel">
<div class="section-head">
<h2>分类总热度</h2>
<span>横向对比</span>
</div>
<div id="bar-chart" class="chart"></div>
</section>
<section class="panel chart-panel top-panel">
<div class="section-head">
<h2>Top10 热点</h2>
<span>柱状排行</span>
</div>
<div id="top-chart" class="chart"></div>
</section>
<section class="panel chart-panel pie-panel">
<div class="section-head">
<h2>分类占比</h2>
<span>环形结构</span>
</div>
<div id="pie-chart" class="chart"></div>
</section>
<section class="panel chart-panel bubble-panel">
<div class="section-head">
<h2>热度分层</h2>
<span>散点气泡</span>
</div>
<div id="bubble-chart" class="chart"></div>
</section>
<section class="panel table-panel">
<div class="section-head">
<h2>热榜明细</h2>
<span id="table-note">按原始顺序保留</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>排名</th>
<th>标题</th>
<th>分类</th>
<th>热度</th>
</tr>
</thead>
<tbody id="table-body"></tbody>
</table>
</div>
</section>
</section>
<section class="panel footer">
本页由 `screen_html_export` 生成,适合在系统浏览器中直接打开进行展示。
</section>
</div>
<script>
const defaultPayload = {
"snapshot_id": "template-snapshot",
"generated_at_ms": 0,
"categories": [],
"table": []
}
const themeMeta = {
title: "知乎热榜图表驾驶舱",
renderer: "screen_html_export"
};
const chartColors = ["#62d0ff", "#ecba51", "#6df0c2", "#7f8cff", "#ff8b7e", "#9fcbff", "#58a6ff"];
const charts = {};
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}
function getTotalHeat(categories) {
return (categories || []).reduce((sum, item) => sum + Number(item.total_heat || 0), 0);
}
function getPeakHeat(table) {
return (table || []).reduce((max, row) => Math.max(max, Number(row.heat_value || 0)), 0);
}
function buildLeadSummary(table, categories) {
const top = (table || [])[0];
const category = (categories || []).slice().sort((a, b) => (b.total_heat || 0) - (a.total_heat || 0))[0];
const parts = [];
if (top) {
parts.push(`榜首是“${top.title}`);
}
if (category) {
parts.push(`主导分类为“${category.category_label}`);
}
parts.push(`共覆盖 ${(table || []).length} 条热点`);
return parts.join("");
}
function ensureCharts() {
if (!window.echarts) {
return;
}
charts.bar = charts.bar || echarts.init(document.getElementById("bar-chart"));
charts.top = charts.top || echarts.init(document.getElementById("top-chart"));
charts.pie = charts.pie || echarts.init(document.getElementById("pie-chart"));
charts.bubble = charts.bubble || echarts.init(document.getElementById("bubble-chart"));
}
function renderBarChart(categories) {
const sorted = (categories || []).slice().sort((a, b) => Number(a.total_heat || 0) - Number(b.total_heat || 0));
charts.bar.setOption({
animationDuration: 700,
grid: {left: 90, right: 18, top: 10, bottom: 20},
xAxis: {
type: "value",
axisLabel: {color: "#8ea6c2"},
splitLine: {lineStyle: {color: "rgba(255,255,255,0.06)"}}
},
yAxis: {
type: "category",
data: sorted.map((item) => item.category_label),
axisLabel: {color: "#eef6ff"},
axisLine: {lineStyle: {color: "rgba(255,255,255,0.1)"}}
},
tooltip: {trigger: "axis", axisPointer: {type: "shadow"}},
series: [{
type: "bar",
data: sorted.map((item, index) => ({
value: Number(item.total_heat || 0),
itemStyle: {color: chartColors[index % chartColors.length], borderRadius: [0, 8, 8, 0]}
})),
label: {show: true, position: "right", color: "#dfeeff"}
}]
});
}
function renderTopChart(table) {
const top = (table || []).slice(0, 10);
charts.top.setOption({
animationDuration: 700,
grid: {left: 42, right: 12, top: 26, bottom: 46},
tooltip: {trigger: "axis", axisPointer: {type: "shadow"}},
xAxis: {
type: "category",
data: top.map((row) => `#${row.rank}`),
axisLabel: {color: "#8ea6c2"},
axisLine: {lineStyle: {color: "rgba(255,255,255,0.1)"}}
},
yAxis: {
type: "value",
axisLabel: {color: "#8ea6c2"},
splitLine: {lineStyle: {color: "rgba(255,255,255,0.06)"}}
},
series: [{
type: "bar",
data: top.map((row, index) => ({
value: Number(row.heat_value || 0),
itemStyle: {color: chartColors[index % chartColors.length], borderRadius: [8, 8, 0, 0]}
})),
label: {show: true, position: "top", color: "#eef6ff", formatter: ({dataIndex}) => top[dataIndex].heat_text}
}]
});
}
function renderPieChart(categories) {
charts.pie.setOption({
animationDuration: 700,
color: chartColors,
tooltip: {trigger: "item"},
legend: {
bottom: 2,
textStyle: {color: "#8ea6c2", fontSize: 11},
itemWidth: 12,
itemHeight: 8
},
series: [{
type: "pie",
radius: ["44%", "72%"],
center: ["50%", "44%"],
itemStyle: {borderColor: "#081a2c", borderWidth: 2},
label: {
color: "#eef6ff",
formatter: "{b}\n{d}%"
},
data: (categories || []).map((item) => ({
name: item.category_label,
value: Number(item.total_heat || 0)
}))
}]
});
}
function renderBubbleChart(table) {
const top = (table || []).slice(0, 12);
charts.bubble.setOption({
animationDuration: 700,
color: chartColors,
grid: {left: 44, right: 18, top: 16, bottom: 36},
xAxis: {
type: "value",
name: "排名",
inverse: true,
min: 0,
max: Math.max(...top.map((row) => Number(row.rank || 0)), 10) + 1,
nameTextStyle: {color: "#8ea6c2"},
axisLabel: {color: "#8ea6c2"},
splitLine: {lineStyle: {color: "rgba(255,255,255,0.06)"}}
},
yAxis: {
type: "value",
name: "热度值",
nameTextStyle: {color: "#8ea6c2"},
axisLabel: {color: "#8ea6c2"},
splitLine: {lineStyle: {color: "rgba(255,255,255,0.06)"}}
},
tooltip: {
formatter: (params) => {
const row = params.data.raw;
return `${row.title}<br/>排名 #${row.rank}<br/>热度 ${row.heat_text}<br/>分类 ${row.category_label}`;
}
},
series: [{
type: "scatter",
symbolSize: (value) => Math.max(18, Math.min(56, value[2] / 80000)),
data: top.map((row, index) => ({
value: [Number(row.rank || 0), Number(row.heat_value || 0), Number(row.heat_value || 0)],
raw: row,
itemStyle: {color: chartColors[index % chartColors.length], opacity: 0.82}
}))
}]
});
}
function renderTable(table) {
document.getElementById("table-body").innerHTML = (table || []).map((row) => `
<tr>
<td class="rank">#${row.rank}</td>
<td>${row.title}</td>
<td><span class="tag">${row.category_label}</span></td>
<td class="heat">${row.heat_text}</td>
</tr>
`).join("");
}
function render(payload) {
const data = payload || defaultPayload;
const categories = data.categories || [];
const table = data.table || [];
document.title = themeMeta.title;
document.getElementById("snapshot-meta").textContent =
`${data.snapshot_id} | 生成时间 ${new Date(data.generated_at_ms || 0).toLocaleString()}`;
document.getElementById("metric-total").textContent = formatNumber(table.length);
document.getElementById("metric-categories").textContent = formatNumber(categories.length);
document.getElementById("metric-heat").textContent = formatNumber(getTotalHeat(categories));
document.getElementById("metric-peak").textContent = formatNumber(getPeakHeat(table));
document.getElementById("lead-summary").textContent = buildLeadSummary(table, categories);
document.getElementById("table-note").textContent =
table.length > 0 ? `当前展示 ${table.length} 条热点` : "暂无热榜数据";
renderTable(table);
ensureCharts();
if (window.echarts) {
renderBarChart(categories);
renderTopChart(table);
renderPieChart(categories);
renderBubbleChart(table);
}
}
window.addEventListener("resize", () => {
Object.values(charts).forEach((chart) => chart && chart.resize());
});
render(defaultPayload);
</script>
</body>
</html>

View File

@@ -0,0 +1,532 @@
import argparse
import re
import sys
import tomllib
from pathlib import Path
from typing import NamedTuple
MAX_TEXT_FILE_BYTES = 512 * 1024
SCRIPT_SUFFIXES = (
".sh",
".bash",
".zsh",
".ksh",
".fish",
".ps1",
".bat",
".cmd",
)
HIGH_RISK_PATTERNS = (
(re.compile(r"(?im)\bcurl\b[^\n|]{0,200}\|\s*(?:sh|bash|zsh)\b"), "curl-pipe-shell"),
(re.compile(r"(?im)\bwget\b[^\n|]{0,200}\|\s*(?:sh|bash|zsh)\b"), "wget-pipe-shell"),
(re.compile(r"(?im)\b(?:invoke-expression|iex)\b"), "powershell-iex"),
(re.compile(r"(?im)\brm\s+-rf\s+/"), "destructive-rm-rf-root"),
(re.compile(r"(?im)\bnc(?:at)?\b[^\n]{0,120}\s-e\b"), "netcat-remote-exec"),
(re.compile(r"(?im)\bdd\s+if="), "disk-overwrite-dd"),
(re.compile(r"(?im)\bmkfs(?:\.[a-z0-9]+)?\b"), "filesystem-format"),
(re.compile(r"(?im):\(\)\s*\{\s*:\|\:&\s*\};:"), "fork-bomb"),
)
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
REPO_ROOT = Path(__file__).resolve().parents[1]
SKILL_LIB_ROOT = REPO_ROOT.parent / "skill_lib"
SKILLS_DIR = SKILL_LIB_ROOT / "skills"
class SkillRecord(NamedTuple):
name: str
description: str
version: str
author: str | None
tags: list[str]
prompt_body: str
location: Path
class AuditReport(NamedTuple):
files_scanned: int
findings: list[str]
class ValidationResult(NamedTuple):
record: SkillRecord
report: AuditReport
ok: bool
def discover_skill_dirs(skills_dir: Path | None = None) -> list[Path]:
root = skills_dir or SKILLS_DIR
if not root.exists():
return []
return sorted(path for path in root.iterdir() if path.is_dir())
def load_skill(skill_dir: Path) -> SkillRecord:
manifest_path = skill_dir / "SKILL.toml"
markdown_path = skill_dir / "SKILL.md"
if manifest_path.is_file():
manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
skill_meta = manifest.get("skill", {})
prompts = manifest.get("prompts", [])
body = ""
if markdown_path.is_file():
_, body = parse_skill_markdown(markdown_path.read_text(encoding="utf-8"))
elif prompts:
body = "\n\n".join(str(prompt) for prompt in prompts)
description = skill_meta.get("description")
if not description or not str(description).strip():
description = extract_description(body)
return SkillRecord(
name=skill_meta.get("name") or skill_dir.name,
description=str(description),
version=str(skill_meta.get("version") or "0.1.0"),
author=skill_meta.get("author") or None,
tags=list(skill_meta.get("tags", [])),
prompt_body=body,
location=manifest_path,
)
skill_path = markdown_path
content = skill_path.read_text(encoding="utf-8")
meta, body = parse_skill_markdown(content)
name = meta["name"] or skill_dir.name
description = meta["description"]
if not description or not description.strip():
description = extract_description(body)
version = meta["version"] or "0.1.0"
author = meta["author"] or None
tags = list(meta["tags"])
return SkillRecord(
name=name,
description=description,
version=version,
author=author,
tags=tags,
prompt_body=body,
location=skill_path,
)
def validate_all_skills(allow_scripts: bool = False) -> list[ValidationResult]:
results = []
for skill_dir in discover_skill_dirs():
record = load_skill(skill_dir)
report = audit_skill_directory(skill_dir, allow_scripts=allow_scripts)
results.append(ValidationResult(record=record, report=report, ok=not report.findings))
return results
def parse_skill_markdown(content: str) -> tuple[dict[str, object], str]:
frontmatter = split_skill_frontmatter(content)
if frontmatter is None:
return empty_meta(), content
raw_frontmatter, body = frontmatter
return parse_simple_frontmatter(raw_frontmatter), body
def split_skill_frontmatter(content: str) -> tuple[str, str] | None:
normalized = content.replace("\r\n", "\n")
if not normalized.startswith("---\n"):
return None
rest = normalized[len("---\n") :]
marker = "\n---\n"
idx = rest.find(marker)
if idx != -1:
return rest[:idx], rest[idx + len(marker) :]
if rest.endswith("\n---"):
return rest[:-4], ""
return None
def parse_simple_frontmatter(frontmatter: str) -> dict[str, object]:
meta = empty_meta()
collecting_tags = False
for raw_line in frontmatter.splitlines():
if collecting_tags:
trimmed = raw_line.strip()
if trimmed.startswith("- "):
tag = trimmed[2:].strip().strip('"').strip("'")
if tag:
meta["tags"].append(tag)
continue
collecting_tags = False
if ":" not in raw_line:
continue
key, value = raw_line.split(":", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key == "name":
meta["name"] = value
elif key == "description":
meta["description"] = value
elif key == "version":
meta["version"] = value
elif key == "author":
meta["author"] = value
elif key == "tags":
if not value:
collecting_tags = True
else:
cleaned = value.lstrip("[").rstrip("]")
meta["tags"] = [
item.strip().strip('"').strip("'")
for item in cleaned.split(",")
if item.strip().strip('"').strip("'")
]
return meta
def empty_meta() -> dict[str, object]:
return {
"name": None,
"description": None,
"version": None,
"author": None,
"tags": [],
}
def extract_description(body: str) -> str:
for line in body.splitlines():
if line.startswith("#"):
continue
if not line.strip():
continue
return line.strip()
return "No description"
def audit_skill_directory(skill_dir: Path, allow_scripts: bool = False) -> AuditReport:
if not skill_dir.exists():
raise FileNotFoundError(f"Skill source does not exist: {skill_dir}")
if not skill_dir.is_dir():
raise NotADirectoryError(f"Skill source must be a directory: {skill_dir}")
canonical_root = skill_dir.resolve()
findings: list[str] = []
files_scanned = 0
has_manifest = (canonical_root / "SKILL.md").is_file() or (canonical_root / "SKILL.toml").is_file()
if not has_manifest:
findings.append(
"Skill root must include SKILL.md or SKILL.toml for deterministic auditing."
)
for path in collect_paths_depth_first(canonical_root):
files_scanned += 1
findings.extend(audit_path(canonical_root, path, allow_scripts=allow_scripts))
return AuditReport(files_scanned=files_scanned, findings=findings)
def collect_paths_depth_first(root: Path) -> list[Path]:
stack = [root]
discovered: list[Path] = []
while stack:
current = stack.pop()
discovered.append(current)
if not current.is_dir():
continue
children = sorted(current.iterdir())
for child in reversed(children):
stack.append(child)
return discovered
def audit_path(root: Path, path: Path, allow_scripts: bool) -> list[str]:
findings: list[str] = []
metadata = path.lstat()
rel = relative_display(root, path)
if path.is_symlink():
findings.append(f"{rel}: symlinks are not allowed in installed skills.")
return findings
if path.is_dir():
return findings
if not allow_scripts and is_unsupported_script_file(path):
findings.append(f"{rel}: script-like files are blocked by skill security policy.")
if metadata.st_size > MAX_TEXT_FILE_BYTES and (is_markdown_file(path) or is_toml_file(path)):
findings.append(f"{rel}: file is too large for static audit (>{MAX_TEXT_FILE_BYTES} bytes).")
return findings
if is_markdown_file(path):
findings.extend(audit_markdown_file(root, path))
elif is_toml_file(path):
findings.extend(audit_manifest_file(root, path))
return findings
def audit_markdown_file(root: Path, path: Path) -> list[str]:
findings: list[str] = []
content = path.read_text(encoding="utf-8")
rel = relative_display(root, path)
pattern = detect_high_risk_snippet(content)
if pattern:
findings.append(f"{rel}: detected high-risk command pattern ({pattern}).")
for target in extract_markdown_links(content):
findings.extend(audit_markdown_link_target(root, path, target))
return findings
def audit_manifest_file(root: Path, path: Path) -> list[str]:
findings: list[str] = []
content = path.read_text(encoding="utf-8")
rel = relative_display(root, path)
pattern = detect_high_risk_snippet(content)
if pattern:
findings.append(f"{rel}: detected high-risk command pattern ({pattern}).")
if any(operator in content for operator in ("&&", "||", ";", "`", "$(")):
findings.append(f"{rel}: manifest content uses shell chaining operators, which are blocked.")
return findings
def extract_markdown_links(content: str) -> list[str]:
return [match.group(1).strip() for match in MARKDOWN_LINK_RE.finditer(content)]
def audit_markdown_link_target(root: Path, source: Path, raw_target: str) -> list[str]:
findings: list[str] = []
normalized = normalize_markdown_target(raw_target)
if not normalized or normalized.startswith("#"):
return findings
rel = relative_display(root, source)
scheme = url_scheme(normalized)
if scheme:
if scheme in {"http", "https", "mailto"}:
if has_markdown_suffix(normalized):
findings.append(
f"{rel}: remote markdown links are blocked by skill security audit ({normalized})."
)
return findings
findings.append(f"{rel}: unsupported URL scheme in markdown link ({normalized}).")
return findings
stripped = strip_query_and_fragment(normalized)
if not stripped:
return findings
if looks_like_absolute_path(stripped):
findings.append(f"{rel}: absolute markdown link paths are not allowed ({normalized}).")
return findings
if has_script_suffix(stripped):
findings.append(f"{rel}: markdown links to script files are blocked ({normalized}).")
if not has_markdown_suffix(stripped):
return findings
base_dir = source.parent
linked_path = base_dir / stripped
try:
canonical_target = linked_path.resolve(strict=True)
except FileNotFoundError:
if is_cross_skill_reference(stripped):
return findings
findings.append(f"{rel}: markdown link points to a missing file ({normalized}).")
return findings
if not is_subpath(canonical_target, root):
skills_root = skills_root_for(root)
if skills_root and is_subpath(canonical_target, skills_root):
if not canonical_target.is_file():
findings.append(f"{rel}: markdown link must point to a file ({normalized}).")
return findings
findings.append(f"{rel}: markdown link escapes skill root ({normalized}).")
return findings
if not canonical_target.is_file():
findings.append(f"{rel}: markdown link must point to a file ({normalized}).")
return findings
def detect_high_risk_snippet(content: str) -> str | None:
for pattern, label in HIGH_RISK_PATTERNS:
if pattern.search(content):
return label
return None
def normalize_markdown_target(raw_target: str) -> str:
trimmed = raw_target.strip()
if trimmed.startswith("<"):
trimmed = trimmed[1:]
if trimmed.endswith(">"):
trimmed = trimmed[:-1]
parts = trimmed.split()
return parts[0] if parts else ""
def strip_query_and_fragment(target: str) -> str:
end = len(target)
hash_idx = target.find("#")
if hash_idx != -1:
end = min(end, hash_idx)
query_idx = target.find("?")
if query_idx != -1:
end = min(end, query_idx)
return target[:end]
def url_scheme(target: str) -> str | None:
if ":" not in target:
return None
scheme, rest = target.split(":", 1)
if not scheme or not rest:
return None
if not all(ch.isalnum() or ch in "+-." for ch in scheme):
return None
return scheme
def looks_like_absolute_path(target: str) -> bool:
if Path(target).is_absolute():
return True
if len(target) >= 3 and target[0].isalpha() and target[1] == ":" and target[2] in "\\/":
return True
return target.startswith("~/")
def is_cross_skill_reference(target: str) -> bool:
normalized = target[2:] if target.startswith("./") else target
path = Path(target)
if ".." in path.parts:
return True
return "/" not in normalized and "\\" not in normalized and has_markdown_suffix(normalized)
def skills_root_for(root: Path) -> Path | None:
current = root
while True:
if current.name == "skills":
return current
if current.parent == current:
return None
current = current.parent
def relative_display(root: Path, path: Path) -> str:
try:
rel = path.relative_to(root)
except ValueError:
return str(path)
return "." if str(rel) == "." else str(rel)
def is_markdown_file(path: Path) -> bool:
return path.suffix.lower() in {".md", ".markdown"}
def is_toml_file(path: Path) -> bool:
return path.suffix.lower() == ".toml"
def is_unsupported_script_file(path: Path) -> bool:
return has_script_suffix(str(path).lower()) or has_shell_shebang(path)
def has_script_suffix(raw: str) -> bool:
lowered = raw.lower()
return any(lowered.endswith(suffix) for suffix in SCRIPT_SUFFIXES)
def has_shell_shebang(path: Path) -> bool:
try:
prefix = path.read_bytes()[:128]
except OSError:
return False
first_line = prefix.decode("utf-8", errors="ignore").splitlines()[0].strip().lower() if prefix else ""
interpreter = shebang_interpreter(first_line)
return interpreter in {"sh", "bash", "zsh", "ksh", "fish", "pwsh", "powershell"}
def shebang_interpreter(line: str) -> str | None:
if not line.startswith("#!"):
return None
shebang = line[2:].strip()
if not shebang:
return None
parts = shebang.split()
first = Path(parts[0]).name
if first == "env":
for part in parts[1:]:
if part.startswith("-"):
continue
return Path(part).name
return None
return first
def has_markdown_suffix(target: str) -> bool:
lowered = target.lower()
return lowered.endswith(".md") or lowered.endswith(".markdown")
def is_subpath(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Validate the sibling skill_lib against ZeroClaw-like rules.")
parser.add_argument(
"--allow-scripts",
action="store_true",
help="Allow shell-script files during auditing.",
)
args = parser.parse_args(argv)
results = validate_all_skills(allow_scripts=args.allow_scripts)
if not results:
print(f"FAIL no skills discovered under {SKILLS_DIR}")
return 1
all_ok = True
for result in results:
status = "PASS" if result.ok else "FAIL"
print(f"{status} {result.record.name}")
for finding in result.report.findings:
print(f" - {finding}")
all_ok = all_ok and result.ok
print(f"Checked {len(results)} skills in {SKILL_LIB_ROOT}")
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,17 +1,110 @@
pub mod planner;
pub mod runtime;
use crate::llm::DeepSeekProvider;
use std::ffi::OsString;
use std::path::PathBuf;
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
use crate::compat::runtime::CompatTaskContext;
use crate::config::SgClawSettings;
use crate::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, Transport};
pub fn execute_task<T: Transport>(
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentRuntimeContext {
config_path: Option<PathBuf>,
workspace_root: PathBuf,
}
impl AgentRuntimeContext {
pub fn new(config_path: Option<PathBuf>, workspace_root: PathBuf) -> Self {
Self {
config_path,
workspace_root,
}
}
pub fn from_process_args<I, S>(args: I) -> Result<Self, PipeError>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let mut config_path = None;
let mut args = args.into_iter().map(Into::into);
let _ = args.next();
while let Some(arg) = args.next() {
if arg == OsString::from("--config-path") {
let Some(value) = args.next() else {
return Err(PipeError::Protocol(
"missing value for --config-path".to_string(),
));
};
config_path = Some(PathBuf::from(value));
continue;
}
let arg_string = arg.to_string_lossy();
if let Some(value) = arg_string.strip_prefix("--config-path=") {
config_path = Some(PathBuf::from(value));
}
}
let workspace_root = config_path
.as_ref()
.and_then(|path| path.parent().map(|parent| parent.to_path_buf()))
.unwrap_or_else(default_workspace_root);
Ok(Self::new(config_path, workspace_root))
}
fn load_sgclaw_settings(&self) -> Result<Option<SgClawSettings>, PipeError> {
SgClawSettings::load(self.config_path.as_deref())
.map_err(|err| PipeError::Protocol(err.to_string()))
}
fn settings_source_label(&self) -> String {
match &self.config_path {
Some(path) if path.exists() => path.display().to_string(),
_ => "environment".to_string(),
}
}
}
impl Default for AgentRuntimeContext {
fn default() -> Self {
Self::new(None, default_workspace_root())
}
}
fn default_workspace_root() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn send_mode_log<T: Transport>(transport: &T, mode: &str) -> Result<(), PipeError> {
transport.send(&AgentMessage::LogEntry {
level: "mode".to_string(),
message: mode.to_string(),
})
}
fn missing_llm_configuration_summary() -> String {
"未配置大语言模型。请先在 sgclaw_config.json 或环境变量中配置 apiKey、baseUrl 与 model。"
.to_string()
}
fn runtime_version_log_message() -> String {
format!(
"sgclaw runtime version={} protocol={}",
env!("CARGO_PKG_VERSION"),
crate::pipe::protocol::PROTOCOL_VERSION
)
}
fn execute_plan<T: Transport>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
instruction: &str,
plan: &planner::TaskPlan,
) -> Result<String, PipeError> {
let plan = planner::plan_instruction(instruction)
.map_err(|err| PipeError::Protocol(err.to_string()))?;
for step in &plan.steps {
transport.send(&AgentMessage::LogEntry {
level: "info".to_string(),
@@ -31,22 +124,201 @@ pub fn execute_task<T: Transport>(
}
}
Ok(plan.summary)
Ok(plan.summary.clone())
}
pub fn handle_browser_message<T: Transport>(
pub fn execute_task<T: Transport>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
instruction: &str,
) -> Result<String, PipeError> {
let plan = planner::plan_instruction(instruction)
.map_err(|err| PipeError::Protocol(err.to_string()))?;
execute_plan(transport, browser_tool, &plan)
}
pub fn handle_browser_message<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
message: BrowserMessage,
) -> Result<(), PipeError> {
match message {
BrowserMessage::SubmitTask { instruction } => {
let completion = match DeepSeekProvider::from_env() {
Ok(provider) => match runtime::execute_task_with_provider(
handle_browser_message_with_context(
transport,
browser_tool,
&provider,
&AgentRuntimeContext::default(),
message,
)
}
pub fn handle_browser_message_with_context<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
context: &AgentRuntimeContext,
message: BrowserMessage,
) -> Result<(), PipeError> {
match message {
BrowserMessage::SubmitTask {
instruction,
conversation_id,
messages,
page_url,
page_title,
} => {
let raw_instruction = instruction;
let trimmed_instruction = raw_instruction.trim().to_string();
if trimmed_instruction.is_empty() {
return transport.send(&AgentMessage::TaskComplete {
success: false,
summary: "请输入任务内容。".to_string(),
});
}
let task_context = CompatTaskContext {
conversation_id: (!conversation_id.trim().is_empty())
.then_some(conversation_id.clone()),
messages,
page_url: (!page_url.trim().is_empty()).then_some(page_url),
page_title: (!page_title.trim().is_empty()).then_some(page_title),
};
let mut instruction = trimmed_instruction;
let mut deterministic_plan = None;
match crate::compat::deterministic_submit::decide_deterministic_submit(
&raw_instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
) {
crate::compat::deterministic_submit::DeterministicSubmitDecision::NotDeterministic => {}
crate::compat::deterministic_submit::DeterministicSubmitDecision::Prompt { summary } => {
return transport.send(&AgentMessage::TaskComplete {
success: false,
summary,
});
}
crate::compat::deterministic_submit::DeterministicSubmitDecision::Execute(plan) => {
instruction = plan.instruction.clone();
deterministic_plan = Some(plan);
}
}
let _ = transport.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: runtime_version_log_message(),
});
if !task_context.messages.is_empty() {
let _ = transport.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: format!(
"continuing conversation with {} prior turns",
task_context.messages.len()
),
});
}
let completion = match context.load_sgclaw_settings() {
Ok(Some(settings)) => {
let resolved_skills_dir =
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
let _ = transport.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: format!(
"DeepSeek config loaded from {} model={} base_url={}",
context.settings_source_label(),
settings.provider_model,
settings.provider_base_url
),
});
let _ = transport.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: format!(
"skills dir resolved to {}",
resolved_skills_dir.display()
),
});
let _ = transport.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: format!(
"runtime profile={:?} skills_prompt_mode={:?}",
settings.runtime_profile, settings.skills_prompt_mode
),
});
if let Some(plan) = deterministic_plan.as_ref() {
let _ = send_mode_log(transport, "direct_skill_primary");
let completion = match crate::compat::deterministic_submit::execute_deterministic_submit(
browser_tool.clone(),
plan,
&context.workspace_root,
&settings,
) {
Ok(outcome) => AgentMessage::TaskComplete {
success: outcome.success,
summary: outcome.summary,
},
Err(err) => AgentMessage::TaskComplete {
success: false,
summary: err.to_string(),
},
};
return transport.send(&completion);
}
if crate::compat::orchestration::should_use_primary_orchestration(
&instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
) {
let _ = send_mode_log(transport, "zeroclaw_process_message_primary");
match crate::compat::orchestration::execute_task_with_sgclaw_settings(
transport,
browser_tool.clone(),
&instruction,
&task_context,
&context.workspace_root,
&settings,
) {
Ok(summary) => {
return transport.send(&AgentMessage::TaskComplete {
success: true,
summary,
})
}
Err(err) => {
return transport.send(&AgentMessage::TaskComplete {
success: false,
summary: err.to_string(),
})
}
}
}
if settings
.direct_submit_skill
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
let _ = send_mode_log(transport, "direct_skill_primary");
let completion = match crate::compat::direct_skill_runtime::execute_direct_submit_skill(
browser_tool.clone(),
&instruction,
&task_context,
&context.workspace_root,
&settings,
) {
Ok(outcome) => AgentMessage::TaskComplete {
success: outcome.success,
summary: outcome.summary,
},
Err(err) => AgentMessage::TaskComplete {
success: false,
summary: err.to_string(),
},
};
return transport.send(&completion);
}
let _ = send_mode_log(transport, "compat_llm_primary");
match crate::compat::runtime::execute_task_with_sgclaw_settings(
transport,
browser_tool.clone(),
&instruction,
&task_context,
&context.workspace_root,
&settings,
) {
Ok(summary) => AgentMessage::TaskComplete {
success: true,
@@ -56,17 +328,22 @@ pub fn handle_browser_message<T: Transport>(
success: false,
summary: err.to_string(),
},
}
}
Ok(None) => AgentMessage::TaskComplete {
success: false,
summary: missing_llm_configuration_summary(),
},
Err(_) => match execute_task(transport, browser_tool, &instruction) {
Ok(summary) => AgentMessage::TaskComplete {
success: true,
summary,
},
Err(err) => AgentMessage::TaskComplete {
Err(err) => {
let _ = transport.send(&AgentMessage::LogEntry {
level: "error".to_string(),
message: format!("failed to load DeepSeek config: {err}"),
});
AgentMessage::TaskComplete {
success: false,
summary: err.to_string(),
},
},
}
}
};
transport.send(&completion)
}

View File

@@ -1,12 +1,21 @@
use reqwest::Url;
use serde_json::{json, Value};
use thiserror::Error;
use crate::config::PlannerMode;
use crate::pipe::Action;
/// Legacy deterministic planner kept for dev-only verification and fixture coverage.
/// Production browser submit flow no longer routes into this planner.
pub const LEGACY_DEV_ONLY: bool = true;
const BAIDU_URL: &str = "https://www.baidu.com";
const BAIDU_DOMAIN: &str = "www.baidu.com";
const BAIDU_INPUT_SELECTOR: &str = "#kw";
const BAIDU_SEARCH_BUTTON_SELECTOR: &str = "#su";
const ZHIHU_HOME_URL: &str = "https://www.zhihu.com";
const ZHIHU_SEARCH_URL: &str = "https://www.zhihu.com/search";
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
#[derive(Debug, Clone, PartialEq)]
pub struct PlannedStep {
@@ -22,6 +31,12 @@ pub struct TaskPlan {
pub steps: Vec<PlannedStep>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionPreview {
pub summary: String,
pub steps: Vec<String>,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum PlannerError {
#[error("unsupported instruction: {0}")]
@@ -32,17 +47,102 @@ pub enum PlannerError {
pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
let trimmed = instruction.trim();
let query = trimmed
.strip_prefix("打开百度搜索")
.or_else(|| trimmed.strip_prefix("打开百度并搜索"))
.ok_or_else(|| PlannerError::UnsupportedInstruction(trimmed.to_string()))?
.trim();
if matches_exact(trimmed, &["打开百度"]) {
return Ok(plan_homepage("打开百度首页", BAIDU_URL, BAIDU_DOMAIN));
}
if let Some(query) = extract_query(trimmed, &["打开百度搜索", "打开百度并搜索"])? {
return Ok(plan_baidu_search(query));
}
if matches_exact(trimmed, &["打开知乎"]) {
return Ok(plan_homepage(
"已打开知乎首页",
ZHIHU_HOME_URL,
ZHIHU_DOMAIN,
));
}
if let Some(query) = extract_query(trimmed, &["打开知乎搜索", "打开知乎并搜索"])? {
return Ok(plan_zhihu_search(query));
}
Err(PlannerError::UnsupportedInstruction(trimmed.to_string()))
}
pub fn build_execution_preview(
mode: PlannerMode,
instruction: &str,
page_url: Option<&str>,
page_title: Option<&str>,
) -> Option<ExecutionPreview> {
if matches!(mode, PlannerMode::LegacyDeterministic) {
return None;
}
let trimmed = instruction.trim();
if crate::runtime::is_zhihu_hotlist_task(trimmed, page_url, page_title) {
return Some(build_zhihu_hotlist_preview(trimmed));
}
if let Ok(plan) = plan_instruction(trimmed) {
return Some(ExecutionPreview {
summary: format!("先规划再执行:{}", plan.summary),
steps: plan
.steps
.into_iter()
.map(|step| step.log_message)
.collect(),
});
}
Some(ExecutionPreview {
summary: "先规划再执行当前任务".to_string(),
steps: vec![
"inspect current browser context".to_string(),
"choose the required sgclaw runtime tools".to_string(),
"execute and return the concrete result".to_string(),
],
})
}
fn extract_query<'a>(
instruction: &'a str,
prefixes: &[&str],
) -> Result<Option<&'a str>, PlannerError> {
let Some(query) = prefixes
.iter()
.find_map(|prefix| instruction.strip_prefix(prefix))
else {
return Ok(None);
};
let query = query.trim();
if query.is_empty() {
return Err(PlannerError::MissingQuery);
}
Ok(TaskPlan {
Ok(Some(query))
}
fn matches_exact(instruction: &str, candidates: &[&str]) -> bool {
candidates.iter().any(|candidate| instruction == *candidate)
}
fn plan_homepage(summary: &str, url: &str, domain: &str) -> TaskPlan {
TaskPlan {
summary: summary.to_string(),
steps: vec![PlannedStep {
action: Action::Navigate,
params: json!({ "url": url }),
expected_domain: domain.to_string(),
log_message: format!("navigate {url}"),
}],
}
}
fn plan_baidu_search(query: &str) -> TaskPlan {
TaskPlan {
summary: format!("已在百度搜索{query}"),
steps: vec![
PlannedStep {
@@ -68,5 +168,49 @@ pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
log_message: format!("click {BAIDU_SEARCH_BUTTON_SELECTOR}"),
},
],
})
}
}
fn plan_zhihu_search(query: &str) -> TaskPlan {
let url = Url::parse_with_params(ZHIHU_SEARCH_URL, &[("type", "content"), ("q", query)])
.expect("valid Zhihu search URL");
let url: String = url.into();
TaskPlan {
summary: format!("已在知乎搜索{query}"),
steps: vec![PlannedStep {
action: Action::Navigate,
params: json!({ "url": url }),
expected_domain: ZHIHU_DOMAIN.to_string(),
log_message: format!("navigate {url}"),
}],
}
}
fn build_zhihu_hotlist_preview(instruction: &str) -> ExecutionPreview {
let normalized = instruction.to_ascii_lowercase();
if normalized.contains("dashboard")
|| instruction.contains("大屏")
|| instruction.contains("新标签页")
{
return ExecutionPreview {
summary: "先规划再执行知乎热榜大屏生成".to_string(),
steps: vec![
"navigate https://www.zhihu.com/hot".to_string(),
"getText main".to_string(),
"call screen_html_export".to_string(),
"return generated local .html path".to_string(),
],
};
}
ExecutionPreview {
summary: "先规划再执行知乎热榜 Excel 导出".to_string(),
steps: vec![
"navigate https://www.zhihu.com/hot".to_string(),
"getText main".to_string(),
"call openxml_office".to_string(),
"return generated local .xlsx path".to_string(),
],
}
}

View File

@@ -3,6 +3,10 @@ use serde_json::{json, Map, Value};
use crate::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
use crate::pipe::{Action, AgentMessage, BrowserPipeTool, PipeError, Transport};
/// Legacy browser-only runtime kept for dev-only validation and narrow regression coverage.
/// Production browser submit flow uses `compat::runtime` plus `runtime::engine`.
pub const LEGACY_DEV_ONLY: bool = true;
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
#[derive(Debug, Clone, PartialEq)]
@@ -21,8 +25,7 @@ pub fn execute_task_with_provider<P: LlmProvider, T: Transport>(
let messages = vec![
ChatMessage {
role: "system".to_string(),
content: "You are sgClaw. Use browser_action to complete the browser task."
.to_string(),
content: "You are sgClaw. Use browser_action to complete the browser task.".to_string(),
},
ChatMessage {
role: "user".to_string(),
@@ -35,8 +38,8 @@ pub fn execute_task_with_provider<P: LlmProvider, T: Transport>(
.map_err(map_llm_error_to_pipe_error)?;
for call in calls {
let browser_call = parse_browser_action_call(call)
.map_err(|err| PipeError::Protocol(err.to_string()))?;
let browser_call =
parse_browser_action_call(call).map_err(|err| PipeError::Protocol(err.to_string()))?;
transport.send(&AgentMessage::LogEntry {
level: "info".to_string(),

View File

@@ -0,0 +1,297 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use reqwest::Url;
use serde_json::{json, Value};
use zeroclaw::skills::{Skill, SkillTool};
use zeroclaw::tools::{Tool, ToolResult};
use crate::pipe::{Action, BrowserPipeTool, Transport};
pub struct BrowserScriptSkillTool<T: Transport> {
tool_name: String,
tool_description: String,
skill_root: PathBuf,
script_path: PathBuf,
args: HashMap<String, String>,
browser_tool: BrowserPipeTool<T>,
}
impl<T: Transport> BrowserScriptSkillTool<T> {
pub fn new(
skill_name: &str,
tool: &SkillTool,
skill_root: &Path,
browser_tool: BrowserPipeTool<T>,
) -> anyhow::Result<Self> {
let script_path = resolve_browser_script_path(skill_root, &tool.command)?;
Ok(Self {
tool_name: format!("{}.{}", skill_name, tool.name),
tool_description: tool.description.clone(),
skill_root: skill_root.to_path_buf(),
script_path,
args: tool.args.clone(),
browser_tool,
})
}
fn build_parameters_schema(&self) -> Value {
let mut properties = serde_json::Map::new();
let mut required = vec![Value::String("expected_domain".to_string())];
properties.insert(
"expected_domain".to_string(),
json!({
"type": "string",
"description": "Bare hostname for the current page, for example www.zhihu.com."
}),
);
for (name, description) in &self.args {
properties.insert(
name.clone(),
json!({
"type": "string",
"description": description
}),
);
required.push(Value::String(name.clone()));
}
json!({
"type": "object",
"properties": properties,
"required": required
})
}
}
#[async_trait]
impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
&self.tool_description
}
fn parameters_schema(&self) -> Value {
self.build_parameters_schema()
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let tool = SkillTool {
name: self.tool_name.clone(),
description: self.tool_description.clone(),
kind: "browser_script".to_string(),
command: self.script_path.to_string_lossy().into_owned(),
args: self.args.clone(),
};
execute_browser_script_tool(&tool, &self.skill_root, self.browser_tool.clone(), args).await
}
}
pub async fn execute_browser_script_tool<T: Transport + 'static>(
tool: &SkillTool,
skill_root: &Path,
browser_tool: BrowserPipeTool<T>,
args: Value,
) -> anyhow::Result<ToolResult> {
if tool.kind != "browser_script" {
return Ok(failed_tool_result(format!(
"browser script tool kind must be browser_script, got {}",
tool.kind
)));
}
let script_path = resolve_browser_script_path(skill_root, &tool.command)?;
let mut args = match args {
Value::Object(args) => args,
other => return Ok(failed_tool_result(format!("expected object arguments, got {other}"))),
};
let raw_expected_domain = match args.remove("expected_domain") {
Some(Value::String(value)) if !value.trim().is_empty() => value,
Some(other) => {
return Ok(failed_tool_result(format!(
"expected_domain must be a non-empty string, got {other}"
)))
}
None => {
return Ok(failed_tool_result(
"missing required field expected_domain".to_string(),
))
}
};
let expected_domain = match normalize_domain_like(&raw_expected_domain) {
Some(value) => value,
None => {
return Ok(failed_tool_result(format!(
"expected_domain must resolve to a hostname, got {raw_expected_domain:?}"
)))
}
};
for required_arg in tool.args.keys() {
if !args.contains_key(required_arg) {
return Ok(failed_tool_result(format!(
"missing required field {required_arg}"
)));
}
}
let script_body = match fs::read_to_string(&script_path) {
Ok(value) => value,
Err(err) => {
return Ok(failed_tool_result(format!(
"failed to read browser script {}: {err}",
script_path.display()
)))
}
};
let wrapped_script = wrap_browser_script(&script_body, &Value::Object(args.clone()));
let result = match browser_tool.invoke(
Action::Eval,
json!({ "script": wrapped_script }),
&expected_domain,
) {
Ok(result) => result,
Err(err) => return Ok(failed_tool_result(err.to_string())),
};
if !result.success {
return Ok(failed_tool_result(format_browser_script_error(&result.data)));
}
let payload = result
.data
.get("text")
.cloned()
.unwrap_or_else(|| result.data.clone());
Ok(ToolResult {
success: true,
output: stringify_tool_payload(&payload)?,
error: None,
})
}
pub fn build_browser_script_skill_tools<T: Transport + 'static>(
skills: &[Skill],
browser_tool: BrowserPipeTool<T>,
) -> Result<Vec<Box<dyn Tool>>, anyhow::Error> {
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
for skill in skills {
let Some(location) = skill.location.as_ref() else {
continue;
};
let Some(skill_root) = location.parent() else {
continue;
};
for tool in &skill.tools {
if tool.kind != "browser_script" {
continue;
}
tools.push(Box::new(BrowserScriptSkillTool::new(
&skill.name,
tool,
skill_root,
browser_tool.clone(),
)?));
}
}
Ok(tools)
}
fn wrap_browser_script(script_body: &str, args: &Value) -> String {
format!(
"(function() {{\nconst args = {};\n{}\n}})()",
serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string()),
script_body
)
}
fn resolve_browser_script_path(skill_root: &Path, command: &str) -> anyhow::Result<PathBuf> {
let script_path = PathBuf::from(command);
let script_path = if script_path.is_absolute() {
script_path
} else {
skill_root.join(script_path)
};
let canonical_skill_root = skill_root
.canonicalize()
.unwrap_or_else(|_| skill_root.to_path_buf());
let canonical_script_path = script_path.canonicalize().map_err(|err| {
anyhow::anyhow!(
"failed to resolve browser script {}: {err}",
script_path.display()
)
})?;
if !canonical_script_path.starts_with(&canonical_skill_root) {
anyhow::bail!(
"browser script path escapes skill root: {}",
canonical_script_path.display()
);
}
Ok(canonical_script_path)
}
fn stringify_tool_payload(payload: &Value) -> anyhow::Result<String> {
Ok(match payload {
Value::String(value) => value.clone(),
Value::Null => "null".to_string(),
Value::Bool(_) | Value::Number(_) | Value::Array(_) | Value::Object(_) => {
serde_json::to_string(payload)?
}
})
}
fn failed_tool_result(error: String) -> ToolResult {
ToolResult {
success: false,
output: String::new(),
error: Some(error),
}
}
fn format_browser_script_error(data: &Value) -> String {
data.get("error")
.and_then(|value| value.get("message"))
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("browser script failed: {data}"))
}
fn normalize_domain_like(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(url) = Url::parse(trimmed) {
return url.host_str().map(|host| host.to_ascii_lowercase());
}
let host = trimmed
.trim_start_matches("https://")
.trim_start_matches("http://")
.split(['/', '?', '#'])
.next()
.unwrap_or_default()
.split(':')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
(!host.is_empty()).then_some(host)
}

View File

@@ -0,0 +1,406 @@
use async_trait::async_trait;
use reqwest::Url;
use serde_json::{json, Map, Value};
use zeroclaw::tools::{Tool, ToolResult};
use crate::pipe::{Action, BrowserPipeTool, ExecutionSurfaceMetadata, Transport};
pub const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
pub const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
const BROWSER_ACTION_TOOL_DESCRIPTION: &str =
"Execute browser actions in SuperRPA through the existing sgClaw pipe protocol.";
const SUPERRPA_BROWSER_TOOL_DESCRIPTION: &str =
"Use SuperRPA's dedicated privileged browser interface for page navigation, DOM reading, clicking, and typing inside the protected browser host.";
const MAX_DATA_STRING_CHARS: usize = 2048;
const MAX_AOM_STRING_CHARS: usize = 128;
const MAX_DATA_ARRAY_ITEMS: usize = 12;
const MAX_DATA_OBJECT_FIELDS: usize = 24;
const MAX_DATA_RECURSION_DEPTH: usize = 4;
pub struct ZeroClawBrowserTool<T: Transport> {
browser_tool: BrowserPipeTool<T>,
tool_name: &'static str,
description: &'static str,
}
impl<T: Transport> ZeroClawBrowserTool<T> {
pub fn new(browser_tool: BrowserPipeTool<T>) -> Self {
Self::named(
browser_tool,
BROWSER_ACTION_TOOL_NAME,
BROWSER_ACTION_TOOL_DESCRIPTION,
)
}
pub fn new_superrpa(browser_tool: BrowserPipeTool<T>) -> Self {
Self::named(
browser_tool,
SUPERRPA_BROWSER_TOOL_NAME,
SUPERRPA_BROWSER_TOOL_DESCRIPTION,
)
}
fn named(
browser_tool: BrowserPipeTool<T>,
tool_name: &'static str,
description: &'static str,
) -> Self {
Self {
browser_tool,
tool_name,
description,
}
}
pub fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
self.browser_tool.surface_metadata()
}
}
#[async_trait]
impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
fn name(&self) -> &str {
self.tool_name
}
fn description(&self) -> &str {
self.description
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"required": ["action", "expected_domain"],
"properties": {
"action": {
"type": "string",
"enum": ["click", "type", "navigate", "getText"]
},
"expected_domain": {
"type": "string"
},
"selector": {
"type": "string"
},
"text": {
"type": "string"
},
"url": {
"type": "string"
},
"clear_first": {
"type": "boolean"
}
}
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let request = match parse_browser_action_request(args) {
Ok(request) => request,
Err(err) => return Ok(failed_tool_result(err.to_string())),
};
let result =
match self
.browser_tool
.invoke(request.action, request.params, &request.expected_domain)
{
Ok(result) => result,
Err(err) => return Ok(failed_tool_result(err.to_string())),
};
let output = serde_json::to_string(&json!({
"seq": result.seq,
"success": result.success,
"data": compact_json_value(&result.data, 0),
"aom_snapshot": compact_aom_snapshot(&result.aom_snapshot),
"aom_snapshot_count": result.aom_snapshot.len(),
"timing": result.timing
}))?;
Ok(ToolResult {
success: result.success,
output,
error: (!result.success).then(|| format_browser_action_error(&result.data)),
})
}
}
struct BrowserActionRequest {
action: Action,
expected_domain: String,
params: Value,
}
fn parse_browser_action_request(
args: Value,
) -> Result<BrowserActionRequest, BrowserActionAdapterError> {
let mut args = match args {
Value::Object(args) => args,
other => {
return Err(BrowserActionAdapterError::InvalidArguments(format!(
"expected object arguments, got {other}"
)))
}
};
let action_name = take_required_string(&mut args, "action")?;
let raw_expected_domain = take_required_string(&mut args, "expected_domain")?;
let action = parse_action(&action_name)?;
validate_action_params(&action_name, &args)?;
let expected_domain = normalize_expected_domain(&action, &raw_expected_domain, &args)?;
Ok(BrowserActionRequest {
action,
expected_domain,
params: Value::Object(args),
})
}
fn parse_action(action_name: &str) -> Result<Action, BrowserActionAdapterError> {
match action_name {
"click" => Ok(Action::Click),
"type" => Ok(Action::Type),
"navigate" => Ok(Action::Navigate),
"getText" => Ok(Action::GetText),
other => Err(BrowserActionAdapterError::UnsupportedAction(
other.to_string(),
)),
}
}
fn take_required_string(
args: &mut Map<String, Value>,
key: &'static str,
) -> Result<String, BrowserActionAdapterError> {
match args.remove(key) {
Some(Value::String(value)) if !value.trim().is_empty() => Ok(value),
Some(other) => Err(BrowserActionAdapterError::InvalidArguments(format!(
"{key} must be a non-empty string, got {other}"
))),
None => Err(BrowserActionAdapterError::MissingField(key)),
}
}
fn failed_tool_result(error: String) -> ToolResult {
ToolResult {
success: false,
output: String::new(),
error: Some(error),
}
}
fn validate_action_params(
action_name: &str,
args: &Map<String, Value>,
) -> Result<(), BrowserActionAdapterError> {
match action_name {
"click" | "getText" => require_non_empty_string(args, "selector", action_name),
"type" => {
require_non_empty_string(args, "selector", action_name)?;
require_non_empty_string(args, "text", action_name)
}
"navigate" => require_non_empty_string(args, "url", action_name),
_ => Ok(()),
}
}
fn require_non_empty_string(
args: &Map<String, Value>,
key: &'static str,
action_name: &str,
) -> Result<(), BrowserActionAdapterError> {
match args.get(key) {
Some(Value::String(value)) if !value.trim().is_empty() => Ok(()),
Some(other) => Err(BrowserActionAdapterError::InvalidArguments(format!(
"{action_name} requires a non-empty {key}, got {other}"
))),
None => Err(BrowserActionAdapterError::InvalidArguments(format!(
"{action_name} requires {key}"
))),
}
}
fn normalize_expected_domain(
action: &Action,
raw_expected_domain: &str,
args: &Map<String, Value>,
) -> Result<String, BrowserActionAdapterError> {
if matches!(action, Action::Navigate) {
if let Some(url) = args.get("url").and_then(Value::as_str) {
if let Some(host) = host_from_url(url) {
return Ok(host);
}
}
}
normalize_domain_like(raw_expected_domain).ok_or_else(|| {
BrowserActionAdapterError::InvalidArguments(format!(
"expected_domain must resolve to a hostname, got {raw_expected_domain:?}"
))
})
}
fn host_from_url(raw: &str) -> Option<String> {
Url::parse(raw)
.ok()?
.host_str()
.map(|host| host.to_ascii_lowercase())
}
fn normalize_domain_like(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if let Some(host) = host_from_url(trimmed) {
return Some(host);
}
let without_scheme = trimmed
.trim_start_matches("https://")
.trim_start_matches("http://");
let host = without_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or_default()
.split(':')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
(!host.is_empty()).then_some(host)
}
fn format_browser_action_error(data: &Value) -> String {
if let Some(error) = data.get("error") {
if let Some(message) = error.get("message").and_then(Value::as_str) {
return message.to_string();
}
return format!("browser action failed: {error}");
}
if data.is_null() {
return "browser action returned success=false".to_string();
}
format!("browser action failed: {data}")
}
fn compact_json_value(value: &Value, depth: usize) -> Value {
compact_json_value_with_string_limit(value, depth, MAX_DATA_STRING_CHARS)
}
fn compact_aom_snapshot(snapshot: &[Value]) -> Value {
Value::Array(
snapshot
.iter()
.take(MAX_DATA_ARRAY_ITEMS)
.map(|item| compact_aom_value(item, 0))
.collect(),
)
}
fn compact_aom_value(value: &Value, depth: usize) -> Value {
if depth >= MAX_DATA_RECURSION_DEPTH {
return Value::String("[truncated nested value]".to_string());
}
match value {
Value::Object(map) => {
let mut compacted = Map::new();
for (key, item) in map.iter().take(MAX_DATA_OBJECT_FIELDS) {
if matches!(key.as_str(), "text" | "value" | "html") {
let summary = item
.as_str()
.map(|text| format!("[{} chars omitted]", text.chars().count()))
.unwrap_or_else(|| "[omitted]".to_string());
compacted.insert(key.clone(), Value::String(summary));
continue;
}
compacted.insert(key.clone(), compact_aom_value(item, depth + 1));
}
Value::Object(compacted)
}
Value::Array(items) => Value::Array(
items
.iter()
.take(MAX_DATA_ARRAY_ITEMS)
.map(|item| compact_aom_value(item, depth + 1))
.collect(),
),
_ => compact_json_value_with_string_limit(value, depth, MAX_AOM_STRING_CHARS),
}
}
fn compact_json_value_with_string_limit(
value: &Value,
depth: usize,
max_string_chars: usize,
) -> Value {
if depth >= MAX_DATA_RECURSION_DEPTH {
return Value::String("[truncated nested value]".to_string());
}
match value {
Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(),
Value::String(text) => Value::String(truncate_string(text, max_string_chars)),
Value::Array(items) => {
let mut compacted: Vec<Value> = items
.iter()
.take(MAX_DATA_ARRAY_ITEMS)
.map(|item| compact_json_value_with_string_limit(item, depth + 1, max_string_chars))
.collect();
if items.len() > MAX_DATA_ARRAY_ITEMS {
compacted.push(Value::String(format!(
"[{} more items omitted]",
items.len() - MAX_DATA_ARRAY_ITEMS
)));
}
Value::Array(compacted)
}
Value::Object(map) => {
let mut compacted = Map::new();
for (key, item) in map.iter().take(MAX_DATA_OBJECT_FIELDS) {
compacted.insert(
key.clone(),
compact_json_value_with_string_limit(item, depth + 1, max_string_chars),
);
}
if map.len() > MAX_DATA_OBJECT_FIELDS {
compacted.insert(
"_truncated_fields".to_string(),
Value::String(format!(
"{} additional fields omitted",
map.len() - MAX_DATA_OBJECT_FIELDS
)),
);
}
Value::Object(compacted)
}
}
}
fn truncate_string(text: &str, max_chars: usize) -> String {
let total_chars = text.chars().count();
if total_chars <= max_chars {
return text.to_string();
}
let prefix: String = text.chars().take(max_chars).collect();
format!("{prefix}...[truncated {} chars]", total_chars - max_chars)
}
#[derive(Debug, thiserror::Error)]
enum BrowserActionAdapterError {
#[error("unsupported action: {0}")]
UnsupportedAction(String),
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("invalid tool arguments: {0}")]
InvalidArguments(String),
}

View File

@@ -0,0 +1,118 @@
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use zeroclaw::config::schema::ModelProviderConfig;
use zeroclaw::Config as ZeroClawConfig;
use crate::compat::cron_adapter::configure_embedded_cron;
use crate::compat::memory_adapter::configure_embedded_memory;
use crate::config::{BrowserBackend, DeepSeekSettings, SgClawSettings};
use crate::runtime::RuntimeProfile;
const SGCLAW_ZEROCLAW_WORKSPACE_DIR: &str = ".sgclaw-zeroclaw-workspace";
const SKILLS_DIR_NAME: &str = "skills";
pub fn build_zeroclaw_config(
workspace_root: &Path,
) -> Result<ZeroClawConfig, crate::config::ConfigError> {
let settings = SgClawSettings::from_env()?;
Ok(build_zeroclaw_config_from_sgclaw_settings(
workspace_root,
&settings,
))
}
pub fn build_zeroclaw_config_from_settings(
workspace_root: &Path,
settings: &DeepSeekSettings,
) -> ZeroClawConfig {
build_zeroclaw_config_from_sgclaw_settings(workspace_root, &SgClawSettings::from(settings))
}
pub fn build_zeroclaw_config_from_sgclaw_settings(
workspace_root: &Path,
settings: &SgClawSettings,
) -> ZeroClawConfig {
let workspace_dir = zeroclaw_workspace_dir(workspace_root);
let active_provider = settings.active_provider_settings();
let mut config = ZeroClawConfig::default();
config.workspace_dir = workspace_dir.clone();
config.config_path = workspace_dir.join("config.toml");
config.default_provider = Some(active_provider.provider.clone());
config.default_model = Some(active_provider.model.clone());
config.api_key = Some(active_provider.api_key.clone());
config.api_url = active_provider.base_url.clone();
config.api_path = active_provider.api_path.clone();
config.default_temperature = match settings.runtime_profile {
RuntimeProfile::BrowserAttached | RuntimeProfile::BrowserHeavy => 0.0,
RuntimeProfile::GeneralAssistant => config.default_temperature,
};
config.skills.prompt_injection_mode = settings.skills_prompt_mode;
config.model_providers = settings
.providers
.iter()
.map(|provider| {
(
provider.id.clone(),
ModelProviderConfig {
name: (!provider.provider.starts_with("custom:"))
.then(|| provider.provider.clone()),
base_url: provider.base_url.clone(),
api_path: provider.api_path.clone(),
wire_api: provider.wire_api.clone(),
requires_openai_auth: provider.requires_openai_auth,
azure_openai_resource: None,
azure_openai_deployment: None,
azure_openai_api_version: None,
max_tokens: None,
},
)
})
.collect::<HashMap<_, _>>();
config.browser.enabled = !matches!(settings.browser_backend, BrowserBackend::SuperRpa);
if let Some(backend) = settings.browser_backend.zeroclaw_backend() {
config.browser.backend = backend.to_string();
}
configure_embedded_memory(&mut config);
configure_embedded_cron(&mut config);
config
}
pub fn zeroclaw_workspace_dir(workspace_root: &Path) -> PathBuf {
workspace_root.join(SGCLAW_ZEROCLAW_WORKSPACE_DIR)
}
pub fn zeroclaw_default_skills_dir(workspace_root: &Path) -> PathBuf {
zeroclaw_workspace_dir(workspace_root).join(SKILLS_DIR_NAME)
}
pub fn resolve_skills_dir(workspace_root: &Path, settings: &DeepSeekSettings) -> PathBuf {
resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref())
}
pub fn resolve_skills_dir_from_sgclaw_settings(
workspace_root: &Path,
settings: &SgClawSettings,
) -> PathBuf {
resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref())
}
fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf {
if configured_dir.file_name() == Some(OsStr::new(SKILLS_DIR_NAME)) {
return configured_dir.to_path_buf();
}
let nested_skills_dir = configured_dir.join(SKILLS_DIR_NAME);
if nested_skills_dir.is_dir() {
nested_skills_dir
} else {
configured_dir.to_path_buf()
}
}
fn resolve_skills_dir_path(workspace_root: &Path, configured_dir: Option<&Path>) -> PathBuf {
configured_dir
.map(normalize_configured_skills_dir)
.unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root))
}

101
src/compat/cron_adapter.rs Normal file
View File

@@ -0,0 +1,101 @@
use std::future::Future;
use chrono::{DateTime, Utc};
use zeroclaw::config::Config as ZeroClawConfig;
use zeroclaw::cron::{self, CronJob, CronRun, JobType, Schedule, SessionTarget};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CronExecutionResult {
pub job_id: String,
pub success: bool,
pub output: String,
}
pub fn configure_embedded_cron(config: &mut ZeroClawConfig) {
config.cron.enabled = true;
config.cron.catch_up_on_startup = false;
config.scheduler.enabled = false;
config.scheduler.max_concurrent = 1;
config.scheduler.max_tasks = config.scheduler.max_tasks.max(1);
}
pub fn add_agent_job(
config: &ZeroClawConfig,
name: Option<String>,
schedule: Schedule,
prompt: &str,
allowed_tools: Option<Vec<String>>,
) -> anyhow::Result<CronJob> {
cron::add_agent_job(
config,
name,
schedule,
prompt,
SessionTarget::Isolated,
None,
None,
false,
allowed_tools,
)
}
pub fn list_jobs(config: &ZeroClawConfig) -> anyhow::Result<Vec<CronJob>> {
cron::list_jobs(config)
}
pub fn list_runs(
config: &ZeroClawConfig,
job_id: &str,
limit: usize,
) -> anyhow::Result<Vec<CronRun>> {
cron::list_runs(config, job_id, limit)
}
pub async fn run_due_jobs<F, Fut>(
config: &ZeroClawConfig,
now: DateTime<Utc>,
mut runner: F,
) -> anyhow::Result<Vec<CronExecutionResult>>
where
F: FnMut(&CronJob) -> Fut,
Fut: Future<Output = anyhow::Result<String>>,
{
let jobs = cron::due_jobs(config, now)?;
let mut results = Vec::with_capacity(jobs.len());
for job in jobs {
if !matches!(job.job_type, JobType::Agent) {
anyhow::bail!(
"unsupported cron job type in sgclaw compat: {:?}",
job.job_type
);
}
let started_at = Utc::now();
let (success, output) = match runner(&job).await {
Ok(output) => (true, output),
Err(err) => (false, err.to_string()),
};
let finished_at = Utc::now();
let duration_ms = (finished_at - started_at).num_milliseconds();
cron::record_run(
config,
&job.id,
started_at,
finished_at,
if success { "ok" } else { "error" },
Some(&output),
duration_ms,
)?;
cron::reschedule_after_run(config, &job, success, &output)?;
results.push(CronExecutionResult {
job_id: job.id,
success,
output,
});
}
Ok(results)
}

View File

@@ -0,0 +1,272 @@
use std::path::Path;
use serde_json::{Map, Value};
use crate::compat::direct_skill_runtime::DirectSubmitOutcome;
use crate::config::SgClawSettings;
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeterministicExecutionPlan {
pub instruction: String,
pub tool_name: String,
pub expected_domain: String,
pub org_label: String,
pub org_code: String,
pub period_mode: String,
pub period_mode_code: String,
pub period_value: String,
pub period_payload: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeterministicSubmitDecision {
NotDeterministic,
Prompt { summary: String },
Execute(DeterministicExecutionPlan),
}
const DETERMINISTIC_SUFFIX: &str = "。。。";
const LINELLOSS_HOST: &str = "20.76.57.61";
const LINELLOSS_TOOL: &str = "tq-lineloss-report.collect_lineloss";
pub fn decide_deterministic_submit(
raw_instruction: &str,
page_url: Option<&str>,
page_title: Option<&str>,
) -> DeterministicSubmitDecision {
let Some(instruction) = strip_exact_deterministic_suffix(raw_instruction) else {
return DeterministicSubmitDecision::NotDeterministic;
};
let normalized_instruction = instruction.trim();
if normalized_instruction.is_empty() {
return unsupported_scene_prompt();
}
if !matches_lineloss_scene(normalized_instruction) {
return unsupported_scene_prompt();
}
let resolved_org = match crate::compat::tq_lineloss::org_resolver::resolve_org_from_instruction(
normalized_instruction,
) {
Ok(Some(resolved_org)) => resolved_org,
Ok(None) => {
return DeterministicSubmitDecision::Prompt {
summary: crate::compat::tq_lineloss::contracts::missing_company_prompt(),
};
}
Err(summary) => {
return DeterministicSubmitDecision::Prompt { summary };
}
};
let resolved_period = match crate::compat::tq_lineloss::period_resolver::resolve_period(
normalized_instruction,
) {
Ok(resolved_period) => resolved_period,
Err(summary) => {
return DeterministicSubmitDecision::Prompt { summary };
}
};
if page_context_conflicts_with_lineloss(page_url, page_title) {
return DeterministicSubmitDecision::Prompt {
summary:
"已命中台区线损报表技能,但当前页面与台区线损场景不匹配,请切换到线损页面后重试。"
.to_string(),
};
}
DeterministicSubmitDecision::Execute(DeterministicExecutionPlan {
instruction: normalized_instruction.to_string(),
tool_name: LINELLOSS_TOOL.to_string(),
expected_domain: LINELLOSS_HOST.to_string(),
org_label: resolved_org.label,
org_code: resolved_org.code,
period_mode: period_mode_name(&resolved_period.mode).to_string(),
period_mode_code: resolved_period.mode_code,
period_value: resolved_period.value,
period_payload: serde_json::to_string(&resolved_period.payload)
.unwrap_or_else(|_| "{}".to_string()),
})
}
pub fn execute_deterministic_submit<T: Transport + 'static>(
browser_tool: BrowserPipeTool<T>,
plan: &DeterministicExecutionPlan,
workspace_root: &Path,
settings: &SgClawSettings,
) -> Result<DirectSubmitOutcome, PipeError> {
let mut args = Map::new();
args.insert(
"expected_domain".to_string(),
Value::String(plan.expected_domain.clone()),
);
args.insert(
"org_label".to_string(),
Value::String(plan.org_label.clone()),
);
args.insert(
"org_code".to_string(),
Value::String(plan.org_code.clone()),
);
args.insert(
"period_mode".to_string(),
Value::String(plan.period_mode.clone()),
);
args.insert(
"period_mode_code".to_string(),
Value::String(plan.period_mode_code.clone()),
);
args.insert(
"period_value".to_string(),
Value::String(plan.period_value.clone()),
);
args.insert(
"period_payload".to_string(),
Value::String(plan.period_payload.clone()),
);
let output = crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output(
browser_tool,
&plan.tool_name,
workspace_root,
settings,
args,
)?;
Ok(summarize_lineloss_output(&output))
}
fn summarize_lineloss_output(output: &str) -> DirectSubmitOutcome {
let Some(payload) = serde_json::from_str::<Value>(output).ok() else {
return DirectSubmitOutcome {
success: true,
summary: output.to_string(),
};
};
let artifact = payload
.as_object()
.and_then(|object| object.get("text"))
.unwrap_or(&payload);
summarize_lineloss_artifact(artifact)
}
fn summarize_lineloss_artifact(artifact: &Value) -> DirectSubmitOutcome {
let Some(artifact) = artifact.as_object() else {
return DirectSubmitOutcome {
success: true,
summary: artifact.to_string(),
};
};
if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") {
return DirectSubmitOutcome {
success: true,
summary: Value::Object(artifact.clone()).to_string(),
};
}
let status = artifact
.get("status")
.and_then(Value::as_str)
.unwrap_or("ok");
let success = matches!(status, "ok" | "partial" | "empty");
let report_name = artifact
.get("report_name")
.and_then(Value::as_str)
.unwrap_or("tq-lineloss-report");
let org_label = artifact
.get("org")
.and_then(Value::as_object)
.and_then(|org| org.get("label"))
.and_then(Value::as_str)
.unwrap_or("");
let period_value = artifact
.get("period")
.and_then(Value::as_object)
.and_then(|period| period.get("value"))
.and_then(Value::as_str)
.unwrap_or("");
let rows = artifact
.get("counts")
.and_then(Value::as_object)
.and_then(|counts| counts.get("rows"))
.and_then(Value::as_u64)
.map(|value| value as usize)
.or_else(|| artifact.get("rows").and_then(Value::as_array).map(Vec::len))
.unwrap_or(0);
let reasons = artifact
.get("reasons")
.and_then(Value::as_array)
.map(|reasons| {
reasons
.iter()
.filter_map(Value::as_str)
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut parts = vec![report_name.to_string()];
if !org_label.is_empty() {
parts.push(org_label.to_string());
}
if !period_value.is_empty() {
parts.push(period_value.to_string());
}
parts.push(format!("status={status}"));
parts.push(format!("rows={rows}"));
if !reasons.is_empty() {
parts.push(format!("reasons={}", reasons.join(",")));
}
DirectSubmitOutcome {
success,
summary: parts.join(" "),
}
}
fn strip_exact_deterministic_suffix(raw_instruction: &str) -> Option<&str> {
let without_suffix = raw_instruction.strip_suffix(DETERMINISTIC_SUFFIX)?;
if without_suffix.ends_with('。') {
return None;
}
Some(without_suffix)
}
fn matches_lineloss_scene(instruction: &str) -> bool {
instruction.contains("线损") || instruction.contains("月累计") || instruction.contains("周累计")
}
fn page_context_conflicts_with_lineloss(page_url: Option<&str>, page_title: Option<&str>) -> bool {
let url = page_url.unwrap_or_default().to_ascii_lowercase();
let title = page_title.unwrap_or_default();
let has_context = !url.is_empty() || !title.is_empty();
if !has_context {
return false;
}
let url_matches = url.contains(LINELLOSS_HOST) || url.contains("lineloss");
let title_matches = title.contains("线损");
!(url_matches || title_matches)
}
fn period_mode_name(mode: &crate::compat::tq_lineloss::contracts::PeriodMode) -> &'static str {
match mode {
crate::compat::tq_lineloss::contracts::PeriodMode::Month => "month",
crate::compat::tq_lineloss::contracts::PeriodMode::Week => "week",
}
}
fn unsupported_scene_prompt() -> DeterministicSubmitDecision {
DeterministicSubmitDecision::Prompt {
summary: "确定性提交当前只支持台区线损月/周累计线损率报表场景,请补充台区线损请求。"
.to_string(),
}
}

View File

@@ -0,0 +1,371 @@
use std::path::Path;
use reqwest::Url;
use serde_json::{Map, Value};
use zeroclaw::skills::{load_skills_from_directory, SkillTool};
use crate::compat::browser_script_skill_tool::execute_browser_script_tool;
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
use crate::compat::runtime::CompatTaskContext;
use crate::config::SgClawSettings;
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectSubmitOutcome {
pub success: bool,
pub summary: String,
}
pub fn execute_direct_submit_skill<T: Transport + 'static>(
browser_tool: BrowserPipeTool<T>,
instruction: &str,
task_context: &CompatTaskContext,
workspace_root: &Path,
settings: &SgClawSettings,
) -> Result<DirectSubmitOutcome, PipeError> {
let configured_tool = settings
.direct_submit_skill
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| PipeError::Protocol("direct submit skill is not configured".to_string()))?;
let expected_domain = derive_expected_domain(task_context)?;
let period = derive_period(instruction)?;
let mut args = Map::new();
args.insert("expected_domain".to_string(), Value::String(expected_domain));
args.insert("period".to_string(), Value::String(period));
let output = execute_browser_script_skill_raw_output(
browser_tool,
configured_tool,
workspace_root,
settings,
args,
)?;
Ok(interpret_direct_submit_output(&output))
}
pub fn execute_browser_script_skill_raw_output<T: Transport + 'static>(
browser_tool: BrowserPipeTool<T>,
configured_tool: &str,
workspace_root: &Path,
settings: &SgClawSettings,
args: Map<String, Value>,
) -> Result<String, PipeError> {
let (skill_name, tool_name) = parse_configured_tool_name(configured_tool)?;
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
let skills = load_skills_from_directory(&skills_dir, true);
let skill = skills
.iter()
.find(|skill| skill.name == skill_name)
.ok_or_else(|| {
PipeError::Protocol(format!(
"direct submit skill {skill_name} was not found in {}",
skills_dir.display()
))
})?;
let tool = skill
.tools
.iter()
.find(|tool| tool.name == tool_name)
.ok_or_else(|| {
PipeError::Protocol(format!(
"direct submit tool {configured_tool} was not found"
))
})?;
let skill_root = skill
.location
.as_deref()
.and_then(Path::parent)
.ok_or_else(|| {
PipeError::Protocol(format!(
"direct submit skill {skill_name} is missing a resolvable location"
))
})?;
execute_browser_script_tool_output(browser_tool, configured_tool, tool, skill_root, args)
}
fn execute_browser_script_tool_output<T: Transport + 'static>(
browser_tool: BrowserPipeTool<T>,
configured_tool: &str,
tool: &SkillTool,
skill_root: &Path,
args: Map<String, Value>,
) -> Result<String, PipeError> {
if tool.kind != "browser_script" {
return Err(PipeError::Protocol(format!(
"direct submit tool {configured_tool} must be browser_script, got {}",
tool.kind
)));
}
let mut tool = tool.clone();
tool.args.remove("expected_domain");
let runtime = tokio::runtime::Runtime::new()
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
let result = runtime
.block_on(execute_browser_script_tool(
&tool,
skill_root,
browser_tool,
Value::Object(args),
))
.map_err(|err| PipeError::Protocol(err.to_string()))?;
if result.success {
Ok(result.output)
} else {
Err(PipeError::Protocol(
result
.error
.unwrap_or_else(|| "direct submit skill execution failed".to_string()),
))
}
}
fn interpret_direct_submit_output(output: &str) -> DirectSubmitOutcome {
let Some(payload) = serde_json::from_str::<Value>(output).ok() else {
return DirectSubmitOutcome {
success: true,
summary: output.to_string(),
};
};
let Some(artifact) = payload.as_object() else {
return DirectSubmitOutcome {
success: true,
summary: output.to_string(),
};
};
if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") {
return DirectSubmitOutcome {
success: true,
summary: output.to_string(),
};
}
let status = artifact
.get("status")
.and_then(Value::as_str)
.unwrap_or("ok");
let success = matches!(status, "ok" | "partial" | "empty");
let report_name = artifact
.get("report_name")
.and_then(Value::as_str)
.unwrap_or("report-artifact");
let period = artifact
.get("period")
.and_then(Value::as_str)
.unwrap_or("");
let detail_rows = count_rows(artifact.get("counts"), artifact.get("rows"), "detail_rows");
let summary_rows = count_summary_rows(artifact.get("counts"), artifact.get("sections"));
let partial_reasons = artifact
.get("partial_reasons")
.and_then(Value::as_array)
.map(|reasons| {
reasons
.iter()
.filter_map(Value::as_str)
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut parts = vec![report_name.to_string()];
if !period.trim().is_empty() {
parts.push(period.to_string());
}
parts.push(format!("status={status}"));
parts.push(format!("detail_rows={detail_rows}"));
parts.push(format!("summary_rows={summary_rows}"));
if !partial_reasons.is_empty() {
parts.push(format!("partial_reasons={}", partial_reasons.join(",")));
}
DirectSubmitOutcome {
success,
summary: parts.join(" "),
}
}
fn count_rows(counts: Option<&Value>, rows: Option<&Value>, key: &str) -> usize {
counts
.and_then(Value::as_object)
.and_then(|counts| counts.get(key))
.and_then(Value::as_u64)
.map(|count| count as usize)
.or_else(|| rows.and_then(Value::as_array).map(Vec::len))
.unwrap_or(0)
}
fn count_summary_rows(counts: Option<&Value>, sections: Option<&Value>) -> usize {
counts
.and_then(Value::as_object)
.and_then(|counts| counts.get("summary_rows"))
.and_then(Value::as_u64)
.map(|count| count as usize)
.or_else(|| {
sections
.and_then(Value::as_array)
.and_then(|sections| {
sections.iter().find_map(|section| {
section
.as_object()
.and_then(|section| section.get("rows"))
.and_then(Value::as_array)
.map(Vec::len)
})
})
})
.unwrap_or(0)
}
fn parse_configured_tool_name(configured_tool: &str) -> Result<(&str, &str), PipeError> {
let (skill_name, tool_name) = configured_tool.split_once('.').ok_or_else(|| {
PipeError::Protocol(format!(
"direct submit skill must use skill.tool format, got {configured_tool}"
))
})?;
let skill_name = skill_name.trim();
let tool_name = tool_name.trim();
if skill_name.is_empty() || tool_name.is_empty() {
return Err(PipeError::Protocol(format!(
"direct submit skill must use skill.tool format, got {configured_tool}"
)));
}
Ok((skill_name, tool_name))
}
fn derive_expected_domain(task_context: &CompatTaskContext) -> Result<String, PipeError> {
let page_url = task_context
.page_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
PipeError::Protocol(
"direct submit skill requires page_url so expected_domain can be derived"
.to_string(),
)
})?;
Url::parse(page_url)
.ok()
.and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
.ok_or_else(|| {
PipeError::Protocol(format!(
"direct submit skill could not derive expected_domain from page_url {page_url:?}"
))
})
}
fn derive_period(instruction: &str) -> Result<String, PipeError> {
let chars = instruction.chars().collect::<Vec<_>>();
if chars.len() < 7 {
return Err(PipeError::Protocol(
"direct submit skill requires an explicit YYYY-MM period in the instruction"
.to_string(),
));
}
for start in 0..=chars.len() - 7 {
let candidate = chars[start..start + 7].iter().collect::<String>();
if is_year_month(&candidate) {
return Ok(candidate);
}
}
Err(PipeError::Protocol(
"direct submit skill requires an explicit YYYY-MM period in the instruction"
.to_string(),
))
}
fn is_year_month(candidate: &str) -> bool {
let bytes = candidate.as_bytes();
bytes.len() == 7
&& bytes[0..4].iter().all(u8::is_ascii_digit)
&& bytes[4] == b'-'
&& bytes[5..7].iter().all(u8::is_ascii_digit)
&& matches!((bytes[5] - b'0') * 10 + (bytes[6] - b'0'), 1..=12)
}
#[cfg(test)]
mod tests {
use super::{
count_rows, count_summary_rows, derive_period, interpret_direct_submit_output,
is_year_month, parse_configured_tool_name,
};
use serde_json::json;
#[test]
fn parse_configured_tool_name_requires_skill_and_tool() {
assert_eq!(
parse_configured_tool_name("fault-details-report.collect_fault_details")
.unwrap(),
("fault-details-report", "collect_fault_details")
);
assert!(parse_configured_tool_name("fault-details-report").is_err());
}
#[test]
fn derive_period_requires_explicit_year_month() {
assert_eq!(derive_period("收集 2026-03 故障明细").unwrap(), "2026-03");
assert!(derive_period("收集三月故障明细").is_err());
}
#[test]
fn year_month_validation_rejects_invalid_month() {
assert!(is_year_month("2026-12"));
assert!(!is_year_month("2026-00"));
assert!(!is_year_month("2026-13"));
}
#[test]
fn interpret_direct_submit_output_maps_report_artifact_statuses() {
let partial = interpret_direct_submit_output(
&json!({
"type": "report-artifact",
"report_name": "fault-details-report",
"period": "2026-03",
"counts": { "detail_rows": 1, "summary_rows": 1 },
"status": "partial",
"partial_reasons": ["report_log_failed"]
})
.to_string(),
);
assert!(partial.success);
assert!(partial.summary.contains("status=partial"));
assert!(partial.summary.contains("report_log_failed"));
let blocked = interpret_direct_submit_output(
&json!({
"type": "report-artifact",
"report_name": "fault-details-report",
"status": "blocked",
"partial_reasons": ["selected_range_unavailable"]
})
.to_string(),
);
assert!(!blocked.success);
assert!(blocked.summary.contains("status=blocked"));
}
#[test]
fn row_count_helpers_fall_back_to_payload_shapes() {
assert_eq!(
count_rows(None, Some(&json!([{ "qxdbh": "QX-1" }, { "qxdbh": "QX-2" }])), "detail_rows"),
2
);
assert_eq!(
count_summary_rows(None, Some(&json!([{ "name": "summary-sheet", "rows": [{ "index": 1 }] }]))),
1
);
}
}

View File

@@ -0,0 +1,88 @@
use std::collections::HashMap;
use serde_json::Value;
use zeroclaw::agent::TurnEvent;
use crate::pipe::AgentMessage;
pub fn log_entry_for_turn_event(
event: &TurnEvent,
skill_versions: &HashMap<String, String>,
) -> Option<AgentMessage> {
match event {
TurnEvent::ToolCall { name, args } => Some(AgentMessage::LogEntry {
level: "info".to_string(),
message: format_tool_call(name, args, skill_versions),
}),
TurnEvent::ToolResult { output, .. } if is_tool_error(output) => {
Some(AgentMessage::LogEntry {
level: "error".to_string(),
message: output.trim_start_matches("Error: ").to_string(),
})
}
_ => None,
}
}
fn format_tool_call(name: &str, args: &Value, skill_versions: &HashMap<String, String>) -> String {
if name == "read_skill" {
let skill_name = args
.get("name")
.and_then(Value::as_str)
.unwrap_or("<missing-skill>");
if let Some(version) = skill_versions.get(skill_name) {
return format!("read_skill {skill_name}@{version}");
}
return format!("read_skill {skill_name}");
}
if !is_browser_tool_call(name) {
return format!("call {name}");
}
let action = args
.get("action")
.and_then(Value::as_str)
.unwrap_or("unknown");
match action {
"navigate" => {
let url = args
.get("url")
.and_then(Value::as_str)
.unwrap_or("<missing-url>");
format!("navigate {url}")
}
"type" => {
let text = args.get("text").and_then(Value::as_str).unwrap_or("");
let selector = args
.get("selector")
.and_then(Value::as_str)
.unwrap_or("<missing-selector>");
format!("type {text} into {selector}")
}
"click" => {
let selector = args
.get("selector")
.and_then(Value::as_str)
.unwrap_or("<missing-selector>");
format!("click {selector}")
}
"getText" => {
let selector = args
.get("selector")
.and_then(Value::as_str)
.unwrap_or("<missing-selector>");
format!("getText {selector}")
}
other => format!("{name} {other}"),
}
}
fn is_browser_tool_call(name: &str) -> bool {
name == "browser_action" || name == "superrpa_browser"
}
fn is_tool_error(output: &str) -> bool {
output.starts_with("Error:")
}

View File

@@ -0,0 +1,30 @@
use std::path::{Path, PathBuf};
use zeroclaw::config::Config as ZeroClawConfig;
use zeroclaw::memory::{self, Memory};
pub fn configure_embedded_memory(config: &mut ZeroClawConfig) {
config.memory.backend = "sqlite".to_string();
config.memory.embedding_provider = "none".to_string();
config.memory.response_cache_enabled = false;
config.memory.snapshot_enabled = false;
config.memory.snapshot_on_hygiene = false;
config.storage.provider.config.provider.clear();
config.storage.provider.config.db_url = None;
config.storage.provider.config.connect_timeout_secs = None;
}
pub fn build_memory(config: &ZeroClawConfig) -> anyhow::Result<Box<dyn Memory>> {
memory::create_memory_with_storage_and_routes(
&config.memory,
&config.embedding_routes,
Some(&config.storage.provider.config),
&config.workspace_dir,
config.api_key.as_deref(),
)
}
pub fn brain_db_path(workspace_dir: &Path) -> PathBuf {
workspace_dir.join("memory").join("brain.db")
}

14
src/compat/mod.rs Normal file
View File

@@ -0,0 +1,14 @@
pub mod browser_script_skill_tool;
pub mod browser_tool_adapter;
pub mod config_adapter;
pub mod cron_adapter;
pub mod deterministic_submit;
pub mod direct_skill_runtime;
pub mod event_bridge;
pub mod memory_adapter;
pub mod openxml_office_tool;
pub mod orchestration;
pub mod runtime;
pub mod screen_html_export_tool;
pub mod tq_lineloss;
pub mod workflow_executor;

View File

@@ -0,0 +1,545 @@
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use zeroclaw::tools::{Tool, ToolResult};
use zip::write::FileOptions;
use zip::{CompressionMethod, ZipWriter};
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
const DEFAULT_SHEET_NAME: &str = "知乎热榜";
const MAX_COLUMNS: [&str; 3] = ["rank", "title", "heat"];
pub struct OpenXmlOfficeTool {
workspace_root: PathBuf,
}
impl OpenXmlOfficeTool {
pub fn new(workspace_root: PathBuf) -> Self {
Self { workspace_root }
}
}
#[derive(Debug, Deserialize)]
struct OpenXmlOfficeArgs {
sheet_name: String,
columns: Vec<String>,
rows: Vec<Vec<Value>>,
#[serde(default)]
output_path: Option<String>,
}
#[async_trait]
impl Tool for OpenXmlOfficeTool {
fn name(&self) -> &str {
OPENXML_OFFICE_TOOL_NAME
}
fn description(&self) -> &str {
"Export structured Zhihu hotlist rows into a local .xlsx file through the OpenXML office pipeline."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"required": ["sheet_name", "columns", "rows"],
"properties": {
"sheet_name": { "type": "string" },
"columns": {
"type": "array",
"items": { "type": "string" }
},
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {}
}
},
"output_path": { "type": "string" }
}
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let parsed = match serde_json::from_value::<OpenXmlOfficeArgs>(args) {
Ok(value) => value,
Err(err) => return Ok(failed_tool_result(format!("invalid tool arguments: {err}"))),
};
if parsed.sheet_name.trim() != DEFAULT_SHEET_NAME {
return Ok(failed_tool_result(format!(
"unsupported sheet_name: expected {DEFAULT_SHEET_NAME}"
)));
}
let expected_columns = MAX_COLUMNS
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>();
let column_order = match resolve_column_order(&parsed.columns, &expected_columns) {
Some(order) => order,
None => {
return Ok(failed_tool_result(
"unsupported columns: expected [rank, title, heat]".to_string(),
))
}
};
if parsed.rows.is_empty() {
return Ok(failed_tool_result("rows must not be empty".to_string()));
}
if parsed
.rows
.iter()
.any(|row| row.len() != parsed.columns.len())
{
return Ok(failed_tool_result(
"each row must match the declared columns length".to_string(),
));
}
if parsed.rows.iter().any(|row| row.len() != 3) {
return Ok(failed_tool_result(
"each row must contain exactly 3 values".to_string(),
));
}
let normalized_rows = parsed
.rows
.iter()
.map(|row| reorder_row(row, &column_order))
.collect::<Vec<_>>();
let job_root = create_job_root(&self.workspace_root)?;
let template_path = job_root.join("zhihu_hotlist_template.xlsx");
let payload_path = job_root.join("payload.json");
let request_path = job_root.join("request.json");
let output_path = parsed
.output_path
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| default_output_path(&self.workspace_root));
write_hotlist_template(&template_path, normalized_rows.len())?;
write_payload_json(&payload_path, &normalized_rows)?;
write_request_json(&request_path, &template_path, &payload_path, &output_path)?;
let rendered = run_openxml_cli(&request_path)?;
let artifact_path = rendered["data"]["artifact"]["path"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| output_path.to_string_lossy().to_string());
Ok(ToolResult {
success: true,
output: json!({
"sheet_name": DEFAULT_SHEET_NAME,
"output_path": artifact_path,
"row_count": normalized_rows.len(),
"renderer": OPENXML_OFFICE_TOOL_NAME
})
.to_string(),
error: None,
})
}
}
fn failed_tool_result(error: String) -> ToolResult {
ToolResult {
success: false,
output: String::new(),
error: Some(error),
}
}
fn create_job_root(workspace_root: &Path) -> anyhow::Result<PathBuf> {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let path = workspace_root
.join(".sgclaw-openxml")
.join(format!("{nanos}"));
fs::create_dir_all(&path)?;
Ok(path)
}
fn default_output_path(workspace_root: &Path) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_nanos())
.unwrap_or_default();
workspace_root
.join("out")
.join(format!("zhihu-hotlist-{nanos}.xlsx"))
}
fn resolve_column_order(
provided_columns: &[String],
expected_columns: &[String],
) -> Option<Vec<usize>> {
if provided_columns.len() != expected_columns.len() {
return None;
}
let canonicalized_columns = provided_columns
.iter()
.map(|value| canonicalize_column_name(value))
.collect::<Option<Vec<_>>>()?;
let provided_set = canonicalized_columns
.iter()
.map(|value| value.to_string())
.collect::<BTreeSet<_>>();
let expected_set = expected_columns.iter().cloned().collect::<BTreeSet<_>>();
if provided_set != expected_set {
return None;
}
expected_columns
.iter()
.map(|expected| {
canonicalized_columns
.iter()
.position(|provided| *provided == expected)
})
.collect::<Option<Vec<_>>>()
}
fn canonicalize_column_name(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"rank" | "排名" | "名次" | "序号" => Some("rank"),
"title" | "标题" | "题目" | "问题" => Some("title"),
"heat" | "热度" | "热值" => Some("heat"),
_ => None,
}
}
fn reorder_row(row: &[Value], column_order: &[usize]) -> Vec<Value> {
column_order
.iter()
.map(|index| row[*index].clone())
.collect()
}
fn write_payload_json(path: &Path, rows: &[Vec<Value>]) -> anyhow::Result<()> {
let mut variables = BTreeMap::new();
for (idx, row) in rows.iter().enumerate() {
let row_index = idx + 1;
variables.insert(format!("RANK_{row_index}"), value_to_string(&row[0]));
variables.insert(format!("TITLE_{row_index}"), value_to_string(&row[1]));
variables.insert(format!("HEAT_{row_index}"), value_to_string(&row[2]));
}
let payload = json!({
"variables": variables,
"tables": {},
"images": {}
});
fs::write(path, serde_json::to_vec_pretty(&payload)?)?;
Ok(())
}
fn write_request_json(
path: &Path,
template_path: &Path,
payload_path: &Path,
output_path: &Path,
) -> anyhow::Result<()> {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
let request = json!({
"api_version": "2026-03-26",
"job": "zhihu_hotlist_export",
"template": {
"kind": "xlsx",
"path": template_path
},
"output": {
"path": output_path
},
"data": {
"json_path": payload_path
},
"options": {
"strict": true,
"allow_unresolved": false,
"dry_run": false
}
});
fs::write(path, serde_json::to_vec_pretty(&request)?)?;
Ok(())
}
fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.map(|path| path.join("openxml_cli").join("Cargo.toml"))
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli manifest path"))?;
let output = if let Some(binary_path) = resolve_openxml_cli_binary(&manifest_path) {
Command::new(binary_path)
.args([
"template",
"render",
"--request",
request_path.to_string_lossy().as_ref(),
"--json",
])
.output()?
} else {
Command::new("cargo")
.args([
"run",
"--quiet",
"--manifest-path",
manifest_path.to_string_lossy().as_ref(),
"--",
"template",
"render",
"--request",
request_path.to_string_lossy().as_ref(),
"--json",
])
.output()?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow::anyhow!(if stderr.is_empty() {
"openxml_cli render failed".to_string()
} else {
stderr
}));
}
let stdout = String::from_utf8(output.stdout)?;
Ok(serde_json::from_str(&stdout)?)
}
fn resolve_openxml_cli_binary(manifest_path: &Path) -> Option<PathBuf> {
let cli_dir = manifest_path.parent()?;
openxml_cli_candidate_paths(cli_dir)
.into_iter()
.find(|path| path.exists())
}
fn openxml_cli_candidate_paths(cli_dir: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
for profile in ["release", "debug"] {
paths.push(
cli_dir
.join("target")
.join(profile)
.join(openxml_cli_binary_name()),
);
}
paths
}
fn openxml_cli_binary_name() -> &'static str {
if cfg!(windows) {
"openxml-cli.exe"
} else {
"openxml-cli"
}
}
fn value_to_string(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Number(number) => number.to_string(),
Value::Bool(flag) => flag.to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn write_hotlist_template(path: &Path, row_count: usize) -> anyhow::Result<()> {
let build_root = path
.parent()
.ok_or_else(|| anyhow::anyhow!("template path has no parent"))?
.join("template-build");
fs::create_dir_all(build_root.join("_rels"))?;
fs::create_dir_all(build_root.join("docProps"))?;
fs::create_dir_all(build_root.join("xl/_rels"))?;
fs::create_dir_all(build_root.join("xl/worksheets"))?;
fs::write(build_root.join("[Content_Types].xml"), content_types_xml())?;
fs::write(build_root.join("_rels/.rels"), root_rels_xml())?;
fs::write(build_root.join("docProps/app.xml"), app_xml())?;
fs::write(build_root.join("docProps/core.xml"), core_xml())?;
fs::write(build_root.join("xl/workbook.xml"), workbook_xml())?;
fs::write(
build_root.join("xl/_rels/workbook.xml.rels"),
workbook_rels_xml(),
)?;
fs::write(
build_root.join("xl/worksheets/sheet1.xml"),
worksheet_xml(row_count),
)?;
if path.exists() {
fs::remove_file(path)?;
}
zip_directory(&build_root, path)?;
let _ = fs::remove_dir_all(&build_root);
Ok(())
}
#[cfg(test)]
mod tests {
use super::{openxml_cli_binary_name, openxml_cli_candidate_paths, zip_entry_name};
use std::path::Path;
#[test]
fn openxml_cli_candidates_prefer_release_before_debug() {
let paths = openxml_cli_candidate_paths(Path::new("E:\\coding\\codex\\openxml_cli"));
assert_eq!(paths.len(), 2);
assert_eq!(
paths[0],
Path::new("E:\\coding\\codex\\openxml_cli")
.join("target")
.join("release")
.join(openxml_cli_binary_name())
);
assert_eq!(
paths[1],
Path::new("E:\\coding\\codex\\openxml_cli")
.join("target")
.join("debug")
.join(openxml_cli_binary_name())
);
}
#[test]
fn zip_entry_name_normalizes_windows_separators() {
let rel = Path::new("xl\\worksheets\\sheet1.xml");
assert_eq!(zip_entry_name(rel), "xl/worksheets/sheet1.xml");
}
}
fn zip_directory(source_root: &Path, zip_path: &Path) -> anyhow::Result<()> {
let file = fs::File::create(zip_path)?;
let mut writer = ZipWriter::new(file);
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
add_directory_to_zip(&mut writer, source_root, source_root, options)?;
writer.finish()?;
Ok(())
}
fn add_directory_to_zip<W: Write + std::io::Seek>(
writer: &mut ZipWriter<W>,
source_root: &Path,
current_dir: &Path,
options: FileOptions,
) -> anyhow::Result<()> {
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
add_directory_to_zip(writer, source_root, &path, options)?;
continue;
}
let relative_path = path.strip_prefix(source_root)?;
writer.start_file(zip_entry_name(relative_path), options)?;
let mut input = fs::File::open(&path)?;
let mut buffer = Vec::new();
input.read_to_end(&mut buffer)?;
writer.write_all(&buffer)?;
}
Ok(())
}
fn zip_entry_name(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn worksheet_xml(row_count: usize) -> String {
let mut rows = Vec::new();
rows.push(
"<row r=\"1\"><c r=\"A1\" t=\"inlineStr\"><is><t>rank</t></is></c><c r=\"B1\" t=\"inlineStr\"><is><t>title</t></is></c><c r=\"C1\" t=\"inlineStr\"><is><t>heat</t></is></c></row>"
.to_string(),
);
for idx in 1..=row_count {
let excel_row = idx + 1;
rows.push(format!(
"<row r=\"{excel_row}\"><c r=\"A{excel_row}\" t=\"inlineStr\"><is><t>{{{{RANK_{idx}}}}}</t></is></c><c r=\"B{excel_row}\" t=\"inlineStr\"><is><t>{{{{TITLE_{idx}}}}}</t></is></c><c r=\"C{excel_row}\" t=\"inlineStr\"><is><t>{{{{HEAT_{idx}}}}}</t></is></c></row>"
));
}
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
<sheetData>{}</sheetData>\
</worksheet>",
rows.join("")
)
}
fn content_types_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>"#
}
fn root_rels_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>"#
}
fn app_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
<Application>sgClaw</Application>
</Properties>"#
}
fn core_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:dcterms="http://purl.org/dc/terms/"
xmlns:dcmitype="http://purl.org/dc/dcmitype/"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>Zhihu Hotlist Export</dc:title>
</cp:coreProperties>"#
}
fn workbook_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="知乎热榜" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>"#
}
fn workbook_rels_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>"#
}

View File

@@ -0,0 +1,94 @@
use std::path::Path;
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
use crate::compat::runtime::CompatTaskContext;
use crate::config::SgClawSettings;
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
pub fn should_use_primary_orchestration(
instruction: &str,
page_url: Option<&str>,
page_title: Option<&str>,
) -> bool {
if crate::compat::workflow_executor::detect_route(instruction, page_url, page_title)
.is_some_and(|route| crate::compat::workflow_executor::prefers_direct_execution(&route))
{
return true;
}
let normalized = instruction.to_ascii_lowercase();
let needs_export = normalized.contains("excel")
|| normalized.contains("xlsx")
|| instruction.contains("导出")
|| instruction.contains("大屏")
|| instruction.contains("新标签页")
|| normalized.contains("dashboard");
crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) && needs_export
}
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
transport: &T,
browser_tool: BrowserPipeTool<T>,
instruction: &str,
task_context: &CompatTaskContext,
workspace_root: &Path,
settings: &SgClawSettings,
) -> Result<String, PipeError> {
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
let route = crate::compat::workflow_executor::detect_route(
instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
);
if let Some(route) = route.clone() {
if crate::compat::workflow_executor::prefers_direct_execution(&route) {
return crate::compat::workflow_executor::execute_route(
transport,
&browser_tool,
workspace_root,
&skills_dir,
instruction,
task_context,
route,
);
}
}
let primary_result = crate::compat::runtime::execute_task_with_sgclaw_settings(
transport,
browser_tool.clone(),
instruction,
task_context,
workspace_root,
settings,
);
match (route, primary_result) {
(Some(route), Ok(summary))
if crate::compat::workflow_executor::should_fallback_after_summary(
&summary, &route,
) =>
{
crate::compat::workflow_executor::execute_route(
transport,
&browser_tool,
workspace_root,
&skills_dir,
instruction,
task_context,
route,
)
}
(_, Ok(summary)) => Ok(summary),
(Some(route), Err(_)) => crate::compat::workflow_executor::execute_route(
transport,
&browser_tool,
workspace_root,
&skills_dir,
instruction,
task_context,
route,
),
(None, Err(err)) => Err(err),
}
}

310
src/compat/runtime.rs Normal file
View File

@@ -0,0 +1,310 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use futures_util::{stream, StreamExt};
use zeroclaw::agent::TurnEvent;
use zeroclaw::config::Config as ZeroClawConfig;
use zeroclaw::providers::traits::{ProviderCapabilities, StreamEvent, StreamOptions, StreamResult};
use zeroclaw::providers::{self, ChatMessage, ChatRequest, ChatResponse, Provider};
use crate::compat::browser_script_skill_tool::build_browser_script_skill_tools;
use crate::compat::browser_tool_adapter::ZeroClawBrowserTool;
use crate::compat::config_adapter::{
build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir_from_sgclaw_settings,
};
use crate::compat::event_bridge::log_entry_for_turn_event;
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
use crate::config::{DeepSeekSettings, OfficeBackend, SgClawSettings};
use crate::pipe::{BrowserPipeTool, ConversationMessage, PipeError, Transport};
use crate::runtime::RuntimeEngine;
#[derive(Debug, Clone, Default)]
pub struct CompatTaskContext {
pub conversation_id: Option<String>,
pub messages: Vec<ConversationMessage>,
pub page_url: Option<String>,
pub page_title: Option<String>,
}
pub fn execute_task<T: Transport + 'static>(
transport: &T,
browser_tool: BrowserPipeTool<T>,
instruction: &str,
task_context: &CompatTaskContext,
workspace_root: &Path,
settings: &DeepSeekSettings,
) -> Result<String, PipeError> {
let sgclaw_settings = SgClawSettings::from(settings);
execute_task_with_sgclaw_settings(
transport,
browser_tool,
instruction,
task_context,
workspace_root,
&sgclaw_settings,
)
}
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
transport: &T,
browser_tool: BrowserPipeTool<T>,
instruction: &str,
task_context: &CompatTaskContext,
workspace_root: &Path,
settings: &SgClawSettings,
) -> Result<String, PipeError> {
let config = build_zeroclaw_config_from_sgclaw_settings(workspace_root, settings);
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
let provider = build_provider(&config)?;
let runtime = tokio::runtime::Runtime::new()
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
runtime.block_on(execute_task_with_provider(
transport,
browser_tool,
provider,
instruction,
task_context,
config,
skills_dir,
settings.clone(),
))
}
pub async fn execute_task_with_provider<T: Transport + 'static>(
transport: &T,
browser_tool: BrowserPipeTool<T>,
provider: Box<dyn Provider>,
instruction: &str,
task_context: &CompatTaskContext,
config: ZeroClawConfig,
skills_dir: PathBuf,
settings: SgClawSettings,
) -> Result<String, PipeError> {
let engine = RuntimeEngine::new(settings.runtime_profile);
let browser_surface_present = engine.browser_surface_enabled();
if let Some(preview) = crate::agent::planner::build_execution_preview(
settings.planner_mode,
instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
) {
let mut message = preview.summary;
if !preview.steps.is_empty() {
message.push('\n');
message.push_str(&preview.steps.join("\n"));
}
transport.send(&crate::pipe::AgentMessage::LogEntry {
level: "plan".to_string(),
message,
})?;
}
let loaded_skills = engine.loaded_skills(&config, &skills_dir);
let loaded_skill_versions = loaded_skills
.iter()
.map(|skill| (skill.name.clone(), skill.version.clone()))
.collect::<HashMap<_, _>>();
let loaded_skill_labels = loaded_skills
.iter()
.map(|skill| format!("{}@{}", skill.name, skill.version))
.collect::<Vec<_>>();
if !loaded_skill_labels.is_empty() {
transport.send(&crate::pipe::AgentMessage::LogEntry {
level: "info".to_string(),
message: format!("loaded skills: {}", loaded_skill_labels.join(", ")),
})?;
}
let browser_tool_for_scripts = browser_tool.clone();
let mut tools: Vec<Box<dyn zeroclaw::tools::Tool>> = if browser_surface_present {
vec![
Box::new(ZeroClawBrowserTool::new_superrpa(browser_tool.clone())),
Box::new(ZeroClawBrowserTool::new(browser_tool)),
]
} else {
Vec::new()
};
if browser_surface_present {
tools.extend(
build_browser_script_skill_tools(&loaded_skills, browser_tool_for_scripts)
.map_err(map_anyhow_to_pipe_error)?,
);
}
if matches!(settings.office_backend, OfficeBackend::OpenXml)
&& engine.should_attach_openxml_office_tool(instruction)
{
tools.push(Box::new(OpenXmlOfficeTool::new(
config.workspace_dir.clone(),
)));
}
if engine.should_attach_screen_html_export_tool(instruction) {
tools.push(Box::new(ScreenHtmlExportTool::new(
config.workspace_dir.clone(),
)));
}
let mut agent = engine.build_agent(
provider,
&config,
&skills_dir,
tools,
browser_surface_present,
instruction,
)?;
if let Some(conversation_id) = task_context
.conversation_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
agent.set_memory_session_id(Some(conversation_id.to_string()));
}
let mut seed_messages = Vec::new();
seed_messages.extend(build_seed_history(task_context));
if !seed_messages.is_empty() {
agent.seed_history(&seed_messages);
}
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<TurnEvent>(32);
let instruction = engine.build_instruction(
instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
browser_surface_present,
);
let task = tokio::spawn(async move { agent.turn_streamed(&instruction, event_tx).await });
while let Some(event) = event_rx.recv().await {
if let Some(log_entry) = log_entry_for_turn_event(&event, &loaded_skill_versions) {
transport.send(&log_entry)?;
}
}
task.await
.map_err(|err| PipeError::Protocol(format!("zeroclaw task join failed: {err}")))?
.map_err(|err| PipeError::Protocol(err.to_string()))
}
fn build_provider(config: &ZeroClawConfig) -> Result<Box<dyn Provider>, PipeError> {
let provider_name = config.default_provider.as_deref().unwrap_or("deepseek");
let model_name = config.default_model.as_deref().unwrap_or("deepseek-chat");
let runtime_options = providers::provider_runtime_options_from_config(config);
let resolved_provider_name = if provider_name == "deepseek" {
config
.api_url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())
.map(|url| format!("custom:{url}"))
.unwrap_or_else(|| provider_name.to_string())
} else {
provider_name.to_string()
};
let provider = providers::create_routed_provider_with_options(
&resolved_provider_name,
config.api_key.as_deref(),
config.api_url.as_deref(),
&config.reliability,
&config.model_routes,
model_name,
&runtime_options,
)
.map_err(map_anyhow_to_pipe_error)?;
Ok(Box::new(NonStreamingProvider::new(provider)))
}
fn map_anyhow_to_pipe_error(err: anyhow::Error) -> PipeError {
PipeError::Protocol(err.to_string())
}
struct NonStreamingProvider {
inner: Box<dyn Provider>,
}
impl NonStreamingProvider {
fn new(inner: Box<dyn Provider>) -> Self {
Self { inner }
}
}
#[async_trait]
impl Provider for NonStreamingProvider {
fn capabilities(&self) -> ProviderCapabilities {
self.inner.capabilities()
}
async fn chat_with_system(
&self,
system_prompt: Option<&str>,
message: &str,
model: &str,
temperature: f64,
) -> anyhow::Result<String> {
self.inner
.chat_with_system(system_prompt, message, model, temperature)
.await
}
async fn chat_with_history(
&self,
messages: &[ChatMessage],
model: &str,
temperature: f64,
) -> anyhow::Result<String> {
self.inner
.chat_with_history(messages, model, temperature)
.await
}
async fn chat(
&self,
request: ChatRequest<'_>,
model: &str,
temperature: f64,
) -> anyhow::Result<ChatResponse> {
self.inner.chat(request, model, temperature).await
}
fn supports_streaming(&self) -> bool {
false
}
fn supports_streaming_tool_events(&self) -> bool {
false
}
fn stream_chat(
&self,
_request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
_options: StreamOptions,
) -> stream::BoxStream<'static, StreamResult<StreamEvent>> {
stream::empty().boxed()
}
}
fn build_seed_history(task_context: &CompatTaskContext) -> Vec<ChatMessage> {
task_context
.messages
.iter()
.filter_map(to_chat_message)
.collect()
}
fn to_chat_message(message: &ConversationMessage) -> Option<ChatMessage> {
let content = message.content.trim();
if content.is_empty() {
return None;
}
match message.role.as_str() {
"user" => Some(ChatMessage::user(content)),
"assistant" => Some(ChatMessage::assistant(content)),
"system" => Some(ChatMessage::system(content)),
_ => None,
}
}

View File

@@ -0,0 +1,393 @@
use async_trait::async_trait;
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use zeroclaw::tools::{Tool, ToolResult};
const SCREEN_HTML_EXPORT_TOOL_NAME: &str = "screen_html_export";
const DEFAULT_SCREEN_TITLE: &str = "知乎热榜主题分类分析大屏";
const TEMPLATE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/resources/zhihu-hotlist-echarts.html"
));
const PAYLOAD_START_MARKER: &str = " const defaultPayload = ";
const PAYLOAD_END_MARKER: &str = "\n\n const themeMeta = {";
pub struct ScreenHtmlExportTool {
workspace_root: PathBuf,
}
impl ScreenHtmlExportTool {
pub fn new(workspace_root: PathBuf) -> Self {
Self { workspace_root }
}
}
#[derive(Debug, Deserialize)]
struct ScreenHtmlExportArgs {
#[serde(default)]
snapshot_id: Option<String>,
#[serde(default)]
generated_at_ms: Option<u64>,
#[serde(default)]
rows: Option<Vec<Vec<Value>>>,
#[serde(default)]
table: Option<Vec<ScreenTableRow>>,
#[serde(default)]
categories: Option<Vec<ScreenCategory>>,
#[serde(default)]
output_path: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct ScreenCategory {
category_code: String,
category_label: String,
item_count: u64,
total_heat: u64,
avg_heat: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct ScreenTableRow {
rank: u64,
title: String,
url: String,
category_code: String,
category_label: String,
heat_text: String,
heat_value: u64,
reply_count: u64,
upvote_count: u64,
favorite_count: u64,
heart_count: u64,
}
#[derive(Debug, Serialize)]
struct ScreenPayload {
snapshot_id: String,
generated_at_ms: u64,
categories: Vec<ScreenCategory>,
table: Vec<ScreenTableRow>,
}
#[async_trait]
impl Tool for ScreenHtmlExportTool {
fn name(&self) -> &str {
SCREEN_HTML_EXPORT_TOOL_NAME
}
fn description(&self) -> &str {
"Render a local Zhihu hotlist ECharts dashboard HTML for leadership demos and new-tab presentation."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"snapshot_id": { "type": "string" },
"generated_at_ms": { "type": "integer" },
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {}
}
},
"table": {
"type": "array",
"items": { "type": "object" }
},
"categories": {
"type": "array",
"items": { "type": "object" }
},
"output_path": { "type": "string" }
}
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let parsed = match serde_json::from_value::<ScreenHtmlExportArgs>(args) {
Ok(value) => value,
Err(err) => return Ok(failed_tool_result(format!("invalid tool arguments: {err}"))),
};
let table = match parsed.table {
Some(table) if !table.is_empty() => table,
Some(_) => return Ok(failed_tool_result("table must not be empty".to_string())),
None => match parsed.rows {
Some(rows) => build_table_from_rows(&rows)?,
None => {
return Ok(failed_tool_result(
"rows or table is required for screen_html_export".to_string(),
))
}
},
};
if table.is_empty() {
return Ok(failed_tool_result("table must not be empty".to_string()));
}
let categories = parsed
.categories
.filter(|items| !items.is_empty())
.unwrap_or_else(|| derive_categories(&table));
let payload = ScreenPayload {
snapshot_id: parsed
.snapshot_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(default_snapshot_id),
generated_at_ms: parsed.generated_at_ms.unwrap_or_else(now_ms),
categories,
table,
};
let rendered = render_template(&payload)?;
let output_path = parsed
.output_path
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| default_output_path(&self.workspace_root));
write_output_html(&output_path, &rendered)?;
let presentation_url = file_url_for_path(&output_path);
Ok(ToolResult {
success: true,
output: json!({
"title": DEFAULT_SCREEN_TITLE,
"output_path": output_path,
"renderer": SCREEN_HTML_EXPORT_TOOL_NAME,
"row_count": payload.table.len(),
"snapshot_id": payload.snapshot_id,
"presentation": {
"mode": "new_tab",
"title": DEFAULT_SCREEN_TITLE,
"url": presentation_url,
"open_in_new_tab": true
}
})
.to_string(),
error: None,
})
}
}
fn failed_tool_result(error: String) -> ToolResult {
ToolResult {
success: false,
output: String::new(),
error: Some(error),
}
}
fn build_table_from_rows(rows: &[Vec<Value>]) -> anyhow::Result<Vec<ScreenTableRow>> {
if rows.is_empty() {
return Err(anyhow::anyhow!("rows must not be empty"));
}
rows.iter()
.enumerate()
.map(|(index, row)| {
if row.len() != 3 {
return Err(anyhow::anyhow!(
"each row must contain exactly 3 values: rank, title, heat"
));
}
let rank = value_to_rank(&row[0]).unwrap_or((index + 1) as u64);
let title = value_to_string(&row[1]);
if title.trim().is_empty() {
return Err(anyhow::anyhow!("title must not be empty"));
}
let heat_text = value_to_string(&row[2]);
let heat_value = parse_heat_value(&heat_text);
let (category_code, category_label) = classify_title(&title);
Ok(ScreenTableRow {
rank,
title,
url: format!("https://www.zhihu.com/question/hotlist-{rank}"),
category_code: category_code.to_string(),
category_label: category_label.to_string(),
heat_text,
heat_value,
reply_count: 0,
upvote_count: 0,
favorite_count: 0,
heart_count: 0,
})
})
.collect()
}
fn derive_categories(table: &[ScreenTableRow]) -> Vec<ScreenCategory> {
let mut grouped: BTreeMap<(String, String), (u64, u64)> = BTreeMap::new();
for row in table {
let key = (row.category_code.clone(), row.category_label.clone());
let entry = grouped.entry(key).or_insert((0, 0));
entry.0 += 1;
entry.1 += row.heat_value;
}
grouped
.into_iter()
.map(
|((category_code, category_label), (item_count, total_heat))| ScreenCategory {
category_code,
category_label,
item_count,
total_heat,
avg_heat: if item_count == 0 {
0
} else {
total_heat / item_count
},
},
)
.collect()
}
fn classify_title(title: &str) -> (&'static str, &'static str) {
let normalized = title.to_ascii_lowercase();
if contains_any(
&normalized,
&["ai", "芯片", "科技", "算法", "机器人", "无人机"],
) {
return ("technology", "科技");
}
if contains_any(
&normalized,
&["电影", "综艺", "明星", "周杰伦", "短剧", "娱乐"],
) {
return ("entertainment", "娱乐");
}
if contains_any(
&normalized,
&["足球", "比赛", "联赛", "国足", "体育", "冠军"],
) {
return ("sports", "体育");
}
if contains_any(&normalized, &["航母", "作战", "", "军事", "演训"]) {
return ("military", "军事");
}
if contains_any(&normalized, &["出口", "经济", "市场", "财经", "消费", ""]) {
return ("finance", "财经");
}
("society", "社会")
}
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
fn parse_heat_value(heat_text: &str) -> u64 {
let compact = heat_text.trim().replace(',', "");
if compact.is_empty() {
return 0;
}
let number_part = compact
.chars()
.filter(|ch| ch.is_ascii_digit() || *ch == '.')
.collect::<String>();
let base = number_part.parse::<f64>().unwrap_or(0.0);
let multiplier = if compact.contains('亿') {
100_000_000.0
} else if compact.contains('万') {
10_000.0
} else {
1.0
};
(base * multiplier).round() as u64
}
fn value_to_string(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Number(number) => number.to_string(),
Value::Bool(flag) => flag.to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn value_to_rank(value: &Value) -> Option<u64> {
match value {
Value::Number(number) => number.as_u64(),
Value::String(text) => text.trim().parse::<u64>().ok(),
_ => None,
}
}
fn render_template(payload: &ScreenPayload) -> anyhow::Result<String> {
let payload_json = serde_json::to_string_pretty(payload)?;
let payload_start = TEMPLATE
.find(PAYLOAD_START_MARKER)
.ok_or_else(|| anyhow::anyhow!("default payload start marker missing"))?;
let payload_end = TEMPLATE
.find(PAYLOAD_END_MARKER)
.ok_or_else(|| anyhow::anyhow!("default payload end marker missing"))?;
let replacement = format!(
"{PAYLOAD_START_MARKER}{}\n",
indent_block(&payload_json, " ")
);
Ok(format!(
"{}{}{}",
&TEMPLATE[..payload_start],
replacement,
&TEMPLATE[payload_end..],
))
}
fn indent_block(value: &str, indent: &str) -> String {
value
.lines()
.map(|line| format!("{indent}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
fn write_output_html(path: &Path, rendered: &str) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, rendered)?;
Ok(())
}
fn default_output_path(workspace_root: &Path) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_nanos())
.unwrap_or_default();
workspace_root
.join("out")
.join(format!("zhihu-hotlist-screen-{nanos}.html"))
}
fn default_snapshot_id() -> String {
format!("zhihu-hotlist-screen-{}", now_ms())
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis() as u64)
.unwrap_or_default()
}
fn file_url_for_path(path: &Path) -> String {
Url::from_file_path(path)
.map(|url| url.to_string())
.unwrap_or_else(|_| format!("file://{}", path.display()))
}

View File

@@ -0,0 +1,50 @@
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedOrg {
pub label: String,
pub code: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PeriodMode {
Month,
Week,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedPeriod {
pub mode: PeriodMode,
pub mode_code: String,
pub value: String,
pub payload: Value,
}
pub fn missing_company_prompt() -> String {
"已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。"
.to_string()
}
pub fn ambiguous_company_prompt() -> String {
"已命中台区线损报表技能,但供电单位存在歧义,请补充更完整名称。".to_string()
}
pub fn missing_period_mode_prompt() -> String {
"已命中台区线损报表技能,但未识别到月/周类型,请补充“月累计”或“周累计”。"
.to_string()
}
pub fn missing_period_prompt() -> String {
"已命中台区线损报表技能但缺少统计周期请补充如“2026-03”或“2026年第12周”。"
.to_string()
}
pub fn contradictory_period_mode_prompt() -> String {
"已命中台区线损报表技能,但月/周类型存在冲突,请只保留“月累计”或“周累计”之一。"
.to_string()
}
pub fn missing_week_year_prompt() -> String {
"已命中台区线损报表技能但周累计缺少年份请补充如“2026年第12周”。"
.to_string()
}

View File

@@ -0,0 +1,4 @@
pub mod contracts;
pub mod org_resolver;
pub mod org_units;
pub mod period_resolver;

View File

@@ -0,0 +1,71 @@
use super::contracts::{ambiguous_company_prompt, ResolvedOrg};
use super::org_units::{OrgUnit, ORG_UNITS};
fn normalize(value: &str) -> String {
value.chars().filter(|ch| !ch.is_whitespace()).collect()
}
fn candidate_names(unit: &'static OrgUnit) -> impl Iterator<Item = &'static str> {
std::iter::once(unit.label).chain(unit.aliases.iter().copied())
}
fn to_resolved_org(unit: &OrgUnit) -> ResolvedOrg {
ResolvedOrg {
label: unit.label.to_string(),
code: unit.code.to_string(),
}
}
pub fn resolve_org(input: &str) -> Result<ResolvedOrg, String> {
let normalized = normalize(input);
if normalized.is_empty() {
return Err(super::contracts::missing_company_prompt());
}
let exact_matches: Vec<&OrgUnit> = ORG_UNITS
.iter()
.filter(|unit| candidate_names(unit).any(|name| normalize(name) == normalized))
.collect();
if exact_matches.len() == 1 {
return Ok(to_resolved_org(exact_matches[0]));
}
if exact_matches.len() > 1 {
return Err(ambiguous_company_prompt());
}
let fuzzy_matches: Vec<&OrgUnit> = ORG_UNITS
.iter()
.filter(|unit| {
candidate_names(unit).any(|name| {
let normalized_name = normalize(name);
normalized_name.contains(&normalized) || normalized.contains(&normalized_name)
})
})
.collect();
if fuzzy_matches.len() == 1 {
return Ok(to_resolved_org(fuzzy_matches[0]));
}
if fuzzy_matches.len() > 1 {
return Err(ambiguous_company_prompt());
}
Err(super::contracts::missing_company_prompt())
}
pub fn resolve_org_from_instruction(instruction: &str) -> Result<Option<ResolvedOrg>, String> {
let normalized_instruction = normalize(instruction);
let direct_matches: Vec<&OrgUnit> = ORG_UNITS
.iter()
.filter(|unit| {
candidate_names(unit).any(|name| normalized_instruction.contains(&normalize(name)))
})
.collect();
if direct_matches.len() == 1 {
return Ok(Some(to_resolved_org(direct_matches[0])));
}
if direct_matches.len() > 1 {
return Err(ambiguous_company_prompt());
}
Ok(None)
}

View File

@@ -0,0 +1,33 @@
pub(crate) struct OrgUnit {
pub(crate) label: &'static str,
pub(crate) code: &'static str,
pub(crate) aliases: &'static [&'static str],
}
pub(crate) const ORG_UNITS: &[OrgUnit] = &[
OrgUnit {
label: "国网兰州供电公司",
code: "62401",
aliases: &["国网兰州供电公司", "兰州供电公司", "兰州公司"],
},
OrgUnit {
label: "国网天水供电公司",
code: "62403",
aliases: &["国网天水供电公司", "天水供电公司", "天水公司"],
},
OrgUnit {
label: "城关供电分公司",
code: "6240108",
aliases: &["城关供电分公司", "城关分公司"],
},
OrgUnit {
label: "国网榆中县供电公司",
code: "6240121",
aliases: &["国网榆中县供电公司", "榆中县供电公司", "榆中县公司"],
},
OrgUnit {
label: "榆中城关供电所",
code: "624012108",
aliases: &["榆中城关供电所"],
},
];

View File

@@ -0,0 +1,244 @@
use chrono::{Datelike, Duration, Local, NaiveDate};
use serde_json::json;
use super::contracts::{
contradictory_period_mode_prompt, missing_period_mode_prompt, missing_period_prompt,
missing_week_year_prompt, PeriodMode, ResolvedPeriod,
};
pub fn resolve_period(input: &str) -> Result<ResolvedPeriod, String> {
let has_month = input.contains("月累计");
let has_week = input.contains("周累计");
match (has_month, has_week) {
(true, true) => return Err(contradictory_period_mode_prompt()),
(false, false) => return Err(missing_period_mode_prompt()),
(true, false) => resolve_month_period(input),
(false, true) => resolve_week_period(input),
}
}
fn resolve_month_period(input: &str) -> Result<ResolvedPeriod, String> {
if let Some(value) = extract_year_month_dash(input) {
return Ok(ResolvedPeriod {
mode: PeriodMode::Month,
mode_code: "1".to_string(),
value: value.clone(),
payload: json!({ "fdate": value }),
});
}
if let Some(value) = extract_year_month_cn(input) {
return Ok(ResolvedPeriod {
mode: PeriodMode::Month,
mode_code: "1".to_string(),
value: value.clone(),
payload: json!({ "fdate": value }),
});
}
if contains_explicit_month_period_hint(input) {
return Err(missing_period_prompt());
}
Ok(default_month_period())
}
fn resolve_week_period(input: &str) -> Result<ResolvedPeriod, String> {
if input.contains('第') && input.contains('周') && !input.contains('年') {
return Err(missing_week_year_prompt());
}
if let Some((year, week)) = extract_year_week(input) {
let Some(week_start) = week_start_date(year, week) else {
return Err(missing_period_prompt());
};
let week_end = week_start + Duration::days(6);
return Ok(ResolvedPeriod {
mode: PeriodMode::Week,
mode_code: "2".to_string(),
value: format!("{year}-W{week:02}"),
payload: json!({
"tjzq": "week",
"level": "00",
"weekSfdate": week_start.format("%Y-%m-%d").to_string(),
"weekEfdate": week_end.format("%Y-%m-%d").to_string(),
}),
});
}
if contains_explicit_week_period_hint(input) {
return Err(missing_period_prompt());
}
Ok(default_week_period())
}
fn default_month_period() -> ResolvedPeriod {
let today = Local::now().date_naive();
let (year, month) = if today.month() == 1 {
(today.year() - 1, 12)
} else {
(today.year(), today.month() - 1)
};
let value = format!("{year}-{month:02}");
ResolvedPeriod {
mode: PeriodMode::Month,
mode_code: "1".to_string(),
value: value.clone(),
payload: json!({ "fdate": value }),
}
}
fn default_week_period() -> ResolvedPeriod {
let today = Local::now().date_naive();
let month_start = today.with_day(1).expect("current month should have day 1");
let start = month_start.format("%Y-%m-%d").to_string();
let end = today.format("%Y-%m-%d").to_string();
ResolvedPeriod {
mode: PeriodMode::Week,
mode_code: "2".to_string(),
value: format!("{start}至{end}"),
payload: json!({
"tjzq": "week",
"level": "00",
"weekSfdate": start,
"weekEfdate": end,
}),
}
}
fn contains_explicit_month_period_hint(input: &str) -> bool {
let trimmed = input.replace("月累计", "");
trimmed.contains('年')
|| trimmed.contains('月')
|| trimmed.contains('-')
|| trimmed.chars().any(|ch| ch.is_ascii_digit())
}
fn contains_explicit_week_period_hint(input: &str) -> bool {
let trimmed = input.replace("周累计", "");
trimmed.contains('年')
|| trimmed.contains('第')
|| trimmed.contains('周')
|| trimmed.contains('-')
|| trimmed.chars().any(|ch| ch.is_ascii_digit())
}
fn extract_year_month_dash(input: &str) -> Option<String> {
let chars: Vec<char> = input.chars().collect();
for window in chars.windows(7) {
let candidate: String = window.iter().collect();
if is_year_month_dash(&candidate) {
return Some(candidate);
}
}
None
}
fn is_year_month_dash(candidate: &str) -> bool {
let bytes = candidate.as_bytes();
bytes.len() == 7
&& bytes[0..4].iter().all(u8::is_ascii_digit)
&& bytes[4] == b'-'
&& bytes[5..7].iter().all(u8::is_ascii_digit)
&& matches!((bytes[5] - b'0') * 10 + (bytes[6] - b'0'), 1..=12)
}
fn extract_year_month_cn(input: &str) -> Option<String> {
let chars: Vec<char> = input.chars().collect();
for index in 0..chars.len() {
if index + 6 >= chars.len() {
break;
}
if !chars[index..index + 4].iter().all(|ch| ch.is_ascii_digit()) {
continue;
}
if chars[index + 4] != '年' {
continue;
}
let mut month_digits = String::new();
let mut cursor = index + 5;
while cursor < chars.len() && chars[cursor].is_ascii_digit() && month_digits.len() < 2 {
month_digits.push(chars[cursor]);
cursor += 1;
}
if month_digits.is_empty() || cursor >= chars.len() || chars[cursor] != '月' {
continue;
}
let month: u32 = month_digits.parse().ok()?;
if !(1..=12).contains(&month) {
continue;
}
let year: String = chars[index..index + 4].iter().collect();
return Some(format!("{year}-{month:02}"));
}
None
}
fn extract_year_week(input: &str) -> Option<(i32, u32)> {
let chars: Vec<char> = input.chars().collect();
for index in 0..chars.len() {
if index + 7 >= chars.len() {
break;
}
if !chars[index..index + 4].iter().all(|ch| ch.is_ascii_digit()) {
continue;
}
if chars[index + 4] != '年' || chars[index + 5] != '第' {
continue;
}
let mut week_digits = String::new();
let mut cursor = index + 6;
while cursor < chars.len() && chars[cursor].is_ascii_digit() && week_digits.len() < 2 {
week_digits.push(chars[cursor]);
cursor += 1;
}
if week_digits.is_empty() || cursor >= chars.len() || chars[cursor] != '周' {
continue;
}
let year: i32 = chars[index..index + 4].iter().collect::<String>().parse().ok()?;
let week: u32 = week_digits.parse().ok()?;
if !(1..=53).contains(&week) {
continue;
}
return Some((year, week));
}
None
}
fn week_start_date(year: i32, week: u32) -> Option<NaiveDate> {
let jan4 = NaiveDate::from_ymd_opt(year, 1, 4)?;
let iso_week1_monday = jan4 - Duration::days(jan4.weekday().num_days_from_monday() as i64);
let candidate = iso_week1_monday + Duration::weeks((week - 1) as i64);
let iso = candidate.iso_week();
(iso.year() == year && iso.week() == week).then_some(candidate)
}
#[cfg(test)]
mod tests {
use super::resolve_period;
use crate::compat::tq_lineloss::contracts::PeriodMode;
#[test]
fn resolves_dash_month() {
let resolved = resolve_period("月累计 2026-03").unwrap();
assert_eq!(resolved.mode, PeriodMode::Month);
assert_eq!(resolved.payload["fdate"], "2026-03");
}
#[test]
fn resolves_week_range() {
let resolved = resolve_period("周累计 2026年第12周").unwrap();
assert_eq!(resolved.mode, PeriodMode::Week);
assert_eq!(resolved.payload["weekSfdate"], "2026-03-16");
assert_eq!(resolved.payload["weekEfdate"], "2026-03-22");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,6 @@
mod settings;
pub use settings::{ConfigError, DeepSeekSettings};
pub use settings::{
BrowserBackend, ConfigError, DeepSeekSettings, OfficeBackend, PlannerMode, ProviderSettings,
SgClawSettings, SkillsPromptMode,
};

View File

@@ -1,46 +1,617 @@
use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
use crate::runtime::RuntimeProfile;
pub use zeroclaw::config::SkillsPromptInjectionMode as SkillsPromptMode;
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-chat";
const DEFAULT_PROVIDER_ID: &str = "deepseek";
const DIRECT_SUBMIT_PROVIDER_ID: &str = "direct-submit";
const DIRECT_SUBMIT_BASE_URL: &str = "http://127.0.0.1/direct-submit";
const DIRECT_SUBMIT_MODEL: &str = "direct-submit-placeholder-model";
const DIRECT_SUBMIT_API_KEY: &str = "direct-submit-placeholder-key";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlannerMode {
ZeroclawPlanFirst,
LegacyDeterministic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserBackend {
SuperRpa,
AgentBrowser,
RustNative,
ComputerUse,
Auto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OfficeBackend {
OpenXml,
Disabled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderSettings {
pub id: String,
pub provider: String,
pub api_key: String,
pub base_url: Option<String>,
pub model: String,
pub api_path: Option<String>,
pub wire_api: Option<String>,
pub requires_openai_auth: bool,
}
impl ProviderSettings {
fn from_legacy_deepseek(
api_key: String,
base_url: String,
model: String,
) -> Result<Self, ConfigError> {
let api_key = normalize_required_value("DEEPSEEK_API_KEY", api_key)?;
let base_url = normalize_base_url(base_url);
let model = normalize_model(model);
Ok(Self {
id: DEFAULT_PROVIDER_ID.to_string(),
provider: DEFAULT_PROVIDER_ID.to_string(),
api_key,
base_url: Some(base_url),
model,
api_path: None,
wire_api: None,
requires_openai_auth: false,
})
}
fn direct_submit_placeholder() -> Self {
Self {
id: DIRECT_SUBMIT_PROVIDER_ID.to_string(),
provider: DIRECT_SUBMIT_PROVIDER_ID.to_string(),
api_key: DIRECT_SUBMIT_API_KEY.to_string(),
base_url: Some(DIRECT_SUBMIT_BASE_URL.to_string()),
model: DIRECT_SUBMIT_MODEL.to_string(),
api_path: None,
wire_api: None,
requires_openai_auth: false,
}
}
fn from_raw(raw: RawProviderSettings) -> Result<Self, ConfigError> {
let id = raw.id.trim().to_string();
if id.is_empty() {
return Err(ConfigError::InvalidValue(
"providers[].id",
"must not be empty".to_string(),
));
}
let api_key = normalize_required_value("providers[].apiKey", raw.api_key)?;
let model = normalize_required_value("providers[].model", raw.model)?;
let base_url = normalize_optional_value(raw.base_url);
let provider = normalize_optional_value(raw.provider)
.or_else(|| base_url.as_ref().map(|url| format!("custom:{url}")))
.ok_or_else(|| {
ConfigError::InvalidValue(
"providers[].provider",
format!("provider {} must define provider or baseUrl", id),
)
})?;
Ok(Self {
id,
provider,
api_key,
base_url,
model,
api_path: normalize_optional_value(raw.api_path),
wire_api: normalize_optional_value(raw.wire_api),
requires_openai_auth: raw.requires_openai_auth,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeepSeekSettings {
pub api_key: String,
pub base_url: String,
pub model: String,
pub skills_dir: Option<PathBuf>,
}
impl DeepSeekSettings {
pub fn from_env() -> Result<Self, ConfigError> {
let api_key = std::env::var("DEEPSEEK_API_KEY")
.map_err(|_| ConfigError::MissingEnv("DEEPSEEK_API_KEY"))?;
Ok(Self::from(&SgClawSettings::from_env()?))
}
pub fn load(config_path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
SgClawSettings::load(config_path)
.map(|settings| settings.map(|settings| Self::from(&settings)))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SgClawSettings {
pub provider_api_key: String,
pub provider_base_url: String,
pub provider_model: String,
pub skills_dir: Option<PathBuf>,
pub direct_submit_skill: Option<String>,
pub skills_prompt_mode: SkillsPromptMode,
pub runtime_profile: RuntimeProfile,
pub planner_mode: PlannerMode,
pub providers: Vec<ProviderSettings>,
pub active_provider: String,
pub browser_backend: BrowserBackend,
pub office_backend: OfficeBackend,
}
impl SgClawSettings {
pub fn from_env() -> Result<Self, ConfigError> {
Self::maybe_from_env()?.ok_or(ConfigError::MissingEnv("DEEPSEEK_API_KEY"))
}
pub fn load(config_path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
if let Some(path) = config_path {
if path.exists() {
return Self::from_config_path(path).map(Some);
}
}
Self::maybe_from_env()
}
pub fn from_legacy_deepseek_fields(
api_key: String,
base_url: String,
model: String,
skills_dir: Option<PathBuf>,
) -> Result<Self, ConfigError> {
Self::new(
api_key,
base_url,
model,
skills_dir,
None,
None,
None,
None,
Vec::new(),
None,
None,
None,
)
}
pub fn active_provider_settings(&self) -> &ProviderSettings {
self.providers
.iter()
.find(|provider| provider.id == self.active_provider)
.expect("active_provider should always resolve to a configured provider")
}
fn maybe_from_env() -> Result<Option<Self>, ConfigError> {
let api_key = match std::env::var("DEEPSEEK_API_KEY") {
Ok(value) => value,
Err(std::env::VarError::NotPresent) => return Ok(None),
Err(std::env::VarError::NotUnicode(_)) => {
return Err(ConfigError::InvalidEnv("DEEPSEEK_API_KEY"))
}
};
let base_url = std::env::var("DEEPSEEK_BASE_URL")
.unwrap_or_else(|_| DEFAULT_DEEPSEEK_BASE_URL.to_string());
let model =
std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| DEFAULT_DEEPSEEK_MODEL.to_string());
if api_key.trim().is_empty() {
return Err(ConfigError::EmptyValue("DEEPSEEK_API_KEY"));
}
if base_url.trim().is_empty() {
return Err(ConfigError::EmptyValue("DEEPSEEK_BASE_URL"));
}
if model.trim().is_empty() {
return Err(ConfigError::EmptyValue("DEEPSEEK_MODEL"));
}
Ok(Self {
Ok(Some(Self::new(
api_key,
base_url,
model,
None,
None,
None,
None,
None,
Vec::new(),
None,
None,
None,
)?))
}
fn from_config_path(path: &Path) -> Result<Self, ConfigError> {
let raw = std::fs::read_to_string(path)
.map_err(|err| ConfigError::ConfigRead(path.to_path_buf(), err.to_string()))?;
let config: RawSgClawSettings = serde_json::from_str(&raw)
.map_err(|err| ConfigError::ConfigParse(path.to_path_buf(), err.to_string()))?;
let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
let runtime_profile = config
.runtime_profile
.as_deref()
.map(parse_runtime_profile)
.transpose()
.map_err(|value| {
ConfigError::ConfigParse(
path.to_path_buf(),
format!("invalid runtimeProfile: {value}"),
)
})?;
let skills_prompt_mode = config
.skills_prompt_mode
.as_deref()
.map(parse_skills_prompt_mode)
.transpose()
.map_err(|value| {
ConfigError::ConfigParse(
path.to_path_buf(),
format!("invalid skillsPromptMode: {value}"),
)
})?;
let planner_mode = config
.planner_mode
.as_deref()
.map(parse_planner_mode)
.transpose()
.map_err(|value| {
ConfigError::ConfigParse(
path.to_path_buf(),
format!("invalid plannerMode: {value}"),
)
})?;
let browser_backend = config
.browser_backend
.as_deref()
.map(parse_browser_backend)
.transpose()
.map_err(|value| {
ConfigError::ConfigParse(
path.to_path_buf(),
format!("invalid browserBackend: {value}"),
)
})?;
let office_backend = config
.office_backend
.as_deref()
.map(parse_office_backend)
.transpose()
.map_err(|value| {
ConfigError::ConfigParse(
path.to_path_buf(),
format!("invalid officeBackend: {value}"),
)
})?;
let providers = config
.providers
.into_iter()
.map(ProviderSettings::from_raw)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| err.with_path(path))?;
Self::new(
config.api_key,
config.base_url,
config.model,
resolve_configured_skills_dir(config.skills_dir, config_dir),
config.direct_submit_skill,
skills_prompt_mode,
runtime_profile,
planner_mode,
providers,
config.active_provider,
browser_backend,
office_backend,
)
.map_err(|err| err.with_path(path))
}
fn new(
api_key: String,
base_url: String,
model: String,
skills_dir: Option<PathBuf>,
direct_submit_skill: Option<String>,
skills_prompt_mode: Option<SkillsPromptMode>,
runtime_profile: Option<RuntimeProfile>,
planner_mode: Option<PlannerMode>,
providers: Vec<ProviderSettings>,
active_provider: Option<String>,
browser_backend: Option<BrowserBackend>,
office_backend: Option<OfficeBackend>,
) -> Result<Self, ConfigError> {
let direct_submit_skill = normalize_direct_submit_skill(direct_submit_skill)?;
let providers = if providers.is_empty() {
if direct_submit_skill.is_some() {
vec![ProviderSettings::direct_submit_placeholder()]
} else {
vec![ProviderSettings::from_legacy_deepseek(
api_key, base_url, model,
)?]
}
} else {
providers
};
let active_provider =
normalize_optional_value(active_provider).unwrap_or_else(|| providers[0].id.clone());
let active_provider_settings = providers
.iter()
.find(|provider| provider.id == active_provider)
.ok_or_else(|| {
ConfigError::InvalidValue(
"activeProvider",
format!("unknown provider id: {active_provider}"),
)
})?;
Ok(Self {
provider_api_key: active_provider_settings.api_key.clone(),
provider_base_url: active_provider_settings
.base_url
.clone()
.unwrap_or_default(),
provider_model: active_provider_settings.model.clone(),
skills_dir,
direct_submit_skill,
skills_prompt_mode: skills_prompt_mode.unwrap_or(SkillsPromptMode::Compact),
runtime_profile: runtime_profile.unwrap_or(RuntimeProfile::BrowserAttached),
planner_mode: planner_mode.unwrap_or(PlannerMode::ZeroclawPlanFirst),
providers,
active_provider,
browser_backend: browser_backend.unwrap_or(BrowserBackend::SuperRpa),
office_backend: office_backend.unwrap_or(OfficeBackend::OpenXml),
})
}
}
impl From<&SgClawSettings> for DeepSeekSettings {
fn from(value: &SgClawSettings) -> Self {
Self {
api_key: value.provider_api_key.clone(),
base_url: value.provider_base_url.clone(),
model: value.provider_model.clone(),
skills_dir: value.skills_dir.clone(),
}
}
}
impl From<&DeepSeekSettings> for SgClawSettings {
fn from(value: &DeepSeekSettings) -> Self {
Self::from_legacy_deepseek_fields(
value.api_key.clone(),
value.base_url.clone(),
value.model.clone(),
value.skills_dir.clone(),
)
.expect("DeepSeekSettings should already be validated")
}
}
fn parse_runtime_profile(raw: &str) -> Result<RuntimeProfile, String> {
let normalized = raw
.trim()
.chars()
.filter(|ch| *ch != '_' && *ch != '-')
.collect::<String>()
.to_ascii_lowercase();
match normalized.as_str() {
"browserattached" => Ok(RuntimeProfile::BrowserAttached),
"browserheavy" => Ok(RuntimeProfile::BrowserHeavy),
"generalassistant" => Ok(RuntimeProfile::GeneralAssistant),
_ => Err(raw.to_string()),
}
}
fn parse_skills_prompt_mode(raw: &str) -> Result<SkillsPromptMode, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"compact" => Ok(SkillsPromptMode::Compact),
"full" => Ok(SkillsPromptMode::Full),
_ => Err(raw.to_string()),
}
}
fn parse_planner_mode(raw: &str) -> Result<PlannerMode, String> {
let normalized = normalize_enum_token(raw);
match normalized.as_str() {
"zeroclawplanfirst" | "planfirst" | "plannerfirst" => Ok(PlannerMode::ZeroclawPlanFirst),
"legacy" | "legacydeterministic" | "deterministic" => Ok(PlannerMode::LegacyDeterministic),
_ => Err(raw.to_string()),
}
}
fn parse_browser_backend(raw: &str) -> Result<BrowserBackend, String> {
let normalized = normalize_enum_token(raw);
match normalized.as_str() {
"superrpa" | "superrpapipe" | "host" | "hostpipe" => Ok(BrowserBackend::SuperRpa),
"agentbrowser" => Ok(BrowserBackend::AgentBrowser),
"rustnative" => Ok(BrowserBackend::RustNative),
"computeruse" => Ok(BrowserBackend::ComputerUse),
"auto" => Ok(BrowserBackend::Auto),
_ => Err(raw.to_string()),
}
}
fn parse_office_backend(raw: &str) -> Result<OfficeBackend, String> {
let normalized = normalize_enum_token(raw);
match normalized.as_str() {
"openxml" => Ok(OfficeBackend::OpenXml),
"disabled" | "none" => Ok(OfficeBackend::Disabled),
_ => Err(raw.to_string()),
}
}
fn resolve_configured_skills_dir(raw: Option<String>, config_dir: &Path) -> Option<PathBuf> {
let trimmed = raw
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?;
let path = PathBuf::from(trimmed);
if path.is_absolute() {
Some(path)
} else {
Some(config_dir.join(path))
}
}
fn normalize_required_value(field: &'static str, raw: String) -> Result<String, ConfigError> {
let trimmed = raw.trim().to_string();
if trimmed.is_empty() {
return Err(ConfigError::EmptyValue(field));
}
Ok(trimmed)
}
fn normalize_optional_value(raw: Option<String>) -> Option<String> {
raw.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn normalize_direct_submit_skill(raw: Option<String>) -> Result<Option<String>, ConfigError> {
let value = normalize_optional_value(raw);
let Some(value) = value.as_deref() else {
return Ok(None);
};
let Some((skill_name, tool_name)) = value.split_once('.') else {
return Err(ConfigError::InvalidValue(
"directSubmitSkill",
format!("must use skill.tool format, got {value}"),
));
};
if skill_name.trim().is_empty() || tool_name.trim().is_empty() {
return Err(ConfigError::InvalidValue(
"directSubmitSkill",
format!("must use skill.tool format, got {value}"),
));
}
Ok(Some(value.to_string()))
}
fn normalize_base_url(raw: String) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
DEFAULT_DEEPSEEK_BASE_URL.to_string()
} else {
trimmed.to_string()
}
}
fn normalize_model(raw: String) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
DEFAULT_DEEPSEEK_MODEL.to_string()
} else {
trimmed.to_string()
}
}
fn normalize_enum_token(raw: &str) -> String {
raw.trim()
.chars()
.filter(|ch| *ch != '_' && *ch != '-')
.collect::<String>()
.to_ascii_lowercase()
}
#[derive(Debug, Deserialize)]
struct RawSgClawSettings {
#[serde(rename = "apiKey", default)]
api_key: String,
#[serde(rename = "baseUrl", default)]
base_url: String,
#[serde(default)]
model: String,
#[serde(rename = "skillsDir", alias = "skills_dir", default)]
skills_dir: Option<String>,
#[serde(rename = "directSubmitSkill", alias = "direct_submit_skill", default)]
direct_submit_skill: Option<String>,
#[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)]
skills_prompt_mode: Option<String>,
#[serde(rename = "runtimeProfile", alias = "runtime_profile", default)]
runtime_profile: Option<String>,
#[serde(rename = "plannerMode", alias = "planner_mode", default)]
planner_mode: Option<String>,
#[serde(rename = "activeProvider", alias = "active_provider", default)]
active_provider: Option<String>,
#[serde(rename = "browserBackend", alias = "browser_backend", default)]
browser_backend: Option<String>,
#[serde(rename = "officeBackend", alias = "office_backend", default)]
office_backend: Option<String>,
#[serde(default)]
providers: Vec<RawProviderSettings>,
}
#[derive(Debug, Deserialize)]
struct RawProviderSettings {
#[serde(default)]
id: String,
#[serde(default)]
provider: Option<String>,
#[serde(rename = "apiKey", default)]
api_key: String,
#[serde(rename = "baseUrl", default)]
base_url: Option<String>,
#[serde(default)]
model: String,
#[serde(rename = "apiPath", alias = "api_path", default)]
api_path: Option<String>,
#[serde(rename = "wireApi", alias = "wire_api", default)]
wire_api: Option<String>,
#[serde(rename = "requiresOpenaiAuth", alias = "requires_openai_auth", default)]
requires_openai_auth: bool,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ConfigError {
#[error("missing environment variable: {0}")]
MissingEnv(&'static str),
#[error("environment variable must not be empty: {0}")]
EmptyValue(&'static str),
#[error("invalid non-utf8 environment variable: {0}")]
InvalidEnv(&'static str),
#[error("failed to read DeepSeek config file {0}: {1}")]
ConfigRead(PathBuf, String),
#[error("invalid DeepSeek config JSON in {0}: {1}")]
ConfigParse(PathBuf, String),
#[error("DeepSeek config value must not be empty: {0} ({1})")]
ConfigValueEmpty(&'static str, PathBuf),
#[error("invalid config value for {0}: {1}")]
InvalidValue(&'static str, String),
#[error("invalid DeepSeek config value for {0}: {2} ({1})")]
ConfigInvalidValue(&'static str, PathBuf, String),
}
impl ConfigError {
fn with_path(self, path: &Path) -> Self {
match self {
Self::EmptyValue(field) => Self::ConfigValueEmpty(field, path.to_path_buf()),
Self::InvalidValue(field, detail) => {
Self::ConfigInvalidValue(field, path.to_path_buf(), detail)
}
other => other,
}
}
}
impl BrowserBackend {
pub fn zeroclaw_backend(self) -> Option<&'static str> {
match self {
Self::SuperRpa => None,
Self::AgentBrowser => Some("agent_browser"),
Self::RustNative => Some("rust_native"),
Self::ComputerUse => Some("computer_use"),
Self::Auto => Some("auto"),
}
}
}

View File

@@ -1,25 +1,34 @@
pub mod agent;
pub mod compat;
pub mod config;
pub mod llm;
pub mod pipe;
pub mod runtime;
pub mod security;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use agent::handle_browser_message;
use agent::{handle_browser_message_with_context, AgentRuntimeContext};
use pipe::{perform_handshake, BrowserPipeTool, PipeError, StdioTransport, Transport};
use security::MacPolicy;
fn default_rules_path_from_executable(executable_path: PathBuf) -> PathBuf {
executable_path
.parent()
.map(|dir| dir.join("resources").join("rules.json"))
.unwrap_or_else(|| PathBuf::from("resources").join("rules.json"))
}
fn default_rules_path() -> PathBuf {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("resources")
.join("rules.json")
std::env::current_exe()
.map(default_rules_path_from_executable)
.unwrap_or_else(|_| PathBuf::from("resources").join("rules.json"))
}
pub fn run() -> Result<(), PipeError> {
let runtime_context = AgentRuntimeContext::from_process_args(std::env::args_os())?;
let transport = Arc::new(StdioTransport::new(std::io::stdin(), std::io::stdout()));
let handshake = perform_handshake(transport.as_ref(), Duration::from_secs(5))?;
let mac_policy = MacPolicy::load_from_path(default_rules_path())?;
@@ -31,7 +40,12 @@ pub fn run() -> Result<(), PipeError> {
loop {
match transport.recv_timeout(Duration::from_secs(3600)) {
Ok(message) => {
handle_browser_message(transport.as_ref(), &browser_tool, message)?;
handle_browser_message_with_context(
transport.as_ref(),
&browser_tool,
&runtime_context,
message,
)?;
}
Err(PipeError::Timeout) => continue,
Err(PipeError::PipeClosed) => return Ok(()),
@@ -39,3 +53,21 @@ pub fn run() -> Result<(), PipeError> {
}
}
}
#[cfg(test)]
mod tests {
use super::default_rules_path_from_executable;
use std::path::PathBuf;
#[test]
fn default_rules_path_uses_executable_directory_instead_of_cwd() {
let executable_path = PathBuf::from("/tmp/out/KylinRelease/sgclaw");
let resolved = default_rules_path_from_executable(executable_path);
assert_eq!(
resolved,
PathBuf::from("/tmp/out/KylinRelease/resources/rules.json")
);
}
}

View File

@@ -4,7 +4,9 @@ use std::time::{Duration, Instant};
use serde_json::Value;
use crate::pipe::protocol::{Action, AgentMessage, BrowserMessage, SecurityFields, Timing};
use crate::pipe::protocol::{
Action, AgentMessage, BrowserMessage, ExecutionSurfaceMetadata, SecurityFields, Timing,
};
use crate::pipe::{PipeError, Transport};
use crate::security::{sign_command, MacPolicy};
@@ -21,17 +23,29 @@ pub struct BrowserPipeTool<T: Transport> {
transport: Arc<T>,
mac_policy: MacPolicy,
session_key: Vec<u8>,
next_seq: AtomicU64,
next_seq: Arc<AtomicU64>,
response_timeout: Duration,
}
impl<T: Transport> Clone for BrowserPipeTool<T> {
fn clone(&self) -> Self {
Self {
transport: self.transport.clone(),
mac_policy: self.mac_policy.clone(),
session_key: self.session_key.clone(),
next_seq: self.next_seq.clone(),
response_timeout: self.response_timeout,
}
}
}
impl<T: Transport> BrowserPipeTool<T> {
pub fn new(transport: Arc<T>, mac_policy: MacPolicy, session_key: Vec<u8>) -> Self {
Self {
transport,
mac_policy,
session_key,
next_seq: AtomicU64::new(1),
next_seq: Arc::new(AtomicU64::new(1)),
response_timeout: Duration::from_secs(30),
}
}
@@ -41,6 +55,10 @@ impl<T: Transport> BrowserPipeTool<T> {
self
}
pub fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
self.mac_policy.privileged_surface_metadata()
}
pub fn invoke(
&self,
action: Action,

View File

@@ -2,7 +2,9 @@ use std::time::Duration;
use uuid::Uuid;
use crate::pipe::protocol::{supported_actions, AgentMessage, BrowserMessage, PROTOCOL_VERSION};
use crate::pipe::protocol::{
supported_actions, AgentMessage, BrowserMessage, ExecutionSurfaceMetadata, PROTOCOL_VERSION,
};
use crate::pipe::{PipeError, Transport};
use crate::security::derive_session_key;
@@ -13,6 +15,17 @@ pub struct HandshakeResult {
pub capabilities: Vec<String>,
}
impl HandshakeResult {
pub fn browser_surface_metadata(&self) -> Option<ExecutionSurfaceMetadata> {
self.capabilities
.iter()
.any(|capability| capability == "browser_action")
.then(|| {
ExecutionSurfaceMetadata::privileged_browser_pipe("browser_host_and_mac_policy")
})
}
}
pub fn perform_handshake<T: Transport>(
transport: &T,
timeout: Duration,

View File

@@ -5,7 +5,8 @@ pub mod protocol;
pub use browser_tool::{BrowserPipeTool, CommandOutput};
pub use handshake::{perform_handshake, HandshakeResult};
pub use protocol::{
supported_actions, Action, AgentMessage, BrowserMessage, SecurityFields, Timing,
supported_actions, Action, AgentMessage, BrowserContext, BrowserMessage, ConversationMessage,
ExecutionSurfaceKind, ExecutionSurfaceMetadata, SecurityFields, Timing,
};
use std::io::{BufRead, BufReader, Read, Write};

View File

@@ -3,6 +3,49 @@ use serde_json::{json, Value};
pub const PROTOCOL_VERSION: &str = "1.0";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionSurfaceKind {
PrivilegedBrowserPipe,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct BrowserContext {
pub page_url: String,
pub page_title: String,
}
impl BrowserContext {
pub fn is_empty(&self) -> bool {
self.page_url.trim().is_empty() && self.page_title.trim().is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionSurfaceMetadata {
pub kind: ExecutionSurfaceKind,
pub privileged: bool,
pub defines_runtime_identity: bool,
pub guard: String,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default)]
pub allowed_actions: Vec<String>,
}
impl ExecutionSurfaceMetadata {
pub fn privileged_browser_pipe(guard: impl Into<String>) -> Self {
Self {
kind: ExecutionSurfaceKind::PrivilegedBrowserPipe,
privileged: true,
defines_runtime_identity: false,
guard: guard.into(),
allowed_domains: Vec::new(),
allowed_actions: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BrowserMessage {
@@ -14,6 +57,14 @@ pub enum BrowserMessage {
},
SubmitTask {
instruction: String,
#[serde(default)]
conversation_id: String,
#[serde(default)]
messages: Vec<ConversationMessage>,
#[serde(default)]
page_url: String,
#[serde(default)]
page_title: String,
},
Response {
seq: u64,
@@ -26,6 +77,40 @@ pub enum BrowserMessage {
},
}
impl BrowserMessage {
pub fn browser_context(&self) -> Option<BrowserContext> {
match self {
Self::SubmitTask {
page_url,
page_title,
..
} => {
let context = BrowserContext {
page_url: page_url.clone(),
page_title: page_title.clone(),
};
(!context.is_empty()).then_some(context)
}
_ => None,
}
}
pub fn requested_surface_metadata(&self) -> Option<ExecutionSurfaceMetadata> {
match self {
Self::SubmitTask { .. } => Some(ExecutionSurfaceMetadata::privileged_browser_pipe(
"browser_host_and_mac_policy",
)),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConversationMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentMessage {
@@ -57,6 +142,7 @@ pub enum Action {
Type,
Navigate,
GetText,
Eval,
GetHtml,
WaitForSelector,
PageScreenshot,
@@ -76,6 +162,7 @@ impl Action {
Action::Type => "type",
Action::Navigate => "navigate",
Action::GetText => "getText",
Action::Eval => "eval",
Action::GetHtml => "getHtml",
Action::WaitForSelector => "waitForSelector",
Action::PageScreenshot => "pageScreenshot",
@@ -108,6 +195,7 @@ pub fn supported_actions() -> Vec<Action> {
Action::Type,
Action::Navigate,
Action::GetText,
Action::Eval,
Action::GetHtml,
Action::WaitForSelector,
Action::PageScreenshot,

388
src/runtime/engine.rs Normal file
View File

@@ -0,0 +1,388 @@
use std::path::Path;
use std::sync::Arc;
use zeroclaw::agent::dispatcher::NativeToolDispatcher;
use zeroclaw::agent::Agent;
use zeroclaw::config::{Config as ZeroClawConfig, SkillsPromptInjectionMode};
use zeroclaw::memory::Memory;
use zeroclaw::observability::{NoopObserver, Observer};
use zeroclaw::providers::Provider;
use zeroclaw::runtime::NativeRuntime;
use zeroclaw::tools::{self, ReadSkillTool};
use zeroclaw::SecurityPolicy;
use crate::compat::memory_adapter::build_memory;
use crate::pipe::PipeError;
use crate::runtime::{RuntimeProfile, ToolPolicy};
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
const READ_SKILL_TOOL_NAME: &str = "read_skill";
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
const SCREEN_HTML_EXPORT_TOOL_NAME: &str = "screen_html_export";
const BROWSER_TOOL_CONTRACT_PROMPT: &str = "SuperRPA browser interface contract:\n- Use superrpa_browser as the preferred dedicated SuperRPA interface inside this browser host.\n- browser_action is a legacy alias with the same contract; prefer superrpa_browser when choosing between them.\n- Browser actions allowed by policy are already approved by the user inside this BrowserAttached host.\n- Do not claim a browser action was denied, blocked, or rejected unless an actual tool call returns an error.\n- expected_domain must be the bare hostname only, for example www.zhihu.com.\n- Never include scheme, path, query, fragment, or port in expected_domain.\n- selector values are executed with document.querySelector(...), so they must be valid CSS selectors only.\n- Never use XPath selectors or jQuery-style :contains().\n- Prefer direct navigation to canonical URLs when they are known, instead of clicking text links to reach common pages.\n- If you need broad page content, use getText with a valid CSS selector such as body or a stable container.\n- If a task matches an installed skill, load that skill first and then execute it through the SuperRPA interface.";
const ZHIHU_HOTLIST_EXECUTION_PROMPT: &str = "Zhihu hotlist execution contract:\n- Treat Zhihu hotlist export/presentation requests as a real browser workflow, not as a text-only summarization task.\n- You must attempt the browser workflow before concluding failure; a prose-only answer is invalid for this workflow.\n- If the current page is not already `https://www.zhihu.com/hot`, navigate there first.\n- If the `zhihu-hotlist.extract_hotlist` skill tool is available, call it before any generic browser probing.\n- Use generic `getText` only as a last-resort fallback when the packaged extractor fails.\n- Extract ordered rows containing `rank`, `title`, and `heat` as structured data.\n- Do not use shell, web_fetch, web_search_tool, or fabricated sample data for this workflow.\n- Do not repeat the same sentence or section in your final answer.";
const OFFICE_EXPORT_COMPLETION_PROMPT: &str = "Export completion contract:\n- This task requires a real Excel export.\n- After the Zhihu rows are available, you must call openxml_office before finishing.\n- Never fabricate, simulate, or invent substitute hotlist data when a live collection/export task fails.\n- If live collection fails, report the failure concisely instead of producing fake rows.\n- Do not stop after describing how you will parse or export the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the generated local .xlsx path.";
const SCREEN_EXPORT_COMPLETION_PROMPT: &str = "Presentation completion contract:\n- This task requires a real dashboard artifact.\n- After the Zhihu rows are available, you must call screen_html_export before finishing.\n- Do not stop after describing how you will render or present the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the local .html path and the presentation object.";
const ZHIHU_WRITE_PUBLISH_PROMPT: &str = "Zhihu article publish contract:\n- This task may publish a Zhihu article.\n- You must not click publish without explicit human confirmation in the current session.\n- If the user asked to publish but no explicit confirmation phrase is present yet, ask for confirmation concisely and stop after the confirmation request.\n- Do not keep exploring tools after you have determined that publish confirmation is missing.\n- If the user only asked to write or draft, stay in draft mode and do not treat it as publish mode.\n- Do not repeat the same sentence or section in your final answer.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeEngine {
profile: RuntimeProfile,
tool_policy: ToolPolicy,
}
impl RuntimeEngine {
pub fn new(profile: RuntimeProfile) -> Self {
Self {
profile,
tool_policy: ToolPolicy::for_profile(profile),
}
}
pub fn profile(&self) -> RuntimeProfile {
self.profile
}
pub fn tool_policy(&self) -> &ToolPolicy {
&self.tool_policy
}
pub fn browser_surface_enabled(&self) -> bool {
self.tool_policy
.allowed_tools
.iter()
.any(|tool| tool == BROWSER_ACTION_TOOL_NAME || tool == SUPERRPA_BROWSER_TOOL_NAME)
}
pub fn build_agent(
&self,
provider: Box<dyn Provider>,
config: &ZeroClawConfig,
skills_dir: &Path,
mut tools: Vec<Box<dyn zeroclaw::tools::Tool>>,
browser_surface_present: bool,
instruction: &str,
) -> Result<Agent, PipeError> {
let memory: Arc<dyn Memory> =
Arc::from(build_memory(config).map_err(map_anyhow_to_pipe_error)?);
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
));
let observer: Arc<dyn Observer> = Arc::new(NoopObserver);
let skills = load_runtime_skills(config, skills_dir);
let (mut runtime_tools, _, _, _, _, _) = tools::all_tools_with_runtime(
Arc::new(config.clone()),
&security,
Arc::new(NativeRuntime::new()),
memory.clone(),
None,
None,
&config.browser,
&config.http_request,
&config.web_fetch,
&config.workspace_dir,
&config.agents,
config.api_key.as_deref(),
config,
None,
);
runtime_tools.append(&mut tools);
if matches!(
config.skills.prompt_injection_mode,
SkillsPromptInjectionMode::Compact
) && skills_dir != config.workspace_dir.join("skills")
{
runtime_tools.retain(|tool| tool.name() != READ_SKILL_TOOL_NAME);
runtime_tools.push(Box::new(ReadSkillTool::with_runtime_skills_dir(
config.workspace_dir.clone(),
Some(skills_dir.to_path_buf()),
config.skills.allow_scripts,
config.skills.open_skills_enabled,
config.skills.open_skills_dir.clone(),
)));
}
Agent::builder()
.provider(provider)
.tools(runtime_tools)
.memory(memory)
.observer(observer)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.config(config.agent.clone())
.model_name(
config
.default_model
.clone()
.unwrap_or_else(|| "deepseek-chat".to_string()),
)
.temperature(config.default_temperature)
.workspace_dir(config.workspace_dir.clone())
.skills(skills)
.skills_prompt_mode(config.skills.prompt_injection_mode)
.allowed_tools(self.allowed_tools_for_config(
config,
skills_dir,
browser_surface_present,
instruction,
))
.build()
.map_err(map_anyhow_to_pipe_error)
}
pub fn build_instruction(
&self,
instruction: &str,
page_url: Option<&str>,
page_title: Option<&str>,
browser_surface_present: bool,
) -> String {
let trimmed_instruction = instruction.trim();
if !browser_surface_present || !self.browser_surface_enabled() {
return trimmed_instruction.to_string();
}
let mut sections = vec![BROWSER_TOOL_CONTRACT_PROMPT.to_string()];
if is_zhihu_hotlist_task(trimmed_instruction, page_url, page_title) {
sections.push(ZHIHU_HOTLIST_EXECUTION_PROMPT.to_string());
}
if task_needs_office_export(trimmed_instruction) {
sections.push(OFFICE_EXPORT_COMPLETION_PROMPT.to_string());
}
if task_needs_screen_export(trimmed_instruction) {
sections.push(SCREEN_EXPORT_COMPLETION_PROMPT.to_string());
}
if task_requests_zhihu_article_publish(trimmed_instruction, page_url, page_title) {
sections.push(ZHIHU_WRITE_PUBLISH_PROMPT.to_string());
}
if let Some(page_context) = build_page_context_message(page_url, page_title) {
sections.push(page_context);
}
sections.push(format!("User task: {trimmed_instruction}"));
sections.join("\n\n")
}
pub fn loaded_skills(
&self,
config: &ZeroClawConfig,
skills_dir: &Path,
) -> Vec<zeroclaw::skills::Skill> {
let mut skills = load_runtime_skills(config, skills_dir);
skills.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then(left.version.cmp(&right.version))
});
skills.dedup_by(|left, right| left.name == right.name && left.version == right.version);
skills
}
pub fn loaded_skill_names(&self, config: &ZeroClawConfig, skills_dir: &Path) -> Vec<String> {
let mut names = self
.loaded_skills(config, skills_dir)
.into_iter()
.map(|skill| skill.name)
.collect::<Vec<_>>();
names.sort();
names.dedup();
names
}
pub fn should_attach_openxml_office_tool(&self, instruction: &str) -> bool {
task_needs_office_export(instruction)
}
pub fn should_attach_screen_html_export_tool(&self, instruction: &str) -> bool {
task_needs_screen_export(instruction)
}
fn allowed_tools_for_config(
&self,
config: &ZeroClawConfig,
skills_dir: &Path,
browser_surface_present: bool,
instruction: &str,
) -> Option<Vec<String>> {
let mut allowed_tools = self.tool_policy.allowed_tools.clone();
if !browser_surface_present {
allowed_tools.retain(|tool| {
tool != BROWSER_ACTION_TOOL_NAME && tool != SUPERRPA_BROWSER_TOOL_NAME
});
}
if matches!(
config.skills.prompt_injection_mode,
SkillsPromptInjectionMode::Compact
) {
allowed_tools.push(READ_SKILL_TOOL_NAME.to_string());
}
if task_needs_office_export(instruction) {
allowed_tools.push(OPENXML_OFFICE_TOOL_NAME.to_string());
}
if task_needs_screen_export(instruction) {
allowed_tools.push(SCREEN_HTML_EXPORT_TOOL_NAME.to_string());
}
if task_needs_local_file_read(instruction) {
allowed_tools.push("file_read".to_string());
}
if browser_surface_present {
allowed_tools.extend(browser_script_tool_names(&load_runtime_skills(
config, skills_dir,
)));
}
allowed_tools.dedup();
if matches!(self.profile, RuntimeProfile::GeneralAssistant)
&& self.tool_policy.may_use_non_browser_tools
{
None
} else {
Some(allowed_tools)
}
}
}
fn browser_script_tool_names(skills: &[zeroclaw::skills::Skill]) -> Vec<String> {
skills
.iter()
.flat_map(|skill| {
skill
.tools
.iter()
.filter(|tool| tool.kind == "browser_script")
.map(|tool| format!("{}.{}", skill.name, tool.name))
.collect::<Vec<_>>()
})
.collect()
}
fn task_needs_local_file_read(instruction: &str) -> bool {
let normalized = instruction.trim();
normalized.contains("/home/") || normalized.contains("./") || normalized.contains("../")
}
pub fn is_zhihu_hotlist_task(
instruction: &str,
page_url: Option<&str>,
page_title: Option<&str>,
) -> bool {
let normalized_instruction = instruction.to_ascii_lowercase();
let normalized_url = page_url.unwrap_or_default().to_ascii_lowercase();
let normalized_title = page_title.unwrap_or_default().to_ascii_lowercase();
let is_zhihu = normalized_instruction.contains("zhihu")
|| instruction.contains("知乎")
|| normalized_url.contains("zhihu.com")
|| normalized_title.contains("zhihu")
|| page_title.unwrap_or_default().contains("知乎");
let is_hotlist = normalized_instruction.contains("hotlist")
|| instruction.contains("热榜")
|| normalized_url.contains("/hot")
|| normalized_title.contains("hotlist")
|| page_title.unwrap_or_default().contains("热榜");
is_zhihu && is_hotlist
}
fn task_needs_office_export(instruction: &str) -> bool {
let normalized = instruction.to_ascii_lowercase();
normalized.contains("excel")
|| normalized.contains(".xlsx")
|| normalized.contains("导出")
|| normalized.contains("xlsx")
}
fn task_needs_screen_export(instruction: &str) -> bool {
let normalized = instruction.to_ascii_lowercase();
normalized.contains("大屏")
|| normalized.contains("看板")
|| normalized.contains("dashboard")
|| normalized.contains("screen")
|| normalized.contains("echarts")
|| normalized.contains("演示")
|| normalized.contains("汇报")
}
pub fn task_requests_zhihu_article_publish(
instruction: &str,
page_url: Option<&str>,
page_title: Option<&str>,
) -> bool {
if !is_zhihu_write_task(instruction, page_url, page_title) {
return false;
}
let normalized = instruction.to_ascii_lowercase();
normalized.contains("publish") || instruction.contains("发布") || instruction.contains("发表")
}
pub fn is_zhihu_write_task(
instruction: &str,
page_url: Option<&str>,
page_title: Option<&str>,
) -> bool {
let normalized_instruction = instruction.to_ascii_lowercase();
let normalized_url = page_url.unwrap_or_default().to_ascii_lowercase();
let normalized_title = page_title.unwrap_or_default().to_ascii_lowercase();
let is_zhihu = normalized_instruction.contains("zhihu")
|| instruction.contains("知乎")
|| normalized_url.contains("zhihu.com")
|| normalized_title.contains("zhihu")
|| page_title.unwrap_or_default().contains("知乎");
let is_write = normalized_instruction.contains("article")
|| normalized_instruction.contains("write")
|| normalized_instruction.contains("publish")
|| instruction.contains("文章")
|| instruction.contains("")
|| instruction.contains("发布")
|| instruction.contains("发表")
|| normalized_url.contains("creator")
|| normalized_url.contains("write")
|| page_title.unwrap_or_default().contains("创作")
|| page_title.unwrap_or_default().contains("写文章");
is_zhihu && is_write
}
fn load_runtime_skills(config: &ZeroClawConfig, skills_dir: &Path) -> Vec<zeroclaw::skills::Skill> {
let default_skills_dir = config.workspace_dir.join("skills");
if skills_dir == default_skills_dir {
return zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
}
let mut skills = zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
skills.retain(|skill| {
skill
.location
.as_ref()
.map(|location| !location.starts_with(&default_skills_dir))
.unwrap_or(true)
});
skills.extend(zeroclaw::skills::load_skills_from_directory(
skills_dir,
config.skills.allow_scripts,
));
skills
}
fn build_page_context_message(page_url: Option<&str>, page_title: Option<&str>) -> Option<String> {
let mut parts = Vec::new();
if let Some(page_url) = page_url.map(str::trim).filter(|value| !value.is_empty()) {
parts.push(format!("Current page URL: {page_url}"));
}
if let Some(page_title) = page_title.map(str::trim).filter(|value| !value.is_empty()) {
parts.push(format!("Current page title: {page_title}"));
}
if parts.is_empty() {
return None;
}
Some(format!("Current browser context:\n{}", parts.join("\n")))
}
fn map_anyhow_to_pipe_error(err: anyhow::Error) -> PipeError {
PipeError::Protocol(err.to_string())
}

9
src/runtime/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
mod engine;
mod profile;
mod tool_policy;
pub use engine::{
is_zhihu_hotlist_task, is_zhihu_write_task, task_requests_zhihu_article_publish, RuntimeEngine,
};
pub use profile::RuntimeProfile;
pub use tool_policy::ToolPolicy;

Some files were not shown because too many files have changed in this diff Show More