# M-CM7 · 交接分层检索（Handoff Layered Retrieval）

> 写于 2026-09-08，基于 v2.2.5 源码。上位：[M-CM-PLAN.md](M-CM-PLAN.md)、[S3-TARGET-ARCHITECTURE.md](S3-TARGET-ARCHITECTURE.md)。
> **问题**：接续已从"能不能接上"推进到"接得准不贵"。当前新窗口消费旧内容只有"全量读"与"机械截断"两档，缺中间层。本文探索把分层检索引入**衔接通路**，并给出实施规划。
> 借鉴来源：OpenViking 公开 README 与论文 VikingMem（arXiv:2605.29640）所描述的分层上下文与目录递归检索机制；**不涉其源码**（AGPLv3，见 S1 §0）。

---

## 1. 问题陈述：分层已存在，缺的是"逐层收敛"

> **2026-09-08 修正**：初版判断"没有分层"有误。源码核对确认 `buildContinueCarry()`（`index.js:2126`）**已实现四层材料**，`index.js:2159` 明确声明：
> `第0层=指令+白板 / 第1层=交接账本（四段式） / 第2层=近期线程（最近 20 条，单条上限 700 字） / 第3层=完整转写（按需 read）`，总预算 `carryText.slice(0, 18000)`。
> 因此本节重写：**问题不是没有分层，而是分层被用作"全量平铺"，而非"逐层收敛"。**

### 1.1 三处使分层失效的实现

| # | 位置 | 现状 | 后果 |
|---|---|---|---|
| **A** | `:2158` 首条指令 | 「请**先读完**下面的交接材料恢复上下文」 | 分层形同虚设——指令要求全读，模型不会选择性取用 |
| **B** | `:2168` 第3层指引 | 「**接续前先用 read 工具读取该转写文件**」 | **最大开销点**：强制全量 read 完整转写，分层退化为一刀切全读 |
| **C** | `:2161-2162` 各层截断 | `plan.slice(0,3000)` / `ledger.slice(0,8000)` / 总 `slice(0,18000)` | 仍是**位置截断**：账本内权重最高的段落（失败原因）可能因排在尾部被整体截掉 |

### 1.2 缺的三样（真正的增量）

分层（layered material）解决的是"材料怎么组织"，尚未解决：

1. **锚点索引层**——缺一张"有哪些可下钻的锚点"的廉价地图。当前模型看到的是内容本身，不是索引，因而无从选择。
2. **段级权重**——四层按**类型**分，未按**重要性**分；账本内部四段无权重，截断时截的是位置。
3. **按需下钻契约**——当前是"材料全部预置 + 强制 read"，缺少"先看索引 → 只取所需 → 确需才读原文"的调用约定。

**一句话**：已有分层是**静态组织**；M-CM7 要补的是**动态收敛**。

### 1.1 同一失效模式的第三个实例

值得单独指出：`lib/ws-overview-rank.js`（M10，2026-09-08）修复的问题——`discoverWorkspaces().slice(0,8)` 按**字母序**取样，导致真正带记忆的活跃工作区排在 20 位开外、永远进不了跨工作区总结——与本项目另外两处已知缺陷同源：

| 位置 | 表现 | 出处 |
|---|---|---|
| `shadow-retrieval-pre.js:248` | `queryTerms` 按字典序截断 | S2 问题 1 |
| `ws-overview-rank.js` | 工作区按字母序取样 | M10 issue #24 |
| **handoff 注入** | 按字符位置截断 | 本文 |

**结论**：机械顺序（字典序 / 位置序）代替相关性选择，是本项目反复出现的失效模式。M-CM7 的方案应作为这一模式的统一解法，而非单点修补。

---

## 2. 借鉴：OpenViking 分层检索的精确机制

据公开 README 与 VikingMem 论文，其分层上下文包含三个要点（均为公开描述，非源码）：

1. **三层表示**：写入时每条内容生成 L0 摘要（~100 tok）/ L1 概览（~2k tok）/ L2 原文，按需加载。
2. **目录亦分层**：**目录本身携带 L0/L1**（`.abstract` / `.overview`），使 Agent 在读取完整文件前即可判断该目录是否值得下钻。
3. **目录递归检索**：向量检索先定位得分最高的**目录**，再**逐层向下钻取**；返回结果自带周围上下文。

