aacode-rs 1.8.13

Rust port of the AACode ReAct coding agent, built on fastshell for mobile AI agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
# 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 特有技术栈

| 组件 | Rust Crate | 说明 |
|------|-----------|------|
| 文件快照 | `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 验证对比:**

| 场景 | `\n` 计数 | unified diff 计数 | 正确值 |
|------|:---:|:---:|:---:|
| 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 典型场景耗时

| 场景 | 文件发现 | 快照 | post_exec | 总计 |
|------|---------|------|-----------|------|
| `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 映射表

| Python `difflib` | Rust `similar` | 说明 |
|------------------|---------------|------|
| `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 版的关键差异

| 维度 | Python 版 | Rust 版 |
|------|-----------|---------|
| 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` 除外)。