dsh-engram 0.8.8 → 0.9.1

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.
@@ -0,0 +1,246 @@
1
+ # 交接 · G6 第六轮(2026-10-28 · 已跑完)
2
+
3
+ > 阅读顺序:本文 → `docs/G6-quality-ab.zh.md` §13(方法/十二道缝/四次事故/两份结果)→ `eval/quality-ab.mjs`。
4
+
5
+ ## 0. 一句话
6
+
7
+ **第六轮跑完了:40/40 行,20 对配对,Δ = +90.0pp,G6 PASS,污染 0 行。**
8
+ `p-a`(带 `dsh-engram` 0.8.8)20/20,`p-b`(裸)2/20。跑在本机 vLLM 端点
9
+ (`local-vllm-61` / `deepseek-v4.1-flash-1017`,与官方开源同款),**两臂同端点**。
10
+
11
+ 第一次尝试走的是官方付费端点,跑到 13/20 对时账户余额用尽(`QUOTA`,¥0.19),那 7 行按 `infra`
12
+ 排除、不当失败算。那份产物(`eval/g6/runs/r6-neutral`,+95.0pp)留在仓库里当**独立复现**:
13
+ 同一个模型 id、两套服务栈、两次跑批,方向一致、`p-a` 一对没输。
14
+
15
+ 详见 `docs/G6-quality-ab.zh.md` §13.4(两份结果怎么读)、§13.3(四次事故)、§13.6(围栏移植)、
16
+ §13.7(换端点必须整轮重跑)。**下一轮别再用付费端点热身**。
17
+
18
+ ## 1. 今天在仓库里留下了什么
19
+
20
+ | commit | 一句话 | 为什么必须有 |
21
+ |---|---|---|
22
+ | `3f62aa9` | 种子键用 `realpath` 后的写法 | 不修的话**治疗臂根本没被种上记忆**(`--out` 写 `/tmp/…`,插件按 `getcwd()` 记 `/private/tmp/…`),两臂跑出来一样,Δ≈0 还看不出来 |
23
+ | `573cb64` | `--resume` 真的能复用 | 原来 resume 块有 TDZ 异常被 `catch` 吞掉,打印成"没有可续的行",整批重跑 |
24
+ | `bf65c4b` | 每行原子落盘 + 空盘预检 | 第六轮第一次在 16/40 行时 ENOSPC,**1.7 小时蒸发**;现在最坏也只丢当前那一行 |
25
+ | `4b8ce6f` | 祖先目录只给 `file-read-metadata`;在跑产物搬到 `--log-dir` | 原来臂能 `ls` 实验根,看到 `runs.json`/`arm-a`/`arm-b`,10/20 的控制会话据此推理"这是评测矩阵" |
26
+ | `e510929` | 矩阵开始前 scrub 共享 home | 两臂共用 `DSH_HOME`,`recall-injections.json` 写着另一臂的 workspace,`ledger.json` 按路径记 `injectedChars`,7/7 控制日志引用了 `profiles/ab-engram` |
27
+ | (文档) | `docs/G6-quality-ab.zh.md` §13:方法、十二道缝、四次事故、§13.4 两份结果 | 每个结论都指到证据;两份产物分别对应当钱/免费两个端点 |
28
+ | `371be60` | G2 分母改成"装了 engram 的会话";run 根改成 `runs/<sha1[0:8]>` | 旧分母把 3 个没装插件的会话算进激活率(50% 假 FAIL);run 路径里写着臂名/任务名/seed |
29
+ | `a27e237` | 一臂一个 `DSH_HOME`;语料 4 → 9 任务 | 共享 home 是结构性的泄漏(`scrubSharedHome` 只能清上一轮);5 个新任务都要求"只能被告知"的约定 |
30
+ | `26d143d` | 语料自检:每个 oracle 双向验证 + 任务与记忆对齐 | 没有这道检查,一个不可满足的 oracle 会让两臂都 0/0,闸门报"两臂持平 PASS";它当场抓出 `check.mjs` 在测试环境里静默跑空 |
31
+
32
+ 测试:`npm test` → 508/508(含 `test/quality-ab` 18 条、`test/g6-sandbox` 4 条、`test/gate-audit` 6 条、
33
+ `test/g6-corpus` 2 条)。
34
+ (后者是新加的围栏回归:node 能在围栏里启动、ws 可读、实验根不可枚举、`runs.json` 与另一臂打不开;
35
+ 把规则改回 `file-read*` 它会红)。
36
+
37
+ ## 2. 停的时候的状态(本机,不随仓库走)
38
+
39
+ | 东西 | 位置 | 是否迁移 |
40
+ |---|---|---|
41
+ | 已跑的 10 行 | `eval/g6/runs/r6-partial/runs.json`(已提交) | ✅ 带走 |
42
+ | 那 10 行的 run.log / check 输出 | `/tmp/g6/r6g-logs/`(596K) | ❌ 不迁移,需要证据就把需要的几份捞出来 |
43
+ | 10 个 workspace | `/tmp/g6r16/`(260K) | ❌ 不迁移 |
44
+ | 实验 home | `~/.dsh/quality-ab/`:profile `p-a`(装了 engram **0.8.2**)/ `p-b`(裸) | ❌ 新机器重建(§3.3) |
45
+
46
+ 停之前没有任何后台进程在跑(`pgrep -f "quality-ab.mjs run"` 为空)。
47
+
48
+ ## 3. 在另一台机器上继续
49
+
50
+ ### 3.1 前置
51
+
52
+ - **macOS**。围栏用的是 `sandbox-exec`/seatbelt;Linux 得换 bwrap/firejail,工具里没有这条路。
53
+ - node 22(`node -p process.version`),dsh 0.2.x(`dsh --version`)。
54
+ - `~/.dsh/.credentials.yaml` 里有 `DEEPSEEK_API_KEY`(harness 自己读进子进程环境,不落日志)。
55
+
56
+ ### 3.2 代码与自检
57
+
58
+ ```bash
59
+ git pull && npm install
60
+ node --test test/quality-ab.test.mjs test/g6-sandbox.test.mjs # 11 个,应全绿
61
+ ```
62
+
63
+ ### 3.3 建两臂(**中性名**,不要再用 `ab-engram`/`ab-bare`)
64
+
65
+ **一臂一个 `DSH_HOME`**(2026-10-28 起,缝 #13):每臂的 home 是 `--home` 下的 `arms/<arm>/`,
66
+ 插件必须装进**本臂**的 home——装到上一层会造出一个没人用的第三个 profile,治疗臂悄悄变成裸的,
67
+ Δ 塌成 0,而闸门会报"两臂持平 PASS"。
68
+
69
+ ```bash
70
+ H=$HOME/.dsh/quality-ab-r7
71
+ node eval/quality-ab.mjs prepare --arm on=p-a,off=p-b --home "$H"
72
+ DSH_HOME="$H/arms/p-a" dsh plugin --profile p-a add "$PWD/dsh-engram-8.8.tgz" # 版本按 §3.3 的决定
73
+ cp ~/.dsh/quality-ab/profiles/p-a/cordis.patch.yml "$H/arms/p-a/profiles/p-a/" # 端点/模型
74
+ cp ~/.dsh/quality-ab/profiles/p-b/cordis.patch.yml "$H/arms/p-b/profiles/p-b/"
75
+ DSH_HOME="$H/arms/p-a" dsh --profile p-a --dump-config | grep -A2 'id: engram' # 有
76
+ DSH_HOME="$H/arms/p-b" dsh --profile p-b --dump-config | grep -c engram # 应为 0
77
+ ```
78
+
79
+ **要先做的一个决定:插件版本。**
80
+
81
+ - 本机 `p-a` 里是 **0.8.2**(从第五轮的 `ab-engram` 整个拷过来的),仓库当前是 **0.8.8**。
82
+ - 想和第五轮(Δ=+90pp)**可比** → 装 0.8.2。
83
+ - 想让这轮数字**给 v0.8 门背书** → 装 0.8.8,并在 §13.4 写明"与第五轮不是同一版本,不可直接比"。
84
+ - 我的建议是后者(门存在的意义是给当前版本出数),但**无论选哪个都要写进 §13.4**。
85
+
86
+ ### 3.4 冒烟(约 2 分钟,别省)
87
+
88
+ ```bash
89
+ node eval/quality-ab.mjs run \
90
+ --tasks <只含 codec 一件任务的 tasks.json> \
91
+ --workspace eval/fixtures/ledger-kata-blind \
92
+ --arm p-a,p-b --seeds 1 \
93
+ --out /tmp/g6-smoke --home "$HOME/.dsh/quality-ab-r7" \
94
+ --seed-memory eval/g6/seed-memory-blind.json \
95
+ --sandbox --control-first --log-dir /tmp/g6-smoke-logs
96
+ ```
97
+
98
+ 期望:`p-b` fail、`p-a` PASS(本机今天 13:15 的冒烟正是这个结果,80.6s vs 187.6s)。
99
+ **若 `p-a` 也 fail,先怀疑种子键**(`3f62aa9`):看 `p-a` 的 run.log 里有没有出现 `[seed-0x]`;
100
+ 没有就是记忆没种上,别往下跑。
101
+
102
+ ### 3.5 跑批
103
+
104
+ ```bash
105
+ # 第七轮(2026-10-28 起):9 任务 × 2 臂 × 2 seed = 18 对,本地 vLLM 端点
106
+ node eval/quality-ab.mjs run \
107
+ --tasks "$PWD/eval/g6/tasks-blind.json" \
108
+ --workspace "$PWD/eval/fixtures/ledger-kata-blind" \
109
+ --arm p-a,p-b --seeds 2 \
110
+ --out "$PWD/eval/g6/runs/r7-local" \
111
+ --home "$HOME/.dsh/quality-ab-r7" \
112
+ --seed-memory "$PWD/eval/g6/seed-memory-blind.json" \
113
+ --sandbox --control-first --min-pairs 10 \
114
+ --log-dir "$HOME/.dsh/quality-ab-logs/r7-local" \
115
+ --resume # 断了就加这条,复用已完成的行
116
+ ```
117
+
118
+ 注意 `--log-dir` 会接住**中途**的 `runs.json`(臂读不到它;`--out` 在围栏里被挂空,
119
+ 中途写在那儿就等于把答案放在臂旁边),跑完的最终产物仍落在 `--out/runs.json` + `REPORT.md`。
120
+
121
+ - **想复用已跑的 10 行**:把 `eval/g6/runs/r6-partial/` 里的 `runs.json` 拷到 `--out` 目录,加 `--resume`。
122
+ 注意那 10 行是"插件 0.8.2 + 本机环境"下跑出来的,混进 0.8.8 的一批里**不是同一个实验**;
123
+ 除非你决定沿用 0.8.2,否则**建议丢掉、整批重跑**(这就是我把它放进 `r6-partial` 而不是 `r6-neutral` 的原因)。
124
+ - 单 run 今天 2–8 分钟(endpoint 波动很大),40 run ≈ **3–4 小时**。
125
+ - 中途断了直接加 `--resume` 重跑同一条命令:现在每行都落盘,会打印"复用了 N 行"。
126
+ - 磁盘 < 512MB 时工具会拒绝启动(`AB_MIN_FREE_MB` 可调)。今天那次 ENOSPC 与本项目无关
127
+ (`$TMPDIR` 里一个外部进程写的 2.5GB Instruments trace),整轮产物只有 1.7MB。
128
+
129
+ ### 3.6 出数与填文档
130
+
131
+ 跑完命令自己会打印 Δ 与门判定(预注册留一法 `Δ ≥ −2pp`,配对下限 10)。要填的:
132
+
133
+ 1. `docs/G6-quality-ab.zh.md` **§13.4**:表(`p-a` / `p-b` 的 pass 与平均耗时)+ 逐任务控制臂 pass 数
134
+ + "能说什么/不能说"(照 §12 的写法,别外推幅度)。
135
+ 2. `docs/GATES.zh.md` 的 G6 行:
136
+
137
+ ```bash
138
+ npm run bench:ledger # 拿 G7 的数字
139
+ node eval/gate-audit.mjs --days 14 --md --out docs/GATES.zh.md \
140
+ --g6 eval/g6/runs/r6-neutral/runs.json --g7 <bench 输出里那个百分比> \
141
+ --api 'http://127.0.0.1:3080/api/dsh-engram?workspace=<repo 绝对路径>'
142
+ ```
143
+
144
+ `--api` 只是为了让 G5(渲染块大小)能算;没有宿主就省掉 G5。
145
+
146
+ ### 3.7 收尾
147
+
148
+ - ESR 任务 `tsk_81`("G6 第六轮:中性臂名复跑 20 对配对")现在是 ACTIVE、三证全缺;
149
+ 跑完用 artifact(`eval/g6/runs/r6-neutral/runs.json`)+ evaluation(Δ 与门判定)+ memory_ref 关它。
150
+ - 存一条 memory:本轮 Δ、版本、以及"六个缝"里新学到的那条。
151
+
152
+ ## 4. 这个评测工具长什么样(只需记住三件事)
153
+
154
+ 1. **判据是命令不是模型**:每个任务一条 `check`(bash 退出码),不引入第二个模型打分。
155
+ 2. **两臂在同一台机上跑,靠 seatbelt 隔离**:`--sandbox` 默认收禁 `/tmp` 和 `~`,只放行本臂 ws、
156
+ 本臂 profile、node、凭据;另一臂 profile 再禁一次;`--control-first` 保证控制臂先跑完
157
+ (后跑的控制臂能读到治疗臂的 run.log,第二轮真发生过)。
158
+ 3. **臂名不能出现在臂能看见的任何地方**:目录叫 `arm-a`/`arm-b`,profile 叫 `p-a`/`p-b`,
159
+ 映射只写在 artifact 的 `armLabels` 里。今天补的六道缝都是为了这一条。
160
+
161
+ ## 5. 未决 / 已知坑
162
+
163
+ 1. **插件版本没对齐**(§3.3)——接手第一件事。
164
+ 2. ~~共享 home 是结构性的~~ **已解决**(commit `a27e237`,缝 #13):一臂一个 `DSH_HOME`
165
+ (`arms/<arm>/{profiles,storages,sessions,engram}`),store 只种进治疗臂自己的 home,
166
+ 控制臂的 home 里没有可读的东西;围栏只 bind 本臂 home。`--hide-store-for` 退化为兼容参数。
167
+ 测试:两臂 `DSH_HOME` 不同、控制臂 home 里不存在 `dsh_engram.json`。
168
+ 3. ~~控制臂仍能看见兄弟目录名~~ **已解决**(commit `371be60`):run 根改为
169
+ `runs/<sha1(task|seed|arm)[0:8]>/ws`,围栏再把 run 根挂空、只挂回 `ws`(连本行 `run.log`
170
+ 都读不到);映射记在 `runs.json` 每行的 `runKey` 里。剩下的可见面只有"本机实验目录存在"。
171
+
172
+ 4. **耗时不可控**:同一任务今天从 53s 到 400s 都有,endpoint 抖动是主因,别用耗时差异下结论。
173
+ 5. `eval/g6/runs/` 下有历史 artifact(`r5-confined` = 第五轮 +90pp,带臂名泄漏),
174
+ 别拿它跟第六轮混着算。
175
+
176
+ ## 6. 证据索引
177
+
178
+ | 要看的 | 在哪 |
179
+ |---|---|
180
+ | 第六轮的方法/事故/两份结果 | `docs/G6-quality-ab.zh.md` §13(结果在 §13.4) |
181
+ | 第一至五轮 | 同文件 §4–§12 |
182
+ | 门表 | `docs/GATES.zh.md` |
183
+ | harness | `eval/quality-ab.mjs`(`scrubSharedHome` / `artifactPath` / `assertFreeSpace` / `snapshotRows`) |
184
+ | 围栏 | `eval/g6/sandbox-profile.mjs` |
185
+ | 测试 | `test/quality-ab.test.mjs`、`test/g6-sandbox.test.mjs` |
186
+ | 第六轮已跑的 10 行 | `eval/g6/runs/r6-partial/runs.json` |
187
+
188
+ ## 7. Linux 接管记录(2026-10-28 下午)
189
+
190
+ 上一台 macOS 上的东西一件没带过来,全部在本机重建。**结果(本地 vLLM,40/40 行)**:
191
+
192
+ | 臂 | 通过 | 通过率 | 平均墙钟 | 中位 |
193
+ |---|---|---|---|---|
194
+ | `p-a`(`dsh-engram` 0.8.8) | 20/20 | 100% | 85s | 67s |
195
+ | `p-b`(裸) | 2/20 | 10% | 200s | 162s |
196
+
197
+ 逐任务:`codec` 5–0 · `errcode` 5–0 · `money` 5–2 · `ranking` 5–0 → **Δ = +90.0pp → G6 PASS**,
198
+ `infra 0` · `contaminated 0`。
199
+
200
+ 官方端点的第一次尝试(`r6-neutral`):13 对有效配对、**+95.0pp**,7 行 `infra`(余额用尽);
201
+ 保守口径(20 对,7 行当失败)**+60.0pp**。三种读法都 PASS。
202
+
203
+ ### 7.1 与 macOS 轮次的差异(读数字前必须知道)
204
+
205
+ | 项 | macOS(第五轮) | 本机(第六轮) |
206
+ |---|---|---|
207
+ | 围栏 | `sandbox-exec`(seatbelt,DENY 列表) | `bwrap`(挂载列表),`fenceCommand()` 按平台选,**未知平台抛错** |
208
+ | 端点 | 官方付费 API | `local-vllm-61`(本机 vLLM,同款模型 id);`assertSameTarget()` 保证两臂一致 |
209
+ | 插件版本 | 0.8.2 | **0.8.8** |
210
+ | 语料 `check` | 写死 `/Users/sunchuxiong/...` | `node ${REPO}/eval/g6/check.mjs …`(拒绝没有占位符的写法) |
211
+ | 实验 home | `~/.dsh/quality-ab` | 同(本机重建,`p-a` 装 0.8.8,`p-b` 裸) |
212
+ | 日志 | `/tmp/g6/r6g-logs` | `~/.dsh/quality-ab-logs/r6-local`(在 home 之外、对臂不可读) |
213
+
214
+ **所以第六轮与第五轮的 +90pp 不能比同**:版本、语料、围栏实现都变了。
215
+
216
+ ### 7.2 这一轮补的五个漏洞(都在 §13.2 的 #7–#11)
217
+
218
+ 12. **两臂可能指向不同端点**:`run` 开头用 `--dump-config` 比两臂的 `provider`/`model`,不同就拒绝开跑;产物记 `target`
219
+
220
+ 7. 插件包自带 `docs/`(**原文含 codec 公式**)被装进治疗臂自己的 `node_modules` → 装完即删 + 围栏挂空
221
+ 8. 语料 `check` 写死上一台机器的绝对路径 → 两臂全 0 → 闸门报"两臂持平 PASS"(静默假结论)→ `${REPO}` + 拒绝规则
222
+ 9. 臂继承了调用者的 `DSH_PROFILE_DIR`/`DSH_SESSION_ID`/`DSH_SHELL` → `armEnv()` 只给 `DSH_HOME`
223
+ 10. `--control-first` 的比较器永不返回 `-1` → 改成显式分区(顺序本身就是隔离)
224
+ 11. 仓库只是"恰好"因为位于 `$HOME` 下被藏住 → 显式 `--tmpfs <repo>`
225
+
226
+ ### 7.3 现在可以直接跑的命令
227
+
228
+ ```bash
229
+ # 整轮重跑(换端点就必须整轮)
230
+ node eval/quality-ab.mjs run --tasks "$PWD/eval/g6/tasks-blind.json" \
231
+ --workspace "$PWD/eval/fixtures/ledger-kata-blind" --arm p-a,p-b --seeds 5 \
232
+ --out "$PWD/eval/g6/runs/r6-local" --home "$HOME/.dsh/quality-ab" \
233
+ --seed-memory "$PWD/eval/g6/seed-memory-blind.json" --hide-store-for p-b \
234
+ --sandbox --control-first --log-dir "$HOME/.dsh/quality-ab-logs/r6-local"
235
+ # (中途断了加 --resume:复用已完成的行,只重跑 infra 行)
236
+
237
+ ### 7.4 本机状态(不随仓库走)
238
+
239
+ | 东西 | 位置 |
240
+ |---|---|
241
+ | 40 行原始日志(run.log + check 输出) | `~/.dsh/quality-ab-logs/r6-local/`(本地端点)· `~/.dsh/quality-ab-logs/r6/`(官方端点那次) |
242
+ | 冒烟证据(2 次) | `~/.dsh/quality-ab-logs/smoke{,2}/` |
243
+ | 实验 home(`p-a` 0.8.8 / `p-b` 裸) | `~/.dsh/quality-ab/` |
244
+ | 产物 | `eval/g6/runs/r6-local/{runs.json,REPORT.md}`(主结果)· `r6-neutral`(官方端点复现)——都已提交 |
245
+
246
+ `dsh-engram-0.8.8.tgz`(`npm pack` 的产物)在仓库根,被 `.gitignore` 忽略;重装插件需要它。
@@ -0,0 +1,78 @@
1
+ # dsh-engram v0.9.0
2
+
3
+ **这一版几乎没改产品代码,改的是「我们凭什么说自己有用」——评测从「跑得动」变成了「作弊不了」。**
4
+
5
+ 从 `0.8.8` 到这里共 22 个 commit,`lib/` 一行未动,唯一的用户可见改动是 CLI 的一处
6
+ preset 扫描优先级。剩下的全部落在 `eval/`、`test/` 和 `docs/`:把上一版还只能"声称"的
7
+ 东西变成能复现的读数。
8
+
9
+ ## 修复
10
+
11
+ - **`web-compaction` 的 preset 扫描不再被陈旧的 legacy 目录抢占**:`--presets-dir`
12
+ 现在优先于一切。此前一个更早的 checkout 留在机器上时,它会先认领 preset id,
13
+ 声明式 patch 文件按 id 被去重掉,于是 `status` 报的是 legacy 布局、
14
+ 而声明式那份被显示成 `custom`。三条 Linux CLI 测试抓到了这个组合
15
+ (`scripts/dsh-engram.mjs`)。
16
+
17
+ ## 评测基础设施(本版真正的内容)
18
+
19
+ - **G6 有了网络围栏**(`--sandbox --net-fence --net-endpoint host:port`):
20
+ bwrap 搭挂载树 → `setpriv` 掉到专用 uid → 三条按 uid 生效的 iptables 规则,
21
+ 默认 REJECT,只放行 loopback 和一个模型端点。按 uid 而不是按进程过滤,
22
+ 所以开发会话、harness、Web GUI 的网络不受影响。
23
+ 跑矩阵前有两个自证,不通过就不计分:出网探针(端点可达 / `github 000` / DNS 死)
24
+ 和写目录探针(臂必须能在自己的 workspace 里落盘)。
25
+ - **第九轮结果**:9 任务 × 2 seed × 2 臂,p-a 18/18 vs p-b 0/18,Δ **+100.0pp**,
26
+ 18 对配对,`infraRows 0`,`contaminated 0`(`eval/g6/runs/r9-net/`)。
27
+ 18 份控制日志里有 4 份试图出网,全部拿到 `exit 124` / "network unavailable"——
28
+ 这是第七轮「控制臂去 GitHub 查到答案」的反面。
29
+ - **语料第 4 条自证**:工作区 stub 必须导出 prompt 点名的函数,参数个数必须与 prompt 一致
30
+ (oracle 可以少传、不能多传)。这条当场抓出三处不一致:`retry` 的 stub 签的是
31
+ `shouldRetry(status, code, attempt)`、`ranking`/`dedupe` 的 stub 导出名 prompt 从未提过。
32
+ 一份靠"猜哪份文档算数"得来的通过不是证据。
33
+ - **污染判定改为三分类**(`classifyLeak` → `memory` / `artifact` / `network`),
34
+ 并提供 `quality-ab.mjs classify` 从既有日志**重算**:围栏下"试过出网"不再等于"拿到了"。
35
+ - **G7 从「未测」变成实测**:账本自身开销最坏操作 **0.2956%**(闸门 < 0.5%),
36
+ 1123 条真实 step 延迟,`npm run bench:ledger` 可复现。
37
+
38
+ ## 其他
39
+
40
+ - 证据面汇总见 `docs/GATES.zh.md`:G2 激活率 83.3% PASS · G4 陈旧率 0.0% PASS ·
41
+ G6 Δ +100pp PASS · G7 0.2956% PASS · G1/G1b/G5 仍未测(未测即未测,不猜)。
42
+ - G6 方法、十二道缝与失效模式见 `docs/G6-quality-ab.zh.md` §16。
43
+
44
+ ## 这一版**不是** 1.0,也请不要这样读
45
+
46
+ `0.9.0` 表示"证据链条够了",不表示"公共面稳定了"。三条至少还挡在 1.0 前面:
47
+
48
+ 1. **宿主还是 rc**(peer `>=0.1.2-alpha.2 <0.3.0-0`,本机 `dsh 0.2.1-rc.2`):
49
+ 1.0 承诺"不无故破坏",在 rc 宿主上做不到。
50
+ 2. **旗舰收益未测**:账本 `avoided 0c`、净 **−1.46M token**、G1 未测——
51
+ 因为省钱的两条路(`boundaryPrune`、`gcReplacesCompaction`)默认关闭。
52
+ 一次开着的实测周(≥5 个 opportunity 会话)才能让 G1 第一次有读数。
53
+ 3. **减法债**:14 个工具(目标 ≤10)、70 个配置键(面向用户目标 ≤12)、
54
+ client bundle 291KB(目标 <150KB);存储格式仍无 schemaVersion/迁移。
55
+
56
+ ## DSH 版本支持
57
+
58
+ - **支持的 DSH 范围**:`>=0.1.2-alpha.2 <0.3.0-0`
59
+ - **peer 声明**:
60
+ - `@deepseek-ai/dsh-compaction`: `>=0.1.2-alpha.2 <0.3.0-0`
61
+ - `@deepseek-ai/dsh-compaction-basic`: `>=0.1.2-alpha.2 <0.3.0-0`
62
+ - `@deepseek-ai/dsh-llm`: `>=0.1.2-alpha.2 <0.3.0-0`
63
+ - `@deepseek-ai/dsh-settings`: `>=0.1.2-alpha.2 <0.3.0-0`
64
+ - `@deepseek-ai/dsh-storage-domain`: `>=0.1.2-alpha.2 <0.3.0-0`
65
+ - **验证版本**:`dsh 0.2.1-rc.2`(本机 CLI + web)
66
+ - **最后验证日期**:2026-10-29
67
+ - **安装方式已验证**:`dsh plugin --profile web add <path|dsh-engram>`
68
+ - **兼容性**:配置键与数据格式与 `0.8.8` 完全一致,**无需迁移**;无破坏性改动。
69
+
70
+ ## 验证
71
+
72
+ ```
73
+ npm test → 519/519
74
+ npm run typecheck → ok
75
+ npm run eval → BENCH PASS
76
+ npm run build:client → 无 diff(bundle 新鲜)
77
+ npm pack → 459KB / 94 files
78
+ ```
@@ -0,0 +1,112 @@
1
+ # dsh-engram v0.9.1
2
+
3
+ **两个「自己说自己活着」的部件:一个在 Windows 上从第一次启动起就没亮过,一个接管了
4
+ 本机 compaction 却一次也没压缩过。**
5
+
6
+ 从 `v0.9.0` 到这里只有两个 commit,修的是同一类故障——**状态面显示正常,功能面从未发生**。
7
+ 这类故障单测抓不到,因为它不是逻辑错了,而是两个面用的键、或者两个面用的上下文不一致:
8
+ 写的时候用了一套,读/挂的时候用了另一套。两次都是先有人在真实环境里看见「那个东西没出现」,
9
+ 才回头找到这条缝。
10
+
11
+ ## 修复
12
+
13
+ ### 1. `[ENGRAM]` 块在 Windows 上恒为空(issue #10)
14
+
15
+ `blockFor` / `esrBlockFor` 用裸 `path.resolve(cwd)` 推会话工作区,而所有写入路径——以及
16
+ 渲染调用下面两行的账本——用的是 `workspaceKey()`。win32 上写入端会把整个 key 小写
17
+ (盘符默认大写),于是原始 key 匹配到 0 行:`[ENGRAM]` 渲染成空串,`sessionCache` 再把
18
+ 空串冻住整场会话,看起来像「记忆库是空的」。
19
+
20
+ `[RESUME]` 与 `[ESR]` 快照/冻结块是同一个第二 key 派生,一起修。三处调用点现在共用
21
+ `wk(cwd)`,`lib/index.js` 不再 import `node:path`——没有第二条派生路可走。
22
+ `test/workspace-key.test.mjs` 不依赖 Windows 主机也能复现这对键,另外用源码不变量把调用点
23
+ 锁住。
24
+
25
+ ### 2. 宿主面 compaction 引擎从未真正挂上(issues #12 / #13)
26
+
27
+ `BasicCompactionEngine` 声明 `static inject = ["llm", "tokenMeter", "sessions"]`,但
28
+ `static inject` 只有被 loader **当插件加载**时才转成供给好的依赖图。`mountCompactionEngine`
29
+ 用的是 `new Engine(ctx)`,而且这个 `ctx` 来自 engram 自己那条 `inject = ["tools"]` 的 fiber,
30
+ 于是 `compactIfNeeded()` 的第一句 `this.ctx.tokenMeter` 直接从 ctx 代理里抛
31
+ `cannot get property "tokenMeter" without inject`——可选链救不了,getter 本身在抛。
32
+
33
+ 两个后端把同一条错误吞成了两种表情:
34
+
35
+ - 注册 `agent/request(prepend)` 的后端 → 错误冒到模型切换上,界面提示
36
+ "select a model with a larger context window"(误导,跟模型大小无关);
37
+ - 在 `agent/pre-step` 里读的后端 → 被 catch 成 `step compaction failed … continuing the turn`,
38
+ 引擎挂着、`context-gc.status.json` 一直写着 `host: "context-gc"`、却从不压缩。
39
+
40
+ 现在 `mountCompactionEngine` 改为 `ctx.plugin(Engine)`,两个子类都接
41
+ `constructor(engineCtx = ctx)`,loader 在引擎自己的 fiber 上供给三个服务;engram 自身仍只
42
+ inject `["tools"]`,minimal/headless profile 不因此多背依赖。
43
+
44
+ 顺手处理的三个坑(都有测试钉住):
45
+
46
+ - **`fiber.await()` 不是就绪信号**:inject 缺失时 fiber 从不启动,`await()` 立即 resolve
47
+ 且 resolve 的是 undefined。改成等引擎自身构造的回调;宿主永不供给服务时挂载保持 pending,
48
+ `lib/index.js` 用 15s 上界把 `host: "unavailable"` 写进 status 并 warn 一行,不再静默。
49
+ - **重复 provider 必须在挂载前拦**(`ctx.get("compaction") !== undefined` → 抛)。否则 cordis
50
+ 从 fiber 内部拒绝并 `logger.error` 打栈,而「base 的 `compaction-basic` 行还启用着」本是
51
+ 设计内的情况,不是事故。
52
+ - **错误路径**:`MountedEngine` 里 `try { super() } catch (e) { settle.reject(e); throw e }`,
53
+ 否则构造失败会让挂载静默挂死(内部 `catch {}` 把异常吃了)。
54
+
55
+ ## 这次怎么验的
56
+
57
+ 单测跑的是真 cordis + 真 backend(`test/compaction-mount.test.mjs`,含两个反向对照:
58
+ 一个断言 `compactIfNeeded()` 确实经注入的 meter 实测,一个保留 `new` 的做法仍复现原报错)。
59
+ 但「真 backend」还不是「真宿主」,所以补了一次真机探针:
60
+
61
+ **做法** —— 把本仓库 `npm pack` 成 tarball,在 `/tmp` 里起一个一次性 `DSH_HOME`,用
62
+ `dsh --from-default-profile web` 造 profile,`dsh plugin --profile probe add <tgz>` 装上,
63
+ 再用一份 overlay patch 插一个只做一件事的探针插件:启动 8 秒后调
64
+ `ctx.compaction.compactIfNeeded(agent, "pressure")`,把结果写进文件;`dsh --port 3199 web`
65
+ 起真宿主,读文件。这套流程已固化成 `npm run host:probe`(`scripts/host-mount-probe.mjs`,
66
+ `--gc` 切 GC 模式、`--tgz <old.tgz>` 当对照;不联网、不调模型,退出码 0 = 通 / 1 = 引擎报错)——
67
+ 下面这张表的每一行都能用一条命令重跑。
68
+
69
+ **读数**(`dsh 0.2.1-rc.2`,本机):
70
+
71
+ | 装上的是 | `status.host` | `compactIfNeeded("pressure")` |
72
+ |---|---|---|
73
+ | 本版(fixed,默认配置) | `default` | `ok: true`,`null`(空会话无需压缩) |
74
+ | 本版(fixed,`gcReplacesCompaction: true`) | `context-gc` | `ok: true`,`null` |
75
+ | 父提交 `42a9552`(对照) | `default` | `ok: false` —— `cannot get property "tokenMeter" without inject` |
76
+
77
+ 第三行是关键:同一套探针、同一个宿主,把插件换回父提交的 tarball,原报错原样复现。也就是说
78
+ 前两行的绿不是「没测到那条路」,是那条路真的通了。
79
+
80
+ ## 其他
81
+
82
+ - 贡献者 PR #11(客户端 i18n + 俄语目录,457 条)已在本机全量验证(合并无冲突、`build:client`
83
+ 与提交产物逐字节一致、527/525 只有本机 fence 失败),但**不在本版**:它改的是 `client/`,
84
+ 等 review 合并后再发。
85
+ - 配置键与数据格式与 `0.9.0` / `0.8.8` 完全一致,**无需迁移**。
86
+
87
+ ## DSH 版本支持
88
+
89
+ - **支持的 DSH 范围**:`>=0.1.2-alpha.2 <0.3.0-0`
90
+ - **peer 声明**:
91
+ - `@deepseek-ai/dsh-compaction`: `>=0.1.2-alpha.2 <0.3.0-0`
92
+ - `@deepseek-ai/dsh-compaction-basic`: `>=0.1.2-alpha.2 <0.3.0-0`
93
+ - `@deepseek-ai/dsh-llm`: `>=0.1.2-alpha.2 <0.3.0-0`
94
+ - `@deepseek-ai/dsh-settings`: `>=0.1.2-alpha.2 <0.3.0-0`
95
+ - `@deepseek-ai/dsh-storage-domain`: `>=0.1.2-alpha.2 <0.3.0-0`
96
+ - **验证版本**:`dsh 0.2.1-rc.2`(本机 CLI + web;宿主面 compaction 挂载另用一次性 profile 实测,
97
+ 见上)
98
+ - **最后验证日期**:2026-11-09
99
+ - **安装方式已验证**:`dsh plugin --profile <name> add <tgz|dsh-engram>`
100
+
101
+ ## 验证
102
+
103
+ ```
104
+ npm test → 532 tests, 530 pass
105
+ (2 个失败仅 test/g6-sandbox.test.mjs:需要 OS 级 fence,本机没有,
106
+ 父提交上同样失败)
107
+ npm run typecheck → ok
108
+ npm run build:client → 无 diff(bundle 新鲜)
109
+ npm run prepublishOnly → packed tarball 里 host + client bundle 双入口 import OK
110
+ npm pack → 476.8 kB / 96 files(client bundle 298476 bytes)
111
+ npm run host:probe → exit 0(`--tgz <父提交 tarball>` 复跑:exit 1,原报错)
112
+ ```
@@ -40,6 +40,9 @@ const LONG_SESSION_REQUESTS = 200;
40
40
  * gate FAIL (or PASS). The gate says 未测 until there is a distribution. */
41
41
  const MIN_SESSION_ROWS = 5;
42
42
  const MIN_LONG_ROWS = 3; // §6: 长会话 = ≥200 请求
43
+ // G2's population floor: "installed sessions". Below this the rate is a coin
44
+ // flip dressed as a percentage.
45
+ const MIN_ACTIVATION_SESSIONS = 5;
43
46
  const ENGRAM_TOOL_RE = /^(engram_|esr_)/;
44
47
 
45
48
  function parseArgs(argv) {
@@ -55,6 +58,9 @@ function parseArgs(argv) {
55
58
  else if (arg === "--g7") out.g7 = Number(argv[++i]);
56
59
  else if (arg === "--g6") out.g6 = argv[++i] ?? null;
57
60
  else if (arg === "--home") out.home = argv[++i] ?? null;
61
+ // Diagnostics: which sessions are in the denominator, and what each one
62
+ // actually did. A rate without its population is a number you cannot act on.
63
+ else if (arg === "--explain") out.explain = true;
58
64
  }
59
65
  return out;
60
66
  }
@@ -200,6 +206,35 @@ function scanSessions({ root = sessionsRoot(), days = 14, now = Date.now() } = {
200
206
  return sessions.sort((a, b) => b.mtime - a.mtime);
201
207
  }
202
208
 
209
+ /**
210
+ * How many memories each workspace has, so a session can be asked the only
211
+ * question that makes push rate meaningful: was there anything to push?
212
+ */
213
+ function countMemoriesByWorkspace(dshHome) {
214
+ const candidates = [
215
+ path.join(dshHome, "storages", "dsh_engram.json"),
216
+ path.join(dshHome, ".dsh", "storages", "dsh_engram.json"),
217
+ ];
218
+ for (const file of candidates) {
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
222
+ } catch {
223
+ continue;
224
+ }
225
+ const table = parsed?.tables?.memories;
226
+ if (table === null || table === void 0 || typeof table !== "object") continue;
227
+ const counts = new Map();
228
+ for (const row of Object.values(table)) {
229
+ if (row === null || typeof row !== "object" || row.kind === "episode") continue;
230
+ if (typeof row.workspace !== "string" || row.workspace.length === 0) continue;
231
+ counts.set(row.workspace, (counts.get(row.workspace) ?? 0) + 1);
232
+ }
233
+ return counts;
234
+ }
235
+ return new Map();
236
+ }
237
+
203
238
  /** Stale share over the memory table (read-only; the store stays the truth). */
204
239
  function readMemoryShare(dshHome) {
205
240
  const candidates = [
@@ -285,12 +320,29 @@ export function auditGates({ days = 14, now = Date.now(), home = void 0, api = n
285
320
  const netShare = pct(positive.length, opportunityRows.length);
286
321
  const longShare = pct(longPositive.length, longRows.length);
287
322
 
288
- // ── G2 Activation: mounted sessions where any engram/esr tool fired
289
- const mounted = sessions.filter((s) => s.tools.size > 0);
290
- const activated = mounted.filter((s) => [...s.tools.keys()].some((name) => ENGRAM_TOOL_RE.test(name)));
291
- const pushFired = mounted.filter((s) => s.pushSeen);
292
- const activation = pct(activated.length, mounted.length);
293
- const pushShare = pct(pushFired.length, mounted.length);
323
+ // ── G2 Activation: installed sessions where any engram/esr tool fired
324
+ //
325
+ // "installed" used to be `tools.size > 0` — that is "the session ran SOME
326
+ // tool", which is true of every session and says nothing about engram. On this
327
+ // machine that put three Kototoro sessions in the denominator whose tool list
328
+ // (bash, read, edit… — no engram_*, no esr_*) shows the plugin was never
329
+ // loaded there: they could not fire, and counting them measured the install
330
+ // state of a workspace, not activation. Evidence of a live mechanism is one of:
331
+ // the prompt carried the [ENGRAM]/[RECALL] block, the ledger counted requests,
332
+ // or an engram tool was actually called.
333
+ const ledgerLive = new Set(rows.map((row) => row.sessionId));
334
+ const engramFired = (session) => [...session.tools.keys()].some((name) => ENGRAM_TOOL_RE.test(name));
335
+ const installed = sessions.filter((s) => s.pushSeen || ledgerLive.has(s.sessionId) || engramFired(s));
336
+ const activated = installed.filter(engramFired);
337
+ const pushFired = installed.filter((s) => s.pushSeen);
338
+ const memoryCounts = countMemoriesByWorkspace(dshHome);
339
+ // The opportunity axis: a session whose workspace HAS memories is a session
340
+ // where the push leg had something to say. Below that, "no push" is not evidence.
341
+ const hadSomethingToPush = installed.filter((s) => (memoryCounts.get(s.workspace) ?? 0) > 0);
342
+ const pushWherePossible = hadSomethingToPush.filter((s) => s.pushSeen);
343
+ const activation = pct(activated.length, installed.length);
344
+ const pushShare = pct(pushFired.length, installed.length);
345
+ const pushOpportunityShare = pct(pushWherePossible.length, hadSomethingToPush.length);
294
346
 
295
347
  // ── G3 DrillRate: detail calls per recall-with-hits
296
348
  const recallWithHits = sessions.reduce((sum, s) => sum + s.recallWithHits, 0);
@@ -302,7 +354,16 @@ export function auditGates({ days = 14, now = Date.now(), home = void 0, api = n
302
354
 
303
355
  return {
304
356
  auditedAt: now,
305
- window: { days, now, sessions: sessions.length, mounted: mounted.length },
357
+ // "mounted" now means what the word says: engram was actually live in the
358
+ // session. The old count (sessions that ran any tool) is kept beside it so a
359
+ // reader can see how much of the population the fix removed.
360
+ window: {
361
+ days,
362
+ now,
363
+ sessions: sessions.length,
364
+ mounted: installed.length,
365
+ anyTool: sessions.filter((s) => s.tools.size > 0).length,
366
+ },
306
367
  ledger: {
307
368
  rows: rows.length,
308
369
  opportunityRows: opportunityRows.length,
@@ -345,19 +406,35 @@ export function auditGates({ days = 14, now = Date.now(), home = void 0, api = n
345
406
  },
346
407
  {
347
408
  id: "G2",
348
- name: "Activation · 机制激活率(挂载会话 fire ≥1 次)",
409
+ name: "Activation · 机制激活率(装上了 engram 的会话 fire ≥1 次)",
349
410
  value: fmtPct(activation),
350
411
  target: "≥ 60%",
351
- verdict: mounted.length === 0 ? "未测" : verdict(activation >= 60),
352
- note: `${activated.length}/${mounted.length} mounted sessions`,
412
+ // Enough sessions to judge: below the floor the honest answer is 未测.
413
+ // n=3 would read 100% PASS and mean nothing.
414
+ verdict:
415
+ installed.length < MIN_ACTIVATION_SESSIONS ? "未测" : verdict(activation >= 60),
416
+ note:
417
+ installed.length < MIN_ACTIVATION_SESSIONS
418
+ ? `${activated.length}/${installed.length} installed sessions — 样本不足(需 ≥ ${MIN_ACTIVATION_SESSIONS});旧口径 ${sessions.filter((s) => s.tools.size > 0).length} 个"有工具"会话里有 ${sessions.filter((s) => s.tools.size > 0 && !s.pushSeen && !ledgerLive.has(s.sessionId) && !engramFired(s)).length} 个根本没装 engram`
419
+ : `${activated.length}/${installed.length} installed sessions`,
353
420
  },
354
421
  {
355
422
  id: "G2b",
356
423
  name: "Push · [RECALL] 注入命中会话占比",
357
424
  value: fmtPct(pushShare),
358
425
  target: "观测项(无预声明门)",
359
- verdict: mounted.length === 0 ? "未测" : "—",
360
- note: `${pushFired.length}/${mounted.length} sessions carry a [RECALL] block in their prompt`,
426
+ verdict: installed.length === 0 ? "未测" : "—",
427
+ note: `${pushFired.length}/${installed.length} installed sessions carry a [RECALL] block in their prompt`,
428
+ },
429
+ {
430
+ id: "G2c",
431
+ name: "Push opportunity · 有记忆可推的会话里推送腿渲染占比",
432
+ value: fmtPct(pushOpportunityShare),
433
+ target: "观测项(无预声明门)",
434
+ verdict: hadSomethingToPush.length === 0 ? "未测" : "—",
435
+ // The only push number that can be read as a miss: the workspace had
436
+ // memories and the block still never rendered.
437
+ note: `${pushWherePossible.length}/${hadSomethingToPush.length} sessions whose workspace has ≥1 memory`,
361
438
  },
362
439
  {
363
440
  id: "G3",
@@ -393,7 +470,11 @@ export function auditGates({ days = 14, now = Date.now(), home = void 0, api = n
393
470
  verdict: g6 === null ? "未测" : (g6.gate?.verdict ?? "未测"),
394
471
  note: g6 === null
395
472
  ? "needs paired A/B runs (not derivable from logs) — pass --g6 <runs.json>"
396
- : `${g6.paired?.pairs ?? 0} 对配对 · ${g6.tasks?.length ?? 0} 任务 × ${g6.seeds ?? 1} 种子 · ${g6.source ?? ""}`.trim(),
473
+ : `${g6.paired?.pairs ?? 0} 对配对 · ${g6.tasks?.length ?? 0} 任务 × ${g6.seeds ?? 1} 种子 · ${g6.source ?? ""}`.trim() +
474
+ // A control row that went and read the answer key is not evidence.
475
+ // If the artifact dropped some, the number on this dashboard is only
476
+ // honest when the drop is visible next to it.
477
+ (g6.notBlindExcluded > 0 ? ` · 已剔除 ${g6.notBlindExcluded} 行不盲控制臂` : ""),
397
478
  },
398
479
  {
399
480
  id: "G7",
@@ -475,7 +556,7 @@ function renderMarkdown(report) {
475
556
  const out = [];
476
557
  out.push(`# 门审计(gate audit)· ${new Date(report.auditedAt ?? Date.now()).toISOString().slice(0, 10)}`);
477
558
  out.push("");
478
- out.push(`窗口:最近 ${report.window.days} 天 · 扫描 ${report.window.sessions} 个会话(其中 ${report.window.mounted} 个挂载了工具)· 账本行 ${report.ledger.rows} 条`);
559
+ out.push(`窗口:最近 ${report.window.days} 天 · 扫描 ${report.window.sessions} 个会话(其中 ${report.window.mounted} 个装了 engram;"用过任意工具"的会话 ${report.window.anyTool} 个)· 账本行 ${report.ledger.rows} 条`);
479
560
  out.push("");
480
561
  out.push("| 门 | 指标 | 实测 | 目标 | 判定 | 备注 |");
481
562
  out.push("|---|---|---|---|---|---|");
@@ -524,7 +605,30 @@ async function main() {
524
605
  return;
525
606
  }
526
607
 
527
- console.log(`gate audit · last ${report.window.days}d · ${report.window.sessions} sessions (${report.window.mounted} mounted) · ${report.ledger.rows} ledger rows`);
608
+ if (args.explain) {
609
+ const sessions = scanSessions({ days: args.days, root: sessionsRoot() });
610
+ const byId = new Map(ledger.sessions.map((row) => [row.sessionId, row]));
611
+ console.log("session ws req push tools engram-tools recall/hit detail all-tools");
612
+ for (const s of sessions) {
613
+ const row = byId.get(s.sessionId) ?? {};
614
+ const engramTools = [...s.tools.keys()].filter((name) => ENGRAM_TOOL_RE.test(name));
615
+ console.log(
616
+ [
617
+ s.sessionId.slice(0, 8).padEnd(12),
618
+ path.basename(s.workspace || "?").slice(0, 20).padEnd(22),
619
+ String(row.requests ?? 0).padStart(3),
620
+ String(s.pushSeen ? "yes" : "-").padStart(5),
621
+ String(s.tools.size).padStart(6),
622
+ (engramTools.join(",") || "-").slice(0, 18).padEnd(18),
623
+ ([...s.tools.keys()].join(",") || "-").slice(0, 46).padEnd(46),
624
+ `${s.recallQueries}/${s.recallWithHits}`.padStart(11),
625
+ String(s.detailCalls).padStart(6),
626
+ ].join(" "),
627
+ );
628
+ }
629
+ console.log("");
630
+ }
631
+ console.log(`gate audit · last ${report.window.days}d · ${report.window.sessions} sessions (${report.window.mounted} with engram live, ${report.window.anyTool} with any tool) · ${report.ledger.rows} ledger rows`);
528
632
  for (const gate of report.gates) {
529
633
  console.log(` ${gate.id.padEnd(4)} ${gate.verdict.padEnd(4)} ${gate.value.padEnd(8)} ≤/≥ ${gate.target.padEnd(10)} ${gate.name}`);
530
634
  }