**可迁移的核心命题**：**每一层是一个独立的检索空间**，检索过程是在层间逐层收敛候选，而非在单一扁平空间里做一次 top-k。

---

## 3. 方案设计：HLR 三层结构

为交接语料（PLAN 白板 + 四段式 ledger + 旧会话转写）建立三层，每层一个独立检索空间。

### L0 · 锚点层（Anchor Layer）

- **内容**：每条交接记录的**结构化锚点**，而非自由摘要。固定 schema：
  ```
  [id] 时间 | 状态标记 | 目标(1行) | 下一步(1行) | 关键实体(文件/命令/错误码)
  ```
- **体量**：每条 ≈ 80–120 tok；N 条全量常驻
- **检索空间**：词法为主（实体、错误码、文件名精确匹配优先）+ 轻量向量
- **注入策略**：**全量固定注入**

### L1 · 段落层（Section Layer）

- **内容**：四段各自的完整内容（`## 任务状态` / `## 目标` / `## 已试方案与失败原因` / `## 进度与下一步`），每段 ≤5 行
- **体量**：单次下钻 ≈ 150–400 tok
- **检索空间**：词法 + 语义双臂（复用 C1/C2/C3）
- **取回方式**：工具调用 `memory_recall(scope=handoff, layer=L1, q=…)`

### L2 · 原文层（Raw Layer）

- **内容**：完整账本原文、旧会话全量转写（`prev-session-*.md`）
- **取回方式**：现有 `read` 工具 / `memory_recall(scope=sessions)`
- **改造点**：转写应**分块并索引**，而非整体 read（见 §5 H5）

### 3.1 层间收敛流程

```
新窗口启动
  └→ 注入 L0 锚点表（全量、字节稳定）
       ↓ 模型读锚点表，判断相关锚点
       ↓ 按需：memory_recall(scope=handoff, layer=L1, q=…)
       └→ 命中段落（150–400 tok）
            ↓ 确需细节
            └→ L2 分块读取（分块号，非整体）
```

---

### 3.4 为什么"纯解析生成锚点"不可行（实际产物核对，2026-09-08）

对 `~/.dsh/memory/workspaces/--D--dsh-auto-memory--/handoff/` 实际产物抽样后的结论：**账本与白板是"给人读的自然语言文档"，不是"给机器检索的结构化索引"**，二者职责冲突。四条证据：

**(1) 段内异质——按段加权必然错配**

`handoff-20260908-142730.md` 的「已试方案与失败原因」三条**全部是成功的解法**（PAT push 成功 / npm publish 退出码 0 / 分叉后纯 FF 合并），并非失败。若按段标记权重，会把**高价值的成功经验**误标为"失败原因"——语义即错。

**(2) 单段内混装多种性质的内容**

同文件「进度与下一步」同时包含：
- `[x] 2.2.1 发布`（已完成 → 实为**状态**）
- `[ ] 下一步第一步：console 打 client ctx…`（真正的**待办**）
- `[ ] 史实以白板「史实更正」段为准`（**元指令**，非待办）

段级权重（`失败原因 .35 / 下一步 .30 / …`）加在**整段**上，而段内有价值密度差异极大的条目——加权对象错误。

**(3) 写入侧污染：标题重复**

抽样最近 6 个账本，**3 个含两个 `# 交接账本` 标题行且时间戳不一致**（如 `handoff-20260908-142730.md` 首行 14:27、第三行 15:00）。纯解析取首个标题将得到错误时间戳，且破坏后续所有段解析。

**(4) 白板退化为日志**

`PLAN.md` 并列堆积 2.2.5 / 2.2.6 发布状态与历史踩坑记录，无老化机制。白板的定位是"当前树"（git tree），实际写成了"提交日志"（commit log）——Hindsight 的「ONE OBSERVATION PER DISTINCT FACET / PRESERVE HISTORY」要求二者**分离存储**，当前实现未分离。

**结论**：锚点索引不能从现有文档**解析**得出，必须在写入侧**另行产出**——即 §3.5 的 **G2′**（结构化 sidecar）。这与 OpenViking 的 `.abstract` / `.overview` **sidecar 与正文分离**是同一设计。

---

## 3.5 改造清单：现在要改的六处（精确到行）

> 全部针对 `buildContinueCarry()`（`index.js:2126-2192`）。按 ROI 排序，**G1+G3 合并即为最小可交付增量**——仅改两处文案/指引，即可消除"强制全读"，零结构风险。

