# Changelog

## 0.18.1

**一处说明文案的返工。** 面板「说明」页签里「总混进不相关的东西」那行，原本举了个具体档位当例子
（「如 4.6 或 6」）—— 那是某一台机器上代价曲线恰好有的档，别人的库不一定有；照着一个不存在的档位
调 `minScore`，正是这次走查报回来的那类问题（说明页指路指到看不见的地方）。现在只指路、不举例：
抬到哪一档、抬了会丢掉几条「曾被用上过」，都在「用上了多少」页签的代价曲线里逐档列着。

## 0.18.0

**面板从「一屏仪表」改成「先回答三个问题」。** 之前的「记忆」标签页是七个页签平铺：想知道的
东西都在，但没人告诉过你该看哪一块 —— 打开率 4.1% 是好是坏、索引 80/200 要不要管、那几条
提醒后来到底有没有被用上，全得自己拼。这一版把首屏让给结论，明细退到页签里，并补上一张
术语表：**看不懂的仪表盘等于没有面板。**

一句话结论：**面板还是只读的**（一条路由、每 3 秒轮询），但把「这一页在说什么」讲清楚了。

- **首屏三问**（在页签之前，永远看得见）：
  - **这一轮提醒了什么？** —— 最近一轮的笔记、相关度，以及每条**后来有没有被翻开**：状态点
    读账本里的累计打开记录（`.engramory-recall.jsonl`），与「用上了多少」同一份口径。
  - **用上了多少？** —— 打开率 + 「给出去的第几条更容易被用上」的柱状图（虚线是中位），
    并明写它是**代理指标**：证明被翻开过，证明不了被写进回答。
  - **有没有人被拦住？** —— 写入保护拦了几次、体检几项要处理或留意（提示单独计数，
    不混进「要处理」）、索引**已用多少 / 上限多少**行（上限读配置 `indexMaxLines`，不写死 200），
    并直接说「还早，不用动」还是「快满了，该压缩了」。
- **新增第八个页签「说明」**：术语表（提醒 / 被用上 / 相关度门槛 / 抑制……）、
  「我要的功能在哪个开关上」（想关提醒、嫌吵、怕它乱改库，各改哪个键）、
  「这些数字从哪来」（账本 / 审计 / 体检 / 命令行，逐条给出处）。
- **「提醒记录」页签**：按轮次类型筛选（全部 / 提醒过的 / 没提醒的 / 拦下写入的 / 问过要不要记的）、
  复制这一轮、导出最近 20 轮（markdown 表格）；每条提醒线索前带状态点，并串到账本口径。
- **「用上了多少」页签补上消费侧门控的账**：这一窗口有多少轮记了档位（off / shadow / assist / enforce 的分布）、
  降权了多少轮多少条、够到抑制线的几条、真被移出注入的几条。老账本没有 `gate` 字段时整块不印 ——
  「没数据」不写成「0 次」。
- **体检那行的口径修了**：以前会印出「体检发现 2 项（要处理 0、留意 1）」这种加不起来的句子 ——
  总数里混着 info。现在只说「要处理 + 留意」，提示另外单独说。
- **无障碍**：页签是 `role="tablist"`、柱状图与状态点是 `role="img"` + `aria-label`
  （读屏念「第 3 条：给过 68 条，被用上 0 条」）、页签按钮有焦点环、切换时页签条自己滚进视野。
- **走查出来的问题，逐条修了**（一个不看代码的「非技术用户」拿着面板做四件事：它帮上忙了吗 /
  怎么关掉它 / 为什么这轮没提醒 / 索引 80/200 要不要管）：
  - **「还没记到它」是错的三种状态混成一句** —— 账本里没这条 id 是「不知道」，不是「没用上」。
    现在写「不知道（账本里没它）」，并且状态点除颜色外还有形状（实心＝被打开过、空心＝没被打开过、灰点＝不知道），
    色盲用户和黑白打印都分得清。
  - **想关掉它找不到门**：首屏多一个「怎么关掉它」按钮直达说明页，说明页写清改哪个文件
    （profile 的 `cordis.patch.yml`，桌面版通常是 `~/.dsh/profiles/desktop/cordis.patch.yml`），
    并说明「当前设置」卡只显示、不写配置。
  - **结论句不再硬编码**：位置曲线下面那句以前固定写「越往后越低」—— 实际数据里尾巴常常反而是最高的，
    等于拿一句模板把人带去相反的结论。现在按数据说三种话之一（尾巴归零 / 比最高档低但没归零 / 尾巴反而最高）。
  - **缺字段的地方不再印矛盾数**：「提醒过的笔记 0 篇 + 每篇都至少被用上一次」与同一张卡的
    「17 条被翻开过」打架；现在缺字段印「—」并写明这份账本没汇总这个数。
  - **时间不再假装是现在**：旧记录缺 `ts` 时印「时间未知」，不再回落到 `Date.now()`。
  - **时长说人话**：「900s」→「15 分钟」，自适应秒 / 分钟 / 小时 / 天。
  - **同一件事的三个名字统一**：页签「体检」、页内标题也叫「体检」（以前叫「自检」）；
    代价表列头改成「被砍掉且曾被用上过的条数」；归档计划行补上「为什么挑它」（宿主一直在发，只是没渲染）；
    笔记页补「共 N 篇 / 筛出 M 篇」；`minScore` 的建议改成曲线里真有的档位。
  - **术语表从 9 条扩到 20 条**：补上「可检索片段 / 打开率 / 代理指标 / 中位·p10·p90 / 配对 / 子代理 /
    失联路径 / 批次章节 / 抑制线 / 规则版本 / 宿主」这些屏上出现却没解释的词。
  - **小字对比度**：状态色与 `currentColor` 混一道（浅色主题自动变深、深色主题自动变亮），
    实测浅色主题下绿 3.39:1 / 橙 3.26:1 不达标；又用浏览器把渲染结果的每个文本节点按 WCAG 公式量了一遍，
    揪出三处叠加降透明度导致的漏网（分隔点嵌在已降透明度的行里掉到 2.9:1、「未知」状态与体检提示 3.74:1、
    配置键小字 4.1:1）—— 现在浅色/深色两套各自 **942 个文本节点全部 ≥ 4.5:1**。
  - **同一个地方的第二个叫法**：用量卡的页内标题以前叫「使用情况」，页签叫「用上了多少」，现在统一。
- **面板测试**：`test/client-panel.test.mjs` 随结构重写，仍然守着那几条老规矩 ——
  脏数据（`null` / 字符串 / `NaN` / `Infinity`）不许抛、不许印假 0、缺字段印「—」、
  英文原因码不上屏。

## 0.17.0

**跨宿主那一层补齐：同一份记忆库，别的宿主也能读得明白。** 之前的 MCP 服务器只有三个工具，
对面（Claude Code / Codex / Cursor）挂上之后得靠猜：库是陌生的、不知道有哪些笔记、也看不到
「这些提醒到底有没有被用上」。这一版把只读视图补成一套完整的入口，并把「门面」也修了 ——
README 首屏一条命令、英文版、以及第三方静态扫描给我们的能力卡。

一句话结论：**我们没有在别人的宿主里写任何东西**（写入治理只有一个家），但把「读」这件事
做到了对面不用猜。

- **`mcp.js` 431 → 777 行**，五工具 + 一资源 + 两提示：
  - `engramory_list`：清单（文件名 · 类型 · 描述 · 字符数，新的在前），`kind` 可按 user /
    feedback / project / reference 筛，`limit` 1–50 默认 20 —— 库陌生时先看这个，再决定搜什么。
  - `engramory_usage`：账本摘要（注入过几条 / 打开率 / 一个字都没进的轮次 / 按来源拆分 /
    预算用量 / 被打开最多的几篇）。
  - 资源 `engramory://index`：`MEMORY.md` 全文，客户端可以先读它再决定搜什么。
  - 提示 `recall`（先搜、再打开最相关的一篇、带着它回答）与 `curate`（把这次会话里定下来的
    事实整理成 Format v1 草稿，**交给你确认后自己写文件**）。
- **「缺数据不是 0」这条纪律进了新工具**：账本不存在 / 窗口内一行都没有 / 老行没有用量字段，
  三种情况说三种不同的话；`used` 与 `usedCounted` 两个口径都印出来并说明差别；末尾永远带一句
  「这是代理指标 —— 它证明笔记后来被打开了，证明不了被用进了回答」。
- **一个实现上的取舍（写在这里免得后人踩）**：`store.js` 的文档对象里**没有 `type`**
  （`splitNote` 只取 name / description / aliases），而类型只活在 frontmatter 原文里。为了不动
  `store.js`，`noteTypeOf()` 自己读一遍头部认 `^type:`（按 mtime 缓存）。代价是 `engramory_list`
  比别的工具多一轮文件读 —— 只在这一个工具里付。
- **测试 818 → 839 例全绿**（新增 `test/mcp-view.test.mjs` 21 例）；`mcp.js` 覆盖率
  99.23% 行 / 92.26% 分支 / 100% 函数。既有那三件套的名字、顺序、输出、错误码、协议协商与
  `--print-config` **一字未改**。
- **这一层怎么验的（如实写）**：本机没有可用的第三方已登录宿主 —— Claude Code 2.1.142 报
  `401 Invalid API key`（`~/.claude.json` 里没有 `oauthAccount`），Gemini CLI 也没有凭据。
  于是换了三条硬证据：① **官方 `@modelcontextprotocol/sdk@1.32.1` 当客户端**，连**发布出去的
  那份产物**（`npm pack` → 临时前缀安装 → 调 `.bin/engramory-recall-mcp`），5 工具 / 3 资源 +
  1 模板 / 2 提示全部走通、路径穿越被拒、全程 61 ms；② `npm run mcp:smoke` 扩到覆盖新面
  （帧序、错误码、越界读逐条断言）；③ 宿主配置片段按官方文档写，其中 Claude Code 的
  `claude mcp add engramory -- engramory-recall-mcp --store ~/memory/engramory` 一行待你在
  已登录的机器上补最后一步。
- **门面**：README 首屏加 npm / MIT / **dsh-xray 能力卡**三枚徽章、一条安装命令、`English →` 链接；
  新增英文 `README.en.md`（285 行，逐个数字标出处，算不出来源的宁可不写）。
