# dsh-mneme 存储无损回收（#275 第一批）

## 概览

库大了以后有两类内容占着体积却零信息增量：

- `dream_runs.input`：每次巩固时的**记忆库快照**，可由记忆库重建；
- 归档行的向量：检索 SQL 恒带 `archived = 0`，按定义不可达。

这个入口把这两样回收掉。一条记忆都不删、一次价值判断都不做。

> #399 起多回收第三样：`distill_records`（蒸馏原文留存，默认关）。它同样是「留着只占体积」——原文的回查价值随保留期衰减，但**溯源链接刻意不删**（链接是记忆的元数据，原文才是可弃的缓存）。见 [蒸馏原文留存](DISTILL.md)。

它**不自动跑**：几项都是不可逆的内容丢弃，只在人看得见数字的时候按下（维护者 2026-09-23 拍板：不挂启动路径、不开 `auto_vacuum`）。

## 用法

```bash
dsh-mneme reclaim                              # dry-run：会清几行、库多大，一个字节都不改
dsh-mneme reclaim --apply                       # 执行清理（不带 --vacuum 文件不会变小）
dsh-mneme reclaim --apply --vacuum              # 清理 + 整库 VACUUM
dsh-mneme reclaim --older-than 30 --apply       # 输入快照保留窗口 30 天（默认 7；0 = 全部）
dsh-mneme reclaim --json                        # 原始 JSON，给脚本用
```

前置：插件设置里打开「外部 API」（默认 `http://127.0.0.1:8790`），token 用 `dsh-mneme config set <url> <token>` 配一次。命令走 standalone 数据面 `POST /maintenance/reclaim`；服务端要求显式 `confirm`，不带就是干跑。

## 清什么、不清什么

| 对象 | 动作 | 为什么零损失 |
|---|---|---|
| `dream_runs.input`（窗口外） | 置空 | 它是当时的记忆库快照，可由记忆库重建 |
| `dream_runs` 的骨架 / `decisions` / `outcome` / `receipt` | 不动 | 不可重建：拆了就回放不了那次裁决 |
| `dream_runs.raw_output` | 不动 | 失败轮的诊断摘要（#534）：收到文本但解析或校验失败时留下的原文头尾，只在那一次调用里存在过，日志滚掉就再也拿不回来。**它不参与回放**——删了不会让保留的决策放不出来，只是丢掉「模型当时到底吐了什么」这条证据；无正文的流错误本来就是 `NULL` |
| `memories.embedding`（`archived = 1`） | 置空 | 检索恒带 `archived = 0`，按定义不可达 |
| `memories` 的行 | 一条不删 | 本入口止体积不止条数；条数与重复归 #254 与整理动作面 |
| 活跃行的向量 | 不动 | 检索正在用 |
| `distill_records`（窗口外，#399） | 删行 | 蒸馏原文留存是 opt-in 旁路索引；到期即弃。**`memory_provenance` 链接保留**指向空区间（面板显示「来源已过期」），记忆与链接都不删——原文本就是「回查用」的，保留期到了就该走 |

`distill_records` 只在 `distillRecords.enabled=true` 时才会有行（默认关，连收集都不做），所以对大多数库这一项恒为 0。**曾经开过再关的库有行**：关掉开关只是不再写、也不再经 HTTP 读出去，已留的行一直躺在库里，直到 `dsh-mneme reclaim`（不读这个键）或重新开启后的启动裁剪（见 [蒸馏原文留存](DISTILL.md)）。

取消归档时（`memory_archive` 还原）服务端会重新排队嵌入，所以被回收的向量能拿回来。

## 代价与实测

VACUUM 是 O(库大小)，且需要排他写锁：库越大越慢，期间的写要等锁。

实测方法：把一份真实库**复制**到临时目录后跑本入口（不动活库），命令即 `reclaim` 的 dry-run 与 `--apply --vacuum`。样本是一次单机实测，不代表全部用户。

| 项 | 读数 |
|---|---:|
| 清理前磁盘足迹（主文件 65.5 MiB + WAL 3.9 MiB） | 69.5 MiB |
| 清 `dream_runs.input` | 177 个 run，列文本 7.24 MiB |
| 清归档行向量 | 295 行，列文本 3.04 MiB |
| 清理 + VACUUM 后足迹 | 50.8 MiB（降 18.66 MiB，约 27%；页数 16800 到 12983） |
| VACUUM（含收尾 checkpoint） | 1.2 s |
| dry-run | 48 ms |

两点值得注意：体积降幅**大于**列文本合计（7.24 + 3.04 MiB），差额来自溢出页与索引页的回收——这就是报告口径按「VACUUM 前后体积」而不按列字节估的原因；另外体积量的是**磁盘足迹**（主文件 + `-wal` + `-shm`），WAL 模式下只量主文件会量到「还没落盘」的旧值。

报数字以自己这次的 dry-run 与 apply 输出为准（dry-run 里的列文本大小只是上界，空闲页字节是「立刻能收回来」的那部分）。

## 已知坑

