# Changelog

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

## [0.12.1] - 2026-09-19

**一张真机截图抓出来的修复版。** 没有新功能，两件事：把三处**硬编码中文**收进词典，
以及让 README 里那张图**第一次真的能在 npm 页面上显示**。

### 修了什么

英文界面下，文档行右边显示的是中文 `3 分钟前` —— 面板其余部分都是英文。
扫过整个客户端后确认，词典之外只有三处裸中文（其余文案全部走 `t()`）：

| 位置 | 原来 | 现在 |
|---|---|---|
| `relTime()` 的六条时间文案 | `刚刚` / `X分钟前` / `X小时前` / `昨天` / `X天前` / `X月X日` | 六个 `time.*` 键，中英各一套 |
| 文档列表的 `aria-label` | `最近文档` | 键 `list.aria` |
| better-sidebar 的 tab 标题 | `Knit 最近文档` | 复用 `guide.title`（顺带把两个宿主的口径统一了） |

违反的是项目自己的规矩：**文案一律走 i18n，宿主只返回错误码**。

### 加了 4 条守卫（测试 276 → 280）

1. 英文环境渲染出的整棵树里**一个汉字都没有**
2. 英文环境下 `aria-label` / `title` / `placeholder` / `alt` 里也没有汉字
3. 相对时间的**六个分支**都随语言
4. better-sidebar 的 tab 标题随语言

既有那条冒烟用例（只写死 `刷新|按` 两个词去查）**当时没抓住 `3 分钟前`** ——
新守卫改成「不许有任何汉字」，不再靠枚举词。

> ⚠️ **每条守卫都做了证伪**（把三处改回硬编码 → 4 条全红；恢复 → 全绿）。
> 第一版守卫用的是现成语料，两篇文档只落在 2 个时间分支上 ——
> **把 `分钟前` 改回去，守卫竟然全绿**。所以新增一份把六个时间粒度走全的语料。
> **「测试通过」不等于「测到了」。**

### 图片

- **README 第 1 屏换成当前 UI 的真机截图**（中文界面、引用条两组都展开）。
  之前那张停在 v0.5.x，与 v0.6–v0.12 的改动全都对不上
- **README 里的图片地址改成绝对 raw URL** —— npm 页面上这张图**从 `0.5.0` 起就没显示过**：
  用的是相对路径，而 `docs/` 不在 `files` 白名单里，文件根本没进包
  （`unpkg.com/dsh-knit@0.12.0/docs/screenshot.png` → 404）。改完之后换图也不必再发版本

### 注意

- 本版**没有行为变化**：不改排序、不改接口、不改面板逻辑
- 客户端半边改动**硬刷新浏览器即可**，不需要重启 DSH

## [0.12.0] - 2026-09-19

**让文档之间那层「引用关系」看得见。** 预览一篇时，面板给出「**这篇被谁引用 / 这篇引用了谁**」，
一键跳过去。

Knit 原来只回答「哪几篇跟你现在的话题相关」（**排序**问题）。这一版回答的
「这几篇之间连着谁」是**结构**问题 —— 两件事不冲突、不重叠，而且后者只有文件系统知道。

### 起因：先量，再决定

`0.11.0` 那轮真机教会我们「收益型主张必须先量」。所以这一版动手前先拿真实语料
（`05_LoreFlow-Copilot`，129 篇）量了三件事：

| 量到的 | 结果 | 决定 |
|---|---|---|
| `[[wikilink]]` 出现次数 | **0** | **不做** Obsidian 语法支持 |
| `` `path/x.md` `` | **946** | 反引号路径才是要认的写法 |
| 重复 basename | **12 个名字 / 68 个文件**（`SKILL.md` ×20、`01_公众号正文.md` ×12） | **歧义一律不算链接** |
| 链接图当排序信号 | BM25 第 45 名 → 融合后 16~24，RRF 最好第 8 | **不碰排序** |

### 加了什么

- **`/knit/api/links`**：只读，返回某篇的 `incoming` / `outgoing`（**只有 rel + title**，
  不泄漏图与 haystack —— 与 `knit_docs` 同一条纪律）
- **`src/host/links.js`**：纯解析层。`extractRefs` / `resolveRef` / `buildLinkGraph` / `linksOf`，
  零依赖，`list` 与 `read` **由调用方注入**（因此不 import `index.js`，没有环）
- **预览头下方的「引用条」**：默认折叠只给计数，展开列两侧，点一项就地跳过去
- 解析优先级：**引用方所在目录 → 工作区根 → 缩写后缀（`-SDD-v0.6.md`）→ basename（仅当唯一）→ 不算**
- 解析结果**按签名缓存**：真实语料**首次 110 ms、二次 9 ms**