- **第三方体检**：[dsh-xray](https://github.com/unStone/dsh-xray) 每天静态扫描 `dsh-plugin`
  话题下的全部仓库（18 697 个），**我们已被收录**（首次收录 2026-10-15，能力卡
  **C2 · 3 flags**）。README 新增「别人怎么体检我们」一节，把三条 flag 逐条对上：
  `runtime_patch` 是配置示例、`exec_tooling ×18` 与 `net_server_tooling ×2` 全在 `bench/`
  （不进 npm 包）、outbound domains 全部来自**默认关闭**的可选语义后端探针。
- **顺手修的文档漂移**：README 与 `docs/how-it-works.md` 里体检器项数还写着 12（实际 14，0.16.0
  就加了 `never-used` / `stale-project`），已改正；仓库描述与话题更新（补 `deepseek-harness` /
  `agent-memory` / `mcp-server`）。

## 0.16.0

**消费侧门控：检索回答「像不像」，门控回答「值不值得占上下文」。** 上限（`maxPointers` /
`maxChars`）只能保证窗口不爆，管不了「这条每次都来、可你从来没读过」。这一版让每篇笔记
自己拿账本上的履历证明自己的价值：**带进上下文的次数**是机会数，**被真正读过的次数**是收益。
默认档 `assist`：只把「给过足够多机会、一次没被读」的笔记往后排，**一个字都不删**。

一句话结论：这一版的重头不是加了几个档位，而是**把门槛从拍脑袋的固定次数改成了一道算术题**。

- **阈值按本库自己的基础打开率算（`confidence.js#gatePolicy`）**：在 5% 的基础率下，
  一条**和平均一样有用**的笔记被带进上下文 8 次而一次没被读的概率仍有 `0.95^8 ≈ 66%` ——
  按「8 次没被读就降权」动手就是把噪声当证据。改成「降权线 = 期望被读 1 次 ÷ 基础率」、
  「抑制线 = 期望被读 3 次 ÷ 基础率」，再与「最少携带 8 次」「观察 ≥ 7 天」取约束。
  本机真库实测：基础率 **2.86%**（560 次携带）⇒ 降权线 35 次、抑制线 105 次；
  「看着像没用」的有 20 篇，**真过线的只有 1 篇** —— 按老口径会有 19 篇纯属运气差的被降权。
- **四个档位（`gate`，默认 `assist`）**：`off` 不干预（旧行为，留作对照）/ `shadow` 只算不改、
  但把「本来会降权、拦掉谁」记进账本 / `assist` 降权（×0.5，仍可见）/ `enforce` 才把证据确凿的
  移出这一轮注入。**信任的笔记（读到的比例 ≥ 25%）任何档都不动**；**`project-` 类过期只降权、
  绝不拦**（旧不等于没用）。
- **防自我强化**：刚写完 10 分钟内读回自己（`selfTouchMs`）不算「被读」——否则一个爱自检的
  会话能把空笔记养成「可信来源」。本机账本里有 82 次这样的读被剔除。
- **每轮记账**：账本行新增 `gate` / `demoted` / `suppressReady` / `suppressed`。
  `suppressReady` 是为 `shadow` / `assist` 准备的：它让「要是我开 enforce 会拦掉谁、拦对了吗」
  变成能从账本算出来的问题。
- **周报新增第六节**：当前档位与生效阈值的来源、两张可疑名单（总也用不上 / 过期）、
  **负反馈回放**（按时间顺序边走边算 as-of 统计，用 `suppressReady()` 同一份判据，
  统计「够到抑制线 K 条、其中 F 条后来真被读过」= 假杀），以及「老账本没这两个字段、只能重算」的说明。
- **体检器 12 → 14 项**：`never-used`（携带 ≥ 8 次、0 次被读，severity warn）与
  `stale-project`（`project-` 类超期未更新，severity info）。**账本缺失或为空时一项都不报** ——
  没有证据不是证据。
- **A/B 数据（本机真库重放 40 轮）**：`assist` 与 `off` 相比，**13 轮（32.5%）**的注入集合或名次
  发生变化：13 条指针掉出前四、13 条被顶进来；假杀候选 4 条，剔除自我碰触后 **0 条**。
- **测试**：777 → **817 例全绿**（`test/confidence.test.mjs` 18 例 + `test/doctor-hygiene.test.mjs` 6 例 +
  召回主线逐档接线 + 周报回放 22 例）。`confidence.js` 覆盖率 100% 行 / 88.8% 分支。

## 0.15.0

**让记忆变得可算账：`npm run recall:report` 出一页人话周报。** 之前「这块记忆到底值不值」
只能靠猜；现在能回答三个问题 —— 谁在干活、花了多少上下文、哪些轮次一个字都没进。

背景：账本从 0.8.0 就在落盘，但翻开它是件只有作者会做的事（自己写 jq）。更麻烦的是，
「省下多少上下文」这件事**根本记不下来** —— 账本里有「这一轮注入了哪几条」，却没有「这几条
整篇有多少字」，于是没人能说清注入一条指针换来的是 1 KB 还是 20 KB。这一版把这块补上，
并把报告里每个数字的来源都钉在活代码上：引擎参数、宿主设置、插件默认，一处都没有抄写。

- **账本升级（`store.js` / `bm25.js` / `index.js`）**：每条命中多带 `chars`（整篇笔记的字符数），
  注入轮多落 `noteChars`（这一轮带进上下文的笔记整篇合计），于是报告能算出
  「送出去 3 000 字、省下 96%」这种真实比例；命中却一条都塞不进预算的轮次仍记
  `budget-too-small` + `spent: 0`（0.14.1 起），现在预算上限也一定落盘。
- **新命令 `npm run recall:report`（`scripts/recall-report.mjs`，随包发布）**：
  `--days` / `--store` / `--ledger` / `--config` / `--top` / `--json`。五节：
  ① 这段时间记忆有没有在干活（分母含**没注入的轮**，逐条给原因）；
  ② 花了多少上下文（省下的字数与比例，缺数据的轮次**单列**，不拿 0 补）；
  ③ 哪些笔记真在干活（打开 / 带入两个榜单 + 长期零打开的名单）；
  ④ 这套尺子现在怎么算（引擎活值 + 宿主设置 + 插件默认，全部标明来源）；
  ⑤ 边界（代理指标、160 字截断、老账本缺字段）。
- **人话词表只有一份（`usage.js`）**：`TERM_WORDS` / `plainTerms()` 从面板搬进宿主并导出，
  CLI 周报与面板共用；面板是客户端 bundle，import 不到宿主模块，所以它那份抄写**由测试逐对盯着**
  （分叉就红）。周报里出现的 `maxPointers` / `minScore` / `openRate` 这类键名都转成人话，
  后面用灰色小字标原名。
- **顺手修掉一个「测试测不到」的问题**：报告脚本的提示以前直接写 `process.stderr`，
  绕开了 `main(argv, io)` 的注入 —— 于是「不认识的参数要提示」这条约定在测试里凭空消失。
  现在提示走 `io.stderr`，新增用例把它钉住。
- **本机第一次跑的真实数字**（2026-10-16，10 小时窗口，256 行账本）：问过 149 轮，
  其中 **109 轮（73.2%）**真带了记忆进上下文，40 轮一个字都没进（与上一轮重复 21 / 寒暄 9 /
  消息太短 7 / 早期日志没记原因 3）；带走 545 条指针，26 条后来被打开（**4.8%**，只看主对话轮是 7.7%）；
  预算中位用掉 93%，10/33 轮顶到 95% 以上。完整表见 `docs/measuring.md`。
- **测试**：764 → **777 例**（新增 `test/recall-report.test.mjs` 13 例：分母口径、省字数的算法、
  缺字段单列、空账本、窗口过滤、宿主设置解析（注释/引号/布尔/相邻插件条目不串味）、
  `--help`/未知参数/`--json`、面板词表比对）。`usage.js` 覆盖率 100% 行 / 97.3% 分支 / 100% 函数。

## 0.14.1

**内核：注入的那一行从此带的是「那句话」，不是提问的回声。** 公开口径「地基句保住率」
39.6% → **85.4%**（合成题集 96 题，留出组 26.7% → 80.0%），且这把尺子从这一版起在 CI 里把门。

背景：一条笔记被找对了、也进了注入列表，注入行却是从标题或描述里抠出来的一截 —— 那句话
就是提问的回声（提问里的口语词天然命中标题），决定性的事实一个字都没进上下文。全球调研里
被引用最多的抱怨正是这一格：*"'With Memory' is 9/10 Relevancy even though it is 100% duplication
of what I had said."* 这一版把它变成可量的，然后按数据改。

- **新尺子（`npm run bench:foundation`，`bench/foundation.mjs` + `bench/foundation-corpus.mjs`）**：
  96 篇合成笔记 / 96 题，四组（罕见词唯一 / 密集 / 长句 / 三篇共用）× 三种提问形态
  （只有罕见词 / 口语域词 + 罕见词 / 长句提问），固定种子 `20261016`。口径与灵枢
  `hive-memory-bench` 的「保地基」一致：**逐字包含**才算保住，宽松口径只当诊断。
  调参与汇报分开（tune 66 / heldOut 30，组别·形态·留出三条独立随机流 —— 第一版用取模分配
  导致留出组与 `rare` 形态完全重合，量出过「留出 87.5% vs 调参 10.9%」的假泛化）。
  另外支持 `--store`（拿真实库里 df=1 的词出题，数据不出本机）与 `--replay`（重放真实账本）。
- **选句换成 IDF 加权 + 按句切分（`bm25.js`）**：`search()` 新增第 4 个可选参数
  `{ snippet: 'idf' | 'count', neighbor?: boolean }`。`'idf'`（新默认）在正文句子间按
  `log(1 + (N-df+0.5)/(df+0.5))` 加权选句子，并对**标题与描述候选乘 0.6 折扣**（打折而不是
  禁用：标题本身含决定性词时仍能赢）；`'count'` 是 0.14.0 的行为（命中词最多的那一行、预先
  clip 到 72 字），逐字节保留，供对照与回退。四个三参老调用方（`index.js:527`、`index.js:818`、
  `mcp.js:219`、`scripts/recall-probe.mjs`）一行未动。
  顺带修掉一个真 bug：`docFreq` 过去按「桶」计数，`df` 可能大于 `N` 从而让 idf 变负 ——
  改成先按文档去重再计 df（这个 bug 在 `'count'` 口径下无感，但会让 IDF 排序彻底失效）。
- **注入渲染抽成 `render.js`（新文件）**：`renderPointers(hits, {maxChars, header, snippetChars, snippetPlan})`
  就是 pre-step 钩子用的那个函数（尺子跑真实路径，不重写渲染）。新增**名次加权片段预算**：
  按名次分配每条片段的字数额度（首条最多 200 字、按 `RANK_DECAY = 0.5` 收窄、最少 24 字，
  每条附带「`- ` + 文件名 + 空格」的开销也算进预算），放不下就整条降级成「只有文件名」，
  降级阶梯与记账口径一个字节没变。
- **显式搜索的返回放宽（`index.js:531` 的 140 → 240 字）**：模型自己调工具搜库时，返回的片段
  从「引擎句子级上限 240 字」取齐，不再多裁一刀 —— 这条路径是模型主动问的，把决定性那句裁掉
  等于让它凭半句话决定要不要 `read`。MCP 那条路径本就未裁剪（`mcp.js:188`），现在两边一致；
  代价是每次显式搜索大约多 400 字（4 条命中）。
- **账本补两项、新增一个原因码（`index.js` + `usage.js` + 面板）**：每条注入记录多存
  `topScore`（本轮最高分，调 `minScore` 的唯一依据）与 `rank`（注入顺序 = 名次，用 `ids` 顺序
  表达）；预算连一条最小行都放不下时不再记成「注入了但没内容」，而是单列原因码
  `budget-too-small`（面板显示「预算太小，一条都放不下」），并记 `spent: 0`。
- **数据（全部可复算）**：
  | 配置 | 保地基（全部） | tune | heldOut | 宽松 | 平均注入行 |
  |---|---|---|---|---|---|
  | 0.14.0（按命中词数选行 + 64 字均分） | 39.6% | 45.5% | 26.7% | 49.0% | 60.1 字 |
  | IDF 选句 + 均分 64 字 | 76.0% | 80.3% | 66.7% | 85.4% | 72.9 字 |
  | IDF 选句 + 均分 120 字 | 85.4% | 87.9% | 80.0% | 85.4% | 73.9 字 |
  | **IDF 选句 + 名次加权预算（本版默认）** | **85.4%** | **87.9%** | **80.0%** | **85.4%** | **73.9 字** |
  | 上面那档 + 邻句扩展 | 85.4% | 87.9% | 80.0% | 85.4% | 85.0 字 |
  | 天花板 `hit@1`（笔记找错的 6.2% 谁也救不回） | 93.8% | 92.4% | 96.7% | — | — |
  真实库（261 篇 / 250 题）：逐字 0.0% → 4.0%、宽松 52.0% → 57.6%（真句普遍长于任何单行预算，
  逐字口径在那里结构性偏低，宽松才是有效读数）；真实库上平均用掉 419 字，均分 120 字要 450 字。
  账本重放（253 行 / 106 次注入 / 84 对可判）：旧引擎今天仍答得出 27.4%、只给文件名 11.9%；
  新引擎 **32.1%**、缩到文件名降到 **7.1%**。
- **实测证伪当场关掉的一件**：邻句扩展（短句借前后一句）在合成题集与真实库上都不涨指标、
  平均每条多花 11 字，因此**默认关**（`neighbor: true` 才开）。代码与测试留着，尺子上
  `--neighbor` 随时可复量 —— 不涨指标的东西不配默认吃预算。
- **CI 多一道门**：`npm run bench:foundation -- --check` 与 `bench/foundation-expectations.json`
  对账（默认档 / profile 档 / 留出组都要过地板，容差 0.02），召回质量不许悄悄退化。
- 测试：**734 → 764 例**（尺子自检 6 例含金丝雀「按词数挑行必须显著低于 IDF 挑行」、
  片段口径 24 例、渲染层预算与降级阶梯等）。`npm run check`、`npm run bench:check` 均通过。


## 0.14.0

**「自立」：仅有的两处逐字移植变成我们自己的实现；格式规范收归本仓库并冻结为 Format v1。**

这一版改的是「这套东西到底是谁的」，功能行为一个字节都没变 —— 而且是**证出来**没变，不是嘴上说没变。

- **`index-guard.js` 重写（171 → 237 行）**：索引上限闸门从「逐字移植上游 `dsh-engramory` v0.2.5」改成我们自己的实现。公开接口（`DEFAULT_MAX_INDEX_LINES` / `DEFAULT_MAX_INDEX_BYTES` / `countLines` / `measureIndex` / `checkIndexWrite`）与判定口径完全不变：**200 行 / 25 600 字节谁先到算谁**、**只拒绝增长**（缩小的重写永远放行，否则越限索引会把自己锁死）、字节判据取**原始 buffer**（有损 decode 会把真增长算成缩小）、行数对**末尾换行不敏感**、只治理库根 `MEMORY.md`（basename 大小写不敏感）。内部函数按自己的结构重命名（`simulateEdit→applyReplacement`、`readRaw→readBytes`、`verdict→judgeAgainstCaps`、`targetsIndex→locate`），返回形状与文案属**被锁行为**，一并保留。
- **等价性黄金测试（新增 `test/index-guard-equivalence.test.mjs` + `test/fixtures/`）**：48 条矩阵输入先喂给**改写前的实现**（`git show 821872a:index-guard.js` 导出的基线），把 `ok` / `skipped` / `reason` 全文 / `message` 全文 / `countLines` / `measureIndex` 全等结果固化成快照（含矩阵指纹防漂移），再让现在的实现逐条复现 —— **48 条全等**，另加「判定跑完文件字节不许变」的副作用断言。矩阵覆盖 200 / 201 行边界、25 600 / 25 601 字节边界、205→203 缩小重写放行、等于上一版放行、越限继续增长拒绝、`oldString→newString` 换成更长/更短/等长/不存在/为空、CRLF、单独 CR、BOM、末尾换行、空文件、文件不存在、坏 UTF-8、`memory.md` 小写名、库外同名、非索引文件、未配置库、10 万字符单行、中文多字节。判定分布 21 拒 : 27 放行，`skipped` 七种取值全覆盖。
- **`skill.js` 重写（200 → 203 行，正文 88 → 103 行）**：`engramory` 技能的 `description` / `whenToUse` / 正文改成我们自己的措辞与章节结构（新增 `## Which turns count` 边界节），**18 条纪律一条不少**（问候不算任务、只读工作算任务、手里有材料不算非任务、绝不按长度判断、显式召回、回忆会过期、写前两道闸、先查重再写、一事一文 + frontmatter、`Why:`/`How to apply:` 与一条指针、只记 settled 事实、至多一条活笔记、就地更新不许日期序列、收尾是判断不是写入、不许逐轮流水与刷时间戳、Sync 五步并报账、上限真拒绝 + 缩小放行、绝不写凭据），末尾照旧写清本插件自己新增的行为（自动召回 / 沉淀提醒 / 本地索引与检索 / 写入闸门与面板）。技能名与注册方式不变，注册行为逐字未动。
- **`test/skill.test.mjs` 18 → 34 例**：一条纪律一条断言（对照表见本次改动说明）。顺手修掉 `test/recall.test.mjs` 里一条**锚死旧正文断行**的断言（`/a greeting,\s+an\s+acknowledgement/` → 断言「纪律在场」），并实测记录一件事：注册那层 `try/catch` **只兜得住同步路径** —— 注册表晚于本插件激活（延迟回调）时抛错会浮出来，两条用例分别钉住这两面。
- **规范与署名收口**：新增 [`docs/format.md`](docs/format.md)（**Format v1**：目录布局、frontmatter 字段表、索引一行指针写法、上限与治理、**与上游规范的差异表**、版本与迁移）与 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)（与上游 Engramory 协议的关系、借用与对齐范围、上游 MIT 许可原文）。README 改口为「布局遵循本仓库自己维护并冻结的 Format v1，借鉴了 Engramory 风格」，文档表补上这两份；`package.json` 的描述改口、`files` 收进 `THIRD-PARTY-NOTICES.md`。
- 全仓清理「融合自 / 照抄 / 逐字来自」表述（`index.js`、`guard.js`、`bm25.js`、`docs/how-it-works.md`），改成「口径来自 Engramory 协议（MIT），实现与措辞是本仓库自己的」。
- 测试：**664 → 734 例**（等价性 54 例 + 技能 16 例）。覆盖率：`index-guard.js` 行 100.00 / 分支 98.46 / 函数 100.00，`skill.js` 100 / 100 / 100；`npm run check` 与 `npm run bench:check`（8 项指标 + 2 个分组全在容差内）均通过。


