# ⑩-a / ⑩-b 规划（2026-09-19）

> **本文件是上下文压缩后的唯一恢复入口**——读完它即可恢复 ⑩ 的全部上下文，无需回溯对话。
> **术语校正**：用户口述「石壁」＝**⑩-b**（「十B」的语音转写）。
> **⑩-b 的定义**＝**记忆文件（`MEMORY.md`）侧的隐患**，含两件事：(i) 尚未引爆的 **M8 回写雷**；(ii) 排版与语义的耦合。

---

## 0. 一句话定位（先看这张表）

| | **⑩-a** | **⑩-b** |
|---|---|---|
| **是什么** | **前端「记忆中枢」面板里看不懂** | **记忆文件侧的隐患**（回写雷 + 排版/语义耦合） |
| **用户本意** | ✅ **这就是 ⑩ 的原意**（2026-09-19 用户以面板截图澄清） | 后续深挖出来的 |
| **涉及语义引擎吗** | **不涉及**（已代码核实） | **涉及** |
| **涉及 `MEMORY.md` 吗** | **不涉及** | **涉及** |
| **今天的状态** | **待修**（用户指示先做） | **取证中**（两个只读子代理并行） |

**⑩ 的文档链**：
- 取证数据 → `R1-READABILITY-FORENSICS-20260919.md`
- 人话版 + 四选修法 → `ISSUE9-PURGE-AND-R1-PLAIN-20260919.md`
- **本文件 = 拆分为 ⑩-a / ⑩-b 后的执行规划**

---

## 1. ⑩-a · 前端面板可读性（**本项要做**）

### 1.1 现象（用户截图，2026-09-19）
前端「记忆中枢」→ 事实（Semantic）区，条目显示为乱码与噪声，**人看不懂**。

### 1.2 根因 = **数据脏，不是渲染差**（已实测）

喂该面板的是 `~/.dsh/memory/hub-pre/facts.json`。探针实测 **4 条中 3 条是坏的**：

| # | factId | 坏在哪 |
|---|---|---|
| 0 | `fact_pre_ac4920327df6601f25200d66e52df71f` | **22 个 U+FFFD**：`DSH ������ \| has three modes \| shadow …` |
| 1 | `fact_pre_a9380f5bca22011e33547a45542c61a3` | object 内嵌运行时信封：`Current DSH file policy: danger-full-access…` |
| 3 | `fact_pre_dda6cc1f36176f5ea000c6f681ea6818` | object 内嵌运行时信封：`Approval prompts are disabled in this session…` |

### 1.3 为什么脏 = **⑨ 只修了一半**（关键发现）

`lib/memory-hub-pre.js` 的 `crossFeed()` **两条通路不对称**：

```
走 procedure（约 :152）→ stripRuntimeIntentPre(ep.intent)        ✅ ⑨ 已修
走 fact     （约 :166）→ subject: ep.intent.slice(0, 30)          ❌ 完全没清洗
                        object:  ep.unresolved[0].slice(0, 60)    ❌ 直接切片
```

同理 `factCandidateFromRow()`（约 `:249-265`）**也不调**清洗器，只做 `String(row.subject || sourceIds[0])`。
⇒ **⑨ 修了「技能」那条路，「事实」这条路整套漏掉** —— 这是 `facts.json` 变脏的直接成因。

### 1.4 修复方案

| 步 | 动作 | 位置 |
|---|---|---|
| **a-1** | **清存量脏数据**（3 条） | `~/.dsh/memory/hub-pre/facts.json` |
| **a-2** | `crossFeed()` 的 **fact 分支**补清洗：`subject`/`object` 先过 `stripRuntimeIntentPre` | `lib/memory-hub-pre.js` 约 `:166-174` |
| **a-3** | `factCandidateFromRow()` 补清洗（`subject`/`predicate`/`object`） | 同文件约 `:249-265` |
| **a-4** | 新增 smoke 套件 + 变异演示 + 全量回归 | `tests/smoke/` · `artifacts/` |