### G1 · 首条指令：从"先读完"改为"先看锚点，按需取"

**位置**：`index.js:2158`

| | 文案 |
|---|---|
| 现 | `接续上一会话的任务。请**先读完**下面的交接材料恢复上下文，然后直接继续推进未完成事项，不要重新开始。` |
| 改 | `接续上一会话的任务。先读第0层的**锚点索引**判断需要哪些上下文，再按需取用对应层，然后直接继续推进未完成事项，不要重新开始。锚点索引不足以判断时才 read 完整转写。` |

**理由**：分层失效的根源在指令——模型被要求全读，就不会选择性取用。

### G2′ · 结构化锚点 sidecar（**取代原"纯解析"方案**）

> **2026-09-08 第二次修正**：原 G2 设想"从账本四段纯解析生成锚点"，经实际产物核对**不可行**。证据见 §3.4。改为**写入侧产出结构化 sidecar**。

**方案**：修改 `refreshRitualPrompt()`（`index.js:2049`），要求 AI 在写账本的**同一轮**内，额外产出一个结构化锚点块，写入 `handoff/anchors-<ts>.json`（或账本末尾的 `<!-- anchors … -->` 注释块）。

**为何是同一轮**：AI 此时持有完整上下文，且**不新增 LLM 轮次**——这是满足 0.75 时序约束（§7.3）的关键。

**锚点单元 schema**：

```json
{"v":1,"items":[
  {"id":"a1","type":"solution","w":0.9,"text":"npm publish ENEEDAUTH → .npmrc 钉 registry+令牌",
   "refs":["npm","publish","ENEEDAUTH"],"st":"stable"},
  {"id":"a2","type":"todo","w":1.0,"text":"console 打 client ctx 确认 remote 服务真实挂载名",
   "refs":["client.js:45","remote.session"],"st":"volatile"},
  {"id":"a3","type":"meta","w":0.8,"text":"史实以白板「史实更正」段为准","refs":[],"st":"stable"},
  {"id":"a4","type":"status","w":0.4,"text":"2.2.1 发布全链路完成","refs":[],"st":"volatile"}
]}
```

| 字段 | 说明 |
|---|---|
| `type` | 枚举：`solution` 解法 / `failure` 失败 / `todo` 待办 / `meta` 元指令 / `status` 状态 / `goal` 目标 |
| `w` | **AI 自评权重**（0–1），非硬编码——避免段级加权的错配 |
| `refs` | 文件名 / 错误码 / 命令 / 行号，供词法精确匹配下钻 |
| `st` | 稳定性：`volatile` / `stable` / `superseded` |

**排序**：`type` 基础优先级 × `w` × 新近度（见 §4.2），**非时间序、非字符序**。

**落地要点**：
- 解析为**纯函数**（`lib/handoff-anchor-pre.js`），schema 校验 fail-soft：sidecar 缺失或非法 → 回退到现有四层平铺，**绝不阻塞接续**。
- 每个锚点给出 `refs`，使下钻可走 `memory_recall(scope=handoff, ref=…)` 精确定位，而非整篇返回。

### G2″ · 顺带修掉两个写入侧缺陷（数据源头质量）

| 缺陷 | 证据 | 修法 |
|---|---|---|
| **账本标题重复** | 最近 6 个账本中 3 个含 2 个 `# 交接账本` 标题行且时间戳不一致（如 `handoff-20260908-142730.md` 为 14:27 / 15:00） | 追加写入前检测已存在标题，存在则跳过；或改为解析时取**最后一个**标题 |
| **白板退化为日志** | `PLAN.md` 并列堆积 2.2.5 / 2.2.6 发布状态与历史踩坑记录，无老化机制 | 白板区分「当前状态」与「历史」；历史在版本收敛后移入 `handoff/archive/`（复用现有 PLAN 归档机制） |

> **⚠️ 附带风险（需确认）**：`PLAN.md` 中存在形如「PAT 在 `D:\dsh_debug\.dsh-memory\MEMORY.md` 第 5 行(93 字符)」「npm 令牌 `npm_ViUqqR5p…`（MEMORY.md 第 24 行）」的凭证**位置与前缀**描述。`stripSensitiveSections` 是否覆盖此类"指针式"泄露（非完整令牌但指明位置与前缀）**需实测确认**；若未覆盖，应在写入侧门禁（`sanitizeForWrite`）增补规则——这与 README「凭证永不进提示词」的承诺直接相关。