## 0.13.16

**面板从「一大屏等宽字」改成给人看的一页；README 从 1333 行收到 233 行。**

- **面板重做（`client/client.js` 702 → 872 行）**：顶部五张概览卡（笔记 / 库大小 / 索引 / 给过提醒 / 写入保护，大字数字 + 一行人话解释）；页签改成有选中态的分段控件，名字也换成人话（`提醒记录` / `使用情况` / `笔记` / `自检` / `索引全文` / `归档` / `写入记录`）；每个页签一张带标题与解释的卡片；日志条目用状态芯片（`提醒 5 条` / `没提醒` / `拦下一个写入` / `自动归档`…），原因码一律翻人话，没见过的码回落成「原因未记（新版才有）」；条目写「提醒 5 条」却只列了 4 条时补一句「上面是前 4 条，一共 5 条」；每个页签的空状态都有一句「怎么才会有记录」。
- **专业词转译**：`maxPointers` / `maxChars` / `minScore` / `nudge` / `guardWrites` / `autoArchive` / `enabled` 这排裸键值不再直接铺在面板底部，改成「当前设置」卡 —— 人话标签在左、值在右，原字段名缩成小号等宽小字挂在后面（懂行的人对照用）。宿主写来的判据句里夹着的键名（如「抬 minScore 只会伤到自己」）在显示层再过一道 `plainTerms()`，换成「相关度门槛 / 字数上限 / 缩掉的是哪几条」等词，中文之间多余的空格一并收掉。
- **半截数据不再假装 0**：「谁在问」那一行缺字段时印「—」（真的 0 才印 0）；面板自己没有的字段一律「—」，不写 `undefined`/`NaN`。
- **README 精简**：1333 → **233 行**；深度内容逐行搬进 `docs/how-it-works.md`（架构与召回流程，365 行）、`docs/limits.md`（边界与已知取舍，501 行）、`docs/measuring.md`（基准与调参，311 行）、`docs/release.md`（发布与校验，181 行）。README 现在只有：一段卖点引子 → 30 秒能读懂的三件事 → 特性表 → 安装三条命令 → 配置表（人话）→ 常见问题 → 一页架构图 → 开发命令 → 文档索引。**`npm pack` 的 `files` 加上 `docs/`**，README 里的相对链接在 npm 页面也点得开。
- 测试：`test/client-panel.test.mjs` +8（人话页签标题、空状态引导句、键名转译、半截桶给「—」、5 条只列 4 条写清楚…）。**656 → 664 例**。


## 0.13.15

**「缩了 17 条」看不出截断有没有代价：账上没记「缩掉的是哪几条」。**

- **落盘：`hit` / `sediment` 行新增 `collapsedIds`** —— 被缩成裸文件名的那几条的 id（空数组也写，表示这一轮记过账、谁都没被缩）。只有塌缩条数时，`25% 的轮被截断` 这类结论只能停在「预算顶到上限了」，答不了另一半：被砍掉的那几条**后来有没有被打开**。老行完全没有这个键，尺子如实印「—」，不许当成「没缩过」。
- **`usage.js`：`summarizeUsage()` 新增 `truncation = { rounds, legacy, collapsed, full }`** —— 按名单把「被缩的那几条」与「完整列出的那几条」分开算打开率，归属规则与位置级曲线完全一致（一次打开算到它之前最近一次注入过这条的那一轮）。`legacy` 单独数「缩过但没记名单」的老轮。
- **新判据 `truncationVerdict()`（介于预算与原因之间）**：两边被打开的样本各要 5 条才敢比大小，否则只报形状并说「先看形状」；被缩的**更容易**被打开 ⇒ 「预算确实在砍有用的东西，抬 maxChars 有据」；被缩的并不更容易 ⇒ 「抬 maxChars 的收益看不出来」；从没缩过 ⇒ 「预算没顶到过上限」。
- **尺子（`npm run recall:stats`）**：预算一节多两行（`被缩的条数 / 其中被打开`、`作对照：完整列出的 / 其中被打开`），判据区多一句。
- **面板**：「预算与分数」卡片补同一行（被缩 vs 完整列出的打开率），判据区多一句；没有记录时写「`collapsedIds` 要 0.13.15 之后的宿主才写」。
- 测试：`test/usage.test.mjs` +3（分开算打开率与 `legacy` 计数、样本够了才比大小的两支话术、没数据时不编故事）、`test/ruler.test.mjs` +1（有名单时两行对照 + 判据；没名单时印「—」）、`test/wiring-paths.test.mjs` 断言落盘行带 `collapsedIds` 且路由里 `verdicts.truncation` 在场、`test/client-panel.test.mjs` 断言面板那一行。**652 → 656 例**。

## 0.13.14

**`host-llm` 这条接缝从落地那天起就没跑通过：它去路由对象上找 `llm`，而路由只有两个名字。**

- **修（真 bug，子代理写语义接缝测试时挖出来的）**：`semantic.js` 的 `expand` 过去写 `route.llm.stream(options)`，可 `route` 出自 `llm.js` 的 `resolveRoute()` —— 只回 `{ provider, model }`，**没有 `llm`**。于是真宿主里这条路的每一次改写都在 `TypeError` 里结束，被归一成 `llm-error` 后降级回词面；`hostLlmExpand` 里那段 JSON 抽取（`[` 到 `]`）从来没有机会执行。改为从运行时取真服务：`module.getLlm(runtime.ctx)`（`llm.js:80` 早就有这个出口）。附带新增第三种「宿在这条路走不通」的码：`no-llm`（运行时在、默认模型也在，但 `ctx.get('llm')` 拿不到带 `stream()` 的服务）—— 过去这种情况要在一次 `TypeError` 之后才看得出来。
- **参考提供方两处「静默」改成明说**：① `cosine()` 遇到维度不一致会用 `Math.min` 截到短的那条 ⇒ 查询 `[1,0]` 对候选 `[1,0,99]` 与 `[1,0,0]` 都是 1.0 分，多出来的那一维被静默丢掉（换过模型、或缓存里混着旧向量时才会发生，而失真的排序看起来完全正常）；现在维度不等当场抛，消息带两个维度与最可能的原因。② 纯空白查询过去原样送出去（真 OpenAI 对 `''` 回 400）；`snippet`/`title`/`id` 都空的笔记也会送出一个 `''` —— 一条空笔记能把**整批**（含正常笔记）一起拖去降级。现在空查询当场抛、不发请求，空文本的笔记不进请求体但**结果里不少一条**（拿 0 分）。
- **尺子（`npm run recall:stats`）不再静默改口径**：`--store ''` 直接退出 2（过去静默回落到真账本 `~/memory/engramory` —— 想读一个临时目录却读到线上账本）；`--limit 5` 仍收回下限 10、`--positions 0` 仍回落默认 5，但**都会在 stderr 说一句按什么算的**（stdout 保持干净，`--json` 仍是纯 JSON）；未知选项点一句（`--strore` 打错时最怕它一声不吭地给你看别人的账本）；新增 `--help` / `-h` 用法输出（且不读账本）；`--json` 新增 `exists`，用来区分「账本还没落盘」与「路径写错」。
- **测试：581 → 652 例**。新增 `test/semantic-edges.test.mjs` 12、`test/embed-provider-edges.test.mjs` 31、`test/ruler-cli.test.mjs` 17、`test/degradation.test.mjs` 6（拷贝一份包到临时目录、把指定依赖换成会抛的桩，专测「崩溃降级」那几条路：状态路由崩了仍 200 且带 crashed 字段、doctor 崩了仍出体检结果、归档抛异常仍记 deny 行）；`test/wiring-paths.test.mjs` +1、`test/index-guard.test.mjs` +2、`test/client-panel.test.mjs` +3。
- **覆盖率：修掉「用 `?query=` 绕 ESM 缓存」把数字抹花的记账。** Node 的 `--test --experimental-test-coverage` 以 `fileURLToPath(url)`（丢掉查询串）作键，于是同一份源码被合并成多条 script，那条「只跑过顶层」的副本把数字抹花：`llm.js` 曾被记成 37% 行 / **0% 函数**，`providers/openai-embeddings.mjs` 曾被记成 57% 行 —— 都是假的。两个受害文件各走一条路：`test/llm.test.mjs` 改成「拷一份到临时目录再 import」（新路径 = 新实例），报告里 `llm.js` 立刻变成真数字 **100/99.48/100**；`test/embed-provider-edges.test.mjs` 保留 `?query=`，但把四份实例**在文件求值期一次性加载完**、功能用例统一只用最后那一份（此后任何用例都不再 import 新实例），于是被留下的那份恰好就是被真跑的那份，`providers/openai-embeddings.mjs` 记到 **99.75/78.43/100**。`test/client-panel.test.mjs` 的同款写法不失真（那个文件只有一条加载路径），保留并注明原因。

## 0.13.13

**「截断了 24% 的轮」看不出上限够不够：日志里没有「用掉了多少预算」。**

- **落盘：`hit` / `sediment` 行新增 `budget`（该轮 `maxChars`）与 `spent`（实际用掉的字符数）。** 这是唯一能区分「上限刚刚好」与「上限在乱砍」的证据；`collapsed`/`dropped` 只说明被砍了，不说明离上限多远。老行没有这两项，汇总里如实不参与。
- **`usage.js`：`summarizeUsage()` 新增 `budgetUse = { n, median, p90, full }`** —— 只统计带 `budget`/`spent` 的轮，比例夹在 `[0,1]`；`full` = 用掉 ≥95% 的轮数。全仓的分位数用同一套 `q()`（nearest-rank 取低）。
- **判据：`budgetVerdict()` 在「预算在砍内容」时补一句用量**（`预算中位用掉 92%、18/41 轮顶到 95% 以上`）—— 过去只说砍了多少，无法回答「该不该抬 `maxChars`」。
- **尺子新增两行**：「预算用掉（中位 / p90）」与「顶到 95% 以上的轮」；老日志印「—（这些行没有 budget/spent：0.13.13 起才落盘）」而不是冒充 0。
- **面板**：「预算与分数」卡片补一行同一口径的用量（没有记录时给人话）。
- 测试：`test/usage.test.mjs` +1（老行不进分母、比例夹紧、判据带用量）、`test/ruler.test.mjs` +1（有字段 / 没字段两种输出）、`test/wiring-paths.test.mjs` +1（真跑 `apply()` 后落盘的 `budget`/`spent` 与 `maxChars` 对得上）、`test/client-panel.test.mjs` 断言面板那行。**578 → 581 例**。

## 0.13.12

**同一份报告里两套打开率：尺子的「分子」一节没跟上 0.13.10 的去重口径。**

- **修：`scripts/recall-stats.mjs` 的「注入后被打开 / 打开率」用去重后的条数。** 它过去印 `summary.used` —— 那是打开**次数**（同一轮里反复 read/edit 会重复计），而 0.13.10 之后判据和「按提问者拆开」用的是去重后的**条数** ⇒ 真数据上同一份报告同时出现 **12.6%** 和 **4.7%** 两个打开率，而这一节恰恰是「窗口该收窄还是放宽」的唯一依据。现在两者同用 `usedCounted`（与 `usage.js` 的 `openRate` 是同一个数），原始次数不丢，单列一行「打开次数（未去重，同一轮重复读会重复计）」。
- **面板同一处口径对齐**：打开率卡片的 hint 从「N 条被 read/edit」改成「N 条被 read/edit（去重后；共 M 次）」，避免卡片上的条数与百分比各说各话。
- 测试：新增 `test/ruler.test.mjs` **4 例**（真跑子进程 + 临时库：去重分子与原始次数分列、一条都没打开时两个 0、还没有日志时给人话、`--json` 里 `used`/`usedCounted`/`openRate` 三字段在场）；`test/client-panel.test.mjs` 的 fixture 补上 `usedCounted` 并断言卡片口径。**574 → 578 例**。

## 0.13.11

**又一笔记不上的账：审计记录序列化失败时，过去连「丢过」都不留痕。**

- **修：`appendAudit()` 序列化失败（BigInt、循环引用……）改成落一条降级记录。** 过去 `JSON.stringify` 在外层 `try` 里抛掉，被那句「审计失败绝不能影响记忆写入」的兜底一起吞了 ⇒ 那一次工具调用在审计里**凭空消失**，读账的人看不出少过一笔。现在序列化单独一层 `try`：失败就写一条 `{"ts","epoch","tool","file","verdict":"audit-unserializable"}`（`tool`/`file` 不是字符串时写 `unknown`），账本仍是对得上的 JSONL，调用方一行都不用改。
- 「永不抛异常」的契约不变：写盘失败（只读挂载、磁盘满、权限）照旧静默跳过 —— 那类失败本来就不该影响记忆写入。
- 测试：`test/audit.test.mjs` 18 → 19 例（原来那条「静默消失（疑似 bug）」改写成钉住新行为，另加一例保底：`tool`/`file` 非字符串写 `unknown`、`entry` 为 `null` 走正常路径而非降级）。
- 变异验证：把 `audit.js` 回退成 HEAD 版本，新用例立刻 **2 条 fail**；改回修复后 19/19 绿。

## 0.13.10

**测试面继续铺厚（481 → 573 例），并修掉一个「静默归零」的真 bug。**