**清洗器的选择**：复用 ⑨ 修好的 `stripRuntimeIntentPre()`（`lib/intent-clean-safe-pre.js`）。
它同时覆盖两类脏数据——**F3**（召回块/信封尾部标记）与 **F4**（`looksEncodingCorruptedPre`，U+FFFD ≥3）
⇒ 一条调用即可同时解决「信封污染」与「乱码」两种症状。

**⚠️ 清存量数据的坑（⑨ 已踩过，务必复用）**：
宿主**内存里持有副本**，`dispose()` 会**整份写回盘** ⇒ **直接编辑 json 会被回滚**。
⑨ 的解法是走宿主自己的 loopback 通路（`POST /api/dsh-auto-memory-pre/memory-hub`）。

### 1.4b ★ a-1 调研结论（2026-09-19 20:45 已查，**未动手**）

**事实一：fact store 没有「按 id 删除」的方法。**
`fact-store-pre.js:409-410` 导出的全集 = `upsert · supersede · get · query · conflictsList · pendingConflicts · resolveConflict · evidenceFor · revokeBySource · snapshot · restore · clear · dispose`
—— **没有 `remove` / `delete` / `revokeById`。**

**事实二：唯一的撤销通路是 `revokeBySource(sourceId)`（`:283-300`），且它按 provenance 反查**：

```js
if (!Array.isArray(f.provenance) || !f.provenance.includes(sid)) continue
f.revoked = true; f.revokedAt = now; f.revokeReason = 'source-deleted'
```

⇒ **只读不删**（注释明写「保留 provenance 与 revokedAt，只读不删（可审计、可追溯）」）。

**事实三：三条脏数据的 provenance 分别是** `mem_af41b679…`（记忆 id）、`epi_pre_66d51268…` / `epi_pre_010a543e…`（episode id）
⇒ 用 `revokeBySource` 清它们，**前提是先删掉那两个来源** —— 代价远大于清三条 fact。

**⇒ a-1 的三条候选路线（待用户拍板，勿自行选）**：

| | 做法 | 代价 | 评价 |
|---|---|---|---|
| **路 A** | 走 `storage-manage` 的 `delete`（`index.js:10476-10487`，级联 `factStore.revokeBySource`） | **要连带删掉那条记忆 / episode** | ❌ 副作用太大 |
| **路 B** | 新增 `revokeById(factId)`（按 id 直接撤销） | 动源码（新增方法 + 套件），但最小精准，**仍符合既有语义**（只读不删 + 留痕） | ⭕ 可选 |
| **路 C** | **不改数据，改行为**：让 `hubFlushTick` 的过滤条件**主动跳过脏 fact**（`subject`/`object` 命中清洗器判据即 skip） | **零数据改动**，脏条目留在盘上但**永远写不出去** | ✅ **倾向** |

**★ 更根本的判断**：⑩-a 的根因是「**fact 通路没清洗**」⇒ **先修 a-2/a-3，新脏数据即断源**。
**存量 3 条怎么处置，是独立的小决策**，不影响主线；且与 ⑨ 的取舍同源（**⑨ 时用户选的是「弃用不删」**）。

### 1.5 验收标准（可自动化）
1. `facts.json` 中 U+FFFD 计数 = 0
2. `facts.json` 中命中 `/Current DSH file policy|Approval prompts are disabled|Current runtime context|Retrieved memory ref/i` 的条数 = 0
3. 新套件断言：`crossFeed` 走 fact 时脏 intent **不落库**
4. 变异演示真红（把新加的清洗调用去掉 ⇒ 断言必须变红）
5. 全量回归 **0 失败**

### 1.6 风险与回退
- **风险：低**。不改注入路径、不改语义语料、不碰 `MEMORY.md`。
- **回退**：清洗器调用是**纯函数前置**，去掉即恢复原行为；数据侧有备份。