### G3 · 第3层：去掉"接续前必须先 read"（**最大 token 节省点**）

**位置**：`index.js:2168`

| | 文案 |
|---|---|
| 现 | `- **接续前先用 read 工具读取该转写文件**，以完全理解旧会话的讨论、结论与未竟事项；任务推进中也可随时回读。` |
| 改 | `- 完整转写已归档：{path}。**仅在锚点索引与第0–2层不足以推进时才 read**；优先用 memory_recall_pre(scope='sessions', query='关键词') 定位片段，避免整篇读取。` |

**理由**：当前实现强制全量 read，分层在此处退化为一刀切。

### G4 · 账本按段解析 + 权重化截断

**位置**：`index.js:2162`（`ledger.slice(0, 8000)`）

改为三段式处理：
1. **解析**：按 `## 任务状态` / `## 目标` / `## 已试方案与失败原因` / `## 进度与下一步` 切分；
2. **加权**：失败原因 `0.35` > 下一步 `0.30` > 目标 `0.20` > 任务状态 `0.15`（理由见 §4.2）；
3. **截断**：预算按权重分配，**不足时从最低权重段开始截**，而非截尾部。

**效果**：直接消除"权重最高的失败原因因排在尾部被整体截掉"这一故障模式。

### G5 · 分层预算取代总截断

**位置**：`index.js:2183`（`carryText: parts.join(NL + NL).slice(0, 18000)`）

改为：锚点表**固定不截** + 各层独立预算（白板 3000 / 账本 8000 / 线程按条）。避免在高权重内容之前被低优先级内容耗尽总预算。

### G6 · 抽纯函数核心 + smoke 锁定

新建 `lib/handoff-anchor-pre.js`（纯函数、零 IO、IO 注入，遵循项目 `*-pre.js` 惯例），导出：

| 函数 | 职责 |
|---|---|
| `parseLedgerSectionsPre(text)` | 四段解析，返回 `{status, goal, failures, next}` |
| `buildAnchorIndexPre(sections, meta)` | 生成锚点表（权重 × 新近度排序） |
| `rankSectionsByWeightPre(sections, budget)` | 权重化预算分配与截断 |

配套 `tests/smoke/smoke-test-handoff-anchor-pre.mjs`，用 fixture 锁定解析与排序——与 `ws-overview-rank.js`（M10）同一范式。

---

## 4. 关键约束与解法

### 4.1 前缀缓存纪律（硬约束）

**风险**：按相关性动态选择注入内容，会破坏"已发请求不可原地改写"的字节级稳定纪律。

**解法（关键）**：
- **L0 锚点表全量固定注入**——不随当前任务变化，因此字节稳定，前缀缓存不失效。
- **下钻全部经由工具调用**——工具返回值在后续轮次追加，不改写已注入前缀。这与既有架构"静态纪律层 + 动态快照 + 按需取回"完全同构。
- **净效果**：模型由"被动接受截断"转为"主动按需取回"，而前缀缓存纪律不动摇。这是本方案成立的前提，也是它优于"按任务动态裁剪注入"的原因。

### 4.2 权重从何而来

用户提出"权重分析"诉求。交接内容的权重应**领域化定义**，而非通用相关性：

| 段落 | 权重理由 |
|---|---|
| **已试方案与失败原因** | **最高**——死路信息不可从其他处推导，重复踩坑成本最高 |
| **进度与下一步** | 高——决定新窗口的第一个动作（refreshRitual 已要求"下一步必须是可直接执行的第一步，带文件路径或命令"） |
| **目标** | 中——稳定少变 |
| **任务状态** | 低——易过期，且常可由进展反推 |

补充三个权重因子（沿用 S3 §3.3 的稳定性维度）：
- `stability`：`volatile`（进行中）/ `stable`（已决定）/ `superseded`（已被取代）
- `recency`：最近一次交接优先
- `explicit`：含可执行命令/文件路径的段落加权

**L0 锚点表按 `importance × recency` 排序输出，取代当前的时间序/字符序**——这直接消除 §1.1 的机械顺序问题。

### 4.3 生成时机