- **修：库路径带尾斜杠时，读取记账整条链被打死。** 库内判定用 `storePath + path.sep` 拼前缀，配置写成 `~/memory/engramory/` 时前缀成了 `…//`，永远匹配不上 ⇒ `opened`/`used` 一行都不落盘、打开率恒为 0、**而且不报错**（同一文件里 `storeSnapshot()` 自己却在去尾斜杠，两边口径不一致）。现在路径归一收敛成唯一一处（纯分隔符保留一个：`/` 仍是 `/`、`C:\` 仍是 `C:\`），库内判定、`MEMORY.md` 拼接、search / summarize / archive / snapshot / 路由共 8 处统一用归一值。
- **修：重复消息 id 的轮次在台账里一行都没有。** 去重早退发生在记账之前 ⇒ 那一轮既不进打开率的分母、也统计不到原因码。现在照「同一 query 跨轮去重」的同口径记一行 `skip`（原因码复用现成的 `duplicate`）。
- **复核后判定「不是缺陷」的一处**：注入预算那份 `+80` 宽容度只给解释性文案（`（另有 N 条命中因预算未列出）` 只有 16–19 字符，指针循环仍守死 `budget`），实测最坏溢出约 +20，不是溢出闸门。
- **测试 481 → 573 例**：新增 `test/index-branches.test.mjs` 72 例（`index.js` 行覆盖 96.36% → 98.54%、分支 70.40% → 87.36%）、`test/audit.test.mjs` +6（18 例）、`test/llm.test.mjs` +14（46 例）。
- **关于 `llm.js` 那个「函数覆盖 0%」**：是覆盖率记账怪癖而非真缺口 —— 测试用 `import('../llm.js?fresh=…')` 做缓存穿透，同一文件被加载成两个 V8 script，而 node 以 `fileURLToPath(url)` 作键（查询串被丢弃）把两条合并，那条「只求值顶层、函数一个没跑」的副本把函数计数抹成 0。剥掉污染后是 **100% 行 / 98.94% 分支**（`test/client-panel.test.mjs` 里也有同款写法，已加注释说明）。
- 两处修复都做了**变异验证**：把代码回退成旧写法，对应用例立刻变红（B1 两条 fail、B2 一条 fail）。

## 0.13.9

**面板与 MCP 两层的测试补厚，外加「半截数据不再把面板整片打白」。**

- **面板渲染层**（`client/client.js`）：行覆盖 61% → 98%（函数 53% → 93%）；`test/client-panel.test.mjs` 10 → 59 例 —— 七个页签、笔记过滤、日志折叠、归档动作与 POST 契约、以及一整组「脏数据不许抛」的用例。
- **MCP 服务器层**（`mcp.js`）：行覆盖 95.8% → 99.5%、分支 61% → 94.7%、函数 100%；`test/mcp-protocol.test.mjs` 10 → 28 例 —— 协议帧与通知、stdio 主循环（含超 64 行排队）、bin 入口与符号链接、离线语义降级。
- **面板不再因脏数据白屏**：`positions` / `neverOpened` / `plan.historyFiles` / `state.log` / `state.notes` / `audit.findings` / `writeAudit.entries` / `archive.plans` / `archive.skipped` / `scoreCurve.points` 统一过 `objsOnly`（非数组当空、元素不是对象就滤掉），`finding.files` 过 `strsOnly`。过去 `state.log` 或 `writeAudit.entries` 里混进一个 `null`，七个页签会一起白屏。
- **显示层净化**：进面板的数字统一过 `finiteNum` / `fmtNum` / `fmtCount` / `fmtPct` / `fmtSecs`（非有限数、超出安全整数 → `—`）；半截字段不再印出 `undefined 字符` / `undefined 轮` / `undefined 行`；`state.config` 整块缺失时写「配置未回显」，缺字段印 `—`，只有真的 falsy 才 `off` —— **「关」与「不知道」分开了**。
- **位置级曲线行标签**改取桶自己的 `position`（过去用过滤后的下标，缺桶时会张冠李戴）。
- 测试总数 414 → 481；`npm test` 全绿。
- 顺带核验（只是补用例，不是修复）：MCP 的 `engramory_read` 越界防线在 basename + 后缀那道检查上，`../x.md` / `/etc/passwd` / `%2Fetc%2Fpasswd` / `%00` 全被拒。

## 0.13.8

**测试面铺到总装与协议层：267 → 414 例；子代理顺手挖出并修掉四处真问题。**

- **新增 6 个测试文件 / 146 例**：`test/bm25.test.mjs`（43：分词口径、同义桥、IDF 方向、
  长度归一按整篇算、衰减地板、boost 夹紧）、`test/store.test.mjs`（51：frontmatter 解析、
  长笔记切桶、索引缓存、写入读回）、`test/audit.test.mjs`（12：JSONL 转义与坏行、轮转上限）、
  `test/usage-fuzz.test.mjs`（15：8 轮 × 240 行脏数据，只验账目恒等式、不验实现细节）、
  `test/mcp-protocol.test.mjs`（10：JSON-RPC 帧与 stdio 主循环）、
  `test/wiring-paths.test.mjs`（10：真跑 `apply()` 的注入路径与状态路由）。
  `test/client-panel.test.mjs` +5（七个页签各渲染一次、过滤器命中/不命中、展开态、宿主不可用）。
- **加固 1（store.js）**：`splitNote()` 入口归一 CRLF/CR → LF 并去掉 BOM。过去记事本「另存为
  UTF-8」带 BOM 的笔记，frontmatter 会被整段当正文喂进索引（description / aliases 全丢）；
  CRLF 笔记的 `\r` 会跟着进词项与 snippet。
- **加固 2（mcp.js）**：`handleMessage()` 对「通知」（没有 id 的帧）不再回包 —— 过去已知方法
  也会回一帧没有 id 的响应，协议上不合规（客户端只能自己忽略）。
- **加固 3（bm25.js）**：`clip()` 按码元截断时不再切出半个代理对（snippet 里的 emoji 会变乱码）；
  同时把分词注释写实：**相邻双字为主、孤立单字才留** —— 单字查不出四字以上连写（`代理节点`
  里没有 `代`），这是已知盲区，目前只靠同义表兜。
- **加固 4（子代理写测试时挖出来的，都已修 + 补用例）**：
  ① `store.js` 单篇笔记 >4MB 时过去是 `break` —— 它后面（以及它自己）的笔记一篇都读不到，
  症状是「库里 69 篇，索引却是空的」；改成跳过该篇，只有**累计**超上限才停读。
  ② `corpusFingerprint()` 的排序比较器对相等 id 恒返回 1（长笔记切桶就是同 id 多文档），
  于是同一份语料换个顺序就得到不同指纹 —— 会让 A/B 对比把顺序抖动误判成「语料变了」；
  改成按 `seq` 补齐的全序。
  ③ `summarizeUsage({positions: 2.5})` 直接 TypeError（`Array.from({length: 2.5})` 只建 2 格、
  循环却能写到下标 2）；`Math.floor` 兜住，尺子的 `--positions` 同样取整。
  ④ **打开率的分母和分子量纲不同**：`used` 行是打开**次数**、`pointers` 是注入**条数**，
  20 条指针被读 30 次就印「打开率 150% / 偏高，窗口可以放宽」—— 结论正好说反。现在按
  (注入轮, 指针) 去重，`openRate = usedCounted / pointers` 恒 ≤ 1，原始行数仍留在 `used` 里。

- 另修了三处**测试自己的错**：audit 的 JSONL 转义断言搞错了行序、store 切桶 fixture 没过 6KB
  阈值、MCP 的临时库 helper 忘了 `await`（用例跑到一半库就被删，症状是「第一次调用好好的、
  后面几次说库是空的」）。

## 0.13.7

**打开率的分母按「谁在问」拆开：委派轮不再冒充主对话轮。**

- **问题**：这套账是一个库一本账，而委派轮（子代理 / Agent Teams）与主对话轮共用同一个记忆库。
  委派提示词里根本没有「先看记忆库」这回事，它们的指针几乎注定不会被打开 —— 实测第一份账里
  **25 个注入轮有 12 轮**是「你是只读侦察员…」这类委派提示词，混进分母把打开率压掉一半以上，
  而打开率正是决定窗口宽窄的那个数。
- **修法**：每个「轮」级的账（`hit` / `sediment` / `skip` / `empty`）都记 `scope`（`main` /
  `subagent`）与 `depth`。判据取 `session.header.delegationDepth` 与 `agent.options.subagentDepth`
  的较大者 —— 与 DSH 自己的 `delegationDepthOf()` 同口径；两处都没有（老宿主）就当主对话轮，
  **宁可少分，不可误杀**。
- **汇总**：`summarizeUsage()` 多一节 `scopes`（各自的注入轮 / 指针 / 被打开 / 打开率），
  「被打开」按**最后一次命中它的那次注入**归因；升级前写的老行进 `unknown`。
- **判据**：委派指针 ≥ 10 条、占全部指针两成以上、且主对话轮自身有 ≥ 20 条指针时，第一句判据
  改用「只看主对话」的率并说明改动；否则照旧。
- **尺子与面板**：`npm run recall:stats` 多一节「按提问者拆开」，面板「使用账」多一行
  「按提问者：主对话轮 … · 子代理轮 …」（空桶不显示）。
- 测试：`test/usage.test.mjs` **17 例**（+2：作用域归因与重复注入的归属、判据换口径的阈值），
  `test/client-panel.test.mjs` **5 例**（面板那一行 + 空桶不渲染）。

## 0.13.6

**给 `minScore` 配一条代价曲线：门槛该抬到哪，终于有代价可看。**

- **新增 `minScoreCurve()`（`usage.js`）**：把线上日志里每条注入指针的（分数，后来有没有被打开）
  配成对，扫一遍候选门槛（分位点，末尾再补一行「最高分 + 0.001」保证「全砍掉」总在），逐档给出
  「留下多少注入 / 砍掉多少 / 其中被打开过的丢了几条」。返回里带 **`freeCap`（零代价上限）**：
  砍得掉、又没丢过任何被打开过的那道最高门槛 —— 这是唯一一个「不看分布、直接看代价」的数字。
- **新增第四句判据** `scoreCurveVerdict()`，写成一句人话（面板与 `npm run recall:stats` 共用）。
  被打开过的样本不足 5 条时它只说「曲线只能看形状」，**不催你调参**。
- **`npm run recall:stats` 新增一节「把 minScore 抬高会付出什么」**；面板「使用账」页签同步渲染
  （最多 6 行，均匀抽样、首尾必留），宿主路由 `verdicts` 多一句 `curve`。
- **两类不可用的数据分开数**：`legacy`（0.13.3 之前写的 `hit` 行没有 `scores`）与 `unaligned`
  （`scores` 与 `ids` 对不上号）—— 它们既不进曲线，也不会被当成 0 分。
- 测试：`test/usage.test.mjs` **15 例**（+3：曲线形状与 `freeCap`、空/老日志/没打开时的人话、
  两种判据话术），`test/client-panel.test.mjs` **5 例**（+1：12 档只显示 6 行、首尾必留、
  被抽样跳过的那行确实不在）。

## 0.13.5

**面板对「半截数据」不再白屏；顺带记下第 14 条被实测证伪的路。**

- **新增面板渲染回归** `test/client-panel.test.mjs`（4 例）。`client/client.js` 是 550 行手写渲染
  代码、没有构建链、此前**一个测试都没有** —— 里面抛任何异常的表现都只是「记忆页签一片空白」，
  只有人肉打开面板才看得见。测试用一个极小的 React 桩（`createElement` 只记树、hooks 按调用序
  返回预设值、effect 不执行）把真实 bundle 跑起来，断言四件事：使用账页签把打开率 / 位置级曲线 /
  预算 / 原因分布 / 三句判据都渲染出来；老宿主没有 `usage` 字段时给一句「重启后就有」；
  还没有注入记录时不显示 0% 的假指标；**usage 缺字段也不抛异常**。
- **它当场逮到一个真崩溃**：宿主给的使用账缺 `perRound`（旧宿主、或日志只写了一半）时
  `usage.perRound.median` 抛 `TypeError` → 整个「记忆」页签白屏。现在这类字段一律先补齐：
  缺 `perRound` 时预算那段显示「还没有逐轮记录（这一节要重启一次 App 让新宿主开始写）」，
  几个计数缺了按 0 算，打开率也走 `|| 0`。
- **自检不再把「面板那一半」说成要重启 App**：`scripts/check-recall-wiring.mjs` 的旧代码检测
  原本把所有运行时文件混在一起比 mtime —— 于是只改了 `client/client.js`（浏览器加载、刷新页面
  就换新）也会提示「重启 App」，把人引去做无用功。现在按加载方拆开：Node 侧（index/semantic/…）
  比进程新 → 照旧警告要重启；只有 `client/` 比进程新 → 一句「刷新页面（⌘R）就生效，不用重启」。
- **README 新增第 14 条被证伪的路**：注入窗口「按分数断层自适应收缩」被量死 —— 会省条数的两条
  规则把 gold 覆盖率从 91.4%（固定 4 条）拉低到 92.5% / 85.6%，因为尾部 gold 的词面分本来就低，
  分数分不开「尾部的 gold」与「尾部的噪声」。窗口宽度照 hit@k 曲线走，不按分数裁。

## 0.13.4

**面板把使用账画出来了；顺带修掉一条把「真问题」糊成「没笔记」的原因码 bug。**

- **面板新增「使用账」页签**（`client/client.js`，刷新页面即可见）：注入指针 / 打开率 /
  注入过的笔记 / 打开延迟四张卡片，位置级曲线（第 N 条被打开的比例，带条形）、
  预算与分数一行、没注入的原因分布表、注入过但从没被打开的笔记清单，
  外加与尺子一致的三句判据。宿主侧的 `usage` 现在把 `maxPointers` 传进
  `summarizeUsage()`（否则「窗口用满了几轮」永远是 0），并附 `verdicts` 三句。
- **修 bug：`empty` 行的原因码恒为 `index-empty`。** `indexOf()` 回的是索引对象
  `{ docs, avgLen }`，而 0.13.3 的判据写成 `!docs.length` —— 对象的 `.length` 恒为
  `undefined`，于是「库里一条都不匹配」与「全被 minScore 拦掉」都被记成「库里没有笔记」，
  原因码的全部意义正好在它最该起作用的分支上失效。改成 `index.docs.length`，
  并由 `test/recall.test.mjs` 的新断言钉住（它就是这样被抓出来的）。
- 测试：`test/recall.test.mjs` 的宿主路由用例补断言（`usage` 里的窗口上限、跳过/零命中原因码、
  位置曲线、三句判据都得在）——上面那个 bug 就是它抓出来的。

## 0.13.3

**把「这轮为什么没注入」写成能统计的原因码，并量掉一条走到门口的路。**

- 原因码：`skip` 行现在带 `chitchat` / `too-short` / `empty-message` / `duplicate` /
  `store-not-configured`；`empty` 行分 `index-empty` / `no-hits` / `below-min-score`，
  并带上 `minScore` 与 `topScore`（`index.js` 新增导出 `searchableReason(text)`；
  `isSearchable` 变成它的一层薄封装）。此前 `skip` 行只有一个光秃秃的 `kind`，
  「寒暄门控」和「消息太短」这两种完全不同的信号在账上长得一模一样。
- 注入分落盘：`hit` 行的 `ids` 旁边多一个一一对应的 `scores`。
- 尺子（`npm run recall:stats`，随包发布）新增一节「为什么会没注入」：原因分类计数 +
  注入分的中位 / p10 / p90 / 首尾落差，以及**第三句判据**（寒暄占大头 = 设计性的安静，
  先别动参数；`below-min-score` / `no-hits` 占大头 = 真漏，照分数定 `minScore`；
  早期日志没有原因码时明说「再聊几轮」）。面板 `usage` 字段同一套算法。
- 文档：README 补原因码表、注入分说明，并把「让每轮自动注入也走语义」写进**被证伪的路**
  （十三条）：174 条卷子上 hit@5 93.1% → 95.4%，但要**每一轮**把最多 10 条候选片段发给端点
  （云端出网 / 本机 +130ms 且常驻 5.5GB）—— 而漏掉的指针在按需检索里仍能找回来，分工不变。
- 测试 +4（`test/usage.test.mjs` 3 例：原因分布、三档判据分流、分数分位与老日志兼容；
  `test/recall.test.mjs` 1 例：原因码）。

## 0.13.2

**打开率尺子补上「预算压力」这一维：窗口调宽了，字跟上了吗。**

- 0.13.1 的尺子只看打开率与位置级曲线，看不出「指针条数够、内容却被 `maxChars` 砍掉」——
  那正是 4→5 那次改动最容易踩的坑（第 5 条会塌缩成裸文件名或被丢）。现在 `recall:stats` 多一节：
  每轮注入条数分布、**被缩成裸文件名 / 因预算整条没列出**的条数与轮数、打开延迟中位/p90，
  以及第二句判据（截断 > 两成 ⇒ 先抬 `maxChars`；窗口顶满且无截断 ⇒ 放宽只需动 `maxPointers`）。
- `summarizeUsage()` 新增 `collapsed` / `collapsedRounds` / `dropped` / `droppedRounds` / `truncatedRounds` /
  `perRound`（min·median·p90·max，可选 `maxPointers` 时多给 `fullRounds`）/ `openLag`（中位·p90），
  新增导出 `budgetVerdict()`；`npm run recall:stats -- --max-pointers 5` 才算「用满了几轮」。
- 这些数字**不用重启 App**：`collapsed`/`dropped` 从 0.13.1 起就在日志里了，只是没被汇总过。
- 顺带修掉接线自检的一个**漏检**：旧代码检测此前只比 `index.js` / `semantic.js` /
  `providers/openai-embeddings.mjs` 三个文件的 mtime —— `usage.js` 改了它却报「接线全部正常」。
  现在文件集从 `package.json` 的 `files` 白名单现算（`.js`/`.mjs`，去掉 `scripts/`：那是独立 CLI，
  改了不用重启 App），以后新增运行时文件自动被覆盖。
- 测试 +4（`test/usage.test.mjs` 2 例：汇总口径与三档判据；`test/wiring-check.test.mjs` 2 例：
  「整份代码都比 App 旧」与「只动 `usage.js` 也要报」）。

## 0.13.1

**给召回补上「用没用上」这一层：`used` / `opened` 记账 + 打开率尺子。**

- 此前的观测是**不对称**的：写入侧有落盘审计（谁动了库、放行还是拒绝），召回侧只有内存里
  最近 80 条（重启即清），而「注入的指针后来有没有被打开」**一个字都没有** —— 于是「窗口该 4 条
  还是 8 条」只能靠离线卷子猜。现在每次注入、每次打开都落一行到 `<storePath>/.engramory-recall.jsonl`
  （512KB 封顶、超了半量截断、永不抛异常；`usageLog: false` 可关回旧行为）。
- 四类行：`hit`/`sediment`（注入，`ids` 按显示顺序）、`skip`/`empty`/`deny`、`used`（注入过的笔记
  后来被 `read`/`edit`，带 `lagMs`）、`opened`（没注入过也被打开的库内笔记 = 对照组）。
- 新尺子 `npm run recall:stats`（`scripts/recall-stats.mjs`，随包发布）：打开率、**位置级曲线**
  （第 1 条 vs 第 5 条各被打开多少）、从未被打开过的注入、以及一句判据（样本 < 20 条不下结论；
  < 30% 该收窄，> 60% 该放宽）。同一条汇总也挂进 `GET /api/engramory-recall/state` 的 `usage` 字段。
- 顺手把**注入窗口从 4 条放宽到 5 条**的依据写进文档：174 条真库卷子的 hit@k 曲线 ——
  4 条 91.4%、5 条 93.1%（+1.7 点）、**6 条 93.1%（白给）**、8 条 95.4%。默认值仍是 4
  （别人的库有不同的密度与预算），线上配置已按这条曲线改成 5 条 + 预算 600 字符。
- 测试 +7（`test/usage.test.mjs`）：模块层的落盘/截断/永不抛异常/汇总归属规则，以及**插件接线**
  （假 ctx 跑真实 `apply()`：注入写一行 → 之后 `read` 那篇写 `used` 带 `lagMs` → 库外路径一个字都不记）。

## 0.13.0

**新的融合档 `guarded`：把握度门槛 —— 只准窗口内部换次序，不准从池子里换人。**

- 上一版结尾写着「若加一道把握度门槛守住保护集那 11 条，真库 hit@1 能从 74.7% 上到约 81%」。
  做了，而且**没要那 81%**：允许换人的形状确实能到 78.2%（智谱），但在垃圾提供方下崩得更狠。
  产品里落的是保守形状 —— 提供方的头名要满足两件事才准插队：**它已经在输出窗口里**（只换次序、不换人）
  且**分差 > `semanticGuard` × 池内分数跨度**（默认 0.5）。
- 174 条真库卷子实测（池 10 / 窗口 4，保护集 117 / 争夺集 52，`npm run bench:guard` 可复算）：
  智谱 `embedding-3` 词面 67.2% → `rrf` 74.7% → **`guarded` 76.4%**（hit@4 与词面恒等，保护集只丢 2 条）；
  本机 `qwen3-embedding:0.6b` 67.2% → 68.4% → **75.9%**（丢 1 条）。
  把提供方分数换成与相关性无关的假分数（垃圾提供方）：`rrf` 34.5%/78.7%、`trust` 10.9%/37.9%，
  `guarded` 是 **54.6%/91.4%** —— 它只赔「插错队」的那一格，hit@4 一格不掉。
- 硬性质：`guarded` **不换人** ⇒ 结果集合与纯词面完全相同，hit@4 恒等；这是它敢叫「少赚不赔」的底气。
- 新条目：`semanticGuard`（门槛 0.5）、`FUSION_MODES` 加 `guarded`、`guardedOrder()` 导出（尺子与测试复用）、
  尺子 `npm run bench:guard`（`bench/guard-sweep.mjs`：`--collect` 采数后离线扫规则，`--fake-cos` 量垃圾提供方）。
- 顺带修掉一个真 bug：`guardedOrder` 里 `Number(null)` 是 0 —— 提供方「部分给分、部分不给」时
  会把没分的那条当 0 分参照物，分差与跨度都算歪。现在 null/undefined/非有限值一律当「没有分」。
- 测试 +3（`test/semantic.test.mjs`）：窗口内提名的集合不变、三种「不动」（分差不够 / 头名在窗口外 / 没给分）、
  门槛比例可调与边界。

## 0.12.9

**探针卷子从 36 条扩到 174 条 —— 上一版「云端买不到质量」的结论被自己的尺子推翻了两处。**

- `scripts/probe-queries.mjs` 新增 `WIDE_PAIRS`（139 条，按笔记成组、刻意避开笔记标题词，好落在词面第 2~10 名）
  与 `ALL_PAIRS`（去重后 174 条）；新尺子 `npm run bench:probe`（`bench/probe.mjs`）离线打印这条卷子的
  词面名次分布：保护集 117 / 争夺集 52 / 宽池集 5 / 深池 0 —— 词面 hit@1 上界 **67.2%**、hit@4 **91.4%**，
  池内召回 池5 93.1% / 池10 **97.1%** / 池20 100%。
- `bench/embed-live.mjs` 的 `--store` 换用全量 174 条，并把「重排到底挣了还是赔了」拆开打：
  争夺集翻盘数 + 保护集被挤掉数（以前只有一个总分，一升一降互相抵消，看不见机制）。
- 重测后**两处反转**：① **云端确实比本机 0.6B 强** —— 智谱 `rrf`·池 10 **74.7%** vs 本机 **68.4%**
  （同一张 174 条卷子差 11 条），0.12.7 说的「无法区分」是 36 条卷子的噪声；② 延迟上**本机反而更快**
  （132ms vs 273ms，模型常驻时），0.12.7 说的「云端赢在速度」同样是短卷子 + 冷启动的产物。
  不变的：`trust` 差得离谱（真库保护集挤掉 53/117），默认仍是 `rrf`。
- 新看到的下一块：`rrf` 在保护集里仍挤掉 11 条（池 10）—— 若加一道把握度门槛把它们守住，
  真库 hit@1 能从 74.7% 上到约 **81%**，还没做。

## 0.12.8

**把「一次检索多少钱」换成实测值。**

- 上一版按候选字符数估成「约 500 token / 0.00025 元每次」；实际用 `fetch` 包一层读真端点返回的
  `usage` 量出：**一次检索 335 prompt tokens，约 0.00017 元**（智谱 `embedding-3`，0.5 元/百万 token，
  约 6000 次一块钱）。结论不变（一次检索千分之二厘），但数字改成量出来的。

## 0.12.7

**付费云端端点的实测数字补上了 —— 结论是「花钱买不到召回质量」。**

- README 新增智谱 `embedding-3`（2048 维、0.5 元/百万 token）在合成 704 条 + 真库探针 36 条上的四组实测：
  真库 hit@1 落在 83.3~88.9%，与本机 `qwen3-embedding:0.6b`（83.3~86.1%）**无法区分**（36 条卷子一条值 2.8 个点）；
  合成 704 条上两家**都**略低于纯词面（45.7~46.2% vs 50.4%）。云端赢的是速度（p50 205~260ms vs 278~535ms）与不占本机显存。
- 顺带钉死：`trust` 融合在两家提供方 × 两份卷子上四个数字全部明显低于 `rrf`，README 里写明**不要碰**。
- README 补云端配置样例（无 query 前缀）与「两份配置文件并存、`cp` 切换、**改配置不用重启 App**」的说明。

## 0.12.6

**`npm run check` 现在会活体探一次语义接缝，并识别「App 里跑的是旧代码」。**

- 新增自检小节 `2b) 语义接缝`：按「patch 整段替换 config」的真实语义算出**生效配置**（profile 覆盖存在就
  整段用它，缺的键落回内置默认值），配了真提供方就**真发一次请求**并报 `code` / 池宽 / 融合 / 耗时。
  用的是内置的 3 条假文档，不碰真库；`host-llm` / `host-embed` 这种只能在 App 内跑的提供方会说明原因。
- 同一节比对仓库代码 mtime 与 DSH 进程启动时间：进程比代码旧就警告「Node 缓存了 ESM 模块，
  **配置会热重载、代码不会**，改代码要重启 App」—— 这是 0.12.5 现场排查时踩到的真坑。
- 上一版那条「profile 覆盖的键都认得」提示补上了整段替换的后果说明。

## 0.12.5

**默认融合从 `trust` 翻回 `rrf`（有实测依据的推翻），本机端点免密钥，接上 Ollama 变成五步。**

- **默认 `semanticFusion` 改成 `rrf`**。0.12.0 选 `trust` 的依据是「理想提供方」实验（全量且完美的排名：
  trust 86.6% vs rrf 52.7%），但真提供方是**有噪音**的，而 trust 把噪音全额转成损失。两组真测量推翻了这个前提：
  哈希假向量在真库探针上 trust 5.6% / rrf 19.4%（纯词面 72.2%）；本机 `qwen3-embedding:0.6b` trust 72.2% / rrf 86.1%。
  `trust` 保留为「量过端点确认它更准」的高天花板档（README 有完整对照表）。
- **本机端点不需要密钥**：`localhost` / `127.0.0.1` / `::1` / `0.0.0.0` 一律免密钥，且不再发 `Authorization`
  头（原本忘了配密钥会直接 `missing-key` 拒掉）。远端内网网关随便填一个值即可。
- **真端点量尺对本机端点也放行**：`bench:embed --real` 不再要求密钥，`--store` 指向真库时可零成本跑对照。
- **README**：新增「五步接上本机 Ollama（零成本、零出网、已实测）」、真模型全量对照表（池 10/20/50 × rrf/trust）、
  指令型模型的 `queryPrefix` 实测（带前缀真库 hit@1 +5.5pt）。合成 704 条这份卷子是按词面关键词造的，
  语义在它上面基本不赚 —— 真库 +11~14pt 才是这条路的价值。

# 变更记录

本项目的所有重要变更都记在这里。格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)，
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。

## [0.12.4] — 2026-10-15

### 修复

- **`npm run bench:embed -- --real` 之前根本跑不起来**：生成的一次性提供方模块用了默认导入，
  而参考提供方只有命名导出 ⇒ 每条查询都记成 `load-error`，质量列直接掉到 0。改成 namespace 导入。
  （用本机假 embeddings 服务把 `--real` 那条完整路径跑通了才发现 —— 也就是说，**这条文档里写着的
  一条命令，之前只有真去接端点的人才会撞上**。）

### 新增

- `bench:embed` 支持 `--timeout <ms>`（默认 20000）：给端点的预算，**超时降级为词面结果而不是报错**。
  实测：服务端每次慢 1.5 秒 + 预算 800 ms ⇒ 每次检索 p50 = 805 ms（预算被当真），
  全列 `timeout×20`、质量退回词面基线。

## [0.12.3] — 2026-10-15

### 新增

- **参考提供方会自动重试限流与临时故障**：`429 / 408 / 409 / 425 / 500 / 502 / 503 / 504` 重试 2 次，
  尊重服务端 `Retry-After`（秒数或 HTTP 日期，上限 5 秒），等待可被接缝的超时预算打断；
  `400 / 401 / 403` 不重试。重试到上限的错误写成 `embeddings HTTP 503（已重试 2 次）：<服务端原话>`。
  新增 3 条测试（重试后成功 / 重试到上限 / 4xx 不重试），都打本地假服务。

### 文档

- README 补「哪些服务能用」：常见可用名单（OpenAI / 智谱 / 阿里百炼 / 硅基流动 / Mistral / Together /
  本机 Ollama / 本机 LM Studio 的 `BASE_URL` + `MODEL`），以及**不能用的两家及其原因** ——
  Anthropic 没有 embeddings 接口；**DeepSeek 官方 API 只有对话模型**（已对照官方模型表），
  所以现有的 DeepSeek key 走不了这条路，零成本试法是把 Ollama / LM Studio 起在本机。

## [0.12.2] — 2026-10-15

### 新增

- **embeddings 接缝的实测尺子**（`npm run bench:embed`）：`--fake` 起本机假服务把管线与代价量清楚
  （池子实际大小、每次检索耗时、HTTP 调用数、发出文本条数、缓存省下多少），`--real` 换成真端点量质量。
  真库那半边要显式 `--store`，因为**它会把笔记片段发给端点**。
- 提供方模块现在也接受**默认导出对象**（`export default { rerank }`）——第三方提供方最自然的写法，
  不必改成命名导出。命名导出照旧。

### 说明

- 假向量（哈希向量 = 最坏提供方）把融合表的结论变成实测：真库探针 hit@1 从 72.2%（词面）掉到
  5.6%（`trust`）与 19.4%（`rrf`）—— `trust` 对垃圾提供方是灾难，**第一次接第三方接口建议先 `rrf`**。
- 缓存实测：26925 条候选只发出 1994 条（省 92.6%），因为同一篇笔记在不同查询里反复出现。
- 池子实际大小中位 31~39 条（不是 50）：词面只回有分的文档，所以 `semanticPool` 是上限不是常态。

## [0.12.1] — 2026-10-15

### 新增

- **池宽天花板尺子**（`npm run bench:ceiling`）：把「候选池放宽到 N 条时，任何提供方最多能拿到几分」
  单独量出来，含按题型拆分与「每查询要发给提供方多少东西」（条数 / 片段字符数 → token 估算，离线可算）。

### 说明

- 量出一个反直觉的事实并写进 README：**公开数据集上池宽买不到分数** —— LongMemEval-S 每题的 haystack
  只有 30~50 个 session，池内召回 top-5 就已经 98.2%、top-20 99.6%；池宽的价值在「库比窗口大得多」时
  （合成 704 条 70.3% → 97.3%）。所以默认 50 是给大库留的余量，不是公共基准上的分数。
- `bench/longmemeval.mjs` 的 session→笔记映射抽到 `bench/sessions.mjs`（与尺子共用一份，避免口径漂移），
  20 题输出逐字未变。

## [0.12.0] — 2026-10-15

### 新增

- **语义提供方现在看的是宽池**（`semanticPool`，默认 50、上限 200）：池子等于输出窗口时，提供方只能
  在词面已经挑中的那几条里排序，语义再准也捞不动排在第 12 名的正确笔记。合成 704 条上池内召回从
  70.3%（池 5）涨到 97.3%（池 50），真库探针从池 5 起就是 36/36（`npm run bench:seam`）。
- **默认融合模式改成 `trust`**（提供方的顺序即结果，词面只用来补满窗口），`semanticFusion: rrf` 保留为
  「不确定提供方靠不靠谱」时的安全档。依据：完美的全量排名提供方在等权 RRF 下 hit@1 只有 52.7%
  （纯词面 50.4%，几乎没赚），`trust` 下 86.6%；而倒排提供方反过来是 trust 1.0% / rrf 26.0%。
- **新增参考提供方 `providers/openai-embeddings.mjs`**：OpenAI 兼容的 `/embeddings` 客户端，零依赖、
  批请求 + 按文本缓存、错误带 HTTP 状态与服务端原话、**密钥不进日志**。配置走环境变量或
  `~/.config/engramory/embed.json`（0600），`npm run embed:check` 一条命令自检。
- **新增 `provider: 'host-embed'`**：复用 DSH 设置页里已经配好的那条路由（`baseURL` + 模型 + 密钥），
  不用再填一遍环境变量。四步（路由 / baseURL / 模型 / 密钥）都从宿主读，每步失败都给可照做的提示；
  凭据每次检索现解析（不跨操作缓存）；MCP 等没有宿主的场景明确报 `no-host`。
- 新测试：`test/embed-provider.test.mjs`（7 例，含假 embeddings 服务）、`test/host-embed.test.mjs`
  （5 例，含宿主三个服务的最小替身）、`test/semantic.test.mjs` 增加池宽与融合模式 3 例。

### 说明

- 参考提供方与 `host-embed` 的**召回收益还没有在真 key 上量过**（要花 token，等用户点头）。已量的是
  接缝本身的上限与代价，以及默认值为什么这么定 —— 这两组数字都在 `npm run bench:seam` 里可复算。
- 默认池 50 的代价：每次检索多算一次词面（50 条），实测平均仍是毫秒级；提供方多收到 45 条候选，
  embeddings 按文本缓存，重复检索不重复付费。

## [0.11.2] — 2026-10-15

### 变更

- **把「换措辞再搜一次」写进模型看得见的地方**：`engramory_search` 的工具描述补一句
  「它匹配的是措辞不是意思 —— 没命中就自己换个说法再搜一次（同义词、术语英文、笔记里可能用的词）」，
  未命中时的提示也从「换关键词再试」改成同一句话。依据是宿主模型改写那把尺子量出来的：
  同一个意思换个说法能命中而原句命不中的比例是 **80.6%**（词面自身 72.2%），
  而让插件内置去「融合模型改写」是负收益。所以这个能力交给调用方用，而不是塞进检索里。

## [0.11.1] — 2026-10-15

### 修复

- **`expand` 型语义提供方改为「只补空位」**：词面排出来的顺序一个字不动，改写带来的新文档接在后面
  （`semantic` 字段新增 `added`，报补了几条）。此前 `expand` 走的是与 `rerank` 相同的 RRF 融合 ——
  用真宿主模型实测，那是**负收益**：模型给的 3 条改写融合进来后，真库探针 hit@1 从 72.2% 掉到 52.8%
  （RRF k=60），归一化取最大更是掉到 27.8%。原因是投票数变成 4 倍，而词面那 1 票并不比模型那 3 票
  更该被信任。证据与全部变体见 README「让宿主模型当改写提供方」一节。
- 回归测试：`test/semantic.test.mjs` 把 expand 的两种情形分别钉住（词面占满 `limit` 时必须逐字相同；
  词面不足时只许接在后面）；`test/semantic-corpus.test.mjs` 新增一例 —— 用「同一篇笔记的其它问法」
  当**真实的好改写**（这正是能把旧实现打出 -7 hit@1 的那类输入），在 117 条真语料查询上断言命中与
  词面逐条相同、`added` 恒为 0。

### 新增

- **宿主模型改写尺子**（`bench/host-rewrite.mjs`，`npm run bench:rewrite`）：以本机 harness 为
  「真宿主模型」逐条采集改写（提示词与解析直接 `import` 产品代码，缓存可续跑、`--offline` 可只重算），
  再离线重放给真接缝。量三件事：逐条延迟、词面 vs 接缝的命中、以及「任意一条改写单独检索」的上界。
  实测：延迟中位 3.8~4.2 秒、**70/70 与 36/36 都超出默认 2000ms 预算**；收益 0（与词面逐条相同）；
  上界 50.0% / 80.6%（词面 70.0% / 72.2%）⇒ 信号在改写里，但宿主没有打分器，用不上。
  `semantic.js` 为此导出 `hostLlmPrompt()` / `normalizeExpansions()`，保证尺子问的就是产品要问的那句话。
- README：新增「实测：让宿主模型当改写提供方」小节（代价/收益/融合变体三张表 + 什么时候才值得开），
  并把「拿模型改写去融合」记进「被实测证伪的路」（现十条）。

## [0.11.0] — 2026-10-15

### 新增

- **同义表 / aliases 的覆盖率尺子**（`bench/bridge-coverage.mjs`，`npm run bench:bridges`）：
  把「这张手写桥表到底有没有在工作」从感觉变成可复算的数字，四件事一起量 ——
  - **死值**：真库与合成语料各算一份 `df`，每个值落进四个格：两边都活 90 · 只对真库有用 312 ·
    **只对基准有用 39** · 两边都死 78（519 个值里 15.0%）。真库口径的全死值 117（22.5%），
    整键全死 9 个键（`下载`、`轮转`、`并发`、`乱码`…）。
  - **覆盖**：低 `df`（≤5）锚点词 2732 个，被桥值接住 118（4.3%）；一个桥都接不住的 chunk 占 50%。
  - **使用面**：合成语料 704 条走到 69 个键，探针 36 条真实 query 走到 44 个键，两卷都没走到的 76 个。
  - **逐键消融**（`--ablate`）：被走到的 101 个键里 57 个零贡献；整表拔掉在合成卷上是
    hit@1 355→327、hit@4 475→365，在**真探针**上是 27→24 / 36→31。
  `--json` 把结果落 `bench/.bridge-coverage.json`；`--store <path>` 可以对着任意一份记忆库跑。
- `scripts/probe-queries.mjs`：探针的三张配对表（口径 22 / 桥 10 / 别名 5）抽成共享模块，
  探针与尺子跑**同一张卷子**，避免「改了探针忘了改尺子」。

### 文档

- README 的「去掉同义表」消融行加了折算说明：合成卷上的 -4.0pt 是**上界**（口语组/桥组查询本身就是
  照着「需要桥」造的，39 个同义值只在这份卷子上有落点），真人查询上的同口径数字是 -3 @1 / -5 @4。
- README「桥和别名是怎么挖出来的」补第 6 条（覆盖率尺子）与上面那张表，并写明**表不动**的理由：
  78 个「两边都死」的值是 `permission` / `idempotent` / `mojibake` / `xpath` 这类通用工程词，
  插件是发布出去的，要服务任意一份记忆库；尺子的用途是把「自用可裁剪、发布留通用词」这笔账
  量出来（`--store` 直接列出别人那份库的死值清单）。
- 自检清单补 `npm run bench:bridges`。

### 说明

- 本轮**没有改检索代码**：`bm25.js` 一个字节都没动。试过按「两卷都死」删掉那 78 个值，
  实测两张卷子的分数逐字不变（正因为它逐字不变，就说明删与不删对**本机**质量没有影响，
  而它对别人的库有影响），所以保留原样并把账记在 README 与 CHANGELOG 里。

## [0.10.0] — 2026-10-15

### 改进

- **查询侧再丢掉一类「求助/建议框架词」**（`bm25.js` 的 `EN_REQUEST_STOPWORDS`，14 个：
  `tips / suggest / suggestions / recommend / recommendations / advice / think / thinking /
  idea / ideas / helpful / good / upcoming / weekend`）。这些词是「同一类对话」的框架，不是话题：
  一次求助会话里几乎必有它们，于是任何同流派的会话都会因为共现框架词被抬分 —— 那是 genre 在
  匹配，不是 topic 在匹配；最吃亏的是「先说出偏好、再问建议」的查询，它的实词被框架词稀释成
  「又一场给建议的对话」。
  - **实测（LongMemEval-S 500 题）**：RecallAny@1 **88.4% → 89.2%**、MRR@5 **0.9202 → 0.9251**、
    RecallAny@10 98.8% → 99.0%；最弱的偏好分片 @1 **46.7% → 60.0%**（MRR 0.627 → 0.708），
    其余五个分片逐位不变。
  - **词表只收「在失败样本里真出现过」的写法**，单复数不自动补齐 —— 补了就是没量过的猜测。
  - 逐个消融：没有哪个词单独扛收益（去掉任意一个最多掉 1 题）；只留子集与大表同分
    ⇒ 收益属于「一类词」，不是一个咒语。
  - 对照实验：换成「按 df 比例自动丢高频查询词」的通用规则反而全线下跌（df<0.35 → @1 87.6%、
    df<0.20 → 85.4%）⇒ 收益来自这一类词的**语义**，不是频次本身。
  - 三道保险保证查询不被清空：单关键词查询不筛；全是虚词的查询退回原样；去掉框架词后为空
    （`any tips` 这种）退回只过虚词的结果。
  - 真库探针与合成基准：同一语料上新旧代码六项数字逐位相同（探针语料本身在此期间被别的会话
    改过，指纹已重设基线）；`npm run bench:check` 全部指标在容差内。
- README 的「八条被实测证伪的路」→「**九条**」，新增一条 **「拿日期当检索信号」**：
  LongMemEval-S 500 题只有 39 题能抽出可用时间窗；硬过滤 11 题救回 / 11 题误伤；
  948 个 gold 的新度百分位平均 0.459 ⇒ 日期侧没有先验；纯按日期排的引擎 @1 仅 3.8%。
  机制写在表里：问题引用的是事件时间，不是 session 时间戳。
- README 的 LongMemEval 表、分题型表、B 对照表与「三句实话」按新数字更新（2.2pt → 3.0pt）。

### 测试

- `test/recall.test.mjs` 增加 2 个用例（框架词被丢而实词不丢；全是框架词时退回而不清空），
  合计 213 例。

## [0.9.0] — 2026-10-15

### 新增

- **可选的语义检索接缝（`semantic.js`，默认关闭）**：`semanticProvider` 指一个本地 ESM 模块
  （导出 `rerank({query, docs, limit, signal})` 或 `expand({query, signal})`），或者字面量
  `host-llm`（用宿主模型做查询改写，走 `llm.js` 的动态 import）。四条边界是设计出来的：
  - 结果是 **RRF 融合**：词面命中的文档一条不丢，提供方只能改顺序；它返回候选之外的文件名
    一律丢弃并报数（`dropped`）；
  - 超时**硬兜一层 `Promise.race`**：提供方忽略 `AbortSignal` 也收得住；
  - 失败不抛错，降级回词面结果并如实报 `code`（`off` / `not-found` / `unsupported` /
    `provider-error` / `timeout` / `provider-empty`），检索工具返回值里多了个 `semantic` 字段；
  - **只管按需检索工具**，每轮自动注入那条热路径永远纯词面（挂外部件等于给每个用户轮加税）。
- README 新增「可选：语义检索接缝」小节（含提供方接口示例与上面这张边界表）；「短板」两处
  关于「无语义检索」的说法改成准确表述：开箱仍是纯词面，接缝不替你选模型、不替你出网。

### 测试

- `test/semantic.test.mjs` 11 例（自建假提供方模块）：抛错 / 卡住 / 返回垃圾 / 读不到模块 /
  编造文件名 / 空结果都降级且报原因；`expand` 带进来的新文档进结果、词面第一名不丢；
  默认关闭时用**副作用文件**证明连 import 都没发生；`module` 形式不静态 import 宿主包与 `llm.js`。
- `test/semantic-wiring.test.mjs` 5 例：关着时不打标不 import、开着时真的融合、超时仍返回词面结果、
  **每轮自动注入不碰接缝**、`index.js` 静态依赖里仍然没有 `llm.js`。
- `test/semantic-corpus.test.mjs` 2 例：在**真语料**（合成基准 704 条查询抽 117 条）上钉住结构性承诺 ——
  抛错 / 卡住 / 返回垃圾 / 编造文件名四种坏提供方，命中与词面路径**逐字相同**；
  真打分的提供方只能重排（同一 limit 下输出是同一批文档），不能凭空增删。
- `npm run bench:seam`（`bench/semantic-seam.mjs`，可复算、零模型调用）：关闭 1.3 ms / 抛错 1.4 ms /
  卡住 53.0 ms（预算 50 ms，证明硬兜生效）/ 编造 id 1.4 ms —— 前四种命中的 hit@1 50.4%、hit@4 67.5%
  与词面**完全一致**；离线字符三元组重排 47.4%/65.5%（**赔** 1.5~3.0pp）；按 gold 重排的作弊上界
  86.6%/86.6%，而 20 个候选里含 gold 的查询只占 610/704 = 86.6% —— 接缝的天花板由提供方质量决定，
  「默认关闭」因此不是保守，是准确。
- 合计 211 例全绿；`bench:check` 与期望值一致（两把中文尺子的数字不受影响 —— 接缝默认关闭）。

### 工程

- 新增 `.github/workflows/release.yml`：推 `v*` tag 即发版，**不用任何长期 token**（npm 的
  Trusted Publisher / GitHub OIDC + `--provenance`）。发版前跑与 CI 完全相同的三条门槛
  （测试 / 基准数字不漂 / 包能打出来），tag 与 `package.json` 版本不一致直接报错，
  同版本已发布则跳过。
- 新增 `npm run release:verify`：发布成功的判定**不看退出码**，而是直查版本端点
  （packument 有分钟级缓存，会骗人），并把 registry 上的 `dist.shasum` / `dist.integrity`
  与本机 `npm pack` 的逐字比对 —— 证明「发上去的就是工作区这一份」。
- 起因：2026-10-15 人肉发版被 npm 的口令机制卡住（bypass token 被限制、普通 token 要 OTP），
  而 npm 的计划正是 **bypass 2FA 的 token 直接发布 2027-01 失效** —— 与其等它到期，不如现在改道。

## [0.8.1] — 2026-10-15

### 修复

- **通过 `engramory-recall-mcp` 命令调用时服务器不回包**（0.8.0 的 bug）：npm 装出来的 bin 是
  符号链接，Node 默认解析真实路径，于是「这个文件是被当程序跑的吗」那句
  `import.meta.url === pathToFileURL(process.argv[1])` 比对为 false，进程安静地读 stdin、
  永不回包 —— 用户只会看到「服务器没反应」，既不报错也不退出。
  改为两边都取 `realpath` 再比。
- `test/mcp.test.mjs` 补一条回归：把服务器软链成 `engramory-recall-mcp` 再起进程
  （npm 的 bin 就是这个形状），确认 initialize 与检索都正常。

## [0.8.0] — 2026-10-15

### 新增

- **零依赖 MCP 服务器（`mcp.js`）**：走 stdio 的只读 MCP 服务器，把同一份记忆库挂给任何支持
  MCP 的宿主（Claude Code / Codex / Cursor…）。三个工具：`engramory_search`（同一个 BM25 内核）、
  `engramory_read`（按文件名读原文，只认库根目录的 `.md`）、`engramory_status`（库位置 / 篇数 /
  索引余量 / 指纹）；每篇笔记另外注册成 MCP 资源（`engramory://note/<文件名>`）。
  只用 `node:` 内置模块、不 import 任何宿主包、**只读** —— 写入闸门与归档依赖宿主工具层的钩子，
  别的宿主没有这套接口，硬做只会把 fail-closed 换成口头约定。
  装了包就多一个命令：`engramory-recall-mcp`（库路径用 `--store` 或环境变量 `ENGRAMORY_STORE`）。