### 刻意不做的（都量过，不是没想过）

`[[wikilink]]` 语法 / 链接图进排序 / 关系图可视化 / 标签与 Dataview 式查询 / 编辑。
理由见 `01_ Knit PRD/Knit_PRD-v0.12-反向链接.md` §三。

### 顺手还了一笔旧债

`0.11.0` ① 的钩子写着「**它读一篇，而不是翻五篇**」—— 那正是四轮真机**没能证实**的收益。
本版把三处改成**机制陈述**（npm `description`、两份 README 顶部钩子）：
「拿回排名**加每篇里命中的那段原文**」。**未证实的收益不再当既成事实写。**

### 测试

**239 → 276**（解析层 31 条 + HTTP 端到端 1 条 + 客户端 5 条），全绿。
`test/eval/` 的 21 条排序用例**结果不变** —— 证明排序一行没碰。

### 注意事项

- ⚠️ **宿主半边改了，必须重启 DSH**（`AGENTS.md` §4.2）—— 新增了一条路由
- ⚠️ **本版不承诺任何行为收益**：只说「引用关系被正确解析出来并且看得见」。
  不写「找得更快」、不写「agent 少读」——那些我们没测
- **已知边界**：不做模糊匹配、不处理 `../` 上跳、不区分代码块里的「示例路径」、
  正文按 512KB 上限截断。**解析不出来就是没有链接 —— 宁可少，不可错**

## [0.11.0] - 2026-09-18

**把 agent 那一面说出来，并把它证明给人看。** 三件事一起：定位外化（①）、
`knit_docs` 返回命中段落（②）、安全属性可核验（③）。
排序算法与面板**零改动**。

> ⚠️ **这一版把 `0.10.0` 一起带上了。** 评审决定：`0.10.0` 不单独发 npm，
> 所以 npm 上会从 `0.9.0` **直接跳到 `0.11.0`**，`0.10.0` 成为一个只存在于 git 的版本号。

### 起因

`00_调研与规划/竞品调研-Codex插件生态.md`（2026-09-18）挖出来的对比：

| 品类 | 代表 | 真实分发量 |
|---|---|---|
| **省 context / 省成本** | `token-optimizer-mcp` | npm **6,240/月**（逐日验过：12/20 非空洞日、日均 ~143，**持续曲线**） |
| 给 agent 找项目文档 | `alcove` | crates.io **总 1,028** |
| 同上 | `agentpack` | npm **305/月** |
| 同上 | `mcp-md-reader` | **没发 npm** |

**同一个生态里「省读」有真实用户，「找文档」没有。** 而 Knit 的 `knit_docs`
本来就是「省读」（agent 不用 glob + 逐个 read 试探），但我们对外只说了面向人的那一面。

### ① agent 价值外化（零代码）

**问题不是没说，是埋太深** —— README 第六节早写了 agent 视角，但打包层全是人：

| 面 | 改前 | 改后 |
|---|---|---|
| npm `description` | 只有人 | 补 `…and hands the same ranking to the agent as a tool, so it reads one doc instead of five.` |
| README 顶部钩子 | 只有人 | 补 **「你的 agent 也一样。同一份排序也给它当工具用 —— 它读一篇，而不是翻五篇。」**（中英各一条） |
| 市场索引 YAML | 没提 agent 工具 | **逐字不改** —— 投稿规范写死「no superlatives / 描述只说功能」，动它有被打回的风险 |

### ② `knit_docs` 返回命中段落

**为什么要先算账**：这件事的成本**不是**门槛 —— 实测 `knit_docs` 一次输出 710 字符，
而**一篇文档平均 12,930 字符**（18.2×）。每篇多 200 字的段落，只等于一篇文档的 7.7%。

**实现后发现实测比预估更省**：

| | 字符 |
|---|---|
| 改前 | 754 |
| 改后 | 1247 |
| **实际多出** | **493**（预估 +750） |
| 占一篇文档 | **3.9%**（预估 5.8%） |

**含义**：只要它拦住 **3.9% 次 `read`** 就回本。**成败 100% 取决于行为问题** ——
agent 拿到段落之后还会不会去读整篇。判据见 `Knit_SDD-v0.11-knit_docs命中段落.md` §六。

设计要点（详见同一份 SDD）：

- 在**原文**上切段落，**不在 `haystack` 上切** —— 后者是小写的，会把 `BM25` 变成 `bm25`，
  与「标签是你自己打的字，大小写原样保留」直接冲突