- `--vacuum` 必须配 `--apply`：dry-run 不改数据，也就没有可回收的页。
- 只清理不 VACUUM，文件不会变小（空闲页留在文件里）；体积收益要等一次 VACUUM。VACUUM 内部收尾会跑一次 `wal_checkpoint(TRUNCATE)`，否则 WAL 模式下主文件可能一动不动。
- VACUUM 拿不到排他锁（别的进程占着库）会失败：**清理已经完成且不可逆**，命令会照旧打印「清了 N 行」并把失败原因写进报告与回执，重跑一次 `reclaim --apply --vacuum` 即可（清理幂等）。
- 窗口是每次调用各自按当时的时间算的：`--older-than 0` 这类窗口在 dry-run 与 apply 之间新建的 run 也会被算进去。要严格对齐就先看数字、紧接着执行。
- 还原归档行后需要一次重嵌入才回到向量检索（排队的语义与写入路径一致：事务中或嵌入器未就绪会延后）。
- `llmAudit.enabled=false` 时回执不落库（那个开关连审计行的启动期清理一起关掉），但清理照做。
- 窗口内（默认 7 天）的输入快照不动：近期 run 仍可能在离线回放里被用到。
- `distill_records` 的窗口用同一个 `--older-than`（默认 7 天），但它的**保留期另有一处**：`distillRecords.retentionDays`（默认 90）在插件启动时裁剪。两处不冲突（启动裁剪是常态、reclaim 是手动瘦身），但别指望只调其中一个就能同时改两边——想一次对齐就把 `--older-than` 显式传成与 `retentionDays` 相同的值。裁剪只删原文，溯源链接留着（详见 [蒸馏原文留存](DISTILL.md)）。
- `run_type='organize'` 的行任何窗口都不清：它们的 `input` 存的是 apply 的重放载荷（`src/organize.js` 直接读它重建候选索引），不是可重建的快照。置空会让那次 apply 静默空转并把报告锁死。

## 升格吸收的 evidence（#230 × #275）

`memory_register_document` 注册成功后，这份文档引用的 evidence 行会**在同一事务里**翻成归档（absorbed）。一次吸收 20 条，活跃面就少 20 行——而不是「21 行不是 1 行」。

- 只翻标志位：内容、`content_history`、审计行一条不删，随时可以还原；
- `constraint` / `preference` 永不自动归档（#249 的逐字保真池），要归档得手动来；
- 只吸收**原子条**：别的 `document` 行与 `summary` 行不碰——前者本体是磁盘上的文件（归档它会让同一路径下次注册留下第二行），后者是 dream 总览与叙述（按 source 身份去重、按注入档位常驻）；
- 想保留活跃面：工具带 `keep_evidence_active: true`（注册器参数是 `archiveEvidence: false`）；
- 重注册同一份文档（出新版）时，那些已归档的 evidence 行仍可为**它所属的那份文档**背书；换个文档拿归档 id 当证据，照旧按捏造拒绝。

## 第五指标：归档净增速率 + 可压掉行数

面板「记忆复用」卡与 `GET /api/dsh-mneme/recall-stats` 的返回里多了一个 `archive` 块（#275 拍板：与既有指标同位）：

- `total` / `addedInWindow` / `perDay`：归档区现有行数、窗口内新进的、以及日均净增速率。归档时刻取**精确列 `archived_at`**（#275 第二批落地）：`setArchived` 只在「活跃 → 归档」那一次落值，取消归档清 `NULL`，重复归档不刷新（幂等）——重复归档不是新一次归档，刷新它就会让「对同一行反复调 `setArchived(true)`」又变成一次净增。本指标 v1 曾用行的 `updated_at` 代理（`setArchived` 翻标志位时刷它），偏差方向是**偏高**：对已归档行做一次普通更新，或重复归档，都会被算成「本窗口新进」。`archived_at` 就是为消灭这两个假阳性而加的，读侧**不要**再退回 `updated_at`，也不要写 `archived_at ?? updated_at` 兜底；
- `undated`：`archived_at` 为 `NULL` 的归档行数——本列落地前就已归档的**存量行**，时刻不可追溯。**刻意不计入 `addedInWindow`**：迁移当刻，窗口内的存量归档行同样是 `NULL`，既无法证明它在窗口内、也无法证明在窗口外。三种处置里选了这个：用 `updated_at` 顶替 = 走回被推翻的代理口径；按 0 充数 = 把「不知道」伪装成「没有」；单列计数让读侧自判可信度，宁少不脏。迁移**不回填**存量行，理由同上——回填等于把代理口径的偏差（偏高）固化成看不见的脏数据，留在 `NULL` 至少是「已知的不可知」；
- `compressible.rows` / `.groups`：**可压掉行数**——同 type、同 scope、内容归一化后逐字节相同的归档行里，每组留下的那一行之外的那些。判据用内容哈希（与写入准入同一把尺），**不**建在向量近重复上：回收动作本身就会清掉归档行的向量，指标不能指望自己的输入还在。

`archived_at` 只在 `setArchived` 与「入库即归档」（`store.save({archived:true})`，时刻 = 入库时刻）两处落值；`update()` 的 patch **不能**写它（否则给精确口径开了绕过幂等/清空语义的后门）。`updated_at` 的既有语义不动——它仍是「最后一次写」，面板排序、快照哈希、CAS 都读它。

这是观察口，不是动作触发：真删仍属第二批（未立项）。

## 与 #254 的分界

本入口止体积、不止重复。归档区里同主题条目堆积是**出口**问题，归 #254 的写入准入与后续整理动作面；这里一条记忆都不碰（维护者拍板 4）。