### 1.7 顺带记录（不在本次范围）
清洗后仍会看到 `XX | 有未决事项 | YY` 这类由 episode 自动派生的事实——**内容质量本身偏低**（它是「有未决事项」的机械模板，不是真的知识）。
**这是 ⑩-a 之后的独立话题**（可称 ⑩-a-2 「fact 质量」），需另立规划，不在本轮。

---

## 2. ⑩-b · 记忆文件侧的隐患（**本项只取证，不修**）

### 2.1 为什么「涉及语义」——三条管线的耦合（已代码核实）

`MEMORY.md` **不是纯文本**，它同时喂三条管线：

| # | 消费方 | 依据 |
|---|---|---|
| ① | **注入上下文**（整篇文本） | `lib/index.js:5498-5499` |
| ② | **L0 摘要抽取**（按 `<!-- memory:mem_<32hex> -->` 切条） | `lib/l0-extract-pre.js:34` |
| ③ | **语义语料**（锚点划字节区间 → `recordDigest`） | `lib/memory-anchor-pre.js:168/248`；复核在 `lib/m4-corpus-pre.js:107` |

**两条硬约束**：
- 文件级先比 `fileDigest`，不等则**跳过整个文件**（`m4-corpus-pre.js:96`，连逐条比对都不做）；
- `sourceVersion` 随 `fileDigest` 变化 **+1**（`memory-anchor-pre.js:267`）⇒ 该文件**向量全部失效需重编码**。

**`memoryId` 是随机的**（`memory-anchor-pre.js:40` `newMemoryId()`）⇒ **就地改文件会换一批新 id**，
已积累的 `seen/read/cite` 证据、`supersededBy` 指针、白板锚点**全部对不上号**。

**正路**：sidecar 是**可重建的派生数据**（`note-status-pre.js:16` 明示）⇒ 改正文须配套走
`storage-manage-pre.js` 的 `repair` 重建 sidecar（**只重建 sidecar，不动正文**）。代价＝向量重编码 + id 漂移。
**真正安全**：**追加式写入**（既有 `memory-writer` 事务路径），不改已有条目的字节布局。
**绝不可做**：改/删文件里的锚点标记——它是**切条唯一依据**，去掉后整篇退化为一个 legacy 块，`memoryId` 全丢。

### 2.2 ★★★ 尚未引爆的雷：**判断已被推翻 —— 它已经引爆过**（2026-09-19 子代理 A 取证 + 主代理独立复核）

> **⚠️ 本节原先写的是「实测尚未引爆」。该结论错误，已作废。**
> 错误根源：把「两份 `MEMORY.md` 里 `（M8 固化）` 计数为 0」当成了「没写过」的证据。
> **真相：写过了、而且是成功写入；后来被容量整理（compaction）移出正文、落进归档。**

**★ 铁证（主代理独立复核，非仅采信子代理）**：

| 证据 | 内容 |
|---|---|
| `archive/notes-archived.md:493-495` | `## DSH ������（M8 固化）` / `- has three modes：shadow ֻ��¼ …` / `- 来源：记忆中枢治理固化（confidence=0.92）` |
| 同文件 `:519-521` / `:684-686` / `:3168-3170` | 另 3 段（`让我自己去试吧…` / `是这样的，我后台在改语义模型…` / `Approval prompts are disabled…`），**均带 `（M8 固化）`** |
| `MEMORY.md.bak-20260917-G2` | 正文里**曾有** `## Approval prompts are disabled（M8 固化）` ⇒ 证明**当时确实在正文** |
| 格式唯一性 | `（confidence=` 这一写法全仓只出现在 `lib/index.js:8981` ⇒ 这些段落**只能由本通路产生** |
| 锚点存在 | 每段都带 `<!-- memory:mem_<32hex> -->` ⇒ 走的是 `appendText` **正经事务**，不是事后手工拼 |

**⇒ 结论：`count: 0` 不是「没写」的证据。**
`index.js:8962` `if (hubFlushState.date !== today) { …count = 0 }` —— `count` 按**记忆日**清零，
当前 `date:2026-09-19 / count:0` 只表示「**今天还没写**」。