- 只看前 **2500 字**（与评分窗口 `HAYSTACK_CHARS` 一致），避免「排上来但段落里没命中词」
- 块太长时**以命中词为中心**截，不是从头截
- **跳过与标题重复的块** —— 真实工作区试跑时发现的：H1 里含全部命中词时，
  「命中最多的块」就是标题本身，而标题第 1 行已经给过了，那是纯浪费
- **抽不到就不给**（字段缺省），不写空串、不编造
- `read` **注入**，第二个参数可选 —— 不传时行为与 v0.10 逐字一致

顺手修掉一个**测试查不出来**的坑：段落提取是 200 字，而原来的 `oneLine()` 上限写死
`SUMMARY_CHARS`(90)，直接复用会把段落**二次截掉一半**且测试不报错。
抽出 `flatten(text, cap)` 修掉，并加了一条专门的回归测试。

### ③ 安全属性可核验

新增 `SECURITY.md`：一张对照表，**每条 ✅ 都指向一个真实存在的检查**，
并允许出现 ⚠️（第一条就是**「未经第三方安全审计」**）。

新增 `test/security.test.mjs`（8 条）—— 它不只是测安全，更是**保证那张表不会腐烂**：

- `SECURITY.md` 引用的每个包内路径必须真实存在
- 每个 ✅ 行的证据必须指向具体检查（不能是「我们声称」）
- `SECURITY.md` 必须在 `package.json` 的 `files` 里（否则「随版本一起发」是空话）

**写这张表的过程真的抓出一个漏洞**：`sandbox` 这个 CSP 指令源码里设了，
但 `test/host-http.test.mjs` **只断言了 `default-src 'none'`、没断言 `sandbox`**。
「每条 ✅ 都要有检查」这条规矩把它逼出来了 —— 已补上断言。

### 测试

**217 → 239**（② 加 14 条、③ 加 8 条），全绿。

### 注意事项

- ⚠️ **宿主半边改了，必须重启 DSH**（`AGENTS.md` §4.2）—— ② 是宿主侧改动，不像 ① 那样只改文案
- ⚠️ **② 的机制已真机证实、行为收益未证实 —— 但已查明演示不了。**
  真实宿主里 `knit_docs` 结果 **20/20 篇带 `match:` 行**，段落内容正确。
  可四轮真机之后查出：**工具只在「要一份候选清单」时被调用，
  而它的收益（段落替代读整篇）只在「要一个事实答案」时体现** ——
  后者 agent 的反射是 `grep`，压根不碰这个工具。**触发条件与收益条件是错开的。**
  结论：**按纯增益发布** —— 结果里多一行 `match:`，不改变其余任何行为；
  没有它，`0.10.0` 照常工作。复盘见 `AGENTS.md` §6.10 与
  `01_ Knit PRD/Knit_SDD-v0.11-knit_docs命中段落.md` §10.9–10.10
- 三条适用边界（都不是缺陷，是它该待的射程）：找**确切字符串**时 `grep` 更强；
  **用户措辞与文档措辞不一致**时词面检索会漏；**权威产物在代码里**时不在射程内
  （Knit 只索引 Markdown 与媒体）
- `package.json` 的 `files` 加了 `SECURITY.md`；`test` 脚本加了 `test/security.test.mjs`

## [0.10.0] - 2026-09-18

**把「新标签页」换成「在本地打开」。** 排序算法、宿主半边、`knit_docs` 工具**零改动** —— 纯客户端。

### 为什么

看到豆包文档工具栏的「打开 ▾」（用本机应用打开当前文档），问我们是不是也该这么做。

查完的结论是：**这个能力我们早就有，只是藏起来了** —— 预览头那行路径面包屑一直可点
（`ctx.remote.session.openWorkspacePath`，官方对这个 API 的定义是
「hands a path to the local opener and leaves the effect on the machine」），
悬停提示写着「用系统默认应用打开这篇文档」。但它是面包屑的外观，没人知道能点。

同时「新标签页」重复度高：它开的是官方文档预览，而面板里已经有就地预览。
（不算严格冗余 —— 官方预览有 PDF 渲染器和渲染方式切换，而且我们刻意不接管 `.md`
路由以保留官方产物卡 —— 但确实用得少。双击列表行仍保留这个入口。）

**于是：把值钱的那个放到显眼处，把鸡肋的那个让位。**

### 改了

- 预览头右上角：`[全屏] [新标签页] [✕]` → `[在本地打开] [全屏] [✕]`
- 新按钮文案中英各一条（`preview.openLocalBtn`），tooltip 复用 `preview.openLocal`（带完整相对路径）
- **路径面包屑保持可点** —— 已有的快捷方式不动
- 「新标签页」能力**没有删**：双击列表行 / 媒体卡仍走它（`onOpenTab`）
- 顺手清掉 `PreviewPanel` 上已成死 prop 的 `onOpenTab`，以及词典里没人用的
  `preview.newTab` / `preview.newTabTitle`