- `npm run mcp:smoke`：拿脚本当 MCP 客户端，把服务器真的拉起来走一遍对话
  （initialize → tools/list → status → search → resources/read → 越界读被拒），打印收发记录。

### 测试

- `test/mcp.test.mjs`：8 例，**全部起真进程**（协议版本协商、工具清单与 inputSchema、检索命中、
  非法参数只回 isError 不炸进程、`../` 越界读一律拒、resources 往返、空库给「库在哪」的提示、
  stdout 上只允许协议帧且日志只走 stderr，外加 `--version` / `--print-config` / 坏参数的退出码）。
  合计 192 例全绿。

## [0.7.0] — 2026-10-15

### 变更

- **长度归一常数 B 从教科书的 0.75 调到 0.4**（`bm25.js`）。三把尺子一致说 0.75 偏大：
  LongMemEval-S 500 题 @1 87.8% → **88.4%**、@5 97.4% → **98.2%**、覆盖 91.5% → **92.5%**、
  MRR 0.9155 → **0.9202**；自建合成卷子 hit@1 49.9% → **50.4%**、hit@4 66.6% → **67.5%**；
  真库探针的中立对照 C top1 141/181 → **150/181**、recall@4 168/181 → **177/181**。
  代价是偏袒短名的对照 A 掉了 2 分（45→43）。机制：长会话/长笔记里同一个词出现得更多，
  `len/avgLen` 的惩罚就变成「谁长谁吃亏」，与相关性无关。扫过 0.2~0.9，0.3~0.5 是平台，取 0.4。