**危险本质（现已确认，不再是假设）**：**facts 侧是脏的（见 §1.2），而这条通路会把脏数据写进 `MEMORY.md`** ——
**从「面板难看」升级为「污染注入 + 污染语义语料」，且已实际发生。**
归档里那段 `## DSH ������（M8 固化）` 就是活证据。

### 2.3 ★ 子代理 A 的其余关键发现（含行号，可复核）

| # | 发现 | 依据 |
|---|---|---|
| 1 | **两条 tick 的开关门控是彻底的**（关掉不写），**但定时器无条件创建**、开关关掉后仍每 30min 空转；**`hubBootTimer` 未纳入 disposer** | `index.js:8918/8958`（门控）· `8992-8993`（无条件创建）· `8999`（只 clear 两个 interval） |
| 2 | **6 道过滤全是「结构性」，没有一道是「内容卫生」** —— 不查脏数据、不查长度、不查换行、不查信封 | `index.js:8970-8974` |
| 3 | **`confidence: null` 直接放行**（`typeof null === 'object'` 判假）⇒ facts.json 里 3 条 null **全部放行** | `index.js:8972` |
| 4 | **`ttl: 0` = 永不过期** ⇒ 4 条全放行 | `index.js:8971` + `fact-store-pre.js:60` |
| 5 | **换行未归一化** ⇒ 一条 fact 可**伪造 `## ` 标题**，并被 `compactLegacyLayer` 的分段正则当成独立段落搬运 | `index.js:8981` · `4796` `if (m = ln.match(/^##\s+(.+)$/))` |
| 6 | **失败静默**：`catch (_) {}` 吞异常（`:8987`），而 `hubFlushSave()`（`:8989`）**在 try 外无条件执行** ⇒ 失败也照写 flush-state | `index.js:8987` / `8989` |
| 7 | **`flushed` 与 `count` 不自洽**：`cur.includes(subj)` 分支只置标记、不 `count++`；且该标记**永久生效**；且判定用**子串**（短主语易误命中） | `index.js:8980` vs `8984-8985`；`8962` 只清 count 不清 flushed |
| 8 | **`flushed` 永不清理** ⇒ 实际语义是「**生命周期总量 8 条**」而非「每日 8 条」；**归档后永不重写**（单向） | 全仓无 `flushed = {}` / `delete` |
| 9 | **无重入保护**（`void hubFlushTick()` 无 in-flight 标志）；文件不撕裂靠 docStore `_queue` 串行，但 **flush-state.json 是裸 `writeFileSync`，无锁无 tmp+rename** | `index.js:8993` · `8895` · `memory-writer-pre.js:345-352/506` |
| 10 | **`hubFlushSave` 写失败被静默吞** ⇒ 重启后 `flushed` 回退 ⇒ **已写过的 fact 会被再写一遍**（正文出现两个同名 `（M8 固化）` 段落） | `index.js:8895` `catch (_) {}` |
| 11 | **可观察性缺失**：成功有 `diag`（`:8986`），**失败零留痕**（无 diag、无 degrade 台账，对照 `:5853` note-status 路径**有** `_degradePre.record`）；`overview()` **不含 flush 字段** ⇒ 面板看不到「写了但没成功」 | `index.js:8986/8987` · `memory-hub-pre.js:192-220` |

### 2.4 待查清单（子代理 B 仍在运行）

**子代理 B —— `MEMORY.md` 全部写入者普查**（`88b6427e-5393-42b7-bed3-ee46c41e8ce6`）：完整清单 → 是否走事务 → `memoryId` 命运 → sidecar 与 digest → 风险面 → 现有防线。

---

### 2.5 ★★★ ⑩-b 完整取证报告（2026-09-19 已完工）

> **`docs/internal/ISSUE10B-FORENSICS-20260919.md`** ← **⑩-b 的唯一权威文档，压缩后读它**

**两份只读子代理普查 + 主代理逐条复核的合并成果**，含：