### 被否决的方案（都查过官方 API，不是猜的）

| 方案 | 为什么不做 |
|---|---|
| **豆包式「文档应用选择器」**（Typora / Obsidian / 预览…） | DSH **没有**这个能力可复用。官方 `open-in-app` 是给**工作区文件夹**的（路由写死校验「an absolute path naming an existing **directory**」），应用目录还是一张编译期表、全是开发工具 —— **没有 Typora / Obsidian**。要做就得自己写应用目录 + 一条**启动本机进程**的宿主路由 + 跨平台分支。那是新的风险等级（Knit 现在全是只读接口），收益却是猜的 |
| **复用官方 `open-in-app` 做「打开工作区 ▾」** | 能做（`GET /open-in-app/apps` 实测返回 `["finder","cursor","vscode","terminal"]`，图标免费），但它是**文件夹级**，跟要的「打开这篇文档」不是一回事，且与已有的「点工作区路径打开文件夹」重叠 |
| **官方文档预览的「打开方式」** | 名字像，但 `candidates = matchingDocumentPreviews(definitions, file.path)` —— 是 **DSH 内部渲染器**切换（Markdown / 纯文本 / PDF），**不是本机应用** |

### 注意事项

- ⚠️ **纯客户端改动：硬刷新浏览器即可**（`Cmd + Shift + R`），不需要重启 DSH
- 测试 **217/217**（新增 2 条：按钮存在且点击打开本文档；没有 `remote.session` 时给可见提示）

## [0.9.0] - 2026-09-18

**改 `knit_docs` 对自己排名的说法。** 算法、面板、客户端**零改动**。

### 起因：v0.8 的修法打偏了

v0.8 加「结果头部给出总数」，假设 agent 交叉验证是因为**怕漏**。
真机验证（干净子代理 N=3）显示**没有修好**：2 个用了工具，其中 1 个仍然
`grep` 全库重新排名；1 个压根没用工具。

看会话日志才发现它**不是怕漏，是不认这个排名** ——
它自己 `grep -c "排序|BM25|相关度|相关性"` 数关键词密度重排了一遍。

### 于是先把问题变成可量的

新增 `knit/tools/scale-benchmark.mjs`：三条路线、N = 20/60/180/540。
语料刻意做成有真实陷阱的（12 篇短而聚焦的主题文档 + 长干扰文档
「把每条主题各提 5 次但哪件都没讲」，像 CHANGELOG；主题文档一半用描述性文件名、
一半看不出内容）。基线照抄真机日志里 agent 的做法，不算放水。

| 路线 | 文件名说得清 | 文件名看不出 | MRR 随规模 |
|---|---|---|---|
| **Knit BM25** | **100%** | **100%** | **1.000（不随规模变）** |
| grep -c 计数 | 17% | **0%** | 0.313 → **0.089** |
| 只看文件名 | 100% | **0%** | 0.602 |

三条结论：

1. **Knit 的排序远强于自己数关键词** —— agent 重算是不理性的
2. **文件名匹配只在名字描述内容时好使**；Knit 是唯一两种都 100% 的
   （这也解释了为什么那个子代理直接 `bash` 就够了：本仓库文档名恰好都描述内容）
3. **自己数的可靠性随规模单调下降**

### 改动：把这三条说出来

- **工具描述**直说「比你自己匹配文件名或数关键词更可靠 —— 优先用它」，
  并点名两个它自己做不到的事：**稀有词权重**与**不依赖文件名**（依旧 59 词，≤60 上限）
- **结果头部**从「`by relevance to …`」改成
  「`ranked by IDF-weighted relevance to … (rare terms weighted, length-normalised —
  not a keyword count or filename match)`」—— 在它**决定要不要重算的那一刻**给出理由

### 注意事项

- ⚠️ **真机验证已完成，结论是「没修好」。** 三个中性提问（不带任何指令）的干净子代理：
  2 个用了 `knit_docs`，**两个都仍然自己 `grep` / `glob` 全库交叉确认**；1 个压根没用工具。
  对照 v0.8 那轮是「2 个用、1 个交叉验证」。所以**「说清排名口径就能少交叉验证」这条假设
  没有被证实** —— 文案这条路试了两轮都没拿到效果，不再试第三轮。
- ⚠️ **宿主半边不热加载，升级后必须重启 DSH**。
- `/knit/api/*` 与面板**零改动**；测试 **215/215**。

### 发布