- README 三张表与「长度归一」小节据此重算；`bench/expectations.json` 与
  `bench/longmemeval-results.json` 一起重设（都写了原因）。

### 新增（记在「被实测证伪的路」里）

- **拿「结果要多样」当选桶护栏（MMR / 覆盖优先）**：在 LongMemEval-S 500 题上，MMR 各档
  （λ 0.15 / 0.3 / 0.5）与基线逐位相同；覆盖优先 β=1.0 让 @1 掉 1.4pt。
  顺带把「差一点」量化出来：948 个 gold 里 87.9% 已进 top-5，没进的里 40.0% 排在第 6~10 名、
  39.1% 在第 11~25 名、16.5% 与问题零词重叠 —— 瓶颈是语义，不是重排。
- **df 下限**（丢掉出现在超过一半文档里的词）：LongMemEval-S 上三档全不涨，@1 持平或更差。

## [0.6.2] — 2026-10-15

### 变更

- **查询侧丢掉英文虚词**（`bm25.js` 的 `EN_STOPWORDS` + `queryTerms()`）：`what / is / the / of /
  did / how…` 这类语法词不再参与打分。只过滤查询、**索引侧一个词不动** —— 索引侧删词会顺带
  改掉每篇的 `len`，那等于动了长度归一，不值当。
  LongMemEval-S 500 题：RecallAny@1 **86.2% → 87.8%**、Recall@5 90.7% → 91.5%、
  MRR@5 0.9056 → 0.9155。自建合成基准与真库探针不变（那两把尺子的查询是中文的）。
  虚词表只收语法词，`time / name / like` 这类可能带语义的词一个不收；只剩虚词的查询退回原样，
  不清空（清空等于一条都召不回）。
