dsh-gokifu-compact 1.11.1 → 1.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +12 -8
- package/cordis.patch.yml +28 -0
- package/package.json +10 -2
- package/scripts/install.mjs +131 -3
- package/src/rooms/hot-inject.mjs +28 -8
- package/src/rooms/ledger.mjs +43 -0
- package/src/rooms/verify.mjs +83 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,112 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 1.11.3 — 2026-10-31
|
|
4
|
+
|
|
5
|
+
### P0-1 · `exit` 判据收紧:不再被正文子串误判
|
|
6
|
+
|
|
7
|
+
旧实现用 `slice(-200)` 在尾部 200 字符里**子串搜索** `[exit code: N]` ⇒ 命令**输出正文里提到**这串就误判成「有退出码」,
|
|
8
|
+
且「标记之后还有输出」时前后结论不一致。
|
|
9
|
+
|
|
10
|
+
- 新增 `lastLineExitCode()`:取**末条非空行**,**整行等于** `[exit code: N]` 才算有退出码;否则视为 no-exit。
|
|
11
|
+
- `verify.mjs` 顶部注释同步;`_internal` 导出 `LOCAL_MUTATE_TOOLS` / `lastLineExitCode`。
|
|
12
|
+
- 测试 +3(`verifier` OPT-V-1 / OPT-V-2、`weak-shell` S2b-14);变异:退回 `slice(-200)` ⇒ OPT-V-1 变红。
|
|
13
|
+
|
|
14
|
+
### P0-2 · `local-mutate` 从只读表拆出(不再被当成「只读工具」)
|
|
15
|
+
|
|
16
|
+
`todo_write` / `update_goal` / `create_goal` / `hindsight_ingest_document` / `team_task_*` / `send_message` /
|
|
17
|
+
`spawn_teammate` / `subagent` / `schedule_*` / `job_kill` … 这些**会改状态但不是文件写**的工具,原先把它们混在只读表里,
|
|
18
|
+
分类结果自相矛盾。现在:
|
|
19
|
+
|
|
20
|
+
- 新增 `LOCAL_MUTATE_TOOLS`;`toolClass()` 返回 **`local-mutate`**(与 `read-only` / `write` 并列)
|
|
21
|
+
- `classify()` 对 local-mutate 给 **`unverified` + kind `none`,且不带 `weak`**(它既不是「进展」也不是「失败」)
|
|
22
|
+
- `WRITE_TOOLS` / `present` 与 `isError` 的**优先级不变**
|
|
23
|
+
- 变异:把 local-mutate 放回只读表 ⇒ S2b-14 + OPT-V-2 变红
|
|
24
|
+
|
|
25
|
+
### 外部入口 · 新增 `dsh-gokifu-compact/graph` 与 `/persist`
|
|
26
|
+
|
|
27
|
+
1.11.2 的 README 教外部工具去读 `node_modules/dsh-gokifu-compact/src/rooms/kifu-model.mjs` —— 那是**深层路径**,
|
|
28
|
+
`exports` 里没有 ⇒ 现代 ESM 解析会直接拒绝。现在补两个正式入口:
|
|
29
|
+
|
|
30
|
+
| 入口 | 目标 | 用途 |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `dsh-gokifu-compact/graph` | `src/rooms/kifu-model.mjs` | `fromJSON` / `toJSON` / `decodeDeltaFrames` / `applyDelta` / `renderIndexMd` … |
|
|
33
|
+
| `dsh-gokifu-compact/persist` | `src/rooms/kifu-persist.mjs` | `graphRoot` / `sessionPaths` / `createPersistStore` / `load` / `flush` … |
|
|
34
|
+
|
|
35
|
+
- 新增 `test/package-exports.test.mjs`(**7 条**):声明齐全、子路径可解析、真实 `import` 成功、导出面非空、不泄漏内部路径
|
|
36
|
+
- README 的「怎么拿最新状态」一行改为指向这两个入口
|
|
37
|
+
|
|
38
|
+
### 零失败边最小注入(OPTIONS 之后又一刀「别让热清单吃白饭」)
|
|
39
|
+
|
|
40
|
+
**问题**:只读统计 **167 个真实会话**,**42 个(25.1%)零失败边** —— 它们照样要付一份完整热清单的 token。
|
|
41
|
+
|
|
42
|
+
**改法**:
|
|
43
|
+
|
|
44
|
+
- `ledger.mjs` 新增 `renderMinimalLedger`:`<chess-ledger>` 内只有三行 —— `GOAL` / `CONSTRAINTS: N` / `NO_FAILED_ATTEMPTS_YET`;
|
|
45
|
+
**`renderLedger` 一个字节不动**(167 会话新旧对比 `byteDiff=0`)
|
|
46
|
+
- `hot-inject.decideInjection`:零失败边且 `hasLedgerState` ⇒ 用最小渲染走**原注入流程**(digest / 限流 / replace / fold 补回全不变);空图仍 `skip no-ledger`
|
|
47
|
+
- 实测(42 个零失败会话):最小注入 **avg 25.4 tok / max 57**(同批会话的完整最小档 avg 386 / max 497)
|
|
48
|
+
- 分布:root 覆盖 **42/42**、constraints **32/42**、完全空 **0**(不会出现「三行全空」的哑注入)
|
|
49
|
+
- 测试 +4:OPT-E-1 触发与三项内容 / OPT-E-2 空图 skip / OPT-E-3 有失败边时字节不变(sha 钉死)/ OPT-E-4 fold 补回
|
|
50
|
+
- 变异:零失败退回 skip ⇒ E-1/E-4 红;删 `NO_FAILED_ATTEMPTS_YET` 行 ⇒ E-1/E-4 红
|
|
51
|
+
|
|
52
|
+
### tier-2 行为矩阵
|
|
53
|
+
|
|
54
|
+
- 新增 `test/tier2.test.mjs`(**6 条**,engine 零改动)+ `engine-harness` 导出 4 个 helper —— 把此前「文档说有、代码没测」的第二档行为逐条核实。
|
|
55
|
+
|
|
56
|
+
### 验证
|
|
57
|
+
|
|
58
|
+
- 全量:**463 passed / 0 failed**(36 个测试文件;443 + 7 exports + 3 verify + 6 tier2 + 4 hot-inject)
|
|
59
|
+
- `rooms` **15/0** · `hot-inject` **12/0** · `verify-install` **16/16**
|
|
60
|
+
- `bench 2000`:**351 tokens** / 1.336 ms(81,260 / 386,945 字符、1.41 MB、2001 节点)
|
|
61
|
+
- `npm pack --dry-run --json`:**70 文件**(数量不变)/ 562,344 B;`test/`·`tmp/` 命中 0
|
|
62
|
+
|
|
63
|
+
**未验证(必须知道)**
|
|
64
|
+
|
|
65
|
+
- 最小注入的**运行期收益未在真机观测** —— 25.4 tok 是离线夹具口径,167 会话统计是只读扫描。
|
|
66
|
+
- tier-2 矩阵跑在假 ctx 上,不是真机行为。
|
|
67
|
+
- README 里的「真实包 8/8 †」「真实进程 fold 3~6/6 †」带 **†** = 历史口径,**本版未复跑**。
|
|
68
|
+
|
|
69
|
+
## 1.11.2 — 2026-10-30
|
|
70
|
+
|
|
71
|
+
### 本包改成 DSH bundle(UI 挂载不再需要用户手改 YAML)
|
|
72
|
+
|
|
73
|
+
**这是第一个 UI bundle 版。** 以前装完包要**手改 profile 的 `cordis.patch.yml`** 加一条
|
|
74
|
+
`- insert: [{id: gokifu-ui, name: 'dsh-gokifu-compact'}]` 才能挂上 UI。手改有两个后患:
|
|
75
|
+
|
|
76
|
+
1. **双挂载**:用户升级包后手工行还留着,和 bundle 通道同时挂 ⇒ 两个 host 半注册同一个 `/gokifu/api` 前缀 ⇒ **启动即失败**(duplicate prefix route);
|
|
77
|
+
2. **没挂载**:`dsh plugin add` 不会自己补挂载行 ⇒ 「装好了但没生效」,且**没有任何报错**。
|
|
78
|
+
|
|
79
|
+
**改法**
|
|
80
|
+
|
|
81
|
+
- 新增 **`cordis.patch.yml`(28 行)**:只做一件事 —— `- insert: [{id: gokifu-ui, name: 'dsh-gokifu-compact'}]`。
|
|
82
|
+
**引擎不在这里**:压缩后端必须挂在 **agent preset 的 compaction group 内部**,profile/bundle 级 patch 打不到 `preset.config.plugins`;那一行仍由 `install.mjs` 改 preset。
|
|
83
|
+
`id` 保持 `gokifu-ui`(与历史手工行一致)⇒ 从手工通道切过来时,日志/文档/doctor 口径都不用改。
|
|
84
|
+
- `package.json`:`dsh.bundle.patch = "./cordis.patch.yml"` · `dsh.manifestVersion = 1` · `files`/`exports` 补 `cordis.patch.yml`;`main` 与 `dsh.client` 不变。
|
|
85
|
+
- **`scripts/install.mjs` 改 bundle 优先**:
|
|
86
|
+
- `findManualUiRow` / `removeManualUiRow`:定位并(带备份)删除 profile 里的旧手工行
|
|
87
|
+
- `patchProfileBundles()`:把本包**追加**进 `dsh.profile.bundles`,保持既有顺序;写前备份 profile/`package.json`
|
|
88
|
+
- `ensureBundleChannel()` = **先加 bundle、再删手工行** —— 顺序保证任何中断都不会出现「两条都没有」的空窗
|
|
89
|
+
- `git`/`registry` 规格**不改写成 `link`**
|
|
90
|
+
- **`test/verify-install.mjs` 判据升级**:旧判据「bundles 里没有它」→ **「UI 挂载通道二选一,不双挂载」**:两条都在 = 红(双挂载)、都没有 = 红(没挂载);检查项总数仍 **16**。
|
|
91
|
+
- **`test/ws-install.test.mjs`**:健康夹具改走 bundle 通道;**新增 S11 四种态**(bundle-only 绿 / manual-only 绿 / both 红 / neither 红)。
|
|
92
|
+
- **`test/install-link.test.mjs`** 夹具同步。
|
|
93
|
+
- **新增 `test/bundle.test.mjs`(6 条)**:B-1 声明齐全 · B-2 patch 只插 UI 一行 · B-3 `findManualUiRow` 三态 · B-4 bundle 优先+备份+幂等 · B-5 dry-run 零写 · B-6 registry 规格不被改写。
|
|
94
|
+
- **变异自检(4 条,逐条实测红)**:删 `dsh.bundle` ⇒ B-1;patch 塞引擎行 ⇒ B-2;安装器不删手工行 ⇒ B-4;判据退回「bundle 里不许有」⇒ S11 + S10e-1。
|
|
95
|
+
|
|
96
|
+
### 验证
|
|
97
|
+
|
|
98
|
+
- 全量:**443 passed / 0 failed**(36 个测试文件;436 + 6 bundle + 1)
|
|
99
|
+
- `rooms` **15/0** · `ws-install` **14/0** · `install-link` **29/0** · `bundle` **6/0** · `verify-install` **16/16**
|
|
100
|
+
- `bench 2000`:**351 tokens** / 1.423 ms(81,260 / 386,945 字符、1.41 MB、2001 节点)
|
|
101
|
+
- `npm pack --dry-run --json`:**70 文件**(69 + `cordis.patch.yml`)/ 558,113 B;`test/`·`tmp/` 命中 0
|
|
102
|
+
- live 当前仍是**手工通道**(`bundle=无|手工行=有`)—— `verify-install` 按「二选一」判据通过
|
|
103
|
+
|
|
104
|
+
**未验证(必须知道)**
|
|
105
|
+
|
|
106
|
+
- **未跑 live install**:`--apply` 到真实 profile 的 bundle 切换没有执行过(只在 dry-run 与夹具上验证)。
|
|
107
|
+
- **未启 DSH**:bundle 通道在真实插件树里的加载行为未观测。
|
|
108
|
+
- 从手工通道切换到 bundle 的**中断恢复**只在单测里模拟(顺序保证 + 备份),真机未演练。
|
|
109
|
+
|
|
3
110
|
## 1.11.1 — 2026-10-30
|
|
4
111
|
|
|
5
112
|
### P6-C 落地:未落地尝试不再被当成残留(**热清单新增 OPTIONS 段**)
|
package/README.md
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
# dsh-GOkifu-compact
|
|
2
2
|
|
|
3
|
-
> 当前版本:**`1.11.
|
|
3
|
+
> 当前版本:**`1.11.3`** · npm [`dsh-gokifu-compact`](https://www.npmjs.com/package/dsh-gokifu-compact) · [GitHub Releases](https://github.com/MYCF711/dsh-GOkifu-compact/releases) · 更新记录见 [CHANGELOG.md](CHANGELOG.md)
|
|
4
4
|
|
|
5
|
-
**DSH 的棋谱式上下文压缩后端 —— 把「历史 → 一段摘要」换成「历史 → 可复盘的棋谱 DAG + 每步只有
|
|
5
|
+
**DSH 的棋谱式上下文压缩后端 —— 把「历史 → 一段摘要」换成「历史 → 可复盘的棋谱 DAG + 每步只有 351 tokens 的热清单」。**
|
|
6
6
|
|
|
7
7
|
```
|
|
8
|
-
热清单
|
|
9
|
-
失败保留 8 / 8
|
|
10
|
-
清单开销 0.2%
|
|
11
|
-
验证
|
|
8
|
+
热清单 351 tokens (2000 边合成夹具 · L5 降级档 · 2026-10-30/10-31 两次实测同值;旧版全量 28,800)
|
|
9
|
+
失败保留 8 / 8 (历史口径 †;官方 8 段模板 4.67 / 8)
|
|
10
|
+
清单开销 未复现/待测 (原载 0.2% = 历史单会话 v1.3.1 后实测;无复跑命令、无对照组;源 evidence/真实计费核对.md)
|
|
11
|
+
验证 463 项单测 · 真实包 8/8 † · 真实进程 fold 3~6/6 †
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不只有落子顺序,还有每一手的评语。
|
|
15
15
|
这个插件把 agent 的每一步变成一手棋(节点)、把当时的思考变成评语(目标 / 约束 / 失败原因),
|
|
16
16
|
整个任务变成一局可以复盘的棋。**棋谱是给模型用的,不是给人看的** —— 热清单里的每个字都为模型决策服务。
|
|
17
17
|
|
|
18
|
+
> **† 历史口径(未复现)**:`失败保留 8/8`、`真实包 8/8`、`真实进程 fold 3~6/6` 都是**早期真机测量**,**本轮没有复跑命令、没有复现**,不要当现行指标引用。
|
|
19
|
+
>
|
|
20
|
+
> **351 tokens 的口径**:这是 **2000 边合成夹具**在 **L5 降级档**下的结果,**不是真实会话均值**。真实会话里预算按 `字符数/3` 估算,实测**系统性低估 5.2%~22.1%(中位 9.7%)**(见 `计划表.md` 的 OPT-TOKENS 节);**真实会话口径尚未校准** —— 正解是引入宿主 tokenizer(`ctx.tokenMeter`)后重测,**未做**。
|
|
21
|
+
|
|
18
22
|
---
|
|
19
23
|
|
|
20
24
|
## 功能
|
|
@@ -48,7 +52,7 @@ GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不
|
|
|
48
52
|
| 压缩范围 | 从 surface 第一个非 system 节点起(**含上次 checkpoint**) | 上一个 checkpoint **之后** |
|
|
49
53
|
| 保留率 | 每轮重摘 → 按 ρ^k 衰减(实测安全规则 5 轮后剩 10%) | 一段内容只摘一次 |
|
|
50
54
|
| 失败记录 | 融进散文摘要,模型可以丢 | 规则抽取 + 逐字注入,模型无权丢 |
|
|
51
|
-
| 热上下文 | 无 |
|
|
55
|
+
| 热上下文 | 无 | 351 tokens / 步(**2000 边合成夹具 · L5 降级档**;真实会话口径未校准),带语义标记与报错原文 |
|
|
52
56
|
|
|
53
57
|
### ⚠ 棋谱落盘:`graph.json` 的语义(1.9.2 起)
|
|
54
58
|
|
|
@@ -67,7 +71,7 @@ graph.json = 最新 graph.json = 上一个回合边界
|
|
|
67
71
|
|
|
68
72
|
| 你要什么 | 怎么拿 |
|
|
69
73
|
|---|---|
|
|
70
|
-
| **最新状态**(推荐) | **基线 + 增量一起读**:`fromJSON(graph.json 文本)` → 用基线内容哈希 `decodeDeltaFrames(delta 文本, baseHash)` → 逐帧 `applyDelta(g, frame)
|
|
74
|
+
| **最新状态**(推荐) | **基线 + 增量一起读**:`fromJSON(graph.json 文本)` → 用基线内容哈希 `decodeDeltaFrames(delta 文本, baseHash)` → 逐帧 `applyDelta(g, frame)`。三个函数都从包入口 `dsh-gokifu-compact/graph` 导出(源码 `src/rooms/kifu-model.mjs`,随包发布);落盘/读盘侧(`graphRoot` / `sessionPaths` / `createPersistStore` 等)走 `dsh-gokifu-compact/persist` |
|
|
71
75
|
| 上一回合基线(做 diff / 审计) | 直接读 `graph.json` 可以,但**要说明「这是基线不是最新」** |
|
|
72
76
|
| 崩溃安全 | 每帧带 `baseHash`(generation)+ `checksum31`:半写帧解码时就地停止,加载时把 delta 截到最后一条好帧;**旧 generation 的帧整体忽略** |
|
|
73
77
|
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# dsh-gokifu-compact bundle patch
|
|
2
|
+
#
|
|
3
|
+
# 这是本包的 `dsh.bundle.patch` 层(`package.json` 的 `dsh.bundle.patch` 指向本文件)。
|
|
4
|
+
# 它只做一件事:**把 UI 的 host 半插进插件树**(`/gokifu/api` + 详情页数据/写配置)。
|
|
5
|
+
#
|
|
6
|
+
# 为什么现在改成 bundle:以前靠 `dsh plugin add` 之后**手改 profile 的 cordis.patch.yml**
|
|
7
|
+
# 加一条 `- insert: [{id: gokifu-ui, name: 'dsh-gokifu-compact'}]`。手改有两个后患:
|
|
8
|
+
# ① 用户升级包之后,这条手工行留在 profile 里,和 bundle 通道**同时**挂载 ⇒ 两个 host 半
|
|
9
|
+
# 注册同一个 `/gokifu/api` 前缀 ⇒ 启动即失败(duplicate prefix route);
|
|
10
|
+
# ② `dsh plugin add` 不会自己补挂载行 ⇒ 「装好了但没生效」,且没有任何报错。
|
|
11
|
+
# 声明成本包的 patch 层之后,profile 只要把本包写进 `dsh.profile.bundles`,补丁会被自动合并,
|
|
12
|
+
# 与手工行**语义完全等价**(同样是 `- insert:` 一行),但不再需要用户改任何 YAML。
|
|
13
|
+
#
|
|
14
|
+
# ⚠ 两条通道**只能二选一**:
|
|
15
|
+
# · bundle 通道:`dsh.profile.bundles` 含 `dsh-gokifu-compact`,profile 自己的
|
|
16
|
+
# `cordis.patch.yml` 里**不得**再有 `id: gokifu-ui` 的手工行;
|
|
17
|
+
# · 手工通道:profile 自己的 `cordis.patch.yml` 有那一行,且 bundles 里**不得**含本包。
|
|
18
|
+
# `scripts/install.mjs` 切到 bundle 通道时会**备份并删除**旧手工行;
|
|
19
|
+
# `test/verify-install.mjs` 有「二选一、不双挂载」的机器判据(两条都在 = 红)。
|
|
20
|
+
#
|
|
21
|
+
# 引擎(压缩后端)不在这里:它必须挂在 **agent preset 的 compaction group 内部**,
|
|
22
|
+
# profile/bundle 级 patch 打不到 preset 的 `config.plugins`(见 docs/DSH-技术测绘.md §7.6)。
|
|
23
|
+
# 那一行由 `scripts/install.mjs` 改 preset 完成;本文件只管 UI。
|
|
24
|
+
#
|
|
25
|
+
# id 保持 `gokifu-ui`(与历史手工行一致):从手工通道切过来时,日志/文档/doctor 都不用改口径。
|
|
26
|
+
- insert:
|
|
27
|
+
- id: gokifu-ui
|
|
28
|
+
name: 'dsh-gokifu-compact'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-gokifu-compact",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.3",
|
|
4
4
|
"description": "DSH context-compression backend with a go game record (kifu) DAG: node = position, edge = attempted move + result. Incremental fold (each span summarized once), failure memory that survives compaction, transposition detection (chess-engine style), and a tiny hot ledger rebuilt every step.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
},
|
|
20
20
|
"./engine": "./src/engine.mjs",
|
|
21
21
|
"./client": "./lib/client.js",
|
|
22
|
-
"./
|
|
22
|
+
"./graph": "./src/rooms/kifu-model.mjs",
|
|
23
|
+
"./persist": "./src/rooms/kifu-persist.mjs",
|
|
24
|
+
"./package.json": "./package.json",
|
|
25
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
23
26
|
},
|
|
24
27
|
"engines": {
|
|
25
28
|
"node": ">=20"
|
|
@@ -33,6 +36,7 @@
|
|
|
33
36
|
"docs",
|
|
34
37
|
"evidence",
|
|
35
38
|
"scripts",
|
|
39
|
+
"cordis.patch.yml",
|
|
36
40
|
"CHANGELOG.md",
|
|
37
41
|
"QUICKSTART.md",
|
|
38
42
|
"LICENSE"
|
|
@@ -79,6 +83,10 @@
|
|
|
79
83
|
"dsh-gokifu-compact": "./scripts/cli.mjs"
|
|
80
84
|
},
|
|
81
85
|
"dsh": {
|
|
86
|
+
"bundle": {
|
|
87
|
+
"patch": "./cordis.patch.yml"
|
|
88
|
+
},
|
|
89
|
+
"manifestVersion": 1,
|
|
82
90
|
"client": {
|
|
83
91
|
"platform": "web",
|
|
84
92
|
"inject": []
|
package/scripts/install.mjs
CHANGED
|
@@ -158,6 +158,127 @@ export function patchProfileForUi(profileDir, log = () => {}, dryRun = false) {
|
|
|
158
158
|
return { changed: true, backup };
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
|
|
162
|
+
/* ─────────────── ②·b bundle 通道(task-118) ─────────────── */
|
|
163
|
+
|
|
164
|
+
/** 手工行的 id(历史值,保持兼容:日志/doctor/文档都用这个口径)。 */
|
|
165
|
+
const MANUAL_UI_ID = "gokifu-ui";
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 在 profile 的 cordis.patch.yml 里定位手工 UI 行(`- insert:` + `- id: gokifu-ui`)。
|
|
169
|
+
*
|
|
170
|
+
* 纯函数:只算行号,不改任何东西(方便测试与 dry-run)。找不到返回 null。
|
|
171
|
+
* 结构不认识时返回 `{ unsupported: true }` —— 调用方必须**拒绝改**,绝不能猜着删。
|
|
172
|
+
* @returns {{startLine:number,endLine:number,start:number,end:number,wholeBlock:boolean}|{unsupported:true,reason:string}|null}
|
|
173
|
+
*/
|
|
174
|
+
export function findManualUiRow(text) {
|
|
175
|
+
const lines = String(text).split("\n");
|
|
176
|
+
const childIdx = lines.findIndex((l) => new RegExp("^\\s*- id:\\s*" + MANUAL_UI_ID + "\\s*$").test(l));
|
|
177
|
+
if (childIdx < 0) return null;
|
|
178
|
+
// 往上找父级 `- insert:`(最多 12 行内;缩进必须比子行浅)
|
|
179
|
+
const childIndent = lines[childIdx].match(/^\s*/)[0].length;
|
|
180
|
+
let parentIdx = -1;
|
|
181
|
+
for (let i = childIdx - 1; i >= Math.max(0, childIdx - 12); i -= 1) {
|
|
182
|
+
const m = /^(\s*)- insert:\s*$/.exec(lines[i]);
|
|
183
|
+
if (m && m[1].length < childIndent) {
|
|
184
|
+
parentIdx = i;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (parentIdx < 0) return { unsupported: true, reason: "找不到 `- insert:` 父行" };
|
|
189
|
+
// 父块范围 = 从父行到下一条**缩进 ≤ 父缩进**的非空行
|
|
190
|
+
const parentIndent = lines[parentIdx].match(/^\s*/)[0].length;
|
|
191
|
+
let endIdx = lines.length;
|
|
192
|
+
for (let i = parentIdx + 1; i < lines.length; i += 1) {
|
|
193
|
+
if (lines[i].trim().length === 0) continue;
|
|
194
|
+
if (lines[i].match(/^\s*/)[0].length <= parentIndent && /^\s*- /.test(lines[i])) {
|
|
195
|
+
endIdx = i;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// 这个 insert 块里还有别的子行吗?(有 ⇒ 只删本行;没有 ⇒ 整块删)
|
|
200
|
+
const otherChildren = lines
|
|
201
|
+
.slice(parentIdx + 1, endIdx)
|
|
202
|
+
.some((l) => l.match(/^\s*/)[0].length === childIndent && /^\s*- /.test(l) && !new RegExp("^\\s*- id:\\s*" + MANUAL_UI_ID + "\\s*$").test(l));
|
|
203
|
+
return { startLine: childIdx, endLine: childIdx, start: parentIdx, end: endIdx - 1, wholeBlock: !otherChildren };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 从 profile 的 cordis.patch.yml 删除手工 UI 行(**带备份**)。
|
|
208
|
+
* 只在 `wholeBlock` 时连注释一起删:注释是与那一行配套的说明,留着会变成误导性死注释。
|
|
209
|
+
*/
|
|
210
|
+
export function removeManualUiRow(profileDir, log = () => {}, dryRun = false) {
|
|
211
|
+
const file = nodePath.join(profileDir, "cordis.patch.yml");
|
|
212
|
+
if (!nodeFs.existsSync(file)) return { changed: false, reason: "没有 profile cordis.patch.yml" };
|
|
213
|
+
const text = nodeFs.readFileSync(file, "utf8");
|
|
214
|
+
const found = findManualUiRow(text);
|
|
215
|
+
if (found === null) return { changed: false, reason: "没有手工 UI 行" };
|
|
216
|
+
if (found.unsupported) return { changed: false, reason: `手工行结构不认识(${found.reason}),拒绝改` };
|
|
217
|
+
const lines = text.split("\n");
|
|
218
|
+
let start = found.start;
|
|
219
|
+
let end = found.end;
|
|
220
|
+
if (found.wholeBlock) {
|
|
221
|
+
// 连带删除紧贴其上、提到 gokifu 的连续注释行
|
|
222
|
+
while (start - 1 >= 0 && /^\s*#/.test(lines[start - 1]) && /gokifu|上下文压缩/i.test(lines[start - 1])) start -= 1;
|
|
223
|
+
} else {
|
|
224
|
+
start = found.startLine;
|
|
225
|
+
end = found.endLine;
|
|
226
|
+
}
|
|
227
|
+
let next = [...lines.slice(0, start), ...lines.slice(end + 1)].join("\n");
|
|
228
|
+
next = next.replace(/\n{3,}/g, "\n\n");
|
|
229
|
+
if (dryRun) {
|
|
230
|
+
log(" [dry] profile cordis.patch.yml 会删掉手工 gokifu-ui 行(改走 bundle 通道)");
|
|
231
|
+
return { changed: true, dryRun: true };
|
|
232
|
+
}
|
|
233
|
+
const backup = `${file}.bak-before-gokifu-ui-${Date.now()}`;
|
|
234
|
+
nodeFs.copyFileSync(file, backup);
|
|
235
|
+
nodeFs.writeFileSync(file, next, "utf8");
|
|
236
|
+
log(` ✓ profile cordis.patch.yml 已删手工 gokifu-ui 行(备份 ${nodePath.basename(backup)})`);
|
|
237
|
+
return { changed: true, backup };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 确保 profile 的 `dsh.profile.bundles` 含本包(**追加在末尾**,保持既有顺序)。
|
|
242
|
+
* 为什么这是主通道:bundle 声明了 patch 层 ⇒ 装载器自动合并本包的 cordis.patch.yml,
|
|
243
|
+
* 与手写那一行语义等价,但用户不用改 YAML,升级也不会漏。
|
|
244
|
+
*/
|
|
245
|
+
export function patchProfileBundles(profileDir, log = () => {}, dryRun = false) {
|
|
246
|
+
const file = nodePath.join(profileDir, "package.json");
|
|
247
|
+
if (!nodeFs.existsSync(file)) return { changed: false, reason: "没有 profile/package.json" };
|
|
248
|
+
let pkg;
|
|
249
|
+
try {
|
|
250
|
+
pkg = JSON.parse(nodeFs.readFileSync(file, "utf8"));
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return { changed: false, reason: `profile/package.json 不是合法 JSON(${error.message})` };
|
|
253
|
+
}
|
|
254
|
+
const dsh = pkg.dsh && typeof pkg.dsh === "object" ? pkg.dsh : {};
|
|
255
|
+
const profile = dsh.profile && typeof dsh.profile === "object" ? dsh.profile : {};
|
|
256
|
+
const bundles = Array.isArray(profile.bundles) ? profile.bundles : [];
|
|
257
|
+
if (bundles.includes(PKG_NAME)) return { changed: false, reason: "已在 dsh.profile.bundles" };
|
|
258
|
+
const next = [...bundles, PKG_NAME];
|
|
259
|
+
if (dryRun) {
|
|
260
|
+
log(` [dry] profile/package.json 的 dsh.profile.bundles 会追加 ${PKG_NAME}(现 ${bundles.length} 项)`);
|
|
261
|
+
return { changed: true, dryRun: true };
|
|
262
|
+
}
|
|
263
|
+
nodeFs.copyFileSync(file, `${file}.bak-before-gokifu-${Date.now()}`);
|
|
264
|
+
pkg.dsh = { ...dsh, profile: { ...profile, bundles: next } };
|
|
265
|
+
nodeFs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
266
|
+
log(` ✓ profile/package.json 的 dsh.profile.bundles 已追加 ${PKG_NAME}(${bundles.length} → ${next.length} 项)`);
|
|
267
|
+
return { changed: true, bundles: next };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* **bundle 优先**:先把本包写进 `dsh.profile.bundles`,再删掉旧的手工 gokifu-ui 行(带备份)。
|
|
272
|
+
* 顺序不能反:先加 bundle 再删手工行,任何一步中断都**不会**出现「两条通道都没有」的空窗。
|
|
273
|
+
*/
|
|
274
|
+
export function ensureBundleChannel(profileDir, log = () => {}, dryRun = false) {
|
|
275
|
+
const bundle = patchProfileBundles(profileDir, log, dryRun);
|
|
276
|
+
if (bundle.changed === false) log(` · UI(bundle 通道):${bundle.reason}`);
|
|
277
|
+
const manual = removeManualUiRow(profileDir, log, dryRun);
|
|
278
|
+
if (manual.changed === false && manual.reason !== "没有手工 UI 行") log(` · 手工行:${manual.reason}`);
|
|
279
|
+
return { bundle, manual };
|
|
280
|
+
}
|
|
281
|
+
|
|
161
282
|
export function run(argv = process.argv.slice(2)) {
|
|
162
283
|
const opts = parseArgs(argv);
|
|
163
284
|
const { dryRun, profile, dshHome, log } = opts;
|
|
@@ -234,6 +355,11 @@ export function run(argv = process.argv.slice(2)) {
|
|
|
234
355
|
samePath(spec.target.realPath, PROJECT_DIR);
|
|
235
356
|
if (correct) {
|
|
236
357
|
log(` · profile/package.json 的 ${PKG_NAME} 已指向本目录:${declared}`);
|
|
358
|
+
} else if (spec !== null && spec.kind === "other") {
|
|
359
|
+
// ★ task-118:用户把手上的依赖写成 registry / git / tarball 规格时**不许改写成 link**。
|
|
360
|
+
// 那是用户的选择(例如从 npm 装正式版),改写会让「装的是哪个」不可追溯;
|
|
361
|
+
// 本地调试请自己显式写 file:/link:,安装器不替用户做这个决定。
|
|
362
|
+
log(` · profile/package.json 的 ${PKG_NAME} 是 ${declared} 规格(非 file:/link:)—— 保持原样,不改写成 link`);
|
|
237
363
|
} else if (dryRun) {
|
|
238
364
|
log(` [dry] 会${declared === null ? "加" : "修"} profile/package.json 的 ${PKG_NAME}:${declared ?? "(无)"} → ${want}`);
|
|
239
365
|
} else {
|
|
@@ -299,9 +425,11 @@ export function run(argv = process.argv.slice(2)) {
|
|
|
299
425
|
log(` ✓ ${t.file} 已换后端(备份 ${nodePath.basename(backup)})`);
|
|
300
426
|
}
|
|
301
427
|
|
|
302
|
-
/* ③ 挂 UI 的 host
|
|
303
|
-
|
|
304
|
-
|
|
428
|
+
/* ③ 挂 UI 的 host 半:**bundle 通道优先**(本包自带 cordis.patch.yml,声明在 dsh.bundle.patch)。
|
|
429
|
+
旧做法是手改 profile 的 cordis.patch.yml 加 `- insert: {id: gokifu-ui}`;那条路已废弃 ——
|
|
430
|
+
升级后手工行与 bundle 会同时挂载,两个 host 半抢同一个 /gokifu/api 前缀,启动直接失败。
|
|
431
|
+
⇒ 这里把本包写进 dsh.profile.bundles,并**备份后删除**旧手工行,保证任何时候只有一条通道。 */
|
|
432
|
+
ensureBundleChannel(profileDir, (m) => log(m), dryRun);
|
|
305
433
|
|
|
306
434
|
if (dryRun) {
|
|
307
435
|
log("\n这是预演,什么都没改。真装去掉 --dry-run。");
|
package/src/rooms/hot-inject.mjs
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { failureEdges, sha } from "./kifu-model.mjs";
|
|
28
|
-
import { renderLedger } from "./ledger.mjs";
|
|
28
|
+
import { renderLedger, renderMinimalLedger } from "./ledger.mjs";
|
|
29
29
|
|
|
30
30
|
/** 限流:两次注入至少隔这么多事件。 */
|
|
31
31
|
export const INJECT_MIN_EVENTS = 40;
|
|
@@ -47,6 +47,17 @@ export function messageText(message) {
|
|
|
47
47
|
.join("\n");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* ★ OPT-EMPTY(task-130):这张图有没有「值得注入」的最小状态?
|
|
52
|
+
* 空图(连 root / agent / constraints 都没有)⇒ 仍然 skip,不拿一行空壳占位。
|
|
53
|
+
*/
|
|
54
|
+
export function hasLedgerState(graph) {
|
|
55
|
+
const nodes = graph?.nodes ?? {};
|
|
56
|
+
if ((graph?.rootUserNodeIds ?? []).some((id) => nodes[id])) return true;
|
|
57
|
+
if (Object.values(nodes).some((n) => n?.kind === "agent" && (n.goal || n.worldHash))) return true;
|
|
58
|
+
return Object.values(nodes).some((n) => (n.constraints ?? []).length > 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
/**
|
|
51
62
|
* 热上下文的「骨架摘要」:只由失败边 + 根目标 + 约束决定。
|
|
52
63
|
* 不含 CURRENT PATH / CURRENT GOAL —— 它们每一步都变,用整段文本做 digest
|
|
@@ -187,14 +198,23 @@ export function decideInjection({
|
|
|
187
198
|
// ★ 2026-10-29(三份评估共同的 P0-2):压缩「连续失败」必须被看见。
|
|
188
199
|
// 一条失败边都没有时也要注入 —— 「没有失败边 + 压缩坏了」才是最危险的组合。
|
|
189
200
|
const warning = foldFailureWarning(state);
|
|
190
|
-
|
|
201
|
+
// ★ OPT-EMPTY(task-130):零失败边 **不再整段跳过** —— 26% 的真实会话没有任何失败边,
|
|
202
|
+
// 旧逻辑让它们连根目标/约束都拿不到。现在仍然走 renderLedger(零失败时它输出
|
|
203
|
+
// ROOT / CONSTRAINTS / CURRENT GOAL / WORKING + 一行 NO_FAILED_ATTEMPTS_YET);
|
|
204
|
+
// 只有**空图**(无 root / 无 agent / 无约束)才 skip。
|
|
205
|
+
// 限流与 digest 全部沿用既有规则:digest = 根目标 + 约束(零失败时天然稳定),
|
|
206
|
+
// 同源且清单还在 ⇒ same-digest skip;被 fold 遮蔽 ⇒ lostToFold 补回。
|
|
207
|
+
const zeroFailure = failureEdges(graph).length === 0 && warning === "";
|
|
208
|
+
if (zeroFailure && !hasLedgerState(graph)) return { decision, action: "skip", reason: "no-ledger" };
|
|
191
209
|
|
|
192
|
-
const ledger =
|
|
193
|
-
maxTokens: config.hotLedgerMaxTokens ?? 500
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
210
|
+
const ledger = zeroFailure
|
|
211
|
+
? renderMinimalLedger(graph, { maxTokens: config.hotLedgerMaxTokens ?? 500 })
|
|
212
|
+
: renderLedger(graph, {
|
|
213
|
+
maxTokens: config.hotLedgerMaxTokens ?? 500,
|
|
214
|
+
markers: config.hotLedgerMarkers,
|
|
215
|
+
treeDir: sessionDir,
|
|
216
|
+
traceRoot: config.hotLedgerTraceRoot,
|
|
217
|
+
});
|
|
198
218
|
// ★ P7:消费 fallback(over-budget 记 warn + overBudgetBy;impossible 记 error),并落盘给 doctor。
|
|
199
219
|
try {
|
|
200
220
|
onBudget(ledger, session);
|
package/src/rooms/ledger.mjs
CHANGED
|
@@ -768,6 +768,49 @@ export function renderLedger(g, { maxTokens = 500, tailFailures = 20, markers =
|
|
|
768
768
|
};
|
|
769
769
|
}
|
|
770
770
|
|
|
771
|
+
/**
|
|
772
|
+
* ★ OPT-EMPTY(task-130):**零失败边**会话的最小注入内容。
|
|
773
|
+
*
|
|
774
|
+
* 背景:真实 167 个会话里 42 个(25.1%)没有任何失败边 —— 旧版 `decideInjection` 直接
|
|
775
|
+
* `skip("no-ledger")`,这些会话连根目标/约束都拿不到。但它们**不是空的**:42/42 有 root,
|
|
776
|
+
* 32/42 有 constraints(见 ws-opt-empty-ledger/REPORT.md 的只读统计)。
|
|
777
|
+
*
|
|
778
|
+
* 为什么不用 `renderLedger` 的最小档:那档仍会带 ROOT 原文,实测 42 个零失败会话
|
|
779
|
+
* avg 386 tok / max 497 —— 对「没有任何失败记忆」的会话是纯噪声。
|
|
780
|
+
* 这里只给三行事实(仍是既有的 `<chess-ledger>` 包裹,不是新的消息格式):
|
|
781
|
+
* GOAL: … (有 goal 用 goal,否则用 root 原话)
|
|
782
|
+
* CONSTRAINTS: N (数量,不展开原文)
|
|
783
|
+
* NO_FAILED_ATTEMPTS_YET
|
|
784
|
+
*
|
|
785
|
+
* @returns {{text:string, tokens:number, level:number, omittedEdges:number, failureCount:number, shownFailures:number, fallback:string, budgetMax:number, overBudgetBy:number}}
|
|
786
|
+
*/
|
|
787
|
+
export function renderMinimalLedger(g, { maxTokens = 500 } = {}) {
|
|
788
|
+
const root = g?.rootNode ?? (g?.rootUserNodeIds ?? []).map((id) => g?.nodes?.[id]).filter(Boolean)[0];
|
|
789
|
+
const latest = latestAgentNode(g);
|
|
790
|
+
const goalText = clip(String(latest?.goal ?? root?.text ?? "").replace(/\s+/g, " ").trim(), 120);
|
|
791
|
+
const constraints = root?.constraints ?? [];
|
|
792
|
+
const lines = ["<chess-ledger>"];
|
|
793
|
+
if (goalText) lines.push(`GOAL: ${goalText}`);
|
|
794
|
+
lines.push(`CONSTRAINTS: ${constraints.length}`);
|
|
795
|
+
lines.push("NO_FAILED_ATTEMPTS_YET");
|
|
796
|
+
lines.push("</chess-ledger>");
|
|
797
|
+
const text = lines.join("\n");
|
|
798
|
+
const tokens = estimateTokens(text);
|
|
799
|
+
const over = Math.max(0, tokens - maxTokens);
|
|
800
|
+
return {
|
|
801
|
+
text,
|
|
802
|
+
tokens,
|
|
803
|
+
level: 5,
|
|
804
|
+
omittedEdges: 0,
|
|
805
|
+
failureCount: 0,
|
|
806
|
+
shownFailures: 0,
|
|
807
|
+
fallback: over > 0 ? "over-budget" : "none",
|
|
808
|
+
budgetMax: maxTokens,
|
|
809
|
+
overBudgetBy: over,
|
|
810
|
+
minimal: true,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
771
814
|
/**
|
|
772
815
|
* L4/L5 的裁决(C-17)。两个兜底档都可能越过预算,选的时候按三层优先:
|
|
773
816
|
* 1. 预算内优先(`tokens <= maxTokens`);
|
package/src/rooms/verify.mjs
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* 现在的分层(顺序即优先级):
|
|
14
14
|
* ① isError(宿主标记) → failure(任何工具)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* ①.b local-mutate(本地状态写入类) → unverified + kind:"none",**不带 weak**
|
|
16
|
+
* ② 末条非空行**精确**是 [exit code: N]:N≠0 → failure;N=0 → success(**最终裁决**)
|
|
17
|
+
* ②.b shell 但没有精确退出码 → unverified(**不猜**:输出可以是任何东西)
|
|
17
18
|
* ③ 非 shell:N passed / N failing → success / failure
|
|
18
19
|
* ④ 非 shell:行首错误行 → failure
|
|
19
20
|
* ④.b 只读工具:行首硬错误(ENOENT…) → failure(注释里的 EACCES 不算)
|
|
@@ -26,10 +27,15 @@
|
|
|
26
27
|
* 为什么改这里而不是新加判据:`verify.kind === "read"` 就是 classify 自己给出的
|
|
27
28
|
* 「只读工具、没抓到外部验证信号」的判定(toolClass 的显式表 + MCP query/mutate 词形),
|
|
28
29
|
* 旧图里已经落盘了这个字段 —— 复用它,就不必在渲染阶段重新猜工具名。
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
*
|
|
31
|
+
* ★ OPT-VERIFY(task-124):上面那条「已知局限」已修 —— `todo_write` / `update_goal` /
|
|
32
|
+
* `create_goal` / `hindsight_ingest_document` / `team_task_*` / `send_message` / `schedule_*` /
|
|
33
|
+
* `job_kill` / `spawn_teammate` / `subagent` 这些**会改本地状态**的工具,已从只读表拆到
|
|
34
|
+
* `LOCAL_MUTATE_TOOLS`(toolClass → `local-mutate`),classify 给
|
|
35
|
+
* `result:"unverified"` + `kind:"none"` 且**不带 weak** ⇒ 不再进 WORKING 路径。
|
|
36
|
+
* `weak:"read"` 现在只属于真正只读的工具(read / grep / web_fetch / list_* 等)。
|
|
37
|
+
* 兼容:旧图里这些工具已落盘 `kind:"read"` + `N chars`,`weakKindOf` 仍按旧形态识别它们
|
|
38
|
+
* (历史数据不改写;新产生的边不再这样标)。
|
|
33
39
|
*
|
|
34
40
|
* 纯函数,零依赖,可单测。
|
|
35
41
|
*/
|
|
@@ -37,12 +43,37 @@
|
|
|
37
43
|
const READ_ONLY_TOOLS = new Set([
|
|
38
44
|
"read", "read_file", "glob", "grep", "ls", "list", "list_dir", "find",
|
|
39
45
|
"web_search", "web_fetch", "read_page", "read_image", "library_search",
|
|
40
|
-
"
|
|
41
|
-
"get_goal",
|
|
46
|
+
"todo_read", "todo_list", "ask_user_question",
|
|
47
|
+
"get_goal",
|
|
42
48
|
"skill", "load_workspace_dependencies", "list_agents", "list_subagent_models",
|
|
43
49
|
"job_list", "job_output", "team_task_list", "team_task_get",
|
|
44
50
|
]);
|
|
45
51
|
|
|
52
|
+
/**
|
|
53
|
+
* ★ OPT-VERIFY(task-124):**本地状态写入类**工具 —— 既不是只读,也不是仓库写入。
|
|
54
|
+
*
|
|
55
|
+
* 它们不产生外部验证信号(没有 exit code / 测试计数 / diff),但**确实改了本地状态**
|
|
56
|
+
* (todo 列表、goal、记忆库、任务板、消息、定时任务、子 agent、job)。
|
|
57
|
+
* 旧版把它们混在 `READ_ONLY_TOOLS` 里 ⇒ 走 ⑥ 兜底分支拿到 `weak:"read"`,
|
|
58
|
+
* 于是热清单的 WORKING 路径会把「写了一条 todo」当成「读过一眼的弱成功」——
|
|
59
|
+
* 25 个最新会话 641 条只读结果里 48 条(7.5%)正是这种形态(S2a 注释里已记为已知局限)。
|
|
60
|
+
*
|
|
61
|
+
* 现在的语义:`toolClass` 返回 **`local-mutate`**,`classify` 给
|
|
62
|
+
* `result:"unverified"` + `kind:"none"`,**不带任何 weak** —— 不进 WORKING 路径,
|
|
63
|
+
* 也不进 success / failureEdges / facts / tewari。
|
|
64
|
+
*
|
|
65
|
+
* ⚠ 与 `WRITE_TOOLS` 的区别:`WRITE_TOOLS` 是**仓库写入**(write / edit / present …),
|
|
66
|
+
* 它们的回执本身算成功信号(`kind:"write"` / `kind:"diff"`),**本刀一个字不改**。
|
|
67
|
+
*/
|
|
68
|
+
const LOCAL_MUTATE_TOOLS = new Set([
|
|
69
|
+
"todo_write", "update_goal", "create_goal", "exit_plan_mode",
|
|
70
|
+
"hindsight_ingest_document", "hindsight_capture_initiative",
|
|
71
|
+
"team_task_create", "team_task_update", "team_task_delete",
|
|
72
|
+
"send_message", "spawn_teammate", "subagent", "subagent_fork", "subagent_role",
|
|
73
|
+
"schedule_create", "schedule_update", "schedule_delete",
|
|
74
|
+
"job_kill", "job_start",
|
|
75
|
+
]);
|
|
76
|
+
|
|
46
77
|
const WRITE_TOOLS = new Set([
|
|
47
78
|
"write", "edit", "write_file", "edit_file", "apply_patch", "multi_edit",
|
|
48
79
|
"str_replace", "create_file", "delete_file", "notebook_edit", "present",
|
|
@@ -58,6 +89,8 @@ export function toolClass(name) {
|
|
|
58
89
|
const n = raw.toLowerCase();
|
|
59
90
|
if (SHELL_TOOLS.has(n)) return "shell";
|
|
60
91
|
if (WRITE_TOOLS.has(n)) return "write";
|
|
92
|
+
// ★ 顺序要紧:local-mutate 必须排在 read 之前,否则这些工具又会被当只读。
|
|
93
|
+
if (LOCAL_MUTATE_TOOLS.has(n)) return "local-mutate";
|
|
61
94
|
if (READ_ONLY_TOOLS.has(n)) return "read";
|
|
62
95
|
const isMcp = n.startsWith("mcp__") || n.includes("__") || n.startsWith("hindsight_") || n.startsWith("bili_");
|
|
63
96
|
if (isMcp) return MUTATE_RE.test(n) && !QUERY_RE.test(n) ? "mcp-write" : "mcp-read";
|
|
@@ -75,7 +108,28 @@ const ERR_LINE_RE = /^\s*(?:bash: line \d+: )?(?:Error:|error:|ERROR:|Exception\
|
|
|
75
108
|
/** 行首硬错误(只读工具也认——读一个不存在的文件)。 */
|
|
76
109
|
const HARD_LINE_RE = /^\s*(?:ENOENT|EACCES|EPERM|ETIMEDOUT|ECONNREFUSED|No such file|Permission denied|Error:|error:|Traceback|Cannot find module|MODULE_NOT_FOUND)/;
|
|
77
110
|
|
|
78
|
-
|
|
111
|
+
/**
|
|
112
|
+
* ★ OPT-VERIFY(task-124):退出码 marker 必须**独占最后一条非空行**(允许行内前后空白)。
|
|
113
|
+
*
|
|
114
|
+
* 旧写法 `EXIT_RE.exec(body.slice(-200))` 双向都有错:
|
|
115
|
+
* ① 只搜末尾 200 字符 ⇒ 标记之后还有别的输出时,**真退出码直接丢失**(判成 no-exit);
|
|
116
|
+
* ② 只要落在末尾 200 字符内就命中 ⇒ `cat` 出来的源码里写着 `const text = "[exit code: 1]" + …`
|
|
117
|
+
* 这种**正文子串**会被误当成命令退出码(独立复核活证据 e8)。
|
|
118
|
+
* 现在的判据只有一条:末条非空行整行等于 `[exit code: N]`;否则一律 no-exit(**不猜**)。
|
|
119
|
+
*/
|
|
120
|
+
const EXIT_LINE_RE = /^\s*\[exit code:\s*(-?\d+)\]\s*$/;
|
|
121
|
+
|
|
122
|
+
/** 末条非空行的精确退出码;不是精确 marker ⇒ `null`。 */
|
|
123
|
+
export function lastLineExitCode(body) {
|
|
124
|
+
const lines = String(body ?? "").split("\n");
|
|
125
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
126
|
+
if (lines[i].trim().length === 0) continue;
|
|
127
|
+
const m = EXIT_LINE_RE.exec(lines[i]);
|
|
128
|
+
return m === null ? null : Number(m[1]);
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
79
133
|
const TEST_PASS_RE = /(\d+)\s+(?:passed|passing|tests? passed|个?通过)|all tests passed|0 failing/i;
|
|
80
134
|
const TEST_FAIL_RE = /\b([1-9]\d*)\s+(?:failed|failing|个?失败|errors?)\b/i;
|
|
81
135
|
const DIFF_RE = /(\d+)\s*(?:insertions?\(\+\)|deletions?\(-\))/;
|
|
@@ -224,6 +278,7 @@ export function classify({ tool, text, isError = false }) {
|
|
|
224
278
|
const body = String(text ?? "");
|
|
225
279
|
const cls = toolClass(tool);
|
|
226
280
|
const readOnly = cls === "read" || cls === "mcp-read";
|
|
281
|
+
const localMutate = cls === "local-mutate";
|
|
227
282
|
|
|
228
283
|
if (isError) {
|
|
229
284
|
return {
|
|
@@ -233,16 +288,29 @@ export function classify({ tool, text, isError = false }) {
|
|
|
233
288
|
};
|
|
234
289
|
}
|
|
235
290
|
|
|
291
|
+
// ★ OPT-VERIFY(task-124):本地状态写入类工具(todo_write / update_goal / send_message /
|
|
292
|
+
// team_task_update / hindsight_ingest_document …)没有外部验证信号,也**不是**「读了一眼」。
|
|
293
|
+
// ⇒ unverified + kind:"none" + **不带 weak**:不许进 WORKING 路径,更不许当进展证据。
|
|
294
|
+
// 放在 isError 之后、shell 之前:宿主报错仍然是 failure(isError 优先级最高,不变)。
|
|
295
|
+
if (localMutate) {
|
|
296
|
+
return {
|
|
297
|
+
result: "unverified",
|
|
298
|
+
verify: { kind: "none", summary: body ? `${body.length} chars` : "empty" },
|
|
299
|
+
errorSnippet: "",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
236
303
|
// ★ 退出码只对 shell 有意义。真实踩坑:`read` 读一个包含 `[exit code: 1]` 字样的测试文件,
|
|
237
304
|
// 旧逻辑会把那行字当成「这个 read 的退出码」→ 整条 read 被判 failure。
|
|
238
305
|
// read/write/mcp 工具的输出是**内容**,不是执行结果。
|
|
239
306
|
if (cls === "shell") {
|
|
240
307
|
// P1-7:退出码只看**末尾**。DSH 把 `[exit code: N]` 追加在结果最后;
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
308
|
+
// P1-7 → OPT-VERIFY(task-124):判据从「末尾 200 字符里搜到 marker」改成
|
|
309
|
+
// **末条非空行整行等于 `[exit code: N]`**。旧写法两头都错:标记后面还有输出时真退出码丢失;
|
|
310
|
+
// 正文里恰好出现 `[exit code: 1]` 子串时误判失败(复核活证据 e8)。
|
|
311
|
+
// 不是精确 marker ⇒ 走下面的 no-exit(**不猜**)。
|
|
312
|
+
const code = lastLineExitCode(body);
|
|
313
|
+
if (code !== null) {
|
|
246
314
|
if (code !== 0) {
|
|
247
315
|
return { result: "failure", verify: { kind: "exit", summary: `exit ${code}` }, errorSnippet: errorSnippet(body) };
|
|
248
316
|
}
|
|
@@ -322,4 +390,4 @@ function firstLine(text) {
|
|
|
322
390
|
return line.trim().slice(0, 160);
|
|
323
391
|
}
|
|
324
392
|
|
|
325
|
-
export const _internal = { READ_ONLY_TOOLS, WRITE_TOOLS, SHELL_TOOLS, firstLine, HARD_ERR_RE, ERR_LINE_RE, HARD_LINE_RE };
|
|
393
|
+
export const _internal = { READ_ONLY_TOOLS, LOCAL_MUTATE_TOOLS, WRITE_TOOLS, SHELL_TOOLS, firstLine, lastLineExitCode, HARD_ERR_RE, ERR_LINE_RE, HARD_LINE_RE };
|