这一版是 `0.5.1` 之后**第一次真正发布** —— `0.6.0` / `0.7.0` / `0.8.0` / `0.9.0`
此前都只存在于本地仓库，公开仓库停在 `0.5.1`。随本次发布一并补齐：

- **npm `description` 改成先说痛点**（原来是一串功能罗列）：`Find the doc your agent just
  wrote. …` —— npm 搜索结果只截前几十个字符，原写法把这最值钱的位置浪费在复述功能上
- **README 开头**去掉那段自嘲式的长说明（它卡在钩子和安装命令之间）；
  并把 v0.9 的**规模基准**补进「『相关』是怎么算出来的」，中英两版同步

## [0.8.0] - 2026-09-18

**修两个真机验收发现的缺陷。** 排序算法、面板、客户端**零改动**。

### Fixed

- **「按「xxx」排序」那行显示跨词边界的碎片。** 候选分是 `出现次数 × 词长²`，
  所以每个 3-gram 都压过所有 2-gram —— 对「项目文档」这种输入，
  `项目文` / `目文档` 先被选中，真实的 `项目` / `文档` 作为它们的子串被去重叠吞掉，
  面板就成了「按「目文档、项目文」排序」。

  修法是**合并命中词在原文里的区间**：`项目文[0,3)` 与 `目文档[1,4)` 是**重叠**的，
  合并后切原文正好还原出 `项目文档`。**`extractKeywords` 的候选与顺序一字未改**，
  所以排序不受影响（实测 top-1 95.2% / MRR 0.976，与 v0.6 逐字相同）。

  合并结果还必须**真的在项目里出现过**才认。这一步是必需的：3-gram 逐个错位、
  彼此都重叠，所以「重叠就合并」会一路串下去（「悬停浮层是怎么做的」→「悬停浮层是怎」），
  **加长度上限没用**（链条会一直长到上限为止）。代价实测只有 +0.1ms（500 篇 4.3 → 4.4ms）。

  | 查询 | 旧 | 新 |
  |---|---|---|
  | `项目文档 索引` | 目文档、项目文 | **项目文档** |
  | `排序算法 相关性排序 BM25` | bm25、关性排、序算法 | **BM25、排序算法、相关性排序** |
  | `扩展名白名单都放行什么` | 名白名、展名白、扩展名 | **扩展名白名单** |
  | `悬停浮层是怎么做的` | 停浮层、悬停浮 | **悬停浮层** |

  顺带：**「对」移出 `CN_EDGE_STOP`**。它是介词但也是构词成分（对比、对话、对象），
  判在首字会把「对比度」切成「比度」。实测四档裁剪后，**只去掉这一个字就够，
  且排序四项指标一个都没变** —— 再往下裁没有额外收益，停在最小改动。

- **`knit_docs` 的结果看起来「不完整」，agent 每次都要再 glob 一遍交叉验证。**
  真机验收的三个会话里，三次调用之后 agent **三次都又跑了 `find` / `glob` / `grep`**
  来对照。现在结果头部给出总数（`Top 5 of 21 Markdown documents in this workspace, …`），
  描述里也说明它数得全。

### Added

- **`test/label.test.mjs`** —— 标签可读性的回归测试，断言是**双向**的：
  真词必须出现（`expect`）+ **旧碎片必须不出现**（`deny`）。
  v0.6 之所以漏掉这个问题，是因为评测只量了排序、从没量过标签。

### 注意事项

- ⚠️ **问题 2 的修复没有被验证**：单测只能证明「头部现在带总数了」，
  「agent 因此不再交叉验证」必须**再跑一次真机**才能确认。
- ⚠️ **宿主半边不热加载，升级后必须重启 DSH**。
- ⚠️ 残留：**孤立碎片仍可能出现**（如「视频上」），它没有重叠伙伴可合并。
  合并相邻段能修掉它，但会制造更糟的「相关性排」——这个取舍是有意的。
- `/knit/api/*` 的响应**字段没有增删**，只有 `topic` 的取值变准了。

### 测试

**215/215**（新增 9 条）。

## [0.7.0] - 2026-09-18

**把同一份排序也交给模型。** 面板、`/knit/api/*` 响应、客户端**零改动**。

### Added

- **`knit_docs` 工具**（宿主侧，只读）：agent 可以问「这个项目里跟当前话题最相关的
  文档是哪几篇」，拿到按相关性排序的**工作区相对路径 + 标题 + 摘要**。
  - 不传 `query` 就用**当前对话**排序；传了就用 `query`（把 query 当成一条最新消息，
    走完全相同的抽取规则，不引入第二条抽取路径）
  - 对话不足以判断相关性时**如实返回 `mode: 'time'`** 并在文本里说明，
    与面板那行「对话内容暂按最新排序」同一个口径
  - **不返回相关度分数** —— 它是相对分数，给模型看会被当成绝对置信度。
    顺序即相关度，与面板同一条规矩
  - **不返回正文** —— agent 有自己的 `read` 工具；Knit 负责发现，不负责搬运
  - `limit` 默认 5、上限 20；越界回落而不抛错
