# run_shell 隐式 Diff 钩子 —— Rust 异步实现方案
> 参照 Python 版 `aacode_local/design_diff_hook.md` 和 `aacode/tools/diff_hook.py`,结合 aacode-rs 项目特点、现有代码结构和 Rust 语言特性设计。
---
## 0. 探索历程
### 0.1 起源:Python 版的迭代
在 Python aacode 中,diff 钩子经历了三轮设计迭代:
| v1(初版) | 命令白名单分类,只解析已知命令参数 | `SequenceMatcher` → 数 `\n` | 三档(指纹/diff/preview) | 重定向/heredoc 遗漏;行内修改报 0;指纹冗余 |
| v2(宽松解析) | 不限命令类型 + 重定向 + -c + heredoc + 任意 token | 同上 | 同上 | 覆盖 95%;但行内修改仍为 0;指纹+diff 双重输出冗余 |
| v3(混合 + 准确计数) | 策略A(宽松) + 策略B(文件系统扫描兜底) | `difflib.unified_diff` 数 `-`/`+`/`@@` 行 | 同上 | 行内修改准确;但三档中 stat 和 diff 信息冗余 |
| v4(最终版) | 同 v3 | 同 v3 | **两档**:≤10行→diff(字符串),>10行→diff_preview(数组,每元素上下文+变更行,最多5条) | 去掉 stats/fingerprint、去掉 large_change_threshold、默认关闭 |
**经验教训对 Rust 版的启示:**
1. **命令分类是死胡同**——不需要区分读/写/搜索命令,所有非选项 token 都尝试解析路径
2. **行计数必须用 unified diff**——`SequenceMatcher` 的 `\n` 统计在行内修改时系统性归零,`unified_diff` 的 `-`/`+` 行计数 100% 准确
3. **文件系统扫描是兜底而非主路径**——仅在命令解析无结果且关注集合为空时触发,正常使用中几乎不跑
4. **post_exec 只 re-stat,不 re-scan**——快照中已有文件列表,逐个 metadata 即可
5. **宁滥勿缺**——过度匹配的成本极低(多 stat 一个不变的文件),漏掉的代价极高(hook 完全失效)
### 0.2 Rust 版的独特约束
| `Tokio` 异步运行时 | 所有文件 I/O 必须在 `spawn_blocking` 中执行,不能阻塞工作线程 |
| `Arc<RwLock<>>` 并发安全 | `DiffHook` 被多任务共享(主 Agent + SubAgent),需要线程安全 |
| `similar` 而非 `difflib` | `similar::TextDiff` 是 Myers 算法,比 Python `difflib` 的 LCS 快 2-10x |
| 零外部依赖原则 | shell 分词、fnmatch、LRU 均手写,仅新增 `similar` 一个 crate |
| fastshell 沙箱 | 移动端的 fastshell 沙箱环境,文件系统是 VFS,`std::fs` 调用需要兼容 |
---
## 1. 目标
在**不增加任何新工具**、**不修改工具 schema**、**模型完全无感知**的前提下,让 `run_shell` 的执行结果中自动附加文件变更反馈,使模型在下一次决策时能利用这些信息自我纠正。
核心理念:**不是新增能力,而是增强现有能力的反馈回路**。
---
## 2. Rust 特有技术栈
| 文件快照 | `std::fs::metadata` | 标准库,记录 mtime + size 作为变更检测依据 |
| diff 生成 | `similar = "2"` | Rust 生态最成熟的纯文本 diff 库,`TextDiff` + `ChangeTag`,Myers 算法,快 2-10x |
| 文件系统扫描 | `std::fs::read_dir` | 标准库,惰性遍历目录条目,`DirEntry::file_type()` 无需额外 syscall |
| .gitignore 匹配 | 手写 `fnmatch_simple`(约 25 行) | 仅需 `*` `?` 通配符,不引入 `glob` crate |
| LRU 关注集合 | 手写 `VecDeque<PathBuf>` + `HashSet<PathBuf>` | 零依赖,50 元素上 O(1) 操作 |
| Shell 分词 | 手写 `split_shell_tokens()`(约 25 行) | 处理单双引号和反斜杠转义,不引入 `shlex` |
| 符号提取 | `regex`(已存在于 Cargo.toml) | 零新增 |
| 异步文件 I/O | `tokio::task::spawn_blocking` | 同步 `std::fs` 桥接到异步世界,复用现有模式 |
| 共享可变状态 | `Arc<RwLock<DiffHookInner>>` | Rust 并发安全基础设施 |
| 超时保护 | `tokio::time::timeout(Duration::from_secs(3), ...)` | post_exec 独立 3s 超时,不影响命令返回值 |
| 配置管理 | 现有 `AgentConfig` 扩展 | 新增 `DiffHookConfig` 子 struct |
**新增外部依赖:仅 `similar = "2"`(约 30KB,纯 Rust,零系统依赖)。** `regex` 已存在于 Cargo.toml。
---
## 3. 整体架构
### 3.1 数据流
```
run_shell(command="sed -i '' 's/old/new/g' main.py")
│
└─ ShellTool.call()
│
├─ [1] tokio::task::spawn_blocking → DiffHook::pre_exec_sync(command)
│ ├─ 策略A: 宽松命令解析(为主,宁可多匹配)
│ │ · 重定向目标(> / >>)后的 token → 路径提取
│ │ · -c "..." 参数字符串内 → 正则扫描已知扩展名的路径
│ │ · heredoc 体(<< 到结束标记间)→ 正则扫描路径
│ │ · 所有非选项/非操作符 token → 解析为路径,is_file() 检查
│ │
│ ├─ 策略B: 文件系统扫描(仅当策略A无结果且关注集合为空时触发)
│ │ · read_dir DFS + 四层过滤(目录黑名单→条目数阈值→.gitignore→扩展名白名单)
│ │ · 文件数硬上限 5000
│ │
│ └─ 对关注文件调用 metadata() → 记录 FileSnapshot {mtime_us, size, content}
│
├─ [2] spawn_blocking → backend.run(command) ← 命令执行
│
└─ [3] tokio::time::timeout(3s, spawn_blocking → DiffHook::post_exec_sync())
├─ 遍历快照逐文件 re-metadata,对比 mtime+size 双因子
├─ 变更文件调用 _measure_diff() → 一次 unified_diff 得到 (total, added, deleted, hunks)
├─ 按规模分两档(无 stats):
│ · ≤10 行 → diff(字符串,多 hunk 以 "..." 分隔)
│ · >10 行 → diff_preview(数组,每元素"<上下文行>\n<变更首行>",最多5条)
│ · 多文件按变更行数升序排列
│ · 总行数 > max_total_changes_lines → 截断 + 聚合摘要
└─ 返回 JSON 附加到 result["_changes"]
```
### 3.2 关键设计点
- `_changes` 字段用下划线前缀,表明是系统级元数据而非命令输出
- `DiffHook` 以 `Option<DiffHook>` 形式注入 `ShellTool`,不在 schema 中、不影响模型
- **post_exec 只对快照中的文件做 re-stat,不重新扫描项目目录**
- **策略A 为主(命中率 >95%)、策略B 为兜底(仅在首次冷启动时触发)**
- **3s超时静默丢弃**:`tokio::time::timeout` 包裹 post_exec,超时或异常均不阻塞命令返回
---
## 4. 文件发现:混合双层策略
核心原则:**命令解析为主(免费、命中率高)、文件系统扫描为辅(兜底、有上限),宁滥勿缺。**
### 4.1 策略A:宽松命令解析(默认路径,<1ms)
**哲学:过度匹配的成本极低——多 snapshot 了一个文件但没有被修改,post_exec 读 mtime 不变就跳过了,几乎零开销。漏掉一个文件则整个 hook 失效。所以宁可匹配多了不能匹配少了。**
解析来源:
| **重定向目标** | `>` 或 `>>` 后的一个 token,解析为路径后 `is_file()` 检查 | `echo "x" > main.py` | main.py |
| **-c 字符串体** | 在 `-c "..."` 的引号内,正则提取所有可识别扩展名的路径 | `python3 -c "open('src/main.py','w')"` | src/main.py |
| **heredoc 体** | `<<` 到结束标记之间的文本,正则扫描文件路径 | `python3 - <<'EOF'\n with open("a.py")` | a.py |
| **任意 token** | 所有非选项(`-`开头)、非操作符 token,解析为路径后 `is_file()` 检查 | `cat main.py`、`node build.js`、`ls output.json` | main.py, build.js, output.json |
**特点:**
- **不限命令类型**:`python`、`node`、`ruby`、`bash` 等任何命令都能命中
- **不需要命令分类**:不区分读/写/搜索命令
- **过度匹配安全**:`echo hello` 的 `hello` 被尝试解析为路径 → `is_file()` 返回 false → 丢弃,成本为 0
**Rust 实现要点——Shell 分词器(最小实现,替代 `shlex` crate):**
- 空格分割,保留单引号 `'...'` 和双引号 `"..."` 包裹的整体
- 处理反斜杠转义 `\"` `\'`
- 失败时回退到 `split_whitespace()`
### 4.2 策略B:文件系统扫描(兜底路径,仅当策略A无结果时触发)
**触发条件(同时满足):**
1. 本轮策略A 返回空
2. 关注文件集合当前为空(前面轮次也没有留下任何关注文件)
**不触发的情况(绝大多数):**
- 模型 `cat main.py` 后再 `sed -i main.py` → 策略A 命中
- 模型已通过策略A 积累关注文件 → 不触发
**四层过滤:**
```
std::fs::read_dir(project_root) ← 惰性迭代器,DFS 栈式遍历
│
├─ 第1层:目录黑名单
│ .git .aacode node_modules __pycache__ .venv venv
│ .tox dist build .egg-info .mypy_cache .pytest_cache
│ .idea .vscode .ruff_cache .direnv
│ 所有以 "." 开头的隐藏目录也跳过
│
├─ 第2层:条目数阈值(自适应剪枝,不依赖目录名)
│ read_dir 条目数 > 1000 → 跳过整棵子树
│ 理由:人类源码目录不会单层 1000+ 文件,这是依赖/构建产物的特征
│
├─ 第3层:.gitignore 匹配(如果项目根有 .gitignore)
│ 被 .gitignore 明确忽略的路径 → 跳过
│
└─ 第4层:扩展名白名单(只收集匹配的文件路径)
.py .js .ts .jsx .tsx .go .rs .java .c .cpp
.h .hpp .yaml .yml .json .toml .md .txt .sh
.html .css .scss .less
```
**硬上限:扫描到第 5000 个文件时停止。**
**Rust 实现要点:**
- `std::fs::read_dir()` 返回的迭代器是惰性的,不需要额外优化
- 使用栈式 DFS 遍历(不用递归,避免栈溢出)
- 第2层调用 `_dir_entry_count()` 时用 `.take(1001)` 提前终止
- `.gitignore` 匹配:手写 `fnmatch_simple(pattern, name) -> bool`,仅支持 `*` `?` 通配符
---
## 5. 变更反馈:混合分级策略
### 5.1 分级逻辑
```
单文件变更规模 反馈策略 决策精度匹配
────────────── ────────── ──────────
≤ 10 行 思路A:内容 diff 手术级编辑,逐行校验
> 10 行 diff_preview 确认改的方向、在哪改的
多文件处理:
小文件优先排序(变更行数少的排在前面)
总输出行数 > max_total_changes_lines → 截断 + 聚合摘要
```
### 5.1.1 变更行数统计算法(unified diff 直接计数)
**核心原则:用 unified diff 的输出直接计数,不依赖 SequenceMatcher 的 `\n` 统计。**
**问题根因(Python 版 v1/v2 的实际发现):** SequenceMatcher 按字符匹配,行内修改不涉及 `\n`:
```
原文: \n## 目录\n
改后: \n## 📑 目录\n
SequenceMatcher:
equal: \n## ← 新行在这
replace: 目录 → 📑 目录 ← \n count == 0!计数器错报
equal: \n ← 新行也在这
```
**正确算法:生成 unified diff 后直接数 `-`/`+`/`@@` 前缀行。** 一次生成得到全部三个数字。
**Python 版 v3 验证对比:**
| 15 个标题加 emoji | 0 ❌ | 30 (15+15) ✓ | 30 |
| 新增 3 行函数 | 3 ✓ | 3 ✓ | 3 |
| 行内改词 `x=1`→`x=2` | 0 ❌ | 2 (1+1) ✓ | 2 |
| 大面积重构 +47 -10 | 57 ✓ | 57 ✓ | 57 |
**Rust 版实现(使用 `similar::TextDiff`):**
```rust
fn measure_diff(before: &str, after: &str) -> (usize, usize, usize, usize) {
let diff = TextDiff::from_lines(before, after);
let mut added = 0usize;
let mut deleted = 0usize;
let mut hunks = 0usize;
let mut prev_equal = true;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => { prev_equal = true; }
ChangeTag::Insert => {
if prev_equal { hunks += 1; }
added += change.value().lines().count();
prev_equal = false;
}
ChangeTag::Delete => {
if prev_equal { hunks += 1; }
deleted += change.value().lines().count();
prev_equal = false;
}
}
}
(added + deleted, added, deleted, hunks)
}
```
**可靠性:** `similar::TextDiff` 使用 Myers diff 算法(与 git diff 同源),比 Python `difflib` 的 LCS 快 2-10x。统一一个算法源,`count_diff_lines`/`compute_fingerprint`/`compute_content_diff` 都从同一个 diff 结果推导。
**对分级的影响(emoji 场景):**
```
旧: count_diff_lines → 0 → ≤10 → 全量 diff → 输出 101 行
新: count_diff_lines → 30 → >10 → diff_preview → 输出 ~5 行
Token 从 100+ 降到 5,分级判断也正确了。
```
### 5.2 思路A:内容 Diff(无行号)
不输出 `@@` 行号头,只输出纯内容的 before/after。行号对模型无意义——模型用内容锚点定位代码。
```
--- [main.py] 变更内容 ---
def calculate(x, y):
- result = old_formula(x, y)
+ result = new_formula(x, y, z)
return result
--- 共 +1 -1 行 ---
```
**Rust 实现要点:** `similar::TextDiff::iter_all_changes()` 零拷贝返回 `&str` 引用,遍历时记录 `ChangeTag::Equal`/`Insert`/`Delete`,拼接 `<prefix> <line>` 格式。
### 5.3 diff_preview (>10 行)
```
--- [main.py] +12 -8 行, 3 hunks ---
涉及: calculate(), VALID_MODES, ConfigParser.__init__
预览(hunk1):
+ def calculate(x, y, z):
+ """Compute with mode support."""
```
### 5.4 批量多文件输出 + 截断聚合
**排序规则:变更行数少的文件排在前面。** 先用 `measure_diff()` 建立分值,升序排列后依次生成反馈。
**截断规则:** 总输出行数超过 `max_total_changes_lines` 后,剩余文件只统计行数和符号,最后附加 `truncated` 聚合摘要:
```json
{
"total_files": 8,
"changes": [
{
"file": "src/utils.py",
"fingerprint": {"lines_added": 2, "lines_deleted": 1, "hunks": 1, "symbols": []},
"diff": " ...\n+ new line"
}
],
"truncated": {
"count": 2,
"files": ["handlers.py", "service.py"],
"lines_added": 320,
"lines_deleted": 230,
"symbols": ["RequestHandler", "DataService"]
}
}
```
---
## 6. 性能分析
### 6.1 典型场景耗时
| `cat main.py` 后 `sed -i main.py` | 策略A: <1ms | 1 文件: <1ms | 1 re-stat: <1ms | <3ms |
| 中型 Rust 项目(500 源码文件) | 策略A 命中: <1ms | 500 metadata: ~30ms | 500 stat(OS缓存): ~5ms | ~35ms |
| 大型 Java 项目(30000 .java,策略A 不命中) | 策略B: ~200ms | 5000 文件(上限): ~300ms | 5000 stat(OS缓存): ~50ms | ~550ms |
注:策略A 不命中才触发策略B,而实际使用中策略A 几乎总命中(模型会 `cat` 或 `grep`),所以常态 <35ms。
### 6.2 Rust 版额外优势
- `std::fs::read_dir` 在 macOS APFS/Linux ext4 上的 `DirEntry::file_type()` 无需额外 syscall
- `similar::TextDiff` 使用 Myers diff 算法,比 Python `difflib` 快 2-10x
- 所有 I/O 在 `spawn_blocking` 线程池中执行,不阻塞 tokio 工作线程
- Rust 零成本抽象——`FileSnapshot` 的 `mtime_us`(纳秒微秒精度)比 Python 的 `float` mtime 更精确
### 6.3 三层兜底
| 策略A 优先 | 命令解析免费且命中率 >95% | 正常情况下策略B 从不触发 |
| 策略B + 四层过滤 | 目录黑名单 + 条目数阈值 + .gitignore + 扩展名 | 99% 噪音被前三层挡掉 |
| 硬上限 5000 | `read_dir` 扫描到 5000 文件停 | 极端项目不会卡死 |
---
## 7. 关注文件集合
### 7.1 生命周期
- **来源1**:策略A 命令解析到的文件路径
- **来源2**:策略B 文件系统扫描到的文件路径
- **存储**:会话级 LRU 有界集合(`VecDeque<PathBuf>` + `HashSet<PathBuf>`,`max_tracked_files=50`)
- **淘汰**:LRU 策略,队首(最久未访问)被踢出
- **作用**:只对比关注集合中的文件变更
### 7.2 为什么需要关注集合
`pip install`/`npm install` 修改几千个文件。关注集合确保只反馈模型关心的文件,非关注文件的修改静默忽略。
---
## 8. 配置项
### 8.1 `DiffHookConfig` (在 `config.rs` 中新增)
```rust
/// Implicit diff hook configuration — auto-detects file changes
/// after run_shell and attaches structured feedback.
/// Disabled by default — enable explicitly in config.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffHookConfig {
/// Enable the diff hook. Default: false (opt-in).
#[serde(default)]
pub enabled: bool,
/// ≤ N changed lines → full content diff. > N → diff_preview array.
#[serde(default = "d_small_threshold")]
pub small_change_threshold: usize, // default: 10
#[serde(default = "d_max_tracked")]
pub max_tracked_files: usize, // default: 50
#[serde(default = "d_max_diff_lines")]
pub max_diff_lines: usize, // default: 60
#[serde(default = "d_max_total_lines")]
pub max_total_changes_lines: usize, // default: 200
#[serde(default = "d_fs_scan_max")]
pub fs_scan_max_files: usize, // default: 5000
#[serde(default = "d_dir_entry_threshold")]
pub fs_scan_dir_entry_threshold: usize, // default: 1000
#[serde(default = "default_fs_ignore_dirs")]
pub fs_ignore_dirs: Vec<String>,
#[serde(default = "default_watched_extensions")]
pub watched_extensions: Vec<String>,
}
// Removed: large_change_threshold, include_git_hint (stats/fingerprint removed in v4)
```
### 8.2 集成到 `AgentConfig`
```rust
pub struct AgentConfig {
// ... existing fields ...
#[serde(default)]
pub diff_hook: DiffHookConfig,
}
```
---
## 9. 模块结构与集成点
### 9.1 目录
```
aacode-rs/
├── Cargo.toml ← 新增 similar = "2" 依赖
├── src/
│ ├── config.rs ← 新增 DiffHookConfig struct(约 80 行)
│ ├── tools/
│ │ ├── mod.rs ← register_core_tools() 创建 DiffHook → ShellTool(约 +15 行)
│ │ ├── shell.rs ← ShellTool 新增 diff_hook: Option<DiffHook> 字段 + call() 注入点(约 +30 行)
│ │ ├── diff_hook.rs ← 【新增】DiffHook + DiffHookInner 核心实现(约 450 行)
```
### 9.2 `shell.rs` 注入点
在现有 `ShellTool` struct 中:
```rust
pub struct ShellTool {
backend: Arc<dyn ShellBackend>,
cwd: PathBuf,
max_output_chars: usize,
default_timeout_secs: u64,
default_idle_timeout_secs: u64,
safety: SafetyConfig,
diff_hook: Option<DiffHook>, // ← 新增
}
```
在 `ShellTool::call()` 中:
```rust
async fn call(&self, args: &Value, _cancel: &AtomicBool) -> Result<String> {
let command = ...;
// [注入点1] pre_exec
if let Some(ref hook) = self.diff_hook {
let h = hook.clone();
let cmd = command.clone();
let _ = tokio::task::spawn_blocking(move || h.pre_exec_sync(&cmd)).await;
}
// ... 原有逻辑:security check → spawn_blocking → backend.run() → truncate ...
let mut result = json!({ "success": true, ... });
// [注入点2] post_exec(独立 3s 超时)
if let Some(ref hook) = self.diff_hook {
let h = hook.clone();
if let Ok(Ok(Some(changes))) = tokio::time::timeout(
Duration::from_secs(3),
tokio::task::spawn_blocking(move || h.post_exec_sync()),
).await {
result["_changes"] = changes;
}
}
Ok(result.to_string())
}
```
### 9.3 `tools/mod.rs` 注册点
```rust
// 在 register_core_tools() 中:
let diff_hook = if config.diff_hook.enabled {
Some(DiffHook::new(config.diff_hook.clone(), project_path.clone()))
} else {
None
};
reg.register(Box::new(ShellTool::new(
backend, project_path.clone(), ..., diff_hook,
)));
```
---
## 10. 核心数据结构
### 10.1 `DiffHook` 与 `DiffHookInner`(`Arc<RwLock<>>` 模式)
```rust
/// Content + metadata snapshot of a tracked file.
struct FileSnapshot {
mtime_us: u64, // 微秒精度 mtime(macOS APFS 纳秒级)
size: u64,
content: String, // >1 MiB 时为空字符串
}
/// Mutable inner state of the diff hook.
struct DiffHookInner {
config: DiffHookConfig,
project_path: PathBuf,
/// LRU watched files: order[0] = LRU, order[last] = MRU.
order: VecDeque<PathBuf>,
/// O(1) membership check.
tracked: HashSet<PathBuf>,
/// Snapshots taken before command execution.
snapshot: HashMap<PathBuf, FileSnapshot>,
/// Cached git repo detection.
is_git_repo: Option<bool>,
/// Cached .gitignore patterns: (base_dir, pattern_line).
gitignore_patterns: Option<Vec<(PathBuf, String)>>,
/// Compiled regex for string-body path scanning.
string_path_regex: regex::Regex,
/// Compiled regex for symbol extraction.
symbol_regex: regex::Regex,
}
/// Public handle — thread-safe, cloneable.
#[derive(Clone)]
pub struct DiffHook {
inner: Arc<RwLock<DiffHookInner>>,
}
```
**为什么用 `Arc<RwLock<>>` 而非 `Arc<Mutex<>>`:**
- 多个 SubAgent 可能共享同一个 `DiffHook`
- `pre_exec` 和 `post_exec` 之间是 `spawn_blocking` 线程
- `RwLock` 允许多读(detect_changes 同时从快照读)但写互斥(take_snapshot 写入)
---
## 11. 关键算法实现
### 11.1 Shell 分词器
手写最小 shell 分词器,处理单双引号和反斜杠转义。失败回退到 `split_whitespace()`。
### 11.2 Heredoc 体提取
`<<` 到结束标记之间。正则 `/<<\s*["']?(\w+)["']?/` 识别标记,`find(marker)` 找到结束位置。
### 11.3 路径解析(`resolve_path`)
`~` 展开 → 项目内相对路径 → 标准化(消除 `..`)→ 检查是否在 project_path 内。
**注意:不调用 `canonicalize()`(需要文件存在 + 额外 syscall),改为手动 Components 遍历。**
### 11.4 文件系统扫描(四层过滤 DFS)
栈式 DFS + `read_dir` 惰性迭代器。每层目录先做过滤决策再入栈。代码结构清晰,无递归。
### 11.5 `fnmatch_simple`(匹配 `*` `?` 通配符)
经典的双指针回溯算法,替代 `glob` crate。约 25 行。
### 11.6 `measure_diff`(统一测量入口)
用 `similar::TextDiff::from_lines` 生成 diff,遍历 `iter_all_changes()` 统计线条数。
### 11.7 内容 Diff(`similar::TextDiff`)
零拷贝引用遍历 `iter_all_changes()`,`ChangeTag` 标记每行类型,格式化时去掉 `@@` hunk 头。
### 11.8 diff_preview
用 `measure_diff` 的行计数 + hunk 计数 + 符号提取。符号从 Insert/Delete 变更行的内容中正则提取。
### 11.9 符号提取
正则 `(?:def|class|async\s+def)\s+(\w+)|(?:const|let|var|function)\s+(\w+)|([A-Z][A-Z_0-9]{2,})` 匹配 Python/JS/常量等。
### 11.10 `classify_and_format`
排序 → 生成反馈 → 截断 → 聚合。`measure_diff()` 的结果同时用于分级(total)和指纹(added/deleted/hunks)。
---
## 12. `similar` 的 API 映射表
| `difflib.unified_diff(before, after)` | `TextDiff::from_lines(before, after)` | 构造 diff 对象 |
| `lines[2:]` 遍历 | `diff.iter_all_changes()` | 遍历所有 diff 行 |
| `line.startswith("+")` | `ChangeTag::Insert` | 新增行 |
| `line.startswith("-")` | `ChangeTag::Delete` | 删除行 |
| `line.startswith("@")` | `prev_tag == Equal && cur_tag != Equal` | hunk 边界 |
| `line.startswith(" ")` | `ChangeTag::Equal` | 未变更行 |
| hunk 合并(n=2 上下文) | `TextDiff` 默认行为 | similar 自动合并相近 hunk |
---
## 13. 与 Python 版的关键差异
| diff 库 | `difflib`(标准库,LCS 算法) | `similar`(Myers 算法,快 2-10x) |
| 行计数方法 | `unified_diff` 数 `-`/`+` 行 | `TextDiff::iter_all_changes` 计数 `ChangeTag` |
| 文件发现 | `shlex.split` + `os.walk` | 手写 shell 分词器 + `read_dir` DFS |
| .gitignore | `fnmatch.fnmatch`(标准库) | 手写 `fnmatch_simple`(25 行) |
| 文件 I/O | 同步 `Path.read_text()` | `std::fs` 在 `spawn_blocking` 线程中 |
| 并发安全 | 单线程 `asyncio` | `Arc<RwLock<DiffHookInner>>` |
| LRU 实现 | `OrderedDict` | `VecDeque<PathBuf>` + `HashSet<PathBuf>` |
| 符号提取 | `re.finditer()` | `regex::Regex::captures_iter()` |
| 配置集成 | `Settings.diff_hook` | `AgentConfig.diff_hook` |
| 超时保护 | `asyncio.wait_for(..., 3.0) + to_thread` | `tokio::time::timeout(3s)` + `spawn_blocking` |
| 错误处理 | try/except 静默 | `Option<T>` / `Result` → `None` |
| 实现行数 | diff_hook.py 535 行 | diff_hook.rs 约 450 行(含更冗长的类型标注) |
| 新增依赖 | 0(纯标准库) | 1(`similar = "2"`,约 30KB) |
---
## 14. 设计原则总结
| **宁滥勿缺** | 命令解析过度匹配的成本极低(多 stat 一个不变的文件),漏掉的代价极高(hook 整个失效) |
| **策略A 为主、策略B 为辅** | 命令解析免费且命中率 >95%;文件系统扫描只在首次冷启动时兜底 |
| **unified diff 统一计数** | 废弃 SequenceMatcher 的 `\n` 统计,改用 unified diff 直接数 `-`/`+` 行——行内修改不再归零 |
| **post_exec 只 re-stat** | 不重新扫描项目目录;只对已跟踪的文件做增量 mtime+size 检查 |
| **分级反馈** | <=10行->diff,>10行->diff_preview(最多5条),无 stats |
| **小文件优先** | 批量修改时变更行数少的文件排前面——信息密度更高 |
| **超时兜底** | post_exec 独立超时 3s,spawn_blocking 线程中执行,不阻塞 tokio 模作线程 |
| **透明注入** | 模型不感知 hook 存在,工具列目和 schema 不变化 |
| **默认关闭** | 需显式 `{"diff_hook": {"enabled": true}}` 开启 |
| **零外部依赖** | 仅新增 `similar`;shell 分词、fnmatch、LRU 均手写 |
---
## 15. 风险与缓解
| diff_hook 阻塞 tokio 运行时 | 所有 I/O 在 `spawn_blocking` 执行;post_exec 3s 超时 |
| 大文件内存爆炸 | >1 MiB 跳过内容快照;max_diff_lines 截断 |
| 策略B 扫描巨型项目卡死 | 四层过滤 + 5000 硬上限;条目数 >1000 的目录跳过 |
| 行内修改计数归零 | 已通过 unified diff 直接数 `-`/`+` 修复 |
| 新建文件首次写入不跟踪 | 边界情况、影响可控;后续 cat 后再编辑即可跟踪 |
| 命令引用错误文件被误跟踪 | 仅当 `Path::is_file()` 为 true 且路径在项目内才加入 |
| 模型忽略 `_changes` 字段 | prompt 不目任何提示——模型天生理解 diff 格式 |
| `similar` crate 版本兼容 | 指定 `similar = "2"`,2.x API 稳定 |
---
## 16. 实现计划
### 阶段 1:基础设施(约 200 行)
1. `Cargo.toml` 新增 `similar = "2"` 依赖
2. `config.rs` 新增 `DiffHookConfig` struct(含 `fs_scan_max_files`、`fs_scan_dir_entry_threshold`、`fs_ignore_dirs` 等新字段)
3. `diff_hook.rs` 创建文件,实现 `DiffHookInner` + `DiffHook` 骨架
4. 手写 shell 分词器 `split_shell_tokens`
5. 手写 `fnmatch_simple`(`*` `?` 通配符)
6. 实现 LRU 关注文件集合 + 路径解析 + 文本文件检测
### 阶段 2:文件发现(约 150 行)
7. 策略A:`extract_filepaths`(重定向、-c 字符串、heredoc、目意 token)
8. `extract_heredoc_body`
9. 策略B:`scan_project`(四层过滤 DFS)
10. `dir_entry_count` + `load_gitignore` + `is_gitignored`
### 阶段 3:diff 生成(约 180 行)
11. `take_snapshot` + `detect_changes`(mtime + size 双因子)
12. `measure_diff`(统一入口:一次 TextDiff 得到 total/added/deleted/hunks)
13. `compute_content_diff`(similar::TextDiff,无行号)
14. `compute_fingerprint`(hunk 计数 + lines_added/deleted + symbols + git_hint)
15. `compute_hunk_preview`(首 3 行)
16. `extract_symbols`(regex)
17. `classify_and_format`(排序 + 分级 + 截断 + 聚合摘要)
### 阶段 4:集成(约 50 行)
18. 修改 `ShellTool` 添加 `diff_hook: Option<DiffHook>` 字段和 `new()` 参数
19. 修改 `ShellTool::call()` 注入 pre/post 钩子(含 3s 超时)
20. 修改 `tools/mod.rs` 的 `register_core_tools()` 创建 `DiffHook` 实例并传递
### 阶段 5:测试(约 120 行)
21. 单元测试:shell 分词、fnmatch、路径提取、heredoc 提取、文本检测、符号提取、LRU
22. 单元测试:strategy B 四层过滤、gitignore 匹配、目录条目数计数
23. 单元测试:measure_diff 准确性(行内修改、新增/删除、混合场景)
24. 单元测试:compute_content_diff、compute_fingerprint、classify_and_format
25. 集成测试:完整 pre/post 流程(通过 ShellTool + NativeShell)
26. `DiffHookConfig` JSON 反序列化测试(添加到 `config.rs` 现有测试)
---
## 17. 不被采纳的方案
### 17.1 基于 inotify / kqueue 的被动监听
❌ 需要异步事件循环集成,移动端不支持。文件数量极少(最多 50),主动 poll metadata 足够。
### 17.2 基于 git 的 diff
❌ 不是所有项目都是 git 仓库,且 `git diff` 需要进程启动开销。
### 17.3 模改 ShellBackend trait 添加 hook 能力
❌ `ShellBackend` 是 shell 执行抽象,不是文件感知抽象。hook 在工具层更合职责分离。
### 17.4 引入 `shlex` / `glob` / `dirs` crate
❌ shell 分词仅需处理引号和转义(约 25 行);glob 匹配仅需 `*` `?`(约 25 行);`~` 展开极少出现于模型命令。首维持零外部依赖(`similar` 除外)。