- README 的 LongMemEval 章节据此重算：本插件现在比教科书 BM25 高 1.6pt @1，并写明**这 1.6pt
  全部来自这一处通用改动**，不是本插件独门机制的功劳。
- 「被实测证伪的路」加入第六条：**伪相关反馈查询扩展**（拿 top-1 的高频词再搜一遍）。
  实测 RecallAny@1 89.2% → 48.3%、Recall@5 只涨 0.4pt —— 只看 top-5 的话会被误判成有效。

## [0.6.1] — 2026-10-15

### 新增

- **公开基准：LongMemEval-S**（`xiaowu0162/longmemeval-cleaned` 的 S 版，500 题）。
  harness 是 `bench/longmemeval.mjs`，逐题结果随仓库发布（`bench/longmemeval-results.json`）。
  本引擎 500 题跑 18 秒、零模型调用：RecallAny@1 86.2%、**RecallAny@5 97.0%**、
  Recall@5（gold 覆盖率）90.7%、MRR@5 0.9056、RecallAny@10 98.2%。
  harness 支持 `--engine unigram|bigram` 跑教科书 BM25 对照。
- **同格式小夹具 + 测试**（`bench/fixtures/longmemeval-mini.json`、`test/longmemeval.test.mjs`，5 例）：
  真数据集 264MB 不进 CI，夹具用四道题（英文命中 / 中文命中 / 多 gold 只找到一半 / 零字面重叠
  必失手）守住判据与两个开关；判据被改动就红。

### 说明

- 在这份**英文**数据集上，本引擎与教科书 BM25 的五个指标完全相同：多出来的机制
  （中文口语桥、标题字段加权、别名双计）在这里基本没被激活。README 里写明了这一点 ——
  它只说明「英文词面检索不输常规写法」，不是「我们更准」的证据。
- 与 `thoth-mem` 公布的 470 题成绩不可直接相减（题集版本与 harness 都不同）。

## [0.6.0] — 2026-10-15

### 新增

- **同义表扩到 177 条 / 519 个值**（105 → 177）。新增的第五波是**通用技术口语**：
  「变慢」「退不回去」「改表结构」「装不上」这类任何写中文技术笔记的人都会用的说法。
  它和第一至四波的性质不同 —— 那几波是从一份具体的库里挖出来的（专有名词、某个人的叫法），
  换一套词汇就大面积失效；第五波不依赖具体某个库。索引里多出 141 个键，实测体积变化在噪声量级。

### 修复

- **桥的碎片降权不再连键本身一起降权**：命中的同义键拆出来的碎片（单字、相邻双字）算 0.5，
  但键本身若原样出现在查询里，照旧算满权重。原先一刀切把键本身也降到 0.5，
  合成基准的 `verbatim` 对照组从 100% 掉到 95.1%；放开键本身后回到 98.2%，桥组没退，
  真库的桥探针还从 9/10 变成 10/10。理由很直白：查询里原样出现的那个词是实打实的证据，
  该降权的只是它拆出来的碎片（那些碎片在别的笔记里到处都是）。
- **CI 上 Node 20/22 的永久 pending**：`test/llm.test.mjs` 的超时用例依赖
  `AbortSignal.timeout` 的定时器把事件循环撑住，而那个定时器是 unref 的，
  只有本地 Node 25 侥幸能过。测试进程现在自己留一个 ref'd 句柄。CI 自 0.5.0 起连续四次都是红的。

### 文档

- README 的基准数字全部换成实算值，并补上两处此前没写清的取舍（别名在合成语料里是把双刃剑；
  碎片降权为什么不能连键本身一起降）。
- `bench/expectations.json` 重设（查询数 683 → 704），`verbatim` 组不再要求满分（98.2%，容差 1pt）。

### 基准口径（不是引擎变好，是尺子以前量错了）

- 覆盖率判据从「整个英文短语有没有键」改成「这个值有没有键」：旧判据把 16 个域词判成只覆盖 5 个、
  14 个事件词只覆盖 5 个，按值判其实是全覆盖。
- 英文体裁的笔记改成真正的术语正文。此前正文里混着中文字面，口语查询靠碎片就能蒙中，
  桥组样本几乎构造不出来（这也是桥组只有 11 条的原因，现在 32 条）。
- 桥样本改为按表里**真实存在的键**抽样，而不是拿整个英文短语去查表。

## [0.5.3] — 2026-10-15

### 新增

- **合成基准变成一道可执行的门**：`npm run bench:check` 把这次跑出来的数字跟 `bench/expectations.json` 对账，任何一项偏差超过 2 个百分点就退出非零；桥组与 verbatim 组要求满分（它们掉下来就是改坏了）。分数低不红 —— 绝对值会随语料演化，卡死绝对值只会逼人改尺子；数字悄悄漂走才红。CI 已接上这一步。

### 文档

- 「测试与自检」补上基准自检这一条，并说明 CI 跑的是哪两件事。

## [0.5.2] — 2026-10-15

### 文档

- README 加一节「不用信这段话，两分钟自己验一遍」：把写入闸门的四条实地探针（索引超限、库外同名文件、文件名不合规、含凭据）连预期原话和实测结论一起写进去。唯一卖点值得一个能当场复现的验法。

## [0.5.1] — 2026-10-15

### 修复

- **接线自检脚本重写成真的能红的那种**（`scripts/check-recall-wiring.mjs`）。
  旧脚本还在检查 `dsh-engramory` 这个上游插件 —— 它的功能 0.4.0 就并进来了，
  于是这个脚本从那时起必然失败：一道永远报错的门等于没有门。新脚本盯的是本插件的五种
  **静默失效**：配置键打错字（宿主忽略未知键 ⇒ 退回空 `storePath` ⇒ 一个 token 都不再注入）、
  库路径指空、profile 里跑的不是你正在改的那份、`files` 白名单漏文件、
  这一行被 `enabled: false` 关掉；另加 `node --check` 语法门（客户端 bundle 写坏时，
  宿主会静默丢掉整个客户端半侧）。它自己也有 6 例测试（`test/wiring-check.test.mjs`）：
  故意把配置改坏，断言它**真的以退出码 1 结束** —— 门坏了比没门更糟。