- 工具与浏览器那半边是**两次独立的 `ctx.inject`**：没有 `tools` 服务时面板照常工作，
  没有 `webServer` 时工具照常注册。

### 注意事项（**升级前请读**）

- ⚠️ **工具描述会进每一次请求的系统提示词**。装 Knit 的用户每个会话多占一点 token ——
  这是「让 agent 有能力」的必要成本，不装作没有。
- ⚠️ **拿不到会话就报错，不兜底**。HTTP 路由在会话查不到时会兜底到进程 cwd
  （兼容不带 `sessionId` 的老客户端）；工具**没有这个包袱** ——
  兜底只会扫到一个不相干的项目并返回它的文档。
- ⚠️ **宿主半边不热加载，升级后必须重启 DSH**。

### 实现说明

- **没有 import `@deepseek-ai/dsh-tools`，手写 `ToolDefinition`。** 原因：Knit 被
  `link:` 挂进 profile，真实路径在 profile 的 `node_modules` **之外**，裸 Node 解析不到
  `@deepseek-ai/*`（实测 `ERR_MODULE_NOT_FOUND`）。手写保持了**零依赖**，
  且三种目录布局下都能跑。
  这不是猜：手写的 `parameters` 与 `output.schema` 已与真实的
  `parameterSchemaSpecToJsonSchema` / `valueSchemaSpecToJsonSchema` 产物**逐字比对通过**，
  并通过了注册期的 `assertSupportedJsonSchema` 与值校验。
- `scan()` 新增可选参数 `options.query`。**HTTP 路由不传它**，所以 `/knit/api/recent`
  的行为逐字不变。

### 测试

**206/206**（新增 20 条工具测试）。

## [0.6.0] - 2026-09-18

**只改排序质量，不加功能、不改界面。** 面板里唯一会变的是**文档的顺序**，
以及那行「按「xxx」排序」显示的词。

### Changed

- **排序从「加权关键词命中」换成 BM25。** 旧做法有三处硬伤：
  没有 IDF（语料里到处都是的词和罕见词同权，高频词于是不产生任何区分度）、
  没有长度归一化（长文档靠堆词就能赢）、命中次数封顶 6 次是手写硬拐点。
  现在每个词先按语料算 IDF，字段内按 BM25 饱和并做长度归一化
  （`k1 = 1.2`，`b` 按标题 / 摘要 / 正文分别为 `0.3 / 0.5 / 0.75`，字段权重仍是 `4 / 2 / 1`）。
- **关键词抽取丢掉跨词边界的碎片。** 中文没有词边界，n-gram 会把相邻两个词的字粘起来
  （「图片和」「个插」「的排」）。它们分数还高，去重叠时会把「图片」「排序」这些真词挤掉 ——
  结果是查询词里几乎没有一个是文档里真有的词。现在**首字或尾字是纯虚词的候选一律丢掉**。
- **「按「xxx」排序」那行改成显示语料里真实存在的词。** 之前会显示成
  「按「图片和、片和视、和视频」排序」这样的碎片；现在同样这句话显示「按「面板里、视频、图片」排序」。
  响应里的 `keywords` 字段同理。

### Added

- **离线质量评测**（`test/eval/`）：21 个「对话片段 → 期望 top-3」用例，
  覆盖纯中文 / 纯英文技术词 / 中英混合 / 高频词陷阱 / 长文档陷阱 / 退化场景，
  并冻结了 v0.5.2 的抽取与排序作为对照（`test/eval/legacy.mjs`）。
  它在 `npm test` 里跑，阈值是**运行时现算的基线 + 固定增幅**，不是写死的数字。

### 实测

同一套语料、同一套用例，两版引擎各跑一遍：

| | top-1 命中 | MRR | 陷阱违例 |
|---|---|---|---|
| v0.5.2（加权命中） | 76.2%（16/21） | 0.830 | 2 |
| **v0.6（BM25）** | **95.2%（20/21）** | **0.976** | **0** |

排序耗时（500 篇 × 2500 字，20 次平均）：旧 3.9ms → 新 4.3ms（+10%，绝对值远低于 30ms 预算）。

### Notes

- **只改宿主半边**（`src/host/relevance.js` 与 `index.js`），客户端未动 ——
  但**宿主半边不热加载，升级后必须重启 DSH**（不是硬刷新浏览器）。