| 方案 | 优劣 |
|---|---|
| **写入时生成**（推荐） | `refreshRitualPrompt()` 已在旧会话末触发一次 LLM 调用重写 PLAN + 写 ledger（`:2049`），**顺带产出 L0 sidecar 的边际成本极低**；且写入时上下文最完整 |
| 读取时惰生成 | 省写入成本，但新窗口首次访问有延迟，且丢失旧会话上下文 |

**结论**：写入时生成，与 refreshRitual 同批次产出。

---

## 5. 实施规划

| 阶段 | 内容 | 依赖 | 验收 |
|---|---|---|---|
| **H1** | 交接语料结构化：ledger 四段解析 + L0 锚点 sidecar 生成（写入时，过 `sanitizeForWrite`） | 无 | 解析正确性 + 门禁不回退；smoke 函数级 |
| **H2** | L0 锚点表注入新窗口（替代现有 PLAN≤1200 + ledger≤800 机械截断） | H1 | 注入字节稳定性断言；token 账本对比（改造前/后） |
| **H3** | `memory_recall` 增 `layer` 参数（L0/L1/L2），支持段级下钻 | H1 | 段级召回正确性；沿用 M5 cite 规范 |
| **H4** | 权重层：importance × recency × stability 排序，取代机械顺序 | H2 | 排序回归锁定（纯函数 + fixture） |
| **H5** | 旧会话转写分块 + 索引，L2 改为按块取回 | H3 | 分块不丢信息；按需取回 token 下降 |
| **H6** | 可观测与评估：衔接成功率、下钻率、token 开销三指标 | H2–H5 | 见 §6 |

**建议起点**：H1 + H2 合并为第一个可交付增量——它已经能消除"机械截断"这一最痛点，且不动检索路径。

---

## 6. 评估指标（与 S1 科学性补强同源）

| 指标 | 定义 | 用途 |
|---|---|---|
| **衔接成功率** | 新窗口 AI 正确回答"上一窗口在做什么 / 下一步是什么"的比例 | 主指标 |
| **死路继承率** | 新窗口是否复述出"已试过的失败方案" | 衡量最高权重段落是否生效 |
| **下钻率** | 需要下钻到 L1/L2 的任务占比 | 验证 L0 是否足以支撑多数任务 |
| **注入 token** | 新窗口启动时的固定注入开销 | 对比改造前（H2 前后对照） |
| **总 token** | 注入 + 下钻之和 | 验证"分层是否真的更省" |

**实验设计**：构造 N 组"旧窗口做完 → 新窗口接手"的对照任务，分别跑 ①现有固定截断 ②全量 read ③HLR 三层，比较上表指标；配对 bootstrap（B=2000）给差值 CI——**直接复用 M7 的 held-out 评估通路**。

---

## 7. 风险与边界

1. **L0 摘要质量决定上限**：锚点写错，下钻方向即错。缓解：固定 schema 填空（弱模型也能填），不靠自由摘要；并对锚点做可审计落盘。
2. **生成成本**：每次 refreshRitual 增加一次结构化输出。缓解：与现有调用同批次，非独立调用。
3. **与官方压缩的边界（阈值 0.75）**：`waterLevelThreshold` 与 `autoContinueThreshold` 已于 2026-09-08 **同步下调至 0.75**（`index.js:229` / `:237`），理由是**官方自动压缩阈值为 80%，必须留余量**（commit `ca75808`）；`c4912f4` 进一步把水位检查补到 pre-step，使交接与官方压缩站同一条边界。
   - **对 HLR 的约束**：L0 锚点注入与 refreshRitual 触发**必须发生在 0.75 之前完成**，不得因新增的锚点生成步骤而推迟到 80% 之后——否则交接将被官方压缩抢先，助产失效。H1 的 sidecar 生成是纯文本处理（非 LLM 独立调用，与 refreshRitual 同批次），应据此验证其不引入新的时序风险。
   - 边界重申：**仍只在 host 允许的时点助产，不替 host 决定压缩**。
4. **不重复造轮子**：L1 检索直接复用 C1/C2/C3 三引擎与现有融合，不新建检索通路。

---

## 8. 一句话

**接续的下半场不是"把更多旧内容塞进新窗口"，而是让新窗口先看到一张便宜的地图（L0 锚点表），再自己决定走哪条路——而这张地图必须是固定的，否则前缀缓存就没有了。**