- 索引上限的数值不再靠文档转述：自检脚本直接 `import` `index-guard.js` 的常量来比余量。

### 文档

- 首屏那句「零模型调用」改成**默认零模型调用**，并注明只有显式开启可选语义增强才会走宿主模型 ——
  0.5.0 加了 `llm.js` 之后，原来那句话在字面上已经不准确了。
- 删掉 README 里对 `verify-panel.mjs` 的引用：那个文件早已不在仓库里；它承诺的
  「加载客户端 bundle」这部分，现在由 `npm run check` 的语法门真实执行。

## [0.5.0] — 2026-10-15

### 新增

- **可选的语义增强**（`llm.js`，466 行，默认关闭）：`semanticConflicts` 注册
  `engramory_conflicts` 工具（让宿主模型找出互相矛盾的笔记对），`semanticSummary` 注册
  `engramory_summarize`（把一篇长笔记压成一段话）。两个开关默认 false，关着时**连工具都不注册**；
  开着时也只在工具被调用那一刻 `await import('./llm.js')`，静态依赖图里没有它 ——
  自动召回、面板、体检、写入闸门在任何情况下都不会顺带发模型请求。
  模块零 import（不引宿主包，实测本机 profile 里的 `@deepseek-ai/dsh-llm` 会解析到另一个
  应用的旧副本 0.1.6-alpha.1，而宿主自带 0.2.0-rc.2），消息用普通对象，文本自己从
  `text-delta` 累加。失败一律带 code 上抛；给体检用的那条路永不抛，降级成一条
  `semantic-unavailable`。模型编造的文件名一律丢弃并报出丢弃数。
- **接线测试**（`test/llm-wiring.test.mjs`，6 例）：钉住「默认只注册一个工具」
  「`index.js` 不许静态 import `llm.js`」「开关打开后模型指认能变成 findings、编造的文件名被丢弃」
  「宿主没有 LLM 服务时降级成 warn 而不抛」。
- **合成召回基准**（`bench/`，只在仓库里，不随 npm 包发布）：224 篇程序生成的笔记、
  683 条查询、固定随机种子，查询按「口语改写 / 关键词 / 逐字 / 桥」四类分开记分，
  并带消融与参数扫描（`node bench/run.mjs`）。它的用途是让「每个机制值多少」可复算，
  而不是只在一份私有库上量。当前总分 hit@1 49.5%，桥组 100%。

### 修复

- **桥被查询自己的字面碎片压住**：查询命中同义键时，该键自身的字面碎片此前按满权重计分，
  而桥到的技术词只算 0.5，于是「崩了」里的「了」在别的笔记里到处都是，
  真正靠同义桥才连上的笔记被挤下去。现在命中同义键时，桥到的词按 `BRIDGE_WEIGHT = 1`
  计入、该键自身的碎片降到 `BRIDGED_FRAGMENT_WEIGHT = 0.5`。
  合成基准的桥组（查询里的词在目标笔记里一个字都没有）从 27.3% 升到 100%。
  代价是总分少 1 个点（50.5% → 49.5%），因为合成语料里大多数查询本来就有字面落点 ——
  这个取舍写在 README 的消融表里，包括「不这么做能多拿 0.5 个点但桥组掉到 90.9%」那条。
  试过的另一条路（直接把同义键的字面碎片剔掉）打红了真实存在的测试：查询 `代理节点与部署`
  里的 `代理`、`部署` 既是同义键也是笔记正文的原词，剔掉等于把真证据扔了。
  所以是降权而不是剔除。

### 文档

- README 重写：去掉真实记忆库的文件名、路径、篇数与带日期的私人事故，改用中性示例；
  补「召回质量：两把可复算的尺子」与「跟同类插件对照」两节，后者如实写明
  **哪些能力不是本插件独有的**（零模型调用、中文词法检索各有四家同类在做）与
  五条短板，避免把「我没读到」写成「别人没有」。

## [0.4.1] — 2026-10-15

### 变更

- `index.js` 的注释里去掉一处个人用户名 —— 发布包里唯一带私人痕迹的地方。
  **没有任何行为变化**，纯粹是让别人打开源码时不看见作者的本机名。

## [0.4.0] — 2026-10-15

**本插件从此是一个完整的记忆系统 —— 不再依赖 `dsh-engramory`。**

### 新增

- **融入 `dsh-engramory` 的两件事**（它一共只做这两件，逻辑与措辞逐字保留）：
  - **索引上限闸门**（`index-guard.js`）：`MEMORY.md` 长过 200 行 / 25 KB 就**拒绝写入**，
    但「缩小的重写永远放行」—— 那条例外看着像后门，实则是唯一的出路：索引一旦超限，
    若不放行缩小，就没有任何一次写入能把它降下来，闸门会把自己锁死。
    路径不必配置，固定是 `<storePath>/MEMORY.md`（原插件为此要人手钉一个绝对路径，
    因为它按 basename 匹配会误伤别的项目里同名的 `MEMORY.md`）。
  - **`engramory` 技能**（`skill.js`）：写入纪律的正文，注册进 `ctx.skills`。
    `description` / `whenToUse` 与正文逐字保留它的措辞 —— 那些句子被反复打磨过，
    尤其「问候不算任务」那段边界，改一个字都可能松动纪律。
  新配置项 `indexMaxLines` / `indexMaxBytes` / `registerSkill`。
  融进来的理由不只是省一个依赖：两套闸门各管一半（这边管笔记文件、那边管索引），
  **判据一旦漂移就会互相打架**，而这个项目已经因为「同一个概念有两处定义」栽过三次。
- **对照 C（正文独有词当查询）** 加进探针：query 词取自某篇正文、且任何标题里都没有它。
  它是**唯一能中立评价标题权重**的构造（A/B 都拿标题当查询，对此结构性偏袒）。
  60 篇 / 172 chunk 下 159 条：`top1 123/159 · recall@4 148/159`。

### 变更

- **`TITLE_EXTRA` 3 → 24。** 0.3.1 把它定成 3 的理由有个没发现的漏洞：「口径在 3 之后
  不再动，所以后面的增量全来自对照，而对照只是别掉分的底线」—— 可对照 A/B **都拿标题
  当查询**，对标题权重是**结构性偏袒**的，它们既不能当目标函数，**也不能**用来证明
  「抬高标题权重是安全的」（它们测不出这件事）。补上中立的对照 C 后，数据说 3 太保守：
  C 从 0 到 96 **一路不降**（`118/140 → 122/140 → … → 148/159`），在 **24** 到达平台，
  之后 32/48/96 一动不动。原先担心的「标题压过正文」在数据里**不存在**。
  取 24 = 第一个让 C 到达平台的取值（48/96 只多 1 个 top1，@4 一个不涨）。
  口径在**所有**取值上恒为 20/21，对这个参数**没有分辨力** —— 这一点必须写下来，
  免得下一轮又有人拿「口径没动」当作「所以取哪个都行」。

### 测试

- 128 个用例全绿（原 94）。新增 `test/index-guard.test.mjs`（17）与 `test/skill.test.mjs`（17）。
  技能那套守的是**融合的完整性**：正文是搬来的，最容易出的不是语法错，而是**边界被悄悄放松**
  （比如「问候不算任务」那句没了），而那不会有任何报错。

## [0.3.1] — 2026-10-15

来源：清点灵枢（`dsh-memory`）最后一份可读产物时，发现两个**我们还没吸收**的机制。
一个采纳、一个实测量到**有害**并否决 —— 两条都留在代码里当证据。

### 新增

- **字段加权（`TITLE_EXTRA = 3`）**：标题与描述里的词额外多计 3 遍（BM25F 的廉价近似）。
  此前 `title/description/body` 被拼成一坨等权处理，于是「长笔记正文里偶然提过一次」
  和「标题就叫这个」同分。实测（指纹 `c9f7fcbf9426`）：对照A `top1 20→21 | @4 79→91`、
  对照B `top1 134→141 | @4 144→146`，口径 / 桥探针 / 别名探针**一项未变**。
  出处：灵枢 `scoreEntry`（`lib/index.js:127-129`）的标题 3 / 标签 2 / 正文 1。

### 记录在案（否决）

- **整句子串命中奖励（`PHRASE_BONUS`）实测有害，保持 0**：机制与常量都保留，
  但实测让它单调恶化 —— 在 `TITLE_EXTRA=3` 之上取 `2/5/10` 时，对照A top1 从
  `21 → 20 → 19 → 17`，而口径一步不动。机理与踩过两次的自污染一致：它奖励的是
  「逐字引用了查询」的笔记，于是把更该命中的那篇挤下去。常量旁的注释里有完整数据表，
  将来谁想加，先看那张表。

### 说明（判断）

- 字段加权的扫描里，`6` 与 `24` 在**对照**上继续变好（`24/92`、`33/93`），但口径
  在 `3` 之后**一步都不再动**。取 `3`（设计值）而非扫描极值：对照在本项目的 README 里
  写明是「别掉分的底线」而非目标函数，拿它当目标去调参正是 `EXTRA_WEIGHT` 那次
  「把功劳记错对象」的同一个坑。要往上抬，`bm25.js` 的常量注释里有现成证据表。

## [0.3.0] — 2026-10-15

这一版的共同点是：**把「看起来在工作」变成「量得出来在工作」**。

### 新增

- **归档第二层**：搬出来的 `<名字>-history.md` 长回去之后能被再次分层，且新文件名从不
  撞已有的（真实库上从 `-history-2.md` 拆出 `-history-5/6.md`）。`baseSlug()` 把
  `x.md` / `x-history.md` / `x-history-2.md` 归成同一篇。
- **笔记级别名（`aliases:`）**：机制本来就在，但真实记忆库 57 篇 0 篇在用。别名按重复
  两遍计入（满权重），与全局同义桥（0.5 权重）的分工写进了 README。
- **召回探针分三组记分**：口径（真实口语问法）/ 桥探针（全局同义表）/ 别名探针（笔记
  自己的别名）。混在一起会被稀释成一个看不出因果的平均分。
- **语料指纹**：把每个 chunk 的 `id / title / description / aliases / body` 哈希成 12 位
  十六进制，自动与基线比对 —— 指纹相同而数字有变才算可信 A/B。
- **死桥自检**：把同义桥的每个值逐个对语料核一遍，指向库里不存在的词会报出来
  （写了等于没写，且不报错）。
- `npm run probe -- --save`：把本次结果存成基线。

### 修复

- **归档的无限接力**。一份 90956 字节的 `-history-3.md` 因为 22 个章节超过
  `ARCHIVE_SECTIONS` 被判超标，执行后生成 91006 字节的 `-history-7.md` ——
  **比原来还大**（新文件要带自己的抬头和指针）。而 91006 又是 22 个章节，于是
  `history-8`、`history-9`… 每点一次「执行」就换个名字，还每次悄悄长几个字节
  （91006 → 91007 → 91012），永远停不下来。根因是「章节多到不像一篇笔记」这条判据
  被套在了 `-history*.md` 上 —— 而它**按定义就是日志**，章节多是正常形态而不是缺陷。
  现在 history 文件只在真的超过 `HISTORY_MAX_BYTES` 时才拆；配合分组上限会先扣掉抬头
  开销，产物必然 ≤ 上限 < 源文件，**终止性是可证的**。两条回归测试守着（其中一条真的
  循环执行到无计划为止），撤掉修复即变红。
- **中文同义词一直是死词项**：`expandQuery` 把同义词原样当词项返回，而库里的词项形状是
  「英文整词 + 汉字相邻双字」—— 中文同义词永远匹配不上。英文同义词 tokenize 后等于自身，
  所以这毛病藏了很久。现在每个同义词先过分词器再用。
- **语料指纹漏字段**：第一版只哈希 `id + body`，只改 `aliases` 时纹丝不动，于是探针一边说
  "语料没变、这是可信 A/B"，一边把一次语料改动伪装成了代码改动。**一个漏字段的指纹比没有
  指纹更坏，因为它是权威口吻的错误。**
- **归档器归档自己的归档**：`planArchive` 原本只排除 `MEMORY.md`，`<名字>-history.md`
  同样超线时会被再拆一层。现在文件名过滤 + 计划时按已有批次标题去重，两层都在。
- **体检与归档器两套判据漂移**：体检自己抄了一份批次正则，报 32 批而归档器算出 46 批，
  于是体检建议归档、用户点了才发现搬不动。现在体检直接调 `planArchive` —— 一个概念一处定义。
- **平台硬编码导致静默失效**：`doctor.js` 的绝对路径检测只认 `/Users/`，在 Linux 上
  `path-conflict` 检查**一条 finding 都不出** —— 不报错、不告警、退出码正常，看起来像
  「没发现问题」。是 GitHub Actions 第一次跑就抓到的。

### 变更

- `EXTRA_WEIGHT` 维持 `0.5`（曾据中途测量改成 0.8，在最终语料上重量后三项数字完全一致，
  那 1 分来自补桥而不是权重 —— 已退回并记入注释）。
- `主题` 同义桥从含 `light-dark / color-mix / data-theme / token` 退回 `['theme']`：
  加词后对照 B 掉 3 分，原因是同一篇长笔记的其它 chunk 会把标题里真写着这个词的那个挤下去。

### 文档

- README 从 188 行扩到 425 行：新增「一分钟选型」四问、逐项横评（未核实的格子**留白不猜**）、
  别名与桥梁的挖掘方法、归档第二层、以及「四条被实测证伪的路」。

## [0.2.0] — 2026-10-14

- 首次发布：BM25 自动召回（门控 + 头尾取样 + IDF 选词）、fail-closed 写入闸门、
  静态体检、写入审计、沉淀提醒、六页签只读面板、自动归档（不截断、整段搬家、可回滚）。

[0.3.0]: https://github.com/yffo/dsh-engramory-recall/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/yffo/dsh-engramory-recall/releases/tag/v0.2.0