- `/knit/api/*` 的响应形状**未变**：`score` 仍是 `0–100` 或 `null`，
  `mode` / `topic` / `keywords` / `docs[]` 字段都在，只是取值更准了。
- 相关度**仍然不做可视化** —— 名次即相关度。
- 测试 **186/186**。

## [0.5.2] - 2026-09-18

### Added

- **阅读态**：正文滚动超过 4px 后，预览面板底色从分层灰**过渡**到纯阅读底色
  （浅色 `#fff` / 暗色 `#151517`）—— 灰底是为了让预览与列表分层，但读起来对比度弱，
  给个过渡两者兼得；换一篇回到分层灰。
  **记的是「哪一篇被滚过」，存在模块级 `readingDocs` 集合里**（key = `sessionId + rel`），
  不是组件 state —— 宿主重挂载不会把它清零，鼠标移出面板再回来仍是阅读底色。
- **点预览头的路径打开这篇文档**：用系统默认应用打开当前预览的本地文档，
  新增 i18n 键 `preview.openLocal`（中英同步）。

### Changed

- **灰底降档**：悬停 / 选中的中性灰在 DSH 令牌透明度上**各降一档**
  （悬停保留 40%、选中保留 60%），两个主题各一套 `--knit-hover-bg` / `--knit-active-bg`。
  起因是默认那档太灰，读文档时对比度被吃掉。
- 去掉排序依据那行顶着的 `🤖` 装饰符号，`topic.relevance` / `topic.relevancePlain`
  中英四条现在都是纯文字。

### Notes

- **只改客户端半边**（`src/client/client.js`），宿主未动 ——
  升级后硬刷新浏览器即可，不需要重启 DSH。
- 本版把工作区里一批已完成但一直未提交的改动落盘，测试 **174/174** 全绿。

## [0.5.1] - 2026-09-16

### Fixed

- **测试不再依赖仓库目录布局。** 原先样本工作区写成 `new URL('../../', import.meta.url)`，
  也就是**包根的上一层目录** —— 在作者本机那是 `08_Knit/`（恰好有 Markdown 与截图样本），
  但别人 `git clone` 公开仓库时那是 clone 的**父目录**，从 npm 装进 `node_modules/` 时
  那是 `node_modules/`，两处都没有样本，于是 `npm test` 在 clone 里 160/162、
  在装好的包里 157/162。
  现在由新增的 `test/fixture.mjs` 在临时目录自造样本工作区（3 篇 Markdown、一张图、
  一个非媒体文件，mtime 用 `utimes` 定死以免排序断言看运气），
  三种布局下都是 **162/162**。顺带去掉了对 `docs/screenshot.png`（193 kB）与
  `08_Knit/` 私有内容的依赖 —— 包的体积只增加 1 kB。
- 运行时代码（`src/`）与 `assets/` **逐字节零改动**，插件行为与 `0.5.0` 完全一致。

### Notes

- 这是一次测试与发布质量修正，**没有新功能、没有行为变更**。
  因此若你正在用 `0.5.0`，没有升级的必要。

[0.5.1]: https://github.com/PolinniZhong/dsh-knit/releases/tag/v0.5.1

## [0.5.0] - 2026-09-16

### Added

- **图片与视频管理**：排序栏下新增「文档 / 图片与视频 / 全部」类型切换（偏好记进
  localStorage，默认仍是文档，老体验与悬停浮层完全不变）
- **图片与视频视图**：方形圆角缩略图网格。**最少 3 列**（面板再窄也不掉到 1~2 列），
  只有变宽才加列（上限 4 列）；格子 **64px 起步**；一屏基准 **8 个**（4 列 × 2 行），
  超过 8 个**一个都不隐藏**，而是整块等比缩小（此时卡片文字让位给缩略图）。
  列数与格子边长是同一个约束，由 `mediaLayoutFor(width, count)` 一次算出后以 CSS 变量下发，
  CSS 只留一行 `repeat(3,1fr)` 兜底。视频用 `<video preload="metadata" src#t=0.5>`
  让浏览器直接解出**首帧**当海报，中央叠播放三角、右下角叠 `m:ss` 时长角标
  —— 零依赖、零转码、宿主不解析容器
- **就地预览图片 / 视频**：点缩略图在下方预览面板看大图或直接播放
  （`<video controls>`，静音自动播放以满足浏览器策略），双击仍在新标签打开
- **「全部」视图分上下两区**：文档区最多 4 条，超出时标题右侧给「查看全部 →」切到文档分类；
  图片视频区**不截断**（只给「已显示 / 总数」计数），超过 8 个与媒体视图同样等比缩小