| 节 | 内容 |
|---|---|
| §0 | 一句话结论：**20 条写入通路 × 3 条消费管线，中间没有任何联合把关** |
| §1 | 三条消费管线（注入 / L0 / 语料）+ 语料的两级静默丢弃 |
| §2 | **20 条写入者全景表** + 清洗门只接 5 处 + **全自动无人值守的只有 3 条** |
| §3 | 三个致命结构问题：`stripAnchorLines` 承诺不成立 · `reuse` 是死代码 · `sanitizeReservedSyntax` 去功能化 |
| §4 | M8 回写通路：**已引爆的物证** + 六道过滤 + 五个失败模式 |
| §5 | **主代理对子代理的三处纠正**（含「默认裸写」对用户本机不成立） |
| §6 | 风险排序 + **「自我放大回路」** |
| **§8** | **★ 修复方案 T0/T1/T2/T3 四层 + 执行顺序 + 验收标准 + 风险回退** |

**★ 最重要的一条新增认知（§6）**：
```
记忆文件字节 → m4-corpus-pre.js:118 取 text → index.js:8943 作 fact.subject
                                    ↓
                         hubFlushTick:8982 写回 MEMORY.md
                                    ↓
                         （若脏）再次成为语料来源 → 再固化 → 再写回
```
⇒ **脏数据不只是「落盘」，它会自己繁殖。**

**★ 关键洞察（§8.1）**：**⑩-a 的修法就是 ⑩-b 的主要止血手段** ——
`crossFeed` 的 fact 分支未清洗，既是 §1.2（面板乱码）的成因，也是 M8 回写脏数据的源头。
**给 fact 通路补清洗器，同时解决 ⑩-a 与 ⑩-b 的脏源。**

**★ 诚实评估紧急度（§8.0）**：急性事故已发生完毕（4 条已写入并归档），**没有持续流血**；
但 `count` 每日清零、`flushed` 永不清零 ⇒ **每个记忆日仍有最多 8 次脏写入机会**，且脏源持续产生。
⇒ **中高优先，应在进入前端前修完**（与用户既定节奏一致）。

### 2.4 取证完成后的流程（用户指定）
1. 把「它是什么、怎么干、会造成什么隐患」讲清楚 → **用户看**
2. 用户与 agent 一起定修法 → **修复方案再落一次盘**
3. 用户压缩上下文 → **agent 一次修完**

---

## 3. 执行序（用户 2026-09-19 指定）

```
[1] 本规划落盘                                    ← 本文件
[2] ⑩-a 修复（面板清晰可懂）                      ← 先做
[3] ⑩-b 全量取证（子代理，只读）                  ← 已并行启动
[4] 向用户讲清 ⑩-b 的隐患 → 用户拍板修法 → 修法落盘
[5] 用户压缩上下文 → agent 修完 ⑩-b
[6] 走到前端方向
[7] 前端做完 → GLM 全量审核 + 白皮书
```

**注意**：⑩-a 与 ⑩-b **不互相阻塞**：⑩-a 不碰 `MEMORY.md`、不碰语义语料。
但 ⑩-a 的清洗器修复**会降低 ⑩-b 的风险**（脏 fact 不再新增）。

---

## 4. 与最终方向的关系（用户 2026-09-19 补充）

⑩-b 不是孤例，而是**一类系统性风险的一次样本**：本插件的「记忆」不是普通文件，
而是**注入面 + 检索面 + 语义面共用的同一份字节**。任何一处写入都要同时满足三套约束，
而**历史实现是分批长出来的**——于是「一处漏清洗」「一处绕过事务」这类雷会零散地留着。

⇒ 这正是**前端做完后必须做「GLM 全量审核 + 白皮书」**的原因：
**逐条修只能清掉已知的雷，全量审核才能把「还有多少雷、都分布在哪一类通路」讲清楚**，
白皮书则是把这套「三面共用一份字节」的约束**显式成文**，让后续任何写入者都不再靠口口相传。