- 宿主扫描从「只收 Markdown」泛化为一次 BFS 同时收 `.md` 与媒体，共享深度 ≤ 6 /
  目录 ≤ 500 / 4s 预算，文档与媒体各自上限 400；媒体**只 stat 不读内容**，只靠文件名参与相关性
- `/raw` 接口支持图片 + 视频白名单（视频 `mp4/m4v/webm/mov/ogv`、≤ 256MB；图片 ≤ 12MB），
  并支持 **HTTP Range（206 Partial Content）**：视频首帧与拖动按需取字节，
  `createReadStream` 流式返回、客户端断开即销毁流，不全量读进内存
- 新增错误码 `knit/media-only`、`knit/media-too-large`，中英双语齐全
- 列表接口新增 `kind=doc|media|all` 参数（缺省 / 非法均回落 `doc`），载荷每项带 `kind` 与 `size`

### Changed

- 面板里的硬编码强调色全部清掉。类型切换的选中态一开始写死紫色，被否（「太突兀了」）；
  换成品牌蓝描边后又被否（「不用加绿色、蓝色的描边，就跟下面列表一样，选中填充背景灰就可以」），
  最终与列表行、排序切换统一用 DSH 的中性令牌（`--dsw-alias-interactive-bg-active` / `-hover`），
  **灰底、不带任何彩色描边**。另有第二套写死的蓝（文档行选中、拖拽条悬停、聚焦圈）
  也一并收进主题令牌
- 品牌色只保留「正在预览」这一处语义：文档行与媒体卡片的那条描边
- 「正在预览」与「键盘焦点」拆开：后者只用中性描边，
  不再让键盘上下移动看起来像「选中了」
- 「全部」不再使用紧凑媒体行（该组件及其样式已删除），媒体一律出方形卡片
- 修掉一处静默样式丢失：`.knit-doc.active` 引用的 `--knit-accent-fill` 已在重构中被删除，
  而未定义变量会让 `background` 整条声明作废（invalid at computed-value time）。
  现已改用中性令牌，并补了「凡 `var(--knit-*)` 无 fallback 就必须有定义」的测试守卫

### Notes

- 视频首帧与时长依赖浏览器对 `<video preload="metadata">` 的支持；宿主页面 CSP 是否放行
  同源媒体（`media-src`）需在真机确认，图片同源已可用
- 安全口径不变：仅监听本机回环、仅 GET、路径越界一律拒绝、扩展名白名单、
  `nosniff` 与 `default-src 'none'; sandbox` 响应头保留

[0.5.0]: https://github.com/PolinniZhong/dsh-knit/releases/tag/v0.5.0

## [0.4.0] - 2026-09-15

首个公开版本。

### Added

- **按当前对话的相关性排序**（纯本地关键词匹配，零模型调用）：标题 ×4 / 摘要 ×2 / 正文前
  2500 字 ×1 加权命中，单词命中封顶 6 次，叠 10% 时间新鲜度微调
- 相关性 / 修改时间双模式一键切换（偏好记在 localStorage）
- 扫描会话工作区内所有 `.md`（递归，深度 ≤ 6，跳过 `node_modules` / `.git` / `dist` 等）
- 每项显示 H1 标题（无则文件名）+ 相对时间 + 首段摘要
- 单击就地展开预览，再点收起
- **相对路径图片真实渲染**（`./img/a.png`、`../assets/b.png`）
- 预览面板可拖高度（夹在 20%–80%，位置记进 localStorage）、可全屏，`Esc` 退出
- 双击在新标签页打开
- 过滤框：按标题 / 摘要 / 路径实时过滤
- 键盘导航：`↑` `↓` 移动即预览 / `Enter` 切换 / `Esc` 收起
- 每 5 秒自动刷新 + 手动刷新；2 分钟内改动过的文档打 🆕
- 会话头部右侧的 Knit 图标入口（`conversation.session.header.utilities`）
- 中英双语，跟随 DSH 语言实时切换（`ctx.locale`，命名空间 `knit`）
- mono 图标，浅色纯黑 / 暗色纯白，四个位置统一
- 双宿主：DSH 自带右侧栏 + `dsh-better-sidebar`（均为可选依赖）
- 对话不足时退回按修改时间排序，并在面板上说明

### Notes

- 零依赖、无安装脚本、无对外网络请求
- 宿主半边改了需要重启 DSH，客户端半边硬刷新浏览器即可
- 相关度不做可视化（不显示百分比、不画长条）—— 排序本身就是答案

[0.4.0]: https://github.com/PolinniZhong/dsh-knit/releases/tag/v0.4.0
